用 Go 写了个撮合引擎,聊聊 Match Apex 里的几个设计
前言
最近把 Match Apex 重新整理了一遍。
这是我写的一个高性能撮合引擎示例。项目没有往“完整交易所”这个方向堆功能,账户、资产、清算这些业务暂时都不在范围内,主要就是把撮合这件事做好。
撮合的规则说起来很简单:买单找卖单,价格合适就成交。但真写起来,问题很快就会冒出来:订单簿怎么存,同一个价格的订单怎么排队,撤单怎么快速定位,并发请求进来以后怎么保证顺序,服务重启又怎么恢复现场。
这篇文章不讲太多概念,主要记录一下我在 Match Apex 里是怎么处理这些问题的。

Match Apex,一个使用 Go 实现的撮合引擎示例。
项目目前使用 Go 1.24,外围接了 Echo、gRPC、MySQL、Redis 和 Kafka,另外还放了一套可选的 Raft 实验实现。
1、订单簿怎么存
每个交易对有自己的一份订单簿,买盘和卖盘分别放在两棵红黑树里。买盘从高价往低价排,卖盘反过来,所以拿最优买价和最优卖价不需要从头遍历。
同一个价格下面会挂很多订单。我这里用双向链表保存,谁先进入,谁就在前面。除此之外,还有一个
orderID -> OrderEntry的 Map。撤单时先通过 Map 找到订单,再从对应链表中移除,不用扫描整本订单簿。<!-- 配图 1:买卖盘红黑树、价格档位链表以及 orderID 索引之间的关系 -->

红黑树管价格,链表管先后顺序,Map 用来快速找订单。
金额和数量没有用
float64,统一使用decimal.Decimal。性能当然重要,但金额算错了,后面的优化也就没什么意义了。现在已经实现了限价单、市价单、止损限价单、止损市价单和冰山单,执行策略支持 GTC、IOC、FOK、GTX。不同订单最后都落到同一本订单簿上,这一块也是项目里比较适合拿来直接读代码的部分。
2、为什么同一交易对串行撮合
单机模式下,每个交易对对应一个
MarketEngine,里面有自己的订单 channel。BTC/USDT 和 ETH/USDT 可以同时跑,但 BTC/USDT 内部还是按顺序处理。我一开始就没打算让多个 goroutine 同时改一本订单簿。锁能保护数据结构,却不一定能让业务顺序变得好理解。按交易对串行以后,撮合顺序比较直观,出了问题也容易复现。
<!-- 配图 2:HTTP、gRPC、Kafka 进入 MarketEngine,再按交易对进入各自的 Channel -->

不同交易对可以并行,同一交易对保持串行。
目前 channel 的容量是 10000。这个值对演示和联调够用,但如果入口一直比撮合快,队列早晚会满。后面做压测时,背压和拒单策略还得单独看,不能只靠把 channel 调大。
3、架构没有拆得很散
Match Apex 现在是模块化单体,依赖关系比较简单:
adapter -> application -> domaindomain里只有订单、市场、订单簿和撮合规则,不直接碰配置、MySQL 或 Kafka。HTTP、gRPC、Kafka Consumer 放在入站适配器,MySQL、Redis、Kafka Producer 和 Raft 放在出站适配器。<!-- 配图 3:Adapter、Application、Domain 三层,以及外围组件的位置 -->

核心撮合逻辑不依赖具体的接口和存储实现。
这里没有必要为了看起来“架构很大”就先拆一堆服务。撮合主链路留在进程内,少一次网络调用,也少一层排查成本。真要往外拆,现有的模块边界也能接着用。
4、项目结构
目录没有铺得太散,主要代码都在
internal下面:match-apex/ ├── api/ │ └── proto/ gRPC 协议定义 ├── cmd/ │ └── server/ 服务启动入口 ├── configs/ 配置文件 ├── deployments/ 部署相关配置 ├── docs/ 项目文档 ├── internal/ │ ├── app/ 启动、装配与关闭流程 │ ├── application/ │ │ └── engine/ 市场路由与订单处理 │ ├── domain/ │ │ ├── matching/ 撮合逻辑 │ │ ├── model/ 订单、成交与市场模型 │ │ └── orderbook/ 订单簿与红黑树 │ ├── adapter/ │ │ ├── inbound/ HTTP、gRPC、Kafka Consumer │ │ └── outbound/ MySQL、Kafka Producer、Raft │ └── platform/ 配置、错误与基础能力 ├── migrations/ 数据库迁移脚本 ├── tests/ 测试代码 └── tools/ └── web/ Web 联调终端第一次看代码,我建议按这个顺序:
- 先看
domain/model,把订单、成交和市场几个基础对象过一遍。 - 再看
domain/orderbook,这里能看到价格档位和订单队列具体怎么存。 - 接着看
domain/matching,不同订单类型的撮合入口都在这里。 - 然后到
application/engine,看订单怎么路由到各个交易对。 - 最后再看
adapter和app,把接口、存储和启动流程串起来。
5、重启以后怎么恢复订单簿
订单簿在内存里,订单和成交记录会写到 MySQL。服务启动时,会找出状态为
new和partially_filled的订单,按订单 ID 升序读取,再放回对应交易对的订单簿。这里不能只关心“订单还在不在”。同一个价格下谁排在前面也得尽量保持,否则服务重启一次,后面的成交顺序可能就变了。
<!-- 配图 4:服务启动、读取未完成订单、按 ID 排序、恢复各交易对订单簿 -->

恢复订单时,同时恢复原来的价格和时间顺序。
订单更新和成交事件目前通过处理器链交给 MySQL 和 Kafka。单个处理器失败时会记日志,然后继续往后走。这种做法方便演示完整链路,不过数据落库失败后的补偿还比较简单,后面可以再加 WAL、Outbox 或重试机制。
6、联调不只靠 curl
接口这块保留了 HTTP、gRPC 和 Kafka 三种入口。为了调试方便,我还写了一个原生 HTML、CSS、JavaScript 的 Web 终端,可以直接看盘口、成交、行情和 K 线,也可以下单观察订单簿变化。
<!-- 配图 5:带有模拟订单和成交数据的 Web 交易终端完整截图 -->

Web 终端主要用来联调,下单以后可以直接看到盘口和成交变化。
项目里带了 Docker Compose,MySQL、Kafka 和 Redis 可以一起启动。如果只想看撮合主流程,也可以先关掉 Kafka,少起一个依赖。
结语
Match Apex 的定位一直是撮合引擎示例,不是交易所 Demo。账户体系、资产冻结、清结算和业务风控都属于上层系统,我暂时没有把它们塞进来。
接下来如果继续做,我更想把时间花在撮合本身:测一下不同盘口深度和订单分布下的吞吐与延迟,看看热点路径还有多少内存分配,再把队列满载时的处理补清楚。Raft 目前也是实验功能,主要用来试撮合状态复制,不是项目主线。
项目地址:https://gitee.com/lanzlz/match-apex
如果你正好也在看订单簿或者撮合逻辑,可以直接从
orderbook和matching两个目录开始。代码有问题就提 Issue,我看到会处理。最后由 蓝湛 编辑于 2026-08-26 11:29- 先看
补一张图
