最近公司有个项目遇到了高峰期数据库压力爆表的情况,我带着团队排查三天三夜后发现问题的罪魁祸首竟然是——缓存穿透。你可能听说过缓存雪崩、缓存击穿这些常见的缓存问题,但缓存穿透这种不起眼的问题一旦发作,真的能让你的数据库瞬间崩溃。今天我们就来重点聊聊,怎么用正确姿势给MySQL数据库穿上防弹衣。
一、当数据库变成筛子的时候发生了什么
上周三凌晨三点,我们的订单系统突然收到大量"查无此单"的请求。这些请求有个共同特点:都在查询不存在的订单号。Redis缓存层被这些非法请求轻松绕过,直接穿透到MySQL数据库,导致数据库CPU飙到98%,查询响应时间从平时的20ms变成恐怖的3秒。
![示意图:大量无效请求穿透缓存攻击数据库]
(这里原要求不放图片,故以文字描述示意图内容)
这种情况就叫做典型的缓存穿透攻击——大量不存在的请求导致缓存层失效,直击数据库。这时我们采取了三步走策略,在半小时内成功止损:
- 即时扩容MySQL集群节点
- 临时启用空值缓存策略
- 部署布隆过滤器防护层
二、救命的三板斧方案
2.1 布隆过滤器:第一道防火墙
(技术栈:Java + Spring Boot + Guava BloomFilter)
布隆过滤器就像高速路口的电子扫描仪,能瞬间识别可疑车辆。我们在网关层部署的这个解决方案,可以有效拦截98%的非法请求。
// 初始化布隆过滤器(这里使用Guava实现)
BloomFilter<String> orderFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.forName("UTF-8")),
1000000, // 预期元素量
0.01 // 误判率
);
// 系统启动时加载有效订单号
List<String> validOrders = orderDao.getAllOrderIds();
validOrders.forEach(orderFilter::put);
// 查询前的过滤检查
public Order getOrder(String orderId) {
if (!orderFilter.mightContain(orderId)) {
log.warn("非法订单号拦截:{}", orderId);
return null;
}
// 正常查询流程...
}
这个方案的优缺点十分明显:
- ✅ 优点:内存消耗极低(百万数据仅需1MB),拦截效率高达万次/秒
- ❌ 缺点:存在1%的误判率需要兜底方案配合,初始化需要全量数据
2.2 互斥锁方案:给缓存上锁
(技术栈:Redis + Redisson分布式锁)
当多个请求同时查询同一个不存在的数据时,用分布式锁确保只有一个请求能访问数据库,其他请求等待缓存重建。
public Order getOrderWithLock(String orderId) {
// 尝试从缓存获取
Order order = redisTemplate.opsForValue().get(orderId);
if (order != null) return order;
// 获取分布式锁
RLock lock = redissonClient.getLock("ORDER_LOCK:" + orderId);
try {
if (lock.tryLock(2, 5, TimeUnit.SECONDS)) {
// 双重检查缓存
order = redisTemplate.opsForValue().get(orderId);
if (order == null) {
// 查数据库
order = orderDao.getById(orderId);
// 即使为空也要缓存
redisTemplate.opsForValue().set(orderId,
order == null ? "NULL" : order,
5, TimeUnit.MINUTES);
}
}
} finally {
lock.unlock();
}
return order;
}
这个方案需要注意锁的粒度——建议使用订单号级别的细粒度锁,避免影响正常查询。在实测中,这种方案将QPS从600降到了150,但数据库压力明显缓解。
2.3 空值缓存:给不存在的请求留位置
(技术栈:Redis + Spring Cache)
给查询结果为空的请求也建立短期缓存,设置较短的过期时间来应对突发流量。
# application.yml配置
spring:
cache:
redis:
time-to-live: 300s # 正常缓存5分钟
cache-null-values: true # 允许缓存空值
additional-key-prefix: "NULL_" # 空值缓存前缀
在代码实现时需要注意:
- 空值缓存的过期时间要设置随机抖动(如5分钟±随机30秒)
- 需要配套的缓存清理机制,在数据真实产生时及时清除空值标记
三、进阶防御:组合拳才是王道
3.1 混合防护体系
在实际生产环境中,我们构建了这样的防护体系:
请求 -> API网关(BloomFilter) -> 本地缓存 -> Redis缓存 -> 分布式锁 -> MySQL
这种层级防护结构,使我们的系统在618大促期间,面对峰值15万QPS的订单查询请求时,MySQL的CPU始终稳定在30%以下。
3.2 智能限流策略
(技术栈:Sentinel + Nginx)
对于频繁请求同一非法ID的情况,我们通过以下规则进行熔断:
// Sentinel规则配置
List<FlowRule> rules = new ArrayList<>();
FlowRule rule = new FlowRule();
rule.setResource("orderQuery");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(50); // 单个ID每秒最大请求量
rules.add(rule);
FlowRuleManager.loadRules(rules);
同时配合Nginx的limit_req模块,实现全局速率限制:
http {
limit_req_zone $arg_orderId zone=order_limit:10m rate=30r/s;
server {
location /order {
limit_req zone=order_limit burst=50;
}
}
}
四、血的教训:这些坑千万别踩
布隆过滤器不是万能的
去年双十一就发生过新订单无法查询的故障,原因是布隆过滤器没有及时更新。现在我们采用异步更新机制,订单创建成功后通过RabbitMQ消息更新过滤器。空值缓存的幽灵
曾出现用户下单后查询不到订单,结果发现是空值缓存未及时清除。现在我们采用双重删除策略:下单成功后立即删除缓存,并通过监听binlog进行二次校验。锁的超时陷阱
早期出现过线程获取锁后因Full GC导致锁超时释放,其他线程拿到锁后又重复查询。现在我们的锁超时时间设置为业务时间的3倍,并增加了重试次数限制。
五、性能对比报告
我们针对三种方案进行了压力测试(8核16G服务器集群):
| 方案 | 1000QPS数据库负载 | 10000QPS响应时间 | 内存占用 |
|---|---|---|---|
| 裸奔方案 | CPU 95% | 3200ms | - |
| 布隆过滤器单层防护 | CPU 45% | 120ms | 12MB |
| 混合防护方案 | CPU 22% | 80ms | 58MB |
测试数据清晰表明,混合方案虽然增加了30%的内存消耗,但整体性能提升显著。
六、总结与展望
经过半年多的实战检验,我们的防护体系经受住了各种突发流量的考验。但缓存穿透防护永远没有终点,下一步我们计划:
- 引入机器学习算法识别异常请求模式
- 实现动态布隆过滤器自动扩容
- 开发智能热点预测系统
记住,好的防护系统就像洋葱——要有很多层。当单一防护失效时,其他层次仍然能提供保护。别等到数据库崩溃了才想起做防护,现在就动手给你的系统穿上盔甲吧!
评论