01 · Encodings & Memory¶
Level 1 kept running into OBJECT ENCODING: int and embstr strings, listpack hashes, intset sets,
skiplist sorted sets. This lesson puts those pieces together. Every Redis type has a compact encoding for small
values and a general one for large values, and the boundary between them is configurable. Knowing where the
boundaries are, and designing around them, is the most effective memory optimization Redis offers. It can cut
memory use several-fold with no change in behaviour.
A survey of encodings¶
This script creates one small and one large value of each type and reports the encoding and MEMORY USAGE:
import redis
r = redis.Redis(decode_responses=True)
r.flushall()
def show(key):
print(f"{key:16} {r.type(key):6} {r.object('encoding', key):11} {r.memory_usage(key):>8} bytes")
r.set("s:int", 123456); r.set("s:short", "hello"); r.set("s:long", "x" * 100)
r.rpush("l:small", *range(10)); r.rpush("l:big", *[f"item{i}" for i in range(10000)])
r.sadd("set:ints", *range(100)); r.sadd("set:small", *[f"m{i}" for i in range(100)])
r.sadd("set:big", *[f"m{i}" for i in range(1000)])
r.hset("h:small", mapping={f"f{i}": i for i in range(100)})
r.hset("h:big", mapping={f"f{i}": i for i in range(1000)})
r.zadd("z:small", {f"m{i}": i for i in range(100)}); r.zadd("z:big", {f"m{i}": i for i in range(1000)})
r.xadd("st", {"a": 1})
for k in ["s:int", "s:short", "s:long", "l:small", "l:big", "set:ints", "set:small", "set:big",
"h:small", "h:big", "z:small", "z:big", "st"]:
show(k)
s:int string int 24 bytes
s:short string embstr 48 bytes
s:long string raw 137 bytes
l:small list listpack 53 bytes
l:big list quicklist 99677 bytes
set:ints set intset 235 bytes
set:small set listpack 525 bytes
set:big set hashtable 30164 bytes
h:small hash listpack 723 bytes
h:big hash hashtable 37052 bytes
z:small zset listpack 723 bytes
z:big zset skiplist 78352 bytes
st stream stream 289 bytes
Compare the 100-member and 1,000-member versions of the same type. Ten times the members cost about 57× the memory for sets (525 → 30,164 bytes), 51× for hashes and 108× for sorted sets. The general encodings aren't just bigger in total. Each element costs far more.
| Type | Compact encoding | General encoding | Switch when (defaults on 8.10) |
|---|---|---|---|
| string | int, embstr (≤44 bytes) |
raw |
non-integer, longer, or modified in place |
| list | listpack |
quicklist (linked listpacks) |
list exceeds one listpack node (list-max-listpack-size, 8 KB) |
| set | intset, listpack |
hashtable |
>512 ints, >128 members, or a member >64 bytes |
| hash | listpack (listpackex with field TTLs) |
hashtable |
>512 fields or a value >64 bytes |
| zset | listpack |
skiplist + hash table |
>128 members or a member >64 bytes |
| stream | radix tree of listpacks | (always) | — |
The cliff at the threshold¶
Here are the costs either side of the default thresholds:
for n in (128, 129):
r.delete("z"); r.zadd("z", {f"m{i}": i for i in range(n)})
print(f"zset with {n} members: {r.object('encoding','z'):9} {r.memory_usage('z')} bytes")
for n in (512, 513):
r.delete("h"); r.hset("h", mapping={f"f{i}": i for i in range(n)})
print(f"hash with {n} fields : {r.object('encoding','h'):9} {r.memory_usage('h')} bytes")
zset with 128 members: listpack 941 bytes
zset with 129 members: skiplist 11692 bytes
hash with 512 fields : listpack 4397 bytes
hash with 513 fields : hashtable 22925 bytes
One extra member made the sorted set 12× bigger. One extra field made the hash 5× bigger. If your data naturally sits near a threshold, say leaderboards of about 150 members or hashes of about 600 fields, moving the threshold or reshaping the data changes your memory bill dramatically.
Hash bucketing: storing a million small values¶
A classic case is a large mapping from IDs to small values, such as photo → owner, user → last-seen timestamp, or SKU → stock. The obvious design is one string key per ID. Here are four layouts for one million pairs:
import redis
r = redis.Redis()
N = 1_000_000
def run(label, write):
r.flushall(); base = r.info("memory")["used_memory"]
p = r.pipeline(transaction=False)
for i in range(N):
write(p, i)
if i % 10000 == 9999: p.execute()
p.execute()
total = r.info("memory")["used_memory"] - base
print(f"{label:44} {total/1e6:7.1f} MB {total/N:6.1f} bytes/item keys={r.dbsize()}")
run("1M string keys photo:<id> -> owner id",
lambda p, i: p.set(f"photo:{i}", i * 7 % 100000))
run("1M string keys long name prefix",
lambda p, i: p.set(f"application:media-service:photo-owner:{i}", i * 7 % 100000))
run("1000 hashes x 1000 fields (listpack limit 512)",
lambda p, i: p.hset(f"photo:{i // 1000}", i % 1000, i * 7 % 100000))
run("2000 hashes x 500 fields (stays listpack)",
lambda p, i: p.hset(f"photo:{i // 500}", i % 500, i * 7 % 100000))
1M string keys photo:<id> -> owner id 46.1 MB 46.1 bytes/item keys=1000000
1M string keys long name prefix 92.5 MB 92.5 bytes/item keys=1000000
1000 hashes x 1000 fields (listpack limit 512) 30.4 MB 30.4 bytes/item keys=1000
2000 hashes x 500 fields (stays listpack) 7.9 MB 7.9 bytes/item keys=2000
- A verbose key prefix doubled memory for the same data, 46 → 92.5 MB.
- Bucketing into hashes of 1,000 fields helped somewhat (30.4 MB), but those hashes had crossed the 512-field
threshold into
hashtable. - Buckets of 500 fields stayed
listpack, and the same million pairs took 7.9 MB, about 6× less than the string-per-key layout.
The access pattern changes only slightly. GET photo:123456 becomes HGET photo:246 456 (bucket = id // 500,
field = id % 500). What you give up: no per-item TTL (unless you use field TTLs, which switch the encoding to
listpackex), and every lookup is a linear scan of up to 500 entries. That's measured in microseconds, but it
isn't free, and raising the bucket size towards thousands makes it worse.
Reading INFO memory¶
On a server holding 2 million 100-byte strings:
used_memory:320135840
used_memory_rss:302366720
used_memory_peak:320136192
used_memory_overhead:82410032
used_memory_dataset:237725808
used_memory_dataset_perc:74.62%
allocator_frag_ratio:1.00
mem_fragmentation_ratio:0.94
mem_allocator:libc
used_memory— bytes Redis's allocator has handed out. This is whatmaxmemoryis compared against.used_memory_overhead— keyspace tables, client buffers, replication backlog and so on.used_memory_datasetis the rest, the data itself.used_memory_rss— what the OS says the process holds in RAM. This is what your container limit or the OOM killer sees.mem_fragmentation_ratio— RSS ÷ used_memory.mem_allocator:libc— this Homebrew macOS build uses the system malloc. Official Linux builds and the official container image use jemalloc, which Redis is tuned for. Some of what follows depends on that.
Then delete every second key (1 million UNLINKs) and look again:
Redis now holds half the data, but the process's RSS didn't shrink. It actually grew, because the pipeline's
client buffers needed memory too. Freed memory sits in the allocator's free lists, in pages that still contain
some live allocations, so it can't go back to the OS. A ratio of 2.12 means half the RAM the process occupies is
holes. MEMORY DOCTOR diagnoses it in plain words (its output, trimmed):
* Peak memory: In the past this instance used more than 150% the memory that is currently using. The allocator
is normally not able to release memory after a peak, so you can expect to see a big fragmentation ratio ...
* High total RSS: This instance has a memory fragmentation and RSS overhead greater than 1.4 ... make sure you
are using the Jemalloc allocator and not the default libc malloc. Note: The currently used allocator is "libc".
What you can do about fragmentation:
- Active defragmentation (
activedefrag yes) moves live allocations to compact pages while the server runs. It needs Redis's bundled jemalloc, and this build refused:
ERR CONFIG SET failed (possibly related to argument 'activedefrag') - Active defragmentation cannot be
enabled: it requires a Redis server compiled with a modified Jemalloc like the one shipped by default with
the Redis source distribution
MEMORY PURGEasks the allocator to return free pages to the OS. On this libc build it returnedOKand RSS fell to 134 MB with a ratio of 0.79. A ratio below 1 on Linux usually means part of Redis has been swapped out, which is a serious latency problem. macOS also compresses idle memory, which can push RSS belowused_memorywithout swapping. Read the ratio with your OS in mind.- Restart a replica after a big deletion and fail over to it. Loading from a snapshot packs memory tightly.
How It Actually Works¶
A listpack is a single allocation: a 6-byte header (total bytes and element count), then entries, then an
end marker. Each entry is an encoding byte or two that describes the type and length (small integers are stored
in as little as one byte), the data, and a back-length so the list can be walked in both directions. There are no
pointers and no per-element allocations, and a few hundred entries fit in a handful of cache lines. Lookup is a
linear scan and insertion is a memmove, which is why the thresholds exist. Beyond a few hundred entries, the
O(N) operations become noticeable, so Redis switches to structures with O(1) or O(log N) access and pays for them
with pointers and headers per element.
The general encodings are where the per-element overhead comes from. A hashtable entry is a bucket pointer,
an entry struct and separately allocated field and value strings. A sorted set at scale has a hash-table entry and
a skip-list node per member, and each skip-list node has a variable number of level pointers plus spans. Each
allocation is rounded up to the allocator's size class (jemalloc uses classes of 8, 16, 32, 48, 64, 80… bytes), so
a 9-byte string costs 16.
Conversions happen inside the command that crosses a threshold. The ZADD that adds member 129 rebuilds the
listpack as a skip list on the spot, which costs O(N) once. They go one way only, because converting back on
every deletion would make workloads oscillate. A key that grew past a threshold and then shrank keeps its large
encoding until it's rewritten (for example, by reloading from an RDB snapshot, which re-encodes compactly).
Fragmentation is an allocator property. Redis frees objects in an unpredictable order, so pages end up partially
used. jemalloc exposes per-allocation hints that Redis's active defrag uses: it re-allocates a value into a better
spot and fixes the one pointer to it, a little at a time within a CPU budget (active-defrag-cycle-min/max). A
generic malloc offers no such hints, which is why the feature needs jemalloc.
Common mistakes¶
- Sizing from one encoding and running in another. Test memory with realistic collection sizes. 128 versus 129 members was 941 versus 11,692 bytes.
- Raising listpack thresholds into the thousands to save memory. Every access becomes a long linear scan, and a listpack bigger than a few KB defeats the cache-locality argument. Benchmark latency as well as memory.
- Verbose key names on hundreds of millions of keys. The name is stored once per key and doubled memory here.
- Watching only
used_memory. The OOM killer looks at RSS. Monitormem_fragmentation_ratiotoo. - Benchmarking memory on a laptop build and assuming production matches. This course's macOS build uses libc
malloc, and Linux production almost always uses jemalloc. Check
mem_allocator. - Bucketing data that needs per-item TTLs without planning for field TTLs (7.4+) or a separate expiry mechanism.
Exercise¶
- Store 1,000,000 user → "last seen" timestamps three ways: string keys, hash buckets of 100, and hash buckets of
1,000 with
hash-max-listpack-entriesraised to 1024. Record memory and the averageHGETlatency for each (time 10,000 random lookups). - Find a sorted set size near the threshold in an application you know (or invent one). Measure
MEMORY USAGEfor 120, 128, 129 and 200 members, then setzset-max-listpack-entries 256and repeat. How doesZRANKlatency change? - Grow a hash past 512 fields, delete back down to 10, and check its encoding. Then
DEBUG RELOAD(lab only: saves and reloads the RDB) and check again. - On a Linux machine or container with an official Redis build, repeat the "delete every second key" experiment
with
activedefrag yesand watchmem_fragmentation_ratioover a minute.