缓存不是越多越快:理解缓存穿透、击穿、雪崩与数据一致性
为了提高系统性能,开发者经常会在数据库前增加缓存。
原本一次查询需要访问数据库:
应用程序 → 数据库
增加缓存后,请求优先读取内存中的数据:
应用程序 → 缓存 → 数据库
如果缓存命中,请求无需访问数据库,响应时间通常会明显缩短。与此同时,数据库需要处理的查询数量也会减少。
但缓存并不是简单地“把数据库数据复制一份”。一旦系统中同时存在缓存和数据库,就会出现两个数据副本,由此带来缓存穿透、缓存击穿、缓存雪崩和数据一致性等问题。
缓存提高了性能,也增加了系统复杂度。
一、缓存为什么比数据库快
缓存通常使用内存保存数据,而数据库还需要处理:
- SQL解析;
- 查询计划;
- 索引查找;
- 事务与锁;
- 磁盘读写;
- 数据页管理;
- 网络连接。
读取内存中的键值通常比执行完整数据库查询更快。
缓存特别适合以下数据:
- 读取频繁、修改较少的数据;
- 计算成本较高的结果;
- 短时间内被重复访问的数据;
- 允许短暂不一致的数据;
- 可以从其他数据源重新生成的数据。
例如:
- 商品基本信息;
- 用户公开资料;
- 系统配置;
- 热门文章;
- 榜单数据;
- 查询结果;
- 页面片段。
并不是所有数据都适合缓存。余额、库存扣减结果和权限状态等对一致性要求较高的数据,需要更加谨慎地设计。
二、缓存命中与未命中
读取缓存时可能出现两种基本结果。
缓存命中
缓存中存在目标数据:
读取缓存
→ 找到数据
→ 直接返回
这种情况下,请求不需要访问数据库。
缓存未命中
缓存中不存在目标数据:
读取缓存
→ 未找到
→ 查询数据库
→ 写入缓存
→ 返回结果
缓存未命中并不一定是故障。数据第一次被访问、缓存过期、缓存被淘汰或缓存服务重启,都可能造成未命中。
真正需要关注的是命中率是否符合预期,以及未命中后是否会对数据库造成过大压力。
三、最常见的旁路缓存模式
旁路缓存是一种常见设计。
读取数据时:
先查缓存
→ 命中则返回
→ 未命中则查询数据库
→ 将结果写入缓存
→ 返回结果
伪代码如下:
def get_product(product_id):
value = cache.get(product_id)
if value is not None:
return value
value = database.query(product_id)
if value is not None:
cache.set(product_id, value, ttl=600)
return value
写入数据时,应用程序直接修改数据库,再处理缓存:
更新数据库
→ 删除缓存
这种模式的优点是逻辑相对清晰,缓存失败时仍然可以访问数据库。
但“数据库和缓存如何保持一致”会成为整个设计中最重要的问题。
四、为什么通常删除缓存,而不是更新缓存
假设商品价格发生变化,有两种处理方式:
方案一:更新数据库后更新缓存
更新数据库
→ 更新缓存
方案二:更新数据库后删除缓存
更新数据库
→ 删除缓存
删除缓存通常更常见,原因包括:
- 更新数据库和更新缓存之间可能发生并发竞争;
- 缓存中的数据可能由多个数据库字段计算得到;
- 同一数据可能存在多个缓存视图;
- 某些数据修改后不一定会立即被读取;
- 更新缓存可能做了不必要的计算;
- 删除后可以让下一次读取重新构建最新数据。
删除缓存并不能消除所有一致性问题,但通常比主动更新多个缓存副本更加简单。
五、先删缓存还是先更新数据库
先删除缓存,再更新数据库
执行顺序:
删除缓存
→ 更新数据库
可能发生以下并发过程:
请求A删除缓存
请求B发现缓存不存在
请求B读取数据库旧值
请求A更新数据库新值
请求B把旧值写回缓存
最终数据库中是新值,缓存中却是旧值,而且旧值可能一直保留到缓存过期。
先更新数据库,再删除缓存
执行顺序:
更新数据库
→ 删除缓存
这种方式通常更合理。
正常情况下,数据库更新成功后删除旧缓存。下一次读取发现缓存不存在,再从数据库加载新值。
不过,它仍然存在失败窗口:
数据库更新成功
→ 删除缓存失败
此时缓存中仍保留旧值。
因此,实际系统还需要为缓存删除失败设计重试、消息补偿或数据变更订阅机制。
六、为什么不能保证绝对一致
只要数据库与缓存是两个独立系统,就无法用简单的两条命令保证它们始终同时成功。
例如:
更新数据库成功
删除缓存失败
或者:
数据库事务尚未提交
缓存已经被修改
数据库随后回滚
如果业务要求强一致,就不能把普通缓存值当作唯一判断依据。
常见处理方式包括:
- 关键操作直接读取数据库;
- 使用数据库事务保证核心状态;
- 缓存只用于展示和查询加速;
- 为数据增加版本号;
- 使用消息队列重试缓存失效操作;
- 订阅数据库变更并异步刷新缓存;
- 对写入后的读取暂时绕过缓存;
- 对极高一致性数据不使用通用缓存。
缓存一致性通常是工程上的权衡,而不是一句“设置过期时间”就能完全解决的问题。
七、缓存过期时间解决不了什么
为缓存设置过期时间非常重要:
key在10分钟后自动失效
它可以限制旧数据存在的最长时间,也能自动清理长期不再访问的数据。
但过期时间并不能保证实时一致性。
如果数据库刚刚更新,而缓存还剩9分钟才过期,用户就可能继续读取9分钟旧数据。
因此,过期时间更像是一道最终保险:
即使主动失效机制失败,旧缓存也不会永久存在。
它不能代替正确的写入、删除和补偿流程。
八、什么是缓存穿透
缓存穿透是指请求查询的数据在缓存和数据库中都不存在。
正常未命中过程是:
缓存不存在
→ 查询数据库
→ 数据库也不存在
→ 返回空结果
如果有人不断请求不存在的编号,每次请求都会绕过缓存访问数据库:
请求不存在的数据
→ 缓存未命中
→ 数据库查询
→ 返回不存在
→ 下一次请求再次查询数据库
由于系统通常不会缓存“不存在”,相同无效请求可以持续给数据库施加压力。
解决方法一:参数校验
在进入缓存和数据库之前,先拒绝明显不合法的参数,例如:
- 编号格式错误;
- 编号超出合理范围;
- 必填字段为空;
- 请求不符合业务规则。
解决方法二:缓存空值
数据库确认数据不存在后,可以在缓存中保存一个短时间空结果:
product:999999 → NOT_FOUND
后续相同请求可以直接返回不存在,不必再次访问数据库。
空值缓存的过期时间通常不宜过长,因为数据以后可能被创建。
解决方法三:布隆过滤器
布隆过滤器可以快速判断某个元素是否“可能存在”。
它具有以下特点:
- 判断为不存在时,该元素一定不在集合中;
- 判断为可能存在时,该元素不一定真的存在;
- 占用空间相对较小;
- 可能产生假阳性,但通常不会产生假阴性。
请求到来后,可以先检查布隆过滤器:
确认不存在
→ 直接返回
可能存在
→ 继续查询缓存或数据库
布隆过滤器适合数据集合相对明确、需要拦截大量无效查询的场景。
九、什么是缓存击穿
缓存击穿通常发生在某个访问量很高的热点数据突然过期时。
假设一个热门商品每秒收到数万个查询。正常情况下,这些请求都由缓存处理。
当缓存刚好过期,大量请求同时发现缓存不存在,于是一起访问数据库:
热点缓存过期
→ 大量请求同时未命中
→ 大量请求同时查询数据库
→ 数据库压力骤增
这类现象也常被称为缓存重建风暴。
解决方法一:互斥重建
缓存未命中后,只有一个请求获得重建资格:
请求A获得锁
→ 查询数据库
→ 写入缓存
→ 释放锁
其他请求可以短暂等待、返回旧值或稍后重试。
需要注意:
- 锁必须设置超时时间;
- 重建失败时必须正确释放;
- 等待时间不能无限增长;
- 获得锁后应再次检查缓存;
- 不应把锁范围扩大到无关操作。
解决方法二:逻辑过期
缓存数据本身不立即删除,而是在内容中记录逻辑过期时间:
{
"expire_at": 1790000000,
"data": {
"name": "热门商品",
"price": 299
}
}
读取时如果发现逻辑过期,可以暂时返回旧数据,同时由一个后台任务刷新缓存。
这种方式牺牲短时间的数据新鲜度,换取热点接口的可用性和稳定性。
解决方法三:热点数据不过期
对极少数核心热点数据,可以不设置普通自动过期,而是通过数据变更事件主动更新或删除缓存。
不过,不设置过期时间意味着必须拥有可靠的主动失效机制,否则旧数据可能长期存在。
十、什么是缓存雪崩
缓存雪崩是指大量缓存数据在同一时间失效,导致请求集中访问数据库。
例如,应用启动时批量写入缓存,并统一设置30分钟过期:
10:00写入大量缓存
10:30大量缓存同时过期
10:30大量请求同时访问数据库
缓存服务整体故障也可能产生类似效果:原本由缓存承担的请求突然全部进入数据库。
解决方法一:过期时间加入随机值
不要让大量数据具有完全相同的过期时间。
例如:
基础过期时间:30分钟
随机偏移:0到5分钟
不同缓存会在不同时间失效,从而分散数据库压力。
解决方法二:多级缓存
可以使用:
进程内缓存
→ 分布式缓存
→ 数据库
当分布式缓存出现短暂问题时,进程内缓存仍能承担部分热点请求。
但多级缓存意味着更多数据副本,一致性处理也会更加复杂。
解决方法三:限流与降级
数据库容量有限时,不能允许所有失败请求无限进入数据库。
系统可以:
- 限制请求速率;
- 返回系统繁忙;
- 暂时使用旧数据;
- 关闭非核心功能;
- 对不同业务设置优先级;
- 对热点接口单独保护。
解决方法四:缓存预热
服务上线前,提前加载高频数据,避免大量请求在启动后同时触发缓存重建。
缓存预热应该只加载真正的热点数据,而不是把整个数据库机械地复制到缓存中。
十一、穿透、击穿和雪崩如何区分
| 问题 | 核心原因 | 典型表现 |
|---|---|---|
| 缓存穿透 | 查询的数据根本不存在 | 同一类无效请求持续访问数据库 |
| 缓存击穿 | 单个热点数据过期 | 某个热点键失效后并发请求激增 |
| 缓存雪崩 | 大量数据同时失效或缓存整体不可用 | 大范围请求集中进入数据库 |
可以简单记忆:
穿透:查不到
击穿:热点破了
雪崩:大片一起失效
十二、缓存污染是什么
缓存空间有限,需要不断淘汰旧数据。如果大量只访问一次的数据进入缓存,就可能把真正高频的数据挤出去。
例如,一个爬虫连续请求数百万个冷门商品,每个商品只访问一次。如果系统把所有结果都写入缓存,热点商品反而可能被淘汰。
这就是缓存污染。
可以通过以下方式降低影响:
- 只缓存访问频率达到阈值的数据;
- 对不同业务使用不同缓存空间;
- 使用合适的淘汰策略;
- 限制单类数据的缓存容量;
- 对批量扫描请求绕过缓存;
- 监控命中率和淘汰率。
缓存容量大并不代表所有数据都值得缓存。
十三、热点Key是什么
热点Key是指访问频率远高于其他键的数据。
例如:
- 热门商品;
- 热搜榜;
- 全局配置;
- 爆款活动;
- 大量用户共同访问的页面数据。
热点Key可能带来:
- 单个缓存节点负载过高;
- 网络带宽集中;
- 缓存过期时发生击穿;
- 大对象传输占用大量资源;
- 单点故障影响大量请求。
处理方式包括:
- 在应用进程内增加短时缓存;
- 复制热点数据到多个键或节点;
- 使用逻辑过期;
- 限制单请求返回的数据量;
- 将大对象拆分;
- 提前识别和预热热点;
- 对热点请求单独限流。
十四、大Key为什么危险
大Key是指单个缓存键保存了过多数据,例如:
- 一个键保存数十MB字符串;
- 一个列表包含数百万元素;
- 一个集合长期只增加不删除;
- 一个哈希结构包含大量字段。
大Key可能导致:
- 单次读取耗时过长;
- 网络带宽瞬间被占满;
- 删除操作阻塞;
- 数据迁移困难;
- 内存分布不均;
- 备份和恢复变慢。
缓存设计时应避免将大量数据集中到一个键中。可以按照时间、业务编号或数据范围进行拆分。
十五、缓存淘汰和缓存过期不是一回事
缓存过期是应用为数据设置的生命周期:
到达指定时间后,数据失效
缓存淘汰则是缓存空间不足时,系统根据策略主动删除部分数据:
内存不足
→ 选择部分键删除
→ 为新数据腾出空间
即使某个缓存还没有到期,也可能因为内存压力被提前淘汰。
因此,程序不能假设“设置了1小时过期,就一定能保存1小时”。任何时候读取缓存都必须能够处理未命中情况。
十六、缓存重建为什么需要防止并发覆盖
假设两个请求同时重建缓存:
请求A读取数据库旧版本
请求B读取数据库新版本
请求B先写入新值
请求A后写入旧值
最终旧值反而覆盖了新值。
可以使用数据版本或时间戳避免旧数据覆盖新数据:
{
"version": 18,
"data": {
"price": 299
}
}
写入缓存前比较版本,只允许更高版本覆盖较低版本。
对于并发更新频繁的数据,单纯依赖“最后写入者获胜”可能并不可靠。
十七、缓存删除失败应该怎么办
更新数据库后删除缓存失败,是常见的一致性风险。
可以采用以下补偿方式。
重试
删除失败后进行有限次数重试,并设置退避间隔。
重试任务必须具备幂等性。重复删除同一个缓存键通常没有问题。
消息队列
数据库更新成功后发送缓存失效消息,由消费者删除缓存。
如果消费失败,消息系统可以重新投递。
但需要处理:
- 消息发送失败;
- 消息重复;
- 消息顺序;
- 消费延迟;
- 队列积压。
订阅数据变更
通过数据库变更日志捕获更新事件,再异步删除或刷新对应缓存。
这种方式可以减少业务代码与缓存失效逻辑的耦合,但系统复杂度和延迟也会增加。
短过期时间兜底
即使主动删除最终失败,较短的过期时间也能限制旧数据存在的最长时间。
十八、延迟双删能解决所有问题吗
所谓延迟双删,通常指:
删除缓存
→ 更新数据库
→ 等待一段时间
→ 再次删除缓存
第二次删除试图清理并发读取过程中被写回的旧数据。
这种方法在部分场景中能够降低不一致概率,但它并不是绝对保证,因为:
- 延迟时间很难准确选择;
- 数据库复制延迟可能不稳定;
- 请求执行时间可能超过预期;
- 第二次删除也可能失败;
- 应用重启可能导致延迟任务丢失;
- 高频写入可能使事件顺序更加复杂。
它可以作为特定场景中的工程补偿,但不应被当作通用的一致性证明。
十九、缓存更新失败是否应该让业务失败
这取决于缓存的角色。
缓存只是性能优化
如果数据库更新成功,但缓存删除失败,可以让核心业务成功,然后异步重试缓存失效。
此时数据库是权威数据源,缓存只是可丢失、可重建的副本。
缓存参与核心状态判断
如果系统把库存、锁或会话等关键状态直接放在缓存中,缓存失败可能影响业务正确性。
这种情况下,必须明确:
- 哪个系统是权威数据源;
- 数据丢失后如何恢复;
- 缓存重启后如何重建;
- 多副本之间如何保持一致;
- 故障时应该拒绝请求还是降级运行。
不能一边把缓存称为“可有可无”,一边又让它承担唯一业务状态。
二十、缓存监控应该看什么
仅仅监控缓存服务是否存活远远不够,还应关注:
- 缓存命中率;
- 每秒读取和写入次数;
- 请求延迟;
- 内存使用量;
- 键数量;
- 过期数量;
- 淘汰数量;
- 热点Key;
- 大Key;
- 连接数;
- 网络带宽;
- 错误率;
- 超时次数;
- 缓存重建耗时;
- 回源数据库请求量。
命中率降低不一定意味着缓存本身故障,也可能是:
- 业务访问模式发生变化;
- 新版本修改了键格式;
- 大量缓存同时过期;
- 淘汰策略不合适;
- 冷数据大量进入;
- 缓存容量不足;
- 数据没有正确写入缓存。
应结合数据库负载和业务流量一起判断。
二十一、哪些数据不适合缓存
以下数据需要谨慎使用缓存:
强一致余额
用户看到的余额与实际可用余额必须一致时,应以事务数据库或专门账务系统为准。
实时权限
如果用户权限被撤销后必须立即生效,长时间缓存权限可能造成安全风险。
高频修改数据
数据变化速度远高于读取速度时,缓存频繁失效,收益可能不足以覆盖维护成本。
低复用数据
每条数据只读取一次,缓存几乎不会命中,只会浪费内存。
超大对象
对象过大会增加序列化、网络传输和内存压力。
无法正确失效的数据
如果系统无法判断数据变化后应该删除哪些缓存,就很容易长期返回旧数据。
二十二、一个实用的缓存设计流程
设计缓存时,可以依次回答以下问题。
第一步:为什么要缓存
先明确性能瓶颈:
- 数据库查询慢;
- 计算成本高;
- 下游接口限流;
- 热点读取过多;
- 静态数据重复获取。
如果没有经过测量就增加缓存,可能只是给系统增加复杂度。
第二步:确定权威数据源
必须明确缓存是否可以丢失,以及数据最终以哪里为准。
通常应满足:
缓存丢失
→ 可以从权威数据源重新构建
第三步:确定缓存键
缓存键需要:
- 稳定;
- 唯一;
- 包含必要的业务维度;
- 避免冲突;
- 便于批量失效;
- 不直接包含敏感信息。
第四步:确定过期策略
考虑:
- 数据允许多旧;
- 更新频率;
- 访问热度;
- 重建成本;
- 是否需要随机过期偏移;
- 是否需要逻辑过期。
第五步:确定一致性策略
明确:
- 写入后删除还是更新;
- 删除失败如何补偿;
- 是否允许短暂旧数据;
- 写后读是否绕过缓存;
- 是否需要版本号;
- 哪些请求必须读取数据库。
第六步:设计过载保护
考虑:
- 穿透拦截;
- 热点重建锁;
- 限流;
- 降级;
- 旧数据兜底;
- 有界重试;
- 数据库保护。
第七步:建立监控
上线后持续观察命中率、延迟、回源量、热点Key和缓存内存使用情况。
二十三、缓存设计检查清单
在上线前,可以检查以下问题:
- 缓存是否真的解决了已确认的性能瓶颈;
- 数据的权威来源是否明确;
- 缓存丢失后能否重新构建;
- 是否为数据设置了合理过期时间;
- 过期时间是否存在随机偏移;
- 是否处理不存在数据的重复查询;
- 是否保护热点Key失效后的数据库;
- 是否限制缓存重建并发;
- 是否存在大Key;
- 是否会被低频数据污染;
- 数据更新后如何删除或更新缓存;
- 缓存操作失败后如何补偿;
- 是否允许用户读取短暂旧数据;
- 强一致操作是否绕过缓存;
- 缓存服务不可用时系统如何降级;
- 是否监控命中率、淘汰率和回源量;
- 是否限制重试次数;
- 是否避免将缓存当成永久存储。
结语
缓存的作用不是简单地“减少一次数据库查询”,而是在响应速度、系统容量、数据新鲜度和实现复杂度之间进行权衡。
缓存穿透是无效数据不断越过缓存,缓存击穿是单个热点数据失效,缓存雪崩则是大量缓存同时失效。它们最终都可能把原本由缓存承担的流量转移到数据库。
与此同时,只要缓存和数据库中同时存在数据副本,就必须面对一致性问题。过期时间可以兜底,却不能代替更新、删除、重试和补偿机制。
一个可靠的缓存系统不仅要考虑“命中时有多快”,还要考虑缓存未命中、过期、删除失败、服务故障和数据库过载时,整个系统是否仍然能够稳定运行。
- 点赞
- 收藏
- 关注作者
评论(0)