行情服务拆分: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-1 网关 Level-2 网关 Level-1 处理服务 快照缓存 + 全量推送 Level-2 处理服务 逐笔合并 + 增量推送 WebSocket 网关 连接管理 + 协议转换 数据分发服务 订阅管理 + 推送调度 客户端 实线:数据流 | 虚线:订阅/控制流

嗯,这里要注意: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