System designCore9 min

In-memory data-structure stores

Redis is often introduced as a cache, but it is a server for data structures: counters, lists, sets, sorted rankings and streams, all in memory and changed by one command at a time. That design makes it fast and atomic, and it also explains its limits: one slow command stalls everyone, memory runs out, and durability is a setting.

1 · The idea

An in-memory store keeps data structures, not just values

A Q&A site for programmers has 3 million visits a day. Besides its PostgreSQL database it needs a dozen small, fast things: sessions, rate limits, a "hot questions" ranking, who's online, a recent-activity feed. Memcached would store each as an opaque value, and every change would mean reading it, editing it in the app and writing it back. Redis is a data structure server: the value has a type, and commands change it in place:

Type

What it is

On the Q&A site

String

Bytes, or a number with atomic increment

Rate-limit counters per user per minute

Hash

Field-value pairs, like a small record

A session: user ID, theme, last seen

List

Strings in insertion order

Recent activity, capped at 100 items

Set

Unique strings; add, remove, test in O(1)

Users online in each tag's chat room

Sorted set

Unique strings ordered by a score

Hot questions ranked by a score (Counters & leaderboards)

Stream

An append-only log read by consumers

Notifications waiting to be sent

There are more, including geospatial indexes (Geospatial indexing), HyperLogLog for counting distinct visitors (HyperLogLog: counting distinct things) and vector sets. The point isn't the list: it's that the work happens next to the data. "Add 5 to this question's score and return its new rank" is one command, not a read-modify-write across the network.

2 · One at a time

Commands run one at a time, so each one is atomic

Redis serves all client requests on a single thread, one command at a time, in the way Node.js serves requests on one event loop. Most commands take well under a microsecond, so one thread handles a lot of traffic. And because nothing else runs during a command, each command is atomic with no locks:

A per-minute rate limit, safe under any concurrency

INCR   rl:user:4410:2026-10-02T09:14     -> 37
EXPIRE rl:user:4410:2026-10-02T09:14 60

# 37 > 30 allowed per minute: reject with 429

Two app servers incrementing the same counter at the same moment get 37 and 38, never 37 twice. Several commands that must happen together go in a Lua script or a MULTI block, which also run without interruption (Transactions & isolation levels covers what MULTI does and doesn't roll back).

The server is rarely the slow part; the network is. A round trip on a 1 Gbit/s network takes about 200 µs, so a page that issues commands one by one pays that every time. Sending them together, with multi-key commands like MGET or by pipelining, pays it once:

50 commands, one by one
10 ms
50 commands, pipelined
0.2 ms

50 × 200 µs = 10 ms of waiting for a question page that loads 50 counters, against one round trip. The commands themselves take microseconds either way.

3 · Slow commands

One slow command stalls every client

The single thread has a cost: while one command runs, every other client waits. Constant-time commands such as GET, SET and LPUSH never notice. Commands that walk many elements do: sorting a large list, intersecting two big sets, or KEYS, which scans every key. KEYS in production is a very common source of latency:

Every page load on the site waits behind one maintenance command. SCAN does the same job in small batches, letting other commands run in between.

The usual causes and their fixes:

  • Whole-keyspace commands. Use SCAN instead of KEYS, and SSCAN, HSCAN, ZSCAN for big collections.
  • Big values. A list with millions of items makes every command that reads it whole slow; cap it (LTRIM) or split it.
  • Mass expiry. Many keys expiring in the same second can make Redis pause to clean them up; add jitter to expiry times.
  • Heavy reads. Run slow queries on a replica. The slow log records which commands took too long.

4 · Memory

Memory is the limit, so decide what happens when it's full

Everything lives in RAM, so the dataset can't outgrow the machine. maxmemory sets the limit and maxmemory-policy decides what happens at it. The choice depends on whether the data can be rebuilt:

Policy

At the limit

Fits

noeviction

Writes that add data fail with an error; reads still work

Data that must not vanish, such as queues and sessions

allkeys-lru

Evict the least recently used keys

Caches; Redis's suggested default when some keys are hotter

allkeys-lfu

Evict the least frequently used keys

Caches with stable popular items

volatile-ttl

Evict keys with the shortest time left to live

Apps that mark expendable keys with short TTLs

Don't mix a cache and data you want to keep in one Redis instance; run two. The Q&A site does exactly that: a cache instance with allkeys-lru, and a second instance for sessions, rate limits and the notification stream with noeviction and alerts well before it fills. Leave headroom below the machine's RAM as well: replication and persistence buffers aren't counted against maxmemory (Cache invalidation & eviction covers how the LRU approximation works).

5 · Durability

Durability is a setting, and each setting loses something different

Data in memory is gone when the process dies, unless it was written to disk. Redis offers two mechanisms, alone or together:

  • RDB snapshots write the whole dataset to a file every few minutes, from a forked child process. Compact, good for backups and fast restarts, but a crash loses everything since the last snapshot.
  • The append-only file (AOF) logs every write. How much it can lose depends on how often it forces the file to disk (fsync).
RDB every 5 minutes
300 s
AOF, fsync every second
1 s
AOF, fsync always
0 s

Writes lost in the worst case after a crash. Fsync every second is Redis's default and recommended AOF setting; fsync on every write is safest and very slow. Redis suggests using both AOF and RDB for safety comparable to PostgreSQL's.

Persistence has its own latency cost. Snapshots and AOF rewrites fork the process, and the fork copies its page table: about 48 MB for a 24 GB instance, which can pause the server for milliseconds, or much longer on some virtual machines. Keep instances moderately sized, and don't schedule snapshots on every replica at the same moment.

6 · Replicas & cluster

Replicas and clusters add availability, not strict consistency

Replication is asynchronous by default: the primary answers the client, then streams the write to its replicas. If the primary dies before a write reaches a replica and the replica is promoted, that acknowledged write is gone. The WAIT command makes the client wait for replicas to confirm, which shrinks the window, but it does not make the system strongly consistent (Database replication).

Redis Cluster spreads keys across several primaries. Every key maps to one of 16,384 hash slots, by CRC16 of the key mod 16,384, and each primary owns a share of the slots:

App serverCRC16(key) mod 16,384slot 5,474Primary Bslots 5,461–10,921keyowner of the slotApp serverCRC16(key) mod 16,384slot 5,474Primary Bslots 5,461–10,921keyowner of the slot

Clients cache the slot map and talk to the right primary directly; a node that doesn't own a slot answers MOVED with the right address. Each primary has replicas of its own, copied asynchronously.

Multi-key commands only work when all keys are in the same slot. Hash tags force that: only the part in braces is hashed, so {user:4410}.session and {user:4410}.prefs always share a slot. Memcached takes the opposite approach: servers don't know about each other at all, with no replication, and the client picks a server by hashing the key (Consistent hashing).

7 · In practice

Keep in memory what you can rebuild, or can afford to lose

The Q&A site keeps the questions and answers themselves in PostgreSQL. Everything in Redis is either derived from that, and can be rebuilt (cached pages, the hot-questions ranking), or short-lived and acceptable to lose a second of (rate counters, sessions, online status). That split is what makes an in-memory store safe to rely on.

  1. Pick the structure first

    A sorted set or a counter does the work in one atomic command instead of a read-modify-write in the app.

  2. Separate cache from state

    One instance that may evict, another that must not, each with its own memory alarm.

  3. Choose durability on purpose

    None for pure caches, AOF every second for state, plus snapshots for backups.

  4. Ban slow commands

    No KEYS in production, cap list and set sizes, watch the slow log.

  5. Batch round trips

    Pipelines and multi-key commands, with hash tags when keys must share a slot.

Check yourself

4 questions

1. Two app servers run INCR on the same rate-limit key at the same moment. What can happen?
2. An admin runs KEYS session:* on a production Redis holding 2 million keys. Why does the site slow down?
3. With AOF and fsync every second, how much acknowledged data can a crash lose?
4. A Lua script must update both {user:4410}.session and {user:4410}.prefs in Redis Cluster. Why does it work?

Takeaways

Remember this

  • Redis stores typed data structures and changes them in place, so work like incrementing or ranking is one command next to the data.
  • One thread runs one command at a time: every command is atomic, and one slow command delays every client.
  • Memory is the limit: choose an eviction policy per instance, and keep caches and must-keep state in separate instances.
  • Durability is configurable: snapshots lose minutes, AOF every second loses about a second, and replication is asynchronous.
  • Redis Cluster maps keys to 16,384 slots; hash tags keep related keys together for multi-key commands.