外观
一次 KEYS 阻塞线上事故复盘:SCAN 改造与危险命令禁用清单
事故背景:一条 KEYS 命令拖垮整个 Redis 实例
凌晨促销活动开始后,应用侧 Redis 连接池被迅速打满,接口 RT 从几十毫秒飙升到数秒,监控显示 Redis CPU 长时间达到 100%,slowlog 中赫然出现一条执行超过 2.8 秒的命令:
bash
# 运维同学为了确认某批临时 key 是否残留,直接在线上执行
127.0.0.1:6379> KEYS order:tmp:*
1) "order:tmp:10001"
2) "order:tmp:10002"
...
# 此时该实例中已有约 4200 万个 key,命令遍历全库,期间主线程无法处理其他请求根因并不复杂:Redis 命令在单线程事件模型中执行,KEYS pattern 会一次性遍历整个键空间,时间复杂度为 O(N),N 是库中全部 key 数量,而不是匹配结果数量。命令返回前,其他所有命令都在排队。数据量小时它看似无害,一旦 key 规模到千万级,就足以造成典型的线上事故。
方案对比:为什么生产环境应使用 SCAN
治理这类问题,核心不是“以后小心点”,而是用默认安全的操作方式替换危险命令,并在服务端切断误用路径。
SCAN 是基于游标的渐进式遍历命令,每次只扫描一部分哈希槽,并返回下一次游标。它单次执行成本可控,但也要注意两点:第一,SCAN 只保证弱一致性,遍历过程中如果 key 发生变化,可能重复返回,也可能漏掉新建 key;第二,COUNT 是提示值而非严格数量,集合编码不同,实际返回数量会有明显差异。
| 对比项 | KEYS | SCAN |
|---|---|---|
| 时间复杂度 | O(N),一次遍历全库 | 分批执行,单次扫描有限数据 |
| 阻塞风险 | 高,key 越多越危险 | 低,但 COUNT 过大仍有压力 |
| 返回方式 | 一次性返回全部匹配 key | 游标迭代,循环直到游标为 0 |
| 一致性 | 执行时刻的强一致快照 | 弱一致,可能重复或遗漏 |
| 适用位置 | 仅适合离线小型实例 | 生产环境按模式巡检、清理 |
实操步骤一:用 SCAN 安全定位与删除 key
先复现一批测试数据,注意下面示例应在测试库执行:
bash
# 写入 10 个临时订单 key,并设置过期时间
127.0.0.1:6379> MSET order:tmp:1 a order:tmp:2 b order:tmp:3 c order:tmp:4 d
OK
# 查看 key 类型,SCAN 只负责找 key,删除前仍要判断类型和大小
127.0.0.1:6379> TYPE order:tmp:1
string
# 从游标 0 开始,MATCH 设置模式,COUNT 只是每轮建议扫描量
127.0.0.1:6379> SCAN 0 MATCH order:tmp:* COUNT 100
1) "0"
2) 1) "order:tmp:3"
2) "order:tmp:1"
3) "order:tmp:4"
4) "order:tmp:2"生产清理不要直接拼接 xargs DEL,更不要在业务高峰执行。推荐使用脚本分批处理,并对每批数量、休眠时间和大 key 做保护:
bash
#!/usr/bin/env bash
# safe_scan_delete.sh:分批扫描并删除 Redis key
# 用法:./safe_scan_delete.sh "order:tmp:*" 200 10000
# 参数:匹配模式、每批删除数量、每批休眠微秒数
REDIS_CLI="redis-cli -h 127.0.0.1 -p 6379"
PATTERN="$1"
BATCH="${2:-200}"
SLEEP_US="${3:-10000}"
cursor=0
while true; do
# 读取一行游标和多行 key,SCAN 输出顺序不保证稳定
result=$($REDIS_CLI SCAN "$cursor" MATCH "$PATTERN" COUNT "$BATCH")
cursor=$(echo "$result" | head -n1)
keys=$(echo "$result" | tail -n +2)
if [ -n "$keys" ]; then
# 关键数据结构为 hash/list/set/zset 时,应先评估元素数量,避免 DEL 大 key 阻塞
echo "$keys" | xargs -r -n "$BATCH" $REDIS_CLI UNLINK
fi
# 主动让出时间片,降低对在线流量的影响
usleep "$SLEEP_US"
# 游标重新变为 0 表示本轮遍历结束
[ "$cursor" = "0" ] && break
done这里使用 UNLINK 而不是 DEL:前者会在后台线程回收大对象内存,主线程只负责从键空间摘除引用;后者会同步释放内存。对于元素很多的 hash、set、zset 或大 list,DEL 本身也可能造成毫秒级甚至秒级抖动。
实操步骤二:禁用危险命令并建立权限边界
事故后我们在 Redis 配置中对高危命令做了重命名。相比简单 rename-command KEYS "",更推荐使用 Redis 6 ACL,给普通业务账号只开放必要命令:
bash
# redis.conf 中重命名高危命令,保留给紧急排障的复杂名称,需配合权限管控
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG "CONFIG_a8f3c9e2b1"
rename-command DEBUG ""
# Redis 6+ 推荐使用 ACL:业务账号禁止危险命令
user appuser on >StrongPassword ~order:* +@read +@write -flushdb -flushall -keys -config -debug
user ops on >OpsPassword ~* +@all -keys +scan +info应用启动后应进行失败验证:
bash
# 普通账号执行 KEYS 应被拒绝
127.0.0.1:6379> AUTH appuser StrongPassword
OK
127.0.0.1:6379> KEYS *
(error) NOPERM this user has no permissions to run the 'keys' command
# 巡检账号只能使用 SCAN,而不是全量遍历
127.0.0.1:6379> SCAN 0 MATCH order:tmp:* COUNT 100
1) "0"
2) (empty array)危险命令禁用清单至少应覆盖:KEYS、FLUSHALL、FLUSHDB、CONFIG、DEBUG、SHUTDOWN、SLAVEOF/REPLICAOF、SCRIPT LOAD、EVAL、MIGRATE、RESTORE、CLUSTER FAILOVER、CLUSTER RESET。其中有些命令并非永远不能使用,而是应从业务账号移除,只保留给受控的运维平台或紧急账号。
避坑总结:SCAN 不是银弹,治理要闭环
不要把 COUNT 当成 LIMIT。 常见错误是设置
COUNT 100000以为能“扫快一点”,结果单次仍扫描大量字典槽位,造成延迟尖刺。修正方法是从 100~500 开始压测,结合slowlog和实例 QPS 调整,高峰期间宁可增加轮次。不要假设 SCAN 结果完整且不重复。 遍历期间 key 过期、删除或扩容重哈希,都可能导致重复返回或遗漏。清理脚本必须把删除设计成幂等操作;需要精确统计时,应在从库、离线实例或业务元数据表中完成,不能依赖线上 SCAN 的瞬时结果。
不要对大 key 使用 DEL。 现象通常是删除某个 hash 或 zset 时 Redis RT 突增,
UNLINK后明显缓解。修正方法是先用MEMORY USAGE key、HLEN、ZCARD等判断规模,大集合使用HSCAN、SSCAN、ZSCAN分批HDEL、SREM、ZREMRANGEBYRANK清理,或在低峰异步删除。不要只靠口头规范禁止 KEYS。 人员轮换、临时脚本、管理台裸连接都会让规范失效。必须在服务端通过 ACL 或重命名拦截,并在代理层、堡垒机审计和慢日志告警中形成第二道防线。
不要在主库高峰执行批量清理。 即使使用 SCAN,大量删除也会产生过期、淘汰和复制压力。更稳妥的方式是优先设置 TTL,让 key 自然过期;确需清理时,在业务低峰小批量执行,并观察主从延迟、CPU、内存和连接数。
这次事故的最终改造结果,是把“人工排查命令”收敛到运维平台:模式巡检默认走 SCAN,删除默认走 UNLINK,业务账号无法执行 KEYS 和 FLUSH 类命令。真正的缓存治理不是记住某条命令危险,而是让危险路径在架构上不可达,让批量操作天然限速、可审计、可回滚。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。