资讯动态

数据缓存实战:穿透、击穿、雪崩的应对与最佳实践

发布时间:2026/10/15 7:03:27 来源:尧图企业网站定制
数据缓存场景这几个字做后端的人天天挂在嘴边但真正把它讲透的人不多。我见过不少团队把缓存当成性能问题的万能药结果上线没多久缓存穿透、击穿、雪崩轮番上阵数据库被打到告警运维半夜爬起来处理事故。这篇文章不是搬运理论而是我在多个真实的“数据缓存场景”项目里反复踩坑之后整理出来的实操总结适合正在做服务端开发、中间件建设或者正准备做性能优化的同学。你会看到这些问题的本质是什么、当时为什么选这个方案、具体怎么做才能少走弯路。1. 数据缓存场景到底在解决什么问题1.1 场景画像什么业务的数据真正“配得上”缓存一说到缓存很多人第一反应就是“数据库慢了就加缓存”这句话说对了一半也说错了一半。缓存的本质是空间换时间把热数据放到更快的存储介质里减少后端存储的压力但它不是为所有数据准备的。我在项目里判断一个业务是否适合用缓存通常会先看三个特征读多写少、数据实时性要求适中、数据的访问热度存在明显差异。举个例子某电商平台的商品详情页商品基础信息一天被读取几百万次但真正被修改的次数可能只有几十次这种数据就是典型的缓存适用对象。而账户余额、库存流水这类数据每次变动都必须强一致就不适合简单粗暴地塞进缓存。还有一个容易被忽略的点如果一份数据本身读的频率就很低缓存它只是白白占用内存命中率低到没法看缓存的价值也就消失了。之前接手过一个项目前一个开发几乎把所有数据库查询结果都放进了缓存连只跑一次的报表查询都没放过。结果内存占用居高不下命中率却只有百分之十几最后不得不做一次全面的Key梳理只保留真正的热点数据才把系统稳下来。所以做缓存选型前第一步应该是画场景而不是直接写代码。1.2 缓存层次本地缓存、集中式缓存与多级缓存很多刚接触缓存的同学容易把“缓存”等同为Redis其实完整的缓存体系至少要分两个层次进程内本地缓存和集中式分布式缓存两者各司其职。本地缓存比如Caffeine、Guava Cache存放在应用进程内存里读取速度是纳秒级几乎没有网络开销非常适合放那些极小、极热、几乎不变的数据。但它有个硬伤每个应用实例各存一份数据在多个实例之间天然的“不一致”。集中式缓存比如Redis、Memcached所有实例共享同一份数据一致性容易保证却多了网络IO开销一次读取大概在毫秒级。我现在的标准做法是两级缓存配合优先读本地缓存读不到再读集中式缓存再读不到才落数据库。这样一来绝大多数热点请求在进程内就返回了集中式缓存的压力也能降下来。不过两级缓存引入了一个新问题——本地缓存的数据更新怎么通知到各实例。这里可以在写操作完成之后通过发布订阅广播一个失效通知各实例收到就清掉对应本地缓存代价很小却能解决绝大部分一致性问题。数据量小、并发极高、允许短暂不一致的场景我用本地缓存数据量大、需要全局一致性的热点数据用集中式缓存。两层加在一起才能把系统的整体性能拉起来。2. 缓存读写模型如何在高性能与一致性之间找到平衡点2.1 读路径的标准姿势Cache Aside模式读多写少的业务最稳妥的缓存读写模型就是Cache Aside旁路缓存核心逻辑说起来非常简单读请求先查缓存命中直接返回没有命中就去查数据库拿到数据后回填缓存并返回写请求则先更新数据库再删除缓存。这套模型我用了很多年最大的感受就是“简单”二字。它没有复杂的分布式事务没有监听机制代码层面用一个拦截器或者切面就能实现。但有一个细节极其重要写操作一定是先更新数据库再删缓存而不是先删缓存、再更新数据库。如果先删缓存紧接着有一个读请求发现缓存没了就会带着老数据回填正好把更新前的旧值写进缓存等真正更新完数据库缓存里残留的还是旧值一致性就坏了。这里有个很多老手都在用的优化技巧延迟双删。先删除缓存更新数据库随后马上再删除一次缓存两次删除之间隔几百毫秒。这个间隔用来等待并发读请求把旧数据读回去的那段时间虽然不能百分之百消除窗口期但实际项目中脏数据停留的时间已经被压缩到了一个可以接受的量级。稳妥的项目还会再加一道保险通过订阅数据库变更日志异步清缓存作为兜底手段。2.2 缓存更新策略过期时间不是随便填的过期时间是数据缓存场景里最容易翻车的地方。设得太短缓存形同虚设设得太长数据一致性问题积累起来会很难看。常规操作是给缓存设置一个业务上可容忍的过期时间比如商品名称、图片地址这类基础信息设个几小时没有问题价格、库存这类数据可能几分钟就必须失效。还有一种用法不设置固定过期而是把过期时间做成相对值在每次写入时基于当前时间加一个随机偏移。这个设计的主要目的是打散过期时刻避免大量Key在同一秒失效后面聊雪崩时我会再展开。大家可以从这两方面考虑。我个人的习惯热点数据统一设置过期时间同时为每个Key设计一个逻辑上的业务版本号。更新时数据库记录版本号自增缓存里附带版本号字段读取时如果发现版本不匹配就直接当作没有命中。这样即便过期时间设得稍长也能通过版本号主动失效兼顾性能和数据新鲜度。2.3 写密集场景的缓存策略要换一种思路Cache Aside在读写比很高时表现很好但如果是写密集场景比如秒杀系统的库存扣减、点赞系统中的计数缓存和数据库的频繁交互会让性能大打折扣。这种场景我通常会把缓存当作主存储来用先写缓存再异步同步到数据库通过消息队列或定时任务保证最终一致。这类方案要求对“丢失少量数据可接受”有明确预期。计数丢失一次影响也许不大如果涉及金额、库存之类的强约束就必须引入分布式锁、事务消息等手段做可靠性保障。我做过一个投票类项目每秒钟有几万次投票请求如果每次都实时写数据库数据库马上就会被打爆。最后的设计是Redis用incr命令累加投票数每五秒把增量批量刷到数据库应用读取时优先读缓存。由于投票数据的准确性要求不像余额那么苛刻这个方案一直稳定运行数据库负载也始终保持在低位。写场景占比超过总请求量20%以上的时候建议重新评估一下“读多写少”的假设是否成立不要拿着锤子看什么都是钉子。3. 高并发下的三个经典事故穿透、击穿、雪崩3.1 缓存穿透恶意请求打的不是一个点而是整个数据库缓存穿透指的是大量请求查询一个缓存和数据库中都不存在的数据。由于缓存永远没有这份数据每次请求都会直冲数据库。最典型的场景是攻击者或者爬虫持续请求不存在的资源ID数据库在极短时间内被无用流量灌满。解决穿透最常用的办法有三个我按优先级排序第一接口层做参数校验非法的、明显不存在的ID直接拦截第二缓存空值——即使查询结果为空也把这个“空”缓存起来设置一个较短的过期时间比如五分钟防止重复打库第三用布隆过滤器在缓存前再加一道屏障把所有可能存在的Key预先映射到过滤器里查询时发现Key不存在就直接返回压根不进数据库。我自己在项目里的选择是“空值缓存布隆过滤器”两种方案并用。布隆过滤器能阻挡大部分不存在的Key但它有误判可能偶尔会把不存在的Key判断为存在这时空值缓存就能兜住剩余的不确定性。布隆过滤器有个弱点不能删除元素如果业务中经常有数据被删掉再重新生成误判率会逐渐上升这种情况下更适合用空值缓存或者定期重建过滤器的策略。3.2 缓存击穿热点Key失效的一瞬间全世界的流量都来了缓存击穿和穿透的差别在于穿透是打一个“根本不存在”的数据击穿是打一个“过去存在但现在缓存正好失效了”的超级热点数据。这个热点Key一旦失效一瞬间所有请求全部穿透到数据库数据库往往扛不住这种并发尖刺。我经历过的真实案例是某个节日活动一个爆款商品的详情数据命中率极高平时Redis扛了99%的流量数据库毫无压力。结果这个Key到点失效的瞬间线上监控曲线直接拉出一条垂直向上的线数据库连接数飙升。当时的解决办法是加互斥锁当线程发现缓存失效时不是立刻去查数据库而是先尝试获取一个分布式锁只有抢到锁的线程才去查库回填缓存其他线程短暂自旋等待从缓存里读取新值。这样一来数据库最多承受一个线程的查询压力而不是几百倍的并发。还有一个思路叫逻辑过期完全不依赖物理过期时间数据写入缓存时不设过期额外存储一个逻辑过期时间比如“写入时间30秒”。读取时发现逻辑时间已过期并不直接删除数据而是返回旧值同时发起一个异步线程去更新缓存。这种做法把过期造成的阻塞降到了最低非常适合读多写少的超高并发热点代价是实现更复杂缓存中短暂存在旧数据。3.3 缓存雪崩大面积Key同时失效系统一下子被打穿雪崩和击穿的区别在于范围。击穿是单个Key失效雪崩是一大批Key在同一段时间内集体失效比如缓存服务重启、大量Key设置了相同的过期时间或者集群中的某个分片不可用。此时底层数据库接收到的是成倍放大的流量后果往往是灾难性的。预防雪崩我在项目中总结出几板斧第一过期时间加随机抖动比如基础时间十分钟再随机加上0到300秒的偏移让Key的失效时刻均匀散布第二多级缓存兜底本地缓存那一层就算集中式缓存暂时不可用应用进程还能扛住一部分请求第三设置限流和降级开关一旦数据库压力超过阈值非核心接口直接返回默认结果宁可少量请求失败也不能让数据库彻底宕机第四缓存服务本身要采取集群架构做好主从复制和持久化避免重启后冷启动导致的大面积穿透。有一次我负责的系统遭遇缓存集群中的一个节点异常重启如果当时没有多级缓存兜底数据库会在重启后的瞬间被读请求淹没。那次事故之后我把“缓存可用性”和“数据库可用性”直接挂钩来设计预案任何缓存层面的抖动都要有对应的降级策略绝不裸奔。4. 实际项目实施过程中的关键细节与排障实录4.1 缓存预热要么主动预热要么等着被流量教做人新上线的服务或者刚重启过的缓存集群缓存里往往空空如也。这时候如果直接引流量进来所有请求都会命不中数据库可能瞬间被打爆。所以缓存预热是数据缓存场景里一个常常被忽视的必选项。我通常用两种方式预热一种是启动阶段的后台任务从数据库里分批读取热点数据写入缓存比如把商品Top1000的详情预先填进去另一种是接口触发式预热运营人员在后台点一个“刷新缓存”按钮程序生成最新的热点数据列表并批量回填。批量写入时要注意使用pipeline管道命令避免逐条写导致的大量网络往返几千个Key用pipeline可能一秒就完成了逐条写可能要十来秒。预热不是做一次就结束的事情热点列表本身在变化所以我还会配一个定时任务每小时根据最新的访问统计重新生成热点数据并增量预热。数据被提前放在该在的位置上流量来了才不会慌。4.2 Key设计与管理大Key和热Key是性能杀手在真实的缓存场景中Key的设计直接决定了问题排查的难度和运行时的性能。这里有两个高频故障点大Key和热Key。大Key指的是单个Key存储的Value特别大比如一个列表里塞了几十万条记录。大Key的危害是读取和写入都很慢网络传输压力大操作时还容易阻塞服务。我之前遇到过一个缓存KeyValue是一整个商家全量订单列表随着订单量增加一次读取要几秒直接拖垮接口。处理方式很简单也很粗暴拆分。把大对象拆成多个小Key或者用Hash结构按字段存储具体属性只取用到的部分。另一个常用方案是压缩JSON序列化后启用压缩算法把体积压下去但要在性能和CPU开销之间做取舍。热Key指的是某个Key被超高频率访问比如某明星一条微博被狂刷。单个Key的流量达到几十万QPS时集中式缓存所在的单节点可能会先扛不住即便集群有多个节点同一个Key也只会落在同一节点上。应对手段有两个方向一是让热点数据在缓存集群的多个节点上各存一份把请求分散到不同机器二是本地缓存配热点识别识别出超高频Key后把它放到进程内内存进一步减轻集中式缓存的压力。4.3 序列化兼容性缓存升级时最容易踩的暗坑很多项目在缓存里直接存Java对象序列化后的二进制或者干脆塞JSON字符串。这里有一个隐蔽的坑版本升级时字段变化了缓存里还留着旧版本的数据反序列化时会报错或者得到空字段。我之前升级一个订单模型某一个字段由String改成Nested对象旧的缓存数据里根本没有这个字段反序列化直接抛异常线上接口瞬间报错一批。后来重新设计了序列化策略优先采用解析宽松的格式比如JSON配上兼容模式并规定所有缓存对象必须带version字段。读取时发现版本不匹配直接删除缓存让数据从数据库重新加载。这样增加了极小的判断开销却彻底解决了升级带来的脏数据风险。缓存中间件版本升级也会带来兼容性问题比如Redis不同版本间数据结构的序列化格式差异。升级前必须先做兼容性测试在灰度环境跑一段时间确认读写行为一致后再全量替换。4.4 命中率监控你看到的数字欺骗了你怎么办不监控缓存的命中率就像蒙着眼睛开车。但监控命中率有一个容易踩坑的地方直接看整体命中率会掩盖问题。整体命中率90%看起来不错但某个核心业务可能只有50%因为大量低价值请求把整体数据拉高了。所以我习惯按业务模块、按接口维度拆开看命中率重点关注核心接口的缓存贡献。除了命中率我还会看三个指标缓存延迟的P99、过期淘汰的数量、以及回源数据库的QPS。回源数据库的QPS才是最直接的数据库风险指标如果它持续走高不管整体命中率多好看都说明缓存策略出了问题。我曾经遇到一个情况换了新的Key前缀之后旧缓存和新缓存并存新Key始终没有命中回源率飙升到70%数据库告警但整体命中率看起来只是从95%降到80%不细看根本发现不了原因。后来加了按业务线分组监控问题才暴露出来十分钟内就定位修掉了。监控数据采集本身最好不要侵入业务代码可以用缓存客户端的回调机制上报统计或者定期用scan命令抽样统计Key型的访问频率。太重的监控会拖垮业务性能得不偿失。4.5 一份可以直接套用的缓存接入流程这几年做下来我整理了一个标准接入流程新项目基本都按这个步骤走第一步梳理业务数据访问特征区分读写比、热点分布、一致性要求判定是否适合缓存第二步设计Key规范和缓存结构预估数据量选择合适的序列化方式第三步选定缓存组件和部署方案规划淘汰策略和过期时间第四步编码实现读写逻辑加入空值缓存、互斥锁、延迟双删等保护机制第五步建立预热任务和监控大盘第六步做全链路压测模拟缓存失效场景确认降级预案能兜住。这里最容易被跳过的就是第六步。很多项目上线时功能正常数据库压力也不大是因为缓存还没遇到真实的并发失效场景。压测时一定要刻意模拟缓存集群重启、核心Key失效、数据库延迟变大等异常情况验证系统在异常状态下的行为是否符合预期。我每次按这个流程走完都能在上线前揪出至少两三个隐患。5. 最后分享一点我自己的体会数据缓存场景看似简单真正做好需要的是对数据特征的理解和对异常场景的预判。我在实际项目中的体会是缓存的价值不在于“加了多少层”而在于每一层是否都清楚自己的职责边界。本地缓存扛高频集中式缓存扛共享数据库永远作为最终真相存在任何一层出问题都要有清晰的降级路径。有一个很小的细节我直到现在都很注意缓存相关的代码要尽量收敛到一个独立的模块里不要让业务代码到处直接操作缓存客户端。统一封装的好处是将来加监控、加空值策略、加分布式锁都只改一个地方不会出现某个接口偷偷绕过了保护逻辑的情况。如果你正在设计自己的缓存方案个人建议从最朴素的Cache Aside模型开始先把命中率、回源率这些指标监控起来再根据实际压力逐步引入多级缓存、互斥锁、逻辑过期等进阶手段。过程可能会慢一点但每一步都有数据支撑踩坑时也知道坑在哪里。希望这些经验对你有参考价值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑