← BACK TO BLOG
我们为什么自研网络栈
INFRAMAY 10, 2026 · ShukeMeng

我们为什么自研网络栈

Unicode Digital 自建 TLS / HTTP2 / WebSocket 协议栈的业务考量与技术决策。

#Infrastructure#Networking

对接 10+ 家交易场所的交易系统里,"连接断了"从来不只是网络层一个人的事 —— 它是整个系统的事件源。把这个起点握在自己手里,值得自写一整套网络栈。

Pandora 是我们用 C++ 写的多市场交易框架,接 10+ 家交易场所。在网络这一层,它做了一个不太常见的决定:TCP / HTTP / HTTP/2 / WebSocket 这一整条栈基本自写,只把底层 TLS 加解密与 HTTP/2 帧解析交给成熟的协议库去做。

听起来像在重新发明轮子。boost::asiolibcurllibwebsockets 都是现成且能用的好库,生产里跑得也都不差。我们最初其实就是用它们拼起来的 —— 一个 HTTP 客户端 + 一个 WS 客户端 + 一套自己的连接管理 + 一套自己的限频 + 一套自己的重连。能跑,但接缝过多。

最后促使我们把这一层全部收回自写的,不是性能,而是事件源这件事 —— 在一个交易系统里,"连接断了"远远不只是网络层的事。它会一路传到订单管理、传到风控、传到策略,触发挂单状态重核、风控阈值收紧、暂停某个 vendor 的下单。这种全栈级的事件源,必须有一个单一、可信、可观测的出处。如果它散落在几段彼此不一致的胶水代码里,你就找不到唯一的真理源 —— 谁先察觉、谁还没赶上来,几秒钟内就分不清了。

注:本文里的"事件源"指事件的发出地,不是 Event Sourcing 那种用事件序列重建状态的架构模式。
性能让我们想优化网络栈,但事件源让我们决定自己写。

用现成库时,几个不那么明显的问题

在 24/7 全球市场里,一个交易系统同时要面对三种性格完全不同的连接:

  • HTTP/1.1 + HTTP/2走 REST API 的下单、查询、账户接口。部分主流交易场所的某些路径用 HTTP/2 多路复用,延迟比 HTTP/1.1 好一截。
  • WebSocket Stream订阅行情、订阅 UserDataStream(订单 / 持仓 / 余额推送)。建立并订阅完成后,数据面基本只收不发。
  • WebSocket API部分交易场所提供的"WS 上发请求"的快速通道。比 REST 低延迟,但建立之后还要走一次签名 Logon 才能下单。

在多数现成组合里,这三种连接最终会落在两到三套客户端、各自的状态机和重连语义上(WS Stream 和 WS API 通常共用一套 WebSocket 客户端,HTTP 另算一套)。具体的痛点是:

语义不一

重连这件事,各家库给的其实是同一个答案 —— 不管,你自己写。但留给你的接缝完全不同:libcurl 的 easy/multi 没有真正意义上的"长连接自动重连",得在 multi 层之外另起逻辑;libwebsockets 要你自己识别断开回调、重新发起 connect;boost::beast 干脆把这块全留给你。结果是:同一个事件 —— "和某家交易场所断了" —— 在三种连接上有三种触发路径,上层很难写出一致的反应。

隐性分配

许多通用 HTTP 客户端内部都会 malloc,WebSocket 库的消息组装也是。这些在毫秒级业务里是空气,但在 P99 延迟敏感的交易系统里,任何隐性分配都意味着尾部抖动 —— 而且这些抖动对你完全不透明。

控制不全

我们的部署是 Linux,基础 socket 选项(TCP_NODELAYSO_SNDBUF 之类)大多还能改;但再深一层 —— Linux 上的 TCP_QUICKACKTCP_USER_TIMEOUT、event loop 绑核、SO_BUSY_POLL 与 epoll 阻塞等待之间的取舍 —— 通用库要么不暴露,要么默认值不适合低延迟场景。你能改,但要绕过封装。

观测稀薄

"为什么这次重连比上次慢 200ms?"这种问题,用现成库几乎答不出来。TLS 握手分阶段耗时、TCP RST 出现在哪一拍、对端是 close 还是 timeout —— 这些信号要么没暴露,要么暴露得不全。

我们的取舍:保留协议层,其余自写

自写网络栈最常见的失败模式,是把所有东西都自写。TLS 协议、HTTP/2 流量控制、WebSocket 帧解析 —— 这些是标准化、变化慢、写错了就只能交学费的东西。自己重写它们的边际收益几乎为零。

所以我们的划法是:

Strategy / OMS / Executor · 上层只看 Session 抽象SELF-BUILTSessionBundle统一抽象 · TCP 选项 · 连接状态机 · 心跳 · 重连WebSocket 帧封装 · HTTP/1.1 请求构造 · session pool事件出口 · Session_Connected / Session_DisconnectedVENDOREDTLS 协议库TLS 1.2 / 1.3 加解密VENDOREDHTTP/2 协议库HTTP/2 帧 · 多路复用 · 流量控制
FIG. 自写与依赖的边界

留下来的是协议规范本身;自写的是把这两个低层胶接成"一个能用的 Session"的所有粘合代码 —— 连接状态机、心跳节奏、错误分类、重连策略、TCP 参数、event loop 集成、缓冲池管理、HTTP/2 请求生命周期管理。

在这之上,我们给整个系统提供的对外抽象只有一个 —— SessionBundle。无论底下是 HTTP/1.1、HTTP/2 还是 WebSocket,上层看到的接口形状是一样的:send() / recv() / state,以及两条事件 —— Session_ConnectedSession_Disconnected。差异只在语义 —— REST 上 send()recv() 是一次配对的请求/响应;WS Stream 上数据面基本只收不发;WS API 仍然是请求/响应配对,但由消息内的 id 字段关联,而不是 TCP 层的同步配对。

真正的回报:断线重连只有一个真理源

当上层只看一个事件源,系统的整体行为就突然变得可推导了。

大多数交易系统的失败模式不是单点错误,而是"重连风暴" —— 网络断了一下,网络层重连、HTTP 客户端重连、WebSocket 客户端重连、订单管理器重新订阅、策略层试图重新对账。这些动作互相触发、互相打架,几秒钟之内系统就进入了一个谁也说不清的状态。

只有 SessionBundle 会重连。其它任何一层都不重连,只反应。

重连被钉死在最底层,默认 1 秒间隔。上层每一处只通过两条事件感知连接状态,不做任何自己的重连尝试:

// 以下为伪代码 · pseudocode
// SessionBundle 的默认配置 —— 整个系统唯一一处重连发生地
SessionBundle::Config {
  reconnect = true,
  reconnectInterval = 1s,
  heartbeatInterval = 15s,
  ...
};
 
// 上层只感知事件,不重连
void Executor::onEvent(const Event& ev) {
  switch (ev.type) {
    case Session_Disconnected:
      status_ = DISCONNECTED;
      if (kind_ == WS_STREAM)
        oms_->onSubscribeDisconnected();   // 通知 OMS:挂单状态需要重核
      break;
    case Session_Connected:
      status_ = (kind_ == WS_API) ? CONNECTED : READY;
      if (kind_ == WS_API)
        sendLogon();                       // 重连后还要登录,Logon 回包后再切 READY
      break;
  }
}

这套设计让整个系统出现断线时的行为完全可枚举:

  1. SessionBundle 检测到断开,广播 Session_Disconnected,并在 1s 后开始重连尝试。
  2. 策略侧通过 Session_Disconnected 拿到信号,自己决定要不要 cancel 旧单、要不要对账。
  3. 底层重连成功 → emit Session_Connected → Executor 状态切到 READY(HTTP / WS_STREAM),或先 Logon、收到回包后再切 READY(WS_API)。

整套流程只有一个起点。你不需要追问"是不是 WS 客户端先重连了所以 OMS 还不知道";也不会出现"网络层认为连着、应用层认为断了"的分裂状态。系统出问题时,只需要看 SessionBundle 这一处的日志。

代价

维护

这一层确实要持续维护。底层协议库的安全升级要跟、版本要跟,各家 vendor 偶尔会改 WS 协议细节(心跳格式、断开错误码、订阅响应字段)。它不是一次性写完就完了。

新人门槛

来一个新工程师,熟悉这层比熟悉 boost::asio 慢一些 —— 不过我们发现一旦熟了,这层反而比开源库更好懂,因为代码量小、关心的事少。

观测

每一次 TLS 握手分阶段耗时、每一次 RST 出现在哪一拍、每一次心跳超时的精确原因 —— 想看就看,加日志只是改两行代码。

延迟可预测

没有隐性分配,buffer 全部预分配。延迟分布的尾巴比用开源库拼出来的版本明显更短。

演化空间

我们正在准备在内部链路上引入 QUIC 作为备选传输 —— 这种事如果挂在第三方库下,就要等社区支持;自己写就只是排进自己的 sprint。

一点反思

自研的边界,不在于"我能不能写"或"这层值不值得",而在于这层的事件,有没有进入业务逻辑。

TLS 加解密的事件不会进业务逻辑 —— 它要么成功要么失败,对上层而言是黑盒,所以留给成熟的协议库。HTTP/2 的多路复用和流控也不会进业务逻辑 —— 它的工作是"把帧调度好",上层只看流,同样留给成熟实现。

但"连接断了"不一样。它会触发挂单状态重核、风控阈值调整、策略反应。它已经进入了业务逻辑。这种"已经进入业务的事件",必须从一个我们自己掌控的源头发出 —— 否则当问题出现在凌晨三点的某个 vendor 上时,你会发现你既不知道"为什么发生",也不知道"接下来会怎样"。

自研网络栈的真正理由,是让那一行 "Session_Disconnected" 的日志,成为系统所有反应的起点。

Pandora 是我们正在持续打磨的交易框架。如果你也对网络栈、连接生命周期、低延迟系统的"事件源"设计感兴趣,欢迎一起聊聊。