qinghuan
← 黑马点评 / 全部笔记

单元 79 / 黑马点评

P0 · 必会主线L2 · 进阶

黑马点评:商户缓存与一致性边界

从商户详情的读写流程出发,分清没缓存、缓存不存在和缓存旧值,再检查更新与删除之间的失败窗口。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能写出缓存读取与更新路径,并用时间线解释为什么仍可能短暂读到旧值。

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

为什么学它

商户查询把缓存收益和一致性代价放在同一条业务链路上,是理解缓存模式的重要练习。

先读这些内容

知识点清单

  • 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["新加载携带代次一读取并回填"]

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

  1. 建立未命中、空值标记、对象值三态读取。
  2. 先验证数据库提交再失效的正常路径与失败补偿。
  3. 用可控暂停复现旧读在失效之后回填的时序。
  4. 对更严格需求设计版本或代次协议,并列出数据源新鲜度与恢复前提。

03对照实现代码

ConcurrentHashMap.compute 的原子映射边界 (新标签页)↗

OpenJDK jdk17u jdk-17.0.16+8 · ConcurrentHashMap#compute(K,BiFunction)

以下是原创缓存代次片段,用真实 JDK API 表达局部原子检查;不是 JDK 源码节选或 Redis 服务端实现。片段只说明单进程协调,不包含 TTL 与回源。

核心机制代码 · java
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 切换后仍保留代次。

代码先调用谁,再调用谁

  1. 加载开始读取当前代次
  2. 原子区外查询数据库
  3. 更新方推进代次并清值
  4. compute 比较代次
  5. 接受新回填或拒绝旧回填

04动手写一个例子

保留失效代次的缓存三态与回填票据

缓存支持 MISS、ABSENT、VALUE 三态。加载持有键和代次票据,失效推进代次,回填只接受当前代次并设置 TTL。值过期只清空值,不能重置代次;时间由测试显式输入。

输入 1
票据代次0;失效到1;票据0回填old
输出 1
拒绝,仍为MISS
输入 2
代次1回填ABSENT,now10,TTL5
输出 2
14时ABSENT,15时MISS

状态分成稳定代次与可过期值,删除值不能删除用于拒绝旧请求的证据。

回填先验证有效期和票据代次,再保存对象或空值标记;空值是明确 Value,不与不存在的 Cell 混淆。

用顺序可控的交错调用模拟竞态,避免依赖线程睡眠碰巧触发;每一条时间线都对应一个明确预期。

为什么这样写是对的一个键完成失效后,所有更旧代次票据永久失去在该状态生命周期内回填的资格;有效缓存值要么明确为空值标记,要么包含非空对象。

时间 平均每次 O(1)额外空间 O(k)

哈希查找与替换平均常数;k包括曾创建但当前无缓存值的代次元数据,不能只按当前命中键数估计内存。

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

展开收起代码 · Main.java · 完整可运行程序Java · 69 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

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

这次练什么

能写出缓存读取与更新路径,并用时间线解释为什么仍可能短暂读到旧值。

本篇重点加练

提交后失效仍存在跨事务与迟到读取窗口,需要用历史而非单次命中率检验一致性。

  • 提交后失效
  • 旧值回填窗口

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

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

基础必做

  1. 作业 01原理问答

    商户详情可能不存在、存在但未营业或暂时无法查询。为这三种状态设计缓存表示,解释每种表示能支持的客户端行为。

    提交内容
    缓存状态表
    怎样算完成
    • 区分业务状态与依赖故障
    • 声明缓存值及有效期含义

    练到的知识点Cache Aside · 未命中与空值标记

  2. 作业 02面试场景

    同一小时出现大量随机商户编号请求和一个热门商户突发访问。分别定义识别证据、对数据库的压力路径及可接受的降级内容。

    提交内容
    两份诊断清单
    怎样算完成
    • 给出可区分两类流量的指标
    • 覆盖首次访问与已缓存阶段

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

  3. 作业 03原理问答

    更新商户时注册了提交后回调,但进程在提交完成与回调运行之间退出。区分数据库结果、回调执行证据及客户端返回状态。

    提交内容
    三类状态对照表
    怎样算完成
    • 允许结果未知
    • 不由异常直接推断数据库回滚

    练到的知识点提交后失效

综合应用

  1. 作业 04代码推演

    一批商户缓存计划同秒到期,更新事务又可能回滚。设计 12 个键的到期试验表及两种事务结局的缓存操作时间线。

    提交内容
    到期样本与事务时间线
    怎样算完成
    • 说明抖动取值范围
    • 标明数据库提交可确认的时点

    练到的知识点TTL 抖动 · 提交后失效

  2. 作业 05设计评审

    管理员连续提交版本 11、12 的商户修改,失效消息重复且乱序,另有历史查询迟到。列出读者可能观察到的状态与恢复判据。

    提交内容
    事件排列与一致性边界说明
    怎样算完成
    • 包含迟到回填和重复失效
    • 为失败重试设置可观察终点

    练到的知识点旧值回填窗口 · 失效重试

  3. 作业 06编程实践

    用 Java 可控调度器模拟商户查询、更新、失效与回填,至少构造四种交错;记录每次响应版本及数据库版本,输出违背自定一致性契约的轨迹。

    提交内容
    模拟器、轨迹与契约
    怎样算完成
    • 可固定顺序重复运行
    • 注明未验证真实 Redis 网络与跨进程行为

    练到的知识点Cache Aside · 未命中与空值标记 · 热点过期 · 旧值回填窗口 · 失效重试

  4. 作业 07代码推演

    查询数据库使用只读副本,主库商户版本已更新而副本尚未追上。分析读取票据新但数据源旧的组合,列出回填时需要说明的假设。

    提交内容
    版本与来源时间线
    怎样算完成
    • 区分缓存代次和数据库版本
    • 标注副本延迟来源

    练到的知识点旧值回填窗口

  5. 作业 08设计评审

    接口一次修改商户地址和营业时段,两个字段由不同缓存键展示。评审更新后页面可观察到的组合以及业务是否接受混合版本。

    提交内容
    跨键响应契约
    怎样算完成
    • 涵盖部分失效和迟到回填
    • 说明展示与交易使用的不同要求

    练到的知识点提交后失效 · 旧值回填窗口

  6. 作业 09编程实践

    编写本地历史检查器读取查询开始、数据库提交、缓存发布及响应版本事件,检查“更新完成后开始的读取满足某版本约束”这一自定契约。

    提交内容
    检查器、日志样例与报告
    怎样算完成
    • 含重叠请求和信息缺失样本
    • 不将模型检查结果当成真实集群证明

    练到的知识点提交后失效 · 旧值回填窗口

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

  1. 作业 10设计评审

    某商户允许 2 秒陈旧数据,但停业状态须尽快阻止下单。设计详情展示与交易校验的分工,并列出缓存不可用时的验收项目。

    提交内容
    读写策略与验收矩阵
    怎样算完成
    • 分别说明展示和下单约束
    • 包含恢复期间的回源预算

    练到的知识点穿透 · 热点过期 · TTL 抖动 · 提交后失效 · 旧值回填窗口 · 失效重试

思维拓展

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

  1. 作业 11思维拓展 · 反例构造

    构造一段命中率接近满分却仍持续返回业务不可接受内容的历史,说明单看命中率为何无法证伪该问题。

    提交内容
    反例历史与替代观测指标
    怎样算完成
    • 错误可追溯到具体版本
    • 不靠篡改统计数据制造反例

    练到的知识点提交后失效 · 旧值回填窗口

  2. 作业 12思维拓展 · 跨域迁移

    把失效协议迁移到浏览器离线商户列表:设备可能一周未联网且通知会丢失。重新定义恢复后数据新鲜度与更新确认的含义。

    提交内容
    离线协议评审
    怎样算完成
    • 覆盖客户端本地版本
    • 说明哪些原假设已不成立

    练到的知识点提交后失效 · 旧值回填窗口

完成后,再检验一次

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

继续阅读

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

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