Skip to content

缓存穿透与布隆过滤器

⭐⭐⭐架构5.7 & 8.0缓存穿透布隆过滤器Bloom Filter缓存架构优化

大促数据库 CPU 100%:缓存穿透攻击

大促期间,监控突然报警:数据库 CPU 100%、连接池耗尽。排查发现大量请求查询不存在的用户 ID(id=-1id=99999999),缓存 miss 后全部打到数据库。这就是经典的缓存穿透攻击。

sql
-- 恶意请求使用不存在的 ID 大量查询
-- 每次: 查缓存(miss) -> 查数据库(miss) -> 返回空
-- 高 QPS 下数据库被无效请求打爆
SELECT * FROM t_user WHERE id = -1;

真实场景

缓存穿透不是 SQL 层面的性能问题,而是架构层面的防护缺失。攻击者用大量不存在的 key 绕过缓存,直接冲击数据库。单次查询极快(主键查找),但高 QPS 下累积的连接占用和 CPU 消耗足以拖垮数据库。

问题分析

bad.sql

sql
-- 直接查询不存在的用户 ID,缓存和数据库都 miss
-- 攻击者用 1 万个不存在的 ID 每秒查 1000 次
-- 数据库每秒执行 1000 次无效查询
SELECT * FROM t_user WHERE id = -1;

EXPLAIN 结果

+----+-------------+-------+------+---------+---------+---------+-------+------+-----------------------------+
| id | select_type | table | type | key     | key_len | ref     | rows  | Extra                       |
+----+-------------+-------+------+---------+---------+---------+-------+------------------------------+
|  1 | SIMPLE      | NULL  | NULL | NULL    | NULL    | NULL    | NULL  | no matching row in const table|
+----+-------------+-------+------+---------+---------+---------+-------+------------------------------+

为什么慢

单看 EXPLAIN,这条查询本身极快--主键等值查找,发现不存在直接返回。真正的代价在架构层面

  1. 每次都打到数据库:不存在的 ID 永远不会被缓存(缓存空结果策略未启用时)
  2. QPS 放大效应:1 万个不存在的 ID × 每秒 1 次 = 每秒 1 万次无效查询
  3. 连接池耗尽:大量无效查询占用数据库连接,正常业务查询排队被拒
  4. CPU 空转:每次查询消耗 CPU 做 B+ 树查找,高并发下累积可观

优化方案

good.sql

本节为架构示意:MySQL 没有原生布隆过滤器,下文以应用层伪代码 + 缓存空值两层方案演示。真实布隆过滤器用 RedisBloom / Guava 实现(见末尾"实现方案")。

sql
-- ── 第一层:应用层布隆过滤器(伪代码,示意拦截点)──
-- 应用进程:检查 user_id 是否在布隆过滤器的位数组中
-- IF NOT bloom_filter.may_contain(user_id):
--     RETURN empty   -- 内存纳秒级判定"一定不存在",不下沉到缓存/DB
-- 
-- ── 第二层:缓存空值兜底(MySQL 真实可执行的兜底 SQL)──
-- 当应用层 BF 命中("可能存在")但实际查 DB 仍 miss 时:
--   SET cache:user:99999999 "" EX 60    -- 缓存空结果 60 秒
--   RETURN empty
-- 
-- 仅当布隆过滤器命中 + 缓存空值未命中时,才执行真正查询:
SELECT * FROM t_user WHERE id = 12345;

原理

布隆过滤器(Bloom Filter)在内存中维护一个位数组:

  1. 写入时:对每个用户 ID 做多个哈希函数,将对应位数组位置为 1
  2. 查询时:对查询 ID 做同样的哈希,检查对应位是否全为 1
    • 全为 1 -> 可能存在(有误判率,约 1%)-> 查数据库确认
    • 有 0 -> 一定不存在 -> 直接返回,不查数据库
  3. 效果:99%+ 的无效请求在布隆过滤器层被拦截,数据库零压力

常见误区

MySQL 没有原生布隆过滤器。如果你的"good.sql"是 SELECT COUNT(*) FROM t_bloom_filter WHERE user_id_hash = -1,那依然是普通 SQL 查表(最终下沉到磁盘 B+ 树),与"内存纳秒级判定"的承诺完全不符。本案例的关键是拦截点在应用层 / 缓存层,而不是 SQL 本身。真正可用的两种落地:

  • RedisBloomBF.ADD / BF.EXISTS,Redis 模块):跨实例共享,单命令 100ns 级
  • Guava BloomFilter(Java 进程内):零网络开销,但每实例独立维护

对比

bad.sql(无防护)good.sql(布隆过滤器)
无效请求到达数据库100%< 1%(误判率)
单次防护开销0~100ns(内存哈希)
数据库 QPS(1万攻击)10,000~100(误判部分)
连接占用耗尽正常
指标优化前 (bad)优化后 (good)
访问类型constN/A
使用索引PRIMARY布隆过滤器
扫描行数00
附加信息no matching row - 但每次都打到数据库99%+ 请求在内存层被拦截,不查库

🚀 无效请求拦截率 99%+,数据库 QPS 从 10000 降到 ~100

避坑指南

注意事项

  1. 布隆过滤器有误判率。说"存在"可能不存在(false positive),说"不存在"一定不存在。误判率通常设 1%,可通过增大位数组降低。

  2. 布隆过滤器不支持删除。删除一个元素会影响其他元素的判断。如需删除可用 Counting Bloom Filter 或 Cuckoo Filter。

  3. 缓存空值是另一种方案。对不存在的 key 缓存空值(SET key "" EX 60),但会占用内存且无法防止随机 key 攻击。

  4. 数据同步问题。新增用户时需同步写入布隆过滤器,否则新用户会被误判为"不存在"。建议在数据库写入后异步更新布隆过滤器。

5.7 vs 8.0 差异

特性5.78.0
布隆过滤器应用层实现(Redis/本地)应用层实现
原生支持❌(MySQL 不内置布隆过滤器)
替代方案缓存空值缓存空值 + 8.0 窗口函数辅助统计

常见实现方案

生产环境通常用 Redis Bloom Filter(RedisBloom 模块)或 Guava BloomFilter(Java 应用本地)。Redis 方案跨实例共享,Guava 方案性能更高但需每实例独立维护。

本地复现

bash
# 默认在 MySQL 8.0 上运行
./scripts/run-case.sh 60-cache-penetration

# 在 MySQL 5.7 上运行(对比)
./scripts/run-case.sh 60-cache-penetration --ver 5.7

# 跳过造数据重跑
./scripts/run-case.sh 60-cache-penetration --no-seed

MIT Licensed