Redis keeps all data in memory and runs commands one at a time on one thread over an event loop (epoll on Linux), so a command takes microseconds with no locks. Redis 6 added optional I/O threads that write replies (and read requests, if enabled) in parallel; commands still run on the main thread.
Memory, not disk: a GET is a hash lookup and a few pointer moves, so the network round trip, not the CPU, is usually the limit. One core serves well over 100,000 simple commands a second.
One thread is a feature: there are no locks to contend on, every command is atomic for free, and one event loop serves thousands of client sockets.
Compact encodings: small hashes and sets are flat listpack or intset arrays that fit in the CPU cache.
I/O threads (io-threads 4, off by default) take socket work off the main thread: in Redis 6 and 7 only writes, and reads only with io-threads-do-reads yes; Redis 8 reworked them to read, parse and write. They help with many clients or large replies. Execution order and atomicity do not change.
The price: one slow command (KEYS *, a huge DEL, a long Lua script) stalls every client. Using more cores means running more instances, which is what Redis Cluster does.
GoMiddle
Redis & Caching
Data types, single-threaded execution, persistence, eviction and expiry, pipelining and Lua, cache penetration and stampedes, Sentinel and Cluster
Questions closed
0 / 17
- 0Review
Questions
Strings hold cached values and counters, hashes hold objects, lists are queues and recent-item feeds, sets keep unique members, sorted sets rank members by a score, and streams are an event log read by consumer groups. The right type lets Redis do the work atomically on the server.
String (up to 512 MB): cached JSON, INCR counters, SET NX EX for locks and idempotency keys.
Hash: a session or a cart where you change one field (HINCRBY) instead of rewriting a JSON blob. Since Redis 7.4 a field can have its own TTL (HEXPIRE).
List: O(1) at both ends: a simple queue (LPUSH, BRPOP) or the last N items (LPUSH plus LTRIM). Access by index is O(N).
Set: tags, who liked a post; SINTER finds the common members on the server.
Sorted set: O(log N) updates by score: leaderboards, sliding-window rate limits, delayed jobs scored by their run time.
Stream: an append-only log with consumer groups and acknowledgements, for events and jobs that must survive a consumer crash.
Special purpose: HyperLogLog counts unique visitors in 12 KB with about 0.81% error, plus bitmaps and geo indexes. Redis 8 also ships JSON and Bloom filters.
Memcached is a multi-threaded in-memory cache of plain values, with no persistence or replication. Redis adds data structures, persistence, replication, scripting and clustering, but runs commands on one thread. Memcached still fits a big blob cache that should use every core of one machine.
Threads: Memcached uses all cores in one process; Redis uses one core per instance and scales out with more instances or Cluster.
Data model: Memcached has get, set, increment and cas on whole values (up to 1 MB by default); Redis changes one hash field or one sorted-set member in place.
Durability: a Memcached restart empties it, and there are no replicas. Clients shard keys with consistent hashing, so a dead node only means misses. Redis has RDB, AOF, replicas, Sentinel and Cluster.
In practice: new Go services usually pick Redis (or Valkey, its BSD-licensed fork), because one system then covers caching, locks, rate limits and queues. Memcached stays where it already runs at scale.
Cache-aside: read Redis; on a miss read Postgres and store the result with a TTL; on a write update Postgres, then delete the key. A stampede (thousands of misses hitting the database at once) is stopped by collapsing concurrent misses with singleflight and jittering TTLs.
Delete, do not set, on write: two writers that each SET can finish in the wrong order and leave the older value cached. A small race remains (a reader can refill a stale value just before the delete), so the TTL bounds how stale an entry can get.
Write-through: writing the cache together with the database keeps reads hot, but couples every write to Redis and still needs a TTL.
**singleflight.Group** (golang.org/x/sync) runs one load per key per process while the other callers wait for its result. Detach the load from the first caller's context, or that caller's cancellation fails everyone.
Jittered TTLs stop keys written together from expiring together. For the hottest keys, refresh early in the background or take a short SET NX lock so one pod reloads for the whole fleet.
Redis is optional: on a Redis error, log it and read the database; a cache outage must not become an outage.
go-redis reports a missing key as the error redis.Nil (its text is redis: nil), not as empty bytes, so every miss takes the err != nil branch and is returned as a failure. The b == nil branch that should load from Postgres never runs.
Why an error: GET on a missing key gets a null reply, and go-redis turns it into the sentinel redis.Nil, so a miss stays distinguishable from a key that holds an empty string (that one returns empty bytes and a nil error).
Other reads do the same: HGet of a missing field, LPop of an empty list, ZScore of a missing member, a blocking read that timed out. MGet is the exception: a missing key is a nil element in its result.
The fix: check errors.Is(err, redis.Nil) before the generic err != nil and treat it as a miss. Any other error (a timeout, a refused connection) is logged as a warning and the database is read, so a Redis problem costs latency instead of a 500.
How it got through review: the tests mocked the cache and returned (nil, nil) for a miss, which the real client never does. A fake with real semantics, such as miniredis, or a Redis container in tests catches it.
Cache penetration is traffic for keys that do not exist: the database finds nothing, so nothing is cached, and every repeat goes to Postgres again. Cache the miss itself for a short TTL, and put a Bloom filter of existing ids in front when the ids are too random for cached misses to repeat.
Where it comes from: scrapers walking ids, clients asking for deleted items, attackers sending random ids. A 99% hit ratio on real products hides it.
Validate first: reject ids that cannot exist (not a positive integer, a malformed UUID) and rate-limit per client.
Negative caching (the code): a "not found" marker with a TTL of about a minute. The create path must delete it, or a new product stays invisible until the marker expires.
Bloom filter: a bit array set by several hashes of each id. "Not present" is always right, "present" is wrong with a chosen probability: about 10 bits per id give 1%, so 10 million ids take 12 MB. It cannot delete, so add each id when it is created and rebuild the filter from time to time. RedisBloom's BF.EXISTS (bundled with Redis 8) shares one filter across pods, but it also answers 0 for a filter key that does not exist, so a restart that loses the filter turns every id into a 404 unless you check for it.
Not a stampede: penetration is misses for keys that never exist; a stampede is a burst of misses for one hot key that does.
Each GET is a full network round trip, and the loop waits for every reply before it sends the next request: 500 × about 0.5 ms is 250 ms, while Redis spends microseconds per command. MGET and a pipeline send the whole batch in one round trip.
Measured: against a local Docker Redis with a 0.25 ms round trip, the loop took 128 ms and the MGET version 0.4 ms. Faster Redis hardware changes neither number.
**MGET**: one command, one reply in key order. A missing key is a nil element, not redis.Nil.
Pipeline: Pipelined buffers any mix of commands, writes them at once and reads all the replies; each command keeps its own result and error. Redis also saves a read and a write system call per command.
Not a transaction: other clients' commands can run between the pipelined ones. TxPipelined wraps the batch in MULTI/EXEC when it must apply as a unit.
Bound the batch: Redis holds every reply in memory until it is sent, so split 100,000 commands into batches of a few hundred to a few thousand.
In Redis Cluster: an MGET must keep all its keys in one hash slot, while ClusterClient splits a pipeline by node for you.
By default go-redis does not apply the context's deadline to the socket: with ContextTimeoutEnabled false, a read waits up to the client's own ReadTimeout, which is seconds. Turn the option on, keep ReadTimeout and WriteTimeout as a backstop, and stop calling Redis while it is failing.
**Measured with CLIENT PAUSE**: the default client returned only after the full 2-second stall, and on an 8-second stall failed at the 5-second ReadTimeout default (3 s in older v9 releases). With the option on, the call failed after 50 ms with context deadline exceeded.
The catch: a deadline that fires mid-reply leaves unread bytes on the socket, so go-redis closes the connection and the next call pays for a new dial, TLS and AUTH. Under a stall that churn feeds itself. The go-redis docs prefer fixed socket timeouts and warn against ones under a second on cloud networks.
Keep the socket timeouts: they still bound calls without a deadline and the client's own background checks. Waiting for a free pooled connection honours the context in both modes.
Decide what a timeout means: for a cache, a counted miss, then the database or a default value. A circuit breaker stops paying 50 ms and a new connection on every request while Redis is down.
Two ways: lazily, when a command touches a key whose TTL has passed, and actively, in a background cycle that samples keys with a TTL about ten times a second. A key that nobody reads waits for that sampler, so expired keys can hold memory for a while.
Lazy expiry: every command checks the key's expiry first; an expired key is deleted on the spot, so a client never reads an expired value.
Active expiry: hz times a second (10 by default) Redis samples 20 keys that have a TTL, deletes the expired ones and keeps sampling while more than 10% of a sample were expired, using at most about a quarter of the CPU time. active-expire-effort lowers that threshold for faster cleanup at more CPU.
Why memory lingers: when millions of keys expire faster than the sampler clears them, they still count toward maxmemory and can push the instance into eviction while dead keys hold the space.
Replicas do not expire keys on their own: the primary sends a DEL, so all copies agree, and meanwhile a replica already answers as if the key were gone.
TTL traps (the code): SET without EX or KEEPTTL drops the old TTL, while INCR, HSET and LPUSH keep it; TTL returns -1 for "no TTL" and -2 for "no such key". Keys written together with one TTL expire together, so add jitter.
With the default policy, noeviction, writes start failing with OOM command not allowed when used memory > 'maxmemory' while reads still work. A cache should use allkeys-lru, or allkeys-lfu when a steady set of hot keys must survive one-off scans of cold ones.
Set a limit: maxmemory 0, the default, means no limit, so Redis grows until the OOM killer stops it. Set it below the container's memory, with headroom for the persistence fork and client buffers.
**allkeys-* policies choose among all keys; volatile-*** ones only among keys with a TTL, and with no such keys they act like noeviction. volatile-ttl removes the keys closest to expiring.
Approximate: Redis samples maxmemory-samples keys (5 by default) and evicts the best candidate, instead of keeping an exact LRU list; 10 samples come closer to exact LRU for more CPU.
LRU or LFU: LRU looks only at the last access, so a batch job that reads a million cold keys once pushes out the hot set. LFU keeps a small decaying frequency counter per key, so keys used often stay.
**When noeviction is right: Redis as a store, a queue or a lock service, where a rejected write is visible and a silently evicted job is not. Keep such data on its own instance rather than mixing it with the cache.
Watch**: evicted_keys growing in INFO stats while the hit ratio falls means the cache is too small.
RDB writes a point-in-time snapshot from a forked child, so a crash loses everything since the last snapshot, often minutes. The AOF logs every write: with the default appendfsync everysec a crash loses about one second, with always almost nothing, at a high cost in write latency.
RDB: BGSAVE or a save rule forks, and the child writes the file while the parent serves. Copy-on-write duplicates every page changed meanwhile, so a write-heavy instance can need close to twice its memory, and the fork itself stalls commands (latest_fork_usec). The file is compact and loads fast: good for backups and new replicas.
AOF fsync: always syncs before replying, everysec once a second, no leaves it to the OS, which on Linux usually flushes within about 30 s.
AOF rewrite: a fork compacts the log. Since Redis 7 the AOF is a directory of a base file plus increments, and with aof-use-rdb-preamble yes the base is in RDB format, so it loads fast. With both enabled, Redis restores from the AOF, the more complete of the two.
For a cache: the defaults (RDB on, AOF off), or nothing at all; a restart then starts cold, so plan for the burst of misses.
For data you keep: AOF everysec, RDB files for backups, and replicas. Replication is asynchronous, so a failover can lose more than the fsync setting suggests.
Every single command is atomic, since one thread runs commands one at a time. MULTI/EXEC runs a queued batch with nothing in between but cannot branch on what it reads, WATCH adds an optimistic check, and a Lua script reads, decides and writes in one step. None of them rolls back.
**MULTI/EXEC**: commands are queued and run together at EXEC, and their replies arrive only after it, so you cannot read a value inside the transaction and decide what to write.
**WATCH**: watch the keys, read them, then MULTI/EXEC. If another client changed a watched key, EXEC runs nothing and you retry (redis.TxFailedErr in go-redis); on a hot key most attempts fail.
Lua (EVAL, or Functions since Redis 7): the script runs to completion with no other command in between, so read-check-write needs no retry and costs one round trip. Pass every key in KEYS; in Cluster they must share a slot, and a long script blocks every client.
No rollback: an error found while queueing (wrong arity, unknown command) discards the transaction with EXECABORT, but a run-time error, such as WRONGTYPE on one key, fails only that command and the rest stay applied. A Lua script that errors halfway keeps its earlier writes too. Redis treats these as programming errors and does not pay for an undo log.
The classic bug: INCR and then EXPIRE as two calls. A crash between them leaves a counter with no TTL forever; send both through TxPipelined or a script.
Redis runs commands on one thread, so an O(N) command blocks every other client until it finishes: KEYS walks the whole keyspace, and DEL of a huge set frees millions of elements on the spot. SCAN walks the keyspace in small cursor steps, and UNLINK frees the memory in a background thread.
Measured on a laptop (Redis 8): KEYS over 10 million keys blocked for about 0.5 s and DEL of a two-million-member set for about 90 ms, while UNLINK of the same set returned at once. Slower production CPUs make it worse, and every client's timeout keeps running meanwhile.
**SCAN** returns a page of keys and the next cursor, and the walk ends when the cursor is 0 again. A key that exists throughout is returned at least once and may come twice (a test that deleted 5,000 keys while scanning got 5,003 names). MATCH filters after the page is fetched, so a page can be empty, and COUNT is only a hint. HSCAN, SSCAN and ZSCAN walk inside one big key.
**UNLINK** removes the key at once and frees a large value in the background; lazyfree-lazy-user-del yes makes DEL do the same.
In Cluster scan every primary (ForEachMaster in go-redis): each node holds only its own slots.
Find and forbid: SLOWLOG GET shows slow commands, redis-cli --bigkeys finds big keys, and an ACL with -@dangerous keeps KEYS and FLUSHALL away from the application's user.
A list popped with BRPOP loses the message a crashed consumer was holding, Pub/Sub stores nothing and drops messages for anyone not connected, and a stream with a consumer group keeps each entry pending until it is acknowledged, so another consumer can claim a crashed one's work.
List: LPUSH plus BRPOP is the simplest queue. For a reliable one, BLMOVE queue processing RIGHT LEFT (Redis 6.2) moves each item atomically into a processing list, LREM removes it after success, and a reaper moves stuck items back. No replay, no fan-out.
Pub/Sub: delivers to whoever is subscribed at that moment. A reconnecting subscriber misses messages, and a slow one is disconnected at client-output-buffer-limit pubsub 32mb 8mb 60. Good for invalidation hints and live notifications, not for jobs.
Stream: XADD appends an entry; XREADGROUP gives each entry to one consumer of the group and keeps it in the group's pending list until XACK. XAUTOCLAIM hands entries idle past a threshold to a live consumer, and the delivery count in XPENDING exposes a poison message. Several groups each read the whole stream; cap its length with MAXLEN ~ on XADD.
What all three share: the data lives in RAM and replication is asynchronous, so a failover can lose the newest entries, and delivery is at least once at best, so handlers must be idempotent. For high volume, long retention or replay by time, Kafka fits better.
Sentinel gives one primary automatic failover: sentinels agree that it is down and promote a replica, but all data still lives on one primary. Cluster also shards: a key maps to one of 16384 slots by CRC16(key) mod 16384, each primary owns a range of slots, and a command spanning slots is refused.
Sentinel
Three or more sentinels on separate hosts. A quorum agrees that the primary is unreachable, and a leader sentinel promotes the best replica (by priority, then by replication offset) and repoints the others.
Clients ask the sentinels for the current primary (NewFailoverClient with a MasterName), so nothing is reconfigured on failover. Writes and memory are still one machine's.
Cluster
Every primary has its own replicas, nodes gossip, and a majority of primaries votes a failed primary's replica in. No sentinels.
A client that asks the wrong node gets MOVED 3999 10.0.0.5:6379, or ASK while a slot migrates; ClusterClient caches the slot map and follows both. Only database 0 exists.
CROSSSLOT
A multi-key command (MGET, SUNIONSTORE, RENAME), a MULTI/EXEC or a script must use keys of one slot, or it fails with CROSSSLOT Keys in request don't hash to the same slot. Some clients split a plain MGET per slot; nothing can split a transaction or a script.
Hash tags fix it by design: only the part inside {} is hashed, so {user:42}:cart and {user:42}:cart-version share a slot. Group one user's or one order's keys, never everything under one tag, or that slot becomes a hot shard.
Why 16384: the slot bitmap travels in every heartbeat, and 16384 bits are 2 KB, enough for the roughly 1,000 nodes Cluster is designed for.
The primary replies before any replica has the write, so a failover to a lagging replica loses what it never received, and an isolated primary keeps accepting writes that are discarded when it rejoins. min-replicas-to-write bounds that window and WAIT narrows it; neither makes Redis consistent.
Where acknowledged writes go
Lag: replication runs milliseconds behind, seconds under load or a network hiccup. Sentinel and Cluster prefer the replica with the largest offset, and it can still miss the last writes.
Split brain: the old primary, cut off with a few clients, keeps answering OK while the majority promotes another node. When the partition heals it becomes a replica, resyncs, and its writes from that window are gone.
An empty primary: a primary with persistence off that restarts quickly comes back empty, and its replicas faithfully sync the empty dataset. Persist on the primary, or do not let it restart on its own.
What the settings change
**min-replicas-to-write 1 with min-replicas-max-lag 10**: the primary refuses writes with NOREPLICAS Not enough good replicas to write. when no replica has acknowledged within 10 s, so an isolated primary stops taking writes after about that long. The cost: writes fail while the replicas are down.
**WAIT 1 100** blocks until one replica has acknowledged the client's earlier writes, or 100 ms pass, and returns how many did. A timeout does not undo the write, and a failover can still pick a replica that missed it. WAITAOF (Redis 7.2) also waits for the AOF on disk.
The design consequence
Anything you cannot lose belongs in Postgres. What lives only in Redis (locks, counters, limits) must tolerate a lost write: fencing tokens for locks, the database's own check for stock.
Postgres was sized for the few percent of reads that miss, so losing the cache multiplies its load many times. You need a cache that fails over instead of disappearing, fast failure on Redis errors, and a cap on how many misses each pod sends to Postgres, with the excess shed or served stale.
Keep the cache up
Replicas with Sentinel or Cluster turn a lost node into seconds of failover; RDB on the cache nodes, or a warm-up job, keeps a restarted node from coming back empty.
Fail fast
A short deadline that reaches the socket, and a circuit breaker around Redis: while it is open, requests skip Redis instead of each paying a timeout. Measure how long your client takes to fail: a default go-redis v9.22 client spent 1.7 s on one call to a port that refused connections, retrying the dial.
Protect the database
A per-pod cap on concurrent loads (the code), sized from what Postgres can serve divided by the number of pods, that sheds the excess with 503 and Retry-After instead of queueing. SetMaxOpenConns still matters, but it queues callers rather than rejecting them.
singleflight per key, so identical misses collapse into one query per pod.
Degrade on purpose: an in-process cache of the hottest keys, stale values on error, and non-critical features switched off by a flag.
Before and after
Know the database's capacity at a 0% hit ratio, and kill Redis in staging under production-like load to see what really happens.
After recovery, ramp traffic back or pre-warm the hottest keys, with jittered TTLs so they do not expire together again.