WebSocket消息推送实战-建立连接宏观

0 阅读6分钟

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/惰性清理兜底收敛”。

    •   这会显得你是按工程思维做设计,不是按理论最优做设计。

完整建连流程,应该怎么设计

  1. 客户端发起 WebSocket 握手,请求携带 userId、token、deviceType
  2. 握手拦截器提取参数并做基础校验
  3. 调认证服务校验 token
  4. 认证通过后完成 HTTP Upgrade
  5. 服务端生成 sessionId,构造 SessionMeta
  6. 当前节点注册本地 L1
  7. 同步注册 Redis L2
  8. 初始化心跳状态,挂入本地超时管理结构
  9. 触发离线补偿任务
  10. 返回连接成功 ACK