上次面腾讯挂了,这次面字节跳动(抖音安全部),阿强又倒在一道实战题上。面试官抛出一个场景:“假设黑客盗取了用户 Token,或者后台检测到违规账号点击‘立即封禁’,如何让这个用户手里的 JWT 瞬间失效,无法再刷出任何一条视频?”
阿强想都没想就背八股文:“把有效期设短一点,比如 5 分钟。封号后,等它 5 分钟过期就行了。”

面试官冷笑一声,连发三问:
- “让他再逍遥 5 分钟?你知道这 5 分钟黑客能爬走我们多少敏感数据吗?”
- “如果为了安全把 Token 设为 1 分钟一换,那几亿用户频繁请求续签的交互压力你承担得起吗?”
- “JWT 的核心优势是‘无状态’,你一旦发出去就像泼出去的水。现在我要你把泼出去的水收回来,你告诉我‘等它自己干’?”
阿强瞬间哑火。这道题是分布式鉴权里的“送命题”。很多人只知道 JWT 方便、能跨域、不用存 Session,却忽略了它最大的硬伤——“请神容易送神难”。
今天我们就来拆解 3 种大厂主流的“踢人”方案,从原理到代码落地,讲清楚如何让 JWT 也能被“遥控”。
为什么 JWT 不能“直接”踢人?
要解决问题,先得明白原理。
传统的 Session 模式:用户信息存在服务端的内存或 Redis 里,客户端只拿一个 SessionID。
- 踢人逻辑:服务端直接把这个
SessionID 从 Redis 里删掉。用户下次请求,查不到 Session,直接报错“未登录”。
主流的 JWT 模式:用户信息(UID、Role、过期时间)是加密签在 Token 里的,存在用户本地。服务端不存 Token(为了无状态和水平扩展)。
- 尴尬点:只要用户的 Token 签名是对的,且时间没过期,网关/服务器就必须认账。你改了数据库里的
status=disabled,但服务器在校验 Token 时根本不去查数据库(为了性能)。

结论:要想实现“踢人”,JWT 必须“妥协”,引入一定的状态机制。想深入了解这类分布式系统中的权衡设计,可以参阅 后端与架构 板块的讨论。
进阶打法:3种“强制下线”架构与代码落地
方案一:Redis 黑名单机制 (Blacklist) —— 立即生效
这是最接近 Session 体验的方案,安全性最高。
原理:虽然我们不存“有效的 Token”,但我们可以存 “失效的 Token”。

逻辑:
- 踢人时:计算该用户 JWT 的剩余过期时间(比如还剩 30 分钟),把它扔进 Redis 黑名单,并设置相同的 TTL。
- 校验时:每次请求进来,先验签名,再多查一次 Redis:这个 Token 在不在黑名单里?

硬核实战:代码落地 (基于 Spring Boot + Java)
1. KickoutService.java (踢人逻辑)
@Service
public class KickoutService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 将某个 Token 拉入黑名单
* @param token 待作废的 JWT 字符串
*/
public void kickoutUser(String token) {
// 1. 解析 Token,获取它的过期时间 (exp)
Date expiration = JwtUtil.getExpiration(token);
long now = System.currentTimeMillis();
// 2. 计算剩余存活时间 (TTL)
long ttl = expiration.getTime() - now;
// 3. 如果 Token 还没过期,就把它塞进 Redis 黑名单
// Key 建议加上前缀,如 "blacklist:token:"
if (ttl > 0) {
redisTemplate.opsForValue().set(
"blacklist:token:" + token,
"1",
ttl,
TimeUnit.MILLISECONDS
);
}
}
}
2. AuthInterceptor.java (网关/拦截器校验)
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
// 第一步:基础校验(校验签名、是否过期),这是 JWT 自带的
if (!JwtUtil.verify(token)) {
throw new UnAuthException("Token 无效或已过期");
}
// 第二步:查 Redis 黑名单(这是关键!)
Boolean isBlacklisted = redisTemplate.hasKey("blacklist:token:" + token);
if (Boolean.TRUE.equals(isBlacklisted)) {
throw new UnAuthException("您的账号已被强制下线,请联系管理员");
}
return true;
}
}
这类与 Java 生态紧密集成的实战代码,是构建稳健后台服务的基础。
优缺点分析:
- 优点:实时性极高,前脚封号,后脚请求就报错。
- 缺点:每次请求都要查 Redis,牺牲了一点“无状态”的性能优势。但在安全面前,这点损耗通常值得。
方案二:版本号机制 (Version) —— 一键全退
如果不希望 Redis 里存海量黑名单 Key,或者想实现 “修改密码后,所有设备自动下线” ,这个方案最合适。
原理:给每个用户绑定一个“许可证版本号”。

逻辑:
- 发证:登录时,去 Redis/DB 查该用户的
version(默认是 1),写在 Token 的 payload 里。
- 验证:请求来了,拿 Token 里的
v=1 和 Redis 里的当前版本 v=1 比对。
- 踢人:把 Redis 里的
version 改成 2。之前所有 v=1 的 Token 就全部失效了。

硬核实战:代码落地
1. LoginService.java (登录颁发)
public String login(String userId) {
// 1. 从 Redis 获取用户当前的 version
String versionKey = "user:version:" + userId;
String currentVersion = redisTemplate.opsForValue().get(versionKey);
// 如果没有版本号,初始化为 1
if (currentVersion == null) {
currentVersion = "1";
redisTemplate.opsForValue().set(versionKey, "1");
}
// 2. 将 version 写入 Token 的 Payload 中
Map<String, Object> claims = new HashMap<>();
claims.put("uid", userId);
claims.put("v", Integer.parseInt(currentVersion)); // 关键点
return JwtUtil.createToken(claims);
}
2. UserService.java (改密/踢人)
public void resetPasswordOrKickout(String userId) {
// 1. 执行修改密码等业务逻辑...
// 2. 关键步骤:将用户的 Version 自增
redisTemplate.opsForValue().increment("user:version:" + userId);
}
3. AuthInterceptor.java (拦截器校验)
public boolean preHandle(HttpServletRequest request, ...) {
String token = request.getHeader("Authorization");
Claims claims = JwtUtil.parse(token);
// 1. 获取 Token 里的版本
Integer tokenVersion = (Integer) claims.get("v");
String userId = (String) claims.get("uid");
// 2. 获取系统当前的最新版本
String sysVersionStr = redisTemplate.opsForValue().get("user:version:" + userId);
// 3. 比对:如果系统版本变了
if (sysVersionStr != null) {
Integer sysVersion = Integer.parseInt(sysVersionStr);
if (!sysVersion.equals(tokenVersion)) {
throw new UnAuthException("登录信息已过期(密码可能已修改),请重新登录");
}
}
return true;
}
方案三:双 Token 机制 (Access + Refresh) —— 大厂标配
这是 OAuth2.0 的标准玩法,完美平衡了 性能 与 管控 。

原理:
- Access Token:有效期极短(比如 5-15 分钟)。完全无状态,网关只验签名,不查 Redis。保证高频请求性能。
- Refresh Token:有效期长(比如 7 天)。有状态,保存在 Redis 中。
- 踢人逻辑:
- 管理员想踢人时,直接删除 Redis 里的 Refresh Token。
- 用户的 Access Token 虽然还在,但最多只能活几分钟。
- 等 Access Token 过期,客户端拿 Refresh Token 来换新的。
- 服务端一查:Refresh Token 没了,拒绝刷新,强制退出。

硬核实战:代码落地
1. TokenService.java (登录返回双 Token)
public Map<String, String> login(String userId) {
// 1. 生成短效 Access Token (15分钟),纯计算,不交互
String accessToken = JwtUtil.createAccessToken(userId, 15, TimeUnit.MINUTES);
// 2. 生成长效 Refresh Token (7天),并存入 Redis
String refreshToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"refresh_token:" + userId,
refreshToken,
7, TimeUnit.DAYS
);
// 3. 返回给前端
Map<String, String> tokens = new HashMap<>();
tokens.put("access_token", accessToken);
tokens.put("refresh_token", refreshToken);
return tokens;
}
2. AdminService.java (踢人逻辑)
public void kickoutUser(String userId) {
// 直接删除该用户的 Refresh Token
redisTemplate.delete("refresh_token:" + userId);
}
3. AuthController.java (刷新 Token 接口)
@PostMapping("/refresh")
public Result refreshToken(@RequestBody String incomingRefreshToken, String userId) {
// 1. 去 Redis 查该用户合法的 Refresh Token
String cachedRefreshToken = redisTemplate.opsForValue().get("refresh_token:" + userId);
// 2. 关键校验:如果 Redis 里查不到或与前端传入的不匹配
if (cachedRefreshToken == null || !cachedRefreshToken.equals(incomingRefreshToken)) {
return Result.fail(401, "登录凭证已失效,请重新登录");
}
// 3. 校验通过,颁发新的 Access Token
String newAccessToken = JwtUtil.createAccessToken(userId, 15, TimeUnit.MINUTES);
return Result.success(newAccessToken);
}
优缺点分析:
- 优点:平时请求完全不查库,QPS 最高。
- 缺点:踢人有“延迟”(取决于 Access Token 的寿命)。对于绝大多数非金融级业务,这几分钟的延迟是可接受的。
最后的“防杠”指南(扫清死角)
面试官可能会问:“如果 Redis 挂了怎么办?黑名单丢了岂不是封禁用户复活了?”
答:这是一个关于系统可用性(Availability)与一致性(Consistency)的取舍问题。

- 架构层面:Redis 必须做高可用(哨兵或集群),避免单点故障。
- 降级策略:如果 Redis 全崩,可将‘踢人’动作降级为‘数据库标记’,并将高危黑名单同步到应用服务的 JVM 本地缓存(如 Guava Cache) 中。
- 业务取舍:在极端情况下,保证普通用户能登录(可用性),暂时允许被踢用户访问几分钟(牺牲一致性),通常是更优解。但涉及 资金转账 等核心接口,必须 强查数据库状态 ,不走 JWT 无状态校验。
总结与建议
三种 JWT 强制下线方案核心对比
| 维度 |
方案一:Redis 黑名单 |
方案二:版本号机制 |
方案三:双 Token (Access+Refresh) |
| 核心原理 |
每次请求查 Token 是否在黑名单中 |
Token 携带版本,比对 Redis 中的用户最新版本 |
短效 Access Token 访问,踢人时删除 Refresh Token |
| 踢人生效速度 |
立即生效 (秒级) |
立即生效 (秒级) |
有延迟 (取决于 Access Token 剩余寿命) |
| 性能损耗 |
高 (每次请求必查 Redis) |
高 (每次请求必查 Redis) |
极低 (平时不查 Redis,仅刷新时查) |
| 存储成本 |
高 (黑名单越多,占用越大) |
极低 (每个用户只存一个版本号) |
中 (每个用户存一个 Refresh Token) |
| 影响范围 |
精准打击 (只踢当前设备/Token) |
全端打击 (踢掉该用户所有设备) |
精准打击 (踢掉对应的 Refresh Token) |
| 核心痛点 |
违背 JWT 无状态初衷,Redis 压力大 |
无法单独踢掉某个设备,一改密全掉线 |
踢人有时间差,极端安全场景不适用 |
| 推荐指数 |
⭐⭐⭐ |
⭐⭐⭐⭐ |
⭐⭐⭐⭐⭐ |
| 最佳适用场景 |
资金/政务系统,对安全性要求极高,零容忍 |
修改密码、注销账号等需要“一键全退”的场景 |
90% 的互联网 App ,追求高并发性能 |
世界上没有绝对的“无状态”。JWT 的无状态是为了换取性能,而在需要“控制权”的时刻(踢人、注销、改密),我们必须引入一点“状态”。
选型建议:
- 想最省心、性能最好:选 方案三(双 Token) 。这是大厂标准,90% 的场景都适用。
- 对安全性有洁癖(即刻生效):选 方案一(黑名单) 。一个请求都别想溜过去,哪怕牺牲一点 Redis 性能。
- 想最省内存(全端下线):选 方案二(版本号) 。存一个 Integer 搞定所有设备。
选择适合你业务场景的方案,给你的 JWT 装上一个可靠的“遥控自毁装置”吧。如果你想与更多开发者交流此类架构设计心得,欢迎来到 云栈社区 参与讨论。