读完这篇,你能做什么
能设计一组可复现的商户查询实验,分别报告命中率、回源量、延迟和内容正确性。
学习理由、准备要求与知识点
为什么学它
缓存策略只有在过期、热点和依赖失败的条件下验证,才能看清回源压力与一致性代价。
先读这些内容
知识点清单
- 隔离压测数据
- 基线
- 穿透实验
- 热点过期
- 并发回源
- TTL 抖动
- 依赖超时
- 正确性和延迟指标
运行前先确认完整算法使用 Java 17 标准库;JDK 源码基线为 OpenJDK 17 GA。示例是内存中的机制模型,外部服务接入需要另外配置和验证。
01先弄懂它是什么
测缓存速度前,先确认返回的数据对不对。准备一个存在的商户、一个不存在的编号和一个高频访问的键,记录数据版本、并发数、请求到达速度和测试时间。在同一时间段查看缓存命中率、数据库请求数、错误率和耗时。旧数据返回得很快,也不代表满足了业务对新鲜度的要求。
不同故障要分开模拟:反复查不存在的数据叫穿透;热点键过期后,大量请求一起查数据库叫热点失效问题;许多键同时过期则会集中增加负担。短期缓存“不存在”、合并同一键的重建请求、错开到期时间,分别解决不同问题。空值缓存也会过期或被淘汰,首次并发未命中仍可能产生多次数据库查询。
缓存不可用时直接让所有流量访问数据库可能放大故障。需要根据容量设置总请求预算、限流、受限回源或可接受旧值策略。扩大连接池不一定提高吞吐,数据库锁等待和CPU已饱和时可能更糟,实验必须同时观察连接获取和查询执行时间。
文中这几个词,先认一认
- 请求合并
- 同一个键已经有人在重建时,后来的请求等待并共享这次结果,减少重复查询。
- 回源放大
- 缓存未命中后,许多请求同时涌向数据库,使原本较少的查询突然增加。
- 可证伪预期
- 提前写明条件、观察指标和什么结果算方案无效,让实验能发现反例而非只证明自己正确。
02看看它怎么工作
本题把时间当成传给程序的数字,模拟一个热点键怎样重新加载:输入每个请求何时到达、查数据库固定要多久,以及缓存能保留多久。最初没有缓存;每次查询完成后,才写入缓存并开始计算 TTL。新请求到达时,先处理已经完成的查询,再判断缓存能不能用。模型假定每次都查到同样数据、查询都成功,也没有网络或淘汰,因此它只用于观察这套处理规则。
不合并时,同一时刻三次未命中会启动三次回源;合并时后两次等待第一份在途结果,回源数从三变一。合并减少数据库工作,但这三位请求者仍要等待首次回源完成,不能把减少回源数直接说成每个请求都零延迟。
程序用最小堆按完成时间排列尚未结束的数据库查询,最早完成的排在前面。每来一个请求,先取出已经完成的查询并更新缓存期限,再处理这个请求。开启合并时,同一时刻最多只保留一份正在进行的查询;关闭合并时,可以同时查询多次,每次完成都回填缓存。这个比较只说明理想条件下少做了多少重复工作,不能给出真实系统每秒处理多少请求、查询多快或性能提升了百分之几。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["固定请求到达与数据"] --> C["缓存命中判断"]
C --> H["命中返回"]
C --> M["同键在途合并"]
M --> D["受限回源与回填"]
D --> V["内容 延迟 回源压力核对"]按顺序看,每一步做了什么
- 建立固定数据和相同负载的预热基线。
- 分别测试不存在ID、单热点过期和缓存超时。
- 每次只改变一个因素并记录响应内容与回源压力。
- 对比空值、合并、旧值或限流策略的代价。
- 恢复配置后重跑基线,说明真实流量尚未覆盖的条件。
03对照实现代码
OpenJDK 17 GA,jdk-17+35 · ConcurrentHashMap.putIfAbsent / remove
下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,不代表Redis或数据库内核实现。完整程序见算法模块。
CompletableFuture<Value> mine = new CompletableFuture<>();
CompletableFuture<Value> previous = inFlight.putIfAbsent(key, mine); // 原子决定本进程内谁负责该键重建。
CompletableFuture<Value> shared = previous == null ? mine : previous;
if (previous == null) startBoundedLoad(key, mine); // 自定义加载器负责超时、异常完成和结果回填。
return shared.thenApply(value -> value); // 返回依赖阶段,避免调用者直接取消共享Future。
// 加载器所有退出路径需用inFlight.remove(key,mine)条件清理,避免删除新一代重建。putIfAbsent对单键执行原子安装,返回已有值或表示成功安装。它可以选出本JVM内的重建负责人,但不会跨多个应用实例自然合并请求。
remove(key, mine) 会先看这个键现在保存的,是否仍是本次重建创建的 Future;只有相同才删除。这样,旧任务晚一步结束时,就不会顺手删掉后来启动的新任务记录。无论查询超时、提交线程池失败,还是任务异常结束,都要走到相应的清理步骤。
startBoundedLoad是应用函数而非JDK方法,它必须正确完成Future并限制等待与回源资源。片段只说明负责人选择,不能单独作为完整缓存实现直接上线。
返回一个依赖于共享 Future 的阶段,能减少调用者取消时直接影响共享 Future 的情况,但不等于数据库查询也会被取消。缓存该写哪个版本、多个实例怎样协调、能否返回旧值,都需要另外设计。
代码先调用谁,再调用谁
- 缓存未命中
putIfAbsent选择本地重建负责人- 其他请求等待共享结果
- 受限加载并回填
- 条件清理在途映射
04动手写一个例子
热点重建的离散事件模拟
输入非递减非负到达时刻、正回源耗时和TTL,比较是否合并请求,输出回源数、峰值在途数及各请求等待时长。只有一个键,所有回源成功且值相同。Java17自测。
- 输入 1
到达[0,0,0],回源10,合并开启- 输出 1
回源1,峰值1,等待[10,10,10]
- 输入 2
相同输入关闭合并- 输出 2
回源3,峰值3
回源完成事件保存在优先队列中。每次到达先处理完成时间不晚于当前的事件,确保恰好完成时的新请求可以命中已回填数据。
缓存有效期采用严格小于判断,恰好到期即未命中。若开启合并且已有在途回源,复用它的完成时间;否则新建一次回源并更新峰值。
等待时长等于目标完成时刻减到达时刻,命中为零。用精确加法检查完成与到期时间,避免long溢出伪装为立即完成或永不过期。
为什么这样写是对的堆中只含当前时刻之后的回源完成事件;开启合并时堆大小最多一,loads等于实际创建完成事件的次数。
未合并最多n个回源事件进出堆,每次对数;合并模式堆容量最多一。结果等待数组长n,模拟不执行真实线程或数据库。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
import java.util.*;
public class Main {
record Result(int loads,int peak,List<Long> waits){}
static Result simulate(long[] arrivals,long delay,long ttl,boolean merge){
if(delay<=0||ttl<=0)throw new IllegalArgumentException("duration");
PriorityQueue<Long> pending=new PriorityQueue<>();long expiry=-1,last=-1;int loads=0,peak=0;List<Long> waits=new ArrayList<>();
for(long time:arrivals){
if(time<0||time<last)throw new IllegalArgumentException("arrival");last=time;
while(!pending.isEmpty()&&pending.peek()<=time)expiry=Math.addExact(pending.remove(),ttl); // 先完成回源再处理同刻请求。
if(time<expiry){waits.add(0L);continue;}
long end;
if(merge&&!pending.isEmpty())end=pending.peek(); // 同键等待已有工作,不再创建回源。
else{end=Math.addExact(time,delay);pending.add(end);loads++;peak=Math.max(peak,pending.size());} // 只统计真正新建的回源。
waits.add(end-time);
}
return new Result(loads,peak,List.copyOf(waits));
}
static void eq(Result e,Result a){if(!e.equals(a))throw new AssertionError(a);}
public static void main(String[] args){
eq(new Result(1,1,List.of(10L,10L,10L)),simulate(new long[]{0,0,0},10,100,true));
eq(new Result(3,3,List.of(10L,10L,10L)),simulate(new long[]{0,0,0},10,100,false));
eq(new Result(2,1,List.of(10L,0L,0L,10L)),simulate(new long[]{0,10,109,110},10,100,true));
eq(new Result(1,1,List.of(10L,5L)),simulate(new long[]{0,5},10,100,true));
eq(new Result(0,0,List.of()),simulate(new long[]{},1,1,true));
try{simulate(new long[]{2,1},1,1,true);throw new AssertionError();}catch(IllegalArgumentException expected){}
try{simulate(new long[]{Long.MAX_VALUE},1,1,true);throw new AssertionError();}catch(ArithmeticException expected){}
System.out.println("PASS 7 tests");
}
}这些特殊情况也要试一试
- 固定成功回源模型不包含数据库拥塞和故障。
- 本地合并不自动跨实例生效。
- 缓存命中零等待只是模型,不是网络实际延迟。
进阶选读 · 后续再读:带容量与故障的缓存队列模拟
场景数据库连接有限,回源耗时受排队影响,部分加载失败。
做法增加服务台数量、排队上限、超时和失败事件,分别统计拒绝、等待与旧值比例;用真实受控测量校准参数。
复杂度事件堆模拟通常O(E log E),E随请求、完成和超时事件增长;模型校准质量比算法速度更影响结论可信度。
好处与代价更多细节提高解释能力,也容易引入未经验证参数,应逐步扩展而非用复杂模拟替代真实证据。
05项目里怎么用
不存在数据的空值缓存应有独立短期限与创建后失效策略,成功写入且缓存可用、未到期未淘汰时,重复请求才应避免回源。首次并发窗口仍可能多次加载,需要合并或互斥才能约束峰值,不能把空值策略夸大成天然一次查询。
热点键失效时,可以让请求等待互斥重建,也可以在业务允许的陈旧时间内先返回旧值、后台刷新。后者常叫逻辑过期,但前提是旧值还在;从未加载、被淘汰或因 Redis 有效期而删除后,仍要有受限查库等补齐办法。TTL 抖动只是错开大量键的到期时间,不能独自解决一个热点。缓存整体不可用时,还要限制查库数量和总等待时间,保护数据库。
报告要写明请求怎样产生。闭环压测会等上一次完成再继续,服务变慢时可能自然减少发送量,从而藏住持续排队压力。分别统计成功、报错和超时的耗时,结合数据库等待及查询次数解释结果。只在隔离数据和依赖上注入故障,恢复后检查是否回到原先正常状态,不只摘录最高吞吐。
容易踩的坑
- 缓存返回快就忽略数据版本错误。
- 空值缓存被说成首次并发也只回源一次。
- TTL抖动被当作单热点重建方案。
- 数据库饱和时盲目扩大连接池。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 空值缓存 | 降低不存在ID重复回源 | 影响新建数据可见时间 | 可容忍短暂空结果 |
| 同键请求合并 | 降低回源并发 | 等待依赖同一加载结果 | 热点重建 |
| 逻辑过期旧值 | 减少前台等待 | 需要陈旧窗口与后台恢复 | 可接受短暂旧数据 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01合并后所有请求都更快吗?
展开参考答案与推导
不一定,首批仍等待加载;主要改善是减少重复工作和下游竞争,真实延迟需测量。
02抖动与互斥解决什么不同问题?
展开参考答案与推导
抖动分散多键到期,互斥限制同键并发重建,不能互相替代。
03缓存超时为何要总预算?
展开参考答案与推导
缓存等待加数据库等待可能超过用户可接受时间,独立长超时会叠加。
换个情况,还会处理吗
01空值缓存仍多次回源,如何排查?
展开参考答案与推导
检查首次并发、写入结果、TTL、淘汰、键一致性和缓存错误,不直接断言策略失效。
02吞吐下降但命中率高,查什么?
展开参考答案与推导
检查慢命中网络、序列化、数据库写锁和其他依赖,命中率不能解释全部延迟。
03一台机器合并有效,多实例又放大,为什么?
展开参考答案与推导
在途Map只在单JVM共享,不同实例各有负责人;需评估跨实例协调与最终容量限制。
写代码试一试
01同刻三请求合并后等多久?
- 输入 / 条件
delay10,到达全0- 输出 / 预期
各10
提示 共享同一完成时刻。
展开参考答案与推导
只创建一次回源,但等待者都在十时得到结果,不能写成后两者零等待。
02恰好TTL到期如何处理?
- 输入 / 条件
完成10,TTL100,到达110- 输出 / 预期
未命中
提示 有效区间不含到期点。
展开参考答案与推导
判断time小于expiry,等于时新建或合并回源,边界测试防止多活一个时间单位。
03模拟能证明QPS吗?
- 输入 / 条件
固定耗时纯内存模型- 输出 / 预期
不能
提示 没有真实资源。
展开参考答案与推导
它验证事件规则与相对回源次数,真实QPS还依赖CPU、网络、数据库和负载模型。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能设计一组可复现的商户查询实验,分别报告命中率、回源量、延迟和内容正确性。
本篇重点加练
可靠基线和正确性、延迟联合指标决定实验是否能支持策略结论,不能只追求吞吐数字。
- 基线
- 正确性和延迟指标
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01设计评审
准备商户缓存实验,机器同时运行日常开发服务。定义独立端口、键前缀、数据集及停止条件,并列出无故障基线必须采集的指标。
- 提交内容
- 实验清单与基线表
- 怎样算完成
- 不得访问公共或生产服务
- 区分预热阶段和测量阶段
练到的知识点隔离压测数据 · 基线
- 作业 02代码推演
构造 100 个商户请求,其中部分编号从未存在,另有一个热点键恰好到期。分别预测每类请求会触发的组件操作。
- 提交内容
- 请求分布与操作轨迹
- 怎样算完成
- 不存在与已过期分开计数
- 输入分布可固定重放
练到的知识点穿透实验 · 热点过期
- 作业 03原理问答
两次实验分别在完全冷缓存和充分预热后开始计时。定义基线条件与预热停止准则,说明哪些差异不能归因于策略本身。
- 提交内容
- 实验条件登记表
- 怎样算完成
- 记录数据分布和起始状态
- 注明无法控制的环境变量
练到的知识点基线
综合应用
- 作业 04原理问答
比较多个请求同时回源与大量不同键分散到期两种压力形态,设计能观察两者差异的计数与时间分布。
- 提交内容
- 指标定义与样本计划
- 怎样算完成
- 区分单键并发和多键总量
- 报告分布而非仅平均 TTL
练到的知识点并发回源 · TTL 抖动
- 作业 05面试场景
缓存依赖超时后系统仍返回内容,但返回了旧版本且 P99 变高。制定同时覆盖正确性、成功率和延迟的实验判定标准。
- 提交内容
- 服务契约与验收矩阵
- 怎样算完成
- 陈旧数据有明确允许窗口
- 超时请求纳入统计
练到的知识点依赖超时 · 正确性和延迟指标
- 作业 06编程实践
实现本地有界负载发生器,按固定种子运行正常、无效编号、热点到期和依赖变慢四组实验,每组结束后核对数据库回源次数。
- 提交内容
- 源码、参数和对照报告
- 怎样算完成
- 限制并发、时长与数据量
- 说明单进程模拟不能证明集群容量
练到的知识点隔离压测数据 · 基线 · 穿透实验 · 热点过期 · 并发回源 · TTL 抖动 · 依赖超时 · 正确性和延迟指标
- 作业 07设计评审
延迟统计只记录成功请求,错误请求被排除,低吞吐时 P99 反而下降。审查指标分母和样本选择导致的解释风险。
- 提交内容
- 指标口径与反例样本
- 怎样算完成
- 失败和取消有独立数量
- 说明分位数样本来源
练到的知识点正确性和延迟指标
- 作业 08编程实践
写本地实验报告核查器,检查两组运行的负载、预热、失败统计和版本正确性是否可比,再决定哪些性能比较有证据支持。
- 提交内容
- 代码和两组虚构报告
- 怎样算完成
- 缺项标为不可判定
- 不自动把更小延迟写成更优
练到的知识点基线 · 正确性和延迟指标
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
实验中策略甲降低回源却增加尾延迟,策略乙相反。为商户浏览和交易确认分别设定选型目标,提出补充实验以避免偶然结论。
- 提交内容
- 两场景评审报告
- 怎样算完成
- 阈值来自需求或明确假设
- 重复测量保持工作负载可比
练到的知识点基线 · 并发回源 · TTL 抖动 · 依赖超时 · 正确性和延迟指标
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
将固定并发实验改为用户按固定到达速率发送请求,探究请求排队与拒绝会怎样改变对缓存策略的评价。
- 提交内容
- 两种负载模型的实验计划
- 怎样算完成
- 统计发出、接纳和完成三类时间
- 本地运行有明确上限
练到的知识点基线 · 正确性和延迟指标
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Microsoft Azure:Cache-Aside 模式 (新标签页)↗
阅读读写步骤、过期策略与一致性限制,设计缓存失效和回源实验。
- 实践参考文档英文
Redis:EXPIRE (新标签页)↗
阅读 TTL、覆盖写入和更新命令的交互,检查缓存是否会意外永不过期。
- 深入参考文档英文
Redis:Key eviction (新标签页)↗
阅读 maxmemory 与淘汰策略,区分过期、内存回收和写入拒绝。
- 深入教程英文
MySQL 8.4:Performance Schema 入门 (新标签页)↗
阅读语句与等待事件汇总,区分 SQL 执行时间和资源等待。
- 深入参考文档英文
OpenJDK 17 ConcurrentHashMap 源码 (新标签页)↗
按指定版本定位
ConcurrentHashMap.putIfAbsent/ remove;对照源码分析理解实际调用边界。