Redis 数据结构:快速的 Redis 有哪些慢操作?

Redis 快,不代表每条命令都快。

一条命令的实际耗时,至少由以下几部分共同决定:

text 复制代码
实际耗时 ≈ 数据结构操作 f(N, M) + 响应处理成本 + 内存与系统副作用

算法复杂度描述的是 f(N, M) 随数据规模增长的趋势,实际的 N、M 决定本次命令到底要处理多少元素;返回字节数和内存管理等成本则不一定完整体现在命令文档的大 O 记号中。

因此,判断 Redis 操作是否安全,不能只记住"哈希表查找是 O(1)",而要同时理解两层数据结构:

  1. Redis 如何根据 Key 找到 Value。
  2. 找到 Value 后,如何在 Value 内部执行操作。

本文聚焦 String、List、Hash、Set、Sorted Set 和 Stream 等经典核心类型。内部编码以 Redis 7.x 和 8.x 的常见实现为主,并补充 Redis 7.2 的 Set listpack 和 Redis 8.10 的 Compact Hash;Redis 6.2 及更早版本使用的 ziplist 会单独说明。具体实例仍应以其版本文档和 OBJECT ENCODING 结果为准。

一、Redis 的两层数据结构

1. 第一层:Key 空间的全局字典

Redis 使用字典组织一个数据库中的 Key。字典底层是哈希表:先计算 Key 的哈希值,再根据哈希值定位哈希桶,最后在桶中查找对应的条目。

哈希条目保存 Key 以及指向 Value 对象的引用。Value 无论是字符串还是拥有大量成员的集合,都可以先通过同一套 Key 空间索引被找到。

在负载分布合理时,哈希表的新增、删除和精确查找平均具有 O(1) 时间复杂度。这里的 O(1) 只表示平均操作次数不会随 Key 总数线性增长,不表示每次请求的耗时绝对相同。

2. 哈希冲突:一个桶可能包含多个条目

不同 Key 可能映射到同一个哈希桶,这就是哈希冲突。Redis 的字典使用链式结构组织发生冲突的条目,查找时需要在桶内继续比较 Key。

如果装载因子持续升高,冲突增多,桶内查找成本也会增加。因此,字典不能无限保持原有桶数量,而需要扩容并重新分布条目。

3. 渐进式 rehash:把迁移成本拆开

rehash 期间,Redis 字典会同时保留旧表和新表:

  1. 新写入的数据进入新表。
  2. 查询和删除可能需要检查两张表。
  3. 字典操作会推进有限数量的桶迁移。
  4. Redis 的周期性维护也会在允许的时间预算内推进迁移。
  5. 旧表迁移完成后被释放,新表成为当前哈希表。

这种方式避免一次性搬迁全部 Key 导致长时间停顿,但 rehash 并不是零成本。迁移期间会暂时占用两张表的空间,每次字典操作也可能附带少量迁移工作。

原文中"每处理一个请求就固定迁移下一个桶"是一种便于理解的简化说法。更准确的理解是:Redis 把 rehash 拆成许多受控的小步骤,由字典操作和周期性维护逐步推进。

4. 第二层:Value 内部的数据结构

通过全局字典找到 Value 后,Redis 还要在 Value 内部执行命令。

例如:

  • GET 找到 String 后可以直接读取对象。
  • HGET 还要在 Hash 中查找字段。
  • ZRANGE 还要在 Sorted Set 中定位起点并遍历返回成员。
  • SMEMBERS 需要访问并返回 Set 的全部成员。

所以,一条命令的总成本可以近似理解为:

text 复制代码
Key 空间查找成本 + Value 内部操作成本 + 结果编码与传输成本

全局字典的平均 O(1) 只能说明第一步通常很快,不能代表所有命令都是 O(1)。

二、逻辑类型与内部编码

Redis 对外提供的是逻辑数据类型,对内使用的是具体编码。同一种逻辑类型可以根据数据规模和元素特征采用不同编码。

逻辑类型 常见内部编码 主要特点
String int、embstr、raw 根据内容和长度减少对象与内存分配开销
List 小型 List 可以使用 listpack;普通编码使用 quicklist,其节点可保存 listpack 兼顾两端操作、顺序遍历和内存利用率
Hash listpack、hashtable;Redis 8.10 还支持基于字段模板的 Compact Hash 在小对象、较大对象以及大量同构 Hash 之间优化空间和访问成本
Set intset、listpack、hashtable 纯整数小集合可用 intset;Redis 7.2 起小型非纯整数集合可使用 listpack
Sorted Set 小对象使用 listpack,较大对象使用 skiplist 常规大集合的 skiplist 编码还会配合字典完成成员查分值等操作
Stream 基数树组织的 listpack 适合按消息 ID 追加、定位和范围读取

1. 紧凑编码为什么省内存

listpack 把多个元素紧凑地放在连续内存中,减少指针和独立内存块的数量。连续布局通常还有更好的 CPU 缓存局部性。

代价是:在紧凑序列中查找中间元素通常需要遍历。Redis 只在元素数量和元素大小处于配置阈值内时使用这类编码;超过阈值后,会转换为更适合较大规模数据的普通编码。

编码转换可能发生在某次写操作中,因此某条看似普通的写命令,可能因为触发扩容或编码转换而比平时更慢。不要通过大幅提高紧凑编码阈值来盲目节省内存,应该先测试转换耗时和访问模式。

2. ziplist 与 listpack 的版本边界

较早的 Redis 版本使用 ziplist 保存小型 List、Hash 和 Sorted Set。Redis 7.0 起,这些类型使用 listpack 作为紧凑编码。List 可以表现为直接的 listpack 编码,也可以使用 quicklist;quicklist 节点中的紧凑容器也由旧版的 ziplist 演进为 listpack。

因此,学习旧资料时不能把"压缩列表"直接当作所有现代 Redis 版本的当前实现。理解其"连续、紧凑、遍历式访问"的设计思想仍然有价值,但排查具体实例时应使用 OBJECT ENCODING key 查看实际编码。

3. Redis 8.10 的 Compact Hash

Redis 8.10 为拥有相同字段集合的大量 Hash 增加了 Compact Hash 内部编码。多个 Hash 可以共享一份字段名模板,每个 Key 主要保存自己的字段值以及对模板的引用,从而减少重复字段名占用的内存。

它适合用户资料、订单、会话等字段集合相同且较稳定的对象。现有 Hash 命令的对外语义不变,但这说明现代 Hash 已经不再只是 listpack 与 hashtable 两种情况。排查 Redis 8.10 及之后版本时,还应结合 Compact Hash 配置和 INFO 中的模板统计指标。

4. 跳表为什么适合有序集合

普通链表查找元素需要顺序遍历,复杂度为 O(N)。跳表在有序链表上增加多层索引,使查找、插入和删除能够以平均 O(log N) 完成。

Sorted Set 不只需要按分值排序,还需要根据成员快速读取分值。因此,大型 Sorted Set 的普通编码同时利用跳表和字典:

  • 跳表支持按分值和排名进行有序访问。
  • 字典支持根据成员快速定位分值。

这说明一个逻辑类型可以组合多种底层结构,以同时满足不同查询路径。

三、怎样从复杂度判断命令成本

1. 单元素操作:通常较稳定

常见单元素操作包括:

  • GET、SET。
  • HGET、HSET。
  • SADD、SREM、SISMEMBER。
  • LPUSH、RPUSH、LPOP、RPOP。

这些命令在典型编码下通常具有 O(1) 或较低的对数复杂度。不过,即使复杂度是 O(1),超大的 String、极高的调用频率、内存分配和网络传输仍可能造成延迟。

2. 多元素参数:复杂度随参数数量增长

一次命令操作多个成员时,总成本会随参数数量 M 增长。例如一次添加多个集合成员,通常可以理解为每个成员执行一次基础操作,总复杂度为 O(M) 或 O(M log N)。

批量命令可以减少网络往返,但单批次过大又会延长一次命令占用执行路径的时间。合理做法是限制每批元素数量,而不是把所有数据塞进一个请求。

3. 全量操作:最容易形成阻塞

以下命令会遍历并返回整个集合:

命令 主要成本 风险
KEYS pattern 遍历当前数据库的 Key 空间 Key 多时阻塞主执行路径
HGETALL O(N),返回全部字段和值 大 Hash 会产生大响应
SMEMBERS O(N),返回全部成员 大 Set 会放大 CPU、内存和网络成本
LRANGE key 0 -1 O(N),返回整个 List 大 List 会产生大响应
ZRANGE key 0 -1 O(log N + M),M 为返回量 即使定位快,返回全部成员仍然昂贵

这类命令的危险不只是遍历本身。服务端还要创建响应、写入客户端输出缓冲区,客户端也要接收和反序列化大量数据。

4. 范围操作:是否慢取决于返回量

范围查询不一定慢。ZRANGE 的典型复杂度是 O(log N + M):定位范围起点的成本较低,真正决定耗时的往往是返回成员数量 M。

下面两条命令虽然形式相似,风险完全不同:

text 复制代码
ZRANGE leaderboard 0 99
ZRANGE leaderboard 0 -1

前者最多返回 100 个成员,后者可能返回整个排行榜。设计接口时应限制每页大小,并避免允许调用方无边界地请求结果。

对于 ZRANGE ... BYSCORE/BYLEX LIMIT offset count,很大的 offset 仍可能需要跳过大量元素。深度翻页更适合使用上一次结果中的分值、成员或业务游标继续读取。

List 范围读取还要考虑起始位置。LRANGE 的复杂度可以表示为 O(S + M),其中 S 是到起始位置的遍历距离,M 是返回元素数量。靠近表头或表尾的小范围读取可以很快,但深位置加大范围返回仍然昂贵。

5. 统计操作:为什么通常是 O(1)

LLEN、HLEN、SCARD 和 ZCARD 等命令通常不需要扫描全部元素,因为数据结构会维护成员数量。命令直接读取元数据即可返回结果。

这也是分析复杂度的重要方法:如果一个结果已经被数据结构维护,就不需要重新遍历计算。

四、Redis 的慢操作从哪里来

1. 高复杂度命令

集合求交并差、排序、按条件删除、全量扫描以及复杂脚本,都可能处理大量元素。常见例子包括 SORT、SINTER、SUNION、SDIFF、LREM、大范围 ZRANGE 和执行时间过长的 Lua 脚本或 Functions。

Redis 的核心命令执行路径主要保持串行语义。一条命令长时间占用执行路径时,后续客户端请求会排队。因此,一次几十毫秒的操作对低延迟系统已经可能产生明显影响。

2. BigKey:O(1) 也可能不够快

BigKey 不只指一个很大的 String,也包括成员数量巨大的 Hash、List、Set、Sorted Set 或 Stream。

BigKey 会同时放大:

  • 读取和序列化成本。
  • 网络传输与客户端反序列化成本。
  • 删除、过期和淘汰时的内存回收成本。
  • RDB、AOF、复制和集群迁移成本。

所以,BigKey 的判断应同时考虑字节数和成员数,而不是只看 Key 名称或逻辑类型。

3. 大响应:服务端执行快,客户端仍然觉得慢

SLOWLOG 记录的是命令在 Redis 服务端的执行时间,不包括向客户端传输响应等网络 I/O。

如果命令快速生成了一个巨大结果,慢日志未必明显,但客户端仍可能因为以下环节感到延迟:

  • 服务端编码响应。
  • 客户端输出缓冲区积压。
  • 网络带宽不足。
  • 客户端读取和反序列化耗时。

因此,诊断延迟不能只看 SLOWLOG,还要检查响应大小、客户端缓冲区和网络往返时间。

4. 删除与内存回收

删除一个 Key 不等于只从哈希表中移除一个条目。对于包含大量成员的集合,DEL 还需要释放 Value 内部的所有对象,单个 Key 的释放成本与成员数相关。

UNLINK 会先把 Key 从 Key 空间移除,再把适合异步处理的内存回收任务交给后台线程,可以降低主执行路径上的释放延迟。但它不表示内存立即全部归还:后台回收完成前,相关内存仍会暂时存在。

过期删除和内存淘汰同样可能遇到 BigKey 的释放问题。治理 BigKey 比事后选择删除命令更重要。

5. 编码转换与扩容

小型聚合对象从紧凑编码转换为普通编码、哈希表扩容、动态字符串重新分配等操作,都会给某次写请求增加额外工作。

这些操作通常被分摊或只在阈值附近发生,但如果单个对象被设计得过大,转换成本仍可能形成延迟尖峰。

五、慢操作的替代与控制方法

高风险用法 更合适的方式 仍需注意
KEYS pattern 使用 SCAN 分批遍历 完整遍历仍是 O(N),可能重复返回元素
大 Hash 使用 HGETALL 已知字段用 HMGET;遍历用 HSCAN COUNT 只是工作量提示,不是严格返回上限
大 Set 使用 SMEMBERS 使用 SSCAN,或调整数据模型 调用方需要处理重复结果和遍历期间的数据变化
LRANGE key 0 -1 限制范围,按业务批次读取 深位置访问和超大返回仍可能昂贵
ZRANGE key 0 -1 限制 M,按排名、分值或字典序分页 大 offset 可能增加遍历成本
DEL bigkey 优先评估 UNLINK 或启用合适的 lazy free 配置 后台释放仍消耗 CPU,内存不会瞬间下降
大集合求交并差 拆分、预计算、异步计算或调整数据模型 避免把重计算直接放在在线主请求路径

正确认识 SCAN

SCAN 把完整遍历拆成多次调用,使单次工作量更容易控制,但它没有消灭总工作量:

  • 单次调用通常工作量较小,完整遍历仍是 O(N)。
  • COUNT 是提示,不保证严格返回指定数量。
  • 一次调用可以返回零个元素,游标不为 0 就不能视为结束。
  • 完整遍历可能重复返回元素,调用方需要保证操作幂等或自行去重。
  • 遍历期间发生变化的元素可能返回,也可能不返回。
  • 对采用紧凑编码的小型 Hash、Set 或 Sorted Set,一次调用可能直接返回全部元素。

因此,SCAN 适合后台巡检、分批处理和允许弱一致遍历的任务,不适合需要稳定快照语义的业务查询。

六、如何发现慢操作

1. 先看命令复杂度

Redis 命令文档会标注时间复杂度,并解释 N、M 等变量代表什么。复杂度必须结合实际参数解释,不能只看大 O 记号。

例如:

  • HGET 是 O(1)。
  • HGETALL 是 O(N),N 为 Hash 字段数。
  • SMEMBERS 是 O(N),N 为 Set 成员数。
  • ZRANGE 是 O(log N + M),M 为返回成员数。

2. 使用运行时诊断入口

  • SLOWLOG GET:查看超过阈值的服务端命令执行记录。
  • LATENCY DOCTOR:分析 Redis 已记录的常见延迟事件;需要先配置非零的 latency-monitor-threshold 才能采集相关事件。
  • INFO commandstats:观察命令调用次数、累计 CPU 执行时间和每次平均 CPU 时间。
  • MEMORY USAGE key:估算单个 Key 占用的内存。
  • OBJECT ENCODING key:查看 Value 当前使用的内部编码。
  • redis-cli --bigkeys:按类型寻找成员多或字符串长的 Key,但它不是精确的全库内存排行榜。
  • redis-cli --memkeys:通过 SCAN 遍历 Key 空间,报告各类型内存占用较大的 Key 和平均大小;它仍不是完整的全库内存排序结果。
  • redis-cli --keystats:在较新的 redis-cli 中结合 --bigkeys 与 --memkeys 的信息,并补充 Key 大小分布和 Top Key;客户端版本较旧时可能没有该选项。

诊断时要区分三种时间:命令真正执行的时间、请求等待前序命令的排队时间、客户端观察到的网络往返时间。

七、快速判断清单与答案

1. 只要复杂度是 O(1),命令就一定安全吗?

不是。 O(1) 不包含对象字节数、调用频率、网络响应大小和内存分配等固定项。读取一个超大 String,即使查找复杂度是 O(1),仍可能产生大响应和明显延迟。

2. 使用 SCAN 后,遍历就不再是 O(N) 了吗?

不是。 SCAN 只是把总工作量分摊到多次调用,完整遍历仍然是 O(N),并且不提供稳定快照。

3. 紧凑编码一定比普通编码更快吗?

不是。 紧凑编码主要节省内存并改善缓存局部性,但中间元素查找通常需要遍历。Redis 只在对象较小时使用它,以平衡空间与时间。

4. BigKey 只是超大的 String 吗?

不是。 成员数量巨大的集合也是 BigKey,而且更容易在全量读取、删除、过期、持久化和迁移时放大成本。

不一定。 Key 会先从 Key 空间移除,但实际对象释放可能在后台继续进行,内存下降存在延迟。

6. 慢日志没有记录,就能排除 Redis 延迟吗?

不能。 SLOWLOG 不包含响应传输时间,还可能漏掉排队、网络、大输出缓冲区、持久化抖动和操作系统调度等延迟来源。

八、核心结论

Redis 的速度来自合适的数据结构和短小的执行路径,而不是所有命令都具有固定低延迟。

判断一条命令是否会变慢,应依次确认:

  1. Key 空间查找之后,Value 内部还要执行什么操作。
  2. 命令复杂度中的 N、M 在本次请求中到底有多大。
  3. 返回多少元素和多少字节。
  4. 是否触发扩容、编码转换或大量内存释放。
  5. 这段执行时间是否会让后续请求排队。

真正需要规避的不是所有 O(N) 命令,而是没有边界的 O(N)、无法控制的 BigKey 和一次返回过多数据的请求。

相关推荐
数据库小学妹1 小时前
数据共享交换平台选型:交换方式对比与避坑指南
数据库·信创·数据同步·数据交换平台·政务数据共享·数据共享交换平台·数据库底座
H.莓飛1 小时前
【数据结构】二叉树_OJ题
linux·开发语言·数据结构·算法
好想像大佬一样能够ak所有2 小时前
Leetcode35.搜索插入位置
数据结构·算法·leetcode
loong_XL3 小时前
决策模型做内容安全检测:从踩坑到上线
数据库·安全·jev·决策模型
chenbingjie_c3 小时前
C++ STL 适配器:stack & queue,从 vector/list 到 deque 深度解析
数据结构·c++
这个DBA有点耶3 小时前
数据库双轨并行实战:全量并行策略、增量延迟控制、双向回切与一致性校验
数据库·架构·dba
adinnet20264 小时前
保单、赔付与渠道问数:保险经营数据如何实现按需查询
大数据·数据库·人工智能
云贝贝贝4 小时前
PostgreSQL 分区表:从设计到运维,大表不再卡
运维·数据库·postgresql
oradh4 小时前
Oracle固定执行计划的方法---SQL Plan Baseline
数据库·sql·oracle