3. 缓存穿透:定义与危害、产生原因、解决方案
3.1 定义与危害
缓存穿透是指客户端请求的数据在缓存中不存在,同时在数据库中也不存在。每次请求都直接穿透缓存层,打到后端数据库,导致缓存完全失效,数据库压力骤增。
核心危害:缓存穿透的本质是“缓存层被绕过”,使得本应被缓存保护的数据库直接暴露在高并发请求下,极易引发数据库连接耗尽、响应超时甚至宕机。
| 危害维度 | 具体表现 | 影响等级 |
|---|---|---|
| 数据库压力 | 大量无效查询直接访问数据库,QPS 飙升 | 严重 |
| 系统响应 | 请求因数据库负载过高而超时,用户体验下降 | 中等 |
| 资源浪费 | 缓存层空转,网络带宽和连接资源被无效请求占用 | 中等 |
| 连锁故障 | 数据库宕机后,依赖该数据的上游服务级联失败 | 严重 |
3.2 产生原因
缓存穿透的产生通常源于以下三种场景:
- 恶意攻击或爬虫:攻击者故意构造大量不存在的 Key(如负 ID、超长字符串)发起请求,绕过缓存直接攻击数据库。
- 业务逻辑漏洞:查询参数未做合法性校验,导致非法参数(如
id=-1、id=null)进入查询流程。 - 数据误删除或未初始化:缓存和数据库中的合法数据被删除后,后续请求无法命中缓存,也无法从数据库获取数据。
典型案例:某电商平台在促销期间,攻击者使用不存在的商品 ID(如
product_id=99999999)循环请求,导致数据库每秒收到数万次无效查询,最终数据库连接池耗尽,正常用户无法访问。
3.3 解决方案
针对缓存穿透,业界有三种主流解决方案,按推荐优先级排列如下:
3.3.1 方案一:参数校验(第一道防线)
在请求入口层对参数进行合法性校验,直接拦截明显不合法的请求。
// 参数校验示例(Java)
public Product getProduct(String productId) {
// 1. 基础校验:非空、格式
if (productId == null || productId.trim().isEmpty()) {
throw new IllegalArgumentException("商品ID不能为空");
}
// 2. 业务校验:ID 范围、格式规则
if (!productId.matches("^[A-Za-z0-9]{8,32}$")) {
throw new IllegalArgumentException("商品ID格式不合法");
}
// 3. 黑名单校验(可选)
if (isInBlackList(productId)) {
return null; // 直接返回空,不查询缓存和数据库
}
// ... 后续缓存查询逻辑
}
最佳实践:参数校验应放在网关层或 API 入口处,使用白名单模式(只允许合法格式通过),而非黑名单模式。校验规则应包含:非空检查、类型检查、长度限制、格式正则、枚举值校验。
3.3.2 方案二:布隆过滤器(Bloom Filter)
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它能够以极小的内存开销,快速过滤掉绝大多数不存在的 Key。
核心原理:使用多个哈希函数将元素映射到一个位数组中的多个位置,并置为 1。查询时,若所有对应位均为 1,则元素“可能存在”;若有任意一位为 0,则元素“一定不存在”。
布隆过滤器在 Redis 中的使用方式:
# Redis 4.0+ 通过 module 加载布隆过滤器
# 1. 加载模块
redis-server --loadmodule /path/to/redisbloom.so
# 2. 初始化过滤器(预计插入 10000 个元素,误判率 1%)
BF.RESERVE product_filter 0.01 10000
# 3. 添加元素(数据预热时执行)
BF.ADD product_filter product_12345
BF.ADD product_filter product_67890
# 4. 查询元素(请求入口处执行)
BF.EXISTS product_filter product_99999 # 返回 0,直接拦截
布隆过滤器 vs 传统 Set:布隆过滤器占用内存极低(通常仅为 Set 的 1/10 ~ 1/100),但存在误判率(会把不存在误判为存在)。对于缓存穿透场景,误判只会导致少量无效请求打到数据库,不会造成系统崩溃,属于可接受的权衡。
3.3.3 方案三:空值缓存(Cache Null Object)
当查询数据库发现数据不存在时,仍然将该 Key 写入缓存,但 Value 设置为一个特殊空值(如 null、""、"NULL"),并设置较短的过期时间(如 30~60 秒)。
// 空值缓存实现示例(Java + Redis)
public Product getProduct(String productId) {
// 1. 查询缓存
String cacheValue = redis.get("product:" + productId);
if (cacheValue != null) {
// 2. 判断是否为空值标记
if ("NULL".equals(cacheValue)) {
return null; // 缓存中标记为空,直接返回
}
return JSON.parseObject(cacheValue, Product.class);
}
// 3. 缓存未命中,查询数据库
Product product = productDao.selectById(productId);
// 4. 根据数据库结果处理
if (product == null) {
// 数据库也不存在:写入空值缓存,TTL 30秒
redis.setex("product:" + productId, 30, "NULL");
return null;
} else {
// 数据库存在:写入正常缓存,TTL 3600秒
redis.setex("product:" + productId, 3600, JSON.toJSONString(product));
return product;
}
}
注意事项:
- 空值缓存的 TTL 必须远小于正常缓存,避免长期占用内存。
- 需要区分“空值标记”和“正常数据”,避免业务逻辑混淆。
- 如果数据可能被恢复(如管理员后台添加了该商品),需要同步删除空值缓存。
3.4 方案对比与选型
| 方案 | 内存开销 | 实现复杂度 | 误判风险 | 适用场景 |
|---|---|---|---|---|
| 参数校验 | 无 | 低 | 无 | 所有系统必须实现的基础防线 |
| 布隆过滤器 | 极低 | 中 | 有(可控) | 高并发、Key 空间大、可预热的场景 |
| 空值缓存 | 中等 | 低 | 无 | Key 空间有限、数据变化不频繁的场景 |
推荐组合策略:参数校验(必选)+ 布隆过滤器(高并发首选)+ 空值缓存(兜底方案)。三层防护叠加,可将缓存穿透风险降至最低。对于极端高并发系统,建议在网关层增加限流和降级措施,作为第四道防线。