理解 Redis 各类数据结构的精妙之处,离不开两个底层设计原则:

  1. 极致节省内存:能省就省,既压缩结构本身,也采用动态或共享等机制确保每一位(bit)都物尽其用。
  2. 最大化 CPU 缓存友好:优先用一块连续小内存管理数据,降低指针跳转和碎片化,以提升访问速度和 CPU 缓存命中率。

这两点支撑着 Redis 各类数据类型的演进路线与编码选择。

版本声明:Redis8.4.0

0. redisObject

在深入具体类型之前,我们先对 Redis 对象头 redisObject 做一个简单的了解,因为 Redis 中的所有 Key 和 Value,在底层都是一个 redisObject 结构体。这是 Redis 多态的基石。redisObject 的定义位于 src/server.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define LRU_BITS 24
#define OBJ_REFCOUNT_BITS 30

struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
unsigned iskvobj : 1; /* 1 if this struct serves as a kvobj base */
unsigned expirable : 1; /* 1 if this key has expiration time attached.
* If set, then this object is of type kvobj */
unsigned refcount : OBJ_REFCOUNT_BITS;
void *ptr;
};
  • 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,这里直接存储整数值本身,不再是指针。

上述多个字段,最重要的就是 typeencoding。总的来说,type 决定了它对外声称是什么(比如 Hash),而 encoding 决定了它在内存里到底怎么存(比如是压缩列表还是哈希表)。优化 Redis 内存的核心,就是想办法让它保持在轻量级的 encoding 上。

1. String

String 是 Redis 最基本的数据类型,也是所有 Key 的类型。

1.1 核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 基础操作
SET key value # 设置键值对
GET key # 获取值
DEL key # 删除键
EXISTS key # 检查键是否存在

# 批量操作
MSET key1 value1 key2 value2 # 批量设置
MGET key1 key2 key3 # 批量获取

# 数值操作
INCR key # 原子递增 +1
DECR key # 原子递减 -1
INCRBY key increment # 按指定值递增
DECRBY key decrement # 按指定值递减

# 字符串操作
APPEND key value # 追加字符串
STRLEN key # 获取字符串长度
GETRANGE key start end # 获取子字符串(闭区间)
SETRANGE key offset value # 从指定偏移量设置字符串

# 过期时间
SETEX key seconds value # 设置带过期时间的键
TTL key # 查看剩余生存时间
EXPIRE key seconds # 为已有键设置过期时间

常见使用场景:

  • 缓存层:存储 JSON 序列化后的对象,如用户信息、商品详情
  • 计数器:文章阅读数、点赞数、库存数量等原子计数场景
  • 分布式锁SET key value NX EX 30 实现简单的分布式锁
  • 限流器:使用 INCREXPIRE 实现滑动窗口限流
  • 会话管理:存储用户 session 信息,配合过期时间自动清理

1.2 底层原理

Redis 没有直接使用 C 语言的字符串 char*,而是自己封装了 SDS(Simple Dynamic String),而且还进一步分成了 sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64。其核心目的是根据字符串的长度,使用不同大小的头部,从而进一步压缩内存的使用。

SDS 的定义位于 src/sds.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

我们重点关注 3 个字段:

  • len: 已用长度。
  • alloc: 分片的总内存大小(不包括头部和末尾的 \0)。
  • buf[]: 实际存储字符数据的柔性数组。

即便都是 String,Redis 为了省内存,也使用了 3 种不同的编码格式:

编码类型 场景 特点 优缺点
int 纯数字且在 long 范围内 ptr 指针直接存数值,不需要 SDS。 最省内存,无额外指针开销。
embstr 字符串 ≤ 44 字节 redisObjectSDS 连续分配,只调用 1 次 malloc。 内存连续,缓存亲和性好;但只读,修改会转 raw。
raw 字符串 > 44 字节 redisObjectSDS 分开分配,调用 2 次 malloc。 适合长字符串,修改灵活。

[!note]

💡 44 字节怎么来的? redisObject (16B) + SDS 头 (3B) + \0 (1B) = 20B。 内存分配器(如 jemalloc)通常分配 64B 的块。 64B - 20B = 44B。刚好填满一个内存块,不浪费。

2. List

List 在 Redis 中逻辑上是一个 双向链表。这意味着:

  • 头尾操作极快LPUSH/RPOPO(1)
  • 随机访问极慢LINDEXO(N)。(千万别把它当数组用!)

2.1 核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 头部操作
LPUSH key value1 value2 # 从左侧头部推入元素(栈:先进后出)
RPUSH key value1 value2 # 从右侧尾部推入元素(队列:先进先出)
LPOP key # 从左侧头部弹出元素
RPOP key # 从右侧尾部弹出元素
LPOP key count # 批量从左侧弹出多个元素

# 阻塞操作(重要)
BLPOP key1 key2 timeout # 阻塞式左弹出,用于消息队列
BRPOP key1 key2 timeout # 阻塞式右弹出,用于消息队列
BLMOVE source destination timeout LEFT|RIGHT LEFT|RIGHT # 阻塞式移动元素

# 查询操作
LLEN key # 获取列表长度
LRANGE key start end # 获取指定范围内的元素(支持负数索引)
LINDEX key index # 获取指定索引的元素
LPOS key value [COUNT count] # 查找元素位置

# 修改操作
LSET key index value # 设置指定索引的值
LINSERT key BEFORE|AFTER pivot value # 在指定元素前/后插入新元素
LTRIM key start end # 修剪列表,只保留指定范围内的元素

# 移动操作
RPOPLPUSH source destination # 从 source 右弹出,destination 左推入
LMOVE source destination LEFT|RIGHT LEFT|RIGHT # 灵活的元素移动

常见使用场景:

  • 消息队列LPUSH/BRPOP 实现简单的生产者-消费者模式,BLPOP 提供阻塞式消费
  • 最新动态:用户动态、新闻列表使用 LPUSH/LRANGE 0 10 获取最新 10 条
  • 任务队列:异步任务处理,配合 RPOPLPUSH 实现可靠队列(失败重试)
  • 数据分页:存储分页数据,使用 LRANGE 实现高效分页查询
  • 排行榜:结合 LPUSH/LTRIM 维护固定大小的排行榜或热榜
  • 限流队列:实现滑动窗口限流,使用 LLEN 监控队列长度

2.2 底层原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;

typedef struct listIter {
listNode *next;
int direction;
} listIter;

typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;

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
2
3
4
5
          +------+     +------+     +------+
... <---- | prev | <-> | prev | <-> | prev | ----> ...
| data | | data | | data |
... <---- | next | <-> | next | <-> | next | ----> ...
+------+ +------+ +------+

优点:完美的 O(1) 头尾操作

缺点:

  • 高昂的内存开销prevnext 指针占据了大量内存,如果 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

既然 linkedlistziplist 各有优劣,能否将它们结合起来,取其精华,去其糟粕?快速列表 (quicklist) 应运而生,并从 Redis 3.2 开始成为 List 的默认实现。

quicklist 的本质,就是一个由 ziplist(或后来的 listpack)节点组成的双向链表

1
2
3
4
5
6
7
+----------------+     +----------------+     +----------------+
| quicklistNode | <-> | quicklistNode | <-> | quicklistNode |
| (ziplist/pack) | | (ziplist/pack) | | (ziplist/pack) |
+----------------+ +----------------+ +----------------+
^ ^ ^
| | |
[ e1, e2, e3 ] [ e4, e5 ] [ e6, e7, e8, e9 ]

它在宏观上是一个 linkedlist,保持了 O(1) 的头尾插入性能和灵活性。而在微观上,每个节点内部是一个 ziplistlistpack,存储了多个元素,极大地节省了内存,并提升了缓存局部性。quicklist 通过将连锁更新的风险限制在一个个独立的小节点内部,完美地规避了 ziplist 最大的风险。

2.2.4 Listpack

quicklist 虽然将 ziplist 的缺点限制在了一个节点之间,但是其缺点依旧存在,为此,紧凑列表(listpack) 诞生了。它去掉了 prev_len,改为在当前节点内部记录自己的长度 back-len(并且经过特殊编码支持反向解码)。修改当前节点,再也不会影响下一个节点的头部的长度了。

一个 listpack 结构如下:

重点在于这个 back-len,它等于该条目自身的 encoding-typeelement-data 两部分加起来的长度(我们称之为"部分长度")。当需要从后向前遍历时,解析器会从前一个条目的尾部,反向解析出这个"部分长度",然后再动态计算出 back-len 字段自身的长度,两者相加得到前一个条目的总长度,从而实现精确的回溯跳转。

3. Hash

Hash 是一个 String 类型的 Field 和 Value 的映射表。

3.1 核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 基础操作
HSET key field value # 设置字段值(可以同时设置多个字段)
HGET key field # 获取字段值
HMSET key field1 value1 field2 value2 # 批量设置字段
HMGET key field1 field2 field3 # 批量获取字段
HGETALL key # 获取所有字段和值
HDEL key field1 field2 # 删除指定字段
HEXISTS key field # 检查字段是否存在

# 字段操作
HLEN key # 获取字段数量
HKEYS key # 获取所有字段名
HVALS key # 获取所有字段值
HSTRLEN key field # 获取字段值的字符串长度

# 数值操作
HINCRBY key field increment # 字段值按指定整数递增
HINCRBYFLOAT key field increment # 字段值按指定浮点数递增

# 高级操作(Redis 6.2+)
HSETNX key field value # 仅当字段不存在时设置
HRANDFIELD key [count [WITHVALUES]] # 随机获取字段

常见使用场景:

  • 对象缓存:存储用户信息、商品属性等结构化数据,如 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct dictEntry {
struct dictEntry *next; /* 指向下一个节点,解决哈希冲突 */
void *key; /* 键 */
union { /* 值(使用联合体优化内存) */
void *val; /* 通常指向一个 redisObject */
uint64_t u64; /* 无符号整数 */
int64_t s64; /* 有符号整数 */
double d; /* 浮点数 */
} v;
};

struct dict {
dictType *type; /* 类型特定函数 */

dictEntry **ht_table[2]; /* 两个哈希表,平时只用[0],迁移时共用[0]和[1] */
unsigned long ht_used[2];/* 两个表已有的节点数量 */

long rehashidx; /* Rehash 进度索引 */

unsigned pauserehash; /* Rehash 暂停标志 */

/* 紧凑排列的小变量,优化内存填充 */
signed char ht_size_exp[2]; /* 大小的指数 (size = 1 << exp) */
signed pauseAutoResize: 15; /* 禁止自动扩容标志 */
unsigned useStoredKeyApi: 1;
void *metadata[]; /* 变长数组,用于存储额外元数据 */
};
  • 拉链法 (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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 基础操作
SADD key member1 member2 # 添加成员到集合
SREM key member1 member2 # 从集合中删除成员
SPOP key [count] # 随机弹出并删除成员
SRANDMEMBER key [count] # 随机获取成员(不删除)
SCARD key # 获取集合成员数量
SMEMBERS key # 获取所有成员

# 成员判断
SISMEMBER key member # 检查成员是否存在
SMISMEMBER key member1 member2 # 批量检查成员是否存在

# 集合运算(重要)
SINTER key1 key2 key3 # 计算多个集合的交集
SINTERCARD numkeys key1 key2 [key3...] [LIMIT limit] # 计算交集的基数
SINTERSTORE destination key1 key2 # 计算交集并存储到新集合
SUNION key1 key2 key3 # 计算多个集合的并集
SUNIONSTORE destination key1 key2 # 计算并集并存储到新集合
SDIFF key1 key2 key3 # 计算差集(key1 - key2 - key3)
SDIFFSTORE destination key1 key2 # 计算差集并存储到新集合

# 集合操作(Redis 6.2+)
SADD key member1 member2 [NX|XX] # NX:仅添加不存在的,XX:仅添加已存在的
SMOVE source destination member # 将成员从源集合移动到目标集合

常见使用场景:

  • 标签系统:文章标签、用户兴趣标签,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 同样采用了双重编码策略,分别是 hashtableintset

编码类型 场景 结构描述 优缺点
IntSet (整数集合) 元素全部是整数 且 数量 < 512 有序的整数数组 二分查找 O(logN);极致省内存。
Hashtable (哈希表) 元素包含非整数 或 数量 > 512 value 为 NULL 的字典 查询 O(1);内存占用大。

其中 hashtable 完全复用上文所述的 dict 结构。集合中的每个元素被存储为 dictEntry 中的 key,而 union v (值) 部分则被忽略(统一设为 NULL)。这种设计直接利用了 dict 键的唯一性来保证 Set 元素的唯一性,并继承了其 O(1) 的查找性能。

当一个 Set 满足以下两个条件时,会采用 intset 编码:

  1. 集合中所有元素均为整数。
  2. 元素数量未超过 set-max-intset-entries 配置项的阈值(默认为 512)。

intset 是一种自适应整数编码的有序数组。它可以根据存入整数的范围,将内部存储格式动态升级为 int16_t, int32_tint64_t,以最小的内存空间存储数据。成员查找通过二分搜索算法实现。它的定义位于 src/intset.h

1
2
3
4
5
6
7
8
9
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

typedef struct intset {
uint32_t encoding; // 当前元素编码宽度:16/32/64 位
uint32_t length; // 元素个数
int8_t contents[]; // 紧凑存放有序整数
} intset;

升级机制

  • 如果当前存的都是小整数(如 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
2
3
// 伪代码:分配一个包含 10 个 int64 元素的 intset
size_t size = sizeof(intset) + (10 * sizeof(int64_t));
intset *is = malloc(size);

声明为 int8_t(即 1 字节),是因为在 C 语言中,int8_t(或 char)是最小的内存寻址单位。这意味着 contents 指向的是一块原始的、未被定义的字节流

Redis 在读取数据时,并不是直接通过下标 contents[i] 来读取(那样只能读到 1 个字节),而是通过以下逻辑进行指针强转和偏移计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* * 根据给定的编码方式 (encoding),返回 intset 中指定位置 (pos) 的元素值。
* 注意:返回值统一提升为 int64_t,确保能容纳 int16, int32 和 int64。
*/
static int64_t _intsetGetEncoded(intset *is, int pos, uint8_t enc) {
int64_t v64;
int32_t v32;
int16_t v16;

/* 情况 1: 集合存储的是 64 位整数 */
if (enc == INTSET_ENC_INT64) {
/* * 核心指针运算:
* 1. (int64_t*)is->contents : 将 int8_t* 强转为 int64_t*。
* 这告诉编译器:这里的内存步长是 8 字节。
* 2. + pos : 指针向后移动 pos * 8 个字节,精准定位到第 pos 个元素。
* 3. memcpy : 从计算出的地址拷贝 8 个字节到栈变量 v64 中。
* 使用 memcpy 而不是直接赋值 (v64 = ...),是为了防止内存未对齐导致的 CPU 异常。
*/
memcpy(&v64,((int64_t*)is->contents)+pos,sizeof(v64));

/* * 字节序处理:
* 如果当前 CPU 是大端序 (Big Endian),则翻转字节顺序为小端序。
* 如果是小端序 (Little Endian),此宏不做任何操作。
* 保证数据在内存中统一以小端序存储,实现跨平台兼容。
*/
memrev64ifbe(&v64);
return v64;
} else if (enc == INTSET_ENC_INT32) {
memcpy(&v32,((int32_t*)is->contents)+pos,sizeof(v32));
memrev32ifbe(&v32);
return v32;
} else {
memcpy(&v16,((int16_t*)is->contents)+pos,sizeof(v16));
memrev16ifbe(&v16);
return v16;
}
}

5. Sorted Set

Sorted Set,即 ZSet,它是一个 Member (成员) 到 Score (分数) 的有序映射。

  • 特性:Member 唯一,Score 可重复。
  • 排序:按 Score 从小到大排;Score 相同,按 Member 字典序排。

5.1 核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 基础操作
ZADD key score1 member1 score2 member2 # 添加成员及其分数
ZREM key member1 member2 # 删除指定成员
ZCARD key # 获取集合成员数量
ZSCORE key member # 获取成员分数
ZCOUNT key min max # 统计指定分数范围内的成员数量

# 排名查询(重要)
ZRANK key member # 获取成员排名(从0开始,分数从小到大)
ZREVRANK key member # 获取反向排名(分数从大到小)
ZRANGE key start stop [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES] # 获取排名范围内成员
ZREVRANGE key start stop [WITHSCORES] # 获取反向排名范围内成员

# 分数范围查询
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] # 按分数范围查询
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count] # 按分数范围反向查询
ZREMRANGEBYRANK key start stop # 按排名范围删除成员
ZREMRANGEBYSCORE key min max # 按分数范围删除成员

# 分数操作
ZINCRBY key increment member # 增加指定成员的分数

# 集合运算
ZINTERSTORE destination numkeys key1 key2 [WEIGHTS w1 w2] [AGGREGATE SUM|MIN|MAX] # 计算交集
ZUNIONSTORE destination numkeys key1 key2 [WEIGHTS w1 w2] [AGGREGATE SUM|MIN|MAX] # 计算并集
ZDIFFSTORE destination numkeys key1 key2 # 计算差集

# 字典序操作(Redis 6.2+)
ZLEXCOUNT key min max # 统计字典序范围内的成员数量
ZRANGEBYLEX key min max [LIMIT offset count] # 按字典序范围查询
ZREMRANGEBYLEX key min max # 按字典序范围删除
ZMSCORE key member1 member2 member3 # 批量获取成员分数

常见使用场景:

  • 排行榜系统:游戏积分榜、文章热度榜、用户财富榜,ZRANGE/ZREVRANGE 获取 Top N
  • 延迟队列:以时间戳为分数,实现定时任务调度,ZRANGEBYSCORE 获取到期任务
  • 优先级队列:任务优先级管理,高优先级任务分数更高,ZPOPMAX 获取最高优先级任务
  • 范围搜索:价格区间商品查询、年龄范围用户筛选,ZRANGEBYSCORE 高效范围查询
  • 加权投票:用户投票系统,投票时间越新权重越高(时间戳作为分数)
  • 实时统计:热门搜索词排行,搜索次数作为分数,ZINCRBY 实时更新
  • 教育资源:学生成绩管理,分数直接作为 zset 分数,ZRANK 获取排名
  • 地理位置排序:虽然有 Geo 类型,但 zset 可以实现更复杂的排序逻辑

5.2 底层原理

Set 同样采用了双重编码策略,分别是 ziplist/listpackskiplist+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 (字典/哈希表):负责"集合"的部分。它建立了一个从 memberscore 的映射。这使得 ZSCORE 这种通过成员获取分数的操作,时间复杂度 𝑂(1)。
  • skiplist (跳表):负责"排序"的部分。它将所有的 (score, member) 对按照 score(分数相同则按 member 字典序)进行排序。跳表的特性使得插入、删除和按排名/分数范围查找的平均时间复杂度都是 𝑂(𝑙𝑜𝑔𝑁)。

为了节约内存,dictskiplist 中的 member 字符串是共享的,即它们都指向同一个 SDS (Simple Dynamic String) 对象。

跳表的核心思想是空间换时间随机化

想象一下,一个普通的单链表,查找效率是 𝑂(𝑁)。现在,我们从这个链表中,随机抽取一些节点,给它们增加一个"上层指针",指向下一个被抽取的节点。这样我们就构建了第 2 层"快速通道"。我们可以不断重复这个过程,构建出多层快速通道。

当我们要查找一个元素时:

  1. 从最高层的"快速通道"开始。
  2. 在当前层向右查找,直到找到的下一个节点比目标大,或者到了链表末尾。
  3. 然后从当前节点下降一层,重复步骤 2。
  4. 最终在最底层(原始链表)找到目标位置。

因为高层索引可以让你"跳过"大量节点,所以平均查找效率被提升到了 𝑂(𝑙𝑜𝑔𝑁)。而这种层级的建立是完全随机的,它通过概率来维持整体的平衡,避免了红黑树那样复杂的平衡操作。

这就是 Redis 选择跳表的原因:用更简单的实现,达到了与平衡树相媲美的性能。

在 Redis 8.4.0 的源码中,关于 zset 的类型定义位于 src/server.h 文件中。如下所示,它主要包含 3 个核心数据结构:zsetzskiplistzskiplistNode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 跳表节点
typedef struct zskiplistNode {
sds ele; /* 成员字符串 */
double score; /* 分值(排序主键) */
struct zskiplistNode *backward; /* 反向遍历指针(指向前一个节点) */
struct zskiplistLevel {
struct zskiplistNode *forward; /* 本层前进指针 */
unsigned long span; /* 到 forward 的节点数,用于排名计算 */
} level[]; /* 可变层高的层数组 */
} zskiplistNode;

// 跳表结构
typedef struct zskiplist {
struct zskiplistNode *header, *tail; /* 哨兵头/尾 */
unsigned long length; /* 节点总数 */
int level; /* 当前最高层数 */
size_t alloc_size; /* 内存统计 */
} zskiplist;

// zset 结构
typedef struct zset {
dict *dict; /* 成员 -> score 映射,O(1) 查找/判重 */
zskiplist *zsl; /* 按 score+字典序排序的跳表,支持范围/排名 */
} zset;

从比较直观的角度来讲的话,跳表的结构可以用下图来演示:

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

本篇限于篇幅原因和阅读性价比的关系,笔者对绝大多数数据结构都不太愿意展开源码,不过介于 skiplist 是一个非常常考的点,也是一个比较艺术性的实现,所以在本章节我们将从一个最核心的插入逻辑来更进一步了解跳表的实现细节。 zset 的核心实现逻辑位于 src/t_zset.c 文件中,其中插入操作由 zslInsert 函数来实现。这里我先把完整的代码和注释给你,接下来我们再一一拆解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/* 在跳表中插入一个新节点。
* 假设:调用者已经检查过该元素不存在(Redis 在调用此函数前会在 dict 中检查)。
* 注意:跳表会接管传入的 SDS 字符串 'ele' 的所有权。*/
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
// update[]: 记录每一层中,新节点应该插入在哪个节点之后(即前驱节点)
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
// rank[]: 记录 update[i] 节点在整个链表中的排名(从头节点到该节点的跨度之和)
unsigned long rank[ZSKIPLIST_MAXLEVEL];
int i, level;

serverAssert(!isnan(score));
x = zsl->header;

/* ----------------------------------------------------------------------
* 步骤 1: 自顶向下查找插入位置
* ---------------------------------------------------------------------- */
for (i = zsl->level-1; i >= 0; i--) {
/* 计算 rank:
* 如果是最高层,rank 初始为 0。
* 否则,rank 继承上一层已经走过的步数 (rank[i+1])。
* 这实现了路径累加:比如 L3 走了 10 步,降到 L2 时,起点就是 10。
*/
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

/* 向后遍历:直到下一个节点的 score >= 新 score (或 score 相等但 ele 字典序更大) */
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
// 累加经过的跨度 span 到 rank[i]
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
// 记录第 i 层的前驱节点
update[i] = x;
}

/* 此时,rank[0] 保存的是插入位置前一个节点的总排名 */

/* ----------------------------------------------------------------------
* 步骤 2: 生成随机层数
* ---------------------------------------------------------------------- */
// 幂次定律随机生成层数 (1/4 概率)
level = zslRandomLevel();

// 如果新生成的层数比当前跳表最高层还高
if (level > zsl->level) {
// 初始化多出来的层级
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
// 新层级的前驱自然是 header
update[i] = zsl->header;
// header 在这些新层级的 span 暂时覆盖整个链表长度
// (因为 header 后直接就是 NULL,或者即将插入的新节点)
update[i]->level[i].span = zsl->length;
}
zsl->level = level; // 更新跳表最大层数
}

/* ----------------------------------------------------------------------
* 步骤 3: 创建节点并调整指针与 Span (最难点)
* ---------------------------------------------------------------------- */
x = zslCreateNode(zsl,level,score,ele);

// 逐层处理指针链接和 span 计算
for (i = 0; i < level; i++) {
// 标准的链表插入操作:
// NewNode->Next = PrevNode->Next
// PrevNode->Next = NewNode
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;

/* 计算 span 的逻辑:
* update[i]->level[i].span 是原先 update[i] 到其下一个节点的距离。
* 现在中间插了个 x。
*
* rank[0]: 插入点之前的总排名。
* rank[i]: update[i] 节点的总排名。
* (rank[0] - rank[i]): update[i] 和 实际插入点(L0层) 之间的距离。
*/

// 1. 设置新节点 x 的 span
// x 的 span = 原前驱的 span - (前驱到 x 的距离)
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);

// 2. 更新前驱节点 update[i] 的 span
// 前驱的 span = (前驱到 x 的距离) + 1 (这个 1 是 x 节点本身)
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}

// 对于比新节点层数更高的层级,结构没变,只是底层多了一个节点
// 所以这些层级的 span 只需要简单的 +1
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}

/* ----------------------------------------------------------------------
* 步骤 4: 设置后退指针 (用于 L0 层双向遍历)
* ---------------------------------------------------------------------- */
// 如果前驱是 header,backward 设为 NULL (header 不作为数据节点)
x->backward = (update[0] == zsl->header) ? NULL : update[0];

// 设置下一个节点的 backward 指向新节点 x
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
// 如果 x 是最后一个节点,更新 tail 指针
zsl->tail = x;

zsl->length++;
return x;
}

插入操作主要分为四个步骤:

  1. 查找插入位置(Search):从最高层往下找,记录每层的前驱节点(update[])和到达前驱节点的排名(rank[])。
  2. 生成随机层数(Random Level):决定新节点的高度。如果比当前跳表高,需要初始化高层的前驱。
  3. 创建节点与链接(Link):调整指针,在 0level-1 的每一层中将新节点插入链表,并极其精细地计算新旧节点的 span 值
  4. 收尾工作:更新高层的 span(只加 1),设置后退指针,更新长度。

接下来我们来一一拆解这段代码的每一个细节。

第 0 步:初始化与准备

1
2
3
4
5
6
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
// update[]: 记录每一层中,新节点应该插入在哪个节点之后(即前驱节点)
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
// rank[]: 记录 update[i] 节点在整个链表中的排名(从头节点到该节点的跨度之和)
unsigned long rank[ZSKIPLIST_MAXLEVEL];
int i, level;
  • zskiplistNode *update[ZSKIPLIST_MAXLEVEL]: 路径记录仪。它是一个指针数组,update[i] 将用于存储新节点在第 i 层的前驱节点。为什么需要它?因为插入操作的本质就是在 update[i] 和它原来的下一个节点之间,插入我们的新节点。这个数组为我们保存了所有需要修改的连接点。
  • unsigned long rank[ZSKIPLIST_MAXLEVEL]: 距离计数器rank[i] 用于记录从跳表 header 头节点出发,到达 update[i] 这个节点时,在最底层(第 0 层)总共跨越了多少个节点。它的核心作用是为后续精确计算和更新节点的 span(跨度)提供数据支持,这是 ZRANK 命令能够实现 𝑂(𝑙𝑜𝑔𝑁) 的基石。

第 1 步:查找插入位置并记录路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
x = zsl->header;

/* ----------------------------------------------------------------------
* 步骤 1: 自顶向下查找插入位置
* ---------------------------------------------------------------------- */
for (i = zsl->level-1; i >= 0; i--) {
/* 计算 rank:
* 如果是最高层,rank 初始为 0。
* 否则,rank 继承上一层已经走过的步数 (rank[i+1])。
* 这实现了路径累加:比如 L3 走了 10 步,降到 L2 时,起点就是 10。
*/
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

/* 向后遍历:直到下一个节点的 score >= 新 score (或 score 相等但 ele 字典序更大) */
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
sdscmp(x->level[i].forward->ele,ele) < 0)))
{
// 累加经过的跨度 span 到 rank[i]
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
// 记录第 i 层的前驱节点
update[i] = x;
}

这是跳表算法的精髓所在——分层查找

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* ----------------------------------------------------------------------
* 步骤 2: 生成随机层数
* ---------------------------------------------------------------------- */
// 幂次定律随机生成层数 (1/4 概率)
level = zslRandomLevel();

// 如果新生成的层数比当前跳表最高层还高
if (level > zsl->level) {
// 初始化多出来的层级
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
// 新层级的前驱自然是 header
update[i] = zsl->header;
// header 在这些新层级的 span 暂时覆盖整个链表长度
// (因为 header 后直接就是 NULL,或者即将插入的新节点)
update[i]->level[i].span = zsl->length;
}
zsl->level = level; // 更新跳表最大层数
}
  • level = zslRandomLevel(): 新节点将拥有几层"快速通道"?这里不是由复杂的平衡算法决定,而是通过一个简单的概率函数随机生成。大部分节点只有 1 层,极少数节点会有很高的层数。正是这种随机性,使得跳表在整体上能够维持一个高效的对数级结构。
  • if (level > zsl->level): 处理一个特殊情况。如果随机出的 level 比当前跳表的最大层数还大,意味着我们需要为整个跳表加盖新的楼层。在这些新楼层,路径上的前驱节点自然就是 header 节点,并且它到 NULL 的跨度就是整个跳表的长度 zsl->length

第 3 步:节点创建、链接与跨度更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* ----------------------------------------------------------------------
* 步骤 3: 创建节点并调整指针与 Span (最难点)
* ---------------------------------------------------------------------- */
x = zslCreateNode(zsl,level,score,ele);

// 逐层处理指针链接和 span 计算
for (i = 0; i < level; i++) {
// 标准的链表插入操作:
// NewNode->Next = PrevNode->Next
// PrevNode->Next = NewNode
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;

/* 计算 span 的逻辑:
* update[i]->level[i].span 是原先 update[i] 到其下一个节点的距离。
* 现在中间插了个 x。
*
* rank[0]: 插入点之前的总排名。
* rank[i]: update[i] 节点的总排名。
* (rank[0] - rank[i]): update[i] 和 实际插入点(L0层) 之间的距离。
*/

// 1. 设置新节点 x 的 span
// x 的 span = 原前驱的 span - (前驱到 x 的距离)
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);

// 2. 更新前驱节点 update[i] 的 span
// 前驱的 span = (前驱到 x 的距离) + 1 (这个 1 是 x 节点本身)
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}

这是整个插入过程中最核心的逻辑:

链接 forward 指针: 这两行是经典的链表插入操作。假设原来是 A -> CA 就是 update[i]C 就是 update[i]->level[i].forward。现在,我们将新节点 x 插入其中,变为 A -> x -> C

更新 span (跨度): 这是最精妙的部分,我们用一个例子来说明。

  • 假设在第 i 层,前驱节点 A (即 update[i]) 原来的 span10,它直接指向 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]);: 新节点 xspan 等于 A span (10) 减去它俩之间的距离 (3),等于 7

验证: 插入后,A 的新 span (4) + x 的新 span (7) = 11。而 A 的旧 span10。总跨度增加了 1,正好等于新加入的节点数量。完美!

另外,对于那些高于新节点 level 的层级,新节点并不会被插入。但是,由于整个跳表的总长度增加了 1,这些更高层级上、位于插入路径上的前驱节点(update[i]),它们指向的下一个节点的相对距离也增加 1。因此,它们的 span 值需要递增。

第 4 步:更新后退指针和表尾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* ----------------------------------------------------------------------
* 步骤 4: 设置后退指针 (用于 L0 层双向遍历)
* ---------------------------------------------------------------------- */
// 如果前驱是 header,backward 设为 NULL (header 不作为数据节点)
x->backward = (update[0] == zsl->header) ? NULL : update[0];

// 设置下一个节点的 backward 指向新节点 x
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
// 如果 x 是最后一个节点,更新 tail 指针
zsl->tail = x;

zsl->length++;
return x;
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 基础位操作
SETBIT key offset value # 设置指定偏移量的位值(0或1)
GETBIT key offset # 获取指定偏移量的位值

# 位统计
BITCOUNT key [start end] # 统计位图中值为1的位数
BITPOS key bit [start end] # 查找第一个指定值的位置

# 位运算(重要)
BITOP operation destkey key1 key2 ... # 位运算操作
# operation: AND(与), OR(或), XOR(异或), NOT(非)

# 字符串和位图转换
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] # 位域操作

# 扩展命令(Redis 6.2+)
BITFIELD_RO key [GET type offset] ... # 只读的位域操作

常见使用场景:

  • 用户签到:用位表示用户每日签到状态,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 基础操作
PFADD key element1 element2 ... # 添加元素到 HyperLogLog
PFCOUNT key1 key2 key3 ... # 获取一个或多个 HyperLogLog 的基数估计
PFMERGE destkey source1 source2 ... # 合并多个 HyperLogLog 到目标键

# 实用命令组合
# 创建并初始化
PFADD daily_users_20231201 "user_1001" "user_1002" "user_1003"

# 查看基数估计
PFCOUNT daily_users_20231201

# 合并多日数据
PFMERGE month_users daily_users_20231201 daily_users_20231202 daily_users_20231203

# 多键统计
PFCOUNT week_users month_users # 可以同时查询多个键的合并基数

常见使用场景:

  • 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)的概念:

  1. 把数据分成 $m$ 个桶。
  2. 分别统计每个桶里的最大前导 0。
  3. 对这些桶的结果求调和平均数(而不是算术平均数),以消除极端值(离群点)的影响。

算术平均数(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#define HLL_P 14 /* The greater is P, the smaller the error. */
#define HLL_Q (64-HLL_P) /* The number of bits of the hash value used for
determining the number of leading zeros. */
#define HLL_REGISTERS (1<<HLL_P) /* With P=14, 16384 registers. */
#define HLL_P_MASK (HLL_REGISTERS-1) /* Mask to index register. */
#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */

/* HyperLogLog 头部:
* magic:固定标识 "HYLL",用于持久化格式识别。
* encoding:两种存储模式
* - HLL_DENSE:每个寄存器 6 bit,16K 寄存器共约 12288 字节,元素多时内存固定。
* - HLL_SPARSE:稀疏 RLE/指令流压缩,元素少时占用小,超过阈值自动转为 DENSE。
* notused[3]:保留位,必须为 0。
* card[8]:缓存的基数估计值(小端),为 0 表示缓存失效,下次查询重算并写回。
* registers[]:寄存器数据区;
* - DENSE 为紧凑 6-bit 数组。
* - SPARSE 为指令序列(ZERO/VAL/XZERO 等表示连续寄存器段)。
*/
struct hllhdr {
char magic[4]; /* "HYLL" */
uint8_t encoding; /* HLL_DENSE or HLL_SPARSE. */
uint8_t notused[3]; /* Reserved for future use, must be zero. */
uint8_t card[8]; /* Cached cardinality, little endian. */
uint8_t registers[]; /* Data bytes. */
};

当执行 PFADD key element 时:

  1. Hash:Redis 使用 MurmurHash64A 算法将 element 转换为一个 64 位的整数(比特串)。
  2. 分桶:Redis 使用这个 64 位整数的 前 14 位 作为桶的索引(Register Index)。$2^{14} = 16384$,所以才说 Redis 内部维护了 16384 个桶(registers[])。
  3. 计数:使用剩下的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 创建布隆过滤器
BF.ADD key item1 item2 item3 # 添加元素(自动创建过滤器)
BF.EXISTS key item # 检查元素是否存在
BF.MADD key item1 item2 item3 # 批量添加元素
BF.MEXISTS key item1 item2 item3 # 批量检查元素

# 手动创建并配置
BF.RESERVE key error_rate capacity [EXPANSION expansion] [NONSCALING]
# error_rate: 期望的错误率(如0.01表示1%)
# capacity: 期望的元素数量
# EXPANSION: 扩容因子(默认2)
# NONSCALING: 禁用自动扩容

# 高级操作
BF.INSERT key [CAPACITY cap] [ERROR error] [NOCREATE] ITEMS item1 item2 ... # 插入元素
BF.INFO key # 查看过滤器信息
BF.CARD key # 获取过滤器中元素数量(近似值)
BF.SCANDUMP key iterator # 用于数据备份和恢复

# 计数布隆过滤器(Cuckoo Filter替代方案)
CF.ADD key item # 添加元素到计数过滤器
CF.ADDNX key item # 仅当元素不存在时添加
CF.EXISTS key item # 检查元素是否存在
CF.DEL key item # 删除元素(支持删除)
CF.COUNT key item # 获取元素计数

常见使用场景:

  • 缓存穿透防护:查询数据库前先用布隆过滤器检查 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 基础操作
GEOADD key longitude latitude member [longitude latitude member ...] # 添加地理位置
GEOPOS key member1 member2 ... # 获取指定位置的坐标
GEODIST key member1 member2 [unit] # 计算两个位置之间的距离

# 单位参数:m(米)、km(千米)、mi(英里)、ft(英尺)
GEODIST cities beijing shanghai km # 计算北京到上海的公里距离

# 范围查询(重要)
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
GEORADIUSBYMEMBER key member radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

# 查询选项说明:
# WITHCOORD: 返回坐标
# WITHDIST: 返回距离
# WITHHASH: 返回geohash值
# COUNT: 限制返回数量
# ASC|DESC: 按距离排序
# STORE: 存储结果到有序集合
# STOREDIST: 存储距离到有序集合

# 搜索示例
GEORADIUS restaurants 116.397128 39.916527 5 km WITHDIST WITHCOORD COUNT 10
GEORADIUSBYMEMBER restaurants starbucks 2 km WITHDIST

# 哈希操作
GEOHASH key member1 member2 ... # 获取位置的 geohash 字符串

# 有序集合操作(底层是zset)
ZSCORE key member # 获取位置的geohash分数
ZRANGE key start stop WITHSCORES # 获取范围位置和分数
ZREM key member1 member2 # 删除位置
ZCARD key # 获取位置数量

# 批量操作(Redis 6.2+)
GEOSEARCH key [FROMMEMBER member | FROMLONLAT longitude latitude] [BYRADIUS radius m|km|ft|mi | BYBOX width height m|km|ft|mi] [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]

常见使用场景:

  • 附近的人:社交应用查找附近用户,GEORADIUS 查找指定范围内的用户
  • 附近商家:生活服务平台查找附近餐厅、酒店、银行等
  • 打车服务:查找附近司机、计算预估距离和费用
  • 外卖配送:查找附近商家、计算配送距离和时间
  • 位置签到:记录用户位置,实现签到功能
  • 地理围栏:设定虚拟地理边界,进入或离开时触发事件
  • 位置营销:基于地理位置的精准营销和广告推送
  • 物流追踪:实时追踪包裹位置,查找附近配送点
  • 旅游导航:查找附近景点、计算景点间距离
  • 紧急服务:查找附近的医院、警察局等紧急服务点

实用示例:

1
2
3
4
5
6
7
8
9
10
# 添加城市位置
GEOADD cities 116.397128 39.916527 beijing
GEOADD cities 121.473701 31.230416 shanghai
GEOADD cities 113.264385 23.129112 guangzhou

# 计算距离
GEODIST cities beijing shanghai km # 输出:1067.5

# 查找北京500km内的城市
GEORADIUS cities 116.397128 39.916527 500 km WITHDIST

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 绘制了一个网页,用于辅助理解,感兴趣的读者可自行下载参考。

https://github.com/hedon954/geohash-display

10. PubSub

PubSub(Publish/Subscribe,发布/订阅)是一种消息通信模式。发送者(Pub)将消息发送到频道(Channel),而不用知道谁订阅了它;订阅者(Sub)接收感兴趣频道的消息,而不用知道谁发送了它。

Pub/Sub 的核心逻辑是 广播 (Broadcasting)。 它和 List/Stream 最大的区别在于:它不存储消息

10.1 核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 发布订阅操作
PUBLISH channel message # 向指定频道发布消息
SUBSCRIBE channel1 channel2 ... # 订阅一个或多个频道
UNSUBSCRIBE channel1 channel2 ... # 取消订阅指定频道

# 模式订阅
PSUBSCRIBE pattern1 pattern2 ... # 订阅匹配模式的频道(支持通配符*和?)
PUNSUBSCRIBE pattern1 pattern2 ... # 取消模式订阅

# 查询操作
PUBSUB CHANNELS [pattern] # 列出活跃的频道(支持模式匹配)
PUBSUB NUMSUB [channel1 channel2 ...] # 查看指定频道的订阅者数量
PUBSUB NUMPAT # 查看模式订阅的数量

# 实用示例
# 发布者
PUBLISH news:sports "中国队获得冠军!"
PUBLISH notifications:user:1001 "您有新的消息"

# 订阅者
SUBSCRIBE news:sports
SUBSCRIBE notifications:user:1001

# 模式订阅(订阅所有新闻频道)
PSUBSCRIBE news:*
PSUBSCRIBE notifications:user:*

# 查看频道状态
PUBSUB NUMSUB news:sports notifications:user:1001
PUBSUB CHANNELS news:*

常见使用场景:

  • 实时通知:系统通知、消息推送、事件提醒等实时通知场景
  • 聊天室:多用户实时聊天系统,群聊广播消息
  • 实时数据推送:股票价格、比赛比分、实时监控数据推送
  • 事件驱动架构:微服务间的事件通信,解耦生产者和消费者
  • 缓存失效:当数据更新时,通过 PubSub 通知缓存失效,实现缓存一致性
  • 日志收集:应用日志实时收集和分发到日志处理系统
  • 监控系统:系统告警、性能指标实时推送
  • 配置更新:分布式系统中的配置变更通知
  • 游戏实时通信:多人游戏中的状态同步、位置更新等

10.2 底层原理

Redis 服务器结构体 redisServer 中维护了关于 Pub/Sub 的重要字段:

1
2
3
4
5
6
7
8
9
/* src/server.h */
struct redisServer {
kvstore *pubsub_channels; /* Map channels to list of subscribed clients */
dict *pubsub_patterns; /* A dict of pubsub_patterns */
int notify_keyspace_events; /* Events to propagate via Pub/Sub. This is an
xor of NOTIFY_... flags. */
kvstore *pubsubshard_channels; /* Map shard channels in every slot to list of subscribed clients */
unsigned int pubsub_clients; /* # of clients in Pub/Sub mode */
}

kvstore *pubsub_channels:

  • 普通频道订阅关系表。Key 是频道名 (Channel),Value 是一个链表,存储了所有订阅该频道的客户端 (Clients)。对应 SUBSCRIBEPUBLISH 命令。
  • 在 Redis 7.0 之前,这里是一个 dict *(字典)。但在新版本中,它升级为了 kvstore *。在 Redis Cluster 模式下,普通的 Pub/Sub 是广播的(一个节点收到消息,会转发给集群内所有节点)。kvstore 是 Redis 内部对"支持分槽(Slot)的字典"的抽象。虽然普通的 Pub/Sub 是广播的,但使用 kvstore 统一了内部的数据结构接口,方便管理和扩容。

dict *pubsub_patterns:

  • 模式订阅关系表。Key 是匹配模式 (Pattern,如 news.*),Value 是订阅了该模式的客户端列表。对应 PSUBSCRIBEPPUBLISH 命令。
  • 当有消息发布到 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)。对应 SSUBSCRIBESPUBLISH 命令 (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 做消息队列主要有两种方式,但都有致命缺陷:

  1. List (BLPOP):无法支持多播;更致命的是,它没有 ACK 机制。消息一旦从队列中 POP 出来,如果消费者处理时宕机,这条消息就永久丢失了。
  2. Pub/Sub:支持多播,但它是 Fire and Forget(即发即弃)模式。如果消费者下线,期间发送的所有消息都会丢失,且无法回溯。

Stream 的设计灵感深受 Kafka 影响,它不仅支持多播(消费组),还引入了 PEL (Pending Entries List) 机制来保证消息的绝对不丢失,同时在底层结构上针对"时间序列 ID"做了极致的内存压缩。

11.1 核心操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 基础操作
XADD key [MAXLEN len [~]] [NOMKSTREAM] *|ID field value [field value ...] # 添加消息
XLEN key # 获取流中消息数量
XRANGE key start end [COUNT count] # 读取范围内的消息(正向)
XREVRANGE key end start [COUNT count] # 读取范围内的消息(反向)

# 消息读取
XREAD [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key1 key2 ... ID1 ID2 ...
# ID选项:$(最新消息)、>(消费者组新消息)、具体时间戳ID

# 消费组操作
XGROUP [CREATE key groupname id|$] [MKSTREAM] # 创建消费组
XGROUP [DESTROY key groupname] # 销毁消费组
XGROUP [SETID key groupname id|$] # 设置消费组最后消息ID
XGROUP [DELCONSUMER key groupname consumername] # 删除消费者
XINFO [GROUPS key] # 查看消费组信息
XINFO [CONSUMERS key groupname] # 查看消费者信息
XINFO [STREAM key] [FULL] # 查看流详细信息

# 消费者读取
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key1 key2 ... ID1 ID2 ...
# ID选项:>(新消息)、0(所有未处理消息)、具体ID

# 消息确认
XACK key group ID [ID ...] # 确认消息已处理
XPENDING key group [start end count consumer] # 查看待处理消息
XCLAIM key group consumer min_idle_time ID [ID ...] [IDLE ms] [TIME ms] [RETRYCOUNT count] [FORCE] [JUSTID] [LASTID id]
XDEL key ID [ID ...] # 删除消息

# 修剪操作
XTRIM key MAXLEN len [~] # 修剪流到指定长度
XTRIM key MINID threshold [~] # 删除小于指定ID的消息

# 实用示例
# 创建流并添加消息
XADD mystream * name "张三" age 25
XADD mystream * name "李四" age 30

# 读取消息
XREAD COUNT 2 STREAMS mystream 0-0

# 创建消费组
XGROUP CREATE mystream mygroup $ MKSTREAM

# 消费者读取
XREADGROUP GROUP mygroup consumer1 COUNT 1 STREAMS mystream >

# 确认消息
XACK mystream mygroup 1672502400000-0

常见使用场景:

  • 消息队列:替代 RabbitMQ/Kafka,处理异步任务、事件驱动架构
  • 事件溯源:记录系统状态变更历史,支持事件回放和时间旅行查询
  • 日志收集:应用日志的可靠收集和转发,支持消费者组并行处理
  • 监控告警:系统监控指标的时序数据存储和分析
  • 数据管道:ETL 过程中的数据缓冲和批处理
  • 实时分析:用户行为分析、点击流处理等实时数据流处理
  • IoT 数据:物联网设备数据的时序存储和处理
  • 金融交易:交易日志记录、审计追踪,支持精确的时间序列查询
  • 游戏系统:游戏事件记录、玩家行为分析
  • 聊天系统:可靠的消息投递,支持离线消息和消息重试

11.2 底层原理

Stream 本质上是一个 仅追加 (Append-only) 的日志结构。为了满足高效的范围查找(按时间范围读消息)和极致的内存节省,Redis 并没有使用简单的链表或数组,而是设计了一种混合结构:Radix Tree (基数树) + Listpack

11.2.1 stream

Stream 定义在 src/stream.h,负责维护消息的元数据、存储引擎(Rax)入口以及消费组状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* Stream 核心结构体
* 位于 src/stream.h */
typedef struct stream {
/* ---- 数据存储区 ---- */
rax *rax; /* 核心存储引擎:Radix Tree。
* Key: 消息 ID (大端序二进制)
* Value: 指向一个 Listpack 的指针 (每个 Listpack 存多条消息) */

uint64_t length; /* 当前 Stream 中的有效消息数量 (不包含已删除的消息) */

streamID last_id; /* 当前 Stream 中最后一条消息的 ID (若为空则为 0-0) */
streamID first_id; /* 当前 Stream 中第一条消息的 ID (若为空则为 0-0) */

/* ---- 统计与清理 ---- */
streamID max_deleted_entry_id; /* 历史上被删除的最大消息 ID (用于协议兼容) */
uint64_t entries_added; /* 历史上累计写入过的消息总数 (即 ID 序列号计数器) */
size_t alloc_size; /* Stream 自身占用的堆内存大小 (不含 key 本身) */

/* ---- 消费组管理 ---- */
rax *cgroups; /* 消费组字典。
* Key: 消费组名称 (String)
* Value: streamCG 结构体指针 */

rax *cgroups_ref; /* 辅助索引:用于加速 NACK 处理等场景 */

streamID min_cgroup_last_id; /* 缓存:所有消费组中,进度最慢的那个 last_id。
* 用于垃圾回收,判断哪些数据可以安全清理 */
unsigned int min_cgroup_last_id_valid: 1; /* 缓存是否有效标记 */
} stream;

11.2.2 streamID

Stream 的消息 ID 默认是自动生成的,格式为 <时间戳>-<序列号>(例如 1702108800000-0)。

1
2
3
4
typedef struct streamID {
uint64_t ms; /* Unix time in milliseconds. */
uint64_t seq; /* Sequence number. */
} streamID;

观察这组 ID,你会发现一个显著特征:前缀高度重复。在同一毫秒甚至同一秒内产生的消息,其 ID 的高位部分是完全相同的。如果使用普通的 Hash 表或跳表存储,这些重复的前缀会浪费大量内存。

11.2.3 Radix Tree

Radix Tree (Rax) 是一种前缀树。它将公共前缀提取出来作为父节点,差异部分作为子节点。这种结构天然适合存储时间序列数据,极大地压缩了索引空间。

undefined

这并非标准的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* Rax (Radix Tree) 头部 */
typedef struct rax {
raxNode *head; /* 指向根节点的指针 */
uint64_t numele; /* 树中存储的元素总数 (即 Key 的数量) */
uint64_t numnodes; /* 树中节点的总数 */
size_t *alloc_size; /* 指向外部变量的指针,用于统计该树占用的总内存 */
void *metadata[]; /* 可选的元数据区域 (通常用于填充对齐) */
} rax;

/* Rax 节点 */
typedef struct raxNode {
/* ---- 位域标志头 (4字节) ---- */
uint32_t iskey:1; /* 1 表示这就到达了一个 Key 的终点 (包含 Value) */
uint32_t isnull:1; /* 1 表示 Value 为 NULL (即使 iskey=1) */
uint32_t iscompr:1; /* 1 表示这是压缩节点 (Compressed),0 表示非压缩 (Normal) */
uint32_t size:29; /* 节点负载大小:
* - 若 iscompr=1: 表示压缩后缀的长度 (字节数)
* - 若 iscompr=0: 表示子节点的数量 (边数) */

/* ---- 柔性数组:数据负载区 ----
* 这里的布局根据 iscompr 的值完全不同
*/
unsigned char data[];
} raxNode;

由于 data[] 是变长的,C 语言无法直接描述其结构,其内存布局如下:

1. 压缩节点(iscompr = 1)

这意味着这是一条单行道。 当前节点只有一个子节点,且中间经过了一串字符。 比如:从节点 A 到节点 B,中间的路径字符串是 "QD"

data[] 的内存布局:它像三明治一样,把路径字符串”唯一的子节点指针紧挨着放。

1
2
3
4
5
6
7
8
9
10
11
+-----------------------+ <-- 节点起始地址
| Header (4 Bytes) | iskey=1, iscompr=1, size=2
+-----------------------+
| 'Q' (1 Byte) | \
+-----------------------+ > 压缩字符串 "QD"
| 'D' (1 Byte) | /
+-----------------------+
| Child Ptr (8 Bytes) | --> 指向下一个 raxNode (代表 ID 后续部分的节点)
+-----------------------+
| Value Ptr (8 Bytes) | --> 指向 Listpack (存储消息内容) [仅当 iskey=1 时存在]
+-----------------------+

2. 非压缩节点(iscompr = 0)

这意味着这是一个十字路口(分叉点)。 当前节点有多个子节点。 比如:节点 A 下面分出了 'A', 'B', 'C' 三条路。

data[] 的内存布局: 它把**所有的路牌(字符)放一起,把所有的路(指针)**放一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+-----------------------+ <-- 节点起始地址
| Header (4 Bytes) | iskey=1, iscompr=0, size=3
+-----------------------+
| 'A' (1 Byte) | \
+-----------------------+ |
| 'B' (1 Byte) | > 子节点索引字符 (共3个)
+-----------------------+ |
| 'C' (1 Byte) | /
+-----------------------+
| Padding (X Bytes) | <-- 内存对齐填充 (视当前偏移量而定,确保后续指针地址对齐)
+-----------------------+
| Child Ptr 1 (8 Bytes) | --> 指向 'A' 分支的下一个 raxNode
+-----------------------+
| Child Ptr 2 (8 Bytes) | --> 指向 'B' 分支的下一个 raxNode
+-----------------------+
| Child Ptr 3 (8 Bytes) | --> 指向 'C' 分支的下一个 raxNode
+-----------------------+
| Value Ptr (8 Bytes) | --> 指向 Listpack (存储消息内容) [仅当 iskey=1 时存在]
+-----------------------+

11.2.4 streamCG

Stream 能替代 List 成为企业级消息队列的核心,在于它引入了消费组 (Consumer Group)PEL (Pending Entries List)

当消费者调用 XREADGROUP 读取消息时:

  1. 消息不会从 Stream 中删除(这点与 List 不同)。
  2. 消息 ID 会被加入到该消费者的 PEL 中。
  3. 只有当消费者显式调用 XACK 后,Redis 才会把该 ID 从 PEL 中移除。

这背后的核心数据结构是 streamCG

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Consumer group. */
typedef struct streamCG {
streamID last_id; /* 该消费组最后一次交付(但未确认)的消息 ID。
当消费者请求“新消息”时,Redis 会提供大于此 ID 的消息。 */

long long entries_read; /* 统计信息:该组读取的消息总数 */

rax *pel; /* Pending Entries List (待处理列表)。
这是一个 Radix Tree。
Key: 消息 ID (64位大端序)
Value: streamNACK 结构 (记录了投递次数、最后投递时间等)
作用: 记录所有已发给消费者但尚未 ACK 的消息。 */

rax *pel_by_time; /* 辅助索引:按“投递时间”排序的 PEL。
Key: pelTimeKey (包含 delivery_time + stream ID)
Value: NULL (所有信息都在 Key 里)
作用: 加速超时消息的查询 (如 XPENDING ... IDLE <time>)。 */

rax *consumers; /* 消费者字典。
Key: 消费者名称
Value: streamConsumer 结构 */
} streamCG;

每个消费组都有一个全局游标 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
2
3
4
5
/* Consumer group. */
typedef struct streamCG {
rax *pel;
rax *pel_by_time;
} streamCG;

这是一种典型的空间换时间设计:

  • 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)

  1. 内存 vs CPU:在数据量少时,倾向于使用时间换空间的紧凑结构(如 Listpack、Intset),通过 CPU 的轮询计算来节省昂贵的内存;在数据量大时,倾向于使用空间换时间的索引结构(如 Hashtable、Skiplist),通过增加指针开销来保证 O(1) 或 O(logN) 的访问速度。
  2. 精确 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、事件溯源、日志 内存型存储,不适合海量历史数据回溯。

参考