从零搭建现货撮合系统:完整架构与性能实测
一套经过生产验证的交易所核心系统,从下单到成交全流程
前言
做交易所技术这几年,经常被问到:撮合引擎怎么设计?能跑多少 QPS?
市面上讲撮合的文章很多,但大多是理论,缺少实际的性能数据和踩坑经验。
这篇文章会分享我们团队自研的现货撮合系统 Exchange,包括:
- 完整的技术架构和选型思路
- 真实的压测数据(不是理论值)
- 遇到的性能瓶颈和优化方向
目前这套系统已经能够:
- ⚡ 完整下单流程 1,440 QPS(含资产校验、冻结、落库)
- 🚀 做市机器人接口 18,000 QPS(轻量级,跳过 DB)
- 🔄 3 节点集群,主从热备,故障自动切换 < 3 秒
- 📊 完整行情系统(实时深度、15 种 K 线周期、Ticker)
🔗 在线体验:web3-ex-omega.vercel.app/
📖 API 文档:apix-docs.vercel.app/
本文是系列文章的第一篇,重点讲整体架构,后续会深入撮合算法、高可用设计等细节。
一、为什么自研?
市面上有一些交易所解决方案,但我们调研后发现都不太满足需求:
- 有些性能不够,延迟太高
- 有些功能不完整,需要大量二次开发
- 有些架构老旧,扩展性差
- 有些是黑盒,出了问题没法排查
最后决定自研,目标很明确:
- 高性能:Go 语言,天然适合高并发场景
- 架构清晰:微服务拆分,每个模块职责单一
- 易扩展:支持水平扩展,方便后续优化
- 可控性强:核心代码自己掌握,出问题能快速定位
二、技术选型
核心技术栈
| 技术 | 用途 | 选型理由 |
|---|---|---|
| Go | 开发语言 | 性能好、并发友好、部署简单 |
| Kafka | 消息队列 | 高吞吐、消息持久化、支持回溯 |
| Redis | 缓存/选举 | Leader 选举、行情缓存 |
| MySQL | 关系数据库 | 订单、成交记录 |
| ClickHouse | 时序数据库 | K 线历史数据 |
| Consul | 服务发现 | 健康检查 |
为什么用 Kafka?
交易所是典型的事件驱动系统,每笔订单会触发一系列事件:
撮合成功 → 清算资金 → 更新深度 → 生成K线 → 推送客户端
选 Kafka 的原因:
- ✅ 消息持久化,出问题可以回溯
- ✅ 单分区有序,撮合结果按顺序处理
- ✅ 吞吐量高,单分区轻松 10 万+ QPS
RabbitMQ 也考虑过,但它更适合任务队列场景。虽然也支持持久化,但在高吞吐场景下 Kafka 表现更好。
为什么用 Redis 做选举?
撮合引擎是 3 节点集群(1 主 2 备),需要选出 Leader。
用 Redis 分布式锁实现,原因很简单:
- 够用:不需要强一致性,只要切换够快
- 简单:几行代码搞定
- 复用:Redis 本来就要用,不用引入新组件
// Leader 选举
func tryBecomeLeader() bool {
return redis.SetNX("match-engine:leader", nodeID, 3*time.Second).Val()
}
TTL 设 3 秒,主节点挂了,备节点最多 3 秒内就能接管。
当然,Redis 选举理论上有脑裂风险。如果要更严谨,可以用 Consul Session 或 etcd。这里为了简单,先用 Redis。
数据库选型
MySQL 存订单和成交记录,这是关系型数据,需要事务保证。
ClickHouse 存 K 线历史数据。一开始想全用 MySQL,但 K 线数据增长太快:
- 1 分钟周期,一天 1440 条 × 交易对数量
- 查询历史 K 线时,MySQL 响应越来越慢
换成 ClickHouse 后,查询性能提升明显,毕竟是专门为时序数据设计的。
三、系统架构
整体架构图
服务划分
系统拆成了 8 个微服务:
| 服务 | 职责 |
|---|---|
| order-service | 订单提交、撤单、资金冻结 |
| match-engine | 核心撮合(3 节点主从集群) |
| trade-service | 成交清算、资金划转 |
| depth-service | 订单簿深度维护 |
| kline-service | K 线聚合(15 种周期) |
| market-service | Ticker 统计(24h 数据) |
| ws-service | WebSocket 推送 |
| bot-service | 做市机器人(测试用) |
撮合引擎:主从复制架构
撮合引擎是整个系统的核心,必须保证高可用。我们采用 1 主 2 从 的集群架构:
┌─────────────────┐
│ Redis 选举 │
│ (Leader Lock) │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ match-engine │ │ match-engine │ │ match-engine │
│ node-1 │ │ node-2 │ │ node-3 │
│ (Master) │ │ (Slave) │ │ (Slave) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ Kafka: order.log-cluster (操作日志复制)
│◄──────────────────┴───────────────────┘
│
▼
┌─────────────────┐
│ Kafka Topic │
│ match.result │
└─────────────────┘
工作原理:
- Leader 选举:3 个节点通过 Redis 分布式锁竞争 Leader
- 只有 Master 处理订单:Slave 节点待命,不参与撮合
- 操作日志复制:Master 的每一笔操作都写入 Kafka
order.log-clusterTopic - Slave 实时同步:从节点消费操作日志,保持订单簿状态一致
- 故障自动切换:Master 挂掉后,TTL 3 秒内 Slave 自动接管
为什么要这样设计?
- 不能多主:撮合必须单点执行,否则同一笔订单可能被撮合两次
- 必须热备:冷启动太慢,从 MySQL 恢复订单簿要几分钟
- 操作日志:类似 MySQL binlog,保证主从数据一致
一笔订单的处理流程
用户下单后,系统内部是这样处理的:
1. 订单服务
→ 校验参数
→ 开启事务
→ 冻结资金(乐观锁,条件更新)
→ 生成订单 ID(Snowflake)
→ 保存订单到 MySQL
→ 提交事务
→ 调用撮合引擎(gRPC)
2. 撮合引擎
→ 订单入订单簿(跳表结构)
→ 执行撮合算法(价格-时间优先)
→ 生成撮合结果
→ 发送到 Kafka(match.result Topic)
3. 清算服务(消费 Kafka)
→ Taker/Maker 资金划转
→ 扣除手续费
→ 更新订单状态
→ 发送到 Kafka(trade.executed)
4. 行情服务(消费 Kafka)
→ depth-service:更新深度
→ kline-service:聚合 K 线
→ market-service:计算 Ticker
5. 推送服务
→ WebSocket 推送给客户端
整个流程是异步的,各服务通过 Kafka 解耦。
四、核心模块设计
4.1 订单服务
关键点 1:Snowflake ID
订单 ID 用 Snowflake 算法生成,保证全局唯一且趋势递增:
type Snowflake struct {
mu sync.Mutex
epoch int64 // 起始时间戳
machineID int64 // 机器 ID
sequence int64 // 序列号
lastTime int64
}
func (s *Snowflake) NextID() int64 {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UnixMilli()
if now == s.lastTime {
s.sequence = (s.sequence + 1) & 0xFFF // 12 位序列号
if s.sequence == 0 {
// 同一毫秒内序列号用完,等下一毫秒
for now <= s.lastTime {
now = time.Now().UnixMilli()
}
}
} else {
s.sequence = 0
}
s.lastTime = now
// 41位时间戳 + 10位机器ID + 12位序列号
return ((now - s.epoch) << 22) | (s.machineID << 12) | s.sequence
}
关键点 2:资金冻结
下单前要先冻结资金,防止余额超卖。采用乐观锁方式,通过条件更新保证原子性:
func (s *OrderService) freezeFunds(tx *gorm.DB, userID int64, asset string, amount decimal.Decimal) error {
// 乐观锁:条件更新,只有余额充足才会成功
result := tx.Model(&UserAsset{}).
Where("user_id = ? AND asset = ? AND available >= ?", userID, asset, amount).
Updates(map[string]interface{}{
"available": gorm.Expr("available - ?", amount),
"frozen": gorm.Expr("frozen + ?", amount),
})
if result.Error != nil {
return result.Error
}
// 检查影响行数,0 表示余额不足
if result.RowsAffected == 0 {
return errors.New("insufficient balance")
}
return nil
}
这里用 WHERE available >= amount 作为乐观锁条件,一条 SQL 完成校验和冻结,避免了 SELECT FOR UPDATE 带来的锁等待。并发下单时不会互相阻塞,只是可能有一方因余额不足而失败。
4.2 撮合引擎
订单簿结构
买单和卖单分别用跳表(SkipList)维护:
- 买单:价格从高到低排序
- 卖单:价格从低到高排序
type OrderBook struct {
Symbol string
BuyOrders *SkipList // 买单
SellOrders *SkipList // 卖单
}
为什么用跳表?实现相对简单,性能和红黑树差不多,都是 O(log n)。后续文章会详细对比。
撮合算法
经典的价格-时间优先:
func (e *Engine) matchOrder(order *Order) []*MatchResult {
var results []*MatchResult
// 获取对手盘
oppositeOrders := e.getOppositeOrders(order)
for oppositeOrders.Len() > 0 && !order.Qty.IsZero() {
topOrder := oppositeOrders.Peek()
// 价格不匹配,停止撮合
if !canMatch(order, topOrder) {
break
}
// 计算成交数量
matchQty := decimal.Min(order.Qty, topOrder.Qty)
// 生成撮合结果
result := &MatchResult{
MakerOrderID: topOrder.ID,
TakerOrderID: order.ID,
Price: topOrder.Price,
Qty: matchQty,
}
results = append(results, result)
// 更新剩余数量
order.Qty = order.Qty.Sub(matchQty)
topOrder.Qty = topOrder.Qty.Sub(matchQty)
if topOrder.Qty.IsZero() {
oppositeOrders.Pop()
}
}
return results
}
五、部署架构
服务组成
整套系统通过 Docker Compose 编排,包含:
基础设施层:
- MySQL 8.0 - 订单和成交数据
- Redis 7 - 缓存和 Leader 选举
- Kafka (KRaft) - 消息队列
- ClickHouse - K 线历史数据
- Consul - 服务注册与发现
业务服务层:
- 订单服务 - 下单入口
- 撮合引擎 × 3 - 集群部署
- 清算服务 - 资金划转
- 深度/K线/行情服务 - 行情数据
- WebSocket 服务 - 实时推送
- 做市机器人 - 流动性提供
监控面板
- Consul UI - 服务健康状态
- 撮合引擎日志 - 实时撮合情况
六、性能测试
跑了一轮压测,数据如下:
测试环境
| 配置项 | 参数 |
|---|---|
| CPU | Intel Core Ultra 9 275HX(24 核) |
| 内存 | 32 GB |
| 系统 | Windows 11 |
| MySQL | 8.0(Docker 容器) |
| 连接池 | max_open_conns = 100 |
重要说明:压测期间,做市机器人一直在运行,持续产生订单和撮合。也就是说,这些数据是在有实际业务负载的情况下测出来的,不是空跑。
关于测试环境:当前是 Windows + Docker Desktop(WSL2),存在一定性能损耗:
- Docker 跑在 WSL2 虚拟化层上,比 Linux 原生容器多一层开销
- 磁盘 IO 经过 NTFS → 虚拟磁盘 → ext4 转换
- 网络走 WSL2 NAT 模式,有额外转发延迟
如果换成 Linux 服务器,预计性能可提升 30-50%:
| 指标 | Windows (当前) | Linux (预估) |
|---|---|---|
| 普通下单 QPS | 1,440 | 1,900-2,200 |
| 机器人下单 QPS | 18,000 | 24,000-27,000 |
| P99 延迟 | 87-144ms | 降低 20-30% |
普通用户下单(完整流程)
这是真实的下单流程:JWT 认证 → 资产校验 → 冻结(MySQL事务) → 写订单 → 调用撮合引擎
| 并发数 | 请求数 | QPS | 最低延迟 | 平均延迟 | P99 延迟 |
|---|---|---|---|---|---|
| 50 | 500 | 1,183 | 8ms | 39ms | 87ms |
| 100 | 1000 | 1,438 | 12ms | 69ms | 144ms |
| 200 | 2000 | 1,360 | 9ms | 142ms | 325ms |
| 300 | 3000 | 1,435 | 4ms | 198ms | 521ms |
瓶颈分析:通过服务端 TIMING 日志分析,单次请求耗时分布:
[TIMING] total=11.5ms | parse=0ms | pre_check=2.3ms | db_tx=8.5ms | match=0.5ms
耗时占比:
pre_check (预查余额): ████ 20%
db_tx (数据库事务): ██████████████ 70% ← 主要瓶颈!
match (撮合引擎): █ 5%
数据库事务占了 70% 的耗时,而撮合引擎本身只需要 0.5ms。
做市机器人下单(轻量级)
机器人接口跳过了 DB 操作:IP 白名单 → 直接调用撮合引擎
| 并发数 | 请求数 | QPS | 最低延迟 | 平均延迟 | P99 延迟 |
|---|---|---|---|---|---|
| 100 | 1000 | 15,902 | <1ms | 6ms | 14ms |
| 200 | 2000 | 17,923 | <1ms | 10ms | 44ms |
性能差 12.5 倍的原因:
普通下单: pre_check(2.3ms) + db_tx(8.5ms) + match(0.5ms) = ~12ms
机器人: IP校验(<1ms) + match(0.5ms) = ~1ms
机器人跳过了 pre_check + db_tx,省去了 90% 的耗时。这也证明了撮合引擎本身性能充足,瓶颈在数据库。
容量估算
| 接口 | QPS | 日订单量 | 适用场景 |
|---|---|---|---|
| 普通下单 | 1,440 | 500-700 万 | 真实用户交易 |
| 机器人下单 | 18,000 | 1-1.5 亿 | 做市/量化机器人 |
1,440 QPS 够用吗? 对于中小型交易所完全够了,可支撑 100-200 万 DAU。币安日订单量是千万级,但人家是分布式多机房部署。
瓶颈在哪?怎么优化?
压测结果很直白:MySQL 是最大的瓶颈。
机器人接口跳过数据库操作后,QPS 直接从 1,440 飙到 18,000,差了 12.5 倍。这说明撮合引擎本身不慢,慢的是数据库。
为什么数据库这么慢?
看一下普通下单的流程:
1. pre_check (预查余额) → 2.3ms
2. db_tx (冻结资金 + 写订单,含事务提交) → 8.5ms ← 主要瓶颈!
3. match (gRPC 调用撮合引擎) → 0.5ms
问题出在 db_tx 占了 70%。MySQL 的 innodb_flush_log_at_trx_commit=1(默认值)意味着每次事务提交都要 fsync 刷盘,这是为了保证数据不丢,但也带来了延迟。
好消息是:撮合引擎只需要 0.5ms,性能非常充足。瓶颈完全在数据库侧。
优化方案:从易到难
方案 1:调参数(5 分钟搞定)
-- 把事务提交从"每次刷盘"改成"每秒刷盘"
SET innodb_flush_log_at_trx_commit = 2;
风险:MySQL 崩溃可能丢 1 秒数据。对于交易系统,可能接受不了。
预期效果:QPS 提升 30-50%
方案 2:加连接池 + 换更好的机器(半天)
# 连接池从 100 加到 200
max_open_conns: 200
# MySQL 换成独立服务器,别跟业务挤在一起
预期效果:QPS 提升 50-80%
方案 3:读写分离(1-2 天)
写:主库(订单写入、资金冻结)
读:从库(查询资产、查询订单)
大部分查询走从库,主库压力小很多。
预期效果:QPS 提升 80-100%
方案 4:分库分表(中等改造)
当前是单库单表,上限就卡在这一个 MySQL 实例上。如果做分库分表,理论上可以线性扩展。
分库策略:按用户 ID 取模
├── db_0: user_id % 4 == 0 的用户
├── db_1: user_id % 4 == 1 的用户
├── db_2: user_id % 4 == 2 的用户
└── db_3: user_id % 4 == 3 的用户
分表策略:按交易对分表
├── orders_btc_usdt
├── orders_eth_usdt
└── orders_xxx_usdt
为什么有效?
- 减少锁竞争:不同用户的订单分散到不同库,行锁不再互相阻塞
- 连接池翻倍:4 个库 = 4 × 100 = 400 个连接
- IO 分散:多个磁盘并行写入
预期效果:
| 分库数量 | 预期 QPS | 提升倍数 |
|---|---|---|
| 单库 | 1,440 | 1x |
| 2 库 | 2,500-2,800 | ~1.8x |
| 4 库 | 4,500-5,000 | ~3x |
| 8 库 | 7,500-8,500 | ~5x |
为什么不是线性的 8 倍?因为还有一些公共开销:
- 分布式事务(跨库操作)
- 路由计算
- 连接管理
实现复杂度:
需要引入分库分表中间件(比如 ShardingSphere、Vitess),或者在代码里自己实现路由逻辑。改造成本中等,但收益明显。
方案 5:异步落库(大改造)
这是头部交易所的做法,但改造成本很高:
当前流程(同步):
下单 → 冻结资金 → 写订单 → 撮合 → 返回
优化后(异步):
下单 → Redis预扣 → 撮合 → 返回(先返回,不等DB)
↓
后台异步写入 MySQL(最终一致性)
核心思路:用户感知的延迟和数据库解耦。
- 资金冻结:从 MySQL 事务改到 Redis(原子操作,微秒级)
- 订单写入:改成异步,先写 Kafka,再慢慢落库
- 数据一致性:最终一致,有对账机制兜底
预期效果:QPS 能到 5,000-10,000
方案 6:内存撮合 + Event Sourcing(终极方案)
币安、火币这个级别的做法:
- 撮合完全在内存,不依赖任何外部存储
- 所有操作先写 Kafka(Event Sourcing),再异步同步到数据库
- 数据库只用于查询和对账,不在关键路径上
这套架构下,纯撮合性能可以到 几十万 TPS,但复杂度也是指数级上升。
我为什么没做这些优化?
说实话,1,440 QPS 对于一个普通项目来说够用了。
日订单 500-700 万,已经超过 90% 的小交易所了。真要做到币安那个量级,光靠代码优化不够,还需要:
- 专业的 DBA 团队
- 多机房部署
- 几百台服务器
这些不是一个人能搞定的。
所以我们的选择是:先把架构做对,性能按需优化。当前这套架构,后续想提升性能有清晰的路径。
七、总结
性能数据一览
| 接口 | QPS | 延迟 | 瓶颈 |
|---|---|---|---|
| 普通下单 | 1,440 | 39-70ms | MySQL 事务 (70%) |
| 机器人下单 | 18,000 | 6-10ms | 撮合引擎 (0.5ms) |
优化路线图
| 阶段 | 方案 | 预期 QPS | 成本 |
|---|---|---|---|
| 当前 | 单库同步落库 | 1,440 | - |
| 阶段 1 | 调参数 + 加连接池 | 2,000 | 低 |
| 阶段 2 | 读写分离 | 3,000 | 中 |
| 阶段 3 | 分库分表 (4库) | 5,000 | 中 |
| 阶段 4 | 异步落库 | 10,000 | 高 |
| 阶段 5 | 内存撮合 | 50,000+ | 很高 |
当前版本处于基础阶段,架构上预留了优化空间,可根据实际业务需求逐步升级。
系统特点
✅ 完整的交易闭环
从下单、撮合、清算到行情推送,全流程覆盖
✅ 生产级高可用
撮合引擎 3 节点主从集群,Kafka 日志复制,故障自动切换 < 3 秒
✅ 灵活的扩展性
微服务架构,可按需扩容单个模块
✅ 清晰的优化路径
瓶颈明确(DB 占 70%),有成熟的优化方案可落地
下一篇预告
《撮合引擎核心算法详解》
- 订单簿数据结构的选择与优化
- 撮合算法的性能调优技巧
- 内存管理与 GC 优化实践
常见问题
Q: 为什么用 Kafka 而不是其他消息队列?
A: Kafka 有持久化和消息回溯能力,服务重启不丢数据,更适合金融场景。
Q: Redis 选举会不会有脑裂问题?
A: 理论上有可能,我们通过 TTL 控制在 3 秒内切换,实际运行中未出现问题。后续可升级为 Consul Session 机制。
Q: 能支持多少个交易对?
A: 测试过 100 个交易对同时运行,性能稳定。更多交易对可通过水平扩展支持。
Q: 日订单量能支撑多少?
A: 当前 1,440 QPS 可支撑日订单 500-700 万,DAU 100-200 万。通过分库分表 + 异步优化,可提升到千万级。
Q: 撮合引擎为什么用主从模式?
A: 撮合必须单点执行(避免重复撮合),但又不能单点故障。主从模式下,Master 处理订单,Slave 通过 Kafka 同步操作日志保持热备,故障时秒级切换。
🔗 在线体验:web3-ex-omega.vercel.app/
📖 API 文档:apix-docs.vercel.app/
💬 技术讨论:github.com/exchangeDem…如果你对交易所技术感兴趣,或者有系统搭建需求,欢迎交流
后续会持续更新撮合算法、高可用设计、性能优化等系列文章,敬请关注。