跳到主内容

一次 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 ​

治理这类问题,核心不是“以后小心点”,而是用默认安全的操作方式替换危险命令,并在服务端切断误用路径。

客户端发送 KEYS主线程遍历全库O(N) 阻塞一次性返回结果客户端发送 SCAN携带游标扫描少量槽位分批可中断返回新游标循环到游标为 0

SCAN 是基于游标的渐进式遍历命令,每次只扫描一部分哈希槽,并返回下一次游标。它单次执行成本可控,但也要注意两点:第一,SCAN 只保证弱一致性,遍历过程中如果 key 发生变化,可能重复返回,也可能漏掉新建 key;第二,COUNT 是提示值而非严格数量,集合编码不同,实际返回数量会有明显差异。

对比项KEYSSCAN
时间复杂度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 不是银弹,治理要闭环 ​

  1. 不要把 COUNT 当成 LIMIT。 常见错误是设置 COUNT 100000 以为能“扫快一点”,结果单次仍扫描大量字典槽位,造成延迟尖刺。修正方法是从 100~500 开始压测,结合 slowlog 和实例 QPS 调整,高峰期间宁可增加轮次。

  2. 不要假设 SCAN 结果完整且不重复。 遍历期间 key 过期、删除或扩容重哈希,都可能导致重复返回或遗漏。清理脚本必须把删除设计成幂等操作;需要精确统计时,应在从库、离线实例或业务元数据表中完成,不能依赖线上 SCAN 的瞬时结果。

  3. 不要对大 key 使用 DEL。 现象通常是删除某个 hash 或 zset 时 Redis RT 突增,UNLINK 后明显缓解。修正方法是先用 MEMORY USAGE key、HLEN、ZCARD 等判断规模,大集合使用 HSCAN、SSCAN、ZSCAN 分批 HDEL、SREM、ZREMRANGEBYRANK 清理,或在低峰异步删除。

  4. 不要只靠口头规范禁止 KEYS。 人员轮换、临时脚本、管理台裸连接都会让规范失效。必须在服务端通过 ACL 或重命名拦截,并在代理层、堡垒机审计和慢日志告警中形成第二道防线。

  5. 不要在主库高峰执行批量清理。 即使使用 SCAN,大量删除也会产生过期、淘汰和复制压力。更稳妥的方式是优先设置 TTL,让 key 自然过期;确需清理时,在业务低峰小批量执行,并观察主从延迟、CPU、内存和连接数。

这次事故的最终改造结果,是把“人工排查命令”收敛到运维平台:模式巡检默认走 SCAN,删除默认走 UNLINK,业务账号无法执行 KEYS 和 FLUSH 类命令。真正的缓存治理不是记住某条命令危险,而是让危险路径在架构上不可达,让批量操作天然限速、可审计、可回滚。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。