大宗商品电子盘这套东西,外行看热闹觉得就是个"能下单的网页",真上手做过的人都知道,它跟普通的电商交易系统完全是两码事。电商卖的是标准品,库存扣减、订单状态流转就完事了;而大宗商品电子盘要同时处理挂牌、撮合、行情推送、保证金、逐笔盈亏、交收匹配这一整条链路,任何一个环节的时序错乱,轻则用户看到的价格和成交价对不上,重则结算数据全盘失真。我前后参与过几个类似系统的搭建,从最初用现成框架硬套、被行情延迟和并发撮合折磨得够呛,到后来把架构拆清楚、把关键路径一个个抠明白,中间踩的坑足够写一本小册子。
这篇内容我想把"大宗商品电子盘交易软件开发"这件事从头到尾捋一遍,重点不在堆概念,而在于讲清楚为什么这么设计、哪些地方最容易翻车、具体怎么落地。适合正在做或准备做这类系统的后端开发、架构师,也适合想理解交易系统底层逻辑的产品和技术负责人。哪怕你之前只做过普通业务系统,跟着往下看也能建立起完整的认知框架——我会尽量用生活化的类比把撮合、行情、结算这些听起来唬人的词讲透。
1. 先搞清楚电子盘到底在交易什么
很多人一上来就问"用什么技术栈",这其实是把顺序搞反了。技术选型是结果,业务模型才是源头。大宗商品电子盘和股票交易所、和普通电商,三者的业务骨架差别很大,不先把这层想明白,后面写出来的代码一定是拧巴的。
1.1 它和股票交易、普通电商的本质区别
股票交易是"标准化合约 + 集中撮合 + T+1交收",标的物是抽象的股权凭证,没有实物交割的烦恼。普通电商是"商品展示 + 下单 + 物流",核心是订单履约。而大宗商品电子盘夹在中间:它交易的往往是钢材、化工品、农产品这类有实物、有品质等级、有交割仓库的东西,既要像交易所那样做价格发现和撮合,又要像电商那样处理实物的交收和仓储。
这个"夹在中间"的定位决定了几件事。第一,合约要素比股票复杂得多,同一个品种要区分品级、交割地、交割月份,这些要素共同构成一个可交易的"合约",而不是简单一个代码。第二,保证金和杠杆是刚需,因为大宗商品价格波动大、单笔金额高,全额付款不现实,必须引入保证金制度来控制风险。第三,交收环节不能省,电子盘上成交只是开始,后面还有配对、验货、仓单流转、货款结算一整套流程。
我见过有团队直接拿股票撮合的开源代码改,结果卡在交收模块上——股票根本不需要实物交收,那套代码里压根没有这个概念。所以第一步一定是把业务模型画清楚,而不是急着找轮子。
1.2 核心业务对象的关系梳理
把电子盘拆开,核心业务对象其实就那么几个,理清它们的关系,整个系统的骨架就出来了:
| 业务对象 | 作用 | 关键属性 | 与其他对象的关系 |
|---|---|---|---|
| 交易品种 | 定义可交易的大类 | 品种代码、名称、报价单位、最小变动价位 | 一对多关联合约 |
| 交易合约 | 真正被买卖的标的 | 合约代码、品级、交割地、交割月、保证金率 | 属于某品种,关联行情与订单 |
| 交易账户 | 资金与持仓的载体 | 账户号、可用资金、冻结资金、持仓明细 | 一对多关联订单与持仓 |
| 委托订单 | 买卖意图的表达 | 方向、价格、数量、类型、状态 | 属于账户,进入撮合队列 |
| 成交记录 | 撮合的结果 | 成交价、成交量、买卖双方、成交时间 | 由两笔订单撮合产生 |
| 持仓 | 账户当前持有的合约头寸 | 多空方向、数量、均价、浮动盈亏 | 由成交累积而成 |
| 仓单 | 实物交收的凭证 | 仓库、品级、数量、状态 | 关联交收流程 |
这张表看着简单,但每一行背后都有坑。比如"最小变动价位"这个属性,它决定了报价的精度,如果系统里用浮点数存价格,撮合时两个本该相等的价格因为精度误差对不上,就会出现"明明价格到了却不成交"的诡异现象。再比如"持仓均价",是用加权平均还是移动平均,直接影响浮动盈亏的计算结果,进而影响保证金占用和强平判断,这个后面会专门讲。
1.3 一笔交易从下单到交收的完整生命周期
理解生命周期是设计状态机的前提。一笔交易在电子盘里大致走这么一条路:
- 下单:用户在客户端提交委托,系统校验账户状态、资金/持仓是否充足、价格是否在涨跌停范围内、合约是否在交易时段。
- 冻结:校验通过后冻结相应资金(买入)或持仓(卖出),防止同一笔钱被重复使用。
- 入队:订单进入撮合引擎的待撮合队列,按价格优先、时间优先排序。
- 撮合:引擎持续匹配买卖队列,产生成交记录。
- 成交回报:成交结果推送给买卖双方,更新订单状态、持仓、资金。
- 结算:日终(或实时)计算盈亏、手续费、保证金占用,生成结算单。
- 交收:进入交割月的持仓按规则配对,完成仓单和货款的转移。
这条链路上,冻结和撮合是两个最容易出问题的地方。冻结如果做得不严谨,会出现超卖超买;撮合如果时序不对,会出现同一笔资金被两笔订单同时占用。我在实际项目里就遇到过因为冻结逻辑用了"先查后改"的非原子操作,在并发下导致账户可用资金变成负数的情况,排查了大半天才定位到。
2. 撮合引擎:整个系统的心脏
如果说电子盘是一个人,撮合引擎就是心脏,它的性能和正确性直接决定系统能不能用。这一块我花的时间最多,也最有心得。
2.1 价格优先、时间优先到底怎么落地
撮合的核心规则说起来就八个字:价格优先、时间优先。买盘里出价高的优先成交,卖盘里要价低的优先成交,价格相同则先挂单的先成交。听起来简单,但要用代码高效地实现,数据结构的选择很关键。
最朴素的做法是用一个数组存所有订单,每次撮合遍历一遍找最优对手价。这个方案在订单量小的时候能跑,但订单一多,每次撮合的复杂度是 O(n),延迟会肉眼可见地上升。正确的做法是用有序数据结构维护买卖两个队列:
- 买盘用一个按价格降序排列的结构,价格相同的按时间升序;
- 卖盘用一个按价格升序排列的结构,价格相同的按时间升序。
这样每次撮合只需要看两个队列的队首,比较价格是否交叉(买价 ≥ 卖价),能成交就撮合,不能成交就停。复杂度降到 O(log n) 甚至 O(1)(用堆或跳表)。我用过跳表(SkipList)来实现价格档位,好处是插入、删除、查找都是 O(log n),而且支持按价格范围快速遍历,很适合做"盘口深度"的展示。
这里有个细节值得说:同一价格档位下的订单要维护成一个 FIFO 队列,因为时间优先要求同价先到先得。所以整体结构是"价格档位的有序集合 + 每个档位内的订单链表",这个组合能同时满足价格优先和时间优先。
2.2 限价单、市价单与各种委托类型的处理差异
电子盘上常见的委托类型有限价单、市价单,有些还会支持止损单、冰山单等。不同类型的处理逻辑差别不小,不能一锅烩。
限价单是最基础的,指定价格和数量,只有达到或优于该价格才成交。它的处理最直接:入队、等待撮合。
市价单不指定价格,要求立即以当前最优价格成交。它的坑在于——如果对手盘深度不够,市价单可能只成交一部分,剩下的怎么办?常见做法是"剩余撤销"或"剩余转限价"。我倾向于默认剩余撤销,因为市价单的语义就是"尽快成交",留着未成交部分反而违背用户意图。但这里必须给用户明确的提示,否则用户以为全成交了,结果只成交一半,容易引发纠纷。
止损单是条件单,当价格触及某个触发价时才转为普通委托。它的实现难点在于触发判断的实时性:行情每跳动一次,都要检查所有挂着的止损单是否被触发。如果止损单数量大,每次行情都全量扫描会很慢,通常要按触发价建索引,只检查被行情穿越的那部分。
下面是一段简化的限价单撮合核心逻辑,用 Python 示意(生产环境肯定要用更低延迟的语言,但逻辑是通用的):
def match(order_book, incoming_order): trades = [] opposite = order_book.sell if incoming_order.side == 'buy' else order_book.buy while incoming_order.remaining > 0 and opposite.has_best(): best = opposite.peek_best() # 判断价格是否交叉 if incoming_order.side == 'buy' and incoming_order.price < best.price: break if incoming_order.side == 'sell' and incoming_order.price > best.price: break # 撮合 deal_qty = min(incoming_order.remaining, best.remaining) trades.append(Trade( price=best.price, qty=deal_qty, buy_order=incoming_order if incoming_order.side == 'buy' else best, sell_order=best if incoming_order.side == 'buy' else incoming_order, )) incoming_order.remaining -= deal_qty best.remaining -= deal_qty if best.remaining == 0: opposite.pop_best() return trades这段代码的关键点在于:成交价取的是被动方(先挂单方)的价格,这是交易所的通行规则,因为先挂单的人提供了流动性,理应享受价格保护。很多新手会误取主动方的价格,导致成交价对一方不利,这是必须避免的。
2.3 撮合的时序与并发:为什么单线程反而更快
这是最反直觉的一点:撮合引擎往往用单线程实现,比多线程更快也更安全。
原因在于,撮合的本质是维护一个全局有序的订单队列,任何并发修改都会破坏这个顺序。如果用多线程同时撮合,就必须加锁,而锁的争用和上下文切换开销,在高频场景下比单线程串行处理还大。更麻烦的是,多线程下很难保证"同一账户的订单按时间顺序处理",容易出现时序错乱。
所以主流做法是:撮合引擎单线程串行处理,把并发的压力挡在引擎之外。具体来说,所有下单请求先进入一个队列(比如 RingBuffer 或消息队列),撮合线程从队列里顺序取出、顺序撮合、顺序产出成交。这样既保证了顺序性,又通过"生产者-消费者"模型把网络 IO、校验等耗时操作和撮合并行开了。
我在一个项目里试过多线程撮合,本意是想提高吞吐,结果发现锁竞争严重,而且偶尔出现成交顺序和订单到达顺序不一致,排查起来极其痛苦。后来改回单线程 + 前置队列,吞吐反而上去了,延迟也更稳定。这个教训让我明白:在强顺序要求的场景下,串行往往是最优解。
2.4 撮合结果的持久化与故障恢复
撮合产生了成交,这些成交必须可靠落盘,否则一旦宕机,内存里的成交数据就丢了,账目对不上。这里的设计要点是先持久化、后回报,还是先回报、后持久化?
我的经验是:成交先写日志(WAL,预写日志),再更新内存状态,最后推送回报。WAL 的好处是,即使更新内存后宕机,重启时也能通过重放日志恢复出完整的成交记录。这跟数据库的 redo log 是一个思路。
具体落地时,撮合线程每产生一批成交,就顺序追加到日志文件(顺序写,性能好),然后异步刷盘。回报推送可以异步做,因为即使回报丢了,用户重新查询也能拿到正确结果,但成交记录本身不能丢。这个"日志优先"的原则,是保证账目最终一致的关键。
3. 行情推送:让用户看到"实时"的价格
行情是电子盘的门面,用户盯着屏幕看的就是那一跳一跳的价格。行情做不好,用户会觉得系统"卡"、价格"假",信任度直接崩。这一块的难点不在计算,而在推送的实时性和一致性。
3.1 行情数据的产生:从成交到盘口
行情不是凭空来的,它来源于撮合产生的成交和订单簿的变化。一条完整的行情通常包含:最新价、成交量、买卖各五档(或十档)的价和量、涨跌幅、最高最低价等。
盘口(买卖档位)的生成逻辑是:从撮合引擎的订单簿里,取买盘价格最高的 N 档和卖盘价格最低的 N 档,每档汇总该价格下的订单总量。这里要注意档位聚合的实时性——每来一笔成交或新挂单,盘口都可能变化,需要及时重算并推送。
我踩过的一个坑是:盘口重算放在了撮合线程里同步做,结果撮合一忙,盘口就更新不及时,用户看到的价格滞后。后来把盘口计算拆到独立的线程,撮合线程只负责把"订单簿变更事件"丢进队列,盘口线程消费事件、重算、推送,两者解耦,问题就解决了。
3.2 推送协议选型:WebSocket 还是长轮询
行情推送对实时性要求高,传统的 HTTP 轮询(客户端每隔几百毫秒问一次)延迟大、浪费带宽,不适合。主流方案是WebSocket,建立一条长连接,服务端有更新就主动推。
WebSocket 的优势是双向、低开销、延迟低。但要注意几个工程细节:
- 心跳保活:长连接容易被中间设备断开,需要定期发心跳包检测,断了要能自动重连。
- 消息压缩:行情数据量大、频率高,开启 permessage-deflate 压缩能显著降低带宽。
- 增量推送:不要每次都推全量盘口,只推变化的部分(比如某一档的量和价变了),客户端自己合并。全量推送在高频下会把带宽吃满。
有些场景下(比如客户端不支持 WebSocket),可以用 SSE(Server-Sent Events)作为备选,它是单向的服务端推送,实现比 WebSocket 简单,但只支持文本、不支持二进制,适合轻量场景。
3.3 行情快照与增量更新的合并逻辑
这是行情推送里最容易出错的地方。服务端推的是增量,客户端要维护一份本地快照,把增量合并进去。如果合并逻辑有 bug,客户端显示的价格就会和服务端不一致。
正确的做法是给每条行情消息带一个序列号(seq),客户端按序列号顺序合并,发现序列号不连续(中间丢了消息)就主动请求一次全量快照重新同步。这个机制叫"快照 + 增量 + 序列号校验",是行情推送的标准套路。
我在实际项目里见过因为没做序列号校验,网络抖动丢了一条增量消息,导致客户端盘口一直错位,用户投诉"价格不对",查了半天才发现是丢包。加上序列号校验和自动重同步之后,这类问题基本绝迹。
3.4 高并发下的行情扇出:一个价格推给一万个人
行情的特点是"一份数据、多方消费"——同一个合约的价格,可能要推给成千上万个在线用户。如果每个用户连接都单独计算、单独序列化,CPU 会被打满。
优化的思路是扇出(fan-out)分离:行情计算线程算出增量后,序列化一次(转成字节流),然后把同一份字节流广播给所有订阅了该合约的连接。这样序列化和计算只做一次,广播是内存拷贝,效率高得多。
再进一步,可以用发布-订阅模型:维护"合约 → 订阅者列表"的映射,行情更新时只遍历订阅了该合约的连接。配合连接分组、批量发送(把多条小消息攒成一个包发),能显著降低系统调用次数。这些优化在用户量上来之后是必须做的,否则行情服务会成为整个系统的瓶颈。
4. 资金、保证金与结算:账目不能差一分钱
交易系统最不能容忍的就是账目出错。用户的钱、持仓、盈亏,每一分都要对得上。这一块的复杂度被很多人低估,觉得"不就是加减法吗",实际上保证金制度、逐笔盈亏、强平逻辑交织在一起,稍有不慎就是资金漏洞。
4.1 保证金制度的计算模型
保证金是杠杆交易的基础。用户不需要付全款,只需按合约价值的一定比例缴纳保证金就能持有头寸。计算模型大致是:
持仓保证金 = 持仓数量 × 合约乘数 × 最新价 × 保证金率
举个具体例子:某合约乘数是 10(一手代表 10 吨),保证金率 10%,最新价 4000 元/吨,用户持有 5 手多头。那么持仓保证金 = 5 × 10 × 4000 × 10% = 20000 元。也就是说,用户账户里至少要有 2 万元被冻结作为保证金。
这里的关键参数是保证金率,它可能随合约、随持仓量、随市场风险动态调整(比如临近交割月提高保证金率)。系统必须支持保证金率的配置和动态更新,而且更新后要能重算所有持仓的保证金占用。我见过有系统把保证金率写死在代码里,结果交易所调整规则时只能改代码重新发版,非常被动。
4.2 逐笔盈亏与浮动盈亏的计算差异
盈亏计算有两种口径,很多人分不清:
- 浮动盈亏:按当前最新价计算的、尚未平仓头寸的盈亏,是"账面"的,随价格实时变化。
- 逐笔盈亏(也叫平仓盈亏):平仓时,按开仓价和平仓价的差额计算的、已经实现的盈亏。
多头浮动盈亏 = (最新价 - 持仓均价)× 持仓数量 × 合约乘数;空头则反过来。而平仓盈亏是在平仓那一刻,用(平仓价 - 开仓价)× 平仓数量 × 合约乘数算出来的。
这两个口径必须严格区分,因为浮动盈亏影响保证金占用和强平判断,平仓盈亏影响账户权益。如果把两者混用,会出现"账户明明盈利却被强平"或者"亏损了却没触发风控"的荒唐情况。我在代码 review 时特别关注这块,要求盈亏计算必须有独立的单元测试覆盖多空、开平各种组合。
4.3 强平触发的判定链路
当用户账户的权益低于维持保证金时,系统要触发强制平仓(强平),防止穿仓。判定链路大致是:
- 实时(或定时)计算账户的动态权益= 静态权益 + 浮动盈亏 - 手续费。
- 计算维持保证金= 持仓数量 × 合约乘数 × 最新价 × 维持保证金率。
- 若动态权益 < 维持保证金,标记账户为"风险",进入强平队列。
- 强平引擎按规则(先平亏损最大的、或按比例平)生成平仓单,送入撮合。
这条链路里,风险计算的频率是个权衡点。算得太频繁,CPU 压力大;算得太稀疏,可能价格剧烈波动时来不及强平,导致穿仓。常见做法是行情每次跳动都触发一次轻量级风险检查(只算权益和保证金,不做复杂逻辑),发现风险再进入详细处理。
强平还有个伦理问题:强平单要不要走正常撮合?我的观点是必须走,否则强平价可能严重偏离市场价,对用户不公平。但强平单可以标记优先级,在同等价格下优先成交,保证风险能及时释放。
4.4 日终结算与对账的工程实现
日终结算是一天的收尾工作:把当天的成交、盈亏、手续费、保证金全部轧差,生成每个账户的结算单,并把持仓结转至下一交易日。这一步必须幂等——万一结算过程中断了,重跑不能重复扣钱。
实现上,结算任务通常设计成"可重入"的:先检查当天是否已结算(用结算批次号或状态标记),已结算则跳过;结算过程用事务包裹,要么全成功要么全回滚。结算完成后,还要做对账:把系统内的账户总权益、总持仓,和成交记录汇总出来的理论值比对,差异超过阈值就告警。
对账这个环节千万别省。我经历过一次因为手续费计算的一个边界 bug(某类订单手续费少算了一分钱),单看每笔都正常,但对账时发现总额差了,才顺藤摸瓜找到问题。如果没有对账,这个 bug 可能潜伏几个月,最后变成一笔糊涂账。
5. 技术选型与架构分层:别为了时髦买单
聊完业务,再说技术。电子盘系统的技术选型有几个硬约束:低延迟、高可靠、强一致。围绕这三点,架构怎么分层、语言怎么选、中间件怎么用,都有讲究。
5.1 撮合与行情为什么偏爱 C++/Rust/Go
撮合和行情是延迟敏感路径,对语言的要求是"可控的延迟、可控的内存"。Java 有 GC(垃圾回收),虽然现代 GC 已经很快,但在极端高频场景下,GC 停顿(STW)仍可能造成毫秒级抖动,这对撮合来说是不可接受的。所以核心撮合常用 C++ 或 Rust,它们没有 GC,内存布局可控,延迟稳定。
Go 是个折中选项:开发效率高、并发模型好,GC 停顿已经优化到亚毫秒级,对于中等频率的电子盘够用。如果团队没有 C++ 积累,用 Go 做撮合是务实的选择。我个人的经验是:延迟要求到微秒级选 C++/Rust,到毫秒级选 Go,再往上 Java 也能接受。不要盲目追求最快,要看业务实际需要和团队能力。
5.2 业务层与撮合层的解耦设计
一个健康的架构应该是分层的,撮合层只做撮合,不掺和业务逻辑:
| 层次 | 职责 | 技术选型建议 | 关键要求 |
|---|---|---|---|
| 接入层 | 连接管理、协议解析、限流 | Go / Netty | 高并发连接 |
| 业务层 | 账户校验、风控、订单管理 | Java / Go | 事务一致性 |
| 撮合层 | 订单匹配、成交生成 | C++ / Rust / Go | 低延迟、强顺序 |
| 行情层 | 盘口计算、行情推送 | Go / C++ | 高扇出、低延迟 |
| 数据层 | 持久化、查询、报表 | 关系库 + 时序库 | 可靠、可追溯 |
层与层之间通过消息队列或共享内存通信,而不是直接函数调用。这样撮合层可以独立部署、独立扩容,业务层的故障也不会拖垮撮合。我见过把撮合和业务写在一个进程里的系统,业务层一个慢查询就把撮合卡住了,这是架构上的大忌。
5.3 数据库选型:关系库、时序库与内存库的分工
电子盘的数据分几类,各用各的库最合适:
- 账户、订单、持仓、结算单:强事务、强一致,用关系型数据库(如 PostgreSQL、MySQL),配合事务保证资金安全。
- 行情历史、成交明细:写多读少、按时间查询,用时序数据库(如 InfluxDB、TDengine)或列式存储,压缩率高、查询快。
- 实时盘口、热点持仓:要求极低延迟读写,放内存数据库(如 Redis)或进程内缓存。
这里要提醒一点:不要把资金数据放 Redis 当唯一存储。Redis 是缓存,持久化不是它的强项,一旦丢数据,资金账目就毁了。资金数据必须以关系库为准,Redis 只能做加速查询的副本。
5.4 消息中间件在订单流转中的角色
订单从接入到撮合、从成交到结算,中间要经过多个环节,用消息中间件(如 Kafka、RocketMQ)串联,好处是解耦、削峰、可回溯。
比如下单请求先进 Kafka 的订单 topic,撮合层消费;成交结果进成交 topic,结算层、行情层、回报层各自消费。这样任何一层慢了,消息在队列里堆积,不会阻塞上游。而且消息持久化后可以重放,便于排查问题和数据修复。
但要注意消息的顺序性。同一账户的订单必须按顺序处理,否则会出现"后下的单先成交"的乱象。Kafka 保证分区内有序,所以可以按账户 ID 做分区键,让同一账户的消息落到同一分区,从而保证顺序。这个设计细节很关键,用错了会导致时序错乱。
6. 那些文档里不会写的踩坑经验
前面讲的都是"应该怎么做",这一节讲"实际做的时候会怎么翻车"。这些是我和同行们用真金白银的教训换来的,希望能帮你少走弯路。
6.1 浮点数存价格:一个必须避开的陷阱
前面提过一嘴,这里展开说。价格如果用 float/double 存,会出现精度问题:0.1 + 0.2 在浮点里不等于 0.3。撮合时比较价格、计算金额,一旦有精度误差,就会出现"价格相等却不成交""金额差一分钱"的问题。
正确做法是用整数存价格,以最小变动价位为单位。比如最小变动价位是 1 元,价格 4000 就存整数 4000;如果最小变动价位是 0.01 元,就存 400000(放大 100 倍)。所有计算都用整数,最后展示时再除以放大倍数。金额同理,用"分"或更小的单位存整数。这个原则叫"定点数运算",是金融系统的铁律。
我接手过一个项目,价格用的 double,上线后偶尔出现撮合不上的问题,查了很久才发现是精度误差导致两个本该相等的价格差了 1e-10。改成整数存储后,问题消失。这个坑太经典了,必须放在第一条。
6.2 冻结资金与可用资金的并发安全
下单时要冻结资金,逻辑是"检查可用资金是否充足 → 扣减可用、增加冻结"。如果这两步不是原子的,并发下就会出问题:两个请求同时检查,都发现资金够,然后都扣减,结果可用资金变成负数,相当于凭空多出了钱。
解决办法是用数据库的行锁或乐观锁保证原子性。关系库可以用UPDATE account SET available = available - ? WHERE id = ? AND available >= ?,靠 WHERE 条件保证不会扣成负数,影响行数为 0 就说明资金不足。或者用乐观锁版本号,更新时校验版本。Redis 里可以用 Lua 脚本保证检查和扣减的原子性。
这个坑的隐蔽性在于:单线程测试永远发现不了,一上并发就暴露。所以资金相关的操作,必须做并发压测,而且要专门构造"同一账户并发下单"的场景。
6.3 行情延迟与撮合延迟的监控盲区
系统上线后,用户抱怨"卡",但你查日志发现每个接口响应都很快,问题出在哪?往往是延迟藏在链路中间:订单从客户端发出到撮合完成,中间经过网络、接入层、队列、撮合,每一段都可能积压,但单看某一段都正常。
解决办法是全链路打点:给每笔订单分配一个唯一 ID,在接入、入队、撮合、回报各个节点记录时间戳,最后算出端到端延迟和各段耗时。这样一旦延迟升高,能立刻定位是哪一段的问题。监控大盘上要能看到 P50、P99、P999 延迟,而不只是平均值——平均值会掩盖长尾问题,而用户体验恰恰由长尾决定。
我吃过这个亏:系统平均延迟 5ms,看着很好,但 P99 到了 500ms,1% 的用户在下单时明显卡顿。加了分位监控后才发现是队列在高峰期积压,扩容队列消费者后解决。
6.4 交割月临近时的规则突变处理
大宗商品合约临近交割月,规则会变:保证金率提高、涨跌停幅度调整、持仓限额收紧、甚至强制平仓。这些规则变化如果系统不支持热更新,就得停机改配置,而交割月恰恰是最不能停机的时期。
设计上要把这些规则做成可配置、可热加载的。保证金率、涨跌停、限额这些参数存在配置中心或数据库里,系统定时拉取或监听变更,变更后立即生效。同时要有"规则生效时间"的概念,比如"从某日结算后开始执行新保证金率",避免盘中突然变更导致混乱。
这块的复杂度在于规则之间的联动:保证金率提高会导致部分账户权益不足,触发强平;强平又会产生成交,影响行情。所以规则变更往往要配合一次全账户的风险重算,这个重算任务要能快速完成且不影响正常交易。
6.5 压测怎么做才接近真实
最后说说压测。很多团队压测就是拿个工具狂发下单请求,看系统能扛多少 TPS,这其实不够真实。真实的压力是混合的:大量行情订阅、部分下单、部分撤单、部分查询,比例还不一样。
更接近真实的压测要模拟这些行为比例,而且要模拟行情驱动的连锁反应——价格一波动,止损单触发、强平触发,瞬间产生大量订单。这种"行情风暴"场景才是系统真正的压力测试。我建议压测至少覆盖三个场景:平稳行情下的常规交易、剧烈波动下的止损/强平风暴、开盘收盘瞬间的集中下单。这三个场景过了,系统才算基本可靠。
另外,压测要看的不只是 TPS,还要看延迟分布、错误率、资源占用、以及压测后的数据一致性(压完对账,账目对不对)。只盯 TPS 的压测是自欺欺人。
7. 写在最后的一点个人体会
做这类系统这些年,我最大的感受是:交易系统的难点从来不在某个炫技的技术点,而在对业务规则的精确理解和工程细节的死磕。撮合算法本身不复杂,难的是并发下的时序、资金的安全、账目的精确、故障的恢复。这些东西没有捷径,只能一个环节一个环节地抠,一个边界一个边界地测。
如果让我给准备入坑的团队一句建议,那就是:先把业务模型和状态机画到不能再细,再动手写代码。我见过太多项目,代码写得飞快,结果业务规则没吃透,返工的成本远超前期多花的那几天。另外,资金和撮合相关的代码,一定要有经验的人 review,一定要有充分的单元测试和并发测试,这两块出问题的代价太大了。
至于技术选型,别被"高性能""低延迟"这些词绑架。先想清楚你的业务到底需要多快——是微秒级还是毫秒级,用户量是几千还是几十万。选一个团队能驾驭、能维护的技术栈,比选一个理论上最快但没人会调优的栈,要靠谱得多。系统能稳定跑、账目不出错,比什么都重要。