session_key 过期引发的集中掉线:code2Session 与小程序会话续期的完整方案
适用读者:负责微信小程序后端登录链路的服务端开发者、被「用户莫名掉线」工单折磨的客户端工程师,以及正在设计 SaaS 小程序会话体系的技术负责人。
某 SaaS 小程序大促当天下午 3 点起,客服后台的掉线工单开始堆积:用户在购物车点结账,页面直接弹回首页重新授权;商家端查单页白屏转圈。事后按同一口径统计,15:00 到 18:00 三个小时里会话失败率 2.7%,是日常水平的二十多倍。后端负责人老周复盘时原话是:「我们以为缓存的凭证能用 30 天,结果微信根本没答应过这件事。」
排查结论很尴尬:服务端把 code2Session 返回的 session_key 当成永久登录凭证缓存了 30 天,而微信侧对这个 key 的生命周期没有任何时长承诺,可能随时失效。这篇文章把整个重写过程拆开讲:登录链路的机制、wx.checkSession 的几个坑、refresh_token 式续期的设计,以及并发 401 下的请求重放怎么实现。改造上线后,同样口径的掉线率降到了 0.02%。
登录链路的机制剖析:code2Session 到底给了你什么
先把基础链路捋一遍。小程序端调 wx.login 拿到一个临时凭证 code,这个 code 有效期只有 5 分钟且只能用一次。客户端把 code 传给自己的服务端,服务端拿它去请求微信的 code2Session 接口,换回三样东西:

| 字段 | 含义 | 该怎么用 |
|---|---|---|
| openid | 用户在当前小程序下的专用标识 | 作为用户身份的主键或关联键 |
| session_key | 会话密钥,用于解密敏感数据、校验签名 | 只留在服务端,绝不下发前端 |
| unionid | 同一开放平台账号下的统一标识 | 有开放平台绑定时才返回 |
官方文档里有一句被大量团队忽略的话:会话密钥 session_key 是对用户数据进行加密签名的密钥,不应该下发给前端。也就是说它的定位是「解密工具」,不是「登录态凭证」。
更麻烦的是它的生命周期。微信并不承诺 session_key 的有效期,以下几种情况都会让它失效:
- 用户在微信里长时间不使用这个小程序;
- 用户切换了微信账号,或者清理了小程序数据;
- 微信侧出于安全策略主动刷新,这种情况没有任何通知。
上面那条「微信侧主动刷新」最致命,因为它不可预测。出事的那家 SaaS 当时的做法是:登录时把 session_key 存进 Redis,TTL 设 30 天,之后所有请求只校验 Redis 里有没有这条记录。Redis 里的 key 活着,业务就认为用户在线——至于微信那边的 session_key 还在不在,没人关心。大促当天大量用户触发微信侧刷新,服务端缓存却还「有效」,前端拿着过期凭证去解密手机号、算支付签名,全部失败,看起来就是集中掉线。
整个登录链路的正确时序应该是这样:
sequenceDiagram
participant MP as 小程序前端
participant SV as 业务服务端
participant WX as 微信接口
MP->>MP: wx.login 获取临时 code
MP->>SV: 携带 code 调用登录接口
SV->>WX: code2Session(appid, secret, code)
WX-->>SV: openid + session_key
SV->>SV: 生成业务 token 与 refresh_token
SV->>SV: session_key 只存服务端,绑定 openid
SV-->>MP: 返回业务 token(不含 session_key)
MP->>MP: token 存入 storage,后续请求携带
这张图里有个关键转折:从 SV->>SV 生成业务 token 那一步开始,会话的主线就交给自己的凭证体系了,微信只负责证明「这个用户是谁」。想通这一点,后面的方案才有讨论基础。
wx.checkSession 的三个坑
wx.checkSession 是官方提供的校验接口,作用很单纯:检查当前的 session_key 是否还有效。听起来像救命稻草,实际用的时候坑不少。
第一个坑是启动时无脑调用。很多团队的习惯写法是在 app.onLaunch 里调一次 checkSession,成功就认为用户登录态没问题,直接放行。这里混淆了两件事:checkSession 校验的只是微信侧的 session_key,它通过了不代表你自己的业务 token 没过期;反过来你自己 token 还活着,session_key 却可能已经被微信刷新了。两个凭证的有效期完全不同步,用一个去推断另一个,迟早出事。
第二个坑是时序问题。checkSession 是异步的,onLaunch 里发出去,结果还没回来,首页 onLoad 已经开始发业务请求了。用户看到的现象就是偶发的首次请求失败,而且这类失败在开发工具里很难复现——真机的网络时延和开发工具完全不是一个量级。
第三个坑是对失败语义的误解。checkSession 失败只说明 session_key 失效了,正确处理是静默走一遍完整重登,而不是弹窗让用户重新授权。出事的那家 SaaS 最初就在失败回调里弹了「请重新登录」,大促当天弹窗铺满屏幕,转化直接掉了一截。
不同调用时机的差异可以看这张表:
| 调用时机 | 实际效果 | 建议 |
|---|---|---|
| app.onLaunch 里无条件调用 | 结果与页面请求存在竞态 | 移除,改为请求拦截器内按需预检 |
| 每次解密手机号前调用 | 时机正确但增加一次往返 | 可接受,失败须静默重登 |
| 业务 token 过期时才调用 | 与自身凭证体系解耦,语义清晰 | 推荐,作为重登前的兜底判断 |
重写方案:预检、静默重登与请求队列
重写后的会话设计分两层。微信层只保留一件事:需要解密手机号或校验签名时,服务端按需使用 session_key,失效就走完整重登。业务层完全自己管:服务端签发短效 access_token(2 小时)和长效 refresh_token(30 天滑动续期),前端每次请求带 access_token,过期后用 refresh_token 换新的。
这里的设计取舍是:access_token 故意做短,泄漏了损失可控;refresh_token 绑定设备指纹,换设备登录时旧 token 作废。refresh_token 续期时服务端会顺手做一次 checkSession 预检,如果微信侧会话已经失效,直接触发静默重登,把两个凭证体系重新对齐。
真正费劲的是并发场景。一个页面同时发五六个请求,access_token 过期,六个请求全部 401。如果每个请求各自去刷新 token,refresh_token 接口会被打到限流,而且微信的 wx.login 存在并发调用时 code 复用的风险。必须加一把互斥锁:第一个发现过期的请求负责刷新,其余请求进队列等待,拿到新 token 后统一重放。
完整的时序长这样:
sequenceDiagram
participant MP as 小程序前端
participant SV as 业务服务端
MP->>SV: 请求 A(携带过期 token)
MP->>SV: 请求 B(携带过期 token)
SV-->>MP: 请求 A 返回 401
SV-->>MP: 请求 B 返回 401
MP->>MP: 请求 A 触发刷新,加锁;请求 B 入队等待
MP->>SV: checkSession 预检 + wx.login + 刷新接口
SV-->>MP: 返回新 token
MP->>MP: 解锁,队列中的请求换新 token 重放
MP->>SV: 请求 A / B 携带新 token 重发
SV-->>MP: 正常响应
服务端实现(Node.js)
环境与依赖:Node.js 18+,Express 4,axios 做 HTTP 请求,Redis 6 存储会话与刷新锁。下面是刷新接口和互斥锁的核心实现:
// Node.js 18+ 环境,先安装依赖:npm i express axios ioredis
// Redis 用于存放会话凭证与刷新互斥锁
const express = require("express");
const axios = require("axios");
const Redis = require("ioredis");
// 初始化 Redis 连接,生产环境建议配置密码与连接池
const redis = new Redis();
const app = express();
// 中间件:解析 JSON 请求体
app.use(express.json());
// 刷新锁的键名,按 openid 隔离,避免不同用户互相阻塞
function lockKey(openid) {
return `refresh_lock:${openid}`;
}
// 尝试获取互斥锁:只有拿到锁的请求才有资格刷新凭证
async function acquireLock(openid) {
// SET NX 保证并发下只有一个请求能写入成功,EX 控制 10 秒自动释放
const got = await redis.set(lockKey(openid), "1", "EX", 10, "NX");
// 返回布尔值:true 表示抢锁成功,false 表示锁已被占用
return got === "OK";
}
async function refreshSession(openid, refreshToken) {
// 从 Redis 取出当初签发的 refresh_token 做比对
const stored = await redis.get(`rt:${openid}`);
if (!stored || stored !== refreshToken) {
// 凭证不匹配说明账号在其他设备重新登录,当前端必须走完整重登
throw new Error("REFRESH_TOKEN_INVALID");
}
// checkSession 预检逻辑放在服务端代理层,见下文说明
await precheckWxSession(openid);
// 用 HMAC 签发新的短效 access_token,有效期 2 小时
const accessToken = signToken({ openid }, "2h");
// 新 token 写入 Redis,TTL 与签发有效期保持一致
await redis.set(`at:${openid}`, accessToken, "EX", 7200);
// 滑动续期:refresh_token 每次使用后重置 30 天有效期
await redis.expire(`rt:${openid}`, 30 * 86400);
// 只返回 access_token,refresh_token 本体不回传
return accessToken;
}
// 刷新接口:并发请求在这里被互斥锁收敛成单次刷新
app.post("/auth/refresh", async (req, res) => {
// 请求体携带 openid 与 refreshToken,由网关层完成基础校验
const { openid, refreshToken } = req.body;
// 拿不到锁说明别的请求正在刷新,直接返回 409 让客户端稍后重试
if (!(await acquireLock(openid))) {
return res.status(409).json({ code: "REFRESH_IN_PROGRESS" });
}
try {
// 持锁刷新,成功则返回新签发的 access_token
const accessToken = await refreshSession(openid, refreshToken);
res.json({ accessToken });
} catch (e) {
// 区分两类失败:凭证失效需要完整重登,其余按服务端异常处理
if (e.message === "REFRESH_TOKEN_INVALID") {
return res.status(401).json({ code: "NEED_FULL_LOGIN" });
}
// 其他异常记录日志后返回 500,避免向前端泄漏内部细节
res.status(500).json({ code: "SERVER_ERROR" });
} finally {
// 无论成功失败都要释放锁,否则用户会被卡住 10 秒
await redis.del(lockKey(openid));
}
});
// 启动 HTTP 服务,监听 3000 端口
app.listen(3000);
precheckWxSession 这个函数做的是服务端侧的微信会话校验:拿着缓存的 session_key 调微信的校验能力,失败就清掉缓存并标记该用户需要完整重登。把预检放在服务端而不是前端,好处是判断口径统一——前端只认服务端给的结论,不用自己猜微信侧的状态。
小程序端:请求封装与并发 401 重放
环境说明:小程序基础库 2.x,原生框架,请求层是纯 JavaScript 实现,无额外依赖。核心是刷新互斥和请求队列:
// 全局单例状态:isRefreshing 相当于前端这把互斥锁
let isRefreshing = false;
// 等待队列:存放刷新期间挂起的请求重放函数
let pendingQueue = [];
// 请求封装:业务代码只管调 request,不用关心 token 生命周期
function request(options) {
// 用 Promise 包装 wx.request,方便业务层 await
return new Promise((resolve, reject) => {
wx.request({
// 展开业务传入的 url / method / data 等字段
...options,
// 每次请求都带上当前最新的业务 token
header: { Authorization: `Bearer ${getToken()}` },
success: (res) => {
if (res.statusCode === 401) {
// 401 时把「带新 token 重发当前请求」的动作推进队列
pendingQueue.push(() => request(options).then(resolve, reject));
// 触发刷新调度,幂等:已在刷新则直接返回
drainRefresh();
return;
}
// 非 401 直接透出响应数据
resolve(res.data);
},
fail: reject,
});
});
}
// 刷新调度:保证同一时刻只有一次刷新在跑
function drainRefresh() {
// 已有刷新在途时直接返回,后续请求靠队列重放
if (isRefreshing) return;
isRefreshing = true;
wx.checkSession({
// session_key 已失效,微信侧需要完整重登拿新 code
fail: () => silentRelogin(),
// complete 回调里无论成败都发起 token 刷新
complete: () => {
wx.request({
url: "https://api.example.com/auth/refresh",
method: "POST",
data: { openid: getOpenid(), refreshToken: getRefreshToken() },
success: (res) => {
// 拿到新 token 先落盘,再统一重放队列
saveToken(res.data.accessToken);
flushQueue();
},
fail: () => {
// 刷新失败说明会话彻底失效,走静默重登兜底
silentRelogin();
},
complete: () => {
// 无论成败都要解锁,允许下一轮刷新发起
isRefreshing = false;
},
});
},
});
}
function flushQueue() {
// 逐个执行挂起的重放动作,此时队列内闭包会取到新 token
pendingQueue.forEach((replay) => replay());
// 清空队列,避免重复重放
pendingQueue = [];
}
// 静默重登:wx.login 拿新 code,全程无感,不弹任何授权窗
function silentRelogin() {
// wx.login 换取全新的一次性 code,5 分钟有效
wx.login({
success: (res) => {
wx.request({
// 把 code 交给服务端走一遍完整的 code2Session 换取流程
url: "https://api.example.com/auth/login",
method: "POST",
data: { code: res.code },
success: (r) => {
// 登录成功后落盘新凭证,再重放此前挂起的请求
saveToken(r.data.accessToken);
flushQueue();
},
});
},
});
}
这段代码有两个细节容易写错。一是队列重放时必须重新读 getToken(),不能在入队时把旧 token 闭包进去——上线第一周就有人踩过这个坑,重放还是带旧 token,死循环 401。二是 complete 里的解锁动作不能省,漏掉之后一次失败就把所有后续请求锁死。
改造前后的数据对比
方案上线后观察了两个月,掉线相关的几项指标变化如下:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 会话失败率(同期口径) | 2.7% | 0.02% |
| 客服掉线类工单(日均) | 40+ 单 | 不到 1 单 |
| refresh 接口 QPS 峰值 | 未限流,曾打到 1200 | 互斥锁收敛后约 90 |
| 解密手机号失败次数(周均) | 3000+ | 20 以内 |
有两个经验值得单独说。第一,掉线率统计口径要提前对齐——是把 401 算掉线还是把整个会话重建算掉线,两种算法出来的数字差好几倍,写复盘报告时口径不一致会吵起来。第二,refresh_token 的滑动续期要设上限,我们给的是 30 天内活跃才续、最长 90 天强制重登,避免三年不活跃的僵尸会话一直挂在 Redis 里。
误区澄清或趋势预判
常见误区有两个。一个是把 session_key 的「30 天」当成官方承诺——文档里从来没写过这个数字,全靠社区口口相传,微信随时可以改变行为,你的架构不能建立在一个没有契约的假设上。另一个是以为 checkSession 通过就万事大吉,它校验的只是微信侧那把密钥,业务凭证的有效性得靠自己维护。
往后看,小程序的身份体系会更依赖服务端自持凭证:微信的角色收窄为「首次身份认证 + 敏感数据解密」,会话生命周期管理逐步回到业务自己手里。这套 refresh_token 加互斥锁的架构不复杂,但每一环都要按「凭证随时可能失效」来设计,而不是按「理论上能用 30 天」来设计。如果你的小程序也在经历奇怪的集中掉线,欢迎在评论区交流排查思路。
参考与延伸
微信小程序开发、code2Session、session_key、会话续期、小程序登录、wx.checkSession、并发重放