Index · How it works

1 min read
14 min read
Rapid overview

How it works

Connection setup

Per the current codebase (TenantService pattern):

// Registration — ONE multiplexer per service, lifetime singleton
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var options = ConfigurationOptions.Parse(
        builder.Configuration.GetConnectionString("Redis")!);
    options.AbortOnConnectFail = false;     // retry forever
    options.ConnectRetry        = 3;
    options.ConnectTimeout      = 5000;
    options.SyncTimeout         = 1000;
    options.KeepAlive           = 60;
    return ConnectionMultiplexer.Connect(options);
});

// Usage in service
public class TenantThemeCacheService
{
    private readonly IConnectionMultiplexer _redis;
    public TenantThemeCacheService(IConnectionMultiplexer redis) => _redis = redis;

    public async Task<string?> GetThemeAsync(Guid tenantId)
    {
        var db = _redis.GetDatabase();
        var value = await db.StringGetAsync($"tenant:theme:{tenantId}");
        return value.HasValue ? value.ToString() : null;
    }
}

Key constraint: ConnectionMultiplexer is NOT cheap to create — it opens TCP connections, negotiates auth, subscribes to keyspace events. Use it as a singleton. IDatabase from GetDatabase() is cheap and stateless.

Common operations

var db = _redis.GetDatabase();

// String GET/SET with TTL
await db.StringSetAsync("session:abc123", json, TimeSpan.FromMinutes(30));
var v = await db.StringGetAsync("session:abc123");

// Atomic INCR (counter)
var hits = await db.StringIncrementAsync("metrics:page-views:home");

// Hash (analog of a struct)
await db.HashSetAsync("tenant:meta:" + id, new HashEntry[] {
    new("slug", "kucy"), new("name", "Kizomba Union CY"), new("plan", "free"),
});
var allFields = await db.HashGetAllAsync("tenant:meta:" + id);

// Sorted set (leaderboard / time-series)
await db.SortedSetAddAsync("leaderboard:daily", userId, score);
var top10 = await db.SortedSetRangeByRankWithScoresAsync(
    "leaderboard:daily", 0, 9, Order.Descending);

// List (lightweight queue)
await db.ListLeftPushAsync("jobs:pending", payload);
var job = await db.ListRightPopAsync("jobs:pending");

// Set (membership)
await db.SetAddAsync("tenant:" + tid + ":seen-ips", ip);
var hasSeen = await db.SetContainsAsync("tenant:" + tid + ":seen-ips", ip);

// Key management
await db.KeyExpireAsync("tenant:theme:" + id, TimeSpan.FromMinutes(15));
await db.KeyDeleteAsync("tenant:theme:" + id);
await db.KeyExistsAsync("tenant:theme:" + id);

Cache-aside pattern (the most common shape)

public async Task<TenantTheme> GetThemeAsync(Guid tenantId)
{
    var cacheKey = $"tenant:theme:{tenantId}";
    var db = _redis.GetDatabase();

    var cached = await db.StringGetAsync(cacheKey);
    if (cached.HasValue) return JsonSerializer.Deserialize<TenantTheme>(cached!)!;

    var theme = await _repository.GetThemeAsync(tenantId);
    if (theme is null) return null!;

    await db.StringSetAsync(cacheKey,
        JsonSerializer.Serialize(theme), TimeSpan.FromMinutes(15));
    return theme;
}

Distributed lock

var lockKey = $"lock:tenant:onboarding:{tenantId}";
var lockValue = Guid.NewGuid().ToString();

if (await db.StringSetAsync(lockKey, lockValue, TimeSpan.FromSeconds(30), When.NotExists))
{
    try { /* critical section */ }
    finally
    {
        // release only if we still own it (atomic via Lua)
        const string releaseScript = @"
            if redis.call('get', KEYS[1]) == ARGV[1]
            then return redis.call('del', KEYS[1])
            else return 0 end";
        await db.ScriptEvaluateAsync(releaseScript,
            new RedisKey[] { lockKey }, new RedisValue[] { lockValue });
    }
}

Caveat: single-Redis locks are NOT distributed consensus. For multi-node Redis use RedLock.net — and even then, read Martin Kleppmann's critique before betting consistency on it.

Rate limiting (sliding window via Lua)

const string rateLimitScript = @"
    local current = redis.call('incr', KEYS[1])
    if current == 1 then redis.call('expire', KEYS[1], ARGV[1]) end
    return current";

var count = (long)await db.ScriptEvaluateAsync(rateLimitScript,
    new RedisKey[] { $"rate:{userId}:{DateTime.UtcNow.Minute}" },
    new RedisValue[] { 60 });

if (count > 100) throw new RateLimitExceededException();

Lua scripts run atomically on the Redis server — single round trip, no race between INCR and EXPIRE.

Pub/Sub vs Streams

// Pub/Sub — fire-and-forget, no persistence, no replay
var sub = _redis.GetSubscriber();
await sub.PublishAsync("tenant.updated", JsonSerializer.Serialize(payload));
await sub.SubscribeAsync("tenant.updated",
    (channel, value) => { /* handle */ });

// Streams (Redis 5+) — durable log with consumer groups, like a lightweight Kafka
await db.StreamAddAsync("events:tenant", new NameValueEntry[] {
    new("type", "TenantUpdated"), new("payload", json),
});

// Read with a consumer group
await db.StreamCreateConsumerGroupAsync("events:tenant", "indexers", "$");
var entries = await db.StreamReadGroupAsync(
    "events:tenant", "indexers", "consumer-1", count: 10);

Use streams over pub/sub for anything that needs durability or replay. Pub/Sub messages disappear if no subscriber is listening.