缓存穿透与布隆过滤器
大促数据库 CPU 100%:缓存穿透攻击
大促期间,监控突然报警:数据库 CPU 100%、连接池耗尽。排查发现大量请求查询不存在的用户 ID(id=-1、id=99999999),缓存 miss 后全部打到数据库。这就是经典的缓存穿透攻击。
-- 恶意请求使用不存在的 ID 大量查询
-- 每次: 查缓存(miss) -> 查数据库(miss) -> 返回空
-- 高 QPS 下数据库被无效请求打爆
SELECT * FROM t_user WHERE id = -1;真实场景
缓存穿透不是 SQL 层面的性能问题,而是架构层面的防护缺失。攻击者用大量不存在的 key 绕过缓存,直接冲击数据库。单次查询极快(主键查找),但高 QPS 下累积的连接占用和 CPU 消耗足以拖垮数据库。
问题分析
bad.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,这条查询本身极快--主键等值查找,发现不存在直接返回。真正的代价在架构层面:
- 每次都打到数据库:不存在的 ID 永远不会被缓存(缓存空结果策略未启用时)
- QPS 放大效应:1 万个不存在的 ID × 每秒 1 次 = 每秒 1 万次无效查询
- 连接池耗尽:大量无效查询占用数据库连接,正常业务查询排队被拒
- CPU 空转:每次查询消耗 CPU 做 B+ 树查找,高并发下累积可观
优化方案
good.sql
本节为架构示意:MySQL 没有原生布隆过滤器,下文以应用层伪代码 + 缓存空值两层方案演示。真实布隆过滤器用 RedisBloom / Guava 实现(见末尾"实现方案")。
-- ── 第一层:应用层布隆过滤器(伪代码,示意拦截点)──
-- 应用进程:检查 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)在内存中维护一个位数组:
- 写入时:对每个用户 ID 做多个哈希函数,将对应位数组位置为 1
- 查询时:对查询 ID 做同样的哈希,检查对应位是否全为 1
- 全为 1 -> 可能存在(有误判率,约 1%)-> 查数据库确认
- 有 0 -> 一定不存在 -> 直接返回,不查数据库
- 效果:99%+ 的无效请求在布隆过滤器层被拦截,数据库零压力
常见误区
MySQL 没有原生布隆过滤器。如果你的"good.sql"是 SELECT COUNT(*) FROM t_bloom_filter WHERE user_id_hash = -1,那依然是普通 SQL 查表(最终下沉到磁盘 B+ 树),与"内存纳秒级判定"的承诺完全不符。本案例的关键是拦截点在应用层 / 缓存层,而不是 SQL 本身。真正可用的两种落地:
- RedisBloom(
BF.ADD / BF.EXISTS,Redis 模块):跨实例共享,单命令 100ns 级 - Guava BloomFilter(Java 进程内):零网络开销,但每实例独立维护
对比
| bad.sql(无防护) | good.sql(布隆过滤器) | |
|---|---|---|
| 无效请求到达数据库 | 100% | < 1%(误判率) |
| 单次防护开销 | 0 | ~100ns(内存哈希) |
| 数据库 QPS(1万攻击) | 10,000 | ~100(误判部分) |
| 连接占用 | 耗尽 | 正常 |
| 指标 | 优化前 (bad) | 优化后 (good) |
|---|---|---|
| 访问类型 | const | N/A |
| 使用索引 | PRIMARY | 布隆过滤器 |
| 扫描行数 | 0 | 0 |
| 附加信息 | no matching row - 但每次都打到数据库 | 99%+ 请求在内存层被拦截,不查库 |
🚀 无效请求拦截率 99%+,数据库 QPS 从 10000 降到 ~100
避坑指南
注意事项
布隆过滤器有误判率。说"存在"可能不存在(false positive),说"不存在"一定不存在。误判率通常设 1%,可通过增大位数组降低。
布隆过滤器不支持删除。删除一个元素会影响其他元素的判断。如需删除可用 Counting Bloom Filter 或 Cuckoo Filter。
缓存空值是另一种方案。对不存在的 key 缓存空值(
SET key "" EX 60),但会占用内存且无法防止随机 key 攻击。数据同步问题。新增用户时需同步写入布隆过滤器,否则新用户会被误判为"不存在"。建议在数据库写入后异步更新布隆过滤器。
5.7 vs 8.0 差异
| 特性 | 5.7 | 8.0 |
|---|---|---|
| 布隆过滤器 | 应用层实现(Redis/本地) | 应用层实现 |
| 原生支持 | ❌ | ❌(MySQL 不内置布隆过滤器) |
| 替代方案 | 缓存空值 | 缓存空值 + 8.0 窗口函数辅助统计 |
常见实现方案
生产环境通常用 Redis Bloom Filter(RedisBloom 模块)或 Guava BloomFilter(Java 应用本地)。Redis 方案跨实例共享,Guava 方案性能更高但需每实例独立维护。
本地复现
# 默认在 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