深蓝黑背景封面。左侧是一条四步决策链,每步左侧一个问题气泡、箭头指向右侧方案卡:第一问「单体应用?」指向「本地 session:内存里存,最快」;第二问「多实例、要随时踢人下线?」指向紫色高亮的「集中式 session:Redis 统一存,可撤销」;第三问「多实例、追求无状态?」指向「JWT:自包含免存储,撤销难」;第四问「要给第三方应用授权?」指向青色高亮的「OAuth2:授权框架,四种模式」。底部一句话「没有万能方案,只有场景匹配」。右侧大标题「分布式系统的会话与鉴权方案怎么选」,副标题「session、JWT、OAuth2 的能力边界」。左上标注「知识延伸 · 会话与鉴权」,右上圆角胶囊标注「Day 10 · 11-27」。 图 1:本文封面,会话与鉴权方案的四步决策链。

同批的 token 鉴权真题讲了 token 与 session 两种机制的逐项对比。这篇把视野拉宽:真实的系统里选项不止两个——本地 session、集中式 session、JWT、OAuth2 各占据选型坐标系的一个象限,选型就是按场景问题一步步收缩。

方案一:本地 session,单体应用的最优解。

用户登录后,服务端在内存里建一份会话对象,回给浏览器一个 session id(走 Cookie),之后每次请求按 id 查内存。查询就是一次哈希表查找,没有任何网络开销,修改、踢人下线都是改自己内存,即时生效。它的能力边界也清清楚楚:会话只存在这一台机器上,实例一扩容,请求落到别的机器就不认识了。所以它的适用面只有一条——单体应用,或能用会话粘滞(sticky session)把同一用户固定到同一实例的小集群。

方案二:集中式 session,用一次网络读换横向扩展。

把会话从各实例内存搬进共享存储(典型是 Redis):任何实例拿到 session id 都去中心存储校验,于是负载均衡可以随意调度,实例可以随时增减。会话依然是「有状态」的——存储里删掉这条记录,用户立即被踢下线,撤销能力完整保留。代价是每次请求多一次网络读(通常亚毫秒级,但要占用基础设施),以及中心存储本身要做高可用。多实例、又要随时踢人下线的后台系统,这是最稳的选择。

方案三:JWT,把会话塞进令牌自己。

JWT(RFC 7519,2015 年发布)的思路是反转:服务端不存任何会话,把用户身份、角色、过期时间编码进令牌本身,用密钥签名防篡改。令牌三段式结构 header.payload.signature,前两段只是 Base64URL 编码的 JSON——不加密,只签名,任何人都能解码看到内容,但改一个字符验签就失败。验证方只需要密钥,不需要查任何存储,所以天然无状态、天然跨服务跨域,网关验签零网络开销。代价是同一枚硬币的另一面:签发之后在过期之前难以单独撤销,踢人下线要靠额外引入黑名单(那就又把状态请回来了)或缩短有效期配刷新令牌。

方案四:OAuth2,解决的是另一个问题。

把 OAuth2 和上面三个并列容易误导,先划清边界:OAuth2(RFC 6749,2012 年发布)是授权框架,不是会话机制。它回答的问题是「怎么让第三方应用在不拿到用户密码的前提下,获得有限的访问授权」。框架定义了四种授权模式:授权码模式(最常用,第三方经重定向拿授权码再换令牌,令牌不经浏览器)、隐式模式(已不推荐用于新系统)、资源所有者密码模式(仅限高度信任的第一方)、客户端凭证模式(服务对服务,没有用户)。OAuth2 体系里签发的访问令牌常落地为 JWT,二者是配合关系;单点登录(SSO)也通常建在授权码流程之上。所以选型时先问「有没有第三方授权需求」——有才需要 OAuth2,没有就别把它请进来增加复杂度。

选型坐标系:两个问题定方向。

把所有方案放到两个轴上:横轴是有状态到无状态,纵轴是单体到分布式/跨域。决策链就是四个问题:单体应用?本地 session。多实例且要随时踢人?集中式 session。多实例且追求无状态、读多写少的校验路径?JWT。要开放能力给第三方应用?叠加 OAuth2。四个方案不是替代关系,而是逐级补上前面方案解决不了的问题。

用代码验证 JWT 的三个关键性质。

下面这段 Node.js 22.15.0 程序(node jwt.js)只用标准库手工实现签发与验签,演示「不加密可解码」「篡改即失效」「验签不查存储」:

const crypto = require('crypto');
const b64u = o => Buffer.from(JSON.stringify(o)).toString('base64url');
const secret = 'demo-secret';

const header = b64u({ alg: 'HS256', typ: 'JWT' });
const payload = b64u({ sub: 'user-42', role: 'admin', exp: 1893456000 });
const sign = data => crypto.createHmac('sha256', secret).update(data).digest('base64url');
const token = `${header}.${payload}.${sign(`${header}.${payload}`)}`;
console.log('签发的 token:', token);

// 性质一:payload 只是 Base64URL 编码,任何人都能读
console.log('任何人解码 payload:', Buffer.from(token.split('.')[1], 'base64url').toString());

// 性质二:验签能发现篡改
const verify = t => {
  const [h, p, sig] = t.split('.');
  return crypto.timingSafeEqual(Buffer.from(sign(`${h}.${p}`)), Buffer.from(sig));
};
console.log('原 token 验签:', verify(token));
const forged = `${header}.${b64u({ sub: 'user-42', role: 'root', exp: 1893456000 })}.${token.split('.')[2]}`;
console.log('篡改 role 后验签:', verify(forged));

实测输出:

签发的 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODkzNDU2MDAwfQ.I9myOyuALGau-l_HG_pu6FdQSnRNGY79PSlcXLLq7LA
任何人解码 payload: {"sub":"user-42","role":"admin","exp":1893456000}
原 token 验签: true
篡改 role 后验签: false

第一行说明 payload 里没有秘密(敏感数据绝不能放进来),第三、四行说明签名保证了完整性。整个验签过程没有读任何服务端存储——这就是无状态的来源,也是撤销难的来源。

深蓝黑背景概念图。主体是一个二维坐标系:横轴从左到右为「有状态 → 无状态」,纵轴从下到上为「单体 → 分布式 / 跨域」。四个方案各以一个圆点加注释卡落在坐标系中:左下「本地 session」(有状态、单体)——内存即会话,快但无法横向扩展;左上偏中「集中式 session」(有状态、分布式)——会话集中到 Redis,任意实例可校验、可随时撤销,代价是多一次网络读;右上偏中「JWT」(无状态、分布式)——令牌自包含、服务端免存储、天然跨域,代价是签发后难以撤销;最上方紫色高亮「OAuth2」(无状态、跨域与第三方授权)——授权框架而非会话机制,含授权码等四种模式,与 SSO 配合,令牌常落地为 JWT。底部总结条:「选型坐标:先问要不要撤销与跨域,再问有没有第三方授权需求」。 图 2:四种方案在「有状态—无状态 × 单体—跨域」坐标系中的位置。

最后强调一句边界:这套选型框架管的是「用户登录态与授权」这一层,传输安全(TLS)、令牌存储位置(Cookie 还是 localStorage)、防 XSS 与 CSRF 是另外几层功课,选好方案不等于系统安全。你的系统现在跑在哪一档?有没有因为「想踢人踢不掉」从 JWT 退回集中式 session 的经历?欢迎聊聊。