主题
第二十六章:缓存双写一致性与击穿规避
本章以「面试问答」的形式,把「Redis + MySQL 双写一致性」这条主线上的高频问题串起来回答。可以把它看成第二十二章《缓存一致性策略》的「面试冲刺版」:第二十二章偏原理拆解,本章偏问题驱动、边答边给结论。
Q80:只要用到缓存就会涉及缓存与数据库双写,只要双写就有数据一致性问题。这个问题到底怎么解决?
答:
先纠正一个前提:「缓存 + 数据库」本质上做不到强一致,我们能追求的只有「最终一致」。原因是缓存(Redis)和数据库(MySQL)是两个彼此独立的存储,中间没有「分布式事务」把它们绑成一个原子操作,任何「先写 A 再写 B」的顺序在系统崩溃或并发下都可能出现偏差。
所以「解决一致性」的正确姿势不是「消除不一致」,而是把不一致的概率和持续时间降到业务可接受的范围,并保证最终能自愈。落地方案分四层,从简到重:
第一层:选对更新顺序(Cache-Aside + 先更 DB 再删缓存)
写入时「更新数据库 → 删除缓存」,而不是「更新缓存」。删缓存(而不是更新缓存)可以避免「写缓存写脏」,也天然利用「下次读再回填」来重建最新值。
text
写:更新 DB → 删除缓存
读:查缓存 → 命中返回;未命中 → 查 DB → 回填缓存第二层:延迟双删(覆盖并发回填旧值的窗口)
「先更 DB 再删缓存」仍有一个小窗口:某读请求在「查 DB」之后、「回填缓存」之前,若写请求更新了 DB 并删除缓存,读请求会把旧值回填。解法是删两次——更新 DB 前删一次、更新后延迟再删一次:
text
1. 删除缓存
2. 更新 DB
3. 延迟 N 毫秒后再删除一次缓存(覆盖并发读回填旧值的窗口)第三层:缓存 TTL 兜底(保证最终自愈)
无论上面做得再精细,极端并发仍可能漏网。所以必须给缓存设过期时间,即使出现脏数据,过期后自动失效重建,回到一致。TTL 是「最终一致性」的底线,也是成本最低、最不能省的一步。
第四层:重方案(强一致诉求才上)
- Canal / Binlog 订阅:监听 MySQL 变更日志,异步删缓存,把「删缓存」与「DB 变更」可靠解耦;
- 同一 key 串行化:对同一 key 的「更新 DB」和「回填缓存」加锁串行,消除竞态;
- 版本号 / 时间戳:回填缓存时只允许更新版本写入,旧版本回填被丢弃。
结论:默认用「先更 DB 再删缓存 + 延迟双删 + TTL」;只有资金、库存这类强一致场景,才再加 Canal 或串行化。
Q81:双写一致性,是先写缓存还是先写数据库?为什么?
答:
先写数据库,再删缓存(不是「写缓存」,而是「删缓存」)。
核心原因有三点:
缓存应该是「数据库的从属副本」,不是「数据源」。数据库是唯一事实来源(Source of Truth),缓存只是它的加速副本。写的时候先落数据库,再让缓存失效/重建,才能保证「以 DB 为准」。
避免「踩丢更新」。如果「先写缓存」,一旦写缓存成功、写 DB 失败,就会造成「缓存有数据、DB 没有」的假数据,且反过来更难纠错。而「先写 DB、后删缓存」,即使删缓存失败,也只是一个「多存活一段时间的旧数据」,靠 TTL 能自愈,方向是「只会旧、不会错」。
避免并发把旧值写回。如果「先删缓存再更新 DB」,在「删缓存」到「更新 DB」之间如果有读请求进来,会把 DB 的旧值读出来回填,导致缓存又变脏且没人再删。而「先更 DB 再删缓存」虽然也有窗口,但配合「延迟双删」能大幅收敛。
一句话总结:先更 DB(保证数据源头是对的),再删缓存(让副本失效重建),缓存永远只做「加速」和「兜底」,不做「真源」。
Q82:延时双删做过吗?会有哪些问题?双检加锁策略了解过吗?
答:
一、延时双删
做过,思路是把「删缓存」拆成两次:更新 DB 前删一次,更新 DB 后再延迟很短时间(比如 500ms)删第二次。第二次延迟删专门用来覆盖「读请求在更新期间回填旧值」的窗口。
python
def update_with_double_delete(key: str, new_value):
redis_client.delete(f"cache:{key}") # 第一次删
update_db(key, new_value) # 更新 DB
# 延迟第二次删,覆盖并发读回填旧值的窗口
threading.Timer(0.5, lambda: redis_client.delete(f"cache:{key}")).start()它会带来几个问题:
- 延迟时间很难精确:延迟要多大于「读请求查 DB + 回填缓存」的耗时?太大则不一致窗口变长,太小则覆盖不到。这是个经验值,和业务 QPS、DB 耗时强相关。
- 只降概率、不保证 100%:极端并发(延迟删还没执行,又有新读回填旧值)仍可能漏网,所以必须配 TTL 兜底,不能把延迟双删当银弹。
- 引入异步 / 分布式复杂度:延迟删要放异步线程或消息队列,否则阻塞主流程;延迟任务本身还有「丢任务、重复执行」的问题,需要幂等(删缓存天然幂等,这点还好)。
- 第一次删可能放大击穿风险:更新前先把缓存删了,热点 key 在这段时间会面临大量回源,需要配合互斥锁/逻辑过期(见 Q84)。
二、双检加锁(Double-check Locking)
了解过,它是「查缓存未命中后回源」场景下控并发的手段,核心是「第一检 → 加锁 → 第二检」:
text
第一检:先查缓存,命中就返回
└─► 未命中 → 加锁
├─► 抢不到锁 → 等待/重试
└─► 抢到锁 → 第二检:再查一次缓存
├─► 已有(别人刚回填)→ 返回,不再查 DB
└─► 仍无 → 查 DB → 回填缓存 → 释放锁python
def get_hot(key: str):
cache_key = f"cache:{key}"
lock_key = f"lock:{key}"
cached = redis_client.get(cache_key) # 第一检
if cached is not None:
return cached
if redis_client.set(lock_key, "1", nx=True, ex=10):
try:
cached = redis_client.get(cache_key) # 第二检
if cached is not None:
return cached
data = db.query(key)
redis_client.set(cache_key, data, ex=600)
return data
finally:
redis_client.delete(lock_key)
time.sleep(0.05)
return get_hot(key)Redisson 完整实现(Java / Spring Boot 风格):
java
package com.example.cache.service;
import com.example.cache.entity.User;
import com.example.cache.mapper.UserMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class UserService {
private final UserMapper userMapper;
private final RedissonClient redissonClient;
private final ObjectMapper objectMapper;
private static final long LOCK_WAIT_SECONDS = 5L; // 抢锁最多等待时长
private static final long LOCK_LEASE_SECONDS = 10L; // 锁自动释放(防死锁)
private static final long CACHE_TTL_SECONDS = 600L; // 缓存有效期
public UserService(UserMapper userMapper,
RedissonClient redissonClient,
ObjectMapper objectMapper) {
this.userMapper = userMapper;
this.redissonClient = redissonClient;
this.objectMapper = objectMapper;
}
/**
* 双检加锁:高并发下避免缓存击穿导致大量回源 DB。
* 流程:第一检(查缓存)→ 加锁 → 第二检(再查缓存)→ 未命中才查 DB 回填。
*/
public User get(Long id) {
RBucket<String> bucket = redissonClient.getBucket("user:" + id);
// 第一检:先查缓存,命中直接返回
String cached = bucket.get();
if (cached != null) {
return deserialize(cached);
}
// 加锁:分布式锁,跨实例互斥
RLock lock = redissonClient.getLock("lock:user:" + id);
boolean locked = false;
try {
locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS);
if (!locked) {
// 抢不到锁,说明有别的线程正在回填,稍后重试
if (Thread.interrupted()) { Thread.currentThread().interrupt(); }
return null;
}
// 第二检:抢到锁后再查一次,可能已被别的线程回填
cached = bucket.get();
if (cached != null) {
return deserialize(cached);
}
// 仍未命中,才真正回源 DB
User user = userMapper.selectById(id);
if (user == null) {
// DB 也无,回填一个短 TTL 空值标记,防缓存穿透
bucket.set("__NULL__", 60, TimeUnit.SECONDS);
return null;
}
bucket.set(serialize(user), CACHE_TTL_SECONDS, TimeUnit.SECONDS);
return user;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
// 只有抢到锁的线程才释放,避免释放别人的锁
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String serialize(User user) {
try {
return objectMapper.writeValueAsString(user);
} catch (JsonProcessingException e) {
throw new RuntimeException("序列化失败", e);
}
}
private User deserialize(String json) {
if ("__NULL__".equals(json)) {
return null;
}
try {
return objectMapper.readValue(json, User.class);
} catch (JsonProcessingException e) {
throw new RuntimeException("反序列化失败", e);
}
}
}几个关键点:
tryLock(waitTime, leaseTime, unit):三个参数分别表示「抢锁等待多久」「锁自动释放多久」「时间单位」。leaseTime是关键——即使业务崩溃没走到unlock,锁也会到期自动释放,避免死锁;isHeldByCurrentThread()再unlock():只有「当前线程持有」才释放,防止锁已超时被释放、又被别人抢走后,旧线程误删他人锁;- 第二检放在锁内、查 DB 之前:这是双检加锁区别于普通互斥锁的核心——锁内再次确认缓存仍为空,才真的回源;
- 回填空值标记
__NULL__:DB 也查不到时,写一个短 TTL 的空值,顺带防缓存穿透(对应第十二章 Q38)。
和简单互斥锁的区别:普通「查缓存 → 抢锁 → 查 DB」里,多个没抢到锁的请求会各自等待后再次查 DB;双检加锁强调「抢到锁后、查 DB 前,再查一次缓存」,让「被别人刚回填」这种情况直接命中,进一步减少 DB 访问。它本质是互斥锁的一次优化,而非另一套独立方案。
锁的选型:单体应用用本地锁(synchronized / Lock);分布式多实例必须用分布式锁(Redisson、SET NX EX 或 Lua 实现),否则跨进程互斥失效。
Q83:微服务里查询 Redis 无、MySQL 有,为保证一致性回写,需要注意什么?
答:
这个场景本质是「缓存未命中而回源,要把 DB 数据正确回填进缓存」。要保证一致性回写,需要关注以下几点:
回填前做「第二检」,避免重复回源:多个实例可能同时「缓存未命中 → 查 DB」,都要回填。应配合双检加锁(见 Q82),只让一个请求查 DB、回填,其余请求等待后直接读缓存,避免对 DB 的重复压力和「旧值覆盖新值」的竞态。
回填的值必须是「读 DB 那一刻的最新值」,并注意竞态:读请求在「查 DB」到「回填缓存」之间,可能另一个写请求已经更新了 DB 并删了缓存。此时读请求若直接回填,会把旧值写回,造成不一致。简单兜底是回填时设置 TTL,让脏数据尽快过期自愈;强一致场景用「版本号 / 时间戳」或「延迟双删」配合。
回填要设过期时间(TTL):这是回写一致性的底线。没有 TTL 的缓存一旦写脏,几乎永远不会自愈。
区分「空值」与「真没有」:如果 DB 都没有,要按穿透处理——回填一个短 TTL 的「空值标记」(缺省值),避免每次都打到 DB(见第十二章 Q38)。
回填失败要兜底:写缓存失败(Redis 抖动)不能阻止主流程,但要保证「下次还能重试回源」或「走异步补偿」,否则会形成「缓存长期缺失 + DB 压力持续」的恶性循环。
微服务多实例下的锁要跨进程:本地锁在多实例下无效,必须用 Redis 分布式锁(
SET NX EX+ 唯一 token / Lua 校验释放)来做回填互斥。
一句话:回写是「读侧的一致性关键点」,要点是「串行化回源 + 回填设 TTL + 防止旧值覆盖」。
Q84:如何避免缓存击穿?
答:
缓存击穿是「单个热点 key 在过期瞬间,大量并发同时回源 DB」。避开的思路有两条线:要么让热点 key 不真正过期,要么让回源串行化。方案如下:
方案一:互斥锁 / 分布式锁(串行化回源)
回源前抢锁,抢到的去查 DB 回填,抢不到的等待重试,把「并发回源」收敛成「一次回源」。配合双检加锁效果更好。
方案二:逻辑过期(缓存「永不过期」,异步刷新)
key 本身不设 TTL(或设很长兜底时间),把真实过期时间放进 value。读到「逻辑已过期」时,先返回旧值,再异步重建。因为始终先返回旧值,几乎不会出现「一拥而上去查 DB」。
text
value = { "data": {...}, "expire_at": 1787800000 }
读:未过期 → 返回 data
已过期 → 先返回旧 data,同时异步触发回源刷新方案三:热点预热
对已知热点(大促商品、首页数据)提前加载或主动续期,过期前就刷新,从源头避免「过期瞬间」。
方案四:二级缓存(主 A 副 B)
主缓存 A 与副缓存 B 错峰过期(B 的 TTL 大于 A),A 失效时 B 还能顶上:
text
查询:先读 A,未命中再读 B
更新:先删 B → 更新 B → 再删 A → 再更新 A
过期:B 的 TTL > A 的 TTL,保证 A 失效时 B 仍可用方案五:物理「永不过期」
对访问极频繁、且能接受短时旧数据的热点 key,干脆不设过期时间,靠异步任务刷新或靠「更新时删除缓存」来保证数据更新。
选型结论:强一致优先用「互斥锁 + 双检加锁」,高可用、能容忍旧值优先用「逻辑过期」,已知热点用「预热」,低成本兜底用「二级缓存」。
Q85:Redis 和 MySQL 双写一定出问题,做不到强一致,如何保证最终一致性?
答:
先明确「最终一致」的定义:允许短暂不一致,但经过一段时间、在没有新的写入后,系统状态最终会收敛到一致。保证手段本质是「让缓存这个副本,具备自动纠错/自愈能力」,而不是追求「写入那一刻就一致」。具体分五招,从必做到增强:
第一招:缓存必须带 TTL(兜底自愈,必做)
给每个缓存 key 设过期时间。即使出现脏数据,TTL 一到缓存失效,下次读会回源 DB 重建正确值。这是「最终一致」最基础、最可靠的机制,任何方案都要叠加。
第二招:正确的失败方向(只做「向后一致」)
更新流程保证「数据源(DB)永远先正确,缓存只会旧、不会错」:
- 先更 DB、再删缓存:哪怕删缓存失败,也只是一个「多存活一段时间的旧值」,TTL 兜底后自愈;
- 绝不要「先写缓存、后写 DB」:否则可能缓存有、DB 无,出现「假数据」且方向反了难以收敛。
第三招:延迟双删(收敛并发窗口)
更新 DB 前后各删一次缓存,第二次延迟删覆盖「并发读回填旧值」的窗口,把不一致概率降到很低,配合 TTL 达到可用的一致性。
第四招:异步可靠删除(Canal + Binlog)
监听 MySQL 的 Binlog,数据变更后由订阅方(Canal 等)异步删除缓存:
text
DB 变更 ──► Binlog ──► Canal 监听 ──► 异步删除 Redis 缓存好处是「删缓存」与「DB 变更」可靠解耦,即使业务代码忘了删,也能靠 Binlog 兜回来;失败可重试,最终一致性强。
第五招:版本号 / 时间戳(防旧值覆盖)
给数据带版本号(或时间戳),回填缓存时只允许「更新版本」写入,旧版本的回填被丢弃。这样即使并发读到旧值,也无法覆盖更新值,从机制上保证「最终一定收敛到最新」。
一张图看「最终一致」的收敛路径
text
写入 → 先更 DB → 删缓存(失败也没关系,只是旧值多活一会儿)
│
├─► 延迟双删 → 覆盖并发回填旧值窗口
├─► TTL 到期 → 缓存失效 → 下次读回源重建(自愈兜底)
└─► Canal/Binlog → 异步再删 → 失败重试(可靠收敛)结论:不追求「写的那一刻一致」,而是用「TTL 兜底 + 删缓存方向正确 + 延迟双删 + Binlog 可靠删除 + 版本号防覆盖」这一套组合,把缓存变成「能自动对账、自动纠错」的副本,从而保证最终一致。
小结
| 问题 | 一句话答案 |
|---|---|
| 一致性怎么解决 | 做不到强一致,用「先更 DB 再删缓存 + 延迟双删 + TTL 兜底 + 重方案」分层收敛 |
| 先写缓存还是先写 DB | 先更 DB、再删缓存,DB 是真源,缓存只做副本 |
| 延时双删 / 双检加锁 | 延时双删覆盖并发旧值窗口但有延迟难定、不 100%、需异步;双检加锁是「第一检→加锁→第二检」控并发回源 |
| 回写需注意什么 | 串行化回源、回填设 TTL、防旧值覆盖、空值用短 TTL 标记、回填失败可重试 |
| 如何避免击穿 | 互斥锁 / 逻辑过期 / 热点预热 / 二级缓存 / 物理永不过期 |
| 如何保证最终一致 | TTL 自愈 + 删缓存方向正确 + 延迟双删 + Binlog 可靠删除 + 版本号防覆盖 |