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.
- You used
SET+EXPIREas two commands and lost atomicity - You implemented "distributed locks" with
SETNXand never released them on crash - You don't know whether to use
LIST,STREAM, orSORTED SETfor 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
- SET then EXPIRE.A crash between the two leaves a key with no TTL โ eternal "cache" entries. Always use
SET key val EX seconds(StringSetAsync(..., expiry)). - KEYS in production.
KEYS *is O(N) over the entire keyspace and blocks the single thread. UseSCANcursors. - Big keys. A single 500 MB hash freezes the server during DEL or migration. Watch
redis-cli --bigkeys; shard large structures. - Pipeline vs MULTI confusion.Pipelining batches network round trips; MULTI/EXEC is a transaction (atomic, no interleaving). They're different tools.
- Cache stampede. 100k requests miss simultaneously and all hit the DB. Solution: HybridCache, request coalescing, or probabilistic early expiration.
- 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.

