session_key 过期引发的集中掉线:code2Session 与小程序会话续期的完整方案

2026-09-26 01:16:04 1 次浏览
微信小程序登录鉴权code2Sessionsession_key架构方案

适用读者:负责微信小程序后端登录链路的服务端开发者、被「用户莫名掉线」工单折磨的客户端工程师,以及正在设计 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、并发重放

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。