Redis caching: how it works and how to set it up properly · DpkDB
Redis caching: how it works and how to set it up properly
What to cache, string vs hash, key naming, how TTLs and expiry work, maxmemory and every eviction policy, a working cache-aside example, and the commands to measure and secure a Redis cache.
Redis caching means keeping a copy of data that is expensive to produce (a query result, a rendered fragment, an API response) in memory under a key, so that the next request reads it in one network round trip instead of redoing the work. Setting it up properly takes four decisions that the defaults do not make for you: a TTL on every key, a maxmemory limit, an eviction policy other than the default noeviction, and application code that still works when the cache is empty or unreachable.
A cache entry is a copy. It belongs in the cache if you could delete it at any moment and lose nothing except time.
Good candidates:
Query results that are read far more often than the underlying rows change: product pages, user profiles, permission sets.
Results that are expensive to compute and tolerate being a little old: aggregates, counts, search facets.
Responses from slow or rate-limited third-party APIs.
Poor candidates:
Anything that is the only copy: queues, locks, counters you bill from, sessions you cannot afford to drop. Those are data. They need noeviction and persistence, on a separate instance, because one instance has exactly one eviction policy.
Values where serving an old copy is a correctness bug: account balances at the point of debit, stock at the point of reservation. Read those from the database.
Choosing a data type: string or hash
Most cached values should be a serialized blob in a string key. Use a hash when you need to read or change individual fields: it avoids the read-modify-write race a blob has when two writers change different attributes.
Writes fail and reads still work. Understand Redis eviction policies, find what's filling memory, and pick the right fix depending on whether Redis is a cache or a datastore.
Set atomically with the value: SET key value EX 300
Separate EXPIRE on the key, so use a pipeline or MULTI
Memory
One allocation, compressible
Compact while small (listpack encoding), a real hash table once large
A small hash is stored as a listpack, a single contiguous block, and is memory-efficient. It converts to a hash table when it exceeds hash-max-listpack-entries (default 512) fields or any value exceeds hash-max-listpack-value (default 64) bytes, and the per-field overhead goes up. Check with OBJECT ENCODING key.
The TTL belongs to the key, not to the fields. Redis 7.4 added per-field expiry (HEXPIRE, HTTL), and Valkey added it in 9.0. On older servers, a hash holding many cached items cannot expire them individually, and eviction always removes the whole hash. Use one key per cached item so expiry and eviction work per item.
Serialization
JSON is the default choice: readable in redis-cli and portable across languages. MessagePack or Protocol Buffers give smaller values and faster decoding when values are large or request rates very high, and compression (zstd, LZ4, gzip) pays off from several kilobytes up. Avoid language-native formats such as Python pickle: they tie the cache to one class layout, and unpickling can execute code, so anyone who can write to the cache can run code in your application.
Key names and versioned namespaces
Use a predictable, colon-separated scheme: <app>:<entity>:<schema version>:<id>, for example shop:product:v1:42.
The application prefix lets several services share an instance and lets ACL rules match by pattern.
The schema version solves deployments. When the serialized shape changes, bump v1 to v2. New code uses v2 keys, old code still running during the rollout keeps using v1, and nobody deserializes a shape it does not understand. The v1 keys expire on their own.
Build the key from every input that changes the result: locale, currency, tenant, page number. Never include a timestamp or request ID, which makes every key unique and the hit ratio zero.
Key names are stored in memory once per key. Hash long composite inputs: search:v1:<sha1 of normalized query>.
A version in the key also gives you bulk invalidation without scanning: keep a counter such as shop:catalog:gen, include its value in the keys, and INCR it to make the whole group unreachable at once. Never invalidate with KEYS pattern and DEL: KEYS walks the entire keyspace on the main thread and blocks everything else while it runs.
TTLs and how expiry really works
Set the TTL in the same command that writes the value, so a key can never exist without one: SET shop:product:v1:42 '...' EX 300. EX takes seconds and PX milliseconds. EXAT and PXAT (Redis 6.2 and later) take an absolute Unix time.
What happens to a TTL afterwards surprises people:
A plain SET on an existing key removes its TTL. SET key newvalue with no expiry option turns a cache entry into a permanent key. Pass EX every time, or KEEPTTL (Redis 6.0 and later) to keep the remaining time.
Commands that modify a value in place (INCR, HSET, LPUSH, APPEND) leave the TTL untouched. RENAME carries it to the new name. PERSIST removes it.
TTL returns -1 for a key with no expiry and -2 for a key that does not exist.
Redis removes expired keys in two ways. Lazy expiry: when a command touches a key whose time has passed, the key is deleted and the command sees it as missing. Active expiry: a background cycle on the main thread repeatedly samples keys that have a TTL and deletes the expired ones, so keys that are never read again still get removed.
So an expired key is never returned by a read, but it can keep occupying memory for a while. With the default active-expire-effort 1, the cycle aims, according to the shipped redis.conf, to keep expired-but-not-yet-removed keys below about ten percent. Raising it (maximum 10) reclaims memory sooner for more CPU. expired_stale_perc in INFO stats reports the current estimate.
In a replicated setup, only the primary expires keys. It sends an explicit DEL to its replicas, so replicas never expire keys on their own schedule.
Configure the instance as a cache
Out of the box, Redis is configured as a datastore: no memory limit on 64-bit systems, and maxmemory-policy noeviction. A cache left like that grows until the operating system kills it or, with only maxmemory set, rejects writes with an OOM error. How do I fix Redis "OOM command not allowed when used memory > maxmemory"? covers that failure. The configuration that avoids it:
# redis.conf for a pure cache
maxmemory 4gb
maxmemory-policy allkeys-lru
save ""
appendonly no
CONFIG SET applies the same settings at runtime, and CONFIG REWRITE saves them if the server was started with a config file.
maxmemory
maxmemory limits the memory Redis uses for data. It is not a limit on the process: allocator fragmentation sits on top of it, and the output buffers for replicas and the AOF buffer are deliberately not counted (see mem_not_counted_for_evict in INFO memory). Leave room for those between maxmemory and the RAM of the machine or container.
maxmemory-policy
Policy
Candidates
Removes
Use it when
noeviction (default)
none
nothing; writes that need memory fail
The instance holds data, not cache
allkeys-lru
all keys
least recently used
General-purpose cache; the usual choice
allkeys-lfu
all keys
least frequently used
A stable set of popular keys, with scans or bursts of one-off keys that LRU would wrongly keep
allkeys-random
all keys
random keys
Access is uniform across keys
volatile-lru
keys with a TTL
least recently used
Cache and permanent keys share an instance
volatile-lfu
keys with a TTL
least frequently used
Same, with frequency-based choice
volatile-random
keys with a TTL
random keys
Rarely the right answer
volatile-ttl
keys with a TTL
shortest remaining TTL
You deliberately encode priority in the TTL
allkeys-lrm, volatile-lrm
all keys, or keys with a TTL
least recently modified
Redis 8.6 and later: reads should not keep an entry alive
Pick allkeys-lru for a dedicated cache unless you have measured a reason not to. It keeps working even if some code path forgets a TTL. The volatile-* policies behave exactly like noeviction once no key with a TTL is left to remove.
LRU and LFU are approximations. Redis does not keep a global ordered list. On each eviction it samples maxmemory-samples keys (default 5) and removes the best candidate. Raising the value to 10 gets close to true LRU for more CPU. LFU keeps a small probabilistic counter per key that decays over time (lfu-log-factor, lfu-decay-time). OBJECT FREQ key shows it, under an LFU policy only.
Eviction is not expiry
Expiry removes a key because its TTL ran out, and counts in expired_keys. Eviction removes a still-valid key because memory was full, and counts in evicted_keys. TTLs bound how stale data can get, and eviction bounds memory. You need both.
Persistence
For a pure cache, turn persistence off: save "" disables RDB snapshots and appendonly no (the default) disables the AOF. A snapshot forks the process, and copy-on-write during the snapshot can need substantial extra memory on a busy instance.
The trade-off is a cold cache after every restart. If your database cannot absorb that, keep RDB snapshots purely as a warm-start aid. If you use replicas, know that a primary with persistence off that restarts automatically comes back empty, and its replicas then synchronize to that empty dataset. That is acceptable for a cache and for nothing else.
A working cache-aside example
Cache-aside is the pattern to start with: the application reads the cache, falls back to the database on a miss, stores the result, and deletes the key when the data changes. This example uses SQLite as the database so it runs as is with pip install redis:
import json
import random
import sqlite3
import redis
r = redis.Redis(
host="localhost", port=6379, decode_responses=True,
socket_connect_timeout=0.2, socket_timeout=0.2,
)
db = sqlite3.connect("shop.db", isolation_level=None)
db.execute("CREATE TABLE IF NOT EXISTS product ""(id INTEGER PRIMARY KEY, name TEXT, price_cents INTEGER)")
db.execute("INSERT OR IGNORE INTO product VALUES (42, 'Kettle', 3499)")
SCHEMA = "v1"# bump when the cached shape changes
BASE_TTL = 300# secondsdefproduct_key(product_id: int) -> str:
returnf"shop:product:{SCHEMA}:{product_id}"defget_product(product_id: int):
key = product_key(product_id)
try:
cached = r.get(key)
except redis.RedisError:
cached = None# the cache is optionalif cached isnotNone:
return json.loads(cached)
row = db.execute(
"SELECT id, name, price_cents FROM product WHERE id = ?", (product_id,)
).fetchone()
if row isNone:
returnNone
product = {"id": row[0], "name": row[1], "price_cents": row[2]}
ttl = BASE_TTL + random.randint(0, BASE_TTL // 10) # jittertry:
r.set(key, json.dumps(product, separators=(",", ":")), ex=ttl)
except redis.RedisError:
pass# serve the result anywayreturn product
defupdate_price(product_id: int, price_cents: int) -> None:
db.execute("UPDATE product SET price_cents = ? WHERE id = ?",
(price_cents, product_id))
try:
r.delete(product_key(product_id)) # invalidate after the commitexcept redis.RedisError:
pass# the TTL bounds the stalenessprint(get_product(42)) # miss: reads SQLite, fills the cache
update_price(42, 2999)
print(get_product(42)) # miss again: the new price
Note the short socket timeouts, the Redis calls wrapped so a failure becomes a miss, the jittered TTL so keys written together do not expire together, and delete-on-write instead of updating the cached copy.
Other patterns exist (read-through, write-through, write-behind, refresh-ahead), and Redis caching patterns: cache-aside, write-through and TTLs introduces them. Cache-aside with delete-on-write is the right default until you have a specific reason to change.
Measure it
Start with the counters:
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|evicted_keys|expired_keys"
redis-cli INFO memory | grep -E "^used_memory:|^maxmemory:|maxmemory_policy|mem_fragmentation_ratio"
redis-cli INFO keyspace
Hit ratio is keyspace_hits / (keyspace_hits + keyspace_misses). The counters are cumulative since startup (or CONFIG RESETSTAT), so graph the rate of change, not the lifetime total. They cover every key lookup on the instance, so an application-side hit counter per namespace is more precise.
evicted_keys rising with a poor hit ratio means the working set does not fit: add memory or shrink values. expired_keys high with few evictions and a poor hit ratio means the TTLs are shorter than the interval between reads.
INFO keyspace prints keys= and expires= per database. On a cache they should be nearly equal.
mem_fragmentation_ratio is resident memory divided by used_memory. Well above 1 on a large instance means fragmentation. Below 1 means part of Redis has been swapped out, which is an emergency for latency.
Then look at latency and at individual keys:
redis-cli --latency # round-trip time of PING, sampled continuously
redis-cli SLOWLOG GET 10 # the slowest recent commands
redis-cli --bigkeys # largest key of each type
redis-cli --memkeys # largest keys by memory
redis-cli MEMORY USAGE shop:product:v1:42
SLOWLOG records commands that took longer than slowlog-log-slower-than (default 10000 microseconds) and keeps the last slowlog-max-len (default 128). It measures execution time only, so a clean slow log with slow clients points to the network or the connection pool. --bigkeys and --memkeys use SCAN and do not block the server. On a busy primary, throttle them with -i 0.1 or use a replica.
Security basics
Redis is designed to run inside a trusted network.
Bind to a private interface. The shipped redis.conf binds to loopback (bind 127.0.0.1 -::1). Add the private address, never a public one, and firewall the port.
Leave protected mode on. With protected-mode yes (the default), a server whose default user has no password accepts only loopback and Unix socket connections. Do not turn it off to make a connection work. Configure authentication instead.
Use ACLs. Give each application a user limited to its key prefix and the commands it needs:
That user cannot run FLUSHALL, CONFIG or KEYS, and cannot touch keys outside shop:. +hello and +client|setinfo cover the handshake client libraries send on connect. Disable default only after you have defined an administrative user.
Encrypt in transit when the network is not yours. TLS is built in: set tls-port 6379, port 0, tls-cert-file, tls-key-file and tls-ca-cert-file. The server must be built with TLS support.
Common mistakes
Leaving maxmemory-policy at noeviction. The cache works until it fills, then every write fails.
Writing keys without a TTL, or resetting the TTL by accident with a plain SET. Compare keys and expires in INFO keyspace.
Sharing one instance between a cache and durable data. Under allkeys-lru your queue can be evicted. Under noeviction your cache causes write errors.
Storing many items in one hash, list or set. The whole structure is one key for expiry and eviction, and deleting a very large key with DEL can block the server. UNLINK frees the memory in a background thread.
Treating Redis errors as request failures. A cache outage should make the application slower, not unavailable.
Using KEYS in production. Use SCAN.
Checklist
Cache only rebuildable copies, and keep durable data on a separate instance.
Use string blobs by default, and hashes only for field-level reads or updates.
Name keys <app>:<entity>:<version>:<id> and include every input that changes the result.
Set a jittered TTL in the same command as the value, on every write path.
Set maxmemory with headroom and maxmemory-policy allkeys-lru (or allkeys-lfu).
Turn off RDB and AOF for a pure cache unless you need a warm restart.
Use short timeouts, treat cache errors as misses, and delete the key after the database commit.
Graph hit ratio, evicted_keys, expired_keys, memory and latency, and run --bigkeys periodically.
Bind to a private address, keep protected mode on, use a per-application ACL user, and add TLS across untrusted networks.
References
Key eviction (Redis docs): maxmemory, all eviction policies, approximated LRU and LFU
EXPIRE and SET: TTL semantics, how keys expire, EX/PX/KEEPTTL