WebSocket 连接生命周期:建立、保活、断开与重连
面向新手的完整指南。从 TCP 握手到重连恢复,逐层剖析 turms-gateway 的 WebSocket 连接管理。
建议先阅读 01-gateway-startup.md 了解 gateway 启动流程和基础组件。
一、系统架构总览
下图展示了客户端、SLB、Gateway 集群、Service 集群、Redis 集群、MongoDB 集群之间的调用关系与主要数据流向。
图中编号对应的数据流说明:
编号 数据流 协议 说明 ① 客户端 → Gateway WebSocket / TCP / UDP 长连接,承载 TurmsRequest(protobuf)和心跳 ② Gateway ↔ Redis TCP (Lettuce) 登录/登出/心跳刷新 Session 状态;底层用 Lua 脚本保证原子性 ③ Gateway ↔ Redis TCP (Lettuce) 用户位置(经纬度)的读写 ④ Gateway → Service TCP (Turms RPC) 业务请求转发:Gateway 不处理业务,只做鉴权+路由+推送 ⑤ Service → Gateway TCP (Turms RPC) 消息推送通知:Service 告诉 Gateway "给这些用户推这条消息" ⑥ Service → Redis TCP (Lettuce) 群聊/私聊消息的 Sequence ID(递增序号) ⑦ Service → MongoDB TCP (Reactive Driver) 消息、用户、群组、会话等业务数据的持久化 ⑧ 所有节点 → MongoDB TCP (Reactive Driver) 集群成员注册、心跳续期、Leader 选举(通过 Change Stream)
关键设计决策
为什么 Gateway 和 Service 分离?
- Gateway 负责长连接管理(百万级并发连接)、认证、流控、推送
- Service 负责业务逻辑(消息、群组、用户)、存储
- 两者独立扩缩容:连接数增长只扩 Gateway,业务压力增长只扩 Service
为什么 Gateway 不直接访问 MongoDB 业务数据?
- 解耦:Gateway 不需要知道业务 schema
- 安全:Gateway 暴露在公网侧,不持有数据库权限
- 缓存效率:Service 可以缓存热点数据,Gateway 无状态
为什么 Session 状态放 Redis 而不是 MongoDB?
- Redis Hash 天然适合"一个用户 → 多个设备 → 多个节点"的映射结构
- TTL 机制天然支持心跳超时自动清理
- 读写延迟远低于 MongoDB(微秒级 vs 毫秒级)
二、完整生命周期一图流
下图用一张状态机囊括了 WebSocket 连接从建立到销毁的全部阶段,以及中间的心跳保活、UDP 切换、断链重连路径。
ini
┌──────────────────────────────────────────────────────────────────┐
│ 客户端发起 TCP 连接 │
│ │ │
│ ↓ │
│ ┌─────────────────────────┐ │
│ │ ① WebSocket 握手 │ │
│ │ │ │
│ │ GET / HTTP/1.1 │ │
│ │ Upgrade: websocket │ ← 验证: 方法=GET │
│ │ Connection: Upgrade │ 头部含 Upgrade/Connection│
│ │ Sec-WebSocket-Key: xxx │ Sec-WebSocket-Key 非空 │
│ │ │ IP 未被封禁 │
│ └────────────┬────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────┐ │
│ │ ② HTTP 101 响应 │ ← 此时: │
│ │ Sec-WebSocket-Accept │ 有 WebSocketConnection │
│ │ │ 有 UserSessionWrapper │
│ │ 创建: │ 无 UserSession (!) │
│ │ WebSocketConnection │ 登录超时计时器启动 │
│ │ UserSessionWrapper │ │
│ │ 预注册 notification- │ │
│ │ Consumer 回调 │ │
│ └────────────┬────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────┐ │
│ │ ③ 客户端发送 │ │
│ │ CREATE_SESSION_REQUEST │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ │ 认证: │ 并发: │ 成功: │
│ │ PASSWORD/JWT/HTTP │ tryRegisterOnline │ 创建 UserSession │
│ │ /LDAP/NOOP/插件 │ User() │ 绑定到连接 │
│ │ │ → CAS 原子写入 Redis│ 触发 notification│
│ │ 失败 → 返回错误 │ → 创建本地 Session │ Consumer │
│ │ 关闭连接 │ → 踢冲突旧设备 │ │
│ └─────────────────────┴─────────────────────┘ │
│ │ │
│ ↓ │
│ ╔══════════════════════════════════════════════════════════╗ │
│ ║ 【状态 A】WebSocket 在线模式 ║ │
│ ║ ║ │
│ ║ isConnected = true isSessionOpen = true ║ │
│ ║ isSwitchingToUdp = false ║ │
│ ║ ║ │
│ ║ HeartbeatManager 每秒巡检: ║ │
│ ║ ┌─────────────────────────────────────────────────┐ ║ │
│ ║ │ 客户端发心跳/业务请求 → 更新本地时间戳 │ ║ │
│ ║ │ 客户端不发任何数据 → 时间戳停留在登录时间 │ ║ │
│ ║ └─────────────────────────────────────────────────┘ ║ │
│ ╚══════════════════════════════════════════════════════════╝ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ 只发心跳无业务 无任何活动 │
│ 持续 540s 持续 180s │
│ │ │ │
│ ↓ ↓ │
│ ╔══════════════════════╗ ╔══════════════════════╗ │
│ ║ 【状态 B】UDP 模式 ║ ║ 【状态 E】离线 ║ │
│ ║ ║ ║ ║ │
│ ║ isConnected=false ║ ║ ① Redis 删除状态 ║ │
│ ║ isSwitchingToUdp ║ ║ ② 本地 Session 清理 ║ │
│ ║ =true ║ ║ ③ IP 映射清理 ║ │
│ ║ isSessionOpen=true ║ ║ ④ 发送 Close 帧 ║ │
│ ║ ║ ║ ⑤ 插件 goOffline ║ │
│ ║ WS 连接已关闭 ║ ╚══════════════════════╝ │
│ ║ 客户端UDP发心跳 ║ │ │
│ ║ 维持会话 ║ │ 客户端重连 │
│ ╚══════╤═════════════╝ │ │
│ │ │ │
│ │ 有新消息推送 │ │
│ │ → tryNotifyClient- │ │
│ │ ToRecover() │ │
│ │ → 发 UDP 信号 │ │
│ │ OPEN_CONNECTION │ │
│ │ │ │
│ │ 客户端收到信号 │ │
│ │ → 建立新 WS 连接 │ │
│ │ → CREATE_SESSION │ │
│ │ → 复用旧 Session │ │
│ │ → 只替换连接 │ │
│ │ │ │
│ └─────────┬───────────────┘ │
│ │ │
│ ↓ │
│ 回到【状态 A】WebSocket 在线模式 │
│ │
└──────────────────────────────────────────────────────────────┘
图 1: WebSocket 连接完整生命周期状态机
状态转换速查表
| 状态转换 | 触发条件 | 核心操作 |
|---|---|---|
| 无连接 → ① WS 握手 | 客户端主动发起 | HTTP Upgrade, 验证握手头, 创建 Wrapper 和 Connection |
| ① → ② HTTP 101 | 握手验证通过 | 创建 WebSocketConnection + UserSessionWrapper, 预注册 notificationConsumer |
| ② → ③ CREATE_SESSION | 客户端发送登录请求 | --- |
| ③ → 状态 A (在线WS) | 认证通过 | Redis CAS 写入 → 本地创建 Session → 绑定连接 → 触发 notificationConsumer |
| 状态 A → 状态 A | 心跳/业务请求 | 更新本地时间戳, HeartbeatManager 后台刷 Redis TTL |
| 状态 A → 状态 B (UDP) | 仅心跳无业务 540s | 关闭 WS 连接, 会话保持, 通知客户端切 UDP |
| 状态 A → 状态 E (离线) | 无活动 180s / 连接断开 | Redis 删除 → 本地清理 → 发 Close 帧 |
| 状态 B → 状态 A | 有新消息推送 / 客户端主动 | 发 OPEN_CONNECTION → 客户端重连 WS → 复用旧 Session |
| 状态 B → 状态 E | 无心跳 180s / 客户端主动断开 | 同状态 A→E |
| 状态 E → 状态 A | 客户端重连 + CREATE_SESSION | 全新 Session 创建(旧 Session 已被销毁) |
三、连接建立
2.1 代码调用链
scss
WebSocketUserSessionAssembler (构造)
└── WebSocketServerFactory.create()
└── HttpServer.create()...bind() ← Netty 启动,监听端口
客户端连接到达:
WebSocketServerFactory.handleHttpRequest(request, response)
├── CORS 预检 → 返回 200
├── 验证握手头 (GET / Upgrade: websocket / Connection: upgrade / Sec-WebSocket-Key)
├── IP 封禁检查
└── response.sendWebsocket((in, out) -> {
in.aggregateFrames() // 聚合 WebSocket 帧
.receiveFrames() // 只接收 BinaryWebSocketFrame
in.receiveCloseStatus() // 捕获关闭事件
connectionListener.onAdded(connection, remoteAddr, inbound, out, onClose)
})
UserSessionAssembler.bindConnectionWithSessionWrapper()
├── 创建 WebSocketConnection (包装 Netty Connection)
├── 创建 UserSessionWrapper (绑定连接 + 地址 + 超时计时器)
│ └── 预先注册 notificationConsumer 回调:
│ 当 UserSession 建立后,此回调会把 TurmsNotification 写入 Netty outbound
├── respondToRequests():
│ 订阅 inbound 数据流,每条数据 → ClientRequestDispatcher.handleRequest()
│ 响应 → netConnection.send() → 写入 WebSocket
└── tryRemoveSessionInfoOnConnectionClosed():
连接关闭时 → closeLocalSession(UNKNOWN_ERROR) ...除非是 UDP 切换
2.2 登录前发生了什么
WebSocket 建连后,客户端和 gateway 之间只有一条 TCP 通道,没有任何 UserSession。此时:
UserSessionWrapper已经创建,但userSession字段为 nullWebSocketConnection已经创建,isConnected = true- 建立了登录超时计时器(
establishTimeoutMillis),超时未登录则关闭连接 - 客户端可以发送任何
TurmsRequest,但ClientRequestDispatcher会检查权限
2.3 登录流程
markdown
ClientRequestDispatcher.handleServiceRequest()
→ KindCase = CREATE_SESSION_REQUEST
→ SessionClientController.handleCreateSessionRequest()
1. 已有 session? → 返回 CREATE_EXISTING_SESSION
2. 解析请求: userId, password, deviceType, userStatus, deviceDetails, location
3. SessionService.handleLoginRequest()
├── 版本校验 (必须 = 1)
├── 设备类型校验 (是否被禁用)
└── SessionIdentityAccessManager.verifyAndGrant()
├── IAM 关闭 → 授予全部权限
├── PASSWORD → 本地密码验证
├── JWT → JWT token 验证
├── HTTP → 外部 HTTP 认证
├── LDAP → LDAP 认证
└── 插件: UserAuthenticator 扩展点
4. tryRegisterOnlineUser()
├── fetchUserSessionsStatus(userId) ← 从 Redis 取最新状态(绕过缓存)
├── 清理本地不一致的旧 session
├── 用户 OFFLINE → addOnlineDeviceIfAbsent()
└── 用户 ONLINE → 设备冲突处理(见 2.5)
5. 取消登录超时计时器
sessionWrapper.setUserSession(session)
→ 触发 notificationConsumer 回调 (绑定到新连接)
→ 插件 goOnline 扩展点
6. 返回 OK 给客户端
2.4 登录时的双层写入
Redis 先写,本地后写:
xml
Redis (Lua 原子操作 try_add_online_user_with_ttl.lua):
HSET <userId> <deviceType> = <nodeId>
HSET <userId> <nodeId> = <heartbeatTimestamp>
HSET <userId> $ = <userStatus>
EXPIRE <userId> <TTL>
本地 (SessionService.addOnlineDeviceIfAbsent):
userIdToSessionsManager.computeIfAbsent(userId, ...)
→ UserSessionsManager.addSessionIfAbsent(...)
→ new UserSession(version, permissions, userId, deviceType, ...)
ipToSessions.computeIfAbsent(ip, ...).add(session)
2.5 并发登录冲突处理
用户可能已有设备在线,新设备登录时触发冲突处理:
ini
tryRegisterOnlineUser() 中:
sessionInfo = Redis 中的同设备类型信息
if sessionInfo.isActive():
localSession = getLocalUserSession(userId, deviceType)
isClosedSessionOnLocal = (localSession存在 且 连接已断开)
if isClosedSessionOnLocal:
→ 这是 UDP→WebSocket 恢复!复用旧 Session
else:
→ 真正的设备冲突
→ LoginConflictStrategy 决定:
DISCONNECT_LOGGING_IN_DEVICE → 拒绝新设备
DISCONNECT_LOGGED_IN_DEVICES → 踢掉旧设备 (默认)
默认策略 DISCONNECT_LOGGED_IN_DEVICES:新设备登录时,旧设备被踢掉,确保新设备能成功登入。
2.6 建立成功/失败返回
| 结果 | 错误码 | 触发位置 |
|---|---|---|
| 成功 | OK |
正常流程 |
| 客户端版本不支持 | UNSUPPORTED_CLIENT_VERSION |
SessionService.handleLoginRequest() |
| 禁止的设备类型 | LOGIN_FROM_FORBIDDEN_DEVICE_TYPE |
同上 |
| 认证失败 | LOGIN_AUTHENTICATION_FAILED |
SessionIdentityAccessManager |
| 设备冲突 | SESSION_SIMULTANEOUS_CONFLICTS_DECLINE |
tryRegisterOnlineUser() |
| 登录超时 | LOGIN_TIMEOUT |
UserSessionWrapper 超时计时器 |
| 已存在会话 | CREATE_EXISTING_SESSION |
SessionClientController |
| 服务不可用 | SERVER_UNAVAILABLE |
ServiceAvailabilityHandler |
| IP 被封禁 | 直接关闭连接(无响应) | ServiceAvailabilityHandler |
四、心跳保活
3.1 三层保护机制
java
┌─────────────────────────────────────────────────────────┐
│ 应用层心跳 (Turms HeartbeatManager) │
│ 检测间隔: 1s 扫描 | 超时: 180s │
│ 作用: 检测静默断开 (NAT超时、防火墙、客户端crash) │
├─────────────────────────────────────────────────────────┤
│ WebSocket Ping/Pong (Netty 自动) │
│ 主动发送间隔: 未配置 | 被动响应: 自动 │
│ 作用: 辅助检测传输层活跃性 │
├─────────────────────────────────────────────────────────┤
│ TCP Keepalive (OS 默认) │
│ 检测间隔: 7200s (2小时) | 探测次数: 9 | 间隔: 75s │
│ 作用: 极慢的兜底,实际不依赖 │
└─────────────────────────────────────────────────────────┘
实际生效的断开判定由应用层心跳主导(180s)。TCP Keepalive 太慢(2小时),没有实用价值。
3.2 心跳的两种形式
| 传输方式 | 心跳形式 | 处理位置 |
|---|---|---|
| TCP/WebSocket | 空 frame(ByteBuf 不可读) |
ClientRequestDispatcher.handleHeartbeatRequest() |
| UDP | 14 字节结构体(1 字节类型 + 8 字节 userId + 1 字节 deviceType + 4 字节 sessionId) | UdpRequestDispatcher.handleDatagramPackage() |
核心设计:心跳只更新本地时间戳,不写 Redis。
客户端发心跳
→ Gateway 更新 UserSession.lastHeartbeatRequestTimestampNanos
→ 返回空响应
→ Redis 不操作
如果每个心跳都写 Redis,海量在线用户下是性能灾难。
3.3 HeartbeatManager:后台巡检线程
HeartbeatManager 运行在单线程 turms-client-heartbeat-refresher 中,每 1 秒执行一次:
scss
while (!thread.isInterrupted()) {
updateOnlineUsersTtl(); // 遍历所有在线 session
Thread.sleep(1000);
}
对每个 session 执行 closeOrUpdateSession(),三步检查(顺序敏感):
scss
closeOrUpdateSession(session, nowNanos):
┌─────────────────────────────────────────────────────────────┐
│ ① UDP 切换检查 │
│ 条件: UDP启用 + 非浏览器 + 连接正常 + 无业务请求超540s │
│ 动作: connection.switchToUdp() → 关闭WS,通知客户端切UDP │
│ 返回: false (本次不继续检查) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ② 心跳超时检查 │
│ 条件: max(心跳时间, 请求时间) 距现在 > 180s │
│ 动作: closeLocalSession(HEARTBEAT_TIMEOUT) │
│ 返回: false │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ③ Redis TTL 刷新 │
│ 条件: 距上次刷新 > 18s 且 客户端期间发过新请求 │
│ 动作: 标记需要刷新 Redis 的 userId │
│ 返回: true │
└─────────────────────────────────────────────────────────────┘
被标记的 userId 通过批量 Lua 脚本 update_users_ttl.lua 一次刷新:
yaml
对每个 userId:
if HEXISTS userId nodeId: # 本节点仍拥有该设备
EXPIRE userId TTL # 续期
HSET userId nodeId now # 更新心跳时间戳
else:
记入"不存在"列表
Lua 脚本返回的不存在 userId 会被 closeLocalSession(DISCONNECTED_BY_OTHER_DEVICE) 关闭------说明 Redis 侧已过期,该用户在集群视角已离线。
3.4 不发送心跳时会发生什么
ini
时间线 (客户端登录后不发任何数据):
t=0 登录. lastHeartbeatTimestampNanos = 登录时间
t=18s HeartbeatManager 首次刷新 Redis TTL
此后不再刷新 Redis (因为客户端没有新活动)
t=180s 心跳超时 → closeLocalSession(HEARTBEAT_TIMEOUT)
→ 完整断开流程 (见第四节)
注意 :max(心跳时间, 请求时间) 意味着任何业务请求也等同于心跳。客户端不发心跳但发消息,会话不会超时。
3.5 UDP 切换
如果客户端只发心跳不发业务请求,540s 后触发 UDP 切换:
ini
时间线:
t=0 登录
t=60s 客户端发心跳 → lastHeartbeatTimestampNanos 更新
t=120s 客户端发心跳 → lastHeartbeatTimestampNanos 更新
... (lastRequestTimestampNanos = 登录时间,从未更新)
t=540s UDP 切换检查:
lastRequestTimestampNanos = 登录时间
540s > 540s? → YES
→ connection.switchToUdp()
- WebSocket 连接关闭 (closeStatus = SWITCH)
- isConnected = false, isSwitchingToUdp = true
- 会话 (isSessionOpen) 保持
- 客户端后续通过 UDP 发心跳维持在线
为什么两个时间戳要分开? UDP 切换检查只看 lastRequestTimestampNanos(业务请求),心跳超时检查看 max(heartbeat, request)。这意味着:如果客户端只发心跳,540s 后切到 UDP 释放 WS 连接资源;如果客户端发业务请求,两个时间戳都更新,永远不触发切换。
五、连接断开
4.1 触发场景
| 触发方式 | 关闭状态码 | 入口 |
|---|---|---|
客户端主动 DELETE_SESSION_REQUEST |
DISCONNECTED_BY_CLIENT |
SessionClientController |
| 连接异常断开 (网络断开、客户端crash) | UNKNOWN_ERROR |
UserSessionAssembler |
| 心跳超时 (180s 无活动) | HEARTBEAT_TIMEOUT |
HeartbeatManager |
| Redis 侧已过期 | DISCONNECTED_BY_OTHER_DEVICE |
HeartbeatManager |
| 服务器关闭 | SERVER_CLOSED |
SessionService.destroy() |
| 管理员封禁/IP 踢人 | 视调用方 | SessionService.closeLocalSessions() |
4.2 统一关闭流程
所有关闭路径汇聚到 closeLocalSessions(userId, deviceTypes, closeReason, manager),顺序至关重要:
erlang
closeLocalSessions():
┌─────────────────────────────────────────────────────────────┐
│ ① Redis 先删 │
│ userStatusService.removeStatusByUserIdAndDeviceTypes() │
│ → Lua 脚本: 只删本节点的设备字段,清理空 Hash │
│ │
│ ⚠️ 必须先删 Redis,再清本地。 │
│ 如果反过来,客户端在"连接已关、Redis未删"的窗口期重连, │
│ 会读到陈旧 Redis 数据误判为设备冲突。 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ② 本地 Session 清理 │
│ manager.closeSession(deviceType, closeReason) │
│ → deviceTypeToSession.remove(deviceType) │
│ → UserSession.close(reason) │
│ → isSessionOpen = false │
│ → WebSocketConnection.close(reason) │
│ 1. 发送 CloseNotification 帧 (含关闭原因) │
│ 2. 等待客户端 close ack (超时可配) │
│ 3. 发送 WebSocket Close Frame (1000) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ③ IP 映射清理 │
│ ipToSessions.computeIfPresent(ip, ...) → remove(session) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ④ 位置信息清理 (可选) │
│ sessionLocationService.removeUserLocation() │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ ⑤ 空 Manager 清理 │
│ if countSessions() == 0: │
│ userIdToSessionsManager.remove(userId) │
│ 插件 goOffline 扩展点 │
└─────────────────────────────────────────────────────────────┘
4.3 连接异常断开详解
当 TCP 连接因网络问题断开时(tryRemoveSessionInfoOnConnectionClosed):
java
// UserSessionAssembler.java:157-204
.doFinally(signal -> {
UserSession userSession = sessionWrapper.getUserSession();
if (userSession == null) {
return; // 还没登录,无需清理
}
if (userSession.isOpen()
&& !userSession.getConnection().isSwitchingToUdp()) {
// ⚠️ 异步!fire-and-forget
sessionService
.closeLocalSession(userId, deviceType, UNKNOWN_ERROR)
.subscribe(null, ...);
}
// 如果 isSwitchingToUdp = true → 跳过,会话保持
});
关键点 :关闭是异步的(.subscribe()),不等待完成。这产生了一个微秒级的竞态窗口(见第五节)。
六、断链重连
5.1 什么会导致断链,什么不会
TCP 层面有重传机制,真正的"网络闪断"(几秒到几十秒的丢包)不会导致 WebSocket 断链:
网络丢包几秒 → TCP 自动重传 → WebSocket 帧延迟到达 → 应用层无感知
只有以下情况才会导致 TCP 连接不可恢复地断开:
| 场景 | 原因 | 能否恢复 |
|---|---|---|
| WiFi ↔ 4G 切换 | IP 地址改变,TCP 四元组失效 | ❌ |
| NAT 网关超时 | NAT 表项过期,包无法到达 | ❌ |
| SLB 空闲超时 | SLB 主动发 RST 断开 | ❌ |
| 客户端进程被杀/休眠 | OS 关闭 socket | ❌ |
| 长时间网络中断 (>15分钟) | TCP 重传次数耗尽 | ❌ |
| Gateway 宕机 | 进程消失,连接全丢 | ❌ |
| 网络丢包几秒 | TCP 重传恢复 | ✅ 不影响 |
| 网络延迟抖动 | TCP 正常处理 | ✅ 不影响 |
5.2 重连流程
当客户端检测到断链(心跳无响应 / 连接事件),执行重连:
scss
客户端 Gateway
│ │
│ ① 新 TCP 连接 │
│ ──────────────────────────────────→ │
│ │
│ ② HTTP Upgrade → WebSocket │
│ ──────────────────────────────────→ │
│ (新 UserSessionWrapper) │
│ (新 WebSocketConnection) │
│ │
│ ③ CREATE_SESSION_REQUEST │
│ ──────────────────────────────────→ │
│ │
│ ┌─────┴──────────────────────┐
│ │ tryRegisterOnlineUser() │
│ │ │
│ │ fetchUserSessionsStatus() │
│ │ 从 Redis 取最新状态 │
│ │ │
│ │ 分支A: 用户 OFFLINE │
│ │ → addOnlineDeviceIfAbsent │
│ │ → 创建全新 Session │
│ │ │
│ │ 分支B: 用户 ONLINE │
│ │ 本地 session 连接已断? │
│ │ → YES: UDP恢复,复用Session│
│ │ → NO: 设备冲突处理 │
│ │ → 踢旧设备 (RPC) │
│ │ → 创建新 Session │
│ └────────────────────────────┘
│ │
│ ④ TurmsNotification (OK) │
│ ←────────────────────────────────── │
5.3 竞态窗口分析
由于 closeLocalSession 是异步的,存在微秒级竞态窗口:
ini
正常情况 (99.99%+):
t=0 连接断开
t=0.01 closeLocalSession.subscribe() → Redis 清理完成
t=1 客户端重连 → fetchUserSessionsStatus → OFFLINE → 新 Session ✓
极端竞态:
t=0 连接断开
t=0.01 closeLocalSession.subscribe() 触发但 Redis 删除尚未执行
t=0.02 客户端重连 → fetchUserSessionsStatus → ONLINE ⚠️
→ getLocalUserSession() → null (本地已清理)
→ isClosedSessionOnLocal = false
→ 设备冲突处理
→ closeSessionsWithConflictedDeviceTypes()
→ RPC 调用本节点关闭 session → 但 session 已不存在
→ addOnlineDeviceIfAbsent(CAS)
→ Redis CAS 检查: 期望值可能不匹配
→ 可能返回 SESSION_SIMULTANEOUS_CONFLICTS_DECLINE
t=0.03 旧的 Redis 清理完成
→ 客户端重试一次 → 成功 ✓
实际影响 :竞态窗口极短(微秒级),因为 Redis Lua 脚本执行非常快。即使发生,客户端重试一次 CREATE_SESSION_REQUEST 即可恢复。
5.4 Gateway 宕机场景
这是最坏的断链场景:
ini
t=0 Gateway-A 宕机
→ 所有本地 Session 丢失
→ Redis 中仍有数据,TTL = 181s
t=1 客户端重连 → 分配到 Gateway-B
→ CREATE_SESSION_REQUEST
→ fetchUserSessionsStatus → ONLINE, nodeId=Gateway-A
→ closeSessionsWithConflictedDeviceTypes()
→ RPC Gateway-A → ConnectionNotFound!
→ Discovery Service 中 Gateway-A 是否还存在?
存在 → 返回错误,客户端重试
不存在 → 认为旧设备已离线,继续登录
181s后 → Redis TTL 过期 → 自动清理,一定成功
恢复时间 = Discovery Service 故障检测时间(通常 15-30s)+ 客户端重连时间。Redis TTL(181s)是最终兜底。
5.5 为什么默认策略保证了重连可用性
makefile
LoginConflictStrategy = DISCONNECT_LOGGED_IN_DEVICES (默认)
含义: 冲突时踢掉旧设备,允许新设备登入
效果: 即使旧 Session 的 Redis 数据还没清理干净
新设备也能通过"踢旧设备"成功登入
而不是被拒绝
如果改成 DISCONNECT_LOGGING_IN_DEVICE:
冲突时拒绝新设备 → 断链重连大概率失败
七、UDP 切换与恢复
6.1 切换流程
ini
WebSocket 模式
│
│ 540s 仅心跳无业务请求
↓
HeartbeatManager.closeOrUpdateSession()
→ connection.switchToUdp()
→ NetConnection.close(CloseReason(SWITCH))
isConnected = false
isSwitchingToUdp = true
→ WebSocket 连接关闭
→ 客户端收到 SWITCH 状态码
→ 会话保持 (isSessionOpen = true)
│
↓
UDP 模式
客户端通过 UDP 发心跳维持会话
6.2 恢复流程
当有新消息要推送时,Gateway 通知客户端恢复 WebSocket:
scss
NotificationService.sendNotificationToLocalClients()
→ userSession.sendNotification(...)
→ connection.tryNotifyClientToRecover()
tryNotifyClientToRecover():
if (!isConnected && !isConnectionRecovering && udpAddress != null):
→ UdpRequestDispatcher.sendSignal(udpAddress, OPEN_CONNECTION)
→ isConnectionRecovering = true // 防止重复发送
UDP 信号 OPEN_CONNECTION
→ 客户端收到
→ 建立新 WebSocket 连接
→ 发送 CREATE_SESSION_REQUEST
Gateway.tryRegisterOnlineUser():
→ sessionInfo.isActive() = true
→ getLocalUserSession() → 找到旧 Session
→ isClosedSessionOnLocal = true // 连接已断但Session仍在
→ 复用旧 Session,只替换连接
→ 新 WebSocket 绑定到旧 Session
→ isConnectionRecovering 随新连接重置为 false
恢复期间的心跳 :isConnectionRecovering = true 时,UDP 心跳不会更新 心跳时间戳。这意味着如果客户端收到 OPEN_CONNECTION 但迟迟不重连,180s 后仍会超时关闭。
八、关键数据结构
7.1 内存结构
ini
SessionService (单例,Gateway 本地)
├── userIdToSessionsManager: ConcurrentHashMap<Long, UserSessionsManager>
│ └── 10001 → UserSessionsManager
│ ├── userId = 10001
│ ├── userStatus = AVAILABLE
│ └── deviceTypeToSession: ConcurrentEnumMap<DeviceType, UserSession>
│ ├── DESKTOP → UserSession
│ │ ├── id = 12345
│ │ ├── userId = 10001
│ │ ├── deviceType = DESKTOP
│ │ ├── isSessionOpen = true
│ │ ├── connection → WebSocketConnection
│ │ │ ├── isConnected = true
│ │ │ ├── isSwitchingToUdp = false
│ │ │ ├── isConnectionRecovering = false
│ │ │ ├── udpAddress = null
│ │ │ └── out → Netty WebsocketOutbound
│ │ ├── notificationConsumer → (ByteBuf) → out.sendObject()
│ │ ├── lastHeartbeatRequestTimestampNanos
│ │ ├── lastRequestTimestampNanos
│ │ └── lastHeartbeatUpdateTimestampNanos
│ └── ANDROID → UserSession { ... }
│
└── ipToSessions: ConcurrentHashMap<ByteArrayWrapper, Queue<UserSession>>
└── "192.168.1.5" → [UserSession(10001, DESKTOP)]
7.2 Redis 结构
makefile
Key: <userId> (Hash)
┌──────────────────────┬─────────────────────────────────┐
│ Field │ Value │
├──────────────────────┼─────────────────────────────────┤
│ $ │ 1 (UserStatus: AVAILABLE) │
│ 0 (DESKTOP) │ "turms0001" (所在Gateway节点ID) │
│ 1 (ANDROID) │ "turms0002" (所在Gateway节点ID) │
│ "turms0001" │ 1234567890 (心跳时间戳 epoch秒) │
│ "turms0002" │ 1234567890 (心跳时间戳 epoch秒) │
└──────────────────────┴─────────────────────────────────┘
TTL = closeIdleSessionAfterSeconds + 1 = 181s
Key: <userId>:d (Hash, 设备详情)
┌──────────────────────┬─────────────────────────────────┐
│ deviceToken │ "abc123..." │
│ registrationId │ "xyz789..." │
└──────────────────────┴─────────────────────────────────┘
TTL = deviceDetailsExpireAfterSeconds
九、网络抖动与闪断的影响总结
8.1 一句话结论
网络层面的短暂丢包(几秒到几十秒)对 WebSocket 连接和 Session 没有任何影响 ------TCP 重传机制在底层自动恢复。只有 TCP 连接不可恢复地断开(换网、进程被杀、NAT 超时、长时间断网)才会触发 Session 销毁和重建。
8.2 三层保护的分工
java
┌─────────────────────────────────────────────────────────────────┐
│ 应用层心跳 (Turms) │
│ 超时: 180s │
│ 职责: 检测静默断开 (NAT 超时、进程 crash、防火墙清会话) │
│ 这是实际生效的断开判定 │
├─────────────────────────────────────────────────────────────────┤
│ TCP 重传 (OS 内核) │
│ 重试: 15次 (约 15-30 分钟才放弃) │
│ 职责: 应对网络丢包、短暂中断 │
│ 几秒的丢包 → 完全透明,应用层无感知 │
├─────────────────────────────────────────────────────────────────┤
│ TCP Keepalive (OS 默认) │
│ 超时: 7200s (2小时) │
│ 职责: 极端兜底,实际不依赖 │
└─────────────────────────────────────────────────────────────────┘
8.3 什么会断,什么不会断
| 网络事件 | TCP 能否恢复 | WebSocket 连接 | Session | 需要重连 |
|---|---|---|---|---|
| 丢包几秒 | ✅ TCP 重传 | 保持 | 保持 | ❌ |
| 延迟抖动 | ✅ TCP 处理 | 保持 | 保持 | ❌ |
| 断网几十秒 | ✅ TCP 重传 | 保持 | 保持 | ❌ |
| WiFi→4G 切换 | ❌ IP 变了 | 断开 | 销毁 | ✅ |
| 4G→WiFi 切换 | ❌ IP 变了 | 断开 | 销毁 | ✅ |
| NAT 超时 (>120s) | ❌ 映射丢失 | 断开 | 销毁 | ✅ |
| 客户端进程被杀 | ❌ OS 关 socket | 断开 | 销毁 | ✅ |
| 客户端设备休眠 | ❌ OS 关网络 | 断开 | 销毁 | ✅ |
| SLB 空闲超时 | ❌ SLB 发 RST | 断开 | 销毁 | ✅ |
| 长时间断网 (>15min) | ❌ 重传耗尽 | 断开 | 销毁 | ✅ |
8.4 断链后的恢复过程
css
断链发生
│
├─ Gateway 侧:
│ ① Netty 检测到 TCP 关闭 (RST/FIN/channelInactive)
│ ② tryRemoveSessionInfoOnConnectionClosed()
│ ③ closeLocalSession(UNKNOWN_ERROR).subscribe()
│ (异步执行,不等待)
│ ④ Redis 先删 → 本地 Session 再删
│ ⑤ 旧 Session 完全销毁,不可恢复
│
├─ 客户端侧:
│ ① 心跳超时 / 发消息失败 → 感知到断链
│ ② 建立新 TCP + WebSocket 连接
│ ③ 发送 CREATE_SESSION_REQUEST
│
└─ 重连结果:
✅ 创建全新 Session(不是恢复旧 Session)
✅ 默认 LoginConflictStrategy 保证不会被旧数据阻塞
⚠️ 极端竞态(微秒级)可能需重试一次
8.5 核心结论
-
"闪断"不丢 Session 是假象:网络丢包几秒确实不影响,因为 TCP 重传在底层兜底。但一旦 TCP 连接真的断开(换网、进程被杀等),旧 Session 必然被销毁,重连后是全新 Session。
-
Session 不可迁移 :
UserSession对象(含 Netty Channel 引用)只存在于内存,连接断开后不可能恢复。重连只能创建新 Session。 -
LoginConflictStrategy = DISCONNECT_LOGGED_IN_DEVICES是重连可用性的关键:如果旧 Session 的 Redis 数据在重连时还没清理完,这个策略会"踢掉旧设备"而不是"拒绝新设备",保证了重连成功。 -
竞态窗口极小,无实际影响 :
closeLocalSession是异步的,但 Redis Lua 脚本执行极快(微秒级),客户端重连请求到达时 Redis 几乎肯定已清理完毕。即使撞上竞态,重试一次即可。 -
NAT 超时是客户端心跳间隔的硬约束:如果 NAT 超时 < 客户端心跳间隔(60s),心跳包还没发出 NAT 映射就丢了,连接静默断开。客户端心跳间隔必须小于 NAT 超时时间。
-
SLB 超时必须 > 180s :如果 SLB 空闲超时 <
closeIdleSessionAfterSeconds(180s),SLB 会先于 Gateway 心跳超时断开连接,导致频繁断连重连。
十、配置速查
SessionProperties(全部支持热更新):
| 配置 | 默认值 | 说明 |
|---|---|---|
closeIdleSessionAfterSeconds |
180 | 无活动超时关闭时间(也是 Redis TTL 基准) |
switchProtocolAfterSeconds |
540 (180×3) | 仅心跳无业务时切 UDP 的等待时间 |
minHeartbeatIntervalSeconds |
18 (180/10) | 刷新 Redis TTL 的最小间隔 |
clientHeartbeatIntervalSeconds |
60 (180/3) | 客户端心跳间隔(用于估算刷新量) |
notifyClientsOfSessionInfoAfterConnected |
true | 登录后是否推送 session 信息 |
SimultaneousLoginProperties:
| 配置 | 默认值 | 说明 |
|---|---|---|
strategy |
ALLOW_ONE_DEVICE_OF_EACH_DEVICE_TYPE_ONLINE |
每种设备类型各允许一个在线 |
loginConflictStrategy |
DISCONNECT_LOGGED_IN_DEVICES |
冲突时踢掉已登录设备 |
十一、关键文件索引
| 关注点 | 文件 |
|---|---|
| WebSocket 服务启动 | turms-gateway/.../websocket/WebSocketServerFactory.java |
| WebSocket 连接包装 | turms-gateway/.../websocket/WebSocketConnection.java |
| 连接绑定 | turms-gateway/.../common/UserSessionAssembler.java |
| 连接包装层 | turms-gateway/.../common/UserSessionWrapper.java |
| 会话对象 | turms-gateway/.../common/UserSession.java |
| 连接抽象 | turms-gateway/.../common/connection/NetConnection.java |
| 请求分发(含心跳) | turms-gateway/.../common/ClientRequestDispatcher.java |
| 登录/登出入口 | turms-gateway/.../session/access/client/controller/SessionClientController.java |
| 会话管理(主) | turms-gateway/.../session/service/SessionService.java |
| 每用户容器 | turms-gateway/.../session/manager/UserSessionsManager.java |
| 心跳巡检 | turms-gateway/.../session/manager/HeartbeatManager.java |
| 认证 | turms-gateway/.../session/service/SessionIdentityAccessManager.java |
| 并发登录策略 | turms-gateway/.../session/service/UserSimultaneousLoginService.java |
| UDP 服务 | turms-gateway/.../udp/UdpRequestDispatcher.java |
| Redis 状态镜像 | turms-server-common/.../session/service/UserStatusService.java |
| 会话配置 | turms-server-common/.../property/env/gateway/session/SessionProperties.java |
| Redis Lua 脚本 | turms-server-common/src/main/resources/redis/session/*.lua |