分类:后端开发 · 作者:站长 · 发布于 2026-09-13 · 标签:Redis / 缓存穿透 / 缓存击穿 / 缓存雪崩 / Spring Boot

一、场景痛点:缓存是怎么把数据库打挂的

电商中台的高并发链路里,Redis 是挡在数据库前面的第一道闸门。商品详情、库存、购物车这些热点数据都走"先查缓存,未命中再查库"的经典模式。但线上跑久了会发现三类事故:

  • 穿透:查询一个根本不存在的 key(比如恶意刷的 -1 商品 ID),缓存永远没有,请求直达数据库,一个攻击脚本就能把 DB 打满。
  • 击穿:某个热点 key 恰好过期的瞬间,大量并发请求同时发现缓存失效,全部涌入数据库重建缓存——秒杀商品最典型。
  • 雪崩:大量 key 在同一时间段集体过期,数据库瞬间被一波流量冲垮,进而拖垮整个服务。

三者的共同点是:缓存失效的瞬间,数据库承受了全部流量。下面逐个给出可落地的解法。

二、穿透:布隆过滤器 + 缓存空值,双层拦截

方案一:布隆过滤器前置拦截。把所有合法 ID 在启动时加载进布隆过滤器,查询前先判断 ID 是否可能存在,不存在直接返回空,根本不碰缓存和数据库:

@Component
public class BloomFilterGuard {

    private static final int EXPECTED_SIZE = 1_000_000;   // 预估商品总量
    private static final double FPP = 0.01;               // 允许的误判率

    private final BloomFilter<Long> filter =
            BloomFilter.create(Funnels.longFunnel(), EXPECTED_SIZE, FPP);

    @Resource
    private ProductMapper productMapper;

    /** 启动时全量加载合法 ID(新增商品在写入时同步 put) */
    @PostConstruct
    public void init() {
        // 分批流式加载,避免一次性 OOM
        productMapper.streamAllIds().forEach(filter::put);
    }

    public boolean mightExist(Long productId) {
        return filter.mightContain(productId);
    }
}

方案二:缓存空值兜底。布隆过滤器有误判率,且删除商品后过滤器不会立即失效,所以对"查无此货"的结果也要写一个短 TTL 的空缓存,把穿透的代价从"打库"降为"写一个空 key":

public String getProduct(Long id) {
    String key = "product:detail:" + id;
    String json = stringRedisTemplate.opsForValue().get(key);
    if (json != null) {
        return json;                    // 命中缓存(含空值占位)
    }
    if (!bloomFilterGuard.mightExist(id)) {
        return null;                    // 布隆过滤器直接拦截
    }
    String value = loadFromDb(id);
    if (value == null) {
        // 空值占位,TTL 给短一些(60s),防止恶意 key 长期占内存
        stringRedisTemplate.opsForValue().set(key, "", Duration.ofSeconds(60));
        return null;
    }
    stringRedisTemplate.opsForValue().set(key, value, Duration.ofMinutes(30));
    return value;
}

三、击穿:互斥锁重建,只让一个线程回源

热点 key 过期瞬间的并发回源,用 SETNX 互斥锁解决:重建缓存的请求先抢锁,抢到锁的线程查库写缓存,其余线程短暂自旋等待后读缓存,避免重复打库:

private static final String LOCK_PREFIX = "lock:product:";

public String getProductWithLock(Long id) {
    String key = "product:detail:" + id;
    String json = stringRedisTemplate.opsForValue().get(key);
    if (json != null) {
        return json;
    }
    // 抢锁:setIfAbsent + 过期时间必须原子,防止持锁线程崩溃导致死锁
    String lockKey = LOCK_PREFIX + id;
    String token = UUID.randomUUID().toString();
    Boolean locked = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, token, Duration.ofSeconds(5));
    if (Boolean.TRUE.equals(locked)) {
        try {
            // 双重检查:可能上一个持锁线程已经重建完缓存
            json = stringRedisTemplate.opsForValue().get(key);
            if (json == null) {
                json = loadFromDb(id);
                if (json == null) {
                    stringRedisTemplate.opsForValue()
                            .set(key, "", Duration.ofSeconds(60));
                    return null;
                }
                stringRedisTemplate.opsForValue()
                        .set(key, json, Duration.ofMinutes(30));
            }
            return json;
        } finally {
            // 用 Lua 保证"先比对 token 再删除",防止误删别人刚抢到的锁
            releaseLock(lockKey, token);
        }
    }
    // 没抢到锁:自旋等待,最多等 300ms
    for (int i = 0; i < 30; i++) {
        try { Thread.sleep(10); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
        json = stringRedisTemplate.opsForValue().get(key);
        if (json != null) {
            return json;
        }
    }
    return loadFromDb(id);   // 极端兜底:直接回源,保证可用性
}

private void releaseLock(String lockKey, String token) {
    // Lua 脚本:GET 比对 + DEL,原子执行
    String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                 "then return redis.call('del', KEYS[1]) else return 0 end";
    stringRedisTemplate.execute(new DefaultRedisScript<>(lua, Long.class),
            List.of(lockKey), token);
}

三个细节最容易踩坑:

  • 锁必须带过期时间:setIfAbsent 和 expire 分开调用在持锁线程宕机时会死锁,必须用 setIfAbsent(key, value, timeout) 原子完成。
  • 释放锁必须比对 token:线程 A 的锁过期后线程 B 抢到锁,此时 A 的 finally 直接 DEL 会把 B 的锁误删,连锁引发多个线程同时回源。
  • 锁粒度要小:锁到 id 而不是全局一把锁,否则一个热点 key 重建会阻塞所有商品的缓存回源。

四、雪崩:过期时间随机化 + 多级缓存兜底

雪崩的第一道防线是打散过期时间,避免大量 key 同一秒失效:

/** 基础 TTL 30 分钟 + 0~5 分钟随机抖动 */
long ttl = Duration.ofMinutes(30)
        .plusSeconds(ThreadLocalRandom.current().nextLong(0, 300))
        .getSeconds();
stringRedisTemplate.opsForValue().set(key, json, Duration.ofSeconds(ttl));

对于批量加载(比如首页推荐位一次性回源 50 个 key),尤其要注意:for 循环里给每个 key 都加随机抖动,而不是统一 TTL,否则这批 key 会整点集体过期。

第二道防线是本地缓存(Caffeine)做二级缓存:Redis 挂了或者大规模失效时,JVM 内还有一份短 TTL 的数据顶着,给数据库留出恢复时间。注意本地缓存要按业务区分——商品详情这类读多写少的数据适合,库存这类强一致数据不要放本地缓存。

第三道防线是限流降级:回源数据库的路径加一个信号量或令牌桶,超过阈值直接返回降级数据(比如"库存紧张"占位文案),宁可牺牲部分体验也不能打垮数据库:

@Component
public class DbFallbackGuard {

    private final Semaphore semaphore = new Semaphore(50);   // 最多 50 个并发回源

    public <T> T withFallback(Supplier<T> supplier, T fallback) {
        if (!semaphore.tryAcquire()) {
            return fallback;
        }
        try {
            return supplier.get();
        } finally {
            semaphore.release();
        }
    }
}

五、踩坑实录:三个真实线上事故

  1. 缓存与数据库不一致,根因是更新顺序反了。我们曾经"先更新数据库,再删缓存",但删除失败的请求会让旧缓存一直存活。后来统一为延迟双删:先删缓存 → 更新 DB → 再删一次缓存(延迟 500ms),并配合 MQ 异步兜底删除。不要用"更新缓存"而要用"删除缓存",因为更新缓存会引入并发写覆盖问题,删缓存让下次查询自然重建。
  2. BigKey 拖垮了 Redis 主从同步。一个商品详情 JSON 序列化后 800KB,热点时期单 key 读写都慢,主从复制积压缓冲区被打满触发全量重同步。解决:详情按字段拆分(基础信息 / 价格 / 库存分 key 存储),超过 100KB 的数据改走 Redis 分片或直接查库。
  3. 序列化不一致导致"缓存永不命中"。用 RedisTemplate<String, Object> 配 JDK 序列化器存进去的对象,和直接用 StringRedisTemplate 读出来的字节对不上,查一次就报错一次。生产环境统一用 StringRedisTemplate + JSON,别混用序列化器。

六、最佳实践清单

  • 穿透:布隆过滤器拦截不存在的 ID + 空值短 TTL 占位,双保险。
  • 击穿:SETNX 互斥锁 + token 比对释放 + 双重检查,锁粒度到 key。
  • 雪崩:TTL 加随机抖动打散 + Caffeine 二级缓存 + 回源限流兜底。
  • 一致性:延迟双删 + MQ 异步兜底删除缓存,禁止直接"更新缓存"。
  • 监控:给缓存命中率、回源 QPS、锁等待超时三个指标都加告警,命中率跌破 90% 就要查。

缓存方案的取舍本质是用一致性换可用性:读多写少的场景大胆缓存,强一致场景(库存扣减、余额)老老实实走数据库事务 + 分布式锁,别为了缓存而缓存。