目录

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 一个极其宝贵的性质:每个命令都是原子的INCRSETNXLPUSHGETSET 全都不需要任何额外同步,这是分布式锁、原子计数器这些用法的基础。多线程模型下这些都要靠锁或 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 的请求完全无法处理。一个线程只能服务一个连接。

解决方案历史上有三条路:

  1. 多进程/多线程(每连接一个线程):Apache 的 prefork 模式。问题是线程创建开销大、上下文切换多、内存占用高,上万连接就崩了(C10K 问题);
  2. IO 多路复用(一个线程管多个连接):Nginx、Redis、Node.js 的选择;
  3. 异步 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 为什么高效(关键点):

  1. fd 注册一次即可epoll_ctl 把 fd 加到内核的红黑树里,之后每次 epoll_wait 不用再传 fd 列表,省掉了 select/poll 每次调用都要做的用户态到内核态的全量拷贝(10000 个 fd 就是每次拷 10000 个);
  2. 回调机制而非轮询:内核给每个被监听的 fd 注册了回调函数(ep_poll_callback),当网卡数据到达、协议栈处理完后主动把该 fd 加入"就绪链表”epoll_wait 只需要看这个链表是否为空,O(1),不需要遍历所有 fd;
  3. 只返回就绪的 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 一次)。它负责:

  1. 更新服务器缓存的时间server.unixtimeserver.mstime,避免频繁系统调用);
  2. 主动过期删除activeExpireCycle,见第 6 篇);
  3. 更新 LRU 时钟
  4. 检查是否需要 RDB/AOF 重写,触发 bgsave/bgrewriteaof
  5. 处理已结束的子进程wait3 回收,更新持久化状态);
  6. 清理超时/空闲的客户端连接
  7. 渐进式 rehash 推进databasesCron 里的 incrementallyRehash,每次最多 1ms);
  8. 主从复制的定时任务(发心跳 REPLCONF ACK、检测超时);
  9. 集群的定时任务clusterCron,gossip 消息、故障检测);
  10. 更新各种统计信息(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回复│
   └─────────┘   └─────────┘    └─────────┘   └─────────┘

具体流程(handleClientsWithPendingWritesUsingThreadspostponeClientRead):

读阶段

  1. 主线程 epoll_wait 得到所有就绪的可读 fd;
  2. 不立即读,而是把这些 client 轮询(round-robin)分配io_threads_list[0..N-1] 这 N 个队列里。注意 io_threads_list[0] 是主线程自己的队列(主线程也参与干活,不闲着);
  3. 主线程设置原子变量 io_threads_pending[i],各 IO 线程自旋检测到有活就开始干(用自旋而不是条件变量,是为了避免线程唤醒的延迟,代价是空转时耗 CPU);
  4. 各线程并行执行 readQueryFromClientread() 数据 + processInputBuffer 解析 RESP,但解析出命令后不执行,只是填好 client->argv
  5. 主线程自旋等待所有线程的 pending 归零;

执行阶段

  1. 主线程串行遍历所有 client,逐个调用 processCommand 执行命令。这一步是单线程的,所以所有原子性保证不变

写阶段

  1. 回复照样被分配给 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 的注意点

  1. 不改变原子性语义:命令执行仍然单线程串行,所以 INCRSETNX、Lua 脚本的原子性全部不变;
  2. 不解决大 key 阻塞HGETALL 一个巨大的 hash,慢在"命令执行"阶段(遍历所有字段),而这仍是主线程串行做的,多线程一点帮助都没有。大 key 问题只能靠改数据模型解决
  3. 自旋等待消耗 CPU:IO 线程在没活时会自旋一段时间(io_threads_pending 的忙等),所以开启后 CPU 使用率的"基线"会上升。低负载时看起来会觉得"更耗 CPU 了";
  4. 6.0 的初版只多线程写,读需要显式开启io-threads-do-reads 默认 no。官方认为读的多线程收益不如写明显(且更容易有 bug),所以保守设计;
  5. 不要跟 CPU 绑核冲突:如果用 taskset 把 Redis 绑到单个核心上,开多线程反而会因为线程争抢一个核而变慢;
  6. 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 用 > 明确标识推送类型,客户端能区分开,所以:

  1. 一个连接可以同时订阅和执行普通命令
  2. 使 客户端缓存(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 变大?

  1. 客户端发了一个巨大的命令(如 MSET 几万个 key、SET 一个 100MB 的 value);
  2. pipeline 一次性发了海量命令而没读回复;
  3. 客户端有 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 clientsclient_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 在服务端还省了:

  1. 系统调用次数:N 个命令原本要 N 次 read + N 次 write,pipeline 后可能只要 1~2 次 read 和 1 次 write。系统调用要陷入内核(用户态/内核态切换约 1~2μs),省下来也很可观;
  2. 事件循环轮次:N 个命令原本要 N 轮 epoll_wait → 处理 → beforeSleep 写回,pipeline 后一轮就处理完,回复合并成一次写。

这就是第 3 节说的"回复攒起来批量写"的直接受益者。

7.3 使用注意

# redis-cli 的 --pipe 是最快的批量导入方式
cat commands.txt | redis-cli --pipe
  1. 不要一次塞太多命令:客户端要等所有回复,回复全部堆在服务端的输出缓冲区和客户端的接收缓冲区里。一次 10 万条命令、每条回复 1KB,就是 100MB 内存。建议每批 100~1000 条
  2. Pipeline 不是事务:命令之间不保证原子性,中间可能插入其他客户端的命令(Redis 只保证每条命令自身原子)。要原子性用 MULTI/EXEC 或 Lua;
  3. Pipeline 里的命令不能有依赖:因为所有命令一次发出,无法根据前一条的结果决定下一条。有依赖就用 Lua;
  4. 集群模式下要按节点分组:跨节点的命令不能放在同一个 pipeline 里,成熟客户端(go-redis、Lettuce)会自动按 slot 分组并行发送;
  5. 不要在 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、关闭文件描述符、惰性释放大对象内存UNLINKFLUSHALL 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 倍。

没解决的

  1. 大 key 阻塞HGETALL 一个百万字段的 hash,慢在"执行"阶段,仍是主线程串行做,多线程毫无帮助;
  2. 慢命令阻塞KEYS *SINTERSTORE 大集合、Lua 死循环,同理;
  3. fork 阻塞:RDB/AOF 重写的 fork 依然会卡住主线程;
  4. 原子性没有变化(这其实是好事,保持了兼容)。

配置注意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 快的两个根本原因

  1. fd 只注册一次,避免了 select/poll 每次调用都要把全部 fd 从用户态拷到内核态(1 万个 fd 每次都拷,开销巨大);
  2. 回调而非轮询:内核给每个 fd 注册回调,数据到达时主动把 fd 加入就绪链表epoll_wait 只看链表,不遍历所有 fd。

Q4:Redis 用的是水平触发还是边缘触发?为什么?

水平触发(LT)

  • LT:只要 fd 上还有未读数据就持续通知,可以一次只读一部分;
  • ET:只在状态变化时通知一次,必须循环 readEAGAIN,否则剩余数据永远收不到通知。

Redis 选 LT 的原因:

  1. 代码简单可靠readQueryFromClient 每次读固定大小(16KB),读不完下轮再读,不用写循环读逻辑,也不会因为漏读导致连接"假死”;
  2. 公平性更好:ET 模式必须一次把某个连接的数据读干,如果某客户端发了个 100MB 的请求,主线程就要一直服务它,其他连接被饿死。LT 天然把大请求切成多轮处理,各连接更公平;
  3. ET 的性能优势在 Redis 场景下并不明显(Redis 的请求通常很小,一次 read 就读完了)。

Q5:为什么 Redis 单线程还这么快?

(第 1 篇讲过,这里给面向本篇的深化版)

  1. 纯内存操作:一次哈希查找几十纳秒,比磁盘快 3~5 个数量级;
  2. IO 多路复用(epoll)+ Reactor 事件模型:单线程高效管理上万连接,事件驱动无空转;
  3. 单线程免去锁和上下文切换:没有加锁开销、没有线程切换(一次切换几微秒)、没有 CPU 缓存失效;
  4. 高效的数据结构与多编码:小集合用连续内存的 listpack/intset(CPU 缓存友好,这点常被忽略),大集合用 hashtable/skiplist 保复杂度;
  5. 精细的工程优化:SDS 预分配减少 realloc、共享整数对象、回复批量写回减少系统调用、渐进式 rehash 避免长阻塞、server.unixtime 缓存时间避免频繁系统调用。

前提:瓶颈是内存和网络带宽,不是 CPU

Q6:BLPOP 阻塞时,Redis 主线程在干什么?

主线程在正常服务其他客户端,完全不受影响。机制是:

  1. BLPOP 发现 list 为空,Redis 把该 client 标记为 CLIENT_BLOCKED,加入 server.blocking_keys[key] 这个字典对应的链表里,然后主线程立即返回事件循环
  2. 另一个客户端 LPUSH 该 key,命令执行完后调用 signalKeyAsReady(),把 key 加入 server.ready_keys
  3. 本轮事件处理完毕,进入下一轮循环的 beforeSleep
  4. handleClientsBlockedOnKeys() 遍历 ready_keys按客户端阻塞的先后顺序(FIFO) 唤醒并发送数据。

所以阻塞的是客户端连接,不是服务端线程。

但要注意两个实际影响

  1. 阻塞的连接不能复用——连接池里的连接被长期占用会耗尽池,客户端必须为阻塞消费开独立连接;
  2. 事务里的 BLPOP 不阻塞——MULTI/EXEC 中执行 BLPOP,列表为空时直接返回 nil(因为事务必须原子快速完成,不能挂起)。

Q7:beforeSleep 做了哪些事?为什么重要?

beforeSleep 是每轮事件循环进入 epoll_wait 之前执行的钩子,挂载了多个关键机制:

  1. 唤醒阻塞客户端handleClientsBlockedOnKeys):处理 BLPOP/BRPOP/BLMOVE/XREAD BLOCK/WAIT 等;
  2. AOF 缓冲区刷盘flushAppendOnlyFile):把 aof_buf 的内容 write 到 AOF 文件。这就是为什么 appendfsync everysec 的"每秒"是靠事件循环驱动的;
  3. 把回复写回 sockethandleClientsWithPendingWritesUsingThreads):命令执行时回复只放进 client 的缓冲区,到这里才统一 write。这让同一轮里的多个回复能合并成一次系统调用,是 pipeline 高效的服务端原因;
  4. 快速过期检查activeExpireCycle(FAST),最多 1ms);
  5. 释放待关闭的客户端、处理集群前置任务、给从节点发 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 类型

  1. RESP2 里订阅了 pub/sub 的连接不能再发普通命令(客户端无法区分"服务端主动推的消息"和"命令的回复")。RESP3 明确标识推送,所以一个连接可以既订阅又执行命令;
  2. 让「客户端缓存(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 60pubsub 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)删除大 keyDEL 一个千万级集合要同步释放大量内存,可能阻塞数秒。用 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 加多线程"更好?

多实例的优势:

  1. 保持单线程的所有好处:无锁、天然原子、代码简单、无并发 bug;
  2. 故障隔离:一个实例挂了只影响 1/N 的数据,而多线程单实例挂了全挂;
  3. 可以绑核:用 taskset 把每个实例绑到独立 CPU 核心,避免线程迁移和 NUMA 跨节点访问;
  4. 持久化压力分散:每个实例的数据量小,fork 更快、RDB 更小、AOF 重写更轻;
  5. 天然的扩展路径:多实例 + 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/selectINFO servermultiplexing_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 协议:首字节标类型(+-:$*),命令统一编码为批量字符串数组;$ 先声明长度所以二进制安全RESP3HELLO 3)新增 Map/Set/Double/Boolean 和最重要的 > Push 类型,使订阅连接可执行普通命令、并支撑客户端缓存(CLIENT TRACKING)
  • 客户端缓冲区:输入 querybuf(上限 1GB,超了断连)、输出 buf(16KB 固定)+ reply 链表(可无限增长)client-output-buffer-limit 的 hard/soft 限制;CLIENT LISTomem/oll/qbuf/cmd/name 是排查利器。
  • Pipeline 省的是 RTT(N 次变 1 次)+ 系统调用 + 事件循环轮次,但不是事务、命令间不能有依赖、每批建议 100~1000 条;要原子性用 MULTI 或 Lua。