那天凌晨3点,我被刺耳的告警电话惊醒------线上核心服务全部超时,Redis集群CPU飙到100%。紧急回滚后查日志,发现罪魁祸首竟是一段潜伏半年的KEYS *查询。这个价值百万的教训,今天必须分享给你。
一、灾难现场:一个"无害"的统计需求
我们的广告竞价系统需要统计当前活跃campaign数量(约200万键),某位同事(好吧就是我)在后台脚本中写了这样的代码:
java
// 错误写法:直接使用KEYS命令
Set<String> activeCampaigns = redisTemplate.keys("campaign:active:*");
log.info("当前活跃活动数: {}", activeCampaigns.size());
在测试环境运行毫无压力,上线后前几个月也风平浪静。直到某天业务暴增,键数量突破500万时------整个Redis集群被这个查询打满单核100%长达8秒,引发连锁雪崩。
二、为什么KEY命令是核弹?
你可能想问:"一个查询而已,至于吗?" 这要从Redis的单线程模型说起:
- 阻塞式遍历 :
KEYS命令会遍历整个键空间(复杂度O(N)),在500万键时实测耗时可达1200ms(测试环境SSD磁盘,生产环境更慢) - 排队效应 :由于Redis单线程处理命令,这个耗时操作会阻塞所有后续请求,包括简单的
GET操作
用redis-cli --latency测试对比更直观:
| 命令 | 键数量 | 平均耗时 | 期间其他请求延迟 |
|---|---|---|---|
GET foo |
500万 | 0.2ms | 正常 |
KEYS campaign:* |
500万 | 1.2s | 全部超时 |
三、救火与根治方案
临时救火:用SCAN替换KEYS
java
// 正确写法:使用SCAN迭代
Set<String> activeCampaigns = new HashSet<>();
ScanOptions options = ScanOptions.scanOptions()
.match("campaign:active:*")
.count(1000) // 每次扫描数量
.build();
Cursor<String> cursor = redisTemplate.scan(options);
while (cursor.hasNext()) {
activeCampaigns.add(cursor.next());
}
但SCAN只是治标------它依然会消耗CPU,只是改为分片执行不阻塞主线程。真正的根治方案是:
终极方案:维护专门计数索引
java
// 用SET维护活跃campaign ID集合
redisTemplate.opsForSet().add("index:active_campaigns", campaignId);
// 获取数量只需要O(1)操作
Long count = redisTemplate.opsForSet().size("index:active_campaigns");
实测性能对比:
| 方案 | 500万键耗时 | 对主线程影响 |
|---|---|---|
| KEYS | 1200ms | 完全阻塞 |
| SCAN | 累计800ms | 轻微延迟 |
| 预维护集合 | 0.05ms | 无影响 |
四、避坑指南:这些雷你也可能踩
- 模糊查询陷阱
KEYS是同步阻塞,SCAN是异步非阻塞但仍有开销- 生产环境所有模糊查询必须走专用索引
- 自以为的小数据量
- "我的业务键很少"------3个月后可能增长100倍
- 所有需要遍历的操作必须有熔断机制
- 测试环境侥幸心理
- 测试数据量不足生产1%------压测必须用等比数据
- 用
DEBUG SLEEP 1模拟长命令测试雪崩效应
- 监控盲区
- 慢查询日志要监控所有超过10ms的操作
- 对
SCAN也要设置调用频率限制
五、血的教训总结
永远不要在生产环境使用KEYS------即使你今天的数据量看起来很小。所有需要遍历的操作,都应该预先设计访问路径。
现在轮到你了:你们团队是如何防止这类问题的?是代码审核流程、还是自动化检测?欢迎在评论区分享你的实战经验。