Index · Additional notes
5 min read- Additional notes
- Pipelining and batching
- Transactions (MULTI/EXEC) — usually use Lua instead
- Performance characteristics
- Cluster considerations
- Observability
- Evolution and "migrations" — how this is actually done
- Strategy 1: Versioned key prefixes (most common)
- Strategy 2: Lazy read-old / write-new
- Strategy 3: Bulk migration script (when lazy isn't fast enough)
- Strategy 4: Definitions snapshot for config (Lua scripts, ACLs, etc.)
- Common pitfalls
- Comparison to Go (`redis/go-redis/v9`)
- Interview talking points
- Further reading
Additional notes
Pipelining and batching
// Fire-and-forget — multiple commands in one round trip
var batch = db.CreateBatch();
var t1 = batch.StringSetAsync("k1", "v1");
var t2 = batch.StringSetAsync("k2", "v2");
var t3 = batch.StringIncrementAsync("counter");
batch.Execute();
await Task.WhenAll(t1, t2, t3);
10–100× faster than sequential awaits when issuing many commands. Use for fan-out writes.
Transactions (MULTI/EXEC) — usually use Lua instead
var tx = db.CreateTransaction();
tx.AddCondition(Condition.StringEqual("balance:" + userId, currentBalance));
var t1 = tx.StringSetAsync("balance:" + userId, newBalance);
var t2 = tx.StringIncrementAsync("ledger:credit");
if (await tx.ExecuteAsync()) { /* committed */ }
MULTI/EXEC is optimistic-locking via WATCH. Lua scripts are simpler and atomic by construction; reach for transactions only when you need conditional commit.
Performance characteristics
| Operation | Typical latency (local Redis) | Notes |
|---|---|---|
GET / SET (small value) | ~50–100 µs | Wire format negligible |
HGETALL (small hash) | ~80–120 µs | One round trip |
ZADD / ZRANGE | ~100 µs | O(log N) |
| Pipelined 10 ops | ~150 µs | One round trip vs 10 |
| Lua script (small) | ~80 µs | One round trip, atomic |
Big-key GET (1 MB+) | ms to seconds | Avoid big keys — block other clients |
KEYS * on 1M-key DB | ~1–3 s | Never use in prod — use SCAN |
Cross-cluster MGET | ms | Server routes; latency dominated by slot lookup |
Rules of thumb:
- Values < 100 KB. Bigger → break into multiple keys or use a different store.
- Hash with > 1000 fields → consider splitting; HGETALL on big hashes blocks.
- Avoid
KEYS,FLUSHALL,MONITOR, long Lua scripts in prod (single-threaded server). - Use
SCANfor iteration,UNLINKfor non-blocking deletes.
Cluster considerations
When you move from single-node to cluster:
- Multi-key operations only work if keys are in the same hash slot. Use
{...}hash tags:{tenant:42}:theme,{tenant:42}:metadata→ same slot. MGET, transactions, Lua scripts with multiple keys all need same-slot guarantees.MOVED/ASKredirections are handled automatically by StackExchange.Redis.
Observability
// Logging via TextWriter
multiplexer.PreserveAsyncOrder = false; // perf boost on .NET 6+
ConnectionMultiplexer.Connect(options, log: Console.Out);
// Metrics — StackExchange.Redis emits events
multiplexer.ConnectionFailed += (s, e) => Log.Error("Redis disconnected: {Endpoint}", e.EndPoint);
multiplexer.ConnectionRestored += (s, e) => Log.Information("Redis reconnected");
// OpenTelemetry
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddRedisInstrumentation(multiplexer));
AddRedisInstrumentation produces a span per command with the operation name. Don't enable SetVerboseDatabaseStatements in prod — exposes keys/values in traces.
Evolution and "migrations" — how this is actually done
Redis has no MigrateAsync. There is no DDL. But you DO have to evolve:
- Key naming conventions (renaming
tenant:theme:{id}→v2:tenant:theme:{id}) - Value formats (changing JSON shape)
- TTL policies
- Data types (string → hash → JSON, etc.)
- Lua scripts (which live in code)
Strategy 1: Versioned key prefixes (most common)
Bump a version in the key prefix. Old code reads v1:, new code writes v2:. Coexist during deploy.
private const string CurrentVersion = "v2";
private string Key(Guid tenantId) => $"{CurrentVersion}:tenant:theme:{tenantId}";
Once the new version has reached steady state, schedule a one-off cleanup that SCANs v1:* and deletes. Risk: zero — old keys naturally expire.
Strategy 2: Lazy read-old / write-new
When reading, check both old and new key formats. When writing, only write the new format. Old keys age out via TTL.
public async Task<TenantTheme?> GetThemeAsync(Guid tenantId)
{
var db = _redis.GetDatabase();
var newKey = $"v2:tenant:theme:{tenantId}";
var v2 = await db.StringGetAsync(newKey);
if (v2.HasValue) return Parse(v2!);
// Fallback to v1, then upgrade
var v1 = await db.StringGetAsync($"v1:tenant:theme:{tenantId}");
if (v1.HasValue)
{
var migrated = MigrateV1ToV2(Parse(v1!));
await db.StringSetAsync(newKey, JsonSerializer.Serialize(migrated),
TimeSpan.FromMinutes(15));
return migrated;
}
return null;
}
Strategy 3: Bulk migration script (when lazy isn't fast enough)
For data that doesn't expire (no TTL) or where you need v1 cleaned up before deploy:
public async Task MigrateV1ToV2()
{
var server = _redis.GetServer(_redis.GetEndPoints()[0]);
await foreach (var key in server.KeysAsync(pattern: "v1:tenant:theme:*"))
{
var db = _redis.GetDatabase();
var v1Value = await db.StringGetAsync(key);
if (!v1Value.HasValue) continue;
var v2Key = key.ToString().Replace("v1:", "v2:");
var migrated = MigrateV1ToV2(Parse(v1Value!));
await db.StringSetAsync(v2Key, JsonSerializer.Serialize(migrated));
await db.KeyDeleteAsync(key);
}
}
Run as a one-off K8s Job or admin command. Use SCAN (via KeysAsync which uses SCAN under the hood) — *never KEYS **.
Strategy 4: Definitions snapshot for config (Lua scripts, ACLs, etc.)
For everything that lives outside data — Lua scripts, ACLs, modules — version them in the repo and apply on startup:
public async Task LoadScriptsAsync()
{
var rateLimitScript = await File.ReadAllTextAsync("scripts/rate-limit.lua");
var hash = await _redis.GetServer(...).ScriptLoadAsync(rateLimitScript);
_cachedScriptHashes["rate-limit"] = hash;
}
How easily? Redis "migrations" are dramatically simpler than SQL migrations because:
- No schema to break
- No locks needed
- Lazy migration absorbs most changes (TTL kills old data)
- No rollback needed — old code keeps reading old keys
The hard part is discipline about key naming — pick a versioned convention from day one. Without that, you end up with tenant:theme:, theme:tenant:, t:th:, and 5 deploys later nobody knows which is current.
Common pitfalls
- Big keys block the server — Redis is single-threaded. A
HGETALLon a 100 K-field hash blocks every other client. Cap hash/set/list sizes. - *
KEYSin production** — O(N), blocks everything. UseSCAN. - Pub/Sub for important data — messages vanish if no subscriber. Use streams.
- Sync API on hot paths —
db.StringGet(noAsync) blocks a thread. Always async in ASP.NET. ConnectionMultiplexerper request — opens connection on every request, blows file descriptors. Singleton, always.- Cache stampede / thundering herd — 1000 requests miss simultaneously, all hit the DB. Mitigate with
SETNXlock around the regen, or with jittered TTLs. - No TTL on cache entries — entries live forever, Redis OOMs over months. Always set TTL or use maxmemory-policy.
StringSetwithoutWhen.NotExistsfor first-writer-wins — silent overwrites.- Serialization mismatch between writer and reader after a deploy — versioned keys solve this.
Comparison to Go (redis/go-redis/v9)
| Aspect | .NET (StackExchange.Redis) | Go (go-redis/v9) |
|---|---|---|
| Connection | IConnectionMultiplexer singleton, internal pool | *redis.Client singleton, internal pool |
| Per-call object | IDatabase (cheap, stateless) | call methods on *redis.Client directly |
| Context propagation | No (sync via Async methods) | context.Context on every call (enforced by type) |
| Pipelining | db.CreateBatch() | client.Pipeline() or client.TxPipeline() |
| Lua scripts | db.ScriptEvaluateAsync | redis.NewScript(...).Run(ctx, ...) |
| Pub/Sub | _redis.GetSubscriber() | client.Subscribe(ctx, channels...) |
| Streams | db.StreamAddAsync etc. | client.XAdd(ctx, ...) etc. |
| Cluster | StackExchange handles MOVED/ASK | go-redis handles MOVED/ASK |
| OpenTelemetry | AddRedisInstrumentation | redisotel.InstrumentTracing(client) |
| Async model | Task<T> everywhere | one method form, context-aware |
| Allocations | Higher (boxed RedisValue) | Lower (typed return values) |
| Performance | Comparable | Comparable, slight edge from less allocation |
Interview talking points
- "Why is
ConnectionMultiplexera singleton?" — It's an expensive object that manages the connection pool, subscribes to keyspace events, and tracks cluster topology. Creating one per request blows file descriptors and adds reconnect overhead.IDatabase(fromGetDatabase()) is the cheap per-call handle. - "How do you handle a cache stampede?" —
SET NXlock around the regenerate path so only one request rebuilds; others wait or return stale. Alternative: jitter the TTL so expiries don't align. - "How would you migrate a Redis key schema without downtime?" — Versioned key prefix + lazy read-old/write-new. Old code keeps working on the old prefix until it ages out. No locks, no downtime, no rollback risk.
- "When would you use Lua over MULTI/EXEC?" — Lua wins for atomic read-modify-write (rate limit, conditional update). MULTI/EXEC is for optimistic locking with WATCH. Lua is simpler in most cases.
- "Pub/Sub vs Streams?" — Pub/Sub is in-memory broadcast, no durability. Streams (5+) are an append-only log with consumer groups, like lightweight Kafka. Default to streams.
- "What's a hash tag in Redis Cluster?" —
{...}substring in the key forces a hash-slot computation that ignores the rest. Lets you co-locate multi-key operations:{user:42}:profileand{user:42}:settingsend up in the same slot. - "How do you avoid big-key pain?" — Cap hash/list/set sizes. Use
MEMORY USAGEto find offenders. Break wide hashes into sharded keys (session:abc:meta,session:abc:state).