一个 Redis 命令的完整旅程

从客户端发出 SET key value,到 Redis 返回 +OK,中间经历了什么?

单线程模型 I/O 多路复用 内存数据结构 RDB / AOF 主从复制

一、一个 Redis 命令的完整旅程

当你在终端敲下 SET name "Tom",这条命令经历了怎样的旅途?

Client SET name Tom TCP 网络层 RESP 协议编码 Event Loop epoll_wait() ← aeApiPoll() 单线程轮询 命令表查找 ← lookupCommand() 执行命令 ← call(cmd) 响应 ← addReply() +OK 返回客户端 处理流水线: ①aeApiPoll ②readQueryFromClient ③processInputBuffer ④lookupCommand ⑤call(cmd) ⑥addReply
Redis 单线程处理流水线:一次只处理一个命令,没有锁竞争
📊 生活类比

Redis = 单窗口银行柜台:只有一个柜员(单线程),一次只办一笔业务

epoll = 叫号机:柜员不需要挨个问"你要办业务吗?",而是同时监控 10000 个取号器,谁叫了就处理谁

Explorer
📁 src/
ae.c
ae_epoll.c
networking.c
server.c
db.c
// ae.c — Redis 事件循环的核心 void aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; while (!eventLoop->stop) { // ← 死循环永不退出 aeProcessEvents(eventLoop, AE_ALL_EVENTS); // ← epoll_wait + 处理 } } // ae_epoll.c — 封装 epoll 系统调用 static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { // 调用 epoll_wait,阻塞等待就绪事件 int retval = epoll_wait(state->epfd, state->events, ...); for (j = 0; j < numevents; j++) { // 将就绪的 fd 搬到 fired 数组 eventLoop->fired[j] = state->events[j]; } return numevents; } // networking.c — 读取客户端命令 void readQueryFromClient(int fd, void *privdata) { nread = read(fd, c->querybuf+qblen, readlen); // ← 读 TCP 数据 processInputBuffer(c); // ← 解析 & 执行 } // server.c — 命令查找与执行 void processCommand(client *c) { c->cmd = lookupCommand(c->argv[0]->ptr); // ← 查命令表 call(c, CMD_CALL_FULL); // ← 执行命令 }
💡 关键理解

Redis 6.0 的 I/O 读写使用了多线程(I/O 多线程),但命令执行仍然是单线程。核心逻辑不变:一次只执行一条命令,保证原子性。

二、单线程为什么这么快?三大法宝

单线程没有锁竞争,加上三大法宝,Redis 才能做到 10 万+ QPS

传统 BIO(阻塞 I/O) Thread 1 阻塞等待 read() Thread 2 阻塞等待 read() Thread 3 阻塞等待 read() 1连接 = 1线程,大量空闲 I/O 多路复用(epoll) 单线程 + epoll 1 个线程监控 10000+ 连接 fd:3 fd:7 fd:9 ★ fd:12 epoll_wait() 只返回就绪的 fd 处理 fd:9 请求 处理下一个就绪 fd 谁有数据就处理谁,从不空等 10000 连接 = 10000 线程 ≈ 10GB 内存 10000 连接 = 1 线程 ≈ 几十 MB
传统阻塞 I/O vs epoll:一个连接一个线程 vs 一个线程管理所有连接

三大法宝详解

法宝一:I/O 多路复用(epoll)

Linux epoll 机制允许一个线程同时监控海量文件描述符。只有当某个 fd 有数据可读时,epoll_wait() 才返回,不会浪费 CPU 在空等待上

法宝二:纯内存操作

Redis 数据全部在内存中,读写延迟在纳秒级别。对比 MySQL 的磁盘随机读(毫秒级),差了10 万倍

法宝三:高效数据结构

SDS(O(1) 获取长度)、ziplist(紧凑连续内存)、skiplist(O(logN) 范围查询),每种结构都为特定场景做了极致优化。

Explorer
📁 src/
ae_epoll.c
ae.c
ae_select.c
// ae_epoll.c — epoll 系统调用封装 static int aeApiCreate(aeEventLoop *eventLoop) { state->epfd = epoll_create(1024); // ← 创建 epoll 实例 return AE_OK; } static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) { struct epoll_event ee; ee.events = mask; ee.data.fd = fd; epoll_ctl(state->epfd, EPOLL_CTL_ADD, fd, &ee); // ← 注册 fd return AE_OK; } static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) { numevents = epoll_wait(state->epfd, state->events, // ← 等待就绪 eventLoop->setsize, timeout); for (j = 0; j < numevents; j++) { // 只返回有数据的 fd,不遍历全部 eventLoop->fired[j].fd = state->events[j].data.fd; } return numevents; }
📊 核心机制

传统 BIO = 1 个线程阻塞等待 1 个连接,连接没数据就傻等,其他连接没人管

epoll = 1 个线程同时监控 100 个连接,谁的 fd 有数据(就绪)就处理谁,不浪费一秒

对比项RedisMySQL (InnoDB)
存储介质纯内存磁盘 + Buffer Pool
单次读写延迟~100ns~10ms (磁盘随机读)
线程模型单线程(6.0前) / I/O多线程+执行单线程多线程 + 锁竞争
典型 QPS10万+数千 ~ 1万
数据结构SDS / skiplist / ziplistB+ Tree
适用场景缓存 / 计数器 / 排行榜持久化存储 / 复杂查询

三、五种数据结构内部长什么样

Redis 对外暴露 5 种数据类型,内部实现却比想象中复杂得多

String → SDS(Simple Dynamic String)

Redis 没有直接用 C 语言的字符串,而是自己实现了 SDS。核心优势:O(1) 获取长度、二进制安全、杜绝缓冲区溢出。

SDS 内存布局 len 5 free 3 H e l l o \0 已用长度 剩余空间 buf[] 实际数据 预分配空间 vs C 字符串:strlen() 要遍历 O(N),SDS 读 len 字段 O(1) SDS 还能:append 时检查 free 空间,防止缓冲区溢出;二进制安全(不怕 \0)
SDS 结构:len(5) + free(3) + "Hello" + 预分配 + \0,代码映射:sds s = sdsempty() → sds_alloc()

Hash → ziplist / hashtable

元素少时用 ziplist(连续内存,省空间),元素多时自动转为 hashtable(数组 + 链表,O(1) 查找)。

ziplist(小 Hash) hashtable(大 Hash) zlbytes 总字节 zltail 偏移 zllen 长度 "name" "Tom" "age" "25" zlend 连续内存,field-value 紧挨排列,省空间 bucket[0] name → Tom bucket[1] NULL bucket[2] age → 25 bucket[N] city → BJ 数组 + 链表,O(1) 查找,冲突时拉链
Hash 底层:元素 ≤ 512 且 value ≤ 64B 用 ziplist,否则用 hashtable

List → quicklist(ziplist 组成的链表)

Redis 3.2 后 List 底层统一使用 quicklist:一个双向链表,每个节点是一个 ziplist。兼顾了链表的灵活和 ziplist 的省空间。

quicklist 结构 ziplist node a b c d prev/next ziplist node e f g h ziplist node i j k 双向链表连接多个 ziplist,中间节点可压缩(LZF)节省内存
quicklist = 双向链表 + ziplist,每个节点可压缩(LZF),代码映射:quicklistCreate() → quicklistPush()

Set → intset / hashtable

当集合元素全是整数且数量 ≤ 512 时,使用 intset(有序整数数组),否则使用 hashtable

intset(全整数小集合) hashtable(混合集合) encoding int16 length 5 1 3 5 7 9 有序数组,二分查找 O(logN),省内存 [0] "apple" [1] 42 [N] NULL 哈希表 O(1) 判重
Set 底层:全整数小集合用 intset(有序数组),否则用 hashtable

ZSet → ziplist / skiplist + dict

有序集合是最复杂的数据结构:skiplist 负责 O(logN) 范围查询dict 负责 O(1) 查 score。元素少时仍用 ziplist。

跳表(Skip List)结构 — ZSet 的范围查询利器 L3 HEAD Tom:90 L2 HEAD Bob:60 Tom:90 L1 HEAD Amy:30 Bob:60 Cat:70 Tom:90 查找 score=70 的路径: L3: HEAD → Tom:90(太大,下移) → L2: HEAD → Bob:60(小,继续) → Tom:90(太大,下移) → L1: Bob:60 → Cat:70 ✓ dict(O(1) 查 score): "Amy" → 30 "Bob" → 60 "Cat" → 70 "Tom" → 90 代码映射:zslInsert() 插入跳表 + dictAdd() 更新字典,两个结构共享同一份数据
ZSet = skiplist(范围查询 O(logN)) + dict(按 key 查 score O(1)),代码映射:zaddCommand() → zslInsert()
Explorer
📁 src/
t_zset.c
sds.c
ziplist.c
intset.c
// t_zset.c — 跳表插入核心逻辑 zskiplistNode* zslInsert(zskiplist *zsl, double score, sds ele) { zskiplistNode *update[ZSKIPLIST_MAXLEVEL]; unsigned int rank[ZSKIPLIST_MAXLEVEL]; int i, level; // 从最高层开始,逐层查找插入位置 x = zsl->header; for (i = zsl->level-1; i >= 0; i--) { while (x->level[i].forward && // ← 逐层向右找 x->level[i].forward->score < score) { rank[i] += x->level[i].span; x = x->level[i].forward; } update[i] = x; // ← 记录每层的前驱节点 } // 随机决定新节点的层数 level = zslRandomLevel(); // ← 概率 1/2^level if (level > zsl->level) { for (i = zsl->level; i < level; i++) { update[i] = zsl->header; } zsl->level = level; } // 创建节点并插入各层链表 x = zslCreateNode(level, score, ele); for (i = 0; i < level; i++) { x->level[i].forward = update[i]->level[i].forward; update[i]->level[i].forward = x; } return x; }
数据类型底层实现生活类比
StringSDS带长度标签的便签贴(一眼知道多长,不用数)
Hashziplist / hashtable两层表格(少用紧凑排列,多用哈希桶)
Listquicklist手风琴(每个折页是 ziplist,拉开是链表)
Setintset / hashtable抽奖箱(整数用排序数组,混合用哈希桶)
ZSetziplist / skiplist+dict排行榜(skiplist) + 花名册(dict)

四、持久化——RDB 与 AOF,数据怎么存盘

Redis 在内存中跑,断电就没了。RDB 和 AOF 两种机制保证数据不丢。

RDB 快照流程(BGSAVE) Client BGSAVE Redis 主进程 ← bgsaveCommand() 继续处理请求 ← fork() 子进程 ← COW 写时复制 ← rdbSave() 内存快照 遍历所有 key 序列化写入 二进制格式 dump.rdb 磁盘文件 COW(写时复制)原理 fork() 后父子共享内存页,主进程修改某页时才复制该页 → 子进程读到的始终是 fork 时刻的数据
RDB 流程:bgsaveCommand() → fork() → COW → rdbSave() → dump.rdb
AOF 追加流程 SET name Tom ← propagate() AOF 缓冲区 ← feedAppendOnlyFile() ← aof_fsync() fsync 策略 always / everysec / no appendonly.aof 文本格式命令日志 # AOF 文件内容示例(RESP 协议) *3 $3 SET $4 name $3 Tom
AOF 流程:propagate() → feedAppendOnlyFile() → aof_fsync() → appendonly.aof
fsync 策略含义数据安全性能推荐场景
always每条命令都 fsync最多丢 1 条最低金融级安全
everysec每秒 fsync 一次最多丢 1 秒数据较高生产环境推荐
no交给 OS 决定可能丢数秒最高可容忍少量丢失
AOF 重写(Rewrite)流程 旧 AOF 文件 SET name Tom SET name Jerry SET name Alice ← fork() 子进程重写 遍历所有 key 用最小命令集表达 ← rewriteAppendOnlyFile() 新 AOF 文件 SET name Alice (只保留最终值) 替换 原子替换 rename 新→旧 重写期间的新命令 → 同时写入旧 AOF + 重写缓冲区 → 重写完后追加到新 AOF
AOF 重写:fork 子进程 → 遍历所有 key 生成最小命令集 → 原子替换旧文件
Explorer
📁 src/
aof.c
rdb.c
server.c
// aof.c — AOF 重写核心逻辑(简化) int rewriteAppendOnlyFile(char *filename) { // 遍历所有数据库 for (j = 0; j < server.dbnum; j++) { while((de = dictNext(di)) != NULL) { // ← 遍历每个 key sds keystr = dictGetKey(de); robj key, *o = dictGetVal(de); switch(o->type) { case OBJ_STRING: rewriteStringObject(&aof, &key, o); break; case OBJ_LIST: rewriteListObject(&aof, &key, o); break; case OBJ_SET: rewriteSetObject(&aof, &key, o); break; case OBJ_ZSET: rewriteSortedSetObject(&aof, &key, o); break; case OBJ_HASH: rewriteHashObject(&aof, &key, o); break; } // 每条命令用一条 SET/HSET/ZADD... 表达当前状态 } } return AE_OK; } // server.c — 触发重写 void bgrewriteaofCommand(client *c) { if (fork() == 0) { // ← 子进程执行重写 rewriteAppendOnlyFile(tmpfile); exitFromChild(0); } else { // 父进程继续处理请求 server.aof_rewrite_scheduled = 1; } }
📊 生活类比

RDB = 拍全家福:定期把所有人叫到一起拍一张照片,恢复时看照片还原

AOF = 记账本:每花一笔钱就记一笔,恢复时从头到尾重新算一遍

AOF 重写 = 重新抄账本:把 100 页的流水账浓缩成 1 页的"当前余额"

COW = 你在看书(原始),我复印一份改复印件:fork 后共享内存页,只有修改时才复制

PRO Redis 4.0+ 支持 混合持久化:AOF 重写时先写 RDB 格式(快),再追加增量 AOF(全),兼顾速度和安全性。

五、主从复制——数据怎么从主到从

一主多从,读写分离。数据如何同步?断了又怎么恢复?

全量同步流程 从库 刚启动 / 首次连接 ① PSYNC ← PSYNC 命令 主库 ② 执行 BGSAVE ← bgsaveCommand() ③ 发送 RDB RDB 数据 网络传输 从库加载 RDB 清空旧数据+加载 repl_backlog ④ 发送积压缓冲区 ← 增量数据 BGSAVE期间的新写命令 ⑤ 增量追赶 保持一致后进入命令传播 全量同步代价大(要生成 RDB + 传输),repl_backlog 就是为了避免每次都全量 代码映射:syncCommand() → startBgsaveForReplication() → sendBulkToSlave()
全量同步:PSYNC → BGSAVE → 传输 RDB → 发送积压缓冲区 → 增量追赶
增量同步(断线重连) 从库重连 带着 offset 请求 PSYNC ? offset 主库检查 offset offset 在 backlog 范围内? ✓ 是 只发差异数据 ← 从 offset 开始发送 ✗ 否 退化为全量同步 repl_backlog(环形缓冲区) offset: 1000 → 5000 从库 offset=3000 → 只发 3000~5000
增量同步:从库带 offset 重连 → 主库在 backlog 中找到差异 → 只发增量数据
哨兵(Sentinel)故障转移流程 Sentinel 1 ← sentinelTimer() Sentinel 2 Sentinel 3 ① 主观下线 SDOWN ← sentinelCheckSubjectivelyDown() ② 客观下线 ODOWN 多数 Sentinel 认同 ③ 选举领头 Sentinel ← sentinelStartFailover() ④ 故障转移 选一个从库升级为新主库 其他从库改为复制新主库 旧主库(已下线) 新主库 从库升级
Sentinel 故障转移:SDOWN → ODOWN → 选举领头 → 故障转移,代码映射:sentinelTimer() → sentinelStartFailover()
Explorer
📁 src/
replication.c
sentinel.c
rdb.c
// replication.c — PSYNC 命令处理(简化) void syncCommand(client *c) { if (!strcasecmp(c->argv[0]->ptr, "psync")) { if (masterTryPartialResynchronization(c) == C_OK) { // ← offset 在 backlog 范围内,增量同步 return; } // 增量失败,退化为全量同步 } // 全量同步 startBgsaveForReplication(); // ← fork 子进程生成 RDB } int masterTryPartialResynchronization(client *c) { long long psync_offset = strtoll(c->argv[3]->ptr, NULL, 10); // 检查 offset 是否在 repl_backlog 范围内 if (psync_offset >= server.repl_backlog_off && psync_offset < server.repl_backlog_off + server.repl_backlog_histlen) { // 发送增量数据 addReply(c, "+CONTINUE"); replicationSendPartialSync(c, psync_offset); return C_OK; } return C_ERR; // 超出范围,需要全量 } // sentinel.c — 哨兵定时器 void sentinelTimer(void) { sentinelCheckSubjectivelyDown(); // ← 检查主观下线 sentinelCheckObjectivelyDown(); // ← 检查客观下线 sentinelStartFailoverIfNeeded(); // ← 触发故障转移 }
📊 生活类比

主从复制 = 总部改了 Excel,分店还没同步

全量同步 = 重新复印整本:把总部的整份 Excel 重新复印给分店

增量同步 = 只复印改的那几页:从 offset 开始,只传新增的修改

repl_backlog = 变更记录本:记录了最近的修改,分店断线重连时从这里补

模式机制数据安全适用场景
全量同步主库 BGSAVE → 传输 RDB完整但慢首次连接 / backlog 不足
增量同步从 backlog 传差异数据快速补齐短时间断线重连
哨兵自动检测 + 故障转移自动恢复高可用(主挂了自动选新主)
集群16384 槽分片 + 多主水平扩展大数据量 / 高并发

六、面试速答

高频 Redis 原理面试题,每题 30 秒内答完

Q1:Redis 为什么用单线程还这么快?
三大法宝:① I/O 多路复用(epoll)一个线程监控万级连接,不空等;② 纯内存操作纳秒级读写,没有磁盘寻道;③ 高效数据结构SDS/ziplist/skiplist 为场景极致优化。单线程还有额外好处:无锁竞争、无上下文切换、无死锁。
Q2:Redis 6.0 的多线程是怎么回事?
6.0 引入的是 I/O 多线程(读写网络数据),命令执行仍然是单线程。所以不影响原子性。默认关闭,需配置 io-threads-do-reads yes 开启。本质是把网络 I/O 的瓶颈解决了,执行逻辑不变。
Q3:RDB 和 AOF 怎么选?
RDB:文件小、恢复快,但可能丢几分钟数据。AOF:数据更安全(everysec 最多丢 1 秒),但文件大、恢复慢。推荐:混合持久化(Redis 4.0+),AOF 重写时先写 RDB 格式再追加增量,兼顾两者优点。
Q4:跳表是什么?为什么 ZSet 用跳表不用红黑树?
跳表 = 多层索引的有序链表,查找 O(logN)。比红黑树好在:① 实现简单,代码好维护;② 范围查询方便(找到起点后沿最底层链表遍历);③ 内存灵活(层数随机);④ 并发友好(Redis 虽单线程但设计上更优)。
Q5:主从复制断线后怎么恢复?
从库重连时发送 PSYNC ? offset,主库检查 offset 是否在 repl_backlog(环形缓冲区)范围内:在范围内 → 增量同步,只传差异数据;超出范围 → 全量同步,重新生成 RDB 传输。所以 backlog 大小很重要,建议调大。
Q6:SDS 和 C 字符串有什么区别?
4 个核心区别:① O(1) 获取长度(SDS 有 len 字段,C 要遍历);② 杜绝缓冲区溢出(append 前检查 free 空间);③ 二进制安全(不怕 \0,用 len 判断结尾);④ 空间预分配(减少重分配次数,free 预留空间)。

Redis 运行原理图解 — 全栈知识库系列

核心三原则速记

Redis三原则:① 合理设置TTL避免内存暴涨 → ② 热点Key做永不过期+异步刷新 → ③ 分布式锁要有看门狗续期