面试里问到 Redis 缓存问题,最常见的失分点不是不知道三个名词,而是把三者的触发条件讲混。很多候选人能把「布隆过滤器」「互斥锁」「随机过期时间」背出来,但面试官追问一句「一个不存在的 user_id 反复打进来,命中率没掉但 DB 的 QPS 涨了,这算哪种」,就答不上来了。本文按「怎么判定 → 为什么会发生 → 具体对策 → 代码怎么写 → 线上怎么验证」的顺序拆一遍,每个对策都给出真实可运行的代码和参数,最后附常见追问。
先建立判定框架:看命中率、看 key 分布、看 DB 压力形态
三种问题在监控曲线上有明显区别,先把这张表记住,面试时能直接用来回答「你怎么判断是哪种」。
| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 请求的 key 是否存在于数据源 | 根本不存在 | 存在,但缓存刚好失效 | 大量 key 同时失效或 Redis 整体不可用 |
| 缓存命中率 | 明显下降(大量 miss) | 短时间局部下降,热点 key 抖动 | 断崖式下跌 |
| 影响范围 | 单个/一类非法 key | 单个热点 key | 大面积 key |
| DB 压力形态 | 持续、低幅但异常的查询量,查询条件高度重复 | 瞬间尖峰,随后恢复 | 持续高压甚至打满连接池 |
| 典型场景 | 爬虫遍历 id、恶意攻击、业务逻辑误传 | 秒杀商品、首页配置、大 V 微博 | 批量预热时统一设置同一过期时间、Redis 主节点宕机 |
实操判定时看三个信号:一是 keyspace_misses 与 DB 查询量的比值,穿透场景下这个比值长期偏高且查询条件重复;二是热点 key 的 expired_keys 是否集中在某一秒;三是 DB 慢查询日志里是否出现大量完全相同(或高度相似)的 SQL。第三种情况最容易被忽略,很多人把「同一个 SQL 在 1 秒内打了 8000 次」当成雪崩,其实是击穿。
缓存穿透:不存在的 key 打穿了缓存层
为什么会发生
缓存的设计前提是「数据存在,缓存只是加速」。当请求查询一个 DB 里也不存在的记录时,流程是:查 Redis → miss → 查 DB → 查不到 → 不写缓存 → 返回空。下一次同样的请求依旧走完整链路。攻击者用脚本遍历 /user/1 到 /user/1000000,DB 就被打穿了。
对策一:空值缓存,最简单的兜底
查到 null 时也写一份缓存,值用特殊标记,过期时间设短一些(通常 30~120 秒),避免正常数据写入后长时间读到脏空值。缺点是如果攻击者每次用随机 key,空值缓存会撑爆内存,所以必须配合对策二或限流。
public User getUser(long userId) {
String key = "user:info:" + userId;
String cached = redis.get(key);
// 用固定哨兵串表示"数据库确认不存在"
if (cached != null) {
if ("__NULL__".equals(cached)) {
return null;
}
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(userId);
if (user == null) {
redis.setex(key, 60, "__NULL__");
return null;
}
redis.setex(key, 1800 + ThreadLocalRandom.current().nextInt(300), JSON.toJSONString(user));
return user;
}
对策二:布隆过滤器前置拦截
把所有合法 id 预先加载进 Bloom Filter,查询前先 mightContain,返回 false 直接拒掉,返回 true 才走缓存和 DB。注意 Bloom Filter 有误判率(false positive,会把不存在的判成存在),但不会漏判,所以能挡掉绝大部分非法请求。Redis 侧可用 BF.RESERVE / BF.ADD / BF.EXISTS(需加载 RedisBloom 模块),也可以用 Guava 的本地实现。
# RedisBloom 真实命令,容量 100 万,误判率 0.1%
redis-cli BF.RESERVE user:bloom 0.001 1000000
redis-cli BF.ADD user:bloom 10086
redis-cli BF.EXISTS user:bloom 10086
# 返回 1 表示可能存在,0 表示一定不存在
# 返回 0 的请求可直接短路,不打 DB
参数解释:BF.RESERVE key error_rate capacity,error_rate 越小内存占用越大,0.001 对 100 万容量约占用 1.8MB。业务上新增用户时要同步 BF.ADD,删除用户时不能 BF.DEL(Bloom Filter 不支持删除,会破坏其他位的判断),只能靠重建。
对策三:接口层参数校验与限流
id 必须是 18 位以内正整数、必须符合业务编码规则,这一层能过滤掉大量畸形请求。再配合网关按 IP / 按用户维度限流(如 Sentinel 的 QPS 流控规则),把攻击流量挡在业务逻辑之外。
缓存击穿:单个热点 key 失效瞬间的并发冲击
为什么会发生
一个 key 承载了极高并发(比如首页 banner 配置、秒杀商品详情),恰好过期的一瞬间,几百上千个线程同时 miss,同时去查 DB,DB 在几十毫秒内被打出尖峰。注意击穿的前提是数据存在,这和穿透有本质区别。
对策一:互斥锁重建缓存
只有拿到锁的线程去查 DB 并回写,其余线程短暂等待后重新读缓存。锁的过期时间要覆盖「查 DB + 写缓存」的最坏耗时,防止持锁线程崩溃导致死锁。用 SET key value NX PX 保证原子性,释放时用 Lua 校验 value,避免误删别人的锁。
public User getUserWithMutex(long userId) {
String key = "user:info:" + userId;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
String lockKey = "lock:user:info:" + userId;
String token = UUID.randomUUID().toString();
// NX: 不存在才设置; PX 5000: 5 秒自动过期
Boolean locked = redis.set(lockKey, token, "NX", "PX", 5000);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查,防止等锁期间别人已经回写
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(userId);
if (user != null) {
redis.setex(key, 1800, JSON.toJSONString(user));
}
return user;
} finally {
// Lua 保证"判断 value 相等再删除"的原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(token));
}
} else {
// 没抢到锁,短暂等待后重试读缓存,最多重试 3 次
for (int i = 0; i < 3; i++) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, User.class);
}
}
// 重试仍未拿到,降级为直接查库,务必配合限流避免再次打满
return userMapper.selectById(userId);
}
}
对策二:逻辑过期,永不物理过期
缓存里存一个 expireAt 字段,Redis 层面不设 TTL(或设很长),读到数据时判断逻辑时间是否过期:未过期直接返回;已过期则尝试获取锁,拿到锁的线程异步刷新缓存,其他线程直接返回旧值。这样能做到「读永远不阻塞」,代价是短时间内可能读到旧数据,适合对一致性要求不高的场景(如商品详情、榜单)。
对策三:热点 key 预热 + 提前续期
在过期前用定时任务或后台线程主动刷新 TTL(比如 TTL 剩余 20% 时续期),让热点 key 永不自然过期。同时把热点 key 打散到多个副本,用 key:1 ... key:N 的 suffix 分摊读压力,读时随机取一个。
缓存雪崩:大面积同时失效
为什么会发生
两种成因要分开讲。第一类是过期时间集中:批量预热时写成 redis.setex(key, 3600, value),一小时后整批 key 一起失效。第二类是Redis 本身不可用:主节点宕机、网络分区、被运维误 flush,此时所有请求直接打到 DB,属于最严重的情况。
对策一:过期时间加随机抖动
这是成本最低也最有效的一条,必须在预热脚本里做。抖动范围取基础 TTL 的 10%~20% 即可。
import random
import redis
r = redis.Redis(host='127.0.0.1', port=6379)
def warm_up(items):
pipe = r.pipeline()
for item in items:
base_ttl = 3600
# 抖动 10%,让失效时间散在 360 秒的窗口内
ttl = base_ttl + random.randint(0, int(base_ttl * 0.1))
pipe.setex(f"product:detail:{item['id']}", ttl, item['json'])
pipe.execute()
对策二:多级缓存
在应用进程内挂一层本地缓存(Caffeine),形成 L1 本地 + L2 Redis + L3 DB。Redis 整体不可用时,本地缓存仍能顶住大部分读流量。Caffeine 的 expireAfterWrite 建议设短一点(如 30~60 秒),保证多实例之间的数据偏差可控,并配合广播失效(如通过 Redis 的 pub/sub 或消息队列通知各节点清本地缓存)。
Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.recordStats()
.build();
public String query(String key) {
String value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
value = redis.get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
value = loadFromDb(key);
if (value != null) {
redis.setex(key, 1800 + ThreadLocalRandom.current().nextInt(300), value);
localCache.put(key, value);
}
return value;
}
对策三:限流降级 + 熔断
Redis 故障时,DB 就是最后一道防线,必须限流。用 Sentinel 对 db 调用做 QPS 保护,超阈值直接返回缓存中的旧值或默认值;接入 Resilience4j 或 Sentinel 的熔断器,当 DB 错误率超过 50% 时快速失败,避免连接池被占满。核心接口可返回 HTTP 200 + 降级文案,非核心接口直接返回空。
对策四:Redis 高可用架构
单点 Redis 本身就是雪崩隐患。生产必须上 Redis Sentinel(主从 + 哨兵,故障自动切换,切换窗口通常在 10~30 秒)或 Redis Cluster(分片 + 副本,单分片故障影响面被限制在 1/N)。同时在客户端配好超时:连接超时 connectTimeout=200ms、读写超时 soTimeout=500ms、重试 maxAttempts=1,超时过长会把线程池拖死,反而放大故障。
对策五:缓存预热与持久化
重启或大促前跑预热任务把热点数据灌回 Redis;开启 appendonly yes + appendfsync everysec,配合 RDB 快照,缩短故障恢复后的冷启动窗口。不建议依赖 FLUSHALL 后的自动恢复,生产上要禁用危险命令:在 redis.conf 中通过 rename-command FLUSHALL "" 将其禁用。
线上验证与常见报错
对策上线后要能验证效果,否则等于没做。
- 用
redis-cli info stats看keyspace_hits/keyspace_misses,穿透治理后 miss 数应显著下降。 - 用
redis-cli --bigkeys确认空值缓存没有把内存撑爆;用info memory看used_memory_human与mem_fragmentation_ratio。 - 用
redis-cli monitor(仅限排障短时开启)确认热点 key 的访问模式,注意它会显著影响性能。 - 压测时用
wrk或JMeter模拟 200 并发,观察 DB 连接池活跃数是否出现尖峰。
几个真实会遇到的报错:
ERR unknown command 'BF.ADD':RedisBloom 模块未加载。解决办法是在 redis.conf 加loadmodule /path/to/redisbloom.so,或用 Docker 启动redis/redis-stack镜像。redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool:连接池耗尽。检查是否在缓存 miss 时同步查 DB 过慢导致线程堆积,先加限流再调大maxTotal。Caused by: io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 second(s):命令超时。排查是否有大 key(用redis-cli --bigkeys)或KEYS *这类 O(N) 命令。OOM command not allowed when used memory > 'maxmemory':内存打满。检查空值缓存是否未设短 TTL,或布隆过滤器容量设置过小导致误判率飙升。
小结
三个问题的判定核心是「key 在不在数据源」和「失效范围有多大」:穿透是查不存在的数据,对策是空值缓存 + Bloom Filter + 参数校验;击穿是单个热点 key 失效瞬间的并发,对策是互斥锁 / 逻辑过期 / 预热续期;雪崩是大面积同时失效或 Redis 不可用,对策是 TTL 抖动 + 多级缓存 + 限流降级 + 高可用架构。面试时先给出判定依据,再按成本从低到高排列对策,比背名词更有说服力。
FAQ
Q1:空值缓存和布隆过滤器选哪个?
两者不互斥。空值缓存实现简单,但对随机 key 攻击无效且占内存;布隆过滤器能挡住随机 key,但有误判且不支持删除。常规做法是布隆过滤器做前置拦截,空值缓存做兜底,再叠加接口限流。
Q2:互斥锁会不会导致请求变慢?
会。没抢到锁的线程要等待重试,最坏情况延迟增加几十到几百毫秒。所以热点 key 更适合用「逻辑过期 + 异步刷新」,读路径永远不阻塞。互斥锁适合对数据新鲜度要求高、并发量中等的场景。
Q3:TTL 抖动加多少合适?
通常取基础 TTL 的 10%~20%。TTL 为 3600 秒时抖动 0~360 秒,能把失效时间摊到 6 分钟窗口内。抖动太小起不到打散作用,太大则缓存利用率下降、DB 查询量上升。
Q4:Redis 主从切换期间的 10~30 秒怎么扛?
靠本地缓存兜底 + 客户端熔断。切换窗口内 Lettuce/Jedis 会抛连接异常,此时应快速失败转读本地缓存或返回降级数据,绝不能无限重试。生产上还可开启 min-replicas-to-write 1 与 min-replicas-max-lag 10,减少切换期间的数据不一致。
Q5:怎么区分「击穿」和「雪崩」的监控曲线?
看失效 key 的数量和时间分布。击穿是单个或极少数 key 的 expired_keys 在某一秒突增,DB QPS 尖峰后迅速回落;雪崩是大批 key 集中在同一时间失效,或 Redis 连接数骤降为 0,DB 压力持续高位不回落。