5240 words
26 minutes
Redis 数据结构学习笔记

1. 前言#

Redis 官方把 Redis 定位为 data structure server,一个内存数据结构服务器。我们使用 Redis 时看到的是 String、List、Hash、Set、Sorted Set、Stream 等逻辑数据类型,而 Redis 内部会根据数据规模、元素大小、元素类型等条件,选择不同的底层编码来存储它们。

也就是说,Redis 的一个数据类型并不一定只对应一种底层结构。比如:

  • String 可能是 intembstrraw
  • List 可能是 listpackquicklist
  • Hash 小的时候可能是 listpack,大了以后会转成 hashtable
  • Set 小整数集合可能是 intset,普通集合可能是 hashtable
  • Sorted Set 小的时候可能是 listpack,大了以后通常是 skiplist + dict
  • Stream 使用的是 radix tree 与 listpack 的组合。

理解这些底层结构,可以帮助我们更好地解释 Redis 为什么快、为什么省内存,以及一些命令在不同数据规模下为什么会有不同表现。


2. Redis 对象模型:type 和 encoding#

Redis 中每个 key 对应的 value 都会被包装成一个 Redis 对象。对象中大致包含几类信息:

  • type:逻辑类型,比如 String、List、Hash、Set、ZSet;
  • encoding:底层编码,比如 int、embstr、raw、listpack、quicklist、hashtable、skiplist;
  • ptr:指向底层数据结构的指针;
  • 其他元信息:比如引用计数、LRU/LFU 相关信息等。

平时使用 TYPE key 命令看到的是逻辑类型:

Terminal window
TYPE user:1

而使用 OBJECT ENCODING key 可以看到该 key 当前的底层编码:

Terminal window
OBJECT ENCODING user:1

这两个概念要区分清楚。比如一个 Hash 在业务上仍然是 Hash,但它的底层编码可能是 listpack,也可能是 hashtable

Redis 会根据数据大小自动选择更节省内存的编码。当数据超过配置阈值,或者某些操作导致紧凑编码不再适合时,Redis 会自动转换成通用编码。这个过程对用户透明,但它会影响内存占用和某些操作的性能。


3. Redis 常见底层数据结构总览#

逻辑类型常见底层编码 / 数据结构主要特点
Stringint、embstr、raw,底层依赖 SDS二进制安全、支持动态扩容
Listlistpack、quicklist适合队列、栈、消息列表
Hashlistpack、hashtable适合对象字段存储
Setintset、listpack、hashtable无序、去重、集合运算
Sorted Setlistpack、skiplist + dict有序、排名、范围查询
Streamradix tree + listpack消息流、消费组
Bitmap基于 String位级操作,适合签到、状态标记
HyperLogLog基于 String 的概率结构估算基数,省内存
Geo基于 Sorted Set地理位置范围查询

Redis 数据结构与底层编码总览

下面逐个展开。


4. String:SDS、int、embstr、raw#

String 是 Redis 最基础的数据类型。虽然名字叫 String,但它并不只是普通字符串,而是二进制安全的字节序列。图片、JSON、序列化对象、整数、位图数据,都可以放在 String 中。

Redis 没有直接使用 C 语言原生字符串,而是使用 SDS,也就是 Simple Dynamic String。

4.1 为什么不用 C 字符串?#

C 字符串以 \0 作为结尾,这会带来几个问题:

第一,获取字符串长度需要遍历,时间复杂度是 O(N)。

第二,字符串中间不能安全地存储 \0,否则会被误认为字符串结束。

第三,拼接字符串时容易出现缓冲区溢出,需要开发者自己处理内存分配。

Redis 作为数据库,需要频繁处理字符串、网络协议、命令参数和 key/value 数据,因此需要一个更安全、更高效的字符串结构。

4.2 SDS 的核心思想#

SDS 可以简单理解为:

struct sdshdr {
int len; // 已使用长度
int free; // 剩余可用空间
char buf[]; // 实际数据
};

实际 Redis 中 SDS 会根据字符串长度使用不同 header 类型,以减少内存浪费,但核心思想差不多:在真正的数据 buf 前面记录长度、容量等元信息。

SDS 的优势主要有:

  1. 获取长度是 O(1)

因为 SDS 直接保存了 len,不需要像 C 字符串那样遍历到 \0

  1. 二进制安全

SDS 根据 len 判断字符串长度,而不是依赖 \0,所以中间可以存任意字节。

  1. 减少频繁内存分配

SDS 扩容时会预留一部分空间,后续追加内容时不一定每次都重新分配内存。

  1. 兼容部分 C 字符串函数

SDS 的 buf 末尾仍然会保留 \0,所以在一些场景下可以兼容 C 字符串函数。

4.3 String 的三种常见编码#

Redis String 常见编码有:

  • int
  • embstr
  • raw

如果字符串可以表示为整数,并且在 64 位有符号整数范围内,Redis 可能使用 int 编码,节省空间。

如果是比较短的字符串,Redis 可能使用 embstr 编码。embstr 会把 Redis 对象头和 SDS 连续分配在一块内存中,减少一次内存分配,也提升缓存局部性。

如果字符串比较长,Redis 会使用 raw 编码。此时 Redis 对象和 SDS 通常是分开分配的,更适合后续修改和扩容。

Redis String 与 SDS 编码示意图

可以通过下面命令观察:

Terminal window
SET a 100
OBJECT ENCODING a
SET b hello
OBJECT ENCODING b
SET c "一个比较长的字符串..."
OBJECT ENCODING c

5. List:从 linkedlist / ziplist 到 quicklist / listpack#

Redis List 是按照插入顺序排列的字符串列表,常用于队列、栈、消息列表等场景。

常见命令包括:

Terminal window
LPUSH queue task1
RPUSH queue task2
LPOP queue
RPOP queue
LRANGE queue 0 -1

很多人容易把 Redis List 理解成“普通链表”。在早期 Redis 中,List 的确和 linkedlist、ziplist 有关,但现代 Redis 的实现已经发生了变化。

5.1 普通链表的问题#

普通双向链表的优点是头尾插入删除很快,时间复杂度是 O(1)。但它也有明显缺点:

  • 每个节点都需要额外保存前驱、后继指针;
  • 每个元素单独分配内存,内存碎片较多;
  • 缓存局部性较差,遍历时可能频繁跳转到不同内存位置。

Redis 是内存数据库,内存占用非常关键。如果 List 中每个元素都用一个独立链表节点,会有不少额外开销。

5.2 listpack 是什么?#

listpack 是一种紧凑的连续内存结构,可以在一块连续内存中存储多个元素。

它和数组有点像,都是连续内存,但 listpack 的每个 entry 是变长编码的,可以存整数,也可以存字符串。它主要追求节省内存。

listpack 的优点:

  • 元素紧凑存储,指针开销小;
  • 缓存局部性较好;
  • 适合小集合、小列表、小哈希、小有序集合。

Redis listpack 紧凑存储结构示意图

缺点也很明显:

  • 中间插入或删除可能需要移动内存;
  • 查找某个位置通常需要顺序遍历;
  • 数据太大时不适合作为唯一结构。

所以 listpack 适合“小而紧凑”的场景。

5.3 quicklist 是什么?#

quicklist 可以理解成“双向链表 + listpack”的组合。

它整体是一个双向链表,每个链表节点内部不是只存一个元素,而是存一个 listpack。也就是说:

quicklist
|
+-- node1 -> listpack[e1, e2, e3, ...]
+-- node2 -> listpack[e4, e5, e6, ...]
+-- node3 -> listpack[e7, e8, e9, ...]

Redis quicklist 结构示意图

这样做相当于在普通链表和连续内存之间做了折中:

  • 和普通链表相比,quicklist 减少了节点数量,降低了指针开销;
  • 和单个巨大 listpack 相比,quicklist 避免了大范围内存移动;
  • 头尾操作仍然比较快;
  • 遍历时也能利用 listpack 的连续内存优势。

所以 List 的底层结构可以概括为:小数据尽量紧凑,大数据用 quicklist 分块管理。

5.4 List 的使用建议#

List 适合:

  • 简单队列:LPUSH + RPOP
  • 简单栈:LPUSH + LPOP
  • 最新消息列表:LPUSH + LTRIM
  • 阻塞队列:BLPOPBRPOP

但 List 不适合频繁按下标随机访问。比如 LINDEX key 100000 这类操作可能需要遍历,性能不如数组型结构。


6. Hash:listpack 与 hashtable#

Redis Hash 是 field-value 结构,很适合存储对象。

例如:

Terminal window
HSET user:1 name Tom age 18 city Guangzhou
HGET user:1 name
HGETALL user:1

业务中可以用 Hash 表示一个用户对象、商品对象、配置对象等。

6.1 小 Hash:listpack#

当 Hash 中字段数量较少,并且 field 和 value 都比较短时,Redis 会使用 listpack 编码。

listpack 中会按顺序存储 field 和 value:

[field1, value1, field2, value2, field3, value3]

这种方式非常节省内存,因为它不需要为每个 field-value 都维护哈希表节点和指针。

但问题是,查找某个 field 时需要顺序扫描。听起来似乎不快,但由于小 Hash 数据量很小,而且 listpack 连续内存的缓存局部性好,所以实际开销可以接受。

6.2 大 Hash:hashtable#

当 Hash 字段数量超过阈值,或者某个 field/value 太大时,Redis 会把它转换成 hashtable 编码。

hashtable 的优点是查询、插入、删除平均时间复杂度接近 O(1),适合字段较多的 Hash。

可以简单理解为:

dict:
field1 -> value1
field2 -> value2
field3 -> value3

Redis 的哈希表使用链式哈希解决冲突,并支持渐进式 rehash。所谓渐进式 rehash,就是在扩容时不一次性把所有数据迁移完,而是把迁移工作拆散到后续多次操作中逐步完成,避免一次性阻塞太久。

Redis hashtable 与渐进式 rehash 示意图

6.3 Hash 的使用建议#

Hash 很适合存储对象型数据,比如:

Terminal window
HSET product:1001 name keyboard price 199 stock 50

相比于把每个字段拆成多个 key:

Terminal window
SET product:1001:name keyboard
SET product:1001:price 199
SET product:1001:stock 50

使用 Hash 通常能减少 key 的数量,降低 Redis key 元数据开销,也更便于整体管理。

不过需要注意:如果一个 Hash 特别大,HGETALL 可能一次返回大量数据,影响 Redis 单线程执行时间。大 Hash 更推荐使用 HSCAN 分批遍历。


7. Set:intset、listpack、hashtable#

Redis Set 是无序、去重的字符串集合,常用于标签、关注关系、去重统计、共同好友等场景。

常见命令:

Terminal window
SADD user:1:tags java redis mysql
SISMEMBER user:1:tags redis
SMEMBERS user:1:tags
SINTER set1 set2
SUNION set1 set2
SDIFF set1 set2

7.1 intset:整数集合的紧凑表示#

如果一个 Set 中的元素全都是整数,并且数量不大,Redis 可能使用 intset 编码。

intset 可以理解为一个有序整数数组。它会根据元素大小选择合适的整数宽度,比如 16 位、32 位、64 位。

当插入更大的整数时,intset 可能会升级编码。例如原本所有元素都能用 16 位表示,后来插入一个需要 32 位表示的整数,那么整个 intset 会升级到 32 位。

intset 的优点是非常省内存,适合小规模整数集合。

缺点是插入、删除、查找通常需要在数组中操作,数据大了以后不如 hashtable。

Redis intset 整数集合编码示意图

7.2 listpack:小规模普通集合#

在较新的 Redis 版本中,小规模 Set 也可以使用 listpack 编码。它适合元素数量少、元素长度较短的场景。

listpack 的目标依旧是节省内存。

7.3 hashtable:通用 Set 编码#

当集合规模变大,或者元素类型不适合紧凑编码时,Redis 会使用 hashtable。

Set 的 hashtable 可以简单理解为:

dict:
member1 -> null
member2 -> null
member3 -> null

value 不重要,重要的是 key 是否存在。这样 SISMEMBERSADDSREM 等操作平均可以接近 O(1)。

7.4 Set 的使用建议#

Set 适合:

  • 用户标签;
  • 点赞用户集合;
  • 抽奖去重;
  • 共同关注、共同好友;
  • 黑名单、白名单;
  • UV 去重。

但要注意集合运算的成本。比如 SINTERSUNIONSDIFF 在大集合上可能比较重,如果集合非常大,要注意慢查询和阻塞问题。


8. Sorted Set:listpack 与 skiplist + dict#

Sorted Set,也叫 ZSet,是 Redis 中非常重要的数据结构。它和 Set 一样要求 member 唯一,但每个 member 都会关联一个 score,Redis 会按照 score 排序。

常见命令:

Terminal window
ZADD rank 100 user1
ZADD rank 90 user2
ZADD rank 120 user3
ZRANGE rank 0 -1 WITHSCORES
ZREVRANGE rank 0 -1 WITHSCORES
ZRANK rank user1
ZSCORE rank user1
ZRANGEBYSCORE rank 90 120

Sorted Set 非常适合:

  • 排行榜;
  • 延迟队列;
  • 滑动窗口限流;
  • 按时间戳排序的任务;
  • 范围查询。

8.1 小 ZSet:listpack#

当 ZSet 元素数量较少,并且 member 较短时,Redis 可能使用 listpack 编码。

listpack 中通常会连续存储 member 和 score:

[member1, score1, member2, score2, member3, score3]

这种编码节省内存,但查找和插入需要顺序处理,适合小数据量。

8.2 大 ZSet:skiplist + dict#

当 ZSet 规模变大后,Redis 通常会使用 skiplist + dict 的组合。

为什么需要两个结构?

因为 ZSet 既需要根据 member 快速查 score,也需要根据 score 做排序和范围查询。

dict 负责:

member -> score

这样 ZSCORE member 可以快速查到分数。

skiplist 负责按照 score 维护有序结构,这样可以支持:

  • 按排名查询;
  • 按 score 范围查询;
  • 获取前 N 名;
  • 删除某个 score 范围内的元素。

8.3 skiplist 是什么?#

skiplist,跳表,是一种多层有序链表。

最底层包含所有元素,上层是部分元素的索引。查找时从最高层开始,如果当前节点的下一个节点还没超过目标,就继续向前;否则下降一层。通过这种多层索引,可以把查找复杂度降低到平均 O(log N)。

可以简单理解为:

Level 3: 1 -------------------- 9
Level 2: 1 -------- 5 --------- 9
Level 1: 1 --- 3 --- 5 --- 7 --- 9
Level 0: 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9

Redis skiplist 跳表结构示意图

相比平衡树,跳表实现更简单,范围遍历也比较自然。Redis 的 ZSet 需要大量范围查询,所以 skiplist 很适合这个场景。

8.4 为什么 ZSet 不只用 dict?#

如果只用 dict,可以很快通过 member 找到 score,但无法高效按 score 排序和范围查询。

8.5 为什么 ZSet 不只用 skiplist?#

如果只用 skiplist,按 score 范围查询很方便,但通过 member 查 score 不够直接。加上 dict 后,可以兼顾 member 查找和 score 排序。

所以 ZSet 的核心思路是:dict 管查找,skiplist 管有序。

Redis ZSet 的 dict 与 skiplist 组合示意图


9. Stream:radix tree + listpack#

Stream 是 Redis 5.0 引入的数据类型,适合消息流场景。

常见命令:

Terminal window
XADD mystream * user Tom action login
XREAD COUNT 10 STREAMS mystream 0
XGROUP CREATE mystream group1 0
XREADGROUP GROUP group1 consumer1 STREAMS mystream >

Stream 中每条消息都有一个 ID,通常形如:

milliseconds-sequence

例如:

1710000000000-0

Stream 的底层可以理解为 radix tree 和 listpack 的组合。

radix tree,也叫基数树或压缩前缀树,适合存储有序 key。Stream 使用消息 ID 作为索引,可以高效定位某个范围内的消息。

listpack 用来紧凑存储具体消息内容。多条消息可以打包在 listpack 中,从而减少内存开销。

所以 Stream 的整体思路是:

radix tree:
message id range -> listpack[messages...]

Redis Stream 的 radix tree 与 listpack 组合示意图

这种设计适合追加写入、按 ID 范围读取、消息消费组等场景。


10. Bitmap:基于 String 的位操作#

Bitmap 不是一种新的独立底层类型,它本质上是把 String 当作 bit 数组来用。

常见命令:

Terminal window
SETBIT sign:2024:03 1001 1
GETBIT sign:2024:03 1001
BITCOUNT sign:2024:03

Bitmap 适合记录大量布尔状态,比如:

  • 用户签到;
  • 用户是否在线;
  • 某天是否活跃;
  • 某个功能是否开启;
  • 布隆过滤器中的位数组。

Bitmap 的优势是非常省内存。一个用户只需要 1 bit。如果有 1 亿个用户,只记录是否签到,理论上大约只需要 12MB 左右。

缺点是 Bitmap 更适合用户 ID 比较连续的场景。如果 ID 非常稀疏,比如最大用户 ID 很大但真实用户很少,可能会浪费空间。


11. HyperLogLog:基数估算#

HyperLogLog 是 Redis 提供的概率型数据结构,常用于估算 UV。

常见命令:

Terminal window
PFADD uv:2024-03-01 user1 user2 user3
PFCOUNT uv:2024-03-01
PFMERGE uv:2024-03 uv:2024-03-01 uv:2024-03-02

它的特点是:

  • 内存占用很小;
  • 可以估算大量元素的去重数量;
  • 结果有一定误差;
  • 不能取出具体元素。

所以 HyperLogLog 适合关心“数量”但不关心“具体有哪些元素”的场景。

例如统计网站日活 UV,如果只需要知道大概有多少独立用户访问,用 HyperLogLog 就很合适。如果需要精确判断某个用户是否访问过,那就不适合用它。


12. Geo:基于 Sorted Set 的地理位置索引#

Redis Geo 用来存储地理位置信息,例如经纬度。

常见命令:

Terminal window
GEOADD shops 113.2644 23.1291 shop1
GEOADD shops 113.2700 23.1300 shop2
GEOSEARCH shops FROMLONLAT 113.2644 23.1291 BYRADIUS 5 km

Geo 的底层和 Sorted Set 有关。Redis 会把经纬度编码成 geohash,然后放到 ZSet 中,用 score 表示位置编码。

因此 Geo 可以利用 ZSet 的有序范围查询能力来查找附近位置。

Geo 适合:

  • 附近门店;
  • 附近的人;
  • 外卖配送范围;
  • 地图位置检索。

需要注意的是,Redis Geo 更适合简单地理范围查询。如果要做复杂 GIS 查询,比如多边形包含、复杂路线规划,通常需要专业的地理信息系统。

Redis Bitmap、HyperLogLog 与 Geo 结构关系示意图


13. Redis 为什么要设计多种 encoding?#

Redis 内部 encoding 的核心目标是:在性能和内存之间做平衡。

对于小数据结构,使用 hashtable、skiplist 这种通用结构虽然操作快,但额外指针和对象头开销比较大。比如一个 Hash 只有几个字段,如果直接用哈希表,元数据可能比真实数据还占空间。

所以 Redis 会在小对象上使用 listpack、intset 这类紧凑结构,牺牲一点查询复杂度,换取更低内存占用。

当数据变大后,顺序扫描、内存移动成本会变高,此时 Redis 再转换成 hashtable、skiplist、quicklist 等更适合大数据量的结构。

可以概括成一句话:

小数据用紧凑编码省内存,大数据用通用结构保性能。


14. 常见误区#

14.1 Redis List 就是普通链表吗?#

不是。现代 Redis List 主要与 listpack、quicklist 有关。quicklist 是双向链表和 listpack 的组合,每个链表节点中存多个元素。

14.2 Hash 一定是哈希表吗?#

不一定。小 Hash 可能是 listpack。只有当数据量或元素大小超过阈值后,才会转成 hashtable。

14.3 ZSet 只用跳表实现吗?#

不准确。大 ZSet 通常是 skiplist 和 dict 的组合。skiplist 负责有序范围查询,dict 负责通过 member 快速查 score。

14.4 Bitmap 是单独的数据类型吗?#

从使用角度看可以把它当成一种数据结构,但底层本质上是 String 的位操作。

14.5 ziplist 现在还常用吗?#

在 Redis 早期版本中,ziplist 用得比较多,比如小 List、小 Hash、小 ZSet 都可能使用 ziplist。但 Redis 7 之后,很多地方已经使用 listpack 替代 ziplist。


15. 如何观察 Redis 的底层编码?#

可以使用 OBJECT ENCODING 命令。

示例:

Terminal window
SET k1 100
OBJECT ENCODING k1
HSET user:1 name Tom age 18
OBJECT ENCODING user:1
ZADD rank 100 user1 90 user2
OBJECT ENCODING rank

如果想观察编码转换,可以不断插入元素,直到超过阈值:

Terminal window
HSET h f1 v1
OBJECT ENCODING h
# 插入大量字段后再次观察
HSET h f2 v2 f3 v3 ...
OBJECT ENCODING h

Redis 的相关阈值可以在配置文件中调整,例如:

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

一般不建议随意把阈值调得过大。紧凑编码虽然省内存,但当元素数量变多后,插入、删除、查找可能变慢,还可能在编码转换时带来额外开销。调整前最好结合业务数据规模进行压测。


16. 总结#

Redis 快,并不只是因为它把数据放在内存里,还因为它为不同数据类型、不同数据规模设计了不同的底层编码。

String 使用 SDS 解决 C 字符串长度获取、二进制安全和扩容问题。

List 使用 listpack 和 quicklist,在内存占用和头尾操作效率之间做平衡。

Hash 小的时候用 listpack 节省内存,大的时候用 hashtable 保证查询效率。

Set 对小整数集合使用 intset,对小普通集合可以使用 listpack,对大集合使用 hashtable。

Sorted Set 小的时候用 listpack,大的时候用 skiplist + dict,同时支持成员查找和范围排序。

Stream 使用 radix tree + listpack,适合按 ID 追加、读取和消费消息流。

这些设计体现了 Redis 的一个重要思想:不是所有场景都使用同一种通用结构,而是根据数据特点选择最合适的编码。理解这一点之后,我们在使用 Redis 时就能更好地选择数据类型、设计 key、控制 value 大小,并且更合理地分析 Redis 的内存和性能问题。

Redis 数据结构学习笔记
https://contrue.top/posts/redis-data-structures-notes/
Author
contrueCT
Published at
2026-04-13
License
CC BY-NC-SA 4.0