Java

布隆过滤器上了以后,为什么穿透还是没止住?

那次把数据库打高的,不是一大片随机脏 ID,而是 8 个反复出现的无效 `skuId`。Bloom Filter 明明开着,可同步链路慢了 6 分多钟,空值缓存又没补上,最后是少量漏下去的空查把等待链拖长了。

  • Redis
  • 布隆过滤器
  • 缓存穿透
  • 缓存
  • 性能排查
18 分钟阅读

那次告警出来时,值班群里已经有人先把一句话说满了:

Bloom Filter 都上了,怎么数据库还是被穿透打高了?

真正把现场说清楚的,不是这句话,而是 10 分钟里拉出来的三份证据:请求样本、空查分布、同步日志。

先看当时最扎眼的一组空查统计。时间窗是 13:40 到 13:50:

指标数值
sku_detail 空查总次数48,216
不同 skuId 数量213
Top 8 skuId 占比79.4%
Redis 空值缓存命中0
Bloom 拒绝率61%

这组数一出来,很多想当然的判断就站不住了。

它说明至少三件事:

  1. Bloom 不是完全没拦住,61% 的请求已经在前面被挡掉了。
  2. 真正把数据库拖高的,不是海量随机 ID,而是少数热点无效 ID 反复来打。
  3. 空值缓存没有接上,所以漏下去的空查一直在重复回源。

先别争 Bloom 有没有用,先看请求到底长什么样

当时抽出来的几条请求样本是这样的:

GET /sku/detail?skuId=9812047781
GET /sku/detail?skuId=9812047781
GET /sku/detail?skuId=9812047781
GET /sku/detail?skuId=9812049904
GET /sku/detail?skuId=9812049904
GET /sku/detail?skuId=9812051137

这批请求有两个特点:

  • 都是标准主键型查询,理论上确实该是 Bloom 的处理范围
  • 不是“打一枪换一个 ID”,而是同几个无效 skuId 被反复访问

这点非常关键。因为如果现场是随机脏流量,Bloom 的拦截率通常更能直接反映效果;但如果现场是少数热点无效 Key,那只要它们有一部分漏下去,又没有空值缓存,数据库就还是会被重复空查打疼。

所以那次我没有先去争“要不要调误判率”,而是先盯住这几个高频 skuId:它们为什么能持续漏过去?

第二眼看空查分布,问题就更具体了

把 48,216 次空查按 skuId 聚一下,前几名长这样:

skuId10 分钟内空查次数说明
981204778111,382促销落地页反复请求
98120499049,746App 推荐位引用旧 ID
98120511376,908H5 页面缓存旧链接
98120520414,117爬虫重复访问

到这一步,现场已经不是抽象的“穿透还在”,而是一个更具体的句子:

少数已经失效的热点 skuId,在 Bloom 开着的情况下,仍然持续被判成“可能存在”,然后一次次打回数据库。

为什么会这样?继续往下翻同步链路日志,答案就接上了。

Bloom 没失效,但它慢了 6 分 43 秒

那次同步任务的日志里有两行特别关键:

13:37:12 INFO bloom-sync consume topic=sku-change lag=403s backlog=182941
13:44:01 INFO bloom-sync apply delete skuId=9812047781 version=202604041337

而业务侧对应的商品变更时间是:

13:37:05 sku 9812047781 marked deleted
13:37:18 landing page still serving old skuId link
13:38:02 /sku/detail?skuId=9812047781 started to spike

也就是说,那批热点无效 skuId 在业务上已经失效了,但 Bloom 的删除同步要到 6 分多钟后才追上。对这 6 分钟里的请求来说,过滤器仍然会给出“可能存在”。

这不是 Bloom 原理错了,而是过滤器数据和真实数据之间出现了可观的时间差

只要这层时间差存在,再叠加热点无效 Key 的重复访问,数据库空查就不会自己消失。

真正把正常请求也拖慢的,是后面的等待链

如果只是偶尔几次空查,现场还不一定难看。真正把接口 RT 一起拖起来的,是漏过去之后的等待链。

13:40 到 13:50,这几项指标是一起抬的:

指标13:3913:46
DB QPS1,2004,900
空查 SQL 占比3%38%
Hikari pending154
/sku/detail P9995ms1.8s
/cart/preview P99110ms920ms

这里最值得盯住的一行,其实不是 /sku/detail,而是 /cart/preview 也被拖慢了。

因为这说明问题已经不是“空查多了一点”那么简单,而是:

  • Bloom 漏下去一批热点无效请求
  • 这些请求没有被空值缓存挡住
  • 数据库连接被空查反复占住
  • 正常请求也开始在连接池前排队

一旦走到这一步,现场看起来就会像“Bloom 明明开着,但穿透还是像没治一样”。

实际上不是没治,而是前面挡掉了大头,后面漏下去的小头又被等待链放大了。

这次真正要补的,不是再讲一遍 Bloom 原理

那次处理动作最后落在三层,而且顺序很明确:

先补空值缓存

对这批热点无效 skuId,先加 60 秒负缓存,数据库空查马上掉下去一截。

再修同步链路滞后

sku-change 的消费堆积处理掉,让删除和下线事件更快反映到 Bloom 数据集上。不把这层时间差压下去,热点无效 Key 还会继续漏。

最后再清脏入口

排查落地页、推荐位、H5 页面为什么还在发旧 skuId。因为只要这些旧链接继续被放大,后面总会有人来问“为什么 Bloom 开了还挡不住”。

这三步里,第一步最立刻,第二步最治本,第三步决定会不会反复再来。

回到那个问题:为什么上了以后,穿透还是没止住

这次现场给出的答案,不是一个抽象原理,而是一条完整链路:

  • Bloom 的确挡掉了 61% 的无效请求
  • 真正把数据库打疼的,是 8 个热点无效 skuId
  • 这 8 个 Key 之所以还会漏,是因为同步链路晚了 6 分多钟
  • 漏下去之后又没有空值缓存,空查被反复放大
  • 最后连接池排队,把正常请求也拖慢了

所以“Bloom Filter 已经开了,为什么穿透还在”这类问题,现场里更值得问的通常不是“它有没有开”,而是:

漏下去的是哪些请求、它们占多少、为什么会漏、漏下去以后谁在继续放大它。

那次把这些东西拉出来后,问题就不再是一个原理争论,而是一串能落到日志、分布和指标上的事实。