Redis Internals & Data Structures - Distributed Caching Foundation
LESSON
Redis Internals & Data Structures - Distributed Caching Foundation
By the end of this lesson, you will be able to...
Trace how the shape of a cached value changes memory use and the time a Redis request occupies the shared server path.
Choose a cache key and value boundary that avoids turning one ordinary request into work proportional to a large collection.
Interpret a TTL and a latency spike without assuming that expiry is instantaneous cleanup or that RAM alone guarantees low latency.
Idea in one sentence: A shared Redis cache is fast when its values stay small enough for the required work, because logical data types, physical representation, and one shared request path all set the cost of a cache hit.
Core Insight
The catalog service caches product cards in Redis. Each card has a title, price, stock label, and image URL. On a quiet day, cache hits return in a few milliseconds and the origin database stays calm.
During a bulk catalog import, p99 latency rises. The first explanation is reasonable:
Redis is in RAM, so the network or the import must be the slow part.
But the slow log shows a different clue: one cache request is reading or rewriting a very large aggregate value. While that request runs, smaller requests wait behind it.
The missing model is that a cache hit is not one fixed unit of work. Redis must locate a key, inspect or update its value, and send a reply. The logical type (string, hash, set, or sorted set) is only the API-level name. The amount and shape of data behind that name determine memory overhead, traversal work, conversion work, and reply size.
Redis is therefore a useful foundation for a shared cache not because it makes authority disappear, but because it makes the cost of a copy visible. A source of truth still owns the product record. Redis holds a reusable copy under a freshness policy. The copy helps only while its representation and request path remain bounded.
The Small Situation
Keep one cache entry in view:
catalog service -> Redis -> product card
key: product:42
value: title, price, stock_label, image_url
authority: catalog database
cache rule: a product card may be reused briefly before the service rechecks or refreshes it
There are four moving parts:
- The catalog database is authoritative. It decides which price and stock label are current.
- The catalog service asks Redis for a reusable product-card copy and falls back to the database on a miss.
- Redis keeps the keyspace and value representations in memory, plus metadata such as expiry information.
- The client request path waits for Redis to finish its current command before it receives its reply.
This is deliberately smaller than a full Redis deployment. It does not cover clustering, persistence, replication, or command-by-command operations. Those are separate concerns. Here, the question is narrower: why can two cache values that are both “in memory” impose very different cost on the same shared cache?
The Initial Model: RAM Makes Every Hit Cheap
The initial model works for a small, bounded value. A lookup for one product:42 entry requires a key lookup, a small amount of value work, and a small reply. If that work is short, serving requests sequentially is efficient: there is little lock coordination around the main data path, and each client waits only briefly.
This model breaks when the cache key hides a collection whose size grows with the business. Imagine replacing individual product-card entries with one key:
key: catalog:all
value: every product and every field
The key still looks like one cache item. It is not one bounded operation. A request that scans, returns, or rewrites a large collection must do work proportional to the collection or reply size. Redis documentation describes its request handling as mostly single threaded: while a slow command is served, other clients wait. Its latency tools distinguish common fast commands from potentially slow command execution for exactly this reason.
So the useful correction is not “never use a shared cache” or “never use a collection.” It is:
Keep the cache boundary close to the piece of data the request actually needs, and check the cost of the operation at the collection size you will really have.
The Better Model: A Logical Type Has a Physical Cost
In plain English, a Redis type names the operations you can ask for. It does not promise one fixed memory layout or one fixed cost.
In this scenario, a product card may be stored as one serialized string or as a small hash of fields. A membership rule may use a set. An ordered “top products” view may use a sorted set. Each choice makes some future operation natural and makes another less attractive.
Technical name: this is a representation trade-off. The same logical data can be represented in forms that emphasize compact storage, cheap point lookup, field-level access, membership, or order.
For small aggregate values, Redis can use compact encodings. Its memory-optimization documentation says that small hashes, lists, integer-only sets, and sorted sets can use memory-efficient encodings up to configured size limits; when a value exceeds a configured limit, Redis converts it to a normal encoding. The exact thresholds and encoding details depend on the Redis version and configuration, so they are a measurement target, not an application guarantee.
That gives us two useful, limited claims:
- A small logical aggregate can be denser than a pointer-heavy representation, which matters when there are many small cache entries.
- A compact representation is not a free pass to create huge aggregates. Once an entry grows, conversion, mutation, traversal, and reply work need a workload-specific check.
The choice should follow the request shape. If a page needs one product card, product:42 is a better starting boundary than catalog:all. If every request needs one field, avoid fetching an entire large record merely because it is convenient. If a value must be scanned or returned in pieces, choose an incremental access pattern and confirm its behavior with the command documentation and a representative benchmark.
A Worked Trace: When One Cache Entry Becomes a Queue
The numbers below are illustrative, not production measurements.
At 10:00, the service stores each product card as a small cache entry. A typical product request needs four fields.
| Time | Redis sees | Work on the shared path | What waiting clients see |
|---|---|---|---|
| 10:00:00.000 | product:42 lookup |
Locate one small value and return four fields. | A short cache-hit reply. |
| 10:00:00.002 | product:43 lookup |
Another small bounded request. | It waits only for the first short request. |
| 10:05:00.000 | bulk-import request against catalog:all |
Read, transform, or return a value containing tens of thousands of products. | Small lookups line up behind a long request. |
| 10:05:00.120 | product:42 lookup |
The lookup itself is still cheap. | Its observed latency includes the earlier request's occupancy. |
The important state is not “Redis is slow.” It is:
starting state: small independent values; short requests
pressure: one key now contains a growing collection
intermediate state: a long command occupies the shared request path
result: unrelated small requests accumulate waiting time
This is also why asymptotic complexity is not merely theory. A command with cost tied to the number of elements can be acceptable on ten elements and harmful on tens of thousands, especially when it runs on the same request path as cache hits. Redis explicitly advises checking command complexity and avoiding slow operations against large values when latency matters.
There are several design options, each tied to a constraint:
- Keep product-card cache entries separate when requests read products independently.
- Store a precomputed, bounded view only when the request genuinely needs that view and its invalidation contract is clear.
- Move bulk maintenance out of the latency-sensitive path when the user request does not require its result immediately.
- Measure the distribution of key sizes, command latency, reply sizes, and p99 rather than relying on the average cache-hit time.
None of these changes makes the database less authoritative. They make the cache copy and the work it requires easier to bound.
Check: A team says, “catalog:all is one key, so it is one lookup.” What is missing from that statement?
Think first, then reveal.
Answer: One key lookup is only the start. Redis may also need to process many elements, allocate or convert representation, and serialize a large reply. For a mostly single-threaded request path, the time spent doing that work becomes waiting time for other clients.
TTL Is a Freshness Rule, Not a Stopwatch for Memory Cleanup
Suppose each product:<id> entry has a 60-second TTL. A tempting model is that, at exactly 60 seconds, Redis removes its memory and every expiry-related signal happens at that instant.
That model is too strong. Redis expires keys passively when a client accesses an already expired key and actively through background work that incrementally checks expiring keys. Its documentation therefore does not promise that an expired-key notification occurs exactly when the TTL reaches zero.
For the catalog service, separate two questions:
- Freshness: after the TTL, may the service serve this copy without revalidation or refresh? The cache policy should answer no unless another explicit freshness rule says otherwise.
- Physical cleanup and signals: exactly when does Redis reclaim or report the expired key? That is maintenance behavior, not the source-of-truth contract.
This distinction matters during a bulk import. A rise in expired-key activity can be a useful operational clue, but it does not prove that the cache returned stale data or that the origin is correct. Investigate the request path, the cache freshness rule, and the origin update flow separately.
Cost, Limits, and Signals
This model improves three things: it makes memory density a design concern, it keeps common requests short, and it gives a concrete explanation for why a cache hit can have poor tail latency.
It costs more deliberate key design. Many bounded entries can mean more keys, more invalidation events, and more choices about what to preload or refresh. A compact encoding can save memory but may no longer be the best fit after the value grows. Separating keys also does not solve hot-key traffic, cache stampedes, network delay, or an incorrect freshness contract.
Use the following signals to test the model:
- Key and value-size distribution: Are a few entries much larger than the rest?
- Command complexity and slow-log evidence: Does the request that precedes the p99 spike touch a large collection or produce a large reply?
- Latency monitor and client-side timings: Is the wait inside Redis, on the network, or before the client sends the request?
- Used memory, RSS, and fragmentation evidence: Is the logical dataset growing, or is allocation behavior adding another cost? The next lesson investigates that distinction.
- Expired and evicted-key rates: Is expiry or memory pressure changing the load sent back to the authoritative database?
These are observations, not a diagnosis by themselves. A high RSS value can reflect allocator behavior as well as live cache data. A slow request can be caused by a large value, persistence activity, host scheduling, or network delay. Form a hypothesis from the request shape, then compare the relevant signals before changing the data model.
Common Confusions
Confusion: “Redis is fast because it is in RAM.”
Why it is tempting: Avoiding disk I/O often is a major improvement.
Better model: RAM removes one source of delay. Representation, command work, reply size, host scheduling, and queueing on the shared request path still determine latency.
Confusion: “A Redis data type tells me its production cost.”
Why it is tempting: Type names make a value look like a stable abstraction.
Better model: The type tells you what operations exist. Size, access pattern, configuration, and encoding determine whether an operation remains cheap for this workload.
Confusion: “A TTL proves that memory disappeared at that timestamp.”
Why it is tempting: A time-to-live reads like a deletion deadline.
Better model: TTL controls expiry semantics; physical cleanup and emitted expiry events use passive and incremental active work.
Practice: Redraw the Cache Boundary
An inventory page needs one product card at a time. The team keeps inventory:all as one large hash because a nightly job updates every product together. At noon, a debugging endpoint reads the whole hash; p99 latency for ordinary product pages rises.
Propose the smallest redesign that protects the ordinary request path. State one trade-off and one signal you would use to test the result.
A good answer should mention:
- a per-product or similarly bounded key/value boundary for ordinary reads;
- a separate path or precomputed bounded view for whole-catalog work, if it is still needed;
- the new invalidation or update coordination cost; and
- a before/after comparison of p99 plus the size and latency of the relevant requests.
Connections
The next lesson looks below the logical value: allocators can retain, fragment, and recycle memory in ways that make RSS differ from the live cache dataset. Keep the two explanations separate: first ask what Redis is storing and doing; then ask how the process obtains and returns memory.
The same boundary model also transfers to any shared service with a serialized or saturated work path. A single database query, queue batch, or API response can look like one operation while hiding work proportional to a large collection. The remedy is not blindly splitting everything. It is making the requested unit, cost, and failure boundary explicit.
Resources
- [DOCS] Redis memory optimization — Focus: Verify the size-sensitive encodings and configuration limits for the Redis version you operate.
- [DOCS] Diagnosing latency issues — Focus: Connect large-value commands, the mostly single-threaded request path, and latency evidence.
- [DOCS]
EXPIRE— Focus: Read the passive and active expiration behavior before treating a TTL as an operational cleanup timestamp.
Key Takeaways
- A Redis cache hit has a cost shaped by value size, representation, command work, reply size, and shared-path waiting—not only by whether the value is in RAM.
- Logical Redis types are useful interfaces, but their physical representation and cost change with size, configuration, and access pattern.
- Keep ordinary cache keys close to the bounded unit an ordinary request needs; a giant aggregate can turn one request into a queue for unrelated hits.
- A TTL is a freshness boundary. Passive and incremental active expiration mean cleanup timing and expired-key signals are not exact clocks.
- Use key-size, command, latency, memory, and expiry evidence together before changing a cache design.
← Back to Caching, Workers, and Performance