问题背景:缓存穿透的典型症状
某电商平台订单查询接口,日均调用量3000万次,Redis命中率长期低于80%。监控发现,当用户查询一个不存在的订单ID时(如恶意遍历或前端异常传参),请求会直接穿透Redis到达MySQL,导致数据库QPS峰值高达8000,平均响应时间从20ms恶化到1.2s。
这就是典型的缓存穿透:缓存和数据库都不存在的数据,每次请求都打到DB。本文用两个实战方案解决,并给出可验证的压测数据。
方案一:空值缓存(简单直接)
思路:对查询结果为空的数据也写入Redis,设置较短过期时间(如60秒),防止恶意流量反复穿透。
// 查询订单(伪代码)
Order getOrder(String orderId) {
Object cache = redis.get("order:" + orderId);
if (cache != null) {
if (cache instanceof EmptyValue) return null;
return (Order) cache;
}
// 缓存未命中,查询DB
Order order = db.query("SELECT * FROM orders WHERE id = ?", orderId);
if (order == null) {
// 空值缓存,60秒过期
redis.set("order:" + orderId, new EmptyValue(), 60);
return null;
}
redis.set("order:" + orderId, order, 3600);
return order;
}
优点:实现简单,对已存在的业务代码侵入小。缺点:大量不存在的key会占满Redis内存,且过期时间窗口内仍可能被攻击(但已大幅缓解)。
方案二:布隆过滤器(前置拦截)
思路:在Redis前加一层布隆过滤器,存储所有合法订单ID。查询时先判断ID是否可能存在,若不存在则直接返回null,根本不查Redis和DB。
// 初始化布隆过滤器(启动时加载全量订单ID)
BloomFilter<String> bloom = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
1000_0000, // 预计元素量1000万
0.01); // 误判率1%
// 查询接口改造
Order getOrder(String orderId) {
if (!bloom.mightContain(orderId)) {
// 布隆过滤器判定不存在,直接返回
return null;
}
// 可能存在的才走缓存/DB逻辑
Object cache = redis.get("order:" + orderId);
if (cache != null) return (Order) cache;
Order order = db.query(...);
if (order != null) redis.set("order:" + orderId, order, 3600);
return order;
}
要点:
- 布隆过滤器只增不删,订单ID不会失效(业务上订单永久有效)
- 误判率设1%,内存占用约12MB(1000万元素),可接受
- 启动时全量加载,可用离线任务生成快照
验证与性能对比
用JMeter压测,模拟100万请求,其中80%是合法ID,20%是不存在的恶意ID。
- 裸Redis+DB(无防护):
QPS 4000,DB连接池打满,平均RT 850ms,错误率12% - 空值缓存方案:
QPS 12000,DB QPS降至2000,平均RT 45ms,错误率0% - 布隆过滤器方案:
QPS 18000,DB QPS仅800,平均RT 22ms,错误率0%
最终采用双方案结合:布隆过滤器拦截99%的非法请求,空值缓存兜底1%的误判请求,DB QPS稳定在500以下,接口RT稳定在20ms以内。
实战总结
缓存穿透不是高深技术,但选择方案要看业务场景:
- 数据量小且固定:空值缓存足够
- 数据量大且只增不减:布隆过滤器更省内存
- 两者结合是生产环境标准配置
关键是用压测数据说话,而不是凭感觉优化。