缓存与数据库一致性
问题
如何保证缓存与数据库的数据一致性?有哪些方案?
答案
缓存和数据库是两个独立的存储系统,无法做到事务级别的强一致性。所有方案本质上都是在一致性和性能之间做取舍。
一、问题根源
缓存不一致的根本原因:缓存更新和数据库更新是两个独立操作,不在同一个事务中。
不管哪种方案,都需要回答两个问题:
- 操作顺序:先操作数据库还是先操作缓存?
- 失败补偿:某一步失败了怎么办?
二、Cache Aside 不一致分析
Cache Aside(先更新数据库,再删除缓存)是最常用的模式,但仍有不一致窗口:
场景一:删除缓存失败
解决:重试机制(见下文)
场景二:并发读写
分析:这个场景要求步骤②的数据库读 + 步骤⑤的缓存写 > 步骤③的数据库写 + 步骤④的缓存删。由于数据库写操作(加锁)通常远慢于读操作,这种情况概率极低。
解决:给缓存设置 TTL 作为兜底。
三、延迟双删
延迟双删(Double Delete with Delay)是一种简单有效的增强一致性方案。
流程
实现
public void updateWithDoubleDelete(String key, Object newValue) {
// 1. 先删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
db.update(newValue);
// 3. 延迟后再次删除缓存(异步执行,不阻塞主流程)
CompletableFuture.runAsync(() -> {
try {
// 延迟时间 = 主从延迟 + 业务读耗时的经验值
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
redisTemplate.delete(key);
});
}
延迟时间如何确定?
延迟时间需要大于「一次读请求的耗时」,确保第二次删除能清除并发读回填的脏缓存。
经验公式:延迟时间 = 业务读耗时 + 主从复制延迟 + 几百毫秒余量
- 延迟时间难以精确设定:设短了可能还没覆盖到脏缓存,设长了数据不一致窗口更大
- 不能防止第二次删除也失败:需要配合重试机制
- 增加了系统复杂度:需要异步线程池或消息队列
四、删除缓存重试机制
当删除缓存失败时,必须有重试机制保证最终一致。
方案一:消息队列重试
@Service
public class CacheDeleteRetryService {
@Autowired
private RabbitTemplate rabbitTemplate;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 删除缓存,失败时投递消息重试
public void safeDelete(String key) {
try {
redisTemplate.delete(key);
} catch (Exception e) {
// 删除失败,发送到重试队列
rabbitTemplate.convertAndSend("cache.delete.retry", key);
}
}
// 消费者:重试删除缓存
@RabbitListener(queues = "cache.delete.retry")
public void retryDelete(String key) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
try {
redisTemplate.delete(key);
return; // 删除成功
} catch (Exception e) {
if (i == maxRetry - 1) {
// 告警并记录
log.error("缓存删除重试失败: key={}", key, e);
alertService.send("缓存删除重试失败: " + key);
}
sleep(1000 * (i + 1)); // 退避重试
}
}
}
}
方案二:基于本地消息表
将缓存删除任务持久化到数据库,由定时任务轮询执行:
CREATE TABLE cache_delete_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cache_key VARCHAR(255) NOT NULL,
retry_count INT DEFAULT 0,
status TINYINT DEFAULT 0 COMMENT '0:待处理 1:成功 2:失败',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
优点是不依赖消息队列,方案简单;缺点是增加了数据库负担。
五、基于 binlog 的异步更新(推荐)
通过监听数据库 binlog 变更来异步更新缓存,是侵入性最低、最可靠的方案。
架构
核心优势
- 业务代码零侵入:应用只需操作数据库,不用关心缓存
- 天然保证顺序:binlog 严格有序
- 可靠性高:即使缓存服务临时不可用,消息可以堆积重放
Canal 使用示例
/**
* Canal 客户端:监听 binlog 变更,同步删除缓存
*/
@Component
public class CanalCacheSync {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@CanalEventListener
public void onEvent(CanalEntry.Entry entry) {
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
return;
}
CanalEntry.RowChange rowChange = CanalEntry.RowChange
.parseFrom(entry.getStoreValue());
String tableName = entry.getHeader().getTableName();
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
// 根据表名和主键生成缓存 Key
String cacheKey = buildCacheKey(tableName, rowData);
// 删除缓存(也可以更新缓存)
redisTemplate.delete(cacheKey);
}
}
private String buildCacheKey(String table, CanalEntry.RowData rowData) {
// 提取主键值
String id = rowData.getAfterColumnsList().stream()
.filter(CanalEntry.Column::getIsKey)
.findFirst()
.map(CanalEntry.Column::getValue)
.orElse("");
return table + ":" + id;
}
}
- MySQL 需开启 binlog,格式设为
ROW - Canal Server 伪装为 MySQL Slave
- 生产环境建议 Canal → Kafka → 消费者,增加缓冲
- 消费者需要幂等处理(同一条 binlog 可能被重复消费)
六、方案对比与选型
| 方案 | 一致性 | 业务侵入 | 复杂度 | 延迟 | 适用场景 |
|---|---|---|---|---|---|
| TTL 过期 | 弱 | 无 | 极低 | TTL 时间 | 一致性要求低 |
| Cache Aside + TTL | 较好 | 低 | 低 | 秒级 | 通用方案 |
| 延迟双删 | 较好 | 中 | 中 | 延迟时间 | 并发写较多 |
| MQ 重试删除 | 好 | 中 | 中 | 秒级 | 需要可靠删除 |
| binlog + Canal | 好 | 无 | 高 | 秒级 | 大型项目,推荐方案 |
小型项目:Cache Aside + TTL 兜底
中型项目:Cache Aside + MQ 重试
大型项目:binlog + Canal + MQ
七、强一致性方案
如果业务要求强一致性(如金融场景),需要牺牲性能:
方案:分布式锁
public Object getWithLock(String key) {
// 1. 查缓存
Object value = redis.get(key);
if (value != null) return value;
// 2. 加分布式锁(只有一个线程可以加载)
String lockKey = "lock:" + key;
if (redis.tryLock(lockKey, 5, TimeUnit.SECONDS)) {
try {
// 双重检查
value = redis.get(key);
if (value != null) return value;
// 查数据库并回填
value = db.query(key);
redis.set(key, value);
return value;
} finally {
redis.unlock(lockKey);
}
}
// 获取锁失败,短暂等待后重试
Thread.sleep(100);
return getWithLock(key);
}
分布式锁会严重影响并发性能。除非业务强制要求,否则不推荐在缓存层使用强一致性方案。大多数场景下,最终一致性 + TTL 兜底 已经足够。
常见面试问题
Q1: 如何保证缓存和数据库的一致性?
答案:
没有完美方案,只有根据场景选择的最优解:
- 基础方案:Cache Aside + TTL 兜底,先更新 DB 再删缓存
- 增强方案:增加删除重试(MQ 投递重试)
- 最优方案:基于 binlog(Canal)异步删除缓存,业务零侵入
- 强一致方案:分布式锁(牺牲性能,不推荐)
核心思路:以数据库为准,缓存通过异步方式保持最终一致,TTL 作为最终兜底。
Q2: 延迟双删的延迟时间怎么确定?
答案:
延迟时间需要覆盖「一次读请求可能回填脏缓存」的窗口:
- 计算公式:主从延迟 + 业务读耗时 + 200~500ms 余量
- 经验值:通常 500ms ~ 1s
- 动态方案:监控接口 P99 耗时,动态调整延迟
Q3: 为什么推荐 binlog 方案?
答案:
- 业务零侵入:不需要修改任何业务代码
- 天然有序:binlog 严格按事务提交顺序
- 不怕遗漏:即使某张表忘了加缓存删除,binlog 方案也能自动覆盖
- 可扩展:同一套 binlog 还能用于数据同步、审计、搜索索引更新
缺点是引入了 Canal 组件,增加了运维复杂度。
Q4: 缓存和数据库双写,能否用分布式事务保证强一致?
答案:
技术上可以(通过 2PC 或 TCC),但强烈不推荐:
- Redis 不支持标准的分布式事务协议
- 引入分布式事务会大幅降低性能(QPS 可能下降 10 倍)
- 缓存的核心价值是高性能,强一致性方案违背了缓存的设计初衷
- 大多数业务场景接受秒级的最终一致性
正确做法是以数据库为权威数据源,缓存保持最终一致。
Q5: 如果 Redis 突然宕机,缓存全部丢失怎么办?
答案:
- 预案:Redis 开启持久化(AOF + RDB),重启后自动恢复大部分数据
- 降级:Redis 不可用时直接穿透到数据库,限流保护数据库
- 预热:Redis 恢复后通过脚本预热热点数据
- 多级缓存:本地缓存(Caffeine)作为一级缓存,Redis 宕机时仍有部分缓存可用
详见多级缓存架构。