理解 Redis 各类数据结构的精妙之处,离不开两个底层设计原则:
- 极致节省内存:能省就省,既压缩结构本身,也采用动态或共享等机制确保每一位(bit)都物尽其用。
- 最大化 CPU 缓存友好:优先用一块连续小内存管理数据,降低指针跳转和碎片化,以提升访问速度和 CPU 缓存命中率。
这两点支撑着 Redis 各类数据类型的演进路线与编码选择。
版本声明:Redis8.4.0
0. redisObject
在深入具体类型之前,我们先对 Redis 对象头 redisObject 做一个简单的了解,因为 Redis 中的所有 Key 和 Value,在底层都是一个 redisObject 结构体。这是 Redis 多态的基石。redisObject 的定义位于 src/server.h
1 |
|
type: 逻辑数据类型(String、List、Hash、Set、ZSet)。encoding: 物理编码格式(int、row、listpack、skiplist)。lru: 淘汰策略数据,如果是 LRU 模式,则存储最后一次访问的时间戳(秒级)。如果是 LFU 模式,则存储方法时间和访问频率。当内存达到maxmemory时,Redis 根据此字段决定淘汰哪个 Key。iskvobj: Redis 8.0 的新特性。1表示该对象是一个kvobj(键值对象)的一部分,意味着 Key、Value 和元数据在内存中是紧凑/内嵌存储的。这样可以消除"键值分离"带来的指针跳转,提升 CPU 缓存命中率。expirable: Redis8.0 的新特性。1表示该 Key 设置了过期时间。这提供了一条快速通道,访问时若为 0,直接跳过查询expires哈希表的步骤,节省昂贵的哈希查找开销。refcount: 记录有多少个指针引用了该对象。用于内存管理和对象共享(主要用于小整数共享)。当计数降为 0 时,回收内存。这里使用了位域压缩以节省空间。ptr: 指向实际存储数据的内存地址(如 SDS、Dict、Quicklist 的地址)。特别地,如果 encoding 是INT,这里直接存储整数值本身,不再是指针。
上述多个字段,最重要的就是 type 和 encoding。总的来说,type 决定了它对外声称是什么(比如 Hash),而 encoding 决定了它在内存里到底怎么存(比如是压缩列表还是哈希表)。优化 Redis 内存的核心,就是想办法让它保持在轻量级的 encoding 上。
1. String
String 是 Redis 最基本的数据类型,也是所有 Key 的类型。
1.1 核心操作
1 | # 基础操作 |
常见使用场景:
- 缓存层:存储 JSON 序列化后的对象,如用户信息、商品详情
- 计数器:文章阅读数、点赞数、库存数量等原子计数场景
- 分布式锁:
SET key value NX EX 30实现简单的分布式锁 - 限流器:使用
INCR和EXPIRE实现滑动窗口限流 - 会话管理:存储用户 session 信息,配合过期时间自动清理
1.2 底层原理
Redis 没有直接使用 C 语言的字符串 char*,而是自己封装了 SDS(Simple Dynamic String),而且还进一步分成了 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。其核心目的是根据字符串的长度,使用不同大小的头部,从而进一步压缩内存的使用。
SDS 的定义位于 src/sds.h。
1 | /* Note: sdshdr5 is never used, we just access the flags byte directly. |
我们重点关注 3 个字段:
len: 已用长度。alloc: 分片的总内存大小(不包括头部和末尾的\0)。buf[]: 实际存储字符数据的柔性数组。
即便都是 String,Redis 为了省内存,也使用了 3 种不同的编码格式:
| 编码类型 | 场景 | 特点 | 优缺点 |
|---|---|---|---|
| int | 纯数字且在 long 范围内 | ptr 指针直接存数值,不需要 SDS。 |
最省内存,无额外指针开销。 |
| embstr | 字符串 ≤ 44 字节 | redisObject 和 SDS 连续分配,只调用 1 次 malloc。 |
内存连续,缓存亲和性好;但只读,修改会转 raw。 |
| raw | 字符串 > 44 字节 | redisObject 和 SDS 分开分配,调用 2 次 malloc。 |
适合长字符串,修改灵活。 |
[!note]
💡 44 字节怎么来的?
redisObject(16B) +SDS头 (3B) +\0(1B) = 20B。 内存分配器(如 jemalloc)通常分配 64B 的块。 64B - 20B = 44B。刚好填满一个内存块,不浪费。

2. List
List 在 Redis 中逻辑上是一个 双向链表。这意味着:
- 头尾操作极快:
LPUSH/RPOP是 O(1)。 - 随机访问极慢:
LINDEX是 O(N)。(千万别把它当数组用!)
2.1 核心操作
1 | # 头部操作 |
常见使用场景:
- 消息队列:
LPUSH/BRPOP实现简单的生产者-消费者模式,BLPOP提供阻塞式消费 - 最新动态:用户动态、新闻列表使用
LPUSH/LRANGE 0 10获取最新 10 条 - 任务队列:异步任务处理,配合
RPOPLPUSH实现可靠队列(失败重试) - 数据分页:存储分页数据,使用
LRANGE实现高效分页查询 - 排行榜:结合
LPUSH/LTRIM维护固定大小的排行榜或热榜 - 限流队列:实现滑动窗口限流,使用
LLEN监控队列长度
2.2 底层原理
1 | typedef struct listNode { |
Redis List 的底层实现经历了三次大的变革:
| Redis 版本 | 底层实现 | 结构描述 | 优缺点 |
|---|---|---|---|
| v3.2 之前 | LinkedList (双向链表) Ziplist (压缩列表) |
元素少用 Ziplist,多用 LinkedList。 | LinkedList:指针占用内存巨大,内存碎片多,Cache 亲和性差。 Ziplist:省内存,但更新效率低。 |
| v3.2 - v6.2 | Quicklist (快速列表) | 链表的节点是压缩列表。 | 集大成者。既保留了链表的伸缩性,又利用了压缩列表的省内存特性。 |
| v7.0+ | Quicklist + Listpack | 将 Quicklist 内部的 Ziplist 替换为 Listpack。 | 解决了 Ziplist 的级联更新问题。 |
2.2.1 LinkedList
我们先来看 LinkedList,这是最直觉的实现:
1 | +------+ +------+ +------+ |
优点:完美的 O(1) 头尾操作
缺点:
- 高昂的内存开销:
prev和next指针占据了大量内存,如果data不够大,那性价比就很低了。 - 糟糕的 CPU 缓存局部性:链表的节点在内存中是离散分布的。当 CPU 遍历链表时,指针跳转的行为极易导致 CPU Cache Miss。
2.2.2 Ziplist
为了克服 linkedlist 的双重代价,Redis 的设计者们创造了一种极其紧凑的数据结构:压缩列表 (ziplist)。
ziplist 的核心思想是,用一块连续的、完整的内存块来存储所有元素,从而彻底消除指针开销,并最大化利用 CPU 缓存。
1 | <zlbytes> <zltail> <zllen> <entry_1> <entry_2> ... <entry_N> <zlend> |
<zlbytes>: 整个ziplist占用的总字节数。<zltail>: 到最后一个 entry 的偏移量,用于快速定位到表尾。<zllen>: entry 的数量。<entry>: 真正的列表元素,每个 entry 也是变长的。<zlend>: 特殊的结束标记0xFF。
ziplist 的精髓在于 entry 的设计。每个 entry 的头部会记录前一个 entry 的长度 (prev_len),这使得 ziplist 可以从后向前遍历。
优点:内存占用小且对 CPU 缓存友好。
缺点:级联更新。因为 entry 包含了前面节点的信息(prev_len),所以前面的节点发生变化时,很容易造成后面节点的级联更新。
2.2.3 Quicklist
既然 linkedlist 和 ziplist 各有优劣,能否将它们结合起来,取其精华,去其糟粕?快速列表 (quicklist) 应运而生,并从 Redis 3.2 开始成为 List 的默认实现。
quicklist 的本质,就是一个由 ziplist(或后来的 listpack)节点组成的双向链表。
1 | +----------------+ +----------------+ +----------------+ |
它在宏观上是一个 linkedlist,保持了 O(1) 的头尾插入性能和灵活性。而在微观上,每个节点内部是一个 ziplist 或 listpack,存储了多个元素,极大地节省了内存,并提升了缓存局部性。quicklist 通过将连锁更新的风险限制在一个个独立的小节点内部,完美地规避了 ziplist 最大的风险。
2.2.4 Listpack
quicklist 虽然将 ziplist 的缺点限制在了一个节点之间,但是其缺点依旧存在,为此,紧凑列表(listpack) 诞生了。它去掉了 prev_len,改为在当前节点内部记录自己的长度 back-len(并且经过特殊编码支持反向解码)。修改当前节点,再也不会影响下一个节点的头部的长度了。
一个 listpack 结构如下:

重点在于这个 back-len,它等于该条目自身的 encoding-type 和 element-data 两部分加起来的长度(我们称之为"部分长度")。当需要从后向前遍历时,解析器会从前一个条目的尾部,反向解析出这个"部分长度",然后再动态计算出 back-len 字段自身的长度,两者相加得到前一个条目的总长度,从而实现精确的回溯跳转。
3. Hash
Hash 是一个 String 类型的 Field 和 Value 的映射表。
3.1 核心操作
1 | # 基础操作 |
常见使用场景:
- 对象缓存:存储用户信息、商品属性等结构化数据,如
user:1001 {name: "张三", age: 25, city: "北京"} - 计数器集合:文章的多维统计,如
article:1001 {views: 1000, likes: 50, comments: 20} - 配置管理:系统配置、用户偏好设置等键值对集合
- 购物车:简化版购物车实现,
cart:1001 {item_001: 2, item_005: 1} - 会话管理:比 String 更灵活的 session 存储,支持细粒度更新
- 实时统计:网站在线用户统计、API 调用次数等分组统计场景
- 标签系统:文章标签管理,
tags:article_001 {tech: 1, redis: 1, database: 1}
3.2 底层原理
Hash 的底层实现有两种:Listpack (原 Ziplist) 和 Hashtable。
| 编码类型 | Redis 7.0 前 | Redis 7.0 后 | 触发条件 (默认配置) | 内存特征 | 查询复杂度 |
|---|---|---|---|---|---|
| 紧凑型 | Ziplist | Listpack | 元素数 < 512 且 值长度 < 64 字节 | 连续内存,无指针 | O(N) |
| 分散型 | Hashtable | Hashtable | 超过上述任一阈值 | 指针+链表,有碎片 | O(1) |
[!NOTE]
💡 关键点:Hash 的 Listpack 布局 和 List 不同,Hash 在 Listpack 中存储时,是成对出现的。
[ Key1 | Val1 | Key2 | Val2 | ... ]查找时,Redis 从头遍历,找到 Key 后,紧接着取下一个 Entry 就是 Value。
listpack 的存储结构在 List 我们已经介绍过了,这里就介绍 Hash 的默认编码格式 hashtable。构成 Redis 哈希表的核心是 dict 及其节点 dictEntry 结构,源码可看:src/dict.c。它不仅支撑了 Redis 的 Hash 数据类型,更重要的是,Redis 整个数据库(KV 空间)本身就是一个巨大的 Dict。
1 | struct dictEntry { |
- 拉链法 (Chaining):
dictEntry中的next指针,是 Redis 解决哈希冲突的基础手段,构建了 bucket 内的单向链表。 - 双表架构 (Double Tables):
ht_table[2]是 Redis 实现无阻塞扩容的物理基础。通常情况下,只有ht_table[0]在工作,ht_table[1]是空的。当ht[0]满了需要扩容(或缩容)时,Redis 并不是在原表上操作,而是先给ht[1]分配更大的空间(两倍),然后将ht[0]的数据慢慢搬到ht[1]。等搬完后,删除ht[0],把ht[1]变成新的ht[0]。 - 渐进式 Rehash (Incremental):
rehashidx记录当前搬迁到了ht[0]的第几个 bucket(数组下标)。每次对字典进行增删改查(CRUD)时,顺便把rehashidx位置的一个 bucket 搬到ht[1],然后rehashidx++。不用担心没有请求时就一直不迁移,后台有定时任务serverCron会包含 rehash 操作用于辅助迁移,以避免这个问题。
4. Set
Set 是一个无序、唯一的字符串元素集合。其核心价值在于高效的成员关系判断(SISMEMBER)和服务器端的集合运算(SINTER, SUNION, SDIFF)。
4.1 核心操作
1 | # 基础操作 |
常见使用场景:
- 标签系统:文章标签、用户兴趣标签,
SINTER找共同标签,SUNION找所有标签 - 共同好友:社交网络中找出两个用户的共同好友,
SINTER user:1001:friends user:1002:friends - 去重统计:访问用户去重、搜索关键词去重,利用 Set 的天然去重特性
- 权限控制:角色权限管理,
SADD role:admin user,delete,create - 推荐系统:基于共同兴趣的用户推荐,
SINTER user:1001:likes user:1002:likes - 黑名单/白名单:IP 黑白名单、用户黑名单等,
SISMEMBER快速判断 - 抽奖活动:
SRANDMEMBER随机抽取中奖用户,SPOP抽取并移除避免重复中奖 - 在线用户:实时在线用户集合,
SADD用户上线,SREM用户下线
4.2 底层原理
Set 同样采用了双重编码策略,分别是 hashtable 和 intset。
| 编码类型 | 场景 | 结构描述 | 优缺点 |
|---|---|---|---|
| IntSet (整数集合) | 元素全部是整数 且 数量 < 512 | 有序的整数数组 | 二分查找 O(logN);极致省内存。 |
| Hashtable (哈希表) | 元素包含非整数 或 数量 > 512 | value 为 NULL 的字典 |
查询 O(1);内存占用大。 |
其中 hashtable 完全复用上文所述的 dict 结构。集合中的每个元素被存储为 dictEntry 中的 key,而 union v (值) 部分则被忽略(统一设为 NULL)。这种设计直接利用了 dict 键的唯一性来保证 Set 元素的唯一性,并继承了其 O(1) 的查找性能。
当一个 Set 满足以下两个条件时,会采用 intset 编码:
- 集合中所有元素均为整数。
- 元素数量未超过
set-max-intset-entries配置项的阈值(默认为 512)。
intset 是一种自适应整数编码的有序数组。它可以根据存入整数的范围,将内部存储格式动态升级为 int16_t, int32_t 或 int64_t,以最小的内存空间存储数据。成员查找通过二分搜索算法实现。它的定义位于 src/intset.h。
1 |
|
升级机制:
-
如果当前存的都是小整数(如 1, 2, 100),数组用
int16存储。 -
当你插入一个大整数(如 65536,超出了 int16 范围),Redis 会将整个数组的所有元素升级为
int32,并重新分配内存。注意:IntSet 不支持降级。一旦升级,这就回不去了。 -
如果你向一个 IntSet 插入了一个字符串(比如 “a”),即使之前存的全是整数,Redis 也会立刻将其转换为 Hashtable。且不可逆:就算你后来把 “a” 删了,它也不会变回 IntSet。
这里简单补充一下 int8_t contents[] 的含义,因为笔者对 C/C++ 并不熟悉,所以一开始阅读这个代码定义的时候还觉得很奇怪?
—— 为什么
int8_t[]类型的数组可以存放int16/int32/int64的元素?
在 C99 标准中,结构体最后一个元素如果是未知大小的数组(如 contents[]),被称为柔性数组成员。contents 本身不占用 intset 结构体的大小(或者只被视为一个偏移量标记)。当我们为 intset 分配内存时,会多分配一块连续内存跟在结构体后面。
1 | // 伪代码:分配一个包含 10 个 int64 元素的 intset |
声明为 int8_t(即 1 字节),是因为在 C 语言中,int8_t(或 char)是最小的内存寻址单位。这意味着 contents 指向的是一块原始的、未被定义的字节流。
Redis 在读取数据时,并不是直接通过下标 contents[i] 来读取(那样只能读到 1 个字节),而是通过以下逻辑进行指针强转和偏移计算:
1 | /* * 根据给定的编码方式 (encoding),返回 intset 中指定位置 (pos) 的元素值。 |
5. Sorted Set
Sorted Set,即 ZSet,它是一个 Member (成员) 到 Score (分数) 的有序映射。
- 特性:Member 唯一,Score 可重复。
- 排序:按 Score 从小到大排;Score 相同,按 Member 字典序排。
5.1 核心操作
1 | # 基础操作 |
常见使用场景:
- 排行榜系统:游戏积分榜、文章热度榜、用户财富榜,
ZRANGE/ZREVRANGE获取 Top N - 延迟队列:以时间戳为分数,实现定时任务调度,
ZRANGEBYSCORE获取到期任务 - 优先级队列:任务优先级管理,高优先级任务分数更高,
ZPOPMAX获取最高优先级任务 - 范围搜索:价格区间商品查询、年龄范围用户筛选,
ZRANGEBYSCORE高效范围查询 - 加权投票:用户投票系统,投票时间越新权重越高(时间戳作为分数)
- 实时统计:热门搜索词排行,搜索次数作为分数,
ZINCRBY实时更新 - 教育资源:学生成绩管理,分数直接作为 zset 分数,
ZRANK获取排名 - 地理位置排序:虽然有 Geo 类型,但 zset 可以实现更复杂的排序逻辑
5.2 底层原理
Set 同样采用了双重编码策略,分别是 ziplist/listpack 和 skiplist+dict。
| 编码类型 | 场景 | 结构描述 | 优缺点 |
|---|---|---|---|
| 紧凑型 (Ziplist / Listpack) | 元素少(< 128)且 元素小(< 64 字节) | 紧凑数组。Member 和 Score 紧挨着存,按 Score 排序。 | 省内存;插入/查询 O(N)。 |
| 分散型 (Skiplist + Dict) | 超过阈值 | 跳表 + 哈希表 (双重索引)。 | 查询/插入 O(logN);内存占用大。 |
相信对阅读到了这里的读者来说,已经不会对 ZSet 使用 listpack 感到意外了,这是 Redis 里典型的用时间换空间的策略了。
当 Sorted Set 中存储的元素数量很少,并且每个元素的值都不大时,Redis 会选择 listpack 编码。
条件是:
- 元素数量小于
zset-max-listpack-entries 128 - 所有元素值的字节长度小于
zset-max-listpack-value 64
它将每个 (member, score) 对紧凑地序列化存储。为了保持有序,每次插入都需要找到正确的位置,这可能导致其后的数据发生移动。当 N 非常小(例如小于 128)时,𝑂(𝑁) 的操作成本极低,几乎是瞬时的,而节省下来的内存却非常可观。
一旦 listpack 的任一触发条件被打破,Redis 会自动将其转换为 skiplist + dict 编码。其实 ZSet 要采用"双引擎"模式,也不难理解,它要解决的核心矛盾是:如何创建一个既能通过成员(member)快速查找、又能根据分数(score)高效排序和范围查找的数据集合?
dict(字典/哈希表):负责"集合"的部分。它建立了一个从member到score的映射。这使得ZSCORE这种通过成员获取分数的操作,时间复杂度 𝑂(1)。skiplist(跳表):负责"排序"的部分。它将所有的(score, member)对按照score(分数相同则按member字典序)进行排序。跳表的特性使得插入、删除和按排名/分数范围查找的平均时间复杂度都是 𝑂(𝑙𝑜𝑔𝑁)。
为了节约内存,
dict和skiplist中的member字符串是共享的,即它们都指向同一个SDS (Simple Dynamic String)对象。
跳表的核心思想是空间换时间和随机化。
想象一下,一个普通的单链表,查找效率是 𝑂(𝑁)。现在,我们从这个链表中,随机抽取一些节点,给它们增加一个"上层指针",指向下一个被抽取的节点。这样我们就构建了第 2 层"快速通道"。我们可以不断重复这个过程,构建出多层快速通道。
当我们要查找一个元素时:
- 从最高层的"快速通道"开始。
- 在当前层向右查找,直到找到的下一个节点比目标大,或者到了链表末尾。
- 然后从当前节点下降一层,重复步骤 2。
- 最终在最底层(原始链表)找到目标位置。
因为高层索引可以让你"跳过"大量节点,所以平均查找效率被提升到了 𝑂(𝑙𝑜𝑔𝑁)。而这种层级的建立是完全随机的,它通过概率来维持整体的平衡,避免了红黑树那样复杂的平衡操作。
这就是 Redis 选择跳表的原因:用更简单的实现,达到了与平衡树相媲美的性能。
在 Redis 8.4.0 的源码中,关于 zset 的类型定义位于 src/server.h 文件中。如下所示,它主要包含 3 个核心数据结构:zset、zskiplist 和 zskiplistNode。
1 | // 跳表节点 |
从比较直观的角度来讲的话,跳表的结构可以用下图来演示:

如果要完全复刻上述所定义出来的数据结构,那表示起来可能会有点复杂,这里我画了张图,供你参考:

本篇限于篇幅原因和阅读性价比的关系,笔者对绝大多数数据结构都不太愿意展开源码,不过介于 skiplist 是一个非常常考的点,也是一个比较艺术性的实现,所以在本章节我们将从一个最核心的插入逻辑来更进一步了解跳表的实现细节。 zset 的核心实现逻辑位于 src/t_zset.c 文件中,其中插入操作由 zslInsert 函数来实现。这里我先把完整的代码和注释给你,接下来我们再一一拆解。
1 | /* 在跳表中插入一个新节点。 |
插入操作主要分为四个步骤:
- 查找插入位置(Search):从最高层往下找,记录每层的前驱节点(
update[])和到达前驱节点的排名(rank[])。 - 生成随机层数(Random Level):决定新节点的高度。如果比当前跳表高,需要初始化高层的前驱。
- 创建节点与链接(Link):调整指针,在
0到level-1的每一层中将新节点插入链表,并极其精细地计算新旧节点的 span 值。 - 收尾工作:更新高层的 span(只加 1),设置后退指针,更新长度。
接下来我们来一一拆解这段代码的每一个细节。
第 0 步:初始化与准备
1 | zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) { |
zskiplistNode *update[ZSKIPLIST_MAXLEVEL]: 路径记录仪。它是一个指针数组,update[i]将用于存储新节点在第i层的前驱节点。为什么需要它?因为插入操作的本质就是在update[i]和它原来的下一个节点之间,插入我们的新节点。这个数组为我们保存了所有需要修改的连接点。unsigned long rank[ZSKIPLIST_MAXLEVEL]: 距离计数器。rank[i]用于记录从跳表header头节点出发,到达update[i]这个节点时,在最底层(第 0 层)总共跨越了多少个节点。它的核心作用是为后续精确计算和更新节点的span(跨度)提供数据支持,这是ZRANK命令能够实现 𝑂(𝑙𝑜𝑔𝑁) 的基石。
第 1 步:查找插入位置并记录路径
1 | x = zsl->header; |
这是跳表算法的精髓所在——分层查找。
for (i = zsl->level-1; i >= 0; i--): 循环从最高层(zsl->level-1)开始,逐层下降到最底层(0)。这模仿了我们在地图上查找位置的过程:先看洲际地图(高层),再看国家地图(中层),最后看城市街道图(底层)。高层级的"快速通道"可以让我们一次性跳过大量节点。while (...): 在当前层级,不断向右移动。判断条件(score < ... || (score == ... && ele < ...))保证了跳表的排序规则:优先按score升序,如果score相同,则按member的字典序升序。rank[i] += x->level[i].span;: 这是rank数组工作的核心。每当我们从x节点跳到它的下一个节点时,并不是只前进了一步,而是前进了x->level[i].span步。我们将这个跨度累加到rank[i]中,rank[i]就实时记录了我们距离起点的总步数。update[i] = x;: 当while循环结束时,x节点就是新节点在第i层的前驱节点。我们将其记录在update[i]中。当整个for循环结束后,update数组就完整记录了从最高层到最底层的一条插入路径。
第 2 步:确定新节点层高并处理新层级
1 | /* ---------------------------------------------------------------------- |
level = zslRandomLevel(): 新节点将拥有几层"快速通道"?这里不是由复杂的平衡算法决定,而是通过一个简单的概率函数随机生成。大部分节点只有 1 层,极少数节点会有很高的层数。正是这种随机性,使得跳表在整体上能够维持一个高效的对数级结构。if (level > zsl->level): 处理一个特殊情况。如果随机出的level比当前跳表的最大层数还大,意味着我们需要为整个跳表加盖新的楼层。在这些新楼层,路径上的前驱节点自然就是header节点,并且它到NULL的跨度就是整个跳表的长度zsl->length。
第 3 步:节点创建、链接与跨度更新
1 | /* ---------------------------------------------------------------------- |
这是整个插入过程中最核心的逻辑:
链接 forward 指针: 这两行是经典的链表插入操作。假设原来是 A -> C,A 就是 update[i],C 就是 update[i]->level[i].forward。现在,我们将新节点 x 插入其中,变为 A -> x -> C。
更新 span (跨度): 这是最精妙的部分,我们用一个例子来说明。
- 假设在第
i层,前驱节点A(即update[i]) 原来的span是10,它直接指向C。 - 我们在第 0 层(最底层)的查找过程中,从
A之后,又前进了 3 步才找到插入点。这意味着rank[0] - rank[i]的值是 3(可以理解为 A 在高层和底层之间的投影偏差)。 update[i]->level[i].span = (rank[0] - rank[i]) + 1;:A的新span变为3 + 1 = 4。因为它现在指向新节点x,它俩之间跨越了3个旧节点,加上x本身,总共是4步。x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);: 新节点x的span等于A的旧span(10) 减去它俩之间的距离 (3),等于7。
验证: 插入后,
A的新span(4) +x的新span(7) =11。而A的旧span是10。总跨度增加了1,正好等于新加入的节点数量。完美!
另外,对于那些高于新节点 level 的层级,新节点并不会被插入。但是,由于整个跳表的总长度增加了 1,这些更高层级上、位于插入路径上的前驱节点(update[i]),它们指向的下一个节点的相对距离也增加 1。因此,它们的 span 值需要递增。
第 4 步:更新后退指针和表尾
1 | /* ---------------------------------------------------------------------- |
x->backward = ...: 跳表的最底层(level[0])是一个双向链表,backward指针用于支持ZREVRANGE等反向遍历命令。这里将新节点的后退指针正确地指向它的前驱节点update[0]。if...else...: 更新新节点的后继节点的backward指针,让它指向新节点x。如果新节点是最后一个节点,则更新整个跳表的tail指针。zsl->length++: 最后,将跳表的总长度加 1。
至此,一个新节点被天衣无缝地织入了跳表这张大网中,所有相关的指针和跨度信息都得到了原子性的更新。
6. Bitmap
Bitmap(位图) 利用 String 的二进制位(Bit)来存数据,适用于处理 bit 级别的数据。因为 SDS 最大 512MB,所以 Bitmap 最多能存 $2^{32}$ (约 40 亿) 个 bit。
1 | # 基础位操作 |
常见使用场景:
- 用户签到:用位表示用户每日签到状态,1 表示已签到,
BITCOUNT统计月签到天数 - 在线状态:用户在线/离线状态跟踪,
SETBIT user:online 1001 1表示用户 1001 在线 - 权限管理:RBAC 权限系统,每位代表一种权限,
BITOP AND实现权限交集 - 布隆过滤器底层:实现自定义布隆过滤器的基础数据结构
- 用户特征:用户画像标签系统,1 表示有该特征,
BITOP OR找相似用户 - A/B 测试:用户分组标识,0 为对照组,1 为实验组
- 数据统计:日活用户统计,
BITOP OR合并多日活跃用户计算总活跃数 - 去重统计:大量 ID 去重,使用位图存储已见过的 ID,节省内存
- 游戏系统:成就系统解锁状态,每个成就对应一个位,
BITCOUNT统计解锁数量
7. HyperLogLog
HyperLogLog 是一个概率数据结构,用于估计集合的基数。它的底层也是 String 类型。每个 HyperLogLog 最多消耗 12KB 的内存,在标准误差 0.81% 的前提下,可以计算 $2^{64}$ 个元素的基数。
7.1 核心操作
1 | # 基础操作 |
常见使用场景:
- UV 统计:网站/APP 日活、月活用户统计,
PFADD记录用户访问,PFCOUNT获取 UV 数量 - 大数据去重:海量数据去重统计,如日志分析中的独立 IP 统计
- 实时统计:社交平台的活跃用户数、电商平台的访客数等实时去重统计
- 用户行为分析:页面浏览量去重、搜索关键词去重统计
- 广告曝光:广告独立曝光人数统计,避免重复计算同一用户
- 数据分析:A/B 测试中的独立用户数统计、转化漏斗的去重计数
- 监控告警:系统独立错误触发次数、独立异常 IP 统计
- 内容分发:视频独立观看人数、文章独立阅读数统计
7.2 底层原理
HyperLogLog 使用概率算法来统计集合的近似基数,而概率算法的本质是伯努利试验(Bernoulli Trial),伯努利试验可以看作一个抛硬币过程。
假设我们玩抛硬币游戏,我让你一直抛,直到抛出"正面"为止,这算一轮。
我们记录每一轮中,"反面"连续出现的次数(也就是前导 0 的个数)。
- 情况 1:你抛了 1 次就出了正面。前导 0 个数 = 0。
- 情况 2:你抛了 2 次(反、正)。前导 0 个数 = 1。
- …
- 情况 N:如果你告诉我,你刚刚那一轮,连续抛了 10 次反面才出正面(前导 0 = 10)。
推断:连续抛 10 次反面的概率是 $(1/2)^{10} = 1/1024$。
如果你能做到这件事,我不仅认为你运气好,我更有理由相信:你肯定已经在背后偷偷抛了很多很多轮(大概 1024 轮左右),才碰巧出现了一次这么罕见的情况。所以我们可以通过观察一组随机数(Hash 值)中**“最大前导 0 的个数”**,来反推这组随机数大概有多少个(基数)。
| 二进制形态 | 描述 | 概率 | 倒数 (预估数量) |
|---|---|---|---|
1... |
开头是 1 (0 个零) | $1/2$ (50%) | $2^1 = 2$ |
01... |
开头是 01 (1 个零) | $1/4$ (25%) | $2^2 = 4$ |
001... |
开头是 001 (2 个零) | $1/8$ (12.5%) | $2^3 = 8$ |
| … | … | … | … |
00...01 ($k$ 个零) |
开头 $k$ 个 0 | $1 / 2^{k+1}$ | $2^{k+1}$ |
如果只依据一次运气最好的记录(最大前导 0)来估算,偶然性太大(比如我第一次就走了狗屎运抛了 10 个反面,你不能说我抛了 1000 次)。
HyperLogLog 引入了 分桶(Bucketing)的概念:
- 把数据分成 $m$ 个桶。
- 分别统计每个桶里的最大前导 0。
- 对这些桶的结果求调和平均数(而不是算术平均数),以消除极端值(离群点)的影响。
算术平均数(Arithmetic Mean)我们都懂:加起来除以 N。
$$
A = \frac{x_1 + x_2}{2}
$$调和平均数是:倒数的平均数,再取倒数。
$$
H = \frac{2}{\frac{1}{x_1} + \frac{1}{x_2}}
$$调和平均数对小数值更敏感,而对大数值(离群值)不敏感。Redis 就是通过这 16384 个桶的调和平均数,把"运气"成分对冲掉,最终让误差稳定在 0.81% 左右。
Redos 内部使用字符串 Bitmap 来存储 HyperLogLog 所有桶的计数值,一共包含 $2^{14}=16384$ 个桶,每个桶都是一个 6bit 的数组。HyperLogLog 的头部定义位于:src/hyperloglog.c
1 |
|
当执行 PFADD key element 时:
- Hash:Redis 使用 MurmurHash64A 算法将
element转换为一个 64 位的整数(比特串)。 - 分桶:Redis 使用这个 64 位整数的 前 14 位 作为桶的索引(Register Index)。$2^{14} = 16384$,所以才说 Redis 内部维护了 16384 个桶(
registers[])。 - 计数:使用剩下的 50 位 (64 - 14) 来计算前导 0 的个数,因为只有 50 位,所以 6 个 bit($2^6 = 64 > 50$)就足够了。如果算出来前导 0 是 $k$,就把第 $index$ 个桶的值更新为 $\max(\text{当前值}, k+1)$。
Redis 为了省内存,也不会一上来就申请 12KB,所以 registers[] 存在两种编码格式:
- 稀疏编码 (Sparse):当 HLL 刚创建,或者元素很少时,Redis 使用一种类似 RLE (Run-Length Encoding) 的压缩格式。比如"连续 100 个桶都是 0",它就存"100 个 0",而不是存 100 个 0 的实际数据。
- 密集编码 (Dense):当数据多了,稀疏编码存不下了,就会转换成标准的 12KB 密集数组。
8. Bloom Filter
Bloom Filter(布隆过滤器)是由 Burton Howard Bloom 于 1970 年提出的,它是一种 space efficient 的概率型数据结构,用于判断一个元素是否在集合中。通常用于快速判断某个元素是否可能存在于一个大型数据集中,而无需实际存储整个数据集。
在 Redis 8 之后,Bloom Filter 已经内置在 Redis 中了。在这之前,它不是 Redis 的标准功能,而是通过 Redis 4.0+ 引入 Module 插件机制安装的,详细可参考 GitHub 丨 RedisBloom。
8.1 核心操作
1 | # 创建布隆过滤器 |
常见使用场景:
- 缓存穿透防护:查询数据库前先用布隆过滤器检查 key 是否存在,避免无效查询穿透到数据库
- URL 去重:爬虫系统中判断 URL 是否已经爬取过,避免重复爬取
- 用户注册:检查用户名/邮箱是否已被注册,快速响应
- 垃圾邮件过滤:维护已知垃圾邮件发送者黑名单,快速过滤
- 数据库分片:快速判断某个 key 应该存储在哪个分片上
- 推荐系统:过滤用户已经看过的内容,避免重复推荐
- 安全防护:IP 黑名单、恶意请求过滤等安全场景
- 数据同步:分布式系统中判断数据是否需要同步,减少网络传输
8.2 底层原理
布隆过滤器的数据结构底层其实就是一个巨大的 Bitmap。通过一组哈希函数,将同一个元素计算出来的多个哈希值对应的 bit 位置为 1,从而判断一个元素是否存在:
- 如果存在 bit 位为
0的:则该元素一定不存在。 - 如果不存在 bit 位为
0的:则该元素大概率存在。
所以很显然,Bloom Filter 是不支持删除元素的。

9. Geospatial
Redis Geospatial 是 Redis 3.2 引入的一组用于处理地理位置信息的命令。它允许我们存储地理坐标(经度和纬度),并执行诸如"计算两点距离"、"查找附近的人"等 LBS(Location-Based Service)操作。
9.1 核心操作
1 | # 基础操作 |
常见使用场景:
- 附近的人:社交应用查找附近用户,
GEORADIUS查找指定范围内的用户 - 附近商家:生活服务平台查找附近餐厅、酒店、银行等
- 打车服务:查找附近司机、计算预估距离和费用
- 外卖配送:查找附近商家、计算配送距离和时间
- 位置签到:记录用户位置,实现签到功能
- 地理围栏:设定虚拟地理边界,进入或离开时触发事件
- 位置营销:基于地理位置的精准营销和广告推送
- 物流追踪:实时追踪包裹位置,查找附近配送点
- 旅游导航:查找附近景点、计算景点间距离
- 紧急服务:查找附近的医院、警察局等紧急服务点
实用示例:
1 | # 添加城市位置 |
9.2 底层原理
Redis Geo 并没有引入新的底层数据结构,其本质上是一个 Sorted Set。
地理位置是一个二维坐标 (经度, 纬度),而 Redis 的 ZSet 只能按一个一维的 score 进行排序。为了复用 ZSet 的排序和范围查找能力,必须将二维坐标映射为一维整数。
GeoHash 就是这种映射算法。它将地球表面划分为无数个矩形网格,并利用 Z-Order Curve(Z 阶曲线)将二维网格编码为一维的二进制串。GeoHash 的编码过程就是将经度和纬度分别二分逼近,大于中间值记为 1,小于记为 0。然后将经纬度的二进制位交错组合(偶数位放精度,奇数位放纬度),生成最终的 52 位整数。
当我们要查询"附近的人"时,实际上是在 ZSet 中查找 score(GeoHash 值)相近的元素。
但是,GeoHash 存在一个著名的边界问题:两个点可能直线距离很近,但刚好位于两个格子的边缘,导致它们的 GeoHash 值差异巨大(例如一个在 Z 曲线的末端,一个在起端)。
为了解决这个问题,Redis 在执行 GEORADIUS 时,不仅会搜索中心点所在的那个格子(GeoHash 范围),还会自动计算并搜索周围的 8 个邻居格子。
我让 Gemini 绘制了一个网页,用于辅助理解,感兴趣的读者可自行下载参考。


10. PubSub
PubSub(Publish/Subscribe,发布/订阅)是一种消息通信模式。发送者(Pub)将消息发送到频道(Channel),而不用知道谁订阅了它;订阅者(Sub)接收感兴趣频道的消息,而不用知道谁发送了它。
Pub/Sub 的核心逻辑是 广播 (Broadcasting)。 它和 List/Stream 最大的区别在于:它不存储消息。

10.1 核心操作
1 | # 发布订阅操作 |
常见使用场景:
- 实时通知:系统通知、消息推送、事件提醒等实时通知场景
- 聊天室:多用户实时聊天系统,群聊广播消息
- 实时数据推送:股票价格、比赛比分、实时监控数据推送
- 事件驱动架构:微服务间的事件通信,解耦生产者和消费者
- 缓存失效:当数据更新时,通过 PubSub 通知缓存失效,实现缓存一致性
- 日志收集:应用日志实时收集和分发到日志处理系统
- 监控系统:系统告警、性能指标实时推送
- 配置更新:分布式系统中的配置变更通知
- 游戏实时通信:多人游戏中的状态同步、位置更新等
10.2 底层原理
Redis 服务器结构体 redisServer 中维护了关于 Pub/Sub 的重要字段:
1 | /* src/server.h */ |
kvstore *pubsub_channels:
- 普通频道订阅关系表。Key 是频道名 (Channel),Value 是一个链表,存储了所有订阅该频道的客户端 (Clients)。对应
SUBSCRIBE和PUBLISH命令。 - 在 Redis 7.0 之前,这里是一个
dict *(字典)。但在新版本中,它升级为了kvstore *。在 Redis Cluster 模式下,普通的 Pub/Sub 是广播的(一个节点收到消息,会转发给集群内所有节点)。kvstore是 Redis 内部对"支持分槽(Slot)的字典"的抽象。虽然普通的 Pub/Sub 是广播的,但使用kvstore统一了内部的数据结构接口,方便管理和扩容。
dict *pubsub_patterns:
- 模式订阅关系表。Key 是匹配模式 (Pattern,如
news.*),Value 是订阅了该模式的客户端列表。对应PSUBSCRIBE和PPUBLISH命令。 - 当有消息发布到
news.sports频道时,Redis 不仅要查pubsub_channels,还要遍历这个pubsub_patterns字典,看news.sports是否匹配其中的模式。
int notify_keyspace_events:
- 键空间通知配置。这是一个 Bitmask (位掩码) 整数。对应
redis.conf配置中的notify-keyspace-events(如 “Ex” 代表过期事件)。 - Redis 的键空间通知(Keyspace Notifications)本质上就是利用 Pub/Sub 机制实现的。当一个 Key 被修改或过期时,如果这个掩码中对应的位被置为 1,Redis 就会自动向特定的频道(如
__keyevent@0__:expired)发送一条 Pub/Sub 消息。
kvstore *pubsubshard_channels:
- 分片频道订阅关系表 (Sharded Pub/Sub)。对应
SSUBSCRIBE和SPUBLISH命令 (Redis 7.0 新增)。 - 普通的 Pub/Sub 在集群中是全量广播的。你在节点 A 发布消息,节点 A 会把它转发给 B、C、D… 即使 B、C、D 上根本没有客户端订阅这个频道。这造成了巨大的网络带宽浪费(写放大)。
- Sharded Pub/Sub 将频道名通过 Hash 算法映射到具体的 Slot (槽),就像普通的 Key 一样。消息只会被发送到持有该 Slot 的节点,不再全网广播。这里的
kvstore发挥了关键作用:它能根据 Slot 快速索引和管理频道。这是 Redis 7.0 针对集群 Pub/Sub 性能优化的一大体现。
unsigned int pubsub_clients:
- 当前的 Pub/Sub 客户端总数。用于
INFO命令统计和监控。 - Redis 需要知道当前有多少个连接处于"订阅状态",因为处于订阅状态的客户端,其 socket 读取逻辑和普通客户端略有不同(只能发送订阅相关命令)。
[!WARNING]
注意,虽然 Pub/Sub 不保存消息,但是为了保证把消息推给(在线的)订阅者,会把积压的消息暂时存在该订阅者 Client 的 输出缓冲区 (Output Buffer) 中。如果消费者处理得很慢,导致积压越来越多,这个缓冲区会无限膨胀,最终 OOM。
Redis 默认配置了保护策略
client-output-buffer-limit:如果缓冲区超过 32MB,或者持续 60 秒超过 8MB,Redis 会强制断开这个订阅者的连接。订阅者断连,消息丢失,但 Redis 活下来了。
11. Stream
Redis Stream 是 Redis 5.0 引入的最重量级特性,它的出现是为了填补 Redis 在消息队列领域的最后一块短板——持久化与可靠性。
在此之前,使用 Redis 做消息队列主要有两种方式,但都有致命缺陷:
- List (
BLPOP):无法支持多播;更致命的是,它没有 ACK 机制。消息一旦从队列中POP出来,如果消费者处理时宕机,这条消息就永久丢失了。 - Pub/Sub:支持多播,但它是 Fire and Forget(即发即弃)模式。如果消费者下线,期间发送的所有消息都会丢失,且无法回溯。
Stream 的设计灵感深受 Kafka 影响,它不仅支持多播(消费组),还引入了 PEL (Pending Entries List) 机制来保证消息的绝对不丢失,同时在底层结构上针对"时间序列 ID"做了极致的内存压缩。
11.1 核心操作
1 | # 基础操作 |
常见使用场景:
- 消息队列:替代 RabbitMQ/Kafka,处理异步任务、事件驱动架构
- 事件溯源:记录系统状态变更历史,支持事件回放和时间旅行查询
- 日志收集:应用日志的可靠收集和转发,支持消费者组并行处理
- 监控告警:系统监控指标的时序数据存储和分析
- 数据管道:ETL 过程中的数据缓冲和批处理
- 实时分析:用户行为分析、点击流处理等实时数据流处理
- IoT 数据:物联网设备数据的时序存储和处理
- 金融交易:交易日志记录、审计追踪,支持精确的时间序列查询
- 游戏系统:游戏事件记录、玩家行为分析
- 聊天系统:可靠的消息投递,支持离线消息和消息重试
11.2 底层原理
Stream 本质上是一个 仅追加 (Append-only) 的日志结构。为了满足高效的范围查找(按时间范围读消息)和极致的内存节省,Redis 并没有使用简单的链表或数组,而是设计了一种混合结构:Radix Tree (基数树) + Listpack。
11.2.1 stream
Stream 定义在 src/stream.h,负责维护消息的元数据、存储引擎(Rax)入口以及消费组状态。
1 | /* Stream 核心结构体 |
11.2.2 streamID
Stream 的消息 ID 默认是自动生成的,格式为 <时间戳>-<序列号>(例如 1702108800000-0)。
1 | typedef struct streamID { |
观察这组 ID,你会发现一个显著特征:前缀高度重复。在同一毫秒甚至同一秒内产生的消息,其 ID 的高位部分是完全相同的。如果使用普通的 Hash 表或跳表存储,这些重复的前缀会浪费大量内存。
11.2.3 Radix Tree
Radix Tree (Rax) 是一种前缀树。它将公共前缀提取出来作为父节点,差异部分作为子节点。这种结构天然适合存储时间序列数据,极大地压缩了索引空间。
这并非标准的 Radix Tree 的实现,标准的 Radix Tree 一个节点只有一个字符,当然这对于 Stream 这个场景依旧是极大的浪费,所以一个改进方案就是将多个相同前缀的字符合并在一个作为一个共享节点。
事实上 Go 的 Gin 框架的路由树也是采取的这种策略,具体可参考:Go 底层原理丨深度剖析 Gin 框架核心机制:从 HTTP 请求生命周期到高性能设计哲学。
如果 Rax 的每个叶子节点只挂一条消息,那指针开销依然很大。Redis 再次运用了"打包"的思想。
Stream 在 Rax 的节点中,并不直接存储单个消息,而是存储一个 listpack(又来了,神奇的 listpack)。一个 Rax 节点(称为宏节点)可能包含几十条甚至上百条消息。
- 宏观上:利用 Radix Tree 对 ID 前缀进行压缩,支持 $O(\log N)$ 的时间范围查找。
- 微观上:利用 Listpack 对具体的消息内容(Field-Value)进行紧凑存储,利用 CPU 缓存行并减少内存碎片。
[!IMPORTANT]
这再次印证了 Redis 的设计哲学:在大规模索引上使用树/跳表(空间换时间),在局部小数据块上使用紧凑数组(时间换空间)。
Radix Tree 定义在 src/rax.h。
1 | /* Rax (Radix Tree) 头部 */ |
由于 data[] 是变长的,C 语言无法直接描述其结构,其内存布局如下:
1. 压缩节点(iscompr = 1)
这意味着这是一条单行道。 当前节点只有一个子节点,且中间经过了一串字符。 比如:从节点 A 到节点 B,中间的路径字符串是 "QD"。
data[] 的内存布局:它像三明治一样,把路径字符串”和唯一的子节点指针紧挨着放。
1 | +-----------------------+ <-- 节点起始地址 |
2. 非压缩节点(iscompr = 0)
这意味着这是一个十字路口(分叉点)。 当前节点有多个子节点。 比如:节点 A 下面分出了 'A', 'B', 'C' 三条路。
data[] 的内存布局: 它把**所有的路牌(字符)放一起,把所有的路(指针)**放一起。
1 | +-----------------------+ <-- 节点起始地址 |
11.2.4 streamCG
Stream 能替代 List 成为企业级消息队列的核心,在于它引入了消费组 (Consumer Group) 和 PEL (Pending Entries List)。
当消费者调用 XREADGROUP 读取消息时:
- 消息不会从 Stream 中删除(这点与 List 不同)。
- 消息 ID 会被加入到该消费者的 PEL 中。
- 只有当消费者显式调用
XACK后,Redis 才会把该 ID 从 PEL 中移除。

这背后的核心数据结构是 streamCG。
1 | /* Consumer group. */ |
每个消费组都有一个全局游标 last_id。
- 当你使用
XREADGROUP GROUP mygroup alice >时,那个>符号实际上就是告诉 Redis:“请把last_id之后的消息发给我”。 - Redis 发送消息后,会更新
last_id。这保证了在同一个组内,消息不会被重复消费(除非显式回溯)。
如下图所示,如果我们把 Radix Tree 摊平来看,每个消费组有自己的游标位置,互不干扰,而 stream 结构中的 min_cgroup_last_id 字段会存储最满的 last_id,那前面的消息就可以删除了。

PEL (Pending Entries List) 是 Stream 实现 At Least Once 的关键。streamCG 维护了一个名为 pel 的 Radix Tree。
- 写入时机:当消息被
XREADGROUP读取但未XACK时,它会立即进入pel。 - 移除时机:只有收到
XACK,Redis 才会将该 ID 从pel中移除。
如果消费者宕机,这条消息会永久停留在 pel 中。运维人员可以通过 XPENDING 命令查看到这些消息,并安排其他消费者进行 Claim (接管)。
仔细观察结构体,你会发现 streamGC 维护了两个与 PEL 相关的 Radix Tree:
1 | /* Consumer group. */ |
这是一种典型的空间换时间设计:
pel: 按 ID 索引,Key 是消息 ID。当消费者发送XACK <id>时,Redis 需要快速找到这条消息并标记完成。使用 ID 索引可以达到 $O(\log N)$ 的查找速度。pel_by_time: 按时间索引,Key 是投递时间 + 消息 ID。用于故障恢复。当我们需要找出"哪些消息已经超时 10 分钟没处理了?"(即XPENDING ... IDLE <time>命令),Redis 不需要遍历整个 PEL,而是直接在pel_by_time这个时间树上进行范围查找。
这种主键索引 + 辅助索引的设计,确保了 Stream 无论是正常确认 (ACK) 还是故障排查 (Pending Query),都能保持极高的性能,不会因为积压消息过多而拖慢 Redis。
[!WARNING]
但这不意味着 Redis Stream 就可以替代传统的 MQ 了,其本质上是受限于昂贵的内存容量和异步持久化机制,无法像基于磁盘的 Kafka 那样以低成本实现海量数据的长期堆积与金融级的零丢失保障。
12. 原理和应用场景总结
Redis 的数据类型虽然丰富多样,但万变不离其宗。回顾全文,我们可以看到 Redis 在设计上始终在做两个维度的权衡(Trade-off):
- 内存 vs CPU:在数据量少时,倾向于使用时间换空间的紧凑结构(如 Listpack、Intset),通过 CPU 的轮询计算来节省昂贵的内存;在数据量大时,倾向于使用空间换时间的索引结构(如 Hashtable、Skiplist),通过增加指针开销来保证 O(1) 或 O(logN) 的访问速度。
- 精确 vs 概率:在处理海量数据统计时,为了突破物理内存的限制,引入了概率型数据结构(HLL、Bloom Filter),用极小的误差换取了巨大的空间收益。
以下是全系数据类型的核心原理与选型指南:
| 数据类型 | 底层结构 (Encoding) | 设计权衡 (Trade-off) | 核心适用场景 | 关键限制/注意 |
|---|---|---|---|---|
| String | int / embstr / raw (SDS) | 根据长度动态分配头部,减少内存碎片。 | 缓存、计数器、分布式锁、会话 | 最大 512MB。 |
| List | Quicklist (Listpack 链表) | 宏观链表 + 微观数组。平衡了内存紧凑性与两端操作的灵活性。 | 消息队列、最新 N 条动态、栈 | 随机访问 (LINDEX) 慢,适合头尾操作。 |
| Hash | Listpack $\to$ Hashtable | 小数据用紧凑数组(省内存但 O(N)),大数据转哈希表(快但费内存)。 | 对象存储、购物车、配置项 | 避免大 Key,大 Hash 迁移会阻塞(虽有渐进式 Rehash)。 |
| Set | Intset $\to$ Hashtable | 纯整数且有序时极致压缩,一旦插入非整数不可逆转为哈希表。 | 标签系统、共同好友、抽奖 | 尽量存整数 ID 以利用 Intset 优化。 |
| ZSet | Listpack $\to$ Skiplist+Dict | 双重结构:Dict 保证查询 O(1),跳表保证范围 O(logN)。 | 排行榜、延迟队列、范围查找 | 内存占用较高(指针多),元素多时注意性能。 |
| Bitmap | String (位数组) | 用 Bit 表示状态,将 String 视为结构体数组 (BITFIELD)。 |
日活统计、签到、用户状态 | 操作稀疏数据时会浪费大量内存。 |
| HLL | String (Sparse/Dense) | 概率统计。用调和平均数消除离群值,12KB 统计亿级基数。 | UV 统计、独立 IP 数 | 有 0.81% 误差,无法取出具体元素。 |
| Bloom | Bitmap + Hash 函数 | 概率判存。绝无假阴性,但有假阳性。 | 缓存穿透防护、黑名单校验 | 不支持删除(除非用 Counting BF),需容忍误判。 |
| Geo | ZSet (GeoHash) | 降维打击。将二维坐标映射为一维整数,复用 ZSet 排序能力。 | 附近的人、距离计算 | 存在边界误差(需查 9 宫格),高纬度畸变。 |
| PubSub | Dict + LinkedList | 无状态广播。即发即弃,不存储数据。 | 实时通知、配置刷新 | 消费者断线即丢消息,无堆积能力。 |
| Stream | Radix Tree + Listpack | 前缀压缩。针对时间序列 ID 优化,引入消费组和 PEL 保证可靠性。 | 轻量级 MQ、事件溯源、日志 | 内存型存储,不适合海量历史数据回溯。 |