Tursm中WebSocket 连接生命周期:建立、保活、断开与重连

WebSocket 连接生命周期:建立、保活、断开与重连

面向新手的完整指南。从 TCP 握手到重连恢复,逐层剖析 turms-gateway 的 WebSocket 连接管理。

建议先阅读 01-gateway-startup.md 了解 gateway 启动流程和基础组件。


一、系统架构总览

下图展示了客户端、SLB、Gateway 集群、Service 集群、Redis 集群、MongoDB 集群之间的调用关系与主要数据流向。

flowchart TB subgraph Clients[&#34;🖥️ 客户端&#34;] C1[&#34;App (Mobile)&#34;] C2[&#34;Browser (Web)&#34;] C3[&#34;Desktop App&#34;] end subgraph LB[&#34;⚖️ 负载均衡&#34;] SLB[&#34;SLB / L4 LB<br/>(TCP 层转发)&#34;] end subgraph Gateway[&#34;🚪 Gateway 集群 (NodeType.GATEWAY)&#34;] direction TB GW1[&#34;Gateway-1<br/>────<br/>· WebSocket Server<br/>· TCP Server<br/>· UDP Server<br/>· SessionService<br/>· HeartbeatManager<br/>· ClientRequestDispatcher&#34;] GW2[&#34;Gateway-2<br/>────<br/>· WebSocket Server<br/>· TCP Server<br/>· UDP Server<br/>· SessionService<br/>· HeartbeatManager<br/>· ClientRequestDispatcher&#34;] end subgraph Service[&#34;⚙️ Service 集群 (NodeType.SERVICE)&#34;] direction TB SV1[&#34;Service-1<br/>(Leader)<br/>────<br/>· ServiceRequestDispatcher<br/>· MessageService<br/>· GroupService<br/>· UserService<br/>· ConversationService&#34;] SV2[&#34;Service-2<br/>────<br/>· ServiceRequestDispatcher<br/>· MessageService<br/>· GroupService<br/>· UserService<br/>· ConversationService&#34;] end subgraph Redis[&#34;📦 Redis 集群&#34;] direction LR R1[(&#34;Session<br/>Status<br/>────<br/>userId Hash<br/>TTL=181s&#34;)] R2[(&#34;Sequence<br/>ID<br/>────<br/>群/私聊<br/>递增序号&#34;)] R3[(&#34;Location<br/>────<br/>用户位置<br/>Geo&#34;)] end subgraph MongoDB[&#34;🗄️ MongoDB 集群 (Sharded)&#34;] direction LR M1[(&#34;Messages<br/>+ 会话&#34;)] M2[(&#34;Users<br/>+ Groups&#34;)] M3[(&#34;Cluster<br/>Members<br/>────<br/>服务发现<br/>+ 配置&#34;)] end %% ===== 连接层 ===== C1 & C2 & C3 -->|&#34;① WebSocket/TCP<br/>长连接&#34;| SLB SLB -->|&#34;TCP 转发&#34;| GW1 SLB -->|&#34;TCP 转发&#34;| GW2 %% ===== Gateway ↔ Redis ===== GW1 & GW2 -->|&#34;② 读写 Session 状态<br/>(登录/登出/心跳)&#34;| R1 GW1 & GW2 -->|&#34;③ 读写用户位置&#34;| R3 %% ===== Gateway → Service (RPC) ===== GW1 & GW2 -->|&#34;④ 转发客户端请求<br/>HandleServiceRequest<br/>(RPC over TCP)&#34;| SV1 GW1 & GW2 -->|&#34;④ 转发客户端请求<br/>HandleServiceRequest<br/>(RPC over TCP)&#34;| SV2 %% ===== Service → Gateway (RPC, 推送) ===== SV1 & SV2 -->|&#34;⑤ 推送通知<br/>SendNotificationRequest<br/>(RPC over TCP)&#34;| GW1 SV1 & SV2 -->|&#34;⑤ 推送通知<br/>SendNotificationRequest<br/>(RPC over TCP)&#34;| GW2 %% ===== Service ↔ Redis ===== SV1 & SV2 -->|&#34;⑥ 读写 Sequence ID&#34;| R2 %% ===== Service ↔ MongoDB ===== SV1 & SV2 -->|&#34;⑦ 业务数据 CRUD<br/>(消息/用户/群/会话)&#34;| M1 SV1 & SV2 -->|&#34;⑦ 业务数据 CRUD&#34;| M2 %% ===== 集群发现 ===== GW1 & GW2 & SV1 & SV2 -->|&#34;⑧ 服务发现<br/>(注册/心跳/选主)&#34;| M3

图中编号对应的数据流说明

编号 数据流 协议 说明
客户端 → 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 字段为 null
  • WebSocketConnection 已经创建,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 核心结论

  1. "闪断"不丢 Session 是假象:网络丢包几秒确实不影响,因为 TCP 重传在底层兜底。但一旦 TCP 连接真的断开(换网、进程被杀等),旧 Session 必然被销毁,重连后是全新 Session。

  2. Session 不可迁移UserSession 对象(含 Netty Channel 引用)只存在于内存,连接断开后不可能恢复。重连只能创建新 Session。

  3. LoginConflictStrategy = DISCONNECT_LOGGED_IN_DEVICES 是重连可用性的关键:如果旧 Session 的 Redis 数据在重连时还没清理完,这个策略会"踢掉旧设备"而不是"拒绝新设备",保证了重连成功。

  4. 竞态窗口极小,无实际影响closeLocalSession 是异步的,但 Redis Lua 脚本执行极快(微秒级),客户端重连请求到达时 Redis 几乎肯定已清理完毕。即使撞上竞态,重试一次即可。

  5. NAT 超时是客户端心跳间隔的硬约束:如果 NAT 超时 < 客户端心跳间隔(60s),心跳包还没发出 NAT 映射就丢了,连接静默断开。客户端心跳间隔必须小于 NAT 超时时间。

  6. 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
相关推荐
zt1985q3 小时前
本地部署开源网络书签与内容管理工具 Karakeep 并实现外部访问
运维·服务器·网络·数据库·网络协议·开源
JEECG低代码平台5 小时前
JimuChatBI 重磅发布 v1.0:首款开源对话式 Chat2BI 智能问数产品
开源
十六年开源服务商6 小时前
2026整合开发:WordPress网站建设的终极方案
开源
Erishen6 小时前
💡 比“怎么做”更值钱的是“为什么不那么做”:ai-analyze 的五个设计决策
开源·agent·mcp
明天谭8 小时前
EzCloud微服务SaaS平台架构解析:多租户零代码一体化企业系统整体设计
开源·springcloud·微服务架构·saas多租户·企业级开发·ezcloud
呆呆敲代码的小Y9 小时前
awesome-llm-apps 开源项目详解:100+ AI Agent 与 RAG 模板一键复用
人工智能·ai·开源·ai编程·ai agent·awesome·llm应用
dong_junshuai10 小时前
每天一个开源项目#62 pdf-inspector:0.47秒解析200份PDF
开源·github
Flynt10 小时前
连续3天霸榜GitHub,Prime Agent这个"会自我进化的编码Agent"到底什么来头
开源·ai编程
物联网软硬件开发-轨物科技10 小时前
【轨物方案】从五维感知到一键顺控:箱变智能化不是一个传感器能解决的事
人工智能·科技·其他·机器人·开源
2401_8949155310 小时前
部署 GEO 优化源码常见报错排查:端口、伪静态、缓存问题解决
java·运维·服务器·后端·缓存·开源