很多人第一次学 Redis,记住的是五种常用类型:String、List、Hash、Set、ZSet。再往后背面试题,又会遇到 SDS、quicklist、listpack、intset、skiplist、hashtable。

这些名字放在一起很容易让人迷糊:Redis 不是 key-value 数据库吗?为什么一个 Hash 后面还会有 listpackhashtable 两种实现?为什么同样是 Set,有时是 intset,有时又变成哈希表?

真正要理解的不是“Redis 有哪些数据结构”这张清单,而是一个更具体的问题:

Redis 为什么要让同一种对外类型,在不同场景下切换不同的内部编码?

顺着这个问题往下看,Redis 的底层数据结构会清楚很多。它不是为了把实现弄复杂,而是在内存、CPU、查询速度、扩容成本之间做了一组很工程化的取舍。

本文写作和更新时的版本背景是:截至 2026 年 5 月 23 日,Redis Open Source 最新稳定版是 8.6.3,这是 2026 年 5 月发布的安全修复版本;Redis GitHub releases 里还能看到 8.8-RC1,但它是预发布版,官方说明不适合生产环境使用。

从底层数据结构这条线看,Redis 8.6 最值得关注的不是“又多背几个结构名”,而是几类更贴近日常排查的问题:

  • 大 Hash 和大 ZSet 的内存占用继续被优化。
  • Stream 增加了 XADD IDMP / IDMPAUTO,用于幂等写入。
  • 新增 HOTKEYS,能直接辅助定位热点 key。
  • 新增 key 内存大小直方图相关能力,排查 List、Set、Hash、ZSet 的规模分布更方便。
  • 新增 volatile-lrmallkeys-lrm 淘汰策略,按“最近修改时间”参与淘汰。

旧版本里经常出现的 ziplist,在 Redis 7 之后大多已经被 listpack 替代,所以不要再只背“压缩列表”那套旧答案。

对外类型和内部编码不是一回事

平时我们写 Redis 命令,面对的是对外类型:

SET name bole
LPUSH queue a b c
HSET user:1 name bole age 18
SADD tags redis cache
ZADD rank 100 alice

这些命令分别对应 String、List、Hash、Set、ZSet。但 Redis 在内存里不会只保存“这是一个 Hash”这么简单的信息。

可以把一个 Redis value 粗略理解成三层:

redisObject
  -> type:     对外类型,比如 string / list / hash / set / zset
  -> encoding: 内部编码,比如 raw / embstr / listpack / hashtable
  -> ptr:      指向真正的数据结构

type 决定这个 key 能执行哪些命令,encoding 决定 Redis 现在用哪种底层结构保存它。

所以同样是 Hash,内部可能是:

小 Hash  -> listpack
大 Hash  -> hashtable

同样是 ZSet,内部可能是:

小 ZSet  -> listpack
大 ZSet  -> skiplist + hashtable

这就是理解 Redis 底层结构的入口:命令看到的是类型,性能和内存更多取决于编码。

你可以用 OBJECT ENCODING 直接观察一个 key 当前的内部编码:

redis-cli SET age 18
redis-cli OBJECT ENCODING age
# 可能输出:int

redis-cli HSET user:1 name bole age 18
redis-cli OBJECT ENCODING user:1
# 可能输出:listpack

具体输出会受 Redis 版本、配置阈值和数据内容影响,但这个命令很适合用来验证“我以为的数据结构”和 Redis 实际选择是否一致。

最外层:整个数据库先是一张哈希表

先不看 value,Redis 的 key 本身也要被保存起来。

一个 Redis 数据库大致可以理解成这样:

db.dict
  key -> value object

db.expires
  key -> expire timestamp

db.dict 是主字典,负责从 key 找到 value。db.expires 是过期字典,负责记录哪些 key 有过期时间。

这也解释了为什么 Redis 按 key 查找通常很快:最外层就是哈希表,按 key 定位 value 平均是 O(1)

但这里的 O(1) 只表示“找到这个 key 对应的 Redis 对象”很快。找到之后,具体命令还要看 value 的内部编码。

比如:

  • GET name 找到 String 后,直接返回内容。
  • HGET user:1 age 找到 Hash 后,还要在 Hash 的内部结构里找 field。
  • ZRANGE rank 0 9 找到 ZSet 后,还要按分数或排名遍历有序结构。

所以不要只用“Redis 是哈希表,所以所有操作都是 O(1)”来理解 Redis。那只说对了最外层的一小段。

String:很多结构的地基都是 SDS

Redis 的 String 不是直接用 C 语言的 char * 当普通字符串。

C 字符串有几个问题:

  • 计算长度需要从头扫到 \0,复杂度是 O(n)
  • 不能自然保存包含 \0 的二进制数据。
  • 追加内容时,如果空间不够,需要重新分配内存。

Redis 自己实现了一套 SDS,也就是 Simple Dynamic String。可以粗略理解成:

SDS header
  len   -> 已使用长度
  alloc -> 已分配空间
  flags -> header 类型
buf
  真正的字节内容

这样一来,Redis 至少拿到了三个好处。

第一,取长度是 O(1)。因为长度直接存在 header 里,不需要每次扫描字符串。

第二,SDS 是二进制安全的。它不依赖 \0 判断字符串结束,所以可以保存图片片段、序列化后的对象、压缩数据。

第三,追加内容时可以预留空间,减少频繁扩容带来的内存拷贝。

不过 Redis String 的内部编码还会继续细分:

编码典型场景直觉
int字符串内容刚好能表示 64 位整数直接存整数,省掉字符串对象
embstr较短字符串Redis 对象和 SDS 放在同一块内存里
raw较长字符串Redis 对象和 SDS 分开分配

比如:

redis-cli SET counter 100
redis-cli OBJECT ENCODING counter
# 可能输出:int

redis-cli SET name bole
redis-cli OBJECT ENCODING name
# 可能输出:embstr

这里的重点不是记住某个具体阈值,而是理解 Redis 的思路:能用更紧凑的表示,就先用更紧凑的表示;当数据变复杂,再换成更通用的结构。

Hash:小的时候挤在一起,大了再换哈希表

Hash 是业务里非常常见的结构,适合保存一个对象的多个字段:

HSET user:1 name bole age 18 city hangzhou

直觉上,Hash 底层应该就是哈希表。但 Redis 不会一上来就为一个很小的 Hash 分配完整哈希表。

如果一个 Hash 只有几个字段,每个字段和值都很短,直接用哈希表会有不少额外开销:桶数组、指针、entry 结构、扩容状态。这些元数据可能比真正的业务数据还占空间。

所以小 Hash 通常会用 listpack

可以把 listpack 想成一段连续内存,field 和 value 挨着放:

name | bole | age | 18 | city | hangzhou

查找 age 时,Redis 在这段紧凑内存里从前往后扫。单次查找不是 O(1),但因为元素很少,实际成本很低,而且非常省内存。

当 Hash 变大,或者某个 field/value 超过配置阈值,Redis 就会把它转换成 hashtable

field -> value
field -> value
field -> value

Redis 默认配置里,Hash 的紧凑编码阈值大致由这两个参数控制:

hash-max-listpack-entries 512
hash-max-listpack-value 64

也就是说,小对象优先省内存;对象变大后,再用哈希表换查询效率。

看到这里,问题就变成了:如果我们把很多小对象拆成很多独立 key,和把它们放进一个 Hash,哪个更省?

很多时候,小 Hash 会更省。因为多个 field 可以共享一个 Redis key 的元数据成本,还能用 listpack 挤在连续内存里。这也是很多“用户属性”“商品轻量信息”喜欢用 Hash 保存的原因。

但别把这个经验用过头。一个超大的 Hash 会带来大 key 问题,影响删除、迁移、持久化和网络传输。Redis 的底层优化能帮你省内存,但不能替你消除大 key 的运维成本。

Redis 8.6 里,Hash 还有一个和底层结构直接相关的变化:官方 release notes 提到,hashtable 编码的 Hash 做了明显的内存缩减,其中一个具体优化是把 field name 和 value 统一到一个结构里。

这句话翻译成使用者能感知的结果是:大 Hash 的元数据成本更低了,但它仍然是大 key。 如果一个 Hash 已经大到影响迁移、删除或持久化,不能因为 Redis 8.6 更省内存就忽略拆分设计。

List:它不是一条朴素的双向链表

如果从数据结构课本出发,List 很容易让人想到双向链表:

node <-> node <-> node <-> node

链表的好处是两端插入删除很快,坏处也明显:每个节点都要存前后指针,内存不连续,CPU cache 友好性差。

Redis 早期确实使用过普通链表和压缩结构。现在理解 Redis List,重点看 quicklist

quicklist 可以理解成“链表 + 紧凑数组块”的组合:

quicklist
  node -> listpack: [a, b, c]
  node -> listpack: [d, e, f]
  node -> listpack: [g, h, i]

外层是链表,方便从两端扩展;内层每个节点是一段 listpack,把多个元素连续放在一起。

这比“每个元素一个链表节点”更省内存,也比“所有元素都塞进一整段连续内存”更容易在两端插入删除。

所以 Redis List 的常见操作会呈现出这种特点:

  • LPUSH / RPUSH:两端插入,适合队列。
  • LPOP / RPOP:两端弹出,也适合队列。
  • LLEN:长度信息直接维护,查询很快。
  • LINDEX / LRANGE:按下标走到中间位置时,仍然可能需要遍历。

这就是为什么 List 适合做简单队列、最近消息列表,不适合频繁随机访问中间元素。

如果你真的需要按 ID 快速定位某条记录,List 通常不是最好的选择。可以考虑 Hash 存内容,再用 ZSet 或 List 保存顺序。

Set:先看成员是不是整数

Set 对外提供的是无序去重集合:

SADD online:users 1001 1002 1003
SISMEMBER online:users 1002

它的内部编码会先看一个很具体的条件:成员是不是都能表示成整数。

如果一个 Set 很小,而且成员全是整数,Redis 可以用 intset 保存:

[1001, 1002, 1003]

intset 本质上是一段有序整数数组。它不需要为每个元素保存字符串对象,也不需要哈希表指针,所以很省内存。

为了容纳不同大小的整数,intset 还会选择合适的整数宽度:

int16 -> int32 -> int64

如果原来都是小整数,后来插入一个很大的整数,Redis 会把整个数组升级到更宽的编码。这个升级是单向的,因为已经变宽后,再缩回去通常不值得。

如果 Set 里出现非整数成员,或者元素数量超过阈值,就会转成更通用的编码。Redis 7.2 之后,小的非整数 Set 也可以使用 listpack;更大的 Set 会使用 hashtable

所以 Set 的选择可以这样记:

小整数集合      -> intset
小的非整数集合  -> listpack(Redis 7.2+)
普通大集合      -> hashtable

从使用者角度看,这些转换是透明的。你仍然执行 SADDSREMSISMEMBER。但如果你关心内存,就应该知道“全是整数”和“混入一个字符串”可能会让底层编码发生变化。

ZSet:排行榜为什么常常提到跳表

ZSet,也就是 sorted set,是 Redis 里最值得细看的结构之一。

它既要支持按 member 查分数:

ZSCORE rank alice

又要支持按 score 范围查询:

ZRANGEBYSCORE rank 80 100

还要支持按排名取一段:

ZRANGE rank 0 9 WITHSCORES

一个结构很难同时把这些操作都做得舒服,所以 Redis 对大 ZSet 使用的是组合结构:

hashtable: member -> score
skiplist:  按 score 和 member 排序

哈希表负责快速通过 member 找到 score,跳表负责按分数范围和排名顺序遍历。

跳表可以理解成带多级索引的有序链表:

level 3:  10 ----------------> 80
level 2:  10 ------> 50 -----> 80
level 1:  10 -> 20 -> 50 -> 70 -> 80

查找时先从高层快速跳过一大段,再逐层下降。它不像平衡树那样需要复杂旋转,平均复杂度也能做到 O(log N)。对 Redis 来说,跳表实现简单,范围遍历也自然。

小 ZSet 则没有必要一上来就维护哈希表和跳表。元素少时,Redis 会用 listpack 把 member 和 score 紧凑地放在一起:

alice | 100 | bob | 95 | cindy | 90

当元素数量或成员长度超过阈值,再转换成 skiplist + hashtable

默认配置里,ZSet 的紧凑编码阈值通常是:

zset-max-listpack-entries 128
zset-max-listpack-value 64

这也解释了一个常见现象:刚开始很小的排行榜,内存占用可能很低;用户数上来后,底层结构一转换,内存会明显上升。不是 Redis 突然变浪费了,而是它从“省内存模式”切到了“保证查询效率的通用模式”。

Redis 8.6 对大 ZSet 也做了类似的内存优化。官方说明里提到,skiplist 编码的 sorted set 有明显内存下降,其中一个具体点是把 score 和 value 统一到一个结构里;同时 ZRANK 也有优化。

所以,如果你的业务大量依赖排行榜、延迟队列、按分数范围查询,升级到 8.6 之后,大 ZSet 的内存和部分排序相关命令可能会有更好的表现。但底层仍然是 skiplist + hashtable 这套组合逻辑:一个负责顺序,一个负责按 member 快速定位。

Stream:radix tree 管索引,listpack 管内容

Redis Stream 是后来的数据类型,用来保存类似消息流的数据:

XADD events * user bole action login
XREAD COUNT 10 STREAMS events 0

Stream 的内部可以粗略理解成:

rax
  stream id range -> listpack entries

rax 是 radix tree,也叫基数树。它适合按前缀和有序 key 做查找。Stream 的消息 ID 天然有顺序,适合用树结构组织。

真正的消息字段和值,则被打包进 listpack。这样一来,Stream 既能按 ID 范围定位,又能把多条小消息紧凑存储。

如果只是使用 Stream 做轻量消息队列,你不一定需要记住每个结构细节。但知道它是“树索引 + 紧凑块存储”,就能理解为什么 Stream 适合追加、按范围读取,也能理解为什么特别大的单个 Stream 仍然需要关注修剪策略。

Redis 8.6 还给 Stream 增加了一个更偏工程语义的能力:XADD 支持 IDMPIDMPAUTO 参数,用来做幂等生产,目标是提供 at-most-once 语义。

这和底层结构的关系在于:Stream 不只是“追加一条消息”那么简单,它还要维护消息 ID、消费者组、待确认消息、修剪策略,以及现在的幂等生产元数据。使用 Stream 做消息队列时,除了看写入速度,也要关注这些元数据会不会随着使用方式不断膨胀。

自动转换:Redis 会帮你换结构,但不会替你设计数据模型

Redis 的很多内部编码转换都是自动发生的。

比如一个 Hash 一开始很小:

HSET profile:1 name bole age 18
OBJECT ENCODING profile:1
# 可能是 listpack

后来字段越来越多,或者某个 value 很长:

HSET profile:1 bio "a very very long text ..."
OBJECT ENCODING profile:1
# 可能变成 hashtable

这个转换对命令语义没有影响,但对内存和性能有影响。

需要注意的是,很多转换是朝更通用的编码走,通常不要指望它自动变回紧凑编码。比如一个 Set 从 intset 升级成 hashtable 后,就算你删掉那个导致升级的元素,它也不会为了省一点内存立刻降回去。

这背后还是同一个取舍:转换本身也有成本。Redis 更愿意避免频繁来回切换,让结构稳定在能覆盖更多操作的编码上。

一张表把常见结构串起来

把前面的内容压成一张表,大概是这样:

对外类型常见内部编码适合记住的重点
Stringint / embstr / raw小整数、小字符串会特殊优化,底层核心是 SDS
Listquicklist / listpack链表负责扩展,listpack 负责紧凑存储
Hashlistpack / hashtable小对象省内存,大对象换查询效率
Setintset / listpack / hashtable小整数集合非常省,混入复杂成员会升级
ZSetlistpack / skiplist + hashtable小集合紧凑,大集合同时照顾 member 查找和范围遍历
Streamrax + listpack树负责按 ID 定位,listpack 保存消息内容

这张表不是让你死记,而是让你看到 Redis 的统一思路:

小数据先压紧,大数据再换成通用结构;读写语义保持不变,内部编码按数据规模和形态调整。

这里还要补一句关于 Redis 8 之后的背景:Redis 8 Open Source 已经把 Redis Stack 的很多能力并进来,比如 JSON、Time Series、概率数据结构、Search/Query 和向量相关能力。它们也有自己的底层结构和索引实现,但这篇文章主要讲 Redis 常用核心类型,所以不展开模块级细节。

如果你关注预发布版本,8.8-RC1 里已经出现了一个新数据结构 Array。但 RC 不是稳定版本,生产环境选型还是应该以当前稳定版和对应 release notes 为准。

平时怎么用这些知识

理解底层结构,最后还是要回到日常开发和排查问题。

第一,遇到内存异常时,不要只看 key 数量。可以配合使用:

OBJECT ENCODING key
MEMORY USAGE key

前者看内部编码,后者看大致内存占用。很多时候,同样数量的 key,因为 value 类型和编码不同,内存会差很多。

在 Redis 8.6 之后,还可以关注 key 内存大小直方图相关能力。官方 release notes 里提到,Redis 增加了 keys memory size histograms,并提供类似 db0_distrib_lists_sizesdb0_distrib_sets_sizesdb0_distrib_hashes_sizesdb0_distrib_zsets_sizes 这样的指标。它们的价值不是替代 MEMORY USAGE key,而是帮你先看整体分布:到底是少数大 key 把内存撑起来,还是大量中小 key 累积出来的压力。

第二,写 Redis 方案时,要考虑数据是否会跨过编码阈值。

一个只有几十个字段的 Hash,和一个有几十万个字段的 Hash,不只是“大小不同”,它们在删除、迁移、持久化、阻塞风险上都不是一类东西。

第三,不要再把旧版 ziplist 当成唯一答案。

很多中文资料还停留在 Redis 3.x、4.x、5.x 的描述里,会说 Hash、ZSet 小对象使用 ziplist。这个说法在旧版本里有历史意义,但 Redis 7+ 已经主要使用 listpack。面试或写文章时最好带上版本背景。

第四,配置阈值不要凭感觉调大。

这些参数看起来很诱人:

hash-max-listpack-entries
hash-max-listpack-value
zset-max-listpack-entries
zset-max-listpack-value
set-max-intset-entries
set-max-listpack-entries

调大它们可能会省内存,但也可能让本该使用哈希表或跳表的数据继续停留在线性扫描的紧凑结构里。数据量、字段长度、访问模式不同,结果会差很多。真正要调,应该先压测。

第五,排查性能问题时,不要只盯慢查询。

Redis 8.6 新增了 HOTKEYS,用于热点 key 检测和报告。热点 key 往往不是内部编码错了,而是访问分布太偏:一个 key 被大量请求命中,单线程主循环、网络输出、复制传播都可能被它拖住。底层数据结构解释的是“这个 key 自己怎么存”,热点排查解释的是“流量怎么打到这个 key 上”。

第六,淘汰策略也开始出现新的维度。

Redis 8.6 新增了 volatile-lrmallkeys-lrm,这里的 LRM 是 least recently modified,也就是按最近修改时间参与淘汰。传统 LRU 更关心最近访问,LFU 更关心访问频率,LRM 则更贴近“最近有没有被写过”。如果你把 Redis 当缓存和状态存储混用,这类策略差异会影响哪些 key 更容易被淘汰。

最后再看 Redis 为什么快

Redis 快,不只是因为“它在内存里”。

更准确地说,Redis 快是因为它把很多工程细节都压到了合适的位置:

  • key 空间用哈希表快速定位。
  • String 用 SDS 避免 C 字符串的长度和二进制问题。
  • 小 Hash、小 ZSet、小 Set 用紧凑编码省内存。
  • 大 Hash、大 Set 用哈希表保证查询效率,Redis 8.6 继续降低了大 Hash 的元数据成本。
  • ZSet 用跳表处理范围和排名,Redis 8.6 也继续优化了大 ZSet 的内存和 ZRANK 路径。
  • List 用 quicklist 在链表和连续内存之间折中。
  • Stream 用 radix tree 和 listpack 兼顾顺序索引与紧凑存储。
  • 新版本还在可观测性上补强,比如 HOTKEYS 和 key 内存大小直方图,帮助你从“单个 key 怎么存”进一步看到“整库 key 怎么分布”。

所以学习 Redis 底层数据结构,最有价值的不是背出每个结构名字,而是理解它的取舍逻辑。

当数据很小,Redis 选择省内存;当数据变大,Redis 选择更稳定的查询复杂度;当一个类型需要多种访问方式,Redis 就组合多种结构。

这也是 Redis 设计里最值得借鉴的地方:接口保持简单,底层实现可以很灵活。

参考资料