【面经】缓存一致性全套解决方案:从旁路删除到延迟双删、MQ 补偿、binlog 监听与多级缓存

文章目录

  • [缓存一致性全套解决方案:从 Cache-Aside 到延迟双删、MQ 补偿、binlog 监听与多级缓存](#缓存一致性全套解决方案:从 Cache-Aside 到延迟双删、MQ 补偿、binlog 监听与多级缓存)
    • 一、讨论背景:为什么缓存一致性这么容易混?
    • 二、先给最终结论:不同场景怎么选?
    • [三、基础模式:Cache-Aside 模式](#三、基础模式:Cache-Aside 模式)
      • [1. 读流程](#1. 读流程)
      • [2. 写流程](#2. 写流程)
    • 四、为什么主流推荐"先更新数据库,再删除缓存"?
      • [1. 错误方式:先删除缓存,再更新数据库](#1. 错误方式:先删除缓存,再更新数据库)
      • [2. 正确方式:先更新数据库,再删除缓存](#2. 正确方式:先更新数据库,再删除缓存)
      • [3. 为什么不是"更新数据库,再更新缓存"?](#3. 为什么不是“更新数据库,再更新缓存”?)
    • [五、方案一:更新数据库 → 删除缓存 → TTL 兜底](#五、方案一:更新数据库 → 删除缓存 → TTL 兜底)
    • 六、方案二:删除缓存失败怎么办?
      • [1. 本地立即重试](#1. 本地立即重试)
      • [2. MQ 异步重试删除](#2. MQ 异步重试删除)
      • [3. 本地消息表 / Outbox 模式](#3. 本地消息表 / Outbox 模式)
      • [4. binlog / CDC 监听删除缓存](#4. binlog / CDC 监听删除缓存)
      • [5. 缓存 TTL 兜底](#5. 缓存 TTL 兜底)
    • 七、方案三:延迟双删
      • [1. 经典延迟双删](#1. 经典延迟双删)
      • [2. 经典问题复盘](#2. 经典问题复盘)
      • [3. 为什么"更新数据库 → 删除缓存"后也可以用延迟双删?](#3. 为什么“更新数据库 → 删除缓存”后也可以用延迟双删?)
      • [4. 延迟多久合适?](#4. 延迟多久合适?)
      • [5. 缺点](#5. 缺点)
    • [八、方案四:更新数据库 → 更新缓存](#八、方案四:更新数据库 → 更新缓存)
    • [九、方案五:更新数据库 → 删除缓存 → 异步预热缓存](#九、方案五:更新数据库 → 删除缓存 → 异步预热缓存)
    • 十、方案六:互斥锁防缓存击穿
    • 十一、方案七:逻辑过期
    • [十二、方案八:Read-Through / Write-Through / Write-Behind](#十二、方案八:Read-Through / Write-Through / Write-Behind)
      • [1. Read-Through](#1. Read-Through)
      • [2. Write-Through](#2. Write-Through)
      • [3. Write-Behind](#3. Write-Behind)
    • 十三、方案九:主从库读写分离场景
      • 解决方案
        • [1. 更新后短时间读主库](#1. 更新后短时间读主库)
        • [2. 延迟双删](#2. 延迟双删)
        • [3. 版本号控制](#3. 版本号控制)
        • [4. 关键读请求强制读主库](#4. 关键读请求强制读主库)
    • 十四、方案十:多级缓存一致性
    • 十五、方案十一:强一致业务不要依赖缓存
    • 十六、不同业务场景的完整选型
      • [1. 普通读多写少业务](#1. 普通读多写少业务)
      • [2. 热点商品 / 首页数据](#2. 热点商品 / 首页数据)
      • [3. 写并发高的同一个 key](#3. 写并发高的同一个 key)
      • [4. 对缓存命中率极高要求,又希望尽量新](#4. 对缓存命中率极高要求,又希望尽量新)
      • [5. 多服务共同修改数据](#5. 多服务共同修改数据)
      • [6. 本地缓存 + Redis](#6. 本地缓存 + Redis)
      • [7. 主从库读写分离](#7. 主从库读写分离)
      • [8. 强一致核心业务](#8. 强一致核心业务)
    • 十七、企业级分层方案
      • [Level 1:基础可用](#Level 1:基础可用)
      • [Level 2:防删除失败](#Level 2:防删除失败)
      • [Level 3:异步补偿](#Level 3:异步补偿)
      • [Level 4:热点保护](#Level 4:热点保护)
      • [Level 5:多服务一致](#Level 5:多服务一致)
      • [Level 6:高并发写一致](#Level 6:高并发写一致)
      • [Level 7:强一致](#Level 7:强一致)
    • 十八、最终记忆口诀
      • [1. 普通数据](#1. 普通数据)
      • [2. 删除失败](#2. 删除失败)
      • [3. 热点数据](#3. 热点数据)
      • [4. 更新缓存](#4. 更新缓存)
      • [5. 强一致](#5. 强一致)
    • 十九、最终推荐组合
    • 二十、总结

缓存一致性全套解决方案:从 Cache-Aside 到延迟双删、MQ 补偿、binlog 监听与多级缓存

本文适合 Java 后端、Redis 缓存设计、面试复习和系统设计总结。
核心目标:彻底搞清楚 数据库与缓存一致性 到底有哪些方案、分别解决什么问题、适合什么业务场景。
适合面试补差 和面试官吹水 缓存一致性的全套解决方案
基于本人多次面试的经验 每次都会存在缓存一致性的场景解决问题 + GPT总结 后写下


一、讨论背景:为什么缓存一致性这么容易混?

在实际业务中,我们经常会使用 Redis 缓存数据库中的热点数据。

比如商品详情:

text 复制代码
数据库:商品价格 100
缓存:商品价格 100

当商品价格更新为 200 时,我们就会面临一个问题:

text 复制代码
数据库变成 200 之后,缓存里的 100 怎么处理?

常见问题包括:

text 复制代码
1. 是先更新数据库,还是先删除缓存?
2. 是删除缓存,还是更新缓存?
3. 删除缓存失败怎么办?
4. 并发读写时,旧数据会不会重新写回缓存?
5. 延迟双删到底是解决什么问题?
6. 热点数据删除缓存后,大量请求打到数据库怎么办?
7. 多个服务都能修改数据库,如何避免漏删缓存?
8. 本地缓存 + Redis 多级缓存如何保持一致?
9. 强一致业务,比如余额、支付、库存,到底能不能依赖缓存?

缓存一致性最容易混的原因是:没有一个方案适合所有场景

我们要先建立一个核心认知:

数据库是"真相",缓存是"副本"。
所有缓存一致性方案,本质都是在处理:真相变了以后,副本如何不要错、不要错太久、不要把系统拖垮。


二、先给最终结论:不同场景怎么选?

业务场景 推荐方案
普通 CRUD、读多写少 更新数据库 → 删除缓存 → TTL 兜底
删除缓存可能失败 删除失败立即重试 / MQ 重试 / 本地消息表
多个系统都可能修改数据库 binlog / CDC 监听数据库变更后删除缓存
热点数据,删除缓存后不能让大量请求打数据库 删除缓存后异步预热 / 互斥锁 / 逻辑过期
业务对缓存命中率要求极高 更新数据库 + 更新缓存,但必须加锁 / 版本号 / CAS
主从库读写分离 更新后短时间读主库 / 延迟双删 / 版本号控制
本地缓存 + Redis 多级缓存 删除 Redis + MQ/PubSub 广播删除本地缓存
点赞数、浏览量、计数器 Redis 原子操作 + 异步批量落库
余额、支付、订单、权限、库存扣减 核心判断不要依赖缓存,走数据库/事务/强一致模型

一句话总结:

普通场景:更新数据库,再删除缓存。
删除失败:靠重试和 MQ 补偿。
热点场景:删除后预热,防击穿。
并发更新缓存:必须加锁或版本号。
多系统修改:用 binlog 统一兜底。
强一致场景:别把缓存当真相。


三、基础模式:Cache-Aside 模式

Cache-Aside 是最常见的缓存使用模式,也叫旁路缓存模式。

1. 读流程

text 复制代码
先读缓存
    ↓
缓存命中:直接返回
    ↓
缓存未命中:查询数据库
    ↓
把数据库结果写入缓存
    ↓
返回结果

Java 伪代码:

java 复制代码
public Product getProduct(Long id) {
    String key = "product:" + id;

    Product cache = redis.get(key);
    if (cache != null) {
        return cache;
    }

    Product db = productMapper.selectById(id);
    if (db != null) {
        redis.set(key, db, 30, TimeUnit.MINUTES);
    }

    return db;
}

2. 写流程

推荐流程是:

text 复制代码
更新数据库
    ↓
删除缓存

也就是:

java 复制代码
@Transactional
public void updateProduct(Product product) {
    productMapper.updateById(product);
    redis.delete("product:" + product.getId());
}

但是更严谨一点,应该是:

text 复制代码
更新数据库
    ↓
数据库事务提交成功
    ↓
删除缓存

因为如果数据库事务还没有提交,你就删除缓存,后面事务又回滚了,就会造成不必要的缓存失效,甚至引入一些并发问题。

Spring 中可以注册事务提交后的回调:

java 复制代码
@Transactional
public void updateProduct(Product product) {
    productMapper.updateById(product);

    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redis.delete("product:" + product.getId());
            }
        }
    );
}

四、为什么主流推荐"先更新数据库,再删除缓存"?

这个地方最容易忘。

请记住这个比喻:

数据库是户口本,缓存是小区公告栏。
修改信息时,应该先改户口本,再撕掉旧公告。
如果先撕公告,但户口本还没改,别人可能根据旧户口本又贴回一张旧公告。

1. 错误方式:先删除缓存,再更新数据库

假设原来:

text 复制代码
数据库:100
缓存:100

现在要改成:

text 复制代码
数据库:200

错误流程:

text 复制代码
线程 A:删除缓存
此时:数据库 100,缓存空

线程 B:读取数据
B 发现缓存为空,于是查询数据库
此时数据库还没有更新,所以 B 查到旧值 100
B 把旧值 100 写回缓存

线程 A:更新数据库为 200

最终:
数据库:200
缓存:100

这就是旧缓存复活。

所以:

先删缓存的问题是:缓存空了,但数据库还是旧的。
读请求会从旧数据库里读到旧值,并把旧值重新写回缓存。

2. 正确方式:先更新数据库,再删除缓存

流程:

text 复制代码
线程 A:更新数据库为 200
线程 A:删除缓存

后续读请求:
缓存没有
查询数据库,读到 200
写入缓存 200

这样缓存最终会恢复为新值。

3. 为什么不是"更新数据库,再更新缓存"?

因为更新缓存会遇到并发写乱序。

假设两个线程同时更新同一个商品:

text 复制代码
线程 A:把价格改成 200
线程 B:把价格改成 300

可能发生:

text 复制代码
A 更新数据库为 200
B 更新数据库为 300
B 更新缓存为 300
A 更新缓存为 200

最终:

text 复制代码
数据库:300
缓存:200

数据库最终值是 B 的 300,但缓存被 A 的 200 覆盖了。

所以主流更推荐:

text 复制代码
更新数据库 → 删除缓存

原因是:

删除缓存不是制造新副本,而是让旧副本失效。
后续谁要读,就自己从数据库重新加载最新值。


五、方案一:更新数据库 → 删除缓存 → TTL 兜底

这是最基础、最常用、最推荐的方案。

适用场景

text 复制代码
1. 普通商品信息
2. 用户资料
3. 文章详情
4. 店铺信息
5. 业务配置
6. 普通读多写少业务

基本流程

text 复制代码
读:Cache-Aside
写:更新数据库 → 删除缓存
兜底:缓存设置 TTL

优点

text 复制代码
1. 实现简单
2. 数据最终一致性容易保证
3. 并发问题相对少
4. 下次读请求会自动加载数据库最新值

缺点

text 复制代码
1. 每次更新后,下一次读会缓存未命中
2. 热点 key 被删除后,可能大量请求打到数据库
3. 删除缓存失败时,旧缓存可能残留

因此基础方案通常还要配合:

text 复制代码
1. 缓存 TTL
2. 删除失败重试
3. MQ 异步补偿
4. 热点 key 互斥锁

六、方案二:删除缓存失败怎么办?

这是一个非常核心的问题。

假设:

text 复制代码
数据库更新成功:200
删除缓存失败

结果:

text 复制代码
数据库:200
缓存:100

这时用户继续读缓存,会拿到旧值。

所以问题本质是:

真相已经改了,但旧副本还没销毁。

所有解决方案都是围绕一件事:

想办法让旧缓存最终一定被删除。


1. 本地立即重试

最简单方式:删失败就重试几次。

java 复制代码
boolean deleted = false;

for (int i = 0; i < 3; i++) {
    try {
        redisTemplate.delete(cacheKey);
        deleted = true;
        break;
    } catch (Exception e) {
        log.warn("删除缓存失败,第 {} 次重试,key={}", i + 1, cacheKey, e);
        Thread.sleep(100);
    }
}

if (!deleted) {
    log.error("缓存删除最终失败,key={}", cacheKey);
}

适合处理:

text 复制代码
1. Redis 网络抖动
2. Redis 短暂超时
3. 连接池瞬时不可用

优点:

text 复制代码
简单直接

缺点:

text 复制代码
1. 会阻塞当前业务线程
2. 重试几次后仍可能失败
3. 服务宕机后,后续没人继续重试

形象理解:

撕公告失败,我站在公告栏前面马上再撕几次。


2. MQ 异步重试删除

更工程化的方案是:删除失败后发送 MQ 消息,让消费者异步重试删除。

流程:

text 复制代码
更新数据库成功
    ↓
删除缓存失败
    ↓
发送 MQ 消息:请删除 product:1
    ↓
消费者异步删除缓存
    ↓
失败则继续重试 / 进入死信队列

业务代码:

java 复制代码
try {
    redisTemplate.delete(cacheKey);
} catch (Exception e) {
    cacheDeleteProducer.send(new CacheDeleteMessage(cacheKey));
}

消费者:

java 复制代码
@RabbitListener(queues = "cache.delete.queue")
public void consume(CacheDeleteMessage message) {
    redisTemplate.delete(message.getKey());
}

优点:

text 复制代码
1. 不阻塞主流程太久
2. 可以异步重试
3. Redis 短暂故障恢复后还能继续删除
4. 删除缓存天然幂等

因为:

text 复制代码
DEL key 一次和 DEL key 十次,效果一样。

缺点:

text 复制代码
如果删除缓存失败后,发送 MQ 消息也失败,就可能丢补偿任务。

所以更可靠的方案是本地消息表。


3. 本地消息表 / Outbox 模式

这是更可靠的补偿方案。

在同一个数据库事务里做两件事:

text 复制代码
1. 更新业务数据
2. 插入一条"待删除缓存"的消息记录

例如:

text 复制代码
product 表更新成功

cache_delete_task 表插入:
id = 1
cache_key = product:1
status = INIT
retry_count = 0

事务提交后,后台任务扫描本地消息表:

text 复制代码
扫描待处理任务
    ↓
删除 Redis 缓存
    ↓
删除成功:标记 DONE
    ↓
删除失败:增加重试次数,稍后继续

优点:

text 复制代码
1. 不怕 MQ 发送失败
2. 不怕服务中途宕机
3. 补偿任务持久化
4. 可观测、可重试、可人工干预

适合:

text 复制代码
1. 订单
2. 商品
3. 支付相关非核心展示缓存
4. 用户关键资料
5. 对一致性要求更高的业务

形象理解:

我撕公告失败了,不是口头喊一句,而是在系统里登记一张工单。
这张工单不完成,就会一直有人处理。


4. binlog / CDC 监听删除缓存

这是大型系统中非常常用的解耦方案。

流程:

text 复制代码
业务系统更新数据库
    ↓
MySQL 产生 binlog
    ↓
Canal / Debezium / Maxwell 监听到数据变更
    ↓
缓存同步服务删除对应 Redis key

例如:

text 复制代码
update product set price = 200 where id = 1;

binlog 记录 product 表 id=1 被修改

缓存同步服务收到事件:
删除 Redis key:product:1

适合:

text 复制代码
1. 多个服务都可能修改同一张表
2. 后台管理系统会修改数据
3. 定时任务会修改数据
4. DBA 脚本可能修改数据
5. 业务代码中容易漏删缓存

优点:

text 复制代码
1. 对业务代码侵入小
2. 数据库变化能统一感知
3. 不依赖每个服务都记得删缓存
4. 多服务修改数据时更可靠

缺点:

text 复制代码
1. 架构复杂度更高
2. CDC 服务本身需要高可用
3. 存在一定延迟
4. 需要维护表和缓存 key 的映射关系

形象理解:

不是靠每个业务员自己撕公告,而是派一个监督员盯着户口本。
只要户口本变了,监督员就去公告栏撕掉对应旧公告。


5. 缓存 TTL 兜底

不管采用哪种方案,缓存都建议设置过期时间。

java 复制代码
redisTemplate.opsForValue().set(
    cacheKey,
    value,
    30,
    TimeUnit.MINUTES
);

作用是:

text 复制代码
即使数据库更新成功后缓存删除失败,
即使 MQ 补偿也失败,
即使 binlog 监听也出问题,
旧缓存最多存活到 TTL 过期。

比如:

text 复制代码
TTL = 30 分钟

最坏情况:

text 复制代码
用户最多读到 30 分钟旧数据。

注意:

TTL 不能保证立即一致,只能限制不一致持续时间。

形象理解:

就算没人撕公告,这张公告过一会儿也会自己掉下来。

建议加随机过期时间,防止缓存雪崩:

java 复制代码
long ttl = 30 * 60 + RandomUtil.randomInt(0, 300);

也就是:

text 复制代码
30 分钟 + 0 到 5 分钟随机值

七、方案三:延迟双删

延迟双删的核心是:

缓存删两次,中间故意等一会儿。


1. 经典延迟双删

经典版本是:

text 复制代码
删除缓存
    ↓
更新数据库
    ↓
延迟一段时间
    ↓
再次删除缓存

它主要针对:

text 复制代码
先删除缓存,再更新数据库

这个顺序下的旧缓存复活问题。

2. 经典问题复盘

假设原来:

text 复制代码
数据库:100
缓存:100

线程 A 要更新为 200:

text 复制代码
A:删除缓存

此时:
数据库:100
缓存:空

线程 B 来读:

text 复制代码
B:发现缓存为空
B:查询数据库,查到旧值 100
B:把 100 写回缓存

线程 A 继续:

text 复制代码
A:更新数据库为 200

最终:

text 复制代码
数据库:200
缓存:100

旧缓存复活。

加延迟双删后:

text 复制代码
A:第一次删除缓存
B:读旧数据库,并把 100 写回缓存
A:更新数据库为 200
A:延迟一会儿
A:第二次删除缓存

最终:

text 复制代码
数据库:200
缓存:空

后续读请求会重新加载数据库新值。

3. 为什么"更新数据库 → 删除缓存"后也可以用延迟双删?

经典延迟双删确实主要针对:

text 复制代码
删除缓存 → 更新数据库

但在工程实践中,也会使用这个变体:

text 复制代码
更新数据库
    ↓
删除缓存
    ↓
延迟一会儿
    ↓
再次删除缓存

它解决的是另一类问题:

text 复制代码
1. 慢读请求提前读到旧数据库,但晚于删除缓存后写回旧缓存
2. 主从延迟导致读请求从从库读到旧值并写回缓存
3. 第一次删除缓存失败,第二次删除作为补偿
场景:慢读请求写回旧缓存

假设缓存刚好为空:

text 复制代码
数据库:100
缓存:空

读线程 R 先开始:

text 复制代码
R:查缓存,发现为空
R:查数据库,拿到旧值 100

此时写线程 W 开始:

text 复制代码
W:更新数据库为 200
W:删除缓存

但 R 前面已经拿到了旧值 100,只是还没写缓存。随后:

text 复制代码
R:把 100 写回缓存

最终:

text 复制代码
数据库:200
缓存:100

如果 W 在删除缓存后延迟再删一次,就可以把这个旧缓存再删掉。

所以:

经典延迟双删是为"先删缓存再改库"擦屁股。
更新数据库后的延迟再删,是给推荐方案加保险。

4. 延迟多久合适?

没有固定答案,原则是:

text 复制代码
延迟时间 > 一次数据库查询 + 写缓存的最大耗时

常见配置:

text 复制代码
500ms
1s
2s
5s

如果存在主从延迟,还要考虑主从同步时间。

5. 缺点

text 复制代码
1. 延迟任务如果只存在内存中,服务宕机会丢
2. 延迟时间不好估
3. Redis 长时间故障时无效
4. 不能保证强一致,只是降低脏缓存概率

所以延迟双删最好配合:

text 复制代码
MQ 重试
本地消息表
TTL
binlog 监听

八、方案四:更新数据库 → 更新缓存

这个方案的目标是提升缓存命中率。

流程:

text 复制代码
更新数据库
    ↓
更新缓存

这样缓存不会被删除,下次读请求仍然能命中缓存。

适用场景

text 复制代码
1. 对缓存命中率要求非常高
2. 数据是热点 key
3. 写操作并发不高
4. 更新后的缓存值可以完整构造
5. 可以接受额外并发控制成本

例如:

text 复制代码
商品详情热点数据
业务字典
配置中心数据
首页模块配置
价格展示缓存

最大问题:并发写乱序

假设:

text 复制代码
数据库:100
缓存:100

两个线程同时更新:

text 复制代码
A:改成 200
B:改成 300

可能发生:

text 复制代码
A:更新数据库为 200
B:更新数据库为 300
B:更新缓存为 300
A:更新缓存为 200

最终:

text 复制代码
数据库:300
缓存:200

所以如果使用更新缓存方案,必须增加并发控制。


1. 分布式锁

对同一个 key 加锁:

text 复制代码
lock product:1
    ↓
更新数据库
    ↓
更新缓存
unlock product:1

代码示意:

java 复制代码
RLock lock = redissonClient.getLock("lock:product:" + id);
try {
    lock.lock(5, TimeUnit.SECONDS);
    updateDatabase();
    updateCache();
} finally {
    lock.unlock();
}

优点:

text 复制代码
同一个 key 的更新串行化,避免并发乱序。

缺点:

text 复制代码
1. 写性能下降
2. 锁过期、锁续期、误删锁都要考虑
3. 锁粒度过大会影响吞吐
4. 热点 key 写竞争严重时性能差

适合:

text 复制代码
同一个 key 写并发不高,但一致性要求较高的场景。

2. 版本号控制

数据库和缓存都带版本号。

例如缓存值:

json 复制代码
{
  "value": 200,
  "version": 2
}

如果 B 已经写入:

json 复制代码
{
  "value": 300,
  "version": 3
}

A 后到时想写:

json 复制代码
{
  "value": 200,
  "version": 2
}

发现当前缓存版本是 3,而自己版本是 2,于是拒绝覆盖。

核心思想:

只允许高版本覆盖低版本,低版本不能覆盖高版本。

优点:

text 复制代码
1. 防止旧请求后写覆盖新缓存
2. 比分布式锁吞吐更好
3. 适合并发更新较多的场景

缺点:

text 复制代码
1. 实现复杂
2. 缓存值需要带版本
3. 写缓存需要保证版本比较的原子性

可以使用 Redis Lua 脚本保证原子比较和写入。


3. CAS 更新缓存

CAS 的核心思想是:

text 复制代码
只有当缓存还是我预期的版本时,我才更新它。

Redis 可以通过:

text 复制代码
WATCH / MULTI / EXEC
Lua 脚本
版本号字段

实现类似 CAS 的效果。

适合:

text 复制代码
并发更新同一个 key,且需要防止乱序覆盖。

九、方案五:更新数据库 → 删除缓存 → 异步预热缓存

这是热点数据场景下非常推荐的方案。

流程:

text 复制代码
更新数据库
    ↓
删除缓存
    ↓
发送 MQ:请预热 product:1
    ↓
消费者查询数据库最新值
    ↓
重新写入缓存

它结合了两个优点:

text 复制代码
删除缓存:一致性更稳
异步预热:减少后续读请求缓存未命中

代码示意:

java 复制代码
public void updateProduct(Product product) {
    productMapper.updateById(product);
    redis.delete("product:" + product.getId());
    mq.send(new CacheWarmupMessage(product.getId()));
}

消费者:

java 复制代码
public void warmup(Long productId) {
    Product latest = productMapper.selectById(productId);
    redis.set("product:" + productId, latest, 30, TimeUnit.MINUTES);
}

适合:

text 复制代码
1. 热点商品
2. 首页数据
3. 活动页配置
4. 排行榜
5. 高访问频率业务数据

优点:

text 复制代码
1. 不直接更新缓存,避免写缓存乱序问题
2. 删除后快速预热,减少缓存未命中
3. 预热失败可以 MQ 重试

缺点:

text 复制代码
1. 引入 MQ
2. 预热有延迟
3. 消费者需要防并发重复预热

十、方案六:互斥锁防缓存击穿

缓存一致性和缓存击穿经常一起出现。

当热点 key 被删除后,大量请求同时来读:

text 复制代码
热点商品 product:1 缓存被删除
1 万个请求同时进来
全部发现缓存为空
全部查询数据库
数据库被打爆

解决方法:互斥锁。

流程:

text 复制代码
读缓存
    ↓
缓存不存在
    ↓
尝试获取重建锁
    ↓
拿到锁:查数据库,写缓存
    ↓
没拿到锁:等待一小会儿,再查缓存

伪代码:

java 复制代码
Product cache = redis.get(key);
if (cache != null) {
    return cache;
}

boolean locked = redis.setIfAbsent("lock:" + key, "1", 5, TimeUnit.SECONDS);

if (locked) {
    try {
        Product db = productMapper.selectById(id);
        redis.set(key, db, ttl);
        return db;
    } finally {
        redis.delete("lock:" + key);
    }
} else {
    Thread.sleep(50);
    return redis.get(key);
}

适合:

text 复制代码
热点 key
缓存删除后容易瞬间打爆数据库

缺点:

text 复制代码
1. 请求会等待
2. 锁超时要设置合理
3. 代码复杂度增加

十一、方案七:逻辑过期

逻辑过期是高并发热点数据常用方案。

普通 TTL 是 Redis 自动删除 key。

逻辑过期是:

text 复制代码
Redis key 不自动删除
缓存值里带一个 expireTime 字段

例如:

json 复制代码
{
  "data": {
    "id": 1,
    "price": 100
  },
  "expireTime": "2026-06-04 12:00:00"
}

读流程:

text 复制代码
读缓存
    ↓
缓存不存在:查数据库重建
    ↓
缓存存在,未逻辑过期:直接返回
    ↓
缓存存在,但逻辑过期:
        先返回旧值
        后台异步刷新缓存

优点:

text 复制代码
1. 高可用
2. 不会出现热点 key 瞬间失效导致数据库被打爆
3. 用户请求延迟低

缺点:

text 复制代码
1. 用户可能短时间读到旧数据
2. 实现更复杂
3. 不适合强一致业务

适合:

text 复制代码
1. 首页数据
2. 商品详情热点数据
3. 排行榜
4. 推荐结果
5. 可以接受短暂旧数据的高并发场景

不适合:

text 复制代码
余额
支付状态
库存真实扣减
权限判断

十二、方案八:Read-Through / Write-Through / Write-Behind

这些属于缓存架构模式。


1. Read-Through

业务只查缓存,缓存组件负责加载数据库。

text 复制代码
业务查缓存
    ↓
缓存没有
    ↓
缓存组件查数据库
    ↓
缓存组件写缓存
    ↓
返回

优点:

text 复制代码
业务代码更干净
缓存加载逻辑集中管理

缺点:

text 复制代码
对缓存组件能力要求较高
普通业务中不如 Cache-Aside 常见

2. Write-Through

业务写缓存,由缓存组件同步写数据库。

text 复制代码
业务写缓存
    ↓
缓存组件写数据库
    ↓
数据库和缓存同步更新

优点:

text 复制代码
封装性好
业务不用自己维护数据库和缓存两套逻辑

缺点:

text 复制代码
1. 写延迟变高
2. 缓存层复杂度高
3. 对基础设施要求高

适合:

text 复制代码
统一缓存平台
中间件团队提供能力

3. Write-Behind

先写缓存,异步写数据库。

text 复制代码
写缓存
    ↓
异步批量写数据库

优点:

text 复制代码
写性能高
适合批量刷库

风险:

text 复制代码
缓存写成功,数据库还没写,服务挂了,数据可能丢。

适合:

text 复制代码
1. 日志
2. 计数器
3. 点赞数
4. 浏览量
5. 可以异步落库的数据

不适合:

text 复制代码
订单
支付
余额
库存真实扣减

十三、方案九:主从库读写分离场景

如果系统是:

text 复制代码
写主库
读从库

会出现主从延迟问题。

流程:

text 复制代码
写请求更新主库为 200
删除缓存
读请求缓存未命中
读请求查询从库
从库还没同步,还是 100
读请求把 100 写回缓存

最终:

text 复制代码
主库:200
缓存:100

解决方案

1. 更新后短时间读主库

用户刚写完数据,接下来几秒内读主库。

适合:

text 复制代码
用户修改资料后立刻查看
订单状态刚变更后立刻查询
2. 延迟双删
text 复制代码
更新数据库
删除缓存
延迟一段时间
再次删除缓存

等从库同步完成后,再删一次可能被旧从库值重建的缓存。

3. 版本号控制

缓存写入时检查版本。

如果从库旧数据版本低,就不能覆盖缓存中的新版本。

4. 关键读请求强制读主库

对于强一致读取,直接读主库,不读从库。


十四、方案十:多级缓存一致性

很多大型系统不是只有 Redis,还有本地缓存。

例如:

text 复制代码
Caffeine 本地缓存
    ↓
Redis 分布式缓存
    ↓
MySQL

读流程:

text 复制代码
先读本地缓存
本地没有读 Redis
Redis 没有读 MySQL

问题是:

text 复制代码
数据库更新了
Redis 删除了
但是各个应用实例的本地缓存还在

推荐方案

text 复制代码
更新数据库
    ↓
删除 Redis
    ↓
发送 MQ / Redis PubSub 缓存失效消息
    ↓
所有应用实例删除本地 Caffeine 缓存

本地缓存必须设置短 TTL。

text 复制代码
即使广播失败,本地缓存也会很快过期。

适合:

text 复制代码
1. 极高 QPS
2. 读多写少
3. 本地缓存命中率很重要
4. 多实例部署

注意:

多级缓存中,只删除 Redis 不够,本地缓存也必须失效。


十五、方案十一:强一致业务不要依赖缓存

有些业务不要幻想靠缓存一致性方案解决。

比如:

text 复制代码
账户余额
支付状态
订单最终状态
库存真实扣减
优惠券核销
权限安全判断

这些场景应该:

text 复制代码
核心判断走数据库 / 分布式事务 / 乐观锁 / 悲观锁 / Redis 原子操作
缓存只做展示或加速

例如:

text 复制代码
展示库存:可以读缓存
扣减库存:必须走 Redis 原子扣减或数据库乐观锁

展示余额:可以缓存
扣款判断:必须查账务系统或数据库

订单列表:可以缓存
是否允许退款:必须查数据库最新状态

核心原则:

缓存可以加速读,但不要让缓存决定生死。


十六、不同业务场景的完整选型

1. 普通读多写少业务

例如:

text 复制代码
商品详情
文章详情
用户主页
店铺信息

推荐:

text 复制代码
Cache-Aside
更新数据库 → 删除缓存
缓存设置 TTL
删除失败简单重试

增强:

text 复制代码
MQ 重试删除
互斥锁防击穿

2. 热点商品 / 首页数据

特点:

text 复制代码
读非常多
缓存命中率很重要
删除缓存后可能打爆数据库

推荐:

text 复制代码
更新数据库 → 删除缓存 → 异步预热缓存
互斥锁防击穿
逻辑过期
TTL 随机化

如果能接受短暂旧数据:

text 复制代码
逻辑过期 + 后台刷新

3. 写并发高的同一个 key

例如:

text 复制代码
库存
点赞数
计数器
排行榜分数

推荐:

text 复制代码
Redis 原子操作
Lua 脚本
异步批量落库
版本号控制
MQ 顺序消费

不要简单使用:

text 复制代码
更新数据库 → 更新缓存

非常容易乱序。


4. 对缓存命中率极高要求,又希望尽量新

例如:

text 复制代码
业务配置
价格缓存
会员权益配置
业务字典

可以考虑:

text 复制代码
更新数据库 → 更新缓存

但必须配合:

text 复制代码
分布式锁
版本号
CAS
TTL
MQ 补偿

如果写少读多,也可以选择:

text 复制代码
更新数据库 → 删除缓存 → 立即预热

5. 多服务共同修改数据

例如:

text 复制代码
商品服务改商品
后台管理系统改商品
活动服务改商品价格
定时任务改商品状态

推荐:

text 复制代码
业务层删除缓存 + binlog 监听删除缓存

或者:

text 复制代码
CDC 统一负责缓存失效

否则某个服务忘记删缓存,就会出现脏数据。


6. 本地缓存 + Redis

推荐:

text 复制代码
更新数据库
删除 Redis
发布缓存失效消息
所有实例删除本地缓存
本地缓存设置短 TTL

7. 主从库读写分离

推荐:

text 复制代码
更新后短时间读主库
延迟双删
版本号防旧值写回
缓存 TTL 兜底

8. 强一致核心业务

推荐:

text 复制代码
核心读写不走缓存
数据库事务 / 乐观锁 / 悲观锁 / Redis 原子操作
缓存只做展示

十七、企业级分层方案

如果做项目,可以按等级设计。

Level 1:基础可用

text 复制代码
读:Cache-Aside
写:更新数据库 → 删除缓存
缓存:设置 TTL

适合普通项目。


Level 2:防删除失败

text 复制代码
更新数据库
事务提交后删除缓存
删除失败立即重试
缓存 TTL 兜底

适合大多数中小业务。


Level 3:异步补偿

text 复制代码
更新数据库
事务内写本地消息表
事务提交后删除缓存
失败则 MQ / 定时任务重试
缓存 TTL 兜底

适合订单、商品、用户等重要业务。


Level 4:热点保护

text 复制代码
更新数据库
删除缓存
异步预热缓存
互斥锁防击穿
逻辑过期
TTL 随机化

适合高并发热点数据。


Level 5:多服务一致

text 复制代码
业务删除缓存
CDC / binlog 监听数据库变更
统一删除或更新缓存
本地缓存广播失效

适合大型微服务系统。


Level 6:高并发写一致

text 复制代码
分布式锁 / 版本号 / CAS / Lua
MQ 顺序消费
Redis 原子操作
最终异步落库

适合库存、计数、点赞、排行榜。


Level 7:强一致

text 复制代码
核心读写不依赖缓存
数据库事务 / 账务系统 / 强一致存储
缓存只做展示

适合钱、支付、订单、权限、安全业务。


十八、最终记忆口诀

1. 普通数据

先改真相,再删副本。

text 复制代码
更新数据库 → 删除缓存

2. 删除失败

撕公告失败,就登记工单继续撕。

text 复制代码
重试 / MQ / 本地消息表 / binlog / TTL

3. 热点数据

不要让公告栏突然空掉,然后所有人冲去派出所查户口本。

text 复制代码
异步预热 / 互斥锁 / 逻辑过期

4. 更新缓存

亲手贴新公告,就要防止别人后贴旧公告覆盖你。

text 复制代码
分布式锁 / 版本号 / CAS

5. 强一致

生死判断不要看公告栏,要看户口本。

text 复制代码
余额、支付、库存扣减、权限判断:不要依赖缓存最终一致。

十九、最终推荐组合

如果是普通 Java 后端项目,我建议默认选择这一套:

text 复制代码
1. 读:Cache-Aside
2. 写:数据库事务提交后删除缓存
3. 缓存:设置合理 TTL + 随机抖动
4. 删除失败:立即重试 2~3 次
5. 仍失败:写本地消息表 / 发 MQ 重试
6. 热点 key:加互斥锁防击穿
7. 高热点:删除后异步预热
8. 多服务修改:加 binlog / CDC 兜底
9. 本地缓存:通过 MQ / PubSub 广播失效
10. 强一致业务:核心逻辑不依赖缓存

二十、总结

缓存一致性没有绝对完美方案,只有适合业务场景的取舍。

普通业务中,最推荐的基础方案是:

text 复制代码
更新数据库 → 删除缓存 → TTL 兜底

如果删除缓存失败,就使用:

text 复制代码
立即重试
MQ 重试
本地消息表
binlog 监听
TTL 兜底

如果是热点数据,就使用:

text 复制代码
异步预热
互斥锁
逻辑过期
TTL 随机化

如果是更新缓存,就必须考虑:

text 复制代码
分布式锁
版本号
CAS
乱序覆盖

如果是强一致业务,要记住:

text 复制代码
缓存不能当真相。
数据库、事务、账务系统、强一致存储才是最终依据。

最后用一句话收尾:

缓存是副本,不是真相。
普通业务让副本最终一致;热点业务让副本别突然消失;强一致业务不要相信副本。

相关推荐
霸道流氓气质2 小时前
异步任务提交 + Redis 状态轮询模式实战指南
数据库·redis·缓存
我是一颗柠檬2 小时前
【Java项目技术亮点】多级缓存一致性方案:Canal+MQ实现数据库与缓存的最终一致
java·数据库·spring·缓存·kafka·rocketmq
Solis程序员3 小时前
拿捏登录安全:RS256 + 双令牌,把非法请求拦在 Redis 白名单门外
java·安全·缓存·面试·bootstrap·html
郝学胜-神的一滴3 小时前
系统设计 014:缓存深度实战:如何用 Cache 优雅优化数据库读写?
java·数据库·python·缓存·oracle·php·软件构建
TDengine (老段)3 小时前
TDengine Cache 与 Last 查询加速 — CACHEMODEL 机制与 RocksDB 缓存层
大数据·数据库·物联网·struts·缓存·时序数据库·tdengine
半夜修仙3 小时前
RabbitMQ应用问题
数据库·分布式·缓存·rabbitmq
我是一颗柠檬3 小时前
【Redis】Cluster集群Day11(2026年)
数据库·redis·后端·缓存
我是一颗柠檬16 小时前
【Redis】发布订阅与消息队列Day8(2026年)
数据库·redis·后端·缓存
sukioe16 小时前
Redis 持久化+高可用详解:RDB/AOF/混合/主从/哨兵/集群
数据库·redis·缓存