Redis 很快,但“快”并不等于所有命令都是 O(1)

一次 GET 通常很轻,一次 ZRANGE 可能返回十万个成员,一次 SINTER 可能扫描几个大集合,一次 DEL 也可能为了释放大 key 在主线程上忙很久。它们都只是一条 Redis 命令,对实例造成的压力却完全不同。

这也是线上 Redis 延迟偶尔尖刺时最容易被忽略的地方:QPS 没有明显上涨,CPU 看起来也不算很高,但某个请求做了一次与数据规模相关的 O(N) 操作,后面的请求就一起排队了。

这篇文章按 Redis 的常用数据类型整理时间复杂度。目的不是背完整张命令表,而是学会看到命令时迅速判断两件事:它会扫描多少数据,又会返回多少数据。

如果还不熟悉 Hash、quicklist、intset、listpack 和 skiplist 的关系,可以先看Redis 底层数据结构。底层结构正是这些复杂度的来源。

读复杂度之前,先认识几个变量

后面的表格会反复使用这些字母:

变量含义
K一次命令传入的 key 数量
N当前集合、列表、Hash 或 ZSet 的元素总数
M本次返回、删除或处理的元素数量
S为了到达起始位置需要跨过的元素数量
B需要读取、复制或传输的字节数

比如 ZRANGE rank 0 9 的复杂度是 O(log N + M):先在有序结构中定位起点,再返回 M 个成员。排行榜里有一百万个人并不可怕,只取前十名依然很轻;真正危险的是 ZRANGE rank 0 -1,因为这时 M 也变成了一百万。

还有三个容易被大 O 记号藏起来的事实。

第一,Redis 文档里的复杂度主要描述服务端查找和处理数据的成本。GET 标为 O(1),不表示读取 1 KB 和 500 MB 的 value 耗时完全一样。大字符串还要复制并通过网络传输,真实成本至少与字节数有关。

第二,O(1) 表示成本不会随集合元素数量增长,不表示它一定只花 1 微秒。序列化、内存分配、网络拥塞和系统调度都会影响实际延迟。

第三,很多核心命令的执行仍然会占住 Redis 的主执行路径。一个很慢的命令不仅让自己慢,还会让后面的轻命令排队。因此评估命令时,不能只看调用者自己的超时,还要看它会阻塞整个实例多久。

先建立整体印象

不看具体命令,Redis 的常用访问模式大致可以归成四类:

复杂度常见操作使用直觉
O(1)按 key 或 member 精确查询、两端操作 List通常稳定,但仍要防大 value
O(log N)ZSet 插入、删除、查排名数据变大后增长缓慢,通常可控
O(M)批量返回或删除 M 个元素一定要控制结果数量
O(N) 及以上全量遍历、集合运算、模糊扫描数据小时没感觉,大 key 上风险很高

一个实用的判断顺序是:

先看要定位多少元素
  -> 再看要返回多少元素
  -> 再看要复制多少字节
  -> 最后看释放内存是否也发生在主线程

下面逐类展开。

Key 空间:找到 key 很快,遍历全部 key 很慢

Redis 数据库最外层是一张字典,按 key 精确查找平均是 O(1)。这让过期时间、类型检查、存在性判断都很轻,但一旦要求 Redis 查看整个 key 空间,复杂度就会变成 O(N)

命令时间复杂度说明
EXISTS key [key ...]O(K)每个 key 做一次存在性检查
EXPIRE / PEXPIREO(1)设置过期时间
TTL / PTTLO(1)读取剩余过期时间
PERSISTO(1)移除过期时间
TYPEO(1)返回 value 的对外类型
RANDOMKEYO(1)返回一个随机 key
KEYS patternO(N)扫描数据库中的全部 key
SCAN cursor单次通常 O(1),完整遍历 O(N)渐进式扫描,不会一次做完全部工作
DEL key [key ...]至少 O(K),还可能加上 value 的释放成本删除大集合可能阻塞主线程
UNLINK key [key ...]主线程 O(K),释放工作异步完成删除大 key 时通常比 DEL 友好

KEYS user:* 在测试环境里很方便,到了大库上却可能让 Redis 一口气遍历几百万个 key。生产环境通常应该使用 SCAN 分批迭代:

redis-cli SCAN 0 MATCH 'user:*' COUNT 1000

不过 SCAN 不是把 O(N) 变成了 O(1)。它只是把一次长遍历拆成很多次短遍历,完整扫完仍然是 O(N),而且 COUNT 只是每次工作的提示值,不保证严格返回指定数量。

DEL 也值得单独留意。删除 String key 很轻;删除包含几十万个元素的 Hash、Set、ZSet 或 List,需要释放大量内存对象。UNLINK 会先把 key 从字典中摘掉,再由后台线程回收内存,更适合删除大 key。

String:命令常是 O(1),value 大小仍然要算

String 的定位非常直接,因此最常见的读写、计数和长度查询都是 O(1)

命令时间复杂度说明
GET / SETO(1)文档按 key 查找计;大 value 仍有复制和网络成本
MGET / MSETO(K)与 key 数量线性相关
INCR / DECR / INCRBYO(1)适合原子计数
STRLENO(1)SDS 直接保存长度
APPEND摊销 O(1)小块追加时成立,扩容和大参数仍需复制
GETRANGEO(M)M 是返回的字节长度
SETRANGE通常 O(M)M 是写入参数的字节长度;小值时可近似看成 O(1)

MGET 比循环执行多个 GET 少很多网络往返,但它的计算量和返回数据量没有消失。一次取 1000 个 1 KB value,至少要产生约 1 MB 响应;一次取 1000 个 1 MB value,就已经不是“只是一个 MGET”了。

所以批量命令通常应该同时限制两件事:key 数量和总字节数。

Bitmap 也是 String,范围操作要看字节数

Redis Bitmap 底层仍然是 String。单个 bit 的随机访问很快,全量统计则需要扫描字符串:

命令时间复杂度说明
GETBIT / SETBITO(1)直接定位某个 bit
BITCOUNTO(B)扫描指定范围内的字节
BITPOSO(B)最坏需要扫描整个范围
BITOPO(B)与最长输入字符串的长度相关

位图能把大量布尔状态压得很紧,但“内存小”不代表所有操作都是常数时间。对多年累计的超长活跃位图执行全量 BITCOUNT,仍然会占用明显的 CPU 时间。按日期拆 key,通常更容易限制单次扫描范围。

Hash:单 field 很快,全量取出仍是 O(N)

Hash 很适合保存对象字段。大 Hash 通常用哈希表实现,按 field 查找平均 O(1);小 Hash 可能采用 listpack,内部会顺序查找,但 Redis 通过编码阈值把这种紧凑结构限制在较小规模内。

命令时间复杂度说明
HGET / HEXISTSO(1)精确访问一个 field
HSET每个 field-value O(1),共 O(M)M 是本次写入字段数
HDELO(M)M 是本次删除字段数
HMGETO(M)M 是请求字段数
HLEN / HSTRLENO(1)长度信息直接维护
HINCRBY / HINCRBYFLOATO(1)原子修改单个字段
HGETALL / HKEYS / HVALSO(N)遍历整个 Hash
HSCAN单次通常 O(1),完整遍历 O(N)适合渐进处理大 Hash

最常见的危险写法是把一个不断增长的业务集合塞进 Hash,然后每次用 HGETALL 取回。开始只有几十个 field 时毫无问题,几年后变成几十万个 field,同一条命令就会同时制造服务端遍历、结果分配和大网络响应。

如果调用方只需要几个字段,就用 HMGET;如果确实要离线遍历全部字段,就用 HSCAN 分批处理。

List:两端是 O(1),越往中间走越贵

Redis List 的底层是 quicklist。它很擅长从头尾插入和弹出,却不适合频繁随机访问中间位置。

命令时间复杂度说明
LPUSH / RPUSH每个元素 O(1),共 O(M)从两端插入
LPOP / RPOP单个 O(1);带 countO(M)M 是返回元素数
LLENO(1)长度直接维护
LINDEX最坏 O(N)首尾元素接近 O(1),中间位置需要遍历
LSET最坏 O(N)定位后再修改;首尾位置较快
LPOS平均 O(N)按值顺序搜索,可用 MAXLEN 限制
LRANGE start stopO(S + M)先走到起点,再返回 M 个元素
LINSERTO(N)需要找到 pivot
LREMO(N + M)扫描列表并删除 M 个匹配项
LTRIMO(M)M 是被移除的元素数
LMOVEO(1)从一个列表端点移动到另一个列表端点

LRANGE queue 0 99LRANGE queue 0 -1 看起来只差一个参数,风险却完全不同。前者最多返回 100 条,后者会返回全部元素。

List 适合队列、最近记录和两端操作。如果业务经常按 ID 找中间元素,或者删除某个任意位置的对象,Hash 保存内容、ZSet 保存顺序通常比强行使用 List 更合适。

Set:成员判断很轻,集合运算要看所有输入

Set 的精确成员判断平均是 O(1),这也是它适合去重、标签和关系判断的原因。危险通常出现在“取出整个集合”或“让多个大集合做运算”。

命令时间复杂度说明
SADD / SREMO(M)每个成员平均 O(1)
SISMEMBERO(1)判断一个成员
SMISMEMBERO(M)判断 M 个成员
SCARDO(1)返回集合基数
SMOVEO(1)在两个 Set 之间移动一个成员
SPOP / SRANDMEMBER不带 countO(1);带 countO(M)与返回数量相关
SMEMBERSO(N)返回全部成员
SSCAN单次通常 O(1),完整遍历 O(N)渐进式遍历
SUNION / SDIFFO(T)T 是所有输入集合的元素总数
SINTER最坏 O(Nmin × K)Nmin 是最小集合大小,K 是集合数量

集合运算的结果即使很小,也不代表过程一定很轻。求交集时,Redis 至少要从较小集合出发检查成员是否存在于其他集合;集合数量越多,检查次数越多。

如果只想知道交集大小而不需要成员,可以使用 SINTERCARD,避免把整个结果通过网络传回。但它仍然需要做集合检查,只是减少了结果构造和传输成本。

SMEMBERSSSCAN 的关系与 KEYSSCAN 类似:前者一次性返回,后者分批遍历。大 Set 上优先考虑 SSCAN

ZSet:单次定位是 O(log N),范围返回还要加 M

ZSet 既需要按 member 找 score,又需要维持 score 顺序。大 ZSet 通常组合哈希表和跳表,所以很多写操作与排名操作是 O(log N)

命令时间复杂度说明
ZADD / ZINCRBY每个成员 O(log N)插入或调整分数
ZSCOREO(1)通过 member 查 score
ZMSCOREO(M)查询 M 个 member
ZRANK / ZREVRANKO(log N)查询排名
ZCARDO(1)返回成员数量
ZCOUNT / ZLEXCOUNTO(log N)返回范围内成员数量
ZRANGE 及按 score/lex 范围查询O(log N + M)定位起点,再返回 M 个成员
ZREMO(M log N)删除 M 个成员
ZPOPMIN / ZPOPMAXO(M log N)弹出 M 个成员
ZREMRANGEBYRANK / ZREMRANGEBYSCOREO(log N + M)M 是被删除成员数

排行榜最典型的分页查询:

ZRANGE rank 0 99 REV WITHSCORES

即使 rank 有一千万个成员,只要返回数量固定为 100,复杂度仍然接近 O(log N + 100)。这正是 ZSet 擅长的场景。

但如果用 ZRANGE rank 0 -1 导出全部排行榜,M 会变成整个 ZSet 的大小。WITHSCORES 还会增加返回字节数。线上接口应该限制页大小,大规模导出则分段执行。

多个 ZSet 运算为什么更重

加权并集和交集还需要读取多个输入并构造有序结果:

命令最坏时间复杂度变量含义
ZUNIONO(T + M log M)T 为输入总元素数,M 为结果数
ZINTERO(Nmin × K + M log M)最小集合大小、集合数和结果数共同决定成本
ZDIFF与输入总量及首个集合大小相关输入越大、结果越大,成本越高

这类命令不适合只看结果数量。即使最终只有几十条,也可能已经检查了几十万个候选成员。对大集合做临时聚合前,最好先估算输入基数,并限制调用频率。

Stream:追加很轻,读取成本与返回消息数相关

Stream 为消息日志、消费者组和待确认消息提供了专门结构。常用操作的复杂度如下:

命令时间复杂度说明
XADD通常 O(1)如果同时触发裁剪,还要加上删除旧消息的成本
XLENO(1)返回 Stream 长度
XRANGE / XREVRANGEO(M)M 是返回消息数
XREAD / XREADGROUP每个 Stream 约 O(M)应使用 COUNT 限制单次返回量
XACK / XDEL每个 ID O(1)批量处理 M 个 ID 时为 O(M)
XPENDING摘要模式接近 O(1);范围查询 O(M)使用 IDLE 过滤还可能扫描更多记录
XTRIMO(M)M 是被淘汰的消息数

XADD 常被标成 O(1),但如果同时执行精确裁剪并删除大量旧消息,本次成本就会升高。使用近似裁剪:

XADD events MAXLEN ~ 100000 * field value

通常比每次严格维持准确长度更省事,因为 Redis 可以按内部节点批量回收。

阻塞式 XREADXREADGROUP 还有一个额外维度:新消息到达时,Redis 需要唤醒等待该 Stream 的客户端。单次返回消息不多,也要留意同一个 Stream 上是否挂着大量阻塞消费者。

HyperLogLog、Geo 与 Pub/Sub

这些结构不是每个项目都会用,但它们的复杂度也有很鲜明的特点。

HyperLogLog

命令时间复杂度说明
PFADD每个元素 O(1)常数成本稳定
PFCOUNT keyO(1)单个 HLL 大小固定,但常数并非零
PFCOUNT key1 key2 ...O(K)需要合并多个 HLL,常数成本较大
PFMERGEO(K)与输入 HLL 数量相关,常数成本较大

HyperLogLog 能用固定空间估算基数,所以它不随“见过多少个不同元素”无限增长。但合并多个 key 时仍要扫描每个 HLL 的寄存器,不能把多个 key 的 PFCOUNT 当成免费的 O(1)

Geo

Geo 底层建立在 ZSet 上:

命令时间复杂度说明
GEOADD每个位置 O(log N)ZADD 相近
GEOPOS / GEODIST每个成员 O(1)精确按 member 查询
GEOSEARCH与候选区域和结果数量相关范围越大、候选点越多,成本越高

地理范围查询不是固定 O(1)。半径从 1 公里改成 1000 公里,可能让候选点数量完全换一个量级。线上接口应该设置合理半径和 COUNT 上限。

Pub/Sub

PUBLISH 的复杂度不是单纯 O(1),而是大致与接收消息的订阅客户端数量和系统中的模式订阅数量相关:

PUBLISH ≈ O(订阅者数量 + pattern 订阅数量)

一个频道只有几个订阅者时几乎感觉不到;扇出到大量客户端时,消息投递和输出缓冲区都会产生明显成本。

Pipeline、事务和 Lua 不会消灭命令复杂度

Pipeline 能把多次网络往返合并,但服务端仍然要逐条完成命令。把 1000 次 HGETALL 放进 Pipeline,不会让 1000 次全量遍历变成 O(1),反而可能瞬间制造很大的响应缓冲区。

MULTI/EXEC 让一组命令连续执行,也不减少其中任何一条命令的工作量。如果事务里有慢命令,其他客户端要等整个事务结束。

Lua 脚本的复杂度则近似等于:

脚本自身循环成本 + 所有 redis.call 的成本 + 返回结果成本

Lua 为读、判断、写提供原子性,但原子性的代价正是脚本执行期间其他命令不能插入。脚本里循环调用一万次命令,或者遍历一个无上限的大集合,会比单独发出这些命令更危险,因为整个过程被绑成了一段连续阻塞时间。

关于 Pipeline 与 Lua 的具体边界,可以看Redis Pipeline 与 Lua

最容易出问题的七类写法

学完所有表格,线上真正需要优先搜索的通常是下面几种模式:

KEYS *
HGETALL 一个不断增长的大 Hash
SMEMBERS 一个大 Set
LRANGE key 0 -1
ZRANGE key 0 -1
DEL 一个包含海量元素的 key
Lua 中遍历无界数据

它们的共同点不是“命令名字危险”,而是单次工作量没有上限

同一个 HGETALL,对 20 个 field 的 Hash 完全正常,对 200 万个 field 的 Hash 就可能成为事故。复杂度分析最后一定要落到实际基数、元素大小和调用频率上。

如何在生产环境里控制风险

第一,给结果集设上限。

列表、排行榜、Stream 和地理查询都应该分页或使用 COUNT / LIMIT。不要让调用方通过一个参数就能请求全部数据。

第二,全量处理改成渐进处理。

key 空间用 SCAN,Hash 用 HSCAN,Set 用 SSCAN,ZSet 用 ZSCAN。渐进式扫描仍有总成本,但能把长时间阻塞拆开。扫描过程中数据还可能变化,因此它更适合巡检、统计和可容忍重复的后台任务,不适合要求强一致的快照导出。

第三,删除大 key 优先考虑 UNLINK

它能把真正的内存释放放到后台。若实例经常产生和删除大 key,还要监控后台释放速度和内存峰值,不能只看命令已经返回成功。

第四,先了解 key 到底有多大。

可以使用这些工具做抽样和排查:

redis-cli --bigkeys
redis-cli --memkeys
redis-cli SLOWLOG GET 20
redis-cli LATENCY DOCTOR
redis-cli INFO commandstats

--bigkeys--memkeys 本身也需要扫描 key 空间,应在了解生产压力后谨慎运行。SLOWLOG 记录的是命令在 Redis 内部的执行时间,不包含网络传输;大结果集造成的客户端慢和网络慢,还要结合流量、输出缓冲区与客户端延迟一起看。

第五,把复杂度和调用频率一起算。

一次 O(N) 命令每天离线跑一次,和每个请求都跑一次,是两种完全不同的系统。反过来,一条 O(1) 命令每秒执行几十万次,也一样需要容量规划。

最后怎么记

不必背下所有命令,只要记住 Redis 数据结构的访问方向:

  • String、Hash、Set 的精确 key/member 查询通常是 O(1)
  • List 的头尾操作是 O(1),随机访问和查找通常是 O(N)
  • ZSet 的插入、删除、排名通常是 O(log N),范围查询还要加上返回数量 M
  • 所有“返回全部”“扫描全部”“集合运算”都要警惕 O(N) 或更高。
  • DEL、大响应和大字符串会带来复杂度表之外的内存释放、复制和网络成本。
  • Pipeline、事务和 Lua 只能改变传输或执行边界,不能让底层工作凭空消失。

真正有用的不是看到 O(N) 就一律禁止,而是给 NM 建立边界。只要单次工作量有上限,Redis 的延迟就更容易预测;一旦允许数据无限增长,再快的命令也可能在某一天突然变慢。

参考资料