写在前面
你好,我是 Evan。
“JWT 不是无状态的吗?那用户退出登录后,Token 为什么还能用?”
这是我在一次 Code Review 中被问住的问题。我当时自信满满地设计了一套 JWT 认证方案——Access Token 有效期 30 分钟,Refresh Token 有效期 7 天。登录、鉴权一切正常,直到产品经理提出一个看似简单需求:“用户退出登录后,Token 要立即失效。”我愣住了。JWT 一旦签发,在过期之前它就是“活”的。服务器端根本没有存储它的状态,谈何“失效”?删除客户端的 Token?那只是掩耳盗铃——被删掉的 Token 照样能通过服务端认证。注销、改密码、踢人下线……这些再正常不过的业务需求,在无状态的 JWT 面前,变成了一道无解的题。
后来我才明白:JWT 的无状态是优势,也是枷锁。想要它“有状态”的能力,就必须付出“有状态”的代价。今天这篇文章,我想用一次完整的生产级实践,聊聊 JWT 的“不可能三角”——如何在不破坏无状态架构的前提下,实现可注销、可续签、可管控的令牌体系。
一、JWT 的“皇帝新装”:无状态的光环与阴影
1.1 为什么 JWT 如此受欢迎?
JWT(JSON Web Token)之所以成为分布式系统的认证标配,核心在于它的无状态性:
服务端不需要存储 Session,水平扩展零障碍
Token 自包含用户信息和签名,一次验证即可信任
天然适合微服务、跨域、移动端等场景
但无状态的另一面是:一旦签发,在 exp 时间到达之前,这个 Token 就是“不死之身”。
1.2 无状态的“三宗罪”
一句话总结:JWT 的“无状态”让认证变得简单,却让注销变得困难。你无法“撤销”一个已经发出去的 Token,只能等它自己过期。
这就引出了我们今天要解决的核心问题:如何在保留 JWT 无状态优势的同时,获得“有状态”的控制能力?
二、破局之道:JWT + Redis 的“有状态无状态”混合架构
2.1 核心思路:用 Redis 给 JWT 加一个“开关”
JWT 本身无状态,但我们可以借助外部存储(Redis)来记录 Token 的“生死状态”。这样既保留了 JWT 的自包含和分布式优势,又获得了主动撤销的能力。
关键设计原则:
最小化存储:Redis 只存必要的状态标记,不存完整的用户数据
TTL 对齐:Redis 键的过期时间严格对齐 JWT 的
exp时间,避免永久堆积异步清理:黑名单过期后自动删除,无需人工维护
2.2 白名单 vs 黑名单:选哪个?
黑名单的优势在于:注销是低频操作,而正常请求是高频操作。用黑名单,99% 的请求不需要查 Redis(或者只需要查一次黑名单),而白名单每次都要查。在生产环境中,黑名单是绝对的主流方案。
三、黑名单实战:从理论到代码
3.1 JWT 中的 jti:为每个 Token 贴上“身份证”
要实现黑名单,首先要让每个 Token 可被唯一标识。JWT 标准中定义了jti(JWT ID)字段,专门用于此目的。
// 生成 JWT 时注入唯一 jti String jwt = Jwts.builder() .setId(UUID.randomUUID().toString()) // 关键:唯一令牌ID .setSubject("user123") .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 30分钟 .signWith(SignatureAlgorithm.HS256, secretKey) .compact();3.2 注销接口:将 jti 加入黑名单
@PostMapping("/logout") public ResponseEntity<?> logout(@RequestHeader("Authorization") String authHeader) { String token = authHeader.replace("Bearer ", ""); Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); String jti = claims.getId(); Date exp = claims.getExpiration(); long ttl = exp.getTime() - System.currentTimeMillis(); // 将 jti 存入 Redis 黑名单,TTL = Token 剩余有效期 + 缓冲时间 // 使用 SET 命令,key 为 blacklist:{jti},过期时间对齐 Token 剩余时间[reference:17] if (ttl > 0) { redisTemplate.opsForValue().set( "blacklist:" + jti, "revoked", ttl + 30_000, // 多留 30 秒缓冲,避免时钟偏差 TimeUnit.MILLISECONDS ); } return ResponseEntity.ok("注销成功"); }3.3 鉴权过滤器:每次请求检查黑名单
@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private RedisTemplate<String, String> redisTemplate; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String token = extractToken(request); if (token == null) { chain.doFilter(request, response); return; } // 1. 解析 JWT,获取 jti Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); String jti = claims.getId(); // 2. 检查黑名单[reference:18] Boolean isBlacklisted = redisTemplate.hasKey("blacklist:" + jti); if (Boolean.TRUE.equals(isBlacklisted)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } // 3. 放行 chain.doFilter(request, response); } }3.4 黑名单的“生命周期管理”
黑名单最怕的就是无限膨胀。想象一下,如果每个注销的 Token 都永久留在 Redis 里,几百万用户注销后,Redis 就爆了。
解决方案:Redis 键的 TTL 严格对齐 JWT 的剩余有效期。Token 过期后,黑名单中的记录也自动消失。
// TTL 计算:Token 剩余有效期 + 30 秒缓冲 long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); redisTemplate.opsForValue().set( "blacklist:" + jti, "revoked", Math.max(ttl, 0) + 30_000, // 至少 30 秒 TimeUnit.MILLISECONDS );四、续签的艺术:Access Token + Refresh Token 双令牌机制
黑名单解决了“注销”问题,但还有一个更常见的场景:Token 过期了怎么办?总不能让用户每隔 30 分钟就重新登录一次吧。
4.1 双令牌架构
4.2 核心设计要点
4.3 Refresh Token 轮转实现
@PostMapping("/refresh") public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) { String refreshToken = request.getRefreshToken(); // 1. 检查 Refresh Token 是否在黑名单中 String jti = extractJti(refreshToken); if (redisTemplate.hasKey("blacklist:refresh:" + jti)) { return ResponseEntity.status(401).body("Refresh Token 已失效"); } // 2. 校验 Refresh Token 签名和有效期 Claims claims = validateToken(refreshToken); // 3. 将旧的 Refresh Token 加入黑名单[reference:28] redisTemplate.opsForValue().set( "blacklist:refresh:" + jti, "revoked", getRemainingTTL(claims) + 30_000, TimeUnit.MILLISECONDS ); // 4. 生成新的 Access Token + Refresh Token[reference:29] String newAccessToken = generateAccessToken(claims.getSubject()); String newRefreshToken = generateRefreshToken(claims.getSubject()); return ResponseEntity.ok(new TokenPair(newAccessToken, newRefreshToken)); }为什么要把旧的 Refresh Token 加入黑名单?如果不这样做,一个 Refresh Token 可以被无限次使用来换取新的 Access Token——相当于 Refresh Token 永不失效。
五、进阶方案:用户级版本号——一票否决所有 Token
黑名单方案的问题在于:每个 Token 需要单独存储。如果用户在多设备登录,注销时需要把每个设备的 Token 都加入黑名单,操作繁琐。
更好的方案:用户级版本号(user_version)
核心逻辑:
每个用户在 Redis 中维护一个
version计数器签发 Token 时,将当前
version写入 Token 的 Claims每次请求验证时,对比 Token 中的
version和 Redis 中的最新version不一致则拒绝
优点:一次操作(version++)即可让该用户所有Token 失效,无需遍历黑名单。每个用户只占用一个 Redis Key,内存开销极小。
适用场景:修改密码、账号封禁、强制所有设备下线。
六、完整架构总览
七、常见陷阱与最佳实践
陷阱 1:黑名单无过期策略
❌错误做法:将注销的 Token 永久存入 Redis。
✅正确做法:TTL 严格对齐 Token 剩余有效期。
陷阱 2:Refresh Token 不轮转
❌错误做法:每次刷新只换 Access Token,Refresh Token 不变。
✅正确做法:每次刷新生成新的 Refresh Token,旧 Token 加入黑名单。
陷阱 3:黑名单查询影响性能
❌错误做法:每次请求都查两次 Redis(黑名单 + user_version)。
✅正确做法:将黑名单查询结果缓存到 ThreadLocal 或本地缓存(Caffeine),减少 Redis 压力。
陷阱 4:把敏感信息放入 Payload
❌错误做法:在 JWT Payload 中存放密码、身份证号等敏感信息。
✅正确做法:Payload 只存放非敏感的用户标识(如 userId、role),敏感信息走数据库查询。
陷阱 5:密钥硬编码
❌错误做法:secretKey = "mySecret"写在代码里。
✅正确做法:使用 KMS / Vault 托管密钥,支持密钥轮换。
八、总结:JWT 不是银弹,但用对组合就是神器
回到开头的问题:JWT 如何实现注销、续签和黑名单?
答案不是“不用 JWT”,而是“JWT + Redis”的混合架构:
黑名单(按 jti):解决单 Token 注销问题
Refresh Token 轮转:解决安全续签问题
用户级版本号(user_version):解决批量失效问题
这套方案既保留了 JWT 的无状态优势(水平扩展、跨域、自包含),又通过 Redis 获得了“有状态”的控制能力(注销、踢人、改密码)。
最后送你一张决策表:
| 需求 | 推荐方案 | 实现成本 |
|---|---|---|
| 用户退出登录 | jti 黑名单 | 低 |
| 修改密码/封号 | user_version 版本号 | 低 |
| Token 过期续签 | Access + Refresh 双 Token + 轮转 | 中 |
| 多设备登录控制 | Redis 存储设备列表 | 中 |
| 强制所有设备下线 | user_version + 1 | 低 |
JWT 不是银弹,但用对组合,它就是神器。