第 01 节 / 共 2 节
缓存键命名空间与参数等价性
先读 Redis 缓存的读写路径。缓存命中说明找到了一个键,并不说明找对了业务结果。本节先把“什么请求可以共享结果”写清,再用下一节的并发实验检查“谁负责加载这份结果”。
资料核查:2026 年 10 月 23 日 JST(UTC+9)。官方滚动指南当日显示 Spring Framework 7.0.10;配套实验固定 Spring Framework 6.2.11、Microsoft OpenJDK 17.0.20.1、Maven 3.9.11、JUnit Jupiter 5.11.4。已核对两个固定版本中与本节有关的键生成代码;这不是整个版本的兼容性认证,也没有升级既有项目。
学完后应能做什么
- 给一个查询写出缓存区域、键的组成和允许共用结果的条件。
- 用返回值和方法执行次数,定位两个方法串用结果、漏掉查询选项和对象相等规则错误。
- 解释业务身份冲突与哈希值碰撞的区别,并选用不同缓存区域或自定义生成策略修复前者。
前置:会定义 Java 类与 record,理解 equals、hashCode 和 Spring Bean 的代理入口。运行时需要完整 JDK 17、Maven;首次解析依赖需要网络。实验只用虚构商户 1001 和本地 ConcurrentMapCache,没有数据库、Redis、HTTP 服务或模型调用。
先写一句“哪些结果可以共用”
可以把结果的地址理解为 缓存实例中的区域名 + 键。在同一个 CacheManager 下,shop-detail 的 1001 和 shop-status 的 1001 是两处;同一个区域里的相同键才会指向同一项。换成另一个独立缓存实例,存储和同步范围也可能已经变了,下一节会实际验证。
默认 SimpleKeyGenerator 使用参数,不会自动加入业务操作名、当前租户或权限范围。无参使用 SimpleKey.EMPTY;单个非空且非数组参数直接作为键;其余情况使用包含参数的 SimpleKey。因此两个不同方法都收到 Long 类型的 1001,又使用同一缓存区域,就可能读到对方留下的结果。固定 6.2.11 键生成实现
这和哈希碰撞不同。哈希表能用 equals 区分哈希值相同但不相等的键;本节错误示例中的两个 Long(1001) 本来就是相等的。问题出在我们给两个不同业务结果分配了同一个地址。默认策略也不会把 Integer(1001) 和 Long(1001) 自动转换成同一种业务编号。
一个最小反例:营业状态拿到了详情
下面是配套测试中 KeyService 的核心片段;计数器用 AtomicInteger,测试通过 Spring 容器取得代理后调用。完整配置和断言在下载包里。
@Cacheable("mixed")
public String detail(Long id) {
counts.add("detail");
return "detail:" + id;
}
@Cacheable("mixed")
public String status(Long id) {
counts.add("status");
return "OPEN:" + id;
}
先调用 detail(1001L),再调用 status(1001L)。本次实跑中,后者返回 detail:1001,而 status 的加载计数是 0。响应是合法字符串、调用也没抛异常,但业务内容错了。修复对照把区域分别设为 detail 和 status,后者返回 OPEN:1001,计数为 1。
如果操作必须共用一个区域,可以显式定义不同键,或使用自己的 KeyGenerator。配套示例将方法签名和参数组成复合键:
@Bean
KeyGenerator operationKey() {
return (target, method, args) ->
new SimpleKey(method.toGenericString(), new SimpleKey(args));
}
@Cacheable(cacheNames = "generated", keyGenerator = "operationKey")
public String generatedStatus(Long id) { /* 返回可区分的状态 */ }
这里的方法签名只是一个可验证的教学策略。方法改名会改变键;同实现类的不同 Bean 若代表不同数据范围,仍需明确范围信息。不要把它当成适用于所有缓存系统的标准格式。注解中的 key 与 keyGenerator 只能选一个。缓存注解与自定义键
参数相等,是业务约定的一部分
查询选项会改变结果时,就不能只用商户编号作为键。实验的 badOptions(id, includeClosed) 故意配置 key="#p0":第一次传 false,第二次传 true,第二次仍读到前一次结果。对照方法使用默认复合键,保留两个参数,两次各自加载。
如果参数是请求对象,要明确它按什么判断相等。实验使用不可变 record:
public record Query(String tenant, long shopId, boolean includeClosed) {}
@Cacheable("recordQuery")
public String recordQuery(Query query) {
counts.add("recordQuery");
return query.toString();
}
两个不同实例只要字段相同,就按 record 的值相等规则共用结果;换成只继承 Object 相等规则的普通请求类,即使字段一样,也会形成两个不同键。后者通常表现为重复加载,前者若漏掉重要字段,则可能表现为错误共享。不要在键已入缓存后再修改参与相等判断的字段。
规范化也要先看业务。例如是否忽略名称大小写、是否去掉空格,应与查询规则一致;不能为了提高命中率擅自合并两种不同条件。追踪请求的随机 ID 通常不改变查询结果,不应无条件放进键。
租户、数据版本或查询范围确实改变结果时,应纳入身份,并由可信服务端上下文提供。缓存键不是授权检查:即使键含租户,当前请求仍须通过相应权限检查;缓存命中可能跳过加载方法,所以不要只把授权放在那个可能被跳过的方法体里。撤权后的行为继续读 现有授权练习。
按步骤运行并验收
下载 完整 Maven 实验,解压后在含 pom.xml 的目录运行 README 中的命令。先用 mvn -version 确认 Maven 实际使用的 JDK,再执行:
mvn -B -ntp '-Dmaven.repo.local=.m2' test
依赖缓存写入本练习目录的 .m2;编译产物在 target。配套 run.ps1 -JdkHome "你的 JDK 17 目录" 会在当前脚本期间指定 JDK,并在结束后恢复环境变量。
| 对照 | 制作时的真实观察 | 验收关注点 |
|---|---|---|
| 两个方法,同区域同参数 | 状态加载 0 次,误读详情 | 同时核对内容和调用计数 |
| 两个方法,分开区域 | 状态加载 1 次,返回状态 | 两种结果都有各自位置 |
| 只以 ID 作键,遗漏布尔选项 | 两次不同条件只加载 1 次 | 命中可能掩盖条件丢失 |
| 默认复合键保留 ID 和选项 | 加载 2 次,返回各自结果 | 不同条件不被合并 |
| 两个等值 record 实例 | 相同范围加载 1 次 | 另一个租户再次加载 |
| 两个普通身份对象 | 加载 2 次 | 内容相近不等于键相等 |
| 方法签名参与自定义键 | 两个方法均返回正确结果 | 生成策略实际被使用 |
动手练习保留给你完成:
- 给
Query增加一个确实影响结果的语言选项。先写出哪些调用应共用结果,再补断言,记录实际区域、逻辑键、加载次数和返回值。 - 加入一个不影响结果的追踪编号,为它选择处理方式;不要只凭“调用次数更少”宣布正确。
- 为“哈希值相同但不相等”增加独立对照,并解释它为什么不是上面的串用问题。原测试已提供一个基线,改用你自己的键类型。
验收提交四件事:调用顺序、相等规则、计数与响应的对应表、实际版本及测试输出。正常路径与故意错误路径都应留下断言;只截一张 BUILD SUCCESS 不足以说明你改过的键契约成立。
常见误区与边界:接 Redis 还要重新核对
本实验验证的是 Java 对象键在本地缓存中的行为。Redis 还涉及对象到字符串的转换、前缀和序列化。今日 Spring Data Redis 缓存指南显示 4.1.1,默认会以缓存名加 :: 作前缀,并提供可配置的键转换与序列化;修改默认配置后须重新检查隔离。该文档版本没有加入实验依赖,本文也未连接 Redis。
已有 TTL、旧读回填、版本下界与故障恢复内容继续使用原课程;键正确不会自动让结果更新及时。现在进入 同键并发加载与同步范围:即使地址正确,十个同时未命中的请求,也未必只加载一次。
补充来源:7.0.10 键生成实现。核对范围是默认键生成分支及提供者适用性提醒;独立发布日期未在本次阅读位置标明。
第 02 节 / 共 2 节
同键并发加载与同步范围
上一节已经规定什么请求能共享结果。这一节再问:同一个键还没有结果时,多个请求会不会一起进入加载方法?我们用真实 Spring 代理和缓存实现,把“结果被缓存”“这次只加载一次”“整个集群只执行一次”分开验证。
资料核查:2026 年 10 月 23 日 JST(UTC+9)。文档参考 Spring Framework 7.0.10;实际运行固定 Spring 6.2.11、Microsoft OpenJDK 17.0.20.1、Maven 3.9.11、JUnit Jupiter 5.11.4。本节只测试同步返回字符串的 ConcurrentMapCache,不将结论扩展到响应式返回值、其他缓存提供者或所有版本。
学习目标和前置
完成后,你应能用受控并发建立重复加载的现场,解释 sync=true 委托给谁执行,并画出缓存实例与进程边界。先完成 键身份练习,能读懂 CountDownLatch、线程池和 Future.get。不熟悉监视器范围时,可回读 synchronized 与监视器。
本节沿用同一个下载包;数据只有假商户 1001。第一组有 10 个工作线程;第二组建立两个独立 Spring 容器;第三组真的启动两个子 JVM,各自持有本地缓存。没有生产连接、Redis 锁或数据库事务。
sync=true 把加载交给缓存提供者协调
默认缓存流程可能出现:请求 A 查到未命中,请求 B 也查到未命中,两者各自调用加载方法,再分别写入结果。缓存本身是线程安全的,并不自动等于整个“检查后加载”过程只执行一次。
Spring 的 sync=true 表达同键同步加载的要求,实际语义由所选提供者决定。配套代码只改变这项配置,仍通过 Spring 代理调用:
@Cacheable("unsynchronized")
public String plain(Long id) {
return gate.load(id);
}
@Cacheable(cacheNames = "synchronized", sync = true)
public String synchronizedLoad(Long id) {
return gate.load(id);
}
这不是给业务方法添加 Java synchronized 关键字。所测的 6.2.11 ConcurrentMapCache 实现在 get(key, Callable) 中调用底层 Map 的 computeIfAbsent,由这个本地存储完成协调。独立 Map 不共享同一份状态。
方法正常返回、条目尚未被移除等条件也很重要。“这一轮成功冷加载一次”不是永久只执行一次;失效、失败重试、进程重启都需要另定行为。加载过程中不应顺便承担“全局只扣款一次”等业务副作用。
怎样确定十个请求真的重叠了
配套测试不用 sleep 猜顺序。每个请求先报告就绪,然后一起经过开始信号;加载方法计数后停在释放信号处:
String load(Long id) {
loads.incrementAndGet();
entered.countDown();
await(release, "loader release"); // 封装的 await 有 8 秒超时
return "shop:" + id;
}
非同步组等待 10 次加载全部进入,才放开加载,因此明确建立了十个重叠未命中。同步组不能等待十次加载进入,否则会把正确的同步行为测成死锁;它等待 1 次加载进入,再确认 10 次请求已到缓存提供者的入口,然后放行。
为观察入口,测试中的 ObservedCache 只在 get(key, Callable) 委托前减一个计数,真正读写和同步仍调用 super.get。它没有替 Spring 实现锁,也没有绕过缓存方法。Future.get、信号等待、线程池关闭以及子进程退出均有时限;finally 总会释放闸门并清理本实验创建的资源。
日志保存每个请求的返回值和记录到的完成顺序。完成顺序会随调度变化,因此不要求某个线程先返回,也不把这份顺序写成公平性保证。
本次实跑观察
下载 完整实验与验证记录,在项目目录执行 README 中的 JDK 检查和 Maven 命令。**2026 年 10 月 23 日已运行 7 个测试方法,失败、错误、跳过均为 0。**它们包含上一节的键测试,不是“7 次压测”。
| 场景 | 实际加载次数 | 本次能说明什么 |
|---|---|---|
同一缓存,10 个重叠请求,sync=false | 10 | 受控交错确实出现重复加载 |
同一缓存,10 个请求,sync=true | 1 | 此提供者在本次成功加载中合并了同键请求 |
| 同一 JVM,两个独立容器各有 10 个请求 | 各 1,总共 2 | 独立本地缓存各自协调 |
| 两个真实 JVM,同时加载相同键 | 各 1,总共 2 | 本地协调没有跨到另一个进程 |
| 成功加载后再读同键 | 计数不增加 | 已缓存的结果被复用 |
sync=true 同时配置 unless | 加载前拒绝 | 所测版本拒绝这项不支持的组合 |
双进程实验通过标准输入/输出传递测试信号:两个子进程都报告已经进入各自的冷加载后,父测试才放行它们。每个子进程再做一次热读,核对本地计数仍为 1,并正常退出。这里没有跨进程共享缓存,也没有实现跨进程锁;总共两次加载正是要观察的边界。
常见误区:配置限制不能靠试着加注解解决
所核对的 6.2.11 Cacheable 契约和 7.0.10 同一契约都写明:同步模式不支持 unless,只允许一个缓存,不能组合其他缓存相关操作;支持程度要查提供者。
因此,已有 unless="#result == null" 的方法不能直接加上同步开关后就宣布完成。应先决定空结果能否缓存、失败如何反馈,以及等待请求的预算。本次实际测试的是 sync + unless 的拒绝行为;另外两项限制依据固定版本契约核对,没有冒充逐项运行过。
本地 ConcurrentMapCache 默认可以通过内部占位对象表示空值,但这并不替业务决定是否应缓存不存在。本次成功加载只返回非空字符串,没有测试并发异常、取消、加载超时、空值和重建失败策略。
换成 Redis,先重新画同步范围
今日 Spring Data Redis 缓存指南显示 4.1.1:默认 writer 不加锁,另有 locking writer,其锁作用于整个缓存区域,而非单个条目。这个 writer 选项不能仅凭名字等同于本节的同键加载实验。
接入 Redis 时,需要核对实际 RedisCache、writer、客户端和服务端版本,以及加载路径到底如何调用它们,再设计共享实例的独立实验。本文没有运行 Redis,因此不对任何 Redis 配置给出“集群内一次”结论,也没有建议修改线上 writer。官方 同步缓存指南提供入口,所选提供者仍是最终核对对象。
动手扩展与验收
下面是留给读者的新练习,尚未在配套基线中运行:
- 保持
sync=true,改为两个不同商户键,各自使用独立到达信号。先预测同键与异键的关系,再记录真实加载;不要把实验成功解释为任意不同键永远互不等待。 - 为首次加载注入明确异常,设计下一次调用的预期,再观察失败是否留下可复用结果。保留调用次数和异常类型,区分框架行为与业务自行重试。
- 在上一节故意遗漏查询条件的错误键上启用同步,说明为什么减少加载次数仍可能稳定地返回错误结果;随后一起修复键和同步范围。
验收提交:缓存提供者及版本、缓存实例数量、进程数量、键规则、到达与释放条件、每个响应、加载次数、有限超时和资源结束记录。等待超时必须算失败,不能转写成“同步成功”;性能提升则需要另行设计测量,本次加载计数不是吞吐或时延成绩。
完成后回到 旧读回填与故障恢复实验。请求合并只减少这次重复工作,仍需由那些协议处理更新、失效和恢复。已有 ClassValue 并发计算文章可作对照阅读:不同 API 对“计算”和“留下结果”的承诺不同,不能互相套用。