jonnify·redis /redis-patterns/data-modeling/memory-optimization

R7.02 · Data Modeling & Memory — module hub

Memory optimization.

Reduce Redis memory consumption by leveraging compact encodings (listpack, intset), using Hashes for small object storage, and choosing memory-efficient data structures.

Redis holds everything in memory, so efficient usage is critical — understanding the engine's internal encodings often saves 50–80% for small objects. The grounding is EchoMQ's memory discipline (echo/apps/echo_mq): the 3-field job HASH stays a listpack, the per-group wake list is LTRIM-capped at 64, the streams are XTRIM…MAXLEN ~-capped. The structures are bounded by design, not tuned after the fact. The three dives take the encodings, the field names, and the caps in turn.

The same data, three layouts. Select an encoding to read what it stores and what it costs; a small structure stays in the compact one until it crosses a threshold.
listpack one byte array no pointers intset sorted ints no hashing hashtable buckets + pointers fast at any size compact below the threshold · converts to hashtable above it (one-way)
readout · Select an encoding. A small Hash is a listpack; a small all-integer Set is an intset; both convert to a hashtable once they cross a threshold.
§1

Small object encoding

memory-optimization.md.txt · the compact layouts

Redis automatically uses compact encodings for small collections. A Hash with few fields is stored as a listpack — a single sequential byte array — rather than a hash table. The listpack eliminates the per-entry pointer and bucket overhead, which can consume up to 80% of memory for small objects. An all-integer Set under its threshold uses the even tighter intset — a sorted array of fixed-width integers, no hashing at all.

The engine switches from the compact encoding to the standard one when a structure crosses a configured threshold. The defaults are Valkey's, not EchoMQ's:

hash-max-listpack-entries 512
hash-max-listpack-value 64
list-max-listpack-size -2
zset-max-listpack-entries 128
zset-max-listpack-value 64
set-max-intset-entries 512

A Hash with fewer than 512 entries, where each value is under 64 bytes, uses the listpack encoding. EchoMQ's job HASH carries exactly three fields — state, attempts, payload (echo/apps/echo_mq/lib/echo_mq/jobs.ex) — far under 512, with short values, so Valkey stores the row as a listpack. Not because EchoMQ tuned the thresholds (the committed infra/valkey/conf/valkey.conf sets none), but because the row is deliberately minimal.

Notes on ValkeyA Hash is stored as a listpack until it exceeds hash-max-listpack-entries (512) or any value exceeds hash-max-listpack-value (64 bytes), when it converts to a hashtable; an all-integer Set is an intset until set-max-intset-entries (512). The conversion is one-way — valkey.io/topics/memory-optimization.

The cheapest memory win is to keep a structure small enough to stay in its compact encoding. The listpack-and-intset dive takes the layouts and the threshold flip apart.

§2

The bucketing pattern

memory-optimization.md.txt · per-key overhead

Each individual key has overhead — memory for the key name, the expiration metadata, and internal bookkeeping. For millions of small values, that per-key overhead dominates the values themselves. Instead of a String key per item, group items into Hash buckets:

# a String key per item — 1,000,000 keys of overhead
SET user:1:name "John"
SET user:2:name "Jane"

# bucketed into Hashes — 1,000 keys, each a compact listpack
HSET users:0 "1:name" "John" "2:name" "Jane"
HSET users:1 "1001:name" "Alice"

With 1000 items per bucket you have 1000 Hash keys instead of 1,000,000 String keys, and each Hash stays small enough for the compact encoding. For item ID 12345 with bucket size 1000: bucket = 12345 / 1000 = 12, key = "users:12", field = "12345:name". The per-key overhead is paid 1000 times, not a million.

Bucketing trades a million keys of overhead for a thousand compact Hashes — the largest gain in this chapter for datasets of many small items.

§3

Key name length & integer encoding

memory-optimization.md.txt · names, numbers, TTLs

Every byte in a key name consumes memory, and the name is stored on every key. At scale, short keys matter: user:profile:12345:display_name becomes u:12345:dn, or — better — a Hash so the prefix is stored once rather than per field (HGET u:12345 dn). EchoMQ names its job-HASH fields state/attempts/payload: short, but not abbreviated to noise — small enough to keep the value under hash-max-listpack-value while staying a word a reader recognizes.

Redis also uses an integer encoding for numeric strings, which share a pool of pre-allocated small integers: SET counter "42" is efficient, SET counter "42 items" is not. When a value can be a pure number, store it as one — EchoMQ's attempts holds the bare integer fence advanced by HINCRBY, never "3 tries". And every key with a TTL costs extra memory for the expiration; grouping keys that expire together under one Hash TTL avoids paying it per entry. EchoMQ's job rows carry no TTL — a job lives until completed or reaped.

Names and numbers are paid per row: keep field names short and whole, store numbers as numbers, and avoid per-key TTLs where one shared TTL will do. The short-field-names dive carves this slice.

§4

Measuring, eviction, and compression

memory-optimization.md.txt · measure · evict · compress

Measure before optimizing: INFO memory for the overall picture, MEMORY USAGE <key> for one key, MEMORY STATS for samples, MEMORY DOCTOR for a report. Then change the lowest-effort technique and measure again.

When memory is exhausted, the maxmemory-policy setting determines what happens. The cache policies (allkeys-lru, allkeys-lfu, volatile-ttl, …) delete keys to keep serving; noeviction returns errors on writes instead. For a cache, LRU or LFU makes sense; for data that must not be lost, noeviction is the only safe posture — which is exactly why EchoMQ runs it. That posture is taught in depth in R7.1 and is not re-taught here:

R7.01.2 · in this course

noeviction — refuse the write, do not drop the record

The eviction-policy menu and why a record of truth runs noeviction — memory pressure as a loud error, not a vanished job — is the subject of the noeviction dive of R7.1. This module lists it as one memory technique and doors there for the depth.

memory-optimization.md.txt

Compression — CPU for memory

For large string values, compress in the application (gzip, lz4, snappy) before storing and decompress on read — effective above a few hundred bytes where the ratio is meaningful. EchoMQ does not compress its small job payload; the win is not there at three short fields, but a large opaque body is a candidate.

Memory work is a loop: measure, apply the lowest-effort technique, measure again. Eviction is a cache posture; a record of truth refuses the write instead, and large values are a compression candidate.

§5

EchoMQ's memory discipline

jobs.ex · lanes.ex · stream.ex

EchoMQ applies three of these techniques by construction. Its job HASH stays a listpack because the row is three short fields. Its growing structures — the per-group wake list and the streams — are capped where they are written, so they cannot leak memory no matter the traffic. Select a structure to read how it is bounded:

Three structures, bounded by design. Select one to read its memory ceiling; the row is a listpack, the wake list and the streams are capped at the write.
job HASH 3 fields → listpack wake list LTRIM 0 63 capped at 64 stream XTRIM MAXLEN ~ window-bounded the memory ceiling is a property of the code, not a hope about volume
readout · Select a structure. EchoMQ keeps the job HASH a listpack, the wake list LTRIM-capped at 64, and the streams XTRIM…MAXLEN ~-capped — each bounded where it is written.
the pattern

Compact encodings and small objects save 50–80% of memory; a structure that is bounded cannot leak it.

its EchoMQ application

The 3-field job HASH stays a listpack (far under the 512 / 64 thresholds); the per-group wake list is LTRIM-capped at 64 (lanes.ex); the streams are XTRIM…MAXLEN ~-capped (stream.ex). The ceiling is in the design, not a knob turned later.

Memory optimization in Redis is mostly choosing a structure small enough to stay compact and bounding the ones that grow. EchoMQ does both: a deliberately minimal row, and lists and streams capped where they are written.

/bcs/store

EchoStore — the compact near-cache

A near-cache leans on the same compact encodings to keep its hot copies small. EchoStore — L1 ETS over L2 Valkey, coherence by mint time — is the Branded Component System's account of the tier that holds copies, where the memory budget is the live working set.

/bcs/fly

The maxmemory 512mb guardrail

The committed valkey.conf sets maxmemory 512mb as a loud guardrail far above the working set, not a working ceiling. The production posture of that config — the release image, Valkey on a Fly machine — is the Branded Component System production chapter.

§6

The three dives

listpack-and-intset → short-field-names → capped-structures

Each dive takes one slice of the memory discipline, in order: the compact encodings and the threshold flip, the field names that keep the row in them, and the caps that bound the structures that grow. Read them in order — the encoding first, the names second, the caps third.

The three dives ground in the same real structures: the listpack row, the short fields, and the caps. Begin with listpack and intset. Return to the Data Modeling & Memory chapter to continue.

§7

References

sources · related

Sources

  • Valkey — Memory optimization — listpack and intset encodings, the configuration thresholds, and how small collections are stored compactly.
  • Valkey — OBJECT ENCODING — read the internal encoding of a key (listpack, intset, hashtable).
  • Valkey — LTRIM — trim a List to a range so it becomes a bounded ring; the wake-list cap.
  • Valkey — XADD — append to a stream with MAXLEN ~, the approximate cap that trims in whole macro-nodes.

Related in this course