前言
当你面试的时候,面试官可能会问你这样的场景问题:
用户下单后,明明已经支付成功,但页面上的订单状态还是"待支付"------刷新一下好了;用户修改了个人资料,再次查看时还是旧信息------等几秒又对了。
这个问题你怎么回答?
这些问题的根源,基本都是缓存与数据库数据不一致。
你的回答是:先删缓存,再写数据库? 如果你这样回答,那就来看看这一篇。
缓存能大幅提升系统性能,但引入缓存的同时,也带来了数据一致性的挑战。缓存和数据库是两个独立的存储系统,更新时机、失败处理稍有不当,就会导致数据不一致。
本文将从原理到实战,系统梳理缓存一致性的解决方案,所有代码按文件整合,方便直接参考。
一、为什么会有一致性问题?
1.1 典型场景
大多数系统的读写流程是这样的:
读操作:先查缓存,命中则直接返回;未命中则查数据库,再写入缓存。
写操作:更新数据库,然后更新缓存或删除缓存。
问题就出在写操作上------更新数据库和操作缓存是两个独立的步骤,任何一步失败,都会导致数据不一致。
1.2 不一致的根源
| 原因 | 说明 |
|---|---|
| 操作非原子 | 更新数据库和更新缓存是两个操作,无法在一个事务内完成 |
| 并发读写 | 一个线程正在更新缓存,另一个线程同时读取,可能读到中间状态 |
| 操作失败 | 数据库更新成功但缓存更新失败,或反之 |
| 延迟同步 | 缓存更新有延迟,短时间内数据不一致 |
1.3 一致性级别
在开始之前,先明确我们能接受什么级别的"一致":
| 级别 | 含义 | 适用场景 |
|---|---|---|
| 强一致性 | 写入后任何时刻都能读到最新值 | 金融、支付、库存扣减 |
| 最终一致性 | 允许短暂不一致,但最终会一致 | 大部分互联网业务 |
| 弱一致性 | 不保证什么时候能读到最新值 | 非关键数据(如浏览量) |
💡 对于大多数业务,最终一致性就已经够用了。追求强一致性会带来巨大的性能开销。
二、常见策略对比
| 策略 | 核心思路 | 优点 | 缺点 |
|---|---|---|---|
| Cache Aside(旁路缓存) | 读未命中时查DB写缓存;写时更新DB并删除缓存 | 实现简单,命中率高 | 删除缓存期间有短暂不一致 |
| Read/Write Through(读写穿透) | 缓存层代理所有读写,由缓存层与DB交互 | 对应用透明 | 缓存层实现复杂 |
| Write Behind(异步写入) | 只写缓存,异步批量刷入DB | 写入性能极高 | 数据丢失风险大 |
| 双写 + 重试 | 先更新DB,再更新缓存,失败则重试 | 保证最终一致 | 实现稍复杂 |
最推荐的是「Cache Aside + 延迟双删」 ------这是业界最成熟、最实用的方案。
三、核心策略详解:Cache Aside
3.1 读流程
text
1. 查询缓存
2. 命中 → 直接返回
3. 未命中 → 查数据库 → 写入缓存 → 返回
3.2 写流程(核心)
text
1. 更新数据库
2. 删除缓存(不是更新缓存!)
为什么是删除缓存,而不是更新缓存?
| 做法 | 问题 |
|---|---|
| 更新缓存 | 并发写可能导致缓存被旧数据覆盖;如果更新频繁,缓存会被无效数据填满 |
| 删除缓存 | 下一次读会查数据库并重建缓存,保证数据是最新的 |
Cache Aside写流程的核心代码:
java
@Service
public class ProductService {
@Transactional
public void updateProduct(Product product) {
// 1. 更新数据库
productMapper.updateById(product);
// 2. 删除缓存(让下次读去重建)
redisTemplate.delete("product:" + product.getId());
}
}
3.3 Cache Aside的并发问题
先更新数据库再删除缓存,中间有时间窗口,可能发生:
| 时间 | 线程A(写) | 线程B(读) | 结果 |
|---|---|---|---|
| T1 | 更新数据库(新值) | ||
| T2 | 查缓存(旧值存在) | ||
| T3 | 删除缓存 | ||
| T4 | 返回旧值 | ❌ 不一致 |
这就是经典的**「先更新DB再删缓存」的并发问题**------在T2到T3之间,读请求会读到旧缓存。
四、解决方案一:延迟双删
4.1 核心原理
延迟双删的核心思路是:删两次缓存------删完等一会儿,再删一次。
text
1. 删除缓存(第一次)
2. 更新数据库
3. 休眠一段时间(大于读请求的执行时间)
4. 删除缓存(第二次)
为什么有效?
- 第一次删除:清空旧缓存,让后续读请求去查数据库
- 休眠:等待可能正在进行的读请求完成(读请求会把旧数据写回缓存)
- 第二次删除:把读请求可能写入的旧缓存再清掉
4.2 完整代码实现
📁 项目结构
text
src/
├── main/
│ ├── java/com/example/demo/
│ │ ├── service/
│ │ │ └── ProductService.java
│ │ └── annotation/
│ │ └── CacheEvict.java
│ └── resources/
│ └── application.yml
└── pom.xml
ProductService.java(延迟双删完整实现)
java
package com.example.demo.service;
import com.example.demo.entity.Product;
import com.example.demo.mapper.ProductMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
/**
* 更新商品 - 延迟双删方案
*/
@Transactional
public void updateProduct(Product product) {
Long id = product.getId();
String cacheKey = CACHE_KEY_PREFIX + id;
// 1. 第一次删除缓存
redisTemplate.delete(cacheKey);
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延迟一段时间(异步执行第二次删除)
delayDeleteCache(cacheKey);
}
/**
* 异步延迟删除缓存
* 延迟时间应大于读请求的执行时间(通常100-300ms)
*/
private void delayDeleteCache(String cacheKey) {
// 方式一:使用线程池异步执行
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(200); // 休眠200ms
redisTemplate.delete(cacheKey);
log.info("延迟双删完成: {}", cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
/**
* 读操作:查缓存 → 未命中则查数据库 → 写缓存
*/
public Product getProduct(Long id) {
String cacheKey = CACHE_KEY_PREFIX + id;
// 1. 查缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 2. 查数据库
product = productMapper.selectById(id);
if (product != null) {
// 3. 写缓存(设置过期时间,防止缓存雪崩)
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
}
return product;
}
}
4.3 延迟双删的注意事项
| 要点 | 说明 |
|---|---|
| 延迟时间怎么定? | 根据业务实际的读耗时来定,一般100-300ms,建议压测后确定 |
| 异步删除必须用 | 同步sleep会阻塞主线程,影响接口响应时间 |
| 第二次删除失败怎么办? | 记录日志 + 定时任务补偿(见下文) |
五、解决方案二:订阅Binlog(终极方案)
延迟双删能解决大部分问题,但有一个前提:第二次删除必须成功。如果第二次删除失败,数据仍然不一致。
更可靠的方式是:监听MySQL的binlog,异步删除缓存。
5.1 原理
text
1. 更新数据库(业务正常执行)
2. Canal监听binlog,捕获数据变更事件
3. 解析变更事件,异步删除对应的缓存
这个方案的好处是:
- 解耦:缓存删除和业务逻辑完全分离
- 可靠:binlog是MySQL的官方机制,不会丢数据
- 最终一致:只要binlog被消费,缓存就一定会被删除
5.2 架构图
text
┌──────────┐ 更新DB ┌──────────┐
│ 业务应用 │ ──────────► │ MySQL主库 │
└──────────┘ └──────────┘
│
binlog推送
▼
┌──────────┐
│ Canal │
└──────────┘
│
解析事件
▼
┌──────────┐
│ MQ/消费者│
└──────────┘
│
删除缓存
▼
┌──────────┐
│ Redis │
└──────────┘
5.3 代码实现(Canal + MQ)
📁 消费端代码
java
package com.example.demo.consumer;
import com.alibaba.fastjson.JSON;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
@RabbitListener(queues = "cache.delete.queue")
public class CacheDeleteConsumer {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@RabbitHandler
public void handleBinlogEvent(String messageJson) {
// 解析binlog事件
BinlogEvent event = JSON.parseObject(messageJson, BinlogEvent.class);
// 只处理product表的变更
if (!"product".equals(event.getTableName())) {
return;
}
// 删除对应的缓存
String cacheKey = "product:" + event.getData().get("id");
redisTemplate.delete(cacheKey);
log.info("binlog触发删除缓存: {}", cacheKey);
}
}
@Data
class BinlogEvent {
private String tableName;
private String operation; // INSERT/UPDATE/DELETE
private Map<String, Object> data;
}
这个方案的最大优势是:彻底解耦,缓存删除不依赖业务代码,只要binlog被消费,缓存一定会被删除。
六、方案三:双写 + 重试补偿
有些场景下,我们需要更新缓存 而不是删除缓存(比如缓存的是聚合数据,重建成本高)。这时候可以采用双写 + 重试方案。
6.1 原理
text
1. 更新数据库
2. 更新缓存
3. 如果缓存更新失败 → 记录失败日志 → 定时任务重试
6.2 完整代码实现
📁 文件结构
text
src/
├── main/
│ ├── java/com/example/demo/
│ │ ├── entity/
│ │ │ └── CacheRetry.java # 重试记录表实体
│ │ ├── mapper/
│ │ │ └── CacheRetryMapper.java
│ │ ├── service/
│ │ │ ├── ProductService.java # 业务服务
│ │ │ └── CacheRetryService.java # 重试补偿服务
│ │ └── consumer/
│ │ └── CacheDeleteConsumer.java # 消费端
│ └── resources/
│ └── db/
│ └── schema.sql # 建表语句
① 重试记录表(schema.sql)
sql
CREATE TABLE cache_retry (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cache_key VARCHAR(255) NOT NULL COMMENT '缓存Key',
cache_value TEXT NOT NULL COMMENT '缓存值(JSON)',
operation VARCHAR(20) NOT NULL COMMENT '操作类型:SET/DELETE',
status TINYINT DEFAULT 0 COMMENT '0-待重试 1-成功 2-失败',
retry_count INT DEFAULT 0 COMMENT '已重试次数',
max_retry INT DEFAULT 5 COMMENT '最大重试次数',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_status_retry (status, retry_count)
);
② ProductService.java(双写逻辑)
java
package com.example.demo.service;
import com.example.demo.entity.CacheRetry;
import com.example.demo.entity.Product;
import com.example.demo.mapper.CacheRetryMapper;
import com.example.demo.mapper.ProductMapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class ProductService {
@Resource
private ProductMapper productMapper;
@Resource
private CacheRetryMapper cacheRetryMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
private static final String CACHE_KEY_PREFIX = "product:";
/**
* 更新商品 - 双写 + 失败记录
*/
@Transactional
public void updateProduct(Product product) {
Long id = product.getId();
String cacheKey = CACHE_KEY_PREFIX + id;
// 1. 更新数据库
productMapper.updateById(product);
// 2. 更新缓存
try {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
} catch (Exception e) {
// 3. 缓存更新失败 → 记录重试表
CacheRetry retry = new CacheRetry();
retry.setCacheKey(cacheKey);
retry.setCacheValue(JSON.toJSONString(product));
retry.setOperation("SET");
retry.setStatus(0); // 待重试
retry.setRetryCount(0);
cacheRetryMapper.insert(retry);
log.error("缓存更新失败,已记录重试: {}", cacheKey, e);
}
}
}
③ CacheRetryService.java(定时补偿)
java
package com.example.demo.service;
import com.example.demo.entity.CacheRetry;
import com.example.demo.mapper.CacheRetryMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
@Component
public class CacheRetryService {
@Resource
private CacheRetryMapper cacheRetryMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
// 每分钟扫描一次,重试失败的缓存操作
@Scheduled(fixedDelay = 60000)
public void retryFailedCacheOperations() {
LambdaQueryWrapper<CacheRetry> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CacheRetry::getStatus, 0)
.lt(CacheRetry::getRetryCount, CacheRetry::getMaxRetry)
.orderByAsc(CacheRetry::getCreateTime)
.last("limit 100");
List<CacheRetry> retryList = cacheRetryMapper.selectList(wrapper);
for (CacheRetry retry : retryList) {
try {
if ("SET".equals(retry.getOperation())) {
// 重建缓存
Object value = JSON.parseObject(retry.getCacheValue(), Object.class);
redisTemplate.opsForValue().set(retry.getCacheKey(), value, 30, TimeUnit.MINUTES);
} else if ("DELETE".equals(retry.getOperation())) {
redisTemplate.delete(retry.getCacheKey());
}
// 标记成功
retry.setStatus(1);
cacheRetryMapper.updateById(retry);
} catch (Exception e) {
// 重试失败,增加重试次数
retry.setRetryCount(retry.getRetryCount() + 1);
cacheRetryMapper.updateById(retry);
log.error("缓存重试失败: {}", retry.getCacheKey(), e);
}
}
}
}
七、方案对比与选型建议
| 方案 | 一致性级别 | 实现复杂度 | 对业务侵入 | 适用场景 |
|---|---|---|---|---|
| 延迟双删 | 最终一致 | 低 | 低 | 大多数业务,对实时性要求不高 |
| 订阅Binlog | 最终一致 | 高 | 极低 | 要求高可靠、低侵入的大型项目 |
| 双写+重试 | 最终一致 | 中 | 中 | 缓存重建成本高、需要更新缓存的场景 |
| 读写穿透(Read/Write Through) | 强一致 | 高 | 中 | 对一致性要求高的场景 |
| 只设过期时间 | 最终一致 | 极低 | 极低 | 对一致性要求极低的场景 |
选型思路
text
是否需要强一致性?
├── 是 → 读写穿透(Write Through)或 放弃缓存
│
└── 否 → 最终一致性
├── 业务简单、快速上线 → 延迟双删
├── 追求可靠性、低侵入 → 订阅Binlog
└── 缓存重建成本高 → 双写+重试
八、最佳实践与避坑指南
8.1 核心原则
| 原则 | 说明 |
|---|---|
| 缓存要有过期时间 | 兜底方案,即使一致性出问题,数据最终也会自动修复 |
| 先更新DB,再操作缓存 | 避免缓存成功但DB失败导致的数据不一致 |
| 缓存操作要设置超时 | 防止缓存操作阻塞主流程 |
| 记录失败日志 | 便于排查和补偿 |
8.2 常见踩坑点
| 坑 | 后果 | 解决方案 |
|---|---|---|
| 先更新缓存再更新DB | 缓存成功但DB失败,数据永久不一致 | 永远先更新DB |
| 更新缓存而不是删除缓存 | 并发写导致旧数据覆盖新数据 | 优先用删除,而不是更新 |
| 缓存操作失败不回滚 | DB成功但缓存失败,数据不一致 | 记录失败日志,异步补偿 |
| 延迟双删同步sleep | 接口响应变慢,线程阻塞 | 必须异步执行第二次删除 |
| 缓存雪崩 | 大量缓存同时过期,DB被打爆 | 设置不同的过期时间(加随机值) |
| 缓存穿透 | 查询不存在的数据,直接打到DB | 布隆过滤器 或 缓存空对象 |
8.3 监控告警建议
- 监控缓存命中率:低于阈值说明缓存失效频繁
- 监控缓存操作失败率:连续失败需告警
- 监控重试表堆积量:大量待重试说明缓存服务有问题
- 监控主从延迟:binlog消费延迟过高会影响一致性
九、总结
缓存一致性是引入缓存后必须面对的问题。没有完美的方案,只有在一致性、性能、复杂度之间做权衡。
回顾全文,几个核心要点:
| 要点 | 说明 |
|---|---|
| 先更新DB,后操作缓存 | 这是底线原则,不能打破 |
| 删缓存优于更新缓存 | 删除后由读请求重建,更安全 |
| 延迟双删是入门首选 | 实现简单,能解决大部分问题 |
| 订阅Binlog是终极方案 | 解耦、可靠,适合大型项目 |
| 缓存一定要设过期时间 | 兜底机制,保证最终一致性 |
💡 最后送大家一句话 :缓存一致性没有银弹,接受最终一致性,设计好补偿机制,比追求完美的强一致更务实。
希望这篇文章能帮你从容应对缓存一致性问题。如有问题,欢迎交流讨论!