In short

Redis is best understood not as "a cache" but as an in-memory database whose product is its data structures. Underneath, every key lives in one giant hash table (dict); the value attached to each key is one of nine typed structures — STRING (bytes, integers, floats — caching, counters, tokens), LIST (doubly linked / quicklist — queues, activity feeds), HASH (small hash table per key — session objects, user profiles), SET (hash table of unique members — tags, deduplication, set algebra), SORTED SET / zset (hash table + skiplist — leaderboards, time-ordered events, priority queues), BITMAP (bit-addressable string — presence flags, feature flags, daily-active-user tracking), HYPERLOGLOG (probabilistic counter — count of unique items in 12 KB regardless of cardinality), STREAM (append-only log with consumer groups — mini Kafka), and GEO (sorted set of geohashes — radius and nearest-neighbour queries). Two architectural choices shape everything: data lives in RAM, so reads cost a hash-table lookup plus a structure operation — typically 50–500 microseconds end-to-end including the network — and the server is single-threaded, so every operation is atomic without locks. Atomicity is the reason INCR, LPUSH, ZADD, and SETNX work as race-free building blocks for distributed systems. Server-side computation is the second multiplier: ZRANGE leaderboard 0 9 REV WITHSCORES returns the top 10 of a million-entry leaderboard in one round trip; doing the equivalent in Postgres means scanning, sorting, and returning rows. Most production Redis is not caching at all: it is a Twitter feed (LIST), a Discord rate limiter (INCR + EXPIRE), a Strava leaderboard (SORTED SET), a Stack Overflow tag set (SET), a Zomato delivery-radius lookup (GEO). Real systems in the family: Redis (canonical, BSD/SSPL since 2024), Valkey (Linux Foundation fork after the license change), KeyDB (Snap fork that adds multi-threaded I/O), DragonflyDB (Rust/C++ rewrite that scales vertically with cores), and Memcached (the simpler ancestor that does only string get/set with LRU). This chapter opens Build 22: chapter 170 covers persistence (RDB snapshots + AOF append-only file), 171 covers replication and Redis Cluster, 172 covers eviction policies (allkeys-lru, volatile-ttl, allkeys-lfu), and 173 covers the cache patterns themselves (cache-aside, write-through, write-behind, refresh-ahead).

Most engineers meet Redis the same way: a senior shows them a slow Postgres query, says "stick a Redis cache in front of it", and they wire up cache.get(key) or compute_and_set(key, value, ttl=300). Six months later, that same engineer is using Redis for the user's shopping cart, the leaderboard on the homepage, the rate limiter on the login endpoint, and the pub/sub bus that fans out notifications. None of that is caching. All of it is the same Redis instance, running the same single-threaded event loop, treating each new use case as a different choice of data structure layered on the same key-value substrate. The product is not the cache. The product is the data structures.

This chapter opens Build 22 — caching and in-memory data stores — and the right way to start is by killing the framing that calls Redis "just a cache". Once you see the nine data types as first-class primitives, with their own atomic operations and their own asymptotic guarantees, the rest of the build (persistence, replication, eviction, cache patterns) becomes a series of refinements on a system you already understand.

The thesis: data structures are the product

A typical key-value store — Memcached, DynamoDB, an unordered_map<string, string> — gives you two operations: get(key) returns bytes, set(key, bytes) stores them. The application is responsible for serialising rich data (JSON, protobuf) into bytes on the way in and parsing it back on the way out. If you want to "increment the visit counter", you do current = parse(get('counter')); set('counter', serialise(current + 1)) — three round trips and a race condition (two clients reading the same value, both incrementing, one update lost).

Redis breaks that pattern. The value attached to each key is not a blob; it is a typed structure, and the operations on that structure live inside the server. Incrementing the visit counter is one command — INCR counter — that goes over the wire as 12 bytes, returns the new value as 4 bytes, takes about 50 microseconds end-to-end, and is atomic because the Redis server is single-threaded and processes commands one at a time. Why single-threaded is a feature, not a bug: a multithreaded server has to lock every shared structure or use lock-free algorithms; both add overhead and bugs. Redis runs the entire command-handling loop on one core and uses the saved cycles (no locks, no thread switches, perfect cache locality) to handle 100 K+ ops/second per instance. The cost is that one slow command (a KEYS * on a million-key keyspace) blocks every other client — which is why "do not call O(N) commands on big keyspaces" is the first rule of Redis operations.

Pushing structure into the server unlocks server-side computation. When you want the top 10 of a leaderboard with a million users, you do not pull a million rows over the network and sort them in Python; you call ZRANGE leaderboard 0 9 REV WITHSCORES and Redis walks the head of the skiplist, returns 10 (member, score) pairs, and you are done in one round trip. The work happened in Redis. The same pattern applies to set intersection (SINTERSTORE), sorted-set range scans, geographic radius queries (GEOSEARCH), and stream consumer groups. The data structure is the API.

The data type zoo: nine primitives, every use case

Every Redis key has a type. The type determines which commands are legal on that key (LPUSH works on lists, errors on strings) and what asymptotic complexity each command has. Here is the zoo, with the use cases that show up in production over and over.

The Redis data type zoo: nine first-class types with canonical use casesA grid showing nine Redis data types, each as a card with the type name, a one-line description of what it stores, the canonical operations, and a one-line real-world use case. STRING stores bytes integers or floats with SET GET INCR for caching counters and tokens. LIST stores an ordered sequence with LPUSH RPOP LRANGE for queues and recent activity feeds. HASH stores a small map of field-value pairs with HSET HGET HGETALL for session objects and user profiles. SET stores unique members with SADD SISMEMBER SINTER for tags and deduplication. SORTED SET stores members with float scores in skiplist order with ZADD ZRANGE ZRANGEBYSCORE for leaderboards and priority queues. BITMAP stores bit-addressable strings with SETBIT GETBIT BITCOUNT for presence flags and daily active users. HYPERLOGLOG stores a probabilistic cardinality estimator with PFADD PFCOUNT for unique counts in 12 KB. STREAM stores an append-only log with XADD XREAD XREADGROUP for messaging and a mini Kafka. GEO stores latitude longitude pairs with GEOADD GEOSEARCH for radius queries and nearest neighbours.The Redis data type zoonine first-class types — pick the right primitive, the use case writes itselfSTRINGbytes / int / float — up to 512 MBSET, GET, INCR, INCRBY, APPEND, SETEXuse cases- HTML / JSON cache (SETEX 300)- visit counter (INCR counter)- session tokens, JWT blocklistLISTordered sequence (quicklist)LPUSH, RPUSH, LPOP, RPOP, LRANGE, BRPOPuse cases- task queue (LPUSH/BRPOP)- recent activity feed (LPUSH + LTRIM)- circular buffer of last N eventsHASHfield → value map per keyHSET, HGET, HGETALL, HINCRBY, HDELuse cases- session object {user_id, csrf, expires}- user profile {name, email, plan}- shopping cart {sku → qty}SETunique members (hash table)SADD, SREM, SISMEMBER, SINTER, SUNIONuse cases- tags on a post- "users who liked X" — set algebra- followers / followeesSORTED SET (zset)member → float score, sortedZADD, ZRANGE, ZRANGEBYSCORE, ZRANKuse cases- leaderboard (score = points)- delayed jobs (score = run_at)- top-N anything by any metricBITMAPbit-addressable string (1 bit / pos)SETBIT, GETBIT, BITCOUNT, BITOPuse cases- "did user N log in today?" (1 bit each)- daily active users (BITCOUNT)- feature flags per userHYPERLOGLOGprobabilistic cardinality (12 KB fixed)PFADD, PFCOUNT, PFMERGEuse cases- count unique visitors / day- unique search terms / hour~0.81% standard errorSTREAMappend-only log + consumer groupsXADD, XREAD, XREADGROUP, XACKuse cases- mini Kafka for messaging- audit log with replay- event sourcing on small scaleGEOlat/lon (geohash in a sorted set)GEOADD, GEOSEARCH, GEODISTuse cases- "delivery riders within 2 km"- nearest petrol pump / ATM- store-locator radius searchPick the type, the use case writes itself. Almost every "real" use of Redis maps to one of these nine cards.
Nine cards, nine production patterns. STRING covers caching and counters because it has `INCR`. LIST covers queues and feeds because it is a deque with cheap end inserts. HASH covers session objects because it lets you update one field without rewriting the whole blob. SORTED SET covers everything ranked — leaderboards, time-ordered jobs, priority queues — because the underlying skiplist makes range queries logarithmic. BITMAP and HYPERLOGLOG cover the "count unique things on huge populations" problem with O(1) memory. STREAM is a Kafka-shaped log on a budget. GEO is geographic radius queries on a sorted set of 52-bit geohashes. Once you can match the use case to the card, the code writes itself.

The cards above hide one detail that matters in production: most types have multiple internal encodings that Redis swaps automatically based on size. A SORTED SET with fewer than 128 members uses a listpack (a flat compact array — cache-friendly, good for small N); above that, it switches to a skiplist + dict combo (logarithmic for everything, more memory per entry). A HASH with fewer than 128 small fields uses a listpack too; above that, it becomes a real hash table. A SET of small integers uses an intset (sorted array, binary search). Why this matters: a HASH with 50 short fields takes maybe 200 bytes total because of listpack packing; the same data in 50 separate STRING keys takes 50 × ~80 bytes of overhead = 4 KB before counting any payload. When you store millions of small objects, picking HASH over many STRINGs cuts memory by an order of magnitude. This is why the canonical "shopping cart per user" pattern is one HASH per user with sku → qty, not one STRING per (user, sku) pair.

The architecture: one giant hash table, values are typed structures

Zoom out. A running Redis server is, fundamentally, a single C process running an event loop on top of an epoll (Linux) or kqueue (BSD) reactor. The state of the database is a redisDb struct, which contains — and this is the load-bearing sentence — a single hash table called dict mapping every key in the database to a robj (Redis object). The robj carries a type tag (string / list / hash / set / zset / stream) and a pointer to the actual data structure for that type.

Redis architecture: one in-memory hash table whose values are typed data structuresAn architecture diagram of a Redis server. At the top, a single-threaded event loop accepts client connections via epoll or kqueue and dispatches commands one at a time. Below it sits the redisDb struct containing one large hash table called dict. Each entry in the dict maps a string key to a redisObject pointer with a type tag. The diagram shows five example keys: user colon 42 colon name pointing to a STRING value Riya, queue colon tasks pointing to a LIST of two strings, cart colon 42 pointing to a HASH with sku to quantity fields, leaderboard pointing to a SORTED SET implemented as a skiplist plus a dict, and tags colon post 99 pointing to a SET. All of this lives entirely in RAM. To the right, a latency budget shows that one command costs roughly fifty microseconds for a hash lookup plus the structure operation plus network return. At the bottom, a note states that single-threaded means atomic and lock-free.Redis architecture: one hash table, typed values, all in RAMsingle-threaded event loop (epoll / kqueue)accepts client conns - parses RESP - dispatches command - repliesone command at a time = no locks, no thread switches, atomic by constructionlatency budget per op- network RTT: 100-300 us- hash lookup + op: 1-50 ustotal: sub-millisecondredisDb.dict — the master hash table (one per database)key (string) -> redisObject* {type tag, encoding, pointer to data}resizes incrementally when load factor exceeds 1.0; everything in RAMkey: user:42:nametype: STRING"Riya Mehta"embstr (small str)SET / GET / APPENDINCR (if integer)cache, counters,tokensqueue:taskstype: LIST"send_otp:42""resize_img:88"quicklist oflistpacksLPUSH / BRPOPqueues, feedscart:42type: HASHSKU101 -> 2SKU207 -> 1SKU555 -> 4listpack -> htHSET / HINCRBYsessions, cartsleaderboardtype: ZSETriya -> 1240rahul -> 1180asha -> 990skiplist + dictZADD / ZRANGEleaderboardstags:post:99type: SET"redis""caching""databases"listpack -> htSADD / SINTERtags, dedupall of this lives in RAM - no disk on the read path - sub-millisecond by construction
One event loop at the top, one master hash table in the middle, typed values at the bottom. The `dict` resolves a key to an object pointer in O(1) amortised; the object then routes to its type-specific data structure (listpack, quicklist, hash table, skiplist, intset). Because the loop is single-threaded, every command runs to completion atomically — no other client can observe a partial state — and because everything lives in RAM, the floor for any operation is set by the network round trip (100–300 μs on the same VPC) plus a handful of microseconds for the in-memory work.

Two consequences of this architecture deserve their own line. First, atomicity is free: any single Redis command, no matter how complex (ZADD, LPUSH, SINTERSTORE, XADD), is observed by every client either as fully done or not done at all, because no other command can interleave with it. This is why INCR is the textbook way to build a distributed counter and why SETNX key value (set if not exists) is the textbook distributed lock primitive — both are race-free for free. Second, slow commands are everyone's problem: a KEYS * against a million-key keyspace, or a SMEMBERS of a million-member set, or a Lua script that runs for 200 ms, blocks every other client for that duration. The whole operational discipline of running Redis is "do not call O(N) commands on big N", and the antidote is to use SCAN-family cursors instead.

Common patterns: five lines of Redis, five production systems

Once you know the data type zoo and the architecture, almost every Redis use case collapses to one of five canonical patterns. Each pattern is a few lines of code in any language. Each pattern, deployed at scale, runs a billion-dollar feature.

Five canonical Redis patterns and the type each one needsA grid of five cards showing the canonical Redis patterns. Card one is cache pattern using STRING with SETEX command setting key to value with TTL of 300 seconds and reading with GET. Card two is session pattern using HASH with HSET writing field-value pairs and HGETALL reading the full session. Card three is rate limit pattern using STRING and INCR plus EXPIRE, where INCR returns the new count and EXPIRE sets the window in seconds. Card four is leaderboard pattern using SORTED SET with ZADD adding score-member pairs and ZRANGE returning the top N. Card five is pub-sub pattern using PUBLISH to send a message on a channel and SUBSCRIBE to receive on that channel.Five patterns that cover most production Redis1. Cache (with TTL)type: STRINGSETEX product:101 300 "{...}"val = GET product:101if not val: fetch + SETEXTTL of 300s expires the keyautomatically — no GC neededcovers: page cache, hot rows2. Sessiontype: HASHHSET sess:abc user_id 42 csrf "tok" exp 1234EXPIRE sess:abc 1800one HASH per session,update fields independentlycovers: login state, carts3. Rate limittype: STRING + TTLn = INCR rl:user:42:m:Nif n == 1: EXPIRE key 60if n > 100: denykey per (user, minute)window = TTL, count = INCRcovers: API throttling, OTP4. Leaderboardtype: SORTED SETZADD lb 1240 "riya"ZRANGE lb 0 9 REV WITHSCORESZRANK lb "riya" -> my postop N in O(log N + K)my-rank in O(log N)covers: rankings, jobs by time5. Pub/SubPUBLISH / SUBSCRIBEPUBLISH chan:order:new "..."SUBSCRIBE chan:order:new(use STREAM for durability)fan-out at the speed of RAM,no disk, no replaycovers: notifications, fanout
Five patterns, five types, almost every production Redis use case. The cache pattern is the one everyone meets first; the rate limiter is `INCR` plus `EXPIRE` and is the basis of every "100 OTPs per minute per phone" feature you have ever seen; the leaderboard is one `ZADD` per score change and one `ZRANGE` per leaderboard view; the session is one HASH per logged-in user; pub/sub is the lightest possible message bus when at-most-once is fine. When you need at-least-once with replay, swap pub/sub for STREAM (covered briefly in chapter 173).

A note on rate limiting because it is the pattern engineers most often get wrong. The "fixed window" version (INCR of a key like rl:user:42:minute:1714060860 with a 60-second TTL) is what the diagram shows; it is correct, fast, and good enough for almost all real systems. The subtle bug is the boundary: a user can fire 100 requests at second 59 and another 100 at second 61 and slip 200 requests through inside three seconds. The fix is the sliding-window variant using a SORTED SET (ZADD the timestamp, ZREMRANGEBYSCORE everything older than 60 seconds, ZCARD to count) — five lines of Lua wrapping it makes the whole thing atomic. We will revisit this in chapter 173 on cache patterns; for now, know that both shapes exist and that the SORTED SET version costs more memory but gives you real per-request precision.

A worked example: one Redis instance, six features, one Indian e-commerce site

Imagine you are the back-end engineer for an Indian e-commerce site — call it kirana.in — and one Redis instance handles all of the following: caching the product detail page, holding each user's shopping cart, tracking recently-viewed products per user, surfacing the top-selling products on the homepage, counting the number of unique active users in the last hour, and rate-limiting the add-to-cart API so a script kiddie cannot drain inventory on a flash sale. One process. Six features. Six different data types. End-to-end latency: under a millisecond per operation.

One Redis, six e-commerce features

This walks through the Python (redis-py) calls for each of the six features. Run a Redis server locally (docker run -p 6379:6379 redis:7-alpine) and connect:

import redis, json, time
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

(1) Product cache — STRING with TTL. The product detail page is rendered from data scattered across MySQL, the inventory service, and the price service. Caching the assembled JSON for 5 minutes turns a 200 ms render into a 1 ms read.

def get_product(sku):
    cached = r.get(f'product:{sku}')
    if cached:
        return json.loads(cached)               # cache hit: ~0.5 ms
    product = assemble_from_backends(sku)       # cache miss: ~200 ms
    r.setex(f'product:{sku}', 300, json.dumps(product))  # TTL = 5 min
    return product

(2) Shopping cart — HASH per user. One HASH per user means you can HINCRBY a single SKU's quantity without rewriting the whole cart, and you can HGETALL to render the cart page in one round trip.

def add_to_cart(user_id, sku, qty=1):
    r.hincrby(f'cart:{user_id}', sku, qty)      # atomic increment
    r.expire(f'cart:{user_id}', 86400 * 7)      # cart lives for a week

def view_cart(user_id):
    return r.hgetall(f'cart:{user_id}')         # {sku: qty} dict

(3) Recently viewed — LIST trimmed to N. Push the SKU on view, trim to the last 10. The user's "recently viewed" rail on the homepage is one LRANGE.

def viewed(user_id, sku):
    key = f'recent:{user_id}'
    r.lpush(key, sku)
    r.ltrim(key, 0, 9)                          # keep newest 10 only
    r.expire(key, 86400 * 30)
def recent_views(user_id):
    return r.lrange(f'recent:{user_id}', 0, 9)

(4) Top sellers — SORTED SET, score = units sold. Every time an order completes, bump the SKU's score by quantity. The homepage's "top sellers today" widget is one ZRANGE.

def record_sale(sku, qty):
    today = time.strftime('%Y-%m-%d')
    r.zincrby(f'sales:{today}', qty, sku)
    r.expire(f'sales:{today}', 86400 * 3)       # keep 3 days
def top_sellers(n=10):
    today = time.strftime('%Y-%m-%d')
    return r.zrange(f'sales:{today}', 0, n - 1, desc=True, withscores=True)

(5) Active users in the last hour — HYPERLOGLOG. You do not need exact counts; ±0.81% is fine for a marketing dashboard. PFADD a user ID per request; PFCOUNT to read. The whole structure costs 12 KB regardless of whether you have 1 K or 1 B users.

def saw_user(user_id):
    bucket = f'active:{int(time.time() // 3600)}'  # one HLL per hour
    r.pfadd(bucket, str(user_id))
    r.expire(bucket, 7200)
def active_last_hour():
    bucket = f'active:{int(time.time() // 3600)}'
    return r.pfcount(bucket)                    # approximate, ~12 KB

(6) Rate limit on add-to-cart — INCR + EXPIRE. Cap each user at 30 add-to-cart calls per minute. The first call sets the TTL; subsequent calls just increment. The if n == 1 check is the standard idiom for "first request in this window".

def allow_add_to_cart(user_id):
    key = f'rl:atc:{user_id}:{int(time.time() // 60)}'
    n = r.incr(key)
    if n == 1:
        r.expire(key, 60)
    return n <= 30                              # True if under the cap

Six features. One Redis. Roughly one network round trip and a handful of microseconds per operation. The product cache cut MySQL load by 95%, the rate limiter survived a Republic Day flash sale that would have melted the API tier, and the leaderboard made the homepage feel real-time without anyone touching the database. Why all six fit on one machine: each operation touches a tiny part of the keyspace and runs in microseconds; even at 50 K req/s — which is enormous for a startup — Redis spends at most 25–50% of one core. The bottleneck on a single instance, when you eventually hit it, is almost always the network card or the per-connection RTT, not Redis itself. Vertical scale (more RAM, faster CPU) carries Redis to about a million ops/second per node before clustering becomes necessary.

The point of the example is not the code; it is the shape of the architecture. Every feature picks a primitive, uses one or two operations, and runs at memory speed. There is no schema migration, no JOIN, no query planner, no surprise full-table scan. The cost is that everything lives in RAM (so your dataset must fit in RAM or you must accept eviction) and that durability is best-effort (covered in chapter 170 on persistence, where RDB snapshots and AOF append-only files give you the trade-offs).

Real systems: Redis, Valkey, KeyDB, DragonflyDB, Memcached

A short tour of the family, because the names get confused.

Redis — the canonical implementation, originally written by Salvatore Sanfilippo (antirez) in 2009. Single-threaded, BSD-licensed until 2024 when it switched to a dual SSPL/RSAL source-available licence. The reference design and the system this whole chapter describes.

Valkey — the Linux Foundation's community fork of Redis 7.2.4, created in March 2024 immediately after the licence change, BSD-licensed and backed by AWS, Google Cloud, Oracle, and others. As of 2025 it is API-compatible with Redis and is what major cloud providers offer when they say "managed Redis-compatible cache". For most users, the difference is invisible — same RESP protocol, same commands, same data structures.

KeyDB — a Snap fork (now part of Snap Inc.) from 2019 that adds multi-threaded I/O and command processing while preserving Redis semantics. The pitch: more throughput per box without sharding. The cost: a more complex codebase that lags the Redis feature set. Used where a single Redis instance cannot keep up but Cluster is too operationally heavy.

DragonflyDB — a 2022 ground-up rewrite in C++ (with Rust components) that uses a thread-per-core "shared-nothing" architecture (similar to ScyllaDB or Seastar). Compatible with most Redis commands. The pitch: scales linearly with cores on a single box — claimed millions of ops/second on a 32-core machine — by partitioning the keyspace internally across threads.

Memcached — the simpler, older ancestor (Brad Fitzpatrick, 2003). Multi-threaded, only does string get/set with LRU eviction, no rich data types, no persistence, no replication. Still excellent for pure HTTP page caching at scale (Facebook ran a famously huge Memcached fleet for years). If your only need is "cache opaque blobs by key with LRU", Memcached is half the operational complexity. If you need anything else — atomic counters, sets, leaderboards — you want Redis.

For a new system in 2026, the default choice is Valkey (open licence, API-identical to Redis, what your cloud provider runs anyway) unless you have specific reasons (existing Redis Enterprise contract, Redis Stack modules like RedisSearch and RedisJSON, or scaling requirements that DragonflyDB solves).

What Build 22 covers next

This chapter set up the substrate. The next four chapters drill into the operational story:

By the end of Build 22, you will be able to design the Redis layer for a real system end-to-end: pick the data structures, configure persistence and eviction, decide between replication and Cluster, and avoid the three or four cache patterns that look right but cause outages.

References