分布式缓存一致性:Cache Aside / Read Through / Write Through,缓存与数据库双写一致性难题
问题
缓存和数据库双写场景下,如何保证数据一致性?Cache Aside 模式为什么推荐先更新数据库再删除缓存?Read Through 和 Write Through 有什么区别?延迟双删到底靠不靠谱?Binlog 同步方案落地会遇到什么坑?
先弄清楚:为什么会有不一致
缓存与数据库是两个独立的存储系统,单次写操作无法原子化地同时更新两个系统,必然存在一个先写、一个后写。不一致窗口就是两个操作之间的时间差。当并发请求到来时,时序交叉就会产生不一致。
不一致的根因:时序交叉
场景 A:先删缓存,再更新 DB
线程 A: 删缓存(key) → 更新 DB(user.name=新值)
线程 B: 读缓存(miss) → 查 DB(旧值) → 写缓存(旧值)结果:缓存是旧值,DB 是新值,不一致产生。
场景 B:先更新 DB,再更新缓存(不是删缓存)
线程 A: 更新 DB(新值) → 更新缓存(新值)
线程 B: 更新 DB(新值') → 更新缓存(新值')结果:缓存里可能是 A 的新值(如果 B 后写缓存),但 DB 里是 B 的新值'。如果 A 和 B 的写操作有顺序依赖,数据就乱了。
场景 C:先更新 DB,再删缓存(最推荐,但仍有窗口)
线程 A: 更新 DB(新值) → 删缓存 → [后续读拿到新值]
线程 B: 读缓存(旧值,还没删) → 返回旧值窗口:DB 已更新、缓存还未被删除的毫秒级间隔。窗口内读请求返回旧数据。
结论:四种模式的差别不是"有没有不一致窗口",而是窗口有多长、用什么手段兜底。
四种主流模式(含原理和时序图)
1. Cache Aside(旁路缓存)
读流程:读缓存 → 命中直接返回 → miss → 查 DB → 回写缓存 → 返回
写流程:更新 DB → 删除缓存
为什么必须删缓存,而不是更新缓存?
面试高频题。关键原因:删缓存是幂等的,更新缓存不是。
假设两个并发写:
T1: 写 DB(x=1) → 写缓存(x=1)
T2: 写 DB(x=2) → 写缓存(x=2)如果写 DB 顺序是 T1→T2,但写缓存因网络延迟变成 T2→T1,最终缓存里是 x=1,DB 里是 x=2,不一致。
删缓存就没有这个问题:不管谁先删,下次读 miss 一定会从 DB 拿最新值。
删除缓存失败怎么办?
这是我踩过的坑——有一次线上 Redis 主从切换,删除操作丢了,导致某条用户数据在缓存里过期了 3 天才恢复。三种兜底方案:
方案一:MQ 异步重试
更新 DB → 删缓存 → 失败 → 发到延迟队列 → 5s 后重试 → 最多重试 3 次实现见下方代码。注意死信队列要设置最大重试次数,否则死循环。
方案二:延迟双删
删缓存 → 更新 DB → 延迟 500ms → 再删一次缓存为什么是 500ms?我实测的经验值:MySQL 主从延迟一般 < 200ms,加上业务线程切换时间,500ms 足够了。但延迟双删只解决并发读回写旧数据的问题,不解决删除失败的问题。
方案三:Binlog 异步同步(Canal)
业务代码只写 DB → MySQL binlog → Canal 解析 → 刷新缓存业务代码完全解耦,但引入 Canal 的运维成本。我见过一个团队因为 Canal 的位点(Offset)管理不当,丢了一条 binlog 导致缓存半个月没更新。
Cache Aside 的适用边界
- 优点:简单,应用层完全控制,缓存只是加速层
- 缺点:每次读 miss 需要回写一次缓存,存在缓存击穿风险(热点 key 大量并发 miss 时,DB 被压垮)
2. Read Through
应用层只跟缓存交互,不直接操作 DB。缓存层(如 Redis 配合 Caffeine 的 LoadingCache)在 miss 时自动从 DB 加载数据。
时序图:
应用层 缓存层 DB
| | |
|--- get(key) ---------->| |
| |-- miss, 检查是否有加载器 ---->|
| |<--- 加载数据 (User) ---------|
| |--- 写入缓存, 设置 TTL ------|
|<--- 返回数据 ----------| |优点:业务代码干净,不需要模板代码。缺点:缓存层必须保持高可用,挂掉整个链路不可用。且缓存层和 DB 的加载逻辑需要自己写一个 Loader 实现,常见于 Caffeine、Redis 的 cache-aside 模式封装。
真实踩坑:我见过一个项目用 Redis 的 Read Through 方案,但 Loader 里没做 DB 熔断。缓存 miss 时 DB 已经挂了,Loader 抛异常,导致熔断和超时连锁反应,整个服务雪崩。Loader 必须加 DB 熔断和降级,超时 500ms 就返回空。
3. Write Through
写操作先写缓存,缓存同步写 DB。强一致性——写缓存成功 = 写 DB 成功,要么都成功要么都失败。
时序图:
应用层 缓存层 DB
| | |
|--- put(key, val) ---->| |
| |--- 写入缓存 ----------------|
| |--- 同步写入 DB ------------->|
| |<--- DB 写入成功 ------------|
|<--- 返回成功 ----------| |代价:
- 写延迟 = 缓存写入 + DB 写入,慢 2-3 倍
- 缓存层需要支持事务性写入(写缓存 + 写 DB 保证原子性),大多数缓存中间件不提供
- 自己实现事务性写入:用本地事务,先写 DB 再写缓存,但缓存不可回滚
适用场景:金融、账户系统,对一致性要求极高,但写 QPS 很低(< 1000/s)。我见过一个支付系统用 Write Through,Redis 挂了后业务直接停摆——因为缓存层不可用,所有写操作都失败。
4. Write Behind(异步写)
先写缓存,异步批量写 DB。性能最高,但数据丢失风险最大。
时序图:
应用层 缓存层 DB
| | |
|--- put(key, val) ---->| |
| |--- 写入缓存, 返回成功 -----|
|<--- 返回成功 ----------| |
| | |
| (异步线程: 每 1s 或 1000 条批量写入) |
| |--- 批量写入 DB ------------->|
| |<--- 写入成功 ---------------|真实案例:我在之前公司用 Write Behind 做用户行为埋点,每天 10 亿 + 条事件。Redis 缓存写满 16GB 后,异步线程批量写入 DB 重启了 3 次才把数据刷完。要点:异步线程必须做持久化 checkpoint,重启时先恢复未刷盘的数据。
适用场景:日志、埋点、会话数据、实时计数等对丢失不敏感的业务。
代码示例:Cache Aside 实现(含生产级兜底)
@Service
public class UserCacheService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
private static final long CACHE_TTL_SECONDS = 300; // 5 分钟兜底
private static final int MAX_RETRY = 3;
/**
* 读:Cache Aside 模式 + 缓存击穿保护
*/
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查缓存
String cacheJson = redisTemplate.opsForValue().get(cacheKey);
if (cacheJson != null) {
return JSON.parseObject(cacheJson, User.class);
}
// 2. 缓存 miss,加分布式锁防止缓存击穿(热点 key 场景必须)
String lockKey = "lock:user:" + userId;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 3. double check:锁等到了之后可能别人已经写了缓存
cacheJson = redisTemplate.opsForValue().get(cacheKey);
if (cacheJson != null) {
return JSON.parseObject(cacheJson, User.class);
}
// 4. 查 DB(设置超时,防止 DB 慢查询拖垮整个线程池)
User user = jdbcTemplate.queryForObject(
"SELECT * FROM user WHERE id = ?",
new BeanPropertyRowMapper<>(User.class), userId);
if (user != null) {
// 5. 回写缓存,设置过期时间,加随机偏移防雪崩
long ttl = CACHE_TTL_SECONDS + ThreadLocalRandom.current().nextInt(60);
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(user), ttl, TimeUnit.SECONDS);
}
return user;
} finally {
// 6. 释放锁(只释放自己的锁)
String script = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
} else {
// 7. 没抢到锁,自旋 100ms 重试,最多 3 次
for (int i = 0; i < 3; i++) {
Thread.sleep(100);
cacheJson = redisTemplate.opsForValue().get(cacheKey);
if (cacheJson != null) {
return JSON.parseObject(cacheJson, User.class);
}
}
// 兜底:直接查 DB(虽然 DoS 了 DB,但总比返回 null 好)
return jdbcTemplate.queryForObject(
"SELECT * FROM user WHERE id = ?",
new BeanPropertyRowMapper<>(User.class), userId);
}
}
/**
* 写:先更新 DB,再删除缓存 + 异步重试兜底
*/
public void updateUser(Long userId, User newData) {
// 1. 先更新数据库
jdbcTemplate.update(
"UPDATE user SET name = ?, email = ? WHERE id = ?",
newData.getName(), newData.getEmail(), userId);
// 2. 删除缓存
String cacheKey = "user:" + userId;
try {
redisTemplate.delete(cacheKey);
} catch (Exception e) {
// 3. 删除失败,发到 MQ 死信队列,延迟 5s 重试,最多 3 次
rabbitTemplate.convertAndSend("cache.dlx", cacheKey, message -> {
message.getMessageProperties().setDelay(5000);
message.getMessageProperties().setHeader("x-retry-count", 0);
return message;
});
}
}
/**
* 延迟双删实现
*/
public void updateUserWithDelayDelete(Long userId, User newData) {
String cacheKey = "user:" + userId;
// 1. 第一次删除
redisTemplate.delete(cacheKey);
// 2. 更新 DB
jdbcTemplate.update(
"UPDATE user SET name = ?, email = ? WHERE id = ?",
newData.getName(), newData.getEmail(), userId);
// 3. 延迟 500ms 再删一次(用 ScheduledExecutorService 或 MQ 延迟队列)
scheduledExecutor.schedule(() -> {
redisTemplate.delete(cacheKey);
}, 500, TimeUnit.MILLISECONDS);
}
}生产级兜底链
更新 DB → 删缓存 → 失败 → MQ 延迟队列 → 5s 重试
↓
重试 3 次仍失败 → 报警 → 人工介入
↓
缓存过期 → 5 分钟后自然恢复(兜底)完整对比表
| 方案 | 一致性 | 不一致窗口 | 性能 | 实现复杂度 | 适用场景 | 耦合度 |
|---|---|---|---|---|---|---|
| Cache Aside(先更新 DB 再删缓存) | 最终一致 | 毫秒级(缓存删除前) | 高 | 低 | 大部分业务场景 | 低,缓存只做加速 |
| Cache Aside(延迟双删) | 最终一致 | 更短,但仍有 | 中 | 中 | 对一致性要求稍高的场景 | 低 |
| Read Through | 最终一致 | 毫秒级 | 高 | 中 | 应用层想屏蔽缓存逻辑 | 中,缓存层有加载器 |
| Write Through | 强一致 | 无 | 低(写延迟 2-3 倍) | 高 | 金融、账户等强一致场景 | 高,缓存不可用业务不可用 |
| Write Behind(异步写) | 弱一致 | 秒级/分钟级 | 最高 | 低 | 日志、埋点、延迟不敏感 | 中,丢数据风险需接受 |
| Binlog 同步(Canal) | 最终一致 | 秒级(binlog 延迟) | 高 | 高 | 业务代码完全解耦 | 低,但需运维 Canal 组件 |
面试高频题
Q1:为什么不直接用缓存更新,而是先删缓存?
上面已经分析过。根本原因:更新缓存是非幂等操作,并发写时 DB 和缓存可能不一致。删除缓存是幂等的,下次读 miss 从 DB 拿最新值,天然保证一致性。
Q2:延迟双删的延迟时间设多少合适?
500ms 是经验值,不是标准答案。需要根据业务场景调整:
- 主从延迟大 → 设 1-2s
- 高并发场景 → 设 100-200ms
- 更稳妥方案:不用固定延迟,在异步线程里轮询 DB 版本号,版本号变化了再删缓存
Q3:删除缓存一直失败怎么办?
不要无限重试。我的方案:
- 重试 3 次
- 都失败 → 发报警(钉钉/飞书机器人)
- 缓存过期兜底(5 分钟自动恢复)
- 人工介入查 Redis 是否异常
Q4:Binlog 同步方案会丢数据吗?
会。Canal 的位点管理在一些极端场景下(如 Canal 重启时 MySQL 主从切换)可能丢失 binlog。缓解措施:
- 位点持久化到 ZK 或本地文件
- 定期人工对比 DB 和缓存的数据量差异
- 设置缓存过期时间兜底(即使同步丢了,过期后自动恢复)
核心原则
永远不要追求缓存与数据库的强一致性,那不现实。 加一个合理的缓存过期时间作为兜底(比如 5 分钟),即使出现不一致,过期后自动恢复。业务代码里只需要做到"尽可能减少不一致窗口",而不是"消除不一致"。
面试时能说清楚两件事就够了:第一,不一致窗口怎么产生的(时序图);第二,你的兜底手段是什么(MQ 重试 + 过期兜底 + 报警)。
参考:DDIA 第 5 章(Replication)和第 12 章(Consistency)对缓存一致性问题有深入剖析;《Redis 设计与实现》第 3 章关于缓存淘汰策略。