缓存穿透:我如何用一颗布隆过滤器撑住10w QPS——那些年掉过的坑和压测背后的真香时刻

当一个不存在的key差点搞垮整个集群

凌晨三点。监控告警狂响。数据库CPU直接飙到95%,主备险些切换。我盯着慢查询日志,后背一阵发凉——成千上万条请求全在查一个压根不存在的商品ID。是的,缓存穿透。就是那种每个请求都绕过缓存,狠狠砸在DB上的感觉。你或许在无数八股文里背过这个概念,但真到了线上,细节里的魔鬼才露出獠牙。布隆过滤器?缓存空对象?都只是纸面上的解法。关键是你得知道这些机制在工程上什么时候会悄悄崩掉。

说实话,这玩意儿原理简单得让人不屑。一个恶意请求拿一个随机字符串来查缓存,缓存没命中,于是查数据库,数据库也没有,返回空。下一次同样的请求又来了……循环反复。如果每秒几万次这类请求,你的数据库连接池很快就被打满,整个服务进入雪崩。问题是——为什么缓存对这种“不存在”的数据无能为力? 因为默认的缓存逻辑是:缓存了才能防住。但压根没有这个数据,你怎么缓存?于是每次都需要穿透到数据库。解决方案无非两条路:要么在到达缓存之前就挡住它,要么在穿透发生后把“空”这个结果也缓存下来。

缓存穿透布隆过滤器位数组映射示意图
缓存穿透布隆过滤器位数组映射示意图

所以,布隆过滤器登场了。很多人只把它当作一个“不存在的key直接过滤”的黑盒,但它的数学内核才是工程美学所在。想象一个巨大的位数组,初始全是0。对于每一个合法的key,我们用K个不同的哈希函数映射到位数组的K个位置,把这些位置都置为1。查询时,同样计算K个哈希,如果所有位置都是1,就认为“可能存在”;只要有一个是0,就肯定不存在。这就是把一个精确的存在性查询,变成了一个概率判断。内存占用极小,O(1)时间,而且误判率可以通过数学严格推导出来:m是位数组大小,n是预计插入元素数量,k是哈希函数个数,误判率p ≈ (1 – e^(-kn/m))^k。当k = (m/n)*ln2 时取最优,此时p ≈ 0.618^(m/n)。这意味着,如果我打算存一亿条数据,想要误判率控制在1%以下,大约需要1.2GB的位数组——内存完全可控。而如果直接缓存空对象……每个空对象至少占几十字节的key,还可能被淘汰策略干扰。

压测场上见真章:没有布隆过滤器,QPS直接跌到三位数

我专门做过对比压测。环境:8核16G的MySQL,Redis同样规格,JMeter施压。数据量:数据库中有1000万真实商品,同时用脚本随机生成不存在ID。压力模型:10%请求查询不存在数据,90%查询存在数据(但缓存不预热,自然过期)。

第一轮——无任何防护:初始QPS 2000左右,数据库CPU在30%附近。当模拟恶意穿透的脚本一启动,每秒新增5000次不存在key查询,数据库CPU瞬间冲到92%,QPS断崖式下跌到350,大量请求超时。第二轮——缓存空对象:设置空值缓存TTL=5分钟,结果缓存内存占用飙升到2.3GB(Redis用的volatile-lru策略),而且由于大量不存在的key挤占了内存,真实的热点数据被错误淘汰,整体命中率降到了60%,DB压力依然不小。第三轮——布隆过滤器:预加载所有真实商品ID到布隆过滤器(误判率设定0.1%,位数组约1.7GB),查询时先过布隆,不存在直接拒绝。效果惊人:相同恶意流量下,QPS稳定在1.8万,数据库CPU停留在5%-8%,Redis内存占用仅多出过滤器本身。更关键的是不存在key根本不会进入缓存层,避免了缓存污染。压测持续半小时,没有出现任何误判导致的漏查(因为商品ID特征固定,哈希碰撞极低)。

缓存穿透压测前后QPS与数据库CPU对比图表
缓存穿透压测前后QPS与数据库CPU对比图表

不过……如果你真以为布隆过滤器是银弹,那你一定会掉进我接下来要说的坑里。每一个都让我在线边滚键盘边冒冷汗。

三个让我半夜惊醒的陷阱,以及怎么爬出来

三个让我半夜惊醒的陷阱,以及怎么爬出来
三个让我半夜惊醒的陷阱,以及怎么爬出来

陷阱一:布隆过滤器的“冷启动”与全量重建。 过滤器里的数据从哪里来?最理想是数据库全量同步。但当你数据库有几亿数据时,构建一次布隆过滤器的耗时可能长达10分钟,这期间如果有新数据写入,过滤器就是缺失的——一个存在的key可能被误判为不存在。我第一版方案用定时全量重建,结果每次重建窗口内,总有用户投诉“明明有商品却说没有”。后来改为增量对账:维护两个布隆过滤器,轮流切换。更新时新数据同时写入当前过滤器和待切换过滤器,切过去的时候再把切之前错过的那一小批补上。虽然逻辑复杂,但可以保证99.99%的一致性。

陷阱二:误判率设置贪小便宜吃大亏。 初版我图省内存,把误判率设成了1%。结果上线一周后,客服工单如雪片般飞来——有用户搜不存在的商品名,居然返回了“可能不存在”?——不,是布隆把一些不存在的误判为存在,然后缓存层去查数据库,虽然数据库最终返回空,但已经穿透了。一个1%的误判,在几万QPS下,意味着每秒有几百次穿透。内存是省了,数据库差点没撑住。最终调整为0.01%,内存虽然多了几十MB,但压测下穿透次数趋近于零。所以算清楚业务容忍度是第一要务。

陷阱三:缓存空对象时TTL设置不当引发的数据一致性灾难。 即便不用布隆,用缓存空值,也不能拍脑袋定个60秒。有个历史遗留系统,将不存在的用户信息缓存空值,TTL设了10分钟。某天数据修复,一个之前不存在的用户突然被创建了,但缓存里的空值还没过期,导致用户一直提示“账号不存在”。运营差点报警。解决方案:主动失效。当数据库发生插入时,通过Binlog监听(我用Canal)实时驱逐对应的空值缓存。同时缓存空值时加一个随机TTL(比如60s±20%),避免缓存雪崩。

这些坑每一个都有血的教训,但正是它们让我对缓存穿透有了更工程化的敬畏。布隆过滤器不是黑科技,它就是概率论在工程上的一次优雅落地。缓存空对象也不是不能用,关键是你得管好它的生命周期。下次有人问你缓存穿透怎么防,别光扔出“布隆”两个字——记得把误判率和重建策略也丢给他,这才是灵魂。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:缓存穿透:我如何用一颗布隆过滤器撑住10w QPS——那些年掉过的坑和压测背后的真香时刻
文章链接:https://m.lfdjt.com/info_23_7747.html