4、Session与会话管理:用户连接状态维护、心跳机制、断线重连,Session层的职责边界。

好,咱们接着聊。上一章我们把网关层和路由层理清楚了,数据包能正确到达目标服务器了。但有个问题随之而来——服务器怎么知道这个玩家还在不在线?

你想想看,玩家可能突然断网、切后台、或者直接拔网线。如果服务器还傻傻地给他推送消息,那不就浪费带宽了吗?更严重的是,如果玩家掉线后重新连上来,他的游戏状态还在不在?背包里的装备会不会丢?

这些事,都归Session层管。

4.1 Session层的核心职责

我个人习惯把Session层看作「玩家在服务器上的身份证」。它不关心业务逻辑,只关心一件事:这个连接是否还活着,以及它属于谁

具体来说,Session层要干这几件事:

  • 连接映射:把网络连接(TCP/WebSocket)映射到玩家ID
  • 状态维护:记录玩家当前是在线、离线、还是断线重连中
  • 心跳检测:定期检查连接是否还活着
  • 断线重连:玩家掉线后,保留他的Session一段时间,允许重新连回来
  • 超时清理:超过一定时间没心跳,就主动断开并清理资源

说白了,Session层就是连接和业务之间的桥梁。业务层不需要知道底层用的是TCP还是WebSocket,它只需要问Session层一句:「这个玩家还在吗?」

核心原则:Session层只管理连接状态,不处理任何游戏业务逻辑。比如「玩家扣血」这种事,Session层绝对不能碰。

4.2 用户连接状态维护

我在项目中遇到过一种情况:玩家A和玩家B在同一个房间,A掉线了,B还在正常玩。这时候如果A的Session没处理好,B可能会收到一堆发给A的消息——嗯,这显然不对。

所以状态维护要清晰。我一般用这么几个状态:

状态含义触发条件
INIT初始状态,连接刚建立TCP握手完成
ACTIVE正常在线,心跳正常登录验证通过
RECONNECTING断线重连中心跳超时,但未到最大等待时间
CLOSED连接已关闭主动断开或超时清理

每个玩家在Session层都有一个这样的状态机。业务层查询时,只需要看状态是不是ACTIVE就行。

小技巧:状态切换一定要加日志。我曾经排查过一个线上问题,发现玩家状态在ACTIVE和RECONNECTING之间反复横跳,最后定位到是心跳间隔设置得太短了。

4.3 心跳机制

心跳机制,说白了就是服务器和客户端互相确认「我还活着」

为什么需要心跳?因为TCP本身虽然有保活机制,但默认时间太长(2小时),对游戏来说完全不适用。你想想看,玩家断网2小时才发现,黄花菜都凉了。

我常用的心跳方案是这样的:

# 伪代码,展示心跳逻辑
class Session:
    def __init__(self, player_id, conn):
        self.player_id = player_id
        self.conn = conn
        self.last_heartbeat = time.time()
        self.heartbeat_interval = 10  # 10秒一次心跳
        self.timeout_threshold = 30   # 30秒没收到心跳就算超时

    def on_heartbeat_received(self):
        self.last_heartbeat = time.time()
        # 如果之前是RECONNECTING状态,切回ACTIVE
        if self.status == SessionStatus.RECONNECTING:
            self.status = SessionStatus.ACTIVE
            logger.info(f"玩家 {self.player_id} 重连成功")

    def check_timeout(self):
        elapsed = time.time() - self.last_heartbeat
        if elapsed > self.timeout_threshold:
            if self.status == SessionStatus.ACTIVE:
                self.status = SessionStatus.RECONNECTING
                logger.warning(f"玩家 {self.player_id} 心跳超时,进入重连等待")
                # 通知业务层:玩家暂时离线
                self.notify_business_layer("player_offline", self.player_id)
            elif elapsed > self.timeout_threshold + 60:  # 额外等待60秒
                self.close()
                logger.info(f"玩家 {self.player_id} 重连超时,关闭Session")

这里有个细节:心跳包要尽量轻量。我见过有人把整个玩家数据都塞进心跳包里,这完全没必要。心跳包只需要一个标识符和当前时间戳就够了,最好控制在几十字节以内。

避坑指南:我曾经把心跳间隔设成5秒,结果高峰期服务器每秒要处理几十万次心跳,CPU直接飙到90%。后来改成10秒一次,配合随机偏移(每个玩家在8-12秒之间随机),压力瞬间降下来了。

4.4 断线重连

断线重连,是Session层最考验设计的地方。玩家掉线后,他的游戏状态(位置、血量、背包)还在服务器上,不能立刻销毁。但也不能无限期保留——否则内存会爆。

我的做法是:给每个Session一个「重连窗口期」

  • 窗口期内:保留Session,状态设为RECONNECTING
  • 窗口期外:销毁Session,通知业务层清理玩家数据

窗口期多长合适?这要看你的游戏类型:

  • MMO:建议30-60秒,玩家可能只是切了一下网络
  • 回合制:可以长一些,2-5分钟,反正不影响实时性
  • 竞技类:建议10-15秒,太长了对手等得不耐烦

重连时的关键步骤:

  1. 客户端用旧的Session ID发起重连请求
  2. Session层查找这个ID,确认还在窗口期内
  3. 验证身份(通常用token,避免被劫持)
  4. 把新连接绑定到旧Session上
  5. 通知业务层:玩家回来了,可以恢复状态了
def reconnect(self, old_session_id, new_conn, token):
    session = self.session_map.get(old_session_id)
    if not session:
        return ReconnectResult.FAILED_SESSION_EXPIRED

    if session.status != SessionStatus.RECONNECTING:
        return ReconnectResult.FAILED_INVALID_STATUS

    if not self.verify_token(session.player_id, token):
        return ReconnectResult.FAILED_AUTH_FAILED

    # 关闭旧连接,绑定新连接
    session.conn.close()
    session.conn = new_conn
    session.status = SessionStatus.ACTIVE
    session.last_heartbeat = time.time()

    logger.info(f"玩家 {session.player_id} 重连成功,旧连接已替换")
    return ReconnectResult.SUCCESS

注意:重连成功后,一定要关闭旧连接。否则会出现一个玩家两个连接同时在线的情况,数据就乱套了。我早期犯过这个错,结果玩家A的操作被玩家B的连接接收了,排查了好久才发现。

4.5 Session层的职责边界

最后,咱们聊聊边界问题。很多新手会把业务逻辑塞进Session层,比如在Session里处理玩家移动、扣血。这是大忌。

Session层的职责边界,我用一句话概括:只管「通不通」,不管「做什么」

具体来说:

  • 该做的:维护连接、心跳检测、断线重连、超时清理、连接映射
  • 不该做的:验证玩家数据、处理游戏逻辑、存储玩家状态、广播消息

举个例子:玩家掉线了,Session层只需要把状态改成RECONNECTING,然后通知业务层「这个玩家暂时离线了」。至于要不要把玩家踢出房间、要不要保存他的位置——那是业务层的事,Session层不掺和。

这样做的好处是:业务层可以随时换,Session层不用动。比如你今天用TCP,明天想换成KCP,只需要改Session层的底层实现,业务层完全不受影响。

总结一下:Session层是游戏服务器的「守门员」。它不进球,但保证球门不丢。把连接状态管好了,业务层才能安心处理游戏逻辑。

嗯,这一章就聊到这儿。下一章咱们讲讲消息路由与协议分发——数据包到了服务器之后,怎么找到它该去的地方。