|

Aimee

Write the Code. Change the World.

缓存:为什么快、三大坑、和数据库怎么保持一致

· 分享镜

"慢了?加个缓存"——几乎是性能优化的第一反应。但缓存加完,新的麻烦才开始:穿透、击穿、雪崩、缓存和数据库对不上……这些坑大多是线上出过事才认识的。

这篇把缓存最该懂的几件事讲清楚:为什么快、怎么用、用在哪、三大坑怎么防,以及最难的——一致性。配套一个零依赖 demo,node demo.js 就能看到缓存的"一生"和每个防护的效果。


一、缓存为什么快,怎么用

为什么快?因为数据在内存里。 内存读写是纳秒级,磁盘是毫秒级,差了几个数量级。把热点数据从数据库搬到内存,读取就快上千倍。最常用的缓存是 Redis——一个内存型的键值库。

缓存挡在应用和数据库中间:大部分读请求被缓存接住,只有少数落到数据库。所以缓存最适合读多写少的数据——用户信息、商品详情、配置、排行榜。

怎么用?最常见的是 Cache-Aside(旁路缓存)模式:

  • :先查缓存 → 命中就返回;未命中 → 查数据库 → 把结果写回缓存 → 返回。
  • :更新数据库 → 删除缓存(为什么是删不是更新,见第三节)。
读:  请求 → 查缓存 → 命中? → 是 → 返回
                          ↓ 否
                       查 DB → 写回缓存 → 返回

衡量缓存有没有用,就看一个指标:命中率。10 次请求 9 次走缓存,命中率 90%,数据库压力降到原来的 1/10。demo 场景一就是这个效果:连读同一条数据 10 次,回源次数从 10 降到 1。

命中率上不去,缓存就是白搭,甚至帮倒忙(多一次缓存查询还得回源)。所以缓存用得好不好,本质是命中率高不高——而下面三大坑,坑的就是命中率。

二、缓存都用在哪 —— 两种角色

很多人以为缓存就是"把数据库的数据复制一份到 Redis"。其实缓存在业务里有两种角色,搞混了容易踩坑。

角色一:数据库的加速副本(旁路缓存)

数据的"家底"在数据库,Redis 里放一份副本,专门给"读"提速。最典型的是商品详情页:

  • 一个详情页的数据往往要 join 好几张表(基本信息 + 价格 + 图片 + 参数),大促时几十万 QPS 全打到数据库做 join,扛不住;
  • 于是把"查一次、拼好的结果"序列化成 JSON,以 product:123 为 key 缓存进 Redis:
key:    product:123
value:  {"id":123,"title":"xx 保温杯","images":[...],"specs":{...}}
TTL:    10 分钟
  • 读:先查 Redis,命中直接返回;没有就查库、回填——这就是第一节的 Cache-Aside;
  • 商家改了商品:更新数据库 → 删掉 product:123 → 下次有人看时自动重建。

这类缓存是数据库的副本,可丢、可重建,丢了只是慢一下、不影响数据安全;但也因此带来"和数据库一致"的问题(见第四节)。

注意:不是所有字段都适合塞进这份缓存。价格、库存这种要准、变得勤的,经常单独处理、甚至直接读库——缓存里的库存一旦不准,会超卖。

角色二:短命数据的主存储

有些数据本身就该放内存、本就短命,压根没有"数据库副本"一说,Redis 直接当它们的主存储。最典型的是登录相关:

数据为什么放 Redis
登录态 / session每个请求都要校验"你是谁",超高频读
短信验证码天生短命(5 分钟),带 TTL、验证完即删
登录失败次数 / 限流"5 分钟内失败 5 次就锁定",原子自增 + TTL 正合适
点赞数 / 阅读数原子自增,异步落库

这类数据不存在"和数据库一致"的问题——它本身就住在 Redis 里。

一句话区分:角色一是"数据库的快副本"(商品、用户信息),角色二是"短命数据的家"(session、验证码、计数器)。后面讲的三大坑、一致性,主要针对角色一

还有第三种更进阶的用法:用 Redis 的原子操作扛高并发扣减(比如秒杀库存)——那属于"高并发 / 秒杀架构"的话题,这里不展开。

三、三大坑:穿透、击穿、雪崩

这三个名字听着像,其实是三种不同的失效方式,共同点是:让请求绕过缓存,直接砸到数据库。一个个拆。

穿透:查的数据根本不存在

什么情况:请求一个数据库里也没有的 key——比如 id = -1、不存在的用户名。缓存永远不会命中(没数据可缓存),每次都打到数据库。常见于恶意攻击(狂刷不存在的 id)或爬虫乱爬。

为什么疼:缓存形同虚设,数据库直面全部流量。

怎么防:

  1. 缓存空值:数据库查不到,也把"空"缓存起来(给个短 TTL),后续相同请求命中这个空值,不再打数据库。简单有效——demo 场景二,回源从 10 降到 1。
  2. 布隆过滤器:在缓存前加一层"这个 key 可能存在吗"的快速判断,判定不存在的直接挡掉。适合 key 空间大、恶意请求多的场景。

取舍:缓存空值会占点内存,且如果那个 key 之后真被写入了,要记得让空值失效;布隆过滤器有极小误判率、且元素不好删除。

击穿:一个热点 key 恰好过期的瞬间

什么情况:某个热点 key(比如爆款商品)过期的那一瞬间,大量并发请求同时未命中,一起涌向数据库重建缓存。

注意它和雪崩的区别:击穿是一个热点 key 失效,雪崩是一大批 key 失效。

为什么疼:平时这个 key 替数据库扛着大流量,它一失效,这些流量瞬间全压到数据库上。

怎么防:

  1. 互斥锁 / 单飞(single-flight):同一个 key,只放一个请求去查数据库重建,其余请求等它的结果。demo 场景三:100 个并发,回源从 100 降到 1。
  2. 逻辑过期:不给 key 设真正的 TTL(物理上不过期),而把"过期时间"写进 value 里;读到发现逻辑上过期了,就异步去更新,同时先返回旧值。好处是请求不用阻塞等待。
  3. 热点数据干脆不过期,靠后台定时任务刷新。

取舍:互斥锁实现简单,但重建那一下其他请求要等;逻辑过期不阻塞,但会短暂返回旧数据。

雪崩:一大批 key 同时失效,或缓存整个挂了

什么情况:两种——① 大量 key 设了相同的 TTL,到点集体过期,流量同时砸向数据库;② 缓存服务(Redis)整个宕机,所有请求瞬间穿到数据库。

为什么疼:数据库承受的是"全量"流量,直接被压垮,还可能引发连锁故障(数据库挂 → 拖垮上游服务)。

怎么防:

  1. TTL 加随机抖动:在基础 TTL 上加一个随机值,把过期时间打散,避免扎堆。demo 场景四:1000 个 key,固定 TTL 时过期跨度只有 2ms(几乎同时),加抖动后摊到 999ms。
  2. 多级缓存:本地缓存(进程内)+ 分布式缓存(Redis),一层挡不住还有一层兜。
  3. Redis 高可用:主从 + 哨兵,或集群,别让缓存成为单点。
  4. 限流 / 熔断 / 降级:缓存真挂了,限制能打到数据库的流量——宁可拒绝一部分请求,也别让数据库全崩。

一句话:穿透、击穿是"点"的问题(单个 key),雪崩是"面"的问题(一大片),所以雪崩的防护更偏架构层——高可用、多级、限流降级。

🌰 生活里见过的:某明星突发热搜,几千万人瞬间涌进同一个话题页——这个超级热点的缓存一旦失效,海量请求同时砸向数据库(击穿),数据库扛不住就连环雪崩,于是你看到"微博又崩了"。应对靠的就是上面这套:热点 key 不过期 + 多级缓存 + 限流降级(崩的时候先降级——热搜暂缓更新、评论排队,保住核心别全挂)。

三大坑速查:

触发一句话主要防护
穿透查不存在的数据缓存里永远没有,次次打 DB缓存空值、布隆过滤器
击穿单个热点 key 过期瞬间一个热点失效,并发全涌入单飞互斥、逻辑过期、热点不过期
雪崩大批 key 同时过期 / 缓存宕机一片同时失效,DB 被压垮TTL 抖动、多级缓存、高可用、限流降级

四、最难的:缓存和数据库怎么保持一致

缓存是数据库数据的一份"副本"。只要有两份数据,就有对不上的风险。更新数据时,数据库和缓存都要动,顺序和方式没选好,就会读到脏数据。

这里有两个要做的选择:更新缓存还是删除缓存?先动缓存还是先动数据库?

为什么是"删除缓存",不是"更新缓存"

直觉上,数据变了,把缓存也改成新值不就行了?但更新缓存有几个问题:

  • 浪费:更新进去的新值可能很久没人读,白算白存。
  • 并发写覆盖:两个写请求,更新数据库的顺序和更新缓存的顺序可能相反,导致缓存里留下的是旧值。
  • 重算成本:如果缓存值是好几个表 join 算出来的,每次更新都要重算一遍,贵。

所以主流做法是:更新数据库,然后删除缓存——让下次读请求未命中时,由 Cache-Aside 自然回填最新值。demo 场景五就是这个:更新后缓存被删,再读时重查数据库,拿到新值。

先删缓存,还是先更数据库

  • 先删缓存,再更数据库:删完到更新完之间,如果有读请求进来,会把旧值重新加载进缓存;等数据库更新完,缓存里却是刚写回的旧值 → 不一致。
  • 先更数据库,再删缓存(更推荐):绝大多数情况是对的。极端并发下仍有极小概率不一致(一个读请求在更新前查到旧值,却在更新+删除之后才慢悠悠写回缓存),但概率很低。
  • 延迟双删:先删缓存 → 更新数据库 → 隔一小段时间再删一次缓存,兜住上面那个并发窗口里可能被写回的旧值。

关键认知:缓存只能做到"最终一致"

想让缓存和数据库时时刻刻强一致,代价极高——几乎要把缓存的性能优势抵消掉,实际工程里很少这么做。

通行的做法是接受最终一致:允许极短时间的不一致,靠"删缓存 + 下次回填" 加 TTL 兜底,让数据最终对上。对绝大多数业务(商品详情晚一秒更新没人在意),这完全够用。

但对一致性要求极高的数据——钱、库存——要么别缓存、直接读数据库,要么上更重的方案:分布式锁,或者订阅数据库 binlog(如阿里开源的 Canal)把变更同步到缓存。

🌰 生活里见过的:抢火车票"明明显示有票,付款却说没票"——你看到的余票是缓存里的数(为扛住海量查询,它不实时精确,可能已被抢光、只是还没更新);而付款时扣的是真实库存(以数据库为准)。系统宁可让你"看到有票却付款失败",也绝不超卖。这不是 bug,正是"缓存做近似的快查询、数据库做最终裁决"这个取舍的结果。

五、什么时候别加缓存

缓存不是免费的。它给系统多塞了一份"要维护一致性的副本",复杂度实打实地上升。所以加之前先想清楚——

不适合缓存的情况:

  • 写多读少:命中率低、还频繁失效,缓存纯添乱。
  • 要求强一致:钱、库存这类,缓存的"最终一致"扛不住。
  • 数据量小、数据库本身就快:加缓存收益小,徒增复杂度。

加缓存前先问三句:这数据读多写少吗?能容忍短暂不一致吗?命中率会高吗?——三个都是 yes,再加。

最后提醒一句:别把缓存当银弹。很多慢查询的正解是加索引、优化 SQL,而不是盖一层缓存把问题藏起来——藏起来的问题,迟早以更难查的方式冒出来。

六、一张表:什么场景用什么招式

把全文的招式对到真实业务,一张表带走:

业务场景怎么处理对应招式
商品详情页Cache-Aside 缓存拼好的商品 JSON旁路缓存(角色一)
爆款商品大促热点 key 过期 → 单飞重建防击穿
大促大量商品缓存同时到期TTL 加随机抖动打散防雪崩
改价格 / 改资料更新库 + 删缓存一致性
恶意刷不存在的商品 id缓存空值 / 布隆过滤器防穿透
排行榜 / 热搜Redis ZSet(自动按分数排序),定时刷新短命数据主存储(角色二)
点赞数 / 阅读数原子自增,异步落库短命数据主存储(角色二)
库存扣减 / 余额别简单缓存——读库 / 分布式锁 / 秒杀另算什么时候别加缓存

名词解释

  • 回源 / 未命中(Cache Miss):缓存里没有,只能去数据库查。
  • Cache-Aside(旁路缓存):最常用的缓存模式——读未命中再回填,写时删缓存。
  • 命中率(Hit Rate):请求命中缓存的比例,衡量缓存有没有用。
  • TTL(Time To Live):缓存的有效期,到点自动失效。
  • 单飞(Single-Flight):同一个 key 的并发请求,只放一个去查源,其余等结果、共享同一份。
  • 布隆过滤器(Bloom Filter):一种省空间的概率数据结构,能快速判断"一定不存在 / 可能存在"。
  • 多级缓存:本地缓存(进程内)+ 分布式缓存(Redis)等多层叠加。
  • 热点 key:被高频访问的少数 key(爆款、明星)。
  • 最终一致性(Eventual Consistency):允许短暂不一致,但保证经过一段时间后数据对上。
  • binlog:数据库的变更日志,订阅它可以把数据变更实时同步到别处(如缓存、搜索引擎)。

配套 demo:backend-notes/01-data/caching-demo —— node demo.js 一跑:场景零打印缓存的一生(创建 / 命中 / 删除),后面五个场景看每个防护把回源次数压下去的效果。

本文属《研发都要懂的事》· 数据存储专题。上一篇:存储与基建选型 —— 传统业务 vs AI 大模型时代。完整代码与系列在 GitHub · backend-notes

评论0

登录后参与评论。

还没有评论,来抢沙发吧。

回到顶部