导读:掘金后端热榜上有一篇"面试中被嘲笑 Token 放在 Redis 里",知乎上同名问题和"jwt 与 token+redis 哪种方案更好用"各有 165 万浏览。争论大多停在观点上,这篇用 JDK 17 把两种方案各跑一遍:每个请求多花多少时间、退出登录后旧 token 还能不能用、改密码怎么把所有设备踢下线、Redis 存 10 万个会话占多少内存。结论先说:把 Token 放 Redis 没什么好笑的,只要你需要"立刻作废",JWT 最后也得查 Redis。
一、先说清楚:两种方案分别是什么
Token + Redis:登录成功后生成一个随机字符串(比如 32 位 UUID)发给前端,服务端在 Redis 里存 login:<token> → 用户信息,设 2 小时过期。之后每个请求带上这个字符串,服务端拿它去 Redis 查人。可以把它理解成存包处的号码牌:牌子本身没有信息,东西在柜台里。
JWT:登录成功后,把用户 ID、角色、过期时间这些内容直接写进 token,用密钥签名后发给前端。服务端收到请求只验签名、看过期时间,不查任何存储。相当于一张盖了章的通行证,门卫看章就放行,不用回去翻名册。
两种方案的差别落在两件事上:
- Redis 方案每个请求多一次网络往返,Redis 挂了,所有人都登录不了
- JWT 发出去就收不回来:用户点了退出、改了密码、被管理员封号,只要 token 没过期,拿着它照样能调接口
什么时候需要认真对待这个选择:新项目定登录方案的时候;产品提出"改密码后其他设备自动下线""后台可以踢人"这类需求的时候;面试被问到的时候。已经跑得好好的老项目,没有上面这些需求就不用动。
二、面试官那句"Token 放 Redis 不就是 Session 吗"
这句话说对了一半。Token + Redis 确实是把 Session 从 Tomcat 内存搬到了 Redis,本质是有状态的服务端会话。但"有状态"不等于落后,它换来的是服务端对登录态的完全控制:想让谁下线,删一个 key 就行。
JWT 的卖点是无状态,服务端不存东西,水平扩容不用管会话共享。代价是放弃了控制权。很多项目用了 JWT 之后,产品一提"退出登录要立刻失效",又在 Redis 里加了一张黑名单,每个请求查一次。到这一步,"无状态"已经没了,还多背了一个比 UUID 大几倍的 token。
所以比较两种方案,比的是你的业务需不需要服务端随时作废登录态,以及每个请求多一次 Redis 查询你在不在乎。后者下面用数字说话。
三、实测环境
| 项 | 版本 |
|---|---|
| JDK | 17.0.12 |
| jjwt | 0.12.5(jjwt-api / impl / jackson) |
| Jedis | 5.2.0 |
| Redis | 7.2.6,本机单独起一个实例(端口 6399,不开持久化) |
| 机器 | Apple M1 Pro,客户端和 Redis 在同一台机器 |
⚠️ Redis 和程序在同一台机器上,走的是本机回环网卡。生产环境 Redis 一般在另一台机器上,多一跳真实网络,耗时只会比下面的数字更高。我没有在跨机器的环境里测,下文不给跨机器的数字。
测试用的 token 内容:用户 ID、用户名、两个角色、租户、签发时间、过期时间、jti(token 唯一 ID)。Redis 方案存的会话 JSON 也是这几项。
四、实测 1:每个请求多花多少时间
我在本机用 JDK 17 把验签和查 Redis 各跑了 20 万次(RS256 跑 2 万次),先预热再计时,跑 3 轮取第三轮:
| 方式 | 单线程每次耗时 | 说明 |
|---|---|---|
| JWT HS256 验签 + 解析 | 6.6 微秒 | 对称密钥,含 Jackson 解析 JSON |
| JWT RS256 验签 + 解析 | 46.6 微秒 | 非对称,公钥验签 |
| Redis GET 查会话 | 平均 82.6 微秒,p99 139.7 微秒 | 本机回环,单连接 |
16 个线程并发各跑 5 万次:HS256 验签每秒约 47.6 万次,Redis GET 每秒约 5.5 万次。Redis 这个数受笔记本上单实例的限制,只能说明它比本地验签慢一个数量级,不代表生产 Redis 的上限。
怎么看这组数字:
- 哪怕是最慢的 Redis 方案,单次也不到 0.1 毫秒(本机)。一个普通业务接口查一次 MySQL 就是毫秒级,登录校验这点开销基本看不出来
- RS256 比 HS256 慢 7 倍左右,但它的用处是"签发方和验证方不共享密钥",比如认证中心签、几十个服务各自验。单体或者一个团队内部的服务,用 HS256 就够了
- JWT 真正省的是 Redis 的压力和"Redis 挂了全站掉登录"这个风险,不太是耗时
测试代码的核心就两段,验签:
// HS256 密钥至少 32 字节,jjwt 0.12 的写法
SecretKey hs = Keys.hmacShaKeyFor("0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
JwtParser hsParser = Jwts.parser().verifyWith(hs).build();
String userId = hsParser.parseSignedClaims(hsJwt).getPayload().getSubject();
查 Redis 就是一句 jedis.get("login:" + token)。
五、实测 2:退出登录后,旧 JWT 还能用吗
能用。这是 JWT 被吐槽最多的地方,跑一下就清楚了。下面的 pureJwt 只验签名和过期时间,withBlacklist 在验签之后再查一次 Redis 黑名单:
// 纯 JWT:只验签和过期时间
static boolean pureJwt(String token) {
try { PARSER.parseSignedClaims(token); return true; } catch (JwtException e) { return false; }
}
// JWT + 黑名单:退出登录时把 jti 写进 Redis,TTL = token 剩余有效期
static boolean withBlacklist(Jedis j, String token) {
try {
Claims c = PARSER.parseSignedClaims(token).getPayload();
return !j.exists("jwt:black:" + c.getId());
} catch (JwtException e) { return false; }
}
static void logout(Jedis j, String token) {
Claims c = PARSER.parseSignedClaims(token).getPayload();
long ttlSec = Math.max(1, (c.getExpiration().getTime() - System.currentTimeMillis()) / 1000);
j.setex("jwt:black:" + c.getId(), ttlSec, "1");
}
签发一个 2 小时有效的 token,调 logout 前后各校验一次,输出:
before logout: pure=true blacklist=true
after logout : pure=true blacklist=false blackTTL=7199
纯 JWT 在退出之后依然返回 true,前端删了 token 没用,只要有人抓包拿到它,2 小时内都能调接口。加了黑名单才真正失效。
黑名单有两个细节:
- key 的过期时间设成 token 的剩余有效期。token 本来就会过期,过期之后黑名单留着也没意义,Redis 自己清掉
- 黑名单要用 jti,不要存整个 token。jti 是签发时生成的 UUID,36 个字符;整个 token 两三百字节,存起来白白占内存
但请注意,加了黑名单以后,每个请求又要查一次 Redis 了,耗时和第四节的 Redis 方案是同一个量级(EXISTS 和 GET 都是一次往返)。
六、实测 3:改密码后,把这个用户所有设备踢下线
黑名单只能作废手上这一个 token。用户在电脑、手机、平板都登录了,改密码时服务端并不知道另外几个 token 的 jti。常见做法是给每个用户存一个版本号,签发 token 时把版本号写进去,校验时比对:
// JWT + 用户版本号:改密码 / 踢下线时 INCR,所有旧 token 一起失效
static boolean withVersion(Jedis j, String token) {
try {
Claims c = PARSER.parseSignedClaims(token).getPayload();
String cur = j.get("user:ver:" + c.getSubject());
long v = cur == null ? 0 : Long.parseLong(cur);
return c.get("ver", Long.class) == v;
} catch (JwtException e) { return false; }
}
签发时多带一个 claim:
static String issue(long userId, long ver, long ttlMs) {
Date now = new Date();
return Jwts.builder().subject(String.valueOf(userId)).id(UUID.randomUUID().toString())
.claim("ver", ver).issuedAt(now).expiration(new Date(now.getTime() + ttlMs))
.signWith(KEY).compact();
}
给同一个用户签两个 token(模拟电脑和手机),然后 INCR user:ver:10086 模拟改密码:
before pwd change: pc=true phone=true
after pwd change : pc=false phone=false newToken=true
两个旧 token 同时失效,按新版本号签的 token 正常。这个方案每个用户只占一个 key,比黑名单省得多。
Token + Redis 方案做同样的事更直接:登录时顺便维护一个 user:tokens:<userId> 的 Set,改密码时把 Set 里的 token 全删掉。两种方案都要查 Redis,区别只在 key 怎么设计。
七、实测 4:Redis 存 10 万个会话占多少内存
担心 Redis 方案吃内存的,可以看这组数。我用 pipeline 写入 10 万个 login:<32位token>,值是 75 字节的会话 JSON,带 2 小时 TTL,前后对比 INFO memory 里的 used_memory:
| 写入内容 | 10 万个增加 | 每个约 |
|---|---|---|
会话:login:<token> → 75 字节 JSON | 21.8 MB | 229 字节 |
黑名单:jwt:black:<jti> → "1" | 14.2 MB | 149 字节 |
按这个比例,100 万在线用户的会话大约 220 MB。值里塞的东西越多(权限列表、菜单),占得越多,但对一台正常配置的 Redis 来说,几百万在线也不是问题。真到了量级很大的时候,会话 JSON 里只放用户 ID 和必要字段,权限这些按需另查。
八、token 本身有多大
| token | 长度 |
|---|---|
| 随机 UUID(去掉横线) | 32 字节 |
| JWT HS256(上面 7 个字段) | 269 字节 |
| JWT RS256(同样内容) | 568 字节 |
每个请求的 Header 里都要带上它。RS256 的 token 是 UUID 的十几倍,字段再多一点,一两 KB 很常见。一般接口无所谓;如果 token 放 Cookie 里,或者经过的网关对请求头有长度限制,就要留意。
还有一点:JWT 的内容只是 Base64 编码,任何人拿到都能解出来看。手机号、身份证这类信息不要写进 claim。
九、到底怎么选
| 你的情况 | 建议 |
|---|---|
| 单体或一个团队的几个服务,需要退出即失效、后台踢人 | Token + Redis,最省心 |
| 已经用了 JWT,产品要求退出立即失效 | 加 jti 黑名单,TTL 等于剩余有效期 |
| 已经用了 JWT,要求改密码后所有设备下线 | 加用户版本号 user:ver:<id> |
| 认证中心签发、很多下游服务各自校验、不想让下游都连 Redis | JWT(RS256),有效期设短,比如 15 分钟,配合刷新 token |
| 服务间调用、一次性的下载链接、邮件里的确认链接 | JWT,天然不需要撤销 |
我的建议是:做业务系统的登录,默认用 Token + Redis;只有在"验证方很多、不想都依赖同一个 Redis"的场景才上 JWT。用了 JWT 又加黑名单、又加版本号的项目,往往不如一开始就用 Redis 简单。
面试被问到"为什么把 Token 放 Redis",可以直接答:因为业务需要服务端能随时让登录失效,JWT 做这件事最后也要查 Redis;每个请求多一次 Redis 查询,本机实测不到 0.1 毫秒,和业务 SQL 比可以忽略。
十、顺手说一下续期
Token + Redis 方案做"用户一直在用就不掉线"(滑动过期)很简单,Redis 6.2 起有 GETEX,查会话的同时把过期时间重置,一次往返:
# redis-cli 里验证:先设 60 秒,GETEX 之后变回 7200 秒
redis-cli -p 6399 setex login:abc 60 v
redis-cli -p 6399 getex login:abc EX 7200
redis-cli -p 6399 ttl login:abc
最后一句返回 7200。Jedis 里对应 jedis.getEx(key, GetExParams.getExParams().ex(7200))。
JWT 改不了已经签出去的过期时间,续期只能重新签:常见做法是 access token 设短有效期,另发一个长有效期的 refresh token,access token 过期后用 refresh token 换新的。这套流程我这次没写代码实测,这里只说思路;refresh token 本身通常也要存服务端才能撤销,又回到了 Redis。
速查表
| 需求 | Token + Redis | JWT |
|---|---|---|
| 每次请求校验 | GET login:<token>(本机约 83 微秒) | 验签(HS256 约 6.6 微秒) |
| 退出登录立即失效 | DEL login:<token> | jti 黑名单,TTL = 剩余有效期 |
| 改密码踢掉所有设备 | 删 user:tokens:<id> 里的全部 token | INCR user:ver:<id>,token 带 ver |
| 滑动过期 | GETEX key EX 7200 | 刷新 token |
| Redis 挂了 | 所有人无法登录 | 纯 JWT 不受影响;加了黑名单的同样受影响 |
| token 大小 | 32 字节 | 269 字节起(HS256) |
常见问题
Q:JWT 黑名单查 Redis,和 Token + Redis 有什么区别?
耗时一样,都是一次往返。区别是黑名单里只存退出了的、还没过期的 token,量通常比全部在线会话小很多;Redis 丢了数据,黑名单方案是"本该失效的又能用了",Token + Redis 方案是"所有人掉线"。前者更隐蔽,后者更容易被发现。
Q:HS256 的密钥写多长?
jjwt 0.12 要求 HS256 的密钥至少 256 位,也就是 32 字节,短了 Keys.hmacShaKeyFor 直接抛 WeakKeyException。密钥放配置中心,不要写死在代码里,上面示例里的字符串只是演示。
Q:Redis 方案要不要本地缓存一层?
可以,但缓存了就意味着踢人有延迟,延迟等于本地缓存的过期时间。大多数项目没到需要这一层的量,第四节的数字说明单次查询很便宜。
Q:Spring Security 里怎么接?
两种方案都是在过滤器里从 Header 取 token、校验通过后往 SecurityContextHolder 里放认证信息,区别只在"校验"那一步调哪段代码。这次没有在 Spring Boot 项目里实测,具体写法以你项目的 Spring Security 版本为准。
参考链接
- RFC 7519 JSON Web Token:www.rfc-editor.org/rfc/rfc7519
- jjwt 官方仓库(0.12 API):github.com/jwtk/jjwt
- Redis GETEX 命令:redis.io/docs/latest…
- OWASP JSON Web Token Cheat Sheet:cheatsheetseries.owasp.org/cheatsheets…
你们项目现在登录用的是 Token + Redis 还是 JWT?如果是 JWT,有没有加黑名单?评论区说说你的方案和踩过的坑,我挑典型的补进 FAQ。
觉得有用的话点个赞、收个藏,下次面试被问到时翻得到。