当你执行一条 SET key value,Redis 如何在微秒级完成存储?当你用 ZRANK 查询排行榜,O(log n) 的跳表如何工作?RDB 和 AOF 两种持久化各自怎么把内存数据落到磁盘?Redis 单线程为何能撑起 10 万+ QPS?
Redis 将一切数据放在内存中,用精心设计的数据结构和单线程事件驱动模型换取极致性能。与基于磁盘的 B 树/LSM 树引擎不同,它没有 Buffer Pool 和 WAL 的包袱,所有读写直接发生在内存里。掌握 Redis 的内部实现,才能在缓存穿透、bigkey、主从延迟等真实问题面前做出正确决策。
前置知识
了解 B 树/LSM 树等磁盘存储引擎的基本模型,便于和 Redis 的内存模型对比
基本的网络编程概念:事件驱动、Reactor 模式、epoll
一、Redis 数据结构全景
1.1 基本类型与底层编码
Redis 提供 5 种基本数据类型(外加 Stream 共 6 种),每种类型在底层可能对应多种编码实现。Redis 会根据数据规模和特征自动选择最优编码:
1.2 类型与编码对应关系
| 类型 | 编码常量 | 触发条件 | 优势 |
|---|---|---|---|
| String | OBJ_ENCODING_INT | 值为长整型(≤LONG_MAX) | 零额外内存,直接存在 ptr 指针位置 |
| String | OBJ_ENCODING_EMBSTR | 字符串长度 ≤ 44 字节 | 一次分配,redisObject 与 SDS 连续内存 |
| String | OBJ_ENCODING_RAW | 字符串长度 > 44 字节 | 独立 SDS,支持动态扩展 |
| List | OBJ_ENCODING_LISTPACK | 元素数 ≤ 128 且每个 ≤ 64 字节 | 紧凑存储,省内存 |
| List | OBJ_ENCODING_QUICKLIST | 超出 listpack 阈值 | 分段压缩,兼顾内存与性能 |
| Hash | OBJ_ENCODING_LISTPACK | field 数 ≤ 128 且每个值 ≤ 64 字节 | 顺序存储,缓存友好 |
| Hash | OBJ_ENCODING_HT | 超出 listpack 阈值 | O(1) 查找 |
| Set | OBJ_ENCODING_INTSET | 元素全为整数且数量 ≤ 512 | 有序紧凑,省内存 |
| Set | OBJ_ENCODING_HT | 超出 intset 阈值 | O(1) 查找 |
| Sorted Set | OBJ_ENCODING_LISTPACK | 元素数 ≤ 128 且每个 ≤ 64 字节 | 紧凑存储 |
| Sorted Set | OBJ_ENCODING_SKIPLIST | 超出 listpack 阈值 | O(log n) 范围查询 |
| Stream | OBJ_ENCODING_STREAM | 始终 | radix tree + listpack,按 ID 前缀组织消息流 |
Redis 7.0 用 listpack 替换了 ziplist(压缩列表)。旧版 ziplist 存在”连锁更新”问题,一个节点的扩容可能引发后续所有节点的级联重分配。listpack 通过将节点长度从”前驱记录”改为”自身记录”消除了这一问题。下文仍以 ziplist 讲解原理,因为理解连锁更新是认识 listpack 改进的前提。
表中”128 元素 / 64 字节”是 ziplist 时代的经典默认阈值(如 hash-max-ziplist-entries=128、hash-max-ziplist-value=64)。Redis 7 改用 listpack 后,阈值改由 list-max-listpack-size、hash-max-listpack-entries、hash-max-listpack-value 等参数控制。以 List 为例,list-max-listpack-size 默认 -2,即按每个 listpack 节点约 8 KB 的字节数上限切分,没有”128 个元素”的硬上限,200 个短元素仍可能装进同一个 listpack。具体阈值以 CONFIG GET 实际输出为准。
Stream 是 Redis 5.0 引入的第 6 种数据类型,不在传统五种基本类型内,它的 radix tree 编码与消费者组机制见2.7 节。
二、底层数据结构
2.1 SDS:简单动态字符串
Redis 没有使用 C 语言原生字符串,而是自己实现了 SDS(Simple Dynamic String)。为什么?
// C 原生字符串char *str = "hello"; // 长度需 O(n) 遍历,不防溢出
// SDS 结构(Redis 5.0 sdshdr16 为例)struct __attribute__((__packed__)) sdshdr16 { uint16_t len; // 已使用长度:O(1) 获取 uint16_t alloc; // 总分配容量(不含 header 和 \0) unsigned char flags; // 类型标识:sdshdr5/8/16/32/64 char buf[]; // 实际数据,以 \0 结尾};SDS 相对 C 字符串有三大关键改进:
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) 读 len 字段 |
| 缓冲区溢出 | strcat 可能越界写 | sdscat 检查容量,自动扩容 |
| 二进制安全 | 遇 \0 截断 | 以 len 判断结束,可存任意二进制数据 |
空间预分配策略:SDS 扩容时不仅分配所需空间,还额外预分配:
// SDS 扩容逻辑(简化)sds sdsMakeRoomFor(sds s, size_t addlen) { size_t len = sdslen(s); size_t newlen = len + addlen;
if (newlen < SDS_MAX_PREALLOC) // 1MB newlen *= 2; // 翻倍分配 else newlen += SDS_MAX_PREALLOC; // 线性增长 1MB
// 分配新空间...}这种策略使得 N 次追加操作的均摊复杂度为 O(1),避免了频繁 realloc。
2.2 压缩列表(ziplist)
压缩列表是为节约内存而设计的顺序数据结构,将所有元素紧凑地排列在一块连续内存中:
压缩列表的内存布局从头部到尾部依次排列:
zlbytes(4 字节):整个 ziplist 占用的字节数zltail(4 字节):尾节点距离起始地址的偏移量,用于从尾部反向遍历zllen(2 字节):节点数量entry1 ... entryN:变长的节点列表zlend(1 字节):固定0xFF,标记结束
每个 entry 内部又由三部分组成:prevlen(1 或 5 字节,记录前一个节点的长度)、encoding(变长,记录数据和编码方式)、data(实际数据)。
每个 entry 的 prevlen 字段记录前一个节点的长度,用于从尾向头遍历。但正是这个设计引发了连锁更新问题:
当 entry1 从 250 字节扩展到 254+ 字节时,entry2 的 prevlen 需要从 1 字节扩展到 5 字节,导致 entry2 自身也超过 254 字节,进而引发 entry3 的 prevlen 扩展……最坏情况下 N 个节点全部级联重分配,复杂度退化为 O(N²)。
连锁更新是概率事件,需要连续多个 entry 的长度恰好在 250~253 字节之间。实际生产中极少触发,但理解这一机制才能明白 Redis 7.0 为什么用 listpack 替代 ziplist。listpack 将 prevlen 改为记录自身长度,彻底消除了级联依赖。
2.3 跳表(skiplist)
跳表是 Sorted Set 的核心数据结构,以概率平衡替代严格的树平衡,实现 O(log n) 的查找、插入和删除:
查找过程(以查找 score=5 为例):从最高层出发,向右比较,若目标大于当前节点则右移,否则下降一层。最终在 Level 0 找到目标。
// Redis 跳表节点typedef struct zskiplistNode { sds ele; // 成员对象 double score; // 分值 struct zskiplistNode *backward; // 后退指针(Level 0) struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度(用于排名计算) } level[]; // 层(柔性数组)} zskiplistNode;
// 跳表typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; // 节点数量 int level; // 最大层数} zskiplist;为什么 Redis 选跳表而不是红黑树?
| 对比维度 | 跳表 | 红黑树 |
|---|---|---|
| 实现复杂度 | 简单,约 200 行代码 | 复杂,旋转/着色逻辑多 |
| 范围查询 | 天然支持,沿 Level 0 遍历 | 需中序遍历,不直观 |
| 内存开销 | 每节点平均 1.33 个指针(晋升概率 1/4) | 每节点固定 3 个指针 |
| 并发友好 | 局部修改,锁粒度小 | 旋转涉及多节点,锁粒度大 |
| 排名计算 | span 字段直接支持 ZRANK | 需额外维护子树大小 |
2.4 整数集合(intset)
当 Set 的元素全是整数且数量较少时,Redis 使用 intset 存储:
typedef struct intset { uint32_t encoding; // 编码方式:INT16/INT32/INT64 uint32_t length; // 元素数量 int8_t contents[]; // 实际数据(按编码决定元素大小)} intset;升级机制:当插入一个比当前编码更大的整数时,intset 会整体升级:
// intset 升级示例:从 INT16 升级到 INT32// 升级前:[1, 2, 3] 每个占 2 字节,共 6 字节// 插入 65535(超出 INT16 范围)// 升级后:[1, 2, 3, 65535] 每个占 4 字节,共 16 字节
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) { uint8_t valenc = _intsetValueEncoding(value); uint32_t pos;
if (valenc > intrev32ifbe(is->encoding)) { // 需要升级:重新分配内存,从后向前搬运元素 return intsetUpgradeAndAdd(is, value); } // ... 正常插入(二分查找 + 移位)}intset 只升级不降级,即使后来删除了触发升级的大整数,编码也不会回退。这是因为降级需要遍历所有元素确认没有大整数,开销不划算。intset 中的元素始终有序排列,支持二分查找,查找复杂度 O(log n)。
2.5 哈希表与渐进式 rehash
Redis 的字典底层是哈希表,使用链地址法解决冲突:
// 哈希表typedef struct dictht { dictEntry **table; // 哈希桶数组 unsigned long size; // 哈希表大小(2 的幂) unsigned long sizemask; // 哈希表大小掩码(size - 1) unsigned long used; // 已有节点数量} dictht;
// 字典typedef struct dict { dictType *type; // 类型特定函数 void *privdata; // 私有数据 dictht ht[2]; // 两个哈希表(rehash 时使用) long rehashidx; // rehash 进度(-1 表示未进行) int16_t pauserehash;// 暂停 rehash 的安全标记} dict;渐进式 rehash 是 Redis 哈希表的核心设计。当负载因子(used / size)超过阈值时,需要扩容。但一次性迁移所有键值对会阻塞服务,因此 Redis 将迁移分摊到每次操作中:
// 渐进式 rehash 核心逻辑(简化)static void _dictRehashStep(dict *d) { // 每次迁移一个桶 dictRehash(d, 1);}
int dictRehash(dict *d, int n) { int empty_visits = n * 10; // 最多访问 10n 个空桶 while (n-- && d->ht[0].used != 0) { dictEntry *de, *nextde; // 跳过空桶 while (d->ht[0].table[d->rehashidx] == NULL) { d->rehashidx++; if (--empty_visits == 0) return 1; } de = d->ht[0].table[d->rehashidx]; // 迁移该桶所有节点到 ht[1] while (de) { uint64_t h; nextde = de->next; h = dictHashKey(d, de->key) & d->ht[1].sizemask; de->next = d->ht[1].table[h]; d->ht[1].table[h] = de; d->ht[0].used--; d->ht[1].used++; de = nextde; } d->ht[0].table[d->rehashidx] = NULL; d->rehashidx++; } // 检查是否迁移完毕 if (d->ht[0].used == 0) { zfree(d->ht[0].table); d->ht[0] = d->ht[1]; _dictReset(d->ht + 1); d->rehashidx = -1; return 0; // rehash 完成 } return 1; // rehash 进行中}rehash 期间,所有 CRUD 操作同时在两个哈希表上进行:查找先查 ht[0] 再查 ht[1],新增只写 ht[1],删除和修改在对应表中操作。
2.6 quicklist:List 的真实编码
前面说 List 在小数据量下用 listpack,超出阈值后换成 quicklist。quicklist(快速列表)是 Redis 3.2 引入的 List 专用编码,本质是”用 listpack 节点串成的双向链表”,兼顾了链表的灵活和 listpack 的紧凑。
为什么不再直接用普通双向链表?普通链表每个节点存一个元素,节点间用前后指针连接,指针本身就占 16 字节,元素少时内存浪费严重,而且节点内存不连续,缓存不友好。纯 listpack 又受限于单段连续内存,元素一多扩容和搬迁代价陡增。quicklist 的折中是:把数据按段切进多个 listpack,每段 listpack 作为一个链表节点,节点之间用前后指针连接。
每个链表节点是一个 quicklistNode,记录前后节点指针、内部 listpack 的指针、元素数量、压缩方式等:
typedef struct quicklistNode { struct quicklistNode *prev; // 前驱节点 struct quicklistNode *next; // 后继节点 unsigned char *entry; // 指向 listpack 的指针 size_t count; // 本节点元素数 unsigned int container : 2; // 节点类型,2 表示 listpack unsigned int encoding : 2; // 编码,1 表示 LZF 压缩 // ...} quicklistNode;quicklist 还会对中间节点做 LZF 压缩。首尾两端的节点保留不压缩,因为 LPUSH/RPOP 这类高频操作都打在两端,压缩了又要频繁解压反而拖慢。中间节点访问概率低,压缩后能显著省内存。压缩行为由 list-compress-depth 控制,默认值为 0,即不对任何 quicklist 节点做 LZF 压缩;调到 1 时两端各保留 1 个未压缩节点、中间节点压缩,调到 2 则两端各保留 2 个未压缩节点,以此类推。生产中对 List 内存敏感时可按需调大该值。
list-max-ziplist-size 控制每个 listpack 节点的容量上限(正数表示元素数上限,负数表示按字节大小算),它决定了 quicklist 在”每个节点存多少元素”和”链表多长”之间怎么分配,是 List 内存与性能的主要调参点。
2.7 Stream 与 radix tree
Stream 是 Redis 5.0 引入的数据类型,专门做消息流。它弥补了 List 只能做简单队列、Pub/Sub 不持久化不回溯的短板,支持消费者组、消息确认、历史回溯。Stream 不在传统”五种基本类型”之列,但它是 Redis 里唯一的原生流式数据结构,值得单独看。
Stream 的存储不是链表也不是哈希表,而是 radix tree(基数树)配 listpack 节点。radix tree 是压缩前缀树的一种,按 key 的公共前缀组织,相同前缀的 entry 共用一个树节点,叶子节点指向一个 listpack,listpack 里存实际的消息条目。
为什么用 radix tree 而不是跳表或哈希表?Stream 的 entry ID 是单调递增的(形如 1704000000-0),radix tree 能复用 ID 的公共前缀,相邻 ID 共享前缀节点,内存极省。同时按 ID 前缀有序,范围读取(XRANGE)和按 ID 跳转(XSEEK)都能顺着树结构高效定位,不像哈希表要全表扫描。
每条消息在 listpack 里以 field-value 对的形式存。一个 Stream entry 可以带多个 field,等价于一个 Hash,所以 Stream 也能看成”带 ID 的、追加写的、可消费的 Hash 序列”。
消费者组(Consumer Group)是 Stream 区别于 List 队列的关键。它让多个消费者分摊同一批消息,靠两个结构支撑:每个消费者组维护一个”最后投递 ID”(last-delivered-id),投递新消息时只取比它大的;同时维护 PEL(Pending Entries List)记录已投递但未确认的消息。消费者 XREADGROUP 取走消息后记进 PEL,XACK 确认后才从 PEL 删除,没确认的消息可以用 XPENDING/XCLAIM 重新投给别的消费者,这就实现了至少一次(at-least-once)投递和故障转移。
Stream 和 List 队列(LPUSH + BRPOP)都能做消息传递,但语义不同:List 是”读即删”的简单队列,消费完就没了;Stream 是日志型结构,消息写进 Stream 后所有消费者组都能各自消费到自己的进度,还能回溯历史。做简单任务分发用 List 够了,要做可靠投递、多消费组、消息回溯就用 Stream。
三、对象系统
3.1 redisObject 结构
Redis 的每种数据类型都通过 redisObject 封装,它是连接”类型”与”编码”的桥梁:
typedef struct redisObject { unsigned type:4; // 类型:STRING/LIST/HASH/SET/ZSET unsigned encoding:4; // 编码:int/embstr/raw/listpack/quicklist/... unsigned lru:24; // LRU 时间戳或 LFU 计数器 int refcount; // 引用计数 void *ptr; // 指向底层数据结构的指针} robj;| 字段 | 位数 | 作用 |
|---|---|---|
type | 4 bit | 标识数据类型,TYPE key 命令返回此值 |
encoding | 4 bit | 标识底层编码,OBJECT ENCODING key 可查看 |
lru | 24 bit | LRU 模式存秒级时间戳;LFU 模式高 16 位存分钟级衰减时间,低 8 位存计数器 |
refcount | 32 bit | 引用计数,为 0 时回收;小整数共享对象 refcount 初始为 OBJ_SHARED_REFCOUNT |
ptr | 64 bit | 指向实际数据结构(SDS/listpack/skiplist 等) |
3.2 内存优化策略
embstr 编码:当字符串 ≤ 44 字节时,redisObject 和 SDS 头分配在同一块连续内存中,只需一次 malloc/free,且缓存友好。布局如下:
| 偏移区域 | 内容 | 字段 |
|---|---|---|
| 前半段 | redisObject(16 字节) | type、encoding、lru、refcount |
| 后半段 | sdshdr8 + buf + \0 | len、alloc、flags、data |
整块内存只需一次 malloc 分配、一次 free 释放。
整数共享对象:Redis 启动时预创建 0~9999 的整数对象,所有用到这些值的键共享同一对象:
// server.c 初始化void createSharedObjects(void) { for (int j = 0; j < OBJ_SHARED_INTEGERS; j++) { shared.integers[j] = makeObjectShared( createObject(OBJ_STRING, sdsfromlonglong(j)) ); shared.integers[j]->refcount = OBJ_SHARED_REFCOUNT; // 无限引用 }}当 maxmemory 配置了 LRU/LFU 淘汰策略时,Redis 会禁用共享整数,因为共享对象的 lru 字段无法为每个键独立维护访问信息。这是内存节省与淘汰精度之间的取舍。
编码转换:Redis 在操作时自动检测并转换编码,对用户完全透明:
# Hash 编码自动转换示例HSET user:1 name "Tom" # listpack 编码(field 数 ≤ 128)# ... 继续添加 129 个 field ...HSET user:1 field129 "value" # 自动转换为 hashtable 编码OBJECT ENCODING user:1 # "hashtable"四、持久化
Redis 是内存数据库,进程退出数据即丢失。持久化机制是 Redis 走向生产的关键。与磁盘数据库的 WAL + Buffer Pool 模式不同,Redis 的持久化设计围绕”内存快照”和”命令日志”两条路线展开。
4.1 RDB 持久化
RDB(Redis Database)将某一时刻的全量数据以二进制快照形式写入磁盘:
COW(Copy-On-Write)机制:fork() 创建子进程时,父子进程共享同一块物理内存。只有当父进程修改某页时,内核才复制该页,这就是 COW。对于读多写少的缓存场景,COW 的额外内存开销极小。
# RDB 自动触发配置(redis.conf,Redis 7 默认)save 3600 1 # 3600 秒内有至少 1 次修改save 300 100 # 300 秒内有至少 100 次修改save 60 10000 # 60 秒内有至少 10000 次修改Redis 6.x 及更早版本的默认 save 配置是 900 1 300 10 60 10000,即 900 秒内 1 次、300 秒内 10 次、60 秒内 10000 次。Redis 7 起默认值改为 3600 1 300 100 60 10000,拉长了低频触发的间隔(900→3600)、调高了中频触发的阈值(10→100),减少小写入频繁触发 BGSAVE 的 fork 开销。具体阈值以 CONFIG GET save 实际输出为准。
4.2 AOF 持久化
AOF(Append Only File)以日志形式记录每一条写命令:
AOF 同步策略对比:
| 策略 | fsync 频率 | 数据安全 | 性能影响 | 宕机丢失 |
|---|---|---|---|---|
always | 每条命令 | 最高 | 严重下降(~数百 QPS) | 最多丢 1 条命令 |
everysec | 每秒一次 | 较高 | 轻微影响 | 最多丢 1 秒数据 |
no | 由 OS 决定 | 最低 | 无影响 | 可能丢数秒数据 |
4.3 AOF 重写
AOF 文件会随时间不断膨胀。AOF 重写通过读取当前数据库状态,用最少的命令重建数据:
# 重写前 AOF(6 条命令)SET counter 1INCR counterINCR counterINCR counterDEL temp_keyRPUSH mylist a b c
# 重写后 AOF(2 条命令)SET counter 4RPUSH mylist a b c// AOF 重写流程(简化)int rewriteAppendOnlyFileBackground(void) { pid_t childpid;
if ((childpid = redisFork()) == 0) { // 子进程:遍历数据库,生成最简命令 char tmpfile[256]; snprintf(tmpfile, sizeof(tmpfile), "temp-rewriteaof-bg-%d.aof", getpid()); if (rewriteAppendOnlyFile(tmpfile) == C_OK) { exitFromChild(0); } else { exitFromChild(1); } } else { // 父进程:记录子进程 PID,继续服务 server.aof_child_pid = childpid; // 同时将重写期间的新命令写入 AOF 重写缓冲区 server.aof_rewrite_buf = sdsempty(); }}4.4 混合持久化
Redis 4.0 引入混合持久化,结合 RDB 和 AOF 的优势:
| 持久化方式 | 文件大小 | 恢复速度 | 数据安全 | 适用场景 |
|---|---|---|---|---|
| RDB | 小(二进制压缩) | 快(直接加载) | 可能丢数分钟数据 | 冷备份、容灾 |
| AOF | 大(文本命令) | 慢(重放命令) | 最多丢 1 秒 | 数据安全优先 |
| 混合持久化 | 中 | 较快 | 最多丢 1 秒 | 生产推荐方案 |
五、主从复制
Redis 的主从复制、哨兵故障转移、Cluster 分片与 Gossip、脑裂防护等高可用机制,是独立的专题,在 redis-主从复制与高可用 中完整讨论。本篇聚焦单机内部实现,复制机制不在此展开。
六、事件驱动模型
Redis 的核心是一个单线程事件循环,这是理解 Redis 性能特征的关键。与磁盘数据库的 Buffer Pool 后台线程模型不同,Redis 的主线程同时处理网络 I/O 和命令执行。
6.1 Reactor 模式
Redis 采用 Reactor 设计模式,由一个事件循环线程监听多个 I/O 事件,事件就绪时分发到对应的处理器:
6.2 IO 多路复用
Redis 根据平台选择最优的 IO 多路复用实现:
// ae.c — 根据平台选择实现#ifdef HAVE_EVPORT#include "ae_evport.c" // Solaris event ports#else #ifdef HAVE_EPOLL #include "ae_epoll.c" // Linux epoll 最常用 #else #ifdef HAVE_KQUEUE #include "ae_kqueue.c" // macOS/FreeBSD kqueue #else #include "ae_select.c" // POSIX select(兜底) #endif #endif#endif// ae_epoll.c — epoll 封装(简化)typedef struct aeApiState { int epfd; // epoll 实例 struct epoll_event *events; // 事件数组} aeApiState;
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { aeApiState *state = eventLoop->apidata; int retval, numevents = 0;
// 等待事件就绪,tvp 为超时时间 retval = epoll_wait(state->epfd, state->events, eventLoop->setsize, tvp ? (tvp->tv_sec * 1000 + tvp->tv_usec / 1000) : -1);
if (retval > 0) { numevents = retval; for (int j = 0; j < numevents; j++) { int mask = 0; struct epoll_event *e = state->events + j; if (e->events & EPOLLIN) mask |= AE_READABLE; if (e->events & EPOLLOUT) mask |= AE_WRITABLE; eventLoop->fired[j].fd = e->data.fd; eventLoop->fired[j].mask = mask; } } return numevents;}6.3 文件事件与时间事件
Redis 事件循环处理两类事件:
| 事件类型 | 触发条件 | 处理函数 | 特征 |
|---|---|---|---|
| 文件事件 | socket 可读/可写 | readQueryFromClient / sendReplyToClient | 即时响应 |
| 时间事件 | 定时器到期 | serverCron | 周期执行(默认 100ms) |
// 事件循环主函数void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop); // 循环前钩子 aeProcessEvents(eventLoop, AE_ALL_EVENTS | AE_CALL_AFTER_SLEEP); // 处理所有事件 }}
// aeProcessEvents 核心逻辑(简化)int aeProcessEvents(aeEventLoop *eventLoop, int flags) { // 1. 查找最近的时间事件,计算阻塞时间 if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT)) shortest = aeSearchNearestTimer(eventLoop);
// 2. 调用 epoll_wait 等待文件事件 numevents = aeApiPoll(eventLoop, &tvp);
// 3. 处理文件事件(优先) for (j = 0; j < numevents; j++) { fe = &eventLoop->events[eventLoop->fired[j].fd]; if (fe->mask & mask & AE_READABLE) fe->rfileProc(eventLoop, fd, fe->clientData, mask); if (fe->mask & mask & AE_WRITABLE) fe->wfileProc(eventLoop, fd, fe->clientData, mask); }
// 4. 处理时间事件 if (flags & AE_TIME_EVENTS) processTimeEvents(eventLoop);}6.4 Redis 6.x 多线程 I/O
Redis 6.0 引入了多线程 I/O,但命令执行仍然是单线程,多线程仅用于网络读写:
# redis.conf 多线程配置io-threads 4 # I/O 线程数(建议 ≤ CPU 核心数)io-threads-do-reads yes # 读操作也使用多线程多线程 I/O 的关键约束:主线程在 I/O 线程工作时必须等待(轮询 spin),因为命令执行必须串行化。这意味着多线程 I/O 只解决了网络 I/O 瓶颈,不解决 CPU 密集型命令(如 SORT、SUNION)的瓶颈。如果你的瓶颈在命令执行而非网络,多线程 I/O 帮助有限。
七、过期与淘汰
7.1 过期删除策略
Redis 使用惰性删除 + 定期删除的组合策略:
// 惰性删除:访问时检查是否过期robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) { robj *val; if (expireIfNeeded(db, key) == 0) { // 键已过期,返回 NULL return NULL; } val = lookupKey(db, key, flags); return val;}
// 定期删除:周期性随机抽样删除过期键void activeExpireCycle(int type) { // 每次循环抽样 20 个键 // 如果过期键占比 > 25%,继续抽样 // 每轮最多执行 25ms(自适应频率) for (int j = 0; j < dbs_per_call; j++) { int expired = 0; do { num = dictGetSomeKeys(db->expires, samples, ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP); for (i = 0; i < num; i++) { if (keyIsExpired(db, samples[i])) { deleteKey(db, samples[i]); expired++; } } } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP / 4); }}| 策略 | 时机 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 | 访问键时 | 零 CPU 开销 | 过期键长期不访问则内存泄漏 |
| 定期删除 | 周期性抽样 | 平衡 CPU 与内存 | 可能遗漏少量过期键 |
| 定时删除 | 过期时立即删 | 内存最友好 | CPU 开销不可控,Redis 未采用 |
7.2 内存淘汰策略
当内存使用超过 maxmemory 限制时,Redis 根据配置的策略淘汰键:
| 策略 | 淘汰范围 | 适用场景 |
|---|---|---|
noeviction | 不淘汰,写入报错 | 数据不可丢失 |
allkeys-lru | 所有键中淘汰最久未使用 | 缓存场景(最常用) |
allkeys-lfu | 所有键中淘汰最少使用 | 访问频率差异大的缓存 |
volatile-lru | 设了过期的键中淘汰最久未使用 | 混合持久化+缓存 |
volatile-lfu | 设了过期的键中淘汰最少使用 | 同上 |
volatile-ttl | 设了过期的键中淘汰 TTL 最短 | 业务明确 TTL 优先级 |
allkeys-random | 所有键中随机淘汰 | 无访问热点 |
volatile-random | 设了过期的键中随机淘汰 | 较少使用 |
LRU vs LFU 实现:Redis 的 LRU 并非精确 LRU(维护全局链表开销太大),而是基于 lru 字段的近似算法,随机抽样 N 个键,淘汰其中最久未访问的。LFU 则在 lru 字段低 8 位维护对数计数器,随时间衰减:
// LFU 计数器更新(简化)uint8_t lfuIncr(uint8_t counter) { if (counter < 255) { double r = (double)rand() / RAND_MAX; double baseval = counter - LFU_INIT_VAL; if (baseval < 0) baseval = 0; // 概率递增:计数器越大,递增概率越低 double p = 1.0 / (baseval * lfu_log_factor + 1); if (r < p) counter++; } return counter;}
// LFU 衰减:每 lfu_decay_time 分钟计数器减 1unsigned long LFUDecrAndReturn(robj *o) { unsigned long ldt = o->lru >> 8; // 高 16 位:衰减时间 unsigned long counter = o->lru & 255; // 低 8 位:计数器 unsigned long num_periods = server.unixtime - ldt; if (num_periods > lfu_decay_time) { counter = (num_periods / lfu_decay_time) > counter ? 0 : counter - (num_periods / lfu_decay_time); } return counter;}7.3 bigkey 问题
bigkey 是 Redis 生产环境最常见的性能杀手之一。现象是某次 DEL、HGETALL 或 RDB 触发后,主线程突然阻塞几十毫秒到几秒。根因在于 Redis 命令执行是单线程的,处理一个包含十几万元素的 bigkey 时,遍历和释放操作会占满事件循环,其他客户端命令只能排队等待。
# 检测 bigkeyredis-cli --bigkeys -i 0.1
# 示例输出-------- summary -------Biggest string found so far: 'user:session:xxx' with 5.2 MBBiggest list found so far: 'queue:tasks' with 128456 entriesBiggest set found so far: 'tag:popular' with 89234 membersBiggest hash found so far: 'user:profile:123' with 523 fieldsBiggest zset found so far: 'rank:daily' with 234567 members| bigkey 危害 | 原因 | 解决方案 |
|---|---|---|
| 阻塞主线程 | DEL/EXPIRE 等操作需遍历所有元素 | UNLINK 异步删除 |
| 网络拥塞 | 一次传输数 MB 数据 | 分批获取(HSCAN/SSCAN) |
| 内存不均 | 集群模式下槽分配倾斜 | 拆分为多个小 key |
| 持久化阻塞 | COW 期间大 key 修改导致大量页复制 | 控制单 key 大小 ≤ 10KB |
# 异步删除 bigkeyUNLINK rank:daily # 非阻塞删除,后台线程回收内存
# 拆分 bigkey 示例# 原:HSET user:123 field1 v1 field2 v2 ... field500 v500# 拆:HSET user:123:1 field1 v1 field2 v2# HSET user:123:2 field3 v3 field4 v47.4 持久化 fork 阻塞
BGSAVE 和 BGREWRITEAOF 都依赖 fork() 创建子进程。fork 本身是轻量操作,但 Redis 实例占用几 GB 甚至几十 GB 内存时,fork 需要遍历并复制页表,这段页表拷贝期间主线程被阻塞。
阻塞时长与实例内存大小正相关。内存越大,页表越大,fork 耗时越长。在这期间所有客户端命令都在排队等待,表现为突发的延迟尖峰。
根因有两层:
- 页表拷贝:
fork需要复制父进程的页表项,10 GB 内存大约对应数百万个页表项,拷贝本身就要消耗几十到几百毫秒。 - COW 期间的页复制:
fork之后父进程继续处理写请求,每修改一页内核就要复制该页。如果大 key 被频繁修改,COW 会把内存翻倍,同时复制操作发生在主线程的写路径上。
# 查看 fork 耗时redis-cli INFO stats | grep latest_fork_usec# latest_fork_usec:82300 # 单位微秒,82ms
# 延迟监控(需开启 latency monitor)redis-cli CONFIG SET latency-monitor-threshold 100redis-cli LATENCY HISTORY fork| 排查维度 | 方法 |
|---|---|
| fork 耗时 | INFO stats 的 latest_fork_usec,超过 100ms 就需要警惕 |
| 延迟事件 | LATENCY HISTORY fork,周期性出现的尖峰对应 BGSAVE/BGREWRITEAOF |
| 内存翻倍 | INFO memory 观察 used_memory_rss 在 fork 期间是否接近翻倍 |
控制方案:
- 控制单实例内存,大实例拆分到多个小实例,单实例建议不超过 10 GB
- 调低
save触发频率或关闭自动 RDB,改为低峰期手动BGSAVE - 开启
activedefrag yes主动碎片整理,降低fork时的实际页数 - AOF 重写同理,调大
auto-aof-rewrite-percentage避免频繁触发
如果 Redis 运行在虚拟化或容器环境(如 KVM、Xen),fork 可能更慢,因为 hypervisor 对 fork 的页表操作有额外开销。生产环境中 Redis 延迟突然飙升,先查 latest_fork_usec。
上述 fork 阻塞的诊断流程和阈值参考 Redis 官方文档和社区经验,具体的阻塞时长和内存翻倍幅度取决于实例规模、写入模式和宿主环境,待补充真实案例数据。
附录:Redis 数据结构与内存分析
本节用 Redis CLI 观察不同数据类型的底层编码和内存使用。需要 Redis 环境。
1. String 类型:embstr vs raw
redis-cli SET mykey "hello"redis-cli GET mykey# "hello"
redis-cli TYPE mykey# string
redis-cli OBJECT ENCODING mykey# embstr短字符串(≤44 字节)使用 embstr 编码,一次内存分配,SDS 头和字符串数据连续存储,缓存友好。
2. List 类型:listpack
redis-cli LPUSH mylist a b c d eredis-cli LLEN mylist# (integer) 5
redis-cli OBJECT ENCODING mylist# listpackRedis 7.0+ 用 listpack 替代了 ziplist,解决了连锁更新问题,同时保持紧凑存储。
3. Hash 类型:listpack vs hashtable
redis-cli HSET myhash field1 value1 field2 value2 field3 value3redis-cli HGETALL myhashredis-cli OBJECT ENCODING myhash# listpack小 Hash(字段数少、值短)使用 listpack 紧凑存储;当字段数或值长度超过阈值时,自动转换为 hashtable。
4. Sorted Set 类型:listpack vs skiplist
redis-cli ZADD myzset 1 "one" 2 "two" 3 "three" 4 "four" 5 "five"redis-cli ZRANGE myzset 0 -1 WITHSCORESredis-cli OBJECT ENCODING myzset# listpack小 Sorted Set 使用 listpack;元素超过阈值后转换为 skiplist + hashtable 双结构。
5. 内存使用分析
redis-cli MEMORY USAGE mykey# (integer) 64
redis-cli MEMORY USAGE mylist# (integer) 72
redis-cli MEMORY USAGE myhash# (integer) 104
redis-cli MEMORY USAGE myzset# (integer) 966. RDB 持久化
redis-cli CONFIG GET save# 1) "save"# 2) "3600 1 300 100 60 10000"
redis-cli BGSAVE# Background saving started
redis-cli LASTSAVE# (integer) 1778209989RDB 是 Redis 的快照持久化,save 3600 1 300 100 60 10000 表示:3600 秒内有 1 次写操作、300 秒内有 100 次写操作、60 秒内有 10000 次写操作时自动触发 BGSAVE。
7. 内存信息
redis-cli INFO memory | head -8# Memoryused_memory:1357784used_memory_human:1.29Mused_memory_rss:8388608used_memory_rss_human:8.00Mused_memory_peak:1357784used_memory_peak_human:1.29Mused_memory_peak_perc:100.01%used_memory_overhead:949432used_memory 是 Redis 分配的内存,used_memory_rss 是操作系统分配给 Redis 的物理内存,RSS 远大于 used_memory 是因为内存碎片和 jemalloc 的分配策略。
八、部署运维要点
8.1 持久化参数
RDB 关键参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
save | 3600 1 300 100 60 10000 | 自动触发 BGSAVE 的条件,格式为「秒数 修改次数」,可多条 |
stop-writes-on-bgsave-error | yes | BGSAVE 失败时拒绝写入,避免静默丢数据 |
rdbcompression | yes | RDB 文件 LZF 压缩,CPU 换磁盘空间 |
rdbchecksum | yes | RDB 文件尾部 CRC64 校验,加载时验证完整性 |
dbfilename | dump.rdb | RDB 文件名 |
dir | ./ | RDB 和 AOF 文件目录 |
AOF 关键参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
appendfsync | everysec | AOF 刷盘策略,always 每条命令刷盘、everysec 每秒、no 交给 OS |
auto-aof-rewrite-percentage | 100 | AOF 文件比上次重写后大 100% 触发重写 |
auto-aof-rewrite-min-size | 64mb | 触发重写的最小文件大小,避免小文件频繁重写 |
aof-use-rdb-preamble | yes | AOF 重写时前半段用 RDB 格式(即混合持久化) |
aof-load-truncated | yes | AOF 文件末尾损坏时截断后继续加载 |
8.2 内存参数
| 参数 | 说明 |
|---|---|
maxmemory | 实例内存上限,超过后按 maxmemory-policy 淘汰,生产环境必须设置 |
maxmemory-policy | 淘汰策略,缓存场景常用 allkeys-lru 或 allkeys-lfu,持久化场景用 noeviction |
maxmemory-samples | 近似 LRU/LFU 的随机抽样数,默认 5,调大更精确但更慢 |
activedefrag | yes 开启主动碎片整理,后台线程合并内存碎片 |
active-defrag-ignore-bytes | 触发碎片整理的最小碎片字节数,默认 100 MB |
active-defrag-threshold-lower | 碎片率超 10% 开始整理(frag / used 比值) |
active-defrag-cycle-min | 碎片整理最小 CPU 占用百分比,默认 1 |
active-defrag-cycle-max | 碎片整理最大 CPU 占用百分比,默认 25 |
8.3 监控指标
生产环境需要持续关注几类指标:
| 指标 | 获取方式 | 关注点 |
|---|---|---|
used_memory_rss / used_memory | INFO memory | 碎片率,比值大于 1.5 说明碎片严重 |
latency | LATENCY HISTORY | 延迟尖峰事件,fork、bigkey、AOF fsync 都会记录 |
latest_fork_usec | INFO stats | 上次 fork 耗时,超过 100ms 需要警惕 |
connected_clients | INFO clients | 客户端连接数,突增可能是连接泄漏 |
rejected_connections | INFO stats | 被拒绝的连接数,非 0 说明 maxclients 不够 |
expired_keys / evicted_keys | INFO stats | 过期和淘汰的键数量,辅助判断内存压力 |
master_repl_offset | INFO replication | 主从复制偏移量,和从节点的差值反映复制延迟 |
# 一次性查看关键指标redis-cli INFO memory | grep -E "used_memory:|used_memory_rss:|mem_fragmentation_ratio:"redis-cli INFO stats | grep -E "latest_fork_usec|expired_keys|evicted_keys"redis-cli INFO clients | grep connected_clients8.4 核心机制速查
| 机制 | 核心设计 | 关键取舍 |
|---|---|---|
| SDS | 预分配 + 二进制安全 | 牺牲部分内存换取 O(1) 长度获取和缓冲区安全 |
| 压缩列表/listpack | 连续内存紧凑存储 | 节省内存 vs 连锁更新风险(listpack 已解决) |
| 跳表 | 概率平衡多层链表 | 实现简单 + 范围查询友好 vs 理论常数略大于平衡树 |
| 整数集合 | 升级不降级 | 节省内存 vs 不支持降级 |
| 渐进式 rehash | 分摊迁移到每次操作 | 避免阻塞 vs rehash 期间双表内存开销 |
| RDB | COW 快照 | 恢复快 vs 可能丢数据 |
| AOF | 追加日志 + 重写 | 数据安全 vs 文件大/恢复慢 |
| 混合持久化 | RDB + AOF | 兼顾恢复速度与数据安全 |
| Reactor | 单线程事件循环 | 简单无锁 vs CPU 密集命令阻塞 |
| 多线程 I/O | 网络 I/O 多线程 | 解决网络瓶颈 vs 命令执行仍单线程 |
延伸阅读:主从复制、哨兵与集群的高可用机制,参见 redis-主从复制与高可用。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






