Your team proposes raising hash-max-listpack-entries and zset-max-listpack-entries from the default 128 to 4096 across the whole Redis fleet to cut memory cost. How would you evaluate that proposal, and what would you set instead?
answer
- RAM saved, single-thread CPU spent
- listpack read = O(n) scan; write = realloc + memmove
- savings flatten by ~1024, scan cost keeps rising
- value-size cap matters more than entry count
- no downgrade: existing hashtable values never benefit
basics
~20 sIt trades RAM for CPU on the single command thread: listpack access is a linear scan and writes may rewrite the whole blob, so 4096-entry values raise tail latency for every access. Evaluate per workload with measured memory and p99, set per-instance rather than fleet-wide, and prefer a moderate bump plus a hard cap on element size.
solid answer
~60 s**Name the trade explicitly.** Compact encodings save large amounts of memory because entries live in one contiguous allocation with no per-entry pointers or headers. The price is that lookups are O(n) scans and inserts may realloc and memmove the entire blob — and all of that runs on Redis's single command-execution thread, where extra microseconds delay every other client. **Evaluate with data, not defaults.** For each workload measure, at 128 / 512 / 1024 / 4096: `MEMORY USAGE` on representative keys, p99 and p999 latency of the actual command mix under production-like concurrency, and `INFO commandstats` usec_per_call. Memory savings flatten quickly past a few hundred entries while scan cost keeps growing linearly — the curve, not the principle, decides the number. **Refuse fleet-wide.** A read-mostly instance of small hashes and a write-heavy leaderboard have opposite optima. Set thresholds per instance or per deployment, alongside the key design they support. **Cap element size too.** `*-max-listpack-value` is the setting that actually protects you: one large element makes a big listpack pathological.
code
text · 12 linesfor n in 128 512 1024 4096; do
redis-cli CONFIG SET hash-max-listpack-entries $n
redis-cli FLUSHDB
./load-representative-slice.sh # rewrite keys so the new limit applies
echo "n=$n encoding=$(redis-cli OBJECT ENCODING sample:key)"
redis-cli INFO memory | grep '^used_memory:'
redis-cli CONFIG RESETSTAT
./run-production-mix.sh # real command mix, real concurrency
redis-cli INFO commandstats | grep -E 'hget|hset'
redis-cli SLOWLOG LEN
done
# choose the knee of the memory curve, not its asymptotego deeper
Understand that bigger compact structures save memory but make each access scan more data.
State the RAM-versus-CPU trade concretely and note that conversion is one-way and applies only to new or modified values.
Design the measurement — memory and p99 across candidate thresholds on a real command mix — and defend a moderate per-instance value with a tight element-size cap.
Reject the fleet-wide framing, sequence cheaper memory levers first, quantify the single-thread cost against the RAM saved, and own the rollout, persistence and rollback story for a sticky configuration change.
## What the proposal is really asking for `hash-max-listpack-entries` and `zset-max-listpack-entries` decide when Redis abandons the compact contiguous representation for a real hash table or skiplist. Raising them keeps more values compact and therefore smaller in memory. The proposal is a straight RAM-for-CPU trade, and the specific number matters far more than the direction. ## The two costs that grow with the threshold **Read cost.** A listpack is scanned linearly: `HGET` on an n-field listpack walks up to n entries, each with variable-length prefixes, so it cannot be indexed. At n=128 this is a handful of cache lines and genuinely faster than hashing plus a pointer chase. At n=4096 it is a real scan, and the hash table it replaced would have been O(1). **Write cost.** Adding or growing an element in a listpack can require reallocating the whole blob and memmoving the tail. So writes are O(bytes), not O(1), and the bytes grow with both entry count and element size. **Where those costs land.** Redis executes commands on a single thread. Extra microseconds per command are not spread across cores; they serialise in front of every other client. A workload doing 100 k ops/s where each op gains 20 µs has just consumed two seconds of thread time per second — it saturates. That is why the defaults look conservative: they are calibrated so that the compact path stays within the noise of a normal command. ## How to evaluate properly 1. **Characterise the workload.** Collection sizes (a histogram, not an average), element sizes, and the read/write mix per command from `INFO commandstats`. Small hashes read once per request behave nothing like a sorted set written thousands of times per second. 2. **Measure memory across candidate thresholds.** Load a representative slice at 128, 512, 1024, 4096 and record `MEMORY USAGE` totals with `OBJECT ENCODING` confirming the compact form. Typically most of the saving is captured well before 1024 — per-entry overhead is already amortised — while scan cost keeps rising linearly. That diminishing-returns curve is the core argument against 4096. 3. **Measure latency, not throughput averages.** p99/p999 under production-like concurrency, plus `usec_per_call` from `commandstats` and `SLOWLOG` counts. Averages hide exactly the effect you are looking for. 4. **Test the worst case, not the median.** The pathological value is a large listpack full of large elements, written frequently. If the data model permits it, it will occur. ## What I would set instead - **A moderate entries bump where measurement supports it** — commonly 256–1024 for read-mostly, small-element workloads with many collections, which captures most of the memory win at a fraction of the CPU cost. - **Leave `*-max-listpack-value` tight** (64, maybe 128). This is the setting that bounds the *bytes* being scanned and memmoved. A 4096-entry listpack of 8-byte values is manageable; a 4096-entry listpack of 1 KB values is a 4 MB blob rewritten on every insert — a latency incident waiting for traffic. - **Per-instance, not fleet-wide.** Thresholds belong with the workload. A session store, a leaderboard cluster and a queue instance should each get the value that measurement justifies, expressed in configuration management rather than as a global edict. - **Persisted config.** Set in `redis.conf` (or `CONFIG REWRITE` after `CONFIG SET`), otherwise a restart silently reverts and behaviour diverges between nodes — a nasty source of "this replica uses twice the memory". ## Deployment realities the proposal must address - **`CONFIG SET` does not re-encode existing data.** There is no downgrade path from `hashtable` to `listpack`, so existing large values keep their current encoding forever; only new and modified values benefit. Realising the saving on an existing dataset means rewriting the keys, effectively a re-import — that is the real cost of the change, not the config edit. - **Rollback is asymmetric.** Lowering the threshold later does not convert oversized listpacks; they convert only when written past the new limit. So a bad value is sticky in a way most config changes are not. - **Replicas and persistence.** Encoding affects RDB size and load time and the memory a replica needs; a fleet-wide change moves those numbers too. ## The question behind the question Raising thresholds is usually a proxy for "we are paying too much for RAM". Before accepting the CPU trade, check the cheaper levers: shorter key names at high key counts, integers stored as integers, strings under 44 bytes for `embstr`, TTL coverage and `maxmemory-policy` (an unexpiring cache is a leak), and whether the data belongs in Redis at all. Encoding tuning is a legitimate optimisation, but it is a late one, and it buys memory with the scarcest resource Redis has: single-threaded command time.
- Why is *-max-listpack-value more dangerous to raise than *-max-listpack-entries?The entries limit bounds how many elements are scanned; the value limit bounds how many bytes each element contributes. Listpack work is ultimately proportional to total bytes, because an insert can realloc and memmove the whole blob. Raising the value limit lets a modest number of entries produce a multi-megabyte listpack, so a single HSET becomes a multi-megabyte memmove on the single command thread — a far worse tail-latency profile than a longer scan over small entries.
- The team applies the new threshold with CONFIG SET on a live fleet and sees no memory improvement. Why?Encoding conversion is one-way and evaluated at write time. Values already converted to hashtable or skiplist are never downgraded, so nothing existing changes; only newly created values, and existing compact values that are modified, are affected by the higher limit. Realising the saving on an existing dataset requires rewriting the keys — a re-import or a background rewrite pass — which is the real cost and risk of the proposal.
- Under what circumstances would you accept 4096 for a specific instance?When measurement supports it on that workload: very many collections whose sizes cluster in the low thousands, elements of a few bytes each, a read-light and write-light access pattern per collection, and memory as the binding constraint. I would still cap the element size tightly, verify p99 and usec_per_call at production concurrency, persist the setting in the config file, and apply it only to that deployment rather than the fleet.
saying these in an interview costs you the question
- Treating the change as pure upside because memory drops
- Applying one threshold fleet-wide across unrelated workloads
- Ignoring that the cost lands on Redis's single command thread
- Expecting CONFIG SET to shrink an existing dataset
- Raising the entries limit while leaving the element-size limit unbounded
- Judging the change on average throughput instead of p99/p999