第二章:订单生命周期——从客户端到撮合引擎的完整路径
大家好,我是你们的讲师。今天我们来聊聊订单的生命周期。
说白了,一个订单从你敲下回车键,到最终被撮合或者取消,中间经历的事情比你想象的多得多。我当年刚接触期货交易系统时,以为订单就是“发出去→成交”这么简单。直到有一次生产环境出了bug,订单状态卡在“PartiallyFilled”整整三分钟……嗯,那天的复盘会开了四个小时。
2.1 订单的“出生”:从客户端到网关
订单的旅程,从客户端开始。
你写了一个买入开仓指令,点击提交。这个指令首先被序列化成FIX协议或者内部二进制协议,然后通过网络发出去。这里有个细节——客户端不会直接把订单发给撮合引擎。中间至少隔着一层网关(Gateway)。
典型路径:
客户端 → 前置网关 → 风控校验 → 订单路由 → 撮合引擎
网关的作用是什么?我个人的理解是:挡掉垃圾流量。比如你发了个格式错误的订单,网关直接返回Rejected,根本不会让撮合引擎知道。这就像公司前台,先帮你筛掉推销电话。
2.2 风控校验:订单的“体检”
订单到了网关,第一件事是风控校验。这一步非常关键。
我曾经见过一个案例:某个客户写了个死循环脚本,每秒发1000个订单。如果没有风控,撮合引擎直接被打挂。所以网关会检查:
- 资金是否足够——保证金不够?Rejected。
- 持仓是否超限——超过持仓限额?Rejected。
- 价格是否合理——超出涨跌停板?Rejected。
- 频率是否过高——每秒超过N笔?Rejected。
你想想看,如果这些校验都放到撮合引擎里做,那引擎的负担就太重了。所以前置风控是必须的。
2.3 订单状态机:New、PartiallyFilled、Filled、Cancelled、Rejected
好,订单通过了风控,到达撮合引擎。这时候它是什么状态?
New——刚进入撮合队列,还没被处理。这是订单的“初始状态”。
然后撮合引擎开始干活。它把订单和对手盘进行匹配。匹配结果有三种可能:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| Filled | 全部成交 | 订单数量完全匹配到对手盘 |
| PartiallyFilled | 部分成交 | 只匹配了一部分,剩余部分继续挂单 |
| Cancelled | 已撤销 | 用户主动取消,或超时自动取消 |
| Rejected | 已拒绝 | 风控校验失败、格式错误、系统内部错误 |
这里有个容易踩坑的地方:PartiallyFilled 之后还能继续成交。比如你挂了10手,先成交了3手,状态变成PartiallyFilled。剩下的7手还在队列里,等对手盘来了继续成交。直到全部成交,状态才变成Filled。
注意:PartiallyFilled 状态不是终态。它只是一个中间状态。系统必须能处理“部分成交后继续撮合”的场景。我曾经见过一个系统,PartiallyFilled之后直接跳到了Filled,把剩余订单丢了……那是个严重的bug。
2.4 状态转换图
为了更直观地理解,我画了一张状态机图。你仔细看:
这张图我建议你保存下来。每次设计订单系统时,对照着看,不容易漏状态。
2.5 订单的“死亡”:终态处理
订单到达终态后,系统需要做什么?
- Filled:生成成交记录,更新持仓和资金。通知客户端“你买到了”。
- Cancelled:从撮合队列中移除。如果已经部分成交,剩余部分取消,已成交部分不变。
- Rejected:记录错误原因。通知客户端“订单被拒,原因是XXX”。
这里有个细节:Rejected 和 Cancelled 的区别。Rejected 是系统主动拒绝,比如资金不足。Cancelled 是用户主动取消。两者虽然都是“没成交”,但业务含义完全不同。风控系统会重点监控 Rejected 订单,因为可能意味着用户操作异常。
个人经验:我建议在订单表里加一个 reject_reason 字段。哪怕只是字符串,调试时能省你半天时间。我曾经因为没加这个字段,查一个Rejected订单的原因查了三个小时……后来发现是价格精度不对。
2.6 实战中的坑
最后分享几个我踩过的坑:
- 状态丢失:订单从PartiallyFilled变成Filled时,如果数据库写入失败,订单就卡住了。解决方案是加一个“状态变更日志表”,每次变更都写日志,方便恢复。
- 重复成交:撮合引擎处理完订单后,如果网络超时,客户端可能重发。这时候要保证幂等性——同一个订单ID只能被处理一次。
- 取消延迟:用户发取消指令时,订单可能正在撮合中。这时候要加锁,或者用“取消标记”的方式,等撮合完再处理取消。
嗯,今天就讲到这里。订单生命周期是撮合引擎的基础,理解了它,后面的内容就好办了。