
写在前面你好我是 Evan。“JWT 不是无状态的吗那用户退出登录后Token 为什么还能用”这是我在一次 Code Review 中被问住的问题。我当时自信满满地设计了一套 JWT 认证方案——Access Token 有效期 30 分钟Refresh Token 有效期 7 天。登录、鉴权一切正常直到产品经理提出一个看似简单需求“用户退出登录后Token 要立即失效。”我愣住了。JWT 一旦签发在过期之前它就是“活”的。服务器端根本没有存储它的状态谈何“失效”删除客户端的 Token那只是掩耳盗铃——被删掉的 Token 照样能通过服务端认证。注销、改密码、踢人下线……这些再正常不过的业务需求在无状态的 JWT 面前变成了一道无解的题。后来我才明白JWT 的无状态是优势也是枷锁。想要它“有状态”的能力就必须付出“有状态”的代价。今天这篇文章我想用一次完整的生产级实践聊聊 JWT 的“不可能三角”——如何在不破坏无状态架构的前提下实现可注销、可续签、可管控的令牌体系。一、JWT 的“皇帝新装”无状态的光环与阴影1.1 为什么 JWT 如此受欢迎JWTJSON 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 标准中定义了jtiJWT 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 RedisTemplateString, 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 剩余有效期。陷阱 2Refresh Token 不轮转❌错误做法每次刷新只换 Access TokenRefresh 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 不是银弹但用对组合它就是神器。