字节二面:JWT 如何实现“强制踢人”?阿强背了八股文,结果被面试官连问 3 次怼懵了...
阿强(这兄弟真是打不死的小强,上次腾讯挂了,这次又冲了)又来找我复盘了。 这次他去面了 字节跳动(抖音安全部)。
面试官抛出了一个极其实战的场景题: “假设一个黑客盗取了用户的 Token,或者我们后台检测到违规账号点击了‘立即封禁’,如何让这个用户手里的 JWT 瞬间失效,无法再刷出任何一条视频?”
阿强想都没想,直接背八股文: “简单啊!JWT 本身包含过期时间(Exp),我们把有效期设短一点,比如 5 分钟。封号之后,等他 5 分钟过期不就行了?”

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

结论: 要想实现“踢人”,JWT 必须“妥协”,引入一定的状态机制。
二、 进阶打法:3 种“强制下线”架构与代码落地
我们由浅入深,看看哪种适合你的业务。
方案 1:Redis 黑名单机制(Blacklist)—— 立即生效
这是最接近 Session 体验的方案,安全性最高。
原理: 虽然我们不存“有效的 Token”,但我们可以存“失效的 Token”。

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

硬核实战:代码落地
1. KickoutService.java (踢人逻辑)
@Service
publicclass KickoutService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 将某个 Token 拉入黑名单
* @param token 待作废的 JWT 字符串
*/
public void kickoutUser(String token) {
// 1. 解析 Token,获取它的过期时间 (exp)
// 假设 JwtUtil.getExpiration(token) 返回 Date 对象
Date expiration = JwtUtil.getExpiration(token);
long now = System.currentTimeMillis();
// 2. 计算剩余存活时间 (TTL)
long ttl = expiration.getTime() - now;
// 3. 如果 Token 还没过期,就把它塞进 Redis 黑名单
// Key 建议加上前缀,如 "blacklist:token:"
// Value 随便填,TTL 设为剩余时间(因为它过期后,黑名单也没意义了)
if (ttl > 0) {
redisTemplate.opsForValue().set(
"blacklist:token:" + token,
"1",
ttl,
TimeUnit.MILLISECONDS
);
}
}
}
2. AuthInterceptor.java (网关/拦截器校验)
@Component
publicclass 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)) {
thrownew UnAuthException("Token 无效或已过期");
}
// 第二步:查 Redis 黑名单(这是关键!)
// 如果 Redis 里有这个 Key,说明被管理员踢了
Boolean isBlacklisted = redisTemplate.hasKey("blacklist:token:" + token);
if (Boolean.TRUE.equals(isBlacklisted)) {
thrownew UnAuthException("您的账号已被强制下线,请联系管理员");
}
returntrue;
}
}
优缺点分析:
-
优点: 实时性极高,前脚点封号,后脚请求就报错。
-
缺点: 每次请求都要查 Redis,稍微牺牲了一点“无状态”的性能优势。但在安全面前,这点损耗完全值得。
方案 2:版本号机制(Version)—— 一键全退
如果不希望 Redis 里存太多的黑名单 Key,或者想实现“修改密码后,所有设备自动下线”,这个方案最合适。
原理: 给每个用户发一个“许可证版本号”。

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

硬核实战:代码落地
1. LoginService.java (登录颁发)
public String login(String userId) {
// 1. 从 Redis 获取用户当前的 version
// Key 示例: "user:version:10086"
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 自增
// 这样之前颁发的所有旧版本 Token (v=1) 都会瞬间失效,因为 Redis 里已经是 2 了
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. 比对:如果系统版本变了,或者 Token 里没版本号
if (sysVersionStr != null) {
Integer sysVersion = Integer.parseInt(sysVersionStr);
if (!sysVersion.equals(tokenVersion)) {
thrownew UnAuthException("登录信息已过期(密码可能已修改),请重新登录");
}
}
returntrue;
}
方案 3:双 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分钟)
// 注意:这里完全不跟 Redis 交互,纯计算
String accessToken = JwtUtil.createAccessToken(userId, 15, TimeUnit.MINUTES);
// 2. 生成长效 Refresh Token (7天)
// 这是一个随机字符串,或者也是个 JWT,但关键是它要存 Redis
String refreshToken = UUID.randomUUID().toString();
// 3. 将 Refresh Token 存入 Redis,绑定用户
redisTemplate.opsForValue().set(
"refresh_token:" + userId,
refreshToken,
7, TimeUnit.DAYS
);
// 4. 返回给前端
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
// 此时用户的 Access Token 还能用几分钟,但这是一个允许的 Trade-off
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 里查不到(说明过期了,或者被管理员踢了)
// 或者前端传来的跟 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 必须做高可用(Sentinel 哨兵或 Cluster 集群),不能单点故障。
-
降级策略: 如果 Redis 真的全崩了,我们可以将‘踢人’动作降级为‘数据库标记’,并将关键的高危黑名单(比如涉及资金安全的)同步到应用服务的JVM 本地缓存(Guava Cache)中。
-
业务取舍: 在极端情况下,保证普通用户能登录(可用性),暂时允许被踢用户访问几分钟(牺牲一致性),通常是更优解。但涉及资金转账等核心接口,必须强查数据库状态,不走 JWT 这一套。”
四、 总结与建议
三种 JWT 强制下线方案核心对比
| 维度 | 方案 1:Redis 黑名单 | 方案 2:版本号机制 | 方案 3:双 Token (Access+Refresh) |
|---|---|---|---|
| 核心原理 | 每次请求去 Redis 查 Token 是否在黑名单中 | Token 携带版本,每次请求比对 Redis 中的用户最新版本 | 短效 Access Token 正常访问,踢人时删除 Redis 中的 Refresh Token |
| 踢人生效速度 | 立即生效 (秒级) | 立即生效 (秒级) | 有延迟 (取决于 Access Token 剩余寿命,通常 5-15 分钟) |
| 性能损耗 | 高 (每次请求必查 Redis) | 高 (每次请求必查 Redis) | 极低 (平时不查 Redis,仅刷新时查) |
| 存储成本 | 高 (黑名单越多,占用越大,需设置 TTL) | 极低 (每个用户只存一个 Integer) | 中 (每个用户存一个 Refresh Token) |
| 影响范围 | 精准打击 (只踢当前的设备/Token) | 全端打击 (踢掉该用户手机、电脑等所有设备) | 精准打击 (只踢掉对应的 Refresh Token) |
| 核心痛点 | 违背了 JWT 无状态初衷,Redis 压力大 | 无法单独踢掉某个设备,一改密全掉线 | 踢人有时间差,极端安全场景不适用 |
| 推荐指数 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 最佳适用场景 | 资金/政务系统,对安全性要求极高,零容忍 | 修改密码、注销账号等需要“一键全退”的场景 | 90% 的互联网 App,追求高并发性能 |
世界上没有绝对的“无状态”。JWT 的无状态是为了换取性能,而在需要“控制权”的时刻(踢人、注销、改密),我们必须引入一点“状态”。
Fox 给兄弟们的选型建议:
-
想最省心、性能最好: 选 方案 3(双 Token)。这是大厂标准,90% 的场景都适用。
-
对安全性有洁癖(即刻生效): 选 方案 1(黑名单)。一个请求都别想溜过去,哪怕牺牲一点 Redis 性能。
-
想最省内存(全端下线): 选 方案 2(版本号)。存一个 Integer 搞定所有设备。
别再让你的系统“裸奔”了,给你的 JWT 装上一个“遥控自毁装置”吧!
更多推荐



所有评论(0)