升级 SpringBoot 4.0 后,Redis 缓存突然清不掉了

最近我们把项目升级到 SpringBoot 4.0 之后没多久,陆陆续续有客户来反馈一个很别扭的问题:在后台给某个用户改了角色、调整了权限,页面上明明白白提示”保存成功”,可这个用户该看不到的菜单还是看得到,该被收走的按钮还在。

退出重登也没用。

第一反应当然是怀疑代码写错了。但诡异的地方在于:这套权限缓存的逻辑,我们一行都没动过。改的只有一件事,SpringBoot 从 3.x 升到了 4.0。

熟悉 PIG 的朋友知道,我们的权限是带缓存的。用户的角色、菜单、按钮权限这些信息,第一次查出来就丢进 Redis,后面每次鉴权直接走缓存,不再压数据库。改权限的时候,对应的 @CacheEvict 会把这个用户的缓存清掉,下次访问重新加载新权限。逻辑很标准,跑了好几年都没出过岔子。

代码大概长这样:

1
2
3
4
5
6
7
8
9
10
@Service
public class PigUserService {

// 改完角色权限,清掉这个用户的权限缓存
@CacheEvict(value = "user_details", key = "#username")
public void updateUserRole(String username, List<Long> roleIds) {
// ... 更新数据库里的用户角色关联
userRoleMapper.update(username, roleIds);
}
}

逻辑没问题,前端提示也是成功的。可缓存就是没清。

先确认一件事:缓存到底清没清

排查 bug 我有个习惯,先别急着看代码,先看事实。前端说成功,那就绕过前端,直接连上线上 Redis 去看那个 key 还在不在。

1
2
3
# 改完权限之后,立刻去 Redis 里捞这个用户的缓存
> KEYS user_details::*
1) "user_details::admin" # key 还稳稳地躺在这儿

key 还在。@CacheEvict 像是没执行一样。

可日志里翻遍了,没有任何报错。应用这边一切风平浪静,evict 方法正常返回,事务正常提交,前端正常收到 200。所有人都觉得自己干得很好,只有 Redis 里那个本该消失的 key 在冷笑。

Spring Cache 清缓存,到底发了什么命令

要往下查,得先搞清楚 @CacheEvict 背后到底干了啥。

简单说,Spring Cache 是一层抽象。你写 @CacheEvict,它不关心你底下用的是 Redis 还是别的什么。真正干活的是一个叫 RedisCacheWriter 的家伙,它负责把”清掉这个 key”这个抽象意图,翻译成一条具体的 Redis 命令发出去。

那它发的是什么命令?

在我的认知里,删 key 不就是 DEL 嘛。这命令从 Redis 1.0 就有了,闭着眼睛都不会错。

于是我做了个最朴素的验证:开 Redis 的 MONITOR,盯着应用清缓存时到底往 Redis 发了什么。

1
2
3
4
> MONITOR
OK
# 触发一次改权限的操作,然后看监控输出
1717900000.123456 [0 10.0.0.5:54321] "UNLINK" "user_details::admin"

UNLINK

不是 DEL,是 UNLINK

我从来没在代码里写过这个命令,Spring Cache 自己什么时候学会发 UNLINK 了?

带着这个疑问去翻 Spring Data Redis 的源码,答案藏在 RedisCacheWriter 的实现里。

对比两个版本,差别一目了然。SpringBoot 3.x 对应的旧版本,清单个 key 走的是:

1
2
// 旧版本:老老实实用 DEL
connection.keyCommands().del(key);

而升级后,SpringBoot 4.0 带进来的 Spring Data Redis 4.0,同一个位置变成了:

1
2
// 4.0 新版本:改用 UNLINK
connection.keyCommands().unlink(key);

批量清理(clear)也从 mDel 换成了 mUnlink

它和 DEL 的语义几乎一样,都是删 key。区别在于:DEL 是同步的,删一个巨大的 key(比如几百万元素的 Hash)时,主线程会被阻塞,直到内存全部回收完才返回;而 UNLINK 只是先把 key 从键空间里”摘下来”,让它对所有命令立刻不可见,真正的内存回收丢到 后台线程 异步去做。对于大 key,这能避免主线程卡顿。

所以 Spring 把默认驱逐命令换成 UNLINK,出发点是好的,更不容易阻塞,吞吐也稳。

问题是,好东西也得你接得住才行。

UNLINK 这个命令,Redis 4.0.0 才引入。在这之前的版本,根本没有这条命令。

然后我去看了一眼我们线上那套 Redis 的版本。

那是好几年前搭的一套环境,一直稳定跑着,没人动过它。版本号停在 4.0 之前的老版本。

链路到这里就全通了:

  1. 升级 SpringBoot 4.0,带进来 Spring Data Redis 4.0
  2. 清缓存时,RedisCacheWriter 不再发 DEL,改发 UNLINK
  3. 我们的 Redis 是 4.0 之前的版本,压根不认识 UNLINK 这条命令
  4. key 没被删掉,但应用这边没炸
  5. 前端收到成功,用户以为权限改好了,实际上缓存里还是旧权限

一个本该删 key 的操作,因为底层 Redis 不认这条新命令,变成了什么都没做。而且是悄无声息地什么都没做。

为什么应用一点都不报错?

这里有个最坑的细节,也是这个 bug 这么难抓的原因:它是静默失败的

按常理想,发一条 Redis 不认识的命令,不应该报个错吗?

新版本默认走的是 Lettuce 的非阻塞驱逐路径,命令异步发出去,应用这边拿到的是”我已经把活儿派出去了”,至于 Redis 那头认不认、删没删成,业务线程并不会等一个明确的回执。于是错误就被吞在了那条异步路径里。

彩蛋:就算你 Redis 是 4.0+,也可能翻车

如果你看到这儿松了口气,觉得”我 Redis 早就 6.x 了,跟我没关系”,那先别急。

我顺手查了下,发现还有一类更隐蔽的情况:有些云厂商的托管 Redis,版本号明明是 4.0 以上,却照样禁用了 UNLINK

最典型的是阿里云的集群版 Redis。StackExchange.Redis 那边就有人 报过这个问题:服务端版本是 4.0,但集群版就是不支持 UNLINK,客户端按版本号判断”应该支持”,结果照样翻车。

所以判断能不能用 UNLINK,光看 redis_version 还不够保险,得拿你实际的那套环境真发一条试试。最稳的验证方式简单粗暴:

1
2
3
4
# 直连你的目标 Redis,手动发一条 UNLINK 试水
> UNLINK test_key_not_exist
# 返回 (integer) 0 → 支持,命令正常
# 返回 ERR unknown command → 不支持,你中招了