数据库回滚,缓存为什么还保留新值

一次操作写入数据库,再通过 @CachePut 更新价格缓存,最后把事务标记为 rollback-only。独立连接确认数据库里没有新记录,缓存里却能读到 uncommitted。这个结果不需要框架出错:普通内存缓存与 JDBC 事务管理器没有共同的提交协议。

缓存一致性至少涉及键空间、加载并发和提交边界。代理命中只能说明某次调用找到了缓存条目,不能证明条目仍然新鲜,也不能证明它来自已经提交的数据库状态。

实验 blog.spring.Chapter33 固定 Spring Framework 6.2.11、JDK 21、ConcurrentMapCacheManager,数据库使用 PostgreSQL 18.0。所有缓存结论都限定在这个 provider 和单 JVM 内;没有用本地 sync=true 推导 Redis 集群的行为。

缓存查找、加载与独立数据库提交

CacheInterceptor 处理调用,provider 保存数据

@EnableCaching 注册缓存 Advisor,CacheInterceptor 把代理调用委托给 CacheAspectSupport。后者解析操作元数据,计算键,解析 Cache,按照操作类型查找、调用目标、写入或失效。实际存储由 CacheManager 返回的 Cache 完成。

这层分工使相同注解可以适配多个 provider,也保留了能力差异。是否有过期时间、是否跨进程共享、加载是否原子、是否支持事务感知,都不能仅凭方法上有 @Cacheable 作答。固定版本缓存拦截实现

实验中的 read("a") 每次真正执行都会增加目标计数并生成新值。两次外部调用返回相同字符串,目标计数保持1,证明第二次没有再次执行加载逻辑。随后目标内部的 self("a") 直接调用 read,计数继续变化,返回值与缓存命中值不同。

1
2
CHECK PASS 33 second read hits cache
CHECK PASS 33 self call bypasses cache

计数放在目标对象中。测试经 Advised.getTargetSource() 取得目标,仅用来读取计数和控制门闩;业务调用仍经过代理。直接读取 CGLIB 代理实例上的字段会混淆代理与目标的状态,这种测试写法本身也必须避免。

默认键没有包括方法名

usd(String key) 与 eur(String key) 使用同一个 collision 缓存,参数都是 sku。先调用 usd 得到 USD,再调用 eur 仍得到 USD,虽然第二个目标方法的实现固定返回 EUR。

原因是默认 SimpleKeyGenerator 根据参数生成键:零参数用空键,单个非空且非数组参数直接作为键,多参数等情况使用 SimpleKey。方法名和所属类不自动进入默认键。因此不同方法共享缓存名和同一参数值,会共享条目。键生成源码

1
CHECK PASS 33 default key omits method identity

这种冲突在实际接口里不一定立即表现为异常。如果两个方法都返回 String,错误值仍满足类型要求,测试只检查非空就会通过。若一个返回商品摘要、另一个返回完整对象,则可能在更远处出现类型错误。

修复应围绕数据身份设计:不同语义使用不同缓存空间,或把币种、租户和版本等真正决定值的维度纳入键。机械地加入方法名能减少方法间碰撞,却不能补上遗漏的租户。键中的可变对象也要谨慎;对象在写入之后改变 equals/hashCode 所依赖字段,会破坏查找预期。

成功、异常与失效有不同的时点

broken("bad") 在加载时抛异常。调用方观察到 IllegalArgumentException,随后直接查询 provider,要求 bad 条目不存在。这一对断言把“异常传播”和“没有缓存失败结果”分别验证。

evict("a") 使用普通 @CacheEvict。调用前保存旧值,失效后再次 read 得到新的计数值。仅仅调用 eviction 方法不够,下一次读取是否重新加载才是失效行为的可见结果。

1
2
3
CHECK PASS 33 failure escapes
CHECK PASS 33 failing invocation not populated
CHECK PASS 33 eviction forces reload

默认在成功调用后失效与 beforeInvocation=true 的行为不同:目标方法失败时,后置失效可能尚未发生,前置失效则已经执行。选择取决于业务对失败后的旧值有什么要求,不能为了提高命中率就隐去更新失败与条目失效的关系。

@CachePut 与 @Cacheable 也不能互换。前者用于执行目标并把结果写入缓存,后者可以在命中时跳过目标。需要执行数据库写入的命令若被不恰当地标成 Cacheable,命中时可能连命令本身都不再执行。Spring 缓存注解

两次 miss 为什么会执行两次加载

普通缓存实验启动两个客户端线程,同时读取 normal 缓存中的 same。目标每次进入都增加 normalLoads,然后等待一个初始为2的门闩。只有两个目标调用都已进入,门闩才打开。

两次 Future 最终都返回 loaded,目标计数是2。这个受控条件证明:线程安全的 Map 并没有把“查找未命中→执行昂贵加载→写入”整体变成互斥操作。Map 自身没有损坏,与数据库查询只执行一次,是两个不同的属性。

1
CHECK PASS 33 concurrent misses execute twice

这也解释了高命中率为什么不能单独防止缓存击穿。热点键失效的一刻,大量调用可能都先读到 miss。即使最后留下的是一个正确值,底层服务也已经承受了多次加载。需要观察加载次数、耗时和队列,而不能只看最终缓存大小。

实验没有以吞吐量测量替代并发关系。门闩使两次加载必然重叠,5秒超时只负责让错误条件有界失败。把并发测试写成启动两条线程后睡一会儿,可能在调度碰巧串行时也通过,无法证明 miss 窗口存在。

sync=true 把加载协调交给 provider

第二组方法使用 @Cacheable(sync=true)。第一条加载进入后阻塞,第二个客户端必须已经到达 provider 的 get(key, Callable) 入口,主线程才释放第一条加载。provider 是 ConcurrentMapCache,实验子类只在入口减少门闩,随后完整委托给原实现,没有替换它的并发语义。

两个客户端得到相同结果,syncLoads 为1。底层实现通过 ConcurrentMap 的原子计算协调同键加载;第二个客户端不会重新执行本次加载函数。ConcurrentMapCache 固定版本源码

1
2
CHECK PASS 33 sync provider loads once
CHECK PASS 33 concurrent clients terminated

这个结论严格限定为本次 provider、同一缓存实例与同一键。换一个 provider 必须核对 get(key, Callable) 的保证;跨两个 JVM 的内存 Map 当然不会共享锁。sync 也有操作组合限制,例如不能随意与 unless 等配置组合,配置合法性应独立验证。

同步加载还引入等待关系。加载函数如果永久阻塞,同键调用会一起等待。减少重复加载不是超时、熔断或幂等协议的替代品。返回可变对象时,所有命中者还可能共享同一引用,读取后修改对象的并发风险也没有被 sync 消除。

回滚反例需要独立数据库观察者

事务实验在专用表 spring_ch33_value 插入记录1,通过 p.put("tx", "uncommitted") 更新缓存,再调用 setRollbackOnly()。TransactionTemplate 返回后,独立 DriverManager 连接查询表内计数,要求为0;随后直接查询缓存,要求仍是 uncommitted。

1
2
3
CHECK PASS 33 independent observer confirms database rollback
CHECK PASS 33 ordinary cache survives database rollback
CHECK PASS 33 JDBC lease returned

程序没有使用同一事务连接验证回滚,也没有把一个内存变量当成数据库状态。数据库查询与缓存读取分别观察两个资源,结果表明默认配置不存在联合回滚。

事务感知缓存装饰器可以把部分写入动作延迟到成功提交之后,但仍需逐项阅读它对 put、evict、clear 及即时操作的契约。延后本地缓存更新也不能自动解决其他进程读旧值、提交与远程缓存写入之间崩溃、失效消息乱序等问题。本篇没有启用该装饰器,不能用默认 provider 的实验宣称其功能失效。TransactionAwareCacheDecorator API

缓存可以作为可重建的读优化,也可以承担幂等判断或状态协调。一旦承担后两者,就需要把一致性与故障恢复写进协议,而不是把 CacheManager 接口当成已经提供了这些保证。

复现与预测练习

JDK 21、数据库启动方式见实验工程 db/README.md,运行:

1
./mvnw -q compile exec:java -Dexec.mainClass=blog.spring.Chapter33

本次12条断言通过,退出码0,日志在 evidence/33/local-20261002/run.txt。程序关闭两个并发客户端线程,检查数据库活跃租约归零,最后由 try-with-resources 关闭容器和连接池。

预测题:给 eur 方法增加不同的方法名,却仍使用默认键和同一个缓存,冲突会消失吗?不会,默认键未包含方法名。改变缓存名或明确包含币种的键才会改变条目身份。

改动练习:把 usd 与 eur 统一成 price(currency, sku),保留同一缓存名,增加两种币种的断言。再把 key 错误地指定为只包含 sku,要求测试重新捕获冲突。这样能确认测试检查的是业务身份,而不是偶然不同的字符串。

上游 AbstractCacheAnnotationTests 的 sync 与异常用例只做源码对照,本次未运行 Framework 的完整测试集。本地12条断言覆盖本文列出的行为,不代表已经完成任意缓存 provider 的兼容性认证。

参考资料

系列入口与完整实验工程