offset 000
Why I keep rereading this book
I have read Designing Data-Intensive Applications three times and I still got partitioning wrong on a project last spring. Which is less a criticism of the book than a description of how books work. It handed me the vocabulary years before I earned the scars.
The first read felt like a tour of things I already half knew. Indexes, replicas, queues, fine. The second read was worse, because by then I had shipped a system that quietly did read-your-writes wrong for eight months and I could see my own bug sitting in chapter five with a name on it. The third read was the useful one. By then I stopped treating it as a catalogue and started treating it as a set of tradeoffs I have to re-pick every time.
So this page is two things bolted together. The first half is my compressed version of the ideas, written the way I would explain them to a colleague at a whiteboard, opinions included. The second half is four designs I have either built, half built, or argued about for long enough that I have a position. There is a KV cache for LLM inference, a vector search stack, a payments ledger, and the old timeline fanout problem.
If you want the canonical treatment, buy the book. What follows is what stuck to me, plus the parts I think the book underweights now that half of us are serving models instead of web pages.
offset 001
Reliable, scalable, maintainable
Kleppmann opens with three properties and I used to skim past them as throat clearing. I was wrong about that, mostly because of the third one.
Reliability is the system continuing to work correctly when things go wrong, which means you have to be specific about which things. Disks die at a few percent a year. Whole racks lose power. But the thing that actually took down the last four systems I worked on was a human: a config change, a bad migration, a retry storm someone shipped on a Friday. Hardware faults you can buy your way out of with redundancy. Human faults you can only design around, with staging environments that resemble production, with deploys that roll back in seconds, and with interfaces that make the dangerous thing harder to reach than the safe thing.
Scalability only means something once you attach a load parameter to it. Requests per second? Simultaneous connections? Ratio of reads to writes? Fan-out per post? Until you name the parameter you are just saying you want the system to be good. Twitter's classic example is the one worth memorizing, since the load parameter that mattered was never tweets per second, it was the distribution of follower counts.
Maintainability is the one that got underrated by me and I suspect by most people reading it for interview prep. Systems do not usually die of throughput. They die because nobody left alive understands why the reconciliation job runs at 04:00, so nobody dares touch it, so it accumulates workarounds until it is load bearing garbage. Operability and evolvability are boring words for the thing that determines whether your architecture is still alive in five years.
offset 002
The tail is the product
Average latency is close to useless and I will die on this hill. If your mean response time is 90ms you have learned almost nothing, because the mean is dominated by the fast requests, and the fast requests are the ones nobody complains about.
Use percentiles. The p50 tells you what a typical request feels like. The p99 tells you what your loudest customer feels like, and the p99 customer is usually your biggest customer, because they have the most data, the most rows, the most items in cart. That correlation is the cruel part. The slowest requests belong to the accounts you least want to annoy.
Two things worth internalizing beyond the definition. First, tail latency amplification: if one user request fans out to twenty backend calls and each backend has a p99 of 100ms, the odds that at least one of your twenty calls hits the slow path are about 18 percent. Your p99 becomes a coin flip nobody planned for. Second, percentiles do not average. You cannot take the p99 of ten machines, average them, and call it a p99. You need the histogram, or at minimum something like HdrHistogram, and you need to merge the buckets rather than the summaries.
The pragmatic version: alert on p99, capacity plan on p50, and make sure your load generator keeps sending requests at a fixed rate rather than waiting for the previous one to return. If it waits, it stops sending traffic exactly when the system is struggling, and your graph will look great during the incident. That artifact has a name, coordinated omission, and it has fooled me at least twice.
offset 003
Data models and the shape of your joins
The document versus relational argument is mostly a proxy fight about where you want the joins to live. Document stores give you locality: one read pulls the whole résumé, the whole order, the whole page. That is genuinely great when your access pattern really is "fetch this one tree and render it." It falls apart the moment somebody asks a question that crosses trees, because now you are doing joins in application code, and application code joins are just joins with worse cardinality estimates and no query planner.
What actually decides it, in my experience, is how many-to-many your domain is. Users and their addresses: fine, embed it. Users and the things they follow and the things those things produce: you have a graph, stop pretending otherwise. Schema-on-read is a real advantage when your objects are genuinely heterogeneous, and a real liability when they are not, because "flexible schema" in practice means every consumer implements a different guess about what fields exist.
The part I wish more people read is the graph section. Property graphs with something like Cypher, or triple stores with SPARQL, or plain recursive CTEs in Postgres, all handle the "how are these two things connected, and how far apart" question that relational schemas make painful. If you find yourself writing a self-join five levels deep with a hardcoded depth limit, that is the model telling you something.
offset 004
B-trees, LSM trees, and write amplification
This is the chapter that changed how I read a database's docs. Once you know whether the engine underneath is a B-tree or an LSM tree, half of its personality becomes predictable.
A B-tree updates in place. Find the page, modify it, write it back. That gives you good read latency and a predictable story for range scans, and it means one logical write turns into a page write plus a write-ahead log entry, so the write amplification is bounded but not small. Pages are typically 4 to 16 KB, so changing an 80 byte row rewrites the whole page.
An LSM tree never updates in place. Writes go to an in-memory table, get flushed as sorted immutable files, and a background compaction process merges those files and drops overwritten keys. Writes become sequential, which is a large win on both spinning disks and SSDs, and the storage compresses better because segments are sorted and immutable. The tax is read amplification (a lookup may check several files, hence bloom filters on each) and compaction, which competes with your foreground traffic for disk bandwidth at the worst possible moment.
My rule of thumb: write heavy workloads with a lot of overwrites and a tolerance for occasional latency spikes go to LSM (RocksDB, Cassandra, ScyllaDB). Workloads that need tight, boring p99s and strong transactional semantics go to B-trees (Postgres, InnoDB). And if you pick an LSM engine, measure compaction debt as a first class metric, because a system that is quietly falling behind on compaction looks perfectly healthy right up until reads collapse.
The nastiest storage incident I have seen was not a crash. It was a Cassandra cluster where a schema change caused tombstones to pile up faster than compaction could clear them, and read latency crept up over nine days. Nobody noticed on the daily graph. The weekly graph looked like a ski slope.
offset 005
Encoding, or how deploys break each other
Every serialization format question is really a question about time. During a rolling deploy, old code and new code run at the same time and read each other's bytes. That is the whole chapter in one sentence.
Backward compatibility means new code reads old data. Forward compatibility means old code reads new data, and it is the harder one, because the old code has to ignore fields it has never heard of without corrupting them on rewrite. Language-specific serializers (Java's built in one, Python pickle) fail this badly and also happen to be a remote code execution hazard, so I treat them as disqualified for anything crossing a process boundary.
JSON is fine and I use it constantly, but be honest about what it costs you: no schema unless you bolt one on, integers above 2^53 become a hazard the moment JavaScript touches them, and no binary strings without base64 padding. Protobuf and Avro give you schema evolution rules that actually hold. Protobuf keys fields by tag number, so you can rename freely and must never reuse a number. Avro matches by name and resolves the writer's schema against the reader's, which is why it fits so well in dataflow systems where you keep the schema alongside the file rather than in the record.
Once you have a schema registry, you get something better than compact bytes. You get the ability to answer "who is still writing v3" before you delete a field, which is the actual bottleneck in large orgs.
offset 006
Replication and the lies it tells
Single leader replication is the default almost everywhere and it works. One node takes writes, followers stream the changes, reads spread across the fleet. The interesting part is not the topology, it is the set of guarantees you silently lose when you let reads hit a follower.
Read-your-writes is the one users notice immediately. Someone posts a comment, the read goes to a lagging replica, the comment is gone, they post it again. Fixes range from routing reads for recently written keys to the leader, to tracking a logical timestamp in the session and refusing to serve from a replica behind it. The second is more work and much better.
Monotonic reads is the one that produces the weird bug reports. A user refreshes and time appears to move backwards, because the first read hit a fresh replica and the second hit a stale one. Pinning a user to a replica by hash of user id fixes it, until that replica fails and you have to fail them over carefully.
Consistent prefix reads matters mostly in partitioned systems, where the answer arrives before the question because they lived on different partitions with different lag. If your partitions are causally related, either put them together or carry causality metadata.
Quorum arithmetic
Drag the dials. The question is whether a read is forced to touch a node that already saw the write.
red acked the write, green answers the read, amber does both
Quorums are a nice piece of arithmetic and a slightly misleading one. Even with w + r > n you do not get a real guarantee in the presence of concurrent writes, sloppy quorums, or a node that restored from a stale backup. Dynamo style systems buy availability and pay for it with conflict resolution you have to write yourself, whether that is last write wins (which silently drops data, and clock skew decides whose), version vectors, or CRDTs.
Multi leader replication is where I have seen the most engineering pain per unit of benefit. It is genuinely right for offline first clients and for multi datacenter writes with high latency between regions. It is genuinely wrong as a way to avoid thinking about a single writer, because you have now signed up to resolve conflicts forever, and the conflict cases you did not think of will be the ones your users find.
offset 007
Partitioning and hot keys
Partition by key range and you keep range scans cheap but invite hotspots, because real keys are never uniform. Timestamps are the classic trap: partition by day and all of today's writes land on one node while the rest of the cluster idles. Partition by hash of key and the load spreads nicely but range scans now have to hit every partition.
Most production systems end up with a compound scheme. Hash the first part of the key, sort within it. Cassandra's partition key plus clustering columns is exactly this, and so is DynamoDB's partition key plus sort key. You get spread across nodes and locality within a node, which is usually what you actually wanted.
Hot keys survive hashing. A celebrity user, a viral product, a single tenant that is a thousand times bigger than the median: hashing a key that is by itself too popular does nothing, because it is still one key. The blunt fix is to append a random suffix of two digits to the key, spreading it over a hundred partitions, and accept that all reads for that key now fan out to a hundred places. Only do it for the handful of keys that need it, which means you need to detect them, which means you need per-key metrics, which almost nobody has until after the first outage.
On rebalancing, the one thing to remember is never to use hash mod n. Adding a node reshuffles nearly everything. Use a fixed large number of virtual partitions assigned to nodes, or consistent hashing with virtual nodes. And keep rebalancing manual, or at least gated by a human, because automatic rebalancing combined with a false failure detection is how you turn a slow node into a dead cluster.
offset 008
Transactions, isolation, and write skew
The isolation levels are worth learning properly, not because you will be quizzed on them, but because the defaults are weaker than you think and the anomalies have very specific shapes.
| level | stops | still allows |
|---|---|---|
| read committed | dirty reads, dirty writes | non-repeatable reads, lost updates, write skew |
| snapshot / RR | the above plus read skew | lost updates in some engines, write skew, phantoms |
| serializable | everything, by definition | throughput, sometimes |
Write skew is the anomaly that gets past code review. Two doctors are on call. Each checks that at least one other doctor is on call, sees the other one, and both go off shift at the same instant. Neither transaction wrote to a row the other read, so no engine below serializable will stop them. The premise each transaction relied on became false, and there is no row that recorded the premise.
The escape hatches are: materialize the conflict into a row you can lock (a shift table with a row per shift), use SELECT ... FOR UPDATE on the rows you checked, or use actual serializable isolation. Postgres's serializable snapshot isolation is optimistic, so it does not block, it aborts and asks you to retry. That is an excellent tradeoff if your transactions are short and your retry path is real. It is a terrible one if a transaction holds open for thirty seconds because someone put an HTTP call in the middle of it.
Two phase commit deserves a mention mainly so you can avoid it deliberately rather than accidentally. The coordinator becomes a single point of failure that holds locks while it is down, and in-doubt transactions block until an operator intervenes. Most of the time, an idempotent operation plus a retry gets you what you wanted with none of the ceremony. That thinking shows up again in the ledger case study below.
offset 009
Consensus, and why CAP is a bad map
CAP gets quoted more than it gets used correctly. The theorem is narrow: during a network partition, a system that must remain available cannot also be linearizable. It says nothing about latency, nothing about normal operation, and its "availability" is a formal property, not an SLA. Calling a database "AP" or "CP" is roughly as informative as calling a car "fast".
The more useful frame is what you pay for linearizability even when nothing is broken. Every linearizable read has to reach consensus or a leader, which means at least one network round trip, which means your latency floor is set by geography. That cost is why sensible systems draw a small linearizable core (leader election, uniqueness constraints, locks) and leave the rest eventually consistent on purpose.
Total order broadcast is the abstraction I find most useful in practice, and it is equivalent to consensus. If every node sees the same messages in the same order, you can build a replicated state machine, and most of the hard problems become "apply the log in order." Raft, ZAB, and Paxos are all ways of agreeing on that order. In application code you will almost never implement one. You will use ZooKeeper or etcd, and the important skill is recognizing which of your problems is secretly a consensus problem, usually leader election or a uniqueness constraint that spans partitions.
Fencing tokens are the small idea I wish everyone knew. A lock is not enough, because the holder can pause for a GC cycle, lose the lease, wake up, and keep writing as though it still owns the resource. The lock service must hand out a monotonically increasing token with each grant, and the storage layer must reject any write carrying an older token. Without that, distributed locks are advisory in the worst sense.
offset 010
The log as the real database
The idea that reorganized how I design systems is that the ordered, immutable log is the source of truth and every database, index, cache, and search cluster is a derived view of it. Once you accept that, a lot of architectural arguments dissolve.
Dual writes stop being tempting. If your service writes to Postgres and then to Elasticsearch, you have two writes that can partially fail and no ordering guarantee between concurrent updates, so your search index will drift and nobody will notice which record is wrong. Change data capture fixes this properly: Postgres logical decoding or Debezium turns the WAL into a stream, and the search index becomes a consumer that can be rebuilt from scratch by replaying.
Rebuildability is the underrated superpower. A derived system you can regenerate from the log is a system you are allowed to change. Want a different analyzer in your search index, a different sort key, a different embedding model? Spin up a new consumer, replay from offset zero into a new index, switch the read path when it catches up, delete the old one. No migration script, no downtime, no praying.
The line between batch and stream is mostly about bounded versus unbounded input. Batch jobs are stream jobs whose input happens to end, which is why the tooling converged. What does not converge is windowing and time. Event time and processing time diverge whenever anything is slow, and every stream framework's hardest API surface is what to do about the events that arrive after you thought the window closed. Watermarks, allowed lateness, side outputs for stragglers. Decide early whether late data corrects the answer or is dropped, because retrofitting that decision means recomputing history.
case study 011 / inference infrastructure
A KV cache for an LLM serving stack
This is the most interesting caching problem I have worked on in years. In a normal service the cache sits off to the side and makes things faster. Here it decides your throughput, your cost per token, and how many people fit on a GPU at once.
- goal
- Serve a 70B model to a chat product and a code assistant on the same fleet, at p95 time-to-first-token under 400ms.
- traffic
- Roughly 3k requests/sec at peak. Median prompt 2,400 tokens, p95 around 18,000. Heavy prefix sharing: system prompts, few-shot blocks, repo files.
- hardware
- H100 80GB nodes, 8 GPUs each. The model weights eat about 140GB across two GPUs with tensor parallelism, so the cache gets what is left.
- hard part
- Memory is the bottleneck, not compute, and the working set is shaped by which prompts share prefixes.
What is actually being cached
During generation, every previously seen token contributes a key and a value vector at each attention layer. Recomputing them for every new token would be quadratic, so you keep them. That is the KV cache, and it grows linearly with sequence length, per request.
The arithmetic decides the design. For a model with 80 layers, 8 key/value heads after grouped query attention, and a head dimension of 128, one token costs roughly 2 (K and V) × 80 × 8 × 128 × 2 bytes in fp16, which is about 320 KB per token. A 20,000 token conversation is around 6.4 GB of cache for one user. On a node with maybe 50GB free after weights, that is eight concurrent long sessions before you are out of memory. Eight. On a machine that cost more than a house deposit.
Everything below follows from that number.
Paging, because contiguous allocation is a disaster
The naive implementation reserves a contiguous block sized to the maximum possible sequence length for every request. If your max context is 128k and the median request uses 2.4k, you are wasting more than 95 percent of the memory you reserved. Internal fragmentation kills you before load does.
The fix is the one operating systems landed on in the 1960s. Chop the cache into fixed size blocks, typically 16 or 32 tokens, keep a per-sequence block table mapping logical positions to physical blocks, and allocate blocks lazily as the sequence grows. This is what vLLM calls paged attention, and the effect on memory utilization is not subtle. Fragmentation drops to at most one partly filled block per sequence, and batch sizes go up several times over for the same hardware.
Prefix sharing is where the real win lives
Once blocks are the unit of allocation, two sequences with the same prefix can point at the same physical blocks. In our traffic that is not a rare case, it is the common one. The chat product prepends a 900 token system prompt to every request. The code assistant sends the same repository files across dozens of turns. Measured on a week of real traffic, about 62 percent of prompt tokens were a prefix somebody else had already computed.
To exploit it you need a lookup structure keyed on token sequences, and a radix tree over token ids works well: each node holds a run of tokens plus the blocks that back them, and inserting a new prompt walks down the tree until it diverges. On a hit you skip the prefill for those tokens entirely, which is the expensive quadratic part. Time to first token for a repeat system prompt drops from a few hundred milliseconds to tens.
Two details that bit me. Hashing must include position and any prefix before it, so a block is only reusable if the entire preceding context is identical. And the tree must be keyed per model and per LoRA adapter, since identical tokens produce different K and V vectors under different weights. I have watched someone spend a day on a "model is answering wrong" bug that was a cache key missing the adapter id.
Eviction
Classic LRU is wrong here, or at least incomplete, because entries have wildly different values. Evicting a leaf node that one idle session might resume costs you a small prefill. Evicting the shared 900 token system prompt block costs every request that arrives in the next second.
What worked: evict leaves first, never evict a node with a live refcount, and weight by (tokens saved on hit × recent hit rate) rather than recency alone. In effect it is closer to GDSF than LRU. Keep the hottest few prefixes pinned outright, since a dozen system prompts cover a large share of traffic and the memory cost is trivial.
Routing, or how the cache changes your load balancer
Here is the part that surprised me. Once the cache is this valuable, round robin routing is actively harmful. Sending a request to a node that does not have its prefix means recomputing work that already exists three racks away.
So the router became cache aware. It keeps an approximate view of which prefixes live where (each node publishes a compact summary of its radix tree roots every few hundred milliseconds), and it scores candidate nodes on prefix overlap against current queue depth. The scoring function needs a load term with real teeth, otherwise every request piles onto whichever node happens to hold the popular prompt and you have rebuilt the hot key problem from offset 007. We ended up capping the cache bonus so it can never outweigh a queue that is more than about twice the fleet median.
Offloading and the memory hierarchy
Blocks that fall out of GPU memory do not have to die. Host RAM is roughly an order of magnitude cheaper per gigabyte and a PCIe transfer of a few hundred megabytes takes single digit milliseconds, which is far less than recomputing prefill for 20k tokens. So there is a second tier in CPU memory, and for the code assistant, a third tier on local NVMe holding the repository prefixes that get reused across a working day.
Quantizing the cache to fp8 roughly halves it and, for most chat workloads, the quality difference is hard to detect. I would still gate it behind an eval on your own traffic, since long context retrieval tasks are noticeably more sensitive than short chat.
Prefix hit rate weighted by tokens rather than requests, since one 18k token hit is worth a hundred short ones. Preemption rate, which tells you the scheduler is admitting more sequences than memory can finish. Block table occupancy versus allocated memory, to catch fragmentation regressions. And time to first token split by cache hit and miss, because a single p95 hides the entire story.
What DDIA already told me
None of this is new, which is the point. The block table is a page table. Copy-on-write is a filesystem trick. Refcounted shared prefixes are a persistent data structure. Cache aware routing is consistent hashing with a load penalty. The radix tree is a trie. The systems ideas transferred almost unchanged. What moved is the constants, and they are brutal. At 320 KB a token, a memory decision is a headcount decision, and you feel it on the invoice.
case study 012 / retrieval
Vector search over 400M chunks
Every company I have talked to in the last two years has built roughly this system, and most of them, including one I worked on, learned the same lessons in the same order. The first version works beautifully on a laptop with 100k vectors. Everything after that is a different problem.
- corpus
- 400M text chunks from documents, tickets, and wiki pages across 12k customer tenants. 1024-dimensional embeddings.
- queries
- 800/sec, always filtered by tenant, usually filtered further by document type or date. p95 budget of 120ms for retrieval.
- freshness
- An edited document should be searchable within about a minute. Deletes must be immediate, for legal reasons.
- hard part
- Filtering. Nearly everything published about ANN benchmarks assumes an unfiltered search, and almost no production query is unfiltered.
Size it before you design it
400M vectors × 1024 dims × 4 bytes in fp32 is 1.6 TB of raw vectors. An HNSW graph adds edge lists on top, typically another 30 to 50 percent depending on M. You are not fitting that in RAM on one machine at any sane price, so the question becomes compress, shard, or spill to disk. In practice, all three.
Product quantization at 8x compression brings it to roughly 200 GB, which is a fleet of a dozen machines rather than a hundred. The recall cost is real but recoverable, because you rerank: retrieve 200 candidates with the compressed representation, then rescore the top candidates against full precision vectors fetched from disk or a separate store. Recall at 10 comes back to within a point or two of exact search, and you only pay full precision reads on 200 rows instead of 400 million.
Picking the index
| index | good at | bad at |
|---|---|---|
| flat | exact recall, tiny corpora, easy deletes | anything over a few million vectors |
| IVF-PQ | memory footprint, huge corpora, batch rebuilds | recall at low latency, incremental updates, drifting centroids |
| HNSW | recall/latency at a given budget, incremental inserts | memory, deletes, long build times |
| DiskANN | SSD-resident graphs, cost per vector | tail latency, dependence on SSD quality |
We landed on HNSW with product quantized vectors held in memory and full precision vectors on NVMe for reranking. M of 32 and efConstruction of 200 built in a bit under a day per shard on a 64 core box, which is fine because you rebuild rarely. efSearch is the knob you actually tune in production; ours sits around 100 and moves with load, since it trades recall for latency almost linearly.
The filtering problem
This is the part that separates a demo from a product. A query is never "find similar chunks". It is "find similar chunks that belong to tenant 8842, are of type wiki, and were updated in the last year".
Pre-filtering means computing the allowed set first, then searching only within it. Correct, but if the filter is selective the HNSW graph becomes disconnected: you walk into a region where every neighbour is filtered out and the search stalls with terrible recall. Post-filtering means searching normally and dropping disallowed results, which is fast and returns three results when you asked for fifty, because the top 200 were all other tenants.
What we do instead, in order of preference. If a filter is high cardinality and always present, make it a physical partition. Tenant id was exactly this, so every tenant gets its own index segment, small tenants share a segment, and the twenty largest get dedicated ones. That single decision solved most of the filtering problem, because the dominant filter stopped being a filter at all.
For remaining filters we do filtered traversal: evaluate the predicate during the graph walk using a bitmap, and if the pass rate drops below roughly one percent, abandon the ANN path and do a brute force scan over the filtered set, which by then is small enough to be cheap. Having both paths and a cost model to choose between them is what a query planner does, and it turns out you need one here too.
Freshness, deletes, and the segment trick
HNSW does not really support deletion. You can tombstone a node so it is skipped in results while still being traversable, but tombstones accumulate and degrade both recall and speed.
The answer is the same one Lucene has used for two decades, which is also the LSM tree from offset 004. Write into small immutable segments. New documents land in a fresh in-memory segment that is searchable within seconds. Deletes go into a bitmap per segment, applied at query time, so a delete is immediate and cheap. A background merge rewrites segments, physically dropping deleted rows and rebuilding the graph. A query fans out to all segments and merges the top k.
Once we had that, the embedding model upgrade problem solved itself. Reindexing 400M chunks with a new model is a replay, not a migration: build new segments in the background, run both indexes in parallel behind a flag, compare on an eval set, flip the read path, drop the old segments. That is the derived data idea from offset 010, doing its job.
Hybrid retrieval, because embeddings cannot spell
Pure vector search loses to BM25 on exact identifiers. Error codes, part numbers, function names, someone's surname. The embedding has no idea that ERR_4471 and ERR_4417 are different in a way that matters, and it will happily return one for the other.
So run both and fuse. Reciprocal rank fusion is the boring choice and is hard to beat without training something: score each document as the sum over retrievers of 1/(60 + rank). No score normalization, no tuning, works immediately. Then rerank the top 50 with a cross encoder if your latency budget allows, which in our case added about 40ms and was worth more than any index tuning we did.
If I could send one message back in time to the start of this project it would be: build the evaluation set before the index. We spent weeks tuning efSearch and M with no ground truth, arguing about whether results felt better. Two hundred labelled queries would have settled every one of those arguments in an afternoon.
case study 013 / money
A payments ledger that survives retries
Money systems are where the transaction chapter stops being academic. The requirement is not "usually correct". It is that after any combination of crashes, timeouts, duplicate requests, and a client that gave up and tried again, the balance is right and you can explain it to an auditor.
- scope
- Internal ledger for wallet transfers and card settlements. Low volume by internet standards, around 400 writes/sec, but every write is load bearing.
- invariant
- Every transfer sums to zero across accounts. No account goes below its allowed floor. Nothing is ever deleted or updated in place.
- hard part
- The upstream network retries aggressively and its timeouts are shorter than our worst case latency, so duplicate requests are normal traffic, not an edge case.
Append only, always
The ledger is a table of immutable entries. A balance is not a column you update, it is a fold over entries. That sounds expensive and it is not, because you keep periodic checkpoint rows and read the entries after the latest checkpoint.
create table entry (
id bigserial primary key,
transfer_id uuid not null,
account_id bigint not null,
amount_minor bigint not null, -- signed, always integers
currency char(3) not null,
created_at timestamptz not null default now()
);
-- double entry: every transfer inserts >= 2 rows summing to zero
create index on entry (account_id, id desc);
Two rules I would fight for. Store money as integers in the minor unit, never floats, and carry the currency alongside every amount so nobody can add pence to cents. And never allow an UPDATE or DELETE on this table; revoke the grants so the rule is enforced by the database rather than by code review. A correction is a new compensating entry, which is also what accountants have done since the fifteenth century.
Idempotency, done properly
The client sends an idempotency key with each transfer. The obvious implementation, check whether the key exists and insert if not, has a race in it wide enough to lose money through, because two concurrent retries both check before either inserts.
Let the database do the work. A unique constraint on the key, an insert that hits the conflict, and the second caller reads the original result:
-- attempt to claim the key; only one caller wins
insert into idempotency (key, request_hash, state)
values ($1, $2, 'in_progress')
on conflict (key) do nothing
returning id;
-- no row returned means someone else owns it:
-- state = 'done' -> return the stored response
-- state = 'in_progress' -> 409, tell the client to retry shortly
-- request_hash differs -> 422, same key with different body is a client bug
That last case matters more than it looks. If a client reuses an idempotency key with a different payload, silently returning the old response is worse than an error, because it hides a bug in their code that will eventually charge the wrong person. Store a hash of the request and reject mismatches loudly.
Expire keys, but slowly. We keep them 30 days, which is longer than any retry policy upstream and long enough that a support engineer investigating a week old complaint can still see what happened.
Getting the event out without dual writes
The moment a transfer commits, five other systems want to know: notifications, fraud scoring, the data warehouse, the customer statement service, the reconciliation job. The tempting implementation is to commit the transaction and then publish to Kafka. That is a dual write, and it fails in both directions. Publish before commit and you can announce a transfer that got rolled back. Publish after commit and a crash in between loses the event silently.
The outbox pattern removes the problem. Insert the event into an outbox table inside the same transaction as the ledger entries, so it is atomic by construction. A separate relay reads the outbox, either by polling or by tailing the WAL with logical decoding, and publishes to Kafka.
Delivery is at least once, so consumers must be idempotent. For the warehouse that means upserting on transfer id. For notifications it means a sent-notifications table keyed on the same id, checked before sending, because customers do notice two identical texts about the same payment.
Isolation, concretely
Checking a balance and then inserting a debit is a textbook write skew: two concurrent transfers each read a balance of 100, each approves a withdrawal of 80, and the account ends at negative 60. Read committed will not stop it and neither will snapshot isolation, because they touch different rows.
Three options that work. Lock the account row with SELECT ... FOR UPDATE before reading the balance, which serializes per account and is usually fine because contention is per account rather than global. Use serializable isolation and retry on 40001, which is clean if your transactions are short. Or make the invariant a constraint on a materialized balance row, so the database rejects the write.
We picked row locks on the account, ordered by account id to avoid deadlocks between two transfers touching the same pair in opposite directions. That ordering rule is three lines of code and it eliminated an entire class of 3am pages.
Reconciliation, because you will still be wrong
Every ledger I have seen eventually disagrees with the bank, usually because of something outside your transaction boundary: a settlement file with a different cutoff time, a chargeback posted with yesterday's date, a partner who rounds differently.
So there is a job that recomputes every account balance from entries and compares it against the checkpoint, and a second job that compares our totals against the provider's settlement file. Both write their findings to a table rather than logging them, because a discrepancy needs an owner and a resolution, not a line in Datadog that scrolls away. The first time it fired, it caught a rounding difference on foreign exchange fees worth about eleven pounds a day, which nobody would ever have noticed and which had been running for four months.
case study 014 / the classic one
Timeline fanout for a chatty network
The Twitter example in chapter one is the one everybody quotes, so it is worth writing down what actually changes when you build it rather than describe it.
Fanout on read is one row per post and a join at query time: fetch everyone you follow, fetch their recent posts, merge, sort. Writes are trivial, reads are expensive and get worse as people follow more accounts. Fanout on write precomputes each user's timeline at post time, so reads are a single sequential scan of one list, and writes cost one insert per follower.
Neither survives on its own. A user with 30M followers turns one post into 30M writes, which at any reasonable rate takes minutes and floods the write path. A user who follows 5,000 accounts turns one timeline read into a 5,000 way merge.
The hybrid everyone converges on: fan out on write for ordinary accounts, and for a small set of very high follower accounts, keep their posts out of the precomputed lists and merge them in at read time. The threshold is a business decision more than a technical one. Somewhere around 100k followers made sense for us, and the number of accounts above it was small enough to hold in memory on every reader.
Practical things that are not in the diagram. Timelines are capped, typically at 800 entries, since nobody scrolls further and unbounded lists are unbounded storage. Deletes are lazy, filtered at read time against a tombstone set, because chasing 30M copies of a deleted post is worse than checking on the way out. And the fanout job must be idempotent and resumable, because it will crash halfway through a large account and you need to restart from an offset rather than from the beginning.
If it were a ranked feed rather than reverse chronological, most of this stays, but the merge step becomes a scoring step, and the capped list becomes a candidate pool rather than the answer. The storage design does not change much. It is the read path that grows a model in the middle of it.
offset 015
What I read after the book
The second edition of DDIA is worth waiting for, but the surrounding material is what made the ideas stick.
- Kleppmann's own paper trail. The blog posts on online event processing and on transactions are shorter and sharper than the chapters.
- The Dynamo paper and the Spanner paper, read back to back. They answer the same question with opposite temperaments and the contrast is the lesson.
- The vLLM paper on paged attention, which is the clearest example I know of an operating systems idea moving into a new field basically intact.
- Jepsen's analyses. Reading someone break a database you use is more instructive than any amount of documentation about what it promises.
- Your own postmortems from two years ago. Genuinely. Half of them will now have a chapter number attached.
If you are reading DDIA for interviews, you will pass. If you are reading it to build things, the trick is to stop after each chapter and ask which system you already own does that badly. That question is what turned the third read into a useful one for me.