商品爆款 30w QPS 雪崩复盘:Redis 缓存三大问题工程修法

商品详情爆款雪崩复盘:5w→30w QPS,Redis 命中率跌到 60%,MySQL 12w QPS 被打挂。系统性修法:布隆过滤器 + 缓存空值防穿透,互斥锁 + 逻辑过期防击穿,随机 TTL + Caffeine 本地多级缓存 + Sentinel 限流防雪崩。命中率 99.5%,DB QPS < 1k。

2023 年我们一个商品详情服务,日常 QPS 5w,缓存命中率 99%,DB 压力小。某天爆款上线,QPS 飙到 30w,缓存命中率跌到 60%,DB 直接被打挂,雪崩拖垮整个商品域。复盘后系统性补了:缓存预热、布隆过滤器、互斥锁回源、二级缓存、随机过期。爆款再上不会挂了,DB QPS 始终 < 1k。本文复盘缓存三大问题(穿透 / 击穿 / 雪崩)的工程修法。

事故现场

服务:商品详情(Spring Boot + Redis + MySQL)
日常:5w QPS,Redis 命中 99%,MySQL 500 QPS
爆款上线:30w QPS

时间线:
20:00 爆款活动开始
20:01 QPS 飙到 30w
20:02 Redis 命中率从 99% 跌到 60%
20:03 MySQL QPS 飙到 12w,慢查询激增
20:05 MySQL 慢查询日志爆掉磁盘
20:06 应用线程池打满,P99 30s
20:08 整个商品域 503,雪崩
20:15 紧急扩容 Redis,降级商品详情
21:00 恢复

复盘:三大问题同时爆发
1. 缓存穿透:有人爬不存在的商品 ID
2. 缓存击穿:爆款 key 突然过期,大量请求穿透到 DB
3. 缓存雪崩:大量 key 同时过期(批量预热的)

问题 1:缓存穿透

现象:查询不存在的数据,缓存 miss 后每次都打 DB
攻击:有人故意拿不存在的 ID 刷,DB 被打爆

修法:
A. 缓存空值(短 TTL)
B. 布隆过滤器(前置过滤)
C. 接口层校验(ID 合法性)
// 方案 A:缓存空值
public Product getById(Long id) {
    String key = "product:" + id;
    String cached = redis.get(key);

    if (cached != null) {
        if ("NULL".equals(cached)) {
            return null;       // 空值标记
        }
        return JSON.parseObject(cached, Product.class);
    }

    Product product = productMapper.selectById(id);
    if (product == null) {
        redis.setex(key, 60, "NULL");   // 空值缓存 60s
        return null;
    }

    redis.setex(key, 3600, JSON.toJSONString(product));
    return product;
}

// 方案 B:布隆过滤器
@Component
public class ProductBloomFilter {
    private final BloomFilter filter;

    public ProductBloomFilter() {
        // 预期 1000w 商品,误判率 0.01%
        filter = BloomFilter.create(Funnels.longFunnel(), 10_000_000, 0.0001);

        // 启动时加载所有有效商品 ID
        productMapper.selectAllIds().forEach(filter::put);
    }

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

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

// 使用
public Product getById(Long id) {
    if (!bloomFilter.mightExist(id)) {
        return null;   // 直接返回,不查 DB
    }
    // ... 正常缓存查询
}

// Redis Bloom 模块(分布式场景)
// 不依赖单机内存
$ redis-cli BF.RESERVE products 0.0001 10000000
$ redis-cli BF.ADD products 12345
$ redis-cli BF.EXISTS products 99999    # 0 (不存在)

问题 2:缓存击穿

现象:某个 hot key 突然过期,大量请求同时打 DB
典型:爆款商品、首页 banner

修法:
A. 互斥锁回源(只一个请求查 DB)
B. 永不过期 + 异步更新
C. 多级缓存
// 方案 A:互斥锁
public Product getByIdWithLock(Long id) {
    String key = "product:" + id;
    String cached = redis.get(key);

    if (cached != null) {
        return JSON.parseObject(cached, Product.class);
    }

    String lockKey = "lock:product:" + id;
    String lockId = UUID.randomUUID().toString();

    try {
        // SET NX EX 加锁(10 秒锁)
        boolean locked = redis.set(lockKey, lockId, "NX", "EX", 10);

        if (!locked) {
            // 没拿到锁,等一会再查缓存
            Thread.sleep(50);
            return getByIdWithLock(id);   // 递归重试
        }

        // 双重检查
        cached = redis.get(key);
        if (cached != null) {
            return JSON.parseObject(cached, Product.class);
        }

        // 查 DB
        Product product = productMapper.selectById(id);
        if (product != null) {
            redis.setex(key, 3600, JSON.toJSONString(product));
        }
        return product;

    } finally {
        // 释放锁(只删自己的)
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                        "then return redis.call('del', KEYS[1]) else return 0 end";
        redis.eval(script, Arrays.asList(lockKey), Arrays.asList(lockId));
    }
}

// 方案 B:逻辑过期(永不真过期)
@Data
public class CacheEntry {
    private T data;
    private long expireAt;       // 业务上的过期时间
}

public Product getByIdLogicalExpire(Long id) {
    String key = "product:" + id;
    String cached = redis.get(key);
    if (cached == null) {
        return null;   // 数据本身不存在(布隆已过)
    }

    CacheEntry entry = JSON.parseObject(cached,
        new TypeReference>(){});

    if (entry.getExpireAt() > System.currentTimeMillis()) {
        return entry.getData();    // 没过期,直接返回
    }

    // 过期了,异步更新(只一个线程更新)
    if (redis.setnx("update_lock:" + id, "1") == 1) {
        redis.expire("update_lock:" + id, 30);
        CompletableFuture.runAsync(() -> {
            try {
                Product fresh = productMapper.selectById(id);
                CacheEntry newEntry = new CacheEntry<>();
                newEntry.setData(fresh);
                newEntry.setExpireAt(System.currentTimeMillis() + 3600000);
                redis.set(key, JSON.toJSONString(newEntry));   // 永不过期
            } finally {
                redis.del("update_lock:" + id);
            }
        });
    }

    return entry.getData();   // 返回旧数据(stale-while-revalidate)
}

问题 3:缓存雪崩

现象:大量 key 同时过期,瞬间 DB 压力暴增
典型:批量预热设了同一 TTL,凌晨同时过期

修法:
A. 随机过期时间(打散)
B. 多级缓存(本地 + Redis)
C. 限流降级
D. Redis 集群高可用
// 方案 A:随机过期
public void setProductCache(Long id, Product product) {
    int baseTtl = 3600;
    int randomOffset = ThreadLocalRandom.current().nextInt(600);   // 0-10min 随机
    redis.setex("product:" + id, baseTtl + randomOffset, JSON.toJSONString(product));
}

// 方案 B:本地缓存(Caffeine)
@Configuration
public class CaffeineConfig {
    @Bean
    public Cache productCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)              // 热门 1w 个本地缓存
            .expireAfterWrite(Duration.ofMinutes(5))
            .refreshAfterWrite(Duration.ofMinutes(1))    // 1min 后异步刷新
            .recordStats()
            .build();
    }
}

@Service
public class ProductService {
    @Autowired private Cache localCache;
    @Autowired private StringRedisTemplate redis;

    public Product getById(Long id) {
        // L1:本地缓存
        Product product = localCache.getIfPresent(id);
        if (product != null) {
            return product;
        }

        // L2:Redis
        String cached = redis.opsForValue().get("product:" + id);
        if (cached != null) {
            product = JSON.parseObject(cached, Product.class);
            localCache.put(id, product);
            return product;
        }

        // L3:DB(加互斥锁)
        product = loadFromDB(id);
        if (product != null) {
            redis.opsForValue().set("product:" + id, JSON.toJSONString(product),
                Duration.ofSeconds(3600 + ThreadLocalRandom.current().nextInt(600)));
            localCache.put(id, product);
        }
        return product;
    }
}

// 方案 C:限流降级(Sentinel)
@SentinelResource(
    value = "getProduct",
    blockHandler = "blockedHandler",
    fallback = "fallbackHandler"
)
public Product getById(Long id) {
    return productService.getById(id);
}

public Product blockedHandler(Long id, BlockException ex) {
    return Product.builder().id(id).name("加载中,请稍后").build();
}

public Product fallbackHandler(Long id, Throwable ex) {
    // 降级:返回默认值或脏读
    return localCache.getIfPresent(id);
}

预热策略

// 启动预热(热点商品)
@Component
public class CacheWarmer {
    @PostConstruct
    public void warmup() {
        // 异步预热,不阻塞启动
        CompletableFuture.runAsync(() -> {
            List hotIds = productMapper.selectHotIds(10000);   // 1w 个热门
            for (Long id : hotIds) {
                try {
                    Product p = productMapper.selectById(id);
                    if (p != null) {
                        int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
                        redis.setex("product:" + id, ttl, JSON.toJSONString(p));
                    }
                    Thread.sleep(2);   // 不打爆 Redis
                } catch (Exception e) {
                    log.error("warmup failed for {}", id, e);
                }
            }
            log.info("cache warmup completed: {}", hotIds.size());
        });
    }
}

// 大促预热(运营触发)
@RestController
public class AdminController {
    @PostMapping("/admin/cache/warmup")
    public String warmup(@RequestBody WarmupRequest req) {
        // 异步执行
        warmupExecutor.submit(() -> {
            for (Long id : req.getProductIds()) {
                Product p = productMapper.selectById(id);
                if (p != null) {
                    int ttl = 7200 + ThreadLocalRandom.current().nextInt(1200);
                    redis.setex("product:" + id, ttl, JSON.toJSONString(p));
                }
            }
        });
        return "OK";
    }
}

监控指标

# Prometheus 指标
- cache_hit_ratio{layer="local"}   # 本地命中率
- cache_hit_ratio{layer="redis"}    # Redis 命中率
- cache_miss_to_db_qps              # 穿透到 DB 的 QPS
- bloom_filter_false_positive       # 布隆误判率
- cache_lock_contention             # 互斥锁竞争次数

# 告警
- alert: CacheHitRatioLow
  expr: cache_hit_ratio{layer="redis"} < 0.95
  for: 5m
  annotations:
    summary: "Redis 命中率 < 95%,可能击穿"

- alert: CacheMissToDbHigh
  expr: cache_miss_to_db_qps > 5000
  for: 2m
  annotations:
    summary: "穿透 QPS 异常,可能缓存雪崩"

- alert: RedisKeyExpireSpike
  expr: rate(redis_expired_keys_total[1m]) > 10000
  for: 1m
  annotations:
    summary: "Redis key 大量过期"

实战效果

压测:模拟爆款 30w QPS,持续 30 分钟

指标                    优化前       优化后        变化
=========================================================
Redis 命中率            60%(雪崩) 99.5%         +66%
本地缓存命中率          0           80%           --
穿透 DB QPS             12w(挂)   < 1k          -99%
P50 延迟                500ms       2ms           -99%
P99 延迟                30s(超时) 15ms          -99%
DB QPS                  12w(挂)   < 1k          -99%
DB CPU                  100%        15%           -85%

业务影响:
- 爆款活动再上无 DB 压力
- 商品详情 SLA 99.99% 达成
- DB 节省两台机器(8w/月)

避坑清单

  1. 缓存空值兜底,布隆过滤器前置,双保险防穿透
  2. 热 key 用互斥锁回源 / 逻辑过期,防击穿
  3. TTL 必须随机化(±10%),防雪崩
  4. 多级缓存:Caffeine 本地 + Redis 分布式
  5. 限流降级是最后兜底,Sentinel / Resilience4j
  6. 预热放在启动 / 大促前,不要等流量来才预热
  7. 布隆过滤器误判率 0.01% 够用,内存可控
  8. 缓存监控全打通,命中率 + 穿透 + 锁竞争
  9. Redis 集群高可用(主从 + 哨兵 / Cluster)
  10. 缓存大 key 拆分(> 10KB 拆 hash 或 zset)

总结

缓存三大问题(穿透/击穿/雪崩)是分布式系统的基本功,但真到出事故才发现自己漏了哪些。这次系统性补完所有手段,爆款活动再上 30w QPS 也稳如老狗。最大的认知改变:缓存不是"加一层就行"的简单优化,是个完整的工程体系 — 布隆 + 多级 + 互斥锁 + 随机 TTL + 预热 + 监控,缺一不可。最容易被忽视的是"随机过期",批量预热设同一 TTL,几个小时后准时雪崩,这是教训。最后,本地缓存的价值被严重低估,Caffeine + Redis 二级缓存能把 Redis 压力降一个数量级,2024 年还在只用 Redis 单层缓存的项目都该升级。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

gRPC 内部 RPC 切换实录:P99 80ms→18ms 带宽 -71%

2026-5-19 12:57:33

技术教程

K8s Pod 每天 20 次 OOMKilled 实录:JVM 堆外内存治理全链路

2026-5-19 13:03:12

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索