引言
Redis之所以成为高性能缓存系统的首选,不仅在于它将数据存储在内存中,更在于它对底层数据结构的精妙设计。每种数据结构都有其独特的底层实现,针对特定操作进行了优化。本文将深入Redis的五种核心数据结构,从底层到应用,详细解析它们的写入和读取过程,揭示Redis为何能实现"微秒级"响应。
一、String:SDS的精妙设计
1. 底层实现:SDS (Simple Dynamic String)
Redis的String类型不是直接使用C语言的字符串,而是使用自定义的SDS结构:
Redis 3.2及之前的SDS结构:
c
struct sdshdr {
int len; // 已使用的字节数
int alloc; // 总分配的字节数
char buf[]; // 字符数组
};
Redis 3.2之后的SDS结构优化 :
Redis 6.x+引入了5种不同类型的SDS结构以节省内存:
c
// 新版SDS有5种类型:sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已使用长度
uint8_t alloc; // 分配的总容量
unsigned char flags;// 低3位存储类型,高5位未使用
char buf[]; // 字符数组
};
// 还有sdshdr16、sdshdr32、sdshdr64用于不同长度场景
SDS的优势:
- 二进制安全:可以存储任意二进制数据(C字符串遇到'\0'会终止)
- O(1)获取长度:直接读取len字段,无需遍历
- 预分配策略:减少内存重分配次数
- 内存优化:新版SDS根据字符串长度选择最合适的结构,减少内存浪费
2. 写入过程详解
场景 :执行SET name "Alice"命令
- 解析命令:Redis解析命令,识别出这是一个SET操作,键为"name",值为"Alice"
- 创建SDS :
- 为键名"name"创建SDS:len=4, alloc=4+1(预留1字节),buf="name"
- 为值"Alice"创建SDS:len=5, alloc=5+1,buf="Alice"
- 内存分配 :
- 对于小字符串(<1KB),Redis采用内存预分配策略 :
- 如果当前alloc=5,新字符串长度为8,分配alloc=10(2倍长度+1)
- 如果当前alloc>1MB,新字符串长度为1.5MB,分配alloc=1.5MB+1MB+1
- 对于小字符串(<1KB),Redis采用内存预分配策略 :
- 写入字典 :
- 将键SDS和值SDS放入Redis的字典(dict)中
- 字典的key是键SDS,value是值SDS
写入后的内存结构:
dict {
"name": {
len: 5,
alloc: 10,
buf: "Alice"
}
}
3. 读取过程详解
场景 :执行GET name命令
- 解析命令:Redis解析命令,识别出这是一个GET操作,键为"name"
- 查找字典 :
- 根据键名"name"的SDS(len=4, buf="name")计算哈希值
- 在字典的哈希表中定位到对应的桶
- 获取值 :
- 从桶中找到键对应的值SDS
- 直接返回值SDS的buf内容
- 返回结果:将值SDS的buf内容返回给客户端
读取效率:
- 字典哈希表查找:O(1)
- SDS直接返回内容:O(1)
- 总时间:O(1) = 100纳秒左右
4. 为什么比JSON高效?
JSON存储示例:
json
{"name": "Alice", "age": 25, "email": "alice@example.com"}
Redis存储示例:
bash
HSET user:1000 name "Alice" age 25 email "alice@example.com"
对比:
| 操作 | JSON方式 | Redis Hash方式 | 效率提升 |
|---|---|---|---|
| 获取年龄 | 解析JSON → 找到age字段 | 直接HGET | 显著更快 |
| 更新邮箱 | 解析JSON → 修改email → 重写整个JSON | 直接HSET | 大幅提高 |
| 网络传输 | 整个JSON对象(约50字节) | 仅需传输"alice@example.com"(20字节) | 大幅节省带宽 |
💡 关键洞察:Redis的SDS和字典设计让String操作在内存中完成,无需序列化/反序列化,极大减少了CPU和网络开销。
二、Hash:ziplist/listpack与hashtable的智能切换
1. 底层实现:ziplist/listpack vs hashtable
Redis的Hash类型根据数据量动态切换底层实现:
- ziplist/listpack:小数据量(默认<512个字段,每个字段<64字节)
- hashtable:大数据量(超过阈值)
版本演进:
- Redis 7.0之前:使用ziplist作为紧凑存储
- Redis 7.0之后:使用listpack替代ziplist(解决级联更新问题)
ziplist结构(Redis 7.0之前):
[<prev_len>][<encoding>][<entry>][<prev_len>][<encoding>][<entry>]...
listpack结构(Redis 7.0之后):
[<total_bytes>][<element1>][<element2>]...[<elementN>][<listpack-end-byte>]
hashtable结构:
[<key> -> <value>][<key> -> <value>]...
2. 写入过程详解
场景 :执行HSET user:1000 name "Alice" age 25(注:Redis 4.0+支持多field-value对)
- 判断底层结构 :
- 检查当前Hash的字段数量和大小
- 如果小于阈值,使用listpack(Redis 7.0+)或ziplist(Redis 7.0之前);否则使用hashtable
- listpack/ziplist写入 :
- 计算新字段的长度
- 在listpack/ziplist中插入新字段
- 更新相关字段信息
- hashtable写入 :
- 计算key的哈希值
- 在哈希表中找到对应桶
- 插入新键值对
- 维护索引 :
- 如果使用hashtable,维护键到值的映射
写入后的内存结构(listpack):
[total_bytes][len=4]["name"][len=5]["Alice"][len=3]["age"][len=2]["25"][listpack-end-byte]
写入后的内存结构(hashtable):
hash_table = {
"name": "Alice",
"age": "25"
}
💡 重要说明 :Redis 4.0之前,HSET命令必须是
HSET key field value格式,不支持多field-value对。Redis 4.0+才支持一次设置多个字段。
3. 读取过程详解
场景 :执行HGET user:1000 name
- 判断底层结构 :
- 检查Hash的当前实现方式
- listpack/ziplist读取 :
- 从listpack/ziplist开头遍历
- 比较字段名是否匹配
- 找到匹配项后返回值
- hashtable读取 :
- 计算字段名的哈希值
- 定位到哈希表的对应桶
- 比较键名,找到匹配项
- 返回值
读取效率:
- listpack/ziplist:O(n),但n很小(<512)
- hashtable:O(1)
- 平均效率:O(1)(因为小数据量时listpack/ziplist也很快)
4. 为什么比JSON高效?
JSON存储示例:
json
{"name": "Alice", "age": 25, "email": "alice@example.com"}
Redis Hash存储示例:
bash
HSET user:1000 name "Alice" age 25 email "alice@example.com"
对比:
| 操作 | JSON方式 | Redis Hash方式 | 效率提升 |
|---|---|---|---|
| 获取年龄 | 解析JSON → 找到age字段 | 直接HGET | 显著更快 |
| 更新邮箱 | 解析JSON → 修改email → 重写整个JSON | 直接HSET | 大幅提升 |
| 内存占用 | 100个用户约280MB | 100个用户约210MB | 节省25%内存 |
💡 关键洞察:Redis的Hash使用listpack/ziplist在小数据量时节省内存,使用hashtable在大数据量时保证查询速度,这种智能切换是Redis高效的关键。
三、List:quicklist的现代实现
1. 底层实现:quicklist(不是ziplist与linkedlist的简单切换)
版本演进:
- Redis 3.2之前:使用ziplist或linkedlist,根据数据量切换
- Redis 3.2之后:统一使用quicklist作为List的底层实现
quicklist结构 :
quicklist是ziplist的双向链表,每个节点是一个小ziplist:
c
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 所有ziplist中entry总数
unsigned long len; // quicklistNode数量
int fill : 16; // 每个节点ziplist大小限制
unsigned int compress : 16; // 压缩深度
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; // 指向ziplist或quicklistLZF
unsigned int sz; // ziplist字节大小
unsigned int count : 16; // ziplist中的元素数量
unsigned int encoding : 2; // RAW==1 or LZF==2
unsigned int container : 2; // NONE==1 or ZIPLIST==2
unsigned int recompress : 1;// 是否被压缩
} quicklistNode;
设计优势:
- 结合了ziplist的内存效率和linkedlist的修改效率
- 支持节点压缩,进一步节省内存
- 避免大型ziplist的重新分配问题
2. 写入过程详解
场景 :执行LPUSH recent_users "Alice" "Bob"
- 定位quicklist节点 :
- 检查头节点(第一个quicklistNode)是否还有空间
- 如果头节点的ziplist未满,直接在ziplist头部插入
- 如果已满,创建新的quicklistNode作为新头节点
- 插入元素 :
- 在新节点或现有节点的ziplist头部插入元素
- 更新quicklist的count计数
- 压缩处理 :
- 根据compress设置,对指定深度的节点进行压缩
写入后的内存结构:
quicklist {
head -> [quicklistNode1: ziplist["Bob", "Alice"]]
tail -> [quicklistNode1]
count: 2,
len: 1
}
3. 读取过程详解
场景 :执行LRANGE recent_users 0 1
- 定位起始位置 :
- 从头节点开始遍历,累加元素计数
- 找到包含第0个元素的quicklistNode
- 顺序读取 :
- 从该节点的ziplist中读取元素
- 如果当前节点元素不足,继续到下一个节点
- 返回结果 :
- 收集指定范围的元素
- 返回结果给客户端
💡 关键补充:LRANGE操作时,quicklist会从两端向中间遍历,优化了查找效率。当查询范围较小时,只需遍历两端的节点,无需遍历整个列表。
4. 为什么比JSON高效?
JSON存储示例:
json
["Alice", "Bob", "Charlie"]
Redis List存储示例:
bash
LPUSH recent_users "Alice" "Bob" "Charlie"
对比:
| 操作 | JSON方式 | Redis List方式 | 效率提升 |
|---|---|---|---|
| 添加新元素 | 解析JSON → 添加元素 → 重写整个JSON | 直接LPUSH | 显著更快 |
| 获取前2个元素 | 解析JSON → 取前2个元素 | 直接LRANGE | 大幅提升 |
| 内存占用 | 100个用户约200MB | 100个用户约150MB | 节省25%内存 |
💡 关键洞察:Redis的quicklist设计巧妙结合了ziplist的内存效率和链表的结构灵活性,通过分块的ziplist避免了大型列表的性能问题。
四、Set:intset与hashtable的智能选择
1. 底层实现:intset vs hashtable
Redis的Set类型根据数据类型和大小动态切换:
- intset:仅包含整数,且数据量小(默认<512个元素)
- hashtable:包含非整数或数据量大
intset结构:
[<encoding>][<length>][<element1>][<element2>]...[<elementN>]
hashtable结构:
[<key> -> <value>][<key> -> <value>]...
注意:Set使用hashtable时,value是NULL或指向同一个共享对象,不是true,这样节省内存:
hash_table = {
"user:2000": NULL,
"user:3000": NULL
}
2. 写入过程详解
场景 :执行SADD user:1000:friends "user:2000" "user:3000"
- 判断底层结构 :
- 检查Set的元素是否都是整数
- 检查元素数量和大小
- intset写入 :
- 如果所有元素都是整数且数量少,使用intset
- 按升序插入新元素(保持有序性)
- 检查是否需要升级encoding(如从int16升级到int32)
- hashtable写入 :
- 计算元素的哈希值
- 在哈希表中找到对应桶
- 插入新元素(key为元素值,value为NULL)
写入后的内存结构(intset):
[encoding=INTSET_ENC_INT16, length=2, 2000, 3000]
写入后的内存结构(hashtable):
hash_table = {
"user:2000": NULL,
"user:3000": NULL
}
3. 读取过程详解
场景 :执行SMEMBERS user:1000:friends
- 判断底层结构 :
- 检查Set的当前实现方式
- intset读取 :
- 直接遍历intset数组中的所有值
- 返回结果
- hashtable读取 :
- 遍历哈希表中的所有键
- 返回键列表作为结果
读取效率:
- intset:O(n),但n很小且内存连续,缓存友好
- hashtable:O(n)
- 平均效率:O(n),但intset在小数据量时更快(连续内存访问)
4. 为什么比JSON高效?
JSON存储示例:
json
["user:2000", "user:3000", "user:4000"]
Redis Set存储示例:
bash
SADD user:1000:friends "user:2000" "user:3000" "user:4000"
对比:
| 操作 | JSON方式 | Redis Set方式 | 效率提升 |
|---|---|---|---|
| 添加新好友 | 解析JSON → 添加元素 → 重写整个JSON | 直接SADD | 显著更快 |
| 获取所有好友 | 解析JSON → 取所有元素 | 直接SMEMBERS | 大幅提升 |
| 检查好友是否存在 | 解析JSON → 遍历查找 | 直接SISMEMBER | 显著更快 |
💡 关键洞察:Redis的Set使用intset在整数集合时节省内存,使用hashtable在非整数或大数据量时保证效率,这种智能选择是Redis高效的关键。
五、Sorted Set:ziplist/listpack与skiplist+dict的完美结合
1. 底层实现:ziplist/listpack vs skiplist + dict
Redis的Sorted Set类型根据数据量动态切换:
- ziplist/listpack:小数据量(默认<128个元素,每个元素<64字节)
- skiplist + dict:大数据量
ziplist/listpack存储格式:
[member1][score1][member2][score2]...
// 元素成对存储,member在前,score在后
skiplist + dict结构:
c
typedef struct zset {
dict *dict; // 字典:member->score,用于O(1)查找分数
zskiplist *zsl; // 跳表:用于范围查询和排序
} zset;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
💡 关键补充 :zskiplistNode包含
backward指针,用于从尾部向头部遍历,这是Redis实现RANGE等操作的重要特性。
2. 写入过程详解
场景 :执行ZADD leaderboard 1000 "user:1000" 2000 "user:2000"
- 判断底层结构 :
- 检查Sorted Set的元素数量和大小
- 如果小于阈值,使用listpack(Redis 7.0+)或ziplist(Redis 7.0之前)
- 否则使用skiplist+dict
- listpack/ziplist写入 :
- 在listpack/ziplist中查找插入位置(保持按score排序)
- 插入member-score对
- skiplist+dict写入 :
- 在dict中检查member是否存在
- 如果存在,更新score并从skiplist删除旧节点
- 在skiplist中找到新插入位置
- 创建zskiplistNode并插入到skiplist
- 更新dict中的score
写入后的内存结构(listpack):
[total_bytes]["user:1000"]["1000"]["user:2000"]["2000"][listpack-end-byte]
写入后的内存结构(skiplist+dict):
zset {
dict = {
"user:1000": 1000,
"user:2000": 2000
}
zskiplist = {
header -> [level3: NULL]
[level2: -> node2]
[level1: -> node1]
[level0: -> node1]
node1: ["user:1000", score=1000]
node2: ["user:2000", score=2000]
}
}
3. 读取过程详解
场景 :执行ZRANGE leaderboard 0 1 WITHSCORES
- 判断底层结构 :
- 检查Sorted Set的当前实现方式
- listpack/ziplist读取 :
- 从listpack/ziplist开头遍历
- 读取member-score对
- 返回指定范围的元素
- skiplist+dict读取 :
- 从skiplist的头节点开始,通过forward指针快速定位
- 遍历获取指定范围的节点
- 从节点中获取member和score
- 返回结果
读取效率:
- listpack/ziplist:O(n),但n很小(<128)
- skiplist:O(log n) + O(m),其中m是要返回的元素数
- 平均效率:大数据量时O(log n),小数据量时接近O(1)
4. 为什么比JSON高效?
JSON存储示例:
json
{"user:1000": 1000, "user:2000": 2000, "user:3000": 1500}
Redis Sorted Set存储示例:
bash
ZADD leaderboard 1000 "user:1000" 2000 "user:2000" 1500 "user:3000"
对比:
| 操作 | JSON方式 | Redis Sorted Set方式 | 效率提升 |
|---|---|---|---|
| 添加新成员 | 解析JSON → 添加元素 → 重写整个JSON | 直接ZADD | 显著更快 |
| 获取前2名 | 解析JSON → 排序 → 取前2名 | 直接ZRANGE | 大幅提升 |
| 按分数范围查询 | 解析JSON → 排序 → 过滤 | 直接ZRANGEBYSCORE | 显著更快 |
💡 关键洞察:Redis的Sorted Set使用skiplist提供O(log n)的查找效率,同时使用dict提供O(1)的分数查询,这种组合是Redis高效实现有序集合的关键。
六、Redis的核心设计:redisObject与内存管理
1. redisObject:所有数据的包装器
所有Redis值都包装在redisObject中:
c
typedef struct redisObject {
unsigned type:4; // 数据类型(string、hash、list、set、zset)
unsigned encoding:4; // 编码方式(底层实现)
unsigned lru:24; // LRU时间戳或LFU计数
int refcount; // 引用计数,用于内存回收
void *ptr; // 指向实际数据的指针
} robj;
type与encoding的关系示例:
| 类型(type) | 可能的编码(encoding) | 说明 |
|---|---|---|
| OBJ_STRING | OBJ_ENCODING_INT | 整数值实现的字符串 |
| OBJ_STRING | OBJ_ENCODING_EMBSTR | embstr编码的SDS字符串 |
| OBJ_STRING | OBJ_ENCODING_RAW | SDS字符串 |
| OBJ_LIST | OBJ_ENCODING_QUICKLIST | quicklist编码的列表 |
| OBJ_HASH | OBJ_ENCODING_ZIPLIST/listpack | ziplist/listpack编码的哈希 |
| OBJ_HASH | OBJ_ENCODING_HT | 字典编码的哈希 |
| OBJ_SET | OBJ_ENCODING_INTSET | 整数集合编码的集合 |
| OBJ_SET | OBJ_ENCODING_HT | 字典编码的集合 |
| OBJ_ZSET | OBJ_ENCODING_ZIPLIST/listpack | ziplist/listpack编码的有序集合 |
| OBJ_ZSET | OBJ_ENCODING_SKIPLIST | 跳表编码的有序集合 |
2. 内存分配器:jemalloc
Redis默认使用jemalloc而非glibc malloc,优势包括:
- 减少内存碎片
- 提升内存分配效率
- 更好的多线程内存管理
3. 惰性删除与内存回收
- 惰性删除:删除操作不立即释放内存,只是标记删除
- 异步删除:Redis 4.0+引入,使用后台线程处理大Key删除
- 内存回收:定期或达到阈值时进行内存回收
七、Redis数据结构设计的终极哲学
Redis之所以高效,不是因为它使用了某种特定的数据结构,而是因为它根据数据特性动态选择最优结构,并针对特定操作进行深度优化。这种设计哲学体现在:
- 空间效率:优先使用紧凑结构(listpack, intset)存储小数据,减少内存占用
- 时间效率:大数据场景切换为高效结构(hashtable, skiplist),保证O(1)或O(log n)的操作复杂度
- 动态转换:支持从小数据结构到大数据结构的自动转换,适应数据增长
- 编码优化:根据数据类型和大小自动选择最佳编码方式
Redis数据结构的智能转换流程:
数据写入 → 检查大小/类型 → 选择最优底层结构 → 写入数据
数据读取 → 检查当前结构 → 选择最优读取方式 → 返回结果
为什么Redis能成为高性能缓存系统的首选?
- 原子操作:所有操作在Redis内部完成,避免了"读-改-写"的网络和CPU开销
- 内存优化:针对不同数据类型使用最优存储结构,减少内存占用
- 网络优化:只传输需要的数据,减少网络带宽消耗
- 并发安全:单线程设计保证了操作的原子性,无需额外锁机制
- 智能编码:根据数据特性自动选择最佳编码方式
结语
Redis的高效不是偶然,而是源于对数据结构 的深度理解和场景化优化。每种数据结构的底层实现都针对特定操作进行了精心设计,使得Redis能够在微秒级时间内完成数据的读写。
当你在使用Redis时,不要仅仅把它看作一个"键值存储系统",而应该把它视为一个数据结构优化引擎。理解这些底层逻辑,你就能更好地利用Redis,构建出真正高性能的应用。
"Redis不是数据库,而是数据库的加速器。" ------ Redis核心开发者
通过深入理解这些底层设计,你将能够:
- 选择最合适的Redis数据结构
- 优化数据存储和查询
- 避免常见的性能陷阱
- 真正发挥Redis的性能优势
版本说明:
- Redis 3.2之前:List使用ziplist或linkedlist切换
- Redis 3.2之后:List统一使用quicklist
- Redis 7.0之前:Hash和Sorted Set的小数据使用ziplist
- Redis 7.0之后:使用listpack替代ziplist
现在,你已经掌握了Redis高效操作的底层逻辑,可以开始在项目中应用这些知识,打造高性能的缓存系统了!🚀