2. Redis 核心数据结构与性能基础:String、List、Hash、Set、ZSet 的底层编码与时间复杂度分析

Redis 之所以能成为企业级热点削峰场景的首选缓存组件,其核心优势不仅在于纯内存操作,更在于其五种基本数据结构(String、List、Hash、Set、ZSet)背后精巧的底层编码机制。理解这些编码方式及其对应的时间复杂度,是进行性能调优和系统设计的前提。

2.1 数据结构全景概览

Redis 对外暴露五种数据类型,每种类型在内部根据存储数据的特点(如数据大小、元素数量、元素类型)动态切换底层编码,以在内存占用和访问速度之间取得最佳平衡。

数据类型 对外接口 底层编码(Redis 7.0+) 核心应用场景
String 字符串、整数、浮点数 int、embstr、raw 计数器、分布式锁、Session 共享
List 双向链表 quicklist(ziplist + linkedlist 混合) 消息队列、时间线、最新列表
Hash 键值对映射 listpack、hashtable 对象缓存、购物车、用户属性
Set 无序唯一集合 intset、hashtable 标签系统、去重、抽奖
ZSet 有序唯一集合 listpack、skiplist + hashtable 排行榜、延迟队列、限流滑动窗口
性能要点: 在热点削峰场景中,String 和 Hash 是最常用的两种结构。String 适合简单值缓存,Hash 适合存储对象字段,能有效减少网络往返次数。

2.2 String:最基础的原子操作单元

String 是 Redis 中最简单的数据类型,但也是性能最极致、使用最广泛的结构。其底层编码根据存储内容动态选择:

  • int 编码: 当存储的字符串可以表示为 64 位有符号整数时,Redis 直接使用 long 类型存储,此时 INCR、DECR 等操作是纯 CPU 级别的原子递增,时间复杂度为 O(1)。
  • embstr 编码: 当字符串长度小于等于 44 字节(Redis 7.0 中为 44,早期版本为 39)时,Redis 使用 embstr 编码。它将 redisObject 和 sdshdr 结构体分配在同一块连续内存中,一次内存分配,一次内存释放,缓存局部性极佳。
  • raw 编码: 当字符串长度超过 44 字节时,使用 raw 编码,redisObject 和 sdshdr 分开分配内存,需要两次分配。
# 时间复杂度示例
SET key value          # O(1)
GET key                # O(1)
INCR counter           # O(1)
APPEND key "suffix"    # O(N),N 为追加后字符串长度
STRLEN key             # O(1)
避坑指南: 虽然 GET 和 SET 是 O(1),但 MGET 和 MSET 是 O(N),N 为 key 的数量。在热点削峰场景中,应优先使用 MGET 批量获取,减少网络 IO 次数。同时,避免将单个 String 值存储过大(超过 10KB),否则会显著增加序列化/反序列化开销。

2.3 List:从 ziplist 到 quicklist 的演进

List 在 Redis 3.2 版本之后统一使用 quicklist 编码。quicklist 是一个双向链表,但每个节点内部是一个 ziplist(紧凑列表)。这种设计兼顾了内存压缩和两端操作的性能。

  • ziplist 节点: 每个 ziplist 存储一定数量的元素(默认 8KB 或 8192 个元素),元素在内存中连续存储,节省指针开销。
  • 链表结构: 通过双向指针连接多个 ziplist,支持在两端快速插入和删除。
# 时间复杂度示例
LPUSH key value        # O(1)
RPUSH key value        # O(1)
LPOP key               # O(1)
RPOP key               # O(1)
LINDEX key index       # O(N),N 为链表长度,需要遍历
LRANGE key 0 -1        # O(N),N 为返回元素个数
削峰场景建议: List 常用于实现轻量级消息队列(LPUSH + BRPOP)。在热点削峰中,应避免对 List 中间元素进行 LINDEX 或 LINSERT 操作,这些操作的时间复杂度为 O(N),会随着队列长度增长而急剧劣化。推荐使用 RPOPLPUSH 实现可靠队列。

2.4 Hash:对象缓存的性能利器

Hash 是存储对象属性的最佳选择。其底层编码在 Redis 7.0 之后发生了重要变化:

  • listpack 编码(替代 ziplist): 当 Hash 中的字段数量小于 hash-max-listpack-entries(默认 512)且每个字段的 key 和 value 长度小于 hash-max-listpack-value(默认 64 字节)时,使用 listpack。listpack 比 ziplist 更紧凑,且避免了 ziplist 的级联更新问题。
  • hashtable 编码: 当超过上述阈值时,自动升级为哈希表,此时所有操作的时间复杂度为 O(1)。
# 时间复杂度示例
HSET user:1001 name "Alice"    # O(1)
HGET user:1001 name            # O(1)
HGETALL user:1001              # O(N),N 为字段数量
HMSET user:1001 age 30 city "NY" # O(N),N 为字段数量
HLEN user:1001                 # O(1)
性能调优关键: 在热点削峰场景中,永远不要使用 HGETALL 获取所有字段,除非字段数量极少。应该使用 HMGET 只获取需要的字段。例如,在用户信息缓存中,只获取 name 和 avatar,而不是全部 20 个字段。这能显著减少网络传输量和序列化开销。
Hash 底层编码动态切换机制 listpack 编码(小数据量) 字段数 < 512 每个字段 key/value < 64 字节 内存连续,无指针开销 读写 O(N),但 N 很小 适合:小对象缓存 超过阈值 hashtable 编码(大数据量) 字段数 >= 512 或字段长度 >= 64 字节 哈希表结构,O(1) 读写 内存开销较大(指针 + 冲突链) 适合:大对象、频繁更新 调优建议:合理设置 hash-max-listpack-entries 和 hash-max-listpack-value

2.5 Set:无序集合的去重与运算

Set 用于存储不重复的元素集合,支持高效的集合运算(交集、并集、差集)。其底层编码有两种:

  • intset 编码: 当集合中所有元素都是整数,且元素数量小于 set-max-intset-entries(默认 512)时,使用整数集合。intset 使用有序数组存储,支持二分查找,时间复杂度为 O(log N)。
  • hashtable 编码: 当不满足 intset 条件时,自动升级为哈希表,所有元素操作的时间复杂度为 O(1)。
# 时间复杂度示例
SADD key member         # O(1)
SREM key member         # O(1)
SISMEMBER key member    # O(1)
SMEMBERS key            # O(N),N 为集合大小
SINTER key1 key2        # O(N * M),N 和 M 为集合大小
SUNION key1 key2        # O(N + M)
SPOP key                # O(1)
避坑指南: SMEMBERS 会返回集合中的所有元素,时间复杂度为 O(N)。在热点削峰场景中,如果集合很大(例如百万级标签),SMEMBERS 会阻塞 Redis 主线程。应使用 SSCAN 进行游标式迭代,或者使用 SRANDMEMBER 随机获取部分元素。

2.6 ZSet:有序集合的排序与范围查询

ZSet 是 Redis 中最复杂的数据结构,它结合了 skiplist(跳表) 和 hashtable(哈希表) 两种底层实现,同时支持按 score 排序和按 member 快速查找。

  • listpack 编码(小数据量): 当 ZSet 元素数量小于 zset-max-listpack-entries(默认 128)且所有元素长度小于 zset-max-listpack-value(默认 64 字节)时,使用 listpack 存储,按 score 排序。
  • skiplist + hashtable 编码(大数据量): 当超过阈值时,使用跳表维护有序性(O(log N) 插入/删除/查找),同时使用哈希表维护 member 到 score 的映射(O(1) 查找)。
# 时间复杂度示例
ZADD key score member       # O(log N)
ZREM key member             # O(log N)
ZSCORE key member           # O(1) - 哈希表直接定位
ZRANK key member            # O(log N) - 跳表查找
ZRANGE key 0 10             # O(log N + M),M 为返回元素数
ZRANGEBYSCORE key min max   # O(log N + M)
ZCARD key                   # O(1)
削峰场景核心应用: ZSet 是实现 滑动窗口限流 和 延迟队列 的最佳选择。例如,使用 ZADD 记录每个请求的时间戳作为 score,使用 ZREMRANGEBYSCORE 清理过期窗口,使用 ZCARD 统计当前窗口请求数。所有操作的时间复杂度均为 O(log N),即使窗口内有百万级数据也能高效运行。
ZSet 底层编码与核心操作时间复杂度 listpack 编码 元素 < 128 元素长度 < 64 字节 O(N) 操作,N 很小 内存紧凑,无额外指针 超过阈值 skiplist + hashtable 编码 skiplist(跳表) ZADD: O(log N) ZRANK: O(log N) ZRANGE: O(log N + M) hashtable(哈希表) ZSCORE: O(1) ZREM: O(log N) ZCARD: O(1) 企业级削峰场景:滑动窗口限流实现 ZADD window:user:1001 timestamp requestId ZREMRANGEBYSCORE window:user:1001 0 (now - windowSize) ZCARD window:user:1001 → 判断是否超过阈值

2.7 时间复杂度对比总结

下表汇总了五种数据结构在典型操作下的时间复杂度,帮助你在系统设计时做出正确的结构选择:

操作类型 String List Hash Set ZSet
写入 O(1) O(1) 两端 O(1) O(1) O(log N)
读取 O(1) O(N) 索引 O(1) O(1) O(log N)
删除 O(1) O(1) 两端 O(1) O(1) O(log N)
范围查询 不支持 O(N) 不支持 不支持 O(log N + M)
集合运算 不支持 不支持 不支持 O(N * M) 不支持
内存效率 高(int/embstr) 中(quicklist) 高(listpack) 中(intset 高) 低(skiplist 指针多)
设计原则: 在热点削峰场景中,优先选择 O(1) 操作的数据结构。如果必须使用 O(log N) 的 ZSet,确保 N 在可控范围内(建议 < 100 万),并通过 ZREMRANGEBYSCORE 定期清理过期数据,避免跳表深度过大导致性能下降。