行情服务拆分:Level-2/Level-1数据流拆分,WebSocket网关与数据分发服务的解耦实战
行情服务,说白了就是量化平台的「眼睛」。
我见过太多团队,一开始图省事,把行情接收、解析、分发、推送全塞在一个进程里。结果呢?Level-2行情一来,WebSocket连接数一上去,整个服务就卡死。我当年在期货公司做量化平台时,就踩过这个坑——某次股指期货交割日,行情数据量暴增,单节点直接OOM,客户电话打爆了。
所以今天咱们聊聊,怎么把行情服务拆明白。
一、Level-2与Level-1:两种完全不同的数据流
先搞清楚一个概念:Level-1和Level-2,不是简单的「快和慢」的区别。
| 维度 | Level-1 | Level-2 |
|---|---|---|
| 数据频率 | 3秒快照 | 逐笔成交 + 逐笔委托(毫秒级) |
| 数据量 | 约 200 字节/只/次 | 约 2-10 KB/只/秒(高峰期) |
| 推送方式 | 全量推送 | 增量 + 快照混合 |
| 客户端需求 | 所有用户都需要 | 付费用户、高频策略 |
你想想看,Level-1 的数据量其实不大,但 Level-2 的逐笔数据,在 5000 只股票同时交易时,每秒可能产生几十万条消息。如果混在一起处理,Level-1 的推送都会被拖慢。
我的建议是:从数据源接入层就开始拆分。Level-1 走一个通道,Level-2 走另一个通道,互不干扰。
二、数据流拆分:从源头开始隔离
我在项目中遇到过一种情况:交易所网关同时接收 Level-1 和 Level-2,结果 Level-2 的 TCP 连接因为数据量太大导致缓冲区溢出,Level-1 也跟着断连。后来我们改了架构,效果立竿见影。
核心原则:物理隔离 + 逻辑隔离
- 物理隔离:Level-1 和 Level-2 使用独立的 TCP 连接,甚至独立的网关进程
- 逻辑隔离:即使在同一进程内,也要用不同的线程池、队列、内存池
来看一个简单的数据流拆分示意图:
嗯,这里要注意:Level-2 的处理服务,不能简单地把数据原样转发。因为逐笔数据是「流式」的,客户端需要的是「合并后的快照 + 增量」。我习惯在 Level-2 处理服务里维护一个「逐笔合并引擎」,把最近几秒的逐笔数据合并成增量包,再推出去。
三、WebSocket网关与数据分发服务的解耦
很多团队把 WebSocket 网关和数据分发写在一起。结果就是:网关既要管连接,又要管推送逻辑,还要管订阅关系。一旦某个环节出问题,整个网关就挂了。
我的做法是:彻底解耦。
解耦后的职责划分:
- WebSocket 网关:只负责连接管理、心跳检测、协议转换(比如 protobuf 转 JSON)
- 数据分发服务:负责订阅关系维护、推送策略、流量控制、数据缓存
它们之间通过消息队列通信。网关收到客户端的订阅请求后,把请求丢到队列里;分发服务从队列里取出来,更新订阅关系。行情数据来了,分发服务根据订阅关系,把数据推给对应的网关,网关再转发给客户端。
这样做的好处很明显:
- 网关可以水平扩展,无状态
- 分发服务可以单独做限流、降级
- 任何一个服务挂了,不影响另一个
四、实战:WebSocket网关的代码骨架
我给大家看一个简化版的 WebSocket 网关核心代码。注意,这不是完整实现,但思路是清晰的。
// WebSocket 网关 - 连接管理
public class WebSocketGateway {
// 连接池:userId -> WebSocketSession
private ConcurrentHashMap<String, WebSocketSession> sessions;
// 消息队列生产者
private MessageProducer producer;
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
// 1. 建立连接
sessions.put(userId, session);
// 2. 发送订阅请求到分发服务
SubscribeRequest req = new SubscribeRequest(userId, "init");
producer.send("subscribe_topic", req);
// 3. 启动心跳检测
startHeartbeat(session);
}
@OnMessage
public void onMessage(String message, Session session) {
// 解析客户端请求(订阅/取消订阅)
ClientRequest req = parseRequest(message);
// 转发到分发服务
producer.send("subscribe_topic", req);
}
@OnClose
public void onClose(Session session, @PathParam("userId") String userId) {
sessions.remove(userId);
// 通知分发服务清理订阅
UnsubscribeRequest req = new UnsubscribeRequest(userId);
producer.send("unsubscribe_topic", req);
}
// 分发服务回调:推送数据给客户端
public void pushToClient(String userId, MarketData data) {
WebSocketSession session = sessions.get(userId);
if (session != null && session.isOpen()) {
session.getAsyncRemote().sendText(data.toJson());
}
}
}
我曾经踩过的坑:
一开始我把「推送数据」的逻辑也写在网关里。结果分发服务要等网关确认收到,才继续推下一条。一旦网关处理慢了,整个推送链路就阻塞了。后来改成「异步推送 + 背压控制」,网关只管转发,不管确认,问题就解决了。
五、数据分发服务的核心设计
数据分发服务,说白了就是一个「智能路由器」。它要知道:谁订阅了什么?该推给哪个网关?推多快?
我习惯用 Redis 来存储订阅关系,用内存来缓存热点数据。
| 组件 | 技术选型 | 说明 |
|---|---|---|
| 订阅关系存储 | Redis Sorted Set | key: stock_code, value: userId, score: 优先级 |
| 数据缓存 | 本地内存 + Redis | Level-1 全量快照放本地,Level-2 逐笔放 Redis |
| 推送队列 | Kafka / Pulsar | 按股票代码分区,保证同一只股票的顺序 |
| 流量控制 | 令牌桶 + 滑动窗口 | 防止某个客户端拉爆整个服务 |
你想想看,如果 1000 个客户端都订阅了「贵州茅台」的 Level-2 行情,分发服务不能同时推 1000 份数据。我一般会做「合并推送」:同一只股票的数据,合并成一份,推给网关,网关再广播给所有订阅者。
六、避坑指南:我踩过的三个坑
坑一:WebSocket 连接数太多导致 OOM
我曾经以为 10 万连接没问题,结果每个连接 50KB 的缓冲区,10 万就是 5GB。后来加了「连接池 + 按需分配缓冲区」,才稳住。
坑二:Level-2 数据顺序错乱
逐笔数据是有顺序的。如果多个网关同时处理同一只股票的数据,顺序就乱了。解决方案:按股票代码做一致性哈希,同一只股票始终由同一个网关处理。
坑三:推送延迟抖动
Level-2 要求毫秒级推送。但 GC 一触发,延迟就飙到几百毫秒。后来我们用了 Netty 的堆外内存 + 低延迟 GC 策略,才把 P99 延迟控制在 10ms 以内。
总结一下今天的核心思路:
- Level-1 和 Level-2 从源头拆分,物理隔离
- WebSocket 网关只做连接管理,不做业务逻辑
- 数据分发服务负责订阅、推送、限流
- 中间用消息队列解耦,异步通信
- 注意连接数、数据顺序、GC 延迟这些细节
行情服务的拆分,其实没那么玄乎。核心就是「各司其职,互不干扰」。你只要把每个组件的职责划清楚,边界定好,剩下的就是填代码了。
好了,今天就聊到这儿。如果你在实际拆分中遇到什么问题,欢迎交流。
公众号:蓝海资料掘金营,微信deep3321