Redis Design Patterns: A Practical Guide to Locks, Counters, CAS, and Lua
A field guide to Redis design patterns that actually hold up in production: distributed locks, atomic counters, rate limiters, compare-and-swap, cache-aside, pub/sub — with real redis-cli commands, Lua scripts, interactive diagrams, and the antipatterns that quietly take down clusters.
Redis looks simple enough that it's easy to reach for it without a plan: SET, GET, EXPIRE, done. Then production happens — two processes think they hold the same lock, a rate limiter lets through triple the allowed traffic, KEYS * freezes the cluster during a demo — and the gap between "Redis is easy" and "Redis is easy to misuse" becomes obvious.
This is a practical, pattern-by-pattern guide to the ways Redis actually gets used in real systems: distributed locks, counters, rate limiters, compare-and-swap, caching, pub/sub, and the Lua scripts that make several of those patterns safe instead of merely fast. Every pattern below ships with the actual redis-cli commands or Lua script, not pseudocode.
The One Fact That Explains Most of Redis's Behavior
Redis executes one command at a time, on a single thread, from an in-memory dataset. That single fact is why SET key value NX is atomic (nothing else can run between the existence check and the write), why KEYS * freezes every other client (the one thread is busy scanning), and why persistence (RDB snapshots, AOF logs) happens around that thread rather than blocking it.
Two consequences fall directly out of this, and both explain why the patterns below look the way they do:
- Any single command is automatically atomic — no other client's command can interleave with it. This is why
SET lock NX EX 30is safe on its own, butSETNXfollowed by a separateEXPIREis not: two commands means a gap another client's command can land in. - A slow command blocks everyone —
KEYS *on a 10-million-key database,HGETALLon a hash with a million fields, or a Lua script with a loop over an unbounded collection all hold the one thread hostage for their entire duration. Every antipattern in this guide traces back to forgetting this.
The Data Structures, at a Glance
| Type | Shape | What it's actually for |
|---|---|---|
| String | bytes / number | counters, flags, JSON blobs, locks, idempotency keys |
| Hash | field → value map | objects with named fields (sessions, cached rows) without one key per field |
| List | ordered sequence | simple queues, recent-activity feeds (bounded with LTRIM) |
| Set | unordered unique members | membership tests, tag sets, deduplication |
| Sorted Set (ZSet) | member → score, ordered by score | leaderboards, sliding-window rate limiters, priority queues |
| Bitmap | string treated as a bit array | Bloom-filter-style membership, feature flags per user id, daily active user tracking |
| Stream | append-only log with consumer groups | durable event queues (unlike Pub/Sub — see the caveat later) |
Pattern 1: Distributed Lock
The problem: several processes across different machines need to agree that only one of them runs a critical section at a time — booking the last seat, processing a payment webhook exactly once, running a scheduled job on exactly one replica.
Acquire — one atomic command, not two
worker-7f3a must be a value unique to the caller (a UUID, or pod name + thread id) — not a constant like "1". That value is what makes a safe release possible.
Release — verify ownership and delete atomically
This has to be Lua, not two separate commands (GET then DEL), because a GET-then-DEL from the client leaves a gap: another process's lock can be acquired in between your check and your delete. Lua runs the whole thing as one atomic step inside the server.
That sequence is the actual failure mode behind the well-known Redlock controversy: even a "correct" locking protocol can't stop a paused process from acting on a lock it no longer holds. The fix isn't a cleverer lock — it's a fencing token: hand out a monotonically increasing number with the lock, and make the protected resource itself reject any write carrying an older token than the last one it saw. The lock tells you who should be acting; the fencing token is what the resource checks to make sure a stale acquirer's write can't land after a newer one already did.
Extend the lock (heartbeat) while the work is still running
Locking several resources at once, all-or-nothing
[!WARNING] A lock that's never released on the failure path is a lock that hangs the system, not a bug that just affects one request. Always set an
EXon acquire — a lock with no TTL turns one crashed worker into a permanent outage for that resource.
Pattern 2: Atomic Counters & Rate Limiting
The problem: many concurrent requests need to increment a shared count and make a decision (allow/deny) based on it — without two requests both reading "9" and both incrementing to "10" when the real answer should have been "11".
The counter itself is already atomic
INCR alone is safe because it's one command on one thread. The moment you need "increment, and check a limit, and set an expiry the first time" — that's three decisions, and it needs Lua to stay atomic.
Fixed window rate limiter
Fixed windows are simple but let through up to 2x the intended rate right at the boundary — 100 requests in the last second of one window, then 100 more in the first second of the next, both technically "within limit."
Sliding window (sorted set) — no boundary burst
| Strategy | Burst behavior | Cost | When to pick it |
|---|---|---|---|
| Fixed window | Up to 2x limit at window edges | 1 key, cheapest | Rough limits, internal tools, low stakes |
| Sliding window (ZSET) | Smooth, no burst | 1 ZSET, one entry per request | Public APIs, billing-adjacent limits |
| Token bucket | Allows planned bursts, refills steadily | 1 key + a bit of client-side math | User-facing APIs where occasional bursts are fine |
Pattern 3: Compare-And-Swap (Optimistic Locking)
The problem: update a value only if it hasn't changed since you last read it — the classic "read balance, check it's sufficient, deduct" sequence, where two concurrent deductions must never both succeed against a balance that only covers one of them.
Redis gives you two different tools for this, and picking the right one matters.
Option A: WATCH / MULTI / EXEC — client-driven, retry on conflict
This is true CAS: optimistic, cheap when contention is low, but it costs a full round trip per attempt and can retry indefinitely under high contention on the same key.
Option B: Lua — server-driven, no retry loop needed
Same guarantee, zero round trips, and no retry loop — because nothing outside the script can run between the read and the write in the first place. There's nothing to conflict with.
WATCH/MULTI/EXEC | Lua (EVAL) | |
|---|---|---|
| Where the "decide" logic runs | Client | Server |
| Round trips per attempt | 3 (WATCH, GET, EXEC) | 1 |
| Behavior under conflict | Client must detect nil and retry | Never conflicts — it's already atomic |
| Best for | One-off transactions, simple precondition checks | Anything you'll call often, or with nontrivial logic |
The practical rule: if you're about to write a client-side retry loop around WATCH, that's usually a sign the logic belongs in a Lua script instead.
Pattern 4: Cache-Aside (and the Stampede It Can Cause)
Without the loading_lock, a popular key expiring under heavy concurrent traffic causes a cache stampede — hundreds of requests all miss at once and all hammer the database to rebuild the same value simultaneously. The lock ensures exactly one rebuild happens; everyone else waits milliseconds and reads the freshly-populated cache instead.
Pattern 5: Session Store
A hash beats one key per field (session:tok:user_id, session:tok:role, ...) for the same reason a struct beats five separate variables: one EXPIRE, one HGETALL, and the fields travel together.
Pattern 6: Leaderboards (Sorted Sets)
ZRANGE ... REV and ZREVRANK are O(log N) — this is the whole reason a sorted set, not a sorted SQL query, backs almost every real-time leaderboard: ranking a million players stays fast because the structure keeps itself ordered on every write, instead of re-sorting on every read.
Pattern 7: Pub/Sub Fan-Out
[!WARNING] Pub/Sub has no persistence and no replay — a subscriber that's disconnected for even a second misses every message published during that gap, permanently. If a consumer being briefly offline and missing an update is unacceptable (order events, financial updates), that's not a Pub/Sub problem — reach for Redis Streams (
XADD/XREADGROUP) instead, which keeps a durable log with consumer-group offsets, or a real message queue.
Pattern 8: Bloom Filter Approximate Membership
The problem: "have I seen this before?" against a huge set, without paying the memory cost of storing every item.
The one property that makes this safe to use for gating (not just hinting): zero false negatives, some tunable rate of false positives. It will never wrongly say "definitely not seen" for something it has seen — it will occasionally say "probably seen" for something it hasn't, at a rate you control by allocating more bits. That asymmetry is exactly right for a blocklist check (a false positive costs one extra confirming lookup; a false negative would let a known-bad actor straight through) and exactly wrong for anything where a false positive silently drops real data (don't use it to decide whether to skip processing an item).
Pattern 9: Idempotency Keys
The NX on the first SET is what prevents two near-simultaneous retries (a client's own timeout-and-retry, or a webhook redelivery) from both starting to process the same request — one wins the SET, the other gets nil and knows to back off.
Where Lua Actually Earns Its Keep
Every pattern above that needed Lua needed it for the same underlying reason: the operation is "read a value, make a decision based on it, then write" — and that whole sequence has to be one atomic unit, not three separate round trips a competing client's command could land in between.
| Situation | Plain commands enough? | Needs Lua / EVAL |
|---|---|---|
| Increment a counter, no limit check | Yes — INCR alone is atomic | — |
| Increment, and enforce a limit + set TTL only once | — | Yes (Pattern 2) |
| Release a lock you might not own anymore | — | Yes (Pattern 1) |
| Read a balance and conditionally deduct it | WATCH/MULTI/EXEC works, with a client retry loop | Yes, if you want zero round trips or high contention (Pattern 3) |
| Read-and-reset a metric atomically | — | Yes — GET then SET 0 from the client has a gap |
| Set a key only if absent, with an expiry | No script needed | SET key val NX EX ttl is already one atomic command |
The recurring signal: if your code would otherwise need WATCH, or a client-side "get, check, act" sequence, or two commands where a crash or a race between them would leave bad state — that's the shape of problem Lua exists for.
Antipatterns to Avoid
| Antipattern | Why it hurts | Do this instead |
|---|---|---|
KEYS * in production | Scans the entire keyspace on the single thread — every other client stalls until it finishes | SCAN with a cursor — same result, doesn't block |
SETNX then a separate EXPIRE for a lock | A crash between the two commands leaves a lock with no TTL — permanent | SET key val NX EX ttl — one atomic command |
HGETALL / SMEMBERS on unbounded collections | A hash or set with a million fields blocks the thread for the entire read | HSCAN / SSCAN — cursor-based, doesn't block |
| Treating Redis as the source of truth for money/inventory | Redis optimizes for speed, not the durability guarantees a ledger needs | Redis as cache/lock/counter in front of an authoritative database |
Large payloads in PUBLISH | The payload is copied to every subscriber connection — a 10MB message saturates the network N times over | Publish a small pointer/id; subscribers fetch the payload from a store |
SELECT in cluster mode | Redis Cluster only has database 0 — this silently does nothing or errors | Namespace keys with prefixes instead of numbered databases |
No maxmemory-policy set, then OOM under load | Default is noeviction — writes start failing hard the moment memory fills | Set an eviction policy (allkeys-lru, etc.) deliberately, matched to whether the data is a cache or must never be evicted |
| A single giant key (a hash with 5M fields, a list with 10M entries) | Any command touching it — even a single-field HGET in some cases, definitely HGETALL/DEL/expiry sweeps — takes proportionally longer and blocks the thread | Shard the collection across multiple keys (e.g. hash-tag by user id range) |
| Storing secrets/PII unencrypted | Redis is often reachable by anything inside the VPC, with no per-key encryption at rest | Encrypt sensitive fields before writing, or don't put them in Redis at all |
Before You Reach for Redis: A Decision Checklist
A short checklist worth running through before committing to Redis for a new use case:
- Durability tolerance — if the process holding this data restarted right now and lost everything, is that a shrug or an incident? Redis is happiest as the shrug case.
- Memory economics — Redis prices your data in RAM, not disk; a dataset that's cheap in Postgres can be expensive in Redis at the same size.
- Persistence trade-off — RDB snapshots are cheap but can lose the last few minutes of writes on a crash; AOF with
fsync alwaysloses far less but costs write throughput. Pick deliberately, not by default. - Eviction policy — decide explicitly whether this key space is allowed to be evicted under memory pressure (
allkeys-lru) or must never be (noeviction, with capacity planned to match). - Blocking command exposure — audit for
KEYS, unboundedHGETALL/SMEMBERS, and any Lua script with an unbounded loop; each one is a latency spike waiting to happen at scale. - Cluster-mode fit — multi-key operations (
MULTI, most Lua scripts) need all keys in the same hash slot in cluster mode; design key names with hash tags ({user:42}:profile,{user:42}:sessions) up front if you'll ever shard. - Hot key risk — a single wildly popular key (a viral post's like counter, one celebrity's session) can saturate a single shard even when the cluster overall has headroom; know your access pattern's skew before it surprises you in production.
