1. 缓存穿透的本质与危害
Redis缓存穿透是指查询一个根本不存在的数据,导致请求直接穿透缓存层到达数据库。这种现象在高并发场景下尤为危险——当大量请求同时查询不存在的数据时,数据库会承受巨大压力,严重时可能导致服务雪崩。
缓存穿透与缓存击穿、缓存雪崩并称为Redis三大经典问题。三者的核心区别在于:
- 缓存击穿:热点key过期瞬间大量请求直达数据库
- 缓存雪崩:大量key同时过期引发数据库压力
- 缓存穿透:查询不存在的数据持续冲击数据库
实际业务中最典型的场景包括:
- 恶意攻击:故意构造不存在的ID发起请求
- 业务缺陷:前端未校验参数直接透传非法值
- 数据淘汰:已下架商品仍被爬虫持续抓取
我曾处理过一个电商案例:攻击者用脚本批量请求/product/-1这类非法ID,导致MySQL的QPS瞬间突破2万,CPU飙升至90%。这种攻击成本极低但破坏性极强。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存空对象的实现方案
2.1 基础实现原理
当查询未命中缓存时,先查数据库。若数据库也不存在该数据,则在Redis写入一个特殊空值标记(如NULL、EMPTY等),并设置较短的TTL(建议5-10分钟)。后续请求会命中这个空标记,避免穿透到数据库。
java复制public Product getProduct(String id) {
// 1. 查缓存
String cacheKey = "product:" + id;
Product product = redis.get(cacheKey);
if (product != null) {
if (product instanceof EmptyProduct) {
return null; // 空对象处理
}
return product;
}
// 2. 查数据库
product = db.query("SELECT * FROM products WHERE id=?", id);
// 3. 处理空结果
if (product == null) {
redis.setex(cacheKey, 600, new EmptyProduct()); // 缓存空对象
return null;
}
// 4. 正常缓存
redis.setex(cacheKey, 3600, product);
return product;
}
2.2 关键参数设计
- 空值TTL不宜过长:避免存储过多无效数据
- 序列化处理:空对象需实现序列化接口
- 内存监控:需关注
used_memory增长趋势
提示:空对象方案可能导致Redis内存占用升高。当恶意攻击持续构造新key时,建议结合第4节的布隆过滤器方案。
3. 布隆过滤器的工程实践
3.1 数据结构原理
布隆过滤器通过一个bit数组和多个哈希函数实现:
- 初始化:长度为m的bit数组,所有位设为0
- 添加元素:用k个哈希函数计算得到k个位置,将这些bit设为1
- 查询元素:检查k个位置是否都为1(可能有误判)
Redis通过BF.RESERVE命令创建过滤器:
bash复制BF.RESERVE product_filter 0.001 1000000
参数说明:
0.001:期望误判率1000000:预计元素数量
3.2 生产环境集成方案
python复制import redis
from pybloom_live import ScalableBloomFilter
# 双重保护:内存过滤器+Redis过滤器
local_filter = ScalableBloomFilter(initial_capacity=1000000, error_rate=0.001)
r = redis.Redis()
def init_filter():
# 预热阶段加载全量key
all_ids = db.query("SELECT id FROM products")
for id in all_ids:
local_filter.add(id)
r.execute_command("BF.ADD", "product_filter", id)
def get_product(id):
# 先查内存过滤器
if id not in local_filter:
return None
# 再查Redis过滤器
if not r.execute_command("BF.EXISTS", "product_filter", id):
return None
# 后续走正常缓存逻辑...
3.3 性能对比测试
在100万数据量下的测试结果:
| 方案 | 内存占用 | 查询耗时 | 误判率 |
|---|---|---|---|
| 纯空对象 | 约500MB | 0.1ms | 0% |
| 纯布隆过滤器 | 约2MB | 0.3ms | 0.1% |
| 混合方案 | 约50MB | 0.2ms | 0.001% |
经验:电商类业务推荐混合方案,用布隆过滤器拦截99%非法请求,剩余部分再用空对象兜底。
4. 多级缓存架构设计
4.1 分层防护体系
code复制请求流:
客户端 → Nginx缓存层 → 本地JVM缓存 → Redis集群 → 布隆过滤器 → DB
每层防护策略:
- Nginx层:对热点接口启用
proxy_cache,拦截明显异常参数 - JVM缓存:使用Caffeine做本地缓存,设置短TTL
- Redis集群:采用分片集群分散压力
- 布隆过滤器:前置校验关键ID有效性
4.2 动态规则配置
通过配置中心实现动态调整:
yaml复制anti_penetration:
bloom_filter:
enable: true
max_capacity: 1000000
error_rate: 0.001
empty_cache:
default_ttl: 300s
max_empty_keys: 50000
4.3 监控指标建设
关键监控项:
redis.penetration.count:穿透请求计数db.query.empty.ratio:空查询比例bloom.filter.hit.rate:过滤器命中率
Grafana看板应包含:
- 穿透请求趋势图
- 空缓存内存增长曲线
- 过滤器误报统计
5. 特殊场景的应对策略
5.1 热点key突发穿透
当某个合法key突然变成"不存在"状态(如商品秒杀售罄),传统方案会导致大量请求穿透。解决方案:
java复制// 使用Redisson的RPermitExpirableSemaphore
RSemaphore semaphore = redisson.getSemaphore("product:"+id+":lock");
if (semaphore.tryAcquire()) {
try {
// 只有第一个请求允许查库
Product p = db.query(...);
if (p == null) {
redis.setex(..., new EmptyProduct(86400)); // 设置长TTL
}
} finally {
semaphore.release();
}
}
5.2 缓存预热优化
对于新品发布等场景,提前预热布隆过滤器:
sql复制-- 使用MySQL游标分批处理
DECLARE cur CURSOR FOR SELECT id FROM new_products;
OPEN cur;
FETCH cur INTO @id;
WHILE done = 0 DO
EXECUTE BF.ADD product_filter @id;
FETCH cur INTO @id;
END WHILE;
5.3 冷启动解决方案
系统初始阶段过滤器数据不足时:
- 启动时加载最近3个月活跃数据
- 对未识别的key先放行,但记录日志
- 夜间任务分析日志,动态调整过滤器
6. 生产环境踩坑实录
6.1 布隆过滤器内存溢出
某次大促前,我们误将过滤器初始容量设为1亿(实际数据量仅1000万),导致Redis内存暴涨。根本原因是:
- Redis的布隆过滤器实现会预分配bit数组
- 容量参数对内存影响是指数级的
教训:初始容量应设置为预估最大数据量的1.2-1.5倍
6.2 空对象序列化异常
使用Jackson序列化空对象时,未考虑多版本兼容:
java复制// 错误示例
redisTemplate.opsForValue().set(key, new EmptyProduct());
// 正确做法
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
6.3 过滤器误判导致资损
某次将误判率从0.1%调整为0.01%后,出现合法订单被拦截。排查发现:
- 新过滤器需要重建,不能直接修改参数
- 业务代码未处理
false positive情况
修正方案:
java复制if (!bloomFilter.mightContain(orderId)) {
// 记录审计日志
auditLog.warn("Order {} filtered", orderId);
// 走人工复核流程
return reviewService.createTicket(orderId);
}
7. 性能优化进阶技巧
7.1 布隆过滤器分片
当数据量超过单实例容量时:
python复制def get_filter_shard(key):
slot = crc32(key) % 1024
return f"bloom_filter_{slot//64}"
# 添加元素时自动路由
shard = get_filter_shard(product_id)
r.execute_command("BF.ADD", shard, product_id)
7.2 空缓存压缩存储
使用MessagePack压缩空对象:
java复制EmptyProduct empty = new EmptyProduct();
byte[] compressed = new MessagePack().write(empty);
redis.setex(key.getBytes(), ttl, compressed);
实测可减少40%内存占用。
7.3 动态TTL调整
根据系统负载自动调整空缓存TTL:
java复制// 当数据库QPS超过阈值时延长TTL
if (dbMonitor.getQps() > threshold) {
redis.expire(key, 1800); // 30分钟
} else {
redis.expire(key, 300); // 5分钟
}
8. 其他防护方案对比
8.1 请求限流
对高频非法请求实施限流:
lua复制-- Redis + Lua滑动窗口限流
local key = "limit:" .. KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
return current > limit and 0 or 1
8.2 参数校验
前置校验层示例:
java复制@GetMapping("/product/{id}")
public Product getProduct(@PathVariable @Valid @Pattern(regexp="\\d+") String id) {
// 自动校验ID格式
}
8.3 异步加载策略
对于可能失效的key:
- 先返回旧值
- 异步线程更新缓存
- 更新失败时触发告警
方案对比总结:
| 方案 | 实现复杂度 | 防护效果 | 适用场景 |
|---|---|---|---|
| 空对象 | ★★☆ | ★★★ | 中小规模系统 |
| 布隆过滤器 | ★★★ | ★★★★ | 大规模稳定业务 |
| 限流 | ★★☆ | ★★☆ | 突发流量场景 |
| 参数校验 | ★☆☆ | ★★☆ | 基础防护层 |
