数据库基础体系 · 第 118/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Redis 内核与事件循环:命令执行、IO 线程、阻塞点和延迟
Redis 经常被概括为“单线程数据库”。这句话抓住了一个重要事实:同一个 Redis 实例中的命令执行通常由主线程串行完成。但它没有完整描述 Redis 的事件循环、网络 I/O 线程、后台线程、子进程以及阻塞命令。
要分析 Redis 延迟,必须区分至少四段时间:
其中:
- :建立连接或连接复用相关的时间;
- :请求已经到达 Redis,但尚未被读取、解析或调度的时间;
- :命令实际执行时间;
- :响应生成后等待写出或发送缓冲区可用的时间;
- :客户端与 Redis 之间的网络传输时间。
SLOWLOG 主要观察命令执行阶段,不能代表客户端感知的完整延迟。IO 线程可以降低网络读写开销,却不会把普通 Redis 命令变成并行执行。真正造成长尾的,往往是执行线程上的长任务、事件循环中的阻塞操作、客户端输出堆积,或者后台操作触发的瞬时停顿。
一、先建立正确的模型:一个 Redis 实例中的几类执行者
一个现代 Redis 实例至少可以从以下角度理解:
-
主线程
- 驱动事件循环;
- 接受连接、处理定时事件;
- 解析和调度命令;
- 执行绝大多数命令;
- 处理事务、Lua 脚本、Redis Functions 等原子执行逻辑;
- 负责复制、过期、客户端管理等许多核心工作。
-
网络 IO 线程
- 在启用并且适用时,协助读取客户端请求或向客户端写出响应;
- 不负责把普通命令分散到多个线程并行执行;
- 受
io-threads和io-threads-do-reads等配置影响。
-
后台线程
- 例如异步释放内存、后台关闭文件等;
- 具体职责取决于版本和配置;
- 后台线程不意味着数据结构命令可以安全地与主线程并发修改。
-
子进程
BGSAVE、AOF 重写等后台持久化流程通常会创建子进程;fork()本身以及写时复制带来的内存压力可能引起延迟;- 子进程与主线程的关系不同于 IO 线程。
因此,“Redis 是单线程”更准确的说法是:
Redis 的核心数据访问和命令执行路径以主线程串行为基础;网络 IO、后台释放、持久化等工作可以使用其他执行实体,但这些并不等价于普通命令的并行执行。
串行执行带来一个重要性质:如果一个命令在主线程中运行,其他需要该线程继续推进的命令就必须等待它完成。
二、事件循环到底做什么
2.1 文件事件和时间事件
Redis 使用事件驱动模型。这里的“文件事件”不是只指普通文件,而是指操作系统提供的文件描述符事件,主要包括:
- 监听 socket 可读:有新客户端连接;
- 客户端 socket 可读:客户端发送了请求;
- 客户端 socket 可写:内核发送缓冲区可以继续接受响应数据;
- 连接异常或关闭。
Redis 还需要处理时间相关的工作,例如:
- 定期执行数据库维护;
- 处理过期键;
- 执行后台任务的状态推进;
- 更新统计信息;
- 处理超时客户端或阻塞客户端。
事件循环可以抽象成:
初始化监听 socket
|
v
等待操作系统报告事件
|
+--> 新连接:accept,创建客户端状态
|
+--> 客户端可读:读取请求,解析完整命令
|
+--> 命令执行:访问数据结构,生成响应
|
+--> 客户端可写:发送响应
|
+--> 时间事件:执行周期性维护
|
v
继续下一轮事件循环
底层等待机制通常根据平台使用 epoll、kqueue 等高效多路复用接口。Redis 不需要为每个连接创建一个阻塞线程,而是让一个事件循环同时管理大量连接。
2.2 “可读”不等于“一条完整命令”
TCP 是字节流,不保留应用层消息边界。一次 read() 可能得到:
- 半个 RESP 请求;
- 一个完整请求;
- 多个请求拼接在一起;
- 请求和后续请求的一部分。
因此 Redis 为每个客户端维护输入缓冲区,过程大致是:
- socket 被标记为可读;
- 读取字节追加到客户端输入缓冲区;
- RESP 解析器检查是否已经形成完整命令;
- 如果不完整,等待下一次可读事件;
- 如果完整,提取命令和参数;
- 执行命令或进入事务、阻塞客户端等状态;
- 将响应放入客户端输出缓冲区;
- 必要时注册写事件。
例如,客户端发送:
*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 通常需要:
- 根据 key 找到对应数据库字典项;
- 检查键是否过期;
- 检查数据类型是否为 String;
- 读取值;
- 构造 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 用排队模型理解延迟
令:
- :请求到达率;
- :单条请求占用命令执行线程的平均时间;
- :执行线程利用率。
当 接近 1 时,即使平均命令执行时间没有变化,排队时间也会迅速增加。
举例:
- 平均执行时间 ;
- 到达率 次/秒。
则:
执行线程平均有 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 线程通常不负责执行 GET、HSET、ZADD 等数据命令。典型流程仍然是:
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
这个命令需要:
- 定位大量有序集合成员;
- 读取并组织结果;
- 构造很大的 RESP 响应。
IO 线程可能协助最后的网络写出,但以下工作仍可能占用主线程:
- 有序集合访问;
- 结果组织;
- 响应对象构造;
- 命令相关的过期、复制和统计处理。
所以 IO 线程可能降低网络瓶颈,却不能把一个 O(N) 的大范围查询变成 O(1),也不能让多个大查询在主线程上同时运行。
5.4 IO 线程的取舍
IO 线程并非无成本:
- 线程调度和同步本身有开销;
- CPU 核心不足时,可能与主线程争用;
- 读写线程数过多会增加上下文切换;
- 网络负载较低时,收益可能很小;
- 诊断时必须区分主线程 CPU 高和 IO 线程 CPU 高。
正确的验证方式不是只看配置,而是进行对照测试:
- 固定数据集、客户端数量和 pipeline 深度;
- 分别测试
io-threads 1与多线程 IO; - 同时观察吞吐、P50/P99、主线程 CPU、IO 线程 CPU、网络带宽;
- 额外测试大响应和慢客户端,因为 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 阻塞命令等待数据:客户端阻塞,服务器仍可工作
BLPOP、BRPOP、XREAD BLOCK 等命令具有“阻塞客户端”的语义。
例如,先启动:
redis-cli BLPOP jobs 10
如果 jobs 为空,客户端会等待最多 10 秒。此时另一个客户端执行:
redis-cli RPUSH jobs task-1
等待中的客户端可能收到:
1) "jobs"
2) "task-1"
这里的“阻塞”是:
- 该客户端进入等待状态;
- Redis 记录它等待哪些 key 或 Stream;
- 当数据到达时唤醒匹配的客户端;
- 超时则返回空结果。
它不是让 Redis 主线程执行一个持续 10 秒的循环。事件循环仍可以处理其他客户端的 GET、SET 等请求。
可以通过以下命令观察阻塞客户端:
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 迭代命令降低单次停顿,但不保证一致快照
SCAN、SSCAN、HSCAN、ZSCAN 采用游标迭代。一个完整遍历通常需要多次调用:
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 处理过期键通常结合两类机制:
- 惰性过期:访问 key 时检查是否已过期;
- 主动过期:周期性抽样检查并删除过期 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
这个实验的目的不是得到固定毫秒数,而是观察:
LRANGE需要处理大量元素;- 它会生成大响应,即使客户端将输出重定向到
/dev/null,Redis 仍需构造和发送响应; - 在同一实例上同时发送其他命令时,其他命令可能出现延迟;
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、LATENCY、INFO、系统 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 的问题,不会消除该节点主线程的串行执行,也不会让跨节点操作自动获得单节点事务语义。
十五、性能优化应从“占用主线程的工作”开始
围绕事件循环和延迟,优化顺序应当是:
- 找出主线程上的长命令和长脚本;
- 限制单次返回规模;
- 使用分页、游标和分批删除;
- 避免把大对象集中创建、读取或释放;
- 检查 pipeline 是否过大;
- 检查慢客户端和输出缓冲区;
- 再评估 IO 线程是否能降低网络处理成本;
- 最后结合持久化、复制、内存和系统指标验证。
例如,下面两种命令虽然都可能涉及很多数据,但延迟形态不同:
LRANGE queue 0 -1
一次性返回整个 List,容易产生大响应和长主线程工作。
LRANGE queue 0 99
限制单次返回范围后,单次占用时间和输出压力通常更可控,但业务需要额外处理分页语义。
同样,下面两种删除也有不同的阻塞风险:
DEL large-hash
可能同步释放大量对象。
UNLINK large-hash
先从键空间移除,再尝试异步释放;它降低了部分主线程停顿,但不能消除内存回收总成本。
最终应记住这条因果链:
事件循环负责发现和推进事件
|
v
IO 线程可分担网络读写
|
v
主线程仍执行核心命令逻辑
|
v
长命令、长脚本、事务和大对象操作形成执行队列
|
v
队列等待传播为其他请求的尾延迟
|
v
慢客户端、持久化、复制和内存压力进一步放大端到端延迟
Redis 的低延迟并不是由“单线程”或“IO 线程”某一个标签单独保证的,而取决于每一轮事件循环中主线程需要完成多少工作、网络缓冲区能否及时排空,以及后台任务是否与机器资源和数据规模相匹配。只有把命令执行时间、事件循环排队时间、网络 IO 时间和客户端等待时间分开测量,延迟诊断才不会停留在表面现象。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:SQLite 安全边界:文件权限、注入、扩展、加密选择和不可信数据库
- 下一篇:Redis 对象编码与内存:SDS、Dict、Listpack、碎片和大 Key
- 延伸:Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream
- 延伸:Redis 监控与延迟诊断:Slowlog、Latency、内存、复制和热点
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论