4、会话恢复策略:会话恢复场景分析、持久化存储方案、Redis实现会话共享
会话恢复,说白了就是用户掉线后能无缝接上。你想想看,用户正填着表单呢,突然网络闪断了一下,结果回来发现要重新登录——这体验谁受得了?我在项目中遇到过好几次这种投诉,后来才下定决心把会话恢复这块做扎实。
这一节,我们就来拆解会话恢复的几种典型场景,看看怎么存、怎么取,最后用 Redis 把共享方案落地。
4.1 会话恢复场景分析
不是所有掉线都需要恢复。我习惯把场景分成三类,每一类的处理策略都不一样。
| 场景 | 典型表现 | 恢复策略 |
|---|---|---|
| 临时网络抖动 | Wi-Fi 切换、4G/5G 信号短暂丢失 | 客户端自动重试 + 令牌重放 |
| 应用后台被杀死 | 手机内存不足、用户主动划掉进程 | 持久化令牌 + 静默恢复 |
| 跨设备/跨浏览器 | PC 切手机、Chrome 切 Safari | 服务端会话共享 + 令牌绑定校验 |
第一种场景最常见,也最好处理。客户端检测到网络恢复后,拿着上次的令牌直接重发请求就行。服务端只需要保证令牌还没过期,并且没有被标记为「已吊销」。
第二种就麻烦一些。应用被杀死后,内存里的令牌全没了。这时候如果客户端没有做持久化,那用户回来就是一张白纸。嗯,这里要注意——持久化不是简单存个字符串就完事了,后面我会讲具体怎么做。
第三种场景最考验架构。跨设备意味着会话必须能在服务端共享,不能依赖客户端的本地存储。我见过不少团队在这上面栽跟头,以为把令牌存到 Cookie 里就万事大吉了,结果用户换个浏览器就登不上了。
核心原则:会话恢复的粒度要跟业务风险匹配。金融类操作建议每次都要重新校验令牌绑定信息,而浏览类内容可以放宽到 5 分钟内自动恢复。
4.2 持久化存储方案
持久化存储,说白了就是把令牌从内存搬到磁盘上。但搬的时候要考虑三个问题:存什么、存哪里、怎么加密。
4.2.1 存什么
我个人习惯存三样东西:
- 访问令牌(Access Token):用于后续请求的鉴权
- 刷新令牌(Refresh Token):用于获取新的访问令牌
- 令牌绑定信息:设备指纹、IP 段、用户代理等
注意,不要存明文。我曾经见过一个项目,直接把令牌明文写进 SharedPreferences,结果被逆向工具一把梭——那场面,惨不忍睹。
4.2.2 存哪里
不同平台有不同的推荐方案:
| 平台 | 推荐存储方式 | 安全等级 |
|---|---|---|
| iOS | Keychain | 高 |
| Android | EncryptedSharedPreferences | 高 |
| Web | HttpOnly Cookie + Secure 标志 | 中 |
| 桌面端 | 系统凭据管理器(如 Windows Credential Manager) | 高 |
Web 端的安全等级我标了「中」,因为 Cookie 虽然能防 XSS,但防不了 CSRF。你需要额外加 SameSite 属性和 CSRF Token 来兜底。
4.2.3 怎么加密
加密不是可选项,是必选项。我建议用 AES-256-GCM 做对称加密,密钥从设备的安全硬件中派生(比如 Android 的 TEE 或 iOS 的 Secure Enclave)。
代码示例(伪代码,展示核心逻辑):
// 存储令牌
fun saveToken(context: Context, token: Token) {
val encryptedData = encryptAes256Gcm(
data = token.toJson(),
key = deriveKeyFromSecureHardware(context)
)
SharedPreferences.edit()
.putString("encrypted_token", encryptedData)
.apply()
}
// 恢复令牌
fun restoreToken(context: Context): Token? {
val encryptedData = SharedPreferences
.getString("encrypted_token", null) ?: return null
val decryptedData = decryptAes256Gcm(
data = encryptedData,
key = deriveKeyFromSecureHardware(context)
)
return Token.fromJson(decryptedData)
}
注意:千万不要把加密密钥硬编码在代码里。我曾经见过有人把密钥写在 BuildConfig 里,结果反编译后直接暴露——那基本等于没加密。
4.3 Redis实现会话共享
单机部署的时候,会话存在本地内存没问题。但一旦上了多节点,问题就来了——用户第一次请求落在节点 A,第二次请求被负载均衡转发到节点 B,节点 B 根本不认识这个令牌。
解决方案就是引入 Redis 做会话共享。说白了,就是把会话数据从各节点的内存里抽出来,统一放到一个中央存储里。
4.3.1 架构设计
下面这张图展示了 Redis 会话共享的核心流程:
你看,客户端先经过负载均衡,负载均衡把请求随机转发给节点 A、B 或 C。每个节点在处理请求时,都会去 Redis 里查会话数据。这样不管请求落到哪个节点,都能拿到同一份会话。
4.3.2 Redis 数据结构设计
我习惯用 Hash 结构来存会话,因为一个会话包含多个字段(令牌、用户 ID、绑定信息、过期时间等)。
Key 的命名规则:session:{sessionId}
字段设计:
| 字段 | 类型 | 说明 |
|---|---|---|
| access_token | string | 当前有效的访问令牌 |
| refresh_token | string | 刷新令牌 |
| user_id | string | 用户唯一标识 |
| bind_info | string (JSON) | 设备指纹、IP、UA 等绑定信息 |
| created_at | long | 会话创建时间戳 |
| expires_at | long | 会话过期时间戳 |
4.3.3 核心操作代码
下面是用 Redis 做会话共享的核心操作,我用 Java 的 Jedis 客户端来演示:
// 创建会话
public void createSession(String sessionId, SessionData data) {
Map<String, String> fields = new HashMap<>();
fields.put("access_token", data.getAccessToken());
fields.put("refresh_token", data.getRefreshToken());
fields.put("user_id", data.getUserId());
fields.put("bind_info", data.getBindInfoJson());
fields.put("created_at", String.valueOf(System.currentTimeMillis()));
fields.put("expires_at", String.valueOf(data.getExpiresAt()));
jedis.hset("session:" + sessionId, fields);
jedis.expireAt("session:" + sessionId, data.getExpiresAt());
}
// 读取会话
public SessionData getSession(String sessionId) {
Map<String, String> fields = jedis.hgetAll("session:" + sessionId);
if (fields == null || fields.isEmpty()) {
return null;
}
// 检查是否过期
long expiresAt = Long.parseLong(fields.get("expires_at"));
if (System.currentTimeMillis() > expiresAt) {
jedis.del("session:" + sessionId);
return null;
}
return SessionData.fromMap(fields);
}
// 更新会话(比如刷新令牌后)
public void updateSession(String sessionId, String newAccessToken) {
jedis.hset("session:" + sessionId, "access_token", newAccessToken);
}
小技巧:设置过期时间时,用 expireAt 而不是 expire。这样你可以精确控制会话在哪个时间点失效,而不是相对当前时间的 N 秒后。对于需要精确控制过期时间的业务场景,这个区别很重要。
4.3.4 高可用与容错
Redis 本身不是万能的。如果 Redis 挂了,所有节点的会话都会丢失。我建议至少做三件事:
- 部署 Redis Sentinel 或 Cluster:保证 Redis 自身的高可用
- 本地缓存兜底:每个节点保留最近 N 条会话的本地副本,Redis 不可用时降级使用
- 熔断机制:检测到 Redis 响应超时后,暂时停止写入,只读本地缓存
我曾经在一个电商大促项目中遇到过 Redis 集群被打满的情况。当时就是因为没有做本地缓存兜底,结果 Redis 一抖动,全站用户都登出了——那真是噩梦般的经历。从那以后,我每个项目都会加上本地缓存作为第二道防线。
4.4 小结
会话恢复不是简单的「存一下、取一下」。你得先分析场景,再选合适的持久化方案,最后用 Redis 把共享问题解决掉。记住三个关键词:场景分析、安全存储、集中共享。把这三点做好了,用户的掉线体验就能从「重新登录」变成「无缝恢复」。