Memory Layout, and the Gap Between Two Numbers

Topics Covered

Where the Memory Actually Goes

The two numbers in INFO memory

What a key costs before its value

The expires dictionary

Strings, and the two encodings of a short one

Shared integers, and the free ones

The parts that are not data at all

The object header, and what it carries

Nested structures, and where the overhead multiplies

The commands that report it

Auditing a keyspace without walking it

Every Redis capacity question starts from a surprise: the data is smaller than the memory. Understanding the difference is not an optimisation exercise, it is the difference between a capacity plan that holds and one that produces an eviction storm at three in the morning.

The two numbers in INFO memory

used_memory is what the allocator has handed to Redis and Redis believes it is using. used_memory_rss is what the operating system says the process occupies in physical memory. They are different numbers measured by different parties and neither is wrong.

Everything in this lesson lives in the gap between them, and the first discipline is to stop saying memory and start saying which of the two. A capacity plan against used_memory under-provisions. An eviction threshold against used_memory_rss evicts too early. maxmemory is compared against used_memory, which is the single most consequential fact in this section.

What a key costs before its value

A key is a string, stored as a simple dynamic string with a header, plus an entry in the keyspace dictionary. The dictionary entry holds a pointer to the key, a pointer to the value and a pointer to the next entry in its bucket chain, which is 24 bytes on a 64-bit build, plus its share of the bucket array, plus the allocator's rounding on each allocation.

The value is a robj, a 16-byte header carrying the type, the encoding, an LRU or LFU field and a reference count, pointing at the actual representation. So a key holding a short string costs somewhere between 70 and 100 bytes before the string itself, and a million such keys cost 70 to 100 megabytes of structure. That figure is the one to carry into any design that proposes many small keys.

The dataset is one slice of used_memory, and used_memory itself is one of two numbers that both get called memory. Every capacity surprise lives in the parts of this breakdown that a naive estimate leaves out.

The expires dictionary

A key with a TTL has a second dictionary entry, in the expires dictionary, keyed by a pointer to the same key string. That is roughly another 30 to 50 bytes amortised per key with a TTL, and it is invisible in any estimate built from the value sizes.

The practical consequence appears when somebody adds a TTL to every key in a large keyspace as a hygiene measure and memory rises by several percent with no data added. That is correct behaviour and it should be budgeted for, because the alternative, a keyspace with no TTLs at all, has its own and larger problems.

Strings, and the two encodings of a short one

A string value of 44 bytes or fewer is stored as an embstr: the robj header and the string data in one allocation, which saves a pointer dereference and an allocation. Above 44 bytes it becomes raw, with the header and an separately allocated simple dynamic string.

The 44-byte threshold is not arbitrary: it is what fits alongside the 16-byte header and the string header inside a 64-byte allocation, which is a jemalloc size class. So the threshold exists because of the allocator, which is the first hint that the allocator shapes more of this than it appears to.

Shared integers, and the free ones

Integer values from 0 to 9,999 are shared: Redis keeps one robj per value and increments a reference count, so a million keys all holding the value 1 cost one object between them. That range is set at compile time by OBJ_SHARED_INTEGERS and it is the reason a counter keyspace is cheaper than it looks.

The sharing is disabled when maxmemory is set with an LRU or LFU eviction policy, because a shared object cannot carry a per-key access time. That is an unpleasant interaction worth knowing: enabling eviction can increase memory on an integer-heavy keyspace, which is the opposite of what anybody expects.

The parts that are not data at all

Client output buffers hold replies not yet written to the socket, and a client issuing a large SMEMBERS or a slow consumer of a pipeline can hold tens of megabytes. The replication backlog is a fixed ring buffer, repl-backlog-size, default one megabyte and usually raised. Replica output buffers hold what each replica has not yet acknowledged.

The AOF buffer holds writes between fsyncs, the Lua script cache holds every script ever loaded until SCRIPT FLUSH, and the query buffer per client holds incoming commands. None of these is the dataset and all of them count towards used_memory, and in an incident the first question is which of them grew.

The object header, and what it carries

Every value is preceded by a robj: four bits of type, four of encoding, a 24-bit field holding either an LRU clock reading or an LFU counter and decay time, a 32-bit reference count, and a pointer to the representation. That is 16 bytes, and it is paid once per value regardless of how small the value is.

The LRU field is the reason shared objects and eviction policies interact: the field belongs to the object, not to the key, so an object shared between keys cannot hold a meaningful access time for either of them. The reference count is what makes sharing possible at all, and it is also what makes a shared object impossible to track.

Sixteen bytes of object header plus 24 bytes of dictionary entry are paid once per key whatever the value is. The LRU field belongs to the object rather than the key, which is why sharing and eviction cannot coexist.

Nested structures, and where the overhead multiplies

A hash field is not a key, which is the whole point of the packing argument, but it is not free either. In the listpack form a field costs its bytes plus a length prefix, a handful of bytes in total. In the hash table form it costs a dictionary entry and two simple dynamic strings, which is 50 bytes or more per field.

So the step between the two representations is not a gradual increase, it is a cliff of several times the memory, crossed when either the entry count or any value length exceeds its threshold. A hash of 130 fields against a default threshold of 128 pays several times what a hash of 128 fields pays, which is an unpleasant thing to discover in production and a trivially cheap thing to check in advance.

The commands that report it

INFO memory gives the totals and the headline ratios. MEMORY STATS breaks it down properly: dataset.bytes, overhead.total, the per-database keyspace and expires overhead, the client buffer totals, the replication backlog and the allocator figures. It is the command to run before forming a theory and it is underused.

MEMORY USAGE key reports what one key costs, including its structure, with a SAMPLES option for collections so that a huge hash is estimated rather than walked. MEMORY DOCTOR offers a short diagnosis in prose, which is occasionally useful and never a substitute for reading the stats.

Auditing a keyspace without walking it

The honest way to find where memory went is to sample: SCAN with a modest COUNT, MEMORY USAGE each key returned, group by a prefix extracted from the key name, and sum. A few tens of thousands of samples is enough to rank prefixes by total cost, and it runs against production without noticeable impact.

That ranking is almost always surprising, and the surprise is usually a prefix nobody remembered, holding data whose producer was decommissioned, with no TTL. Running it quarterly is a twenty-minute job that has paid for itself every time I have seen it done, and redis-cli --bigkeys and --memkeys do a coarser version of the same thing with a single flag.

Key Insight

A key costs 70 to 100 bytes before its value: the key string with its header, a 24-byte dictionary entry plus a share of the bucket array, a 16-byte object header, and the allocator's rounding on each of those. Another 30 to 50 if it has a TTL. Any design that proposes to store a million small items as a million keys is proposing a hundred megabytes of structure, which is the argument for packing them into hashes instead.