Fork me on GitHub

分布式缓存架构实战:三大经典问题与解决方案

分布式缓存架构实战:三大经典问题与解决方案

标签:缓存 Redis 高并发 分布式锁 布隆过滤器

一、为什么需要缓存

缓存是互联网系统的"性能加速器"。它把热数据挡在数据库前,让绝大多数读请求不落到 DB 上,从而支撑起比数据库吞吐量高几个数量级的并发。

但缓存从来不是银弹。引入缓存后,系统多了一层存储,也顺势引入了三个绕不开的经典问题:

问题 一句话描述
缓存一致性 缓存和数据库是两个独立存储,写入时可能"一边成功一边失败"
缓存穿透 大量查询根本不存在的 key,缓存永远兜不住,请求直击 DB
缓存雪崩 大量 key 同一时刻失效,瞬时海量请求同时打到 DB

下面逐一拆解每个问题的根源与对策。


二、问题一:缓存与数据库的双写不一致

2.1 现象与根源

无论采用"先写缓存后写数据库",还是"先写数据库后写缓存",都可能因为网络抖动、事务失败、并发交错,出现一方成功、另一方失败的情况,最终导致缓存里的数据和 DB 对不上。

根源在于:缓存与数据库是两个独立存储,天然无法保证原子性,而错误的更新顺序还会放大脏数据。

2.2 推荐方案:Cache-Aside(旁路缓存)模式

  • 读:先读缓存 → 命中直接返回;未命中则读 DB,成功后回填缓存。
  • 写:先写 DB → 成功后删除缓存(而非更新缓存)。
读流程:查缓存 → 未命中 → 查DB → 回填缓存
写流程:写DB → 删除缓存

为什么"删除"优于"更新"?

  • 删除操作幂等且轻量,避免了并发更新时互相覆盖造成的脏写。
  • 下一次读请求会自然地触发 DB 查询并重建缓存,保证拿到的数据最新。

三、问题二:缓存穿透(海量不存在的 Key)

3.1 现象

大量请求查询系统中根本不存在的 key(例如恶意攻击随机生成的 ID 或业务里从未出现过的值)。这些 key 缓存永远无法命中,于是每个请求都穿透缓存直接打到数据库,最终压垮 DB。

3.2 初级方案:缓存空值(恶意工具撑爆内存)

查 DB 后发现数据不存在,也往缓存里写一个 key → null,并设一个较短的过期时间,避免同一 key 反复穿透。

缺点:攻击者可以穷举出无限多的不存在 key,空值缓存本身就会撑爆内存。

3.3 终极方案:布隆过滤器

在访问缓存之前,先用布隆过滤器判断这个 key "是否可能存在":

  • 判断为不存在 → 直接返回,压根不碰缓存和 DB;
  • 判断为可能存在 → 再走正常的缓存 → DB 流程。

优点:内存占用极小(bit 数组),判断复杂度 O(1)。
注意:布隆过滤器有极小概率的误判(把不存在误判为存在),可接受;需定期重建或增量更新。

布隆过滤器的本质是"用极小空间换掉绝大多数无效查询",把穿透挡在系统最外层。


四、问题三:缓存雪崩(集体失效 + 瞬时爆量)

4.1 现象

以下任一情况都可能引发雪崩:

  • 大量 key 设置了相同的过期时间,同一时刻集体失效;
  • 缓存服务重启,内存被清空;
  • 大量不存在的 key 穿透(此条已由布隆过滤器解决)。

此时,原本命中缓存的流量瞬间全部落到 DB,引发连接池耗尽、服务宕机,形成"雪崩"。

4.2 三种化解思路

针对缓存雪崩,常见有三种化解手段:

  1. 串行化更新:利用互斥锁(如 SETNX)控制只有一个线程去查库回填,其余线程等待或直接返回旧数据,把并发 DB 冲击转成串行读库操作。
  2. 打散失效时间:为 TTL 增加随机偏移量(如 基础时间 ± 随机秒数),让过期时间均匀分布,避免同一时刻集体失效。
  3. 多级缓存:增设本地缓存(一级)配合分布式缓存(二级),二级失效时本地仍可短暂兜底,大幅减少穿透到 DB 的请求量。

4.3 归纳为两种本质策略

如果要把上面三招提炼成"两种不同思路",可以这样归纳:

思路 本质 对应手段
方案一(控制并发量) 把"并发查库"变成"串行查库" 加排它锁/队列,使缓存更新串行化执行
方案二(分散与冗余) 把"同时失效"错开,并增加兜底层 随机错开 key 失效时间 + 本地缓存层兜底

两种思路可组合使用,效果更佳。


五、核心实战:串行化更新(Java + Redis)

前文的"串行化更新"是解决雪崩最关键的武器,这里给出三种由"底层"到"最简"的 Spring 写法。

先用大白话把流程说清楚(后面三段代码都只是在用不同工具实现它):

1. 查缓存  → 有数据?直接返回
2. 没数据  → 大家去抢一把锁(lock:商品ID)
3. 抢到的那 1 个  → 再查一次缓存(双重检查)→ 查 DB → 写回缓存 → 释放锁
4. 没抢到的 N 个  → 不查库,等 50ms 重试,醒来时缓存里已经有了

5.1 方式一:Spring Data Redis(StringRedisTemplate,看懂机制)

Spring Boot 项目默认就有的写法,无需额外依赖。

@Service
public class ProductService {

    @Autowired
    private StringRedisTemplate redis;   // Spring Boot 自动装配,连接配置写在 application.yml 的 spring.data.redis 下

    /**
     * 解锁脚本:必须是"先比对 value,再删除"两步,用 Lua 保证原子性。
     *
     * 为什么不能直接 DEL?
     *   场景:线程 A 抢到锁 → A 卡顿,锁 5 秒后自动过期 → 线程 B 抢到锁
     *        → A 恢复过来执行 DEL,把【B 的锁】删掉了 → 锁彻底失效
     *   所以删除前必须比对 value:确认这把锁还是自己那把,才允许删。
     *
     * 为什么必须用 Lua?
     *   "GET 比对"和"DEL 删除"是两条命令,中间仍可能被打断(上面同样的时序问题)。
     *   Redis 执行 Lua 脚本是单线程原子的,整段脚本等同于一条命令。
     *
     * KEYS[1] = 锁的 key,ARGV[1] = 自己的 lockValue
     */
    private static final String UNLOCK_LUA =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) else return 0 end";

    public String getById(String id) {
        String key = "product:" + id;

        // ===== 第 1 步:查缓存 =====
        // opsForValue() = 操作 Redis String 类型的入口
        // (Redis 五种基本类型各有一个入口:opsForHash / opsForList / opsForSet / opsForZSet)
        String cache = redis.opsForValue().get(key);
        if (cache != null) {
            return cache;          // 命中缓存直接返回,不进入加锁逻辑
        }

        // ===== 第 2 步:缓存未命中 → 抢分布式锁 =====
        String lockKey = "lock:" + key;                    // 锁 key 带上业务 key,保证不同商品互不阻塞
        String lockValue = UUID.randomUUID().toString();   // 锁的"主人标识",解锁时用来证明"这把锁是我的"

        // 【本段核心】等价于一条 Redis 命令:SET lock:product:123 <uuid> NX EX 5
        //   · NX(Not eXists)→ 只有 key 不存在时才写入成功,即"抢锁"
        //   · EX 5            → 5 秒后自动过期,兜底防止服务宕机造成死锁
        //   · 返回值 Boolean   → true = 抢到锁;false = 已被别人持有
        //
        // ⚠️ 为什么必须"一条命令"完成?
        //    早期写法是 SETNX + EXPIRE 两条命令,若第一条成功后进程崩溃,
        //    第二条永远执行不了 → 锁没有过期时间 → 死锁,这个 key 再也无人能抢到。
        //    Redis 2.6.12 起 SET 支持 NX/EX 组合参数,setIfAbsent(k, v, timeout) 就是它的封装,
        //    "加锁 + 设过期"在一个原子操作里完成。
        //
        // ⚠️ 为什么用 Boolean 而不是 boolean 接收?
        //    某些客户端实现下返回值可能为 null,用 boolean 会自动拆箱抛 NPE,
        //    所以下面统一用 Boolean.TRUE.equals(locked) 判断。
        Boolean locked = redis.opsForValue()
                .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5));

        if (Boolean.TRUE.equals(locked)) {
            try {
                // ===== 第 3 步:双重检查(Double Check)=====
                // 排队等锁的这几十毫秒里,上一个抢到锁的线程可能已经把数据写进 Redis 了。
                // 不检查就会白白再查一次 DB —— 而那正是加锁要保护的动作
                cache = redis.opsForValue().get(key);
                if (cache == null) {
                    // ===== 第 4 步:查库 + 回写缓存(被锁保护的核心区域)=====
                    cache = queryDB(id);

                    if (cache == null) {
                        // 数据库里确实没有 → 写短 TTL 空值,防止该 key 被反复穿透(见 3.2 节)
                        // ⚠️ 若之后 DB 新增了这条数据,要主动删掉空值缓存,否则 TTL 内一直读到"不存在"
                        redis.opsForValue().set(key, "", Duration.ofMinutes(2));
                        return null;
                    }

                    // 写回缓存。TTL 建议叠加随机偏移量(如 10 分钟 ± 随机 60 秒),
                    // 避免大量 key 同一时刻集体失效引发雪崩(见 4.2 节)
                    redis.opsForValue().set(key, cache, Duration.ofMinutes(10));
                }
                return cache;
            } finally {
                // ===== 第 5 步:释放锁 =====
                // 执行上面的 Lua 脚本:GET 比对 value 一致才 DEL,且整个过程原子
                // execute(RedisScript, keys, args):
                //   · DefaultRedisScript 指定脚本内容和返回值类型(DEL 返回删除条数,用 Long)
                //   · 第二个参数是 KEYS 列表,第三个是可变参数,对应 ARGV
                redis.execute(new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
                        Collections.singletonList(lockKey), lockValue);
            }
        }

        // ===== 没抢到锁 =====
        // 说明已有别的线程正在查库,这里【绝对不能】直接查 DB
        try {
            Thread.sleep(50);      // 短暂休眠,50ms 后缓存大概率已写好
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();   // 恢复中断标记,不要吞掉异常
        }
        return getById(id);        // 递归重试(生产建议改成 for 循环 + 最大重试次数,防止栈溢出)
    }
}

一句话记住分布式锁的本质:利用 Redis 单线程执行命令的特性,让多台机器上的线程去"抢"同一个 key,
setIfAbsent 成功谁就获得锁。NX 保证了只有一个赢家,EX 保证了赢家消失后锁能自动释放。

5.2 方式二:Redisson(生产推荐,自动续期)

方式一要自己处理"锁过期时间"和"释放原子性",很容易写错。Redisson 把这些都封装好了,还提供看门狗自动续期(业务没跑完就自动延长锁时间)。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.2</version>
</dependency>
@Service
public class ProductService {

    @Autowired
    private RedissonClient redisson;   // 引入 redisson-spring-boot-starter 后自动注入,连接配置写在 application.yml

    public String getById(String id) {
        String key = "product:" + id;

        // ===== 第 1 步:查缓存 =====
        // RBucket 是 Redisson 对 Redis String 的封装,等价于 GET 命令
        RBucket<String> bucket = redisson.getBucket(key);
        String cache = bucket.get();
        if (cache != null) {
            return cache;          // 命中缓存直接返回。绝大多数请求都走这里,不会进入加锁逻辑
        }

        // ===== 第 2 步:缓存未命中,抢分布式锁 =====
        // 锁 key 带上业务 key → 不同商品的请求各抢各的锁,互不阻塞;
        // 只有"同一个商品的并发重建"才会被串行化(见 6.3 节的前提 2)
        RLock lock = redisson.getLock("lock:" + key);

        boolean locked = false;
        try {
            // tryLock(等待时间, 锁自动释放时间, 时间单位)
            //   · waitTime  = 3 秒  → 最多排队 3 秒去抢锁,抢不到就放弃,避免线程无限堆积
            //   · leaseTime = 10 秒 → 拿到锁后 10 秒自动释放,兜底防止服务宕机造成死锁
            //
            // ⚠️ 关键坑:一旦显式传了 leaseTime,Redisson 的看门狗(watchdog)就【不会】自动续期!
            //    如果 queryDB 可能超过 10 秒,改用 lock.tryLock(3, TimeUnit.SECONDS)(不传 leaseTime),
            //    看门狗会每 10 秒检查一次,业务没跑完就自动把锁续到 30 秒
            locked = lock.tryLock(3, 10, TimeUnit.SECONDS);

            if (!locked) {
                // 没抢到锁 → 说明已有别的线程正在查库,这里【绝对不能】直接查 DB
                Thread.sleep(50);          // 短暂休眠,50ms 后缓存大概率已写好
                return getById(id);        // 递归重试(生产建议改成 for 循环 + 最大重试次数,防止栈溢出)
            }

            // ===== 第 3 步:双重检查(Double Check)=====
            // 为什么还要再查一次?因为在排队等锁的这几十毫秒里,
            // 上一个抢到锁的线程很可能已经把数据写进 Redis 了。
            // 不检查就会白白再查一次 DB —— 而那正是加锁要保护的动作
            cache = bucket.get();
            if (cache == null) {
                // ===== 第 4 步:查库 + 回写缓存(被锁保护的核心区域)=====
                cache = queryDB(id);

                if (cache == null) {
                    // 数据库里确实没有 → 写短 TTL 空值,防止该 key 被反复穿透(见 3.2 节)
                    // 注意:bucket.set(null) 会抛 NPE,所以判空后写空字符串
                    // ⚠️ 若之后 DB 新增了这条数据,要主动删掉空值缓存,否则 TTL 内一直读到"不存在"
                    bucket.set("", 2, TimeUnit.MINUTES);
                    return null;
                }

                // 写回缓存。TTL 建议叠加随机偏移量(如 10 分钟 ± 随机 60 秒),
                // 避免大量 key 在同一时刻集体失效引发雪崩(见 4.2 节)
                bucket.set(cache, 10, TimeUnit.MINUTES);
            }
            return cache;

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();   // 恢复中断标记,不要吞掉
            return null;                          // 被中断:降级返回,也可直接抛业务异常
        } finally {
            // ===== 第 5 步:释放锁 =====
            // 两个判断缺一不可:
            //   · locked                      → 只有自己抢到过锁才需要释放,否则抛 IllegalMonitorStateException
            //   · lock.isHeldByCurrentThread() → 锁可能已因 leaseTime 到期被自动释放,此时再 unlock 同样报错
            // Redisson 内部用 Lua 保证"校验 value + 删除"的原子性,不会误删别人的锁
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

方式一(手写 StringRedisTemplate)里的每一步,方式二都做了,只是把"加锁/解锁/续期"三件最容易写错的事交给了 Redisson —— 这也是生产环境推荐它的原因。

5.3 方式三:Spring Cache 注解(日常最简,但有局限)

如果只是普通的热key缓存,不需要手写上面几十行,一个注解就够了:

先做一次全局配置:

@Configuration
@EnableCaching        // 开启 Spring 缓存抽象。少了它,下面所有 @Cacheable / @CacheEvict 都不生效
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                // entryTtl:全局默认过期时间
                // ⚠️ 坑:所有 key 共用同一个 TTL,正是"雪崩"的温床。
                //    生产建议按业务分别配置 TTL,或在 TTL 上叠加随机值(见 4.2 节)
                .entryTtl(Duration.ofMinutes(10))

                // 禁止缓存 null
                // 默认情况下 Spring 会把 null 也写进缓存(用一个 NullValue 占位符),
                // 关掉是为了避免"空值长期占着内存"。穿透防护请用 3.2 节的短 TTL 空值方案
                .disableCachingNullValues()

                // 序列化:默认是 JDK 序列化,Redis 里是一堆二进制乱码,没法排查问题。
                // 改成 JSON 后可读,也方便跨语言读同一份缓存
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()))

                // 统一 key 前缀,方便按业务批量清理 / 隔离环境
                .prefixCacheNameWith("app:");

        return RedisCacheManager.builder(factory)
                .cacheDefaults(config)     // 作为所有 @Cacheable 的默认配置
                .build();
    }
}

然后业务方法上一个注解就够:

/**
 * @Cacheable:方法执行【前】先查缓存
 *   · 命中   → 直接返回缓存值,方法体一行都不执行
 *   · 未命中 → 执行方法体(查库),并把返回值写进缓存
 *
 * cacheNames = "product" → 缓存分区名,最终 key = app:product::123
 * key        = "#id"     → SpEL 表达式,取方法参数 id 当 key;也可写 #user.id、#p0 等
 * sync       = true      → 未命中时加锁,保证同一时刻只有一个线程执行方法体
 *
 * ⚠️ sync=true 的具体局限见下方表格
 */
@Cacheable(cacheNames = "product", key = "#id", sync = true)
public String getById(String id) {
    return queryDB(id);       // 只有缓存未命中时才会走到这里
}

配套的写操作必须@CacheEvict 删缓存(呼应 2.2 节的 Cache-Aside):

/**
 * @CacheEvict:方法执行【后】删除缓存,等下一次读请求自己回填
 *
 * ⚠️ 不要用 @CachePut(更新缓存):
 *    1. 并发写时两次更新可能互相覆盖,产生脏数据;
 *    2. 有些字段没参与本次更新,写回去反而是旧值。
 *    "删除"幂等且轻量,是更安全的做法(见 2.2 节)
 */
@CacheEvict(cacheNames = "product", key = "#product.id")
public void update(Product product) {
    updateDB(product);        // 先写 DB,方法成功返回后再删缓存
}

最容易踩的坑:自调用失效。 @Cacheable@CacheEvict 靠 Spring AOP 代理生效,
同一个类里 this.xxx() 的内部调用不会走代理,注解直接失效。
解决方式:把方法拆到另一个 Service,或注入自己(AopContext.currentProxy())。

参数 作用
key = "#id" 缓存 key = product::123
sync = true 缓存未命中时加锁,只允许一个线程查库

⚠️ 两个必须知道的坑:

  1. sync = true 只是单个 JVM 进程内的本地锁。多实例部署时,N 台机器仍可能各查一次库——它防不住真正的分布式雪崩,只防单机并发。
  2. 注解版解决不了缓存穿透(不存在的 key 依然每次打到 DB),也解决不了空值缓存的时序问题。

结论:注解版适合日常 CRUD 提速;热点 key + 高并发重建场景,必须上方式二的分布式锁

5.4 要点说明

要点 说明
锁的作用 确保只有一个线程执行 queryDB(),其余线程排队等待,从而把并发 DB 请求串行化
双重检查 获取锁后再次检查缓存,避免重复查库
锁超时 设置合理过期时间,防止线程挂起导致死锁(Redisson 会自动续期)
锁粒度 锁绑定到单个 key(lock: + key),不同 key 互不干扰
释放锁 必须用 value 比对 + Lua 原子删除,否则可能误删别人的锁
缓存空值 queryDB 返回 null 时也要写短 TTL 空值,否则该 key 会持续穿透(见 3.2 节)

5.5 三种方式怎么选

方式 代码量 适用场景
方式三 @Cacheable 1 行 日常缓存提速,单机并发不高
方式一 StringRedisTemplate 40 行 想理解底层机制,或项目不能引入新依赖
方式二 Redisson 20 行 生产环境首选,有自动续期、可重入、看门狗

六、常见误解:串行化 ≠ 定时任务

很多人第一次看这段代码会误以为是"定时任务查库 → 逐个加锁写缓存 → 释放锁"。这是理解反了

6.1 定时批量刷新(错误理解)

定时任务查库 → 遍历所有 key → 加锁写入 → 释放锁

这样做的问题:

  • 定时任务本身就在全表扫描式查库,恰恰制造了我们想避免的数据库压力;
  • 锁在这里毫无意义——因为只有一个定时任务在跑,根本没有并发竞争。

6.2 串行化更新的真实场景:被动触发

它发生在"高并发读请求"到达的那一瞬间,而不是后台定时跑。假设同一秒有 10 万个请求同时查询 Key=A,而此刻 Redis 里 Key=A 刚好失效:

  1. 10 万个请求同时发现 Redis 里没数据;
  2. 它们同时尝试获取分布式锁(lock:A);
  3. 只有 1 个请求抢到锁,进入数据库查询——DB 只被查了 1 次
  4. 其余 99,999 个请求没抢到锁,不查库,而是等待(自旋重试或阻塞);
  5. 抢到锁的请求查完 DB、写入 Redis、释放锁;
  6. 其他等待的请求醒来,再去 Redis 一查,数据已经有了,直接返回。

6.3 一个生活化类比

场景 类比
定时任务 每天定时去菜市场把所有菜买回来放冰箱,不管吃不吃
串行化更新 1000 个人同时要了同一道菜(Redis 没菜),大家抢一把厨房门钥匙,只有 1 个人去菜市场买菜(查 DB),买回来放冰箱后开门,其余 999 人直接去冰箱拿菜

6.4 小结

  • 触发时机:请求实时到达时触发,不是后台定时跑;
  • 锁的作用:把"查库 + 写缓存"这个危险动作,从并发 10 万次压缩成串行 1 次,保护数据库;
  • 不用定时任务的原因:无法预知用户会查哪个 key,也不可能把所有 key 定时全量刷新(数据量巨大且浪费)。

七、总结

问题 核心对策
双写不一致 先写 DB,后删除缓存(Cache-Aside)
缓存穿透 布隆过滤器前置拦截
缓存雪崩 TTL 随机化 + 分布式锁串行化更新 + 多级缓存

架构师心法:缓存设计要防患于未然——把"高并发冲击"转化为"有秩序的单点操作",再辅以兜底策略。没有银弹,只有组合拳。

posted @ 2026-09-02 21:26  秋夜雨巷  阅读(12)  评论(0)    收藏  举报