← 返回 Java 后端知识路线
阶段 04Spring 与后端

Redis 与缓存正确性:先画读写时序,再写注解

从文章详情缓存出发,理解 cache-aside、TTL、序列化、击穿和数据库提交后的失效时机。

第 32 / 35 篇
Redis缓存TTL一致性Spring Cache

先看这一课值不值得学

学完后,你手里多了哪些代码积木

让缓存和数据库在失败、并发和过期情况下仍保持可解释。

本课正式新增

语法 / API / 命令你必须会到什么程度
RedisTemplate / StringRedisTemplate读写 Redis 键值
@Cacheable / @CacheEvict声明缓存读取和失效
TTL限制缓存生命周期
cache-aside显式组织读缓存、查库、回填和失效

本课只借用,先别硬背

  • 分布式锁只说明适用边界,不作为本课默认方案

学完必须能独立写

  • 为文章详情增加缓存并正确失效
  • 解释穿透、击穿、雪崩和旧值回填
本课目录
  1. 1. 现实问题:加了缓存,文章却一直是旧的
  2. 2. 最小可运行示例:显式的读写边界
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:把缓存放在服务用例而不是实体里
  6. 6. 易错排查
  7. 7. 一页复习

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 与缓存一致性实践」的学习范围组织,正文与示例均为本站原创整理。