Building a Prediction Market From Scratch (3): Three Services and the Journey of One Order
The first time I took apart a real trading system, the thing that threw me wasn’t the matching math. It was that the matching engine seemed to be guarded like something precious — kept away from the network, away from the database, fed through a narrow slot. It took me a while to understand why, and once I did, a lot of architectural decisions that had looked arbitrary suddenly made sense.
So before we read any engine code, let me walk you through the shape of this system: why it’s three services instead of one, and what actually happens to a single order from the moment it leaves your terminal to the moment it becomes a trade.
我第一次拆一套真实的交易系统时,把我绊住的不是撮合的数学。是撮合引擎居然被像什么宝贝似的护着——离网络远远的,离数据库远远的,只从一道窄缝里喂东西进去。我花了好一阵才明白为什么。一旦明白了,很多原本看着随意的架构决定,忽然就都讲得通了。
所以在读任何引擎代码之前,我想先带你看清这套系统的形状:为什么是三个服务而不是一个,以及一笔订单,从离开你终端的那一刻,到变成一笔成交,中间究竟经历了什么。
Why three services
You could cram all of this into one process. But splitting it into three, each minding its own business, buys you something real.
There’s the Server — the HTTP API. It faces the network, speaks JSON, checks your token, validates your request. It does no matching whatsoever.
There’s the Engine — matching, the order book, balances, positions. This is the part that has to be fast and certain. It keeps all its state in memory and processes commands one at a time, in order, through a single actor. No locks, no races, no two threads arguing over the same balance.
And there’s the DB Worker — persistence and history. Databases are slow and occasionally moody, so we keep that slowness on the far side of a wall.
The decision underneath all three is one belief I hold strongly: the matching engine must be fast and deterministic, so it isn’t allowed to do anything slow. It can’t block on a network call, can’t wait on a database write. Those slow chores get pushed out to the two ends, and a message queue sits in the middle to decouple them. Everything else about the architecture falls out of that one rule.
为什么是三个服务
你大可以把这一切塞进一个进程。但拆成三个、各管各的,能换来实打实的好处。
有 Server——HTTP API。它面向网络,说 JSON,验你的 token,校验你的请求。撮合的事它一概不碰。
有 Engine——撮合、订单簿、余额、持仓。这是必须又快又确定的那一块。它把所有状态放在内存里,用单个 actor 一次一条、按顺序处理命令。没有锁,没有竞态,没有两个线程为同一笔余额吵架。
还有 DB Worker——持久化和历史。数据库慢,偶尔还闹脾气,所以我们把这份慢,关在一堵墙的另一边。
这三者底下,是我一个挺坚定的信念:撮合引擎必须又快又确定,所以它不被允许做任何慢的事。 它不能卡在一次网络调用上,不能等一次数据库写入。那些慢活儿全被推到两端去,中间搁一个消息队列把它们解耦。架构的其余部分,全是从这一条规则里长出来的。
The picture
┌────────────┐ server_requests ┌────────────┐
HTTP client → │ Server │ ─────────────────→ │ Engine │
│ (Actix-web)│ ←───────────────── │ (matching) │
└─────┬──────┘ reply by id └─────┬──────┘
│ db_read_requests │ db_events
▼ ▼
┌──────────────────────────────────────────────┐
│ DB Worker (PostgreSQL) │
└──────────────────────────────────────────────┘The services never call each other directly. They pass messages over Redis Streams — named channels you can append to and read from. The Server drops a request onto a stream; the Engine reads it, works, and replies on another; the Engine also emits events that the DB Worker quietly consumes and writes down.
这张图
┌────────────┐ server_requests ┌────────────┐
HTTP 客户端 → │ Server │ ─────────────────→ │ Engine │
│ (Actix-web)│ ←───────────────── │ (撮合) │
└─────┬──────┘ 按 id 回包 └─────┬──────┘
│ db_read_requests │ db_events
▼ ▼
┌──────────────────────────────────────────────┐
│ DB Worker (PostgreSQL) │
└──────────────────────────────────────────────┘服务之间从不直接互相调用。它们通过 Redis Streams 传消息——一种你能往里追加、也能从里读取的具名通道。Server 把一条请求丢上某个流;Engine 读到、干活、在另一个流上回包;Engine 同时还吐出一些事件,DB Worker 在一旁默默消费、记下来。
What happens to one order
Remember the limit bid from the hands-on chapter — Alice buying YES at 60. Here’s the loop it actually runs:
It starts as POST /orders. The Server’s auth middleware verifies her token and pulls out her user ID, then checks the request body makes sense. It packages the order into a request — with a unique request_id stamped on it — and appends it to the server_requests stream. Then the HTTP handler does something that surprises people the first time they see it: it waits.
Over on the Engine, a consumer is reading that stream. It picks up the message, turns it into a PlaceOrder command, and hands it to the actor. The actor, one command at a time, reserves Alice’s funds, runs the matching, and either rests the order on the book or fills it. Then it does two things: it writes the result back onto a reply stream keyed by that request_id, and it emits events — OrderPlaced, and if a trade happened, TradeExecuted — onto the db_events stream.
Back on the Server, another consumer is watching reply streams. It sees the result come back under Alice’s request_id, wakes up the HTTP handler that was waiting, and returns the JSON. Meanwhile, completely out of the critical path, the DB Worker consumes those db_events and writes the order, the trade, the balance change into Postgres.
That “send a request, wait for a reply keyed by ID” dance is a little request-response pattern wrapped up in a shared redis-client package. We’ll take it apart in its own chapter.
一笔订单经历了什么
还记得动手那章里的限价买单吗——Alice 用 60 买 YES。它真正跑的是这么一圈:
起点是 POST /orders。Server 的鉴权中间件验过她的 token、取出她的用户 ID,再检查请求体合不合理。它把订单打包成一条请求——盖上一个唯一的 request_id——追加到 server_requests 流上。然后,HTTP handler 做了一件头一回看到会愣一下的事:它等着。
在 Engine 那边,一个消费者正读着这个流。它捞起这条消息,变成一个 PlaceOrder 命令,交给 actor。actor 一次一条命令地:冻结 Alice 的资金,跑撮合,要么把订单挂上簿,要么吃掉它。然后它做两件事:把结果写回一个以那个 request_id 为键的回包流,同时把事件——OrderPlaced,如果成交了还有 TradeExecuted——吐到 db_events 流上。
回到 Server,另一个消费者盯着回包流。它看见结果带着 Alice 的 request_id 回来了,唤醒那个一直在等的 HTTP handler,把 JSON 返回。与此同时,完全在关键路径之外,DB Worker 消费那些 db_events,把订单、成交、余额变化写进 Postgres。
这套"发一条请求,等一个按 ID 对上的回包"的舞步,是一个小小的请求-响应模式,包在一个共享的 redis-client 包里。我们会单开一章把它拆开。
The part worth carrying with you
Look again at where the database sits in that story. The hot path — read the command, match it, reply — never touches Postgres. Writing to the database happens afterward, asynchronously, off to the side. If the database lags, or hiccups, or falls over for a minute, trades keep matching at full speed. The Engine’s in-memory state is the source of truth; the database is its mirror, catching up a beat behind.
That used to feel wrong to me — surely the database is the truth? But in a system that has to be fast and correct under load, you learn to flip it. Memory is truth, persistence is a consequence. It’s also exactly the trade-off that opens up the next set of questions: what if the reply gets lost? What if the Engine restarts and its memory is gone? Those aren’t holes I’m glossing over — they’re the whole point of later chapters on messaging reliability and on a write-ahead log that lets the Engine rebuild its state after a crash.
值得你随身带着的那一点
再看一眼数据库在这个故事里站的位置。热路径——读命令、撮合、回包——从不碰 Postgres。写数据库发生在之后,异步地,在一旁。数据库慢了、打了个嗝、甚至趴下一分钟,成交照样全速进行。Engine 的内存状态才是真相之源;数据库是它的镜子,慢一拍地追着。
这事我以前觉得别扭——数据库不才是真相吗?但在一套必须又快又对、还得扛住压力的系统里,你会学着把它翻过来。内存是真相,持久化是结果。这也正是那个会引出下一组问题的取舍:回包丢了怎么办?Engine 重启、内存没了怎么办?这些不是我在含糊带过的窟窿——它们正是后面那几章的全部用意:消息可靠性,以及一个让 Engine 在崩溃后重建状态的预写日志(WAL)。
Where we go next
So that’s the lay of the land: three services with one job each, talking over Redis, and a hot path that stays clear of the database on purpose. With this map in your head, the code in the coming chapters won’t feel like wandering — you’ll always know which box you’re standing in.
From here the series goes hands-on, module by module, starting from an empty repository: the project scaffold, then the account model, then the matching engine itself. And as promised — every line in the open, free, no strings.
接下来去哪
这就是地形了:三个服务,各管一摊,通过 Redis 对话,还有一条故意躲开数据库的热路径。脑子里有了这张地图,后面几章的代码就不会像在乱走——你随时都知道自己站在哪个盒子里。
从这儿开始,系列转入动手,一块一块来,从一个空仓库起步:项目脚手架,然后是账户模型,然后是撮合引擎本身。而且说好的——每一行都公开,免费,没有附加条件。