返回文章列表

Redis 高性能原理

系统梳理 Redis 高性能的请求链路、事件循环、I/O 多路复用、主线程、I/O Threads、Pipeline、批量命令、大 Key 与性能瓶颈定位方法。

第 12 章:Redis 高性能原理

1. 本章定位

Redis 的高性能不能简单归结为“数据放在内存里”或“Redis 是单线程的”。一个 Redis 请求的端到端耗时还包括客户端排队、连接池等待、网络往返、协议解析、命令排队、命令执行、响应编码、套接字写出和客户端解码。内存访问只是其中一环。

本章以 Redis Open Source 8.8 为主要技术基线,解释事件循环、I/O 多路复用、命令执行线程、后台线程和 Redis 8 I/O Threads 的职责边界;同时分析 Pipeline、批量命令、Lua、大 Key、大集合运算以及 CPU、网络、内存、磁盘对延迟和吞吐量的影响。Redis 当前下载页面提供 Redis Open Source 8.8,官方 Go 客户端 go-redis/v9 也已声明支持 Redis 8.8。(Redis)

本章的核心目标不是背诵“Redis 很快”,而是建立一套可用于生产优化和故障诊断的模型:

先分解请求链路,再定位瓶颈资源,最后选择针对性的优化手段。


2. 学习目标

完成本章后,应能够:

  1. 解释为什么内存访问只是 Redis 高性能的一部分。
  2. 描述 Redis 事件循环及 I/O 多路复用的工作方式。
  3. 完整说明客户端请求从读取、解析、执行到响应写出的路径。
  4. 区分命令执行主线程、I/O Threads、后台 I/O 线程和持久化子进程。
  5. 说明 Redis 6/7 与 Redis 8 的 I/O Threads 差异及其适用边界。
  6. 根据 Pipeline 批次、命令复杂度和响应大小估算其收益与风险。
  7. 区分 CPU、网络、内存、磁盘以及客户端自身造成的性能瓶颈。
  8. 使用 redis-cliredis-benchmarkgo-redis/v9 进行有边界的性能测试。

2.1 本章边界与跳转

本章是 Redis 高性能原理的主章节,回答“为什么快”和“为什么仍会慢”。第 1 章中的“Redis 为什么快”和“Redis 是单线程吗”只保留入门版回答;本章给出端到端性能模型。第 20 章会再次出现性能题,但定位是可观测性、故障诊断和综合面试入口;若需要排查步骤、指标和 Runbook,以第 20 章为主。Pipeline 的原子语义见[第 9 章](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions/),持久化引发的延迟见[第 14 章](/blog/tech/Redis/14.RDB、AOF 与数据恢复/)。

3. 核心概念

3.1 Redis 快,不只是因为内存

一次 Redis 请求的端到端延迟,可以抽象为:

总延迟
= 客户端排队
+ 连接池等待
+ 请求序列化
+ 网络发送
+ Redis 服务端排队
+ RESP 协议解析
+ 命令执行
+ 响应构造
+ 网络返回
+ 客户端反序列化

Redis 把主要数据集保存在内存中,避免了普通随机查询频繁等待磁盘寻道和块读取。但即使数据已经在内存里,系统仍可能受以下因素限制:

  • 单个命令占用主线程过久。
  • CPU 缓存未命中或内存带宽不足。
  • 大量数据在用户态与内核态之间复制。
  • 网络 RTT 或网卡带宽不足。
  • RESP 请求解析和响应编码成本。
  • AOF、RDB、fork 和 Copy-on-Write 引起的延迟。
  • 客户端连接池耗尽、Go GC 或无边界并发。
  • 大 Key、大响应和慢消费者造成的输出缓冲区膨胀。

Redis 官方性能文档也将网络、CPU、内存带宽、虚拟化、连接数量、持久化和客户端压测能力列为影响性能的重要因素。(Redis)

因此,更准确的表述是:

Redis 的高性能来自内存数据访问、高效数据结构、事件驱动网络模型、紧凑协议、串行命令执行、批处理能力以及后台任务隔离等因素的共同作用。


3.2 RESP 与请求—响应模型

Redis 客户端与服务端通常通过 TCP 或 Unix Domain Socket 通信,使用 RESP,即 Redis Serialization Protocol。RESP 的目标是易于实现、快速解析且二进制安全;客户端将命令和参数编码为字符串数组,服务端根据命令返回相应类型的结果。(Redis)

普通请求—响应过程为:

客户端发送命令

服务端读取和解析命令

服务端执行命令

服务端返回响应

客户端读取响应后再发送下一个命令

如果客户端必须等到上一个响应回来后才能发送下一个请求,那么即使 Redis 每个命令只执行几微秒,整体吞吐量也可能受 RTT 限制。

例如,单连接非 Pipeline 模式下,吞吐量的粗略上限约为:

单连接吞吐量 ≈ 1 / 单次往返耗时

如果 RTT 为 1 毫秒,那么一个严格串行的连接理论上大约只能完成 1000 次往返,而不是充分利用 Redis 服务端的处理能力。


3.3 事件循环

Redis 使用事件循环协调文件事件和时间事件。

  • 文件事件:套接字可读、套接字可写、连接建立等。
  • 时间事件:周期性维护任务、客户端检查、过期处理、统计更新等。

Redis 的 aeMain 会不断调用 aeProcessEvents;每轮循环在必要时执行 beforeSleep,调用底层多路复用 API 等待事件,处理就绪的读写事件,再处理时间事件。(GitHub)

可以将其简化为:

while Redis 未停止:
    执行 beforeSleep 阶段任务
    等待套接字或定时事件
    处理可读连接
    处理可写连接
    处理时间事件

事件循环的意义不是让一个线程“同时执行很多命令”,而是:

一个线程不需要阻塞在某一个连接上等待数据,可以只处理当前已经就绪的连接。

如果一个命令本身执行 500 毫秒,那么事件循环也会被它占用约 500 毫秒。在这段时间内,其他普通命令仍然需要等待。


3.4 I/O 多路复用

I/O 多路复用允许一个事件循环监视大量套接字,并由操作系统通知哪些套接字当前可读或可写。

Redis 会根据操作系统选择相应实现,例如:

  • Linux 通常使用 epoll
  • macOS、FreeBSD 等通常使用 kqueue
  • 其他平台可使用 pollselect 等实现。

在 Linux 上,Redis 将可读和可写事件注册给 epoll,调用 epoll_wait 等待就绪事件,再将 EPOLLINEPOLLOUT 等映射为 Redis 内部的可读、可写事件。(GitHub)

在支持 kqueue 的系统上,Redis通过 EVFILT_READEVFILT_WRITE 注册事件,并通过 kevent 获得已经就绪的连接。(GitHub)

抽象过程如下:

Redis:我关心 fd=10、11、12、13 的读事件
操作系统:fd=11、13 已经可读
Redis:只处理 fd=11、13

这避免了两种低效模型:

  1. 为每个连接永久创建一个服务端线程。
  2. 每轮循环逐个扫描全部连接并尝试读取。

设当前有 N 个连接、其中 K 个连接就绪,Redis 应用层主要处理的是这 K 个就绪事件,而不是为所有 N 个连接执行完整请求处理。不过,内核内部的精确复杂度与操作系统实现、事件注册方式和负载模式有关,不应简单宣称所有操作都是严格的 O(1)


3.5 一个请求从读取到响应的路径

以 Redis 8 开启 I/O Threads 的普通请求为例,请求链路可以概括为:

客户端
  │  TCP + RESP

I/O 线程读取 socket

  ├─ 写入客户端输入缓冲区
  ├─ 解析 RESP
  └─ 得到完整命令


主线程
  ├─ 命令查找
  ├─ 参数与权限检查
  ├─ 执行命令
  ├─ 修改数据集
  ├─ 生成响应对象
  └─ 处理复制/AOF 等传播逻辑


I/O 线程
  ├─ 编码或整理响应
  ├─ 写入 socket
  └─ 继续监听客户端

Redis 8 的 I/O 线程可以拥有自己的事件循环,负责客户端套接字读取、写出和协议解析;当获得完整命令后,将客户端交给主线程执行,主线程完成命令后再交还 I/O 线程。Redis 8.8 的 iothread.c 中可以看到 I/O 线程与主线程之间的客户端队列和交接过程。(GitHub)

不开启 I/O Threads 时,读取、解析、执行和大部分响应写出工作都由主线程的事件循环处理。


3.6 Redis 不能简单描述成“完全单线程”

更准确的线程与进程划分如下。

执行单元主要职责是否直接执行普通数据命令
主线程数据集访问、普通命令执行、事件循环协调、复制和持久化传播
I/O Threads客户端 socket 读写、RESP 解析、客户端交接通常否
后台 I/O 线程延迟释放、部分文件关闭、AOF fsync 等后台工作
RDB/AOF 子进程BGSAVE、AOF 重写等重量级持久化工作
扩展组件工作线程搜索、索引或模块自身后台任务,依实现而定不代表核心命令可任意并行

Redis 官方配置文件也明确表述:Redis“主要是单线程”,但 UNLINK、慢速 I/O 等工作会在旁路线程执行,客户端套接字读取和写出也可以由 I/O Threads 处理。(GitHub)

所以面试中应回答:

Redis 核心数据命令通常由主线程串行执行,但 Redis 整个进程并不是只有一个线程。网络 I/O、协议解析、异步释放和持久化后台任务可以由其他线程或子进程承担。


3.7 为什么串行命令执行反而能获得高性能

普通命令串行执行有几个重要收益。

1. 减少锁竞争

如果多个线程同时修改全局键空间、哈希表、过期字典、客户端状态和复制状态,就需要大量互斥锁、读写锁或无锁结构。

锁会带来:

  • 获取与释放开销。
  • 线程阻塞和唤醒。
  • CPU Cache Line 在多个核心间迁移。
  • 死锁和锁顺序管理。
  • 优先级反转。
  • 更复杂的故障复现和状态推理。

Redis 让大部分普通命令在主线程串行执行,就不需要为每个 Key 或每个数据结构增加细粒度锁。

2. 命令原子性更容易保证

单个普通命令在执行期间,其他普通命令不会插入其中,因此诸如 INCRHINCRBY 等天然具有原子性。

这并不意味着多个命令组成的业务操作天然原子;多命令原子性仍然需要事务、Lua、Functions 或重新设计数据模型。

3. 更好的局部性与可预测性

连续处理命令时,命令表、热点 Key、客户端对象和共享元数据可能停留在 CPU Cache 中。没有大量线程切换时,执行路径也更稳定。

4. 简化实现

更简单的并发模型意味着更少的同步代码和异常状态,能够把更多 CPU 时间用于实际命令处理。

但其代价也很明确:

一个 Redis 实例的普通命令执行能力,最终受单个主执行线程所能提供的 CPU 时间限制。


3.8 单线程命令执行的瓶颈

以下操作会长期占用主线程:

  • 运行时间过长的 Lua 脚本或 Function。
  • SMEMBERSHGETALL 等返回超大集合的命令。
  • 大规模 SINTERSUNIONSDIFF 等集合运算。
  • 对超长 List、Sorted Set 执行大范围遍历。
  • 大 Key 同步删除和内存回收。
  • 读取或写入超大 Value。
  • 一次处理过大的 Pipeline。
  • 高成本 Search、JSON 或模块命令中的同步阶段。
  • 过高的过期、淘汰或内存管理压力。

主线程利用率接近饱和后,延迟往往不是线性增加,而是因为排队迅速恶化:

请求到达速度 < 服务速度:队列基本稳定
请求到达速度 ≈ 服务速度:尾延迟快速上升
请求到达速度 > 服务速度:队列持续增长,最终超时

因此,CPU 使用率达到 100% 之前,P99 延迟就可能已经不可接受。


3.9 I/O Threads 的作用与限制

Redis 6 和 Redis 7

Redis 6 引入经典 I/O Threads。Redis 6/7 默认主要将 socket 写出交给 I/O 线程;如果配置 io-threads-do-reads yes,还可以并行进行读取和协议解析。该功能默认关闭,并不改变普通命令仍由主线程执行的事实。(GitHub)

Redis 8

Redis 8 引入新的 I/O threading 实现。启用后,I/O Threads 可以同时处理:

  • socket 读取。
  • RESP 协议解析。
  • socket 写出。
  • 独立的客户端事件循环。
  • 客户端与主线程之间的批量交接。

Redis 8 中旧的 io-threads-do-reads 配置已经作为废弃配置被忽略;当前只需通过 io-threads 配置线程数。Redis 8.0 官方发行说明将新的 I/O threading 实现列为主要性能改进之一。(Redis)

I/O Threads 不能解决什么

I/O Threads 不会:

  • 让普通 GETSET 在多个执行线程上同时修改核心数据集。
  • 加速长时间运行的 Lua 脚本。
  • 降低大集合运算本身的算法复杂度。
  • 消除 HotKey。
  • 解决大 Key 产生的网络流量。
  • 提升持久化强度或数据一致性。
  • 自动利用所有核心获得线性扩展。

I/O Threads 主要适用于以下情况:

  • CPU 时间有较大比例消耗在 socket 读写和协议解析。
  • 请求较小、请求数量很大。
  • 网络吞吐量较高。
  • 主机至少有多个可用 CPU 核心。
  • 实测表明 I/O 阶段而不是命令计算阶段是瓶颈。

官方 redis.conf 建议只在多核且确实存在 CPU 性能问题时启用,并为其他工作留出 CPU;该参数默认关闭,io-threads 1 表示仍使用主线程。(GitHub)


3.10 Pipeline、批量命令与 Lua

Pipeline

Pipeline 允许客户端连续发送多个命令,不必等待每个命令的独立响应,随后批量读取结果。它主要减少:

  • 网络 RTT。
  • readwrite 系统调用次数。
  • 用户态与内核态切换次数。
  • 客户端逐条调度开销。

官方文档明确指出,Pipeline 是将多个请求一次发出并批量读取响应的性能优化方式。(Redis)

设有 N 条命令,每批 B 条:

非 Pipeline 网络往返次数 ≈ N
Pipeline 网络往返次数 ≈ ceil(N / B)
服务端命令执行次数仍然约为 N

所以 Pipeline 不会:

  • 降低单个命令的算法复杂度。
  • 自动提供事务原子性。
  • 让普通命令在主线程上并行执行。
  • 消除响应数据的网络传输。
  • 解决后一个命令依赖前一个结果的场景。

Pipeline 过大时,客户端和服务端都需要保存更多在途命令及响应。官方建议将大量请求切分为有界批次,而不是无限制地发送。(Redis)

批量命令

MGETMSETHMGET 等批量命令通常比 Pipeline 中的多个独立命令少一些命令分派和协议开销。

但它们也可能:

  • 一次占用主线程较长时间。
  • 返回巨大响应。
  • 在 Cluster 中受到 Hash Slot 限制。
  • 形成不公平的大请求。

Lua 与 Functions

Lua 可以把多个有依赖关系的读、计算、写过程放到服务端执行,减少多次 RTT,并提供脚本范围内的原子执行。

代价是:

  • 脚本执行期间普通命令不能插入。
  • 复杂循环会长期阻塞主线程。
  • 脚本中的数据扫描仍然受算法复杂度影响。
  • 客户端超时不代表脚本已经停止。
  • 脚本执行结果未知时,重试可能造成重复副作用。

因此,Lua 应用于短小、确定、有界的原子逻辑,而不是把大段业务计算迁移到 Redis。


3.11 大 Key 与大集合运算

一个命令在命令文档中标记为 O(1),不等于其端到端耗时与 Value 大小无关。

例如,GET 的键查找可以视为平均 O(1),但返回一个大小为 V 的 Value 时,仍然至少需要:

响应构造/复制成本:O(V)
网络传输成本:O(V)
客户端解析成本:O(V)

类似地:

  • SMEMBERS:通常需要遍历并返回全部成员,时间和响应空间都与成员数相关。
  • HGETALL:需要遍历全部 Field 与 Value。
  • SUNION:成本与输入集合总元素数和输出集合大小有关。
  • ZRANGE:定位起点和返回 M 个元素的成本通常可表示为 O(log N + M)
  • 大 Key 同步删除:键空间删除可能很快,但递归释放大量内部对象会占用主线程。

大 Key 的风险不是单一的“占内存”,而是同时增加:

  • 内存分配和释放时间。
  • CPU Cache Miss。
  • 主线程连续占用时间。
  • 网络流量。
  • 客户端反序列化和 GC。
  • 复制和 AOF 数据量。
  • 故障转移后的恢复时间。

4. 命令与 Go 使用方法

4.1 使用 redis-cli 观察性能

查看线程和事件循环信息

redis-cli INFO threads
redis-cli INFO stats
redis-cli INFO cpu
redis-cli INFO commandstats
redis-cli INFO latencystats
redis-cli CONFIG GET io-threads

Redis 8.0 增加了 INFO threads,可以查看各 I/O 线程的客户端数、读事件数和写事件数。INFO stats 还包括网络流量、命令总量、事件循环周期、事件循环耗时以及由 I/O 线程处理的读写事件数。(Redis)

重点观察:

instantaneous_ops_per_sec
instantaneous_input_kbps
instantaneous_output_kbps
total_commands_processed
eventloop_duration_sum
eventloop_duration_cmd_sum
instantaneous_eventloop_duration_usec
io_threaded_reads_processed
io_threaded_writes_processed

判断思路:

  • eventloop_duration_cmd_sum 占比高:命令执行或数据结构操作可能是瓶颈。
  • 网络 KB/s 接近链路上限:优先检查响应大小、Pipeline 和部署距离。
  • I/O 线程处理量很低:可能没有启用、客户端未分配过去,或负载不适合。
  • commandstats 中某命令 usec_per_call 很高:检查慢命令、大集合和 Lua。

测量客户端观察到的延迟

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

该结果包含网络、服务端排队和 PING 处理时间,并不等于某个业务命令的纯执行时间。

测量运行环境的固有延迟

在 Redis 服务端所在机器执行:

redis-cli --intrinsic-latency 30

该命令会持续占用 CPU 进行测试,不应在业务高峰期随意运行。Redis 官方延迟诊断文档也要求在服务端本机运行该测试。(Redis)

查看慢命令和延迟事件

redis-cli SLOWLOG GET 20
redis-cli SLOWLOG LEN
redis-cli LATENCY LATEST
redis-cli LATENCY DOCTOR

排查时应将这些结果与应用端 P95/P99、连接池等待时间和网络指标联合分析。


4.2 使用 redis-benchmark 对比 Pipeline

先测试无 Pipeline:

redis-benchmark \
  -h 127.0.0.1 \
  -p 6379 \
  -t get,set \
  -n 1000000 \
  -c 128 \
  -d 256 \
  -P 1

再测试每批 16 条:

redis-benchmark \
  -h 127.0.0.1 \
  -p 6379 \
  -t get,set \
  -n 1000000 \
  -c 128 \
  -d 256 \
  -P 16

Redis 8 开启 I/O Threads 后,可以对压测端也启用线程:

redis-benchmark \
  -h 127.0.0.1 \
  -p 6379 \
  -t get,set \
  -n 1000000 \
  -c 256 \
  -d 256 \
  -P 16 \
  --threads 4

-P 指定 Pipeline 深度,默认值为 1;--threads 开启压测工具的多线程模式。官方文档提醒,Pipeline 深度必须接近真实应用,否则测试结果不具有业务代表性。(Redis)

压测时至少保持以下变量一致:

  • Key 分布与热点比例。
  • Value 大小。
  • 命令比例。
  • 连接数。
  • Pipeline 深度。
  • 持久化配置。
  • TLS 配置。
  • Redis 与客户端的网络距离。
  • 是否预热。
  • 是否同时发生 RDB/AOF、主从同步或扩容。

不要用一个本机、无持久化、全部命中、短 Key、小 Value 的结果,直接推断生产容量。


4.3 使用 go-redis/v9 执行有界 Pipeline

以下示例按批次读取 Key,使用整体超时并逐条检查命令结果。

package redisperf

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// BatchGet 返回与 keys 下标对应的结果。
// nil 表示对应 Key 不存在。
func BatchGet(
	parent context.Context,
	rdb *redis.Client,
	keys []string,
	batchSize int,
) ([]*string, error) {
	if rdb == nil {
		return nil, errors.New("redis client is nil")
	}
	if batchSize <= 0 {
		return nil, errors.New("batchSize must be greater than zero")
	}

	// 这是整个批量操作的截止时间,而不是每个批次重新获得 2 秒。
	ctx, cancel := context.WithTimeout(parent, 2*time.Second)
	defer cancel()

	values := make([]*string, len(keys))

	for start := 0; start < len(keys); start += batchSize {
		end := start + batchSize
		if end > len(keys) {
			end = len(keys)
		}

		batchKeys := keys[start:end]
		cmds := make([]*redis.StringCmd, len(batchKeys))

		_, execErr := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
			for i, key := range batchKeys {
				cmds[i] = pipe.Get(ctx, key)
			}
			return nil
		})

		// Pipeline 的 Exec 会返回首个命令错误。
		// GET 不存在时会出现 redis.Nil,但同一批中的其他结果仍需读取。
		if execErr != nil && !errors.Is(execErr, redis.Nil) {
			return nil, fmt.Errorf(
				"execute redis pipeline [%d:%d]: %w",
				start,
				end,
				execErr,
			)
		}

		for i, cmd := range cmds {
			value, err := cmd.Result()
			switch {
			case err == nil:
				// 创建独立副本,避免多个结果共享同一个循环变量。
				v := value
				values[start+i] = &v

			case errors.Is(err, redis.Nil):
				values[start+i] = nil

			default:
				return nil, fmt.Errorf(
					"get key %q: %w",
					batchKeys[i],
					err,
				)
			}
		}
	}

	return values, nil
}

关键说明

  1. 批次必须有界

    batchSize 不应等于所有 Key 的总数。常见起点可以是几十到几百条,再根据平均请求大小、响应大小、P99 和输出缓冲区调整。

  2. Pipeline 级错误与命令级错误要分别检查

    网络错误可能导致整个 Pipeline 失败;redis.NilWRONGTYPE 等则可能只影响特定命令。

  3. 超时后执行结果可能不确定

    请求可能已经写入服务端并执行,但客户端在读取响应前超时。对于 INCR、扣减库存等非幂等命令,不得无条件重试。

  4. 客户端与 Pipeline 的并发安全性不同

    *redis.Client 内部管理连接池,可由多个 Goroutine 并发使用;但一个 Pipeline 对象不能被多个 Goroutine 并发追加命令或执行。每个 Goroutine 应创建并独占自己的 Pipeline,或者由一个拥有者串行操作。go-redis 源码对此有明确说明。(GitHub)

  5. 大 Pipeline 需要匹配超时和缓冲区

    go-redis 当前默认使用自动连接池,并支持配置读写缓冲区;较大的 Pipeline 可能受缓冲区、连接池和超时配置影响,但不应在没有测量的情况下盲目把缓冲区调到数 MB。(GitHub)

  6. Pipeline 超时可能触发重传

    go-redis 源码特别提示,Pipeline 在超时后的重传可能使命令执行不止一次。因此写入命令应优先保证幂等,或引入业务请求 ID、状态机和结果缓存。(GitHub)


4.4 Go 并发压测注意事项

固定 Worker 数量

不要每个请求创建一个无限增长的 Goroutine。应使用固定 Worker 数,并分别测试:

Worker 数 < PoolSize
Worker 数 ≈ PoolSize
Worker 数 > PoolSize

需要明确区分:

  • Redis 服务端延迟。
  • 网络延迟。
  • 客户端连接池等待。
  • Go 调度和 GC 延迟。

每个 Worker 独占 Pipeline

正确模型:

Worker 1 → Pipeline 1
Worker 2 → Pipeline 2
Worker 3 → Pipeline 3

错误模型:

多个 Worker → 同一个 Pipeline

不要只统计平均值

至少记录:

  • 吞吐量。
  • P50。
  • P95。
  • P99。
  • P99.9。
  • 超时率。
  • 错误率。
  • 连接池等待时间。
  • Redis CPU 和网络吞吐量。

区分批次延迟和单命令延迟

一批 100 条命令在 2 毫秒内返回,不代表每条命令的业务延迟都是 0.02 毫秒。对于批次中的第一条命令,它可能等待客户端凑批、服务端执行整批以及客户端解析整批响应。

避免在测量循环中做无关工作

例如:

  • fmt.Sprintf 生成 Key。
  • JSON 编解码。
  • 写日志。
  • 随机数加锁。
  • 动态创建客户端。
  • 每次请求重新建 TCP 连接。

这些成本可能使压测首先测到 Go 程序,而不是 Redis。


5. 典型业务场景

场景是否适用数据量要求一致性要求主要性能风险可替代方案
高频小对象缓存非常适用单 Value 宜保持较小,总容量受内存约束通常允许短暂不一致HotKey、连接池耗尽、缓存击穿本地缓存、两级缓存
同一请求批量读取几十个 Key适用 Pipeline 或 MGET批次必须有界读取时点不一定完全一致大响应、跨 Slot、批次过大聚合对象、Hash、数据模型重构
跨机房逐条执行大量命令不适合逐条往返命令数量越多问题越明显视业务而定RTT 成为主要瓶颈Pipeline、Lua、就近部署
单 Key 保存数十 MB 对象通常不适用大 Value 风险高无法改善一致性网络、复制、AOF、GC、故障恢复对象存储、数据库、分块存储
在 Lua 中扫描大集合并复杂计算不适用数据规模必须严格有界脚本内原子长时间阻塞主线程应用计算、异步任务、预计算
高并发简单 GET/SET,网络解析占比较高可考虑 I/O Threads小命令、高请求量不改变一致性语义线程交接、过度配置、CPU 争用多实例、Cluster、客户端 Pipeline
强持久化高写入业务有条件适用受磁盘、AOF 和恢复目标约束需要明确 RPOfsync、fork、COW、磁盘抖动专用数据库、日志系统
超大集合交并集在线计算通常不适用必须严格限制集合基数单命令原子但会阻塞CPU、临时内存、长尾延迟离线计算、预聚合、倒排索引

6. 底层实现

6.1 事件循环的数据结构

Redis 的事件循环维护:

  • 按文件描述符定位的文件事件信息。
  • 当前已就绪事件集合。
  • 时间事件。
  • beforeSleepafterSleep 回调。
  • 操作系统多路复用实现所需的状态。

按文件描述符索引事件信息,可以快速定位特定连接的读写回调。其空间成本大体与事件循环可管理的文件描述符容量相关。

一次事件循环的成本可以近似理解为:

等待成本
+ K 个就绪文件事件的回调成本
+ 本轮时间事件成本
+ 本轮命令执行成本

其中 K 是本轮实际就绪的事件数量。最关键的部分通常不是“发现连接”,而是发现连接后执行的命令及传输的数据量。


6.2 输入缓冲区与输出缓冲区

每个客户端都需要保存尚未完整解析的输入和尚未写完的响应。

假设 Pipeline 批次为 B

客户端请求内存 ≈ B × 平均请求大小
服务端响应内存 ≈ B × 平均响应大小
客户端响应内存 ≈ B × 平均响应大小

如果一次 Pipeline 包含 10 万个请求,或者每个响应都很大,那么即使网络往返次数减少,也可能出现:

  • 客户端内存暴涨。
  • Redis 输出缓冲区增长。
  • 慢消费者断开。
  • 单批次 P99 上升。
  • 其他客户端等待时间增加。

因此,Pipeline 优化的核心不是“越大越好”,而是找到吞吐量、内存和尾延迟的平衡点。


6.3 命令查找与数据访问

Redis 收到完整命令后,需要:

  1. 根据命令名查找命令描述。
  2. 检查参数个数。
  3. 执行 ACL、集群 Slot、内存和状态检查。
  4. 查找对应 Key。
  5. 根据具体数据结构执行算法。
  6. 构造响应。
  7. 传播到 AOF、复制流或通知机制。

命令名和 Key 的字典查找通常具有平均 O(1) 特征,但最终复杂度由命令本身决定。例如:

操作服务端核心复杂度额外数据量成本
小 Value GET平均 O(1)O(V) 响应复制与传输
N 个独立 GET PipelineO(N)O(ΣV)
MGET N 个 KeyO(N)O(ΣV)
SMEMBERSO(N)O(N) 响应
SUNION与输入集合总元素数相关与结果集合相关
ZRANGE 返回 M 个元素通常 O(log N + M)O(M) 响应
Pipeline B 条 O(1) 命令服务端仍约 O(B)往返次数下降,缓冲空间上升

所谓“Redis 命令是 O(1)”必须补充条件:

只描述核心索引操作还不够,完整端到端成本还包括 Value 大小、响应数量、内存分配和网络传输。


6.4 Redis 8 I/O Threads 的客户端交接

Redis 8 的新实现大致采用以下状态流转:

I/O 线程持有客户端

读取并解析完整命令

将客户端加入待主线程处理队列

通知或等待主线程批量取走

主线程执行命令并生成响应

将客户端加入待 I/O 线程处理队列

I/O 线程继续写响应和读取新请求

这种模型试图减少以下开销:

  • 主线程直接处理所有 socket I/O。
  • 每一阶段频繁全局同步。
  • 单个客户端在多个线程间无序访问。
  • I/O 线程处理完一个小步骤就立即唤醒主线程。

Redis 8 源码还使用批量交接和预取等手段,尽量降低线程间交互与 Cache Miss。(GitHub)

但线程交接本身并非免费,它会引入:

  • 队列操作。
  • 互斥和通知。
  • CPU Cache Line 转移。
  • 线程调度。
  • 更高的核心占用。

所以低负载或单核环境开启 I/O Threads,性能可能没有提升,甚至变差。


6.5 版本差异

版本网络 I/O 模型普通命令执行关键差异
Redis 5 及更早主事件循环承担主要网络读写主线程串行执行已有持久化子进程和部分后台任务,不能理解为整个进程只有一个执行单元
Redis 6引入经典 I/O Threads仍由主线程执行默认主要并行写出;可通过 io-threads-do-reads 开启读取和解析
Redis 7/7.4延续经典 I/O Threads仍由主线程执行读取并行仍需单独配置,线程数量过多收益有限
Redis 8.0+新 I/O threading 实现核心普通命令仍主要由主线程执行I/O 线程处理读、写和协议解析,拥有自己的事件循环
Redis 8.8延续 Redis 8 新模型同上io-threads-do-reads 已废弃;可通过 INFO threads 观察各线程

Redis 6/7 的配置文件明确说明默认主要将写操作交给 I/O 线程,而读取和协议解析需要 io-threads-do-reads yes;Redis 8 配置文件则明确说明启用后会同时处理读、写和协议解析。(GitHub)


6.6 时间复杂度与空间成本的统一分析

对任何优化方案,都应同时回答四个问题:

  1. 命令执行复杂度是否下降?
  2. 网络往返次数是否下降?
  3. 请求和响应总字节数是否下降?
  4. 在途数据与临时内存是否增加?

例如,N 次 GET:

方案RTT 次数命令执行量响应字节数临时内存
逐条 GETNNΣV
Pipeline,批次 Bceil(N/B)NΣVO(B)
MGET1N 个 Key 查找ΣVO(N)
Lua 批量读取1N 个 Key 查找 + 脚本成本ΣV与脚本和结果相关

这说明:

  • Pipeline 减少 RTT,但不减少总结果数据量。
  • MGET 减少命令分派,但可能构造大响应。
  • Lua 可以表达依赖逻辑,但会延长单次主线程占用。
  • 最优方案取决于批次、网络距离、响应大小和一致性要求。

7. 高性能、高并发、高可用分析

7.1 高性能

CPU

CPU 瓶颈可能来自:

  • 命令执行。
  • RESP 解析。
  • TLS 加解密。
  • 大 Value 复制。
  • Lua 计算。
  • 集合运算。
  • 内存分配与释放。
  • 过期与淘汰处理。

如果 CPU 高且 eventloop_duration_cmd_sum 占比较大,应优先优化命令和数据模型,而不是直接增加 I/O Threads。

如果 CPU 主要花在网络读写、协议解析,Redis 8 I/O Threads 才可能明显有效。

内存

内存性能不仅是“容量是否够”,还包括:

  • 内存带宽。
  • CPU Cache Miss。
  • 碎片。
  • Page Fault。
  • Swap。
  • 大量临时对象。
  • fork 后的 Copy-on-Write。

紧凑编码之所以重要,不只是节省容量,还能让更多数据落入更少的 Cache Line,降低内存带宽压力。

网络

粗略估算网络需求:

入站带宽 ≈ QPS × 平均请求字节数
出站带宽 ≈ QPS × 平均响应字节数

大 Value、HGETALLSMEMBERS 和复制流很容易让出站网络先于 CPU 饱和。Redis 官方文档也指出,实际场景中网络可能比 CPU 更早成为限制。(Redis)

磁盘

Redis 的数据访问以内存为主,但磁盘仍会通过以下路径影响延迟:

  • AOF writefsync
  • AOF 重写。
  • RDB 快照。
  • 磁盘空间不足。
  • 共享云盘抖动。
  • fork 与 Copy-on-Write。

AOF fsync always 提供更小的数据丢失窗口,但延迟和吞吐量成本更高;everysec 是常见折中,但不能描述成零丢失。(Redis)

批处理

批处理能减少固定开销,但应限制单批大小。合理目标不是最大化每批命令数,而是在以下条件下获得稳定 P99:

  • 服务端不会长时间独占主线程。
  • 客户端不会等待过久才能凑满一批。
  • 输出缓冲区不会异常增长。
  • 超时后重试成本可控。
  • 其他租户不会被大批次饿死。

7.2 高并发

Redis 的串行命令执行减少了服务端内部锁竞争,但并不意味着高并发没有竞争。竞争转化为了:

  • 主线程执行队列竞争。
  • HotKey 竞争。
  • 客户端连接池竞争。
  • 网络出口竞争。
  • 内存和 CPU 时间竞争。
  • 故障后的重试竞争。

热点问题

一个高频 Key 即使由 GET 访问,也可能导致:

  • 单分片流量集中。
  • 单个 Redis 实例网卡打满。
  • 大量响应复制。
  • 副本同样承受热点。
  • 故障后请求集中到剩余节点。

可采用:

  • 本地缓存。
  • 请求合并。
  • 热 Key 副本。
  • 业务级 Key 拆分。
  • 限流和降级。

惊群与重试

Redis 发生短暂延迟后,大量客户端可能同时超时并重试,形成:

Redis 抖动
→ 请求超时
→ 客户端立即重试
→ Redis 负载更高
→ 更多请求超时

应使用:

  • 有界重试。
  • 指数退避。
  • 随机抖动。
  • 熔断。
  • 请求幂等。
  • 总调用截止时间。
  • 降级缓存。

Pipeline 与公平性

一个客户端不断发送巨型 Pipeline,可能让主线程连续处理大量命令,降低其他客户端的调度公平性。

所以多租户系统应限制:

  • 单 Pipeline 命令数。
  • 单请求参数总大小。
  • 单响应大小。
  • 每连接输出缓冲区。
  • 单租户 QPS。

7.3 高可用

I/O Threads 和 Pipeline 是性能机制,不是高可用机制。

它们不会直接提供:

  • 数据副本。
  • 自动故障转移。
  • 强一致。
  • 写入确认。
  • 备份。
  • 灾难恢复。

但性能问题会间接影响高可用:

  1. 长命令会延迟事件循环和复制处理。
  2. 大响应会占用网络,增加复制延迟。
  3. 大量写入会放大 AOF 和复制流。
  4. 主线程长期饱和会使健康检查和故障判断变得不稳定。
  5. 故障转移期间 Pipeline 可能部分执行,客户端却只看到连接错误。
  6. 自动重试可能把非幂等写入执行两次。
  7. 切换后立即恢复全部流量,可能造成新主节点过载。

客户端应根据错误类型和命令幂等性决定是否重试,而不是将所有错误统一重试。

对于 Pipeline 写入,必须接受一种现实:

连接中断后,客户端可能无法判断一条命令究竟没有执行、已经执行,还是已经执行但响应丢失。

高可用设计必须通过业务幂等、请求状态、唯一标识和结果查询解决这种不确定性。


8. 常见错误与生产事故

事故一:Lua 脚本导致所有接口延迟突增

  • 现象:某个接口上线后,Redis P99 从 2 毫秒上升到数百毫秒;其他完全无关的接口也一起变慢。
  • 根因:Lua 脚本遍历大型集合并执行复杂计算,单次运行长时间占用主线程。
  • 排查方法:检查 SLOWLOG、命令延迟统计、CPU、事件循环命令耗时;定位 EVAL 或 Function 调用。
  • 修复方案:将脚本改为有界操作;提前聚合;把复杂计算放回应用或异步任务;必要时拆分数据。
  • 如何预防:为脚本规定最大 Key 数、集合基数、循环次数和执行预算;上线前使用生产量级数据压测。

事故二:一次 Pipeline 发送十万条命令造成内存暴涨

  • 现象:批处理任务运行时 Redis 内存和客户端内存同时增长,其他请求 P99 上升,部分连接因输出缓冲区限制断开。
  • 根因:客户端无限制累积 Pipeline,服务端需要缓存大量响应,客户端也需要保存命令对象和返回值。
  • 排查方法:检查客户端输出缓冲区、连接内存、网络吞吐量、Pipeline Hook、客户端堆内存和 Goroutine 栈。
  • 修复方案:将批次限制为固定大小,例如从 100~500 条起步;每批执行并消费结果后再继续。
  • 如何预防:在客户端库封装中设置最大批次、最大参数字节数和最大响应估算值。

事故三:开启 I/O Threads 后性能没有改善

  • 现象:配置 io-threads 8 后吞吐量几乎不变,甚至 P99 更差。
  • 根因:实际瓶颈是 Lua、大集合运算或单线程命令执行;或者机器核心不足,线程交接增加了同步和 Cache 抖动。
  • 排查方法:比较 eventloop_duration_cmd_sumINFO threads、CPU 核心利用率、网络吞吐量和命令统计。
  • 修复方案:恢复较小线程数;优化慢命令;拆分 Key;采用多实例或 Cluster 扩展命令执行能力。
  • 如何预防:开启前后使用相同负载、相同客户端线程数和相同 Pipeline 深度做 A/B 压测。

事故四:大 Value GET 打满网络

  • 现象:Redis CPU 并不高,但接口超时,出站网络接近上限,Go 服务频繁 GC。
  • 根因:单个缓存对象达到数 MB,大量请求重复读取并反序列化;瓶颈是网络、内存复制和客户端分配,不是 Key 查找。
  • 排查方法:检查 instantaneous_output_kbps、Key 大小、应用响应体、Go 分配和 GC 指标。
  • 修复方案:拆分对象;只缓存必要字段;压缩但评估 CPU 成本;使用本地缓存;将大对象转移到对象存储。
  • 如何预防:制定 Value 大小上限,并对大 Key 和大响应持续监控。

事故五:大集合运算阻塞主线程

  • 现象:某个报表接口请求时 Redis 延迟周期性升高,其他 GET/SET 也变慢。
  • 根因:在线执行 SINTERSUNIONSMEMBERS,集合基数远超设计预期。
  • 排查方法:查看慢日志、集合基数、命令调用方和 commandstats;检查是否由定时任务触发。
  • 修复方案:改为预计算、离线计算、分页扫描或使用专门的搜索/分析系统。
  • 如何预防:业务层维护集合基数上限,禁止无边界全量读取命令进入核心链路。

事故六:Go 无边界并发导致连接池等待与重试风暴

  • 现象:Redis 服务端 CPU 只有 50%,但应用大量超时;Goroutine 和连接池等待数持续增加。
  • 根因:应用为每个任务启动 Goroutine,实际并发远超连接池;超时后又立即重试,形成自激负载。
  • 排查方法:检查 go-redis PoolStats、Goroutine 数、请求排队时间、上下文截止时间和重试次数。
  • 修复方案:固定 Worker 数;设置并发信号量;使用总调用超时;有界重试并加退避。
  • 如何预防:将连接池等待纳入可观测性,区分“获取连接超时”和“Redis 命令超时”。

事故七:AOF 或 fork 引发周期性延迟尖峰

  • 现象:Redis 每隔一段时间出现 P99/P99.9 尖峰,与业务流量没有明显相关性。
  • 根因:AOF fsync、AOF 重写、RDB 快照、慢磁盘、THP 或 fork/Copy-on-Write 造成抖动。
  • 排查方法:检查 INFO persistencelatest_fork_usec、磁盘延迟、AOF pending fsync、延迟监控和系统 Page Fault。
  • 修复方案:调整持久化策略;使用低延迟独立磁盘;关闭 THP;控制写入峰值;保证内存余量。
  • 如何预防:在容量测试中同时触发 BGSAVE/AOF 重写,不只测试无持久化稳定态。

9. 方案选型与权衡

方案主要优化目标优点局限与风险适用条件
普通单命令简单、低延迟语义清晰,单次占用短RTT 多时吞吐低单次调用、低 RTT
Pipeline减少 RTT 和系统调用通用、无需改变命令语义非事务;批次过大增加内存和尾延迟多条互不依赖命令
MGET/MSET 等批量命令减少命令分派一次命令完成批量访问大响应;Cluster Slot 约束批量 Key 可控且数据模型合适
Lua/Functions减少有依赖操作的 RTT并提供原子执行服务端原子逻辑长脚本阻塞主线程,运维复杂短小、有界、确定性逻辑
I/O Threads并行网络 I/O 和协议解析更好利用多核处理高 I/O 负载不并行普通命令执行;存在交接成本网络和解析是瓶颈
多 Redis 实例利用多个命令执行线程简单扩展多核吞吐数据与连接管理复杂可按业务或 Key 分片
Redis Cluster水平分片与节点级高可用自动 Slot 路由和扩容多 Key、事务、Lua 受 Slot 限制数据规模和吞吐超过单实例
只读副本扩展部分读吞吐分担只读流量可能读到旧数据,热点仍可能集中可接受复制延迟
本地缓存消除部分 RTT 和 Redis 流量极低延迟一致性和失效复杂极热点且可容忍短暂旧值

推荐的优化顺序是:

确认业务目标
→ 测量端到端延迟
→ 找到瓶颈资源
→ 消除慢命令和大 Key
→ 优化数据模型
→ 使用有界 Pipeline/批量命令
→ 调整客户端并发与连接池
→ 验证 I/O Threads
→ 最后考虑多实例或 Cluster

不要在不知道瓶颈的情况下直接提高 io-threads、连接池大小或 Pipeline 深度。


10. 高频面试题

问题 1:Redis 为什么快?只是因为使用内存吗?

推荐回答

简洁回答:

内存访问是重要原因,但不是全部。Redis 还使用事件驱动 I/O、多路复用、高效数据结构、紧凑 RESP 协议、串行命令执行、Pipeline,以及后台线程或子进程隔离慢任务。

详细展开:

内存避免了普通随机请求等待磁盘,但完整延迟仍包括网络 RTT、排队、协议解析、命令复杂度、响应大小和持久化影响。大 Key、慢 Lua 或网络带宽不足时,Redis 即使所有数据都在内存里仍然会很慢。

面试官追问

  1. 为什么大 Value 的 GET 仍然可能很慢?
  2. Redis CPU 不高但延迟高,可能是什么原因?
  3. 内存带宽什么时候会成为瓶颈?

常见错误回答

“Redis 快是因为它完全在内存里,而且所有命令都是 O(1)。”

评分点

  • 初级:知道内存和单线程。
  • 中级:能补充事件循环、Pipeline、数据结构和网络。
  • 高级:能从端到端链路分析 CPU、网络、内存、磁盘和客户端排队。

问题 2:Redis 是单线程的吗?

推荐回答

简洁回答:

Redis 核心普通命令通常由主线程串行执行,但 Redis 整个进程并不是单线程。网络 I/O、协议解析、异步释放、AOF fsync、RDB 和 AOF 重写等可以由 I/O 线程、后台线程或子进程承担。

详细展开:

Redis 6 开始提供经典 I/O Threads,Redis 8 引入新的 I/O threading 实现。Redis 8 的 I/O 线程可以读取 socket、解析 RESP 和写出响应,但普通核心命令仍主要交给主线程执行。

面试官追问

  1. UNLINK 的内存释放在哪执行?
  2. BGSAVE 是线程还是进程?
  3. I/O Threads 会不会让两个 SET 同时执行?

常见错误回答

“Redis 完全只有一个线程。”

评分点

  • 初级:知道命令执行是单线程。
  • 中级:能区分主线程、后台线程和子进程。
  • 高级:能说明 Redis 6/7 与 Redis 8 的 I/O Threads 差异。

问题 3:什么是 I/O 多路复用?

推荐回答

简洁回答:

I/O 多路复用让一个事件循环同时监视大量连接,只处理当前已经可读或可写的连接,而不需要一个连接一个线程,也不需要不断轮询所有连接。

详细展开:

Linux 通常使用 epoll,BSD/macOS 通常使用 kqueue。Redis 把套接字读写兴趣注册给操作系统,等待操作系统返回就绪事件,再调用相应的读写处理函数。

它优化的是连接管理和 I/O 等待,不代表多个命令在一个线程中真正并行执行。

面试官追问

  1. epoll 和 kqueue 在 Redis 中分别负责什么?
  2. I/O 多路复用与异步 I/O 是否完全等价?
  3. 一个慢命令为什么仍会影响其他连接?

常见错误回答

“epoll 会自动使用多个 CPU 并行执行 Redis 命令。”

评分点

  • 初级:知道一个线程可管理多个连接。
  • 中级:知道可读、可写就绪事件。
  • 高级:能区分事件发现、I/O 处理和命令执行。

问题 4:描述 Redis 请求从客户端到响应的完整路径。

推荐回答

简洁回答:

客户端将命令编码为 RESP 并写入 TCP;Redis 读取到输入缓冲区,解析完整命令,主线程完成命令检查和执行,生成响应,再由主线程或 I/O 线程写回客户端。

详细展开:

Redis 8 开启 I/O Threads 后,I/O 线程可以持有客户端连接,完成读取和协议解析;完整命令交给主线程执行,主线程生成响应后再交还 I/O 线程。整个过程还可能涉及 ACL、过期判断、AOF、复制传播和通知。

面试官追问

  1. 哪些步骤可能被 Pipeline 优化?
  2. 命令执行之前有哪些检查?
  3. 响应很大时主要消耗什么资源?

常见错误回答

“Redis 从内存读取数据后直接返回,没有排队和协议处理。”

评分点

  • 初级:能说出读、执行、写。
  • 中级:能补充协议解析和响应构造。
  • 高级:能描述线程交接、传播和客户端缓冲区。

问题 5:为什么 Redis 使用串行命令执行还能获得高吞吐?

推荐回答

简洁回答:

串行执行减少了锁竞争、上下文切换和 Cache Line 争用,也简化了命令原子性。只要单条命令足够短,主线程就能快速连续处理大量请求。

详细展开:

多线程访问共享键空间需要细粒度锁或复杂无锁结构。Redis 将大部分普通命令串行化,避免在数据结构操作中频繁加锁。但它的上限是单个主执行线程,一旦出现慢命令或主线程 CPU 饱和,所有请求都会排队。

面试官追问

  1. 串行执行与单命令原子性有什么关系?
  2. 如何突破单主线程的 CPU 上限?
  3. 为什么 I/O Threads 不能完全解决主线程瓶颈?

常见错误回答

“单线程天然比多线程快。”

评分点

  • 初级:知道没有锁竞争。
  • 中级:能同时说明收益和瓶颈。
  • 高级:能联系缓存一致性、排队模型和分片扩展。

问题 6:Redis I/O Threads 适合解决什么问题?

推荐回答

简洁回答:

I/O Threads 适合 socket 读写和协议解析占用较多 CPU 的高吞吐场景,不适合解决 Lua、大集合运算和复杂命令造成的主线程瓶颈。

详细展开:

Redis 6/7 默认主要并行写出,可选并行读取和协议解析;Redis 8 使用新实现,启用后同时处理读、写和解析。开启前应通过 CPU、事件循环指标和基准测试确认瓶颈确实位于 I/O 路径。

面试官追问

  1. 为什么线程越多不一定越快?
  2. io-threads 1 代表什么?
  3. 开启后应观察哪些 INFO 指标?

常见错误回答

“开启 8 个 I/O Threads 后 Redis 就能用 8 个核心并行执行命令。”

评分点

  • 初级:知道 I/O Threads 处理网络。
  • 中级:知道命令仍由主线程执行。
  • 高级:能说明版本差异、线程交接成本和验证方法。

问题 7:Pipeline 为什么能提高吞吐量?

推荐回答

简洁回答:

Pipeline 将多条命令连续发送并批量读取响应,减少 RTT、系统调用和用户态/内核态切换,但服务端仍需逐条执行命令。

详细展开:

N 条命令、每批 B 条时,网络往返次数可由 N 降到约 ceil(N/B)。Pipeline 不提供事务原子性,也不会减少总响应字节数。批次过大还会增加输出缓冲区、内存和尾延迟。

面试官追问

  1. Pipeline 是不是批次越大越好?
  2. Pipeline 与事务有什么区别?
  3. Pipeline 中某一条命令失败怎么办?

常见错误回答

“Pipeline 会让 Redis 同时并行执行多条命令。”

评分点

  • 初级:知道减少网络往返。
  • 中级:知道非事务和逐条错误。
  • 高级:能分析批次、内存、公平性和超时重试不确定性。

问题 8:MGET、Pipeline 和 Lua 应该如何选择?

推荐回答

简洁回答:

无依赖的多条命令优先考虑 Pipeline;同类 Key 批量读取且响应可控时考虑 MGET;后续操作依赖前一步结果且需要原子性时考虑短小 Lua 或 Function。

详细展开:

  • Pipeline:通用,不改变原命令,但仍有多条命令分派。
  • MGET:一次命令,开销更低,但大响应和 Cluster Slot 约束更明显。
  • Lua:减少依赖式往返并提供原子执行,但脚本过长会阻塞主线程。

面试官追问

  1. Cluster 中 MGET 有什么限制?
  2. Lua 是否一定比 Pipeline 快?
  3. 如果只需要批量 GET,为什么不总是使用 Lua?

常见错误回答

“Lua 一定是最快的,因为只需要一次网络请求。”

评分点

  • 初级:知道三者用途不同。
  • 中级:能说明原子性和 RTT。
  • 高级:能结合响应大小、Slot、主线程占用和运维成本权衡。

问题 9:大 Key 为什么会影响 Redis 性能?

推荐回答

简洁回答:

大 Key 会增加内存访问、复制、网络传输、客户端解析、持久化、复制和释放成本,并可能长时间占用主线程。

详细展开:

即使 GET 的字典查找是平均 O(1),返回数 MB Value 的复制和网络成本仍是 O(V)。大集合全量读取、同步删除和故障恢复也会放大尾延迟。

面试官追问

  1. DEL 和 UNLINK 对大 Key 有什么区别?
  2. 如何定义大 Key?
  3. 大 Key 对主从复制有什么影响?

常见错误回答

“大 Key 只会多占一些内存,不影响 CPU。”

评分点

  • 初级:知道网络和内存影响。
  • 中级:能说明主线程阻塞和释放。
  • 高级:能联系复制、AOF、客户端 GC 和恢复时间。

问题 10:Redis CPU 很高但 QPS 不高,如何排查?

推荐回答

简洁回答:

先看是命令执行、网络解析、持久化还是内存管理消耗 CPU,再结合 commandstats、SLOWLOG、INFO stats、INFO threads 和系统级 CPU Profile 定位。

详细展开:

排查顺序可以是:

  1. 检查慢命令、Lua 和大集合操作。
  2. 查看 eventloop_duration_cmd_sum
  3. 查看各命令 usec_per_call
  4. 检查大 Key、过期、淘汰和同步删除。
  5. 检查 TLS、协议解析和 I/O Threads。
  6. 使用 perf/eBPF 进行 CPU Profile。
  7. 检查客户端是否产生大量无效请求或失败重试。

面试官追问

  1. CPU 高而 commandstats 看起来正常怎么办?
  2. 如何区分 I/O CPU 和命令 CPU?
  3. HotKey 一定会让 CPU 100% 吗?

常见错误回答

“直接增加 I/O Threads 或连接池就行。”

评分点

  • 初级:知道看慢日志。
  • 中级:能按命令、网络和持久化分类。
  • 高级:能结合事件循环指标、Profile 和客户端调用链定位。

问题 11:Redis 出现周期性延迟尖峰,应如何分析?

推荐回答

简洁回答:

将尖峰时间与慢命令、RDB/AOF、fork、磁盘延迟、THP、Swap、过期扫描和客户端批处理时间对齐,不能只看平均 CPU。

详细展开:

如果尖峰与 BGSAVE、AOF 重写或 fsync 同步,重点检查持久化和磁盘;如果与业务定时任务一致,检查大 Pipeline、大集合或全量扫描;如果多个进程同时尖峰,检查虚拟化、CPU 抢占和系统固有延迟。

面试官追问

  1. 为什么 Redis CPU 不高仍可能出现尖峰?
  2. --intrinsic-latency 解决什么问题?
  3. SLOWLOG 没记录是否说明 Redis 没问题?

常见错误回答

“Redis 延迟尖峰一定是网络问题。”

评分点

  • 初级:知道查慢日志和磁盘。
  • 中级:能关联 fork、AOF 和系统延迟。
  • 高级:能区分命令执行时间、排队时间和网络时间。

问题 12:使用 go-redis 做 Pipeline 和并发压测要注意什么?

推荐回答

简洁回答:

*redis.Client 可以被多个 Goroutine 共享,但同一个 Pipeline 不能并发使用。压测应使用固定 Worker、有界批次、合理连接池和超时,同时统计尾延迟、池等待、错误率和 Redis 资源指标。

详细展开:

还要注意:

  • Pipeline 超时后命令可能已执行。
  • 非幂等写不能无条件重试。
  • 每个 Worker 使用独立 Pipeline。
  • 不在测量循环里生成 Key、建连接或写大量日志。
  • 区分批次延迟与单命令业务延迟。
  • 使用与生产一致的 Value 大小和热点分布。
  • 压测客户端本身可能先达到 CPU 或网络上限。

面试官追问

  1. Worker 数是否必须等于连接池大小?
  2. Pipeline 中 redis.Nil 如何处理?
  3. 为什么平均延迟不足以评估结果?

常见错误回答

“启动越多 Goroutine,压测结果越接近 Redis 最大性能。”

评分点

  • 初级:知道共享客户端和设置超时。
  • 中级:知道 Pipeline 非并发安全以及连接池等待。
  • 高级:能分析超时歧义、幂等、开放/闭环负载和客户端瓶颈。

11. 一分钟面试回答

Redis 的高性能不只是因为数据在内存中,还来自事件循环、I/O 多路复用、高效数据结构、RESP 协议、串行命令执行和 Pipeline。Redis 不能简单说成完全单线程:普通核心命令通常由主线程串行执行,因此减少了锁竞争并简化了原子性;网络 I/O、协议解析、异步释放和持久化任务可以由 I/O 线程、后台线程或子进程处理。Redis 8 使用新的 I/O threading 实现,启用后可以并行处理 socket 读写和 RESP 解析,但不会让 Lua、集合运算或普通写命令在多个主执行线程上并行。Pipeline 主要减少 RTT 和系统调用,不提供事务原子性,批次过大还会增加内存、输出缓冲区和尾延迟。实际排障时要区分 CPU、网络、内存、磁盘和客户端连接池瓶颈,并重点检查慢命令、大 Key、大集合、Lua、AOF、fork 以及客户端重试风暴。


12. 本章总结

  1. Redis 的端到端性能由客户端、网络、事件循环、协议解析、命令执行、响应大小和持久化共同决定。
  2. I/O 多路复用让少量线程高效管理大量连接,但不会自动并行执行普通命令。
  3. Redis 核心普通命令通常由主线程串行执行,整个 Redis 进程则包含多种后台线程和子进程。
  4. 串行执行减少了锁竞争、线程切换和状态复杂度,但也形成了单主执行线程的 CPU 上限。
  5. Redis 8 I/O Threads 可以处理读取、写出和协议解析,主要解决 I/O 路径问题,而不是慢命令问题。
  6. Pipeline 减少网络往返和系统调用,但服务端仍需执行全部命令,也不提供事务原子性。
  7. 大 Key、大集合、大响应和长 Lua 会同时影响 CPU、内存、网络、复制和客户端 GC。
  8. 性能优化必须以测量为基础。先定位瓶颈,再调整命令、数据模型、批次、并发、I/O Threads 或分片方案。
  9. 对非幂等写入而言,超时后的执行状态可能不确定,重试安全性必须由业务幂等机制保证。
  10. 高性能不等于高可用,I/O Threads、Pipeline 和缓存命中率都不能替代复制、故障转移和备份。

13. 自测清单

  1. 为什么数据在内存中并不能保证 Redis 请求一定低延迟?
  2. I/O 多路复用解决的是连接管理问题还是命令并行执行问题?
  3. Redis 事件循环中的文件事件和时间事件分别是什么?
  4. Redis 8 开启 I/O Threads 后,一个普通 SET 命令在哪个线程执行?
  5. Redis 6/7 与 Redis 8 的 I/O Threads 配置和工作方式有什么差异?
  6. 为什么 Pipeline 能提高吞吐量,却不一定降低每条命令的业务延迟?
  7. 一个标记为 O(1) 的 GET 为什么仍可能耗时数十毫秒?
  8. Redis CPU 很高时,如何判断是命令执行还是网络 I/O 导致?
  9. go-redis 的客户端与 Pipeline 对象在并发安全性上有什么区别?
  10. Pipeline 写入超时后,为什么不能直接认为所有命令都没有执行?

14. 官方资料

  • Redis Open Source 下载与当前版本信息。(Redis)
  • Redis RESP 协议规范。(Redis)
  • Redis Pipeline 官方说明。(Redis)
  • Redis Benchmark 官方文档。(Redis)
  • Redis 延迟诊断文档。(Redis)
  • Redis INFO 命令及 I/O Threads 指标。(Redis)
  • Redis 8.0 发行说明。(Redis)
  • Redis 8.8 redis.conf 中的 I/O Threads 配置说明。(GitHub)
  • Redis 8.8 事件循环、epoll 与 kqueue 源码。(GitHub)
  • Redis 8.8 I/O Threads 源码。(GitHub)
  • go-redis/v9 官方仓库及 Pipeline 实现。(GitHub)