Skip to content

Redis 与数据库缓存一致性

Redis 与数据库一致性讨论的是:MySQL 里的事实数据发生变化后,Redis 里的缓存副本如何尽快、可靠地变成正确状态

商业系统里最常见的定位是:

MySQL 是事实源,Redis 是可重建的加速层。普通缓存不追求每一毫秒都强一致,而是控制不一致窗口,并通过重试、补偿、TTL、监控和降级保证最终一致。

学习目标

目标学完要能说明什么
知道定位为什么数据库是事实源,Redis 只是缓存副本
知道风险为什么双写缓存和数据库会出现并发、失败、旧值覆盖
知道方案Cache Aside、延迟双删、消息补偿、Binlog CDC、TTL 兜底怎么选
知道原理为什么推荐“先更新数据库,再删除缓存”,而不是直接更新缓存
会写 Demo能写读缓存、回源、事务提交后删缓存、删除失败重试
会做商业设计商品详情、资产详情、字典配置、权限、库存分别怎么缓存
会排查用户看到旧数据、删除失败、缓存击穿、数据库被打满怎么查

先明确 Redis 和数据库的分工

组件负责什么是否是最终事实
MySQL事务、约束、状态机、可靠持久化
Redis低延迟读取、热点数据、临时状态、可重建副本通常不是
本地缓存进程内热点数据、配置、字典不是
MQ 或 Binlog变更通知、异步补偿不是

如果是账户余额、支付状态、库存最终扣减这类强约束数据,最终判断必须回到数据库事务或专门业务服务。Redis 可以做加速、预扣、限流、互斥,但不能单独承担最终正确性。

为什么会不一致

只要数据库和 Redis 是两个存储,就一定存在时间差、失败和并发。

mermaid
flowchart TD
    A["写请求修改 MySQL"] --> B["MySQL 已经是新值"]
    B --> C["Redis 仍可能是旧值"]
    C --> D["读请求命中 Redis"]
    D --> E["用户看到旧数据"]

常见不一致来源:

来源具体表现后果
更新顺序先删缓存还是先写库并发读可能把旧值写回缓存
删除失败数据库成功,删 Redis 失败旧缓存一直存在
并发读写读请求回源和写请求交叉旧数据覆盖新缓存
事务时机数据库事务未提交就删缓存其他请求可能读到旧库并重建旧缓存
主从延迟回源读到数据库从库旧数据Redis 被写入旧值
本地缓存JVM 本地缓存没有失效Redis 对了,本地还旧
缓存击穿热点 key 失效后大量回源数据库压力暴涨

所以缓存一致性不是一个命令能解决的,而是一套读写顺序、失败重试、并发控制和兜底机制。

一致性边界

普通缓存系统通常不承诺:

  1. 写数据库成功后,所有读请求立刻读到新值。
  2. Redis 和 MySQL 每一刻都完全相同。
  3. 删除缓存永远成功。
  4. 消息或 Binlog 同步永远不延迟。
  5. 本地缓存、Redis、数据库可以零延迟同步。

通常要承诺:

承诺含义
不长期旧旧缓存不能无限期存在
能修复删除失败、消息失败后能重试
可观测能看到失败、延迟、命中率、回源量
可兜底TTL、补偿、人工重放能恢复
关键读正确下单、支付、权限判断等关键路径回事实源

一句话:缓存一致性重点不是消灭所有短暂不一致,而是避免短暂不一致变成长期错误。

最常用模式:Cache Aside

Cache Aside 也叫旁路缓存,是商业项目最常用的 Redis 缓存模式。应用代码自己负责读缓存、查数据库、写缓存、删缓存。

读流程

mermaid
flowchart TD
    A["读请求"] --> B["查 Redis"]
    B --> C{"是否命中"}
    C -- "命中" --> D["返回缓存"]
    C -- "未命中" --> E["查 MySQL"]
    E --> F{"是否存在"}
    F -- "存在" --> G["写 Redis 并设置 TTL"]
    F -- "不存在" --> H["写短 TTL 空值或直接返回"]
    G --> I["返回数据"]
    H --> I

写流程

推荐写流程是:先更新数据库,再删除缓存

mermaid
flowchart TD
    A["写请求"] --> B["开启数据库事务"]
    B --> C["更新 MySQL"]
    C --> D{"事务是否提交"}
    D -- "失败" --> E["返回失败,不动缓存"]
    D -- "成功" --> F["删除 Redis 缓存"]
    F --> G{"删除是否成功"}
    G -- "成功" --> H["完成"]
    G -- "失败" --> I["记录重试和告警"]

为什么是删除缓存,而不是更新缓存:下一次读请求会基于数据库最新值重建缓存,能减少并发更新时旧值覆盖新值的风险。

为什么不推荐直接更新缓存

很多初学者会想:数据库更新后,把 Redis 也更新成新值,不就一致了吗?

问题在于真实业务里缓存值经常不是数据库一行。

场景缓存值可能来自哪里
商品详情商品表、价格表、库存服务、类目表、标签表
用户权限用户表、角色表、菜单表、组织表、数据权限
资产详情资产表、字段表、机构表、标签表、采集任务表
首页统计多张表聚合、离线统计、实时计数

直接更新缓存的风险:

  1. 缓存组装逻辑复杂,写接口不一定能拿到完整字段。
  2. 多个写请求并发时,后完成的旧请求可能覆盖新缓存。
  3. 很多数据改完后短时间不会再读,更新缓存浪费资源。
  4. 部分字段变化会影响多个缓存 key,容易漏更新。
  5. 更新缓存成功但数据库事务回滚,会产生错误缓存。

因此通用方案是:写数据库后删除缓存,让读请求按最新数据库状态重建缓存

为什么不推荐先删缓存再写数据库

先删缓存再写数据库看起来也合理,但并发下会出问题。

mermaid
flowchart TD
    A["写请求删除 Redis"] --> B["缓存已空"]
    B --> C["读请求未命中 Redis"]
    C --> D["读 MySQL 旧值"]
    D --> E["把旧值写回 Redis"]
    E --> F["写请求更新 MySQL 新值"]
    F --> G["Redis 仍是旧值"]

这个问题的本质是:删除缓存和更新数据库之间有窗口。读请求可能在这个窗口里把旧数据库值重新写入缓存。

所以不建议把“先删缓存再写库”作为默认方案。除非配合延迟双删、强互斥、读写串行化等额外机制,否则容易留下旧缓存。

先写数据库再删缓存还有问题吗

有。它不是绝对强一致,只是综合风险最低。

mermaid
flowchart TD
    A["更新 MySQL 成功"] --> B["删除 Redis"]
    B --> C{"删除是否成功"}
    C -- "成功" --> D["下一次读回源重建"]
    C -- "失败" --> E["Redis 保留旧值"]
    E --> F["重试、TTL、Binlog 补偿"]

主要风险:

风险说明解决办法
删除缓存失败Redis 网络异常、超时、权限问题重试任务、MQ、告警
事务未提交就删除其他请求读旧库并重建旧缓存事务提交后再删除
主从延迟回源读从库旧数据关键回源读主库,或延迟删除
热点并发回源删除后大量请求查库互斥重建、逻辑过期
多级缓存Redis 删除了,本地缓存没删广播失效、本地 TTL

所以正确说法不是“先写库再删缓存就万无一失”,而是:它是最常见基础策略,还要配套失败重试、TTL、补偿和并发保护。

四种写法的并发全过程

缓存一致性最容易学成结论:“先写库再删缓存”。但面试和生产排查真正考的是:每种写法在并发下为什么会错。

写法一:先更新缓存,再更新数据库

mermaid
flowchart TD
    A["写请求"] --> B["先更新 Redis 为新值"]
    B --> C["更新 MySQL"]
    C --> D{"MySQL 是否成功"}
    D -- "成功" --> E["看起来一致"]
    D -- "失败" --> F["Redis 是新值<br/>MySQL 是旧值"]
    F --> G["读请求读到不存在的事实"]

问题:

  1. 数据库事务可能失败或回滚。
  2. Redis 已经暴露了新值。
  3. 用户读到的是数据库里并不存在的事实。
  4. 如果是价格、权限、库存,就可能造成业务错误。

这个方案不适合把 MySQL 作为事实源的普通业务缓存。

写法二:先更新数据库,再更新缓存

看起来比第一种好,但并发下仍然可能旧值覆盖新值。

mermaid
flowchart TD
    A["请求 A 更新 MySQL 为 A1"] --> B["请求 B 更新 MySQL 为 B1"]
    B --> C["请求 B 先更新 Redis 为 B1"]
    A --> D["请求 A 后更新 Redis 为 A1"]
    D --> E["MySQL 最终是 B1<br/>Redis 却是 A1"]

这类问题通常发生在:

  1. 两个写请求并发更新同一个业务对象。
  2. 先提交数据库的新请求先写了缓存。
  3. 后完成的旧请求又把旧计算结果写回缓存。

所以直接更新缓存并不总是更一致,尤其是缓存值来自多表聚合、接口组装、权限计算、价格计算时,更容易出错。

写法三:先删除缓存,再更新数据库

这个方案的问题在“删除后、数据库更新前”的窗口。

mermaid
flowchart TD
    A["写请求删除 Redis"] --> B["Redis 为空"]
    B --> C["读请求进来"]
    C --> D["读 MySQL 旧值"]
    D --> E["旧值写回 Redis"]
    E --> F["写请求更新 MySQL 为新值"]
    F --> G["Redis 长期保留旧值"]

为什么旧值会长期存在?因为读请求把旧值重新写回了缓存,而写请求后面只更新数据库,没有再次删除缓存。

这也是“先删缓存再写库”最经典的并发问题。

写法四:先更新数据库,再删除缓存

这是最常用的基础方案。

mermaid
flowchart TD
    A["写请求更新 MySQL"] --> B["事务提交成功"]
    B --> C["删除 Redis"]
    C --> D["下一次读 Redis 未命中"]
    D --> E["读取 MySQL 新值"]
    E --> F["新值写回 Redis"]

它为什么相对更好:

  1. 数据库先成为事实源。
  2. 删除缓存后,下一次读会基于数据库新值重建。
  3. 不需要在写接口里重新组装复杂缓存。
  4. 旧值覆盖新值的概率更低。

但它仍然有两个风险:

风险说明兜底
删除缓存失败Redis 超时、网络失败,旧值还在重试、MQ、CDC、TTL
极端并发读写读请求在写事务提交前查旧库,写缓存晚于删除延迟双删、版本号、短 TTL

先写库再删缓存的极端并发窗口

很多文章会说“先写库再删缓存基本没问题”,但为了真正理解,要看极端情况。

mermaid
flowchart TD
    A["读请求 R 缓存未命中"] --> B["R 查询 MySQL 旧值"]
    C["写请求 W 更新 MySQL 新值"] --> D["W 删除 Redis"]
    B --> E["R 查询很慢,晚于 W"]
    D --> F["Redis 已被删除"]
    E --> G["R 把旧值写入 Redis"]
    G --> H["Redis 又变成旧值"]

这个窗口要求几个条件同时成立:

  1. 读请求先发生缓存未命中。
  2. 读请求查数据库很慢,拿到旧值后迟迟没写缓存。
  3. 写请求在中间完成数据库更新并删除缓存。
  4. 读请求最后把旧值写回 Redis。

概率不高,但热点 key、慢 SQL、数据库抖动时可能出现。

解决思路:

方案作用代价
延迟双删写库后立即删一次,延迟后再删一次延迟时间不好估,增加异步任务
缓存版本号旧版本写缓存时被识别需要业务版本字段
短 TTL旧值最多保留一段时间增加回源
互斥重建防止多个读请求同时回源写缓存增加等待
关键读回源关键场景不读缓存牺牲性能

延迟双删全过程

延迟双删不是银弹,它解决的是“读请求慢查询后把旧值写回缓存”的窗口。

mermaid
flowchart TD
    A["写请求更新 MySQL"] --> B["事务提交成功"]
    B --> C["第一次删除 Redis"]
    C --> D["等待一段时间"]
    D --> E["第二次删除 Redis"]
    E --> F["下一次读回源新值"]

为什么要延迟?因为要等并发读请求完成旧值回写,然后第二次删除把旧值清掉。

延迟时间怎么定

延迟时间不能拍脑袋。它应该参考:

参考项说明
读数据库 P99 耗时并发读查库多久能结束
缓存重建耗时查库后组装 DTO、序列化、写 Redis 的时间
主从延迟如果读从库,要考虑从库同步时间
业务可接受旧值时间用户能接受旧数据多久

例如读库 P99 是 200ms,缓存组装 P99 是 100ms,主从延迟偶尔 300ms,可以考虑延迟 500ms 到 1s 再删一次。但这不是固定答案,必须看监控。

延迟双删的问题

问题说明
仍然不是强一致第二次删除前仍可能短暂读旧
延迟不好定太短删不掉旧回写,太长旧值窗口变长
删除仍可能失败第二次删除也要重试
高并发下任务多大量延迟删除会带来任务系统压力

所以延迟双删适合对短暂旧值敏感但又不要求强一致的热点缓存。强一致业务仍应回数据库或业务服务。

Binlog CDC 兜底全过程

Binlog CDC 的思路是:既然 MySQL 是事实源,那就订阅 MySQL 的变更日志,用异步消费者修正缓存。

mermaid
flowchart TD
    A["业务更新 MySQL"] --> B["MySQL 写 binlog"]
    B --> C["Canal / Debezium 订阅 binlog"]
    C --> D["解析表名、主键、变更字段"]
    D --> E["计算受影响缓存 key"]
    E --> F["删除或刷新 Redis"]
    F --> G["记录消费位点"]
    F --> H["失败进入重试"]

它适合解决:

  1. 业务代码漏删缓存。
  2. 删除缓存失败后异步补偿。
  3. 多个服务都会改同一张表,难以统一删缓存。
  4. 想用数据库变更做最终兜底。

但 CDC 也不是强一致:

风险说明
消费延迟Binlog 到缓存删除有时间差
解析失败表结构变化、字段变更可能导致消费者失败
key 映射复杂一条表变更可能影响多个列表、详情、聚合 key
顺序问题多表聚合缓存要处理事件顺序
消费失败仍要重试、告警、死信和人工补偿

CDC 和业务删除缓存怎么配合

商业项目里更稳的模式通常是:

  1. 写接口事务提交后主动删除缓存,缩短不一致窗口。
  2. 删除失败写重试表或 MQ。
  3. Binlog CDC 作为兜底,再删一次相关 key。
  4. 所有缓存设置 TTL,防止补偿链路全失败时旧值永久存在。
mermaid
flowchart TD
    A["写接口提交事务"] --> B["主动删除 Redis"]
    B --> C{"是否成功"}
    C -- "失败" --> D["重试表或 MQ"]
    C -- "成功" --> E["完成主链路"]
    A --> F["MySQL binlog"]
    F --> G["CDC 再次删除相关 key"]
    D --> H["后台重试删除"]
    G --> I["TTL 最终兜底"]
    H --> I

事务提交后再删缓存

在 Spring 里,不能在数据库事务还没提交时就删除缓存。更稳妥的方式是在事务提交后执行删除。

java
@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final StringRedisTemplate redisTemplate;
    private final CacheDeleteRetryRepository retryRepository;

    public ProductService(ProductRepository productRepository,
                          StringRedisTemplate redisTemplate,
                          CacheDeleteRetryRepository retryRepository) {
        this.productRepository = productRepository;
        this.redisTemplate = redisTemplate;
        this.retryRepository = retryRepository;
    }

    @Transactional
    public void updateProductName(Long productId, String productName) {
        Product product = productRepository.findById(productId);
        if (product == null) {
            throw new BizException("商品不存在");
        }

        product.setProductName(productName);
        productRepository.update(product);

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
            @Override
            public void afterCommit() {
                deleteCacheWithRetry("product:detail:" + productId);
            }
        });
    }

    private void deleteCacheWithRetry(String key) {
        try {
            redisTemplate.delete(key);
        } catch (Exception ex) {
            CacheDeleteRetryTask task = new CacheDeleteRetryTask();
            task.setCacheKey(key);
            task.setErrorMessage(ex.getMessage());
            task.setStatus("WAIT_RETRY");
            task.setRetryCount(0);
            task.setNextRetryTime(new Date());
            retryRepository.insert(task);
        }
    }
}

注意点:

  1. 数据库事务失败时不要删缓存。
  2. 删除缓存失败不能只打日志。
  3. 删除缓存最好写成独立方法,方便重试和监控。
  4. 如果同一个业务更新影响多个 key,要统一管理 key 列表。

删除缓存失败怎么办

删除失败是最常见的追问。正确答案不是“再删一次”,而是要形成闭环。

mermaid
flowchart TD
    A["删除 Redis 失败"] --> B["记录重试任务"]
    B --> C["后台任务扫描"]
    C --> D["再次删除缓存"]
    D --> E{"是否成功"}
    E -- "成功" --> F["标记成功"]
    E -- "失败但未超限" --> G["更新下次重试时间"]
    G --> C
    E -- "超过阈值" --> H["告警和人工处理"]

重试表 Demo:

sql
create table cache_delete_retry (
  id bigint primary key auto_increment,
  cache_key varchar(300) not null,
  biz_type varchar(64) not null,
  biz_id varchar(64) not null,
  retry_count int not null default 0,
  status varchar(32) not null,
  error_message varchar(500),
  next_retry_time datetime not null,
  created_at datetime not null,
  updated_at datetime not null,
  key idx_status_retry(status, next_retry_time),
  key idx_biz(biz_type, biz_id)
) engine = InnoDB default charset = utf8mb4;

后台重试 Demo:

java
public class CacheDeleteRetryJob {

    private final StringRedisTemplate redisTemplate;
    private final CacheDeleteRetryRepository retryRepository;

    public CacheDeleteRetryJob(StringRedisTemplate redisTemplate,
                               CacheDeleteRetryRepository retryRepository) {
        this.redisTemplate = redisTemplate;
        this.retryRepository = retryRepository;
    }

    public void execute() {
        List<CacheDeleteRetryTask> tasks = retryRepository.findWaitRetry(100);
        for (CacheDeleteRetryTask task : tasks) {
            try {
                redisTemplate.delete(task.getCacheKey());
                retryRepository.markSuccess(task.getId());
            } catch (Exception ex) {
                int nextRetryCount = task.getRetryCount() + 1;
                if (nextRetryCount >= 5) {
                    retryRepository.markFailed(task.getId(), ex.getMessage());
                    alarm(task, ex);
                } else {
                    retryRepository.markRetry(
                            task.getId(),
                            nextRetryCount,
                            nextRetryTime(nextRetryCount),
                            ex.getMessage()
                    );
                }
            }
        }
    }

    private Date nextRetryTime(int retryCount) {
        int[] seconds = new int[] {10, 30, 60, 300, 900};
        int index = Math.min(retryCount - 1, seconds.length - 1);
        return new Date(System.currentTimeMillis() + seconds[index] * 1000L);
    }

    private void alarm(CacheDeleteRetryTask task, Exception ex) {
        // 接入企业微信、钉钉、Prometheus Alertmanager 等告警渠道。
    }
}

这个方案解决的是:Redis 删除失败后,旧缓存不会永久存在而没人知道。

TTL 是最终兜底

所有缓存都建议设置过期时间。TTL 不是为了替代删除缓存,而是为了在删除失败、消息失败、代码漏删时给系统一个最终恢复机会。

数据类型建议 TTL
商品详情几分钟到几十分钟,配合随机值
字典配置几分钟到数小时,变更时主动删除
用户权限较短 TTL,关键操作回权限服务
首页统计可按业务接受程度设置
空值缓存几十秒到几分钟
强一致状态不建议依赖长 TTL 缓存

TTL 也不能完全随机乱设。太短会导致频繁回源数据库,太长会放大旧数据窗口。热点 key 的 TTL 还要加随机抖动,避免大量 key 同时过期。

java
private Duration randomTtlMinutes(int baseMinutes, int randomMinutes) {
    int extra = ThreadLocalRandom.current().nextInt(randomMinutes + 1);
    return Duration.ofMinutes(baseMinutes + extra);
}

延迟双删

延迟双删的思路是:写库前删一次,写库后再删一次,隔一段时间再删一次。

mermaid
flowchart TD
    A["删除缓存"] --> B["更新 MySQL"]
    B --> C["提交成功"]
    C --> D["再次删除缓存"]
    D --> E["延迟一段时间"]
    E --> F["第三次删除缓存"]

它想解决的问题是:并发读请求可能在写库期间把旧值写回缓存,延迟再删一次可以清理这个旧值。

但延迟双删有明显缺点:

问题说明
延迟时间难确定太短删不掉旧值,太长旧值窗口变大
增加复杂度需要延迟任务、线程池或 MQ
不适合所有场景高频写入会产生大量延迟删除任务
仍不是强一致延迟期间仍可能读到旧值

适用场景:

  1. 高并发读写同一个 key。
  2. 回源可能读到旧库或从库旧数据。
  3. 业务能接受短暂旧值。
  4. 有可靠延迟任务或 MQ。

不建议把延迟双删当成万能答案。多数场景先用“写库后删缓存 + 重试 + TTL”,只有并发窗口明显时再加延迟双删。

Binlog CDC 修正缓存

对一致性要求更高、业务写入口很多的系统,可以订阅 MySQL Binlog,发现数据变更后异步删除或刷新缓存。

mermaid
flowchart TD
    A["业务服务写 MySQL"] --> B["MySQL binlog"]
    B --> C["Canal 或 Debezium"]
    C --> D["缓存失效事件"]
    D --> E["删除 Redis key"]
    E --> F["失败重试和告警"]

优点:

优点说明
侵入少不需要每个写接口都手动删缓存
能兜住漏删人工 SQL 或旧代码更新也能捕获
统一治理可以集中处理缓存 key 删除和重试

难点:

难点说明
表到 key 的映射一张表变更可能影响多个缓存 key
跨表缓存商品详情可能来自多张表
延迟Binlog 消费有延迟,不是强实时
位点可靠性CDC 消费位点要能恢复
重复事件删除缓存必须幂等

Binlog CDC 更适合做补偿和统一修正,不一定替代业务代码里的主动删缓存。成熟系统常常组合使用:业务写后主动删除,CDC 兜底修正。

版本号和缓存内容设计

如果缓存对象里带版本号或更新时间,排查会容易很多。

json
{
  "id": 10001,
  "productName": "无线蓝牙耳机",
  "price": 29900,
  "version": 18,
  "updatedAt": "2026-07-04T10:00:00"
}

好处:

  1. 用户反馈旧数据时,可以直接比较 Redis 和 MySQL 的 version
  2. 补偿任务可以按版本判断是否落后。
  3. 多级缓存可以判断本地缓存是否过期。
  4. 排查时不用只靠感觉判断“是不是旧缓存”。

但版本号不能代替删除缓存。版本号是诊断和防旧覆盖工具,不是自动同步机制。

版本号防旧值回写

前面讲过一种极端并发窗口:读请求先查到数据库旧值,但写缓存动作发生得很晚;写请求中间已经把数据库改成新值并删除缓存;最后慢读请求又把旧值写回 Redis。

mermaid
flowchart TD
    A["读请求 R 未命中 Redis"] --> B["R 查询 MySQL 得到 version=18"]
    C["写请求 W 更新 MySQL 为 version=19"] --> D["W 删除 Redis"]
    B --> E["R 因网络或序列化很慢"]
    D --> F["Redis 已空"]
    E --> G["R 尝试写入旧缓存 version=18"]
    G --> H["如果不校验版本,旧值覆盖缓存"]

版本号防旧值回写的思路是:缓存里保存数据版本,写缓存前先判断“当前要写入的版本是否比缓存中已有版本新”。如果要写入的是旧版本,就拒绝写入。

这类方案适合:

场景原因
热点详情缓存并发读写高,慢读回写旧值概率更高
多表聚合缓存组装缓存耗时更长,更容易晚写
本地缓存 + Redis多级缓存里旧值更难排查
用户反馈旧数据敏感版本号可以直接定位旧值来源

不适合把它当成唯一方案。它解决的是“旧值不要覆盖新值”,仍然要配合写库后删缓存、删除失败重试、TTL 和关键读回源。

版本号写缓存 Demo

先让缓存对象携带版本:

java
public class CacheValue<T> {
    private T data;
    private long version;
    private LocalDateTime cachedAt;

    public CacheValue(T data, long version) {
        this.data = data;
        this.version = version;
        this.cachedAt = LocalDateTime.now();
    }

    public T getData() {
        return data;
    }

    public long getVersion() {
        return version;
    }
}

数据库表也要有可比较版本字段,常见选择:

字段说明
version乐观锁版本号,每次更新递增
updated_at更新时间,精度和时钟一致性要注意
data_version业务数据版本,适合多表聚合缓存

写 Redis 前用 Lua 做“只允许新版本覆盖旧版本”。这样比较版本和写入缓存是 Redis 端原子执行,不会被其他客户端插入打断。

lua
local key = KEYS[1]
local newVersion = tonumber(ARGV[1])
local newValue = ARGV[2]
local ttlSeconds = tonumber(ARGV[3])

local oldVersion = redis.call('HGET', key, 'version')
if oldVersion ~= false and tonumber(oldVersion) > newVersion then
  return 0
end

redis.call('HSET', key, 'version', newVersion, 'value', newValue)
redis.call('EXPIRE', key, ttlSeconds)
return 1

Java 侧伪代码:

java
public AssetDTO getAsset(Long assetId) {
    String key = "asset:detail:" + assetId;

    AssetDTO cached = readCache(key);
    if (cached != null) {
        return cached;
    }

    AssetPO po = assetRepository.findById(assetId);
    if (po == null) {
        writeNullCache(key);
        return null;
    }

    AssetDTO dto = convert(po);
    writeCacheIfNewer(key, po.getVersion(), dto, Duration.ofMinutes(30));
    return dto;
}

这个 Demo 的关键点不是语法,而是顺序:

mermaid
flowchart TD
    A["读请求回源 MySQL"] --> B["拿到数据和 version"]
    B --> C["准备写 Redis"]
    C --> D["Lua 读取 Redis 当前 version"]
    D --> E{"当前 version 是否更新?"}
    E -->|"是"| F["拒绝旧值写入"]
    E -->|"否"| G["写入新值和 TTL"]

为什么只比较版本不够

版本号能防“旧值覆盖新值”,但不能解决所有一致性问题:

问题版本号能否解决还需要
删除 Redis 失败不能重试、MQ、CDC、TTL
本地缓存旧值不能完全本地短 TTL、广播失效
回源读从库旧值部分能识别关键回源读主库、主从延迟治理
多个缓存 key 漏删不能key 影响范围管理
强一致读不能直接读事实源或业务服务

如果发现要写入的版本比 Redis 旧,通常说明发生了慢读回写、主从延迟或乱序事件。此时应该记录指标:

text
cache_stale_write_rejected_total{cache="asset:detail"} +1

这个指标对线上排查很有价值:它能告诉你“旧缓存回写不是理论风险,线上真的发生过”。

热点 key 重建和一致性

删除热点 key 后,可能大量请求同时未命中 Redis,全部打到数据库,这就是缓存击穿。

mermaid
flowchart TD
    A["热点 key 被删除或过期"] --> B["大量请求未命中"]
    B --> C["同时查 MySQL"]
    C --> D["数据库压力暴涨"]
    D --> E["接口超时"]

处理方式:

方案思路一致性影响
互斥锁重建只有一个线程查库写缓存其他请求等待或返回空
逻辑过期先返回旧值,后台异步重建会短暂返回旧数据
热点预热提前加载热点缓存变更后仍要删除或刷新
本地缓存降低 Redis 压力增加本地缓存一致性问题

逻辑过期适合商品详情、文章详情、配置展示等允许短暂旧值的读多写少场景。不适合支付状态、权限最终判断、库存最终确认。

本地缓存的一致性

很多高并发系统不只用 Redis,还会用 Caffeine、Guava Cache 或应用内 Map 做本地缓存。此时一致性链路变成:

mermaid
flowchart TD
    A["MySQL"] --> B["Redis"]
    B --> C["应用本地缓存"]
    C --> D["接口返回"]

本地缓存的问题是:Redis 删除了,本地缓存可能还在。解决思路:

方案说明
本地短 TTL最简单,接受短暂旧值
Redis Pub/Sub 广播数据变更后通知各实例删除本地缓存
MQ 广播更可靠,适合多实例
配置中心推送适合配置和字典
关键读绕过本地缓存权限、交易类关键判断

本地缓存只适合读多写少、允许短暂旧值的数据。不要把强一致权限或账户余额长期放在本地缓存里直接做最终判断。

其他缓存模式对比

模式读写方式优点风险适合场景
Cache Aside应用读写缓存和数据库简单、最常用需要处理删除失败大多数业务缓存
Read Through应用只读缓存,缓存层负责回源对应用透明缓存组件复杂统一缓存平台
Write Through写缓存时同步写数据库写入路径统一写延迟高,失败复杂少量强管控写入
Write Behind先写缓存,异步写数据库写入快丢数据和一致性风险大日志、统计等可补偿场景
Refresh Ahead快过期时提前刷新降低击穿需要预测热点热点数据

普通业务最常用还是 Cache Aside。Write Behind 不能用于资金、订单最终状态、库存最终扣减这类核心事实数据。

读己之写怎么处理

有些场景用户刚修改完资料,下一秒进入详情页,希望马上看到新值。普通最终一致缓存可能短暂读到旧值。

处理方式:

方案说明
写后短时间读数据库修改成功后的详情页直接查 MySQL
删除缓存后再返回确保缓存删除完成再返回,失败则提示或降级
返回写入后的新值写接口直接返回最新 DTO
前端本地更新展示适合非关键展示字段
使用版本号判断读到旧版本时回源数据库

关键点是按业务体验选择,不要为了所有读请求都强一致而把缓存价值打没。

商业场景怎么选

场景推荐方案原因
商品详情Cache Aside + 写库后删缓存 + TTL + 热点互斥重建读多写少,允许短暂旧值
商品价格展示短 TTL + 删除缓存 + 下单回源校验展示可短暂旧,下单必须准
库存最终扣减数据库或库存服务强约束,Redis 只做预热或预扣不能只信缓存
用户权限短 TTL + 变更主动删除 + 关键操作回权限服务权限错误风险高
字典配置Redis + 本地缓存 + 变更广播读多写少
资产详情Cache Aside + 标签/字段变更删除多个 key + 补偿文档可能来自多表
首页统计定时刷新或异步统计通常允许延迟

资产平台示例

医疗数据采集与资产平台里,资产详情可能包含资产表、字段表、机构表、标签表、采集任务状态等信息。

缓存 key:

text
asset:detail:{assetId}
asset:fields:{assetId}
asset:tags:{assetId}
org:asset:list:{orgId}:{pageNo}

一致性设计:

  1. 资产编辑先更新 MySQL。
  2. 事务提交后删除 asset:detail:{assetId}
  3. 字段变更删除 asset:fields:{assetId}asset:detail:{assetId}
  4. 标签变更删除 asset:tags:{assetId}asset:detail:{assetId}
  5. 上下线变更还要删除机构资产列表缓存。
  6. 删除失败写入重试任务。
  7. 缓存设置随机 TTL。
  8. 关键审批、权限判断回 MySQL 或权限服务。

这个场景里最大的坑是:只删除了详情 key,没有删除列表 key 或标签 key,导致详情新、列表旧、搜索筛选旧。

线上排查流程

用户看到旧数据

mermaid
flowchart TD
    A["用户看到旧数据"] --> B["查 MySQL 当前值"]
    B --> C["查 Redis 当前值"]
    C --> D{"Redis 是否旧值"}
    D -- "否" --> E["查本地缓存或前端缓存"]
    D -- "是" --> F["查写接口是否删缓存"]
    F --> G["查删除失败重试记录"]
    G --> H["查 TTL 和版本号"]

排查清单:

检查项说明
MySQL 当前值事实源是否已经更新
Redis 值是否旧版本、TTL 还剩多久
本地缓存JVM 进程内是否还有旧值
删除日志写接口是否执行删除
重试任务删除失败是否进入重试
事务时机是否事务未提交就删除
主从延迟回源是否读了从库旧值
key 范围是否漏删列表、聚合、标签 key

删除缓存失败

重点看:

  1. Redis 是否超时或连接池耗尽。
  2. 删除命令是否被权限或网络拦截。
  3. key 是否拼错。
  4. 删除失败是否进入重试表或 MQ。
  5. 重试任务是否正常运行。
  6. 失败次数是否告警。

数据库突然被打满

可能不是数据库本身问题,而是缓存层把流量放下来了。

现象可能原因
Redis 命中率下降大量 key 过期、淘汰策略触发、缓存删除过猛
某个接口 DB QPS 暴涨热点 key 击穿
大量查询不存在 ID缓存穿透
Redis timeout 同时 DB 慢回源变慢拖住应用线程

处理方向:

  1. 查 Redis 命中率和 key 过期情况。
  2. 查是否有热点 key 被删除或过期。
  3. 加互斥重建或逻辑过期。
  4. 增加限流和降级保护数据库。
  5. 对不存在数据做空值缓存或布隆过滤器。

监控指标

指标为什么重要
Redis 命中率判断缓存是否有效保护数据库
回源数据库 QPS判断缓存失效对数据库的冲击
缓存删除失败数判断一致性风险
重试任务积压判断失败是否长期未修复
key TTL 分布判断是否大量同时过期
热点 key 访问量判断是否存在击穿风险
本地缓存命中率判断多级缓存是否生效
旧版本命中次数判断用户是否读到旧数据
Redis 连接池等待判断应用是否卡在 Redis 客户端

没有监控的缓存一致性方案,本质上是靠运气。

常见错误

错误后果正确做法
把 Redis 当事实源缓存丢失或淘汰后数据不可恢复MySQL 保存事实
先删缓存再写库并发读可能写回旧值默认先写库再删缓存
事务未提交就删缓存其他请求可能读旧库重建旧缓存afterCommit 后删除
删除失败只打日志旧缓存可能长期存在重试、告警、TTL
直接更新复杂缓存并发下旧值覆盖新值删除缓存后回源重建
缓存不设 TTL旧数据和无效数据长期存在所有缓存设置合理 TTL
强一致业务依赖缓存下单、支付、权限可能错关键判断回事实源
只删详情不删列表列表旧、详情新维护 key 影响范围
Bulk 删除无失败记录漏删后无法定位逐个 key 记录结果

关联知识点

知识点继续学习什么
Redis 缓存问题穿透、击穿、雪崩
Redis 高并发缓存治理大 Key、热 Key、限流降级、生产排查
布隆过滤器原理缓存穿透治理
数据一致性设计强一致、最终一致、补偿和对账
可靠消息与本地消息表删除失败异步补偿
CDC与Outbox数据同步MySQL 到 Redis、ES、MQ 的可靠传播、位点、重放和对账
Redis 面试题按题目复习缓存一致性相关问法

本章小结

Redis 与数据库一致性的核心是:数据库保存事实,Redis 保存可重建副本。最常用方案是 Cache Aside:读缓存,未命中查库并写缓存;写操作先更新数据库,事务提交后删除缓存。删除失败要进入重试、告警和补偿,缓存本身要设置 TTL 作为兜底。对热点 key 要防击穿,对多级缓存要处理本地失效,对关键交易和权限判断要回事实源。成熟系统不是追求缓存和数据库每一刻强一致,而是让短暂不一致可控、可观测、可修复。