读完这篇,你能做什么
能为读写路径设计键、TTL 与失效,并说明其无法自动保证强一致。
学习理由、准备要求与知识点
为什么学它
缓存提升读取效率同时引入陈旧与失败窗口,必须设计失效和恢复路径。
先读这些内容
知识点清单
- Cache-Aside 读取回填
- 数据库事实源
- 业务维度键
- 序列化契约
- SET EX 原子过期
- 事务后删除
- 失败重试与事件
- 并发旧值回填和一致性边界
运行前先确认Java17、Redis7.4;Jedis客户端源码固定v5.2.0。算法是单线程、逻辑时间驱动的本地缓存模型,不连接Redis,不提供数据库与缓存之间的强一致性。
01先弄懂它是什么
Cache Aside 的读法很直接:应用先找缓存,没有就查数据库,再把结果放回缓存。数据库通常保存准确信息,缓存是便于读取的副本。缓存键要区分真正不同的请求,例如商户、分类、分页和上架状态;如果只用分类编号,却把不同页面合并到一起,就算命中率很高,返回的也可能是错误数据。
缓存结果至少分三类:没有缓存、确实有数据、已确认数据不存在。数据库访问失败不等于不存在,不能把一次超时缓存成“没有这条记录”。对反复查询不存在 ID 的情况,可以短期保存这个结果,叫负缓存;新增记录时也要考虑删除对应空值。它只能帮助之后的请求,不能自动消除首次并发未命中的多次查库。
TTL 规定缓存条目的有效期限,却不能直接回答“数据库改完后,用户最多还会看到多久旧值”。例如一个慢查询先读到旧数据,等数据库已经更新、缓存也删除后,才把旧数据填回来;这时 TTL 会重新开始。先更新数据库再删缓存是常用办法,但删除失败仍需可靠重试或失效通知。数据库自己的本地事务不会自动把 Redis 也纳入同一次提交,不能仅凭调用顺序保证两边始终一致。
文中这几个词,先认一认
- 命中
- 找到了仍然有效的缓存项,它可能保存业务数据,也可能明确记录数据不存在。
- 负缓存
- 暂时保存数据库等可信来源确认“不存在”的结果,不能把查询失败当成不存在。
- 回源
- 缓存没有可用结果时,再去数据库等原始来源读取,需要控制并发和失败时的处理。
02看看它怎么工作
写值与设置TTL应尽量在同一Redis命令完成,避免SET成功后进程退出而EXPIRE未执行。进入redis-cli交互模式后,可输入SET study:dish:101 '{"id":101,"name":"番茄炒蛋"}' EX 300,再用GET和TTL观察。这里单引号属于redis-cli参数边界;从其他shell直接传递JSON时,还要遵守该shell的转义规则。
普通SET覆盖已有键会影响原TTL,若要保留需明确KEEPTTL等选项;业务缓存通常重新设置本次有效期。空值缓存使用可区分的封装格式,不能与合法空字符串混淆。写缓存失败时,读请求可能仍返回已读到的数据库结果,但必须记录回填失败并防止持续回源压垮数据库。
更新路径常先提交数据库事务,再删除相关缓存。删除失败应有有界重试或可靠事件恢复。并发窗口例如读者R拿到旧值,写者W提交并删除,R随后回填旧值;这说明顺序口诀本身不够。强一致敏感决策可以直接访问权威数据,展示缓存则根据业务容忍窗口选择版本、互斥或失效协议。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["查询业务Key"] --> B{"缓存条目有效"}
B -->|"是"| C["返回值或明确不存在"]
B -->|"否"| D["受控访问数据库"]
D --> E{"读取成功"}
E -->|"否"| F["故障处理,不写负缓存"]
E -->|"是"| G["值与TTL一起回填"]
G --> C
H["数据库更新提交"] --> I["删除相关缓存"]
I --> J["失败重试与恢复"]按顺序看,每一步做了什么
- 定义Key维度、序列化格式、正负缓存状态与TTL。
- 验证首次回源、第二次命中、恰好过期三种读行为。
- 数据库提交后使相关缓存失效,并测试失败恢复。
- 模拟缓存不可用,给数据库回源设置容量和超时预算。
- 分别观察命中率、回源量、错误和旧值窗口。
03对照实现代码
Jedis v5.2.0 · SetParams#ex、#px、#keepTtl、#addParams
原创Java命令参数模型,非源码节选。只演示值与EX一起形成参数,并主动检查正TTL;真实SetParams主要保存选项,服务端语义由Redis处理。
import java.util.*;
class SourceModel {
static List<String> setWithExpiry(String key,String value,long seconds) {
Objects.requireNonNull(key);
Objects.requireNonNull(value);
// 应用提前拒绝没有业务意义的非正有效期。
if (seconds<=0) throw new IllegalArgumentException("ttl");
// 值与有效期组成同一SET参数序列,避免两条命令间的窗口。
return List.of("SET",key,value,"EX",Long.toString(seconds));
}
// 本类只组织参数,不代表已经发送或被Redis确认。
}SetParams将存在条件与过期选项保存在对象中,ex和px分别使用秒与毫秒,单位必须跟随接口一起理解。
addParams把已选择选项追加到命令参数,帮助客户端将SET和TTL表达为一次操作,而不是先写值后另发过期。
该版本多次设置过期选项会覆盖相应字段;不要认为链式调用能同时启用互斥的所有模式。
客户端构建参数成功不代表网络发送或服务端执行成功。Redis过期、持久化和复制的机制不在这个Java类中。
代码先调用谁,再调用谁
- 应用构建
SetParams - ex或px记录过期选项
addParams加入命令参数- Jedis连接发送命令
- Redis执行并返回结果
04动手写一个例子
容量有界的正负缓存与TTL读取模型
实现单线程Cache Aside缓存,容量有限、按最近使用淘汰。正结果TTL10、确认不存在TTL2;逻辑时间单调递增,到期时回源。来源异常不得缓存为不存在;容量和TTL单位均由模型显式规定。
- 输入 1
同一存在Key在0和1时刻读取- 输出 1
只回源一次
- 输入 2
不存在Key在0、1、2读取- 输出 2
前两次共享负缓存,时刻2再次回源
Entry存Optional值和到期时间,使有效负缓存与没有Entry不同。读取先检查期限,到期相等也视为失效;只有来源函数正常返回后才建立新Entry。
访问顺序LinkedHashMap保存最近使用次序,成功回填后超过容量就移除最旧条目。逻辑时间由调用者给出并要求不倒退,使自测不依赖真实等待。
缓存只保存有限条目,但单线程模型没有请求合并。真实多线程同时未命中时仍可重复回源,单机single-flight或互斥也只能覆盖对应实例与协议范围。
为什么这样写是对的缓存条目数不超过capacity;有效条目只来自一次成功来源读取,来源异常不会产生负缓存。
哈希和访问顺序链维护均摊常数,容量为C;本题每次插入最多淘汰一个条目。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
import java.util.function.Function;
public class Main {
record Entry(Optional<String> value,long expires) {}
static class Cache {
final int capacity;
final Function<String,Optional<String>> origin;
final LinkedHashMap<String,Entry> entries=new LinkedHashMap<>(16,0.75f,true);
long last=-1;
Cache(int capacity,Function<String,Optional<String>> origin) {
if (capacity<1) throw new IllegalArgumentException("capacity");
this.capacity=capacity; this.origin=origin;
}
Optional<String> get(String key,long now) {
if (now<0||now<last) throw new IllegalArgumentException("clock");
last=now;
Entry cached=entries.get(key);
if (cached!=null&&now<cached.expires()) return cached.value();
entries.remove(key);
// 来源异常直接传播,不把失败翻译成不存在。
Optional<String> value=Objects.requireNonNull(origin.apply(key));
long ttl=value.isPresent()?10:2;
long expires=Math.addExact(now,ttl);
// 成功读取后才回填,负缓存有独立较短有效期。
entries.put(key,new Entry(value,expires));
if (entries.size()>capacity) {
Iterator<String> oldest=entries.keySet().iterator();
oldest.next(); oldest.remove(); // 淘汰最久未使用项。
}
return value;
}
void invalidate(String key) { entries.remove(key); }
}
static void eq(Object expected,Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
}
public static void main(String[] args) {
int[] loads={0};
Cache cache=new Cache(2,key->{loads[0]++;return Optional.of(key);});
eq(Optional.of("a"),cache.get("a",0));
cache.get("a",1); eq(1,loads[0]);
cache.get("a",10); eq(2,loads[0]);
cache.get("b",10); cache.get("a",10); cache.get("c",10);
cache.get("b",10); eq(5,loads[0]);
int[] misses={0};
Cache negative=new Cache(2,key->{misses[0]++;return Optional.empty();});
negative.get("x",0); negative.get("x",1); eq(1,misses[0]);
negative.get("x",2); eq(2,misses[0]);
int[] failures={0};
Cache broken=new Cache(1,key->{failures[0]++;throw new IllegalStateException("database");});
for (int i=0;i<2;i++) {
try { broken.get("x",i); throw new AssertionError("expected failure"); }
catch (IllegalStateException expected) {}
}
eq(2,failures[0]); eq(0,broken.entries.size());
cache.invalidate("b"); cache.get("b",11); eq(6,loads[0]);
System.out.println("PASS 9 cache-aside assertions");
}
}这些特殊情况也要试一试
- 过期时间等于当前时间即失效,不能多保留一个时间单位。
- 负缓存正常命中与数据库异常分别处理;异常后再次请求仍回源。
- 容量淘汰可能让TTL尚未结束的条目提前消失,因此TTL不是最短驻留承诺。
进阶选读 · 请求合并与失效版本的并发协议
场景热点Key并发过期导致大量回源,且旧读可能在写者失效后重新回填。
做法对同Key合并本实例正在执行的加载任务,设置等待预算并在失败后清理;若需要拒绝旧回填,加入与写入失效协调的版本或代际检查,并分析检查与回填是否原子。
复杂度协调表按活跃Key占空间,等待者数和来源并发需额外上限;版本协议成本取决于存储与原子机制。
好处与代价请求合并只减少重复工作,不自动提供跨实例强一致;版本检查若与写者没有统一协议,仍可能留下竞态。
05项目里怎么用
缓存坏了之后,不能不看容量就让所有请求直接查数据库,否则可能把数据库也拖慢。可以限制进入的请求数、同时查库的数量和等待时间;业务允许时,再返回明确的替代结果。还要分别统计“确实没有数据”“等待超时”和“服务出错”,不能都记成一次缓存未命中,否则后面很难判断压力和错误从哪里来。
负缓存能保护重复无效查询,但新增数据要及时失效。只有空值成功写入且缓存可用、未过期也未淘汰时,后续重复请求才不回源;首次并发仍可能多次读取数据库。随机TTL抖动可分散一批键同时过期,不能修复单热点无限并发。
不同接口对旧数据的容忍度不同。确认价格、扣库存等关键决定,应由保存真实业务事实的规则裁定;展示内容可以允许明确的一段延迟。数据库更新后若删缓存失败,要留下可恢复记录;如果靠 TTL 兜底,还要考虑它从何时计时,以及旧查询稍后回填的可能,不能随意保证固定多久必定最新。
容易踩的坑
- 把数据库异常写成空值缓存,持续遮蔽真实故障。
- 把TTL当成条目一定驻留到期的保证,忽略淘汰。
- 数据库提交后删缓存就声称跨系统强一致。
- 缓存宕机时无限回源,将数据库拖垮。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 短TTL | 减少旧副本长期存活。 | 提高回源与重建成本。 | 可容忍短暂旧值且重建便宜的数据。 |
| 负缓存 | 减少重复不存在查询。 | 新增记录可能被暂时遮挡。 | 不存在查询明显且有失效策略的场景。 |
| 请求合并 | 减少同Key并发重复加载。 | 需超时、失败清理与容量上限。 | 热点回源成本较高的查询。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01缓存缺失与负缓存相同吗?
展开参考答案与推导
不同,前者需要读取来源,后者表示近期成功确认不存在。
02SET后再EXPIRE有什么窗口?
展开参考答案与推导
SET成功而EXPIRE未执行时可能留下无预期TTL的键,宜一次命令设置。
03TTL能单独保证更新后五分钟内一定新鲜吗?
展开参考答案与推导
不能直接这样推断,旧读取可能稍后回填并重新计时,还需分析并发与恢复。
换个情况,还会处理吗
01数据库短暂超时后用户持续看到不存在怎么办?
展开参考答案与推导
检查是否把异常写成负缓存,区分来源失败和确认不存在并修复缓存。
02负缓存有效期内仍多次回源正常吗?
展开参考答案与推导
可能首次并发未命中、写入失败、缓存不可用或被淘汰,需对应证据。
03写后删缓存仍偶尔读旧值怎么办?
展开参考答案与推导
分析旧读回填窗口,按一致性要求选择版本协议、失效恢复或直接读取权威数据。
写代码试一试
01怎样验证过期边界?
- 输入 / 条件
到期时间10,读取时间9和10- 输出 / 预期
9命中、10回源
提示 使用严格小于。
展开参考答案与推导
断言来源计数仅在10增加,避免依赖真实sleep。
02怎样验证异常不变成负缓存?
- 输入 / 条件
来源连续抛异常- 输出 / 预期
两次都调用来源,缓存仍空
提示 只有正常返回才写Entry。
展开参考答案与推导
检查调用计数和缓存大小,不只检查响应异常。
03如何验证LRU淘汰?
- 输入 / 条件
容量2,访问a、b、a后插c- 输出 / 预期
b被淘汰
提示 访问顺序而非插入顺序。
展开参考答案与推导
再次读b观察来源计数增加,并确认a此前访问更新了最近使用位置。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能为读写路径设计键、TTL 与失效,并说明其无法自动保证强一致。
本篇重点加练
重点强化缓存键的业务维度与并发新鲜度合同,检查缓存是否返回正确用户的正确版本。
- 业务维度键
- 并发旧值回填和一致性边界
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
为课程详情读取画出缓存命中、缓存缺失、数据库不存在和数据库读取失败四条路径,定义每条路径可返回的信息与事实依据。
- 提交内容
- 四分支流程图和状态合同。
- 怎样算完成
- 不存在与读取失败区分
- 事实源职责明确
练到的知识点Cache-Aside 读取回填 · 数据库事实源
- 作业 02设计评审
同一商品在租户、语言和价格版本不同的情况下展示不同信息。规定缓存键与值的版本规则,并说明旧版本程序读取新值时如何被发现。
- 提交内容
- 键字段规范、值结构和兼容性测试样本。
- 怎样算完成
- 业务维度完整
- 序列化格式可演进
练到的知识点业务维度键 · 序列化契约
综合应用
- 作业 03排错实验
用本地 Redis 和独立练习数据库测试写入带 TTL 的缓存及数据库更新后的失效流程;在受控测试里注入缓存命令失败,记录数据库提交状态和缓存状态。
- 提交内容
- 隔离实验、自写过期边界测试与运行证据。
- 怎样算完成
- 只操作题目专用键前缀
- 明确 TTL 写入和提交时序
- 失败证据可复现
练到的知识点SET EX 原子过期 · 事务后删除
- 作业 04面试场景
t1 读请求取到旧数据库值,t2 写请求提交新值并删除缓存,t3 读请求准备回填。分析可观测结果与业务新鲜度要求,提出验收条件而非只写操作口诀。
- 提交内容
- 时间线、候选策略及新鲜度验收规格。
- 怎样算完成
- 涵盖并发回填
- 区分最终恢复与每次读取承诺
练到的知识点失败重试与事件 · 并发旧值回填和一致性边界
- 作业 05编程实践
实现注入时钟的本地缓存读取模型,区分缓存未命中、已缓存空结果、正常值与加载异常;输入键 A 的数据源调用次数须可统计。
- 提交内容
- Java 代码、自写时间/异常/空值边界测试及运行证据。
- 怎样算完成
- 状态可以独立判别
- 测试不依赖睡眠
- 加载失败政策写入合同
练到的知识点Cache-Aside 读取回填 · 数据库事实源 · 序列化契约 · SET EX 原子过期
- 作业 06编程实践
实现带租户与语言的本地详情缓存模型,加载函数可由测试暂停;安排两次不同版本加载交错完成,记录每个调用和缓存最终状态。
- 提交内容
- Java 模型、自写隔离/交错/边界测试与运行证据。
- 怎样算完成
- 键维度有交叉样本
- 新鲜度政策由测试前声明
练到的知识点业务维度键 · 并发旧值回填和一致性边界
- 作业 07设计评审
商品公开资料和会员价格准备共用缓存值,会员等级可能改变。分析键和值需要携带的身份维度,并制定权限变化后的读取验收。
- 提交内容
- 维度表、数据流和权限变更样本。
- 怎样算完成
- 共享粒度有业务依据
- 公开与个性化信息边界明确
练到的知识点业务维度键
- 作业 08面试场景
缓存 TTL 为 30 秒,回源任务最多耗时 40 秒。产品要求修改后 35 秒内所有读取都满足新鲜度政策。列出评估承诺所需时序与额外前提。
- 提交内容
- 最慢路径时间线和可承诺范围。
- 怎样算完成
- 回源起止和缓存写入时间均可见
- 承诺不只来自 TTL 数字
练到的知识点并发旧值回填和一致性边界
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
完成事件处理基础后:失效事件可能重复、乱序或暂时丢失,设计一个可监测的恢复流程,并定义积压、重试和人工介入的边界。
- 提交内容
- 事件合同、监测指标与故障演练矩阵。
- 怎样算完成
- 重复与乱序各有验收
- 明确不可保证的并发一致性范围
练到的知识点事务后删除 · 失败重试与事件 · 并发旧值回填和一致性边界
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
详情缓存改为可离线使用的客户端副本,用户可能三天不联网。重新定义键身份、版本和过期后的用户可见行为,比较便利性与信息正确性。
- 提交内容
- 离线合同、版本变化样本和方案取舍。
- 怎样算完成
- 离线限制明确
- 业务维度迁移到客户端后仍能验证
练到的知识点业务维度键 · 并发旧值回填和一致性边界
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Microsoft:Cache-Aside 模式 (新标签页)↗
阅读读缓存、回填、更新失效和一致性限制,设计可恢复的数据路径。
- 实践参考文档英文
Redis:SET (新标签页)↗
阅读 NX、EX
/PX等选项,优先把值与过期时间在一次命令中设置。 - 深入参考文档英文
Redis:EXPIRE (新标签页)↗
检查 TTL 生命周期与更新行为,区分缓存副本、登录态和持久记录。
- 实践实践指南英文
Caffeine:为什么刷新期间还能读到旧缓存 (新标签页)↗
先读 Refresh 的短示例和失败说明,暂跳批量加载与框架整合。画出“写入旧值—达到刷新条件—一次读取触发刷新—成功或失败”两条时间线,标注何时可能返回旧值;不要把
refreshAfterWrite当作固定周期任务。 官方仓库文档公开可读。Caffeine 3.x 需要 Java 11 或以上及项目依赖;这是进程内缓存,运行不需要 Redis 服务,也不自动同步多个应用实例。 - 深入参考文档英文
Jedis v5.2.0 SetParams (新标签页)↗
查看Java客户端如何组织参数,区分命令构建和服务端执行。