本文目录导读:

我们通常所说的“雪崩”,在Java分布式系统中,一般指“缓存雪崩”,但也可能是“服务雪崩”,这两者是不同的概念,下面分别用两个经典的代码级案例来还原场景、分析原因并给出解决方案。
缓存雪崩(Cache Avalanche)
场景还原:
某电商平台在凌晨12点进行大促,Redis中存储了所有商品的库存和详情,为了节省内存,所有缓存设置了统一的过期时间(如30分钟),当12:00:00准时,大量缓存同时过期,大量用户请求同时涌入数据库(MySQL),数据库连接池瞬间被击穿,CPU飙升,导致数据库宕机,整个系统瘫痪。
代码模拟(JVM层模拟,原理与Redis相同):
public class CacheAvalancheDemo {
// 模拟缓存存储(用JVM Map代替Redis)
private static Map<String, String> cache = new ConcurrentHashMap<>();
private static Map<String, String> db = new ConcurrentHashMap<>(); // 模拟数据库
// 模拟数据库查询(很慢)
public static String queryDb(String key) {
try {
Thread.sleep(300); // 模拟慢SQL
} catch (InterruptedException e) {}
return "value-" + key;
}
// 初始化缓存,设置统一过期时间
public static void initCache() {
for (int i = 0; i < 1000; i++) {
String key = "product_" + i;
cache.put(key, queryDb(key));
}
System.out.println("缓存初始化完成,等待统一过期...");
}
// 获取数据(不带缓冲的原始方法)
public static String getDataBad(String key) {
String value = cache.get(key);
if (value == null) {
// 缓存没数据,直接去打DB —— 雪崩点!
value = queryDb(key);
cache.put(key, value);
}
return value;
}
// 获取数据(带互斥锁:防止大量请求同时打DB,只允许一个线程去查DB)
public static String getDataWithLock(String key) {
String value = cache.get(key);
if (value == null) {
// 只有第一个线程能拿到锁
synchronized (CacheAvalancheDemo.class) {
value = cache.get(key); // 二次检查
if (value == null) {
value = queryDb(key);
cache.put(key, value);
}
}
}
return value;
}
public static void main(String[] args) throws InterruptedException {
initCache();
System.out.println("模拟统一过期时刻...");
Thread.sleep(1100); // 假设缓存清空
// 模拟100个请求同时访问同一个不存在的key
int threadCount = 100;
CountDownLatch latch = new CountDownLatch(threadCount);
// 不用锁的情况:
long start = System.currentTimeMillis();
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
pool.execute(() -> {
getDataBad("product_1"); // 由于缓存失效,会打DB 100次
latch.countDown();
});
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("不加锁总耗时:" + (end - start) + "ms (数据库被打了100次)");
// 重置
Thread.sleep(1000); // 等待完成
// 用锁的情况:
start = System.currentTimeMillis();
CountDownLatch latch2 = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
pool.execute(() -> {
getDataWithLock("product_2");
latch2.countDown();
});
}
latch2.await();
end = System.currentTimeMillis();
System.out.println("加锁总耗时:" + (end - start) + "ms (数据库只被打了1次)");
pool.shutdown();
}
}
解决方案(针对缓存雪崩):
- 过期时间打散:不要统一过期,加一个随机数(如
base + random(0~300s))。 - 互斥锁/单飞:如上述代码,只允许一个线程去查DB并回填缓存(使用
SETNX或语言级锁)。 - 多级缓存:本地缓存(Caffeine) + Redis,本地缓存不会全部过期。
- 缓存永久不过期:后台线程异步更新缓存。
- 熔断降级:如果DB压力过大,直接返回默认值或限流。
服务雪崩(Service Avalanche,依赖故障蔓延)
场景还原:
微服务架构中,OrderService 依赖 UserService 和 ProductService。UserService 因为某个接口慢(比如SQL死锁),导致响应时间变长(从50ms变成5秒)。OrderService 的Tomcat线程池有100个线程,因为 UserService 慢,100个线程全部卡在等待响应上,新的请求 OrderService 无法处理,全部排队超时。ProductService 也被拖垮,因为 OrderService 占着连接不释放,最终整个系统雪崩。
代码模拟(线程池耗尽):
public class ServiceAvalancheDemo {
// 模拟下游服务:一开始正常,后来变得极慢
static class DownstreamService {
static boolean isSlow = false;
static String call() {
if (isSlow) {
try { Thread.sleep(5000); } catch (InterruptedException e) {}
return "slow-response";
} else {
try { Thread.sleep(50); } catch (InterruptedException e) {}
return "fast-response";
}
}
}
public static void main(String[] args) throws InterruptedException {
// 模拟上游服务(OrderService)的线程池,大小只有10个
ExecutorService orderServicePool = Executors.newFixedThreadPool(10);
int totalRequests = 50; // 50个请求进来
// 阶段1:下游正常,快速处理
System.out.println("=== 阶段1:下游正常 ===");
CountDownLatch latch1 = new CountDownLatch(totalRequests);
for (int i = 0; i < totalRequests; i++) {
orderServicePool.execute(() -> {
DownstreamService.call(); // 50ms
latch1.countDown();
});
}
latch1.await();
System.out.println("10个线程处理50个请求,无压力");
// 阶段2:下游变慢,导致线程池耗尽
System.out.println("=== 阶段2:下游变慢(雪崩开始) ===");
DownstreamService.isSlow = true;
// 只发5个请求,但每个都要5秒,占用5个线程5秒
for (int i = 0; i < 5; i++) {
orderServicePool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 开始阻塞5秒");
DownstreamService.call(); // 5s
});
}
Thread.sleep(100); // 确保线程全部被占用
// 这时候再来新请求,线程池已满,会走拒绝策略(抛异常/等待)
System.out.println("尝试提交第6个任务...");
try {
orderServicePool.execute(() -> {
System.out.println("新请求进来了");
});
} catch (RejectedExecutionException e) {
System.err.println("雪崩发生:新请求被拒绝(线程池已满)");
}
// 如果使用线程池+无界队列,则是排队等待,最终超时。
Thread.sleep(1000);
orderServicePool.shutdownNow();
}
}
解决方案(针对服务雪崩):
- 超时控制:在HTTP/RPC调用时,设置
connectTimeout和readTimeout(如Dubbo的timeout=2000)。 - 线程池隔离(舱壁模式):为高风险的依赖分配独立的线程池,如Hystrix的
ThreadPoolKey。 - 熔断器:如果错误率超过阈值(如50%),直接熔断,不再调用下游,快速失败(如Sentinel、Hystrix)。
- 限流:对入口进行QPS限制(如Sentinel的
FlowRule)。 - 重试降级:失败后走兜底逻辑(返回默认值/缓存旧值)。
对应代码(超时+熔断的关键逻辑伪码):
// 模拟带熔断的调用
public class CircuitBreaker {
volatile boolean open = false; // true则熔断
long lastOpenTime;
int failCount = 0;
public String call() {
if (open) {
throw new RuntimeException("熔断中");
}
try {
String result = doRpcWithTimeout(2000); // 超时2秒
failCount = 0; // 成功则重置
return result;
} catch (Exception e) {
if (++failCount >= 5) {
open = true; // 5次失败则熔断
lastOpenTime = System.currentTimeMillis();
}
throw e;
}
}
}
- 缓存雪崩:是数据层面的击穿(大量缓存同时失效导致DB打爆),应对 “错峰过期”、互斥锁、多级缓存。
- 服务雪崩:是资源层面的耗尽(依赖慢导致线程池/连接池占满),应对 “超时”、隔离、熔断、限流。
实际生产环境中,两者常常叠加:缓存雪崩 → 数据库压力大 → 服务响应慢 → 服务雪崩,建议监控告警 + 兜底策略 + 压测演练(混沌工程/故障演练)三者结合。