后端认证、鉴权、高并发:从 Session 到 JWT 再到 Redis

后端认证、鉴权、高并发:从 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. 返回数据
     │<───────────────────────────────────│

整个过程核心就两步:

  1. 登录时 :服务端生成一个随机字符串(Session ID),把用户信息存到 Redis,把 Session ID 通过 Set-Cookie 还给浏览器
  2. 后续请求:浏览器自动把 Cookie 带上,服务端拿 Session ID 去 Redis 查出用户是谁

💡 关键理解:Cookie 里只存了一个"门牌号"(Session ID),真正的数据(你是谁、什么角色)都存在服务端。这叫"服务端持有真相"。

2.3 Session 存哪里?三种方案

方案 实现 优点 缺点
内存 直接用 Mapexpress-session 默认模式存进程内存 最快,零网络开销 重启丢失;多台服务器不共享
数据库 存 MySQL/PostgreSQL 持久化,不丢数据 慢,每次请求都查 DB,压力大
Redis session:{session_id} → 用户信息 JSON,TTL 30min 快(<1ms)+ 自动过期 + 多机共享 需要额外维护 Redis

生产环境基本都用 Redis 方案。 内存只适合开发调试,数据库做 Session 存储是拿牛刀杀鸡。

2.4 优点和缺点

优点:

  1. 服务端完全控制 ------ 想在服务端踢人下线?删除 Redis 里的 Session 就行了,即时生效
  2. 不暴露业务数据 ------ Cookie 只传一个随机 ID,用户信息不在网络上跑
  3. 思维方式直观 ------ "会话"这个概念符合直觉,新人也能快速上手
  4. Cookie 有安全属性加持 ------ HttpOnly(JS 读不到)、Secure(仅 HTTPS 传输)、SameSite(防跨站)

缺点:

  1. 不适用于分布式/微服务 ------ 要么共享 Redis(增加网络调用),要么用粘性会话(IP hash 绑定,但服务器下线就丢了)
  2. 每次请求都要查 Redis ------ 高并发下 QPS 高了就是瓶颈
  3. 移动端/APP 不友好 ------ Cookie 是浏览器机制,小程序、APP 要么不支持要么实现很别扭
  4. 跨域让人头疼 ------ Cookie 默认受同源策略限制,跨域需要加 withCredentials 和各种 CORS 配置
  5. CSRF 攻击 ------ Cookie 是浏览器自动带的,攻击者可能利用这点伪造请求

Session 方案之所以到现在还在用,不是因为它多先进,而是因为它足够简单。简单意味着不容易出错。如果你的系统是传统的服务端渲染(SSR)单体应用,Session + Redis 完全够用。


三、JWT:无状态的革命...和它的代价

3.1 JWT 的三段式结构

JWT(JSON Web Token)全称里最关键的两个词:JSONToken

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 愉快~


📌 参考资料

相关推荐
piglet121381 小时前
把搜索调到 Claude.ai 的水准
前端·人工智能
JieE2121 小时前
JS 到底有多少种数据类型?从ECMA规范到内存本质,一文彻底搞懂
javascript·数据结构·面试
前端Hardy1 小时前
前端圈沸腾!这个动画库月下载超 3000 万次,已经快成行业标准了
前端
文阿花1 小时前
Echarts实现自动旋转柱状3D扇形图
前端·3d·echarts
sp421 小时前
使用 Vite 与 NativeScript
前端
dearxue2 小时前
这一次,我们一起把AI的复杂一口吃掉
人工智能·后端
前端Hardy2 小时前
GitHub 爆火!Three.js + React + ECharts 打造最强数据大屏
前端·javascript
如果超人不会飞2 小时前
TinyRobot AI 对话组件库全组件使用指南
前端·vue.js
打字机v2 小时前
OOP 面向对象 java 基础--服务+maven+mysql
后端