☰
Redis通用命令深度解析:从DEL阻塞到SCAN遍历的实战避坑指南
2026/10/12 22:44:30 网站建设 项目流程

提到 Redis,大多数人入门时先学的就是那一小撮看起来最简单的命令:SET、GET、DEL、EXPIRE。在文档里它们被归在 Generic Commands 这一组,中文习惯叫"Redis 通用命令"。说实话,我刚工作那会儿也觉得这十几个命令没什么深度,真正用熟只需要半天。直到后来在线上因为一条 DEL 把主线程卡了两秒,因为 KEYS 让一个几百万 key 的实例差点进入不可服务状态,又因为 TTL 返回的 -1 和 -2 被我记反,排障时白白绕了一个大弯,我才意识到:通用命令恰恰是整个 Redis 命令体系里最容易被低估的一类。

这篇文章想聊聊我自己整理 Redis 通用命令的完整思路:每个高频命令的语义边界、参数组合、阻塞风险,以及在日常开发和线上运维中应该遵守的操作纪律。适合刚学完 Redis 五种数据结构、想深入理解命令底层行为的读者,也适合已经写过一阵子 Redis、想系统查漏补缺的朋友。文中涉及的命令演示我用的是 Redis 7.x 的行为,兼容 4.0 以上的绝大部分场景。

1. 通用命令在 Redis 命令体系里的特殊位置

1.1 两级抽象:数据结构命令与通用命令

Redis 的命令体系可以粗分成两层。底层是各数据结构的专属命令,比如字符串的 INCR、哈希的 HSET、列表的 LPUSH、集合的 SADD、有序集合的 ZADD,这些命令只对特定类型有意义。上层则是本文要聊的通用命令,它们不关心 value 的内部类型,直接作用在 key 这一抽象层:检查 key 是否存在、删除 key、查看剩余过期时间、给 key 设置过期时间。

这个分层非常关键。很多新手把精力都花在背数据结构命令上,对通用命令的语义反而一知半解。但在实际项目里,通用命令的使用频率远高于任何一组数据结构命令。判断缓存 key 是否命中、清理临时数据、排查线上问题、评估实例内存水位,靠来靠去都是这十几个命令。

我经常跟团队里的新人说一句话:数据结构命令决定你能用 Redis 做什么,通用命令决定你能不能把 Redis 用得稳。前者是功能边界,后者是运维底线。

1.2 为什么容易出问题的偏偏是它们

通用命令作用在 key 抽象层,这意味着一条命令的影响范围可能横跨所有数据类型。DEL 一个 string key 和 DEL 一个包含上百万字段的 hash key,从命令格式上看毫无区别,内部工作量却是天壤之别。KEYS 在一个几百万 key 的实例上做模糊匹配,会让单线程的 Redis 完全停下来处理匹配逻辑,期间所有读写请求全部排队。

另一个容易被忽略的问题是,通用命令的返回值经常携带"暗号"。TTL 的 -1 和 -2 含义不同,EXISTS 一次传多个 key 返回的是存在数量,SCAN 的游标从 0 开始、回到 0 才算真正遍历结束。这些细节如果不亲自验证,只靠印象去猜,排障时很容易被误导。

2. SET/GET/DEL 的边界和隐藏参数:读写命令的完整语义

2.1 SET 的 NX、XX、EX 参数:一个命令当三个用

SET 的完整语法很多人只记得SET key value这一种形态。实际它还有几个高频参数值得认真掌握:

SET key value [NX | XX] [EX seconds | PX milliseconds] [GET]
  • NX:仅当 key 不存在时写入,相当于"新建"语义。
  • XX:仅当 key 已存在时写入,相当于"更新"语义。
  • EX / PX:同时设置过期时间,单位分别是秒和毫秒。
  • GET:写入前返回旧值,如果旧值不是字符串会报错。

我最常用来做的是分布式锁的最小实现:SET lock:order:1001 token NX EX 30。这一条命令同时完成"判断不存在、写入、设置超时"三个动作,原子性天然成立。早期不少教程还在推荐"先用 SETNX 再用 EXPIRE"的两步操作,这两步之间如果进程崩溃,锁就会变成永不释放的僵尸锁。现在完全没有必要再这么写。

XX 参数则适合做缓存覆写。比如只允许已存在的 key 被更新,防止并发环境下先把旧值覆盖了。配合 GET 参数还能拿到旧值做对比,省一次额外查询。

2.2 GET 的类型限制:不是所有值都能直接读

GET 不是真正"通用"的读命令,它只对 string 类型生效。如果你对一个 hash 或 list 类型的 key 执行 GET,Redis 会返回:

WRONGTYPE Operation against a key holding the wrong kind of value

这个报错在开发环境不常见,但在线上很常见。原因通常是代码里某个 key 的前缀被复用了,不同业务模块往同一个 key 里写了不同类型的值。我之前就遇到过一个缓存 key,平时存的是 JSON 字符串,某次发版后一段新代码用 HSET 往里塞了 hash,结果所有读这个 key 的接口全部报 WRONGTYPE。

遇到这种报错,第一反应不是改代码,而是先用TYPE key确认线上 key 的真实类型,再决定是清理数据还是修正代码。

2.3 DEL 的 O(N) 风险和 UNLINK 异步删除

DEL 的复杂度需要掰开看。删除字符串类型的 key 是 O(1),但如果删的是一个包含几十万元素的 list、hash、set 或 zset,Redis 需要在主线程里逐个释放这些元素的内存,整体复杂度是 O(M),M 是元素数量。在元素量大时,这条 DEL 足以让 Redis 主线程阻塞几十毫秒甚至几秒。

我实际碰到过一次:某项目在夜间任务里清理一批临时 hash key,其中一个 key 里有八十多万个字段。DEL 执行时,监控图上 ops 直接掉到接近 0,持续了大概一点几秒。好在是低峰期,没有造成明显事故,但那次之后我就把项目里所有删大 key 的操作统一改成了 UNLINK。

UNLINK 是 Redis 4.0 引入的异步删除命令,它把释放内存的动作交给后台线程处理,主线程只负责把 key 从键空间里摘掉,返回值依然是删除是否成功。对调用方来说,UNLINK 和 DEL 的返回效果一致,但阻塞风险大幅下降。如果你的 Redis 版本是 4.0 以上,建议把所有可能删除大 key 的场景都默认用 UNLINK。也可以通过配置lazyfree-lazy-user-del yes让 DEL 直接走异步逻辑,不过我更推荐显式使用 UNLINK,代码意图更清楚。

2.4 批量操作的 MSET/MGET 与 Pipeline 取舍

循环 100 次调用 SET 意味着 100 次网络往返。在网络延迟 1ms 的数据中心内部环境下,100 次就是 100ms;如果客户端和 Redis 之间有公网延迟,这个数字会更难看。批量操作的第一个选择是 MSET / MGET:

MSET user:1001:name "张明" user:1001:age "28" user:1001:city "上海" MGET user:1001:name user:1001:age user:1001:city

MSET 是一条命令写入多个 key,MGET 一条命令读出多个 key,往返次数从 N 次降到 1 次,吞吐提升立竿见影。

但 MSET / MGET 只能覆盖 string 类型,也不能处理像"每个 key 独立设置过期时间"这样的个性化需求。这时候可以用 Pipeline,把一组 SET、EXPIRE、DEL 等命令打包在一次网络请求里发给 Redis,Redis 依次执行后把结果一次性返回。

Pipeline 的注意点有两个。第一,它不具备事务性,中间某条命令失败不影响其他命令执行,不要把它当作原子批量操作。第二,Pipeline 打包的命令数不是越多越好。我之前见过有人一个 Pipeline 塞了两万条命令,客户端缓冲区被撑爆,直接掉线。一般建议单批控制在几百到一两千条,具体以压测为准。

3. 过期生命周期:EXPIRE、TTL、PERSIST 的返回值暗号

3.1 设置过期的三种方式与选择

给 key 设置过期时间,常见写法有三种:

  • SET key value EX 3600
  • SETEX key 3600 value
  • SET key value之后再调用EXPIRE key 3600

前两种都是原子完成"写入 + 设置过期",我更推荐第一种,因为它的参数形式统一,后续如果要把过期时间改成毫秒级别,只需把 EX 换成 PX,其他逻辑不动。至于第三种分开写的方式,存在一个时间窗口:写完值之后、设置过期之前,如果进程崩溃,这个 key 就成了永不过期的脏数据。

如果需要动态调整过期时间,可以直接对已有 key 调用 EXPIRE。Redis 7.0 还支持给 EXPIRE 追加 NX、XX、GT、LT 参数,用于只在特定条件下更新过期时间,比如 LT 表示"仅当新过期时间比当前更早时更新",适合做滑动续期类逻辑,但日常用得不多。

3.2 TTL 的三种返回值:正数、-1、-2

TTL 是查看 key 剩余存活时间的命令,返回值的语义有三个分支:

  • 正数:剩余存活秒数。
  • -1:key 存在,但没有设置过期时间。
  • -2:key 不存在。

我见过不少人在排障时把 -1 和 -2 搞混。-1 只代表"没设过期",key 本身还在;-2 才是"这个 key 根本没存在过"。判断"缓存是否命中"应该用 EXISTS 或 GET,而不应该依赖 TTL 的返回值,因为一个永远不过期的 key 用 TTL 看是 -1,非常容易误判为"查不到数据"。

如果要移除 key 的过期时间,让它永久存活,用 PERSIST。这在某些"临时 key 转长期 key"的场景下有效,但操作前要确认这个 key 是否真的不需要过期策略,否则它会一直在内存里占位置。

3.3 固定过期时间引发的缓存雪崩与随机偏移

一个经典问题:批量缓存预热时,所有 key 都设置 3600 秒过期。它们在同一时刻写入,也会在同一时刻过期。过期瞬间,大量请求同时回源数据库,数据库压力瞬间拉满,这就是缓存雪崩的一种典型成因。

解决办法不是不用缓存,而是把过期时间做一个随机偏移:

SET user:1001:profile "..." EX 3600 SET user:1002:profile "..." EX 3720 SET user:1003:profile "..." EX 3550

实际操作中,代码里可以给基础过期时间加一个 0 到 600 秒的随机数,让过期时间分散在一个区间内。我在某电商促销活动里用过这个方案:基础 TTL 定在 300 秒,随机增加 0 到 300 秒,缓存过期回源引起的数据库峰值明显比固定 TTL 时平缓很多。

3.4 过期触发机制与超大 key 过期阻塞

Redis 的 key 过期依赖两种机制:惰性删除和主动删除。惰性删除是当某个 key 被访问时,发现它已过期就顺手删除;主动删除是后台定时采样一批已过期的 key 并清理。

但有一个细节容易被忽略:如果一个大 key 到了过期时间被主动删除,释放这块大内存的操作同样会发生在主线程里,跟 DEL 大 key 一样可能造成阻塞。Redis 4.0 引入的惰性释放机制也覆盖了这个场景,通过lazyfree-lazy-expire yes可以让过期 key 的内存释放转移到后台线程。建议在内存型业务里开启这个配置,代价很小,收益很实在。

4. 键管理命令的暗礁:KEYS、SCAN、TYPE、RENAME 的实战教训

4.1 KEYS 的全局阻塞与 SCAN 游标方案

KEYS pattern 可能是通用命令里最危险的一个。它需要遍历整个键空间做正则匹配,在几百万 key 的实例上执行时,Redis 主线程会被一直占住,直到匹配完成。这个时间可能是几秒甚至十几秒,期间所有读写请求都排在后面。

生产环境的标准做法是禁用 KEYS,改用 SCAN。SCAN 是游标式遍历,每次只返回一小批 key,配合下一次调用传入的游标继续迭代,不会一次性把所有 key 都扫描出来。

这里要记住三个语义:

  • 第一次调用游标从 0 开始,每次返回新的游标,当返回的游标是 0 时表示遍历完成。
  • COUNT 参数只是提示,不是精确保证,实际每次返回的 key 数量可能不同。
  • 遍历过程中 key 可能被新增或删除,SCAN 不保证结果完全一致,也可能返回重复 key。

4.2 SCAN 批量删除指定前缀键的标准循环

我经常需要在线上清理一批带相同前缀的临时 key,比如tmp:*。正确姿势是 SCAN + 批量 UNLINK,而不是先 KEYS 再 DEL。伪代码逻辑如下:

cursor = "0" loop: cursor, keys = SCAN cursor MATCH "tmp:*" COUNT 1000 if len(keys) > 0: UNLINK keys... if cursor == "0": break

需要注意两点。第一,返回的 key 可能有重复,考虑到 UNLINK 对不存在的 key 只是返回 0,重复执行没有副作用,所以通常不需要专门去重。第二,如果线上流量很高,SCAN 对 CPU 仍有开销,建议在低峰期执行,并且控制 COUNT 不要太大。

4.3 TYPE 与 EXISTS:操作前的身份确认

TYPE key 返回 key 对应的数据类型,常见值是 string、list、set、zset、hash、stream、none。它是一个 O(1) 命令,成本很低,适合在异常排查时第一时间确认 key 的真实类型。

EXISTS key [key...] 的返回值是数字,表示指定的一批 key 中有多少个存在。如果只传一个 key,返回值就只有 0 或 1。这一点在监控告警里很有用,比如写脚本每分钟检查三个核心缓存 key,EXISTS 返回 2 就说明有一个丢了,可以直接触发告警。

4.4 RENAME 覆盖与 RENAMENX 的取舍

RENAME key newkey 会把 key 改名成 newkey,如果 newkey 已经存在,原来的值会被直接覆盖,没有任何提示。这个覆盖行为在特定场景下有风险:比如两个模块共用同一个 key 的命名约定,一段脚本把某个 key 重命名到另一个业务正在使用的名字上,就等于把对方的数据瞬间清空了。

如果希望只在目标 key 不存在时重命名,用 RENAMENX。它在业务上有一种典型用法:实现"抢占式迁移",把旧 key 改到新名字,但同时保证不会覆盖别人正在写入的数据。虽然实际场景不多,但知道这个区别可以避免写代码时凭直觉用错命令。

5. 服务器运维:INFO、CONFIG、FLUSH、SELECT 的操作纪律

5.1 INFO 的黄金指标:内存、连接、持久化

INFO 命令返回整个实例的运行状态,按段输出:server、clients、memory、persistence、stats、replication、cpu、keyspace 等。排查问题的时候我最关注的指标有三个。

第一个是内存段。used_memory表示当前分配的内存总量,used_memory_rss是操作系统视角下的常驻内存,两者差距大通常说明内存碎片化,maxmemory则决定 Redis 何时开始走淘汰策略。当used_memory接近maxmemory时,就要赶紧查大 key 和过期清理情况。

第二个是客户端段。connected_clients是当前连接数,blocked_clients是阻塞等待中的连接数。如果 blocked_clients 长期不为 0,说明有命令在阻塞主线程,比如 BLPOP 等待数据,或者某条慢命令卡住了 Redis。

第三个是 stats 段的evicted_keys。这个数字表示因内存达到上限而被淘汰的 key 数量。如果 evicted_keys 持续上涨,意味着内存压力很大,大量缓存被强制驱逐,缓存命中率会直线下降。

5.2 SELECT 多库与 db0 之争

Redis 默认自带 16 个逻辑库,可以用 SELECT 切换。很多初学者喜欢把不同业务的数据放在不同 db 里,比如 db0 放缓存、db1 放 session。我不建议在生产环境这么做。

原因有几个。第一,SCAN、KEYS、FLUSHALL 这类命令的粒度和 db 并不总是好用,比如排查问题时要在不同 db 之间反复切换,非常别扭。第二,db 只是逻辑隔离,底层内存是共享的,一个 db 的内存压力照样拖垮整个实例。第三,使用多 db 后,误操作的概率显著上升,比如有人想在 db1 执行 FLUSHDB,结果 SELECT 没切过去,把 db0 的业务数据清了。

我见过的绝大多数成熟团队都只用 db0,业务隔离靠 key 前缀区分,实例隔离靠部署多套 Redis。这样操作边界清晰,也方便后续做集群扩展,因为 Redis Cluster 本身就不支持跨 db。

5.3 FLUSHDB/FLUSHALL 的同步阻塞与 ASYNC 参数

FLUSHDB 清空当前 db,FLUSHALL 清空整个实例。这条命令可以在毫秒级清掉全部数据,但它默认是同步的:把这么多 key 和内存一次性释放,主线程会卡住相当久,实例越大卡得越久。Redis 4.0 开始,FLUSHALL 和 FLUSHDB 都支持 ASYNC 参数:

FLUSHALL ASYNC

加上 ASYNC 后,数据清理交给后台线程,主线程立刻返回。

我自己的操作纪律是:任何环境都不手工执行不带 ASYNC 的 FLUSHALL。如果一定要清库,先确认当前环境,再确认实例角色,最后再加上 ASYNC。曾经某测试环境被人直接 FLUSHALL,因为实例上有大量测试数据,同步清理让 Redis 停顿了两三秒,虽然测试环境无所谓,但这个习惯一旦养成,换到生产环境就是灾难。

另外,SLOWLOG GET 可以查慢命令历史,如果怀疑有人执行过 FLUSHALL,慢日志里通常会留下记录,这也是事后排查的有力线索。

5.4 CLIENT LIST 与连接排障

CLIENT LIST 会输出当前所有客户端连接的信息,包括客户端地址、连接名称、db 编号、最后执行的命令、输入缓冲区大小等。接口偶发超时、连接数异常飙高的场景,第一件事就是执行它,看看连接从哪里来、在做什么、缓冲区有没有被撑大。

配合 CLIENT KILL 可以定向清理异常连接。比如某个客户端因为 bug 一直保持空闲连接不放,连接数打满 maxclients,可以按地址批量杀掉。线上排障时这个命令比重启实例温和得多,至少不会丢数据。

6. 把这些命令串起来:一次典型的缓存故障排错

6.1 场景一:缓存突然大面积失效

现象:接口报错率上升,数据库查询量翻倍,缓存命中率骤降。

排障路径:先INFO stats看expired_keys和evicted_keys。如果expired_keys在短时间窗口内集中增长,说明大量 key 在同一时间段过期,回源请求集中打到了数据库。接着用 SCAN 抽查几个业务 key 的 TTL,确认它们的过期时间是否高度集中在同一秒附近。

这类问题的修复方向是把过期时间加随机偏移,或者把缓存 Preload 逻辑改成渐进式预热,不要同一时刻全部写入。

6.2 场景二:接口超时与连接堆积

现象:Redis 本身 CPU 不高,但客户端大量超时,INFO clients里connected_clients很高,blocked_clients不为 0。

排障路径:先CLIENT LIST,重点看有没有连接正在执行耗时命令,再SLOWLOG GET拉最近的慢命令。如果有 KEYS、DEL 大 key、FLUSHALL 这类记录,问题基本就定位到了。

我当时遇到的情况是一条 DEL 大 key 导致主线程卡了 800ms,所有在这段时间内发起的命令全部排队,客户端等不到响应就批量超时。修复方案是把删除改成 UNLINK,并给清理任务加上限流。

6.3 场景三:大 key 导致的周期性 CPU 抖动

现象:实例 CPU 每隔一段时间出现尖峰,伴随阻塞命令。

排障路径:用redis-cli --bigkeys扫描实例中体积最大的 key,或者用DEBUG OBJECT key查看某个 key 的serializedlength,快速定位大 key。找到之后评估这个 key 是否还需要完整存在于 Redis,如果只是临时聚合数据,可以拆分成多个小 key,或者用哈希分片的方式降低单个 key 的体积。

这类问题的根因通常不是某个命令用错了,而是数据结构设计没有考虑单 key 容量的上限。通用命令只是帮你把它暴露出来。

7. 一些通用命令的使用习惯

7.1 前缀规范与脚本化清理

通用命令里最需要配合"约定"来使用的是 SCAN、DEL 这类批量操作。我建议所有 Redis key 都带业务前缀,比如order:lock:*、user:profile:*、tmp:*。有了前缀规范,清理临时数据、监控特定业务缓存、统计缓存规模都只需要一条 SCAN 就能圈定范围。没有前缀规范,你连"这个 key 是谁写的"都查不清楚。

批量清理逻辑最好写成脚本或工具函数,固定使用 SCAN + UNLINK 的循环模式,不要每次手工敲 KEYS 再删。脚本里还可以加一个"确认环境"的开关,比如必须传入环境参数才能执行,避免在错误的实例上跑了清理任务。

7.2 线上危险操作的自我保护

涉及 FLUSHALL、大量 DEL、RENAME 这类可能造成数据丢失或阻塞的命令,我个人的习惯是"三个先":先在预发环境完整演练一遍,先确认实例角色和连接地址,先在命令末尾加上 ASYNC 或使用 UNLINK 这类异步版本。

另外,尽量避免直接在命令行手工敲删除类命令。人都可能打错字,把user:*打成一个更宽的前缀,一次误操作要恢复可能需要从备份重新同步。把操作收敛到脚本、工具甚至内部管理平台里,比靠个人记忆力靠谱得多。

7.3 简化记忆:把通用命令按功能分组

最后分享一个记忆方法。我从不孤立地背通用命令,而是按功能把它们分成四组:

  • 读写删除组:SET、GET、MSET、MGET、DEL、UNLINK
  • 生命周期组:EXPIRE、TTL、PERSIST、SETEX
  • 键查询组:EXISTS、TYPE、KEYS、SCAN、RENAME
  • 运维管理组:PING、INFO、CONFIG、CLIENT、FLUSHDB、FLUSHALL、DBSIZE

分组之后,遇到问题能快速定位该用哪一组。新人在排查线上问题的时候,往往不是不知道某个命令,而是不知道此刻该调出哪一组的命令。把这个分组挂在脑子里,排障思路会清晰很多。

我这几年在 Redis 通用命令上踩过的坑,几乎都集中在"高估命令的简单性"这件事上。一个看起来人畜无害的 DEL,可能因为 key 的规模变成阻塞炸弹;一个看起来功能完善的 KEYS,可能因为键空间太大拖垮整个实例。如果你能把每个通用命令的返回值、复杂度、阻塞边界都像背自己名字一样熟悉,线上 Redis 的稳定性和可维护性会提升一个档次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询