Redis-05 单线程模型与网络IO
1. 单线程模型的真相
1.1 「Redis 是单线程」到底指什么
这句话必须精确到"命令执行“这个层面:
Redis 用一个主线程串行地执行所有命令,但 Redis 进程从来不是只有一个线程。
Redis 进程里的线程/进程清单:
| 执行单元 | 引入版本 | 职责 |
|---|---|---|
| 主线程 | 1.0 | 事件循环、执行所有命令、过期删除、复制、集群通信 |
| bio 线程(3 个) | 2.4 / 4.0 扩展 | 1) AOF 的 fsync;2) 关闭文件描述符;3) 惰性释放大对象内存(UNLINK) |
| IO 线程(可配) | 6.0 | 并行地从 socket 读数据/解析协议、并行地把回复写回 socket |
| RDB/AOF 子进程 | 1.0 | fork 出的独立进程,负责生成 RDB 快照或重写 AOF |
| jemalloc 后台线程 | - | 内存分配器自己的 purge 线程(归还内存给 OS) |
所以标准答案是:命令执行单线程,服务整体多线程。
1.2 为什么选择单线程
(1)瓶颈不在 CPU
Redis 操作的是内存里的数据结构,一次 GET 的核心工作只是「哈希查找 + 拷贝内存」,纳秒级完成。真正的耗时在网络 IO 和内存带宽上。
既然 CPU 不是瓶颈,加线程能带来的吞吐提升就很有限,反而要付出巨大代价。
(2)避免锁的代价
多线程访问共享数据必须加锁。Redis 的核心数据是一个巨大的哈希表,几乎所有操作都要读写它。如果加全局锁,多线程等于退化成单线程还多了加锁开销;如果做细粒度锁(分段锁),会引入死锁风险、锁竞争、以及复杂到无法维护的代码。
更麻烦的是:Redis 的很多操作是复合的(比如 ZADD 要同时改 dict 和 skiplist、rehash 要动两张表、过期删除要改主字典和过期字典),细粒度锁很难保证这些操作的一致性。
(3)单命令天然原子
单线程串行执行给了 Redis 一个极其宝贵的性质:每个命令都是原子的。INCR、SETNX、LPUSH、GETSET 全都不需要任何额外同步,这是分布式锁、原子计数器这些用法的基础。多线程模型下这些都要靠锁或 CAS 实现。
(4)代码简单可靠
单线程消除了整整一大类 bug(竞态、死锁、ABA、内存可见性)。Redis 能以一个很小的团队维护到今天的稳定性,单线程是重要原因。
(5)多核可以靠多实例利用
一台 32 核的机器,可以起 8 个 Redis 实例(每个绑不同 CPU 核心),用 Cluster 组成集群。这样既利用了多核,又保持了每个实例的简单性,还顺便获得了故障隔离(一个实例挂了只影响 1/8 的数据)。
1.3 单线程的代价
单线程也带来了一个必须时刻警惕的问题:任何一个慢操作都会阻塞全部请求。
时间轴 →
主线程: [cmd1][cmd2][----- KEYS * 耗时 2 秒 -----][cmd3][cmd4]...
↑
cmd3、cmd4 以及这 2 秒内到达的所有请求都在排队
这就是为什么第 2 篇要专门列一张"危险命令表”。在 Redis 里,慢查询不是"这一个请求慢",而是"整个实例卡住"。
常见的阻塞源(第 15 篇会展开排查手法):
| 阻塞源 | 典型耗时 | 应对 |
|---|---|---|
KEYS * / HGETALL 大 hash / SMEMBERS 大 set |
数百 ms ~ 数秒 | 用 SCAN 系列,控制 key 大小 |
DEL 一个千万级集合 |
数百 ms ~ 数秒 | 用 UNLINK,开 lazyfree-* |
FLUSHALL / FLUSHDB |
秒级 | 加 ASYNC |
| fork(RDB/AOF 重写) | 内存每 GB 约 20~100ms | 控制实例大小,关 THP |
AOF appendfsync always |
每次写都等磁盘 | 用 everysec |
| 磁盘 IO 打满时的 AOF 写入 | 不确定 | 独立磁盘、监控 IO |
| 大 value 的网络传输 | 与带宽相关 | 拆分大 key |
SINTERSTORE 等集合运算 |
O(N*M) | 控制规模或移到从库 |
| Lua 脚本里的死循环/大遍历 | 直到 busy-reply-threshold |
脚本必须简短 |
| 内存交换(swap) | 毫秒 ~ 数十毫秒 | 禁用 swap,设 maxmemory |
| 主从全量同步时从库加载 RDB | 秒级 | 错峰、控制实例大小 |
2. IO 多路复用与 Reactor 模型
2.1 从阻塞 IO 说起
如果用最朴素的方式写一个服务器:
while (1) {
int fd = accept(listen_fd, ...); // 阻塞等新连接
read(fd, buf, size); // 阻塞等这个客户端发数据
process(buf);
write(fd, reply, len); // 阻塞等写完
}
问题很明显:read 阻塞在客户端 A 上时,客户端 B 的请求完全无法处理。一个线程只能服务一个连接。
解决方案历史上有三条路:
- 多进程/多线程(每连接一个线程):Apache 的 prefork 模式。问题是线程创建开销大、上下文切换多、内存占用高,上万连接就崩了(C10K 问题);
- IO 多路复用(一个线程管多个连接):Nginx、Redis、Node.js 的选择;
- 异步 IO(AIO):Linux 上
io_uring之前一直不好用。
2.2 IO 多路复用的本质
核心思路:让内核帮我们同时监视一批文件描述符,只在有 fd “就绪"时才通知我们。
// 伪代码
while (1) {
ready_fds = epoll_wait(epfd, events, maxevents, timeout); // 阻塞在这一处
for (fd in ready_fds) {
if (fd 可读) read(fd, ...); // 保证不会阻塞,因为已就绪
if (fd 可写) write(fd, ...);
}
}
一个线程通过一次 epoll_wait 就能知道「这 10000 个连接里哪 5 个有数据了」,然后只处理那 5 个。“多路"指多个 socket,“复用"指复用同一个线程。
2.3 select / poll / epoll 对比
这是操作系统层面的经典面试题,Redis 相关面试也常问。
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 1024(FD_SETSIZE 编译期常量) |
无上限(链表/数组) | 无上限 |
| 数据结构 | 位图(fd_set) | pollfd 数组 |
内核红黑树 + 就绪链表 |
| 每次调用是否要传全量 fd | 是(每次都要重新拷贝整个 fd_set 到内核) | 是 | 否(epoll_ctl 一次注册,长期有效) |
| 查找就绪 fd 的复杂度 | O(N) 遍历所有 fd | O(N) | O(1) 直接读就绪链表 |
| 返回后 fd_set 是否被修改 | 是(每次要重新设置,很坑) | 否 | 否 |
| 触发模式 | 只有水平触发 | 只有水平触发 | 水平触发(LT)+ 边缘触发(ET) |
| 可移植性 | 几乎所有平台 | POSIX | 仅 Linux |
epoll 的三个系统调用:
int epoll_create1(int flags); // 创建 epoll 实例,返回 epfd
int epoll_ctl(epfd, op, fd, event); // 增/删/改要监听的 fd(ADD/MOD/DEL)
int epoll_wait(epfd, events, max, timeout);// 等待就绪,返回就绪的 fd 数组
epoll 为什么高效(关键点):
- fd 注册一次即可:
epoll_ctl把 fd 加到内核的红黑树里,之后每次epoll_wait不用再传 fd 列表,省掉了 select/poll 每次调用都要做的用户态到内核态的全量拷贝(10000 个 fd 就是每次拷 10000 个); - 回调机制而非轮询:内核给每个被监听的 fd 注册了回调函数(
ep_poll_callback),当网卡数据到达、协议栈处理完后主动把该 fd 加入"就绪链表”。epoll_wait只需要看这个链表是否为空,O(1),不需要遍历所有 fd; - 只返回就绪的 fd:select/poll 返回后你还得遍历所有 fd 逐个
FD_ISSET判断谁就绪了(O(N)),epoll 直接给你一个只含就绪 fd 的数组。
水平触发(LT)vs 边缘触发(ET):
- LT(Level Triggered,默认):只要 fd 上还有未读数据,每次
epoll_wait都会持续通知。编程简单(可以一次只读一部分),不容易漏数据; - ET(Edge Triggered):只在状态发生变化时通知一次(有数据到达的那一刻)。必须一次把数据读干(循环 read 到
EAGAIN),否则剩下的数据不会再被通知。效率略高但编程易错。
Redis 用的是水平触发(LT)。原因:Redis 的处理逻辑是"读一次、处理、回复”,LT 让代码简单可靠;即使一次没读完(比如超过读缓冲区大小),下次事件循环还会被通知,不会丢数据。而 ET 要求循环读到 EAGAIN,一个大请求可能让单次处理占用过长时间,反而伤害其他连接的公平性。
2.4 Redis 的 ae 事件库
Redis 没有用 libevent/libev 这类现成的事件库,而是自己写了一个只有约 1000 行的极简事件库 ae(A simple event-driven programming library),理由是"通用库为了跨平台做了太多抽象,代码量大、依赖多、还有我们不需要的功能”。
ae.c 在编译时根据平台自动选择最优实现(config.h 里的宏判断):
#ifdef HAVE_EVPORT
#include "ae_evport.c" // Solaris
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c" // Linux ← 我们最关心的
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c" // macOS / FreeBSD
#else
#include "ae_select.c" // 兜底,任何平台都能编译
#endif
#endif
#endif
可以用 INFO server 看当前用的是哪个:
127.0.0.1:6379> INFO server
...
multiplexing_api:epoll # Linux 上是 epoll,macOS 上是 kqueue
ae 的核心结构:
typedef struct aeEventLoop {
int maxfd; // 当前最大 fd
int setsize; // 能容纳的 fd 数(= maxclients + 预留)
long long timeEventNextId;
aeFileEvent *events; // 已注册的文件事件数组,下标就是 fd
aeFiredEvent *fired; // 本轮就绪的事件
aeTimeEvent *timeEventHead; // 时间事件链表
int stop;
void *apidata; // 指向平台特定数据(如 epoll 的 epfd 和 events 数组)
aeBeforeSleepProc *beforesleep; // 每轮阻塞前的钩子 ← 很重要
aeAfterSleepProc *aftersleep; // 每轮阻塞后的钩子
int flags;
} aeEventLoop;
typedef struct aeFileEvent {
int mask; // AE_READABLE | AE_WRITABLE | AE_BARRIER
aeFileProc *rfileProc; // 读事件处理函数
aeFileProc *wfileProc; // 写事件处理函数
void *clientData; // 通常指向对应的 client 结构
} aeFileEvent;
注意 events 是以 fd 为下标的数组,所以 setsize 要足够大(Redis 启动时会设为 maxclients + 32,如果系统的 ulimit -n 不够,Redis 会自动下调 maxclients 并在日志里警告)。
2.5 两类事件
文件事件(File Event) —— socket 上的可读/可写:
| 事件 | 处理函数 | 说明 |
|---|---|---|
| 监听 socket 可读 | acceptTcpHandler |
有新连接到达,accept 后创建 client 并注册其读事件 |
| 客户端 socket 可读 | readQueryFromClient |
读取命令、解析 RESP、执行命令 |
| 客户端 socket 可写 | sendReplyToClient |
把回复缓冲区的数据写回客户端 |
| Unix socket 可读 | acceptUnixHandler |
本机 unix domain socket 连接 |
时间事件(Time Event) —— 定时任务:
最核心的是 serverCron,默认每秒执行 hz 次(hz 默认 10,即每 100ms 一次)。它负责:
- 更新服务器缓存的时间(
server.unixtime、server.mstime,避免频繁系统调用); - 主动过期删除(
activeExpireCycle,见第 6 篇); - 更新 LRU 时钟;
- 检查是否需要 RDB/AOF 重写,触发
bgsave/bgrewriteaof; - 处理已结束的子进程(
wait3回收,更新持久化状态); - 清理超时/空闲的客户端连接;
- 渐进式 rehash 推进(
databasesCron里的incrementallyRehash,每次最多 1ms); - 主从复制的定时任务(发心跳
REPLCONF ACK、检测超时); - 集群的定时任务(
clusterCron,gossip 消息、故障检测); - 更新各种统计信息(QPS 采样等)。
# hz 越大定时任务越精准(过期删除更及时),但 CPU 消耗越高
hz 10
# 4.0+ 动态 hz:客户端多时自动提高频率
dynamic-hz yes
3. 事件循环完整流程
3.1 主循环代码
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS |
AE_CALL_BEFORE_SLEEP |
AE_CALL_AFTER_SLEEP);
}
}
aeProcessEvents 的核心逻辑:
1. 计算最近的时间事件还有多久到期 → 作为 epoll_wait 的 timeout
(这样既不会错过定时任务,又能在没事时让线程休息)
2. 调用 beforeSleep() 钩子 ← 关键!见下文
3. aeApiPoll() → epoll_wait(epfd, events, setsize, timeout)
线程在此阻塞,等待 IO 就绪或超时
4. 调用 afterSleep() 钩子
5. 遍历就绪的文件事件,依次调用 rfileProc / wfileProc
(即 readQueryFromClient / sendReplyToClient / acceptTcpHandler)
6. 处理到期的时间事件(processTimeEvents → serverCron)
3.2 beforeSleep 做了什么
beforeSleep 在每次进入 epoll_wait 之前执行,是很多机制的挂载点,也是理解 Redis 行为的关键:
void beforeSleep(struct aeEventLoop *eventLoop) {
// 1. 处理集群相关的前置工作
if (server.cluster_enabled) clusterBeforeSleep();
// 2. 快速过期检查(ACTIVE_EXPIRE_CYCLE_FAST,最多 1ms)
if (server.active_expire_enabled && iAmMaster())
activeExpireCycle(ACTIVE_EXPIRE_CYCLE_FAST);
// 3. 【重要】唤醒被 BLPOP/BRPOP/XREAD 等阻塞、现在数据已就绪的客户端
handleClientsBlockedOnKeys();
// 4. 【重要】AOF 缓冲区刷盘(把 aof_buf 写入文件)
flushAppendOnlyFile(0);
// 5. 【重要】把所有客户端的回复缓冲区内容写回 socket
handleClientsWithPendingWritesUsingThreads();
// 6. 释放待关闭的客户端
freeClientsInAsyncFreeQueue();
// 7. 给从节点发送 ACK 请求、处理 WAIT 命令
...
}
两个最重要的点:
(1)阻塞客户端的唤醒在这里
第 2 篇讲过 BLPOP 阻塞不占主线程。具体机制是:
1. BLPOP 发现 list 为空 → 把 client 加入 server.blocking_keys[key] 链表,
标记 CLIENT_BLOCKED,主线程立即返回去处理别的请求
2. 另一个客户端执行 LPUSH → 命令执行后,signalKeyAsReady() 把该 key
加入 server.ready_keys 列表
3. 本轮事件处理结束,进入下一轮 beforeSleep
4. handleClientsBlockedOnKeys() 遍历 ready_keys,
按 FIFO 顺序唤醒阻塞在这些 key 上的客户端并给它们数据
所以阻塞的粒度是「客户端连接」,主线程始终在跑。
(2)回复是"攒起来批量写"的
命令执行时,回复并不直接 write 到 socket,而是先放进 client 的输出缓冲区,并把这个 client 加入 server.clients_pending_write 队列。到 beforeSleep 时统一处理。
这样做的好处:一次事件循环里如果同一个客户端发了多个命令(pipeline),回复可以合并成一次 write 系统调用,大幅减少系统调用次数。这也是 pipeline 性能好的服务端原因之一。
3.3 一条命令的完整旅程
以客户端执行 GET name 为例,把整条链路串起来:
【客户端侧】
1. 客户端把命令编码成 RESP:*2\r\n$3\r\nGET\r\n$4\r\nname\r\n
2. write() 到 socket
【内核侧】
3. 数据经网卡 → 协议栈 → 放入该 socket 的接收缓冲区
4. epoll 的回调把该 fd 加入就绪链表
【Redis 主线程】
5. epoll_wait 返回,得知该 fd 可读
6. 调用 readQueryFromClient:
a. read() 数据到 client->querybuf
b. processInputBuffer() 解析 RESP,填充 client->argv / argc
7. processCommand():
a. lookupCommand() 在命令表(一个 dict)里查 "get" 对应的 redisCommand
b. 各种检查:参数个数、认证状态、ACL 权限、内存是否超限(超限则先淘汰)、
是否是只读从节点收到写命令、集群是否需要重定向(MOVED/ASK)、
是否在 MULTI 事务中(是则入队直接返回 QUEUED)
c. call() 真正执行 → getCommand() → lookupKeyRead() 查字典
(顺便检查 key 是否过期、更新 LRU/LFU 信息、更新命中率统计)
8. addReply() 把结果写入 client->buf(16KB 固定缓冲)
或 client->reply(链表,装不下时用)
同时把 client 加入 server.clients_pending_write
9. 命令传播:写命令要写入 AOF 缓冲区、传播给从节点(GET 是读命令,跳过)
10. 本轮就绪事件都处理完 → 进入下一轮循环的 beforeSleep
11. handleClientsWithPendingWrites() → write() 把 buf 写回 socket
(如果一次没写完,注册 AE_WRITABLE 事件,下次可写时继续)
【客户端侧】
12. 收到 $3\r\ntom\r\n,解析后返回给应用
4. Redis 6.0 多线程 IO
4.1 为什么要引入多线程
到 6.0 之前,随着硬件发展,Redis 的瓶颈越来越明显地落在网络 IO 上:
一条命令的耗时分布大致是:
读 socket + 解析协议 ≈ 40% ← 纯 CPU 密集的字符串处理 + 系统调用
执行命令 ≈ 20% ← 内存操作,很快
组装回复 + 写 socket ≈ 40% ← 同样是 CPU + 系统调用
执行命令只占 20%,而读写 socket 和协议解析占了 80%。这部分工作是可以并行的(不同客户端的数据互不相干,不涉及共享数据结构),所以 6.0 把它拆给多个线程。
注意这跟"给命令执行加多线程"完全是两回事——Redis 依然坚决不让多线程碰核心数据结构。
4.2 实现原理
┌──────────────────────────────┐
│ 主线程 │
│ epoll_wait 拿到就绪连接 │
└──────────┬───────────────────┘
│ 轮询分配到各线程的待处理队列
┌──────────────┬───────┴───────┬──────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│IO线程 0 │ │IO线程 1 │ │IO线程 2 │ │IO线程 3 │
│(主线程) │ │ │ │ │ │ │
│read+解析│ │read+解析│ │read+解析│ │read+解析│
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
└──────────────┴───────┬───────┴──────────────┘
│ 主线程自旋等待所有 IO 线程完成
┌──────────▼───────────────────┐
│ 主线程【串行】执行所有命令 │ ← 关键!
│ (数据结构操作完全无锁) │
└──────────┬───────────────────┘
│ 再次分配
┌──────────────┬───────┴───────┬──────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│IO线程 0 │ │IO线程 1 │ │IO线程 2 │ │IO线程 3 │
│write回复│ │write回复│ │write回复│ │write回复│
└─────────┘ └─────────┘ └─────────┘ └─────────┘
具体流程(handleClientsWithPendingWritesUsingThreads 和 postponeClientRead):
读阶段:
- 主线程
epoll_wait得到所有就绪的可读 fd; - 不立即读,而是把这些 client 轮询(round-robin)分配到
io_threads_list[0..N-1]这 N 个队列里。注意io_threads_list[0]是主线程自己的队列(主线程也参与干活,不闲着); - 主线程设置原子变量
io_threads_pending[i],各 IO 线程自旋检测到有活就开始干(用自旋而不是条件变量,是为了避免线程唤醒的延迟,代价是空转时耗 CPU); - 各线程并行执行
readQueryFromClient:read()数据 +processInputBuffer解析 RESP,但解析出命令后不执行,只是填好client->argv; - 主线程自旋等待所有线程的
pending归零;
执行阶段:
- 主线程串行遍历所有 client,逐个调用
processCommand执行命令。这一步是单线程的,所以所有原子性保证不变;
写阶段:
- 回复照样被分配给 N 个线程并行
write()回 socket。
4.3 配置
# IO 线程数(包含主线程)。1 = 关闭多线程(默认)
io-threads 4
# 是否也用多线程处理"读+协议解析"(默认 no,只多线程处理写回)
io-threads-do-reads yes
官方建议:
io-threads设为 CPU 核心数的 1/2 到 3/4,比如 4 核机器设 2~3,8 核设 4~6;- 不要超过 8,官方明确说超过 8 个线程收益极小(因为主线程的串行执行部分成了新瓶颈,符合阿姆达尔定律);
io-threads 1时多线程完全关闭,走原来的单线程路径;- 只有在网络带宽和 CPU 都不是瓶颈之前(即 QPS 很高、value 较大)才有明显收益。如果你的 QPS 只有几千,开多线程纯属浪费。
官方压测数据:GET/SET 场景下开 4 个 IO 线程,吞吐大约能提升到 2 倍左右。
4.4 多线程 IO 的注意点
- 不改变原子性语义:命令执行仍然单线程串行,所以
INCR、SETNX、Lua 脚本的原子性全部不变; - 不解决大 key 阻塞:
HGETALL一个巨大的 hash,慢在"命令执行"阶段(遍历所有字段),而这仍是主线程串行做的,多线程一点帮助都没有。大 key 问题只能靠改数据模型解决; - 自旋等待消耗 CPU:IO 线程在没活时会自旋一段时间(
io_threads_pending的忙等),所以开启后 CPU 使用率的"基线"会上升。低负载时看起来会觉得"更耗 CPU 了"; - 6.0 的初版只多线程写,读需要显式开启:
io-threads-do-reads默认no。官方认为读的多线程收益不如写明显(且更容易有 bug),所以保守设计; - 不要跟 CPU 绑核冲突:如果用
taskset把 Redis 绑到单个核心上,开多线程反而会因为线程争抢一个核而变慢; - 7.0+ 有进一步优化:把部分命令的回复组装也移到了 IO 线程。8.0 对整个 IO 路径做了更大幅度的重构(异步 IO 线程模型),吞吐提升更明显。
4.5 Redis 与 Memcached 线程模型的对比
| Redis 6.0+ | Memcached | |
|---|---|---|
| 网络 IO | 多线程(可配) | 多线程 |
| 协议解析 | 多线程(需开 io-threads-do-reads) |
多线程 |
| 数据操作 | 单线程串行 | 多线程 + 分段锁 |
| 原子性 | 天然原子 | 需要锁或 CAS |
| 复杂数据结构 | 支持(因为单线程好实现) | 不支持 |
这张表说明了两者的根本设计分歧:Memcached 选择"多线程 + 锁"换取多核利用率,代价是只能支持简单的 KV;Redis 选择"单线程执行"换取实现复杂数据结构的自由和天然原子性,代价是单实例吃不满多核(用多实例弥补)。
5. RESP 协议
理解 RESP(REdis Serialization Protocol)对排查问题、写客户端、看抓包都很有用。
5.1 设计目标
RESP 的三个设计目标(官方文档原话):实现简单、解析快、人类可读。
它是基于文本行的协议,每部分以 \r\n(CRLF)结尾,第一个字节标识类型。
5.2 RESP2 数据类型
| 首字节 | 类型 | 示例 | 说明 |
|---|---|---|---|
+ |
简单字符串 | +OK\r\n |
不含 CRLF 的短字符串,用于状态回复 |
- |
错误 | -ERR unknown command\r\n |
错误信息,客户端应抛异常 |
: |
整数 | :1000\r\n |
64 位有符号整数 |
$ |
批量字符串 | $5\r\nhello\r\n |
先给长度再给内容,二进制安全 |
* |
数组 | *2\r\n:1\r\n:2\r\n |
先给元素个数,再给各元素 |
两个特殊值:
$-1\r\n Null Bulk String(key 不存在时 GET 的返回)
*-1\r\n Null Array(BLPOP 超时的返回)
$0\r\n\r\n 空字符串(注意这不是 null)
5.3 客户端发送的命令永远是数组
所有命令都编码成"批量字符串数组"(这叫 unified request protocol):
SET name tom
↓
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$3\r\ntom\r\n
拆解:
*3\r\n ← 数组有 3 个元素
$3\r\n ← 第 1 个元素长度 3
SET\r\n ← 内容
$4\r\n ← 第 2 个元素长度 4
name\r\n
$3\r\n ← 第 3 个元素长度 3
tom\r\n
因为用 $ 显式给出长度,所以参数可以包含任何字节(空格、换行、\0、二进制数据),这就是 Redis 二进制安全的协议基础。
用 telnet/nc 手动发命令(Redis 也支持简化的 inline 命令格式):
$ nc 127.0.0.1 6379
PING
+PONG
SET name tom
+OK
GET name
$3
tom
nc 里直接敲的是 inline 命令(空格分隔的纯文本),Redis 为了方便调试保留了对它的支持。但 inline 命令不能包含二进制数据,正式客户端都用数组格式。
5.4 各类回复示例
# 简单字符串
SET k v → +OK\r\n
# 错误
GET → -ERR wrong number of arguments for 'get' command\r\n
LPUSH k v; GET k → -WRONGTYPE Operation against a key holding the wrong kind of value\r\n
# 整数
INCR counter → :1\r\n
EXISTS k → :1\r\n
SISMEMBER s m → :0\r\n
# 批量字符串
GET name → $3\r\ntom\r\n
GET nothing → $-1\r\n ← nil
# 数组
LRANGE l 0 2 → *3\r\n$1\r\na\r\n$1\r\nb\r\n$1\r\nc\r\n
HGETALL h → *4\r\n$2\r\nf1\r\n$2\r\nv1\r\n$2\r\nf2\r\n$2\r\nv2\r\n
↑ 注意 RESP2 里 HGETALL 返回的是"扁平数组",客户端要自己配对
BLPOP q 1 (超时) → *-1\r\n
# 嵌套数组(如 XRANGE、CONFIG GET 的复杂结构)
*2\r\n*2\r\n$2\r\nid\r\n$1\r\n1\r\n*2\r\n...
5.5 RESP3(6.0+)
RESP2 有个明显的缺陷:类型信息不足。比如 HGETALL 返回一个扁平数组,客户端必须知道这个命令的语义才能把它还原成 map;ZSCORE 返回的浮点数被编码成字符串;EXISTS 的 1/0 到底是数字还是布尔也分不清。
这导致每个客户端库都要为几百个命令硬编码"返回值该怎么转换",非常笨重。
RESP3 引入了更丰富的类型:
| 首字节 | 类型 | 说明 |
|---|---|---|
_ |
Null | 统一的 null(_\r\n),不再有 $-1 和 *-1 两种 |
# |
Boolean | #t\r\n / #f\r\n |
, |
Double | ,3.14\r\n(还支持 ,inf、,-inf、,nan) |
( |
Big Number | 超过 64 位的大整数 |
% |
Map | %1\r\n$1\r\na\r\n:1\r\n ← 明确是键值对 |
~ |
Set | 明确是无序集合 |
= |
Verbatim String | 带格式提示的字符串(如 txt:、mkd:) |
! |
Blob Error | 长错误信息 |
| ` | ` | Attribute |
> |
Push | 服务端主动推送(pub/sub 消息、客户端缓存失效通知) |
协商方式:连接建立后发 HELLO 3:
127.0.0.1:6379> HELLO 3
1# "server" => "redis"
2# "version" => "7.2.5"
3# "proto" => (integer) 3
4# "id" => (integer) 5
5# "mode" => "standalone"
6# "role" => "master"
7# "modules" => (empty array)
不发 HELLO 3 就默认用 RESP2,保证了完全的向后兼容。
RESP3 最重要的实际价值——> Push 类型:
RESP2 里订阅了 pub/sub 的连接会进入"订阅模式",不能再发普通命令(因为客户端无法区分"服务端主动推的消息"和"我上一个命令的回复")。RESP3 用 > 明确标识推送类型,客户端能区分开,所以:
- 一个连接可以同时订阅和执行普通命令;
- 使 客户端缓存(Client-side caching / Tracking) 成为可能——服务端可以在 key 被修改时主动推 invalidate 消息给客户端,让客户端的本地缓存失效。这是 6.0 一个很重要的新能力:
CLIENT TRACKING ON # 开启键追踪
GET foo # 客户端本地缓存 foo
# 别人修改了 foo 后,服务端主动推送:
# >2\r\n$10\r\ninvalidate\r\n*1\r\n$3\r\nfoo\r\n
6. 客户端与缓冲区
每个连接在 Redis 内部对应一个 client 结构。理解它的几个缓冲区对排查内存问题至关重要。
6.1 输入缓冲区(querybuf)
客户端发来的命令先存到 client->querybuf(一个 SDS)。
- 单个客户端的输入缓冲区硬上限 1GB,超过 Redis 会直接关闭该连接并记录日志
Closing client that reached max query buffer length; - 但 1GB 已经太大了,实际风险在于「很多客户端同时占用较大的输入缓冲」会耗尽内存;
client-query-buffer-limit(默认 1gb)可配置;- 输入缓冲区的内存不计入
maxmemory的淘汰判断(这是个坑:它属于"额外开销",所以maxmemory要留余量)。
什么情况会让 querybuf 变大?
- 客户端发了一个巨大的命令(如
MSET几万个 key、SET一个 100MB 的 value); - pipeline 一次性发了海量命令而没读回复;
- 客户端有 bug,不停发数据但不读。
排查:CLIENT LIST 里看 qbuf(已用)和 qbuf-free(剩余)字段。
6.2 输出缓冲区
输出分两级:
(1)固定缓冲区 client->buf:默认 16KB,用于装小回复。这块是 client 结构里直接分配的数组,不用额外 malloc;
(2)回复链表 client->reply:16KB 装不下时,后续内容挂到一个链表上(每个节点是一块动态分配的内存)。这部分可以无限增长,是内存爆炸的主要风险点。
所以有了 client-output-buffer-limit:
# 格式:<class> <hard limit> <soft limit> <soft seconds>
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
三种 class:
- normal:普通客户端。默认 不限制(0 0 0),因为普通客户端通常是"请求-响应"模式,来不及堆积;
- replica:从节点。主节点要把命令流发给从节点,如果从节点慢/网络差就会堆积。默认 hard 256MB;
- pubsub:订阅者。发布速度大于订阅者消费速度时会堆积。默认 hard 32MB。
两种限制:
- hard limit:一旦超过,立即关闭连接;
- soft limit + soft seconds:持续超过 soft limit 达到 soft seconds 秒,才关闭连接。
经典事故:主从复制时,从节点因为在加载 RDB 或网络慢,导致主节点上该 replica 的输出缓冲区超过 256MB → 主节点断开连接 → 从节点重连 → 触发全量同步 → 又产生大量输出 → 再次超限 → 无限循环的复制风暴。这时要调大 client-output-buffer-limit replica 或者排查根本原因(见第 10 篇)。
排查:CLIENT LIST 里的 omem(output buffer memory)字段,或 INFO clients 的 client_recent_max_output_buffer。
6.3 复制缓冲区与积压缓冲区
主节点上还有两块专门用于复制的内存(第 10 篇详讲):
- replication buffer:每个从节点一个(就是上面的 replica class 输出缓冲区),主节点把写命令分别放进每个从节点的这个 buffer;
- repl_backlog(复制积压缓冲区):全局只有一个的环形缓冲区(
repl-backlog-size默认 1MB),保存最近传播的命令。从节点短暂断线重连时,如果它需要的数据还在 backlog 里,就能做部分重同步(只补发缺失的部分)而不用全量同步。
6.4 CLIENT LIST 字段速查
127.0.0.1:6379> CLIENT LIST
id=5 addr=127.0.0.1:52133 laddr=127.0.0.1:6379 fd=8 name=worker-1
age=1234 idle=0 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 watch=0
qbuf=26 qbuf-free=20448 argv-mem=10 multi-mem=0 tot-net-in=100 tot-net-out=200
rbs=1024 rbp=0 obl=0 oll=0 omem=0 tot-mem=22298
events=r cmd=client|list user=default redir=-1 resp=2
| 字段 | 含义 |
|---|---|
id |
连接 ID(CLIENT KILL ID 用) |
addr / laddr |
客户端地址 / 本地地址 |
name |
CLIENT SETNAME 设的名字(强烈建议应用都设置,排查时能一眼定位是哪个服务) |
age |
连接已存活秒数 |
idle |
空闲秒数(idle 很大说明连接闲着,可能是连接池过大) |
flags |
N=普通, M=主节点, S=从节点, O=monitor, x=在事务中, b=阻塞中 |
sub/psub |
订阅的频道数 / 模式数 |
multi |
事务中已入队的命令数,-1 表示不在事务中 |
qbuf/qbuf-free |
输入缓冲区已用/剩余 |
obl |
固定输出缓冲区(buf)已用字节 |
oll |
输出缓冲区链表的节点数(大于 0 说明 16KB 不够用了) |
omem |
输出缓冲区占用的内存(排查大 key / 慢消费者的关键指标) |
tot-mem |
该连接总共占用的内存 |
cmd |
最后执行的命令(排查"谁在跑 KEYS"的利器) |
resp |
协议版本(2 或 3) |
排查内存异常的常用组合:
# 找出输出缓冲区最大的客户端
redis-cli CLIENT LIST | awk '{for(i=1;i<=NF;i++) if($i ~ /^omem=/) print $i, $0}' | sort -t= -k2 -rn | head
# 找出输入缓冲区最大的
redis-cli CLIENT LIST | grep -o 'qbuf=[0-9]*' | sort -t= -k2 -rn | head
7. Pipeline 为什么快
Pipeline(管道)不是 Redis 的服务端特性,而是客户端的批量发送技巧,但它的收益来自上面讲的事件循环机制。
7.1 RTT 是主要开销
普通模式(N 个命令):
客户端 --SET k1 v1--> 服务端 |
<-- +OK -- | 1 个 RTT
--SET k2 v2--> |
<-- +OK -- | 1 个 RTT
... N 个 RTT
Pipeline 模式:
客户端 --SET k1 v1 SET k2 v2 ... SET kN vN--> 服务端
<-- +OK +OK ... +OK -- 1 个 RTT
假设 RTT = 1ms、单命令执行 = 0.01ms:
- 普通模式 1000 个命令:
1000 × (1 + 0.01) = 1010ms; - Pipeline:
1 + 1000 × 0.01 = 11ms;
快了约 90 倍。同机房 RTT 通常是 0.1~0.5ms,跨机房可能 2~30ms,RTT 越大 pipeline 收益越大。
7.2 服务端也有收益
除了省 RTT,pipeline 在服务端还省了:
- 系统调用次数:N 个命令原本要 N 次
read+ N 次write,pipeline 后可能只要 1~2 次read和 1 次write。系统调用要陷入内核(用户态/内核态切换约 1~2μs),省下来也很可观; - 事件循环轮次:N 个命令原本要 N 轮
epoll_wait→ 处理 →beforeSleep写回,pipeline 后一轮就处理完,回复合并成一次写。
这就是第 3 节说的"回复攒起来批量写"的直接受益者。
7.3 使用注意
# redis-cli 的 --pipe 是最快的批量导入方式
cat commands.txt | redis-cli --pipe
- 不要一次塞太多命令:客户端要等所有回复,回复全部堆在服务端的输出缓冲区和客户端的接收缓冲区里。一次 10 万条命令、每条回复 1KB,就是 100MB 内存。建议每批 100~1000 条;
- Pipeline 不是事务:命令之间不保证原子性,中间可能插入其他客户端的命令(Redis 只保证每条命令自身原子)。要原子性用
MULTI/EXEC或 Lua; - Pipeline 里的命令不能有依赖:因为所有命令一次发出,无法根据前一条的结果决定下一条。有依赖就用 Lua;
- 集群模式下要按节点分组:跨节点的命令不能放在同一个 pipeline 里,成熟客户端(go-redis、Lettuce)会自动按 slot 分组并行发送;
- 不要在 pipeline 里放阻塞命令(
BLPOP等),会把整个 pipeline 卡住。
7.4 Pipeline / 事务 / Lua / 批量命令 的区别
| 方式 | 网络往返 | 原子性 | 命令间可依赖 | 说明 |
|---|---|---|---|---|
| 逐条命令 | N 次 | 单命令原子 | 是 | 最慢 |
MGET/MSET |
1 次 | 是 | 否 | 只适用于同类操作,集群需 hash tag |
| Pipeline | 1 次 | 否 | 否 | 纯性能优化 |
| MULTI/EXEC | 1~2 次 | 是(不隔离失败) | 否(WATCH 除外) | 见第 8 篇 |
| Lua 脚本 | 1 次 | 是 | 是 | 最灵活,可含逻辑判断 |
| Pipeline + MULTI | 1 次 | 是 | 否 | 常见组合 |
8. 高频面试题
Q1:Redis 是单线程还是多线程?
必须分层回答:
命令执行是单线程的——一个主线程串行执行所有命令,这保证了单命令的原子性和无锁访问数据结构。
但 Redis 进程是多线程的:
- 2.4/4.0 起有 3 个 bio 后台线程:AOF 的
fsync、关闭文件描述符、惰性释放大对象内存(UNLINK、FLUSHALL ASYNC、lazyfree 系列配置); - 6.0 引入多线程 IO:多个线程并行做「读 socket + 解析 RESP」和「写回 socket」,但解析出的命令仍然交回主线程串行执行;
- RDB/AOF 重写是
fork出的子进程(不是线程); - jemalloc 还有自己的后台 purge 线程。
一句话总结:IO 可以多线程,命令执行永远单线程。
Q2:Redis 6.0 的多线程解决了什么问题?没解决什么问题?
解决的:网络 IO 和协议解析的 CPU 开销。这部分占单个命令总耗时的约 80%(读+解析 40%,组装+写 40%,执行只占 20%),且天然可并行(不同连接的数据互不相干)。官方压测 GET/SET 场景开 4 线程吞吐约提升 2 倍。
没解决的:
- 大 key 阻塞:
HGETALL一个百万字段的 hash,慢在"执行"阶段,仍是主线程串行做,多线程毫无帮助; - 慢命令阻塞:
KEYS *、SINTERSTORE大集合、Lua 死循环,同理; - fork 阻塞:RDB/AOF 重写的 fork 依然会卡住主线程;
- 原子性没有变化(这其实是好事,保持了兼容)。
配置注意:io-threads 建议设为核心数的 1/2~3/4 且不超过 8(超过 8 收益极小,因为主线程的串行部分成为瓶颈);io-threads-do-reads 默认 no 需要显式开启;开启后因为 IO 线程有自旋等待,低负载时 CPU 使用率基线会上升。
Q3:什么是 IO 多路复用?select、poll、epoll 的区别?
IO 多路复用:用一个线程同时监视多个文件描述符,由内核告知哪些 fd “就绪”,线程只处理就绪的那些。“多路"指多个 socket,“复用"指复用一个线程。它是单线程能服务上万连接的基础。
三者区别:
| select | poll | epoll | |
|---|---|---|---|
| fd 上限 | 1024(编译期常量) | 无 | 无 |
| 每次调用传全量 fd | 是(用户态↔内核态全量拷贝) | 是 | 否(epoll_ctl 注册一次) |
| 找就绪 fd | O(N) 遍历 | O(N) | O(1) 读就绪链表 |
| 内核数据结构 | 位图 | 数组 | 红黑树 + 就绪链表 |
| 触发模式 | LT | LT | LT + ET |
| 平台 | 全平台 | POSIX | 仅 Linux |
epoll 快的两个根本原因:
- fd 只注册一次,避免了 select/poll 每次调用都要把全部 fd 从用户态拷到内核态(1 万个 fd 每次都拷,开销巨大);
- 回调而非轮询:内核给每个 fd 注册回调,数据到达时主动把 fd 加入就绪链表,
epoll_wait只看链表,不遍历所有 fd。
Q4:Redis 用的是水平触发还是边缘触发?为什么?
水平触发(LT)。
- LT:只要 fd 上还有未读数据就持续通知,可以一次只读一部分;
- ET:只在状态变化时通知一次,必须循环
read到EAGAIN,否则剩余数据永远收不到通知。
Redis 选 LT 的原因:
- 代码简单可靠:
readQueryFromClient每次读固定大小(16KB),读不完下轮再读,不用写循环读逻辑,也不会因为漏读导致连接"假死”; - 公平性更好:ET 模式必须一次把某个连接的数据读干,如果某客户端发了个 100MB 的请求,主线程就要一直服务它,其他连接被饿死。LT 天然把大请求切成多轮处理,各连接更公平;
- ET 的性能优势在 Redis 场景下并不明显(Redis 的请求通常很小,一次 read 就读完了)。
Q5:为什么 Redis 单线程还这么快?
(第 1 篇讲过,这里给面向本篇的深化版)
- 纯内存操作:一次哈希查找几十纳秒,比磁盘快 3~5 个数量级;
- IO 多路复用(epoll)+ Reactor 事件模型:单线程高效管理上万连接,事件驱动无空转;
- 单线程免去锁和上下文切换:没有加锁开销、没有线程切换(一次切换几微秒)、没有 CPU 缓存失效;
- 高效的数据结构与多编码:小集合用连续内存的 listpack/intset(CPU 缓存友好,这点常被忽略),大集合用 hashtable/skiplist 保复杂度;
- 精细的工程优化:SDS 预分配减少 realloc、共享整数对象、回复批量写回减少系统调用、渐进式 rehash 避免长阻塞、
server.unixtime缓存时间避免频繁系统调用。
前提:瓶颈是内存和网络带宽,不是 CPU。
Q6:BLPOP 阻塞时,Redis 主线程在干什么?
主线程在正常服务其他客户端,完全不受影响。机制是:
BLPOP发现 list 为空,Redis 把该 client 标记为CLIENT_BLOCKED,加入server.blocking_keys[key]这个字典对应的链表里,然后主线程立即返回事件循环;- 另一个客户端
LPUSH该 key,命令执行完后调用signalKeyAsReady(),把 key 加入server.ready_keys; - 本轮事件处理完毕,进入下一轮循环的
beforeSleep; handleClientsBlockedOnKeys()遍历ready_keys,按客户端阻塞的先后顺序(FIFO) 唤醒并发送数据。
所以阻塞的是客户端连接,不是服务端线程。
但要注意两个实际影响:
- 阻塞的连接不能复用——连接池里的连接被长期占用会耗尽池,客户端必须为阻塞消费开独立连接;
- 事务里的
BLPOP不阻塞——MULTI/EXEC中执行BLPOP,列表为空时直接返回 nil(因为事务必须原子快速完成,不能挂起)。
Q7:beforeSleep 做了哪些事?为什么重要?
beforeSleep 是每轮事件循环进入 epoll_wait 之前执行的钩子,挂载了多个关键机制:
- 唤醒阻塞客户端(
handleClientsBlockedOnKeys):处理BLPOP/BRPOP/BLMOVE/XREAD BLOCK/WAIT等; - AOF 缓冲区刷盘(
flushAppendOnlyFile):把aof_buf的内容write到 AOF 文件。这就是为什么appendfsync everysec的"每秒"是靠事件循环驱动的; - 把回复写回 socket(
handleClientsWithPendingWritesUsingThreads):命令执行时回复只放进 client 的缓冲区,到这里才统一write。这让同一轮里的多个回复能合并成一次系统调用,是 pipeline 高效的服务端原因; - 快速过期检查(
activeExpireCycle(FAST),最多 1ms); - 释放待关闭的客户端、处理集群前置任务、给从节点发 ACK 请求等。
理解它的重要性在于:很多"Redis 什么时候做 X"的问题,答案都是"在 beforeSleep 里”。
Q8:RESP 协议是什么样的?为什么说它二进制安全?
RESP 是基于文本行的协议,每部分以 \r\n 结尾,首字节表示类型:+ 简单字符串、- 错误、: 整数、$ 批量字符串、* 数组。
客户端发的命令统一编码成"批量字符串数组":
SET name tom → *3\r\n$3\r\nSET\r\n$4\r\nname\r\n$3\r\ntom\r\n
二进制安全的原因:$ 类型先显式声明长度再给内容($4\r\nname\r\n),解析器按长度精确读取字节,不依赖任何分隔符或结束符。所以参数里可以包含空格、换行、\0、任意二进制数据(图片、protobuf),都不会破坏协议解析。
对比:如果协议是"空格分隔的纯文本"(inline 命令那样),value 里有空格就歧义了。
Q9:RESP3 相比 RESP2 有什么改进?
RESP2 的核心问题是类型信息不足:HGETALL 返回扁平数组(客户端必须硬编码知道要两两配对成 map)、浮点数用字符串表示、布尔和整数分不清。每个客户端库都要为几百个命令硬编码返回值转换逻辑。
RESP3(6.0+)新增:_ Null(统一,不再有 $-1/*-1 两种)、# Boolean、, Double、( Big Number、% Map、~ Set、= Verbatim String、! Blob Error、| Attribute、> Push。
最重要的实际价值是 > Push 类型:
- RESP2 里订阅了 pub/sub 的连接不能再发普通命令(客户端无法区分"服务端主动推的消息"和"命令的回复")。RESP3 明确标识推送,所以一个连接可以既订阅又执行命令;
- 让「客户端缓存(Client-side Caching / Tracking)」成为可能:
CLIENT TRACKING ON后,服务端能在 key 被修改时主动推invalidate通知,客户端据此失效本地缓存。这是 6.0 的重要新能力,能把热点 key 的读请求彻底挡在应用本地。
协商方式:连接后发 HELLO 3;不发就默认 RESP2,完全向后兼容。
Q10:客户端输出缓冲区溢出会怎样?怎么排查?
Redis 用 client-output-buffer-limit <class> <hard> <soft> <soft-seconds> 控制:
- hard limit:一超过立即关闭连接;
- soft limit + seconds:持续超过 soft 达到指定秒数才关闭。
三种 class 的默认值:normal 0 0 0(不限)、replica 256mb 64mb 60、pubsub 32mb 8mb 60。
经典事故:复制风暴。从节点因为网络慢或正在加载 RDB,导致主节点上它的输出缓冲区堆积超过 256MB → 主节点断开它 → 从节点重连 → 因为 offset 已经不在 backlog 里,触发全量同步 → 主节点又产生大量输出 → 再次超限 → 无限循环,主节点 CPU 和内存被反复的 bgsave 拖垮。
排查手段:
CLIENT LIST # 看 omem(输出缓冲内存)、oll(链表节点数)
INFO clients # client_recent_max_output_buffer
INFO stats # client_output_buffer_limit_disconnections(被踢的次数)
# 日志里搜 "scheduled to be closed ASAP for overcoming of output buffer limits"
根因通常是:大 key 的全量读取(HGETALL/LRANGE 0 -1)、MONITOR 长期开着、pub/sub 消费者太慢、从节点同步慢。
Q11:Redis 的哪些操作会阻塞主线程?
按类别记:
(1)O(N) 命令:KEYS *、HGETALL/SMEMBERS/LRANGE 0 -1 大集合、SINTERSTORE 等集合运算(O(N*M))、SORT 大集合、FLUSHALL/FLUSHDB(不带 ASYNC)、ZUNIONSTORE。
(2)删除大 key:DEL 一个千万级集合要同步释放大量内存,可能阻塞数秒。用 UNLINK + lazyfree-* 配置。
(3)持久化相关:
- fork:内存每 GB 约 20~100ms(复制页表),开了 THP 会更糟;
appendfsync always:每条写命令都等磁盘 fsync;- 磁盘 IO 打满时 AOF 的
write也会阻塞(Redis 有个专门的检测:aof_delayed_fsync计数器); - AOF 重写期间如果
no-appendfsync-on-rewrite no,主线程的 fsync 会和重写抢 IO。
(4)Lua 脚本:脚本执行期间 Redis 完全不响应其他命令(脚本是原子的)。超过 busy-reply-threshold(默认 5 秒)后其他客户端会收到 BUSY 错误,此时只能 SCRIPT KILL(如果脚本没写操作)或 SHUTDOWN NOSAVE。
(5)系统层面:内存 swap(延迟从 μs 恶化到 ms)、CPU 竞争、numa 跨节点内存访问。
(6)复制相关:从节点加载 RDB 期间不响应请求(replica-serve-stale-data no 时会直接报错);主节点 bgsave 时的 COW 内存压力。
Q12:Pipeline 和 MULTI/EXEC 有什么区别?
| Pipeline | MULTI/EXEC | |
|---|---|---|
| 本质 | 客户端的批量发送技巧 | 服务端的事务机制 |
| 原子性 | 无,中间可能插入其他客户端的命令 | 有,EXEC 时所有命令连续执行不被打断 |
| 命令何时执行 | 服务端收到就逐条执行 | 先入队,EXEC 时才执行 |
| 服务端内存 | 只占输入/输出缓冲区 | 命令要在服务端队列里暂存 |
| 失败处理 | 各命令独立,互不影响 | 语法错误整个事务不执行;运行时错误其他命令继续执行(不回滚) |
| 网络往返 | 1 次 | 1~2 次(可以放进 pipeline 一起发) |
| 目的 | 性能(省 RTT) | 原子性 |
两者常组合使用:把 MULTI ... EXEC 整个塞进一个 pipeline 里发送,既原子又只用一次 RTT。
Q13:为什么说"Redis 用多实例利用多核"比"给 Redis 加多线程"更好?
多实例的优势:
- 保持单线程的所有好处:无锁、天然原子、代码简单、无并发 bug;
- 故障隔离:一个实例挂了只影响 1/N 的数据,而多线程单实例挂了全挂;
- 可以绑核:用
taskset把每个实例绑到独立 CPU 核心,避免线程迁移和 NUMA 跨节点访问; - 持久化压力分散:每个实例的数据量小,fork 更快、RDB 更小、AOF 重写更轻;
- 天然的扩展路径:多实例 + Cluster 可以横向扩到多机,多线程只能纵向扩到单机核数上限。
代价:运维复杂度上升(要管理 N 个实例、N 份配置)、跨实例操作受限(不能跨 slot 事务)、客户端要支持集群。
这也解释了 Redis 6.0 为什么只把网络 IO 多线程化而坚决不动命令执行——它想要 IO 并行的收益,但不愿意放弃单线程执行的任何一条好处。
小结
- “Redis 单线程"精确指命令执行单线程;进程里还有 3 个 bio 线程(AOF fsync、关 fd、惰性释放内存)、6.0 的 IO 线程、fork 出的 RDB/AOF 子进程。
- 选单线程的理由:瓶颈在内存和网络不在 CPU、避免锁的开销与复杂度、单命令天然原子、代码简单可靠、多核可用多实例弥补。
- 代价是任何慢操作都阻塞全部请求——这是"危险命令表"存在的根本原因。
- IO 多路复用让一个线程管上万连接;epoll 快在"fd 注册一次不用重复拷贝"和"回调机制维护就绪链表(O(1))”;Redis 用水平触发(LT),因为代码简单且各连接更公平。
- Redis 自研 ae 事件库(约 1000 行),编译期按平台选
epoll/kqueue/evport/select;INFO server的multiplexing_api可查。 - 事件分文件事件(accept/read/write)和时间事件(
serverCron,默认hz 10即每 100ms 一次,负责过期删除、rehash 推进、持久化检查、复制与集群定时任务)。 beforeSleep是关键挂载点:唤醒阻塞客户端、AOF 刷盘、批量写回所有回复、快速过期检查。回复"攒起来批量写"是 pipeline 快的服务端原因。- 6.0 多线程 IO:主线程 epoll → 轮询分配给 N 个线程并行 read+解析 → 主线程串行执行命令 → 再分配给 N 个线程并行 write。
io-threads建议核数的 1/2~3/4 且不超过 8;不解决大 key 阻塞。 - RESP 协议:首字节标类型(
+-:$*),命令统一编码为批量字符串数组;$先声明长度所以二进制安全。RESP3(HELLO 3)新增 Map/Set/Double/Boolean 和最重要的>Push 类型,使订阅连接可执行普通命令、并支撑客户端缓存(CLIENT TRACKING)。 - 客户端缓冲区:输入 querybuf(上限 1GB,超了断连)、输出 buf(16KB 固定)+ reply 链表(可无限增长);
client-output-buffer-limit的 hard/soft 限制;CLIENT LIST的omem/oll/qbuf/cmd/name是排查利器。 - Pipeline 省的是 RTT(N 次变 1 次)+ 系统调用 + 事件循环轮次,但不是事务、命令间不能有依赖、每批建议 100~1000 条;要原子性用 MULTI 或 Lua。
xingliuhua