很多系统都会在数据库前面加一层 Redis 做缓存。
请求来了,先查缓存;缓存里有就直接返回,没有再查数据库,并把查询结果写回缓存。
这个流程平时运行得很好,但有一天,数据库突然扛不住了。监控显示,每秒有上万次查询直接落到了数据库上,而 Redis 的命中率几乎为零。
查日志后发现,请求里的订单 ID 根本不存在。
缓存里没有这些订单,数据库里也查不到。可每次请求到来,系统还是会老老实实地先查缓存、再查数据库。大量请求就这样轻松绕过了缓存,全部压到数据库上。
这就是缓存穿透。

先用一个例子说清楚
可以把缓存想象成学校收发室的取件架,把数据库想象成后面的仓库。
- 查询一个存在的订单,就像取一件真实存在的快递。取件架上有,直接拿走;架子上没有,才去仓库找。
- 查询一个不存在的订单,就像拿着一个错误的取件码来取快递。取件架上没有,仓库里也没有,但工作人员仍然要跑一趟仓库去确认。
偶尔查错一次没什么影响。可如果有人每秒提交上万个完全错误的取件码,仓库再大也经不起这样反复折腾。
所以,缓存穿透指的是:请求的数据既不在缓存中,也不在数据库中,导致请求一次次越过缓存,直接访问数据库。
正常查询和缓存穿透有什么不同?
正常情况下:
请求 → 查缓存 → 命中 → 返回
↓ 未命中
查数据库 → 查到数据 → 写入缓存 → 返回
发生缓存穿透时:
请求 → 查缓存 → 未命中
↓
查数据库 → 仍然没有
↓
返回空结果
↓
下一次查询相同 ID 时,再走一遍数据库
问题不在于某一次查询,而在于这些不存在的数据无法被普通缓存记住。同一个无效 ID 被反复查询,或者大量随机无效 ID 同时出现时,数据库的压力就会持续居高不下。
你可能会问,那该怎么应对?下面介绍三种常见的处理办法。
方案一:缓存空值
最直接的办法,是把“数据库里没有这条数据”这个事实也记录到缓存中。
下次再查询同一个 ID,系统看到我们设置的空值标记,就可以直接返回,不必再次访问数据库。
def get_order(order_id):
# 1. 先查缓存
data = redis.get(f"order:{order_id}")
if data is not None:
if data == "NULL": # 空值标记
return None
return data
# 2. 缓存未命中,再查数据库
order = db.query("SELECT * FROM orders WHERE id = ?", order_id)
if order is None:
# 3. 数据库也没有,短时间缓存空值
redis.setex(f"order:{order_id}", 60, "NULL")
return None
# 4. 查到数据,正常写入缓存
redis.setex(f"order:{order_id}", 3600, order)
return order
空值通常会设置一个较短的过期时间,比如 60 秒。这样一来可以避免长时间占用缓存空间,二来也能避免数据刚写入数据库后,接口仍然一直返回空结果。
| 优点 |
局限 |
| 实现简单,改动小 |
每个不存在的 ID 也会占用一个缓存 Key |
| 能挡住同一个无效 ID 的重复查询 |
如果无效 ID 不断变化,仍可能占用大量内存 |
| 容易接入现有缓存流程 |
过期时间需要根据业务情况调整 |
这种方案适合处理少量、重复出现的无效 ID。
方案二:布隆过滤器
如果无效 ID 是随机生成的,缓存空值就不太划算了。攻击者每次换一个 ID,Redis 里就会不断增加新的空值 Key。
这时,可以在缓存前面加一层布隆过滤器(Bloom Filter)。
布隆过滤器是一种非常节省内存的数据结构,适合用来判断一个元素是否可能存在。它的判断结果只有两种:
- 肯定不存在:可以直接拦截,不再查询缓存和数据库。
- 可能存在:继续走正常查询流程。
注意,“可能存在”不等于“一定存在”。布隆过滤器有一定的误判概率,它可能把一个本不存在的 ID 判断为“可能存在”,但它绝不会把已经加入过滤器的 ID 漏判为“不存在”。
它是怎么工作的?
布隆过滤器底层可以看成一个很长的二进制数组,开始时所有位置都是 0。
初始状态:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
添加 ID=10086:
用 3 个哈希函数算出位置 3、7、12,并把这些位置设为 1
[0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0]
以后再查询某个 ID 时,用同样的哈希函数计算位置:
- 如果其中任意一位是 0,这个 ID 肯定没有加入过过滤器。
- 如果所有位置都是 1,这个 ID 可能加入过,也可能只是发生了哈希碰撞。
在缓存查询中,可以这样使用:
# 系统初始化时,把已有的合法 ID 加入布隆过滤器
bloom_filter = BloomFilter(capacity=1_000_000, error_rate=0.01)
for order_id in db.query("SELECT id FROM orders"):
bloom_filter.add(order_id)
def get_order(order_id):
# 1. 先经过布隆过滤器
if not bloom_filter.contains(order_id):
return None # 肯定不存在,不查缓存,也不查数据库
# 2. 可能存在,再走正常的缓存和数据库查询流程
data = redis.get(f"order:{order_id}")
if data is not None:
return data
order = db.query(...)
# 后续按正常流程处理
| 优点 |
局限 |
| 很省内存,适合保存大量 ID |
存在一定误判概率 |
| 查询速度快 |
新增数据时要同步更新过滤器 |
| 能提前拦住大量不存在的 ID |
普通布隆过滤器不支持直接删除元素 |
布隆过滤器更适合数据量大、无效 ID 随机变化的场景。
方案三:接口参数校验
还有一道很基础、但经常被忽略的防线:先判断参数是否合法。
def get_order(order_id):
# 第一道防线:检查参数格式和取值范围
if not isinstance(order_id, int) or order_id <= 0 or order_id > 10_000_000:
return None
# 参数合法,再走缓存和数据库查询流程
...
负数、超大数字、特殊字符这类明显不合法的参数,可以直接拒绝,连 Redis 都不必查询。
不过,参数校验只能挡住“格式或范围不合法”的请求。如果攻击者使用格式正确、但实际不存在的 ID,仍然需要缓存空值或布隆过滤器来兜底。
三种方案怎么选?
| 场景 |
更合适的方案 |
| 少数几个无效 ID 被反复查询 |
缓存空值 |
| 无效 ID 随机变化,而且请求量大 |
布隆过滤器 |
| 合法 ID 数量多,需要节省内存 |
布隆过滤器 |
| 不能接受过滤器误判 |
缓存空值 |
| 数据频繁新增 |
缓存空值,或使用支持删除和计数的布隆过滤器变体 |
| 参数有明确的格式和范围 |
接口参数校验 |
在实际项目里,这三种方法通常不是三选一,而是搭配使用:
请求 → 参数校验 → 布隆过滤器 → 查缓存 → 查数据库
↓ ↓
命中返回 数据不存在时缓存空值
参数校验先拦住明显不合法的请求;布隆过滤器过滤掉绝大部分不存在的 ID;缓存空值再处理少量漏网的重复查询。
小结
缓存穿透的原理并不复杂:查询的数据根本不存在,缓存没能挡住请求,数据库就只能被反复访问。
处理时可以记住三点:
- 重复查询少量无效 ID,可以缓存空值。
- 无效 ID 数量大、变化快,可以使用布隆过滤器。
- 参数有明确规则,一定要先做接口校验。
单独使用一种方案未必能覆盖所有情况。根据数据规模和请求特点组合使用,才能真正减轻数据库压力。
对于这类高并发场景下的系统设计问题,通常没有一招鲜的银弹,更多是依据业务特点做出权衡与组合。如果你对后端架构中这类问题的处理思路感兴趣,可以到【云栈社区】看看更多关于高可用的实战讨论。
用简单的话,讲清楚一个技术问题。我们明天见!