Redis个人总结版(底层相关知识点梳理)
很早就接触并使用过 Redis,但仅停留在业务应用层面,本次将从底层原理重新系统学习。
Redis(Remote Dictionary Server,远程字典服务),是一款开源的、基于内存的、支持多种数据结构的高性能键值存储系统。
1. 环境搭建和实操
学习一个新技术,最好的方法是先用,对它有个大致了解,然后再一步一步深入。如果从来没有使用过,就去看各种概念,很容易淹没在知识点和各种术语的海洋里。
1.1 使用包管理器安装Redis(Ubuntu系统)
# 更新软件包列表
sudo apt update
# 在线安装 Redis 服务端(Ubuntu)
sudo apt install redis-server -y
# 启动 Redis 服务
sudo systemctl start redis-server
# 测试是否安装成功
redis-cli ping
结果返回 PONG,说明安装成功,且服务器正常运行。
安装后的配置文件位置:/etc/redis/redis.conf(是 Redis 核心配置文件,端口、密码、持久化、内存限制、底层编码阈值等所有运行规则均在此定义。)
1.2 使用 redis-cli进行实操
redis-cli 是 Redis 自带的命令行工具,安装 Redis 时会自动安装。刚刚安装完Redis我们已经用redis-cli测试过了。下面就可以开始练习常用命令了(这些命令最好动手敲两遍,遇到error日志AI搜索一下原因,可以加深对Redis的理解)。
Redis 的键值对中的 key 就是字符串对象,而 value 就是指Redis的数据类型,可以是String,也可以是List、Hash、Set、 Zset、Bitmap、Stream 等数据类型。本文主要介绍五种常见的数据类型:String,List,Hash,Set,Zset。
redis-cli 小贴士
- 命令不区分大小写,但习惯用大写(行业通用规范:命令大写、key/value 小写,提升可读性)
- 按 Tab 键可以自动补全命令
- 上下箭头可以查看历史命令
- 输入 HELP @<分类> 查看帮助
- 安全退出redis-cli 客户端:用EXIT或QUIT
一. 基础命令练习(String 类型)
1. 键值对操作
# 设置键值
SET name "张三"
SET age 25
SET email "zhangsan@example.com"
# 获取键值
GET name
GET age
GET email
# 设置带过期时间的键(10秒后自动删除)
SET session "abc123" EX 10
# 查看剩余存活时间
TTL session
# 判断键是否存在
EXISTS name
EXISTS phone
# 删除键
DEL email
# 获取键的类型
TYPE name
TYPE age
2. 数值操作(原子操作)
# 设置数值
SET counter 10
# 自增 1
INCR counter
INCR counter
# 自增指定数值
INCRBY counter 5
# 自减 1
DECR counter
# 自减指定数值
DECRBY counter 3
# 浮点数增加
SET price 19.99
INCRBYFLOAT price 0.01
# 查看当前值
GET counter
GET price
3. 同时操作多个键
# 批量设置
MSET user:1 "Alice" user:2 "Bob" user:3 "Charlie"
# 批量获取
MGET user:1 user:2 user:3
# 如果不存在则设置(用于分布式锁)
SETNX lock "locked"
SETNX lock "again" # 键已存在,设置失败,返回 0
二. 列表命令(List,有序可重复)
# 从右侧推入(尾部)
RPUSH tasks "任务1"
RPUSH tasks "任务2"
RPUSH tasks "任务3"
# 从左侧推入(头部)
LPUSH tasks "紧急任务"
# 查看列表范围(0 到 -1 表示全部)
LRANGE tasks 0 -1
LRANGE tasks 0 2
# 从左侧弹出(取出并删除)
LPOP tasks
# 从右侧弹出
RPOP tasks
# 获取列表长度
LLEN tasks
# 获取指定索引的值
LINDEX tasks 0
三. 哈希表命令(Hash,类似对象)
# 设置哈希表的字段
HSET user:1000 name "张三"
HSET user:1000 age 30
HSET user:1000 city "北京"
# 批量设置哈希表字段
HSET user:1001 name "李四" age 25 city "上海"
# 获取单个字段
HGET user:1000 name
# 获取所有字段和值
HGETALL user:1000
# 获取所有字段名
HKEYS user:1000
# 获取所有值
HVALS user:1000
# 获取字段数量
HLEN user:1000
# 删除字段
HDEL user:1000 city
# 字段自增
HINCRBY user:1000 age 1
四. 集合命令(Set,无序不重复)
# 添加元素
SADD colors "red"
SADD colors "green"
SADD colors "blue"
SADD colors "red" # 重复添加无效
# 查看所有元素
SMEMBERS colors
# 判断元素是否存在
SISMEMBER colors "green" # 返回 1
SISMEMBER colors "yellow" # 返回 0
# 获取集合大小
SCARD colors
# 随机获取元素(不删除)
SRANDMEMBER colors
# 随机弹出元素(删除并返回)
SPOP colors
# 删除元素
SREM colors "blue"
# 集合运算
SADD set1 1 2 3
SADD set2 2 3 4
SINTER set1 set2 # 交集:2, 3
SUNION set1 set2 # 并集:1, 2, 3, 4
SDIFF set1 set2 # 差集:1
五. 有序集合命令(ZSet,也称作Sorted Set,带分数排序)
# 添加元素(分数决定排序)
ZADD leaderboard 100 "Alice"
ZADD leaderboard 85 "Bob"
ZADD leaderboard 95 "Charlie"
ZADD leaderboard 100 "David"
# 查看排名(按分数升序)
ZRANGE leaderboard 0 -1 WITHSCORES
# 查看排名(按分数降序)
ZREVRANGE leaderboard 0 -1 WITHSCORES
# 获取前3名(降序)
ZREVRANGE leaderboard 0 2 WITHSCORES
# 查看指定分数范围的元素
ZRANGEBYSCORE leaderboard 90 100 WITHSCORES
# 获取元素的排名(从0开始)
ZRANK leaderboard "Bob" # 升序排名
ZREVRANK leaderboard "Alice" # 降序排名
# 增加分数
ZINCRBY leaderboard 5 "Bob"
# 获取元素个数
ZCARD leaderboard
# 删除元素
ZREM leaderboard "David"
六. 键管理命令
# 查找所有符合模式的键(生产环境慎用!)
KEYS *
KEYS user:*
KEYS *:1000
# 生产环境推荐使用 SCAN(不阻塞)
SCAN 0 MATCH user:* COUNT 10
# 检查键是否存在
EXISTS name
# 删除键
DEL name
# 设置过期时间(秒)
EXPIRE session 60
# 设置过期时间(毫秒)
PEXPIRE session 60000
# 移除过期时间(永久保存)
PERSIST session
# 重命名键
RENAME old_key new_key
RENAMENX old_key new_key # 仅当新键名不存在时
# 随机返回一个键
RANDOMKEY
七. 五种数据类型的主要应用场景
| 数据类型 | 典型应用场景 |
| String (字符串) | 缓存对象、计数器、分布式锁 |
| List (列表) | 消息队列、最新消息列表(如评论、通知) |
| Hash (哈希) | 存储对象(如用户信息、商品详情),可单独操作字段 |
| Set (集合) | 标签系统、共同好友、数据去重 |
| Sorted Set (有序集合) | 排行榜、带权重的任务队列、范围查找 |
2. 常用数据类型的底层实现
下面介绍五种常用数据类型依托的底层数据结构。
2.1 SDS(Simple Dynamic String - 简单动态字符串)
SDS是 Redis 自己造的字符串结构,用来完全替代 C 语言字符串。Redis 里所有需要存储字符串数据的地方(包括 Key、String 类型的Value、Hash 的 field/value、List/Set/ZSet 的成员、命令名字等),底层都是用 SDS 来存储的。
1. 为什么 Redis 不直接用 C 字符串?
C 语言字符串有 4 个致命缺陷:
- 获取长度慢:必须遍历到 \0 才能知道长度,O(N)
- 容易缓冲区溢出:拼接字符串不检查空间
- 修改时频繁内存重分配:性能差
- 不能存二进制(图片、视频):遇到 \0 就截断
Redis 是高性能内存数据库,C 字符串完全扛不住。所以 Redis 自己造了 SDS。
2. SDS 长什么样?(底层结构)
SDS 的经典简化模型(C语言)
struct sdshdr {
// 已使用长度
unsigned int len;
// 剩余可用空间
unsigned int free;
// 真实存字符的数组
char buf[];
};
你存一个字符串:"redis",SDS 在内存里长这样:
len = 5
free = 10
buf = ['r','e','d','i','s','\0']
- len:已经用了多少字节
- free:还剩多少空间
- buf:真实数据
3. SDS 解决了 C 字符串的所有问题
① 获取长度 = O (1)
- C 语言:遍历到 \0 → O (N)
- SDS:直接读 len → O(1)
② 不会缓冲区溢出
SDS 拼接前会先检查:free >= 需要的空间吗?
不够就先扩容,再写入。
③ 减少内存重分配(Redis 高性能关键)
SDS 做了两个优化:
- 空间预分配:扩容时多分配一点,给未来用
- 惰性空间释放:字符串缩短时,不立即回收内存,留着备用
这让 Redis 字符串修改极快。
④ 二进制安全
C 字符串遇到 \0 就停。SDS 靠 len 判断长度,不是靠 \0。所以能存图片、音频、视频、序列化对象。
2.2 从 linkedlist 到 quicklist
quicklist 是 Redis 3.2 版本引入的,它结合了旧版本中 ziplist(压缩列表)和 linkedlist(双端链表)的优点,形成了一种双向链表 + 节点内压缩的混合结构,兼顾了性能和内存。
所以这里我们先讲 linkedlist 和 ziplist,然后再来讲quicklist,最后介绍 listpack (Redis 7新结构)。
2.2.1 Redis 双向链表 linkedlist
① 链表节点 (listNode):这是存储数据的最小单元。
typedef struct listNode {
struct listNode *prev; // 指向前一个节点的指针
struct listNode *next; // 指向后一个节点的指针
void *value; // 指向节点中具体存储的数据
} listNode;
一个节点包含了前驱和后继指针,因此可以双向遍历。void* 指针的设计让这个节点可以持有任何类型的数据,实现了多态性。
② 链表管理器 (list):为了方便管理整个链表,Redis 还定义了一个链表结构体。
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 可以 O(1) 复杂度地获取链表的头节点、尾节点和长度,这是直接用节点拼接无法做到的。
结构示意图:
head <-> node1 <-> node2 <-> node3 <-> tail
len = 3
Redis双向链表 linkedlist 的优缺点
优点:
✅ 双向:可以双向遍历
✅ 无环:头尾指向 NULL
✅ 带头尾指针:O(1) 获取头尾
✅ 带长度计数器:O(1) 获取长度
✅ 多态:void* 可保存任意类型
缺点:
- 内存开销大:每个节点需要 16 字节(prev + next)
- 缓存不友好:节点不连续存储,CPU 缓存命中率低
- 每个节点独立内存,内存碎片化严重
- 查找效率低:需要遍历,时间复杂度O(N)
2.2.2 ziplist - 压缩列表
ziplist 是一种连续内存的紧凑存储结构,它是由连续内存块组成的顺序型数据结构,类似于数组。它通过特定的编码方式存储多个元素(字符串或整数),整个结构无需指针关联,极大地节省了内存空间。其整体结构及单个元素(entry)的存储格式如下
整体结构

一共 5 个部分:
- zlbytes(4 字节):整个 ziplist 占多少字节
- zltail(4 字节):最后一个 entry 的偏移量(快速找尾)
- zllen(2 字节):entry 个数
- entryN:真正存的数据(核心)
- zlend(1 字节):结束符 0xFF
entry 结构

- prevlen:记录前一个 entry 的长度,目的是为了实现从后向前遍历,长度:1 字节 或 5 字节
- encoding:记录当前节点的类型(字符串 / 整数)、长度,主要通过它来解析 data
- data:当前节点的实际数据,类型和长度都由 encoding 决定
prevlen 属性的空间大小跟前一个节点长度值的关系如下:
- 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值
- 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值
在压缩列表中:
- 查找第一个元素:可以通过表头固定长度直接定位,复杂度 O(1)。
- 查找最后一个元素:可以通过表头中的 zltail 字段(尾部偏移量)直接定位,复杂度 O(1)。
- 查找其他元素:只能逐个遍历,复杂度 O(N)。
因此,压缩列表不适合保存过多的元素。
往压缩列表中插入数据时,压缩列表会根据前一个节点的总长度决定 prevlen 使用 1 字节还是 5 字节;同时根据当前节点的数据类型(字符串/整数)和数据大小决定 encoding 使用不同长度。这种根据数据情况动态分配空间的设计思想,是 Redis 为了节省内存而采用的。
连锁更新问题
场景:多个长度为 253 字节的节点
初始:
[e1:253B] [e2:253B] [e3:253B] [e4:253B]
↑ prevlen = 253 (1 字节)插入 254 字节的 e0:
[e0:254B] [e1:253B] [e2:253B] [e3:253B] [e4:253B]连锁反应:
1. e1 的 prevlen 需要记录 254
→ 从 1 字节扩展为 5 字节
→ e1 长度变为 257 字节(增加了5-1=4个字节)2. e2 的 prevlen 需要记录 257
→ 也要扩展为 5 字节
→ e2 长度变为 257 字节3. e3、e4... 依次连锁更新
最坏情况:O(N²) 时间复杂度
ziplist 的特性
| 特性 | 说明 |
| ✅ 内存极致压缩 | 连续内存,紧凑编码 |
| ✅ 遍历高效 | CPU 缓存友好 |
| ❌ 插入删除慢 | 需要重新分配内存 |
| ❌ 新增或修改有连锁更新风险 | 最坏 O(n²) |
因此,小数据集、低修改频率的场景,ziplist性能更高效。
注:Redis 7.0 开始 ziplist 被 listpack 替代(解决连锁更新)
2.2.3 quicklist - 快速列表
quicklist 设计初衷
linkedlist 问题:内存开销大,缓存不友好
ziplist 问题:插入删除慢,连锁更新可能
quicklist 方案: 链表 + 压缩列表的混合结构,即把多个元素打包成一个压缩块(ziplist),再用双向链表串联所有块,每个链表节点是一个 ziplist。
注:quicklist自Redis 3.2 沿用至今,6.0/7.0 版本仅内部包裹的压缩结构不同。
版本差异:
Redis 6.0:链表节点内部包裹 ziplist
Redis 7.0:链表节点内部包裹 listpack
整体架构完全不变,仅内部压缩单元替换。
quicklist 结构
// quicklist
typedef struct quicklist {
quicklistNode *head;
quicklistNode *tail;
unsigned long count; // 所有ziplist中元素的总数
unsigned long len; // quicklistNode的个数
//...其他字段省略
} quicklist;
// quicklist 节点
typedef struct quicklistNode {
struct quicklistNode *prev;
struct quicklistNode *next;
unsigned char *zl; // 指向 ziplist 的指针
unsigned int sz; // ziplist 字节大小
unsigned int count : 16; // ziplist 元素个数
//...其他字段省略
} quicklistNode;
示意图如下:

增删查逻辑(结合结构理解)
- 头部 / 尾部增删:直接操作 quicklist 的 head/tail 对应的压缩块,O (1) 级别,效率极高。
- 中间位置读写:需要遍历链表找到对应块,再遍历块内元素,效率下降。
- 块分裂:单个压缩块元素达到 fill 阈值时,自动拆分出新的 quicklistNode。避免了单个ziplist过大导致的插入/删除性能问题。
- 块合并:删除元素后,相邻块元素数极少时,会触发合并,减少内存碎片。
2.2.4 listpack - 紧凑列表
listpack目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,解决压缩列表的连锁更新问题。
整体内存结构

各字段说明:
- total-bytes(4 字节):整个 listpack 占用总字节数
- num-entries(2 字节):元素总个数,O (1) 获取长度
- entry:数据项(核心单元)
- end-bytes(1 字节):结束标记 0xFF,固定占位
entry结构
![]()
各字段说明:
- encoding:标识数据类型(整数 / 字符串)+ 数据长度,解析 data
- data:真实存储的数据内容
- entry-length:当前整个 entry 的总字节长度,即encoding+data的总长度
listpack 没有压缩列表中记录前一个节点长度的字段,只记录当前节点的长度,向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。
2.3 dict /hashtable
dict 是 Redis 核心中的核心:Redis 整个数据库、Hash、Set 类型底层都依赖它。
dict 本质是哈希表,作用:
- 存储 Redis 全局所有 Key-Value(整个数据库就是一个大 dict)
- Hash 数据类型:元素数量 / 大小超阈值后,由 listpack/ziplist 转为 dict/hashtable
- Set 数据类型:整数集合不满足时,底层切换为 dict/hashtable
设计目标:查询、增删 O (1),兼顾读写性能与内存利用率。
完整结构体
Redis 字典由三层结构组成:dict(总管理器)、dictht(哈希表)、dictEntry(哈希节点)。
1. 节点实体 dictEntry
typedef struct dictEntry {
// 键
void *key;
// 值,联合体,可存指针/整数/长整型
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 哈希冲突:单向链表,指向下一个节点
struct dictEntry *next;
} dictEntry;
- key:字典的键
- 联合体 v:灵活存储不同类型值,节省内存
- next:链地址法解决哈希冲突,同一桶位的节点串成单向链表
2. 哈希表 dictht
一个字典会维护两张哈希表 ht[0]、ht[1],用于 rehash:
typedef struct dictht {
// 哈希表数组,每个元素指向一条链表
dictEntry **table;
// 桶的总数量(数组长度)
unsigned long size;
// size 掩码,计算索引:hash(key) & sizemask
unsigned long sizemask;
// 当前已存储节点数量
unsigned long used;
} dictht;
- table:一维数组,俗称桶数组
- sizemask = size - 1,用来快速计算 key 所在桶下标
- used:已使用节点数,用于判断负载因子
3. 字典总结构 dict
typedef struct dict {
// 哈希表数组,默认只用 ht[0],rehash 时启用 ht[1]
dictht ht[2];
// rehash 进度标记,-1 表示不在 rehash
long rehashidx;
// 迭代器数量,安全迭代时使用
unsigned long iterators;
} dict;
结构示意图
dict
├─ ht[0] (主哈希表)
│ ├─ table[0] → entry1 → entry2 → null
│ ├─ table[1] → null
│ └─ table[2] → entry3 → null
├─ ht[1] (备用表,仅rehash时使用)
└─ rehashidx = -1 (无rehash)
渐进式 Rehash
这是 Redis 的核心优化之一,避免一次性 rehash 导致服务停顿。
1. 触发条件
在正常服务请求阶段,插入的数据,都会写入到哈希表 0,此时的哈希表 1 并没有被分配空间。
随着数据逐步增多(根据负载因子等进行判断),触发 rehash 操作,详细的触发情况如下:
load_factor = ht[0].used / ht[0].size
// 扩容条件
if (load_factor >= 1 && 未在执行BGSAVE/BGREWRITEAOF) {
rehash(); // 扩展为第一个 >= used * 2 的 2^n
}
if (load_factor >= 5) {
rehash(); // 强制扩容
}
// 缩容条件
if (load_factor < 0.1) {
rehash(); // 缩小为第一个 >= used 的 2^n
}
2. Rehash 流程
初始状态:
ht[0]: size=4, used=4 (负载因子 1.0)
ht[1]: 空
rehashidx = -1步骤 1:开始 rehash
ht[0]: size=4, used=4
ht[1]: size=8, used=0 ← 分配新空间
rehashidx = 0 ← 开始迁移步骤 2:每次操作时,顺带迁移一个桶
迁移 ht[0].table[0] → ht[1]
rehashidx = 1步骤 3:继续迁移...
rehashidx = 2, 3, 4...步骤 N:迁移完成
ht[0] → 释放
ht[1] → 成为新的 ht[0]
rehashidx = -1
3. Rehash 期间的操作
// 查找:先查 ht[0],再查 ht[1]
dictEntry *dictFind(dict *d, const void *key) {
if (d->rehashidx != -1) {
entry = findInHashTable(&d->ht[0], key);
if (entry) return entry;
return findInHashTable(&d->ht[1], key);
}
return findInHashTable(&d->ht[0], key);
}
// 插入:新节点只插入 ht[1]
// 修改/删除:同时在 ht[0] 和 ht[1] 中修改/删除
dict/hashtable 优缺点
优点
- 读写查询平均 O (1),性能极强
- 自动扩容缩容,自适应数据量
- 渐进式 rehash,避免阻塞服务
缺点
- 哈希冲突严重时,链表变长,性能退化
- rehash 阶段临时占用双倍内存
2.4 intset - 整数集合
当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。
结构定义
typedef struct intset {
uint32_t encoding; // 编码类型:决定每个元素占几字节
uint32_t length; // 元素数量
int8_t contents[]; // 连续数组:内存完全连续、有序、无重复(实际类型取决于encoding)
} intset;
// 编码类型
// -32768 ~ 32767, 每个元素占 2 字节(范围小整数)
#define INTSET_ENC_INT16 (sizeof(int16_t))
// -2^31 ~ 2^31-1, 每个元素占 4 字节
#define INTSET_ENC_INT32 (sizeof(int32_t))
// -2^63 ~ 2^63-1, 每个元素占 8 字节(大整数)
#define INTSET_ENC_INT64 (sizeof(int64_t))
编码升级
当插入的元素超出当前编码范围时,IntSet 会自动升级:
存储小整数 [1, 5, 10, 100]:
┌──────────────────────────────┐
│ encoding = INT16 │
│ length = 4 │
│ contents = [1, 5, 10, 100] │
│ (每个元素 2 字节) │
└──────────────────────────────┘插入 65535 后升级:
┌──────────────────────────────────┐
│ encoding = INT32 │
│ length = 5 │
│ contents = [1, 5, 10, 100, 65535] │
│ (每个元素 4 字节) │
└──────────────────────────────────┘
升级流程
- 1. 根据新编码分配空间
- 2. 从后往前迁移元素(避免覆盖)
- 3. 插入新元素(必定在头部或尾部)
- 4. 更新 encoding 和 length
intset 优缺点
优点
- 内存连续、占用极小,CPU 缓存友好
- 元素有序,二分查找效率高
- 天然去重,适配 Set 特性
缺点
- 仅支持整数,类型单一
- 中间插入 / 删除需要移动元素,数据量大后性能变差
- 编码只升不降,可能存在内存轻微浪费
- 元素超量 / 出现非整数,强制转为 hashtable
2.5 skiplist - 跳表
跳表是 Redis ZSet(有序集合)的核心底层结构之一。ZSet 同时使用跳表 + 哈希表:跳表负责维护元素的有序性(支持范围查询、排名操作),哈希表负责 O(1) 快速查找单个元素的分值。两者通过指针共享同一个元素对象,不重复存储数据。
如何理解跳表?在了解跳表之前,我们先从普通链表开始讲起。
首先,对于普通单链表,即使链表是有序的,我们要查找某个元素,也需要从头到尾遍历整个链表。

如果你想寻找这个链表中有没有节点8,那么你就要从节点1开始,依次向后遍历,直到遍历到一个数值等于8的节点,共需要8步。
那么如何能让这个遍历的次数减少呢?
我们可以考虑在原链表之上建立索引层:每个节点可以出现在多个层级中,高层级的节点作为下一层级的“索引”,通过额外的指针跳过中间节点,从而加速查找,如下图:

在查询的时候,我们可以从索引层去查询,先找到节点1,然后到节点4,然后到节点7,节点7之后没有节点了,那么就去下一层去寻找,下一层的节点7之后就是节点8,这次我们只需要4次就找到了答案。
那如果我们再多建一级索引呢?如下图所示:在顶层索引中,从 1 开始,下一个是 7,7 小于 8,继续往后;发现 7 后面没有索引节点了,就下沉到下一层,从 7 的底层指针找到 8,共 3 次比较。虽然加了两级索引优化效果不是很明显,这是因为我们的数据量太少。在数据量大时,优化效果比较显著。

在有序链表的基础上增加多级索引,这就是跳表。传统链表的查找时间复杂度为 O(N),而跳表通过建立多层索引链表,优化了查找性能,平均查找时间复杂度为 O(log N)。
跳表结构示意图(简化版)
第3层索引: head -------------------------------------------------> 9
第2层索引: head -------------> 3 -------------------------------> 9
第1层索引: head -> 1 -------> 3 -------> 5 -------> 7 -------> 9
原始链表: head -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9
- 最底层:完整有序链表,保存所有真实数据
- 上层:逐层精简的索引层,每层节点是下层节点的 “抽样”
- 查找逻辑:从最高层开始,能跳就跳,不能跳就下沉到下一层,直到底层
跳表不是固定间隔建索引,而是通过随机算法决定每个节点的层数(例如 Redis 中最大 32 层,每升一层的概率为 0.25)。这种随机化保证了平均性能,同时简化了插入/删除操作。
skiplist 结构定义
// 跳表节点
typedef struct zskiplistNode {
// 成员数据(SDS 字符串)
sds ele;
// 分值(排序依据,浮点型)
double score;
// 后退指针(仅底层的原始链表有,用于反向遍历)
struct zskiplistNode *backward;
// 多层前进指针,每层对应一个指针
struct zskiplistLevel {
// 下一个节点
struct zskiplistNode *forward;
// 当前层两节点之间的元素数量(跨度)
unsigned long span;
} level[];
} zskiplistNode;
// 跳表总管理结构
typedef struct zskiplist {
// 头节点、尾节点
struct zskiplistNode *header, *tail;
// 节点总数(不含头节点)
unsigned long length;
// 当前跳表最大层数
int level;
} zskiplist;
关键字段解读
- ele:ZSet 的成员元素;score:排序用的分值。
- backward:后退指针,只有底层链表有,支持从尾向前遍历。
- level[]:柔性数组,代表多层索引;每个层级包含 forward(后继指针)和 span(跨度,记录当前节点到下一节点之间有多少个真实元素,用于排名计算)。
ZSet(有序集合)底层为什么选择跳表而不选红黑树?
跳表相比红黑树有如下优点:
- 内存开销可控:跳表多层索引有少量额外开销,但 Redis 随机层数策略让整体内存占用稳定;红黑树每个节点需维护多个颜色、父子、兄弟指针,开销并不更低
- 实现简单:跳表代码逻辑远比重平衡二叉树(红黑树)简单,维护成本低,出错概率小。
- 范围查询优势:ZSet 高频场景是区间遍历、排行榜。跳表天然是链表结构,定位起点后可直接顺序遍历;红黑树是树形结构,范围遍历需要中序遍历,实现更复杂、性能更弱。
- 并发友好:跳表修改节点时,仅需修改相邻节点指针;红黑树增删会触发多次旋转,锁竞争 / 逻辑复杂度更高。
2.6 五种数据类型和底层数据结构的关系
前面我们提到过,Redis 的键值对中的 key 就是字符串对象,它的value可以是不同的数据类型。Redis通过 redisObject 统一管理所有value,redisObject包含三个关键字段:
- type:数据类型(String/List/Hash/Set/ZSet)
- encoding:底层数据结构编码
- ptr:指向实际数据结构的指针(SDS/ziplist/intset/quicklist/hashtable/skiplist)
这种设计使Redis能根据数据特点动态选择最优编码方式。Redis 会根据 value 的数据类型和数据大小对底层数据结构进行自动转换。如下表(以下转换基于Redis默认配置,实际阈值可通过配置文件调整)
| 数据类型 | 数据量小 | 数据量大 | 转换条件 |
| STRING | int / SDS | SDS | 整数执行字符串操作时转为SDS |
| LIST | quicklist | quicklist | 无转换(始终quicklist) |
| HASH | ziplist | hashtable | entries>512 或 任一field/value>64字节 |
| SET | intset | hashtable | 出现非整数 或 entries>512 |
|
ZSET |
ziplist | skiplist+hashtable | entries>128 或 任一member>64字节 |
注:
一. 实际上 String 有三种编码:
- 1. int:value 是可以用 long 表示的整数
- 2. embstr:短字符串(≤44字节),redisObject和SDS连续分配
- 3. raw:长字符串(>44字节),redisObject和SDS分离分配
但本质上embstr和raw都是 SDS,它们俩的区别只是内存分配方式不同。
二. 以上内容基于Redis 6.0。Redis 7.0 开始有重要变化:
- 用 listpack 替换 ziplist
- quicklist 节点内部由 ziplist 改成 listpack
- 避免了 ziplist 的连锁更新问题
上述转换的核心理念是:在内存效率和访问性能之间取得平衡。当数据量小或元素较小时,使用内存紧凑的编码(如 ziplist, intset);当数据规模增长后,自动转为读写性能更稳定但内存占用稍高的编码(如 hashtable, skiplist)。这也是Redis为什么快的重要原因之一——高效的数据结构。
2.7 与底层数据结构相关的问题理解
2.7.1 SDS简单动态字符串相关问题
| 问题描述 | 分析 |
| 为什么len命令是O(1)? | SDS 结构体内部维护了 len 字段(当前有效字符长度),读取长度直接取该字段,无需遍历字符数组,时间复杂度 O(1)。 |
| 为什么追加字符串不一定导致内存重分配? | 1. 追加后总长度 < 1MB 新容量 = 实际长度 × 2(双倍预分配) 一次扩容,后续多次小追加都不用再分配。 2. 追加后总长度 ≥ 1MB 新容量 = 实际长度 + 1MB(多预留 1MB 空闲,不再翻倍) 3. 字符串缩短时,不会立即回收空闲内存,避免频繁分配释放。 |
2.7.2 ziplist 相关问题
| 问题描述 | 分析 |
| 为什么小Hash节省内存? | 数据量小、内容短时,Redis Hash 底层使用 ziplist 压缩列表。 ziplist 采用连续内存 + 紧凑编码,无指针、哈希桶等额外冗余开销,内存利用率极高。 |
| Hash 什么时候会从节省内存模式转为普通模式? |
默认配置的情况下,如果哈希内 field 数量 > 512或者任意一个 field /value 的 字节长度 > 64就会由ziplist转换为hashtable |
2.7.3 skiplist 相关问题
skiplist是ZSet的底层数据结构之一。
| 问题描述 | 分析 |
| ZRANK 为什么快?—— 查排名 | 操作本质:给定一个成员,查找它在有序集合中的排名(分值排第几)。 底层支持:Redis 的跳表在实现时,每个节点除了存元素和分值,还会维护一个 span 属性,表示当前节点到下一个节点跨越了多少个元素。 快速计算:在查找某个成员的过程中,Redis 会不断累加沿途经过的 span 值。等找到成员时,累加结果就是它的排名。 |
| ZRANGE 为什么快?—— 按排名范围取数据 | 操作本质:给定起始排名和结束排名(例如第 0 名到第 9 名),取出对应成员。 两步走: 先用 O(log N) 找到起始排名的节点(利用跳表的索引快速定位)。 然后沿着底层链表向后遍历,取出后续节点,直到达到结束排名。遍历过程是 O(M),M 是要返回的元素个数。 ZRANGE 的时间复杂度是 O(log N + M)。相比遍历整个集合,效率极高。 |
2.7.4 intset 相关问题
| 问题描述 | 分析 |
| 为什么纯整数Set内存占用极低? | 底层是 intset 整数集合,连续内存、自适应整型编码、无指针和哈希冗余,内存占用极低。 |
| 为什么SADD一个字符串后,内存突然变大? | intset 仅支持纯整数,插入字符串会不可逆转为 dict 哈希表;dict 存在大量节点、指针、哈希桶等冗余结构,内存开销大幅上升。 |
2.7.5 其他问题
| 问题描述 | 分析 |
| ZSet 为什么天然有序 |
核心:底层主力是跳表 (SkipList) |
| Set 为什么自动去重 |
Set底层无论使用intset还是dict/hashtable,其设计核心都是存储唯一元素。intset通过有序数组+二分查找保证唯一,dict/hashtable则利用键的全局唯一性自然实现去重。 |
3. 单线程 + I/O多路复用
Redis 核心网络模型是单线程处理命令 + I/O 多路复用监听套接字,并非全程只有一个线程,只是核心读写、命令执行串行化。
3.1 Redis 的「单线程」到底指什么?
Redis 主线程只做这几件事,并且是串行执行:
- 监听客户端连接、读写网络数据
- 解析客户端命令(GET/SET 等)
- 执行数据读写、内存操作
- 返回结果给客户端
这部分全程单线程,不并发。
注:Redis 不是纯单线程。
Redis 有多线程 / 后台线程,只是不处理核心命令:
- 后台线程:持久化(RDB/AOF)、过期键删除、大内存异步释放、文件刷盘
- 新版本 (6.0+) I/O 多线程:仅处理网络读写、协议解析,命令执行、数据操作依旧单线程
目的:缓解大网络 I/O 瓶颈,不改变核心串行特性。
为什么核心要用单线程?
- 避免线程竞争、锁开销:内存数据结构不用加锁,不存在多线程并发修改冲突,性能损耗极低。
- CPU 不是瓶颈:Redis 是内存数据库,99% 耗时在网络 I/O,而非计算。单线程完全吃不满 CPU。
- 实现简单、无线程切换开销:上下文切换、线程调度都会拖慢速度,单线程规避这些问题。
单线程的短板:单个慢命令会阻塞整个主线程(如 KEYS、FLUSHALL、超大集合遍历),生产严禁使用。
3.2 I/O 多路复用:单线程如何处理上万客户端?
I/O 多路复用(I/O Multiplexing)是一种单线程监控多个文件描述符(FD) 的技术,当其中任何一个 FD 准备就绪(可读/可写/异常)时,系统通知应用程序进行相应操作。在 Linux 下,客户端连接、服务端监听端口、文件、网络套接字,都抽象成 FD(数字编号)。Redis 主线程把所有客户端 FD 交给多路复用器监听。
问题场景
假设 Redis 要同时处理多个客户端连接:
传统方式(多进程/多线程):
客户端1 → 线程1(阻塞等待)
客户端2 → 线程2(阻塞等待)
客户端3 → 线程3(阻塞等待)
- 每个连接需要一个线程/进程
- 线程切换开销大
- 连接数越多,性能越差
I/O 多路复用方式:
一个线程同时监控多个连接:
┌──→ 客户端1(有数据?)
│
主线程 ──┼──→ 客户端2(有数据?)
││──→ ...(有数据?)
│
└──→ 客户端n(有数据?)
- 一个线程监控多个连接
- 哪个连接有数据,就处理哪个
- 没有数据时,线程可以休眠(不占 CPU)
生活类比
| 方式 | 类比 | 问题 |
| 多线程 | 餐厅每个客人配一个服务员 | 服务员太多,成本高 |
| I/O多路复用 | 一个服务员同时服务多个客人 | 客人喊一声,服务员过去处理 |
Linux 主流多路复用模型(Redis 选用 epoll)
从旧到新演进:
- select:FD 数量有限,遍历所有 FD,效率低
- poll:解除数量限制,依旧全量遍历
- epoll(Redis 默认):事件驱动,只返回就绪的 FD,不用遍历全部;高并发下性能碾压前两者,是 Linux 高并发网络标配
epoll 的工作流程:
// 1. 创建一个 epoll 实例
int epfd = epoll_create(1024);
// 2. 将多个客户端 socket 加入监控列表
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd1, &ev);
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd2, &ev);
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd3, &ev);
// 3. 循环:等待任意一个 socket 有数据
while (1) {
int nfds = epoll_wait(epfd, events, 1024, -1); // 阻塞等待
for (int i = 0; i < nfds; i++) {
read(events[i].data.fd, buffer); // 读取数据
process_command(buffer); // 执行 Redis 命令
}
}
Redis 单线程 + I/O 多路复用完整流程
详细步骤
1. 启动时:
Redis 创建 epoll 实例,将监听 socket 加入监控2. 客户端连接:
客户端1 → 监听 socket 可读 → epoll 通知 → Redis 调用 accept() 接受连接
→ 将客户端1 的 socket fd 加入 epoll 监控3. 客户端发送命令:
客户端1 发送 "GET key" → socket 可读 → epoll 通知
→ Redis 读取数据 → 解析命令 → 执行命令 → 写回结果4. 没有事件时:
epoll_wait() 阻塞 → Redis 休眠 → CPU 空闲
为什么这套组合速度极快?
| 原因 | 说明 |
| 内存操作 | 数据都在内存,没有磁盘 I/O 瓶颈 |
| I/O 多路复用 | 单线程高效管理成千上万连接 |
| 避免上下文切换 | 没有线程竞争、锁等待、切换开销 |
| 数据结构优化 | 哈希表、跳表、压缩列表等高效结构 |
| 非阻塞 I/O | 读取数据时,如果数据还没准备好,立刻返回(不等待),去处理下一个有数据的连接 |
单线程的瓶颈
任何慢命令(如KEYS *、HGETALL大Hash、ZUNIONSTORE)、大网络 I/O、CPU 密集操作都会阻塞之后所有命令。生产禁止使用阻塞命令。
3.3 与单线程+I/O多路复用相关的问题
| 问题描述 | 分析 |
| 为什么单线程能处理10万+QPS? | 核心机制 I/O 多路复用,Linux 使用 epoll,单线程监听海量连接,有请求才处理,规避多线程上下文切换、锁竞争开销。 |
| Redis 为什么快 | 数据全存内存、核心命令复杂度低、单线程无额外线程开销。 |
| Redis的性能瓶颈 | 网络 I/O/ 内存。Redis 命令处理逻辑简单、都是内存操作,CPU 很难打满;瓶颈普遍出在网络吞吐、带宽、客户端连接数、内存大小 |
| 慢查询危害 | 主线程串行执行命令,一条慢命令会阻塞所有后续请求,拖垮整体服务。 |
| Redis 6.0 多线程 |
该多线程指的是I/O多线程,只处理网络读写、协议解析,命令执行、数据操作依旧单线程(Redis 6.0+ 引入的 I/O 多线程默认关闭,需在配置文件中设置) |
4. 数据持久化:如何保证重启不丢数据?
Redis 数据默认全放内存,断电 / 重启数据丢失,持久化就是把内存数据落地到磁盘,分三大方案:RDB、AOF、混合持久化,核心底层依赖 fork + 写时复制 (COW)。
4.1 前置基础:fork 与 写时复制 Copy-On-Write
1. fork 子进程
Redis使用Linux系统的 fork() 系统调用创建子进程:
fork 创建子进程后,并不会立刻拷贝物理内存,仅复制页表,父子进程共享内存页
目的:让子进程负责把数据刷磁盘,父进程继续正常处理客户端命令,不阻塞主进程(父进程)业务。
2. 写时复制 COW(Copy-On-Write,Linux 内核机制)
fork 之后并不会立刻复制整份内存(内存太大时瞬间复制会卡死、耗资源),而是采用页只读共享:
1. 父子进程共用同一块物理内存,所有内存页标记为「只读」。
2. 父进程收到新写命令(SET/DEL 等):
- 内核检测到「只读内存被修改」
- 触发 COW:把被修改的这一页内存单独复制一份给父进程
- 父进程在新副本上修改,子进程继续读原来的旧内存
3. 子进程全程只读取 fork 那一刻的内存快照,不受后续写操作影响。
一句话总结:fork 出子进程 → 内存共享只读 → 有写操作才复制对应内存页 → 子进程安心落地快照。
4.2 RDB快照(Redis Database)
RDB是全量二进制快照,把某个时刻的Redis内存数据全量保存到磁盘。
内存数据: RDB 文件:
┌─────────┐ ┌─────────────┐
│ name:tom │ ──→ │ 二进制数据 │
│ age:25 │ │ (经过压缩) │
│ ... │ └─────────────┘
└─────────┘
怎么触发?
| 方式 | 命令 | 特点 |
| 手动 | SAVE | 阻塞主进程,不推荐 |
| 手动 | BGSAVE | fork 子进程,不阻塞主进程,推荐 |
| 自动 | save 900 1 | 900秒内至少1次修改则触发 |
注:其他隐式触发:1. Redis 正常关机 shutdown;2. 主从复制:从节点初次全量同步,主节点自动生成 RDB。
完整执行流程(BGSAVE)
- 父进程收到 BGSAVE 指令,调用 fork() 创建子进程(COW 机制生效)。
- 子进程遍历内存,把fork 瞬间的全量数据写入临时 RDB 文件。
- 写入完成,临时文件改名 dump.rdb,覆盖旧文件。
- 子进程退出,父进程继续对外服务。
特点总结
✅ 优点
二进制压缩文件,体积小,适合备份、迁移。
恢复速度极快(直接加载二进制)。
子进程后台执行,几乎不影响主线程性能。
❌ 缺点
会丢数据:两次 RDB 之间的新数据,断电全部丢失。
例:5 分钟生成一次快照,最多丢失 5 分钟数据。
频繁 fork 会有短暂卡顿;数据量极大时,COW 复制内存页会消耗磁盘 / 内存。
4.3 AOF日志(Append Only File)
AOF是增量文本日志:不存数据本身,记录每一条客户端写命令,追加到 appendonly.aof 文件末尾。重启 Redis 时,重新从头到尾回放所有命令,恢复数据。
AOF的三种刷盘策略
| 配置 | 含义 | 安全性 | 性能 |
| appendfsync always | 每次写命令都刷盘 | 最高(最多丢 1 条数据) | 最差(频繁 IO,高并发下性能差) |
| appendfsync everysec | 每秒刷一次 | 高(最多丢1秒数据) | 好(性能 & 安全性折中,生产最常用) |
| appendfsync no | 交给操作系统自行刷盘 | 低(宕机可能丢失大量数据) | 最好 |
AOF 重写
问题:长期运行 AOF 文件会无限膨胀(一条 key 反复 SET,会存几十上百条重复命令)。
AOF 重写:生成一份「等价、精简」的新 AOF 文件,替换旧文件。
AOF 文件会越来越大,比如:
同一个 key 执行了 100 次 INCR:
INCR counter # 1
INCR counter # 2
...
INCR counter # 100
重写后变成一条:
SET counter 100
重写流程(依旧 fork + COW)
- 父进程 fork() 子进程。
- 子进程遍历当前内存数据,直接根据现有数据生成最简写命令(不是复制旧日志),写入新 AOF 临时文件。
- 重写期间,父进程新命令正常写入旧 AOF + 重写缓冲区。
- 子进程写完新文件后,把「重写缓冲区」里的增量命令追加到新文件。
- 原子替换新旧 AOF 文件,重写结束。
特点总结
✅ 优点
数据安全性高,配合 everysec 最多丢 1 秒数据。
文本格式,可打开查看、手动修复。
❌ 缺点
日志文件体积远大于 RDB。
数据恢复慢(需要逐条回放命令)。
持续磁盘 I/O,性能略低于 RDB。
4.4 混合持久化(Redis 4.0+)
为什么需要混合?
单独用 RDB:丢数据;单独用 AOF:文件大、恢复慢。
混合持久化 = 结合两者优点。
流程和原理
开启配置:
aof-use-rdb-preamble yes # 开启混合持久化(Redis4.0+ 默认开启)
1. 执行 AOF 重写 时:
- 先把当前全量内存数据以 RDB 二进制格式写入新 AOF 文件头部。
- 再把重写期间增量的 AOF 命令追加在后面。
2. 最终 AOF 文件结构:
【RDB 二进制全量数据】 + 【后续增量 AOF 命令日志】

重启时的恢复流程
服务器重启
│
↓
读取 AOF 文件
│
├── 先加载 RDB 部分(快速恢复全量数据)
│
└── 再重放 AOF 部分的增量命令(补上后续修改)
│
↓
恢复完成
优缺点
✅ 优点
兼具 RDB 恢复快、文件小 + AOF 数据安全。
目前生产环境标准最优选择。
❌ 缺点
文件前半段是二进制,后半段是文本,无法手动完整编辑。
4.5 三种持久化方式汇总对比
| 特性 | RDB | AOF | 混合持久化 (4.0+) |
|---|---|---|---|
| 存储形式 | 二进制快照 (全量) | 文本命令 (增量) | RDB 头 + AOF 增量 |
| 数据丢失风险 | 丢一段区间数据 | 最多丢 1 秒 (默认) | 几乎不丢 |
| 文件体积 | 小 | 大 | 中等偏小 |
| 恢复速度 | 极快 | 慢 | 快 |
| 磁盘 I/O 压力 | 低(低频) | 高(持续写入) | 中等 |
| 适用场景 | 冷备、迁移、容忍丢数 | 高数据安全场景 | 生产首选 |
4.6 数据持久化相关的问题
| 问题描述 | 分析 |
| 为什么BGSAVE和BGREWRITEAOF都能做到非阻塞? | BGSAVE 和 BGREWRITEAOF 都靠 fork 子进程 + 写时复制(COW)把 “繁重的磁盘 I/O” 放到后台,主线程只短暂阻塞在 fork 那一瞬间,之后全程非阻塞、继续处理命令。 |
| 为什么在RDB期间,Redis内存占用可能突然变高(几乎是原来的两倍)? |
如果写操作非常频繁,涉及大量不同的内存页,操作系统会不断拷贝被修改的页。极端情况下,几乎所有数据页都被修改过一次,那么内存占用就接近原内存 × 2(一份给子进程做快照,一份给主进程写新数据)。 |
| 为什么需要合理设置maxmemory,给fork()留出内存空间。 |
maxmemory 不应该设置为物理内存的 100%,必须预留内存给操作系统和 fork()。 |
更多推荐


所有评论(0)