Redis核心数据结构
Redis作为高性能键值数据库,其核心优势不仅在于内存存储和单线程模型,更在于对五大基础数据结构(String、List、Hash、Set、ZSet)的极致优化——通过动态切换底层存储结构,在内存占用与读写性能间找到完美平衡。
# String:二进制安全的动态字符串
String是Redis最基础的数据结构,支持存储字符串、数字、二进制数据(如图片、序列化对象),最大容量512MB。其底层并非C语言原生字符串,而是SDS(Simple Dynamic String)——一种专为Redis设计的动态字符串结构,替代了C语言原生字符串,解决了原生字符串的缺陷。
# 1. SDS:解决C字符串的三大缺陷
C语言原生字符串以\0为结束标识,存在三大问题:长度获取需O(n)遍历、二进制数据中含\0会被截断、修改需频繁内存重分配。SDS通过精巧设计彻底解决这些问题:
# 核心结构体
Redis为不同长度字符串设计4种头部(sdshdr8/16/32/64),用最小位数存储长度,避免内存浪费。以最常用的sdshdr8为例:
# 核心代码
struct sdshdr8 {
uint8_t len; // 已使用字节数(O(1)获取长度)
uint8_t alloc; // 总分配字节数(不含头部和终止符)
unsigned char flags;// 标识类型(8/16/32/64,占1字节)
char buf[]; // 柔性数组,存储实际数据(二进制安全)
};
// flags 0=sdshdr8, 1=sdshdr16, 2=sdshdr32,3=sdshdr64
2
3
4
5
6
7
8
- 二进制安全:通过
len字段记录有效长度,读取时直接按len截取buf,即使数据含\0也不会截断(末尾额外存\0仅为兼容C函数)。 - 内存预分配:修改时减少内存重分配次数(malloc/free是昂贵操作):
- 若新长度≤1MB:
alloc翻倍(如从100字节扩容到200字节); - 若新长度>1MB:
alloc增加1MB(避免过度预分配); - 若新长度过大:触发
SDS结构体升级(如新数据长度>2^8-1字节,会先创建并分配sdshdr16的结构体,将原sdshdr8的buf数据完整拷贝到sdshdr16的buf中,写入新增数据,更新len字段,释放当前sdshdr8结构体占用的内存,完成扩容)。
- 若新长度≤1MB:
- 惰性缩容:缩短字符串时仅更新
len,不释放多余内存,后续写入可复用已分配的buf,减少内存重分配开销。SDS仅升级不会主动降级
# 2. String的三种编码:按需切换的内存优化
String实际是redisObject包裹的底层编码,根据存储内容自动切换:
# 核心代码(redisObject结构)
typedef struct redisObject {
unsigned type:4; // 数据类型(STRING/LIST/HASH等)
unsigned encoding:4;// 编码类型(int/embstr/raw等)
unsigned lru:24; // LRU时间戳(用于内存淘汰)
int refcount; // 引用计数(内存回收)
void *ptr; // 指向底层数据(SDS/整数)
} robj;
2
3
4
5
6
7
8
| 编码类型 | 适用场景 | 内存布局 | 优势 |
|---|---|---|---|
| int | 字符串为64位内整数(如"123") | ptr直接存整数,无SDS | 极致省内存,O(1)读写 |
| embstr | 短字符串(≤44字节) | redisObject + sdshdr + buf连续内存(一次malloc) | 内存碎片少,创建/销毁效率高 |
| raw | 长字符串(>44字节) | redisObject与sdshdr分开分配(两次malloc) | 避免长字符串重分配影响对象头 |
什么是malloc?
malloc是C语言动态申请堆内存的核心接口,Redis依靠它实现SDS、哈希表等数据结构的动态内存管理,是Redis能灵活存储任意长度的基础,而free是配套的内存回收操作。为什么embstr上限是44字节?
redisObject占16字节,sdshdr8头部占3字节,末尾\0占1字节,总固定开销20字节。64字节内存块中剩余44字节用于存储实际数据(64-16-3-1=44),刚好适配CPU缓存行(64字节),提升访问速度。embstr的ptr指向
ptr字段在embstr编码下,并非指向独立的SDS,而是指向同一块内存中紧跟redisObject后的sdshdr起始地址。
# 实战建议
- 存储数字时优先用String的int编码(如计数器场景),避免转为embstr/raw;
- 二进制数据(如图片)建议拆分后存储(单key不超过100KB),避免大字符串扩容开销;
- 频繁修改的短字符串(如用户昵称)天然适配embstr,无需额外优化。
# List:从ziplist到quicklist
List是有序、可重复的字符串集合,支持两端插入/删除、范围查询,底层实现随数据规模动态变化,核心目标是小数据省内存,大数据保性能。
# 1. ziplist:紧凑存储的双向链表(小列表)
ziplist是Redis为小数据设计的紧凑结构,用连续内存块存储元素 + 无指针开销的设计大幅降低内存占用,替代普通双向链表(普通双向链表每个节点都包含前后指针,内存开销大且易产生碎片)适合节点少、元素小的场景。
# 核心结构
# 结构示意图
+--------+--------+--------+---------+---------+-------+
| zlbytes| zltail | zllen | entry1 | entry2 | zlend |
+--------+--------+--------+---------+---------+-------+
2
3
4
zlbytes:总字节数(快速内存重分配);zltail:尾节点偏移量(O(1)定位尾节点);zllen:节点数(≤65535时准确,超则需遍历);entry:存储元素(含prevlen+encoding+data);zlend:结束标记(固定为0xFF)。
# entry结构细节
每个元素由三部分组成:
prevlen:前节点长度(1字节或5字节,<254用1字节,否则用1字节0xFE+4字节长度);encoding:数据类型(区分字符串/整数,如0x00表示短字符串);data:实际数据(按encoding规则存储)。
# 双向遍历
正向遍历是从首节点开始,通过解析当前节点的encoding计算长度,定位下一个节点;反向遍历是通过zltail定位尾节点,再通过节点的prevlen反向找前一个节点。
# 痛点:连锁更新
当某节点长度从253→254时,下节点的prevlen需从1字节扩为5字节,可能触发下下个节点的prevlen更新,形成连锁反应(极端O(n)耗时)。因此Redis对ziplist使用限制严格:
- 节点数≤
list-max-ziplist-entries(默认512); - 单个元素≤
list-max-ziplist-value(默认64字节)。
# 2. adlist:通用双向链表(旧版大列表)
adlist是Redis早期用于大列表的底层结构,兼顾灵活性但内存开销较高。
# 核心代码(adlist结构)
// 链表节点
typedef struct listNode {
struct listNode *prev; // 前驱节点指针
struct listNode *next; // 后继节点指针
void *value; // 数据指针(支持任意类型)
} listNode;
// 链表本体
typedef struct list {
listNode *head; // 头节点指针
listNode *tail; // 尾节点指针
unsigned long len; // 节点数量(O(1)获取长度)
void *(*dup)(void *ptr); // 节点值复制函数(自定义逻辑)
void (*free)(void *ptr); // 节点值释放函数(自定义逻辑)
int (*match)(void *ptr, void *key); // 节点值匹配函数(自定义逻辑)
} list;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# adlist核心特征
- 双向遍历+无环设计:通过prev/next指针支持正反遍历,避免死循环;
- O(1)核心操作:直接通过len字段获取长度,无需遍历;
- 缺陷:节点分散导致内存碎片多,遍历需频繁切换内存地址,效率低于ziplist。
# 3. quicklist:ziplist与链表的融合(Redis3.2+默认)
为解决ziplist连锁更新和adlist内存碎片问题,quicklist采用“链表包裹ziplist”的设计:每个链表节点(quicklistNode)封装一个ziplist,兼顾紧凑性和灵活性。
# 核心代码(quicklist结构)
// quicklist 整体结构体
typedef struct quicklist {
quicklistNode *head; // 链表头节点指针
quicklistNode *tail; // 链表尾节点指针
unsigned long count; // 所有ziplist中的总元素数(O(1)获取长度)
unsigned long len; // quicklistNode的数量(链表节点数)
int fill : 16; // 每个ziplist的最大填充限制(元素数/字节数)
unsigned int compress : 16; // 压缩深度(控制中间节点LZF压缩)
} quicklist;
// quicklist 链表节点
typedef struct quicklistNode {
struct quicklistNode *prev; // 前驱节点指针
struct quicklistNode *next; // 后继节点指针
unsigned char *zl; // 指向ziplist的指针(未压缩)
unsigned int zl_bytes; // ziplist的总字节数
unsigned int count : 16; // 当前ziplist中的元素数
unsigned int encoding : 2; // 编码方式:RAW(未压缩)/LZF(压缩)
unsigned int container : 2; // 容器类型:仅支持ZIPLIST
unsigned int recompress : 1; // 标记:是否需要重新压缩(临时解压后)
unsigned int attempted_compress : 1; // 标记:是否尝试过压缩
unsigned int extra : 10; // 预留位
} quicklistNode;
// 压缩后的ziplist结构(LZF压缩)
typedef struct quicklistLZF {
unsigned int sz; // 压缩后的字节数
char compressed[];// 压缩后的ziplist数据
} quicklistLZF;
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
# 关键优化
- 压缩策略:通过
compress控制中间节点LZF压缩(头尾部不压缩,保证读写效率); - 动态拆分/合并:插入元素时若ziplist超
fill阈值,自动拆分新节点;删除后若ziplist过空,合并相邻节点。
# 实战建议
- 消息队列场景建议将
list-max-ziplist-entries调大(如1024),减少quicklist节点数; - 频繁修改中间元素的场景(如日志记录)避免用List,改用ZSet按时间戳排序;
- 启用压缩(
compress=1)可降低长列表内存占用(如历史记录)。
# Hash:键值对集合的两种存储策略
Hash用于存储field-value键值对(如用户信息:user:1 {name: "xxx", age: 20}),底层根据数据规模在ziplist和dict间切换。
# 1. ziplist:小Hash的紧凑存储
当Hash满足:
- 节点数≤
hash-max-ziplist-entries(默认512); - 单个field/value≤
hash-max-ziplist-value(默认64字节);
存储格式为field1 → value1 → field2 → value2(连续排列),查找时需遍历ziplist匹配field。
# 2. dict:大Hash的高效存储
dict是Redis实现的高性能哈希表,支持O(1)读写,核心解决普通哈希表扩容阻塞问题。
# 核心代码(dict结构)
// 哈希节点(最小单元)
typedef struct dictEntry {
void *key; // 键(Redis中通常是字符串对象)
union { // 值(支持多类型,节省内存)
void *val; // 通用值指针(如Hash的value)
uint64_t u64; // 无符号整数(优化小整数存储)
int64_t s64; // 有符号整数
double d; // 浮点数
} v;
struct dictEntry *next; // 下一个节点指针(解决哈希冲突,形成链表)
} dictEntry;
// 哈希表
typedef struct dictht {
dictEntry **table; // 哈希槽数组(元素是dictEntry*,初始为NULL)
unsigned long size; // 哈希槽数组大小(2的幂次,如4、8、16)
unsigned long sizemask; // 掩码 = size - 1,用于计算哈希槽索引(hash & sizemask)
unsigned long used; // 已存储的dictEntry数量(键值对总数)
unsigned long deleted; // 已标记删除的节点数(惰性删除)
} dictht;
// 字典本体
typedef struct dict {
dictType *type; // 自定义操作函数(哈希、对比、复制、释放)
void *privdata; // 私有数据(传给自定义函数)
dictht ht[2]; // 双哈希表:ht[0]正常使用,ht[1]用于rehash
long rehashidx; // rehash进度索引(-1表示未开启rehash)
int16_t pauserehash; // 标记:是否暂停rehash(如阻塞操作时)
} dict;
// 自定义操作函数(适配不同数据类型)
typedef struct dictType {
uint64_t (*hashFunction)(const void *key); // 哈希函数(默认SIPHASH)
int (*keyCompare)(const void *privdata, const void *key1, const void *key2); // 键对比
void *(*keyDup)(void *privdata, const void *key); // 键复制
void *(*valDup)(void *privdata, const void *obj); // 值复制
void (*keyFree)(void *privdata, void *key); // 键释放
void (*valFree)(void *privdata, void *obj); // 值释放
} dictType;
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
# 渐进式rehash:避免主线程阻塞
普通哈希表扩容需一次性迁移所有数据,会阻塞Redis。dict采用分批次迁移:
- 触发条件:
- 扩容:负载因子(
used/size)>1(非BGSAVE时)或>5(强制); - 缩容:负载因子<0.1或已删除节点数>used/2;
- 扩容:负载因子(
- 迁移过程:每次操作(增删改查)或定时任务迁移部分槽,
rehashidx记录进度; - 读写逻辑:读查
ht[0]→ht[1],写直接入ht[1],保证数据一致性。
# Redis7.0+优化
- 哈希冲突解决:桶内元素>8时,链表转为跳表(查询从O(n)→O(logn));
- 异步rehash:迁移任务交给后台线程,彻底避免主线程阻塞。
# 实战建议
- 存储对象属性时优先用Hash(比多个String节省内存);
- 高频更新的大Hash(如商品属性)建议提前将
hash-max-ziplist-entries设为较小值(如128),避免ziplist遍历耗时; - 避免大量小Hash(如千万级用户信息),可按ID分片(
user:1000:info、user:2000:info)。
# Set:无序唯一集合的两种实现
Set是无序、唯一的字符串集合(如标签、好友ID),底层根据元素类型自动选择intset或dict。
# 1. intset:纯整数集合的紧凑存储
当Set元素全为整数且数量少(≤set-max-intset-entries,默认512)时,用intset存储:
# 核心代码(intset结构)
typedef struct intset {
uint32_t encoding; // 编码(INT16/32/64,根据最大元素确定)
uint32_t length; // 元素数
int8_t contents[]; // 柔性数组,存储有序、唯一的整数(按编码对齐)
} intset;
2
3
4
5
6
# 核心特性
- 自动升级:插入元素超出当前编码范围时,扩容内存并迁移所有元素(不可逆);
- 有序性:元素按升序排列,支持二分查找(O(logn));
- 唯一性:插入前检查元素是否存在,避免重复。
# 2. dict:通用集合存储
当元素含字符串或数量超阈值时,用dict存储:key为集合元素,value为NULL(仅用key保证唯一性)。
# 实战建议
- 纯整数集合(如用户ID)优先用Set的intset编码,内存占用仅为List的1/10;
- 交集/并集操作(
SINTER/SUNION)适合用Set,底层通过哈希表高效实现; - 避免存储大量重复元素(Set会自动去重,浪费写入资源)。
# ZSet:有序唯一集合的跳表+字典组合
ZSet是按score排序的唯一集合(如排行榜、延迟队列),底层根据数据规模在ziplist和“跳表+dict”间切换。
# 1. ziplist:小ZSet的紧凑存储
当满足:
- 元素数≤
zset-max-ziplist-entries(默认128); - 单个元素≤
zset-max-ziplist-value(默认64字节);
存储格式为member1 → score1 → member2 → score2(按score升序),插入时需遍历找到位置。
# 2. 跳表+dict:大ZSet的高效存储
为同时支持“按score排序”和“快速查询member的score”,ZSet采用双结构:
- 跳表(zskiplist):按score排序存储member+score,支持O(logn)插入、删除、范围查询;
- dict:映射member→score,支持O(1)查询score和判断member是否存在。
# 核心代码(ZSet结构)
// 跳表节点
typedef struct zskiplistNode {
sds ele; // member字符串(SDS)
double score; // 分值
struct zskiplistNode *backward; // 后退指针(仅用于尾节点遍历)
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 到下一个节点的元素个数(用于排名计算)
} level[]; // 随机层数(1-32)
} zskiplistNode;
// 跳表主结构
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头/尾节点
unsigned long length; // 节点数
int level; // 最大层数
} zskiplist;
// ZSet组合结构
typedef struct zset {
dict *dict; // member → score映射
zskiplist *zsl; // 按score排序的跳表
} zset;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 跳表核心特征
- 分层设计:底层(0层)含所有节点,高层节点更少,作为“快速通道”,查找效率O(logn);
- 严格有序:按score升序排列,所有层的链表遵循同一顺序;
- 随机层数:新节点层数随机生成(1-32),避免层级结构失衡。
# 结构示例(简化)
假设 Zset 存储 {a:1, b:2, c:3},跳表结构简化如下(仅展示核心层):
# 层 2(最高层):header → c (span=3) → NULL
# 层 1:header → b (span=2) → c (span=1) → NULL
# 层 0(基础层):header → a (span=1) → b (span=1) → c (span=1) → NULL
2
3
# 实战建议
- 排行榜场景用
ZADD+ZREVRANGE,利用跳表高效范围查询; - 延迟队列(按时间戳score)用
ZRANGEBYSCORE获取到期任务; - 大ZSet(百万级元素)建议禁用ziplist(调小
zset-max-ziplist-entries),避免插入耗时增长。
# 总结:Redis数据结构的设计哲学
Redis对数据结构的优化始终围绕“平衡内存与性能”:
- 小数据用紧凑结构(ziplist、intset、embstr)减少内存开销;
- 大数据用高效结构(quicklist、dict、跳表)保证读写性能;
- 动态切换机制(阈值控制)适配不同场景;
- 避免阻塞(渐进式rehash、异步操作)保证高可用性。