Index · Additional notes

5 min read
14 min read
Rapid overview

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

OperationTypical latency (local Redis)Notes
GET / SET (small value)~50–100 µsWire format negligible
HGETALL (small hash)~80–120 µsOne round trip
ZADD / ZRANGE~100 µsO(log N)
Pipelined 10 ops~150 µsOne round trip vs 10
Lua script (small)~80 µsOne round trip, atomic
Big-key GET (1 MB+)ms to secondsAvoid big keys — block other clients
KEYS * on 1M-key DB~1–3 sNever use in prod — use SCAN
Cross-cluster MGETmsServer 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 SCAN for iteration, UNLINK for 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 / ASK redirections 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

  1. Big keys block the server — Redis is single-threaded. A HGETALL on a 100 K-field hash blocks every other client. Cap hash/set/list sizes.
  2. *KEYS in production** — O(N), blocks everything. Use SCAN.
  3. Pub/Sub for important data — messages vanish if no subscriber. Use streams.
  4. Sync API on hot pathsdb.StringGet (no Async) blocks a thread. Always async in ASP.NET.
  5. ConnectionMultiplexer per request — opens connection on every request, blows file descriptors. Singleton, always.
  6. Cache stampede / thundering herd — 1000 requests miss simultaneously, all hit the DB. Mitigate with SETNX lock around the regen, or with jittered TTLs.
  7. No TTL on cache entries — entries live forever, Redis OOMs over months. Always set TTL or use maxmemory-policy.
  8. StringSet without When.NotExists for first-writer-wins — silent overwrites.
  9. 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)
ConnectionIConnectionMultiplexer singleton, internal pool*redis.Client singleton, internal pool
Per-call objectIDatabase (cheap, stateless)call methods on *redis.Client directly
Context propagationNo (sync via Async methods)context.Context on every call (enforced by type)
Pipeliningdb.CreateBatch()client.Pipeline() or client.TxPipeline()
Lua scriptsdb.ScriptEvaluateAsyncredis.NewScript(...).Run(ctx, ...)
Pub/Sub_redis.GetSubscriber()client.Subscribe(ctx, channels...)
Streamsdb.StreamAddAsync etc.client.XAdd(ctx, ...) etc.
ClusterStackExchange handles MOVED/ASKgo-redis handles MOVED/ASK
OpenTelemetryAddRedisInstrumentationredisotel.InstrumentTracing(client)
Async modelTask<T> everywhereone method form, context-aware
AllocationsHigher (boxed RedisValue)Lower (typed return values)
PerformanceComparableComparable, slight edge from less allocation

Interview talking points

  • "Why is ConnectionMultiplexer a 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 (from GetDatabase()) is the cheap per-call handle.
  • "How do you handle a cache stampede?"SET NX lock 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}:profile and {user:42}:settings end up in the same slot.
  • "How do you avoid big-key pain?" — Cap hash/list/set sizes. Use MEMORY USAGE to find offenders. Break wide hashes into sharded keys (session:abc:meta, session:abc:state).

Further reading