后端认证、鉴权、高并发:从 Session 到 JWT 再到 Redis
面试官问"说说你们的认证系统怎么设计的",你脑子里能蹦出 JWT、Session、Redis 这些词,但磕磕绊绊说不出一条完整的链路。这篇文章就是帮你把这条线串起来的。
一、为什么面试官总盯着这些问
说出来你可能不信------认证、鉴权、并发,这三个东西在面试里被问到的频率,比你背的八股文都高。
原因很简单: 不管你做什么业务,只要是个正经的后端系统,登录注册你得有吧?不同角色权限得区分吧?用户量一上来,并发你得处理吧?
这三个东西本质上是一条链子:
HTTP 是无状态的 → 必须想办法记住用户 → Session 出现了 → Session 水平扩展困难 → JWT 出现了 → JWT 无法主动失效 → 双 Token 出现了 → 双 Token 需要存 Redis → Redis 顺便把分布式锁、缓存、限流全干了
每一样都不是凭空发明出来的,而是被前一个方案的问题"逼"出来的。 这条进化线,就是面试官真正想听到的东西。
读完这篇文章,你会清楚:
- 每个方案为什么被发明出来(痛点)
- 每个方案怎么工作(原理)
- 不同场景怎么选(trade-off)
- 面试时被追问怎么答(加分项)
二、Session + Cookie:传统方案的优雅与局限
2.1 HTTP 是无状态的 ------ 问题的起点
先看一个最简单的场景:
bash
你:GET /api/user/info
服务器:你是谁?(返回401)
你:POST /api/login { username, password }
服务器:密码对了,但下一秒你再来请求,我还是不认识你
HTTP 协议天生就是无状态的------每次请求都是独立的,服务器不会"记住"上一个请求是谁发的。这本来是个设计上的优点(简单、可伸缩),但当我们需要做登录功能的时候,就变成了一个大问题。
你不能每次请求都让用户输一次密码吧。
所以整个"认证"这件事的本质就是:在无状态的 HTTP 上强行挂上状态。
2.2 Session 的工作原理
Session 的思路非常朴素:服务端来决定你是谁。
css
┌─────────┐ ┌──────────┐
│ 浏览器 │ │ 服务器 │
└────┬────┘ └────┬─────┘
│ POST /login {user, password} │
│───────────────────────────────────>│
│ │ 1. 验证密码
│ │ 2. 在 Redis/内存里
│ 200 OK │ 存一条:session_abc123
│ Set-Cookie: session_id=abc123 │ = {uid: 1, role: "admin"}
│<───────────────────────────────────│
│ │
│ GET /api/user/info │
│ Cookie: session_id=abc123 │
│───────────────────────────────────>│
│ │ 3. 拿到 abc123
│ │ 4. 去 Redis 查出用户信息
│ 200 {name:"张三", role:"admin"} │ 5. 返回数据
│<───────────────────────────────────│
整个过程核心就两步:
- 登录时 :服务端生成一个随机字符串(Session ID),把用户信息存到 Redis,把 Session ID 通过
Set-Cookie还给浏览器 - 后续请求:浏览器自动把 Cookie 带上,服务端拿 Session ID 去 Redis 查出用户是谁
💡 关键理解:Cookie 里只存了一个"门牌号"(Session ID),真正的数据(你是谁、什么角色)都存在服务端。这叫"服务端持有真相"。
2.3 Session 存哪里?三种方案
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| 内存 | 直接用 Map 或 express-session 默认模式存进程内存 |
最快,零网络开销 | 重启丢失;多台服务器不共享 |
| 数据库 | 存 MySQL/PostgreSQL | 持久化,不丢数据 | 慢,每次请求都查 DB,压力大 |
| Redis | session:{session_id} → 用户信息 JSON,TTL 30min |
快(<1ms)+ 自动过期 + 多机共享 | 需要额外维护 Redis |
生产环境基本都用 Redis 方案。 内存只适合开发调试,数据库做 Session 存储是拿牛刀杀鸡。
2.4 优点和缺点
优点:
- 服务端完全控制 ------ 想在服务端踢人下线?删除 Redis 里的 Session 就行了,即时生效
- 不暴露业务数据 ------ Cookie 只传一个随机 ID,用户信息不在网络上跑
- 思维方式直观 ------ "会话"这个概念符合直觉,新人也能快速上手
- Cookie 有安全属性加持 ------ HttpOnly(JS 读不到)、Secure(仅 HTTPS 传输)、SameSite(防跨站)
缺点:
- 不适用于分布式/微服务 ------ 要么共享 Redis(增加网络调用),要么用粘性会话(IP hash 绑定,但服务器下线就丢了)
- 每次请求都要查 Redis ------ 高并发下 QPS 高了就是瓶颈
- 移动端/APP 不友好 ------ Cookie 是浏览器机制,小程序、APP 要么不支持要么实现很别扭
- 跨域让人头疼 ------ Cookie 默认受同源策略限制,跨域需要加
withCredentials和各种 CORS 配置 - CSRF 攻击 ------ Cookie 是浏览器自动带的,攻击者可能利用这点伪造请求
Session 方案之所以到现在还在用,不是因为它多先进,而是因为它足够简单。简单意味着不容易出错。如果你的系统是传统的服务端渲染(SSR)单体应用,Session + Redis 完全够用。
三、JWT:无状态的革命...和它的代价
3.1 JWT 的三段式结构
JWT(JSON Web Token)全称里最关键的两个词:JSON 和 Token。
bash
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMDA4NiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcxODEyMzQwMH0.4GQ7wQ6zQl8HkY5vO2vR3TmJfKsNxPqWjL9bZcVdA1M
│ │ │
│ Header(算法) │ Payload(数据) │ Signature(签名)
│ {"alg":"HS256", │ {"sub":"10086","role":"admin","exp":1718123400} │ HMAC-SHA256(
│ "typ":"JWT"} │ │ base64(header)+"."+
│ │ │ base64(payload),
│ │ │ secret_key)
│ │ │
│ Base64URL 编码 │ Base64URL 编码(⚠️ 不是加密,任何人可以解码看到内容!) │ 数学签名
三段用 . 分隔,每段都是 Base64URL 编码过的 JSON:
- Header:声明签名算法(HS256 / RS256)和 Token 类型
- Payload :放用户 ID、角色、过期时间等 claims ------ 这是明文,不是加密
- Signature :用服务端的密钥对前两部分做哈希,保证数据没被篡改
⚠️ 面试陷阱 :面试官问你"JWT 安全吗?"------千万别只回答"有签名所以安全"。Payload 是 Base64 编码不是加密,任何人都能解码看到你的数据。你应该说:JWT 能保证防篡改(签名),但不能保证防窥探(Payload 明文)。 所以敏感信息不要放在 Payload 里,传输层要上 HTTPS。
3.2 签发和验证流程
签发(登录时):
css
用户信息 → JSON → Base64URL → Header + Payload
↓
用 secret_key 签名 → Signature
↓
Header.Payload.Signature → 返回给客户端
验证(每次请求时):
makefile
Authorization: Bearer <token>
↓
拆分成三段 → 用 secret_key 对 Header+Payload 重新算签名
↓
算出来的签名 == Token里的签名? → 相等 = 没被改过,信任 Payload 里的数据
↓ ↓
不等 = 被篡改,拒绝 ✅ 不需要查任何数据库
核心魅力 就在这里:服务端不需要存任何东西,做一次哈希运算就能验证身份。 这就是所谓的"无状态"------验证变成了计算,而不是查询。
3.3 JWT 真的"无状态"吗?
"无状态"听起来很美,但有一个致命问题:
用户改了密码,你怎么让之前签发的 JWT 马上失效?
你服务端什么都没存,Token 在客户端手里,它还有 30 分钟才自己过期。这 30 分钟内,拿着旧 Token 的人还能访问所有 API。
于是你面临一个选择题:
| 方案 | 做法 | 问题 |
|---|---|---|
| Token 有效期设很短(5-15分钟) | 过期快,影响小 | 用户体验差,频繁掉线 |
| 服务端维护黑名单(Redis) | 把要撤销的 JWT ID 存 Redis,TTL 设为 Token 剩余有效时间 | 你又引入状态了,"无状态"名存实亡 |
| 不管了,等自然过期 | 零成本 | 银行、支付等安全敏感场景绝对不行 |
真相是:生产环境里几乎不存在"纯无状态"的 JWT 方案。 大多数系统都用短期 JWT + Redis 黑名单,本质上是一个 JWT 和 Session 的混合方案。
3.4 JWT vs Session 选型对照表
| 维度 | Session + Cookie | JWT |
|---|---|---|
| 状态位置 | 服务端存(Redis) | 客户端存 |
| 水平扩展 | 需要共享 Redis | ✅ 天然支持 |
| 注销/踢人 | ✅ 即时,删 Redis | ❌ 困难,需额外机制 |
| 跨域 | ❌ 受 Cookie 同源策略限制 | ✅ 放 Header 里,灵活 |
| 移动端 | ❌ 不友好 | ✅ 原生支持 |
| 每次请求开销 | 查 Redis(~1ms) | 本地哈希运算(~0.01ms) |
| Token 体积 | 几个字节的 Session ID | 几十到几百字节 |
| 适合场景 | 传统 Web SSR、管理后台 | SPA、移动端、微服务间通信 |
💡 一句话总结:Session 是"服务端相信自己的存储",JWT 是"服务端相信自己的签名"。 信哪个都行,关键是理解每种选择的代价。
四、双 Token 机制:生产环境的标配
4.1 为什么要两个 Token
JWT 的致命弱点我们已经说了------签出去了就收不回来。双 Token 机制就是专门解决这个问题的。
它的核心思想很巧妙:把"你是谁"和"你能换新凭证"分开。
| Access Token | Refresh Token | |
|---|---|---|
| 职能 | 访问 API 的"身份证" | 换新 Access Token 的"介绍信" |
| 有效期 | 短(5-15 分钟) | 长(7-30 天) |
| 存储位置 | 客户端内存(或 localStorage) | HTTP-only Cookie / 安全存储 |
| 暴露频率 | 每!次!请!求!都!带! | 只在刷新时发一次 |
| 服务端存吗? | 不存(纯 JWT 签名验证) | ✅ 存 Redis,用于撤销控制 |
| 泄露后果 | 影响 15 分钟 | 需要立即处理,但通过旋转机制可检测 |
💡 关键洞察:Access Token 短,泄露了影响有限;Refresh Token 长,但不频繁传输,且服务端可控。把高频的、暴露面大的 Token 变短,把低频的、能控制状态的 Token 变长------这就是双 Token 的设计哲学。
4.2 工作流程
arduino
用户登录 ──→ 服务端返回 { accessToken: "eyJ...", refreshToken: "rT_abc123" }
│ │
存内存/内存变量 存 httpOnly Cookie
(15分钟过期) (7天过期, 服务端Redis里也有)
│ │
┌─────────┘ │
▼ │
访问 /api/xxx │
Authorization: Bearer <accessToken> │
│ │
┌───────┴───────┐ │
▼ ▼ │
签名有效 签名过期(401) │
│ │ │
▼ ▼ │
正常返回数据 前端拦截器捕获401 │
│ │
▼ │
POST /auth/refresh │
Cookie: refreshToken=rT_abc123 │
│ │
▼ │
服务端: 检查 Redis 里这个 RT 是否有效 │
├─ 有效 → 返回新 Access Token + 新 Refresh Token│
│ (旧 RT 标记为已使用 → Rotation) │
└─ 无效 → 返回 401,前端跳转登录页 │
服务端能做什么?
- 用户主动退出 → 删 Redis 里的 Refresh Token → 等 Access Token 过期后,用户自然下线
- 管理员踢人 → 删指定设备的 Refresh Token → 最长 15 分钟强制下线
- 检测到异常 → 立即清空该用户所有设备的 Refresh Token
4.3 Token 轮换(Rotation)------ 让盗窃能被发现
这是双 Token 机制里最精妙的设计:
css
正常用户: 攻击者(偷了 Refresh Token):
请求刷新(token_A) │
│ │
服务端: token_A 有效 │
返回: 新 access + 新 refresh(token_B) │
标记: token_A 已使用 │
│ ├── 请求刷新(token_A)
下次刷新(token_B) ✅ │
服务端: token_A 已经被用过了!
标记: 该用户所有 Refresh Token
全部作废 ← 🔴 盗窃被发现
返回: 401,通知用户"您的账号
可能存在安全风险"
每次刷新换一个新 Refresh Token,旧的就废了。 如果攻击者和正常用户都拿着同一个 Refresh Token(比如 token_A),谁先刷新,另一个人的就失效了。当服务端看到"一个已经用过的 Token 又被拿来用"------这只能是盗窃行为。
⚠️ 现实注意 :Rotation 机制天然假设"只有一台设备在刷新"。如果你手机和电脑同时登录,手机刷新了,电脑的 Token 就废了------你会被自己的正常行为踢下线。解决方案是每个设备独立分配 Refresh Token (用
device_id区分),这样 Rotation 只在同一设备内生效。
4.4 客户端静默刷新 ------ 生产级实现
下面是一个真实的 axios 拦截器实现,解决"多个请求同时遇到 401"的并发刷新问题:
javascript
// ⚠️ 关键:保证全局只有一个刷新请求
let refreshPromise = null
axios.interceptors.response.use(
response => response,
async error => {
if (error.response?.status !== 401) return Promise.reject(error)
// 如果已经在刷新中了,排队等着
if (!refreshPromise) {
refreshPromise = axios.post('/auth/refresh', {}, { withCredentials: true })
.then(res => {
const newAccessToken = res.data.accessToken
localStorage.setItem('accessToken', newAccessToken)
return newAccessToken
})
.finally(() => { refreshPromise = null })
}
// 等刷新完成,拿新 Token 重放原请求
const newToken = await refreshPromise
error.config.headers.Authorization = `Bearer ${newToken}`
return axios(error.config)
}
)
💡 这个"Promise 队列"模式是区分"用过"和"理解"的分水岭。面试时能把这个模式画出来,面试官会另眼相看。
五、Redis:让认证系统撑得住高并发
上面说了半天双 Token、Session 黑名单,全部依赖同一个东西------Redis。那 Redis 凭什么能撑住?如果 Redis 挂了怎么办?
5.1 Redis 为什么这么快
Redis 能做到单机 10W+ QPS,不是靠魔法,靠的是几个非常务实的设计选择:
| 设计 | 说明 |
|---|---|
| 纯内存操作 | 所有数据在 RAM 里,没有磁盘 I/O。持久化(RDB/AOF)是异步的,不影响读写 |
| 单线程处理命令 | 没有上下文切换、没有锁竞争、不用考虑并发安全的复杂数据结构。一个命令 = 一次原子操作 |
| I/O 多路复用 | 一个线程用 epoll/kqueue 同时监听几万个连接,哪个有数据就处理哪个 |
| 数据结构专为快设计 | SDS(简单动态字符串)预分配空间减少 realloc、ziplist 紧凑存储小数据、skiplist 支持 O(log n) 范围查 |
| RESP 协议极其简单 | 文本协议,解析几乎零开销 |
💡 面试加分点:"Redis 的单线程反而是优势------避免了多线程的上下文切换和锁竞争。Redis 的核心操作都是 O(1) 的,单线程完全够用。Redis 6.0 之后引入的多线程只处理网络 I/O(读 socket、写 socket),命令执行仍然是单线程的。"
但单线程也有坑:KEYS *、FLUSHALL、一个巨大的 DEL set 都会阻塞主线程。生产环境禁止用 KEYS,用 SCAN 代替;大 key 分批删。
5.2 分布式锁进化史
这是面试必问的深水区。我们从最 naive 的方案开始,一步步推到生产级别。
第一代:SETNX ------ "我觉得我拿到了锁"
python
if redis.setnx("lock:order_123", "my_id"): # 锁不存在?那我拿到了
do_business()
redis.delete("lock:order_123") # 释放
else:
print("别人拿着锁,等会再试")
问题 :如果 do_business() 中途进程崩溃了,delete 永远不会执行 → 死锁。
第二代:SETNX + 过期时间 ------ "30秒后自动解锁"
python
# SET key value NX EX 30
# NX: 只在 key 不存在时才成功(互斥)
# EX 30: 30 秒后自动过期(防死锁)
redis.set("lock:order_123", "my_id", nx=True, ex=30)
try:
do_business()
finally:
redis.delete("lock:order_123")
问题 :如果 do_business() 执行了 35 秒------锁在第 30 秒就自动过期了,第 31 秒另一个请求拿到了锁。然后第 35 秒第一个请求 delete 了锁------但它删的是别人的锁!
第三代:释放时验证"锁是我的"
lua
-- 用 Lua 脚本保证"判断"和"删除"的原子性
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
现在删不错了,但"锁过期业务没执行完"的问题还没解决。
第四代:Redisson Watch Dog ------ 自动续期
markdown
拿到锁 → 启动后台线程 → 每 10 秒(过期时间/3)检查
├─ 业务还在跑 → 续期到 30 秒
└─ 业务结束 → 停止续期,手动释放
进程崩溃 → 续期线程死了 → 锁 30 秒后自动过期 → 不会有死锁
java
// Redisson 用法
RLock lock = redisson.getLock("lock:order_123");
lock.lock(); // 自动启动 Watch Dog
try {
doBusiness(); // 放心跑,锁一直在
} finally {
lock.unlock();
}
第五代(争议):RedLock 算法
向 N 个独立的 Redis 实例(N 为奇数,≥3)分别加锁,超过半数成功才算拿到锁。
⚠️ RedLock 争议很大。Martin Kleppmann(《数据密集型应用系统设计》作者)专门写文章反驳,认为它在网络分区时存在安全隐患。实际项目中,大多数场景用 Redisson 就够了,RedLock 只在金融级一致性要求下才考虑。
5.3 缓存三大问题:面试最容易混淆的概念
80% 的候选人分不清这俩:
| 现象 | 穿透 | 击穿 | 雪崩 |
|---|---|---|---|
| 问题 | 查不存在的数据,每次都穿透到 DB | 热点 key 过期瞬间,大量请求打到 DB | 大量 key 同时过期,DB 瞬时压力暴增 |
| 根因 | 恶意攻击或不存在的 key | 热点数据设置了一样的 TTL | TTL 过于集中 |
| 解法 | ① 布隆过滤器 ② 缓存空值(短 TTL) | ① 互斥锁(一个线程重建,其他等待) ② 逻辑过期(不设 TTL,后台异步刷新) | ① TTL 加随机值 ② 多级缓存 ③ 限流降级 |
💡 记忆技巧:穿透是"查的东西根本不存在"(打穿到底),击穿是"查的东西存在但过期了"(热点被打穿)。一字之差。
穿透------布隆过滤器 + 缓存空值:
python
def get_product(product_id):
# 1. 布隆过滤器:这个 ID 一定不存在 → 直接返回
if not bloom_filter.might_contain(product_id):
return None
# 2. 查缓存
cache_key = f"product:{product_id}"
data = redis.get(cache_key)
if data is not None:
return data # 可能是真实数据,也可能是空值标记
# 3. 查 DB
data = db.query(product_id)
if data is None:
# 空值也缓存,但设很短的 TTL(防止占用太多内存)
redis.set(cache_key, "NULL", ex=60)
bloom_filter.add(product_id) # 可选
else:
redis.set(cache_key, data, ex=3600)
return data
击穿------互斥锁:
python
def get_hot_product(product_id):
data = redis.get(f"product:{product_id}")
if data is not None:
return data
# 用 SETNX 抢"重建权",只有一个请求能抢到
lock_key = f"lock:rebuild:{product_id}"
if redis.set(lock_key, "1", nx=True, ex=10): # 拿到了锁
try:
# 双重检查:等锁期间可能别人已经建好了
data = redis.get(f"product:{product_id}")
if data is not None:
return data
# 我是唯一重建者
data = db.query(product_id)
redis.set(f"product:{product_id}", data, ex=3600)
return data
finally:
redis.delete(lock_key)
else:
# 没抢到,等 50ms 重试
time.sleep(0.05)
return get_hot_product(product_id) # 递归重试
5.4 Redis 在认证系统里到底干了多少活
来个全景清单:
css
Redis 在认证系统里的角色:
├── Session 存储
│ session:{session_id} → {uid, role, ...} TTL 30min
│
├── JWT 黑名单(强制失效)
│ jwt:blacklist:{jti} → "revoked" TTL = JWT 剩余有效期
│
├── Refresh Token 存储(双Token核心)
│ refresh:{user_id}:{device_id} → token_hash + metadata TTL 7d
│ user:sessions:{user_id} → Set of device_ids
│
├── 分布式锁(并发控制 + 幂等)
│ lock:order:{order_id} → request_id TTL 10s
│ idempotency:{idempotency_key} → "processing" TTL 5min
│
├── 限流计数器
│ rate:login:{ip} → count TTL 1min
│ rate:api:{user_id}:{endpoint} → count TTL 1min
│
└── 缓存加速
cache:user:perm:{user_id} → 权限列表 TTL 5min
cache:hot_data:{key} → value TTL 根据业务设
一个 Redis,干了六件完全不同的事。这就是为什么面试官问你 Redis 不光问数据结构------他真正想看的是你有没有在生产环境中用它解决过实际问题。
六、从零搭一个认证系统:全部串起来
前面的知识都散开了,这一章把它们拼成一张完整的图。
6.1 架构全景图
markdown
┌──────────────┐
│ CDN/WAF │ 静态资源 + 基础防护
└──────┬───────┘
│
┌──────▼───────┐
│ Nginx │ SSL 卸载 + 限流(limit_req)
└──────┬───────┘
│
┌────────────▼────────────┐
│ API Gateway │
│ ┌────────────────────┐ │
│ │ 1. JWT 签名校验 │ │ ← 不查库,纯计算
│ │ 2. 黑名单检查(Redis) │ │ ← 每条请求 1ms
│ │ 3. 解析 uid + role │ │
│ │ 4. RBAC 权限匹配 │ │
│ └────────────────────┘ │
└──┬──────────────┬───────┘
│ │
┌─────────▼──┐ ┌──────▼──────────┐
│ Auth Service│ │ Business Services│
│ - 登录/注册 │ │ - 订单/商品/用户 │
│ - 刷新Token │ │ - 拿到 uid 直接干 │
│ - 注销 │ │ │
└──────┬─────┘ └──────┬───────────┘
│ │
┌──────▼─────┐ ┌──────▼───────────┐
│ Redis │ │ MySQL/PostgreSQL │
│ - Session │ │ - 用户表 │
│ - JWT黑名单 │ │ - 订单表 │
│ - Refresh │ │ - 权限表 │
│ - 分布式锁 │ └──────────────────┘
│ - 限流 │
└────────────┘
6.2 用户登录完整流程(时序图)
sql
浏览器 API Gateway Auth Service Redis MySQL
│ │ │ │ │
│──POST /login───────>│ │ │ │
│ {username,password}│ │ │ │
│ │──转发──────────────>│ │ │
│ │ │──限流检查────────>│ │
│ │ │ INCR rate:login:ip │
│ │ │<──count:3───────│ │
│ │ │ │ │
│ │ │──查用户─────────────────────────>│
│ │ │<──user row─────────────────────│
│ │ │ │ │
│ │ │ bcrypt.compare(password, hash) │
│ │ │ ❌密码错误?→ 返回401 │
│ │ │ ✅ 密码正确: │
│ │ │ │ │
│ │ │ 1. 生成 accessToken (JWT,15min) │
│ │ │ 2. 生成 refreshToken (opaque,7d)│
│ │ │ │ │
│ │ │──存 Refresh Token──────────────>│
│ │ │ SET refresh:10086:web rT_xyz EX 604800 │
│ │ │ SADD user:sessions:10086 "web" │
│ │ │<──OK────────────│ │
│ │ │ │ │
│ │<──200──────────────│ │ │
│<──200───────────────│ │ │ │
│ body: {accessToken}│ │ │ │
│ Set-Cookie: refreshToken (httpOnly) │ │ │
6.3 鉴权拦截器设计(Gateway 层统一处理)
python
# 伪代码:API Gateway 的 Auth 拦截器
def auth_interceptor(request):
# 1. 提取 Access Token
token = request.headers.get("Authorization", "").replace("Bearer ", "")
if not token:
return 401, {"error": "missing token"}
# 2. 验证 JWT 签名 + 过期
try:
payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
user_id = payload["sub"]
role = payload["role"]
jti = payload["jti"] # JWT ID
iat = payload["iat"] # 签发时间
except jwt.ExpiredSignatureError:
return 401, {"error": "token expired"} # 触发客户端刷新
except jwt.InvalidTokenError:
return 401, {"error": "invalid token"}
# 3. 检查黑名单(用户被禁/管理员踢人后,JWT ID 会被加入黑名单)
if redis.exists(f"jwt:blacklist:{jti}"):
return 403, {"error": "token revoked, please re-login"}
# 4. 检查是否需要重新登录(用户改密码后,所有旧 Token 作废)
last_password_change = redis.get(f"user:password_changed:{user_id}")
if last_password_change and iat < int(last_password_change):
return 403, {"error": "password changed, please re-login"}
# 5. 写入请求上下文,后续业务代码直接用
request.context = {
"user_id": user_id,
"role": role,
"jti": jti,
}
return next_handler(request)
为什么要放在 Gateway 层? 每个业务服务都自己写一遍认证逻辑?又重复又容易遗漏。Gateway 统一拦截,业务服务只关心自己的业务逻辑,拿到 user_id 就开始干活。
6.4 Token 刷新完整流程
python
# POST /auth/refresh
def refresh_token(request):
old_rt = request.cookies.get("refreshToken")
if not old_rt:
return 401, {"error": "no refresh token"}
# 1. 验证 Refresh Token(可以是 JWT,也可以是不透明字符串)
# 不透明 Token 的做法:直接查 Redis
user_id, device_id = parse_refresh_token(old_rt) # 反解/解密/查 Redis
stored_key = f"refresh:{user_id}:{device_id}"
stored_hash = redis.get(stored_key)
if not stored_hash:
return 401, {"error": "refresh token not found, please login"}
# 2. 比对(存的是 hash,不是明文)
if not bcrypt.compare(old_rt, stored_hash):
# hash 不匹配 → 可能是被盗用的旧 Token!
# 立即清空该用户所有 Refresh Token
device_ids = redis.smembers(f"user:sessions:{user_id}")
for did in device_ids:
redis.delete(f"refresh:{user_id}:{did}")
redis.delete(f"user:sessions:{user_id}")
# 触发告警
alert_security_team(f"Token reuse detected for user {user_id}")
return 401, {"error": "security alert: token reuse detected"}
# 3. ✅ 验证通过 → 签发新 Token 对
new_rt = generate_opaque_token() # 新的 Refresh Token
new_at = generate_jwt(user_id, role, ttl_minutes=15)
# 4. 轮转:存新 RT,覆盖旧 RT
redis.set(stored_key, bcrypt.hash(new_rt), ex=7*24*3600)
# 5. 返回
response = {
"accessToken": new_at
}
response.set_cookie(
"refreshToken", new_rt,
httponly=True, secure=True, samesite="Strict",
max_age=7*24*3600,
path="/auth/refresh" # 只在 refresh 路径发送
)
return 200, response
6.5 并发场景的幂等性保证
用户手快(或网络抖动)连续点了两次"下单",产生两个一模一样的请求。怎么保证不创建两个订单?
python
# 客户端:每次操作前生成唯一幂等键
idempotency_key = f"{user_id}:order:{uuid4()}"
headers = {"Idempotency-Key": idempotency_key}
# 服务端:
def create_order(request):
key = request.headers.get("Idempotency-Key")
if not key:
return 400, {"error": "missing idempotency key"}
# SETNX:只有第一个请求能写进去
locked = redis.set(f"idempotency:{key}", "processing", nx=True, ex=300)
if not locked:
# 第二次请求 → 直接返回已有结果
existing = redis.get(f"idempotency_result:{key}")
if existing:
return 200, existing
return 409, {"error": "request is being processed"}
try:
# 正常业务逻辑
order = create_order_in_db(request.data)
redis.set(f"idempotency_result:{key}", order, ex=300)
return 200, order
except Exception:
# 失败后删除幂等键,允许重试
redis.delete(f"idempotency:{key}")
raise
这个模式把前面所有的知识点都串起来了:JWT 告诉你用户是谁(认证)→ RBAC 判断有没有权限(鉴权)→ 分布式锁保证不超卖(并发)→ 幂等键防止重复提交(数据一致性)。
七、面试常见追问及答案
前面六章把知识体系拆开了讲,这一章直接给面试弹药。每个问题都有标准答案 (让你过)和加分项(让你脱颖而出)。
Q1:JWT 和 Session 的根本区别是什么?
标准答案: Session 是服务端存状态,客户端只存一个 Session ID 作为"指针";JWT 是客户端存状态,服务端通过验证签名来信任客户端发来的数据。Session 依赖共享存储(Redis)做水平扩展,JWT 天然无状态更容易扩展。
加分项:
"根本区别其实是对 'trust' 的设计哲学不同。Session 假设客户端不可信,所有状态必须服务端持有;JWT 假设签名后的令牌在一定时间内可信,把验证从'查询存储'变成了'数学计算'。但实践中这个边界是模糊的------大多数生产系统用 JWT 同时也会引入 Redis 做黑名单和强制下线,本质上是一个混合方案。"
Q2:JWT 怎么实现注销(Logout)?
标准答案: JWT 因为无状态,服务端不能让它凭空失效。常见做法有三:① 客户端删除 Token(但这只防好人);② 设置很短的过期时间(5-15 分钟);③ 服务端维护黑名单,把需要提前失效的 JWT ID 存到 Redis,TTL 设为 Token 剩余有效期,每次请求都检查。
加分项:
"如果你不需要'即时注销',短期 Token 就够了。如果需要------比如金融/支付场景------推荐用 Redis 黑名单 + JWT ID。黑名单的开销其实很小,因为你只需要存被主动撤销的 Token,正常过期的 Token 不用管。而且黑名单可以放在 Gateway 层统一检查,业务代码零侵入。"
Q3:Refresh Token 被泄漏了怎么办?
标准答案: 使用 Refresh Token Rotation + Reuse Detection。每次刷新都发新的 Refresh Token,旧 Token 立即失效并标记为"已使用"。如果攻击者拿着已经用过的旧 Token 来刷新,服务端检测到一次 reuse,立刻作废该用户的所有 Refresh Token 并触发告警,强制所有设备重新登录。
加分项:
"Rotating 以外,还可以绑定设备指纹(User-Agent + IP 段 + 屏幕分辨率组合)。Token 使用时如果设备指纹发生突变 → 触发二次验证(短信/邮箱验证码),而不直接作废。这样既不误伤正常用户的多设备切换,又能挡住盗刷。"
Q4:分布式锁过期了但业务还没执行完怎么办?
标准答案: 两个方案:一是用 Redisson 的 Watch Dog 机制,后台线程定期给锁续期,只要业务在跑锁就不会过期,进程崩溃了续期停止锁自然释放。二是评估业务的最长执行时间,把锁的过期时间设置成远超最坏情况的值(比如预期 5 秒设 30 秒),宁可锁长一点也不能锁丢了。
加分项:
"但要小心------Watch Dog 也不是万能的。如果业务代码不小心写了个死循环或者 GC 停顿,Watch Dog 会一直续期,锁就永远不释放了。所以 SETNX + 固定过期时间在某些场景反而更安全:它能保证一个明确的最大阻塞时间。另外,释放锁时一定要校验锁的持有者是自己(用 Lua 脚本),不然锁到期被别人拿走了,你把别人的锁删了。"
Q5:缓存穿透和缓存击穿有什么区别?分别怎么解决?
标准答案: 穿透是查询一个根本不存在的数据 (缓存和 DB 都没有),每次请求都穿透到 DB。击穿是一个热点 key 突然过期,所有请求同时打到 DB。穿透用布隆过滤器或缓存空值解决;击穿用互斥锁或逻辑过期解决。
加分项:
"布隆过滤器有误判率------它会告诉你'可能存在'或'一定不存在'。如果它说'可能存在',你还需要查 DB 确认。但优势是内存占用极小:误判率 1% 的情况下,10 亿条数据只需要约 1.5GB。面试官追到这里,说明他对你有兴趣。
另外,穿透和击穿的边界有时候是模糊的。比如秒杀场景,商品'存在'但库存为 0------从缓存角度看是击穿(key 存在),从业务角度看和穿透一样(请求不应该打到 DB)。这种时候需要在业务层加库存预检------Redis 里的库存为 0 就直接返回'已售罄',别再查 DB 了。"
Q6:认证系统能撑多少 QPS?怎么水平扩展?
标准答案: 认证(JWT 验证)本身是纯计算,一个 4C8G 的实例做 HS256 签名验证可以到 15W+ QPS。真正的瓶颈通常不在 CPU,在于:① 网络带宽;② 登录时的密码哈希(bcrypt 一次 50-100ms,高并发登录需要专门优化);③ Redis(每个请求都查黑名单)。水平扩展:Auth 服务无状态部署(K8s 多副本);Redis 用 Cluster 分片;数据库读写分离 + 连接池。
加分项:
"我们实际压测过:瓶颈不是 JWT 验证,是下游 Redis 的 round-trip。如果每个请求都查黑名单,100W QPS 就是 100W 次 Redis 查,Redis 单机扛不住。我们做了一个优化:Gateway 本地维护一个 LRU 内存缓存(存最近被禁用的用户 ID 列表),用 Redis Pub/Sub 推送更新。这样绝大多数请求不查 Redis,只有 LRU miss 时才查。100W QPS 下可能只有几千次 Redis 查询。"
Q7:用户被管理员禁用,但他的 Access Token 还没过期,怎么阻止他继续操作?
标准答案: 把该用户的 JWT ID(jti)或 user_id 加入 Redis 黑名单,设置 TTL = Token 签发时剩余的过期时间。每次请求在 Gateway 层查一次黑名单。黑名单命中的直接返回 403 并清理客户端 Cookie。TTL 到期后自动从 Redis 清除,不占内存。
加分项:
"这里有个取舍:是按 user_id 拉黑还是按 jti 拉黑?按 user_id 粒度粗------一个用户可能有多台设备,比如公司电脑被禁了但个人手机还想保留,按 user_id 全给踢了就不好。按 jti 粒度细------每个设备独立,但 Redis 里存的 key 数量更多。实际项目里,我们支持两级:普通场景按 device 粒度(jti),安全事件(账号被盗)按 user 粒度全清。"
八、写在最后
这篇文章从头到尾其实只讲了一件事:
每一个技术的出现,都是因为前一个技术在某个场景下"不够用了"。
HTTP 无状态不够用 → 加 Session Session 扩展性不够用 → 上 JWT JWT 控制力不够用 → 双 Token 双 Token 需要一个高性能存储 → Redis Redis 顺便把缓存、锁、限流都干了 → 一套完整的认证+并发基础设施
面试官问你"你们的认证系统怎么设计的",他不想听你背这些名词。他想听到的是你理解这条进化线上的每一个取舍:
- 为什么这里用 JWT 而不是 Session?
- 为什么 Access Token 设 15 分钟而不是 1 小时?
- 为什么 Redis 锁不用 SETNX 而是用 Redisson?
- 缓存雪崩来的时候你打算怎么办?
每个选择背后都是场景和约束的博弈,没有银弹。
把这篇文章里的关键词用自己的话串成一段故事,下次面试你就不是背八股文,而是讲自己的理解了。
有什么想法、补充或者不同的实战经验,欢迎在评论区交流。
Coding 愉快~
📌 参考资料