现货撮合面试手册
这份手册面向 Java 后端、交易系统和数字资产交易所岗位,重点覆盖现货撮合、限价单、市价单、资金风险控制、可靠性设计以及业务场景追问。
内容标记:
[必会]:必须能独立讲清楚,最好能画流程或写伪代码。[高频]:面试中很容易被追问,至少准备一个完整业务例子。[加分]:适合系统设计题或高级岗位,用来体现生产经验。
项目代码参考:
src/main/java/com/zood/cex/matching/domain/OrderBook.javasrc/main/java/com/zood/cex/matching/domain/SkipListPriceLevelIndex.javasrc/main/java/com/zood/cex/matching/engine/SymbolEngine.javasrc/main/java/com/zood/cex/matching/service/TradingPairMatchingService.javasrc/main/java/com/zood/cex/matching/service/SpotMatchingService.java时间不够时,按下面的顺序复习。
| 优先级 | 主题 | 至少要能回答 |
|---|---|---|
[必会] 1 | 价格优先、时间优先 | 为什么同价订单必须 FIFO,成交价取谁的价格 |
[必会] 2 | 限价单与市价单 | 能否立即成交、剩余数量是否入簿、如何控制滑点 |
[必会] 3 | GTC、IOC、FOK、POST_ONLY | 四者的成交与剩余处理有什么区别 |
[必会] 4 | 余额冻结与结算 | 下单冻结什么、部分成交扣什么、撤单解冻什么 |
[必会] 5 | 单交易对单线程 | 为什么不对订单簿做多线程并发写,如何横向扩展 |
[高频] 6 | 幂等与超时 | API 超时后为什么不能直接生成新订单号重试 |
[高频] 7 | 撮合失败处理 | WAL、订单状态、冻结资产、成交事件如何保证一致 |
[高频] 8 | 市价买单语义 | 按基础币数量买,还是按计价币金额买 |
[高频] 9 | 风险控制 | 价格笼子、精度、限额、频控、自成交保护 |
[加分] 10 | 恢复与消息可靠性 | WAL、快照、Outbox、下游幂等如何配合 |
一段 30 秒总述可以这样回答:
现货撮合的核心是维护买卖双边订单簿,按价格优先、同价时间优先执行。每个交易对由单线程撮合器顺序消费命令,避免并发写导致结果不确定。下单前资产服务先做校验和冻结,撮合器产生不可变的成交结果,结算服务再按成交明细转移冻结资产。限价 GTC 的剩余数量可以挂单,IOC 和市价单的剩余取消,FOK 要么全成要么拒绝,POST_ONLY 保证只做 maker。生产系统还要用 clientOrderId 幂等、WAL 恢复、Outbox 投递和价格保护处理超时与故障。
[必会]以 ETH-USDT 为例:
ETH 是基础币 base asset,订单数量通常用 ETH 表示。USDT 是计价币 quote asset,价格表示一枚 ETH 值多少 USDT。名义成交额:
TEXT1quoteAmount = price * baseQuantity
示例:以 3,000 USDT 的价格成交 2 ETH,成交额为 6,000 USDT。
[必会]Maker:订单先进入订单簿,为市场提供流动性。Taker:新订单立即与已有挂单成交,拿走流动性。POST_ONLY 的业务意义是确保订单不会立即吃单,常用于做市商控制手续费和策略身份。
[必会]订单簿分为:
bids 买盘:价格从高到低,最高买价是买一。asks 卖盘:价格从低到高,最低卖价是卖一。本项目使用两层结构:
TEXT1价格索引:price -> PriceLevel 2价格档内:FIFO queue<Order>
价格索引用跳表,平均查找、插入、删除复杂度为 O(log n);同一价格档内部用 FIFO 队列保证时间优先。
[必会]以新买单为例:
这样可以保证公平性、确定性和可回放性。只要输入命令顺序一致,撮合输出就应该一致。
[高频]通常使用 maker,也就是订单簿中已有挂单的价格。
例如卖盘中已有 100 USDT 的卖单,新买单限价 105 USDT,成交价是 100 USDT,买方获得了价格改善。买方多冻结的部分应在结算后释放。
面试中不要只回答“取买卖均价”。撮合规则必须明确、稳定且可回放,主流连续竞价通常按被动方价格成交。
[必会]TEXT1客户端下单 2 -> 网关鉴权、频控、生成请求上下文 3 -> 交易规则校验 4 -> 风控与资产冻结 5 -> 按 symbol 路由到撮合器 6 -> 写 WAL 7 -> 订单簿撮合 8 -> 产生订单事件和成交事件 9 -> 结算 available / frozen 10 -> Outbox 投递 RabbitMQ 11 -> 行情、订单、资产推送
需要主动说明:撮合与结算不是一回事。
[高频]可以把状态机简化为:
TEXT1NEW 2 -> OPEN 3 -> PARTIALLY_FILLED 4 -> FILLED 5 -> CANCELED 6 -> REJECTED 7 -> FAILED
业务规则:
REJECTED:业务条件不满足,例如余额不足、FOK 无法全成。FAILED:系统执行异常,例如内部依赖不可用且无法完成命令。CANCELED:订单曾经有效,但未成交剩余量被用户或系统取消。[高频]修改价格或增加数量会改变订单的竞争条件,通常应失去原来的时间优先级。因此常见实现是:
部分交易所允许减少数量时保留优先级,但这必须是明确的交易规则,不能由代码偶然决定。
[必会]限价买单表示成交价不能高于指定价格;限价卖单表示成交价不能低于指定价格。
交叉条件:
TEXT1限价买单:buyPrice >= bestAsk 2限价卖单:sellPrice <= bestBid
[必会]GTC,Good-Til-Canceled:
例子:卖盘有 100 x 1 ETH,用户提交 105 x 3 ETH 的限价买单:
1 ETH 以 maker 价 100 成交。2 ETH 以 105 进入买盘。[必会]IOC,Immediate-Or-Cancel:
它适合“现在尽量成交,但不要留下挂单”的场景。
[必会]FOK,Fill-Or-Kill:
实现 FOK 时,预检查和真正撮合必须在同一个交易对串行执行上下文中完成,否则预检查后盘口可能被其他线程修改。
[必会]面试追问:为什么不能先成交一部分再挂剩余?因为这样订单已经成为 taker,违反了只做 maker 的承诺。
[高频]限价买单通常冻结:
TEXT1frozenQuote = limitPrice * quantity + estimatedFee
限价卖单通常冻结:
TEXT1frozenBase = quantity
如果买单以更低的 maker 价成交,应按实际成交额扣减,多冻结的计价币释放回 available。部分成交后只继续冻结剩余挂单所需的金额和费用缓冲。
[必会]市价单不指定确定成交价格,而是从当前对手盘最优价开始逐档吃单,直到数量或金额满足、盘口耗尽,或者触发保护条件。
市价单不能进入订单簿。盘口不足时,剩余部分应取消或拒绝,不能变成“没有价格的挂单”。
[必会]市价卖通常容易定义:卖出指定的基础币数量,例如卖出 2 ETH。
市价买有两种常见 API 语义:
| 模式 | 用户输入 | 结束条件 | 示例 |
|---|---|---|---|
| 按基础币数量 | 要买多少 ETH | 累计成交数量达到目标 | 买入 2 ETH |
| 按计价币金额 | 最多花多少 USDT | 累计成交额达到预算 | 花 5,000 USDT 买 ETH |
[高频] 面试时要先确认接口语义,再讨论冻结逻辑。本项目当前 marketBuy 使用基础币数量;真实 CEX 前端也经常提供按 USDT 金额买入的模式。
[高频]市价卖:冻结要卖出的基础币数量。
按金额市价买:冻结用户输入的计价币预算和手续费缓冲。
按数量市价买:没有固定价格,不能简单使用最新成交价冻结。常见方案有:
保护价 * 数量 + 手续费缓冲。[必会]当前卖盘:
TEXT1100 x 1 ETH 2101 x 2 ETH 3105 x 5 ETH
市价买入 4 ETH:
1 ETH 成交于 100。2 ETH 成交于 101。1 ETH 成交于 105。407 USDT。101.75 USDT。最新成交价或卖一价不等于最终平均成交价。大额市价单会消耗多档流动性,因此必须考虑滑点。
[高频]不要回答“市价单可以不计价格一直成交”。生产系统必须给市价单设置边界,否则薄盘口或异常挂单可能造成极端成交。
[必会]TEXT1submit(taker): 2 validate(taker) 3 handle POST_ONLY and FOK 4 oppositeBook = taker is BUY ? asks : bids 5 6 while taker has remaining quantity and oppositeBook is not empty: 7 bestLevel = oppositeBook.firstEntry() 8 if limit order cannot cross bestLevel.price: 9 break 10 11 while taker has remaining quantity and bestLevel is not empty: 12 maker = bestLevel.peek() 13 qty = min(taker.remaining, maker.remaining) 14 create trade at maker.price 15 decrease both orders 16 remove fully-filled maker 17 18 remove empty price level 19 20 handle taker remaining quantity by timeInForce
[高频]设订单簿有 n 个价格档,本次成交击穿 k 笔 maker 订单:
O(log n)。O(1)。O(k),再加价格档维护成本。d 档约为 O(d)。不要只说“撮合是 O(log n)”。真正成交必须逐笔生成 trade,至少要访问被成交的 k 笔订单。
[高频]O(log n)。生产中也可以使用红黑树、基数结构、数组化价格网格或专门设计的内存结构。选型要结合价格精度、价格范围、订单规模和延迟目标。
[必会]同一个订单簿的修改具有严格顺序。单线程 actor 的优势:
横向扩展方式不是给同一本订单簿增加写线程,而是按交易对分片:
TEXT1BTC-USDT -> engine A 2ETH-USDT -> engine B 3SOL-USDT -> engine C
热门交易对可独占进程或 CPU 核;冷门交易对可以共享分片。单个超热门交易对通常通过更高效的数据结构、批处理、绑核和减少分配来纵向优化,而不是随意让多个线程同时改簿。
[加分]“时间优先”依赖一个权威顺序,不能直接相信客户端时间。常见做法是由网关、日志系统或撮合入口分配单调递增 sequence,再由交易对撮合器按 sequence 消费。
多实例部署时还要保证同一 symbol 只有一个有效 leader,避免双写订单簿。可以通过一致性路由、分区日志和租约或 fencing token 防止脑裂。
[必会]至少包括:
风控通过不代表一定成交,只代表订单有资格进入撮合系统。
[必会]账户至少要区分:
available:可用于新下单或提现的余额。frozen:已被挂单占用,不能重复消费的余额。限价买单冻结示例:
TEXT1下单:available USDT -= 1,000 2 frozen USDT += 1,000 3 4成交:frozen USDT -= actualTradeAmount + fee 5 available ETH += filledQuantity 6 7撤单:frozen USDT -= unusedAmount 8 available USDT += unusedAmount
所有余额变化都应写不可变账本流水,并带业务幂等键。缓存中的余额不是最终审计凭证。
[必会]订单每产生一笔成交,结算服务按 trade 处理:
结算接口必须以 tradeId 幂等。RabbitMQ 重投同一成交事件时,不能重复加减余额。
[高频]同一用户或同一实际控制账户的买卖单相互成交可能制造虚假成交量。常见策略:
CANCEL_TAKER:取消新进入的 taker。CANCEL_MAKER:取消订单簿中的 maker。CANCEL_BOTH:双方都取消。DECREMENT_AND_CANCEL:抵消可自成交数量但不产生真实 trade。策略应在撮合器内部执行,因为只有撮合器知道当前 maker 和 taker 的归属。
[高频][业务高频]这通常不是撮合规则,而是资金安全和合规策略:
交易所可能允许站内交易但禁止提现,也可能直接冻结可用余额。面试时要区分 交易冻结、提现冻结 和 链上确认中,不能笼统地说“区块链到账慢”。
[必会]客户端等待超时时,命令可能:
因此客户端不能换一个订单号盲目重试。应使用相同 clientOrderId 查询或重试,服务端以:
TEXT1symbol + userId + clientOrderId
作为幂等键,返回同一笔订单结果。
[必会]WAL 先记录撮合命令,再修改内存订单簿。进程重启后按顺序回放命令,恢复相同状态。
关键点:
[高频]撮合或结算落库与消息发送不能依赖“先写数据库,再直接发 MQ”的双写侥幸。常见模式:
eventId 或 tradeId 幂等消费。Outbox 通常保证至少一次投递,不保证天然只有一次。所谓 exactly-once 业务效果来自幂等、唯一约束和状态机,而不是假设 MQ 永不重复。
[业务高频]不能回滚已经向市场公开的成交,也不能删除 trade。正确方向是:
tradeId。[高频]恢复步骤:
严格系统会先进入只撤单或停止交易状态,确认恢复完成后再开放下单。
[业务高频]先观察队列水位、p95/p99 延迟、处理速率、拒绝数和最近处理时间,再采取:
扩容不能简单启动第二个实例同时处理同一订单簿,必须先解决 symbol 所有权和顺序问题。
[必会]答题要点:
[必会]根据产品规则:
绝不能把剩余 3 ETH 作为无价格挂单放进订单簿。
[必会]客户端为一次业务意图生成稳定的 clientOrderId。服务端对 symbol + userId + clientOrderId 建幂等约束:
[必会]用户应携带相同 clientOrderId 重试或查询订单。服务端返回第一次执行的订单,不能再撮合一次。504 只代表调用方没有及时拿到结果,不代表撮合命令一定失败。
[高频][高频]这说明边界设计有问题。撮合热路径通常以有序日志或 WAL 作为权威输入,数据库订单表是事件投影,而不是让内存状态与普通数据库事务互相回滚。短期可以停止交易对并恢复重放,长期应通过日志、Outbox 和幂等投影消除这类不一致窗口。
[高频]成交命令和撤单命令进入同一 symbol 队列,由 sequence 决定先后:
不能让 HTTP 线程直接从订单簿删除订单,否则会和撮合线程竞态。
[高频]在正确的单线程撮合上下文中,预检查和执行之间没有其他订单簿写命令插入,所以应当全成。如果把预检查放到撮合器外部,盘口可能变化,就会破坏 FOK 语义。
[必会]按本项目规则整笔拒绝。另一种产品规则是自动调整到不会成交的价格,但必须在接口文档中明确。无论哪种方案,都不能让订单先吃单再声称它是 maker。
[高频]撮合时比较 maker 与 taker 的用户、账户组或受益所有人标识,根据 STP 策略取消 taker、maker 或双方。只比较 userId 可能不足,因为子账户和关联账户也可能属于同一控制人。
[加分]可能正常,因为订单、成交、深度属于不同事件投影,网络和消费者延迟不同。解决思路:
[高频]多个层次都要负责:
不能把全部责任推给用户“选择了市价单”。
[必会]消费者以 tradeId 或 eventId 做唯一约束,在同一数据库事务中检查并写入账本。重复事件返回已处理结果,不再次变更余额。ack 必须发生在业务事务成功之后。
[加分]如果使用事务 Outbox,短时 MQ 故障不必立即停止撮合:事件先可靠落库,MQ 恢复后补发。但要监控 outbox 积压;如果积压导致订单、结算或行情无法在风险容忍时间内完成,就应降级或暂停交易。
[加分]不能草率处理。超时不代表外部订单一定未成交,直接挂回可能造成双重成交。应查询外部订单状态,使用外部 clientOrderId 幂等,并在状态不确定时冻结剩余量、进入人工或异步对账,直到确认最终状态。
[必会]| 类型 | 可立即成交 | 可部分成交 | 剩余入簿 | 无法满足时 |
|---|---|---|---|---|
| GTC | 是 | 是 | 是 | 保留剩余量 |
| IOC | 是 | 是 | 否 | 取消剩余量 |
| FOK | 是 | 否 | 否 | 整笔拒绝或取消 |
| POST_ONLY | 否 | 否 | 是 | 会吃单则拒绝 |
[高频]| 阶段 | 核心问题 | 主要产物 |
|---|---|---|
| 撮合 | 谁和谁以什么价格成交多少 | trade |
| 清算 | 双方应收、应付、手续费是多少 | settlement instruction |
| 结算 | 账户余额最终如何记账 | ledger entries、balance |
[高频]| 状态 | 含义 | 例子 |
|---|---|---|
| REJECTED | 订单不满足业务规则,未成为有效订单 | 余额不足、FOK 深度不足 |
| CANCELED | 有效订单的未成交部分被终止 | 用户撤单、IOC 剩余取消 |
| FAILED | 系统执行异常,结果需查询或补偿 | 内部服务异常、持久化失败 |
题目:“设计一个支持百万用户的现货撮合系统。”
建议按下面顺序回答,避免一上来只讲 Redis 或 MQ:
[必会][必会]TEXT1API Gateway / Auth 2 -> Order Service / Risk 3 -> Asset Freeze 4 -> Symbol Router 5 -> Matching Engine 6 -> Trade Log 7 -> Settlement / Ledger 8 -> Market Data / User Push
[必会][必会][高频][加分]关键指标:
可以这样介绍:
我把订单簿拆成价格索引和同价 FIFO 两层。买盘使用倒序 comparator,卖盘使用正序 comparator,所以双方都能用 firstEntry 取得最优价。价格档默认用跳表,撮合过程逐笔生成 trade,并在订单完全成交后删除队列节点和空价格档。
对应代码:OrderBook、SkipListPriceLevelIndex、PriceLevel。
可以这样介绍:
HTTP 请求可以并发到达,但 SpotMatchingService 会按 symbol 路由到 SymbolEngine。每个 SymbolEngine 使用单线程队列执行下单、撤单、改单和快照命令,因此 OrderBook 不需要方法级 synchronized。不同交易对可以并行,同一个交易对的结果保持确定。
对应代码:SpotMatchingService、SymbolEngine。
可以这样介绍:
下单使用 symbol、userId、clientOrderId 组成业务幂等键。撮合命令先写 WAL 再修改内存订单簿,API 超时后客户端可以使用同一个 clientOrderId 重试。恢复时回放 WAL 重建订单簿,但不会重复发布历史成交事件。
对应代码:TradingPairMatchingService、FileMatchingWriteAheadLog。
[加分]教学项目已经体现核心设计,但面试中应主动说明生产化还需要:
能指出边界比把教学实现包装成“生产可用”更有说服力。
[必会] 市价单不是“按最新价成交”,而是按盘口逐档成交。[必会] API 超时不等于订单失败,必须依靠幂等查询或重试。[必会] FOK 不能先部分成交再回滚,因为已经产生的成交可能已被市场观察。[必会] 市价单剩余量不能进入订单簿。[必会] 撮合成功不代表资金结算已经完成。[高频] 同一交易对单线程不等于整个交易所只有一个线程。[高频] MQ 的至少一次投递要求消费者幂等,不要只说“设置不重复消费”。[高频] Redis 锁不能天然解决订单簿顺序、一致性和回放问题。[高频] 余额表不是账本,审计和补偿必须依赖不可变流水。[加分] 外部流动性超时是未知状态,不能直接当成失败重新下单。重点完成:
不用看资料,独立画出:
TEXT1下单 -> 冻结 -> 路由 -> WAL -> 撮合 -> trade -> 结算 -> Outbox -> 推送
然后逐段回答“这里失败会怎样、重试会不会重复”。
至少口述第 9 节的 Q1、Q2、Q4、Q5、Q7、Q12、Q13。每个答案都按以下结构组织:
TEXT1先给结论 2-> 再讲状态如何变化 3-> 再讲资金如何变化 4-> 再讲幂等与异常处理 5-> 最后补监控和生产边界
能不看答案连续讲清下面问题,现货撮合主线就比较稳了: