一句话导读:给"登录态是否有效"加一层缓存,是几乎所有人都会想到的优化——每次请求少查一次库,吞吐立刻上去。但 TTL 这个参数决定的从来不是性能,而是"一个已经被撤销的令牌还能活多久"。本文从一条真实红线(登出必须立即失效)出发,讲清撤销延迟是怎么产生的、主动失效的三种做法、什么时候才真的需要缓存,以及如何用一次估算代替拍脑袋。
一个看起来很美的优化
上一篇《JWT 登录态可撤销设计与会话表方案》落地之后,每个请求都要按 jti 查一次会话表。这是那套方案唯一明显的成本,于是"加个缓存"几乎必然会被提出来:把"这个令牌还有效吗"的答案在本机存 30 秒,热点令牌就不必每次都查库。
代码写出来大概是这样。
/** 看起来很美:30 秒内不再查库 */
private final Map<String, CachedActive> cache = new ConcurrentHashMap<>();
public boolean isActive(String jti) {
CachedActive cached = cache.get(jti);
if (cached != null && !cached.expired()) {
return cached.active();
}
boolean active = mapper.countActiveByTokenId(jti) > 0;
cache.put(jti, new CachedActive(active, Instant.now().plusSeconds(30)));
return active;
}
30 秒 TTL,单次查库被摊掉绝大部分,逻辑也没变复杂。上线之后吞吐上去了,数据库的读压力也降了。看起来是一次干净利落的优化。
然后某天,一个用户点了"退出登录",关掉页面,又顺手点了浏览器后退。
10:00:00 用户点退出,服务端把该会话的 revoked_at 写上
10:00:01 用户带着旧令牌请求接口
10:00:01 过滤器查缓存 -> 命中 10:00:00 之前写下的"有效"
10:00:01 请求被放行 <- 会话已被撤销,令牌却还能用
10:00:30 缓存过期,才真正失效
问题不在那段代码的实现质量,而在这个参数本身:只要 TTL 大于 0,撤销就必然有延迟。这不是可以靠写得更仔细来规避的 bug,是"缓存 + 没有主动失效"这个组合的数学结果。
TTL 决定的是陈旧度
缓存做的事只有一件:把一次代价较高的计算结果存成副本,下次直接取副本。TTL 是这份副本的保质期,过了这个时间副本作废,重新算一次。
这两个词常常被当成性能参数来讨论——"TTL 设小一点性能不够,设大一点数据会脏"。但因果关系是反的。TTL 决定的是副本能有多旧,性能提升是它的副产品。
把它放进"登录态"这个场景,含义就变得很具体:TTL 决定的是"一个已经被撤销的令牌,最多还能活多久"。
这里有个容易混淆的地方。系统里同时存在两个"有效期",性质完全不同。
| 谁的有效期 | 由谁决定 | 到点后发生什么 | |
|---|---|---|---|
| 令牌自身的有效期 | JWT 载荷里的 exp |
签发时的策略(本项目为 2 小时) | 令牌自然死亡 |
| 缓存 TTL | 副本的保质期 | 缓存的实现参数 | 副本被丢弃,重新读一次权威数据 |
前者是"这个东西天生能活多久",后者是"我们对它当前状态的记忆能有多旧"。前者到期属于正常业务,后者造成的偏差是事故。把两者混为一谈,就会出现"令牌 2 小时才过期,缓存 30 秒有什么关系"这种推理——关系恰恰在于,那 30 秒里被放行的是已经被主动撤销的令牌,而撤销的意义就在于不等它自然过期。
撤销延迟是怎么产生的
把那次事故拆成时序,会看到问题出在读路径,不在写路径。
sequenceDiagram
participant U as 用户
participant S as 服务
participant D as 会话表(权威数据)
participant C as 本地缓存(副本)
U->>S: 退出登录
S->>D: UPDATE revoked_at = now()
D-->>S: 已撤销
Note over C: 缓存没有任何变化
U->>S: 带旧令牌请求(1 秒后)
S->>C: 这个 jti 还有效吗
C-->>S: 有效(撤销前写下的旧答案)
S-->>U: 放行
权威数据在第一步就已经是对的,错的是那份副本。写路径更新了真相,读路径读到的却是一个旧答案。
所以"撤销延迟"的时长不是大约等于 TTL,而是精确等于最坏情况下的 TTL:用户恰好在写库前一刻命中了缓存,那份"有效"会一直保留到它过期。要缩短这个窗口,只有两条路——把 TTL 压到很小,或者让撤销这件事主动去通知缓存。
三种做法的取舍
| 做法 | 撤销延迟 | 缓存命中时是否还要访问共享存储 | 适用前提 |
|---|---|---|---|
| 不缓存 | 0 | — | 读放大尚未成为瓶颈 |
| 本地短 TTL,无主动失效 | 最长一个 TTL | 不需要 | 能容忍秒级陈旧 |
| 共享存储 + 主动失效 | 接近 0 | 需要(一次 Redis 读) | 已具备共享存储 |
第一行是最容易被忽略的选项。很多人一提"性能优化"就默认要加缓存,但实际上"不加"也是一个正式选项,而且当读放大还不构成瓶颈时,它是最优的那个。
第二行的代价被低估得最厉害。它省掉的是最便宜的那一段(一次等值索引查找),付出的却是撤销延迟。而且它有一个隐藏成本:多实例部署时,每台机器各存一份,行为不一致——同一个令牌在 A 机器上已被判定为失效,在 B 机器上还能再用几秒。
第三行才是"缓存"这个词的完整形态。它的关键不在存储介质,而在于把"撤销"和"过期"这两件事解耦:TTL 只负责自然刷新,撤销由主动失效负责。
主动失效的三种实现
主动失效的目标是让撤销不依赖 TTL。做法按"失效的粒度"分三类。
删键
写路径发现某个令牌被撤销时,顺手把它对应的缓存键删掉。
public void logout(String jti) {
mapper.revokeByTokenId(jti, "LOGOUT");
cache.invalidate(jti); // 删掉本机副本
}
逻辑最直白,适合单实例部署,或者缓存本身就在共享存储上。它最大的问题在多实例:invalidate 只能删掉执行这句代码的那台机器上的副本,其它机器上的副本照旧。这时候"主动失效"是假的,撤销延迟又退回到 TTL。
要修这一点,要么让缓存本身放在共享存储上(删一次全局生效),要么引入广播(发布失效消息,各实例各自删本机键)。广播引入了消息中间件和"消息丢了怎么办"的问题,成本不低。
版本号
给账号挂一个单调递增的会话版本,撤销时版本加一,读路径比对签发时的版本。
/** 签发时把当时的账号版本写进令牌(或随会话一起存) */
public IssuedToken issue(String accountType, long accountId) {
long epoch = sessionEpoch.current(accountId); // Redis 里一个计数器
// ... 把 epoch 放进令牌载荷
}
/** 校验:命中缓存后,仍要比一次版本 */
public boolean isActive(String jti, long accountId, long epochAtIssue) {
if (sessionEpoch.current(accountId) != epochAtIssue) {
return false; // 账号下所有会话已被整体作废
}
// ... 再走 jti 级别的判定
}
版本号适合"一个账号整体失效"的场景:修改密码、停用账号、新设备登录顶掉旧会话。这三种业务本来就要求把该账号的所有会话一次性作废,版本加一就完成了,不需要枚举每一个 jti。
代价是每次校验多一次版本读取。这个读取放在 Redis 里是 O(1) 的内存操作,比查库便宜一个量级——但它仍然是一次网络往返,不能算"零成本"。这也是为什么"共享存储 + 主动失效"这一行在表格里写的是"需要一次 Redis 读"。
撤销名单
只记录被作废的 jti,读路径先查名单,命中即拒绝。
/** 撤销时写入名单,带一个略长于令牌有效期的过期时间 */
public void revoke(String jti, Duration ttl) {
redis.opsForSet().add("revoked:jti", jti);
redis.expire("revoked:jti", ttl);
}
它和版本号的分工很清楚:撤销名单处理"只废一个"(用户主动退出),版本号处理"废掉一片"(改密码、封号)。名单的过期时间可以设成令牌有效期,因为令牌自然过期之后,名单里那一项就没有意义了。
名单的缺点是它会一直增长——每一个登出都留一条记录。设好过期时间可以控制规模,但如果登出量极大,这个集合本身会成为热点。
一次估算,代替拍脑袋
要不要缓存,取决于读放大是否真的成了瓶颈。这件事可以用一个估算回答,不需要靠感觉,也不需要压测。
估算的依据是利特尔法则的一个变体:一个连接池能支撑的吞吐,约等于可用连接数除以单次占用连接的时长。
吞吐 ≈ 可用连接数 ÷ 单次操作耗时
代入本项目的真实配置。会话校验的 SQL 是按 token_id 做等值查找,而 token_id 上有唯一索引,所以这是一次索引命中,本身在亚毫秒级;再加上一次内网 HTTP 往返,整体按 1 毫秒量级估。
10 条连接 ÷ 0.001 秒 ≈ 10000 次/秒
再看需求侧。按 150 个并发在线用户、管理后台的典型交互节奏(每人约 0.2 到 0.5 个请求每秒)估算,前端请求量是 30 到 75 次每秒。而本项目里,每个前端请求会产生两次会话校验——BFF 门户校验一次,转发时带着令牌,下游服务再校验一次。
| 项 | 数值 |
|---|---|
| Hikari 连接池上限 | 10 |
| 单次会话校验耗时 | 约 1 毫秒(含内网往返) |
| 单实例理论吞吐 | 约 10000 次/秒 |
| 150 并发下的稳态校验量 | 60 到 150 次/秒 |
| 150 并发下的峰值校验量 | 约 900 次/秒 |
| 峰值时的容量占用 | 不到 10% |
余量在十倍以上。在这个量级上谈缓存,省下的是那 1 毫秒里最便宜的一段,付出的是撤销延迟——这笔交易不划算。
值得注意的是那个"每个请求两次校验"的放大系数。它比 TTL 更值得先处理:这是纯粹的结构性浪费,砍掉它不产生任何正确性代价。详见下一节。
什么时候该重新评估这个结论?给出一个可观测的触发条件,而不是靠感觉:当单实例的会话校验量稳定超过 3000 到 5000 次每秒,或者这一跳的 P99 明显拖慢了整体接口响应。到那时再考虑上共享存储,TTL 也可以给得宽松些。
落地:本项目的现状与三种改法
现状:不缓存,实时裁决
本项目的选择是 TTL 等于 0,也就是不缓存。每次请求实时向会话表的归属方要一个答案。
校验入口在过滤器里,验签通过之后必须再查一次账。
String token = header.substring(BEARER_PREFIX.length()).trim();
Claims claims = jwtUtil.parse(token);
// 登录态可撤销:会话被登出 / 被顶下线 / 被强制下线后,令牌立即失效
TokenSessionChecker checker = sessionCheckerProvider.getIfAvailable();
if (checker != null && !checker.isActive(claims.getId())) {
writeUnauthorized(response, ErrorCode.NO_LOGIN);
return;
}
这里的 TokenSessionChecker 是一个 SPI 接口,刻意只声明一个方法。
public interface TokenSessionChecker {
/**
* 令牌对应会话是否仍然有效(未被撤销且未过期)。
*
* @param tokenId 令牌唯一标识 jti
*/
boolean isActive(String tokenId);
}
实现方由持有会话表的服务提供。在本项目里,这个服务是 auth-service,其它服务通过一次内部 HTTP 调用向它要答案。
/**
* 内部会话校验:按 jti 判定令牌会话是否仍有效。
*
* <p>会话表的归属方是 auth-service,本服务不持有会话数据,故登出 / 被顶下线后
* 旧令牌在本服务侧的立即失效依赖本调用。服务不可用时抛异常(失败即拒绝),不放行。</p>
*/
public boolean isSessionActive(String jti) {
Result<Boolean> result = call(() -> restClient.get()
.uri(uriBuilder -> uriBuilder.path(PATH_SESSION_ACTIVE)
.queryParam("jti", jti)
.build())
.retrieve()
.body(BOOL_TYPE));
return Boolean.TRUE.equals(data(result));
}
裁决落在 auth-service 一侧,执行的仍然是最朴素的一条 SQL。
<select id="countActiveByTokenId" resultType="int">
SELECT count(*)
FROM tb_login_session
WHERE token_id = #{tokenId}
AND revoked_at IS NULL
AND expires_at > now()
</select>
配套的索引是这条语句能便宜的前提。
-- 令牌标识唯一,校验时精确定位到一行
CREATE UNIQUE INDEX uk_login_session_token_id
ON tb_login_session (token_id);
把裁决收口在 auth-service,而不是让每个服务各自读会话表,换来的是"会话语义只有一个实现"。会话表被谁改、revoked_at 怎么算、过期怎么判定,只有一处代码需要维护。代价就是那一跳 HTTP——也就是上面估算里被证明完全可以承受的那一跳。
顺带说明一点:这个内部校验接口本身在认证服务的白名单里。原因是它要校验的令牌可能已经被撤销,如果要求调用方先出示一个有效令牌,就形成了自锁。它的安全边界不靠令牌,而靠"服务不对外暴露、只允许内网和反向代理访问"这条部署红线。
改法一:本地短 TTL 缓存
如果一定要在应用侧加缓存,最保守的形态是极短 TTL,例如 1 到 3 秒。
private static final Duration TTL = Duration.ofSeconds(2);
public boolean isActive(String jti) {
CachedActive cached = cache.get(jti);
if (cached != null && !cached.expired()) {
return cached.active();
}
boolean active = delegate.isActive(jti);
cache.put(jti, new CachedActive(active, Instant.now().plus(TTL)));
return active;
}
2 秒是"人眼无感"的量级,用户点完退出再看到自己被放行一次,几乎不可能察觉。它能把同一用户的连续请求在 2 秒内折叠成一次校验,命中率取决于交互密度。
但它有两个绕不开的问题。一是多实例下各存一份,撤销延迟会变成"最长 2 秒,且不同机器表现不一致"。二是它和那条红线仍然冲突——红线说的是"立即失效",2 秒不是立即。如果这条红线是硬性的,这个方案就不成立;如果它实际上只是"用户无感即可",那这个方案成立,但要在设计文档里把这句话写清楚,而不是默认它满足红线。
改法二:先砍掉重复校验
比加缓存更值得先做的,是消掉那个两倍的放大系数。
本项目里,前端只调用 BFF 门户,门户校验过一次之后,转发请求时会把令牌原样带上,下游服务于是又完整校验了一遍。同一个令牌,在同一个请求链路里被验了两次。
下游服务本来就不对公网暴露,只接受内网和反向代理的流量。那么它可以信任"这个请求已经过门户校验",不必再向 auth-service 要一次答案。做法是让门户在转发时带一个只有内网知道的标记,下游认这个标记就跳过重复校验。
- 校验量直接减半,且不引入任何缓存、不产生任何失效窗口
- 撤销延迟仍然是 0
- 代价是必须守住"下游服务不对外暴露"这条部署红线——一旦某个服务被直接暴露到公网,这个信任关系就变成了漏洞
这条改法的收益和"加缓存"是同一量级(都是一次网络往返),但正确性代价为零。这也是为什么它应该排在缓存之前。
改法三:共享存储加主动失效
如果将来真的需要缓存,正确的形态是引入共享存储(本项目在设计里为它预留了 platform.cache 模块的位置),然后按上一节的"版本号 + 撤销名单"组合来做。
/** 撤销:版本加一,账号下所有会话整体作废 */
public void revokeAll(Long accountId, String reason) {
mapper.revokeAllActive(accountId, reason);
epoch.increment(accountId); // Redis INCR
}
/** 校验:缓存命中后仍要比一次版本,撤销不必等 TTL */
public boolean isActive(String jti, Long accountId, long epochAtIssue) {
if (epoch.current(accountId) != epochAtIssue) {
return false;
}
return cache.get(jti, () -> delegate.isActive(jti));
}
这套组合里,TTL 可以放宽到 30 秒甚至 5 分钟,因为它不再承担撤销的时效性——撤销由版本号在常数时间内穿透。TTL 退回去只管一件事:没人撤销时,副本什么时候自然刷新一次。
这才是 TTL 该有的角色。它是个刷新周期,不是安全参数。
容易踩的坑
把令牌有效期当成缓存 TTL。 两个数字量级接近(都是分钟到小时级),但一个决定"令牌能活多久",一个决定"记忆有多旧"。用前者给后者做背书,是最常见的推理错误。
只缓存"有效",不缓存"无效"。 只缓存肯定结果可以避免无效令牌把缓存撑爆,但代价是每个无效请求都会穿透到后端。反过来全缓存又会让"未登录"这类高频无效值占用空间。这里没有通解,取决于无效请求的比例。
缓存键里漏了维度。 会话校验的键是 jti,看起来不会撞。但如果缓存的是"某账号的权限范围",键就必须带上账号,甚至带上账号的会话版本——否则改密码后权限范围变了,缓存还在供应旧范围。
多实例下只删本机键。 前面说过,这是"主动失效"最典型的假实现。判断方法很简单:问一句"这个失效动作,在其它实例上生效吗"。
用 TTL 掩盖写后读不一致。 如果一个功能出现"刚保存完刷新还是旧数据",加缓存只会让它更严重。先确认那是不是缓存造成的,再决定要不要缓存。
时钟偏移。 版本号方案里,如果版本由某一台机器的时间戳生成,时钟回拨就会让版本倒退。用单调递增的计数器,不要用时间戳。
小结
TTL 的正确问法不是"设多大能扛住多少并发",而是"能容忍多久的旧值"。这两个问题看起来都在问 TTL,但前者没有答案——因为 TTL 根本管不了并发。
顺着正确的问法走,结论会自然分岔。能容忍陈旧,就设 TTL;不能容忍,就只有两条路:不缓存,或者把撤销做成主动失效,让 TTL 退回只管自然刷新。
而"要不要缓存"这个问题,可以用一次估算回答。按本项目的真实配置算下来,150 并发下的校验量只占用不到一成的容量,余量十倍以上。在这个阶段,缓存省下的是一次亚毫秒的索引查找,付出的是撤销延迟——不划算。
真正值得先动的是那个两倍的放大系数。它不是缓存问题,是结构问题:门户已经验过的令牌,下游不必再验一遍。修它没有正确性代价,收益却和缓存同一量级。
评论 (0)
还没有评论,来抢沙发。