Choosing a database
How to choose a database by access pattern, consistency and scale. Relational, key-value, document, wide-column, NewSQL and when Postgres stops being enough.
The problem it solves
Section titled “The problem it solves”A chat app stores 40 billion messages and takes 700,000 new ones a second at peak. Almost every read is the same question: the latest 50 messages of one conversation. A second team wants a search box: every message containing “invoice” from last month.
Put both questions to two databases and the answers swap.
| Question | One PostgreSQL primary | A Cassandra cluster |
|---|---|---|
| Latest 50 of conversation 7 | One index range scan on (chat_id, seq), about 1 ms |
One partition, rows already sorted, about 1 ms |
| 700,000 inserts a second | About 70 times what one primary sustains | 2.1 million replica writes over about 200 nodes, and more nodes scale it about linearly |
| Messages containing “invoice” | A full-text index works, slowly at 40 billion rows | Impossible without scanning every partition on every node |
| Move a message between chats | One transaction | Two writes with no transaction between them |
Neither store is “better”. Each makes some questions cheap and others impossible. Choosing a database means listing the questions the system asks, how often and how fresh, and picking the store that makes the frequent ones cheap. A store picked because “NoSQL scales” makes some question that matters expensive, and moving billions of rows out later takes months.
How it works
Section titled “How it works”Step 1: Write down the access patterns
Section titled “Step 1: Write down the access patterns”Before naming a database, list every read and write the system does, with its rate and its consistency need. For a ticketing system it looks like this:
| Access pattern | Rate (peak) | Shape | Must be fresh? |
|---|---|---|---|
| Hold seats for a buyer | 2,000/s | Conditional update on several rows at once | Yes: two buyers, one seat |
| Show the seat map of an event | 50,000/s | All seats of one event | A few seconds stale is fine |
| Find events by name, city and date | 5,000/s | Full-text plus filters | Minutes stale is fine |
| Sales per event per hour for organizers | 10/s | Aggregate over millions of rows | Minutes stale is fine |
Four patterns, four different needs. The first needs a transaction across rows. The second is a hot read that a cache absorbs. The third is a search engine’s job. The fourth is an analytics query that should not run on the database taking bookings. The table is the input to every later step on this page.
Step 2: One relational database
Section titled “Step 2: One relational database”Start with what a relational database, such as PostgreSQL or MySQL, gives you on one machine. It answers more of that table than any other single store.
- Tables, and a B-tree per index. The primary key and every secondary index are sorted trees. So both a point lookup and a range (“seats of event 42”, “orders since Monday”) take a few page reads. A new question needs a new index, built online, not a new copy of the data.
- Joins and a query planner. You store each fact once and combine facts at read time. The planner picks the indexes, so a query you did not plan for still runs, if not always fast.
- Transactions. Several rows change together or not at all, and constraints (unique keys, foreign keys, checks) are enforced by the engine. “Hold seats 14 and 15 only if both are free” is one conditional update and a row count check inside one transaction.
- Isolation levels decide what concurrent transactions see of each other. Postgres defaults to
READ COMMITTED, which is enough for a conditional
UPDATE ... WHERE status = 'FREE'because the row lock serializes the two writers. Invariants that span rows nobody locks, such as “at least one doctor on call”, need SERIALIZABLE or an explicit lock. The consistency models page covers the anomalies.
Here’s a planning figure. One Postgres primary with NVMe disks sustains about 10,000 indexed write transactions a second. It serves tens of thousands to a few hundred thousand cached point reads a second, depending on cores. Treat these as orders of magnitude, not benchmarks. Small rows, few indexes and batched commits can reach several times that. Wide rows with many indexes reach less. Read replicas multiply reads, not writes. Ten billion rows and a few terabytes on one node are normal.
Go deeperIsolation levels in one table1 min
| Level | Prevents | Still allows | Default in |
|---|---|---|---|
| READ COMMITTED | Dirty reads | Lost updates from read-then-write, write skew | Postgres, Oracle, SQL Server |
| REPEATABLE READ (snapshot) | Non-repeatable reads, lost updates (Postgres aborts the second writer) | Write skew | MySQL InnoDB (with gap locks) |
| SERIALIZABLE | Every anomaly: the result equals some serial order | Nothing, but transactions abort and must retry | CockroachDB, Spanner |
MySQL’s REPEATABLE READ is not quite snapshot isolation. A plain SELECT reads the snapshot, but an
UPDATE reads the latest row. So read-then-write code can still lose updates. Postgres’s
SERIALIZABLE (SSI, since 9.1) runs at close to snapshot cost. It aborts a transaction when its reads
and writes could form a cycle with others. The check is conservative, so some aborts are false
positives. Callers must retry on serialization errors.
Step 3: Postgres until it hurts
Section titled “Step 3: Postgres until it hurts”The default for a new system is one relational primary, with a replica or two. It stays the default until a specific signal says otherwise. The signals are measurable:
- Write rate. Sustained writes above about half of what the primary can take, with WAL volume and replica lag growing at peak.
- Operating size. A backup restore, a replica rebuild, or a major version upgrade takes many hours. That usually happens somewhere past 5 to 10 TB on one node.
- Working set. The hot data no longer fits in RAM, and the buffer cache hit ratio falls below about 99%. So reads start waiting on disk.
- Geography. Users on several continents need low-latency writes, or the business needs zero data loss when a whole region fails.
- A different kind of question. Relevance-ranked text search, deep graph traversals, or scans over billions of rows for dashboards.
None of these is “we have a lot of rows” or “joins are slow”. A billion rows is routine, and a slow join is almost always a missing index. When a signal appears, climb one rung at a time:
- Color key
- Database
- Service
- Cache
The ladder is ordered by cost. An index costs minutes. A replica costs a machine and a little stale reading. Sharding costs a permanent tax on every query that does not carry the shard key, and every schema change runs N times. Each rung is worth it only when the cheaper ones are used up.
Step 4: Key-value stores
Section titled “Step 4: Key-value stores”Drop everything but “get and put by key” and a store can be very fast and very simple to scale. The key is hashed to a partition, so any number of machines can share the data, and no request touches more than one partition.
- Redis keeps everything in RAM, and executes commands on a single thread per shard. That’s about 100,000 to 200,000 simple commands a second per core without pipelining, and well over a million with it, at sub-millisecond latency. Its costs are RAM prices and durability. With the append-only file synced every second, a crash loses up to a second of writes. And asynchronous replication means a failover can drop writes the client saw succeed.
- DynamoDB is the durable, managed version. A partition key spreads items, a sort
key orders items inside one partition, so “orders of user 42, newest first” is one
Query. Reads and writes take single-digit milliseconds at any size. Each physical partition accepts about 1,000 writes and 3,000 reads a second, which is why a single hot key needs its own sharding scheme. Transactions cover up to 100 items at twice the cost.
The price is everything else. There are no joins. A query on any attribute other than the key needs a secondary index designed in advance, or a full scan. (In DynamoDB, global secondary indexes are always eventually consistent.)
Step 5: Document stores
Section titled “Step 5: Document stores”A document store such as MongoDB stores a whole aggregate, an order with its line items and address, as one JSON-like document. One read returns everything a screen needs, and changes to one document are atomic. Secondary indexes on any field, including fields inside arrays, make it far more queryable than a key-value store.
The modeling rule is: embed what you read together, and reference what grows without bound or is
shared. An order embeds its line items. A user does not embed every order they ever placed. MongoDB
caps a document at 16 MB, and rewriting a large document on every change is slow. Joins ($lookup)
exist, but cost more than in SQL. Multi-document transactions work, also across shards, at a higher
price than single-document writes.
Postgres’s jsonb columns with GIN indexes cover a lot of this ground, inside a relational database.
So “the data is JSON” alone is not a reason to add a document store. The reasons are operational:
built-in sharding, and a team that already runs it well.
Step 6: Wide-column stores
Section titled “Step 6: Wide-column stores”Cassandra, ScyllaDB, HBase and Bigtable store rows inside partitions, and the primary key has two parts that do different jobs:
- The partition key is hashed to pick the nodes that hold the partition. All rows with the same partition key live together on the same replicas.
- The clustering key sorts rows inside the partition on disk. A range over it is one sequential read.
- Color key
- Service
- Database
Writes go to a commit log and an in-memory table. That table is flushed to sorted files later (an LSM tree, covered on the indexes page). So a write never reads first, and never updates in place. That’s why a node takes roughly 10,000 to 30,000 writes a second, counting every replica write. And a cluster scales about linearly. Netflix measured 1.1 million client writes a second on 288 nodes in 2011. That’s about 11,000 replica writes per node, at replication factor 3, on the cloud instances of the time. Every datacenter can accept writes. Conflicts resolve per column by timestamp: last write wins.
The model is query-first. A table serves one query, so “messages by sender” is a second table that the application also writes to. Keep each partition under about 100 MB, or reads and compaction slow down. There are no transactions across partitions, and compare-and-set is a lightweight transaction, a Paxos round of about four round trips.
import java.nio.charset.StandardCharsets;import java.util.*;
// A wide-column table, messages_by_chat, reduced to its two ideas:// PRIMARY KEY ((chat_id), seq) WITH CLUSTERING ORDER BY (seq DESC)// The partition key picks the node. The clustering key sorts rows inside the partition.record Message(String chatId, long seq, String sender, String text) {}
class WideColumnTable { private final int nodes; // One sorted map per partition: this is what makes "latest 50" a single slice read. private final Map<String, TreeMap<Long, Message>> partitions = new HashMap<>(); int nodesTouched;
WideColumnTable(int nodes) { this.nodes = nodes; }
// Real stores use Murmur3 tokens on a ring. FNV-1a keeps the example deterministic // and the same in every language; the point is only that the key alone picks the node. int nodeFor(String partitionKey) { int h = 0x811c9dc5; for (byte b : partitionKey.getBytes(StandardCharsets.UTF_8)) { h ^= (b & 0xff); h *= 0x01000193; } return Integer.remainderUnsigned(h, nodes); }
// A write is an append into one partition on one node: no read first, no lock. void insert(Message m) { partitions.computeIfAbsent(m.chatId(), k -> new TreeMap<>()).put(m.seq(), m); }
// The query the table was designed for: names the partition key, reads a sorted slice. List<Message> latest(String chatId, int limit) { nodesTouched = 1; TreeMap<Long, Message> p = partitions.getOrDefault(chatId, new TreeMap<>()); return p.descendingMap().values().stream().limit(limit).toList(); }
// A query that does not name the partition key: every node must look at every partition. // This is why wide-column modeling starts from the queries, and adds a second table // (messages_by_sender) instead of a filter. List<Message> bySender(String sender) { nodesTouched = nodes; List<Message> out = new ArrayList<>(); for (TreeMap<Long, Message> p : partitions.values()) for (Message m : p.values()) if (m.sender().equals(sender)) out.add(m); return out; }}
class Main { public static void main(String[] args) { WideColumnTable table = new WideColumnTable(6); for (long seq = 1; seq <= 5; seq++) { table.insert(new Message("chat-7", seq, seq % 2 == 0 ? "ana" : "bo", "hello " + seq)); table.insert(new Message("chat-9", seq, "ana", "hi " + seq)); } System.out.println("chat-7 lives on node " + table.nodeFor("chat-7")); List<Message> last = table.latest("chat-7", 3); System.out.println("latest 3: " + last.stream().map(m -> m.seq()).toList() + ", nodes touched: " + table.nodesTouched); List<Message> ana = table.bySender("ana"); System.out.println("by sender ana: " + ana.size() + " rows, nodes touched: " + table.nodesTouched); }}from bisect import insortfrom dataclasses import dataclass, field
# A wide-column table, messages_by_chat, reduced to its two ideas:# PRIMARY KEY ((chat_id), seq) WITH CLUSTERING ORDER BY (seq DESC)# The partition key picks the node. The clustering key sorts rows inside the partition.
@dataclass(frozen=True, order=True)class Message: seq: int chat_id: str = field(compare=False) sender: str = field(compare=False) text: str = field(compare=False)
class WideColumnTable: def __init__(self, nodes: int) -> None: self.nodes = nodes # One sorted list per partition: this is what makes "latest 50" a single slice read. self.partitions: dict[str, list[Message]] = {} self.nodes_touched = 0
def node_for(self, partition_key: str) -> int: # Real stores use Murmur3 tokens on a ring. FNV-1a keeps the example deterministic # and the same in every language; the point is only that the key alone picks the node. h = 0x811C9DC5 for b in partition_key.encode("utf-8"): h ^= b h = (h * 0x01000193) & 0xFFFFFFFF return h % self.nodes
def insert(self, m: Message) -> None: # A write is an append into one partition on one node: no read first, no lock. insort(self.partitions.setdefault(m.chat_id, []), m)
def latest(self, chat_id: str, limit: int) -> list[Message]: # The query the table was designed for: names the partition key, reads a sorted slice. self.nodes_touched = 1 return list(reversed(self.partitions.get(chat_id, [])))[:limit]
def by_sender(self, sender: str) -> list[Message]: # A query that does not name the partition key: every node must look at every partition. # This is why wide-column modeling starts from the queries, and adds a second table # (messages_by_sender) instead of a filter. self.nodes_touched = self.nodes return [m for p in self.partitions.values() for m in p if m.sender == sender]
def main() -> None: table = WideColumnTable(6) for seq in range(1, 6): table.insert(Message(seq, "chat-7", "ana" if seq % 2 == 0 else "bo", f"hello {seq}")) table.insert(Message(seq, "chat-9", "ana", f"hi {seq}")) print(f"chat-7 lives on node {table.node_for('chat-7')}") last = table.latest("chat-7", 3) print(f"latest 3: {[m.seq for m in last]}, nodes touched: {table.nodes_touched}") ana = table.by_sender("ana") print(f"by sender ana: {len(ana)} rows, nodes touched: {table.nodes_touched}")
if __name__ == "__main__": main()using System;using System.Collections.Generic;using System.Linq;using System.Text;
// A wide-column table, messages_by_chat, reduced to its two ideas:// PRIMARY KEY ((chat_id), seq) WITH CLUSTERING ORDER BY (seq DESC)// The partition key picks the node. The clustering key sorts rows inside the partition.public sealed record Message(string ChatId, long Seq, string Sender, string Text);
public sealed class WideColumnTable{ private readonly int _nodes; // One sorted map per partition: this is what makes "latest 50" a single slice read. private readonly Dictionary<string, SortedDictionary<long, Message>> _partitions = new(); public int NodesTouched { get; private set; }
public WideColumnTable(int nodes) => _nodes = nodes;
// Real stores use Murmur3 tokens on a ring. FNV-1a keeps the example deterministic // and the same in every language; the point is only that the key alone picks the node. public int NodeFor(string partitionKey) { uint h = 0x811c9dc5; foreach (byte b in Encoding.UTF8.GetBytes(partitionKey)) { h ^= b; h = unchecked(h * 0x01000193); } return (int)(h % (uint)_nodes); }
// A write is an append into one partition on one node: no read first, no lock. public void Insert(Message m) { if (!_partitions.TryGetValue(m.ChatId, out var p)) _partitions[m.ChatId] = p = new SortedDictionary<long, Message>(); p[m.Seq] = m; }
// The query the table was designed for: names the partition key, reads a sorted slice. public List<Message> Latest(string chatId, int limit) { NodesTouched = 1; return _partitions.TryGetValue(chatId, out var p) ? p.Values.Reverse().Take(limit).ToList() : new List<Message>(); }
// A query that does not name the partition key: every node must look at every partition. // This is why wide-column modeling starts from the queries, and adds a second table // (messages_by_sender) instead of a filter. public List<Message> BySender(string sender) { NodesTouched = _nodes; return _partitions.Values.SelectMany(p => p.Values).Where(m => m.Sender == sender).ToList(); }}
public static class Program{ public static void Main() { var table = new WideColumnTable(6); for (long seq = 1; seq <= 5; seq++) { table.Insert(new Message("chat-7", seq, seq % 2 == 0 ? "ana" : "bo", $"hello {seq}")); table.Insert(new Message("chat-9", seq, "ana", $"hi {seq}")); } Console.WriteLine($"chat-7 lives on node {table.NodeFor("chat-7")}"); var last = table.Latest("chat-7", 3); Console.WriteLine($"latest 3: [{string.Join(", ", last.Select(m => m.Seq))}], nodes touched: {table.NodesTouched}"); var ana = table.BySender("ana"); Console.WriteLine($"by sender ana: {ana.Count} rows, nodes touched: {table.NodesTouched}"); }}package main
import ( "fmt" "sort")
// A wide-column table, messages_by_chat, reduced to its two ideas:// PRIMARY KEY ((chat_id), seq) WITH CLUSTERING ORDER BY (seq DESC)// The partition key picks the node. The clustering key sorts rows inside the partition.
type Message struct { ChatID string Seq int64 Sender string Text string}
type WideColumnTable struct { nodes int // One sorted slice per partition: this is what makes "latest 50" a single slice read. partitions map[string][]Message NodesTouched int}
func NewWideColumnTable(nodes int) *WideColumnTable { return &WideColumnTable{nodes: nodes, partitions: map[string][]Message{}}}
// Real stores use Murmur3 tokens on a ring. FNV-1a keeps the example deterministic// and the same in every language; the point is only that the key alone picks the node.func (t *WideColumnTable) NodeFor(partitionKey string) int { h := uint32(0x811c9dc5) for _, b := range []byte(partitionKey) { h ^= uint32(b) h *= 0x01000193 } return int(h % uint32(t.nodes))}
// A write is an append into one partition on one node: no read first, no lock.func (t *WideColumnTable) Insert(m Message) { p := t.partitions[m.ChatID] i := sort.Search(len(p), func(i int) bool { return p[i].Seq >= m.Seq }) p = append(p, Message{}) copy(p[i+1:], p[i:]) p[i] = m t.partitions[m.ChatID] = p}
// The query the table was designed for: names the partition key, reads a sorted slice.func (t *WideColumnTable) Latest(chatID string, limit int) []Message { t.NodesTouched = 1 p := t.partitions[chatID] out := []Message{} for i := len(p) - 1; i >= 0 && len(out) < limit; i-- { out = append(out, p[i]) } return out}
// A query that does not name the partition key: every node must look at every partition.// This is why wide-column modeling starts from the queries, and adds a second table// (messages_by_sender) instead of a filter.func (t *WideColumnTable) BySender(sender string) []Message { t.NodesTouched = t.nodes out := []Message{} for _, p := range t.partitions { for _, m := range p { if m.Sender == sender { out = append(out, m) } } } return out}
func main() { table := NewWideColumnTable(6) for seq := int64(1); seq <= 5; seq++ { sender := "bo" if seq%2 == 0 { sender = "ana" } table.Insert(Message{"chat-7", seq, sender, fmt.Sprintf("hello %d", seq)}) table.Insert(Message{"chat-9", seq, "ana", fmt.Sprintf("hi %d", seq)}) } fmt.Printf("chat-7 lives on node %d\n", table.NodeFor("chat-7")) seqs := []int64{} for _, m := range table.Latest("chat-7", 3) { seqs = append(seqs, m.Seq) } fmt.Printf("latest 3: %v, nodes touched: %d\n", seqs, table.NodesTouched) ana := table.BySender("ana") fmt.Printf("by sender ana: %d rows, nodes touched: %d\n", len(ana), table.NodesTouched)}// A wide-column table, messages_by_chat, reduced to its two ideas:// PRIMARY KEY ((chat_id), seq) WITH CLUSTERING ORDER BY (seq DESC)// The partition key picks the node. The clustering key sorts rows inside the partition.
interface Message { chatId: string; seq: number; sender: string; text: string;}
class WideColumnTable { // One sorted array per partition: this is what makes "latest 50" a single slice read. private partitions = new Map<string, Message[]>(); nodesTouched = 0;
constructor(private readonly nodes: number) {}
// Real stores use Murmur3 tokens on a ring. FNV-1a keeps the example deterministic // and the same in every language; the point is only that the key alone picks the node. nodeFor(partitionKey: string): number { let h = 0x811c9dc5; for (const b of new TextEncoder().encode(partitionKey)) { h ^= b; h = Math.imul(h, 0x01000193) >>> 0; } return h % this.nodes; }
// A write is an append into one partition on one node: no read first, no lock. insert(m: Message): void { const p = this.partitions.get(m.chatId) ?? []; const i = p.findIndex((x) => x.seq >= m.seq); p.splice(i === -1 ? p.length : i, 0, m); this.partitions.set(m.chatId, p); }
// The query the table was designed for: names the partition key, reads a sorted slice. latest(chatId: string, limit: number): Message[] { this.nodesTouched = 1; return [...(this.partitions.get(chatId) ?? [])].reverse().slice(0, limit); }
// A query that does not name the partition key: every node must look at every partition. // This is why wide-column modeling starts from the queries, and adds a second table // (messages_by_sender) instead of a filter. bySender(sender: string): Message[] { this.nodesTouched = this.nodes; return [...this.partitions.values()].flat().filter((m) => m.sender === sender); }}
const table = new WideColumnTable(6);for (let seq = 1; seq <= 5; seq++) { table.insert({ chatId: 'chat-7', seq, sender: seq % 2 === 0 ? 'ana' : 'bo', text: `hello ${seq}` }); table.insert({ chatId: 'chat-9', seq, sender: 'ana', text: `hi ${seq}` });}console.log(`chat-7 lives on node ${table.nodeFor('chat-7')}`);const last = table.latest('chat-7', 3);console.log(`latest 3: [${last.map((m) => m.seq).join(', ')}], nodes touched: ${table.nodesTouched}`);const ana = table.bySender('ana');console.log(`by sender ana: ${ana.length} rows, nodes touched: ${table.nodesTouched}`);Step 7: Specialized stores beside the primary
Section titled “Step 7: Specialized stores beside the primary”Three kinds of question need data structures a general store does not keep. Each specialized store usually runs beside the main database, fed from it, not instead of it.
- Search engines (Elasticsearch, OpenSearch) keep an inverted index, from each term to the documents that contain it. They have analyzers for stemming and typos, and BM25 relevance scoring. A write becomes searchable after the next refresh, 1 second by default. They are not a system of record. They have no transactions, and the index is rebuilt from the source when the mapping changes. The search page goes deeper.
- Time-series and columnar stores (Prometheus, ClickHouse, Apache Pinot, TimescaleDB) store each column separately, sorted by series and time. They compress hard. Facebook’s Gorilla encoding averages about 1.4 bytes per sample. A query that sums one column over a billion rows reads only that column. And a server scans billions of values a second. Point lookups and updates are slow, and inserts want batches.
- Graph databases (Neo4j, Amazon Neptune) store each node with direct pointers to its edges. So a hop follows a pointer, instead of an index lookup. A friends-of-friends-of-friends query costs the edges it visits, not the size of the graph. Most deployments keep the whole graph on every server, and one leader takes every write.
Many large “graphs” are not served by graph databases. Facebook’s social graph lives in sharded MySQL, behind its TAO cache. That’s because almost every query is one hop from a known node: “friends of user 42”, “likes on post 9”. A graph database earns its place when queries walk three or more hops.
Step 8: Distributed SQL (NewSQL)
Section titled “Step 8: Distributed SQL (NewSQL)”Google Spanner, CockroachDB and YugabyteDB keep the relational model and its transactions, while spreading data over many machines. Rows are sorted by primary key, and split into ranges (512 MiB by default in CockroachDB). Each range is a consensus group of three or five replicas: Paxos in Spanner, Raft in CockroachDB. Transactions that touch several ranges commit with two-phase commit across those groups.
Spanner adds TrueTime, a clock API with bounded uncertainty. It waits out that uncertainty (a few milliseconds) before acknowledging a commit. That gives external consistency: if transaction A commits before B starts, B sees A.
What it costs:
- Latency. Every write is a consensus round. In one region, that is a few milliseconds. With replicas in several regions, every commit waits on a quorum of regions. That’s tens of milliseconds when they sit on one continent, and 100 ms or more when the quorum spans continents. Add the trip to the range’s leader, if the writer is far from it. Reads stay local only if they accept slight staleness (follower or stale reads), or the data is pinned near its readers.
- Money. More machines for the same load, or a managed price that starts above a single primary’s.
- Hot ranges. A monotonically increasing key (a timestamp, a sequence) sends every insert to the last range. Hash or reverse the key, as the unique ID generator discusses.
- Cross-node joins cost network hops that a single machine never paid.
It wins when a system needs transactions that span shards, or must survive the loss of a region with zero data loss. And when the team does not want to build sharding itself.
Step 9: Several stores, one truth
Section titled “Step 9: Several stores, one truth”Real systems end up with several stores: Postgres for orders, Redis for sessions, Elasticsearch for search, a columnar store for analytics. This is polyglot persistence, and its cost is consistency. Each extra store holds a copy, and copies drift unless one store is the source of truth and the others are derived from it.
The tempting design is a dual write: the service writes Postgres, then writes Elasticsearch. Now say the process dies between the two. Or the second write times out. Or two updates to the same order reach the index in the opposite order. The stores disagree, and nothing notices. A distributed transaction across the two is not on offer, because Elasticsearch has no prepare phase.
The fix is to write once. There are two ways:
- The service writes the event into an outbox table, in the same transaction as the change. A relay publishes it.
- A change data capture (CDC) tool, such as Debezium, reads the database’s own log (the Postgres WAL, the MySQL binlog). It publishes every committed change.
Either way, the derived store receives every change at least once: late, not never. It applies each change only if it is newer than what it holds.
- Color key
- Database
- Queue or stream
- Service
import java.util.*;
// The transactional outbox: how a second store (a search index here) follows the source of// truth without a dual write. The order row and the event that describes it commit together.record OutboxEvent(long id, String orderId, long version, String status) {}
record Order(String id, long version, String status) {}
class Database { private final Map<String, Order> orders = new HashMap<>(); private final List<OutboxEvent> outbox = new ArrayList<>(); private final Set<Long> published = new HashSet<>(); private long nextEventId = 1;
// One transaction: the row change and its event are both kept or both lost. Writing to // the index here instead would be a dual write: a crash between the two leaves them apart. synchronized void setStatus(String orderId, String status) { Order old = orders.get(orderId); long version = old == null ? 1 : old.version() + 1; orders.put(orderId, new Order(orderId, version, status)); outbox.add(new OutboxEvent(nextEventId++, orderId, version, status)); }
// Oldest first, so one order's events leave in the order they were written. synchronized List<OutboxEvent> unpublished() { return outbox.stream().filter(e -> !published.contains(e.id())).toList(); }
synchronized void markPublished(long eventId) { published.add(eventId); }}
class SearchIndex { final Map<String, Order> docs = new HashMap<>(); int applied, ignored;
// Idempotent and order-safe: a document only moves to a higher version, so a redelivered // or late event changes nothing. The relay may repeat itself; the index does not care. void apply(OutboxEvent e) { Order cur = docs.get(e.orderId()); if (cur != null && cur.version() >= e.version()) { ignored++; return; } docs.put(e.orderId(), new Order(e.orderId(), e.version(), e.status())); applied++; }}
class Relay { // At least once: publish, then mark. A crash between the two means the event goes out // again after restart. Late or repeated, never lost. void runOnce(Database db, SearchIndex index, boolean crashBeforeMark) { for (OutboxEvent e : db.unpublished()) { index.apply(e); if (crashBeforeMark) return; // the process dies here db.markPublished(e.id()); } }}
class Main { public static void main(String[] args) { Database db = new Database(); SearchIndex index = new SearchIndex(); Relay relay = new Relay(); db.setStatus("o-1", "PLACED"); db.setStatus("o-1", "PAID"); db.setStatus("o-2", "PLACED");
relay.runOnce(db, index, true); // publishes event 1, crashes before marking it relay.runOnce(db, index, false); // restart: event 1 again, then 2 and 3
System.out.println("index: " + index.docs.get("o-1") + ", " + index.docs.get("o-2")); System.out.println("applied " + index.applied + ", ignored " + index.ignored + ", left in outbox " + db.unpublished().size()); }}from dataclasses import dataclassfrom threading import Lock
# The transactional outbox: how a second store (a search index here) follows the source of# truth without a dual write. The order row and the event that describes it commit together.
@dataclass(frozen=True)class OutboxEvent: id: int order_id: str version: int status: str
@dataclass(frozen=True)class Order: id: str version: int status: str
class Database: def __init__(self) -> None: self.orders: dict[str, Order] = {} self.outbox: list[OutboxEvent] = [] self.published: set[int] = set() self.next_event_id = 1 self.lock = Lock()
def set_status(self, order_id: str, status: str) -> None: # One transaction: the row change and its event are both kept or both lost. Writing to # the index here instead would be a dual write: a crash between the two leaves them apart. with self.lock: old = self.orders.get(order_id) version = 1 if old is None else old.version + 1 self.orders[order_id] = Order(order_id, version, status) self.outbox.append(OutboxEvent(self.next_event_id, order_id, version, status)) self.next_event_id += 1
def unpublished(self) -> list[OutboxEvent]: # Oldest first, so one order's events leave in the order they were written. with self.lock: return [e for e in self.outbox if e.id not in self.published]
def mark_published(self, event_id: int) -> None: with self.lock: self.published.add(event_id)
class SearchIndex: def __init__(self) -> None: self.docs: dict[str, Order] = {} self.applied = 0 self.ignored = 0
def apply(self, e: OutboxEvent) -> None: # Idempotent and order-safe: a document only moves to a higher version, so a redelivered # or late event changes nothing. The relay may repeat itself; the index does not care. cur = self.docs.get(e.order_id) if cur is not None and cur.version >= e.version: self.ignored += 1 return self.docs[e.order_id] = Order(e.order_id, e.version, e.status) self.applied += 1
class Relay: def run_once(self, db: Database, index: SearchIndex, crash_before_mark: bool) -> None: # At least once: publish, then mark. A crash between the two means the event goes out # again after restart. Late or repeated, never lost. for e in db.unpublished(): index.apply(e) if crash_before_mark: return # the process dies here db.mark_published(e.id)
def main() -> None: db, index, relay = Database(), SearchIndex(), Relay() db.set_status("o-1", "PLACED") db.set_status("o-1", "PAID") db.set_status("o-2", "PLACED")
relay.run_once(db, index, True) # publishes event 1, crashes before marking it relay.run_once(db, index, False) # restart: event 1 again, then 2 and 3
print(f"index: {index.docs['o-1']}, {index.docs['o-2']}") print(f"applied {index.applied}, ignored {index.ignored}, left in outbox {len(db.unpublished())}")
if __name__ == "__main__": main()using System;using System.Collections.Generic;using System.Linq;
// The transactional outbox: how a second store (a search index here) follows the source of// truth without a dual write. The order row and the event that describes it commit together.public sealed record OutboxEvent(long Id, string OrderId, long Version, string Status);
public sealed record Order(string Id, long Version, string Status);
public sealed class Database{ private readonly Dictionary<string, Order> _orders = new(); private readonly List<OutboxEvent> _outbox = new(); private readonly HashSet<long> _published = new(); private readonly object _tx = new(); private long _nextEventId = 1;
// One transaction: the row change and its event are both kept or both lost. Writing to // the index here instead would be a dual write: a crash between the two leaves them apart. public void SetStatus(string orderId, string status) { lock (_tx) { long version = _orders.TryGetValue(orderId, out var old) ? old.Version + 1 : 1; _orders[orderId] = new Order(orderId, version, status); _outbox.Add(new OutboxEvent(_nextEventId++, orderId, version, status)); } }
// Oldest first, so one order's events leave in the order they were written. public List<OutboxEvent> Unpublished() { lock (_tx) return _outbox.Where(e => !_published.Contains(e.Id)).ToList(); }
public void MarkPublished(long eventId) { lock (_tx) _published.Add(eventId); }}
public sealed class SearchIndex{ public Dictionary<string, Order> Docs { get; } = new(); public int Applied { get; private set; } public int Ignored { get; private set; }
// Idempotent and order-safe: a document only moves to a higher version, so a redelivered // or late event changes nothing. The relay may repeat itself; the index does not care. public void Apply(OutboxEvent e) { if (Docs.TryGetValue(e.OrderId, out var cur) && cur.Version >= e.Version) { Ignored++; return; } Docs[e.OrderId] = new Order(e.OrderId, e.Version, e.Status); Applied++; }}
public sealed class Relay{ // At least once: publish, then mark. A crash between the two means the event goes out // again after restart. Late or repeated, never lost. public void RunOnce(Database db, SearchIndex index, bool crashBeforeMark) { foreach (var e in db.Unpublished()) { index.Apply(e); if (crashBeforeMark) return; // the process dies here db.MarkPublished(e.Id); } }}
public static class Program{ public static void Main() { var db = new Database(); var index = new SearchIndex(); var relay = new Relay(); db.SetStatus("o-1", "PLACED"); db.SetStatus("o-1", "PAID"); db.SetStatus("o-2", "PLACED");
relay.RunOnce(db, index, true); // publishes event 1, crashes before marking it relay.RunOnce(db, index, false); // restart: event 1 again, then 2 and 3
Console.WriteLine($"index: {index.Docs["o-1"]}, {index.Docs["o-2"]}"); Console.WriteLine($"applied {index.Applied}, ignored {index.Ignored}, left in outbox {db.Unpublished().Count}"); }}package main
import ( "fmt" "sync")
// The transactional outbox: how a second store (a search index here) follows the source of// truth without a dual write. The order row and the event that describes it commit together.
type OutboxEvent struct { ID int64 OrderID string Version int64 Status string}
type Order struct { ID string Version int64 Status string}
type Database struct { mu sync.Mutex orders map[string]Order outbox []OutboxEvent published map[int64]bool nextEventID int64}
func NewDatabase() *Database { return &Database{orders: map[string]Order{}, published: map[int64]bool{}, nextEventID: 1}}
// One transaction: the row change and its event are both kept or both lost. Writing to// the index here instead would be a dual write: a crash between the two leaves them apart.func (db *Database) SetStatus(orderID, status string) { db.mu.Lock() defer db.mu.Unlock() version := db.orders[orderID].Version + 1 // zero value for a new order gives version 1 db.orders[orderID] = Order{orderID, version, status} db.outbox = append(db.outbox, OutboxEvent{db.nextEventID, orderID, version, status}) db.nextEventID++}
// Oldest first, so one order's events leave in the order they were written.func (db *Database) Unpublished() []OutboxEvent { db.mu.Lock() defer db.mu.Unlock() out := []OutboxEvent{} for _, e := range db.outbox { if !db.published[e.ID] { out = append(out, e) } } return out}
func (db *Database) MarkPublished(eventID int64) { db.mu.Lock() defer db.mu.Unlock() db.published[eventID] = true}
type SearchIndex struct { Docs map[string]Order Applied, Ignored int}
// Idempotent and order-safe: a document only moves to a higher version, so a redelivered// or late event changes nothing. The relay may repeat itself; the index does not care.func (ix *SearchIndex) Apply(e OutboxEvent) { if cur, ok := ix.Docs[e.OrderID]; ok && cur.Version >= e.Version { ix.Ignored++ return } ix.Docs[e.OrderID] = Order{e.OrderID, e.Version, e.Status} ix.Applied++}
type Relay struct{}
// At least once: publish, then mark. A crash between the two means the event goes out// again after restart. Late or repeated, never lost.func (Relay) RunOnce(db *Database, ix *SearchIndex, crashBeforeMark bool) { for _, e := range db.Unpublished() { ix.Apply(e) if crashBeforeMark { return // the process dies here } db.MarkPublished(e.ID) }}
func main() { db := NewDatabase() ix := &SearchIndex{Docs: map[string]Order{}} relay := Relay{} db.SetStatus("o-1", "PLACED") db.SetStatus("o-1", "PAID") db.SetStatus("o-2", "PLACED")
relay.RunOnce(db, ix, true) // publishes event 1, crashes before marking it relay.RunOnce(db, ix, false) // restart: event 1 again, then 2 and 3
fmt.Printf("index: %+v, %+v\n", ix.Docs["o-1"], ix.Docs["o-2"]) fmt.Printf("applied %d, ignored %d, left in outbox %d\n", ix.Applied, ix.Ignored, len(db.Unpublished()))}// The transactional outbox: how a second store (a search index here) follows the source of// truth without a dual write. The order row and the event that describes it commit together.
interface OutboxEvent { id: number; orderId: string; version: number; status: string;}
interface Order { id: string; version: number; status: string;}
class Database { private orders = new Map<string, Order>(); private outbox: OutboxEvent[] = []; private published = new Set<number>(); private nextEventId = 1;
// One transaction: the row change and its event are both kept or both lost. Writing to // the index here instead would be a dual write: a crash between the two leaves them apart. // (Synchronous code on one event loop stands in for the database's transaction.) setStatus(orderId: string, status: string): void { const version = (this.orders.get(orderId)?.version ?? 0) + 1; this.orders.set(orderId, { id: orderId, version, status }); this.outbox.push({ id: this.nextEventId++, orderId, version, status }); }
// Oldest first, so one order's events leave in the order they were written. unpublished(): OutboxEvent[] { return this.outbox.filter((e) => !this.published.has(e.id)); }
markPublished(eventId: number): void { this.published.add(eventId); }}
class SearchIndex { readonly docs = new Map<string, Order>(); applied = 0; ignored = 0;
// Idempotent and order-safe: a document only moves to a higher version, so a redelivered // or late event changes nothing. The relay may repeat itself; the index does not care. apply(e: OutboxEvent): void { const cur = this.docs.get(e.orderId); if (cur && cur.version >= e.version) { this.ignored++; return; } this.docs.set(e.orderId, { id: e.orderId, version: e.version, status: e.status }); this.applied++; }}
class Relay { // At least once: publish, then mark. A crash between the two means the event goes out // again after restart. Late or repeated, never lost. runOnce(db: Database, index: SearchIndex, crashBeforeMark: boolean): void { for (const e of db.unpublished()) { index.apply(e); if (crashBeforeMark) return; // the process dies here db.markPublished(e.id); } }}
const db = new Database();const index = new SearchIndex();const relay = new Relay();db.setStatus('o-1', 'PLACED');db.setStatus('o-1', 'PAID');db.setStatus('o-2', 'PLACED');
relay.runOnce(db, index, true); // publishes event 1, crashes before marking itrelay.runOnce(db, index, false); // restart: event 1 again, then 2 and 3
console.log('index:', JSON.stringify(index.docs.get('o-1')), JSON.stringify(index.docs.get('o-2')));console.log(`applied ${index.applied}, ignored ${index.ignored}, left in outbox ${db.unpublished().length}`);The derived stores lag the source by the relay’s delay, typically under a second and minutes during an incident. Any read that must see the user’s own write goes to the source of truth.
Options and tradeoffs
Section titled “Options and tradeoffs”Pick a workload, or set the requirements yourself. Then open any row for its reasons. The summary picks the store with no blockers and the fewest costs, counting the cost of running it at all. So Postgres wins whenever nothing rules it out.
✓ fits! fits, at a cost✕ does not fitPick a row to see every reason.
The data model
Section titled “The data model”| Option | Shape | Wins when | Costs |
|---|---|---|---|
| Relational (Postgres, MySQL) | Normalized tables, joins, secondary indexes | Invariants across rows, questions that change, under one primary’s load | One primary for writes; sharding is yours to build |
| Key-value (DynamoDB, Redis) | Value by key, sometimes a sorted range | Every access names the key: sessions, carts, feature flags, counters | Each new query is a new index or table; Redis costs RAM and durability |
| Document (MongoDB) | One aggregate per document | Aggregates read and written whole, with filters on many fields | Cross-document invariants are expensive; schema drifts without validation |
| Wide-column (Cassandra, Bigtable) | Partitions of rows sorted by clustering key | Huge write rates, time-ordered data per key, writes in every region | One table per query, no transactions, last write wins |
Specialized stores
Section titled “Specialized stores”Each answers a different question, so the decision is whether one earns its pipeline.
Go deeperSearch, columnar and graph stores side by side1 min
| Option | Data structure | Wins when | Costs |
|---|---|---|---|
| Search engine (Elasticsearch, OpenSearch) | Inverted index, per-field doc values | Relevance-ranked text, facets, log search | Not a system of record; refresh lag; reindex on mapping changes |
| Columnar or time-series (ClickHouse, Pinot, Prometheus) | Compressed columns sorted by key and time | Aggregates over billions of rows, metrics, dashboards | Slow point lookups and updates; wants batched inserts |
| Graph (Neo4j, Neptune) | Nodes with direct edge pointers | Queries of three or more hops: fraud rings, recommendations | One writer, hard to shard, a separate skill set |
Scaling a relational database past one primary
Section titled “Scaling a relational database past one primary”| Option | How it scales writes | Wins when | Costs |
|---|---|---|---|
| Bigger machine, replicas, cache | It does not: it buys headroom | Under about 10,000 writes a second, which is most systems | One region for writes, failover takes seconds to a minute |
| Sharded Postgres or MySQL (Citus, Vitess, app-level) | Each shard has its own primary | One key owns almost every query: user, tenant, account | Cross-shard queries fan out, cross-shard transactions need 2PC, resharding is a project |
| Distributed SQL (Spanner, CockroachDB) | Ranges with their own consensus groups | Cross-shard transactions are common, or RPO zero across regions | Consensus latency on every write, higher price, hot ranges on monotonic keys |
| Move the hot pattern to NoSQL | Partitions spread across nodes | The hot pattern is a key-value or time-ordered append | That data loses joins and transactions; two stores to keep in step |
Keeping several stores in step
Section titled “Keeping several stores in step”| Option | How | Wins when | Costs |
|---|---|---|---|
| Dual write | The service writes each store in turn | Never, for data that must agree | A crash or timeout between writes leaves them apart, silently |
| Transactional outbox | An outbox row in the same transaction, published by a relay | You control the service and want explicit events | An extra insert per change; the relay is another process to run |
| Change data capture (Debezium) | A connector reads the WAL or binlog | Many consumers, or services you cannot change | Events mirror table rows, not business meaning; a stalled slot fills the primary’s disk |
How it fails
Section titled “How it fails”| Failure | What breaks | How you detect it | Blast radius | Fix |
|---|---|---|---|---|
| Hot partition | One key (a celebrity, a big tenant, today’s date) takes most writes; its partition throttles | Per-partition throttling, one node’s CPU far above the rest | Every key on that partition | Spread the key: add a suffix bucket, split the tenant, never partition by date alone |
| Scatter-gather query | A query without the partition key asks every node; one slow node sets the latency | p99 tracks the slowest node; query fan-out metrics | Every node, on every such query | A second table or index keyed for that query, or move it to a search or columnar store |
| Unbounded wide partition | One partition grows to gigabytes; reads time out, compaction stalls, repair fails | Partition size histograms, large-partition warnings in logs | The replicas holding it | Add a time bucket to the partition key, such as (chat_id, month) |
| Replica lag | A user saves, then reads from a replica and sees the old value | Replication lag in seconds, alerts at a threshold | Reads routed to lagging replicas | Read your own writes from the primary for a short window, or route by a version the client holds |
| Vacuum falls behind (Postgres) | Dead row versions bloat tables; in the worst case transaction IDs near wraparound and writes stop | Dead tuple counts, age of the oldest transaction ID | The whole database | Tune autovacuum per hot table, kill long-running transactions, monitor XID age well before 2 billion |
| Derived store drifts | A dual write failed halfway, or a consumer applied events out of order | Periodic count and checksum comparison against the source | Searches and dashboards show wrong data | Outbox or CDC with version checks; rebuild the index from the source |
Go deeperFour more failures, store by store1 min
| Failure | What breaks | How you detect it | Blast radius | Fix |
|---|---|---|---|---|
| Tombstone pile-up | Deletes in Cassandra write tombstones; a read scanning 100,000 of them fails | Tombstone warnings, failed reads on queue-like tables | Reads of that partition | Do not use a wide-column table as a queue; use TTLs with time-window compaction |
| Connection exhaustion | Each Postgres connection is a process using several MB; thousands of app threads overwhelm it | Connection count near max_connections, rising latency |
Every client of the database | A pooler such as PgBouncer in transaction mode; a few hundred server connections at most |
| Stalled CDC slot | A dead consumer keeps a Postgres replication slot; the primary keeps all WAL for it | Retained WAL size per slot | The primary’s disk, then all writes | Alert on retained WAL, cap it with max_slot_wal_keep_size, drop abandoned slots |
| Failover loses writes | An async replica is promoted before it received the last writes (Redis, async Postgres) | Gaps in sequence numbers after failover, client errors | Writes in the last second before the crash | Synchronous replication for data that must not be lost, or accept and reconcile |
The two failures that cause the most damage are the quiet ones. One is a derived store that slowly diverges. The other is a Postgres instance whose vacuum has been losing ground for weeks. Both are visible long before they hurt, on dashboards nobody looks at until the outage.
At scale
Section titled “At scale”Numbers per node. These are order-of-magnitude planning figures for current hardware, not benchmarks. They move by several times with row size, indexes, consistency level and configuration. So use them to decide between one node and fifty. Measure your own workload before sizing anything finer.
| Store | Writes per node | Reads per node | Latency | Practical size per node |
|---|---|---|---|---|
| Postgres or MySQL primary | About 10,000 indexed write transactions/s | Tens of thousands to a few hundred thousand cached point reads/s | Under 1 ms server side, cached | A few TB comfortably, 10+ TB with pain |
| Cassandra or ScyllaDB node | About 10,000 to 30,000/s (ScyllaDB claims several times more) | Similar to writes for single-partition reads | 1 to 5 ms | 1 to 2 TB per node is common |
| Redis shard | 100,000 to 200,000 simple commands/s, 1M+ pipelined | Same | Under 1 ms | Bound by RAM, tens of GB |
| DynamoDB partition | 1,000 write units/s | 3,000 read units/s | Single-digit ms | 10 GB per partition, table unbounded |
| Elasticsearch data node | Several thousand to tens of thousands of docs/s, bulk | Hundreds to thousands of searches/s | 10 to 100 ms | A few TB of index |
| ClickHouse server | Millions of rows/s, in batches | Billions of values scanned/s | 10 ms to seconds | Tens of TB compressed |
At 10 times the load, the relational default usually hits its first real limit. Writes reach tens of thousands a second, so the busiest table gets partitioned or the workload gets sharded by its main key. Search and analytics leave the primary for stores fed by CDC, and a cache absorbs the hottest reads. This is where the Instagram and payment designs on this site shard.
At 100 times, the questions change. Resharding has to happen online. So plan for many logical shards (thousands), mapped onto fewer physical machines. Then growth moves logical shards, instead of rehashing rows. Multi-region becomes a requirement. With it comes a choice of three:
- A home region per user (sharded relational).
- Last-write-wins in every region (wide-column).
- Consensus across regions (distributed SQL).
The partitioning and replication pages cover those mechanics.
What to monitor, in order of how often it pages someone:
- Replication lag and retained WAL per replication slot.
- p99 latency per query shape, not just the average, and the top queries by total time.
- Connections in use against the pool limit.
- For Postgres: dead tuples, autovacuum progress and the oldest transaction ID’s age. For Cassandra: pending compactions, tombstones per read and the largest partitions.
- Throttled requests per partition (DynamoDB) or per node, which is how hot keys show up.
- Disk headroom, with weeks of warning, not hours.
- Lag between the source and every derived store, checked end to end with a canary write.
In the interview
Section titled “In the interview”Interviewer asksWhich database would you use for this system, and why?
Answer from the access pattern table, not the product name. “Three of our four patterns are point lookups and conditional updates on bookings, at about 2,000 writes a second at peak. That fits one Postgres primary, which sustains about 10,000 indexed write transactions a second. And it gives us the transaction we need, to hold two seats at once. The search pattern goes to Elasticsearch, fed by CDC from Postgres, so the booking path never depends on it. Two things would change the answer. Writes past roughly 10,000 a second sustained mean sharding by event. And a requirement to accept bookings in several regions, with no data loss, means distributed SQL.” Then name what you give up with the choice.
Interviewer asksYour Postgres primary is at 85% CPU at peak. What do you do?
Find out what the CPU is doing, before changing architecture. Look at the top queries by total time. A missing index, or a sequential scan on a hot path, is the most common cause. It costs an hour to fix. If the load is reads, add replicas and a cache. If it is writes, batch them, and buy a bigger machine, which costs one failover. Only when sustained writes approach what one primary can do, shard by the key almost every query carries. Use many logical shards, so the next step is moving shards, not rehashing.
Interviewer asksHow do you keep the search index in sync with the database?
Never by writing both from the service. The order change and an outbox row commit in one transaction, or Debezium reads the WAL. Events go to Kafka, keyed by order id, so one order’s changes stay in order. The indexer applies an event only if its version is newer than the document’s. So duplicates and late events do nothing. Monitor end-to-end lag with a canary write. Compare counts and checksums nightly. Keep a full rebuild from the source as the recovery for anything else. Reads that must see the user’s own write go to Postgres.
Interviewer asksWhen would you choose Spanner or CockroachDB over sharded Postgres?
When transactions across shards are the normal case, not the exception. For example, transfers between any two accounts. Or when the business needs writes to survive losing a whole region, with zero data loss. Distributed SQL gives serializable transactions across shards and regions, without application code. The price is a consensus round on every write. That’s a few milliseconds in one region, tens of milliseconds across nearby regions, and 100 ms or more across continents. Add a higher bill, and hot ranges if keys increase monotonically. If one key, such as tenant or user, owns almost every transaction, sharded Postgres is cheaper and faster.
Seen in
Section titled “Seen in”- Payment system: Postgres, because the payment state, the ledger lines and the outbox row must commit in one transaction. That’s 3,600 transactions a second at peak, against one primary’s 10,000. It’s sharded by payment at ten times the load.
- Hotel reservation: Postgres books a stay with conditional updates on every night, in one transaction, sharded by hotel later. DynamoDB transactions are the managed alternative.
- Ticketmaster: Postgres holds seats with conditional updates that are safe under READ COMMITTED, and is sharded by event later.
- Job scheduler: Postgres with partial indexes and
SKIP LOCKED, partitioned from day one because 100,000 run writes a second at peak is ten primaries’ worth. - Notification system: Postgres for user preferences behind a cache, Cassandra for the delivery log that takes a write per status change.
- Chat: a wide-column store partitioned by conversation and clustered by sequence number, exactly the “latest 50” pattern from the top of this page.
- Ad click aggregator: Postgres for raw clicks until the rate passes one primary, then Apache Pinot, a columnar store, for the rollups.
- File sync: sharded MySQL or Postgres by namespace rather than a wide-column store, because moving a folder must be atomic.
- News feed: Cassandra for posts, sharded MySQL for the follow graph, as Facebook does.
- Instagram: Postgres sharded by user, 8,192 logical shards on 64 physical primaries.
- Metrics and monitoring: a purpose-built time-series database with blocks on object storage, after ruling out a wide-column store.
- Proximity service: Postgres as the source of truth, with the search index fed by CDC through Kafka.
- Video streaming: sharded relational metadata, and a wide-column store for counters and watch positions.
- Key-value store builds the inside of a Dynamo-style store: an LSM tree, consistent hashing and leaderless quorums.
The method. List the access patterns, with rate, shape and freshness. Size them against per-node numbers. Start with one relational primary. Leave it only for a named signal: write rate, operating size, working set, geography, or a different kind of question.
Numbers. Postgres primary: about 10,000 indexed write transactions a second, a few TB comfortably. Cassandra: 10,000 to 30,000 writes a second per node, partitions under 100 MB. Redis: 100,000 to 200,000 commands a second per core. DynamoDB: 1,000 writes a second per partition. Distributed SQL: a few ms per commit in one region, tens of ms across one continent, 100 ms or more across continents. All of these are orders of magnitude, not benchmarks.
The models. Relational for invariants and changing questions. Key-value when every access names the key. Document for aggregates read whole. Wide-column for huge, time-ordered writes per key. Search, columnar and graph stores beside the primary, fed by CDC. Distributed SQL when transactions cross shards or regions.
The traps. Dual writes, queries without the partition key, monotonic keys on range-sharded stores, unbounded partitions, vacuum and replication slots nobody watches.
Further reading
Section titled “Further reading”- On this site: indexes for B-trees and LSM trees, replication, partitioning and sharding, consistency models, CAP and PACELC, distributed transactions and search.
- Designing Data-Intensive Applications, Martin Kleppmann. Chapters 2, 3 and 7 cover data models, storage engines and transactions, in more depth than any other single source.
- Transaction isolation, PostgreSQL documentation: exactly what each level prevents in Postgres.
- Pattern: Transactional outbox, microservices.io: the outbox and the ways to relay it.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.