qinghuan
← 黑马点评 / 全部笔记

单元 107 / 黑马点评

P1 · 重点提升L3 · 深入

黑马点评演练:缓存失效时,数据库会承受多少请求

分别模拟不存在的商户、热点过期和缓存不可用,在同一时间段同时检查数据正确性与数据库压力。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能设计一组可复现的商户查询实验,分别报告命中率、回源量、延迟和内容正确性。

学习理由、准备要求与知识点

为什么学它

缓存策略只有在过期、热点和依赖失败的条件下验证,才能看清回源压力与一致性代价。

先读这些内容

知识点清单

  • 隔离压测数据
  • 基线
  • 穿透实验
  • 热点过期
  • 并发回源
  • TTL 抖动
  • 依赖超时
  • 正确性和延迟指标

运行前先确认完整算法使用 Java 17 标准库;JDK 源码基线为 OpenJDK 17 GA。示例是内存中的机制模型,外部服务接入需要另外配置和验证。

01先弄懂它是什么

测缓存速度前,先确认返回的数据对不对。准备一个存在的商户、一个不存在的编号和一个高频访问的键,记录数据版本、并发数、请求到达速度和测试时间。在同一时间段查看缓存命中率、数据库请求数、错误率和耗时。旧数据返回得很快,也不代表满足了业务对新鲜度的要求。

不同故障要分开模拟:反复查不存在的数据叫穿透;热点键过期后,大量请求一起查数据库叫热点失效问题;许多键同时过期则会集中增加负担。短期缓存“不存在”、合并同一键的重建请求、错开到期时间,分别解决不同问题。空值缓存也会过期或被淘汰,首次并发未命中仍可能产生多次数据库查询。

缓存不可用时直接让所有流量访问数据库可能放大故障。需要根据容量设置总请求预算、限流、受限回源或可接受旧值策略。扩大连接池不一定提高吞吐,数据库锁等待和CPU已饱和时可能更糟,实验必须同时观察连接获取和查询执行时间。

文中这几个词,先认一认

请求合并
同一个键已经有人在重建时,后来的请求等待并共享这次结果,减少重复查询。
回源放大
缓存未命中后,许多请求同时涌向数据库,使原本较少的查询突然增加。
可证伪预期
提前写明条件、观察指标和什么结果算方案无效,让实验能发现反例而非只证明自己正确。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

本题把时间当成传给程序的数字,模拟一个热点键怎样重新加载:输入每个请求何时到达、查数据库固定要多久,以及缓存能保留多久。最初没有缓存;每次查询完成后,才写入缓存并开始计算 TTL。新请求到达时,先处理已经完成的查询,再判断缓存能不能用。模型假定每次都查到同样数据、查询都成功,也没有网络或淘汰,因此它只用于观察这套处理规则。

不合并时,同一时刻三次未命中会启动三次回源;合并时后两次等待第一份在途结果,回源数从三变一。合并减少数据库工作,但这三位请求者仍要等待首次回源完成,不能把减少回源数直接说成每个请求都零延迟。

程序用最小堆按完成时间排列尚未结束的数据库查询,最早完成的排在前面。每来一个请求,先取出已经完成的查询并更新缓存期限,再处理这个请求。开启合并时,同一时刻最多只保留一份正在进行的查询;关闭合并时,可以同时查询多次,每次完成都回填缓存。这个比较只说明理想条件下少做了多少重复工作,不能给出真实系统每秒处理多少请求、查询多快或性能提升了百分之几。

正在加载流程图…

图中状态只描述本篇可执行机制;业务调用仍需由外层应用负责。
查看流程图源码(Mermaid)
flowchart TD
  A["固定请求到达与数据"] --> C["缓存命中判断"]
  C --> H["命中返回"]
  C --> M["同键在途合并"]
  M --> D["受限回源与回填"]
  D --> V["内容 延迟 回源压力核对"]

按顺序看,每一步做了什么

  1. 建立固定数据和相同负载的预热基线。
  2. 分别测试不存在ID、单热点过期和缓存超时。
  3. 每次只改变一个因素并记录响应内容与回源压力。
  4. 对比空值、合并、旧值或限流策略的代价。
  5. 恢复配置后重跑基线,说明真实流量尚未覆盖的条件。

03对照实现代码

OpenJDK 17 ConcurrentHashMap 源码 (新标签页)↗

OpenJDK 17 GA,jdk-17+35 · ConcurrentHashMap.putIfAbsent / remove

下方为原创 Java 教学片段,调用已核验的 JDK API 说明局部机制;不是该类原样源码,不代表Redis或数据库内核实现。完整程序见算法模块。

核心机制代码 · java
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 的情况,但不等于数据库查询也会被取消。缓存该写哪个版本、多个实例怎样协调、能否返回旧值,都需要另外设计。

代码先调用谁,再调用谁

  1. 缓存未命中
  2. putIfAbsent选择本地重建负责人
  3. 其他请求等待共享结果
  4. 受限加载并回填
  5. 条件清理在途映射

04动手写一个例子

热点重建的离散事件模拟

输入非递减非负到达时刻、正回源耗时和TTL,比较是否合并请求,输出回源数、峰值在途数及各请求等待时长。只有一个键,所有回源成功且值相同。Java17自测。

输入 1
到达[0,0,0],回源10,合并开启
输出 1
回源1,峰值1,等待[10,10,10]
输入 2
相同输入关闭合并
输出 2
回源3,峰值3

回源完成事件保存在优先队列中。每次到达先处理完成时间不晚于当前的事件,确保恰好完成时的新请求可以命中已回填数据。

缓存有效期采用严格小于判断,恰好到期即未命中。若开启合并且已有在途回源,复用它的完成时间;否则新建一次回源并更新峰值。

等待时长等于目标完成时刻减到达时刻,命中为零。用精确加法检查完成与到期时间,避免long溢出伪装为立即完成或永不过期。

为什么这样写是对的堆中只含当前时刻之后的回源完成事件;开启合并时堆大小最多一,loads等于实际创建完成事件的次数。

时间 O(n log n),合并模式O(n)额外空间 O(n)

未合并最多n个回源事件进出堆,每次对数;合并模式堆容量最多一。结果等待数组长n,模拟不执行真实线程或数据库。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能设计一组可复现的商户查询实验,分别报告命中率、回源量、延迟和内容正确性。

本篇重点加练

可靠基线和正确性、延迟联合指标决定实验是否能支持策略结论,不能只追求吞吐数字。

  • 基线
  • 正确性和延迟指标

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    准备商户缓存实验,机器同时运行日常开发服务。定义独立端口、键前缀、数据集及停止条件,并列出无故障基线必须采集的指标。

    提交内容
    实验清单与基线表
    怎样算完成
    • 不得访问公共或生产服务
    • 区分预热阶段和测量阶段

    练到的知识点隔离压测数据 · 基线

  2. 作业 02代码推演

    构造 100 个商户请求,其中部分编号从未存在,另有一个热点键恰好到期。分别预测每类请求会触发的组件操作。

    提交内容
    请求分布与操作轨迹
    怎样算完成
    • 不存在与已过期分开计数
    • 输入分布可固定重放

    练到的知识点穿透实验 · 热点过期

  3. 作业 03原理问答

    两次实验分别在完全冷缓存和充分预热后开始计时。定义基线条件与预热停止准则,说明哪些差异不能归因于策略本身。

    提交内容
    实验条件登记表
    怎样算完成
    • 记录数据分布和起始状态
    • 注明无法控制的环境变量

    练到的知识点基线

综合应用

  1. 作业 04原理问答

    比较多个请求同时回源与大量不同键分散到期两种压力形态,设计能观察两者差异的计数与时间分布。

    提交内容
    指标定义与样本计划
    怎样算完成
    • 区分单键并发和多键总量
    • 报告分布而非仅平均 TTL

    练到的知识点并发回源 · TTL 抖动

  2. 作业 05面试场景

    缓存依赖超时后系统仍返回内容,但返回了旧版本且 P99 变高。制定同时覆盖正确性、成功率和延迟的实验判定标准。

    提交内容
    服务契约与验收矩阵
    怎样算完成
    • 陈旧数据有明确允许窗口
    • 超时请求纳入统计

    练到的知识点依赖超时 · 正确性和延迟指标

  3. 作业 06编程实践

    实现本地有界负载发生器,按固定种子运行正常、无效编号、热点到期和依赖变慢四组实验,每组结束后核对数据库回源次数。

    提交内容
    源码、参数和对照报告
    怎样算完成
    • 限制并发、时长与数据量
    • 说明单进程模拟不能证明集群容量

    练到的知识点隔离压测数据 · 基线 · 穿透实验 · 热点过期 · 并发回源 · TTL 抖动 · 依赖超时 · 正确性和延迟指标

  4. 作业 07设计评审

    延迟统计只记录成功请求,错误请求被排除,低吞吐时 P99 反而下降。审查指标分母和样本选择导致的解释风险。

    提交内容
    指标口径与反例样本
    怎样算完成
    • 失败和取消有独立数量
    • 说明分位数样本来源

    练到的知识点正确性和延迟指标

  5. 作业 08编程实践

    写本地实验报告核查器,检查两组运行的负载、预热、失败统计和版本正确性是否可比,再决定哪些性能比较有证据支持。

    提交内容
    代码和两组虚构报告
    怎样算完成
    • 缺项标为不可判定
    • 不自动把更小延迟写成更优

    练到的知识点基线 · 正确性和延迟指标

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    实验中策略甲降低回源却增加尾延迟,策略乙相反。为商户浏览和交易确认分别设定选型目标,提出补充实验以避免偶然结论。

    提交内容
    两场景评审报告
    怎样算完成
    • 阈值来自需求或明确假设
    • 重复测量保持工作负载可比

    练到的知识点基线 · 并发回源 · TTL 抖动 · 依赖超时 · 正确性和延迟指标

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    将固定并发实验改为用户按固定到达速率发送请求,探究请求排队与拒绝会怎样改变对缓存策略的评价。

    提交内容
    两种负载模型的实验计划
    怎样算完成
    • 统计发出、接纳和完成三类时间
    • 本地运行有明确上限

    练到的知识点基线 · 正确性和延迟指标

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗