WebSocket 建连不只是握手:长连接平台的连接建立设计
本章的主线就是讨论在 长连接 系统里,“连接建立成功”不等于 HTTP Upgrade 成功,而是“这条连接已经完成身份确认、会话注册、存储落盘、活性接管,并具备后续路由能力”。
引出问题:为什么 WebSocket 建连不是一次简单握手
WebSocket 建连,真正建立的不是 TCP 通道,而是“可路由会话”
大部分人去理解websocket建立连接,仅仅停留在“客户端发起握手,服务端Upgrade成功,连接就建立了,这个理解如果在单机里面也没有问题,但是一旦进入了分布式长连接场景,就不够了。因为在这种场景下,我们不仅仅只需要思考这个socekt已经连上,而是还要去思考这几个方面:
-
这条连接是谁的
-
这条连接在哪个节点上
-
后面的点对点消息如何找到他
-
心跳超时后谁来清除
-
用户多端同时在线时,系统如何表示这组关系
建立连接的本质,不只是握手成功,而是把一条物理连接去升级成一条平台可以管理,可以寻址,可以清理的会话
为什么建连时不能只做鉴权和创建 Session
建立连接时至少要做四件事情:
- 鉴权
- 创建SessionMeta
- 写入会话存储
- 启动心跳
下面咱们来探讨如果缺少这些步骤会出现的问题:
- 鉴权:系统要确认这条连接到底属于谁,如果身份不可信,后续会话映射,消息路由都会失去作用。
- session创建不能省,因为后续系统不是按照userid来管理连接的,而是按sessionid来管理,sessionid是独立连接单元,它承载的是一条具体连接的生命周期。
- 缓存写入:连接如果只存在内存里,那么其他节点会找不到它,对系统而言就是存在但不可见状态。
- 心跳任务:长连接系统并不怕连接多,而是怕失效连接清不掉,如果建立连接后不进行心跳,后面redis会残留脏会话,本地也会保留僵尸连接
多端共存的做法
允许一个用户持有多个连接并存。也就是说,平台的基本假设不是“一个用户一个连接”,而是“一个用户多个 session”。
所以会话映射要设计成:
- 本地:userId -> sessionIds
- Redis:ws:user:sessions:{userId} -> Set(sessionId)
- 单连接详情:ws:session:{sessionId}
这个模型的好处:
- 支持手机、电脑同时在线
- 支持同一设备多个 tab
- 支持一个用户在不同业务场景下并行持有多条连接
- 后续点对点推送可以做“全量在线连接投递”
多端在线不是异常分支,而应该是 长连接 平台默认支持的基础能力。
L1 本地缓存 + L2 Redis 中心缓存,为什么是最合适的组合
首先指出存在的问题
单用 本地缓存,优点是快,没有网络往返,适合高频读写;缺点是只在当前节点可见,跨节点无法共享。
单用 Redis,优点是全局共享,天然适合分布式路由;缺点是每次操作都有网络成本,频繁触发会放大延迟和压力。
所以最合适的方案不是二选一,而是分层:
-
L1 本地缓存保存真实 WebSocketSession 和热点索引
-
L2 Redis 保存全局会话真相和跨节点路由信息
-
L1 负责:
-
本机消息发送
-
本机心跳更新
-
本机超时清理
-
本机连接关闭
-
L2 负责:
-
用户全局在线连接索引
-
单连接会话详情
-
跨节点路由
-
节点哨兵
- L1 存的是“真实连接对象”,L2 存的是“全局可见真相”。
多节点本地缓存和中心缓存,怎么保证一致性
这里最值得写的不是“如何强一致”,而是“为什么不要追求错误的强一致”。
在分布式 WebSocket 场景里,不应该要求每个节点都维护一份全量本地连接缓存。因为真实连接只存在于持有它的那个节点上,其他节点就算同步到了元数据,也拿不到真实 WebSocketSession 对象。
所以要保证的一致性是:
-
当前持有连接的节点,本地 L1 必须准确
-
Redis L2 作为全局真相,必须尽快完成注册
-
其他节点不维护全量本地副本,而是在路由时按需查 Redis
- 这就是一致性问题的关键转向:不是“让所有本地缓存完全同步”,而是“让 Redis 成为唯一全局真相,本地只维护自己负责的真实连接”。
- 所以完整策略可以写成:
-
建连时:先写本地 L1,再写 Redis L2
-
Redis 写失败:立即回滚本地并关闭连接
-
读路径:本机优先读 L1,跨节点优先查 Redis
-
失效路径:本机主动清理 + Redis TTL 兜底 + 惰性清理悬挂索引
-
节点级故障:通过 ws:node:alive:{nodeId} 哨兵识别
- 这比“定时全量同步 本地缓存 ”靠谱得多,也比“所有节点双写全量本地缓存”简单得多。
Redis 三次独立写入,怎么优化性能(根据具体场景再来思考)
建连时通常要做三件 Redis 写入:
-
SADD ws:user:sessions:{userId} sessionId
-
HMSET ws:session:{sessionId} ...
-
EXPIRE ws:session:{sessionId} ttl
-
如果每一步都单独网络往返,连接量一大,开销会明显放大。
-
第一层优化是 Pipeline。
-
把多条命令批量发送,减少 RTT,这个最适合建连和心跳这类“多命令、弱原子、重吞吐”的场景。
-
第二层优化是 Lua 。
-
如果你特别强调“用户索引和会话详情必须一起成功”,可以用 Lua 脚本把它们包起来。在这个场景下,我更建议建连主链路优先用 Pipeline + 失败回滚。原因是它更轻,吞吐更高,也更符合“平台级会话注册”的性能要求。
-
这里博客里可以写你的取舍:
-
我不追求把建连注册做成 Redis 侧的绝对原子大事务,而是追求“主链路足够快 + 失败立即回滚 + 后续 TTL/惰性清理兜底收敛”。
-
这会显得你是按工程思维做设计,不是按理论最优做设计。
-
完整建连流程,应该怎么设计
- 客户端发起 WebSocket 握手,请求携带 userId、token、deviceType
- 握手拦截器提取参数并做基础校验
- 调认证服务校验 token
- 认证通过后完成 HTTP Upgrade
- 服务端生成 sessionId,构造 SessionMeta
- 当前节点注册本地 L1
- 同步注册 Redis L2
- 初始化心跳状态,挂入本地超时管理结构
- 触发离线补偿任务
- 返回连接成功 ACK