缓存问题与方案
开篇:缓存用得好是加速器,用不好是定时炸弹
缓存就像你家门口的便利店——大部分日常需求在这里就能搞定,不用跑去市中心的大超市(数据库)。但便利店如果管理不好,缺货、过期、标错价,反而比没有还麻烦。
实际项目中,缓存带来的问题往往不是"用不用"的问题,而是"用了之后怎么兜底"的问题。这篇文章带你逐一拆解缓存体系中最常见的几颗"定时炸弹":穿透、雪崩、击穿、数据一致性,以及大 Key 和热 Key 问题。每个问题都配有真实场景和可直接落地的方案。
先用一张图鸟瞰全局:
一、缓存穿透:查不存在的数据
1.1 问题场景
想象一下:有人恶意用一个根本不存在的商品 ID(比如 -1)疯狂请求你的接口。缓存里没有,数据库里也没有,每次请求都会穿透缓存直达数据库。
用户请求(id=-1) → 缓存:没有 → 数据库:也没有 → 返回空
用户请求(id=-1) → 缓存:没有 → 数据库:也没有 → 返回空
... 一万次 ...好比口红门店来了一群人,反复追问一个品牌根本没生产过的色号"五彩斑斓的黑"。店员每次都得打电话问总部,总部说没有,然后下一个人又来问。两边都在做无用功。
这就是缓存穿透——请求的数据在缓存和数据库中都不存在,缓存形同虚设,数据库白白扛了所有压力。
怎么记住"穿透"这个词?
穿透的"透"字是关键——不仅缓存被穿过了,数据库也被穿过了,整个链路从头透到尾,谁也没拦住。
1.2 解决方案
方案一:缓存空值
思路很直接——查不到就缓存一个 null,下次再来直接返回"没有"。就像店员第一次问完总部之后,在本子上记一笔"这个色号没货",下次有人问直接告知。
public String getProduct(String id) {
String value = redis.get("product:" + id);
if (value != null) {
return "null".equals(value) ? null : value;
}
String dbValue = db.queryProduct(id);
if (dbValue == null) {
// 缓存空值,设置较短的过期时间
redis.setex("product:" + id, 60, "null");
return null;
}
redis.setex("product:" + id, 3600, dbValue);
return dbValue;
}关键细节
空值缓存的 TTL 要设短一些(比如 60 秒),否则数据库后来有了这条数据,用户还是拿不到。就像店员要定期擦掉"没货"的记录,万一总部补货了呢。
方案二:布隆过滤器
如果恶意请求用的是大量随机 Key(每次 ID 都不一样),空值缓存就扛不住了——内存会被大量无用的 null 占满。
布隆过滤器(Bloom Filter)就像门口的安检:它是一种概率性数据结构,能快速告诉你"这个 ID 一定不存在"或者"可能存在"。一定不存在的直接拦截,可能存在的才放行去查缓存和数据库。相比 Map、Set 等传统数据结构,布隆过滤器占用内存极少。
实际使用中,需要在服务启动时把所有合法 Key 的哈希值加载到布隆过滤器中。当数据库新增数据时,也需要同步更新布隆过滤器。
方案三:参数校验
最朴素也最有效的一招——在入口处做参数校验,ID 小于 0 的、格式不对的、长度超标的直接拒绝,根本不让它碰缓存和数据库。这一条经常被忽略,但它是成本最低的第一道防线。
三种方案的对比:
| 方案 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 恶意请求的 Key 比较固定 | 实现简单 | 大量随机 Key 时内存浪费 |
| 布隆过滤器 | 海量随机 Key 的恶意请求 | 内存占用极小 | 存在误判、需要维护 |
| 参数校验 | 所有场景 | 零成本 | 只能挡住格式明显错误的 |
二、缓存雪崩:大量 key 同时过期
2.1 问题场景
双十一零点,你在预热阶段把所有热门商品一口气加载到缓存里,TTL 统一设了 2 小时。零点两点一到——所有缓存同时过期,瞬间数以万计的请求直扑数据库。数据库:"我扛不住了。"
还有一种情况更暴力:Redis 服务器本身宕机了,所有缓存全部消失,所有请求涌向数据库。
这就是缓存雪崩——大量 Key 在同一时间集体失效,或者缓存服务本身宕机,请求洪流直接冲垮数据库。用一句话概括:雪崩的时候没有一个 Key 是无辜的。
2.2 解决方案
方案一:随机 TTL,打散过期时间
给每个 Key 的过期时间加一个随机偏移量,让它们像错峰上下班一样,分散失效:
int baseTTL = 7200; // 基础 2 小时
int randomOffset = new Random().nextInt(600); // 随机加 0~600 秒
redis.setex(key, baseTTL + randomOffset, value);这一招简单有效,是防止雪崩的最基本手段。
方案二:永不过期 + 异步刷新
对于核心热点数据,不设 Redis 层面的 TTL,由后台定时任务负责定期查询数据库并刷新缓存。缓存永远有值,数据库永远不会被突然打穿。
代价是数据有一定的延迟(取决于定时任务的频率),但对于商品信息、配置数据等变化不频繁的场景完全够用。
方案三:集群部署
针对"Redis 宕机导致雪崩"的情况,核心手段就是集群化部署。使用 Redis Sentinel 或 Redis Cluster,即使一个节点挂了,其他节点还能继续提供服务,避免缓存层的单点故障。
三、缓存击穿:热点 key 失效
3.1 问题场景
某明星突然官宣结婚,全网涌入微博搜索。相关的热点 Key 恰好在这一刻过期了——瞬间上百万请求同时发现缓存没了,一起去查数据库。一个 Key 引发的"踩踏事故"。
缓存击穿和雪崩的区别:雪崩是"一群 Key 一起过期",击穿是"一个超级热点 Key 过期"。雪崩是群体事故,击穿是精准爆破。
3.2 解决方案
方案一:互斥锁(Mutex)
第一个发现缓存为空的线程去加锁查数据库,其他线程排队等待(或短暂休眠后重试)。查到数据后写入缓存,后续请求直接命中。
public String getHotData(String key) {
String value = redis.get(key);
if (value != null) return value;
// 尝试获取分布式锁,10秒超时
if (redis.setnx("lock:" + key, "1", 10)) {
try {
// 双重检查,可能等待期间其他线程已回填
value = redis.get(key);
if (value != null) return value;
value = db.query(key);
redis.setex(key, 3600, value);
} finally {
redis.del("lock:" + key);
}
} else {
// 没拿到锁,短暂等待后重试
Thread.sleep(50);
return getHotData(key);
}
return value;
}优点:保证只有一个线程查库,数据库压力可控。缺点:其他线程需要等待,响应时间会增加。
方案二:逻辑过期
不给 Key 设 Redis 层面的 TTL(让它永不过期),而是在 Value 中存一个逻辑过期时间。读的时候发现过期了,开一个异步线程去更新数据,当前请求先返回旧数据。
// Value 中存储的结构
{ "data": "真实数据", "expireTime": 1700000000 }优点:用户体验极好,不需要等待,每次都能拿到数据。缺点:在异步线程更新完成之前,用户拿到的是旧数据,存在短暂的数据不一致。
两种方案怎么选? 追求数据强一致用互斥锁,追求高可用和极致用户体验用逻辑过期。
四、数据一致性:缓存和数据库怎么保持同步?
这是缓存领域最经典、争论最多的问题。只要用了缓存,就绑不开"先更新谁、后更新谁"的灵魂拷问。
4.1 为什么删除缓存而不是更新缓存?
先说结论:优先选择删除缓存,而不是更新缓存。
原因有两个:
第一,操作更简单。 缓存中的值可能是一个大 JSON,更新它需要反序列化 → 修改字段 → 序列化 → 写回,一通操作还容易出错。而删除只需要一条 DEL 命令。
第二,并发安全性更好。 看下面这个写写并发的场景——两个线程同时更新缓存:
| 线程 A | 线程 B |
|---|---|
| 写数据库,更新成 20 | |
| 写数据库,更新成 10 | |
| 更新缓存为 10 | |
| 更新缓存为 20 |
最终数据库里是 10,缓存里是 20——不一致了。
而如果都是删除缓存,不管谁先谁后,缓存都会被清掉,下次读的时候从数据库加载最新值,就不会出现这个问题。
删除缓存唯一的小代价是多一次 cache miss——删了之后下一次查询要去数据库取。但这比数据错误要好得多。需要注意的是,这次 cache miss 有可能导致缓存击穿(刚好是热点 Key),但加个互斥锁就能解决。
4.2 Cache Aside Pattern(先更新 DB 再删缓存)
这是 Facebook 推崇的经典模式,也是业界最常用的方案。核心就两步:
为什么不先删缓存再更新库? 因为先删缓存会放大"读写并发"的问题:
| 写线程 | 读线程 |
|---|---|
| 删除缓存 | |
| 读缓存,缓存中没有值 | |
| 读数据库,得到旧值 10 | |
| 更新数据库,更新成 20 | |
| 写缓存,写入旧值 10(不一致!) |
你刚删了缓存,一个读线程发现缓存空了,去数据库读到旧值写回缓存,然后你的写操作才更新数据库。结果缓存里永远是旧值,而且在下次缓存过期之前,所有命中缓存的查询结果都是错的。
而先更新库再删缓存,读写并发问题虽然理论上也存在,但发生概率极低——因为读操作通常几十毫秒就完成了,在这个窗口期内恰好发生写操作的概率很小。
适合场景:95% 的业务场景,尤其是并发量不大或者对一致性要求不极致的情况。
4.3 延时双删
在高并发场景下,需要更强的保障。延时双删就是在"先删缓存再写库"的基础上,多做一步:
第一次删除的意义:先删缓存再写库,即使写库失败也不会有脏数据(缓存空了就是空了,不是错误数据)。相比"先写库后删缓存",如果删缓存失败了,数据库是新值、缓存是旧值,那就真的不一致了。而且相对于缓存和数据库来说,数据库失败的概率更大一些。
第二次删除的意义:在写库期间,可能有读线程把旧数据重新写进了缓存(就是上面分析的读写并发问题)。延迟一小段时间后再删一次,把这个脏数据清掉。
public void updateProduct(Product product) {
// 第一次删除缓存
redis.del("product:" + product.getId());
// 更新数据库
db.update(product);
// 延迟 2 秒后第二次删除
scheduler.schedule(
() -> redis.del("product:" + product.getId()),
2, TimeUnit.SECONDS
);
}延迟多久合适?一般建议 1~2 秒,要大于一次读操作的耗时(通常几十毫秒),这样才能确保在"读写并发"窗口之后再做清理。
有了第二次删除,第一次还有意义吗?
有。如果去掉第一次,只靠第二次删除,那方案就变成了"先更新数据库,再延迟删除缓存"。一旦第二次删除失败,就直接不一致了,连兜底都没有。两次删除是"概率叠加降低风险"的思路——第一次解决非并发场景下的一致性,第二次解决并发场景下的一致性。
4.4 Canal + 异步更新
大厂更常用的方案——代码主逻辑里只管写数据库,缓存的清理交给旁路异步完成:
Canal 模拟 MySQL Slave,监听数据库的 binlog 变更。检测到数据变化后发消息到 MQ,消费者收到消息后删除对应的缓存 Key。
好处:对业务代码零侵入,不需要在业务代码里操作缓存;基于 binlog 的监听相对可靠,可以不断重试直到删除成功。
缺点:需要引入 Canal 和 MQ 组件,架构复杂度增加。适合有完善中间件支持的团队。
为什么 Canal 方案可以不做延迟双删?
因为 Canal 的 binlog 监听本身是相对可靠的,监听到 binlog 之后可以不断重试进行删除。而通过写代码去删除缓存,重试机制并不一定可靠。当然,如果要更加完美,可以再加上第一次删除:先删缓存 → 更新数据库 → Canal 监听 binlog 再删除缓存。
4.5 强一致 vs 最终一致的选择
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 先更新 DB 后删缓存 | 简单 | 删缓存失败会导致不一致 | 95% 一般场景 |
| 延迟双删 | 一致性保障更好 | 延迟时间不好控制 | 高并发、一致性要求高 |
| Canal + 异步 | 解耦、可靠性高 | 复杂,需引入新组件 | 大厂、完善中间件支持 |
除了这三种最常见的方案,业界还有一些其他的缓存更新设计模式:
- Read/Write Through:应用不直接操作数据库,缓存层负责读写数据库。读未命中时缓存自动从数据库加载;写入时缓存先存储数据,再同步写入数据库。对应用来说更简单,但缓存层需要支持这种模式。
- Write Behind Caching:写入时只更新缓存,不更新数据库,然后异步定时把缓存数据持久化到数据库。读写速度极快,但可能丢数据。适合统计浏览量、点赞数等可以容忍少量数据丢失的场景。
没有银弹
《人月神话》的作者 Fred Brooks 说过:没有万能的银弹。缓存一致性也是如此——没有"完美"的方案,只有"适合"的方案。要权衡的事情很多:业务的具体情况、实现复杂度、团队能力、可维护性。选方案的过程本身,就是工程师的价值所在。
五、大 Key 与热 Key 问题
大 Key:一个 Key 吃掉你的内存
什么算大? 经验参考值:
| 类型 | 阈值 |
|---|---|
| String | 值超过 5MB |
| Hash | 成员数 > 1000 或总 Value > 100MB |
| List / Set / ZSet | 成员数 > 10000 |
这些不是绝对的限制,而是经验值,具体情况需要根据应用场景调整。
危害:
- 性能下降:读写大 Key 速度慢,阻塞主线程。
- 内存不均:集群中某个节点被一个大 Key 撑爆,其他节点却很空闲。
- 主从同步卡顿:大 Key 的同步耗时长,影响复制延迟。
- 过期删除阻塞:Redis 在删除过期大 Key 时会阻塞主线程,导致其他请求延迟。
- 迁移困难:大对象的迁移和复制压力大,容易破坏缓存一致性。
排查:redis-cli --bigkeys 可以扫描出各类型中最大的 Key。
解决方案:
- 业务层拆分:按日期、用户尾号等维度把大 Key 拆成多个小 Key。
- 合理设置 TTL:避免缓存不断膨胀、持续增长。
- 异步删除:Redis 4.0+ 的
UNLINK命令在后台线程中删除大 Key,不阻塞主线程。 - 使用 Cluster 分散存储:让大 Key 的子 Key 分散到不同节点上。
热 Key:一个 Key 扛不住所有流量
什么算热? 比如 Redis 集群总 QPS 10000,其中一个 Key 独占 7000,那它就是热 Key。又比如一个 1000 成员的 HASH Key 被高频 HGETALL,带宽直接被打满。
识别方式:
- 经验预测:大促秒杀活动开始前就能预判哪些商品是热点。局限性是突发热点(如明星官宣)无法预测。
- 实时收集:在客户端、代理层或服务端统计 Key 的访问频次。京东开源的 hotkey 框架就是做这个事的——单位时间内访问超过设定阈值就判定为热 Key。
- 官方工具:
redis-cli --hotkeys(Redis 4.0.3+)。
解决方案:
1. 多级缓存
在 Redis 前面加一层本地缓存(如 Caffeine),热点数据就近读取。用户请求先查本地缓存,命中就直接返回,不命中再查 Redis。这样大部分流量在本地就消化了,Redis 的压力大大降低。
2. 热 Key 备份
把同一份数据复制到多个 Redis 节点上,请求根据负载均衡策略分散到不同节点,一个节点扛不住就多来几个。
3. 热 Key 拆分
把 淄博烧烤 拆成 淄博烧烤_0001、淄博烧烤_0002、淄博烧烤_0003,存到 Cluster 的不同节点上。用户请求时根据用户 ID 哈希到不同的子 Key。
这个方案的前提是:不一定每个用户都需要完整数据。比如抖音的热点视频,可以把同一话题下的视频分散存储,不同用户看到不同的推荐内容。等热度下降后再做数据汇总。
4. 限流
在缓存层或网关层对热 Key 做限流,超过阈值的请求直接返回降级数据或排队等待。这是最后一道防线。
六、常见面试题精选
Q1:穿透、击穿、雪崩怎么区分和记忆?
一句话口诀:雪崩是"一群 Key 一起挂",击穿是"一个热 Key 挂了",穿透是"Key 压根不存在"。从集体到个体到虚无。穿透的"透"字暗示连数据库也被穿透了——缓存和数据库里都没有。
Q2:为什么删缓存而不是更新缓存?
两个原因:(1)更新操作在写写并发下容易出现"最后更新的不是最新值"的问题,而删除操作无论谁先谁后,缓存都会被清空,下次读取时自动从数据库加载最新数据。(2)删除比更新的操作更简单,不需要反序列化和重新计算。
Q3:延迟双删中,有了第二次删除,第一次还有意义吗?
有。第一次删除是为了降低"写库成功但删缓存失败"的不一致概率。如果去掉第一次只靠第二次删除,一旦第二次也失败,连兜底都没有了。两次删除是"概率叠加降低风险"的思路——各自解决不同场景下的问题。
Q4:Cache Aside Pattern 为什么不先删缓存再更新库?
先删缓存会放大"读写并发"问题:你刚删了缓存,另一个读线程发现缓存空了,去数据库读到旧值写回缓存,然后你的写操作才更新数据库——缓存里就一直是旧值了。而先写库再删缓存,读写并发窗口极小(读操作通常几十毫秒),问题发生概率低得多。
Q5:大 Key 和热 Key 的区别是什么?
大 Key 是"体积大"(单个 Value 占用内存多),热 Key 是"访问多"(QPS 集中在一个 Key 上)。一个关注存储,一个关注流量。它们可以同时出现在同一个 Key 上——那就是最危险的情况,既占内存又扛流量。
Q6:数据库和缓存的一致性能做到强一致吗?
几乎不能。缓存操作和数据库操作不在同一个事务中,无法保证原子性。所有方案追求的都是"最终一致性"——在一个可接受的时间窗口内,让缓存和数据库的数据趋于一致。如果业务真的要求强一致,最好的方案可能是:不用缓存。
小结
| 问题 | 本质 | 核心方案 |
|---|---|---|
| 缓存穿透 | 数据不存在 | 布隆过滤器 + 空值缓存 + 参数校验 |
| 缓存雪崩 | 大量 Key 同时过期 | 随机 TTL + 永不过期 + 集群 |
| 缓存击穿 | 热点 Key 过期 | 互斥锁 / 逻辑过期 |
| 数据一致性 | 缓存和 DB 不同步 | Cache Aside + 延迟双删 / Canal |
| 大 Key | 单 Key 内存过大 | 拆分 + 异步删除 |
| 热 Key | 单 Key 流量过大 | 多级缓存 + Key 拆分 + 限流 |
记住一个原则:缓存的本质是用空间换时间、用一致性换性能。没有完美方案,只有适合你业务场景的方案。选择的过程本身,就是工程师的价值所在。