0%
Redis Deep Dive: Encodings, Memory, and Failover
The Server Model
Memory Management
Persistence
Replication and Cluster
Correct Use
Hashes, Lists, and the Listpack Underneath Both
A hash is a map from field names to string values stored under one key, and it is the workhorse of large Redis datasets. The reason is the arithmetic from the previous lesson: Redis charges roughly a hundred bytes per key and close to nothing per field inside a small hash, so collapsing many keys into fewer hashes is the single highest-value memory change available in most keyspaces.
The two representations
A hash is stored either as a listpack, a single flat allocation holding field and value pairs one after another, or as a hash table, a dictionary of the same kind the keyspace itself uses. The choice is made by Redis from two configuration thresholds, and OBJECT ENCODING reports which one is in use.
hash-max-listpack-entries, defaulting to 128, caps the number of fields. hash-max-listpack-value, defaulting to 64, caps the length in bytes of any single field name or value. Exceeding either promotes the hash to a hash table, permanently, for that key. The second threshold is the one people trip over, because a single long value, a URL or a description, converts a hash that is otherwise well inside the entry limit.
What each one costs
In the listpack, a field and value pair costs the bytes of the field name, the bytes of the value, and a few bytes of length framing for each. There are no pointers, no per-entry object headers, no hash buckets and no allocation per entry: the whole hash is one allocation. A hash of twenty short fields can cost a couple of hundred bytes in total.
In the hash table, each field is a dictionary entry of 24 bytes, a separately allocated field name string, a separately allocated value object with its 16 byte header, and an amortised share of the bucket array. That is upwards of 80 bytes per field before the data, which is roughly the per-key cost from the previous lesson, because it is the same machinery. The difference between the two encodings for a twenty field hash is therefore around a factor of five to eight, which is why the thresholds matter.
The trade is scanning against lookup
The cost of the listpack is that field lookup is a linear scan: to find a field, Redis walks the entries from the start comparing names. HGET on a listpack hash is therefore proportional to the number of fields rather than constant, which contradicts the documented complexity, and the documentation is right because the thresholds keep the scan short enough not to matter.
That is the whole justification for the default of 128. A linear scan over 128 short entries in one contiguous allocation is faster in practice than a hash computation plus a pointer chase into a bucket, because it is cache-friendly and branch-predictable, where the hash table version touches several cache lines. The crossover is not at a small number, which is why the threshold is as high as it is, and raising it to 256 or 512 for a workload of memory-bound small hashes is a legitimate and common tuning decision. Raising it to 10,000 is not: the scan becomes the dominant cost and every write rewrites a large allocation.
Why writes to a listpack are more expensive than they look
One consequence of the flat layout deserves emphasis. Changing a field's value to one of a different length means the bytes after it have to move, so the write is a memory move proportional to the hash's size, and growing the hash means reallocating the whole thing. A field write to a listpack is therefore linear in the hash's total bytes, not constant.
For a hash of a few hundred bytes that is irrelevant, a single short memmove. For a listpack of 50 kilobytes, reached by raising the thresholds, every field write copies 50 kilobytes, and a workload writing fields frequently will be slower than the hash table version while using less memory. The thresholds therefore encode a read-heavy assumption, and a write-heavy hash with many fields is the case where promoting to a hash table deliberately, by raising nothing and letting it convert, is the right outcome.
The commands, and the ones to avoid
HSET and HGET are the per-field operations, HMGET reads several fields in one round trip, HDEL removes fields, HEXISTS tests one, and HINCRBY and HINCRBYFLOAT are atomic increments on a field, which makes a hash a map of counters. HLEN is the field count, maintained rather than computed.
The ones to use carefully are the whole-hash commands. HGETALL returns every field and value, HKEYS and HVALS return one side each, and all three are proportional to the hash's size and return a reply proportional to it as well. On a hash that was supposed to have twenty fields and has twenty thousand, HGETALL is both a long blocking operation and a large output buffer allocation. HSCAN is the incremental form, with the caveat from the keyspace lesson that it ignores the cursor entirely on a listpack-encoded hash, so the incremental behaviour you tested against a small hash is not what you get against a large one. HRANDFIELD returns random fields with an optional count and is the sampling tool.
HGETALL is the single most common cause of a surprise latency spike in a Redis workload, because it is written when the hash is small and stays in the code when the hash is not. If a hash can grow without a bound you enforce, the call that reads it whole is a time bomb. Use HMGET with the fields you actually need, which is almost always a short list, and reserve HGETALL for hashes with a fixed, small schema.
Field expiry, and what it changed
Until Redis 7.4 a TTL could only apply to a whole key, which was the one structural reason to prefer a key per field over a hash: individual expiry. Redis 7.4 added HEXPIRE, HPEXPIRE, HEXPIREAT, HPEXPIREAT, HTTL, HPERSIST and their relatives, which set and inspect expiry per field.
This removes the last good argument for key-per-field and is worth knowing about when evaluating an older design. Two caveats apply. A hash with field expiries carries extra per-field state, so the memory saving is smaller than for a plain hash, and a listpack-encoded hash with TTLs uses a distinct encoding to hold them. And the feature is recent enough that client library support and operational familiarity vary, so a design depending on it should confirm the deployed version rather than assuming it.
Reading and writing several fields in one command
Both HSET and HMGET take many fields in one call, and the difference that makes is a network difference rather than a server one. Writing ten fields as ten HSET calls costs ten round trips, which at a millisecond each is ten milliseconds of wall clock for perhaps fifty microseconds of server work; writing them as one HSET with ten pairs costs one round trip.
This matters more than it sounds because the round trip is usually the dominant term in the latency a user experiences, and it is the term that is invisible when testing against a local Redis. The same argument applies to HMGET against a sequence of HGET calls, and to pipelining generally when the fields are spread across keys.
There is a second effect on the server side for a listpack-encoded hash. A multi-field HSET performs one reallocation and one memory move for the whole batch rather than one per field, so writing ten fields at once to a listpack is genuinely cheaper on the server as well, not merely fewer round trips. For the hash table encoding the saving is only the round trips, since each field is an independent dictionary insert either way.
Sizing a hash deliberately
The practical conclusion is that hash size is a design parameter rather than an accident. For a dataset sharded into hashes, as in the counter and profile examples from the previous lesson, the field count per hash should be chosen to sit comfortably under the configured entry threshold, with headroom, because crossing it is permanent and loses most of the saving.
Checking is cheap and worth building into a load test: OBJECT ENCODING on a sample of keys after loading representative data answers in one command whether the design you intended is the design you got. The failure to run that check is how a memory estimate ends up wrong by a factor of six, and the usual cause is not the field count but a single oversized value quietly exceeding hash-max-listpack-value on every key.