Redis数据结构底层深度解析:写入与读取的高效逻辑

引言

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"命令

  1. 解析命令:Redis解析命令,识别出这是一个SET操作,键为"name",值为"Alice"
  2. 创建SDS
    • 为键名"name"创建SDS:len=4, alloc=4+1(预留1字节),buf="name"
    • 为值"Alice"创建SDS:len=5, alloc=5+1,buf="Alice"
  3. 内存分配
    • 对于小字符串(<1KB),Redis采用内存预分配策略
      • 如果当前alloc=5,新字符串长度为8,分配alloc=10(2倍长度+1)
      • 如果当前alloc>1MB,新字符串长度为1.5MB,分配alloc=1.5MB+1MB+1
  4. 写入字典
    • 将键SDS和值SDS放入Redis的字典(dict)中
    • 字典的key是键SDS,value是值SDS

写入后的内存结构

复制代码
dict {
  "name": {
    len: 5,
    alloc: 10,
    buf: "Alice"
  }
}

3. 读取过程详解

场景 :执行GET name命令

  1. 解析命令:Redis解析命令,识别出这是一个GET操作,键为"name"
  2. 查找字典
    • 根据键名"name"的SDS(len=4, buf="name")计算哈希值
    • 在字典的哈希表中定位到对应的桶
  3. 获取值
    • 从桶中找到键对应的值SDS
    • 直接返回值SDS的buf内容
  4. 返回结果:将值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对)

  1. 判断底层结构
    • 检查当前Hash的字段数量和大小
    • 如果小于阈值,使用listpack(Redis 7.0+)或ziplist(Redis 7.0之前);否则使用hashtable
  2. listpack/ziplist写入
    • 计算新字段的长度
    • 在listpack/ziplist中插入新字段
    • 更新相关字段信息
  3. hashtable写入
    • 计算key的哈希值
    • 在哈希表中找到对应桶
    • 插入新键值对
  4. 维护索引
    • 如果使用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

  1. 判断底层结构
    • 检查Hash的当前实现方式
  2. listpack/ziplist读取
    • 从listpack/ziplist开头遍历
    • 比较字段名是否匹配
    • 找到匹配项后返回值
  3. 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"

  1. 定位quicklist节点
    • 检查头节点(第一个quicklistNode)是否还有空间
    • 如果头节点的ziplist未满,直接在ziplist头部插入
    • 如果已满,创建新的quicklistNode作为新头节点
  2. 插入元素
    • 在新节点或现有节点的ziplist头部插入元素
    • 更新quicklist的count计数
  3. 压缩处理
    • 根据compress设置,对指定深度的节点进行压缩

写入后的内存结构

复制代码
quicklist {
  head -> [quicklistNode1: ziplist["Bob", "Alice"]] 
  tail -> [quicklistNode1]
  count: 2,
  len: 1
}

3. 读取过程详解

场景 :执行LRANGE recent_users 0 1

  1. 定位起始位置
    • 从头节点开始遍历,累加元素计数
    • 找到包含第0个元素的quicklistNode
  2. 顺序读取
    • 从该节点的ziplist中读取元素
    • 如果当前节点元素不足,继续到下一个节点
  3. 返回结果
    • 收集指定范围的元素
    • 返回结果给客户端

💡 关键补充: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"

  1. 判断底层结构
    • 检查Set的元素是否都是整数
    • 检查元素数量和大小
  2. intset写入
    • 如果所有元素都是整数且数量少,使用intset
    • 按升序插入新元素(保持有序性)
    • 检查是否需要升级encoding(如从int16升级到int32)
  3. 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

  1. 判断底层结构
    • 检查Set的当前实现方式
  2. intset读取
    • 直接遍历intset数组中的所有值
    • 返回结果
  3. 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"

  1. 判断底层结构
    • 检查Sorted Set的元素数量和大小
    • 如果小于阈值,使用listpack(Redis 7.0+)或ziplist(Redis 7.0之前)
    • 否则使用skiplist+dict
  2. listpack/ziplist写入
    • 在listpack/ziplist中查找插入位置(保持按score排序)
    • 插入member-score对
  3. 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

  1. 判断底层结构
    • 检查Sorted Set的当前实现方式
  2. listpack/ziplist读取
    • 从listpack/ziplist开头遍历
    • 读取member-score对
    • 返回指定范围的元素
  3. 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之所以高效,不是因为它使用了某种特定的数据结构,而是因为它根据数据特性动态选择最优结构,并针对特定操作进行深度优化。这种设计哲学体现在:

  1. 空间效率:优先使用紧凑结构(listpack, intset)存储小数据,减少内存占用
  2. 时间效率:大数据场景切换为高效结构(hashtable, skiplist),保证O(1)或O(log n)的操作复杂度
  3. 动态转换:支持从小数据结构到大数据结构的自动转换,适应数据增长
  4. 编码优化:根据数据类型和大小自动选择最佳编码方式

Redis数据结构的智能转换流程

复制代码
数据写入 → 检查大小/类型 → 选择最优底层结构 → 写入数据
数据读取 → 检查当前结构 → 选择最优读取方式 → 返回结果

为什么Redis能成为高性能缓存系统的首选?

  1. 原子操作:所有操作在Redis内部完成,避免了"读-改-写"的网络和CPU开销
  2. 内存优化:针对不同数据类型使用最优存储结构,减少内存占用
  3. 网络优化:只传输需要的数据,减少网络带宽消耗
  4. 并发安全:单线程设计保证了操作的原子性,无需额外锁机制
  5. 智能编码:根据数据特性自动选择最佳编码方式

结语

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高效操作的底层逻辑,可以开始在项目中应用这些知识,打造高性能的缓存系统了!🚀

相关推荐
CoderYanger2 小时前
C.滑动窗口-求子数组个数-越短越合法——3134. 找出唯一性数组的中位数
java·开发语言·数据结构·算法·leetcode
ndzson2 小时前
从前序与中序遍历序列构造二叉树 与
数据结构·算法
今天你TLE了吗2 小时前
LeeCode Hot100随机链表的复制 java易懂题解
java·数据结构·链表
山峰哥2 小时前
现代 C++ 的最佳实践:从语法糖到工程化思维的全维度探索
java·大数据·开发语言·数据结构·c++
别动哪条鱼3 小时前
FFmpeg AVFormatContext 分配函数详解
数据结构·ffmpeg·音视频
小股虫3 小时前
Redis实现轻量级消息队列:实操手册与项目应用指南
数据库·redis
Full Stack Developme3 小时前
Nginx 代理 mysql redis MQ 等各种软件,供客户端访问链接
redis·mysql·nginx
CoderYanger3 小时前
C.滑动窗口-求子数组个数-越短越合法——LCP 68. 美观的花束
java·开发语言·数据结构·算法·leetcode
hweiyu003 小时前
数据结构:树状数组
数据结构