首页 / 资讯动态 / JWT黑名单Redis存储

JWT黑名单Redis存储

JWT本身是无状态的,签发后只要没过期就能用,这是它最大的优点,但也是权限控制里最头疼的缺陷。一旦发现某个用户被盗号、被管理员封禁,或者需要强制所有人重新登录,你会发现单纯靠JWT的过期时间根本做不到实时撤销。于是黑名单机制应运而生,而Redis因为其内存读写速度和数据结构特性,几乎成了实现JWT黑名单的首选存储。

为什么不用数据库而用Redis

有人可能会想,在MySQL或者PostgreSQL里建一张黑名单表,把要拉黑的JWT存进去不就行了。技术上当然可行,但问题出在性能上。每次请求都要查一次黑名单,网关或拦截器里多出一次数据库查询,高并发场景下数据库连接池很快就会被耗尽。而且JWT黑名单有个特点,数据是临时的,token过期后这条记录就没用了,需要定时清理。关系型数据库做定时清理要么写定时任务,要么依赖运维人员手动维护,运维成本高。

Redis把这两件事都解决了。内存读写单次操作微秒级,单机几万QPS毫无压力。更关键的是Redis支持键过期时间,你可以直接把JWT的剩余有效期设置为键的TTL,时间一到自动删除,连清理逻辑都不用写。数据结构上,String类型存黑名单标记足够简单,Set或Hash类型可以做更灵活的批量管理,后面会详细讲。

黑名单存储的三种数据结构选型

第一种是String结构,最直接的做法。键名用被拉黑的JWT或者其唯一标识,值存一个标记比如"1"或者封禁原因,然后用EXPIRE设置过期时间。这种方案查找速度最快,时间复杂度O(1),适合绝大多数场景。代码大概长这样:

// 将token加入黑名单,过期时间设置为token剩余有效时间
redis.setex("blacklist:" + tokenId, remainingSeconds, "revoked");
// 检查token是否在黑名单中
boolean isBlacklisted = redis.exists("blacklist:" + tokenId);

第二种是Set结构。如果需要对某个用户的所有token做批量拉黑,比如管理员封禁账号时需要把该用户所有已签发的JWT全部失效,Set就能派上用场。键名用用户ID,成员存该用户所有已签发的token标识。封禁时直接删除整个Set,或者把Set内的token逐个标记。但Set没有成员级别的TTL,需要额外维护一个定时清理机制,复杂度上去了。

第三种是Hash结构。当黑名单记录需要携带更多信息时,比如封禁时间、封禁原因、操作人,Hash可以把这些字段存在一个键下。但同样面临字段级别TTL的问题,Redis不支持Hash字段独立过期,只能整个键过期。所以除非有明确的附加信息需求,否则String方案最省心。

JWT唯一标识怎么取

黑名单里存完整JWT是不明智的,一个JWT动辄几百字节,高并发下Redis内存和网络带宽都吃不消。正确做法是只存JWT的唯一标识。JWT规范里有一个jti声明,专门用来放唯一ID,签发时用UUID生成即可。如果没有jti,可以把payload和签发时间戳组合起来做一次哈希,取前16位或32位作为标识。关键是要保证同一用户不同时间签发的token标识不同,否则拉黑一个会把所有token都干掉。

还有一个细节,不要把用户ID当唯一标识。一个用户可能同时在手机、电脑、平板上登录,每个设备一个JWT,如果只按用户ID拉黑,那封禁一个设备会把所有设备都踢下线。除非业务上就是要全端踢出,否则jti级别的粒度才是合理的。

校验流程怎么嵌入

在网关层或全局拦截器里,JWT验证通过后、业务逻辑执行前,加一段黑名单检查逻辑。顺序很重要,必须先验证签名和过期时间,确认token本身合法了再去查黑名单,避免无效请求也打一次Redis。流程大致是:解析JWT拿到jti,用jti拼装Redis键,执行exists判断。如果存在则直接返回401或403,同时在响应里给一个特定的错误码,让前端知道是token被吊销而不是过期,前端据此决定是跳转登录页还是给出提示。

这里有一个容易踩的坑,Redis查询和业务逻辑之间不是原子操作。极端情况下,检查黑名单通过后、业务处理完成前,管理员恰好把该token拉黑,这次请求还是会正常执行。对于大多数业务这个时间窗口可以接受,如果场景敏感,可以用Redis事务或Lua脚本把检查和业务标记原子化,但性能会受影响,需要权衡。

内存占用和过期策略

一个jti按UUID字符串算大约36字节,加上Redis键前缀和内部开销,一条记录大概50到80字节。假设每天有10万个token需要拉黑,同时存在的黑名单记录峰值可能也就几十万条,内存占用几十MB,对Redis来说微不足道。真正需要关注的是过期时间设置,必须严格对齐JWT本身的剩余有效期。如果黑名单记录比JWT本身活得还久,不仅浪费内存,还可能误伤新签发的token,虽然jti是唯一的但键名冲突风险依然存在。

设置过期时间时,建议用JWT的exp减去当前时间,而不是直接用一个固定值。代码实现时在签发JWT阶段可以把jti和exp对应关系也存一份,拉黑时查出来计算剩余秒数。或者直接在黑名单写入时解析JWT的payload取exp,省去额外存储。两种方式都可以,看架构习惯。

分布式环境下的同步问题

如果系统是单Redis实例,黑名单天然一致,没有同步问题。但如果是Redis集群或主从架构,主从复制存在毫秒级延迟。一个token刚被写入主节点黑名单,从节点还没同步,此时读从节点的请求就会漏过。解决方案有两个,一是黑名单写入后对关键操作强制读主节点,二是接受这个极短的时间窗口,毕竟JWT黑名单本身就不是为了绝对实时设计的,几十毫秒的延迟在业务上通常可接受。

多机房部署的情况更复杂。如果Redis跨机房,网络延迟可能到几十甚至上百毫秒,每次请求都跨机房查黑名单不现实。这时可以考虑在本地机房部署Redis实例做全量同步,或者用消息队列广播黑名单变更事件,各机房自行维护本地缓存。但引入缓存就会带来一致性问题,需要根据业务对实时性的要求做取舍。

黑名单与白名单的配合

有些场景下单纯靠黑名单不够。比如系统要求只有管理员在后台主动踢出用户时才拉黑token,但token本身有效期设得很长。一旦Redis宕机或数据被误清,所有黑名单记录丢失,被拉黑的token又能用了。这时可以引入白名单思路做双重保险:签发JWT时把jti写入一个白名单Set,校验时先查白名单是否存在,不存在直接拒绝。拉黑操作就是从白名单中移除对应jti。这样即使Redis数据丢失,恢复后只要白名单还在,安全性不受影响。代价是每次请求多一次Redis查询,且白名单数据量等于所有有效token数量,内存占用会大不少。

还有一种折中方案是黑名单为主、白名单为辅。正常情况只查黑名单,Redis故障时降级为只验证签名和过期时间,同时在监控上报警。这种降级策略需要提前设计好,避免安全漏洞被利用。

实际代码落地参考

下面给一个Spring Boot拦截器里的完整校验逻辑,用String结构,Redis模板用Spring Data Redis:

public class JwtBlacklistInterceptor implements HandlerInterceptor {
    
    private final StringRedisTemplate redisTemplate;
    private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = extractToken(request);
        if (token == null) {
            response.setStatus(401);
            return false;
        }
        
        // 验证JWT合法性
        Claims claims = JwtUtil.parseToken(token);
        if (claims == null) {
            response.setStatus(401);
            return false;
        }
        
        // 检查黑名单
        String jti = claims.getId();
        if (jti != null && Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + jti))) {
            response.setStatus(401);
            response.setHeader("X-Token-Status", "revoked");
            return false;
        }
        
        // 将用户信息写入请求上下文
        request.setAttribute("userId", claims.getSubject());
        return true;
    }
    
    public void revokeToken(String jti, long remainingSeconds) {
        redisTemplate.opsForValue().set(BLACKLIST_PREFIX + jti, "1", Duration.ofSeconds(remainingSeconds));
    }
}

这个实现里,revokeToken方法在管理员执行封禁操作时调用,remainingSeconds从JWT的exp字段计算得出。拦截器里先验证JWT合法性再查黑名单,响应头里加了X-Token-Status方便前端区分过期和吊销。

监控和运维要点

黑名单机制上线后,需要关注几个指标。Redis的内存使用率,虽然单条记录小但积少成多,如果发现内存异常增长,大概率是过期时间设置错误导致记录没有自动清理。黑名单命中率,这个指标能反映系统被攻击的频率和管理员操作频率,突然飙升可能意味着有安全事件。Redis的响应时间,如果黑名单查询开始变慢,检查是否存在大键或者网络抖动。

另外建议给黑名单操作加上审计日志,谁在什么时间拉黑了哪个token,原因是什么。这在安全溯源和合规审计时非常有用。日志可以异步写入,不影响主流程性能。

常见误区总结

一是把JWT本身完整存进Redis,浪费内存还拖慢速度。二是用用户ID代替jti做黑名单标识,导致粒度太粗。三是忘记设置过期时间,黑名单只增不减最终撑爆内存。四是黑名单检查和业务逻辑之间没有考虑原子性,敏感业务出现时间窗口漏洞。五是在高并发场景下用数据库做黑名单存储,导致性能瓶颈。六是Redis主从延迟导致黑名单短暂失效,没有做读写策略区分。这些坑在实际项目中都很常见,提前规避能省去很多线上排查时间。

JWT黑名单用Redis存储,本质上是给无状态令牌加了一层有状态的撤销能力。技术方案本身不复杂,但细节决定成败。数据结构选String就够了,jti粒度要精确,过期时间要对齐,分布式环境要考虑一致性,监控和审计要跟上。把这些点做到位,就能在保留JWT无状态优势的同时,获得灵活的权限管控能力。