交易消息协议:请求与应答的结构和请求序号

项目 trade-app

  • Protobuf
  • 长连接
  • 协议
  • 请求序号
  • Socket.IO
  • WebSocket

1. 背景与问题

App 有两条长连接:行情走原生 WebSocket,交易走 Socket.IO(鉴权、期货、证券三个命名空间),连接本身见两条长连接。这篇只讲连接上跑的消息。

长连接和 HTTP 最大的不同,是没有「一个请求对应一个响应」的天然边界:请求发出去之后,回来的可能是这次的应答,也可能是服务端主动推的数据,还可能是另一台设备那次请求的结果。协议要回答几件事:

  • 一帧二进制里,怎样知道它是什么消息、该怎么解;
  • 一条应答,怎样知道它是回给哪次请求的;
  • 应答和主推怎样区分;
  • 失败怎样表达;
  • 协议怎样升级而不把两端弄崩。

2. 两层结构:信封包着负载

两条通道都是两层:外层信封是固定结构,内层负载是具体的业务消息,两层都用 Protobuf 编码。

Protobuf 编码
Protobuf 编码
解信封再解负载
业务消息 如登录请求负载字节装进信封 类型 + 序号 + 负载一帧二进制,经长连接发出收到一帧按类型分支具体的应答或主推

先解信封、再按类型解负载,信封层不需要认识任何业务消息。类型相当于 HTTP 的接口名:行情信封约 70 种类型值,行情协议定义里约 81 个消息,交易(期货网关)约 48 个。

行情信封 交易信封(期货 / 证券同构)
类型 有 有,期货、证券各一套枚举
序号 消息 id 请求序号
负载 二进制 二进制
版本 有 无(版本在握手头里)
返回结果 在负载里 信封上有
平台、设备标识 无 有
应答 / 主推 靠类型区分 两个事件名,再靠类型区分

交易网关的请求与应答模型、应答事件的分发都在这一层:网关的每一种应答对应一个处理分支,负载在这里解成具体的消息再往上交。


3. 字段

下面的字段名都是泛化后的叫法,只保留结构。

3.1 信封

行情信封消息类型消息 id负载字节版本交易信封请求序号消息类型负载字节返回结果平台设备标识返回结果返回码

在交易信封里补上平台与设备标识以后,同一个账号在多台设备上登录时,收到的应答和主推可以先判断「是不是本机的」。

3.2 负载:请求与应答

几种典型负载的字段(只列和本文有关的):

行情登录请求行情凭证起始时间戳用户标识推送频率行情登录应答返回结果交易登录应答返回结果会话号是否需要二次认证强制改密 / 建议改密
账户心跳请求会话号交易账号重复登录主推最新登录设备的会话号账户断线主推平台设备标识是否已断线

请求只带业务字段,序号不在负载里,在信封上。这样负载的定义不用关心对号的事,信封也不用关心业务。


4. 请求序号

4.1 怎样生成

交易请求序号由客户端生成:取一个随机 UUID 的哈希值,再取绝对值。它只要求在一次运行里不重复,不要求单调递增,也不和服务端协商。服务端在应答里把它原样带回。

行情的消息 id 用得少:大多数行情请求不需要对号,回来的数据按类型处理就够了。用到它的典型场景是 K 线左滑加载更早的历史:请求带一个专门的「加载更多」消息 id,应答按这个 id 认出来,插到数组前面(见 K 线)。

4.2 用在哪里

序号带回来以后,并不是每条应答都去对号。代码里真正比对序号的地方,集中在登录:

场景 记下的序号 应答回来时
开启生物识别时的静默绑定登录 绑定那次登录的序号 序号相同:只更新会话号,不走界面分支
交易首页的生物识别登录 这次登录的序号 序号相同且密码错误:自动解绑生物识别
证券重复登录主推 绑定那次登录的序号 先排除这一次,再比会话号
K 线加载更多 「加载更多」消息 id 认出是历史应答

4.3 静默绑定登录

开启生物识别时,App 会用用户输入的交易密码做一次登录。这次登录的应答和普通登录长得一样,不拦截的话就会走进登录成功、改密、二次认证这些界面分支。

交易网关登录管理设置页交易网关登录管理设置页alt[序号是 A][其他序号]开启生物识别,输入交易密码生成序号 A,记为「绑定中」登录(序号 A)登录应答(序号 A)只更新会话号,结束进入普通登录分支

绑定登录的应答按请求序号拦下,直接返回,不再往下走。界面层完整的分支见交易会话。


5. 应答怎样回到发起方

5.1 现在的做法:按类型分发

交易应答回来以后,不是按序号找回调,而是按消息类型分发:

登录、登出、被踢、改密资金、持仓、委托其他
收到应答或主推解信封消息类型写进登录管理的对应属性 属性的 didSet 就是处理器交给数据中心交给当前页面的代理

登录类的应答赋给登录管理器上的属性(期货登录、证券登录、重复登录、掉线、登出、改密各一个),每个属性的 didSet 就是那一类应答的处理器。Socket 层只管投递,不懂业务。

这种做法下,「这条应答是谁发的」大多不需要知道:资金查询的应答回来,不管是哪次查询触发的,都写进同一个数据中心;页面只看数据中心的最新值。

5.2 「是不是本机发起的」

只按类型分发有一个问题:同一个账号在两台设备上,另一台输错了期货交易密码,网关把「密码错误」的应答也发到了这台,这台就会无端弹出提示。

处理方式是给登录命令记一个「本机是否发起过」的状态:发登录时置上,收到应答时先看这个状态,本机没发过就不提示。它按命令记,不按序号记。

5.3 三种对号方式

序号匹配 按类型分发 回调表
做法 发请求时记下序号,应答按序号认 按类型交给固定的处理器 以序号为键存回调,应答到达时取出
现在用在哪 静默绑定登录、生物识别登录、加载更多 绝大多数应答和全部主推 未使用
能区分同类型的两次请求 能 不能 能
能处理主推 不适用 能 不适用
超时 要另外做 没有「哪次请求」的概念 天然能挂超时
代价 每个场景各记各的序号 最简单,但隐式 要管理表的生命周期,防止泄漏
适合 少数要精确对号的请求 状态类、数据中心类的应答 一次请求一个结果的调用,如下单

6. 应答与主推

交易通道上,应答和主推用两个不同的事件名:一个是请求的应答,一个是服务端主动推送;发请求用的是第三个事件名。期货和证券两个命名空间各自一套。

交易网关App交易网关App指令事件:查资金(序号 1)应答事件:资金(序号 1)主推事件:成交主推事件:资金变化指令事件:账户心跳(序号 2)应答事件:心跳(序号 2)

主推没有对应的请求,所以只能按类型处理。行情通道没有事件名这一层,订阅之后的数据都按类型分发。

多设备场景下,应答和主推都先过一遍「是不是本机」:

消息 先过滤什么
证券掉线、登出应答 平台 + 设备标识
期货账户断线主推 平台 + 设备标识,且「已断线」为真
证券重复登录主推 先排除绑定登录的序号,再比会话号
证券登录应答 取消了证券登录应答上的设备判断

7. 返回结果与错误文案

失败的处理分三类:

成功账户未登录 / 会话校验失败其他失败
一条应答返回码业务分支按掉线处理按柜台查启动时拉取的文案 表
  • 返回码的具体取值这里不列。
  • 任何应答里出现「账户未登录」或「会话校验失败」,都按掉线处理;证券还要先比对平台和设备标识。
  • 交易错误的文案不写死在客户端:启动时按柜台拉一张错误码对应文案的表,提示时查表。
  • 行情登录应答里,「这个客户端已经登录过」没有独立的返回码,用返回文本来认,见下面的坑。

8. 超时、迟到与乱序

长连接上的请求可能遇到三种情况:应答一直不来、超时之后才来、几条应答的顺序和请求顺序不同。现有设计里和它们相关的行为:

行为 说明
交易通道没连上时发送 触发重连,本次请求直接丢弃,不排队、不补发
行情登录发出后 10 秒后若仍未登录,再发一次登录,有次数上限
交易登录 1 秒的防重入闸门,避免连点产生多次登录
资金类应答 按类型交给数据中心
连上后立即发消息 连接成功后稍等 0.5 秒再通知「已建立」,避免刚连上就发、收不到应答

交易指令不排队补发是合理的:下单这类请求晚几秒再发出去,用户看到的价格和意图都可能已经变了。

后面三类问题,需要在协议层有「哪次请求」的概念才能处理:

交易网关App交易网关App如果只看类型,序号 1 的旧结果可能盖掉序号 2 的新结果查询(序号 1)查询(序号 2)应答(序号 2)应答(序号 1,迟到)

建议(未做):

  • 在途请求表 + 超时。 对需要结果的请求(下单、改单、撤单、登录),发出时以序号为键登记回调和截止时间,到期没有应答就按超时回调,并从表里移除。
  • 迟到的应答直接丢弃。 应答到达时在表里找不到序号,说明已经超时或已经处理过,记一条日志后丢弃,不再进业务分支。
  • 乱序:只认最新。 同一类查询连续发出时,只接受最后一次的序号;或者在负载里带服务端时间戳,旧于当前数据的不覆盖。
  • 超时不等于失败。 下单超时后,订单可能已经在柜台成交。超时回调里不能提示「下单失败」,应该提示「结果未知」,并立即查一次委托来确认。
  • 断线时清表。 连接断开时,表里所有在途请求统一按「连接中断」回调,避免回调永远不来。

9. 版本与协议升级

子模块引入脚本调用 protoc同一份协议
后端协议仓库 .proto 源文件客户端工程里的协议目录生成 Swift 代码后端 C++ 服务改调用方 视图模型与页面
  • 协议源文件以 git 子模块引入,跟踪后端协议仓库的开发分支;一个脚本更新子模块并生成代码。同一份协议也给后端的 C++ 服务生成代码,两端的字段定义天然一致。
  • 版本信息有两处:行情信封上有版本字段;交易长连接在握手头里带版本。
  • 日常的协议更新很频繁,每次都是更新子模块、重新生成。Protobuf 对新增字段向后兼容;真正麻烦的是不兼容的改动,例如一次交易协议的大版本升级,以及交易页面与视图模型对新协议的适配:生成代码本身变化很大,调用方也要跟着改。

10. 踩过的坑

静默绑定登录走进界面分支、另一台设备的「密码错误」弹到这台。 两个都是只按类型分发带来的,处理分别见第 4.3 节和第 5.2 节,界面上的细节见交易会话。

「已经登录」靠文案判断。 行情登录时,服务端用返回文本表示「已经登录过」,客户端按文本匹配。文本一改,这个判断就失效。

刚连上就发,收不到应答。 交易命名空间连上后立即发消息,有时收不到应答。现在的处理是连上后稍等 0.5 秒再通知外部,原因没有查清。


11. 对比与取舍

选择 好处 代价
信封 + 负载两层 Protobuf 信封层不认识业务;体积小 调试时不能直接看明文,要靠日志解码
序号放在信封上 负载不关心对号 只有少数场景真的比对
按类型分发为主 简单,主推和应答同一条路 同类型的两次请求分不清;没有超时
应答与主推分事件名 一眼能分清是不是请求的结果 只在交易通道有,行情通道没有
序号取 UUID 哈希的绝对值 不用维护计数器 哈希值每次运行不同,只在一次运行内有意义
交易请求不排队 不会延迟补发交易指令 断线瞬间的请求直接丢失,需要页面自己重试

12. 可以改进的地方

建议(未做):

现状 问题 建议
应答按类型分发 同类型请求分不清,没有超时 加在途请求表,见第 8 节
序号是 UUID 哈希的绝对值 理论上可能重复;最小整数取绝对值会溢出 改用自增计数,或直接用 UUID 字符串
「已经登录」按文本判断 文本一改就失效 和服务端约定独立的返回码
「本机是否发起」按命令记 同一命令并发两次时可能错位 用请求序号精确匹配
版本只在握手和行情信封上 交易消息本身不带版本 握手时协商协议版本,不兼容时提示升级
二进制不可读 排查问题要先解码 调试包里按类型打印解码后的消息摘要
现在 按类型分发,少数场景比对 序号在途请求表 序号 → 回调 + 截止时间超时回调 下单类提示「结果未知」并 查委托迟到应答丢弃 乱序只认最新断线时统一回调 「连接中断」