穿透、击穿、雪崩的本质区别
数据库运维中,Redis缓存策略的设计直接决定系统在高流量下的存活能力。三个最常见的问题——穿透、击穿、雪崩——经常被混淆,但它们的触发机制和解决方案完全不同:
– 缓存穿透:请求查询一个数据库中根本不存在的数据,缓存无法命中,请求直达数据库。恶意攻击或爬虫遍历ID时最容易触发。
– 缓存击穿:一个热点Key过期瞬间,大量并发请求同时穿透缓存打到数据库。本质是单Key的并发竞争问题。
– 缓存雪崩:大量Key同时过期或Redis节点宕机,请求大面积穿透到数据库。本质是批量Key的失效问题。
区分标准:穿透是”数据不存在”,击穿是”单Key过期”,雪崩是”批量Key失效”。
缓存穿透的诊断与防护
穿透问题的特征是Redis和数据库的QPS同时升高,且Redis的miss率接近100%。
方案一:空值缓存
最直接的方法——查不到数据时,在Redis中缓存一个空值(null或特殊标记),设置较短的TTL:
public String getProduct(String productId) {
String cacheKey = "product:" + productId;
String cached = redis.get(cacheKey);
if (cached != null) {
if ("NULL".equals(cached)) {
return null; // 命中空值缓存,不查数据库
}
return cached;
}
// 查数据库
Product product = productMapper.selectById(productId);
if (product == null) {
// 缓存空值,TTL设短(30秒-5分钟),避免占用过多内存
redis.setex(cacheKey, 60, "NULL");
return null;
}
redis.setex(cacheKey, 3600, JSON.toJSONString(product));
return JSON.toJSONString(product);
}
空值缓存的陷阱:恶意请求如果使用随机ID,每个ID都会产生一条空值缓存记录,可能撑爆Redis内存。解决方案是对ID做格式校验,不合法的ID直接拒绝。
方案二:布隆过滤器
在请求到达Redis之前,先用布隆过滤器判断数据是否可能存在:
// RedisBloom模块(Redis 4.0+支持)
// 初始化布隆过滤器
redis.execute("BF.RESERVE", "product_bloom", 0.001, 10000000);
// 插入所有合法产品ID
redis.execute("BF.ADD", "product_bloom", productId);
// 查询时先判断
public String getProduct(String productId) {
// 布隆过滤器判断
boolean exists = redis.execute("BF.EXISTS", "product_bloom", productId);
if (!exists) {
return null; // 一定不存在,直接返回
}
// 可能存在,查缓存再查数据库
String cached = redis.get("product:" + productId);
// ...
}
布隆过滤器的误判率需要根据业务规模计算:预期1000万数据、0.1%误判率需要约18MB内存,1000万数据、0.01%误判率需要约24MB。误判率越低内存越大,需在内存成本和穿透概率间取平衡。
数据迁移实战中,布隆过滤器需要全量预热——把数据库所有ID加载到布隆过滤器中,新写入的数据同步更新布隆过滤器。
缓存击穿的防护:互斥锁与逻辑过期
击穿的核心是”一个Key过期时,多个请求同时穿透”。解决方案有两种思路。
方案一:互斥锁(Mutex Lock)
只允许一个请求去查数据库并重建缓存,其他请求等待:
public String getProductWithLock(String productId) {
String cacheKey = "product:" + productId;
String cached = redis.get(cacheKey);
if (cached != null) {
return cached;
}
// 缓存未命中,获取互斥锁
String lockKey = "lock:" + productId;
String lockValue = UUID.randomUUID().toString();
// SET NX EX:原子性获取锁,10秒自动过期
boolean locked = redis.set(lockKey, lockValue, "NX", "EX", 10);
if (locked) {
try {
// 双重检查:拿到锁后再查一次缓存
cached = redis.get(cacheKey);
if (cached != null) {
return cached;
}
// 查数据库
Product product = productMapper.selectById(productId);
String value = product != null ? JSON.toJSONString(product) : "NULL";
redis.setex(cacheKey, 3600, value);
return value;
} finally {
// 释放锁(Lua脚本保证原子性)
String unlockScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redis.eval(unlockScript, 1, lockKey, lockValue);
}
} else {
// 未获锁,短暂等待后重试
Thread.sleep(50);
return getProductWithLock(productId);
}
}
互斥锁的问题:等待的请求会阻塞线程,高并发下线程池可能耗尽。解决方案是加最大重试次数限制(3次),超限直接返回降级数据。
方案二:逻辑过期
不设物理TTL,在值中嵌入逻辑过期时间。过期后返回旧数据,异步刷新缓存:
// 缓存值结构
{
"data": { ... }, // 业务数据
"expireTime": 1722384000 // 逻辑过期时间戳
}
public String getProductLogicalExpire(String productId) {
String cacheKey = "product:" + productId;
String cached = redis.get(cacheKey);
if (cached == null) {
return null;
}
CacheWrapper wrapper = JSON.parseObject(cached, CacheWrapper.class);
if (wrapper.getExpireTime() > System.currentTimeMillis()) {
return JSON.toJSONString(wrapper.getData());
}
// 逻辑过期,异步刷新
String lockKey = "lock:" + productId;
boolean locked = redis.set(lockKey, "1", "NX", "EX", 10);
if (locked) {
CompletableFuture.runAsync(() -> {
try {
Product product = productMapper.selectById(productId);
CacheWrapper newWrapper = new CacheWrapper(
product,
System.currentTimeMillis() + 3600_000
);
redis.set(cacheKey, JSON.toJSONString(newWrapper));
} finally {
redis.del(lockKey);
}
});
}
// 无论是否获取锁,都返回旧数据
return JSON.toJSONString(wrapper.getData());
}
逻辑过期适合对一致性要求不高的场景(如商品详情页)。好处是用户永远不等待缓存重建,缺点是可能返回过期数据。
缓存雪崩的防护体系
1. TTL随机化:
批量设置缓存时,在基础TTL上叠加随机偏移:
// 基础TTL 1小时 + 随机0-10分钟
int baseTTL = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(0, 600);
redis.setex(cacheKey, baseTTL + randomOffset, value);
2. Redis高可用架构:
Sentinel模式在主节点宕机时自动故障转移(30秒内完成切换)。Cluster模式分片存储,单节点宕机只影响1/N的数据。但Cluster模式下如果某个slot迁移失败,可能导致部分Key暂时不可用——需要配置客户端的集群拓扑自动刷新。
3. 多级缓存:
本地缓存(Caffeine/Guava Cache)+ 分布式缓存(Redis),形成两层防护:
// Caffeine本地缓存配置
Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats()
.build();
public String getProductMultiLevel(String productId) {
String cacheKey = "product:" + productId;
// L1: 本地缓存
String cached = localCache.getIfPresent(cacheKey);
if (cached != null) {
return cached;
}
// L2: Redis
cached = redis.get(cacheKey);
if (cached != null) {
localCache.put(cacheKey, cached);
return cached;
}
// L3: 数据库
Product product = productMapper.selectById(productId);
String value = product != null ? JSON.toJSONString(product) : "NULL";
redis.setex(cacheKey, 3600, value);
localCache.put(cacheKey, value);
return value;
}
多级缓存的难点在于本地缓存的一致性:当数据更新时,需要通知所有应用实例清除本地缓存。常用方案是Redis Pub/Sub广播失效消息,各实例监听并清除对应的本地缓存条目。
监控与告警配置
Redis缓存问题的监控指标:
# Prometheus + Grafana 监控指标
# 1. 缓存命中率
redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)
# 2. 缓存穿透检测:miss率突然升高
rate(redis_keyspace_misses_total[5m]) > threshold
# 3. 连接数异常
redis_connected_clients > redis_maxclients * 0.8
# 4. 内存使用率
redis_memory_used_bytes / redis_memory_max_bytes > 0.85
# 5. 阻塞命令数
rate(redis_slowlog_length[5m]) > 10
告警规则建议:缓存命中率低于90%持续5分钟触发warning,低于80%触发critical;Redis内存使用率超过85%触发warning,超过92%触发critical。结合数据库高可用架构的监控,形成从缓存到数据库的全链路告警覆盖。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shi-zhan-chuan-tou-ji-chuan-yu-xue/