一笔委托从券商到交易所,中间隔着一层协议网关:它对外讲 FIX(上交所)或 STEP(深交所,证券交易数据交换协议),对内把消息转成柜台内部格式。这一层看起来只是"翻译",实际承担了交易链路里最重要的可靠性职责——有状态的会话管理。绝大多数"委托莫名消失"的生产事故,根因都能在网关的会话状态机和序列号处理里找到。这篇讲协议本身的最小结构,再讲会话管理为什么难。
FIX/STEP 消息的最小结构
FIX(Financial Information eXchange)是金融报文的事实标准,STEP 是它的中国本地化版本(GBK 编码、部分字段语义调整),两者报文结构一致:标签=值对,用 SOH 分隔:
8=STEP.1.0.0?9=148?35=D?34=1027?49=BKR01?56=EXCH01?
52=20261022-09:30:01.125?11=ORD2026102200001?40=2?
54=1?55=600519?38=1000?44=1680.00?10=088?
关键标签:35 是消息类型(D=新委托、8=成交回报、3=拒绝)、34 是消息序号(MonotonicSeqNum)、49/56 是发送方/接收方标识、52 是发送时间、11 是客户侧的委托引用(ClOrdID)、10 是校验和。网关的解析器不需要"理解"每个业务字段——55 是代码还是 38 是数量,对传输层无所谓,它只负责按标签切分、校验和验证、按序号排序,业务语义交给下游。
这个设计的好处是分层干净:传输层(标签编码、会话、序号)和业务层(委托、回报)完全解耦。20 年前的设计,今天看就是"不用协议的协议"——没有 schema 演进地狱,新字段加一个标签号即可,老系统不认识就跳过。
会话:有状态的两人对话
FIX 会话和 HTTP 的本质区别是有状态:两个固定身份(SenderCompID → TargetCompID)之间维护一个会话,会话内每条消息带递增序号。会话生命周期:
- Logon:双方交换加密信息,重置或续接序号(ResetSeqNumFlag)。这一步定下整场对话的基调;
- 心跳:约定间隔互发 Heartbeat(35=0),超时就认为对方死了;
- 业务消息:委托、回报、撤单确认,全部在会话内流动;
- Logout / 断开:正常退出或连接中断。
心跳机制值得多看一眼:它不是"TCP keepalive 的重复"。TCP keepalive 只能探测链路通不通,而 FIX 心跳探测的是对端应用活不活——对端进程 hang 住(比如 JVM 长 GC)时 TCP 连接照样通,只有应用层心跳能发现。所以心跳间隔是系统级 SLA 参数:间隔短,故障发现快,但心跳风暴挤占带宽;量化通道常见 1 秒甚至 300 毫秒,普通通道 30 秒。
序列号:可靠性的真正来源
会话机制里最核心的设计是单调递增的 34 序号,它同时解决三个问题:
丢消息检测。 收到 34=N 时发现上一条是 N-2,说明 N-1 丢了,立即发 ResendRequest 要求对方重传。这是应用层的 TCP——TCP 保证字节流不丢,但保证不了业务消息在应用层被正常处理(进程崩溃时消息已到缓冲区但没落库)。序号检查把"到底哪条没处理"从猜测变成确定。
重复消息检测。 重传的消息序号重复,接收方查"已处理序号集合",重复的丢弃或只更新状态。注意这和委托幂等是两层:序号幂等管传输层(同一条回报传了两遍),委托幂等(按 ClOrdID 或交易所订单号)管业务层(同一笔委托被报了两遍)。两层都要,缺一不可。
断线恢复。 连接重建时 Logon 报文里的序号告诉对方"我上次处理到第几条了"。这里有个经典陷阱:序号落在哪一侧持久化。正确做法是双方都本地持久化(WAL,先写盘后发送/确认),发送方发消息前先记 last_sent=N,接收方处理完再记 last_recv=N。如果先发送后记盘,进程在间隙崩溃,序号就回退了,对方会看到序号倒退——多数 FIX 实现对"序号倒退"直接判定会话损坏强制重置,而重置意味着靠序号对齐的丢消息检测暂时失效,风险窗口由此打开。
最小实现的骨架
一个能用的网关核心其实只有几百行,骨架长这样(Go 风味伪代码):
type Session struct {
senderCompID, targetCompID string
lastSent, lastRecv int // 持久化在 WAL
store *WALStore
}
func (s *Session) Send(msg Msg) error {
s.lastSent++
msg.Set(34, s.lastSent) // 序号进消息
s.store.Append(s.lastSent, msg) // 先落盘
return s.conn.Write(msg.Encode())
}
func (s *Session) OnMessage(msg Msg) {
seq := msg.GetInt(34)
if seq <= s.lastRecv {
return // 重复,丢弃
}
if seq > s.lastRecv+1 {
s.SendResendRequest(s.lastRecv+1) // 空洞,请求重传
s.pending.Store(seq, msg) // 缓存乱序到达的后续消息
return
}
s.process(msg)
s.lastRecv = seq
s.store.CommitRecv(seq)
s.drainPending() // 补洞后释放缓存
}
三个细节决定这个骨架能不能扛生产:WAL 必须 fsync(否则"先落盘"是假的);pending 缓存要有上限(对方永远不回填时不能 OOM);心跳和超时要在单独的定时器协程里,不能和业务处理共享事件循环。
为什么不用更"现代"的协议
每个接 FIX 的新工程师都问过:为什么不换 protobuf + gRPC?答案是生态锁定。交易所的网关接口、券商间的 PB 通道、国际市场的对接,全是 FIX 家族;协议本身的蠢(标签文本、无 schema)恰恰是它的长寿秘诀——任何一方升级都不需要对方配合。工程上的启示也通用:在强协作的分布式系统里,演进成本最低的协议往往是设计最保守的协议,这是金融系统对技术选型的特殊约束,和互联网服务的逻辑正相反。