从零搭建现货撮合系统:完整架构与性能实测

66 阅读16分钟

从零搭建现货撮合系统:完整架构与性能实测

一套经过生产验证的交易所核心系统,从下单到成交全流程

前言

做交易所技术这几年,经常被问到:撮合引擎怎么设计?能跑多少 QPS?

市面上讲撮合的文章很多,但大多是理论,缺少实际的性能数据和踩坑经验。

这篇文章会分享我们团队自研的现货撮合系统 Exchange,包括:

  • 完整的技术架构和选型思路
  • 真实的压测数据(不是理论值)
  • 遇到的性能瓶颈和优化方向

目前这套系统已经能够:

  • 完整下单流程 1,440 QPS(含资产校验、冻结、落库)
  • 🚀 做市机器人接口 18,000 QPS(轻量级,跳过 DB)
  • 🔄 3 节点集群,主从热备,故障自动切换 < 3 秒
  • 📊 完整行情系统(实时深度、15 种 K 线周期、Ticker)

🔗 在线体验web3-ex-omega.vercel.app/
📖 API 文档apix-docs.vercel.app/

demo.gif

本文是系列文章的第一篇,重点讲整体架构,后续会深入撮合算法、高可用设计等细节。


一、为什么自研?

市面上有一些交易所解决方案,但我们调研后发现都不太满足需求:

  • 有些性能不够,延迟太高
  • 有些功能不完整,需要大量二次开发
  • 有些架构老旧,扩展性差
  • 有些是黑盒,出了问题没法排查

最后决定自研,目标很明确:

  1. 高性能:Go 语言,天然适合高并发场景
  2. 架构清晰:微服务拆分,每个模块职责单一
  3. 易扩展:支持水平扩展,方便后续优化
  4. 可控性强:核心代码自己掌握,出问题能快速定位

二、技术选型

核心技术栈

技术用途选型理由
Go开发语言性能好、并发友好、部署简单
Kafka消息队列高吞吐、消息持久化、支持回溯
Redis缓存/选举Leader 选举、行情缓存
MySQL关系数据库订单、成交记录
ClickHouse时序数据库K 线历史数据
Consul服务发现健康检查

为什么用 Kafka?

交易所是典型的事件驱动系统,每笔订单会触发一系列事件:

撮合成功 → 清算资金 → 更新深度 → 生成K线 → 推送客户端

选 Kafka 的原因:

  • ✅ 消息持久化,出问题可以回溯
  • ✅ 单分区有序,撮合结果按顺序处理
  • ✅ 吞吐量高,单分区轻松 10 万+ QPS

RabbitMQ 也考虑过,但它更适合任务队列场景。虽然也支持持久化,但在高吞吐场景下 Kafka 表现更好。

为什么用 Redis 做选举?

撮合引擎是 3 节点集群(1 主 2 备),需要选出 Leader。

用 Redis 分布式锁实现,原因很简单:

  1. 够用:不需要强一致性,只要切换够快
  2. 简单:几行代码搞定
  3. 复用: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 后,查询性能提升明显,毕竟是专门为时序数据设计的。


三、系统架构

整体架构图

exchangehubx-architecture.png

服务划分

系统拆成了 8 个微服务:

服务职责
order-service订单提交、撤单、资金冻结
match-engine核心撮合(3 节点主从集群)
trade-service成交清算、资金划转
depth-service订单簿深度维护
kline-serviceK 线聚合(15 种周期)
market-serviceTicker 统计(24h 数据)
ws-serviceWebSocket 推送
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   │
└─────────────────┘

工作原理:

  1. Leader 选举:3 个节点通过 Redis 分布式锁竞争 Leader
  2. 只有 Master 处理订单:Slave 节点待命,不参与撮合
  3. 操作日志复制:Master 的每一笔操作都写入 Kafka order.log-cluster Topic
  4. Slave 实时同步:从节点消费操作日志,保持订单簿状态一致
  5. 故障自动切换: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 编排,包含:

docker.png

基础设施层:

  • MySQL 8.0 - 订单和成交数据
  • Redis 7 - 缓存和 Leader 选举
  • Kafka (KRaft) - 消息队列
  • ClickHouse - K 线历史数据
  • Consul - 服务注册与发现

业务服务层:

  • 订单服务 - 下单入口
  • 撮合引擎 × 3 - 集群部署
  • 清算服务 - 资金划转
  • 深度/K线/行情服务 - 行情数据
  • WebSocket 服务 - 实时推送
  • 做市机器人 - 流动性提供

监控面板

  • Consul UI - 服务健康状态
  • 撮合引擎日志 - 实时撮合情况

六、性能测试

跑了一轮压测,数据如下:

测试环境

配置项参数
CPUIntel Core Ultra 9 275HX(24 核)
内存32 GB
系统Windows 11
MySQL8.0(Docker 容器)
连接池max_open_conns = 100

重要说明:压测期间,做市机器人一直在运行,持续产生订单和撮合。也就是说,这些数据是在有实际业务负载的情况下测出来的,不是空跑。

关于测试环境:当前是 Windows + Docker Desktop(WSL2),存在一定性能损耗:

  • Docker 跑在 WSL2 虚拟化层上,比 Linux 原生容器多一层开销
  • 磁盘 IO 经过 NTFS → 虚拟磁盘 → ext4 转换
  • 网络走 WSL2 NAT 模式,有额外转发延迟

如果换成 Linux 服务器,预计性能可提升 30-50%

指标Windows (当前)Linux (预估)
普通下单 QPS1,4401,900-2,200
机器人下单 QPS18,00024,000-27,000
P99 延迟87-144ms降低 20-30%

普通用户下单(完整流程)

这是真实的下单流程:JWT 认证 → 资产校验 → 冻结(MySQL事务) → 写订单 → 调用撮合引擎

并发数请求数QPS最低延迟平均延迟P99 延迟
505001,1838ms39ms87ms
10010001,43812ms69ms144ms
20020001,3609ms142ms325ms
30030001,4354ms198ms521ms

瓶颈分析:通过服务端 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 延迟
100100015,902<1ms6ms14ms
200200017,923<1ms10ms44ms

性能差 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,440500-700 万真实用户交易
机器人下单18,0001-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

为什么有效?

  1. 减少锁竞争:不同用户的订单分散到不同库,行锁不再互相阻塞
  2. 连接池翻倍:4 个库 = 4 × 100 = 400 个连接
  3. IO 分散:多个磁盘并行写入

预期效果

分库数量预期 QPS提升倍数
单库1,4401x
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,44039-70msMySQL 事务 (70%)
机器人下单18,0006-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…

如果你对交易所技术感兴趣,或者有系统搭建需求,欢迎交流

后续会持续更新撮合算法、高可用设计、性能优化等系列文章,敬请关注。