微信小程序登录态续期refresh_token 与互斥锁方案适用读者负责微信小程序后端登录链路的服务端开发者、被「用户莫名掉线」工单折磨的客户端工程师以及正在设计 SaaS 小程序会话体系的技术负责人。某 SaaS 小程序大促当天下午 3 点起客服后台的掉线工单开始堆积用户在购物车点结账页面直接弹回首页重新授权商家端查单页白屏转圈。事后按同一口径统计15:00 到 18:00 三个小时里会话失败率 2.7%是日常水平的二十多倍。后端负责人老周复盘时原话是「我们以为缓存的凭证能用 30 天结果微信根本没答应过这件事。」排查结论很尴尬服务端把code2Session返回的session_key当成永久登录凭证缓存了 30 天而微信侧对这个 key 的生命周期没有任何时长承诺可能随时失效。这篇文章把整个重写过程拆开讲登录链路的机制、wx.checkSession的几个坑、refresh_token 式续期的设计以及并发 401 下的请求重放怎么实现。改造上线后同样口径的掉线率降到了 0.02%。排查结论很尴尬服务端把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存进 RedisTTL 设 30 天之后所有请求只校验 Redis 里有没有这条记录。Redis 里的 key 活着业务就认为用户在线——至于微信那边的session_key还在不在没人关心。大促当天大量用户触发微信侧刷新服务端缓存却还「有效」前端拿着过期凭证去解密手机号、算支付签名全部失败看起来就是集中掉线。整个登录链路的正确时序应该是这样微信接口业务服务端小程序前端微信接口业务服务端小程序前端wx.login 获取临时 code携带 code 调用登录接口code2Session(appid, secret, code)openid session_key生成业务 token 与 refresh_tokensession_key 只存服务端绑定 openid返回业务 token不含 session_keytoken 存入 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_token2 小时和长效refresh_token30 天滑动续期前端每次请求带access_token过期后用refresh_token换新的。这里的设计取舍是access_token故意做短泄漏了损失可控refresh_token绑定设备指纹换设备登录时旧 token 作废。refresh_token续期时服务端会顺手做一次checkSession预检如果微信侧会话已经失效直接触发静默重登把两个凭证体系重新对齐。真正费劲的是并发场景。一个页面同时发五六个请求access_token过期六个请求全部 401。如果每个请求各自去刷新 tokenrefresh_token接口会被打到限流而且微信的wx.login存在并发调用时 code 复用的风险。必须加一把互斥锁第一个发现过期的请求负责刷新其余请求进队列等待拿到新 token 后统一重放。完整的时序长这样业务服务端小程序前端业务服务端小程序前端请求 A携带过期 token请求 B携带过期 token请求 A 返回 401请求 B 返回 401请求 A 触发刷新加锁请求 B 入队等待checkSession 预检 wx.login 刷新接口返回新 token解锁队列中的请求换新 token 重放请求 A / B 携带新 token 重发正常响应服务端实现Node.js环境与依赖Node.js 18Express 4axios做 HTTP 请求Redis 6 存储会话与刷新锁。下面是刷新接口和互斥锁的核心实现// Node.js 18 环境先安装依赖npm i express axios ioredis// Redis 用于存放会话凭证与刷新互斥锁constexpressrequire(express);constaxiosrequire(axios);constRedisrequire(ioredis);// 初始化 Redis 连接生产环境建议配置密码与连接池constredisnewRedis();constappexpress();// 中间件解析 JSON 请求体app.use(express.json());// 刷新锁的键名按 openid 隔离避免不同用户互相阻塞functionlockKey(openid){returnrefresh_lock:${openid};}// 尝试获取互斥锁只有拿到锁的请求才有资格刷新凭证asyncfunctionacquireLock(openid){// SET NX 保证并发下只有一个请求能写入成功EX 控制 10 秒自动释放constgotawaitredis.set(lockKey(openid),1,EX,10,NX);// 返回布尔值true 表示抢锁成功false 表示锁已被占用returngotOK;}asyncfunctionrefreshSession(openid,refreshToken){// 从 Redis 取出当初签发的 refresh_token 做比对conststoredawaitredis.get(rt:${openid});if(!stored||stored!refreshToken){// 凭证不匹配说明账号在其他设备重新登录当前端必须走完整重登thrownewError(REFRESH_TOKEN_INVALID);}// checkSession 预检逻辑放在服务端代理层见下文说明awaitprecheckWxSession(openid);// 用 HMAC 签发新的短效 access_token有效期 2 小时constaccessTokensignToken({openid},2h);// 新 token 写入 RedisTTL 与签发有效期保持一致awaitredis.set(at:${openid},accessToken,EX,7200);// 滑动续期refresh_token 每次使用后重置 30 天有效期awaitredis.expire(rt:${openid},30*86400);// 只返回 access_tokenrefresh_token 本体不回传returnaccessToken;}// 刷新接口并发请求在这里被互斥锁收敛成单次刷新app.post(/auth/refresh,async(req,res){// 请求体携带 openid 与 refreshToken由网关层完成基础校验const{openid,refreshToken}req.body;// 拿不到锁说明别的请求正在刷新直接返回 409 让客户端稍后重试if(!(awaitacquireLock(openid))){returnres.status(409).json({code:REFRESH_IN_PROGRESS});}try{// 持锁刷新成功则返回新签发的 access_tokenconstaccessTokenawaitrefreshSession(openid,refreshToken);res.json({accessToken});}catch(e){// 区分两类失败凭证失效需要完整重登其余按服务端异常处理if(e.messageREFRESH_TOKEN_INVALID){returnres.status(401).json({code:NEED_FULL_LOGIN});}// 其他异常记录日志后返回 500避免向前端泄漏内部细节res.status(500).json({code:SERVER_ERROR});}finally{// 无论成功失败都要释放锁否则用户会被卡住 10 秒awaitredis.del(lockKey(openid));}});// 启动 HTTP 服务监听 3000 端口app.listen(3000);precheckWxSession这个函数做的是服务端侧的微信会话校验拿着缓存的session_key调微信的校验能力失败就清掉缓存并标记该用户需要完整重登。把预检放在服务端而不是前端好处是判断口径统一——前端只认服务端给的结论不用自己猜微信侧的状态。小程序端请求封装与并发 401 重放环境说明小程序基础库 2.x原生框架请求层是纯 JavaScript 实现无额外依赖。核心是刷新互斥和请求队列// 全局单例状态isRefreshing 相当于前端这把互斥锁letisRefreshingfalse;// 等待队列存放刷新期间挂起的请求重放函数letpendingQueue[];// 请求封装业务代码只管调 request不用关心 token 生命周期functionrequest(options){// 用 Promise 包装 wx.request方便业务层 awaitreturnnewPromise((resolve,reject){wx.request({// 展开业务传入的 url / method / data 等字段...options,// 每次请求都带上当前最新的业务 tokenheader:{Authorization:Bearer${getToken()}},success:(res){if(res.statusCode401){// 401 时把「带新 token 重发当前请求」的动作推进队列pendingQueue.push(()request(options).then(resolve,reject));// 触发刷新调度幂等已在刷新则直接返回drainRefresh();return;}// 非 401 直接透出响应数据resolve(res.data);},fail:reject,});});}// 刷新调度保证同一时刻只有一次刷新在跑functiondrainRefresh(){// 已有刷新在途时直接返回后续请求靠队列重放if(isRefreshing)return;isRefreshingtrue;wx.checkSession({// session_key 已失效微信侧需要完整重登拿新 codefail:()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:(){// 无论成败都要解锁允许下一轮刷新发起isRefreshingfalse;},});},});}functionflushQueue(){// 逐个执行挂起的重放动作此时队列内闭包会取到新 tokenpendingQueue.forEach((replay)replay());// 清空队列避免重复重放pendingQueue[];}// 静默重登wx.login 拿新 code全程无感不弹任何授权窗functionsilentRelogin(){// wx.login 换取全新的一次性 code5 分钟有效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解密手机号失败次数周均300020 以内有两个经验值得单独说。第一掉线率统计口径要提前对齐——是把 401 算掉线还是把整个会话重建算掉线两种算法出来的数字差好几倍写复盘报告时口径不一致会吵起来。第二refresh_token的滑动续期要设上限我们给的是 30 天内活跃才续、最长 90 天强制重登避免三年不活跃的僵尸会话一直挂在 Redis 里。线上故障排查清单收到掉线工单后按下面 5 个步骤从现象一路追到根因每一步都给出可执行的命令或日志关键字确认掉线规模与时间窗口先看监控大盘确认是集中爆发还是零星个案。用日志聚合工具按小时聚合 401 与NEED_FULL_LOGIN的数量关键字401、NEED_FULL_LOGIN、session_key expired。如果 401 曲线在某个时间点陡增说明是微信侧批量刷新或服务端缓存集中过期而不是单用户问题。检查 Redis 中 session_key 的 TTL 剩余时间登录时把session_key存进 Redis 的做法很常见先看它到底还剩多久。命令redis-cli TTL session_key:{openid}再批量抽查一批用户redis-cli --scan --pattern session_key:* | head -100 | xargs -I{} redis-cli TTL {}。如果大量 key 的 TTL 都接近 30 天且同时过期说明是缓存 TTL 设置不当导致的集中失效。对比微信侧 code2Session 返回码挑几个报错用户用他们的 code 重新调一次code2Session看返回码。关键字errcode、40029code 无效、45011频率限制、40163code 已被使用。如果返回40029或40163说明微信侧会话已失效而服务端还在用旧session_key解密——这就是掉线的直接根因。确认 refresh_token 是否被设备指纹绑定检查刷新接口的日志看refresh_token是否携带设备指纹、换设备后是否被拒。关键字REFRESH_TOKEN_INVALID、device_fingerprint、rt:{openid}。如果大量REFRESH_TOKEN_INVALID出现在同一批用户身上且这些用户都换了设备或清了缓存说明指纹绑定逻辑把正常用户误判成了异地登录。核对互斥锁是否生效看刷新接口的 QPS 和锁冲突日志。关键字REFRESH_IN_PROGRESS、acquireLock、refresh_lock:{openid}。如果刷新接口 QPS 峰值很高、REFRESH_IN_PROGRESS频繁出现说明前端并发重放没收敛每个 401 都在各自刷新如果锁冲突极少但掉线依旧问题更可能出在微信侧会话失效而不是刷新链路。误区澄清或趋势预判常见误区有两个。一个是把session_key的「30 天」当成官方承诺——文档里从来没写过这个数字全靠社区口口相传微信随时可以改变行为你的架构不能建立在一个没有契约的假设上。另一个是以为checkSession通过就万事大吉它校验的只是微信侧那把密钥业务凭证的有效性得靠自己维护。往后看小程序的身份体系会更依赖服务端自持凭证微信的角色收窄为「首次身份认证 敏感数据解密」会话生命周期管理逐步回到业务自己手里。这套 refresh_token 加互斥锁的架构不复杂但每一环都要按「凭证随时可能失效」来设计而不是按「理论上能用 30 天」来设计。如果你的小程序也在经历奇怪的集中掉线欢迎在评论区交流排查思路。参考与延伸小程序登录流程官方文档wx.login 与 code2Sessionwx.checkSession 接口文档code2Session 服务端接口文档微信小程序开发、code2Session、session_key、会话续期、小程序登录、wx.checkSession、并发重放