1.一个 限价单 / 市价单 从请求进来,到最终成交,在你们系统业务里具体是怎么走的
下单请求先经过网关鉴权和风控,通过后由订单服务做价格、余额校验,并在事务里冻结资产、生成订单。
订单创建成功后,会把撮合指令丢到 Kafka,Kafka 在我们这里主要是解耦和调度,不是一致性保证。
撮合引擎是单交易对单线程模型,每个交易对对应一个撮合实例,按 Kafka partition 顺序消费,避免加锁。
订单簿放在内存里,买卖盘按价格有序,同价位按时间FIFO。
成交结果通过可靠消息交给结算系统更新资产,结算是资产的唯一写入口;成交和行情通过 WebSocket 推送给用户
2.单交易对单线程撮合。BTC/USDT 这种核心交易对QPS暴涨,单线程已经成为瓶颈,你们怎么办?
在极端高QPS场景下,我们考虑的是逻辑拆分而不是并行撮合
撮合主线程仍然串行维护订单簿,但会把订单校验、风控、持久化、行情计算等非撮合逻辑拆到并行线程
如果一定要在撮合层扩展,只会在严格不交叉的价格区间内做局部并行,并且由主线程统一提交撮合结果,保证价格和时间优先不被破坏。
实际生产中,更常用的是:
限流 + 撮合队列堆积 + 行情降级,而不是无限横向扩展撮合逻辑
3.为什么用红黑树,为什么不用跳表/堆/TreeMap/自研数组结构
首先,撮合为了保证订单的有序性,所以用的是单线程;对于单线程,treeMap足够了,另外它的查找、删除、插入时间复杂度都是OlogN 概率是确定的
为什么不使用跳表?因为java并没有现成的跳表数据结构,使用并发的工具ConcurrentSkipListMap 虽然线程安全,但内部大把volatile节点,遍历时每次 next都是读屏障,延迟比 TreeMap 高 20%+;
自己写无锁跳表?维护成本高且新增很多代码,堆只能拿“顶”,无法 O(log n) 删除任意节点;撤单、部分成交时要把订单从堆里摘掉,需要额外哈希索引 + 标记删除,逻辑复杂,延迟反而飘;自研数据结构还是之前说的那个问题,维护成本高且需要时间压测性能瓶颈
4.如果撮合引擎进程崩了/重启了,怎么办?
我们每个交易对会有 主撮合节点 + 热备从节点。
主节点在撮合前会把订单指令顺序写入顺序 redo log(基于 Kafka / 本地 WAL),
从节点实时拉取并重放这些指令,构建和主节点一致的内存订单簿。
主节点宕机后:
该交易对会被临时冻结新下单,从节点确认 replay 到最新 offset 后提升为主节点
解冻下单,继续撮合,因为所有撮合输入都来自单一顺序日志,所以不会出现乱序或双撮合问题。
实际切换时间在 百毫秒到秒级,对用户表现为短暂不可下单
5.链上充值监听,如何做到不漏记,不重记,防止重放攻击
不漏记:
我们自己跑全节点,监听模块只认“6个确认后的区块”,并且把已处理区块高度持久化到本地DB。
如果程序重启,就从上次记录的高度-1开始回扫,分叉链也能被重新扫描到,低于 6 确认的孤立块直接丢弃,保证链上最终一致性。不重记:
每笔充值都以 (txid + vout) 做联合唯一索引;入账前先用 INSERT IGNORE 试写流水表,主键冲突立即丢弃,SQL 层天然幂等。
入账成功后把 txid 写进 Redis SET,30 天过期,内存里再挡一次重复,双层去重。防重放攻击:
节点只连接自己的白名单对等节点,拒绝unsolicited block/tx push;
所有区块头做一次 PoW 难度校验,防止有人喂假链;
对于支持 ** EIP-155 这类带 ChainID 的链,校验 ChainID 与签名匹配,跨链重放直接拒收**;
充值地址用 一次性 UID 专属地址,监听脚本发现地址不属于任何用户直接忽略,外部重放进不来。
6.在结算完成之前,用户在前端看到的资产/订单状态应该是什么?后端内部订单状态如何流转?如何避免用户资产出现 负数 / 多记?
前端展示为 “结算中” 的状态
资产侧:下单时已经冻结资金;可用余额减少,冻结余额增加;不会再二次扣减
订单状态走:
NEW → PARTIAL/FILLED → SETTLING → SETTLED
结算服务是资产唯一写入口,基于全局tradeId做幂等,避免重复扣款。
结算失败会进入重试队列,超过阈值告警人工介入,保证资产不负数、不多扣
7.如果:撮合成功且结算消息已发,但在结算前订单被取消/用户强制平仓,怎么处理?这笔冻结资金怎么办?成交了但没结算的钱归谁?
撮合一旦产生成交记录,这笔交易就是不可逆的事实。
即使此时:用户发起撤单或强制平仓,这些操作只影响未成交部分的订单和可用余额,不会影响已经撮合成功、正在结算的成交。
冻结资金会优先用于完成该笔成交的结算,结算完成后多余的冻结资金才会被释放回可用余额
8.撮合线程正在处理该订单,这个时候订单可能:刚好被部分成交或正要成交,如何保证:不多退钱,不少成交,订单状态不乱?
撮合是单交易对单线程模型,所以撤单和成交在撮合层面天然串行。
已经成交的部分是不可逆的铁单,只能进入结算流程,绝不回滚。
撤单只作用于:尚未成交的剩余委托量,针对对应的冻结资金
状态流转由撮合线程统一推进:
无成交:NEW → CANCELED
部分成交:PARTIAL → PARTIAL_CANCELED
因为只有撮合线程能修改撮合状态下的订单状态,所以不会出现多退钱、少成交或状态错乱
8.1 撮合线程修改的订单状态和结算服务修改的订单状态是一个东西吗?为什么这么设计
不是,撮合服务的本质是基于内存来高效匹配订单,这里面维护了一个基于高性能队列Disruptor内存的订单薄,而结算服务存储的持久化的订单数据,是结算态的数据;撮合服务为了高性能高吞吐量必须要基于内存来设计,而结算是持久化数据,这一步是mq分流后的服务节点;
撮合引擎只负责推进 成交相关状态,比如是否成交、成交数量,这部分是不可逆的事实。
结算系统只负责推进结算状态,表示这笔成交对应的资金是否已经完成入账。
结算系统有以下表:
1 | Trade(成交记录) |
8.2 结算完成后会反写撮合引擎中的订单状态吗?也就是内存里的订单状态会同步刷新吗?还是说订单撮合时在撮合引擎中内存中的订单状态就是部分成交或全部成交?
结算完成后,不会、也不应该反写撮合引擎里的内存订单状态,撮合引擎里的订单状态,在撮合完成那一刻就已经“终结”了
撮合阶段的状态:
未成交:NEW
部分成交:PARTIAL_FILLED
全部成交:FILLED
撤单:CANCELED / PARTIAL_CANCELED
📌一旦进入FILLED/CANCELED/PARTIAL_CANCELED,订单就会从内存订单簿移除
8.3 在撮合完成或撤单时已经进入终态并从订单簿移除?移除后后续这个订单的撮合终态在哪里有记录?后面对账时的订单重放根据啥来判断?
订单从内存订单簿移除前,撮合引擎已经把成交和终态事件顺序写入撮合日志。
内存订单簿只是工作区,真正的事实来源是不可变的顺序事件日志。
系统重启或对账时,都是基于日志重放和结算账本进行校验,不依赖任何内存状态
9.你们的行情是怎么做的?如何保证顺序、低延迟,以及慢客户端不拖垮整体?
每成交一笔就写一笔行情数据,通过kafka单分区来保证顺序,为了保证低延迟采用行情网关层主动推送模式,每成交一笔就全量广播推送一次,当检测到慢客户端TCP缓存写满时主动断开慢客户端,避免拖垮整体;
9.1 单分区kafka会不会成为性能瓶颈?针对TPS很高的交易币对儿,你们怎么做的?
做流量拆分,撮合引擎还是单线程,但每出一次成交同步写两条流:
HotTickStream(只含成交价/量/方向)→ UDP多播,延迟 < 0.5 ms,给高频量化/内部风控;
FullTickStream(带全深度增量)→ 原Kafka分区,降低70%流量,立刻掉到10MB/s 以下
10.行情服务为啥为什么不用 TCP?WS会不会慢?
你可以这样回答:
WebSocket 本身是基于TCP的,对我们这种前台行情推送场景,
延迟瓶颈主要在业务处理和网络,而不是协议本身。
而且WS在浏览器兼容性、连接管理、运维成本上更可控,真正需要极致低延迟的内部链路,才会考虑定制TCP或更底层方案。
简历需要修改点
撮合(重点写、写深)
你可以写:
单线程撮合
redo log
主从切换
内存订单簿
撮合恢复钱包 / 资产(重点写)
全节点监听
6确认
幂等入账
防重放行情(只写“边界”)
只写:数据来源
顺序保证方式
隔离策略
不写:TCP 参数
批量合并
压缩算法
WS 内核调优