all_lessons / system_design / cases / C37 C37 / C44

Design a stock exchange

An exchange is a correctness-first, latency-sensitive system whose whole architecture bends around one decision: make the matching core a single deterministic writer, and arrange everything else to feed it and read from it without ever getting in its way.

Source: Vol. 2 Ch. 13, archive Anchor: exchange overview + order book Trade-off first
First principle
An exchange has to satisfy two demands that pull in opposite directions: it must be fair and auditable to the cent (every match decided by a rule a regulator can replay), and it must be fast (matching in microseconds so a quote is still meaningful when it fills). The forcing observation is that order matching for a single symbol is inherently serial — whether order A or order B trades first is the product, and you cannot decide it concurrently without inventing a tiebreaker that is itself a serialization. So the dominant design move is not "scale the matcher" but the opposite: shrink it to one thread per symbol, give it a single ordered input stream, and make it a deterministic state machine. Everything else in this case — sequencing, persistence, risk, market data — exists to protect that property.

This is the anchor case for the exchange arc. Here we build the whole pipeline and the order book. Two satellites zoom in: C39 (match buy and sell orders) opens the matching kernel itself — price-time priority, partial fills, cancel/replace — and C38 (microsecond-latency architecture) opens the latency budget — kernel bypass, busy-spin, NUMA pinning. We introduce the pipeline; they go deep. Cross-link, don't duplicate.

1. Clarify the contract

Treat the prompt as a product contract before drawing boxes. A central-limit-order-book (CLOB) exchange must:

And, just as importantly, what it need not do, so the core stays small: it is not a custody/settlement system (T+1 settlement, clearing, and ledger live downstream — see C33's double-entry ledger); it is not where you do analytics or surveillance (those consume the published log offline); and it does not need horizontal scaling within a symbol. Keeping these out of the matching loop is what keeps the loop fast.

2. Put numbers on the shape

The numbers drive the architecture, so derive them rather than asserting them.

Per-symbol throughput. A well-written single-threaded matching loop touches only in-memory data: a couple of price-level lookups, a queue pop, an arithmetic fill. That is on the order of ~1 μs of CPU work per order, giving a ceiling near 1 / 1\,\mu s = 10^61M orders/s for one symbol on one core. Real venues run lower (more bookkeeping, risk, logging) but the point stands: one core is more than enough for even the busiest single symbol, whose peak is tens of thousands of messages/s. The scarce resource is not throughput — it is latency and determinism. That is why you spend the core on being predictable, not on being parallel.

Scaling across symbols. With ~10,000 listed symbols and one logical matcher per symbol, you do not need 10,000 cores — you pin many symbols' books to a core and shard cores across a handful of matching servers. Symbols are independent (an AAPL order never touches the TSLA book), so this is embarrassingly parallel between books and strictly serial within one.

The journal latency delta — the load-bearing number. Every input order must be journaled before it is considered accepted (that is what makes replay possible, below). You can do this two ways, and the gap is enormous:

Synchronous fsync per order
~1 ms
Buffered append (group commit)
~10 μs
Latency ratio
~100×
Matching loop budget
~1 μs/order

An fsync() that forces the write to stable storage costs on the order of 1 ms — a spinning disk seek, or a flush to an SSD/NVMe and back. Your entire matching loop is ~1 μs. So a naive "fsync every order" makes durability 1000\times more expensive than matching — the journal becomes the whole latency budget. A buffered, batched append (write to an OS page-cache-backed log and group-commit many orders per fsync) brings the per-order amortized cost to ~10 μs, a ~100× win. The price you pay is a recovery window: orders accepted but not yet flushed can be lost on a sudden power cut. The whole sync-vs-async persistence trade-off below is exactly this number — you are buying ~100× latency by accepting a sub-millisecond loss window, and you close that window with replication (a second node acks the input) rather than with the disk.

3. Define the surface area

Keep the API small; it expresses operations, not internals.

API / operationWhy it exists
submit(client_order_id, symbol, side, price, qty, type)Place a limit/market order. client_order_id is the idempotency key (C35 mechanics) so a retried submit never double-places.
cancel(client_order_id)Remove a resting order. Also serialized through the same input stream as a submit.
subscribe(symbol, depth)Join the market-data feed — top of book, or N levels of depth, or a full event stream.
trade_report (push)Per-participant fill notification: which of my orders traded, at what price and quantity.

4. Model the data — the order book

The data model is the architecture. The central object is the order book for one symbol: two sorted collections of price levels (bids, descending; asks, ascending), where each level is a FIFO queue of resting orders. Price-time priority falls straight out of this shape: the best price is the head of the sorted side, and within a level the oldest order is the head of the queue.

ORDER BOOK — symbol AAPL (price-time priority = sorted levels, FIFO within a level) ASKS (sell) ─ ascending, best = lowest each price level is a FIFO queue ─────────────────────────────────────── ─────────────────────────────── ▲ 150.04 │ 300 head ──▶ [o7:100][o9:200] ──▶ tail | 150.03 │ 500 (oldest) (newest) | 150.02 │ 200 ◀── best ask (lowest sell) an incoming BUY market order ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ spread ─ ─ ─ ─ ─ ─ ─ ─ lifts the best ask, popping the | 150.01 │ 400 ◀── best bid (highest buy) FIFO head first (o7 before o9) | 150.00 │ 900 ▼ 149.99 │ 250 BIDS (buy) ─ descending, best = highest ─────────────────────────────────────── best bid 150.01 / best ask 150.02 → spread = 0.01, mid = 150.015
orderprice level (sorted)FIFO queue per leveltradesequence numberrisk state

A new buy limit order at 150.02 would cross the spread, match against the resting 200 at the best ask, and either fill or rest as the new best bid. The matcher's inner loop — exactly how it walks levels, splits partial fills, and handles cancel/replace — is the subject of C39; here we care that the book is an in-memory, single-owner structure.

5. Linearized design — follow one order through the pipeline

Walk the order in the order it actually moves. The shape to internalize is a funnel into a single writer, then a fan-out: gateway → risk check → sequencer → matcher → market-data fanout.

participants ┌─ pre-trade ─┐ ┌── single ───┐ ┌─ SINGLE WRITER ─┐ ─────────── │ RISK │ │ SEQUENCER │ │ MATCHING CORE │ trader ─┐ ┌────────┐ │ check limit │ │ assign seq# │ │ in-mem book │ ┌── MARKET DATA ──┐ trader ─┼─▶ │GATEWAY │─▶│ exposure, │─▶ │ → SEQUENCED │─▶ │ match by │─▶ │ FANOUT │ algo ──┘ │ auth, │ │ fat-finger │ │ INPUT LOG │ │ price-time │ │ top-of-book, │─▶ public feed │ normalize│ └─────────────┘ │ (journal, │ │ deterministic │ │ depth, trades │─▶ per-party fills └────────┘ │ replicated)│ └────────┬────────┘ └─────────────────┘ └─────────────┘ │ ▲ the total order └─▶ fills + book deltas (the ONLY outputs, every downstream derives from it themselves derived from the log)
  1. 1. Gateway. Authenticate the session, validate and normalize the message (symbol exists, price tick valid, quantity sane), and stamp the client_order_id for idempotency. This is the only place that talks to untrusted clients; the core never does.
  2. 2. Pre-trade risk. Check the participant's limits — buying power, position/exposure caps, fat-finger price bands — before the order is allowed to be sequenced. (Why before, not after, is a deep dive.)
  3. 3. Sequencer. Assign every accepted order a strict, gap-free sequence number and append it to the sequenced input log. This single ordered stream is the heart of the system: it is the total order of everything that ever happened.
  4. 4. Matching core (single writer). One thread per symbol consumes the sequenced log in order and applies each input to its in-memory book deterministically. It is a pure function of (current book state, next input) → (new book state, output events). No clocks, no random, no shared mutable state.
  5. 5. Market-data fanout. The matcher's outputs — fills and book deltas — are published to the public feed and to per-participant fill reports. Crucially this is a separate path that reads the output stream; it never reaches back into the matcher.

The first bottleneck is now visible and intentional: everything funnels through one writer per symbol. That is not a flaw to fix; it is the property that makes the exchange correct and replayable. The job is to keep that writer's loop tiny.

6. Deep dives

6.1 Single-thread book vs distributed book — why exchanges choose determinism

The tempting "scalable" design is to shard one symbol's book across machines and coordinate with locks or a consensus protocol per order. It is the wrong instinct here, and seeing why is the senior signal. Two costs sink it:

So the design inverts: one thread, no locks, no contention — the book lives in L1/L2 cache and the loop is a tight, branch-predictable hot path. The "throughput ceiling" cost (you cannot exceed one core's matching rate per symbol) is a non-issue because one core already exceeds any single symbol's real volume by 1–2 orders of magnitude. This is single-leader replication (DDIA Ch. 5) applied to matching: one writer owns the data so that ordering is unambiguous, and you scale across leaders (symbols), never within one. The microsecond-level techniques that make this single thread actually hit its budget — busy-spin instead of blocking, kernel bypass, pinning the thread to an isolated core — are C38.

6.2 The sequenced log: total-order broadcast → deterministic replay = state-machine replication

This is the conceptual core. The sequencer turns a chaotic set of concurrent submits and cancels into a single linear sequence — order #1, #2, #3, … — that every downstream component sees identically. That is precisely total order broadcast (DDIA Ch. 9): a message-delivery primitive where all consumers see all messages in the same order. The matcher is then a deterministic state machine, and feeding the same ordered inputs to a copy of that state machine reproduces the same state exactly. That equivalence — total order broadcast + deterministic state machine = state-machine replication — is what buys you three things at once:

DDIA Ch. 11 makes this an explicit example: log-based stream processing where the log is the system of record and all views are derived from it. The matcher's in-memory book is just a materialized view of the log; the log, not the book, is the durable truth. Note the ordering point is also where durability lives — you journal at the sequencer, before matching, so the recorded input order is the authority. The persistence trade-off from §2 lives right here.

SEQUENCED INPUT LOG (the system of record — append-only, totally ordered) ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐ │ #1 │ #2 │ #3 │ #4 │ #5 │ #6 │ #7 │ #8 │ #9 │.. │ ← sequencer assigns gap-free seq# └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘ │ │ │ replay from checkpoint │ tail in real time ▼ ▼ ┌──────────────┐ ┌──────────────┐ deterministic ⇒ both books │ RECOVER book │ ==同== │ STANDBY book │ ◀── identical at every seq# │ (after crash)│ │ (hot backup) │ (compare via state checksum) └──────────────┘ └──────────────┘

6.3 Market-data fanout — read-side scaling without touching the writer

Order ingress is modest (thousands/s per symbol); market-data egress can be orders of magnitude larger — every price change multiplied by every subscriber (market makers, brokers, retail apps, surveillance). If the matcher had to fan that out itself, the slowest subscriber would back-pressure the hot path. So fanout is a strictly separate stage that consumes the matcher's output stream:

7. Trade-offs

ChoiceBuysCostsChoose when
Single-thread book vs distributed bookdeterminism + ~1 μs matches, replayabilityper-symbol throughput ceiling (one core)essentially always for a CLOB core
Sync fsync vs buffered/group-commit journalsync: zero loss windowsync: ~1 ms/order, ~100× the matching costbuffered + replication for latency; sync only if no replica
Full market data vs conflated feedfull: every tick, exactfull: high fanout bandwidthfull for pro/algo feeds, conflated for retail
Strict price-time fairness vs throughput batchingauditable fairnessless batching efficiencyCLOB; batching suits periodic-auction venues

The sharpest one is sync vs async persistence, and the senior framing is to refuse the false choice. Synchronous fsync (~1 ms) makes durability dominate the entire latency budget; pure async risks losing accepted orders on a crash, which is unacceptable for a regulated venue. The resolution is to move durability off the disk's critical path and onto the network: a buffered, group-committed local journal (~10 μs amortized) plus synchronous replication of each sequenced input to a hot standby. An order is "accepted" once a second node has it in memory — replication latency (microseconds on a colo LAN) replaces fsync latency (milliseconds), and the loss window closes because two independent nodes would have to fail simultaneously. You bought ~100× latency without giving up durability.

8. Failure modes

FailureMitigation
Matching engine crashes mid-sessionPromote the hot standby (already tailing the sequenced log) for instant failover; rebuild any cold replica by replaying the log from the last checkpoint and verifying with state checksums (see Q&A).
Duplicate order submit (client retry)Gateway dedups on client_order_id (C35 idempotency mechanics) before sequencing — the matcher only ever sees each order once.
Market-data lag / slow subscriberFanout is a separate path from matching, so it cannot back-pressure the core; subscribers detect gaps by sequence number and request retransmit; conflate for those who fall behind.
Risk state stale (risk service unreachable)Fail closed: fence the session and reject new orders rather than admit unchecked exposure into the book.
Sequencer is a single point of failureActive/standby sequencer with the seq# as a fencing token; the log's gap-free numbering makes a missed or duplicated sequence immediately detectable.

9. Interview prompts you should be ready for

  1. The matching engine crashes mid-session — rebuild the exact book state. (senior answer) The book is a deterministic function of the sequenced input log, so I replay it. Concretely: take the most recent checkpoint (a periodic full book snapshot tagged with the seq# it reflects), load it, then replay every sequenced input after that seq# in order. Because the matcher is deterministic — no clocks, no randomness, no shared state — replay reproduces the identical book. I verify with a state checksum: the live engine emits a rolling hash of book state at known sequence points, and the rebuilt engine must match it at the same seq#, proving the reconstruction is bit-for-bit correct. In practice a hot standby has been tailing the same log all along, so failover is instant and replay is only needed to bring a cold node back. This is state-machine replication (DDIA Ch. 9/11): the log is the truth, the book is a derived view.
  2. Why single-threaded? Isn't that throwing away parallelism? (senior answer) Matching one symbol is inherently serial — deciding which order trades first is the product, you cannot do it concurrently without a tiebreaker that is itself a serialization. A single in-cache thread matches in ~1 μs, which beats any locked or consensus-coordinated design by 2–3 orders of magnitude, and it is deterministic, which locking is not. I parallelize across symbols (independent books, embarrassingly parallel) and never within one. One core already exceeds any real symbol's volume.
  3. How is durability not a latency disaster if matching is ~1 μs? (senior answer) A synchronous fsync is ~1 ms — 1000× the match cost — so fsync-per-order is a non-starter. I use a buffered, group-committed journal (~10 μs amortized) and get my durability guarantee from synchronous replication to a hot standby instead of from the disk: an order is accepted once a second in-memory node holds it. Replication on a colo LAN is microseconds, so I keep low latency and still survive a single-node crash.
  4. Why must risk checks run before sequencing, not after matching? (senior answer) Once an order is sequenced and matched, it has created a trade — an irreversible, published, settling event. Risk after the fact can only detect a breach, not prevent it; you would be unwinding real trades. Pre-trade risk is the only place to reject buying-power or fat-finger violations before they become market events. The cost is a bit of latency in the gateway path, which is acceptable because risk state is read-mostly and cacheable per session.
  5. What exactly does the sequencer guarantee, and why does it matter? (senior answer) Gap-free total order: every accepted input gets a strictly increasing seq# and all consumers — matcher, standby, market data, audit — see the same sequence. That is total-order broadcast (DDIA Ch. 9). It is the single thing that makes deterministic replay, hot standbys, and regulatory replay all possible from one mechanism. Without a single ordering authority, "what happened first" becomes ambiguous and the system is neither replayable nor auditable.
  6. Why separate the market-data path from the matching path? (senior answer) Egress can be orders of magnitude larger than ingress, and subscribers vary wildly in speed. If the matcher fanned out directly, the slowest subscriber would back-pressure the hot path. So fanout consumes the matcher's output stream asynchronously (multicast, plus conflation for slow consumers), and a stalled subscriber can never slow matching. Read-side load is fully isolated from the write path.
  7. How do you handle a participant submitting the same order twice on a network retry? (senior answer) Idempotency at the gateway on client_order_id (C35's insert-if-absent mechanics): the first submit is sequenced, a retry with the same ID is recognized and returns the prior result without re-sequencing. The matcher therefore only ever sees each order once, preserving the determinism of the input log.

Related lessons

This case anchors the exchange arc: C39 opens the matching kernel (price-time priority, partial fills, cancel/replace) that we treated as a black box here, and C38 opens the microsecond latency budget (kernel bypass, busy-spin, NUMA pinning) that makes the single-thread loop hit ~1 μs. On the foundations: the sequenced log is lesson 09's total-order broadcast and linearizable ordering; making the sequencer/standby reliable is lesson 10's consensus and leader election; the single-writer-owns-the-data pattern is lesson 08's single-leader replication; the read-side decoupling is lesson 11's async messaging; and idempotent submits reuse C35's idempotency-key mechanics.

C39 matching kernel C38 microsecond latency Consistency & total order Consensus Async messaging Replication
Takeaway
An exchange is a correctness-first system whose architecture is organized around protecting one tiny serial thing: a single deterministic matching loop per symbol. Funnel all inputs through a sequencer into a gap-free, totally-ordered, replicated log; let one lock-free in-memory writer match against it in ~1 μs; derive everything else — recovery, hot standby, audit, market data — from that log. Don't fsync per order (1 ms would dwarf the 1 μs match); get durability from synchronous replication instead. When the engine dies, you don't panic — you replay the log from a checkpoint and check the state checksum, because the log is the truth and the book is just a view of it.