模块二:Session 会话的结构、存储与生命周期
本模块深入 session 的存储 与生命周期 。session 的对象模型(
UserSession/UserSessionWrapper/NetConnection三层抽象)已在模块一第 5 节讲过,此处只补齐「结构」中模块一未覆盖的容器与索引部分,重点放在「存储」与「生命周期」。涉及代码横跨
turms-gateway(本地会话管理)与turms-server-common(集群状态镜像)。
1. 总览:两层存储 + 三段生命周期
turms 的 session 采用两层存储:
- 内存层 (本节点):持有带活跃连接的
UserSession对象,仅供本 gateway 节点访问。 - Redis 层 (集群全局):镜像每个 session 的状态与位置(用户在线否、在哪个节点、哪个设备),供集群任意节点查询。
生命周期分三段:创建(登录)-> 心跳保活 -> 销毁。
一句话:活连接只在内存,状态/位置镜像到 Redis;任何节点问「某用户在线否、在哪」都查 Redis,真正发数据才走对应节点的内存 session。
2. Session 结构(承接模块一)
模块一讲过单设备会话 UserSession 与绑定层 UserSessionWrapper。这里补两个容器:
2.1 UserSessionsManager:每用户容器
UserSessionsManager(UserSessionsManager.java:43)是一个用户所有在线设备的容器:
| 字段 | 含义 |
|---|---|
userId |
用户 ID |
userStatus |
用户状态(AVAILABLE/INVISIBLE/...) |
deviceTypeToSession |
ConcurrentEnumMap<DeviceType, UserSession>,每个设备类型一个 session |
关键方法:addSessionIfAbsent(putIfAbsent 语义,返回新 session 或 null)、closeSession(deviceType, closeReason)(移除并关闭)、pushSessionNotification(给某设备发 session 信息通知)、countSessions、getLoggedInDeviceTypes。
设计含义:一个用户最多每设备类型一个 session。同设备类型重复登录会走并发登录冲突处理(第 7 节)。
2.2 SessionService:两个内存索引
SessionService(@Service,SessionService.java:96)持有两张本地表:
| 索引 | 类型 | 用途 |
|---|---|---|
userIdToSessionsManager |
ConcurrentHashMap<Long, UserSessionsManager> |
主索引:userId -> 其会话容器 |
ipToSessions |
ConcurrentHashMap<ByteArrayWrapper, ConcurrentLinkedQueue<UserSession>> |
副索引:客户端 IP -> session 队列,用于按 IP 封禁/踢人 |
注释(SessionService.java:114-118)说明 ipToSessions 用 ByteArrayWrapper 而非 long 是为了支持 16 字节的 IPv6 地址。两张表都纯内存、仅本节点可见 ,UserSession 对象(含活跃连接)从不序列化。
SessionService 还实现了 RpcSessionService,暴露 closeLocalSession / getSessions 给其它节点经 RPC 调用(如并发登录时跨节点踢人)。
3. 存储:内存 + Redis 双层
3.1 Redis 数据模型
UserStatusService(@ConditionalOnBean("sessionRedisClientManager"),UserStatusService.java:79)是集群状态的唯一权威。它用每个用户一个 Redis Hash 存储(UserStatusService.java:98-123 Javadoc):
| Hash 字段 | 值 | 含义 |
|---|---|---|
$ |
用户状态数字 | 固定字段,存 UserStatus(AVAILABLE/INVISIBLE/...) |
<设备类型数字> (0,1,2...) |
node ID | 该设备的 session 归属哪个 gateway 节点 |
<nodeId> |
心跳时间戳(秒) | 该节点最后刷新时间,用于 TTL 与死节点检测 |
另外,设备详情(deviceToken 等)存在独立的 Hash <userId>:d,有自己的 TTL(DEVICE_DETAILS_TTL),与主 Hash 解耦。
TTL :deviceStatusTtlSeconds = closeIdleSessionAfterSeconds + 1(UserStatusService.java:156)。每次注册/刷新都对主 Hash 执行 EXPIRE,所以用户若停止心跳,整个 Hash 会在约 181s 后自动过期。
缓存 :userIdToStatusCache(Caffeine,受 userStatus.cacheUserSessionsStatus 开关控制,UserStatusService.java:129)缓存在线与离线状态,减少 Redis 读。注意 fetchUserSessionsStatus 绕过缓存直读 Redis--登录注册时必须拿最新状态,防止缓存陈旧导致并发登录误判。
3.2 五个 Lua 脚本
所有 Redis 写操作都用 Lua 脚本保证原子性 (check-and-set 不能被中间打断),脚本位于 turms-server-common/src/main/resources/redis/session/:
| 脚本 | 作用 | 返回 |
|---|---|---|
try_add_online_user_with_ttl.lua |
原子注册「用户+设备+本节点」 | '0' 冲突/未加 / '1' 由离线变在线 / '2' 加入已在线用户 |
update_users_ttl.lua |
批量刷新本节点用户的 TTL 与心跳时间戳 | 不存在的 userId 列表(需关本地 session) |
remove_user_statuses.lua |
移除本节点拥有的设备项,清理空 Hash | 是否删过设备 |
update_online_user_status_if_present.lua |
存在则更新 $ 状态字段 |
是否更新 |
get_users_device_details.lua |
批量取设备详情 | userId -> details |
3.3 核心脚本 try_add_online_user_with_ttl.lua
这是登录注册的原子操作,逻辑(try_add_online_user_with_ttl.lua):
HMGET userId <device> $取出该设备现有的归属节点existing_node_id与现有状态。- 冲突判定 (返回 '0'):
- 若
existing_node_id == node_id(本节点已拥有)-> '0'; - 若已有归属节点且其心跳仍在
DEVICE_STATUS_TTL内、且不满足 CAS 条件 -> '0'(不能踢)。
- 若
- CAS 条件 :脚本接收
expected_existing_node_id与expected_device_timestamp两个期望值。只有当 Redis 里的实际值匹配期望值时才允许踢掉旧 session。这是乐观锁--「我先前读到的是这个节点这个时间戳,现在仍是它,我才能安全替换」,避免两个客户端同时登录互相踩踏。 - 写入 :
HMSET device->nodeId, $->status, nodeId->now;EXPIRE userId ttl。 - 清理旧节点项 :若被替换的旧
nodeId已无其它设备引用,HDEL它的心跳时间戳字段,避免残留。 - 设备详情 :若有,
HMSET userId:d ...并EXPIRE。 - 返回码 :
HLEN == 3(只有$+ 设备 + nodeId,即首个设备)-> '1';否则(已有其它设备)-> '2'。
Java 侧(UserStatusService.addOnlineDeviceIfAbsent,:633)把 '1'/'2' 都映射为 true(注册成功),'0' 映射为 false(冲突拒绝)。
3.4 死节点检测
节点宕机未必能清理 Redis。turms 用两道防线判定「某设备所属节点是否还活着」(UserStatusService.handleUserSessionsStatusEntries,:348):
- 心跳时间戳 TTL :若
now - nodeId的心跳时间戳 > deviceStatusTtlMillis,标记该设备 inactive。 - discovery 成员检查 :对非本节点的 active 节点,
fetchNodeStatus(nodeId)->discoveryService.checkIfMemberExists(nodeId)(结果缓存 15s,UserStatusService.java:81、:483)。若 discovery 说该节点已不存在,标记 inactive。
若某用户所有设备都 inactive -> 该用户判为 OFFLINE。这保证了节点崩溃后,其残留的 Redis 记录不会永远挡住用户重新登录。
4. 生命周期之一:创建(登录)
4.1 完整链路
CREATE_SESSION_REQUEST 由 gateway 本地处理(不下发 service),链路如下:
4.2 逐步对照源码
-
入口 (
SessionClientController.handleCreateSessionRequest,:92):若 wrapper 已有 session 返回CREATE_EXISTING_SESSION;否则解析请求,调sessionService.handleLoginRequest(...)。成功后取消建立超时任务,若连接仍连通则sessionWrapper.setUserSession(session)(这一步把 session 绑到连接、触发回调设置notificationConsumer,接通推送通道),再onSessionEstablished+goOnline插件。若建立超时已触发或连接已断 -> 以LOGIN_TIMEOUT关闭。 -
登录校验 (
SessionService.handleLoginRequest,:229):version != 1->UNSUPPORTED_CLIENT_VERSION;- 设备类型被禁用(
userSimultaneousLoginService.isForbiddenDeviceType)->LOGIN_FROM_FORBIDDEN_DEVICE_TYPE; - 调
sessionAuthenticationManager.verifyAndGrant(...)认证;返回 OK 才进tryRegisterOnlineUser。
-
认证 (
SessionIdentityAccessManager.verifyAndGrant,:107):按gateway.session.identityAccessManagement.type选策略--NOOP/HTTP/JWT/PASSWORD/LDAP(构造时 switch,:85)。若 IAM 关闭 -> 直接授予全部权限;admin 请求者 ID 一律拒绝。还支持插件UserAuthenticator(顺序执行、首个命中),先于默认策略。 -
注册核心 (
SessionService.tryRegisterOnlineUser,:612)--这是并发登录与 UDP 恢复的枢纽:fetchUserSessionsStatus(userId):必须取最新(绕过缓存),处理 Redis 崩溃重启、本节点与 Redis 失联等导致本地与 Redis 不一致的边缘情况。- Step 1 :若本地有该用户的 session,但 Redis 显示某设备已归属其它节点 且 active -> 关掉本地那个 session(
DISCONNECTED_BY_OTHER_DEVICE)。清理本地陈旧数据。 - 若用户离线 ->
addOnlineDeviceIfAbsent直接注册。 - 若用户在线 :
- 若同设备类型已 active:
- 若本地存在该 session 且其连接已断(
isClosedSessionOnLocal)-> 这是 UDP->TCP 恢复:返回本地 session,由下游用新连接替换旧连接(模块九详述);可顺带更新状态与位置。 - 否则若策略为「踢登录方」(
shouldDisconnectLoggingInDeviceIfConflicts)-> 报SESSION_SIMULTANEOUS_CONFLICTS_DECLINE。
- 若本地存在该 session 且其连接已断(
- 否则(该设备无冲突):先
closeSessionsWithConflictedDeviceTypes(按冲突表 RPCSetUserOfflineRequest踢其它节点上的冲突设备),再addOnlineDeviceIfAbsent带 CAS 期望值注册。
- 若同设备类型已 active:
-
建本地 session (
SessionService.addOnlineDeviceIfAbsent,:867):先userStatusService.addOnlineDeviceIfAbsent(Lua 原子注册)成功后,userIdToSessionsManager.computeIfAbsent建/取容器,manager.addSessionIfAbsent建UserSession,加入ipToSessions索引,按需写位置。
4.3 跨节点踢人
closeSessionsWithConflictedDeviceTypes(SessionService.java:794)按冲突设备类型分组到目标节点,对每个目标节点发 SetUserOfflineRequest RPC(SessionCloseStatus.DISCONNECTED_BY_CLIENT)。若 RPC 报 ConnectionNotFound:
- 目标节点是已知成员 -> 返回错误(可能网络问题,让客户端重试直到 TTL 过期);
- 目标节点已不在 discovery -> 视为已离线,返回
true(让客户端能登录,改善体验)。
5. 生命周期之二:心跳保活
5.1 两条心跳路径
| 路径 | 触发 | 处理 |
|---|---|---|
| TCP/WebSocket | 空包(不可读 buffer) | ClientRequestDispatcher.handleHeartbeatRequest -> sessionService.handleHeartbeatUpdateRequest(session) -> 仅更新本地 lastHeartbeatRequestTimestamp |
| UDP | 14 字节 HEARTBEAT 信号 |
UdpRequestDispatcher -> sessionService.authAndUpdateHeartbeatTimestamp(userId, deviceType, sessionId)(校验 session 存在 + sessionId 匹配 + 未在恢复中)-> 更新时间戳 + 记录 UDP 地址 |
关键设计 :心跳只更新本地时间戳,不写 Redis。若每个心跳都写 Redis,海量在线用户下是性能灾难。
5.2 HeartbeatManager:后台巡检
HeartbeatManager(HeartbeatManager.java:57)用单线程 turms-client-heartbeat-refresher 每 1s(UPDATE_HEARTBEAT_INTERVAL_MILLIS=1000)跑一轮 updateOnlineUsersTtl():
对每个在线 session 执行 closeOrUpdateSession(session, now)(:200),三步检查:
- 切 UDP :UDP 已启用 +
supportsSwitchingToUdp+ 仍连接 + 非心跳请求空闲超switchProtocolAfterNanos(默认 540s)->connection.switchToUdp()(模块八)。 - 关空闲 :
max(心跳时间, 请求时间)空闲超closeIdleSessionAfterNanos(默认 180s)->closeLocalSession(HEARTBEAT_TIMEOUT)。 - 刷 Redis :距上次刷新超
minHeartbeatIntervalNanos(默认 18s)且客户端在此期间发过请求 -> 标记需刷新。
被标记的 userId 经 userStatusService.updateOnlineUsersTtl(批量 Lua update_users_ttl.lua)一次刷新:对每个 userId,若 HEXISTS userId nodeId(本节点仍拥有其设备)则 EXPIRE + HSET nodeId now;否则计入「不存在」列表。返回的不存在 userId 会被关掉本地 session (DISCONNECTED_BY_OTHER_DEVICE)--因为 Redis 侧已过期,说明该用户在集群视角已离线。
5.3 为何这么做
类注释(HeartbeatManager.java:46-56)列出三种候选策略:
- 每个心跳都刷 Redis -> 性能灾难(否决);
- MPSC 队列攒批定期发 -> 浪费内存、队列操作损耗性能(否决);
- (采用)不存请求,直接遍历内存在线表,每秒快照出需要刷新/下线的用户。
即用「单线程定期遍历内存 + 批量 Lua」替代「每心跳一次 Redis 写」,把 Redis 写频率从「每用户每心跳」降到「每用户每 18s 一次」。
5.4 关键配置(均热更新)
SessionProperties(SessionProperties.java,全部 @GlobalProperty @MutableProperty):
| 配置 | 默认 | 含义 |
|---|---|---|
closeIdleSessionAfterSeconds |
180 | 无任何请求多久后关 session(=Redis TTL 基准) |
switchProtocolAfterSeconds |
180*3=540 | 仅收心跳多久后切 UDP |
minHeartbeatIntervalSeconds |
180/10=18 | 刷 Redis 的最小间隔 |
clientHeartbeatIntervalSeconds |
180/3=60 | 客户端心跳间隔(仅用于估算每轮刷新量,不改变客户端行为) |
notifyClientsOfSessionInfoAfterConnected |
true | 连接后是否推送 session 信息 |
SessionService 构造时注册了全局属性变更监听(:176),这些值改了即时生效,HeartbeatManager 的对应字段也被热更新。
6. 生命周期之三:销毁
6.1 统一入口与关键顺序
所有关闭路径最终汇聚到 SessionService.closeLocalSessions(userId, deviceTypes, closeReason, manager)(:491)。顺序至关重要 (注释 :502-504):
先删 Redis 状态,再关连接。若反过来,客户端在连接关闭后、Redis 状态未删前重新登录,会读到陈旧的 Redis 数据而误判冲突。
userStatusService.removeStatusByUserIdAndDeviceTypes(Luaremove_user_statuses.lua):HMGET各设备字段,只HDEL值 == 本 nodeId 的(只删自己的设备);若剩余字段数 <= 2(只剩$+ nodeId 心跳)则DEL整个 Hash,否则清理无设备引用的 nodeId 心跳字段。- 逐设备
manager.closeSession-> 从deviceTypeToSession移除并connection.close(closeReason)。 - 从
ipToSessions移除(空则删 key)。 - 移除位置信息(如启用)。
- 通知
onSessionClosedListeners。 removeSessionsManagerIfEmpty:容器空则从userIdToSessionsManager移除;触发goOffline插件扩展点。
6.2 触发场景
| 场景 | 关闭状态码 | 入口 |
|---|---|---|
客户端主动 DELETE_SESSION_REQUEST |
DISCONNECTED_BY_CLIENT |
SessionClientController.handleDeleteSessionRequest |
| 连接断开 | UNKNOWN_ERROR(除非正在切 UDP) |
UserSessionAssembler.tryRemoveSessionInfoOnConnectionClosed |
| 心跳超时 | HEARTBEAT_TIMEOUT |
HeartbeatManager |
| Redis 侧已过期 | DISCONNECTED_BY_OTHER_DEVICE |
HeartbeatManager(更新 TTL 返回不存在) |
| Admin 封禁/按 IP 踢 | 视调用方 | closeLocalSessions(ip...) / closeLocalSessions(userIds...) |
| 节点关停 | SERVER_CLOSED |
destroy() -> closeAllLocalSessions |
SessionService 构造时注册 CLOSE_SESSIONS 关闭钩子(:195),destroy() 先停 HeartbeatManager 再 closeAllLocalSessions(SERVER_CLOSED)。
6.3 关闭状态码
SessionCloseStatus(SessionCloseStatus.java:25)按区间分类:
| 区间 | 含义 | 示例 |
|---|---|---|
| 1xx | 客户端行为异常/超时 | 100 ILLEGAL_REQUEST、110 HEARTBEAT_TIMEOUT、111 LOGIN_TIMEOUT、112 SWITCH |
| 2xx | 服务端行为 | 200 SERVER_ERROR、201 SERVER_CLOSED、202 SERVER_UNAVAILABLE |
| 3xx | 网络错误 | 300 CONNECTION_CLOSED |
| 4xx | 未知错误 | 400 UNKNOWN_ERROR |
| 5xx | 用户主动 | 500 DISCONNECTED_BY_CLIENT、501 DISCONNECTED_BY_OTHER_DEVICE |
| 6xx | 管理员主动 | 600 DISCONNECTED_BY_ADMIN |
| 7xx | 用户状态变更 | 700 USER_IS_DELETED_OR_INACTIVATED、701 USER_IS_BLOCKED |
CloseReason(record,CloseReason.java:29)= (closeStatus, businessStatusCode, reason),按 closeStatus 池化;CloseReason.get(throwable) 能从异常映射出对应的 closeStatus(服务端错误->SERVER_ERROR,非法请求->ILLEGAL_REQUEST,等)。
7. 并发登录策略
UserSimultaneousLoginService(UserSimultaneousLoginService.java:43)把「哪些设备类型互斥」做成可配置策略。核心是 deviceTypeToExclusiveDeviceTypes 映射(每个设备类型冲突的设备类型集合,每个设备类型都与自己冲突 ),由 SimultaneousLoginStrategy 推导:
| 策略 | 含义 |
|---|---|
ALLOW_ONE_DEVICE_OF_EACH_DEVICE_TYPE_ONLINE |
每种设备类型各允许一个(仅与自己冲突) |
ALLOW_ONE_DEVICE_FOR_ALL_DEVICE_TYPES_ONLINE |
全设备类型只允许一个(全部互斥) |
ALLOW_ONE_DEVICE_OF_DESKTOP_AND_ONE_DEVICE_OF_MOBILE_ONLINE |
桌面一个 + 移动一个(ANDROID/IOS 互斥) |
...DESKTOP_AND_BROWSER_AND_MOBILE... |
桌面、浏览器、移动各一个 |
...DESKTOP_OR_BROWSER_AND_MOBILE... |
(桌面或浏览器)一个 + 移动一个 |
...DESKTOP_OR_MOBILE_ONLINE |
(桌面或移动)只一个 |
两个配套机制:
forbiddenDeviceTypes:某些策略下BROWSER被禁;UNKNOWN/OTHERS默认禁用(除非allowDeviceTypeUnknownLogin/allowDeviceTypeOthersLogin)。LoginConflictStrategy:冲突时踢谁。shouldDisconnectLoggingInDeviceIfConflicts()为 true 时踢登录方 (拒绝登录),否则踢已在线方 (默认,配合closeSessionsWithConflictedDeviceTypes跨节点踢旧 session)。
登录时 getConflictedDeviceTypes(deviceType) 返回需要被踢的设备类型集合,驱动第 4.3 节的跨节点 RPC。
8. 关键设计要点(Takeaways)
- 两层存储分工:活连接只在内存(本节点),状态/位置镜像 Redis(集群全局)。任何节点查「在线否、在哪」走 Redis,真正下发才路由到归属节点的内存 session。
- 心跳不写 Redis :心跳只更新本地时间戳;
HeartbeatManager单线程每秒巡检,批量 Lua 刷新 Redis TTL(每用户最快 18s 一次)。把 Redis 写从「每心跳」降到「每 18s」。 - 销毁先 Redis 后连接:避免「连接已关、Redis 未删」窗口内重登录读到陈旧状态而误判冲突。
- 原子注册 + CAS :
try_add_online_user_with_ttl.lua用 expected nodeId/timestamp 乐观锁,保证并发登录不互相踩踏。 - 死节点双保险:心跳时间戳 TTL + discovery 成员检查,节点崩溃后其 Redis 残留不会永久挡住用户重登。
- UDP 恢复复用 session :
isClosedSessionOnLocal分支让 UDP->TCP 重连复用原 session,只换连接不重建会话(模块九详述)。
9. 关键文件索引
| 关注点 | 文件 |
|---|---|
| 本地会话管理(主) | turms-gateway/src/main/java/im/turms/gateway/domain/session/service/SessionService.java |
| 每用户容器 | turms-gateway/src/main/java/im/turms/gateway/domain/session/manager/UserSessionsManager.java |
| 心跳巡检 | turms-gateway/src/main/java/im/turms/gateway/domain/session/manager/HeartbeatManager.java |
| 登录入口(CREATE/DELETE_SESSION) | turms-gateway/src/main/java/im/turms/gateway/domain/session/access/client/controller/SessionClientController.java |
| 认证 | turms-gateway/src/main/java/im/turms/gateway/domain/session/service/SessionIdentityAccessManager.java |
| 并发登录策略 | turms-gateway/src/main/java/im/turms/gateway/domain/session/service/UserSimultaneousLoginService.java |
| Redis 状态镜像 | turms-server-common/src/main/java/im/turms/server/common/domain/session/service/UserStatusService.java |
| 状态 BO | turms-server-common/src/main/java/im/turms/server/common/domain/session/bo/UserSessionsStatus.java、UserDeviceSessionInfo.java |
| 关闭状态 | turms-server-common/src/main/java/im/turms/server/common/domain/session/bo/SessionCloseStatus.java、CloseReason.java |
| 会话配置 | turms-server-common/src/main/java/im/turms/server/common/infra/property/env/gateway/session/SessionProperties.java |
| Redis 脚本 | turms-server-common/src/main/resources/redis/session/*.lua |
下一模块:03-mongodb-schema.md MongoDB 表结构(实体、集合、分片键、索引、分层存储)。