Skip to content

第二十六章:缓存双写一致性与击穿规避 ​

本章以「面试问答」的形式,把「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:双写一致性,是先写缓存还是先写数据库?为什么? ​

答:

先写数据库,再删缓存(不是「写缓存」,而是「删缓存」)。

核心原因有三点:

  1. 缓存应该是「数据库的从属副本」,不是「数据源」。数据库是唯一事实来源(Source of Truth),缓存只是它的加速副本。写的时候先落数据库,再让缓存失效/重建,才能保证「以 DB 为准」。

  2. 避免「踩丢更新」。如果「先写缓存」,一旦写缓存成功、写 DB 失败,就会造成「缓存有数据、DB 没有」的假数据,且反过来更难纠错。而「先写 DB、后删缓存」,即使删缓存失败,也只是一个「多存活一段时间的旧数据」,靠 TTL 能自愈,方向是「只会旧、不会错」。

  3. 避免并发把旧值写回。如果「先删缓存再更新 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()

它会带来几个问题:

  1. 延迟时间很难精确:延迟要多大于「读请求查 DB + 回填缓存」的耗时?太大则不一致窗口变长,太小则覆盖不到。这是个经验值,和业务 QPS、DB 耗时强相关。
  2. 只降概率、不保证 100%:极端并发(延迟删还没执行,又有新读回填旧值)仍可能漏网,所以必须配 TTL 兜底,不能把延迟双删当银弹。
  3. 引入异步 / 分布式复杂度:延迟删要放异步线程或消息队列,否则阻塞主流程;延迟任务本身还有「丢任务、重复执行」的问题,需要幂等(删缓存天然幂等,这点还好)。
  4. 第一次删可能放大击穿风险:更新前先把缓存删了,热点 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 数据正确回填进缓存」。要保证一致性回写,需要关注以下几点:

  1. 回填前做「第二检」,避免重复回源:多个实例可能同时「缓存未命中 → 查 DB」,都要回填。应配合双检加锁(见 Q82),只让一个请求查 DB、回填,其余请求等待后直接读缓存,避免对 DB 的重复压力和「旧值覆盖新值」的竞态。

  2. 回填的值必须是「读 DB 那一刻的最新值」,并注意竞态:读请求在「查 DB」到「回填缓存」之间,可能另一个写请求已经更新了 DB 并删了缓存。此时读请求若直接回填,会把旧值写回,造成不一致。简单兜底是回填时设置 TTL,让脏数据尽快过期自愈;强一致场景用「版本号 / 时间戳」或「延迟双删」配合。

  3. 回填要设过期时间(TTL):这是回写一致性的底线。没有 TTL 的缓存一旦写脏,几乎永远不会自愈。

  4. 区分「空值」与「真没有」:如果 DB 都没有,要按穿透处理——回填一个短 TTL 的「空值标记」(缺省值),避免每次都打到 DB(见第十二章 Q38)。

  5. 回填失败要兜底:写缓存失败(Redis 抖动)不能阻止主流程,但要保证「下次还能重试回源」或「走异步补偿」,否则会形成「缓存长期缺失 + DB 压力持续」的恶性循环。

  6. 微服务多实例下的锁要跨进程:本地锁在多实例下无效,必须用 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 可靠删除 + 版本号防覆盖