1. 现实问题:加了缓存,文章却一直是旧的
文章详情读多写少,很适合缓存;但发布、改标题、删除文章后,如果只更新数据库,缓存仍可能返回旧内容。如果先删缓存再写数据库,写失败时下一次读又会把旧数据填回;如果先写缓存再写数据库,数据库失败则缓存领先。缓存不是数据库的副本按钮,而是一组有时序和失败窗口的设计。
先用 cache-aside(旁路缓存)说明:读缓存命中直接返回,未命中查库并回填;写入以数据库提交为事实边界,再失效缓存。之后再讨论并发和 TTL。
2. 最小可运行示例:显式的读写边界
public class ArticleCacheService {
private final ArticleRepository repository;
private final StringRedisTemplate redis;
public ArticleCacheService(ArticleRepository repository, StringRedisTemplate redis) {
this.repository = repository;
this.redis = redis;
}
public ArticleView find(long id) {
String key = "article:view:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) return ArticleView.fromJson(cached);
ArticleView fresh = repository.find(id);
redis.opsForValue().set(key, fresh.toJson(), Duration.ofMinutes(10));
return fresh;
}
@Transactional
public void rename(long id, String title) {
repository.rename(id, title);
// 事务提交后再删除,实际项目可用事件/事务同步器处理
redis.delete("article:view:" + id);
}
}
示例省略序列化和提交后回调的完整实现,重点是 key、TTL 和读写顺序。生产代码要处理 Redis 不可用时回源、序列化失败、缓存值大小和命名空间;不能让缓存故障直接把数据库打爆。
3. 调用链与对象变化
读请求先把 long id 拼成稳定 key,Redis 返回字符串或 miss;命中时字符串反序列化为 ArticleView,未命中时 Repository 从数据库行映射成 View,再序列化写入 Redis。两个对象不是同一个引用,也不共享数据库的实时变化。
写请求在事务中更新数据库,提交成功后删除 key;下一个读 miss 再回源填充。若删除发生在提交前而事务回滚,缓存暂时空但会重新读到旧数据库值;若删除失败,则旧值保留到 TTL 或补偿任务执行。把这些窗口画出来,比在方法上加一个注解更重要。
4. 为什么这样设计
Redis 是速度和容量优化,不是事实来源;TTL 防止永久旧数据,key 版本避免结构升级时解析旧值。序列化格式要有兼容策略,不能把 Java 类名和私有字段变化当作稳定协议。缓存粒度按读取用例设计,文章详情和列表缓存的失效范围不同。
热点 key 可能击穿:大量请求同时 miss;缓存空值能防止不存在文章反复回源;互斥锁、singleflight 或预热可以控制并发回源。雪崩来自大量 key 同时过期,要设置随机 TTL 和分批预热。任何策略都要考虑 Redis 故障时的降级和数据库容量。
5. 项目落点:把缓存放在服务用例而不是实体里
ArticleService 决定哪些读用例缓存,Repository 只负责数据库,Redis adapter 负责序列化和连接。发布/编辑/删除文章统一发出 cache invalidation 事件,列表 key 采用版本或标签命名,避免逐个扫描。监控 hit rate、miss、回源耗时、key 数量、内存淘汰和 Redis 错误。
练习:为详情缓存增加空值缓存和随机 TTL,模拟数据库更新后 Redis delete 失败;再设计一个补偿队列。用并发测试同时 miss 同一热 key,比较无保护、互斥锁和预热的数据库 QPS 与 P99 延迟。
6. 易错排查
- key 拼写不一致:集中生成 key,并把版本、租户和资源 id 写进测试。
- TTL 没生效:检查单位、Redis 命令、覆盖写入和客户端时区;查询 TTL 作为证据。
- 只更新缓存不更新数据库:重启或缓存淘汰后数据回退,数据库必须是事实边界。
- Redis 挂了全站超时:设置连接/命令超时、降级回源和限流,避免无限重试。
7. 一页复习
旁路缓存读是 hit 返回、miss 回源并回填;写是数据库提交后失效,Redis 负责速度不是事实。正确性来自 key、TTL、序列化、失效时序、并发保护和降级。缓存上线前要用读写竞态、Redis 故障和更新回滚测试证明,而不是看一次命中率。
缓存对象的状态要同时记录事实版本和缓存版本:数据库 article version=3,第一次回填得到 key article:view:7:v3;更新提交后版本=4,旧 key 必须失效或读逻辑拒绝旧版本。命中只说明 Redis 有值,不说明值新;TTL 是最后的保险,不是写后立即一致的证明。序列化失败时应回源或失败,而不是把半个 JSON 当有效对象。
项目中 ArticleService 负责 cache-aside 时序,ArticleCache 负责 key/序列化/TTL,Repository 负责事实数据,提交后事件或 outbox 负责失效。列表缓存、详情缓存和权限缓存的 key 空间分开,文章改标题不应误删整个 Redis。Redis 配置包括连接超时、命令超时、最大值大小和降级策略,写在运行配置和指标里。
排错时命中但旧值先查 key/version 和更新提交时机,miss 暴增查 key 拼写/TTL/淘汰,数据库被打爆查热点回源和并发保护,Redis 故障查是否无限重试,序列化异常查类字段兼容。缓存清除成功仍旧读旧值,可能是多实例本地缓存或 CDN,必须沿完整读取链确认每一层。
练习是为文章详情实现空值缓存、随机 TTL 和单 key 互斥回源;用两个线程同时 miss,测数据库调用次数;再让 Redis delete 抛异常,验证告警、回源和最终补偿。最后把读写时序写成测试名称,避免未来换 @Cacheable 后丢掉一致性假设。
缓存读写的对象状态要和数据库版本绑定:miss 时从数据库读出 ArticleView(version=3),序列化为 Redis 值并设置 TTL;hit 时得到的是副本,不能修改数据库;写事务提交后 version=4,删除详情和受影响列表 key;删除失败时旧值可能继续存在,必须通过 TTL、重试或版本校验收敛。缓存存在不等于内容新鲜。
@Cacheable、手写 StringRedisTemplate 和本地 Caffeine 的边界不同:注解方便,但 key、条件、序列化和失效时机仍要从生成结果检查;手写方式可表达回源/空值/锁,但需要自己的指标;本地缓存命中后可能绕过 Redis 和数据库,更新事件必须覆盖所有实例。先选择一条读取链,再决定工具。
项目文件把 key 生成、TTL、序列化和 Redis 命令集中在 infrastructure/cache/ArticleCache,Service 维护 cache-aside 时序,Repository 维护事实查询,outbox/event 维护提交后失效。列表缓存不要用全表扫描删除,使用版本前缀、标签集合或明确的失效清单。监控 hit/miss、回源并发、命令超时、淘汰和序列化错误。
排错时命中旧查 key/版本/更新提交,miss 暴增查 TTL/淘汰/拼写,数据库 QPS 飙升查热点击穿和空值缓存,Redis 故障查降级与无限重试,读到另一份旧值查本地缓存/CDN。练习是两个线程同时 miss 热点,比较无保护、互斥回源和预热;再让删除失败,验证告警和最终补偿。
验证清单:以文章 version=3 为初始事实,先执行 miss 回源并回填,再执行 hit;随后提交标题更新得到 version=4,观察详情 key、列表 key、数据库值、Redis 值和下一次读出的版本。让两个线程同时 miss 热点,分别测无保护、互斥回源和随机 TTL 的数据库调用次数;让 delete、序列化和 Redis 命令分别失败,记录回源、告警、降级和补偿结果。只有数据库提交成功后失效的时序才算写成功,不能用“Redis 有值”代替事实确认。
项目验收把 key、value、版本和生命周期写成表:key 包含租户/id/查询维度,value 是可兼容 DTO,TTL 是收敛保险,更新动作是提交后的删除或版本推进,读动作能识别旧版本。Service 负责 cache-aside,ArticleCache 负责序列化和指标,Repository 负责事实,outbox/event 负责跨实例失效。若本地缓存或 CDN 也存在,必须把它们加入读取链,不能只查 Redis。
练习复盘:为 hit、miss、空值、并发刷新、数据库更新、Redis 故障和旧版本读七条路径写状态表,验收输出同时包含返回版本、数据库查询次数、缓存命令耗时和最终一致结果。若 Redis 挂掉后所有请求无限等待,修正连接/命令超时、回源限流和降级策略;若命中率高但用户看到旧标题,优先查失效时序和多级缓存,而不是继续调 TTL。
进阶附录:分布式锁和缓存版本
分布式锁需要租约、持有者 token、超时和释放校验,不能用一个简单 SET/DEL 片段假设所有失败都可控。更轻量的策略是 key 版本或数据库版本号:读取同时比较版本,更新后使旧版本失效。强一致场景优先减少缓存范围,而不是叠加越来越复杂的锁。
本课按「Redis 数据结构、Spring Cache 与缓存一致性实践」的学习范围组织,正文与示例均为本站原创整理。