Redis 缓存穿透、击穿、雪崩与一致性策略

三个词经常被混用,根因和修法完全不同。空值缓存与布隆过滤器、互斥锁与逻辑过期、TTL 抖动与本地兜底,以及四种一致性策略的取舍。

「加个 Redis 缓存」是性能优化的万能答案,但缓存引入的问题比它解决的问题更隐蔽:数据库被穿透打挂、热点 key 失效瞬间压死 DB、缓存和数据库数据不一致导致用户看到旧数据。

这篇文章把缓存的三类失效问题(穿透、击穿、雪崩)和一致性策略分开讲清楚,每类给出可落地的代码。

一、先分清三个词

这三个词经常被混用,但根因和修法完全不同:

问题 现象 根因 影响面
穿透 查一个不存在的数据,每次都打到 DB 缓存里没有,DB 里也没有 特定恶意请求,量大
击穿 单个热点 key 失效瞬间,大量请求打 DB 热点 key 过期 单个 key,瞬间高峰
雪崩 大量 key 同时失效,或 Redis 挂了 过期时间集中 / 实例故障 全站

关键区别:穿透是「数据本来不存在」,击穿是「数据存在但缓存刚好没了」,雪崩是「大面积同时没了」。

二、缓存穿透:查不存在的数据

典型攻击场景:用 id = -1 或者随机 ID 高频请求,缓存里没有 → 查 DB → DB 也没有 → 不写缓存 → 下次继续查 DB。每一万个请求就是一万次 DB 查询。

方案 A:缓存空值

public User getUser(Long id) {
    String key = "user:" + id;
    String cached = redis.get(key);

    if (cached != null) {
        return EMPTY_MARKER.equals(cached) ? null : JSON.parseObject(cached, User.class);
    }

    User user = userMapper.selectById(id);
    if (user == null) {
        // 缓存空值,过期时间短一些(60 秒)
        redis.setex(key, 60, EMPTY_MARKER);
        return null;
    }
    redis.setex(key, 1800, JSON.toJSONString(user));
    return user;
}

为什么空值的过期时间要短? 因为数据可能后来被创建了。空值缓存 30 分钟,意味着新注册的用户 30 分钟内查不到自己。60 秒是个折中。

缺点:如果攻击者用大量不同的随机 ID,空值缓存本身就成了一种内存攻击——每个不存在的 ID 都占一个 key。所以要配合方案 B。

方案 B:布隆过滤器前置拦截

@Component
public class UserBloomFilter {
    private RBloomFilter<Long> filter;

    @PostConstruct
    public void init() {
        filter = redisson.getBloomFilter("user:bloom");
        filter.tryInit(10_000_000L, 0.01);   // 预期 1000 万,误判率 1%

        // 预热:把已有的所有 ID 灌进去
        userMapper.selectAllIds().forEach(filter::add);
    }

    public boolean mightExist(Long id) {
        return filter.contains(id);
    }

    public void add(Long id) {
        filter.add(id);
    }
}

查询前先过布隆:

public User getUser(Long id) {
    if (!bloomFilter.mightExist(id)) {
        return null;     // 一定不存在,直接返回
    }
    // ... 走缓存 + DB
}

布隆过滤器的特性必须记住:

  • 说「不存在」→ 一定不存在(无假阴性)
  • 说「存在」→ 可能存在(有假阳性)
  • 不支持删除。用户被删了,ID 还在过滤器里,会继续放行到 DB

误判率决定内存占用。1000 万元素、1% 误判,约 12 MB;0.1% 误判,约 18 MB。这个开销通常可以接受。

新增数据时要同步 add,否则新用户会被判定为「不存在」。放在事务提交后(TransactionSynchronization.afterCommit),避免回滚后过滤器里有脏数据。

方案 C:参数校验(最便宜)

if (id == null || id <= 0 || id > MAX_VALID_ID) {
    throw new BizException("参数非法");
}

大部分穿透攻击用的是负数、0、超大数。一个 id > 0 的判断就能挡掉一半。

三、缓存击穿:热点 key 失效

一个首页 Banner 的缓存 key,QPS 2000,TTL 30 分钟。第 30 分钟那一刻,key 失效,2000 个请求同时发现缓存没了,同时去查 DB——DB 瞬间被打穿。

注意:这个 key 是热点,所以哪怕只失效 100ms,也是 200 个并发查询压到 DB。

方案 A:互斥锁(保证只有一个线程回源)

public String getBanner(String key) {
    String cached = redis.get(key);
    if (cached != null) return cached;

    // 只有抢到锁的线程去查 DB
    String lockKey = "lock:" + key;
    boolean locked = redis.set(lockKey, "1", SetParams.setParams().nx().ex(10));

    if (!locked) {
        // 没抢到锁,短暂等待后重试读缓存
        try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return getBanner(key);      // 递归重试,实际要加最大重试次数
    }

    try {
        // 双重检查,防止等锁期间别人已经写入
        cached = redis.get(key);
        if (cached != null) return cached;

        String data = loadFromDb(key);
        redis.setex(key, 1800, data);
        return data;
    } finally {
        redis.del(lockKey);         // 注意:必须校验是自己的锁
    }
}

两个细节:

finally 里直接 del 有风险。 如果查 DB 超时(超过 10 秒锁自动过期),此时另一个线程拿了锁,你的 del 会删掉别人的锁。正确做法是锁的 value 存唯一标识,删除前比对:

String token = UUID.randomUUID().toString();
redis.set(lockKey, token, SetParams.setParams().nx().ex(10));
// ...
// Lua 保证原子性
String lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redis.eval(lua, ScriptOutputType.INTEGER, new String[]{lockKey}, token);

用 Redisson 更省事,它的 RLock 自带看门狗续期和可重入。

递归重试要有上限。 上面的写法如果 DB 一直超时,会无限递归。加一个重试计数器,超过 3 次返回降级数据。

方案 B:逻辑过期(不设 TTL,异步刷新)

// 缓存结构:{"data": {...}, "expireAt": 1730000000000}
public String getBannerLogicExpire(String key) {
    String cached = redis.get(key);
    if (cached == null) {
        return loadAndSet(key);        // 首次加载
    }

    CacheWrapper w = JSON.parseObject(cached, CacheWrapper.class);
    if (System.currentTimeMillis() < w.getExpireAt()) {
        return w.getData();            // 未过期,直接返回
    }

    // 已逻辑过期:先返回旧数据,同时异步刷新
    boolean locked = redis.set("lock:" + key, "1", SetParams.setParams().nx().ex(10));
    if (locked) {
        executor.execute(() -> {
            try {
                String fresh = loadFromDb(key);
                redis.set(key, wrap(fresh), 86400);   // 物理 TTL 设很长
            } finally {
                redis.del("lock:" + key);
            }
        });
    }
    return w.getData();               // 立即返回旧数据,不阻塞
}

优点:响应永远不阻塞,用户看到的可能是几十秒前的数据。 适用: 商品详情、首页 Banner、排行榜——能容忍短暂陈旧。 不适用: 库存、余额、订单状态——不能有脏读。

方案 A vs B 怎么选

互斥锁 逻辑过期
一致性 强(回源后是最新数据) 弱(有一段窗口返回旧值)
响应时间 抢锁失败的请求要等 恒定快速
实现复杂度 低 中(要异步线程池)
适用 交易相关 展示类

四、缓存雪崩:大面积失效

三种成因:

1. 过期时间集中。 凌晨批量预热 1000 个 key,都设 3600 秒,一小时后集体失效。

修法:加随机抖动。

int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);   // 1800 ± 300 秒
redis.setex(key, ttl, value);

2. Redis 实例故障。 单节点挂了,全部请求打 DB。

修法:

  • 主从 + 哨兵(或 Cluster)保证高可用
  • 本地缓存做二级兜底(Caffeine)
  • 熔断降级:Redis 不可用时直接返回降级数据,不要让流量涌向 DB
public String get(String key) {
    try {
        String v = redis.get(key);
        if (v != null) return v;
    } catch (Exception e) {
        log.warn("Redis 不可用,走本地缓存", e);
        String local = localCache.getIfPresent(key);
        if (local != null) return local;
        throw new DegradeException("服务繁忙");   // 快速失败
    }
    // ...
}

3. 冷启动。 服务重启后缓存是空的,所有请求都回源。

修法:启动预热。 用 ApplicationRunner 把热点数据先加载进缓存:

@Component
public class CacheWarmer implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        List<Long> hotIds = statMapper.topProductIds(500);
        hotIds.parallelStream().forEach(id -> {
            String data = loadProduct(id);
            redis.setex("product:" + id, 3600 + random(300), data);
        });
        log.info("缓存预热完成,{} 个 key", hotIds.size());
    }
}

五、一致性:先删缓存还是先更新 DB

这是面试高频题,也是实操最容易出错的地方。

四种策略对比

策略 步骤 问题
先删缓存,再更新 DB del → update 并发下会读到旧值并写回缓存
先更新 DB,再删缓存 update → del 概率极低的不一致(Cache Aside 推荐)
先更新 DB,再更新缓存 update → set 并发写入顺序错乱,缓存永久脏
延迟双删 del → update → sleep → del 缓解但仍不严格

为什么「先更新 DB,再删缓存」是最优解

时序分析:

线程 A:update DB (id=1, v=2)   ← t1
线程 B:read cache               ← t2,缓存未命中
线程 B:read DB → v=2            ← t3(此时 A 已提交)
线程 A:del cache                ← t4
线程 B:set cache = 2            ← t5

最终缓存是 v=2,正确。

极端情况:

线程 A:update DB                ← t1
线程 B:read cache 未命中         ← t2
线程 A:del cache                ← t3
线程 B:read DB → v=1(旧值,如果 B 的读在 A 提交前快照)← t4
线程 B:set cache = 1            ← t5  ← 缓存变脏

这个窗口要求 B 的读在 A 提交之前开始、写入缓存在 A 删除之后,概率极低(需要 B 的读跨越 A 的整个事务)。而且这个脏数据会一直存在直到 TTL 过期,所以 TTL 不能设太长。

关键结论:任何 Cache Aside 策略都不是强一致的,必须靠 TTL 兜底。 别指望用「延迟双删」做到强一致——sleep 多久合适没有任何理论依据,只是碰运气。

强一致场景怎么做

如果是余额、库存这类不能有任何脏读的场景,正确做法是:

方案 A:不用缓存,直接查 DB。 最可靠。用索引和读写分离优化性能。

方案 B:缓存只做「降低 DB 压力」,关键判断走 DB。

// 展示用缓存
public BigDecimal getBalanceDisplay(Long uid) { return cacheOrDb(uid); }

// 扣款必须走 DB + 行锁
@Transactional
public void deduct(Long uid, BigDecimal amount) {
    accountMapper.deductWithCheck(uid, amount);   // UPDATE ... WHERE balance >= ?
}

方案 C:订阅 binlog 异步失效(最终一致 + 可靠)

用 Canal / Debezium 订阅 MySQL binlog,数据变更时发消息,消费者删缓存:

MySQL → binlog → Canal → Kafka → 消费者 → del cache

优点是解耦且不漏——只要 binlog 落了,缓存一定会被删。缺点是延迟(通常 100ms~1s)。

六、本地缓存 + Redis 的两级架构

单靠 Redis,一次网络往返 0.5~2ms。超高 QPS 下这仍是瓶颈。

加一层 Caffeine 本地缓存:

public class TwoLevelCache {
    private final Cache<String, String> local = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(30, TimeUnit.SECONDS)   // 本地 TTL 短,保证及时性
        .recordStats()
        .build();

    public String get(String key) {
        // 1. 本地
        String v = local.getIfPresent(key);
        if (v != null) return v;

        // 2. Redis
        v = redis.get(key);
        if (v != null) {
            local.put(key, v);
            return v;
        }

        // 3. DB
        v = loadFromDb(key);
        if (v != null) {
            redis.setex(key, 1800, v);
            local.put(key, v);
        }
        return v;
    }
}

本地缓存的最大问题是多实例不一致。 3 个 Pod 各有一份本地缓存,更新数据后要通知所有实例失效。

轻量方案用 Redis Pub/Sub 广播失效:

// 更新后广播
redis.publish("cache:invalidate", key);

// 每个实例订阅
redis.subscribe("cache:invalidate", (ch, msg) -> local.invalidate(msg));

Pub/Sub 不保证送达(没有 ack)。要可靠就用 Redis Stream 或者接受本地 TTL 兜底(30 秒内的不一致)。

七、几个容易忽略的细节

1. key 设计要用冒号分层,但别太深

user:1001:profile              ✓
shop:88:order:20261105:list    ✓
user_profile_1001              ✗(无法按前缀批量操作)
a:b:c:d:e:f:g:h                ✗(不好读,也占内存)

2. 大 key 是定时炸弹

一个 key 存了 10 万元素的 List,LRANGE 会阻塞 Redis 主线程几百毫秒。检查方法:

redis-cli --bigkeys
redis-cli --memkeys          # Redis 6+

判断标准:String 超过 10 KB、集合元素超过 5000 个,就该拆分。

3. 热 key 会打爆单个分片

Cluster 模式下,一个 10 万 QPS 的 key 全落在同一个分片。检测:

redis-cli --hotkeys          # 需要 maxmemory-policy 是 lfu*

处理:本地缓存挡掉 90%,或者把 key 拆成 hotkey:1..10 分散到不同分片。

4. TTL 一定要设

// 危险:永不过期
redis.set(key, value);

// 安全:总有过期时间
redis.setex(key, ttlWithJitter(1800), value);

不设 TTL 的缓存迟早会因为业务变更变成脏数据,而且永远无法自愈。Redis 的 maxmemory-policy 也要设成 allkeys-lru,否则内存满了会拒绝写入。

小结

三件事对应三个动作:

问题 核心动作
穿透 空值缓存(短 TTL)+ 布隆过滤器 + 参数校验
击穿 互斥锁(交易类)或逻辑过期(展示类)
雪崩 TTL 随机抖动 + 高可用 + 本地缓存兜底 + 启动预热

一致性上记住一句话:缓存不是数据库,做不到强一致。 用「更新 DB 后删缓存 + 合理 TTL」做最终一致,把强一致的要求留给 DB 本身。真要缓存强一致,成本会远超收益。

查看「数据库与缓存」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。