缓存是性能优化里收益最直接的一招,也是出问题时最难查的一类。数据不对、缓存雪崩、重建风暴,这些事故的共同点是:缓存引入的不只是速度,还有一套独立于数据库的失效逻辑。把缓存当成”加一层就快”的透明加速器,迟早要为它补课。
读写模式先选对
缓存读写有三种基本形态,选哪种决定了后面所有问题的形状。
旁路缓存(cache aside)是最常见的形态:读的时候先查缓存,没有就查库再回填;写的时候只更新数据库,然后删除(或失效)缓存。它的好处是代码直白、缓存和数据库各司其职,代价是每次读未命中都要付一次数据库查询,且写后到下次读之间存在短暂的不一致窗口。
读写穿透(read/write through)把回填和写库的责任挪到缓存客户端或独立代理里:写入时缓存层同步写库,读取时缓存层负责加载。应用代码干净了,但缓存层变厚,且写路径仍然绕不开数据库的延迟。
写回(write behind)是激进路线:写只写缓存,由异步任务批量刷回数据库。写性能极好,但崩溃就丢数据,只适合能容忍丢失、或有其他持久化兜底的场景(比如计数器、会话临时数据)。
多数业务系统从旁路缓存起步就够了,模式越特殊,排障时的心智负担越重。
穿透:查一个根本不存在的 key
缓存穿透指的是查询一个数据库里也没有的数据,缓存永远不命中,每次都打到库里。正常的业务零星发生还好,一旦被恶意构造(比如遍历不存在的 ID),数据库会被无效查询压垮。
最简单的防线是空值缓存:查询结果为空时也写入一条空值,设置较短的过期时间(比如几分钟)。实现成本几乎为零,能挡住绝大多数随机穿透,代价是存在误伤——数据后来被创建的几分钟内,读到的还是空值,需要配合创建时主动失效来弥补。
布隆过滤器适合 key 空间大、攻击性强的场景:把所有合法 ID 先哈希进一个位图,查询前先过滤器判一下,明显不存在的直接拒绝。它省内存、判断快,但有误判率(把不存在的判成可能存在),所以只能做前置粗筛,不能作为唯一依据,且集合变化时需要有更新机制。
参数校验是最便宜的一层。ID 格式、范围、类型在入口就拦掉,别让明显非法的请求走到缓存层。这层不花什么成本,却挡掉了相当比例的低级穿透。
击穿:热 key 过期的瞬间
缓存击穿说的是某个热点 key 在过期的一瞬间,大量并发请求同时发现缓存没命中,一起涌向数据库。单个 key 的失效本是常态,但热点 key 的失效会把数据库瞬间打到峰值。
互斥重建是常用解法:重建期间只放一个请求去查库,其余请求要么等待、要么先拿旧值。实现上可以用分布式锁或本地锁加占位标记,把”重建”变成互斥区。代价是重建期间的请求延迟上升,且锁本身要处理超时和续期。
逻辑过期是另一种思路:value 里带上逻辑过期时间,发现过期就返回旧值、后台异步刷新。用户体验上永远不会等重建,代价是数据有一段时间的陈旧窗口,适合对实时性要求不极端的展示类数据。
热点探测本质上是运维侧的配合:提前识别热点 key(榜单、秒杀商品、大 V 主页),在活动前预热,必要时给热点 key 单独设置更长的过期或永不过期加主动刷新。缓存击穿的很多解法,其实都是在为”我没预料到它会热”擦屁股。
雪崩:大面积同时失效
缓存雪崩比击穿严重一个量级:大批 key 在同一时间过期,或缓存服务整体不可用,请求全部穿透到数据库,数据库扛不住就是全站故障。
最基础的防御是过期时间打散。给 TTL 加一个随机抖动(比如基准 30 分钟加 0 到 10 分钟随机),避免同批写入的 key 同批死亡。一行代码的事,却挡掉了”定时任务批量刷新缓存”埋的雷。
多级缓存是进阶手段:本地缓存(进程内)扛住最热的那部分,分布式缓存兜底,数据库最后。本地缓存要注意容量上限和本地失效(服务实例间缓存不一致、重启即丢失),但在缓存服务整体抖动时,它是把请求挡在数据库外的最后一层。
缓存服务本身的高可用同样是雪崩防线的一部分。主从切换、集群部署、合理的客户端超时和熔断策略——缓存挂了是降级到数据库,还是直接返回错误,要在设计时就定好,而不是故障时临场发挥。
一致性:没有免费的午餐
缓存和数据库是两份数据,一致性永远是权衡而非保证。
延迟双删是常见做法:更新数据库后删缓存,隔一小段时间(大于一次读请求的耗时)再删一次,覆盖”读请求读到旧库值后回填缓存”的竞态。间隔时长是经验值,没有普适公式,需要结合读写耗时估计。
订阅失效(订阅数据库的 binlog 或变更事件来刷新缓存)把失效逻辑从应用代码里挪到独立组件,好处是多服务共享一份缓存时不遗漏,坏处是链路变长、延迟不确定,且引入了新的运维对象。
更强的一致性要求可以考虑把缓存当成读模型,由数据变更事件驱动更新,业务侧只读缓存。这套做法在读多写少、容忍秒级延迟的场景里很舒服,但架构复杂度显著上升,不适合所有系统。
经验上的取舍是:能接受最终一致就用失效而非更新(删除比重写简单、不易出错);关键一致性字段(余额、库存)要么不缓存、要么缓存极短 TTL 并配合主动失效;展示类数据大胆缓存,交易类数据谨慎缓存。
键设计与容量评估
缓存键设计得对,问题少一半。键里带上业务语义和版本号(比如 user:1001:profile:v2),改结构时换版本号就完成迁移,比”先全清缓存再发布”干净得多。键不要太长(内存开销)、不要用易变信息(时间戳、随机数)、不要把敏感信息明文放进键名(日志里会泄露)。
容量评估常被跳过。粗算公式并不复杂:QPS × 平均命中耗时 ≈ 缓存能替数据库省下的时间,结合缓存实例的内存上限、单条 value 大小、命中率预期,能推出”这份缓存能撑多久”和”未命中时数据库扛不扛得住”。命中率从 99% 掉到 90%,对后端的压力可能是十倍的变化,容量规划要把最坏情况(缓存全失效)算进去,而不是只看理想态。
监控指标盯这几个:命中率、慢查询数、连接数、内存使用率、key 数量增长曲线。命中率突然下跌通常是 key 设计或过期策略出了问题,比等数据库报警早得多。
缓存的诱惑在于立竿见影,风险在于它悄悄变成了第二个数据源。把失效逻辑、重建互斥、容量边界当成正式需求来设计,缓存才能长期稳定地站在数据库前面,而不是站在它和故障之间。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。