缓存的三大问题分析与解决
概览:三大问题一览
- 缓存穿透(Cache Penetration):查询一个不存在的数据,既不在缓存也不在数据库,导致每次请求都穿透到数据库,压力直达 DB。
- 缓存击穿(Cache Breakdown / Cache Miss Storm):某个热点 key 在短时间内失效,瞬时大量并发请求同时击穿缓存去加载 DB,产生雪崩式 DB 压力。
- 缓存雪崩(Cache Avalanche):大量缓存 key 在同一时间失效(或缓存服务宕机),导致大量请求同时落到后端 DB,引起不可用或崩溃。
1. 缓存穿透(Cache Penetration)
1.1 问题描述
客户端频繁请求不存在或被伪造的 key(例如:/product?id=99999999
),缓存里没有,DB 也没有,每次都打到 DB,造成 DB 压力或被恶意刷。
1.2 造成原因
- 用户或攻击者查询随机/不存在 id(漏洞、爬虫、恶意攻击)。
- 接口缺乏有效参数校验(比如 id 格式校验不严格)。
1.3 检测指标
- 某些 key 的 DB 查询命中率极高但缓存命中率为 0。
- DB 慢查询增加且来源于相似参数(同一接口大批不存在 id)。
1.4 常见解决方案(按实用度排序)
A. 参数校验 + 白名单
- 在业务层先做合法性校验(id 格式、范围、签名校验等),对非法请求直接拒绝或返回错误,不查询缓存/DB。
- 适用于可以通过规则识别的大量无效请求。
B. 布隆过滤器(Bloom Filter)
- 在请求到达缓存/DB 前,先在布隆过滤器里判断是否可能存在(布隆过滤器误判为存在,但不会误判为不存在)。
- 当布隆过滤器判断“不存在”时直接响应空/错误,不查询 DB。
- 适合高 QPS 场景且数据集合较稳定(例如用户 id、商品 id 集合)。
实现示例(伪代码)
if not bloom.mightContain(id):return 404 / empty
# 否则再查缓存 -> 查 DB -> 填充缓存
注意:布隆过滤器需要定期与 DB 同步(新增/删除)或采用可增量更新的方案。
C. 缓存空值(Negative Caching)
- 对 DB 确认不存在的 key,在缓存中写入一个特殊空值(例如
NULL
、__NULL__
)并设置较短 TTL(如 1~5 分钟)。 - 对后续相同请求直接返回空值而不触发 DB。
Redis 示例
SET key "__NULL__" EX 60 # 缓存 60s
权衡:
- 优点:实现简单,能快速阻止重复穿透请求。
- 缺点:空值也占内存;若大量不同不存在 key 被缓存,会导致缓存被污染(建议把负缓存 TTL 设置短一些)。
D. 接口限流 / 认证
- 对短时间内的某些 IP 或某些接口进行限流或验证码校验(防止恶意探测)。
2. 缓存击穿(Cache Breakdown / Hot Key Miss Storm)
2.1 问题描述
某个热点 key(例如商品详情页、热门排行榜)在到期时或被主动删除时,瞬间有大量并发请求同时去读取 DB 重建缓存,造成 DB 瞬时高并发访问,产生高延迟或宕机风险。
2.2 造成原因
- 热点 key 的 TTL 相同且到期集中。
- 热点 key 被批量清理或缓存失效策略不合理。
- 单点热点(某个 key 请求量 >> 其他)。
2.3 检测指标
- 某单 key 的 QPS 高且缓存 miss 突增。
- DB 连接池瞬间耗尽或慢查询率暴涨。
- 同一 key 同一时刻大量缓存重建日志。
2.4 常见解决方案(组合使用更可靠)
A. 互斥锁(Mutex / Lock)
- 在缓存未命中时,某个请求获取到重建缓存的锁,其他请求等待(或短时间返回空)。
- 推荐使用 Redis 的
SET key value NX PX ttl
获取分布式锁,并用 Lua 脚本安全释放锁(以防删除别人的锁)。
获取锁示例(Redis)
SET lock_key token NX PX 30000
-- 成功表示获取到锁
安全释放(Lua)
if redis.call("GET",KEYS[1]) == ARGV[1] thenreturn redis.call("DEL",KEYS[1])
elsereturn 0
end
伪代码 flow
-
请求查缓存,miss。
-
尝试获取锁:
- 成功:从 DB 加载,写入缓存,释放锁。
- 失败:短轮询(sleep+retry)或直接返回旧值/降级。
注意:锁要设置合理 TTL,且重建耗时不可超过锁 TTL(或可采用续期)。
B. 单飞机制 / Request Coalescing(Singleflight)
- 把并发请求合并为一次后端加载(Go 的
singleflight
就是这个思想)。 - 多个请求等待同一次加载结果,加载完成后将结果广播给等待者。
C. 永不过期 / 主动刷新(Cache Refresh / Refresh-Ahead)
- 对热点数据不使用短 TTL,而是:在 TTL 到期前由后台线程异步刷新(refresh-ahead),保证缓存持续存在。
- 或者把热点 key 设为长期 TTL(或不失效),并通过消息/事件在数据更新时主动刷新或删除对应缓存。
D. 限流 + 降级(Fallback)
- 在重建期间,对请求进行限流(固定窗口、令牌桶等),超额请求返回降级数据(如上次快照、空页面、错误码或静态页面)。
E. 本地缓存 + 多级缓存(L1 + L2)
- 对于单机热点,可以在应用内维护本地 LRU/LFU 缓存(例如 Guava Cache、Caffeine),减少对 Redis 的并发访问,再以 Redis 作为集中缓存层。对热点 key,本地缓存命中可大幅降低到 Redis 的并发。
2.5 实践建议
- 热点检测:定期统计 top-N key 的 QPS,针对热点采取“永不过期 + 后台刷新”策略。
- 使用单独的缓存策略(不同 TTL、不同刷新策略)来隔离热点 key。
- 锁与单飞机制要注意超时与异常情况下的容错(避免死锁或缓存永远不生效)。
3. 缓存雪崩(Cache Avalanche)
3.1 问题描述
大批量缓存 key 在同一时间失效(例如统一 TTL、Redis 宕机重启导致数据丢失、批量清理),导致大量请求同时落到 DB,引发 DB 宕机或服务不可用。
3.2 造成原因
- 大量 key TTL 一致且同时到期(常见于统一设置 TTL 的批量写入)。
- 缓存服务宕机/重启/网络抖动(缓存瞬间不可用)。
- 在扩容/缩容或运维过程中误操作批量清空缓存。
3.3 检测指标
- 大量缓存 miss 同时出现,DB QPS 突增。
- Redis 主从切换或重启日志与 DB 请求峰值时间一致。
3.4 常见解决方案
A. TTL 随机化(Jitter)
为 key 设置 baseTTL + 随机值,避免大量 key 同一时刻过期。
示例(Python)
import random, redis
ttl = base_ttl + random.randint(0, jitter_seconds)
redis.set(key, value, ex=ttl)
B. 分批过期 / 保持短平替换
把一批写入的 key 分批设置不同 TTL 或在写入后异步按批刷新,避免集中过期。
C. 预热(Pre-warming / Cache Warming)
- 在服务启动或维护后,预先把热点数据加载到缓存(异步批量加载)。
- 适用于可预估热点集合。
D. 多级缓存与降级(Graceful Degradation)
- 应用端 fallback:当缓存不可用时,限制请求速率访问 DB 或返回降级内容(静态页面、默认值)。
- 使用本地缓存作为短期降级缓存(L1),在 Redis 不可用时仍能应对一部分流量。
E. 利用读副本与限流
- 将读请求在 DB 的只读副本中分摊,或在缓存击穿时先在副本上读取。
- 对突发流量做保护,采用限流、熔断策略。
F. 异步填充 / 队列
- 在缓存失效高峰时,通过队列对 DB 的请求做排队,单线程或少量线程逐步填充缓存,避免直接并发打爆 DB。
3.5 运维/治理措施
- 不要在高峰期做批量清除缓存或重启缓存集群。
- 对关键业务增加多可用区(AZ)架构,缓存使用持久化(RDB/AOF)与高可用方案(主从 + 哨兵 / 集群)。
- 定期演练“缓存不可用”的降级方案(chaos engineering 风格)。
4. 缓存与数据一致性(补充重要问题)
虽然用户只问三大问题,但在实际工程中缓存一致性是与三大问题并列的常见挑战,也要重视。
4.1 场景(常见的 race)
2 个并发请求同时更新 DB 和缓存,错误的更新顺序会导致旧数据回写到缓存(脏数据):
- 流程 1(错误):客户端 A 写 DB(成功),删除缓存;客户端 B 读取旧缓存(未命中),读 DB,得到旧值并写回缓存 —— 最终缓存为旧值。
- 常见操作顺序问题:
delete cache
与write DB
的并发竞争。
4.2 常见解决策略
A. Cache Aside(旁路缓存) + DB 更新后删除缓存
- 推荐顺序:先更新 DB, 再删除缓存。但仍可能出现并发时序问题(上例)。
- 改良:写完成后,异步延迟删除或使用消息队列保证删除操作可见;或在写完 DB 后同时发布一条 invalidation 消息(使用消息队列或 Redis pub/sub)。
B. 使用分布式锁
- 在写操作时对该 key 加锁,序列化读写(牺牲并发)。
C. 写入缓存(Write-Through / Write-Behind)
- Write-Through:写缓存同时写 DB(同步),读来自缓存。优点:一致性好;缺点:写延迟高、DB/缓存耦合强。
- Write-Behind(Write-Back):写入缓存后异步刷盘到 DB(性能好但有丢失风险,复杂)。
D. 乐观并发控制(版本号 / 时间戳)
- 每条记录带版本号或 timestamp,读取时比较版本,写入时检查版本,避免旧值覆盖新值。
E. 事件驱动的缓存失效
- 所有写事件都会通过 MQ 广播,订阅方负责使对应缓存失效或更新。
4.3 详细顺序建议(工程化)
- 对于强一致要求:使用事务 + 分布式锁或 write-through。
- 对于可弱一致场景:使用 cache-aside + publish/subscribe invalidation + 短 TTL。
- 使用消息队列(可靠投递)来做缓存失效通知,能显著降低并发 race 的概率。
5. 常用实现细节与代码片段
5.1 Redis 获取分布式锁(安全释放示例)
# 获取锁(原子)
SET lock_key token NX PX 30000# 释放锁(Lua 脚本,防止误删别人锁)
local script = [[
if redis.call("GET", KEYS[1]) == ARGV[1] thenreturn redis.call("DEL", KEYS[1])
elsereturn 0
end]]
redis.eval(script, 1, "lock_key", token)
5.2 双重检查 + 锁(伪码)
value = cache.get(key)
if value is not None:return value# 未命中,尝试获取分布式锁
if acquire_lock(lock_key):try:# 再次检查(双重检查)value = cache.get(key)if value is not None:return valuevalue = db.load(key)if value is None:cache.set(key, "__NULL__", ex=short_ttl)return Nonecache.set(key, value, ex=ttl_with_jitter)return valuefinally:release_lock(lock_key)
else:# 没拿到锁,短轮询或快速返回降级值time.sleep(0.05)return cache.get(key) or fallback_value
5.3 TTL 随机化设置示例
import random
base_ttl = 3600 # 1 hour
jitter = 600 # up to 10 minutes
ttl = base_ttl + random.randint(0, jitter)
redis.set(key, value, ex=ttl)
5.4 布隆过滤器伪代码
# 服务启动时从 DB 或同步服务初始化布隆过滤器
bloom.add(all_valid_ids)# 请求处理
if not bloom.mightContain(id):return 404
# else -> 查缓存 -> 查 DB -> 填充缓存
6. 生产实践建议与权衡
6.1 架构与策略选择
- 读多写少:常用 Cache Aside(懒加载)+ 负缓存 + TTL 随机化 + 热点策略。
- 强一致性:采用写穿或写回并结合分布式锁或版本控制。
- 高并发热点:使用单飞合并/本地 L1 缓存 + 永不过期 + 后台刷新。
- 防攻击/穿透:布隆过滤器 + 参数校验 + 接口限流。
6.2 配置与参数建议
- 负缓存 TTL:短(1min ~ 5min)以防空值污染。
- 热点数据 TTL:设置较长或不失效,并异步刷新。
- 锁 TTL:略大于数据加载最大耗时,避免死锁;使用安全释放确保不会误删锁。
- 缓存容量:监控
maxmemory
,设置合理的 eviction 策略(如volatile-lru
/allkeys-lru
)根据访问模式选择。
6.3 监控与告警(必须)
- 缓存命中率(hit ratio)— 低命中率意味着缓存策略问题或穿透。
- Top hot keys — 发现单点热点。
- 缓存 miss 突增 / DB QPS 突增 — 可能是击穿或雪崩。
- Redis 内存 / evictions / keyspace misses。
- 锁等待时间 / 重建耗时 / 后端响应时间。
6.4 测试与演练
- 做压力测试(带有热点、穿透、故障情况)。
- 演练 Redis 宕机、主从切换、批量过期等故障场景(Chaos)。
- 验证降级逻辑(当缓存不可用或 DB 限流时的用户体验)。
7. 常见反模式(避免)
- 把所有数据都设置同一 TTL(易雪崩)。
- 把负缓存 TTL 设太长(易污染缓存)。
- 不做参数校验就直接用缓存/DB(易被恶意扫描)。
- 锁实现不安全(不使用 token 验证就删除锁,可能删到别人的锁)。
- 写操作只删除缓存不做重试/补偿(易出现脏数据)。
8. 最佳实践清单(简短)
- 对外接口做参数校验与限流。
- 使用布隆过滤器或负缓存防穿透。
- 对热点 key 使用单独策略(长期 TTL + 后台刷新 / local cache)。
- 随机化 TTL,避免同时过期。
- 在缓存失效期间采用降级与限流保护后端。
- 使用安全的分布式锁和单飞合并来避免并发重建。
- 对写操作采用事件驱动的缓存失效或版本号控制来保证一致性。
- 完善监控与故障演练。
总结
缓存可以大幅降低后端压力与提升响应,但如果策略不慎,会带来穿透、击穿、雪崩等风险。生产环境中要把参数校验、数据结构(布隆)、锁与单飞、TTL 随机化、本地多级缓存、消息驱动的失效机制、监控与演练等结合起来,制定针对不同数据(热点/冷数据/写热点)的差异化策略,才能既保证性能又保证系统稳定性与数据一致性。