Redis缓存穿透、击穿、雪崩有什么区别?
Redis 缓存策略是使用 Redis 作为缓存时的核心问题,需要解决缓存穿透、缓存击穿、缓存雪崩等问题,同时需要设计合理的缓存更新策略。
缓存穿透:查不存在的数据怎么办?
缓存穿透是指查询一个数据库和缓存中都不存在的数据,请求每次都会穿过缓存直接打到数据库。典型场景:攻击者用不存在的 ID 批量请求接口,如 /user/-1。
方案一:缓存空对象
当数据库也查不到时,将空值写入缓存并设置短 TTL,避免同一 key 反复穿透。
javapublic User getUserById(Long id) { User user = redis.get("user:" + id); if (user != null) { return "NULL".equals(user) ? null : user; } user = db.queryUserById(id); if (user == null) { redis.set("user:" + id, "NULL", 300); // 缓存空对象,5分钟过期 } else { redis.set("user:" + id, user, 3600); } return user; }
注意:空对象 TTL 不宜过长,否则该 key 对应的真实数据写入后,缓存仍是空值,导致数据不一致。可结合主动删除策略,写入数据时同步删除空缓存。
方案二:布隆过滤器
在缓存前加一层布隆过滤器,快速判断 key 是否可能存在。布隆过滤器说不存在则一定不存在,说存在则可能存在(有误判率)。
javaif (!bloomFilter.mightContain("user:" + id)) { return null; // 一定不存在,直接返回 } User user = redis.get("user:" + id); if (user != null) { return user; } user = db.queryUserById(id); if (user != null) { redis.set("user:" + id, user, 3600); } return user;
选型建议:数据量小且查询模式固定 → 缓存空对象更简单;数据量大且 key 空间稀疏 → 布隆过滤器更省内存。
缓存击穿:热点 key 过期瞬间怎么办?
缓存击穿是指某个热点 key 在过期的一瞬间,大量并发请求同时查询该 key,全部穿透到数据库。与雪崩的区别:击穿是单个热点 key,雪崩是大面积 key 同时失效。
方案一:互斥锁
用分布式锁保证只有一个线程查库并回填缓存,其他线程等待后重试。
javapublic User getUserById(Long id) { User user = redis.get("user:" + id); if (user != null) { return user; } String lockKey = "lock:user:" + id; try { if (redis.setnx(lockKey, "1", 10)) { // 获取锁,10秒自动释放 user = db.queryUserById(id); redis.set("user:" + id, user, 3600); } else { Thread.sleep(100); return getUserById(id); // 等待后重试 } } finally { redis.del(lockKey); } return user; }
注意:锁要设超时时间防止死锁;重试要设上限防止无限递归。
方案二:逻辑过期
缓存永不过期,在值中存一个逻辑过期时间戳。读到逻辑过期数据时,异步更新缓存,当前请求返回旧数据。牺牲短暂一致性换取高可用。
javapublic User getUserById(Long id) { String value = redis.get("user:" + id); if (value != null) { JSONObject json = JSON.parseObject(value); if (json.getBoolean("expired")) { asyncUpdateCache(id); // 异步更新,不阻塞当前请求 } return json.getObject("data", User.class); // 返回旧数据 } User user = db.queryUserById(id); JSONObject json = new JSONObject(); json.put("data", user); json.put("expired", false); redis.set("user:" + id, json.toJSONString(), 3600); return user; }
选型建议:一致性要求高 → 互斥锁;可用性要求高、允许短暂脏读 → 逻辑过期。
缓存雪崩:大面积 key 同时失效怎么办?
缓存雪崩是指大量 key 在同一时间过期,或 Redis 宕机,导致请求全部打到数据库。
方案一:过期时间加随机偏移
给 TTL 加上随机值,避免大量 key 在同一时刻集中过期。
javaint expire = 3600 + new Random().nextInt(600); // 3600~4200秒随机 redis.set("user:" + id, user, expire);
方案二:缓存预热
系统启动或低峰期提前加载热点数据到缓存,避免冷启动时流量直接打库。
java@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点预热 public void warmUpCache() { List<User> users = db.queryHotUsers(); for (User user : users) { int expire = 3600 + new Random().nextInt(600); redis.set("user:" + user.getId(), user, expire); } }
方案三:高可用 + 降级
- 部署 Redis Sentinel 或 Cluster,避免单点故障
- 数据库前加熔断限流,雪崩时快速失败而非拖垮数据库
- 使用本地缓存(Caffeine/Guava)作为二级缓存兜底
javapublic User getUserById(Long id) { try { User user = redis.get("user:" + id); if (user != null) { return user; } } catch (Exception e) { log.error("Redis error", e); return localCache.get("user:" + id); // 降级到本地缓存 } User user = db.queryUserById(id); redis.set("user:" + id, user, 3600); return user; }
缓存更新策略怎么选?
三种常见策略,适用场景不同:
Cache Aside(旁路缓存):读时回填,写时删缓存。最常用,一致性较好。
java// 读 User user = redis.get("user:" + id); if (user == null) { user = db.queryUserById(id); redis.set("user:" + id, user, 3600); } // 写 db.updateUser(user); redis.del("user:" + user.getId()); // 删缓存而非更新缓存
为什么删缓存而不是更新?并发写时更新缓存可能出现旧值覆盖新值的问题,删除更安全,下次读时自然回填最新值。
Write Through(写穿透):写数据时同步更新缓存和数据库。数据一致性强,但写延迟高,适合写少读多的场景。
Write Behind(写回):先更新缓存,异步批量写入数据库。写性能极高,但有数据丢失风险,适合写密集且容忍少量丢失的场景(如浏览量计数)。
缓存和数据库不一致怎么处理?
缓存与数据库不一致是分布式系统的经典问题,根本原因是两者无法原子操作。
方案一:延时双删
先删缓存 → 更新数据库 → 延时再删缓存。第二次删除用于清除更新数据库期间被旧缓存回填的数据。
javapublic void updateUser(User user) { redis.del("user:" + user.getId()); // 第一次删除 db.updateUser(user); Thread.sleep(500); // 延时,确保读请求回填旧缓存完成 redis.del("user:" + user.getId()); // 第二次删除 }
缺点:延时时间难以精确设定,过长影响性能,过短仍可能不一致。
方案二:订阅 Binlog
通过 Canal 等中间件订阅数据库 Binlog,数据变更时自动更新或删除缓存。解耦业务代码,一致性更可靠。
java@CanalEventListener public class CacheUpdateListener { @ListenPoint(destination = "example", schema = "test", table = "user") public void onEvent(CanalEntry.Entry entry) { User user = parseUserFromBinlog(entry); redis.del("user:" + user.getId()); // 或更新缓存 } }
选型建议:一致性要求一般 → 延时双删够用;一致性要求高 → Binlog 方案更可靠。
面试怎么答?
被问到这三个概念时,建议按以下结构回答:
- 先说定义:穿透是数据不存在,击穿是热点 key 过期,雪崩是大面积 key 同时失效
- 再说区别:穿透查的是不存在的数据,击穿和雪崩查的是存在的数据;击穿是单个 key,雪崩是批量 key
- 最后说方案:穿透用空缓存或布隆过滤,击穿用互斥锁或逻辑过期,雪崩用随机过期+预热+高可用
追问方向:生产环境怎么监控缓存健康度?关注缓存命中率(低于 80% 需告警)、慢查询日志、内存使用率和 key 过期分布。