1. 当缓存遇见穿透时会发生什么?
最近我们在电商大促监控中发现,每当秒杀活动开启后,PolarDB的CPU使用率会突然飙升到90%以上,但此时Redis缓存命中率却只有20%。经过日志分析发现,这是由于大量请求都在查询"当前用户是否拥有抢购资格"的状态,而实际上很多请求对应的是从未参与活动的无效用户ID。
这就是典型的缓存穿透场景:当一个不存在的数据被高频请求时,缓存层和数据库都会被穿透。没有防护的情况下,每个请求都会直接落到数据库。根据我们的测试,10万次/s的无效请求就能让PolarDB主节点CPU负载增加50%。
2. 布隆过滤器方案实现
示例技术栈:Java + Spring Boot + Guava BloomFilter + Jedis
// 初始化布隆过滤器(预期包含1亿元素,误判率0.1%)
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
100_000_000,
0.001
);
// 预热过滤器(从PolarDB加载有效用户ID)
String sql = "SELECT user_id FROM active_users";
List<String> activeUserIds = jdbcTemplate.query(sql, (rs, rowNum) -> rs.getString("user_id"));
activeUserIds.forEach(userId -> bloomFilter.put(userId));
// 查询拦截逻辑
public boolean checkUserExists(String userId) {
if (!bloomFilter.mightContain(userId)) {
return false; // 快速拦截不存在请求
}
// 存在则继续后续逻辑...
}
布隆过滤器的内存占用优势非常明显:存储1亿元素仅需约114MB内存,相较于存储完整键值,空间节省率超过90%。不过需要注意误判率的累积效应,当实际元素数量超过预设值时,误判率会成指数级上升。
3. 空值缓存动态补偿机制实现
当布隆过滤器发生误判时(存在性判断错误),需要通过空值缓存作为第二道防线:
public String getProductDetail(String productId) {
// 优先尝试获取缓存
String cacheKey = "product:" + productId;
String value = jedis.get(cacheKey);
if (value != null) {
return "NONE".equals(value) ? null : value; // 处理空值标记
}
// 穿透情况下查询数据库
Product product = productMapper.selectById(productId);
if (product == null) {
// 设置短周期的空值缓存(5分钟)
jedis.setex(cacheKey, 300, "NONE");
return null;
}
// 正常缓存数据(1小时)
jedis.setex(cacheKey, 3600, product.toJson());
return product;
}
这里存在一个关键的时间平衡:空值缓存的过期时间设置过长会导致数据更新不及时,设置过短会削弱防护效果。我们建议结合业务场景动态调整,例如商品信息设置5-10分钟,用户信息设置为30分钟。
4. 双重防护联合方案
将两种方案组合使用时,完整的查询流程应该包含三级校验:
public Product getProductWithProtection(String productId) {
// 第一层:内存级布隆过滤器
if (!bloomFilter.mightContain(productId)) {
log.warn("BloomFilter阻断非法请求:{}", productId);
return null;
}
// 第二层:空值缓存检查
String cachedData = jedis.get("product:" + productId);
if ("NONE".equals(cachedData)) {
return null;
} else if (cachedData != null) {
return parseProduct(cachedData);
}
// 第三层:数据库查询
Product dbResult = productMapper.selectById(productId);
if (dbResult == null) {
// 回填空值缓存
jedis.setex("product:" + productId, 300, "NONE");
// 注意此时不应更新布隆过滤器
return null;
}
// 数据存在时回填布隆过滤器
if (!bloomFilter.mightContain(productId)) {
// 异步更新布隆过滤器
updateExecutor.execute(() -> bloomFilter.put(productId));
}
// 设置正常缓存
jedis.setex("product:" + productId, 3600, dbResult.toJson());
return dbResult;
}
这里的并发控制需要特别注意:当多个线程同时遇到缓存穿透时,应该使用Redis的SETNX命令防止缓存击穿。我们通过引入分布式锁机制来保证数据一致性:
// 数据库查询前获取锁
String lockKey = "lock:product:" + productId;
if (jedis.setnx(lockKey, "1") == 1) {
jedis.expire(lockKey, 10); // 设置锁有效期
try {
// 二次校验缓存
String doubleCheck = jedis.get("product:" + productId);
if (doubleCheck == null) {
// 执行数据库查询
}
} finally {
jedis.del(lockKey);
}
} else {
// 等待锁释放后重试
Thread.sleep(50);
return getProductWithProtection(productId);
}
5. 性能压测对比
使用JMeter对三种场景进行测试(测试环境:PolarDB 8核32G,Redis集群3节点):
| 场景 | QPS | P99延迟 | DB查询次数/s |
|---|---|---|---|
| 无防护 | 1200 | 850ms | 980 |
| 单独布隆过滤器 | 4500 | 120ms | 45 |
| 双重防护 | 6800 | 65ms | 12 |
双重防护方案在缓存穿透场景下表现优异,特别是对于突发的大规模无效请求(模拟5万/s的随机UUID查询),数据库负载始终稳定在10%以下。
6. 应用场景分析
6.1 典型适用场景
- 高并发查询场景:如电商中的商品库存查询、票务系统的座位状态查询
- 动态数据校验场景:社交平台的用户关系检查(如A是否关注B)
- 时序性数据场景:物联网设备的状态查询(当设备未激活时)
6.2 特殊场景处理
对于需要频繁变更的列表型数据(如热门话题排行榜),建议采用以下优化方案:
// 布隆过滤器版本管理
AtomicInteger filterVersion = new AtomicInteger(0);
void refreshFilter() {
BloomFilter<String> newFilter = createNewFilter();
// 原子切换版本
int currentVersion = filterVersion.get();
filterVersion.compareAndSet(currentVersion, currentVersion + 1);
}
boolean checkValid(String key) {
int versionSnapshot = filterVersion.get();
return bloomFilterMap.get(versionSnapshot).mightContain(key);
}
这种版本化设计可以实现过滤器热更新,避免在更新期间出现服务中断。
7. 技术优缺点对比
7.1 布隆过滤器优势边界
- 内存效率:存储1亿元素仅需114MB(0.1%误判率)
- 查询性能:内存级响应(100ns内完成判断)
- 但存在假阳性率,且不支持删除操作
7.2 空值缓存的适应范围
- 准确拦截已确认的无效请求
- 实现简单,无需预加载数据
- 但可能引发短期数据不一致
8. 五大实施注意事项
- 容量规划:布隆过滤器的预期元素数量建议按实际业务量的2倍预估
- 误判监控:在过滤器后增加审核日志,统计实际误判率
- 缓存雪崩防护:空值缓存的过期时间应加入随机因子(如300±60秒)
- 数据更新策略:数据库写入时需要同步更新布隆过滤器
- 监控埋点:重点关注穿透率、布隆过滤器命中率、空值缓存占比
9. 运维最佳实践
生产环境建议采用Redis的RedisBloom模块(原生支持布隆过滤器),对比测试发现其性能比Guava实现提升3倍以上:
# RedisBloom操作示例
BF.RESERVE user_filter 0.001 100000000
BF.ADD user_filter user123
BF.EXISTS user_filter user123
集群环境下需要进行key分片设计,比如按业务前缀进行哈希分片,避免单个过滤器过大导致性能下降。
10. 最终方案总结
经过三个月的生产环境验证,在PolarDB的缓存穿透防护中,双重方案相比单一方案效果显著:
- 数据库无效查询减少99.8%
- Redis内存占用仅增加15%(主要来自空值缓存)
- 99.9%的请求在5ms内完成校验
未来的优化方向包括:基于机器学习的动态参数调整,自适应调整布隆过滤器参数;以及研究新型数据结构如Cuckoo Filter(支持删除操作)的应用可能。
评论