Contents

Building a Prediction Market From Scratch (3): Three Services and the Journey of One Order

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 对话,还有一条故意躲开数据库的热路径。脑子里有了这张地图,后面几章的代码就不会像在乱走——你随时都知道自己站在哪个盒子里。

从这儿开始,系列转入动手,一块一块来,从一个空仓库起步:项目脚手架,然后是账户模型,然后是撮合引擎本身。而且说好的——每一行都公开,免费,没有附加条件。