大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
Cache-Aside:本站缓存的读写路径
这一课你将能做到
- 画出 Cache-Aside 的完整读路径和写路径,说清每一步「谁先谁后、为什么」
- 解释「为什么删缓存而不是更新缓存」,并能讲出并发下的两种竞态窗口和兜底手段
- 读懂本站现成的 Caffeine 30 秒本地缓存(CacheConfig),理解它和 Redis 组成的「两层缓存」架构
为什么要学
前几课你掌握了命令和 TTL,但真正写业务时要回答一个系统设计问题:MySQL 和 Redis 之间的数据怎么保持一致?
本站例子:课程数据在 MySQL(cf_track 表),首页的 tracks 列表想用 Redis 顶住读流量。什么时候写 Redis?什么时候删?MySQL 更新后缓存里的旧数据怎么办?回答错一步就是「改了课程标题,前端还是旧的」这类线上问题。
Cache-Aside(旁路缓存)是工业界最常用的答案,也是面试「Redis 和 MySQL 一致性」的标准考点。本站已经用 Caffeine 做了第一层(30 秒本地缓存),Redis 是第二层——这一课把两层串起来。
先建立图像
Cache-Aside 的两条路径:
【读路径】
请求 → 查 Redis
├─ 命中 → 直接返回(大多数请求止步于此)
└─ miss → 查 MySQL → 写回 Redis(带 TTL)→ 返回
【写路径】(管理端改课程)
请求 → 更新 MySQL(权威,必须成功)
→ DEL Redis 的相关 key(列表 + 详情,成组失效)
(下次读走 miss,自然回源重建)
关键词:读时填充,写时失效。Redis 里永远只有「读出来的副本」,写请求不直接更新它。
跟着做
步骤 1:手工演练读路径
模拟缓存 miss 和填充(此 key 在第 12 课才真正接入,本步先理解流程):
DEL coderoadmap:tracks:list # 制造 miss
curl -s localhost:8080/api/tracks | head -c 200
docker exec redis-dev redis-cli EXISTS coderoadmap:tracks:list # 接入后应为 1
步骤 2:手工演练写路径
管理端更新课程后,必须删除两类 key:
DEL coderoadmap:tracks:list # 列表缓存(含该课程的聚合结果)
DEL coderoadmap:track:java-fundamentals # 详情缓存
为什么删列表 key:列表里嵌着课程摘要,改了标题不清列表缓存,首页还是旧标题——这就是「失效要成组」。
步骤 3:想清楚「删 vs 更新」
写路径为什么 DEL 而不是 SET 新值:
更新缓存的三个问题:
1. 并发写 A、B 两次,缓存可能被后到的旧写覆盖(AB 乱序)→ 脏数据
2. 更新的值如果还要算(聚合、关联查询),写路径变重
3. 可能根本没人读 → 白更新
删除的问题:只多一次回源 miss(成本可控,TTL 兜底)
步骤 4:并发 miss 的竞态与兜底
线程1 miss ──查MySQL───写缓存(旧值)──┐
线程2 miss ──查MySQL──写缓存──────┴─ 最后写的赢
回源期间 MySQL 数据又变了 → 缓存短暂不一致。解法(按成本递增):
- TTL 兜底:不一致窗口 ≤ TTL,本站可接受
- 短互斥锁:
SET mutex NX EX 5让重建串行(第 5 课的击穿方案) - 延迟双删(进阶):写后延迟几百毫秒再删一次
步骤 5:读本站第一层缓存 CacheConfig
打开 backend/src/main/java/com/codeforge/config/CacheConfig.java:
@EnableCaching
CaffeineCacheManager
expireAfterWrite 30 秒
maximumSize 200 条
cacheNames: roadmaps / tracks / questionsSummary / resources / articles / search
TrackService.listAll() 上有:
@Cacheable(cacheNames = CacheConfig.TRACKS, unless = "#result == null || #result.isEmpty()")
public List<Track> listAll() { ... }
deleteTrack 上有 @CacheEvict(cacheNames = ..., allEntries = true)——这就是写路径失效,只不过是本地层的。
步骤 6:两层缓存的分工图
请求 → Caffeine(进程内,30s,扛最热的读,零网络开销)
→ miss → Redis(跨实例共享,5 分钟级 TTL,扛第二波)
→ miss → MySQL
本站单机开发时只有 Caffeine 生效;引入 Redis 后,多实例各自的 Caffeine 失效不同步的问题由「30 秒短 TTL + Redis 兜底」消化。跨实例主动广播失效方案见第 10 课 Pub/Sub。
讲清楚要点
- Cache-Aside 由应用代码自己维护缓存(区别于 Redis 的 read-through 模式),好处是逻辑完全可控,坏处是每个读接口都要写样板代码——所以第 12 课会把它封装成 TrackCacheService。
- 写路径铁律:先 MySQL 后 DEL 缓存——顺序反了,MySQL 写失败时缓存已被删(无害),但「缓存更新成功而 MySQL 失败」就是脏缓存。
- 删缓存失败要有兜底:TTL 保证最终一致;更严格的场景加重试或订阅 binlog(进阶)。
- 失效要成组:改一条 track 影响列表 + 详情两个 key,漏删一个就是一个不一致 bug。
- 两层缓存的价值:Caffeine 挡住进程内重复读(0 网络成本),Redis 挡住跨实例重复读(1 次网络),MySQL 只承担真正的回源。
- 一致性强度是权衡出来的:本站用「短 TTL + 写时失效」达到最终一致,秒级窗口,对内容型站点足够;交易系统要求强一致就别把这类数据放缓存。
你可能会懵的地方
-
为什么是先更新 MySQL 再删缓存,而不是反过来? 反过来时:删了缓存 → MySQL 还没写完 → 并发读 miss 回源读了旧库值并写回缓存 → MySQL 写完 → 缓存留下脏数据直到 TTL。先写库后删,最坏是短暂 miss,不会留下脏数据。
-
@CacheEvict(allEntries=true) 会不会太暴力? 暴力但正确:track 数量少(几十个量级),全清的 miss 风暴可忽略;比精确清更不会漏。
-
为什么不直接只用 Caffeine? 多实例下各进程各一份,更新后 A 实例清了,B 实例还持有最多 30 秒旧数据;Redis 提供跨实例一致视图和更大容量。
-
「延迟双删」本站用了吗? 没有。它解决「回源线程把旧值写回」的窗口,本站用 TTL 兜底 + 数据低频变更,没必要上复杂度——工具尺寸匹配问题规模。
-
写路径删缓存失败了怎么办? 本站依靠 TTL 到期自然重建(最长 5 分钟旧数据);更严格系统用重试队列或 Canal 订阅 binlog 删缓存。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 写路径只更新 MySQL 不动缓存 | 长期旧数据 | 更新库后 DEL 相关 key |
| 删了详情忘了删列表 | 列表里旧摘要 | 失效成组:list + detail |
| 先删缓存再写 MySQL | 回源旧值写回脏缓存 | 先写库后删缓存 |
| 写路径直接 SET 新值进缓存 | 并发乱序脏写、白算聚合 | 写路径只删不更新 |
| 缓存里存写路径的中间态 | 缓存变成权威源 | 缓存只放只读副本 |
| 只用 Caffeine 且无 TTL 上限 | 多实例长期不一致 | expireAfterWrite 30s(本站现状) |
卡点急救
- 改了数据接口还是旧值:逐层查——Caffeine(≤30s 自己消失)→ Redis(DEL 对应 key)→ MySQL(确认写入成功)。
- DEL 了的 key 又出现旧值:并发回源写回,属 TTL 兜底窗口,等下一轮或加互斥锁。
- @Cacheable 方法内部调用不生效:同类内部方法调用不走代理,改从外部 Bean 调用或注入自身代理。
- 缓存对象反序列化报错:改了 DTO 结构,旧 JSON 字段对不上——DEL 全部相关 key 重建。
- Redis 挂了读路径全变慢:回源风暴。代码里 Redis 异常要 catch 降级直查 MySQL(第 12 课的封装会做)。
面试常问
-
Redis 和数据库的一致性怎么保证? Cache-Aside:读时填充、写时先更新 MySQL 再 DEL 缓存,靠 TTL 最终一致。要讲清楚「为什么删不更新」和并发窗口,可提延迟双删/binlog 订阅作为进阶方案。
-
为什么删缓存而不是更新缓存? 并发写乱序会覆盖出脏数据;更新可能白做(没人读);删除让下次读自然回源,逻辑最简单、最不易错。
-
说说你们的多级缓存。 Caffeine 30 秒/200 条本地层(@Cacheable/@CacheEvict)+ Redis 共享层(Cache-Aside,5 分钟±抖动)+ MySQL 权威层;单机开发可整体降级。兼顾性能与跨实例一致性。
自测三问
- 完整画出读路径和写路径的每一步顺序,标出哪一步失败会有什么后果。
- 先删缓存后写 MySQL 会出什么问题?推演一遍时序。
- 本站两层缓存各层的 TTL 和职责是什么?为什么 Caffeine 在前、Redis 在后?
小结一句话
Cache-Aside = 读时填充、写时先库后删、失效成组、TTL 兜底最终一致;本站是 Caffeine 30 秒 + Redis 5 分钟的两层实践。
延伸
- Cache-Aside 模式(微软云设计模式)
- 下一课:Spring Data Redis 接入——把今天的模式翻译成 Java:依赖、配置、RedisConfig 逐行读。