尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebSocket 高并发连接管理:四层映射架构设计与实践

WebSocket 高并发连接管理:四层映射架构设计与实践 做实时通信的兄弟应该都有这种感觉连接数从几百涨到上千最先出问题的往往不是带宽而是你用来管理连接的那堆 Map。我在 WeClaw 网关里负责连接路由模块时把 BridgeConnectionManager 拆成了四层映射八百个并发 WebSocket 长连接在本地环境压测消息转发平均耗时只有 0.6 毫秒。这篇文章把这套设计的来龙去脉和落地过程中的坑完整拆开讲适合做 WebSocket 服务、消息推送、IM 或者物联网接入的开发者参考。如果你想复现这套方案不需要什么黑科技两个 Kotlin/Java 文件就够了。关键是搞清楚为什么四层映射比简单的一个MapuserId, Channel要可靠得多。下面我从数据结构开始讲然后给压测数据最后把实际运维中踩过的问题都列出来。1. 四层映射先说为什么一个 Map 会炸1.1 单层 Map 的问题很多初版网关会写成MapLong, Channelkey 是用户 IDvalue 是 WebSocket 连接。看起来简单但第一个项目到两百个连接就乱了。用户多端登录时同一 userId 要对应多个 channel你只能改成MapLong, ListChannel。接着业务方要按房间推送你又得维护一个MaproomId, ListChannel还要在每次连接时和这两个 Map 同步连断时又在所有 List 里删除越写越痛苦。更麻烦的是时效性。WebSocket 连接不是恒定的断线、重连、心跳超时都会让 channel 失效。如果索引维护不干净最典型的表现是用户明明下线了消息还往旧连接上写客户端一直收不到服务器端还看不出报错。单层 Map 很难把这些状态拆干净。1.2 四层映射分别是哪四层BridgeConnectionManager 的四层映射本质是把“连接”到“业务目标”之间的路径切成四段层级映射名称含义典型 Key索引 Value第一层ConnectionMap物理连接注册表connIdWsConnection 对象第二层SessionMap会话与连接绑定sessionIdconnId第三层UserIndex用户与会话绑定userIdSetsessionId第四层TopicIndex订阅路由表topicSetsessionId其中第一层和第二层是强绑定第三层和第四层是逻辑映射。为什么要这样拆ConnId 是每个 WebSocket 连接在服务器内部生成的唯一标识只代表一条 TCP 通道SessionId 是业务会话标识一个连接可以被业务层拆成多个 session也可以一个 session 跨多个连接比如同一个用户在手机和电脑同时登录。用户和主题这两个维度再往上一级就形成了查询路径消息如果要发给某个用户先查 UserIndex 得到 sessionId再查 SessionMap 得到 connId最后从 ConnectionMap 取出 channel。1.3 双向索引和反向引用四层映射不是只建四个正向 Map关键还在于每个层级都要有对应的反向索引。比如 SessionMap 的正向是 sessionId - connId反向连接对象里要维护 SessionId 集合这样连接断开时不至于把 SessionMap 里的残留数据漏掉。反向索引的作用最重要体现在清理阶段。一次正常的连接关闭需要从 TopicIndex 的所有 topic 集合里把这个 sessionId 摘除从 UserIndex 的 userId 集合里摘除再删掉 SessionMap 的 sessionId 键最后删除 ConnectionMap 的 connId。四个操作必须全部执行且执行顺序先摘业务路由再删物理连接否则热点主题的集合里会留下一堆僵尸 sessionId。实测中僵尸 sessionId 第一次看数量很小但积累一个晚上就会让 TopicIndex 的遍历耗时从 0.1ms 涨到 5ms 以上。2. 从收到消息到写出响应完整的路由链路2.1 消息进来之后的三次查找当服务端收到一条带路由目标的消息比如{to:room:demo,payload:hello}BridgeConnectionManager 的处理路径非常固定从topicToSessions里取出订阅了该 topic 的所有 sessionId 集合挨个通过sessionToConn找到 connId通过connMap拿到实际的 Netty Channel调用 writeAndFlush。三层查找都是纯内存的哈希操作所以路由耗时基本和控制多少个连接没有关系。理论上 800 个连接和 8 万个连接单次查询都是 O(1)。压测时发现的 P99 耗时抖动绝大多数来自 Channel 的写缓冲或者 GC而不是查找本身。2.2 核心数据结构的定义用 Java 伪代码展示一下核心字段public class BridgeConnectionManager { // 第一层连接注册表 private final MapString, WsConnection connMap new ConcurrentHashMap(); // 第二层会话到连接的绑定 private final MapString, String sessionToConnMap new ConcurrentHashMap(); // 第三层用户到会话集合 private final MapString, SetString userToSessionMap new ConcurrentHashMap(); // 第四层主题到会话集合 private final MapString, SetString topicToSessionMap new ConcurrentHashMap(); public void register(WsConnection conn) { // 统一注册入口 connMap.put(conn.connId(), conn); // 这里会同时写入 sessionToConnMap / userToSessionMap / topicToSessionMap } public void routeToTopic(String topic, Object payload) { SetString sessionIds topicToSessionMap.get(topic); if (sessionIds null || sessionIds.isEmpty()) { return; } for (String sessionId : sessionIds) { String connId sessionToConnMap.get(sessionId); if (connId null) { continue; } WsConnection conn connMap.get(connId); if (conn ! null conn.channel().isActive()) { conn.channel().writeAndFlush(payload); } } } }注意topicToSessionMap的 value 我建议用ConcurrentHashMap.newKeySet()不要用普通HashSet因为消息路由是高频读断线摘除时又涉及写普通 HashSet 在多线程下要么加锁要么漏数据。ConcurrentHashMap.newKeySet()本身是并发安全的读成本比 CopyOnWriteArraySet 低得多。2.3 为什么查询是毫秒级关键是绕开锁这四层 Map 看起来都是 ConcurrentHashMap但如果每次都先拿一个全局锁再遍历照样慢。关键点是写入消息时不要在外面套锁。topicToSessionMap.get(topic)读到的Set是同一个对象如果恰好在断线清理时被并发修改最简单的处理是 for 循环里判空和isActive()而不要在遍历时用全局锁锁住整个方法。Netty 的writeAndFlush会把消息投递到对应 Channel 的 EventLoop 队列不是一个完全同步的写操作。如果你的业务线程直接调用它返回值里的ChannelFuture不能阻塞等待否则 EventLoop 被卡住整个连接的读和写都会卡。2.4 连接注册时的幂等处理高并发场景最容易忽略的就是注册幂等。同一个 sessionId 由于客户端重试可能会连续注册两次。如果按自然逻辑直接 put最严重的后果是旧连接没关新连接也注册进来了消息会被双写客户端看到重复推送。我在 register 里先根据 sessionId 查一次 SessionMap如果存在旧连接就把旧连接的 channel 强制关闭再挂新的。强制关闭旧连接时要注意顺序先关闭旧连接再从映射里删除旧数据最后注册新连接。否则会出现短暂窗口内新连接还没注册好旧连接已经被摘除正在路由的消息全部丢空。这个小顺序是当初压测后复盘发现的改完之后重复注册场景的丢消息率直接归零。3. 800 连接压测实录场景、参数与结果3.1 压测环境配置测试机用的是 8C16G 的 Linux 云主机JVM 堆设了 2G网关服务跑一个 Netty 实例监听 8080 端口。客户端在同一局域网内开 800 个 WebSocket 连接每个连接模拟一个不同的设备 ID登录后随机订阅 3 个业务主题其中有 1 个公共热点主题比如hot_room。压测目标是路由模块本身所以把业务处理、鉴权都剥离了只关注 BridgeConnectionManager 的查询和写出。压测之前先把 800 个连接全部建立好确认四层映射中的注册数据完整。如何确认我加了一个只读的 /debug/maps 接口打印各层 Map 的 size三个数字分别为 800、800、期望的 session 总数。后面每次压测前都先看这个接口能避免测试了半天结果数据其实没进 topic 集合的尴尬。3.2 消息模型与压测工具用 Node.js 写压测客户端依赖少ws 库就能满足。模拟一秒内服务端向hot_room推送 5000 条消息统计每个客户端收到消息的时间差。时间标记放在消息体里服务器生成消息时带上ts客户端收到后用Date.now()减去ts这个差值包含了网络、内核协议栈、Netty 调度和路由查询是真实的端到端延迟。const WebSocket require(ws); const total 800; const clients []; for (let i 0; i total; i) { const ws new WebSocket(ws://127.0.0.1:8080/ws?deviceId i); ws.on(open, () { ws.send(JSON.stringify({type: subscribe, topic: hot_room})); }); ws.on(message, data { const msg JSON.parse(data); console.log(Date.now() - msg.ts); }); clients.push(ws); }注意 ws 的 message 回调不是实时调用手脚架压测客户端时唯一的方式关键是 800 个连接同时输出日志会产生很大的 IO 开销。压测时我把日志汇总到内存数组里在压测结束后一次性落地否则客户端本身就会成为瓶颈延迟数据会虚高。3.3 实测数据与解读推送 5000 条消息后统计结果大致是这样的指标数值平均端到端延迟4.8msP99 端到端延迟8.2msBridgeConnectionManager 路由耗时不含网络平均 0.6ms路由耗时 P992.1ms丢消息数0很多做业务的朋友第一反应是端到端 4.8ms 太慢但请别忽略这已经包含了 800 个客户端在同一个热点主题下的广播成本。真正和 BridgeConnectionManager 相关的是路由耗时平均 0.6ms。这个 0.6ms 是通过在 routeToTopic 入口和出口各打一个时间戳减出来的。也就是说从拿到消息对象到把所有 800 个 Channel 都命中并调用 writeAndFlush只需要不到一毫秒。哪些因素会导致 Route 耗时上涨我跟踪过几类情况第一某个连接长时间不读写缓冲积压writeAndFlush返回变慢第二GC 停顿导致 ConcurrentHashMap 读出现毛刺第三topicToSessionMap 的 Set 太大且遍历时每次都要做sessionToConnMap.get和connMap.get虽然都是 O(1)但如果 session 对象的内存指纹大缓存命中率会下降。关于最后一点可以优化为直接把 connId 也放进 session 索引对象里这里就不展开了。3.4 对比一下没有四层映射的版本为了验证设计我把压测代码改回单层 MapMaptopic, ListChannel。同样 800 连接路由耗时平均从 0.6ms 涨到 2.7msP99 直接从 2.1ms 跳到 11ms。这个差距在单独一次推送时感觉不明显但当你开始一秒推几千条消息时单层 Map 的遍历、删除、重建集合会让服务端的 CPU 打满。最典型的是某个客户端连接断开时如果你用List.removeIf去删除 Channel而这段代码在热点 topic 的推送循环里被并发执行轻则 ConcurrentModificationException重则推送线程阻塞。单层 Map 还有个隐蔽问题你没有办法精确知道这条 channel 到底属于谁。一旦连接断开你只能从所有 List 里删同一个 channel逻辑上是对的但出了问题后根本没有现场可查。四层映射虽然多了三个索引却给每一个断线操作留下了精确的审计路径哪个 sessionId 断开、影响了哪些 topic、哪些 user都能按层排查。4. 运维实战心跳、重连与索引清理4.1 连接不清理会让四层映射越来越臃肿WebSocket 断线不总是能触发 close 事件。客户端突然拔网线、进程被杀、网络切换服务端可能很久才能感知。于是 BridgeConnectionManager 里的僵尸连接会越积越多。我见过最糟糕的一次业务侧通过心跳把 800 个真实连接清理到只剩 300 多个但 ConnectionMap 里还有 800 个记录其中一半都是无效的。要根治必须在心跳超时后主动关闭连接并执行完整的反注册。注册时记录lastActiveTime每次收到任何消息都更新它。心跳定时任务每 30 秒扫描一次连接超过 90 秒没动的连接就触发 close。这段逻辑不是简单把 Map 清掉而是要走统一的 deregister 方法反向索引四张表都清理干净。4.2 心跳机制和路由表的联动心跳有客户端主动 ping 和服务端主动 ping 两种。我建议服务端主动 ping因为服务端对连接状态有最终解释权。每个连接分配一个TimerTask如果 60 秒内没有收到 ping 响应就认为连接不可用。这里的关键是 TimerTask 不能直接调 close因为它执行在定时器线程里而 Channel 的 close 必须在对应的 EventLoop 线程里调用。正确方式是通过channel.eventLoop().execute()提交一个清理任务。心跳超时后先不马上摘除路由而是给一次重连机会。我在实际项目中给连接标记为 halfOpen继续保留在 topic 集合里但消息不再 flush。客户端如果立刻重连老连接的 sessionId 会被幂等注册流程接管不会丢消息。如果超过 10 秒仍未恢复才执行完整 deregister。这套策略对移动端网络切换特别友好用户从 Wi-Fi 切到 4G/5G心跳会短暂超时但重连很快所以几乎无感知。4.3 800 个连接同时断线怎么保护热点主题加上一堆物联网设备偶尔会出现所有连接同时掉线。如果每个连接都立刻触发 deregister瞬间会有大量写操作争抢同一批 ConcurrentHashMap虽然并发安全但会让事件循环线程的负载飙高。我的做法是给清理任务加一个节流队列把同一时刻到达的断开事件按 connId 聚合分批处理每批最多处理 100 个批次之间 sleep 5ms。这个节流操作只影响服务端主动清理的力度客户端侧不会察觉。同时在线下压测时我把这个场景单独测了一遍800 个连接强制退出服务端 CPU 从 25% 涨到 60%2 秒内完成所有清理没有引发连锁奔溃。清理过程中依然能正常转发消息因为新注册连接不受旧清理任务影响。4.4 常见问题速查表症状可能原因排查方向某用户收不到消息反向索引未建立或已摘除检查 userToSessionMap 里是否还有 sessionId消息重复推送sessionId 被注册了两次检查幂等注册是否关闭旧连接内存持续上涨僵尸连接没有触发 close检查心跳超时任务的生命周期路由耗时突然变高某个 topic 的 Set 中积压大量失效 session打印 topic 中包含的 sessionId查看所有连接状态丢消息路由线程和连接清理线程并发读写检查是否是 CopyOnWriteArraySet建议换 ConcurrentHashMap.newKeySet这张表是我们在维护 WeClaw 网关过程中沉淀下来的每次接入新的业务方我都会先给他们发这张表。很多问题其实是索引一致性问题而不是网络问题先把四层映射的状态对齐往往能解决一半。5. 这套四层映射能不能用在其他 WebSocket 项目5.1 四层映射本身是通用设计BridgeConnectionManager 这个名字和 WeClaw 不绑定你完全可以用同样的思路去重构自己项目里的连接管理。核心思想其实就一句话把连接、会话、用户、主题四个维度解耦每一层只维护和自己直接相关的映射并且提供完整的反向索引。回到文章标题里的“800 个连接”它本身不是一个门槛而是一个校验点。如果你的网关连接数还不到 800也许一个 Map 也能跑但一旦你开始做多端登录、按业务域推送、组织通讯录同步这类需求四层映射带来的可维护性收益会远大于那几个 Map 的内存和 CPU 开销。连接管理模块的价值不在于它写得花哨而在于当规模上来后你还能按层级去排查问题。5.2 简化版方案什么时候可以砍到三层如果是小项目确实可以把用户层和会话层合并直接用用户 ID 作为 sessionId变成三层映射用户 - conn、主题 - conn。这样少一层查找代码也更短。但前提是用户单设备登录且同一个连接不会经历多会话切换。如果未来要支持多端登录你又得把用户层拆回来所以一开始想清楚业务边界更重要。我的建议是任何计划支撑超过一年的实时通信项目都直接上四层。别为了省代码量把后续的扩展空间堵死。拆开很容易再合回来难这是最常见的返工原因。5.3 集群化部署后的路由演进单个实例能支撑的 WebSocket 连接数终究有限800 连接只是单机验证。水平扩展时四层映射中的 ConnectionMap 会变成分布式状态你要考虑把 topicToSessionMap 放到 Redis 或内存消息总线里。但这并不代表本地的四层映射要废弃反而要本地缓存热点 topic 的 session 集合并监听变更事件增量更新本地缓存。跨实例转发时BridgeConnectionManager 就不再是简单的 HashMap 组合了。消息先进入某个实例该实例根据目标 topic 在集群里的分布把消息投给其他实例上对应的 Channel。这时候四层映射的 SessionId 要设计成全局唯一比如 “instanceId:localSessionId” 的字符串便于快速定位目标实例。5.4 一个小提醒不要让路由方法变成万能方法我在 WeClaw 里踩过另一个坑业务方会把所有消息都通过同一个routeToTopic发送理由是方便。结果路由方法上堆了一堆逻辑包括消息过滤、频率限制、扩展字段最终把一个应该在业务层处理的问题带进了连接管理层。四层映射只负责“把消息投给正确的连接”复杂策略请放到调用方去做。保持路由模块的单一职责你后期维护时才不会崩溃。最后再分享一个我在实际调试中总结的小习惯每次版本上线后先观察四个 Map 的 size 变化曲线正常情况下必须和连接数、会话数、在线用户数一一对应。如果某个 metric 出现偏差先不要查业务逻辑先查是不是有连接漏删了。连接管理的隐性成本永远藏在索引一致性里。
返回列表