读完这篇,你能做什么
能写出缓存读取与更新路径,并用时间线解释为什么仍可能短暂读到旧值。
学习理由、准备要求与知识点
为什么学它
商户查询把缓存收益和一致性代价放在同一条业务链路上,是理解缓存模式的重要练习。
先读这些内容
知识点清单
- Cache Aside
- 未命中与空值标记
- 穿透
- 热点过期
- TTL 抖动
- 提交后失效
- 旧值回填窗口
- 失效重试
运行前先确认点评应用通用 Cache-Aside 教学场景,MySQL 8.4、Redis 7.4。源码固定 OpenJDK 17.0.16+8;Java 17 完整程序验证单进程回填代次协议,不实现数据库与 Redis 的强一致事务。
01先弄懂它是什么
商户详情可以先采用 Cache-Aside:按编号查缓存,没有就查数据库,再把结果放回缓存并设有效期。这样能减少数据库读取,也多了一份可能过时的数据。先明确页面最多能接受多旧的数据、缓存失效时数据库能承受多少请求,以及更新后应何时看到新值,再决定有效期和补救方式。
读取缓存至少要分三种结果:还没有缓存、已缓存“商户不存在”、已缓存商户内容。若把前两种都当成同一个 null,每次查询不存在的商户都会再查数据库。短期保存“不存在”能减少重复查询,但前提是它已成功写入、还没过期、也没被淘汰,而且缓存服务可用。首次同时到达的多个请求仍可能一起查库。
更新路径通常先提交数据库事务,再删除对应缓存。这个顺序避免了先删除后更新时旧值被立即重新填入的一类问题,但并不保证所有窗口都一致。数据库提交与 Redis 删除之间有失败空隙,先前开始的慢查询还可能在删除之后回填旧值。需要把具体时间线展开,不能仅凭两行调用顺序宣布强一致。
文中这几个词,先认一认
- 穿透
- 反复查询数据库里本来就不存在的数据,没做好处理时每次都会绕到数据库。
- 击穿
- 一个访问量很高的缓存项失效,许多请求同时开始重建它。
- 雪崩
- 很多缓存项同时失效,或缓存服务整体不可用,使大量请求集中转向数据库。
02看看它怎么工作
模拟一次旧回填:A 缓存未命中并读到旧商户,随后暂停;B 更新数据库并提交,删除缓存;A 恢复将旧商户放回。旧值的 TTL 从这次回填开始计算,因此不能简单把 TTL 解释为从 B 提交时刻起的严格最大陈旧窗口。只做延迟双删也需要假定慢请求和调度延迟上界,不能对所有执行时序给出确定保证。
可以给每个缓存键记一个不断增加的失效编号,本文叫它“代次”。开始查数据库时先记下当前编号,这张记录就是回填票据;慢查询放在原子检查之外执行。更新方让缓存失效时,同时增加编号并清空缓存值。
查询回来后,把票据编号与当前编号一起检查,只有相同才写入缓存,这个检查和写入不能被拆开。删除缓存值时仍要保留编号,不能归零,否则旧票据可能又被误认为有效。这个模型只拦住跨过失效操作的旧查询,没有消除数据库已经更新、缓存尚未失效的时间段。
这套办法还有两个前提:所有写入方都遵守相同的失效规则,查询数据库时也能读到约定的新鲜数据。如果拿到新编号后,仍从旧事务快照或尚未同步的副本读取,编号相同也可能填回旧值。多台应用要共同使用它,就得让共享存储一次完成比较、更新和期限设置,并处理编号丢失、故障切换和重试。Java 的 synchronized 只能协调当前进程,管不到另一台应用。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["A 取得代次零并读取旧值"] --> B["B 提交数据库更新"]
B --> C["失效操作推进代次一并清值"]
C --> D["A 携带代次零尝试回填"]
D --> E["比较失败 拒绝旧回填"]
C --> F["新加载携带代次一读取并回填"]按顺序看,每一步做了什么
- 建立未命中、空值标记、对象值三态读取。
- 先验证数据库提交再失效的正常路径与失败补偿。
- 用可控暂停复现旧读在失效之后回填的时序。
- 对更严格需求设计版本或代次协议,并列出数据源新鲜度与恢复前提。
03对照实现代码
OpenJDK jdk17u jdk-17.0.16+8 · ConcurrentHashMap#compute(K,BiFunction)
以下是原创缓存代次片段,用真实 JDK API 表达局部原子检查;不是 JDK 源码节选或 Redis 服务端实现。片段只说明单进程协调,不包含 TTL 与回源。
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
class SourceModel {
record Cell(long generation, String payload) {}
private final ConcurrentHashMap<String,Cell> cells = new ConcurrentHashMap<>();
void invalidate(String key) {
cells.compute(key, (k, old) -> new Cell(
old == null ? 1 : Math.addExact(old.generation(),1), null));
// 清空缓存值仍保留代次,避免旧加载票据重新匹配
}
boolean publish(String key, long expected, String value) {
AtomicBoolean accepted = new AtomicBoolean();
cells.compute(key, (k, old) -> {
Cell current = old == null ? new Cell(0,null) : old;
if (current.generation() != expected) return current; // 比较与替换不能分两次调用
accepted.set(true);
return new Cell(expected,value);
});
return accepted.get(); // 网络和数据库读取应在短小回调之外执行
}
}真实 compute 对指定键执行原子的重新映射调用,并在空桶、普通节点和树节点等不同状态下协调更新;它不是给整个数据库事务上锁。
回调可能使其他更新等待,因此应短小简单,不能把慢数据库读取放在 compute 内作为通用重建方案。
compute 回调返回 null 表示删除这个键。本例返回的是一个 Cell 对象,只把里面的 payload 设为空,为的是清掉业务内容后仍留下失效代次,供后续判断旧结果能否回填。
真实实现协调当前 Map 的竞争,不跨 JVM、不保存磁盘状态。教学模型能证明局部竞态被拒绝,不能由此推出 Redis 切换后仍保留代次。
代码先调用谁,再调用谁
- 加载开始读取当前代次
- 原子区外查询数据库
- 更新方推进代次并清值
- compute 比较代次
- 接受新回填或拒绝旧回填
04动手写一个例子
保留失效代次的缓存三态与回填票据
缓存支持 MISS、ABSENT、VALUE 三态。加载持有键和代次票据,失效推进代次,回填只接受当前代次并设置 TTL。值过期只清空值,不能重置代次;时间由测试显式输入。
- 输入 1
票据代次0;失效到1;票据0回填old- 输出 1
拒绝,仍为MISS
- 输入 2
代次1回填ABSENT,now10,TTL5- 输出 2
14时ABSENT,15时MISS
状态分成稳定代次与可过期值,删除值不能删除用于拒绝旧请求的证据。
回填先验证有效期和票据代次,再保存对象或空值标记;空值是明确 Value,不与不存在的 Cell 混淆。
用顺序可控的交错调用模拟竞态,避免依赖线程睡眠碰巧触发;每一条时间线都对应一个明确预期。
为什么这样写是对的一个键完成失效后,所有更旧代次票据永久失去在该状态生命周期内回填的资格;有效缓存值要么明确为空值标记,要么包含非空对象。
哈希查找与替换平均常数;k包括曾创建但当前无缓存值的代次元数据,不能只按当前命中键数估计内存。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
enum Kind { MISS, ABSENT, VALUE }
record Value(boolean absent, String text) {
Value {
if (absent && text!=null || !absent && text==null) throw new IllegalArgumentException("value");
}
static Value empty() { return new Value(true,null); }
static Value of(String text) { return new Value(false,text); }
}
record Ticket(String key, long generation) {}
record Cell(long generation, Value value, long expiresAt) {}
record Answer(Kind kind, String text) {}
static final class Cache {
private final Map<String,Cell> cells = new HashMap<>();
private Cell cell(String key) {
Objects.requireNonNull(key);
return cells.computeIfAbsent(key,k -> new Cell(0,null,0));
}
synchronized Ticket begin(String key) { return new Ticket(key,cell(key).generation()); }
synchronized void invalidate(String key) {
Cell old=cell(key);
cells.put(key,new Cell(Math.addExact(old.generation(),1),null,0)); // 保留并推进代次
}
synchronized boolean publish(Ticket ticket, Value value, long now, long ttl) {
Objects.requireNonNull(value);
if(now<0 || ttl<=0) throw new IllegalArgumentException("time");
long expiry=Math.addExact(now,ttl);
Cell current=cell(ticket.key());
if(current.generation()!=ticket.generation()) return false; // 原子拒绝跨失效点的旧加载
cells.put(ticket.key(),new Cell(current.generation(),value,expiry));
return true;
}
synchronized Answer get(String key, long now) {
if(now<0) throw new IllegalArgumentException("time");
Cell current=cell(key);
if(current.value()==null) return new Answer(Kind.MISS,null);
if(now>=current.expiresAt()) {
cells.put(key,new Cell(current.generation(),null,0)); // 过期清值,但不能将代次归零
return new Answer(Kind.MISS,null);
}
Value v=current.value();
return new Answer(v.absent()?Kind.ABSENT:Kind.VALUE,v.text());
}
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
public static void main(String[] args) {
Cache c=new Cache();
check(c.get("shop:1",0).kind()==Kind.MISS);
Ticket old=c.begin("shop:1");
c.invalidate("shop:1");
check(!c.publish(old,Value.of("old"),1,10));
Ticket fresh=c.begin("shop:1");
check(c.publish(fresh,Value.of("new"),2,10));
check(c.get("shop:1",11).equals(new Answer(Kind.VALUE,"new")));
check(c.get("shop:1",12).kind()==Kind.MISS);
check(c.begin("shop:1").generation()==1);
check(!c.publish(old,Value.of("old"),13,10)); // 过期后也不接受代次零
check(c.publish(c.begin("shop:2"),Value.empty(),10,5));
check(c.get("shop:2",14).kind()==Kind.ABSENT && c.get("shop:2",15).kind()==Kind.MISS);
Ticket absent=c.begin("shop:2");
c.invalidate("shop:2");
check(!c.publish(absent,Value.empty(),16,5)); // 商户创建也应使旧空值加载失效
c.invalidate("shop:1");
check(!c.publish(fresh,Value.of("new"),20,10));
System.out.println("11 cache generation checks passed");
}
}这些特殊情况也要试一试
- 值恰好到期时返回MISS,代次仍保留。
- 商户从不存在变成存在,也必须处理旧空值回填。
- 模型没有合并同代次并发加载,不能承诺热点只回源一次。
进阶选读 · 生产拓展:共享版本、可靠失效与元数据回收
场景多实例和故障切换可能丢失代次,旧加载长时间未结束,数据库失效事件还可能重放。
做法使用可靠的版本来源或可恢复失效记录,将版本比较与缓存写入原子化;明确哪些加载仍在途后再回收元数据,或用不会重用的代次空间。失效任务按版本去重,数据源读新鲜度也要纳入协议。
复杂度单键原子比较可保持常数级命令数;状态空间随活跃键、在途任务及恢复窗口增长。
好处与代价协议复杂度明显高于普通Cache-Aside,应只为明确的一致性目标支付这部分成本。
05项目里怎么用
先看压力从哪里来,再选办法。总查不存在的数据,可以短期缓存“不存在”;一个热点键失效后大家同时查库,可以合并相同请求,或限制同一时间只让少量请求重建。很多键一起到期,可以把 TTL 稍微错开。缓存整体出问题时,还需要限制总请求量、数据库连接占用和等待时间,并按业务允许的方式返回替代结果。没抢到重建机会的请求也要有等待上限,不能一直占着线程。
缓存坏了就让所有请求直接查数据库,可能把故障扩大到整个站点。先测数据库单独能处理多少,再限制同时查库的数量和等待时间。商户介绍若业务允许,可以返回标出年龄的旧内容;库存和支付结果则通常需要到保存真实业务事实的系统确认,不能所有接口都用同一种降级。
数据库提交后删缓存的任务,要能在进程崩溃后继续,内存里重试几次并不能保证最终修好。可保存可靠失效事件、使用事务 Outbox 或已有变更订阅,并观察积压与失败。报告一致性改善时,仍要说明哪些时间窗口未消除、恢复依赖什么,以及实际验证了哪些并发先后顺序。
容易踩的坑
- 把MISS与明确不存在混成一个
null。 - 空值缓存尚未成功写入就承诺所有并发只回源一次。
- 将TTL视为从数据库提交起的严格最大陈旧时间。
- 删除缓存值时同时重置代次,使旧票据重新有效。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 普通Cache-Aside加TTL | 实现简单、读取成本低 | 存在并发旧值与跨系统失败窗口 | 允许短暂陈旧的商户详情 |
| 请求合并与受控重建 | 降低热点同时回源压力 | 增加等待管理且仍需数据新鲜度规则 | 热门键与成本较高的查询 |
| 版本校验和可靠失效 | 可减少明确的陈旧回填窗口 | 需要共享协议、状态恢复与监控 | 对更新可见性要求更高的读取 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01为什么空值标记不能等同于未命中?
展开参考答案与推导
空值标记代表已经查询过且不存在,应在有效期内直接返回不存在;未命中才进入受控回源。
02先提交数据库再删缓存是否强一致?
展开参考答案与推导
不是。删除可能失败,旧查询也可能在删除后回填,还存在提交与删除之间的窗口。
03compute能协调多个Java实例吗?
展开参考答案与推导
不能,它只保证当前ConcurrentHashMap里的原子映射操作;共享状态需要跨进程协议。
换个情况,还会处理吗
01A读旧值,B提交并失效,A再回填,应怎样验证?
展开参考答案与推导
让A持有旧代次票据,B推进代次,再断言A发布被拒绝;不能只用正常顺序请求测试。
02热点空值键首次并发请求仍多次回源,是否违反空值缓存设计?
展开参考答案与推导
不一定。空值缓存只在写入后生效,要限制首次并发需请求合并或互斥。
03代次相等但数据库来自旧事务快照,值一定新吗?
展开参考答案与推导
不一定。协议还要求加载来源符合新鲜度约定;代次只证明未跨过已观察的失效点。
写代码试一试
01代次0票据在失效到1后发布,应返回什么?
- 输入 / 条件
ticket=0,current=1- 输出 / 预期
false
提示 比较与写入同一原子范围。
展开参考答案与推导
拒绝旧回填且不修改当前缓存状态。
02代次1的缓存值过期,下一票据的代次是多少?
- 输入 / 条件
generation=1,值到期- 输出 / 预期
仍为1
提示 值和失效元数据分离。
展开参考答案与推导
只清除值,保留拒绝旧加载所需的代次。
03商户创建之前开始的空值查询在创建失效之后返回,怎么办?
- 输入 / 条件
旧空值票据跨过失效- 输出 / 预期
拒绝空值回填
提示 空结果也携带代次。
展开参考答案与推导
否则刚创建的商户会继续被隐藏至空值TTL到期,版本协议必须覆盖空结果。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能写出缓存读取与更新路径,并用时间线解释为什么仍可能短暂读到旧值。
本篇重点加练
提交后失效仍存在跨事务与迟到读取窗口,需要用历史而非单次命中率检验一致性。
- 提交后失效
- 旧值回填窗口
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
商户详情可能不存在、存在但未营业或暂时无法查询。为这三种状态设计缓存表示,解释每种表示能支持的客户端行为。
- 提交内容
- 缓存状态表
- 怎样算完成
- 区分业务状态与依赖故障
- 声明缓存值及有效期含义
练到的知识点Cache Aside · 未命中与空值标记
- 作业 02面试场景
同一小时出现大量随机商户编号请求和一个热门商户突发访问。分别定义识别证据、对数据库的压力路径及可接受的降级内容。
- 提交内容
- 两份诊断清单
- 怎样算完成
- 给出可区分两类流量的指标
- 覆盖首次访问与已缓存阶段
练到的知识点穿透 · 热点过期
- 作业 03原理问答
更新商户时注册了提交后回调,但进程在提交完成与回调运行之间退出。区分数据库结果、回调执行证据及客户端返回状态。
- 提交内容
- 三类状态对照表
- 怎样算完成
- 允许结果未知
- 不由异常直接推断数据库回滚
练到的知识点提交后失效
综合应用
- 作业 04代码推演
一批商户缓存计划同秒到期,更新事务又可能回滚。设计 12 个键的到期试验表及两种事务结局的缓存操作时间线。
- 提交内容
- 到期样本与事务时间线
- 怎样算完成
- 说明抖动取值范围
- 标明数据库提交可确认的时点
练到的知识点TTL 抖动 · 提交后失效
- 作业 05设计评审
管理员连续提交版本 11、12 的商户修改,失效消息重复且乱序,另有历史查询迟到。列出读者可能观察到的状态与恢复判据。
- 提交内容
- 事件排列与一致性边界说明
- 怎样算完成
- 包含迟到回填和重复失效
- 为失败重试设置可观察终点
练到的知识点旧值回填窗口 · 失效重试
- 作业 06编程实践
用 Java 可控调度器模拟商户查询、更新、失效与回填,至少构造四种交错;记录每次响应版本及数据库版本,输出违背自定一致性契约的轨迹。
- 提交内容
- 模拟器、轨迹与契约
- 怎样算完成
- 可固定顺序重复运行
- 注明未验证真实 Redis 网络与跨进程行为
练到的知识点Cache Aside · 未命中与空值标记 · 热点过期 · 旧值回填窗口 · 失效重试
- 作业 07代码推演
查询数据库使用只读副本,主库商户版本已更新而副本尚未追上。分析读取票据新但数据源旧的组合,列出回填时需要说明的假设。
- 提交内容
- 版本与来源时间线
- 怎样算完成
- 区分缓存代次和数据库版本
- 标注副本延迟来源
练到的知识点旧值回填窗口
- 作业 08设计评审
接口一次修改商户地址和营业时段,两个字段由不同缓存键展示。评审更新后页面可观察到的组合以及业务是否接受混合版本。
- 提交内容
- 跨键响应契约
- 怎样算完成
- 涵盖部分失效和迟到回填
- 说明展示与交易使用的不同要求
练到的知识点提交后失效 · 旧值回填窗口
- 作业 09编程实践
编写本地历史检查器读取查询开始、数据库提交、缓存发布及响应版本事件,检查“更新完成后开始的读取满足某版本约束”这一自定契约。
- 提交内容
- 检查器、日志样例与报告
- 怎样算完成
- 含重叠请求和信息缺失样本
- 不将模型检查结果当成真实集群证明
练到的知识点提交后失效 · 旧值回填窗口
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
某商户允许 2 秒陈旧数据,但停业状态须尽快阻止下单。设计详情展示与交易校验的分工,并列出缓存不可用时的验收项目。
- 提交内容
- 读写策略与验收矩阵
- 怎样算完成
- 分别说明展示和下单约束
- 包含恢复期间的回源预算
练到的知识点穿透 · 热点过期 · TTL 抖动 · 提交后失效 · 旧值回填窗口 · 失效重试
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造一段命中率接近满分却仍持续返回业务不可接受内容的历史,说明单看命中率为何无法证伪该问题。
- 提交内容
- 反例历史与替代观测指标
- 怎样算完成
- 错误可追溯到具体版本
- 不靠篡改统计数据制造反例
练到的知识点提交后失效 · 旧值回填窗口
- 作业 12思维拓展 · 跨域迁移
把失效协议迁移到浏览器离线商户列表:设备可能一周未联网且通知会丢失。重新定义恢复后数据新鲜度与更新确认的含义。
- 提交内容
- 离线协议评审
- 怎样算完成
- 覆盖客户端本地版本
- 说明哪些原假设已不成立
练到的知识点提交后失效 · 旧值回填窗口
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Microsoft Azure:Cache-Aside 模式 (新标签页)↗
阅读读写步骤、过期策略与一致性限制,设计缓存失效和回源实验。
- 实践参考文档英文
Redis:EXPIRE (新标签页)↗
阅读 TTL、覆盖写入和更新命令的交互,检查缓存是否会意外永不过期。
- 深入参考文档英文
Redis:Key eviction (新标签页)↗
阅读 maxmemory 与淘汰策略,区分过期、内存回收和写入拒绝。
- 实践实践指南英文
Caffeine:为什么刷新期间还能读到旧缓存 (新标签页)↗
先读 Refresh 的短示例和失败说明,暂跳批量加载与框架整合。画出“写入旧值—达到刷新条件—一次读取触发刷新—成功或失败”两条时间线,标注何时可能返回旧值;不要把
refreshAfterWrite当作固定周期任务。 官方仓库文档公开可读。Caffeine 3.x 需要 Java 11 或以上及项目依赖;这是进程内缓存,运行不需要 Redis 服务,也不自动同步多个应用实例。 - 深入参考文档英文
OpenJDK17 ConcurrentHashMap源码 (新标签页)↗
核对compute的原子范围和回调限制。
- 深入参考文档英文
Redis SET (新标签页)↗
使用单命令设置值及期限,并理解条件写入。
- 深入参考文档英文
Redis Lua脚本 (新标签页)↗
将共享状态比较与修改映射为服务端原子步骤。