Index · How it works
1 min readRapid 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.