在这里插入图片描述

👋 大家好,欢迎来到我的技术博客!
💻 作为一名热爱 Java 与软件开发的程序员,我始终相信:清晰的逻辑 + 持续的积累 = 稳健的成长
📚 在这里,我会分享学习笔记、实战经验与技术思考,力求用简单的方式讲清楚复杂的问题。
🎯 本文将围绕Redis这个话题展开,希望能为你带来一些启发或实用的参考。
🌱 无论你是刚入门的新手,还是正在进阶的开发者,希望你都能有所收获!


Redis100篇 - Redis缓存雪崩怎么复现?排查+解决全流程 🌨️💥

在高并发系统中,Redis 作为缓存层,是保护后端数据库的“第一道防线”。然而,一旦这道防线崩溃——比如发生缓存雪崩(Cache Avalanche),大量请求将瞬间穿透到数据库,轻则接口超时、服务降级,重则数据库连接池耗尽、CPU 打满,最终导致整个系统瘫痪

更可怕的是,缓存雪崩往往来得突然、破坏力强、恢复困难。很多团队直到线上事故爆发,才意识到问题的严重性。

但“纸上得来终觉浅”,只有亲手复现 + 深度排查 + 实战修复,才能真正掌握应对之道。

本文将带你:
从零搭建一个可复现缓存雪崩的 Java 项目
模拟高并发下大量 Key 同时过期的场景
使用监控工具定位雪崩根因
提供 4 种工业级解决方案 + 完整代码示例
附赠 Mermaid 可视化流程图 + 可访问外链

无论你是准备面试,还是保障生产系统稳定性,这篇文章都将为你提供可落地、可验证、可复用的实战经验!🚀


🧪 一、什么是缓存雪崩?为什么它如此危险?❄️

📌 定义

缓存雪崩:指在某一时刻,大量缓存 Key 同时失效,导致所有请求直接打到数据库,造成数据库瞬时压力剧增,甚至宕机的现象。

⚠️ 与缓存穿透、击穿的区别

问题 触发条件 请求特征 危害程度
缓存穿透 查询不存在的数据(如 ID=-1) 恶意/无效请求 中(可防御)
缓存击穿 单个热点 Key 过期瞬间高并发 集中打一个 Key
缓存雪崩 大量 Key 同时过期 全局性流量洪峰 极高(系统级风险)

💡 简单记忆:

  • 穿透:查不到(DB 无数据)
  • 击穿:热点崩(单点失效)
  • 雪崩:集体崩(批量失效)

🌰 真实案例

某电商大促期间,商品详情页缓存统一设置 TTL=3600 秒(1小时)。凌晨 00:00:00,数万个商品缓存同时过期。此时恰逢用户活跃高峰,10w QPS 的请求全部穿透到 MySQL,数据库 CPU 瞬间飙至 100%,服务完全不可用,持续 20 分钟。


🔁 二、动手复现:构建缓存雪崩实验环境 🏗️

我们将使用 Spring Boot + Jedis + JMeter 构建一个可复现雪崩的微服务。

🛠 技术栈

  • JDK 17
  • Spring Boot 3.x
  • Redis 7.x(单机即可)
  • JMeter 5.6(压测工具)
  • Prometheus + Grafana(可选,用于监控)

📂 项目结构

redis-snowstorm-demo/
├── src/main/java/com/example/snowstorm/
│   ├── SnowstormApplication.java
│   ├── controller/ProductController.java
│   ├── service/ProductService.java
│   └── config/RedisConfig.java
├── pom.xml
└── jmeter/snowstorm-test.jmx

1️⃣ Step 1:初始化大量缓存 Key(统一过期时间)

// ProductService.java
@Service
public class ProductService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    // 模拟数据库(实际应为 MyBatis/DataSource)
    private final Map<Long, String> db = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        // 预热 10,000 个商品数据到 DB
        for (long i = 1; i <= 10_000; i++) {
            db.put(i, "Product-" + i + "-Details");
        }

        // 写入 Redis,TTL 统一设为 10 秒(制造雪崩条件)
        for (long i = 1; i <= 10_000; i++) {
            redisTemplate.opsForValue().set(
                "product:" + i,
                db.get(i),
                Duration.ofSeconds(10) // ⚠️ 所有 Key 10秒后同时过期!
            );
        }
        System.out.println("✅ 缓存预热完成,10,000 个 Key 将在 10 秒后集体失效!");
    }

    public String getProductById(Long id) {
        String cacheKey = "product:" + id;
        String cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return cached;
        }

        // 模拟数据库查询(加日志便于观察)
        System.out.println("🔍 DB 查询: " + id);
        String dbData = db.get(id);
        if (dbData != null) {
            // 重新写入缓存(但未加锁!)
            redisTemplate.opsForValue().set(cacheKey, dbData, Duration.ofSeconds(10));
        }
        return dbData;
    }
}

🔥 关键点:Duration.ofSeconds(10) —— 所有 Key 在同一时间点过期!

2️⃣ Step 2:暴露 HTTP 接口供压测

// ProductController.java
@RestController
public class ProductController {

    @Autowired
    private ProductService productService;

    @GetMapping("/product/{id}")
    public ResponseEntity<String> getProduct(@PathVariable Long id) {
        if (id <= 0 || id > 10_000) {
            return ResponseEntity.badRequest().body("Invalid ID");
        }
        String product = productService.getProductById(id);
        return product != null ? 
            ResponseEntity.ok(product) : 
            ResponseEntity.notFound().build();
    }
}

3️⃣ Step 3:启动应用 & Redis

# 启动 Redis(默认配置即可)
redis-server

# 启动 Spring Boot 应用
mvn spring-boot:run

你会看到日志:

✅ 缓存预热完成,10,000 个 Key 将在 10 秒后集体失效!

4️⃣ Step 4:使用 JMeter 模拟高并发请求

创建 jmeter/snowstorm-test.jmx

  • 线程数:500(模拟 500 并发用户)
  • Ramp-up 时间:1 秒
  • 循环次数:10
  • HTTP 请求GET http://localhost:8080/product/${__Random(1,10000)}

💡 ${__Random(1,10000)}:随机请求 1~10000 的商品 ID

点击 Start,等待 10 秒后(缓存过期瞬间),观察控制台输出。


🔍 三、现象观察:确认缓存雪崩已发生 👀

📊 控制台日志(关键证据)

✅ 缓存预热完成...
(等待 10 秒)
🔍 DB 查询: 5821
🔍 DB 查询: 9342
🔍 DB 查询: 1203
🔍 DB 查询: 7765
...(连续输出数千行 DB 查询日志)...

❗ 正常情况下,每个 ID 只应查一次 DB。但现在每个请求都查 DB,说明缓存完全失效!

📈 监控指标(如有 Prometheus)

  • Redis 命中率:从 99% 骤降至 0%
  • MySQL QPS:从 10 飙升至 5000+
  • 应用 P99 延迟:从 5ms 升至 800ms+

📊 雪崩发生过程(Mermaid)

用户 应用服务 Redis 数据库 所有 Key 在 T+10s 过期 GET /product/123 GET product:123 (nil) SELECT * FROM products WHERE id=123 返回数据 SET product:123 ... EX 10 返回结果 loop [高并发请求] 数千请求同时查 DB → 雪崩! 用户 应用服务 Redis 数据库

至此,我们成功复现了缓存雪崩!


🕵️‍♂️ 四、根因分析:为什么雪崩会发生?🧠

核心原因

  1. TTL 设置不合理:所有缓存 Key 使用相同的固定过期时间
  2. 无重建保护机制:缓存失效后,多个线程同时查 DB 并重建缓存(无互斥)。
  3. 数据库无熔断:DB 被瞬间打爆,无法自愈。

技术本质

Redis 的过期策略是惰性删除 + 定期删除。当大量 Key 同时过期,Redis 会在短时间内集中清理,但更重要的是——所有客户端请求在同一时刻 Miss 缓存,形成流量洪峰。


🛠 五、解决方案:4 种工业级避坑策略 ✅

✅ 方案一:TTL 加随机值(最简单有效)

原理:在基础 TTL 上增加随机偏移,避免集体过期。

public void setWithRandomTTL(String key, String value) {
    long baseExpire = 3600; // 1小时
    long randomOffset = new Random().nextInt(600); // ±10分钟
    redisTemplate.opsForValue().set(
        key, 
        value, 
        Duration.ofSeconds(baseExpire + randomOffset)
    );
}

📌 适用场景:绝大多数缓存场景,成本低、效果好。

✅ 方案二:互斥锁重建缓存(防 DB 击穿)

原理:只有一个线程能查 DB,其他线程等待或重试。

public String getProductByIdWithLock(Long id) {
    String cacheKey = "product:" + id;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) {
        return cached;
    }

    // 尝试获取分布式锁
    String lockKey = "lock:product:" + id;
    String requestId = UUID.randomUUID().toString();
    try {
        Boolean locked = redisTemplate.execute(
            (RedisCallback<Boolean>) conn -> 
                conn.set(lockKey.getBytes(), requestId.getBytes(),
                        Expiration.seconds(3), RedisStringCommands.SetOption.SET_IF_ABSENT)
        );

        if (Boolean.TRUE.equals(locked)) {
            // 查 DB
            String dbData = db.get(id);
            if (dbData != null) {
                redisTemplate.opsForValue().set(
                    cacheKey, dbData, Duration.ofSeconds(3600 + new Random().nextInt(600))
                );
            }
            return dbData;
        } else {
            // 未获取到锁,短暂等待后重试(或返回旧数据)
            Thread.sleep(50);
            return redisTemplate.opsForValue().get(cacheKey); // 再查一次缓存
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        // 安全释放锁(Lua 脚本保证原子性)
        String script = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "   return redis.call('del', KEYS[1]) " +
            "else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(lockKey), requestId);
    }
}

⚠️ 注意:锁的粒度要细(按 ID 锁),避免全局锁影响性能。

✅ 方案三:逻辑过期(永不过期 + 后台更新)

原理:缓存不设物理 TTL,而是存储一个“逻辑过期时间”,由后台线程异步更新。

// 缓存结构:{ "data": "...", "expireTime": 1712345678 }
public void preloadCacheInBackground() {
    ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
    scheduler.scheduleAtFixedRate(() -> {
        for (long i = 1; i <= 10_000; i++) {
            String dbData = db.get(i);
            Map<String, Object> wrapper = new HashMap<>();
            wrapper.put("data", dbData);
            wrapper.put("expireTime", System.currentTimeMillis() + 3600_000);
            redisTemplate.opsForValue().set("product:" + i, JSON.toJSONString(wrapper));
        }
    }, 0, 30, TimeUnit.MINUTES); // 每30分钟刷新一次
}

public String getProductWithLogicalExpire(Long id) {
    String json = redisTemplate.opsForValue().get("product:" + id);
    if (json == null) return null;

    JSONObject obj = JSON.parseObject(json);
    long expireTime = obj.getLongValue("expireTime");
    String data = obj.getString("data");

    // 已过期?触发异步更新(但本次仍返回旧数据)
    if (System.currentTimeMillis() > expireTime) {
        CompletableFuture.runAsync(() -> refreshProduct(id));
    }
    return data;
}

📌 优点:永不 Miss,用户体验好
📌 缺点:数据可能短暂不一致

✅ 方案四:多级缓存 + 熔断降级(终极防护)

架构

用户 → L1 本地缓存(Caffeine) → L2 Redis → DB
                              ↓
                      Sentinel/Hystrix 熔断
// 使用 Caffeine 作为一级缓存
LoadingCache<Long, String> localCache = Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(1, TimeUnit.MINUTES)
    .build(id -> loadFromRedisOrDb(id));

private String loadFromRedisOrDb(Long id) {
    try {
        return productService.getProductByIdWithLock(id);
    } catch (Exception e) {
        // 熔断:返回兜底数据 or 空
        return "Default Product Info";
    }
}

配合 Sentinel 配置规则:

  • 当 DB 查询异常率 > 50%,自动熔断 10 秒
  • 限流:DB 查询 QPS ≤ 1000

🔗 Alibaba Sentinel 官网

📊 四种方案对比(Mermaid)

💡 实际项目中,方案一 + 方案二组合使用最为常见。


📈 六、验证修复效果:再次压测 🧪

修改 ProductService,采用 TTL 随机 + 互斥锁

public String getProductByIdSafe(Long id) {
    String cacheKey = "product:" + id;
    String cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) return cached;

    return getProductByIdWithLock(id); // 使用带锁版本
}

// 初始化时也加随机 TTL
redisTemplate.opsForValue().set(
    "product:" + i,
    db.get(i),
    Duration.ofSeconds(10 + new Random().nextInt(5)) // 10~15秒随机
);

重新运行 JMeter 测试:

控制台日志:每个 ID 仅输出一次 DB 查询
Redis 命中率:维持在 95%+
DB QPS:稳定在 100 以下

雪崩问题彻底解决!


🛡️ 七、生产环境最佳实践清单 📋

  1. 永远不要设置固定 TTL → 必须加随机偏移(±10%~20%)
  2. 热点数据单独处理 → 使用永不过期 + 异步更新
  3. 缓存重建必须加锁 → 避免 DB 被并发打爆
  4. 关键接口加熔断 → 如 Sentinel、Hystrix
  5. 监控缓存命中率 → 低于 90% 告警
  6. 定期演练故障 → 模拟缓存失效,验证系统韧性

🔗 八、延伸阅读(均可正常访问)


💎 结语

缓存雪崩不是“会不会发生”的问题,而是“何时发生”的问题。通过本文的复现 → 排查 → 修复全流程,你不仅掌握了技术方案,更建立了系统性风险防控思维

记住:优秀的工程师,不是不出问题,而是让问题无法造成灾难。

愿你的系统,永远无雪崩!🎉


🙌 感谢你读到这里!
🔍 技术之路没有捷径,但每一次阅读、思考和实践,都在悄悄拉近你与目标的距离。
💡 如果本文对你有帮助,不妨 👍 点赞、📌 收藏、📤 分享 给更多需要的朋友!
💬 欢迎在评论区留下你的想法、疑问或建议,我会一一回复,我们一起交流、共同成长 🌿
🔔 关注我,不错过下一篇干货!我们下期再见!✨

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐