跳到主要内容

缓存与数据库一致性

问题

如何保证缓存与数据库的数据一致性?有哪些方案?

答案

缓存和数据库是两个独立的存储系统,无法做到事务级别的强一致性。所有方案本质上都是在一致性性能之间做取舍。

一、问题根源

缓存不一致的根本原因:缓存更新和数据库更新是两个独立操作,不在同一个事务中

不管哪种方案,都需要回答两个问题:

  1. 操作顺序:先操作数据库还是先操作缓存?
  2. 失败补偿:某一步失败了怎么办?

二、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);
});
}

延迟时间如何确定?

延迟时间需要大于「一次读请求的耗时」,确保第二次删除能清除并发读回填的脏缓存。

经验公式:延迟时间 = 业务读耗时 + 主从复制延迟 + 几百毫秒余量

延迟双删的局限
  1. 延迟时间难以精确设定:设短了可能还没覆盖到脏缓存,设长了数据不一致窗口更大
  2. 不能防止第二次删除也失败:需要配合重试机制
  3. 增加了系统复杂度:需要异步线程池或消息队列

四、删除缓存重试机制

当删除缓存失败时,必须有重试机制保证最终一致。

方案一:消息队列重试

@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 变更来异步更新缓存,是侵入性最低、最可靠的方案

架构

核心优势

  1. 业务代码零侵入:应用只需操作数据库,不用关心缓存
  2. 天然保证顺序:binlog 严格有序
  3. 可靠性高:即使缓存服务临时不可用,消息可以堆积重放

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;
}
}
Canal 部署要点
  1. MySQL 需开启 binlog,格式设为 ROW
  2. Canal Server 伪装为 MySQL Slave
  3. 生产环境建议 Canal → Kafka → 消费者,增加缓冲
  4. 消费者需要幂等处理(同一条 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: 如何保证缓存和数据库的一致性?

答案

没有完美方案,只有根据场景选择的最优解:

  1. 基础方案:Cache Aside + TTL 兜底,先更新 DB 再删缓存
  2. 增强方案:增加删除重试(MQ 投递重试)
  3. 最优方案:基于 binlog(Canal)异步删除缓存,业务零侵入
  4. 强一致方案:分布式锁(牺牲性能,不推荐)

核心思路:以数据库为准,缓存通过异步方式保持最终一致,TTL 作为最终兜底

Q2: 延迟双删的延迟时间怎么确定?

答案

延迟时间需要覆盖「一次读请求可能回填脏缓存」的窗口:

  • 计算公式:主从延迟 + 业务读耗时 + 200~500ms 余量
  • 经验值:通常 500ms ~ 1s
  • 动态方案:监控接口 P99 耗时,动态调整延迟

Q3: 为什么推荐 binlog 方案?

答案

  1. 业务零侵入:不需要修改任何业务代码
  2. 天然有序:binlog 严格按事务提交顺序
  3. 不怕遗漏:即使某张表忘了加缓存删除,binlog 方案也能自动覆盖
  4. 可扩展:同一套 binlog 还能用于数据同步、审计、搜索索引更新

缺点是引入了 Canal 组件,增加了运维复杂度。

Q4: 缓存和数据库双写,能否用分布式事务保证强一致?

答案

技术上可以(通过 2PC 或 TCC),但强烈不推荐

  1. Redis 不支持标准的分布式事务协议
  2. 引入分布式事务会大幅降低性能(QPS 可能下降 10 倍)
  3. 缓存的核心价值是高性能,强一致性方案违背了缓存的设计初衷
  4. 大多数业务场景接受秒级的最终一致性

正确做法是以数据库为权威数据源,缓存保持最终一致

Q5: 如果 Redis 突然宕机,缓存全部丢失怎么办?

答案

  1. 预案:Redis 开启持久化(AOF + RDB),重启后自动恢复大部分数据
  2. 降级:Redis 不可用时直接穿透到数据库,限流保护数据库
  3. 预热:Redis 恢复后通过脚本预热热点数据
  4. 多级缓存:本地缓存(Caffeine)作为一级缓存,Redis 宕机时仍有部分缓存可用

详见多级缓存架构

相关链接