skip to content

A Redis list of a million elements reports the "quicklist" encoding while a three-element list reports "listpack". Explain how a quicklist is built and what list-max-listpack-size and list-compress-depth control.

level: seniorimportance: should knowfreq 30%

answer

  1. quicklist = linked list of listpack nodes
  2. positive size = elements/node, negative = KB (-2 = 8 KB default)
  3. compress-depth N: N uncompressed at each end, LZF the middle
  4. small list (7.0+) = bare listpack encoding
  5. LRANGE 0 -1 still O(n) on the single thread

basics

~20 s

A quicklist is a doubly linked list whose nodes are each a listpack holding many elements. list-max-listpack-size caps a node by element count (positive) or by bytes (negative, e.g. -2 = 8 KB). list-compress-depth LZF-compresses interior nodes, leaving that many nodes uncompressed at each end.

solid answer

~60 s

Small lists are stored as a single **listpack** — one contiguous blob, minimal overhead. Once a list outgrows that, Redis uses a **quicklist**: a doubly linked list whose *nodes* are themselves listpacks holding many elements each. That hybrid avoids both extremes — a plain linked list would cost pointers and an allocation per element; one giant listpack would make every insert a whole-blob realloc and memmove. `list-max-listpack-size` (alias `list-max-ziplist-size`) sizes a node. **Positive** = maximum elements per node (e.g. 128). **Negative** = a byte cap per node from a fixed table: -1 = 4 KB, -2 = 8 KB (the default), -3 = 16 KB, -4 = 32 KB, -5 = 64 KB. Byte-based is usually better because it bounds work regardless of element size. `list-compress-depth` (default 0 = off) LZF-compresses **interior** nodes while leaving N nodes at each end uncompressed. Set it to 1 and everything but the head and tail node is compressed — ideal for a queue where you only ever touch both ends, and bad for a list you index into the middle of, since each access must decompress.

code

text · 14 lines
text
# bound each quicklist node to 8 KB (default), not by element count
CONFIG SET list-max-listpack-size -2

# queue-shaped access: compress everything but head and tail nodes
CONFIG SET list-compress-depth 1

> RPUSH q a b c
> OBJECT ENCODING q
"listpack"

> for i in $(seq 1 100000); do redis-cli RPUSH q "msg$i"; done
> OBJECT ENCODING q
"quicklist"
> MEMORY USAGE q

go deeper

for a junior

Know that big lists are stored as a linked list of compact blocks rather than one node per element.

for a middle

Explain the hybrid rationale and that list-max-listpack-size bounds each node by count or bytes.

for a senior

Add the negative-value byte table, the compress-depth access-pattern rule, and the residual O(n) command risks on the single thread.

for a principal

Judge whether a list is the right primitive at that scale at all — versus a stream or an external queue — and set node-size and compression policy from the real access pattern and payload entropy.

## Why a hybrid structure Redis lists must support cheap `LPUSH`/`RPUSH`/`LPOP`/`RPOP` at both ends, at any size, and they are used as queues holding millions of entries. Two naive designs both fail: - A **plain doubly linked list** costs two pointers plus an allocation header plus an SDS header per element — tens of bytes of overhead for a 10-byte element — and scatters elements across the heap, so traversal is a chain of cache misses. - A **single listpack** has almost no per-element overhead and perfect locality, but every insertion in the middle, and any growth that outgrows the allocation, requires reallocating and memmoving the entire blob. At a million elements that is a multi-megabyte memmove on the single command thread. The **quicklist** takes the middle path: a doubly linked list of nodes, where each node is a listpack containing many elements. Pointer overhead is amortised across the elements in a node; realloc/memmove cost is bounded by the node size, not the list size. Pushes and pops touch only the head or tail node. Since Redis 7.0, a list small enough to fit in one node is stored as a bare `listpack` (no quicklist wrapper at all), so `OBJECT ENCODING` returns `listpack` for small lists and `quicklist` for large ones. On Redis 6 and earlier the two encodings were `ziplist` and `quicklist`, with quicklist nodes being ziplists. ## list-max-listpack-size This single setting is overloaded, and the sign selects the mode: - **Positive N** — each node holds at most N elements. Simple, but with variable-length elements a node's byte size is unbounded: 128 elements of 1 MB each is a 128 MB node, which reintroduces the giant-memmove problem. - **Negative N** — each node is capped by size, from a fixed table: `-1` = 4 KB, `-2` = 8 KB (the default), `-3` = 16 KB, `-4` = 32 KB, `-5` = 64 KB. The negative form is the safer default because it bounds the *work* per operation rather than the element count. Larger nodes mean fewer pointers (less memory, better locality) but longer per-node scans and larger memmoves; smaller nodes mean the opposite. 8 KB sits near the sweet spot for typical element sizes, which is why it is the default. Note that an element larger than the node cap gets a node of its own — Redis never splits a single element across nodes. ## list-compress-depth Lists used as queues have a striking access pattern: producers touch the tail, consumers touch the head, and the middle is never read until it reaches an end. `list-compress-depth` exploits that. Its value N means: leave N nodes uncompressed at **each** end and LZF-compress every node in between. `0` (the default) disables compression entirely; `1` compresses everything except the head and tail node; `2` leaves two at each end, and so on. LZF is fast and gives useful ratios on text-like payloads, so a large queue of JSON messages can shrink substantially. The cost: any operation that must read or modify a compressed node (an `LINDEX` into the middle, `LINSERT`, `LRANGE` spanning the interior, `LREM`) must decompress it first, and re-compress after a write. For a strict FIFO queue that never happens; for a list you index into randomly, it turns cheap reads into decompress-per-access. So the decision rule is behavioural, not numerical: compress the interior when the list is genuinely a queue or a capped log that is only ever appended to and consumed from the ends, and leave it at 0 when the middle is read. ## Operational notes - Both settings apply at write time. Changing them does not re-encode existing lists; nodes are re-sized as the list is modified. - Very large lists remain dangerous regardless of encoding: `LRANGE key 0 -1` on a million elements is O(n) in elements returned and blocks the single thread while it serialises the reply. `LPOS`, `LINSERT` and `LREM` are also O(n). Quicklist bounds the *memory layout* cost, not the algorithmic cost of the command. - `DEL` on a huge list frees every node synchronously; `UNLINK` moves the freeing to a background thread, which is the safer habit for multi-million-element lists. - Use `OBJECT ENCODING` plus `LLEN` plus `MEMORY USAGE` to see whether a list's footprint is payload or structure, and remember that with compression enabled `MEMORY USAGE` reflects the compressed size.

  • When would setting list-compress-depth to a non-zero value hurt?
    When the list is not accessed only at its ends. Any LINDEX into the middle, LINSERT, LREM, or an LRANGE spanning interior nodes must LZF-decompress those nodes first, and a write must re-compress afterwards, turning a cheap operation into CPU work on the single command thread. It also hurts when elements are already compressed or high-entropy — binary blobs, images, pre-gzipped payloads — where LZF spends cycles for almost no size reduction.
  • Would you prefer a positive or a negative list-max-listpack-size, and why?
    Negative, in almost all cases. A positive value caps elements per node, so with variable-length elements the node's byte size is unbounded and a node holding a few very large elements reintroduces the multi-megabyte realloc and memmove that quicklist exists to avoid. The negative form caps the node in bytes (-2 = 8 KB by default), which bounds the work per operation regardless of how big individual elements are.
  • Why is a million-element list still risky even with quicklist encoding?
    Quicklist bounds the memory layout and per-node work, not command complexity. LRANGE key 0 -1 is O(n) in elements returned and must serialise the whole reply on the single command thread, stalling every other client; LINSERT, LREM and LPOS are also O(n). Deleting the key with DEL frees all nodes synchronously, so UNLINK is safer. Large lists should be consumed at the ends, trimmed with LTRIM, and never fully materialised.

A quicklist is a train of boxes rather than a chain of loose items or one giant crate: each box holds many items compactly, and you only ever open the boxes at the front and back. list-compress-depth is shrink-wrapping the boxes in the middle, which is free until you need something out of one.

saying these in an interview costs you the question

  • Describing a quicklist as a plain linked list of individual elements
  • Not knowing the sign of list-max-listpack-size changes its meaning from count to bytes
  • Enabling list-compress-depth on a list that is randomly indexed
  • Believing quicklist makes LRANGE over the whole list cheap
  • Assuming changing the settings re-encodes existing lists immediately

context