http+json在期权盘口场景下必然抖动,因协议层和序列化路径无法承载百万级订阅与高频更新,需改用长连接+二进制协议(如websocket+msgpack)并采用事件驱动+连接池模型。

为什么 HTTP + JSON 在期权盘口场景下必然抖动
不是框架不行,是协议层和序列化路径本身扛不住。盘口数据每秒更新多次,用户订阅量动辄百万级,net/http 默认配置下,单实例在 800 QPS 左右就出现 P99 延迟跳变,runtime.GC 调用频率飙升,encoding/json.Marshal 和 crypto/tls.(*Conn).write 占比超 60%。
常见错误现象包括:
-
read: connection reset by peer或i/o timeout频发 - 服务端
gctrace显示每秒触发多次 stop-the-world - pprof 火焰图里 TLS 加密和 JSON 编解码成为瓶颈热点
关键不是换 fasthttp 或 gin,而是绕过 HTTP header、TLS 全链路加密、JSON 反射开销这三层重载。
长连接 + 二进制协议才是盘口分发的正确起点
必须放弃每个请求新建连接 + 文本协议的思路。真实生产中(如广发行情云),采用的是基于 gorilla/websocket 或 net/http 的升级机制构建长连接,再配合 msgpack 或 gogoprotobuf 序列化。
这样做的好处:
- 连接复用,避免 TLS 握手与 session resumption 开销
-
msgpack比 JSON 小 40–60%,解析快 3–5 倍,无反射 - 可预分配
[]byte缓冲区,配合sync.Pool控制 GC 压力 - 禁用 TLS 1.3 的 0-RTT(重放风险高),改用双向证书 + session resumption
示例片段(WebSocket 升级):
func upgradeToWs(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer conn.Close()
// 后续用 conn.WriteMessage(websocket.BinaryMessage, data) 发送 msgpack 数据
}
单 goroutine 管理数千连接的关键设计
误区是为每个连接起一个 goroutine —— 80 万用户 × 平均 12 个合约订阅 = 近千万 goroutine,调度器直接过载。实际方案是「事件驱动 + 连接池」混合模型。
核心落地点:
- 用
gnet(基于epoll封装)或自研 reactor,单 goroutine 轮询数千连接的读就绪事件 - 每个连接绑定预分配的
sync.Pool缓冲区,例如pool.Get().([]byte),避免 malloc/free 频繁触发 GC - 盘口更新不遍历所有连接,而是用
map[uint64]*connection(按用户 ID)和map[string][]*connection(按合约代码)做两级索引分发 - 对 idle 连接(ping 超过 30s 无响应)仅标记,延迟 close,防止
TIME_WAIT暴涨
该设计下,单节点可稳定承载 30 万+ 长连接,内存常驻约 1.2GB(不含行情数据本身)。
批量聚合推送与分层通道设计的实际效果
盘口变化高频但多数微小,逐条推送给每个订阅者会浪费大量带宽和 CPU。必须做聚合:比如同一秒内某合约的买一价变动 3 次,只推最后一次;或把同一用户订阅的 5 个合约更新合并成一条二进制包发送。
具体实现依赖两个关键结构:
- 使用
time.Ticker触发周期性 flush,配合slices.Grow预分配输出缓冲区,避免扩容抖动 - 分层通道:
chan []byte接收原始行情 →chan *AggregatedUpdate做合约维度聚合 →chan *UserBatch做用户维度打包 → 最终写入各连接 socket - 每个通道配独立 buffer size 和 timeout,防止单点阻塞拖垮整条流水线
这个环节最容易被忽略的是“聚合粒度”——太粗(如 100ms 一推)导致延迟超标,太细(如每次变更都推)又失去聚合意义。实测中,50ms + 变更去重是广发等机构的常用平衡点。










