Problem Context

Redis turned 16 in 2025 and licensed itself out of the open-source ecosystem in 2024 (RSALv2 + SSPL), spawning the Linux Foundation's Valkey fork that AWS, Google, and Oracle now ship as a drop-in. Whichever badge you run, the data structures and protocol are the same: an in-memory key-value server that doubles as a cache, a job queue, a rate limiter, a leaderboard, a pub/sub bus, and a distributed lock. The breadth is also the trap โ€” the wrong primitive will silently corrupt invariants under load.

The 2026 conversation has shifted from "Redis as a cache" to "Redis (or Valkey) as the operational data fabric", with Azure Cache for Redis Enterprise, AWS ElastiCache for Valkey, and Upstash all offering serverless or HA deployments. .NET 9 ships HybridCache (L1 in-memory + L2 distributed, with stampede protection), making cache-aside almost pleasant.

๐Ÿค” Sound familiar?
  • You used SET + EXPIRE as two commands and lost atomicity
  • You implemented "distributed locks" with SETNX and never released them on crash
  • You don't know whether to use LIST, STREAM, or SORTED SET for a queue
  • You hit a thundering herd because your TTL was the same for every key

Six primitives cover 95% of Redis use. Pick the right one and you avoid the standard production fires.

Concept Explanation

Redis is a single-threaded event loop with O(1) command dispatch. The data model is keys mapping to typed values:

  • STRING โ€” bytes, integers (with INCR), bitmaps, HyperLogLog.
  • HASH โ€” field-to-value map; perfect for objects.
  • LIST โ€” doubly linked list; LPUSH/RPOP for a FIFO queue.
  • SET โ€” unordered unique strings; intersection/union are O(N).
  • SORTED SET (ZSET) โ€” leaderboard, time-based queue, geo index.
  • STREAM โ€” append-only log with consumer groups (Kafka-lite, since 5.0).

flowchart LR
    APP["App"] --> L1["L1: in-process MemoryCache"]
    L1 -->|miss| L2["L2: Redis / Valkey"]
    L2 -->|miss| DB["Source of truth (DB)"]
    DB --> L2
    L2 --> L1
    L1 --> APP

    style L1 fill:#0078D4,color:#fff,stroke:#005a9e
    style L2 fill:#16a34a,color:#fff,stroke:#15803d

Implementation

Step 1: Cache-aside with HybridCache (.NET 9)

// Program.cs
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redisConn);
builder.Services.AddHybridCache(o => {
    o.DefaultEntryOptions = new() {
        Expiration = TimeSpan.FromMinutes(10),
        LocalCacheExpiration = TimeSpan.FromSeconds(30)   // L1 TTL
    };
});

public class ProductService(HybridCache cache, IProductDb db)
{
    public ValueTask<Product> GetAsync(int id, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            $"product:{id}",
            async tok => await db.LoadAsync(id, tok),
            cancellationToken: ct);
    // HybridCache coalesces concurrent misses โ†’ no stampede
}

Step 2: Atomic counter with TTL (rate limiting)

// StackExchange.Redis 2.8+
var key  = $"ratelimit:{userId}:{DateTimeOffset.UtcNow:yyyyMMddHHmm}";
var db   = mux.GetDatabase();
var hits = await db.StringIncrementAsync(key);
if (hits == 1) await db.KeyExpireAsync(key, TimeSpan.FromMinutes(1));
if (hits > 100) throw new RateLimitedException();

Step 3: Distributed lock done correctly (SET NX EX + token)

var token = Guid.NewGuid().ToString("N");
bool got = await db.StringSetAsync(
    $"lock:order:{id}", token,
    expiry: TimeSpan.FromSeconds(30),     // โ† MUST set TTL atomically
    when: When.NotExists);

if (!got) throw new LockBusyException();
try { /* critical section */ }
finally
{
    // Release only if WE still hold it (Lua = atomic compare-and-delete)
    const string lua = @"
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else return 0 end";
    await db.ScriptEvaluateAsync(lua,
        new RedisKey[] { $"lock:order:{id}" },
        new RedisValue[] { token });
}
// For multi-node correctness use Redlock or a Cosmos lease โ€” single-node
// SET NX EX is fine for many apps but is NOT a consensus algorithm.

Step 4: Streams for a real queue with consumer groups

// Producer
await db.StreamAddAsync("orders", new NameValueEntry[] {
    new("order_id", id), new("payload", json) });

// Consumer (creates group once)
try { await db.StreamCreateConsumerGroupAsync("orders", "billing", "0-0", true); }
catch (RedisServerException) { /* group exists */ }

while (!ct.IsCancellationRequested)
{
    var entries = await db.StreamReadGroupAsync(
        "orders", "billing", "worker-1", count: 10, noAck: false);
    foreach (var e in entries)
    {
        await ProcessAsync(e);
        await db.StreamAcknowledgeAsync("orders", "billing", e.Id);
    }
}

Step 5: Leaderboard with sorted sets

await db.SortedSetIncrementAsync("leaderboard:season-9", userId, 10);
// Top 100, descending
var top = await db.SortedSetRangeByRankWithScoresAsync(
    "leaderboard:season-9", 0, 99, Order.Descending);
// User's rank
long? rank = await db.SortedSetRankAsync(
    "leaderboard:season-9", userId, Order.Descending);

Step 6: TTL with jitter to avoid stampedes

// Don't expire 100k keys at the same instant
TimeSpan TtlWithJitter(TimeSpan baseTtl)
{
    var jitter = Random.Shared.NextDouble() * 0.2 - 0.1;   // ยฑ10%
    return baseTtl * (1 + jitter);
}
await cache.SetAsync(key, value, TtlWithJitter(TimeSpan.FromMinutes(10)));

Common Pitfalls

  1. SET then EXPIRE.A crash between the two leaves a key with no TTL โ€” eternal "cache" entries. Always useSET key val EX seconds (StringSetAsync(..., expiry)).
  2. KEYS in production. KEYS * is O(N) over the entire keyspace and blocks the single thread. UseSCAN cursors.
  3. Big keys. A single 500 MB hash freezes the server during DEL or migration. Watch redis-cli --bigkeys; shard large structures.
  4. Pipeline vs MULTI confusion.Pipelining batches network round trips; MULTI/EXEC is a transaction (atomic, no interleaving). They're different tools.
  5. Cache stampede. 100k requests miss simultaneously and all hit the DB. Solution: HybridCache, request coalescing, or probabilistic early expiration.
  6. Treating Redlock as consensus.Redlock is fast and good enough for many cases, but Martin Kleppmann's critique stands: for correctness-critical mutual exclusion, use a system with a real fencing token (Cosmos lease, ZooKeeper, etcd).

Practical Takeaways

  • Use HybridCache (.NET 9) โ€” it gives you L1+L2 with stampede protection out of the box.
  • Always set TTL atomically with the value (SET key val EX), and add jitter to avoid synchronized expiry.
  • Streams beat lists for queues: consumer groups, acknowledgements, replay.
  • Sorted sets are unbeatable for leaderboards, time-windowed counters, and priority queues.
  • Distributed locks with SET NX EX+ Lua release are good enough for "mostly mutual exclusion"; use a real consensus system when correctness is critical.
  • Avoid KEYS, big keys, and unbounded LPUSH โ€” they break the single-threaded server.
  • Valkey is API-identical to Redis 7.2; pick based on licensing / managed offering, not API.