数据库基础体系 · 第 118/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Redis 内核与事件循环:命令执行、IO 线程、阻塞点和延迟

Redis 经常被概括为“单线程数据库”。这句话抓住了一个重要事实:同一个 Redis 实例中的命令执行通常由主线程串行完成。但它没有完整描述 Redis 的事件循环、网络 I/O 线程、后台线程、子进程以及阻塞命令。

要分析 Redis 延迟,必须区分至少四段时间:

Tend-to-end=Tconnect+Tread-queue+Texecute+Twrite-queue+TnetworkT_{\text{end-to-end}} = T_{\text{connect}} + T_{\text{read-queue}} + T_{\text{execute}} + T_{\text{write-queue}} + T_{\text{network}}

其中:

  • TconnectT_{\text{connect}}:建立连接或连接复用相关的时间;
  • Tread-queueT_{\text{read-queue}}:请求已经到达 Redis,但尚未被读取、解析或调度的时间;
  • TexecuteT_{\text{execute}}:命令实际执行时间;
  • Twrite-queueT_{\text{write-queue}}:响应生成后等待写出或发送缓冲区可用的时间;
  • TnetworkT_{\text{network}}:客户端与 Redis 之间的网络传输时间。

SLOWLOG 主要观察命令执行阶段,不能代表客户端感知的完整延迟。IO 线程可以降低网络读写开销,却不会把普通 Redis 命令变成并行执行。真正造成长尾的,往往是执行线程上的长任务、事件循环中的阻塞操作、客户端输出堆积,或者后台操作触发的瞬时停顿。


一、先建立正确的模型:一个 Redis 实例中的几类执行者

一个现代 Redis 实例至少可以从以下角度理解:

  1. 主线程

    • 驱动事件循环;
    • 接受连接、处理定时事件;
    • 解析和调度命令;
    • 执行绝大多数命令;
    • 处理事务、Lua 脚本、Redis Functions 等原子执行逻辑;
    • 负责复制、过期、客户端管理等许多核心工作。
  2. 网络 IO 线程

    • 在启用并且适用时,协助读取客户端请求或向客户端写出响应;
    • 不负责把普通命令分散到多个线程并行执行;
    • io-threadsio-threads-do-reads 等配置影响。
  3. 后台线程

    • 例如异步释放内存、后台关闭文件等;
    • 具体职责取决于版本和配置;
    • 后台线程不意味着数据结构命令可以安全地与主线程并发修改。
  4. 子进程

    • BGSAVE、AOF 重写等后台持久化流程通常会创建子进程;
    • fork() 本身以及写时复制带来的内存压力可能引起延迟;
    • 子进程与主线程的关系不同于 IO 线程。

因此,“Redis 是单线程”更准确的说法是:

Redis 的核心数据访问和命令执行路径以主线程串行为基础;网络 IO、后台释放、持久化等工作可以使用其他执行实体,但这些并不等价于普通命令的并行执行。

串行执行带来一个重要性质:如果一个命令在主线程中运行,其他需要该线程继续推进的命令就必须等待它完成。


二、事件循环到底做什么

2.1 文件事件和时间事件

Redis 使用事件驱动模型。这里的“文件事件”不是只指普通文件,而是指操作系统提供的文件描述符事件,主要包括:

  • 监听 socket 可读:有新客户端连接;
  • 客户端 socket 可读:客户端发送了请求;
  • 客户端 socket 可写:内核发送缓冲区可以继续接受响应数据;
  • 连接异常或关闭。

Redis 还需要处理时间相关的工作,例如:

  • 定期执行数据库维护;
  • 处理过期键;
  • 执行后台任务的状态推进;
  • 更新统计信息;
  • 处理超时客户端或阻塞客户端。

事件循环可以抽象成:

初始化监听 socket
        |
        v
等待操作系统报告事件
        |
        +--> 新连接:accept,创建客户端状态
        |
        +--> 客户端可读:读取请求,解析完整命令
        |
        +--> 命令执行:访问数据结构,生成响应
        |
        +--> 客户端可写:发送响应
        |
        +--> 时间事件:执行周期性维护
        |
        v
继续下一轮事件循环

底层等待机制通常根据平台使用 epollkqueue 等高效多路复用接口。Redis 不需要为每个连接创建一个阻塞线程,而是让一个事件循环同时管理大量连接。

2.2 “可读”不等于“一条完整命令”

TCP 是字节流,不保留应用层消息边界。一次 read() 可能得到:

  • 半个 RESP 请求;
  • 一个完整请求;
  • 多个请求拼接在一起;
  • 请求和后续请求的一部分。

因此 Redis 为每个客户端维护输入缓冲区,过程大致是:

  1. socket 被标记为可读;
  2. 读取字节追加到客户端输入缓冲区;
  3. RESP 解析器检查是否已经形成完整命令;
  4. 如果不完整,等待下一次可读事件;
  5. 如果完整,提取命令和参数;
  6. 执行命令或进入事务、阻塞客户端等状态;
  7. 将响应放入客户端输出缓冲区;
  8. 必要时注册写事件。

例如,客户端发送:

*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n

解析器得到:

命令名:GET
参数:key

而如果只收到:

*2\r\n$3\r\nGET\r\n

Redis 不能执行 GET,因为第二个 bulk string 尚未完整到达。

2.3 事件循环不是“每个事件立即完成”

事件循环中的一次工作可能包含多个阶段。主线程读取请求后,不一定只执行一条命令:

  • 客户端可能使用 pipeline,一次发送多条命令;
  • 事务中的命令会先排队,EXEC 时集中执行;
  • Pub/Sub、阻塞列表等命令可能改变客户端状态;
  • 某些命令会触发键空间通知、复制传播、过期处理或内存回收。

因此,网络事件只是进入命令处理流程的入口,不是命令延迟的全部来源。


三、一次普通命令如何从客户端走到数据结构

以单机 Redis 中的 GET user:1 为例,可以按以下步骤理解。

第一步:客户端发送 RESP 请求

RESP 是 Redis 客户端协议。请求可以表示为:

*2\r\n
$3\r\nGET\r\n
$6\r\nuser:1\r\n

客户端通过 TCP 发送这些字节。

第二步:操作系统报告 socket 可读

Redis 的事件循环等待多路复用器返回事件。收到客户端 socket 可读事件后,Redis 将数据读入客户端输入缓冲区。

如果客户端一次发送了 1000 条 pipeline 命令,那么这 1000 条命令可能在一次或少数几次网络读取中到达。

第三步:解析和命令查找

Redis 从 RESP 中提取命令名和参数,并在命令表中查找 GET 的实现、参数规则、标志和复杂度信息。

参数数量不正确时,命令不会进入数据访问阶段。例如:

redis-cli GET

会返回参数错误,而不是访问某个默认键。

第四步:执行命令

GET 通常需要:

  1. 根据 key 找到对应数据库字典项;
  2. 检查键是否过期;
  3. 检查数据类型是否为 String;
  4. 读取值;
  5. 构造 RESP 响应。

如果 key 实际保存的是 Hash,执行 GET 会返回类型错误:

127.0.0.1:6379> HSET user:1 name Alice
(integer) 1
127.0.0.1:6379> GET user:1
(error) WRONGTYPE Operation against a key holding the wrong kind of value

这说明命令执行不仅是“查字典”,还包括过期、类型和数据结构语义。

第五步:生成响应并写出

响应先进入客户端输出缓冲区。Redis 可能立即写 socket;如果内核发送缓冲区暂时不可用,则保留写事件,等下一轮事件循环继续发送。

对于小响应和快速网络,读取、执行、写出可能在很短路径内完成。对于大响应、慢客户端或网络拥塞,命令执行完成后,客户端仍可能长时间收不到完整结果。


四、命令执行的串行性:为什么一个慢命令会影响其他请求

对同一个 Redis 实例,可以把主线程命令执行抽象为一个服务台:

请求队列 -> 主线程 -> 一个命令完成 -> 下一个命令

假设请求按顺序到达:

GET a
LRANGE big-list 0 -1
SET b 1

如果 LRANGE big-list 0 -1 需要构造一个很大的响应,那么实际顺序是:

执行 GET a
执行 LRANGE big-list 0 -1
执行 SET b 1

在第二条命令完成前,第三条命令不能获得主线程的普通执行机会。即使第三条 SET 本身只需要很短时间,它的响应也会被前面的长任务推迟。

4.1 用排队模型理解延迟

令:

  • λ\lambda:请求到达率;
  • SS:单条请求占用命令执行线程的平均时间;
  • ρ=λS\rho = \lambda S:执行线程利用率。

ρ\rho 接近 1 时,即使平均命令执行时间没有变化,排队时间也会迅速增加。

举例:

  • 平均执行时间 S=100 μsS=100\ \mu s
  • 到达率 λ=8000\lambda=8000 次/秒。

则:

ρ=8000×100μs=0.8\rho = 8000 \times 100\mu s = 0.8

执行线程平均有 80% 的时间在工作。若突然出现一个 20 ms 的长命令,后续请求会在这 20 ms 内积压。假设有 5000 个请求在等待,它们并不需要自身执行很慢,也会因为队列前方的长任务产生延迟。

这也是 Redis 中“平均延迟正常、P99 突然很高”的典型来源:长尾可能由少数命令制造,然后传播给后续请求。

4.2 一个更具体的算例

假设三个请求进入主线程队列:

请求 本身执行时间 前方等待 客户端看到的执行相关延迟
GET a 0.1 ms 0 ms 0.1 ms
LRANGE 20 ms 0.1 ms 20.1 ms
SET b 1 0.1 ms 20.1 ms 20.2 ms

第三条命令并没有变慢,但它的等待时间变长了。

反例是把 Redis 误认为“每个连接一个线程”:

即使 SET b 1 来自另一个 TCP 连接,它仍不能绕过正在执行大 LRANGE 的主线程。


五、Redis IO 线程做什么,不做什么

5.1 IO 线程解决的是网络处理,不是命令并行

Redis 6 及之后提供了网络 IO 线程能力。它的核心目标是把部分网络读写从主线程移出,尤其在以下场景中有帮助:

  • 连接数很多;
  • 请求或响应较大;
  • 网络带宽较高;
  • 主线程花费较多时间在 socket 读写和协议处理上。

但 IO 线程通常不负责执行 GETHSETZADD 等数据命令。典型流程仍然是:

IO 线程读取请求
        |
        v
主线程解析/调度并执行命令
        |
        v
IO 线程写出响应

即使读取和写出并行化,命令执行部分仍然集中在主线程。

5.2 写 IO 与读 IO

Redis 配置中常见的相关选项包括:

io-threads 1
io-threads-do-reads no

含义可以概括为:

  • io-threads 1:不启用多线程 IO;主线程处理网络 IO;
  • io-threads N,其中 N > 1:配置多个 IO 线程;
  • io-threads-do-reads yes:允许 IO 线程参与客户端请求读取;
  • 未启用读 IO 时,读路径仍主要由主线程完成,IO 线程主要用于写路径。

不同稳定版本的具体实现细节可能变化,因此生产环境应以当前版本 redis.conf 和官方文档为准。配置 IO 线程数不是把 Redis 配成 N 个命令执行线程,也不能消除数据结构操作的串行约束。

5.3 为什么 IO 线程不能解决大 ZRANGE

假设:

ZRANGE leaderboard 0 999999 WITHSCORES

这个命令需要:

  1. 定位大量有序集合成员;
  2. 读取并组织结果;
  3. 构造很大的 RESP 响应。

IO 线程可能协助最后的网络写出,但以下工作仍可能占用主线程:

  • 有序集合访问;
  • 结果组织;
  • 响应对象构造;
  • 命令相关的过期、复制和统计处理。

所以 IO 线程可能降低网络瓶颈,却不能把一个 O(N) 的大范围查询变成 O(1),也不能让多个大查询在主线程上同时运行。

5.4 IO 线程的取舍

IO 线程并非无成本:

  • 线程调度和同步本身有开销;
  • CPU 核心不足时,可能与主线程争用;
  • 读写线程数过多会增加上下文切换;
  • 网络负载较低时,收益可能很小;
  • 诊断时必须区分主线程 CPU 高和 IO 线程 CPU 高。

正确的验证方式不是只看配置,而是进行对照测试:

  1. 固定数据集、客户端数量和 pipeline 深度;
  2. 分别测试 io-threads 1 与多线程 IO;
  3. 同时观察吞吐、P50/P99、主线程 CPU、IO 线程 CPU、网络带宽;
  4. 额外测试大响应和慢客户端,因为 IO 线程的收益通常依赖网络工作量。

六、“阻塞”至少有四种不同含义

工程讨论中说“Redis 被阻塞了”,可能指完全不同的问题。必须先判断阻塞发生在哪一层。

6.1 命令本身耗时很长:主线程执行阻塞

这类阻塞不是“等待某个条件”,而是命令执行本身占用主线程很久。

常见来源包括:

  • 对大 List、Set、Hash、ZSet 做全量或大范围操作;
  • 删除超大对象;
  • 执行复杂 Lua 脚本或 Redis Function;
  • 大规模集合运算;
  • 大批量事务;
  • 模块命令执行复杂计算。

例如:

127.0.0.1:6379> KEYS *

KEYS 需要扫描当前数据库中的 key,复杂度通常为 O(N)。当 key 数量很大时,它会长时间占用主线程。

替代方式通常是:

127.0.0.1:6379> SCAN 0 COUNT 1000
1) "13952"
2) 1) "key:1"
   2) "key:2"

SCAN 将遍历拆成多个调用,单次调用通常更短,并允许事件循环在调用之间处理其他请求。但这不表示:

  • 整个遍历变成 O(1);
  • 结果是快照;
  • 不会返回重复 key;
  • 可以在遍历期间无条件修改数据而保持严格一致性。

SCAN 是降低单次主线程占用的工具,不是消除总工作量的工具。

6.2 阻塞命令等待数据:客户端阻塞,服务器仍可工作

BLPOPBRPOPXREAD BLOCK 等命令具有“阻塞客户端”的语义。

例如,先启动:

redis-cli BLPOP jobs 10

如果 jobs 为空,客户端会等待最多 10 秒。此时另一个客户端执行:

redis-cli RPUSH jobs task-1

等待中的客户端可能收到:

1) "jobs"
2) "task-1"

这里的“阻塞”是:

  • 该客户端进入等待状态;
  • Redis 记录它等待哪些 key 或 Stream;
  • 当数据到达时唤醒匹配的客户端;
  • 超时则返回空结果。

它不是让 Redis 主线程执行一个持续 10 秒的循环。事件循环仍可以处理其他客户端的 GETSET 等请求。

可以通过以下命令观察阻塞客户端:

redis-cli INFO clients
redis-cli CLIENT LIST

INFO clients 中的 blocked_clients 可以反映阻塞客户端数量;CLIENT LIST 中也可以看到客户端状态标志。需要注意,阻塞客户端数量高不必然意味着 Redis 主线程被卡死,必须结合命令延迟和 CPU 使用率判断。

6.3 同步等待外部条件:命令语义阻塞主线程流程,但不等于死循环

某些命令会等待复制或集群相关条件。例如 WAIT 可以等待写入被一定数量的副本确认,直到满足条件或超时。

这类等待的特点是:

  • 当前客户端的命令不会立即返回;
  • Redis 需要等待副本反馈或超时;
  • 其他客户端通常仍可被事件循环处理;
  • 但等待时间会增加该请求的响应延迟。

因此,WAIT 的延迟可能来自复制链路、网络或副本负载,而不是本地数据结构操作。

在使用事务、脚本或模块阻塞 API 时,边界更复杂。某些操作会让客户端进入阻塞状态;如果模块代码直接在主线程中执行外部阻塞调用,则可能真正阻塞整个实例。模块开发者必须使用 Redis 提供的阻塞客户端和线程安全 API,而不能在命令回调中直接等待不可控的外部服务。

6.4 网络写不出去:响应排队

如果客户端读取速度很慢,而 Redis 持续产生响应,输出缓冲区可能积压。此时:

  • 命令可能已经执行完成;
  • 响应仍在 Redis 用户态缓冲区或操作系统发送缓冲区;
  • 客户端实际收到响应的时间继续增长;
  • 极端情况下触发客户端输出缓冲区限制并断开连接。

这类问题通常不应通过无限增大缓冲区解决。大响应、慢客户端、Pub/Sub 消费者落后都可能制造这类风险。


七、时间复杂度不是延迟保证

Redis 命令文档中的复杂度是分析工具,但不是生产延迟承诺。

7.1 O(1) 也可能很慢

GET 的字典查找通常接近 O(1),但以下因素仍可能增加耗时:

  • value 本身很大;
  • 响应序列化和网络发送量很大;
  • key 过期处理或内存管理产生额外工作;
  • CPU 被其他任务占用;
  • 主线程前方已有长命令排队。

因此,“命令是 O(1)”只能说明数据结构操作的渐进复杂度,不能推出客户端必然低延迟。

7.2 O(N) 中的 N 必须具体化

对于不同命令,N 可能表示:

  • 数据库中的 key 数量;
  • List 的元素数量;
  • Set 的成员数量;
  • Hash 的字段数量;
  • ZSet 的返回范围;
  • Stream 的读取条目数;
  • 脚本循环次数;
  • 事务中命令数量。

例如:

DEL huge-key

如果 huge-key 是一个包含大量元素的复合对象,删除可能需要释放大量内存。可以考虑:

UNLINK huge-key

UNLINK 会先从键空间中摘除 key,再尝试异步释放其内存。它可以降低主线程直接释放大对象的风险,但不代表释放工作消失:

  • 后台线程仍需要处理内存释放;
  • 大对象释放可能增加后台 CPU;
  • 内存压力和碎片情况仍会影响系统;
  • 某些对象或场景的释放路径并不一定获得同样的收益。

7.3 迭代命令降低单次停顿,但不保证一致快照

SCANSSCANHSCANZSCAN 采用游标迭代。一个完整遍历通常需要多次调用:

redis-cli SCAN 0 COUNT 2
redis-cli SCAN <上一次返回的游标> COUNT 2

终止条件是返回游标为 0。遍历期间可能出现:

  • 重复元素;
  • 修改导致元素在不同阶段出现或消失;
  • 单次返回数量与 COUNT 不完全相等。

所以它适合渐进式清理、统计和迁移等场景,但不能直接当作严格快照扫描。


八、事务、脚本和 Function 为什么会形成长阻塞

8.1 MULTI/EXEC 的原子性不是并行性

事务示例:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> LPUSH jobs task-1
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1

MULTI 后的普通命令通常只是进入事务队列,并没有立即执行。EXEC 时,Redis 按顺序执行队列中的命令,事务执行期间不会插入其他客户端的普通命令。

因此:

  • 事务保证一组命令的连续执行;
  • 事务不会把命令并行化;
  • 事务中积累大量命令会形成一次较长的主线程工作;
  • 客户端在排队阶段看到的是 QUEUED,真正的数据修改发生在 EXEC

WATCH 还会增加乐观事务控制:如果被监视 key 在 EXEC 前发生变化,事务可能取消。它解决的是条件更新竞争,不是执行线程并行问题。

8.2 Lua 脚本和 Redis Functions 是原子执行单元

例如:

redis-cli EVAL "return redis.call('INCR', KEYS[1])" 1 counter

脚本执行期间,Redis 不会在脚本内部插入其他客户端命令。这个性质适合实现“检查并修改”的原子逻辑,但也带来明确边界:

for i = 1, 100000000 do
    -- 大量计算
end
return 1

如果脚本执行时间很长,其他客户端会等待。脚本超时处理也不能简单理解为可以安全抢占脚本;脚本执行模型强调原子性,强制终止可能带来状态一致性问题。生产中应限制脚本工作量,并通过拆分数据、减少循环和控制输入规模避免长脚本。

Redis Functions 也遵循类似的原子执行思路。将逻辑注册到服务器并不会改变其主线程执行边界。


九、过期、淘汰、内存释放和持久化如何影响延迟

9.1 过期键不是只在后台删除

Redis 处理过期键通常结合两类机制:

  1. 惰性过期:访问 key 时检查是否已过期;
  2. 主动过期:周期性抽样检查并删除过期 key。

因此,某条读命令可能顺带触发过期处理。大规模过期集中发生时,主动过期也可能消耗主线程时间。

9.2 内存淘汰会增加写命令成本

当达到 maxmemory,写命令可能触发淘汰。淘汰策略需要选择候选 key、检查对象并释放内存,因此写延迟不再只取决于写入数据结构本身。

必须区分:

  • 内存不足导致的命令失败;
  • 淘汰策略导致的额外工作;
  • 大对象释放导致的主线程或后台线程负载;
  • 内存碎片导致的 RSS 增长。

9.3 BGSAVE 和 AOF 重写不等于零延迟

后台持久化把主要写盘工作放入子进程,但创建子进程需要 fork()fork() 采用写时复制语义:

  • 子进程起初共享父进程的内存页;
  • 父进程继续写入时,修改页需要复制;
  • 数据集越大,fork 的页表处理和后续写时复制压力可能越明显;
  • 物理内存不足时,复制和换页可能放大延迟。

因此,“后台保存”意味着持久化工作不完全占用主线程,并不意味着持久化期间绝对没有延迟影响。

AOF 重写、复制建立、加载大数据集、磁盘抖动也都可能在不同阶段影响延迟。诊断时应同时看 Redis 进程、子进程、磁盘和内存,而不能只看命令耗时。


十、延迟诊断:指标分别说明什么

10.1 SLOWLOG:命令执行阶段的慢记录

可以先查看:

redis-cli SLOWLOG GET 10

典型结果结构类似:

1) 1) (integer) 42
   2) (integer) 1710000000
   3) (integer) 12500
   4) 1) "LRANGE"
      2) "big-list"
      3) "0"
      4) "-1"

这里的时间单位通常是微秒。结果中的字段包括慢日志 ID、时间戳、执行耗时和命令参数;具体返回结构以当前版本为准。

需要注意:

  • slowlog-log-slower-than 控制记录阈值;
  • slowlog-max-len 控制保留数量;
  • 慢日志记录的是命令执行相关耗时,不等于客户端完整 RTT;
  • 网络排队、客户端读取慢、请求在执行前等待,都可能不完整反映在 SLOWLOG 中;
  • 记录命令参数可能包含敏感信息,生产环境需考虑暴露风险。

10.2 LATENCY:延迟事件和尖峰

可以启用延迟监控阈值:

redis-cli CONFIG SET latency-monitor-threshold 100
redis-cli LATENCY LATEST
redis-cli LATENCY DOCTOR

这里的 100 通常表示微秒阈值。延迟监控用于记录 Redis 内部不同延迟事件的尖峰,例如命令、fork、AOF、过期处理等类别;可用事件类别和诊断输出应以当前版本为准。

风险与验证:

  • 设置过低的阈值会产生更多监控开销和数据;
  • 临时 CONFIG SET 的持久化行为与配置文件不同,重启后未必保留;
  • 关闭或恢复时应明确执行并验证:
redis-cli CONFIG SET latency-monitor-threshold 0
redis-cli CONFIG GET latency-monitor-threshold

10.3 INFO:判断是主线程、客户端还是复制问题

常用检查:

redis-cli INFO stats
redis-cli INFO clients
redis-cli INFO commandstats
redis-cli INFO persistence
redis-cli INFO replication

可以重点关注:

  • connected_clients:连接数;
  • blocked_clients:处于阻塞状态的客户端数;
  • 命令处理量和拒绝量;
  • 各命令累计调用次数与耗时;
  • AOF、RDB、fork 状态;
  • 复制偏移、连接状态和延迟相关信息。

INFO commandstats 是累计统计,不是最近一次请求的实时剖析。某命令累计耗时高,可能来自调用次数多,也可能来自单次很慢,必须结合调用次数计算平均值并进一步看分位数。

10.4 CLIENT LIST:定位客户端状态

redis-cli CLIENT LIST

可以观察客户端地址、空闲时间、输入输出缓冲区以及状态标志。诊断时要寻找:

  • 长时间空闲但输出缓冲区很大的客户端;
  • 使用 Pub/Sub 但消费速度过慢的客户端;
  • 处于阻塞状态的客户端;
  • 连接数量异常增长;
  • 单个客户端发送 pipeline 或大命令造成的输入积压。

10.5 客户端侧指标不可缺失

Redis 端没有记录完整客户端 RTT 的单一指标。客户端应记录:

  • DNS、连接建立和连接池等待;
  • 请求写入耗时;
  • 等待响应耗时;
  • 响应读取耗时;
  • 超时、重试和连接断开;
  • pipeline 中批量请求的总耗时与单条命令估算。

否则只看 SLOWLOG,容易把“网络慢”误诊为“Redis 执行慢”。


十一、几个可复现的延迟实验

以下实验适用于本地或测试 Redis,命令输出中的具体数字会因版本、机器和数据规模不同而变化。

实验一:观察慢命令如何推迟后续命令

先构造一个较大的 List:

redis-cli DEL big-list
for i in $(seq 1 100000); do
  redis-cli RPUSH big-list "$i" >/dev/null
done

然后执行:

redis-cli SLOWLOG RESET
redis-cli LRANGE big-list 0 -1 >/dev/null
redis-cli SET probe 1
redis-cli SLOWLOG GET 10

这个实验的目的不是得到固定毫秒数,而是观察:

  1. LRANGE 需要处理大量元素;
  2. 它会生成大响应,即使客户端将输出重定向到 /dev/null,Redis 仍需构造和发送响应;
  3. 在同一实例上同时发送其他命令时,其他命令可能出现延迟;
  4. SLOWLOG 可能记录 LRANGE,但它不等于另一个客户端测得的完整 RTT。

清理数据:

redis-cli UNLINK big-list

UNLINK 可以减少同步释放大对象造成的主线程停顿,但仍需观察后台内存释放和 RSS。

实验二:观察客户端阻塞而非服务器停顿

终端一:

redis-cli BLPOP jobs 10

终端二:

redis-cli SET health ok
redis-cli INFO clients | grep blocked_clients

在没有 RPUSH 的前提下,终端一会等待或超时;终端二的 SET 仍应可以处理。然后执行:

redis-cli RPUSH jobs task-1

终端一会被唤醒。

该实验说明:

  • BLPOP 的等待状态属于客户端阻塞语义;
  • 事件循环并没有为了这个客户端忙等;
  • blocked_clients 增加不能直接证明主线程被卡住。

实验三:观察脚本对其他命令的影响

执行一个人为延长的脚本:

redis-cli EVAL '
local x = 0
for i = 1, 50000000 do
  x = x + i
end
return x
' 0

同时从另一个终端反复执行:

redis-cli --latency -h 127.0.0.1 -p 6379

redis-cli --latency 的输出也会受本机和网络影响,但通常可以看到脚本运行期间延迟尖峰。循环次数不是性能承诺,可能因 CPU 架构而需要调整;不要在生产实例运行这种实验脚本。


十二、常见误解及其反例

误解一:Redis 单线程,所以不会有并发问题

错误。Redis 主线程串行执行,反而意味着一个长任务可以影响所有客户端。

反例:

客户端 A:执行大范围 ZRANGE
客户端 B:执行 GET

B 的 GET 不能绕开 A 正在占用的主线程。

误解二:启用 IO 线程后,命令会自动并行

错误。IO 线程处理网络读写,核心数据命令仍通常由主线程执行。

反例:

io-threads 8

并不表示可以同时在八个线程中修改同一个 Hash,也不改变事务、脚本和 Function 的原子执行边界。

误解三:BLPOP 60 会让 Redis 卡住 60 秒

通常错误。它使当前客户端等待,Redis 可以继续处理其他客户端。

真正需要警惕的是:

  • 阻塞客户端数量过多;
  • 客户端连接资源被耗尽;
  • 业务线程因等待连接或响应而堆积;
  • 模块代码在主线程直接阻塞;
  • 某个普通命令或脚本真的长时间占用主线程。

误解四:SCAN 可以完全替代 KEYS

它降低单次调用的停顿风险,但二者语义不同。SCAN 不是快照,也可能返回重复项;完整遍历仍需要处理大量数据。

误解五:SLOWLOG 没有记录,就说明客户端不慢

错误。请求可能在以下位置等待:

客户端连接池
网络
Redis 输入队列
主线程命令队列
Redis 输出缓冲区
客户端读取逻辑

SLOWLOG 主要覆盖命令执行阶段,必须和客户端 RTT、LATENCYINFO、系统 CPU 和网络指标结合。


十三、按故障路径组织排查

遇到 Redis 延迟升高时,可以沿着请求路径定位,而不是先随意修改线程数。

路径一:命令是否正在主线程中执行过久

检查:

redis-cli SLOWLOG GET 128
redis-cli INFO commandstats
redis-cli LATENCY LATEST

重点寻找:

  • 大范围集合读取;
  • KEYS、全量删除或集合运算;
  • 长事务;
  • Lua、Function;
  • 复杂模块命令。

修复方向是减少单次工作量、限制返回范围、分批执行或调整数据模型,而不是首先增加 IO 线程。

路径二:是否是大响应或慢客户端

检查:

redis-cli CLIENT LIST
redis-cli INFO clients

如果某客户端输出缓冲区持续增大,应确认:

  • 客户端是否及时读取;
  • 是否订阅了高流量 Pub/Sub;
  • 是否一次请求返回过多成员;
  • 是否使用了不合适的 pipeline 深度;
  • 是否需要限制单个客户端的输出缓冲区。

断开慢客户端可以缓解资源压力,但会造成业务失败,必须确认客户端重连、重放和数据一致性策略。

路径三:是否是持久化、fork 或磁盘问题

检查:

redis-cli INFO persistence
redis-cli INFO memory

并结合操作系统观察:

  • Redis 主进程和子进程 CPU;
  • RSS、可用内存和交换分区;
  • 磁盘延迟、吞吐和写入队列;
  • fork 发生时间与延迟尖峰是否一致。

如果内存接近极限,盲目开启更多后台工作可能使问题更严重。持久化策略需要在数据安全目标、磁盘能力和延迟预算之间取舍。

路径四:是否是复制或等待语义

如果业务使用 WAIT、同步复制要求或集群故障转移相关操作,应检查:

redis-cli INFO replication

并区分:

  • 主节点本地命令执行慢;
  • 副本处理慢;
  • 主从网络延迟;
  • 副本断连或重同步;
  • 客户端等待确认超时。

这类问题不能仅通过降低本地命令复杂度解决。


十四、部署边界:单机、复制和 Cluster 不改变基本执行模型

14.1 单机 Redis

单个实例的命令执行主路径由一个主线程串行推进。IO 线程和后台工作只能分担特定阶段。

14.2 主从复制

复制会增加:

  • 命令传播;
  • 复制缓冲区维护;
  • 副本网络输出;
  • 副本重同步或 RDB 传输;
  • 可能的 WAIT 等等待语义。

读请求如果仍发送到主节点,主节点执行线程仍是共享资源;把读流量分发到副本可以分散实例负载,但会引入复制延迟和读一致性取舍。

14.3 Redis Cluster

Cluster 将 key 空间分布到多个节点。不同 slot 的请求可能由不同节点并行处理,但这不是单个节点内部把命令执行改成多线程。

事务、脚本和多 key 命令还受到 hash slot 约束。相关 key 通常需要通过 hash tag 放入同一 slot,例如:

user:{42}:profile
user:{42}:orders

这样做只解决路由到同一 slot 的问题,不会消除该节点主线程的串行执行,也不会让跨节点操作自动获得单节点事务语义。


十五、性能优化应从“占用主线程的工作”开始

围绕事件循环和延迟,优化顺序应当是:

  1. 找出主线程上的长命令和长脚本;
  2. 限制单次返回规模;
  3. 使用分页、游标和分批删除;
  4. 避免把大对象集中创建、读取或释放;
  5. 检查 pipeline 是否过大;
  6. 检查慢客户端和输出缓冲区;
  7. 再评估 IO 线程是否能降低网络处理成本;
  8. 最后结合持久化、复制、内存和系统指标验证。

例如,下面两种命令虽然都可能涉及很多数据,但延迟形态不同:

LRANGE queue 0 -1

一次性返回整个 List,容易产生大响应和长主线程工作。

LRANGE queue 0 99

限制单次返回范围后,单次占用时间和输出压力通常更可控,但业务需要额外处理分页语义。

同样,下面两种删除也有不同的阻塞风险:

DEL large-hash

可能同步释放大量对象。

UNLINK large-hash

先从键空间移除,再尝试异步释放;它降低了部分主线程停顿,但不能消除内存回收总成本。

最终应记住这条因果链:

事件循环负责发现和推进事件
        |
        v
IO 线程可分担网络读写
        |
        v
主线程仍执行核心命令逻辑
        |
        v
长命令、长脚本、事务和大对象操作形成执行队列
        |
        v
队列等待传播为其他请求的尾延迟
        |
        v
慢客户端、持久化、复制和内存压力进一步放大端到端延迟

Redis 的低延迟并不是由“单线程”或“IO 线程”某一个标签单独保证的,而取决于每一轮事件循环中主线程需要完成多少工作、网络缓冲区能否及时排空,以及后台任务是否与机器资源和数据规模相匹配。只有把命令执行时间、事件循环排队时间、网络 IO 时间和客户端等待时间分开测量,延迟诊断才不会停留在表面现象。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。