Redis 之所以能在单机缓存领域长期保持 10 万 QPS 以上的吞吐量,与其精心设计的网络模型密不可分。从经典的单线程事件循环到 Redis 6.0 引入的多线程 I/O,从 RESP 明文协议到 TLS 加密传输,每一步架构选择都蕴含着深刻的工程权衡。本文深入 Redis 底层源码与操作系统机制,带你理解 C10K(单机万级连接)甚至 C100K 场景下 Redis 如何做到高并发低延迟,以及在生产环境中如何调优网络参数、规避性能陷阱。
一、单线程事件循环:ae.c 的实现细节
Redis 的主线程处理所有客户端连接的读写请求,但"单线程"绝不等于"串行阻塞"。Redis 通过事件驱动(Event-Driven)模型,将 I/O 操作委托给操作系统内核的多路复用机制,主线程仅在事件就绪时执行用户空间的命令处理。
1.1 核心架构:一个线程 + 多路复用
+----------------------------------------------------+
| Redis 服务器主线程 |
| |
| +-------------+ +-------------+ +-------+ |
| | 文件事件 | -> | 命令处理器 | -> | 回复 | |
| | (ae.c) | | (processFileEvents) | |
| +-------------+ +-------------+ +-------+ |
| ^ | |
| +------ epoll_wait / kqueue ------+ |
+----------------------------------------------------+
|
v
+----------------------------------------------------+
| OS 内核:epoll / kqueue / devpoll |
| (监控所有 socket,告知哪些连接有数据可读) |
+----------------------------------------------------+
这一系列机制的核心实现位于 src/ae.c(Antirez Event)模块。Redis 的事件驱动系统由三个核心组件构成:
- aeEventLoop:主事件循环结构体,持有
apidata(多路复用后端私有数据)、文件事件数组、时间事件链表等 - aeFileEvent:文件事件,表示一个套接字的可读/可写状态
- aeTimeEvent:时间事件,用于
serverCron等后台周期性任务
1.2 aeEventLoop 结构体
// ae.h 中的核心结构
typedef struct aeEventLoop {
int maxfd; // 当前注册的最大文件描述符
int setsize; // fds 数组大小(= maxclients + 32)
long long timeEventNextId;
time_t lastTime; // 上次执行时间事件的时间
aeFileEvent *events; // 文件事件数组(索引即 fd)
aeFiredEvent *fired; // 就绪事件数组
aeTimeEvent *timeEventHead; // 时间事件链表
int stop;
void *apidata; // epoll 或 kqueue 的私有数据
aeBeforeSleepProc *beforesleep;
aeBeforeSleepProc *aftersleep;
} aeEventLoop;
Redis 将文件事件注册到操作系统内核的多路复用接口,然后进入主循环不断调用 aeProcessEvents。
1.3 事件循环主流程
// ae.c - aeProcessEvents 简化逻辑
int aeProcessEvents(aeEventLoop *eventLoop, int flags) {
int numevents;
if (eventLoop->maxfd != -1) {
// 计算最近的时间事件,作为阻塞等待的超时时间
tvp = aeSearchNearestTimer(eventLoop);
}
// 1. 调用 epoll_wait / kqueue 等待就绪事件
numevents = aeApiPoll(eventLoop, tvp);
// 2. 处理就绪的文件事件
for (j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
int mask = eventLoop->fired[j].mask;
int fd = eventLoop->fired[j].fd;
int rfilemask = 0, wfilemask = 0;
if (fe->mask & mask & AE_READABLE) {
rfilemask |= AE_READABLE;
fe->rfileProc(eventLoop, fd, fe->clientData, mask);
processed++;
}
if (fe->mask & mask & AE_WRITABLE) {
wfilemask |= AE_WRITABLE;
if (!rfilemask || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop, fd, fe->clientData, mask);
processed++;
}
}
}
// 3. 处理时间事件
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
return processed;
}
关键点:
- 无余力等待:
aeApiPoll的阻塞时间不会超过最近一个定时任务的触发时间,因此 Redis 不会像传统阻塞服务器那样"空等"。 - 事件分离:每个 fd 注册独立的读处理函数(
rfileProc)和写处理函数(wfileProc),回调处理实现解耦。 - 单线程顺序执行:
epoll_wait返回后,就绪事件在主线程中逐个串行处理。没有任何并发竞态,不需要锁,这是 Redis 高性能的核心设计哲学。
1.4 回调函数的注册
当新客户端连接时,Redis 在 acceptTcpHandler 中为新 fd 注册读事件:
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
// accept 新连接
cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
if (cfd == ANET_ERR) return;
// 创建客户端结构体并注册读事件
acceptCommonHandler(connCreateAcceptedSocket(cfd), 0, cip);
}
// 后续在 networking.c 中注册到 event loop
void createClient(connection *conn) {
client *c = zmalloc(sizeof(client));
...
if (connSetReadHandler(conn, readQueryFromClient) == C_ERR) {
freeClient(c);
return;
}
}
当客户端有数据到达时,内核通过 epoll_wait 通知 Redis,主线程调用 readQueryFromClient 读取请求、解析 RESP 协议、执行命令、将回复写入客户端输出缓冲区。如果输出缓冲区有数据等待发送,Redis 还会注册写事件,让 epoll_wait 在 socket 变得可写时触发 writeToClient 将数据发回。
二、I/O 多路复用:epoll vs kqueue vs select
单线程是否能支撑上万并发,完全取决于底层多路复用机制的效率。Redis 在编译期自动探测操作系统支持的多路复用接口,按优先级依次选择 epoll(Linux)、kqueue(BSD/macOS)、evport(Solaris)、select(兜底)。
2.1 四种机制的深度对比
| 特性 | select | poll | epoll(Linux) | kqueue(BSD/macOS) |
|---|---|---|---|---|
| 时间复杂度 | O(N),每次遍历所有 fd | O(N),遍历整个数组 | O(1),事件数量与 fd 总数无关 | O(1),仅返回就绪事件 |
| fd 上限 | 默认 1024(可修改 FD_SETSIZE 重编译) | 无硬编码上限(受内存限制) | 无上限(/proc/sys/fs/file-max) | 无上限 |
| 触发模式 | 水平触发 | 水平触发 | 支持 ET(边缘触发)和 LT(水平触发) | 支持 EV_CLEAR(类似 ET) |
| 内存拷贝 | 每次调用拷贝 fd_set 到内核 | 每次调用拷贝整个数组 | 通过 epoll_ctl 注册后,epoll_wait 不再拷贝 | 通过 kevent 注册后,仅返回就绪事件 |
| 数据结构设计 | 位图(bitmap),fd 为索引 | 数组,逐个遍历 | 红黑树维护全部 fd,就绪列表维护就绪 fd | kqueue 内核队列 |
| Redis 使用场景 | 不用于生产 | 不用于生产 | Linux 生产环境 | macOS 开发、FreeBSD 生产 |
2.2 select 的性能瓶颈
// select 的核心问题:每次调用都要传递整个 fd_set
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);
// ... 将所有需要监控的 fd 加入 readfds
select(maxfd + 1, &readfds, NULL, NULL, &timeout);
// 返回后需要遍历所有 fd,检查 FD_ISSET(fd, &readfds)
select 的两个致命缺陷:
- fd_set 大小固定为 1024,对于 C10K 场景根本不够。虽然可以修改
FD_SETSIZE重编译内核和 glibc,但线性扫描的开销会急剧恶化。 - 每次调用需要重新传递 fd_set 到内核,内核返回后再全部拷贝回用户空间。假设监控 10000 个 fd,每次都要发生 10000/8 = 1250 字节的内存拷贝。
在 Redis 中,select 仅在极其老旧的系统上作为兜底方案,生产环境不应使用。
2.3 epoll 的设计优势
// epoll 的三步操作模式
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // ET 模式
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); // 只注册一次
// 主循环中反复调用
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout);
for (int i = 0; i < nfds; i++) {
handle_event(events[i].data.fd, events[i].events);
}
epoll 的关键改进:
- 红黑树管理 fd:所有被监控的 fd 存储在内核红黑树中,
epoll_ctl增删改的复杂度为 O(log N)。 - 就绪链表:当网络事件发生时,内核回调
ep_poll_callback将 fd 加入就绪链表。epoll_wait只需将链表中的事件拷贝到用户空间,复杂度 O(活跃事件数),与总 fd 数无关。 - ET(边缘触发)vs LT(水平触发):
- LT 模式下,只要 fd 可读,
epoll_wait每次返回都会报告该 fd。 - ET 模式下,仅在 fd 状态从不可读变为可读时通知一次,要求用户空间必须一次性读尽数据。Redis 使用 LT 模式,因为处理单个客户端的读请求后可能还有其他逻辑需要处理,不需要在每个 fd 上死循环读取直到 EAGAIN。
- LT 模式下,只要 fd 可读,
2.4 kqueue:BSD 系的优等生
// kqueue 的 API 更为统一,将文件事件、信号、进程事件统一到一个队列
int kq = kqueue();
struct kevent changes[2], events[10];
// 注册事件
EV_SET(&changes[0], fd1, EVFILT_READ, EV_ADD, 0, 0, NULL);
EV_SET(&changes[1], fd2, EVFILT_WRITE, EV_ADD, 0, 0, NULL);
kevent(kq, changes, 2, NULL, 0, NULL);
// 等待事件
int nev = kevent(kq, NULL, 0, events, 10, &timeout);
kqueue 的设计更加简洁优雅,且与定时器(EVFILT_TIMER)天然集成。macOS 上开发 Redis 时,ae_kqueue.c 会被自动编译。不过 macOS 不适合作为 Redis 生产服务器,性能测试环境感受即可。
2.5 查看你的 Redis 使用哪种多路复用
# 查看编译配置
redis-cli INFO server | grep multiplexing_api
# 输出示例:multiplexing_api:epoll
# 确认系统支持
$ cat /proc/sys/fs/file-max
9223372036854775807
# 调整单进程 fd 上限(Redis 并发连接数受限于此)
ulimit -n 65535
Redis 的 maxclients 默认 10000,但实际能支持的连接数还受限于操作系统的文件描述符上限。建议:
# /etc/security/limits.conf
redis soft nofile 65535
redis hard nofile 65535
# /etc/sysctl.conf
fs.file-max = 100000
net.core.somaxconn = 65535
三、RESP 协议:RESP2 与 RESP3
响应式协议(REdis Serialization Protocol)是 Redis 客户端与服务端之间的通信语言。RESP2 自 Redis 1.2 沿用至今,RESP3 在 Redis 6.0 引入,新增丰富的数据类型。
3.1 RESP2 协议格式
RESP2 是一个二进制安全的文本协议,基于 TCP 流传输,使用 \r\n(CRLF)作为分隔符。
| 类型前缀 | 含义 | 示例 |
|---|---|---|
+ | 简单字符串(Simple String) | +OK\r\n |
- | 错误(Error) | -ERR unknown command\r\n |
: | 整数(Integer) | :1000\r\n |
$ | 批量字符串(Bulk String),二进制安全 | $6\r\nfoobar\r\n |
* | 数组(Array),批量字符串的集合 | *2\r\n$3\r\nGET\r\n$3\r\nkey\r\n |
3.2 Wire-Level 示例:一次 GET 命令
# 客户端发送的原始字节(十六进制表示)
# *2\r\n$3\r\nGET\r\n$4\r\nname\r\n
2a 32 0d 0a # *2\r\n -> 2 个元素的数组
24 33 0d 0a # $3\r\n -> 第一个元素:长度为 3 的批量字符串
47 45 54 0d 0a # GET\r\n -> 字符串内容
24 34 0d 0a # $4\r\n -> 第二个元素:长度为 4 的批量字符串
6e 61 6d 65 0d 0a # name\r\n -> 字符串内容
服务端回复:
$5\r\nAlice\r\n
24 35 0d 0a # $5\r\n -> 长度为 5 的批量字符串
41 6c 69 63 65 0d 0a # Alice\r\n
回复空值:
$-1\r\n # nil / null(空键时返回)
3.3 RESP3 新增类型
RESP3(Redis 6.0+)解决了 RESP2 中"一切皆为字符串"的语义模糊问题,引入强类型系统:
| 类型前缀 | 类型 | 说明 |
|---|---|---|
_ | Null | 空值,取代 RESP2 的 $-1 |
# | Boolean | #t\r\n = true, #f\r\n = false |
, | Double | 浮点数 ,3.14\r\n |
( | Big Number | 大整数 |
! | Bulk Error | 带长度的错误信息 |
= | Verbatim String | 逐字字符串(带格式标注) |
% | Map | %{count}\r\n + key-value 对 |
~ | Set | ~{count}\r\n + 元素数组(无序) |
| | Attribute | 元数据属性 |
> | Push | 服务端主动推送(用于 Client Side Caching) |
3.4 协议切换:HELLO
# 客户端请求切换到 RESP3
redis-cli HELLO 3
# 返回 Map 格式的服务端信息:
# 1# "server" => "redis"
# 2# "version" => "7.2.4"
# 3# "proto" => (integer) 3
# 回退 RESP2
redis-cli HELLO 2
RESP3 的价值在于语义精确:RESP2 中布尔值、数字、null 都是字符串表示,客户端需要猜测实际类型。RESP3 为 RedisJSON 等模块提供了原生的 Map/Set 支持,也为 客户端缓存 提供了 > Push 类型实现主动失效通知。
3.5 协议对性能的影响
RESP 是文本协议而非二进制协议,解析器需要逐字节扫描 \r\n。但 RESP 的设计足够紧凑,且解析不涉及复杂语法分析(没有巴科斯范式文法)。在 10 万 QPS 级别,协议解析的 CPU 开销只占命令处理总时间的 5-10%。真正的时间大头在:
- 网络 I/O:
read()/write()系统调用 - 命令执行:数据结构操作、内存分配
- 序列化/反序列化:特别是大 Key 的批量回复
这也引出了 Pipeline 和 IO 多线程优化的必要性。
四、Pipeline 与批量命令优化
Redis 的性能瓶颈往往不在服务端处理速度,而在网络往返(Round Trip Time, RTT)。在 1ms 跨机房网络中,即便服务端处理耗时 10 微秒,单次请求也需要 1ms 的总耗时(0.5ms 去 + 处理 + 0.5ms 回)。Pipeline 通过一次发送多条命令、一次读取所有回复,将多个 RTT 压缩为一个。
4.1 RTT 对吞吐量的影响
无 Pipeline: 有 Pipeline (n=100):
Client: SET k1 v1 Client: SET k1 v1
| ======> SET k2 v2
| <====== ...
| ======> SET k100 v100
| <====== | ========>
| ... | <========
| ======>
| <====== 100 条命令,1 个 RTT
100 次 RTT
吞吐量: 1 / RTT (约 1k QPS) 吞吐量: 100 / RTT (约 100k QPS)
4.2 Pipeline 的使用方式
# redis-cli 的 Pipeline 模式(使用 --pipe 选项)
cat << 'EOF' | redis-cli --pipe
SET user:1001:name Alice
SET user:1001:age 28
HSET user:1001:profile city "Shanghai" country "CN"
EXPIRE user:1001:name 3600
EXPIRE user:1001:age 3600
EOF
# Python 使用 Pipeline
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
pipe = r.pipeline()
for i in range(1000):
pipe.set(f"key:{i}", f"value:{i}")
pipe.expire(f"key:{i}", 3600)
# 一次性发送 2000 条命令,只产生 1 次 RTT
results = pipe.execute()
print(f"成功执行 {len(results)} 条命令")
// Go 使用 Pipeline
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
)
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
pipe := rdb.Pipeline()
for i := 0; i < 1000; i++ {
pipe.Set(ctx, fmt.Sprintf("key:%d", i), fmt.Sprintf("val:%d", i), 0)
}
cmds, err := pipe.Exec(ctx)
if err != nil {
panic(err)
}
fmt.Printf("执行了 %d 条命令\n", len(cmds))
}
4.3 Pipeline vs MULTI 事务
| 特性 | Pipeline | MULTI/EXEC 事务 |
|---|---|---|
| 原子性 | 非原子,命令可能被中间插⼊ | 原子,事务内命令连续执行 |
| 回滚 | 无 | 无(Redis 事务不支持回滚) |
| 性能 | 最高,仅减少 RTT | 中等,需加锁和 WATCH 乐观锁 |
| 适用场景 | 大量独立命令的批量写入 | 需要条件性执行的原子操作 |
注意:Pipeline 中的命令不是原子性的。如果中间某个命令失败,后续命令仍然执行。对于需要原子性的场景,使用 Lua 脚本或 MULTI/EXEC 事务。
4.4 Pipeline 的最佳实践
# 1. 控制单次 Pipeline 的命令数量,避免输出缓冲区膨胀
# 一般建议 100-1000 条为一个 batch
BATCH_SIZE=500
# 2. 使用 Redis 2.6+ 的原生批量命令(比 Pipeline 更高效)
# MGET/MSET 的原子批量操作
MSET key1 val1 key2 val2 key3 val3
MGET key1 key2 key3
# HMSET/HMGET(现在推荐使用 HSET 的批量形式)
HSET user:1001 name Alice age 28 city Shanghai
# LPUSH/RPUSH 批量入队
LPUSH queue:tasks task1 task2 task3 task4 task5
# 3. 使用 UNLINK 替代 DEL 批量删除(异步释放内存)
UNLINK key1 key2 key3
4.5 Pipeline 与输出缓冲区
Pipeline 大批量发送命令时,服务端会在连接对应的 client->buf 或 client->reply 链表中累积回复。若 Pipeline 条数过多(如 10 万条),可能触发 Redis 的 client-output-buffer-limit,导致连接被强制断开。
# redis.conf 输出缓冲区限制
client-output-buffer-limit normal 0 0 0 # 普通客户端无限制
client-output-buffer-limit replica 256mb 64mb 60 # 副本客户端限制
client-output-buffer-limit pubsub 32mb 8mb 60 # 发布订阅客户端限制
建议:单次 Pipeline 命令数控制在 1000 以内,大批量写入时拆分成多个 Pipeline 批次,每批次之间留空行检查是否需要调整。
五、连接处理与 TCP Backlog 调优
高并发场景下,连接建立阶段是 Redis 常见的性能瓶颈之一。TCP 的三次握手、SYN Flood 攻击防护、accept 队列长度都直接影响并发能力。
5.1 TCP 连接建立流程
客户端: SYN
服务端: SYN + ACK -> 进入 SYN_RECV,连接放入半连接队列
客户端: ACK
服务端: 连接移入 accept 队列,等待 accept()
Redis 主线程: accept() -> 创建 client 结构体 -> aeCreateFileEvent(读取)
5.2 关键内核参数调优
| 参数 | 说明 | 建议值 |
|---|---|---|
net.core.somaxconn | accept 队列最大长度 | 65535 |
net.ipv4.tcp_max_syn_backlog | SYN 半连接队列大小 | 65535 |
net.ipv4.tcp_syncookies | SYN Cookie 防洪水攻击 | 1(开启) |
net.ipv4.tcp_tw_reuse | TIME_WAIT 状态复用 | 1 |
net.ipv4.tcp_tw_recycle | TIME_WAIT 快速回收 | 0(内核 4.12+ 已废弃) |
net.ipv4.tcp_keepalive_time | TCP 保活探测间隔 | 300 |
net.ipv4.tcp_keepalive_intvl | 保活探测重试间隔 | 30 |
net.ipv4.tcp_keepalive_probes | 保活探测重试次数 | 3 |
# 临时生效
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.ipv4.tcp_syncookies=1
# 永久生效
# /etc/sysctl.d/99-redis.conf
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
sysctl --system
5.3 Redis 层面配置
# redis.conf
# 最大客户端连接数(默认 10000)
maxclients 30000
# TCP backlog 长度(默认 511,但受限于 somaxconn)
tcp-backlog 65535
# TCP 保活探测间隔(秒)
tcp-keepalive 60
# 端口绑定
port 6379
bind 0.0.0.0
# 连接超时(秒)
timeout 0
tcp-backlog 设置为 65535 的同时,必须确保 net.core.somaxconn >= 65535。如果系统参数小于 Redis 配置,实际生效值会以系统参数为准,Redis 启动时会在日志中提示:
# WARNING: The TCP backlog setting of 65535 cannot be enforced because
# /proc/sys/net/core/somaxconn is set to the lower value of 128.
5.4 客户端连接池调优
频繁创建/关闭 Redis 连接会产生大量 TIME_WAIT 状态的套接字,消耗本地端口资源。应用层必须使用连接池:
// Jedis 连接池配置
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(100); // 最大连接数
poolConfig.setMaxIdle(20); // 最大空闲连接
poolConfig.setMinIdle(5); // 最小空闲连接
poolConfig.setMaxWaitMillis(3000); // 获取连接超时
poolConfig.setTestOnBorrow(false); // 借用时检测(关闭可降低延迟)
poolConfig.setTestWhileIdle(true); // 空闲时检测
JedisPool pool = new JedisPool(poolConfig, "localhost", 6379, 2000);
# Python redis-py 连接池
import redis
pool = redis.ConnectionPool(
host='localhost',
port=6379,
max_connections=100,
socket_keepalive=True,
socket_keepalive_options={
socket.TCP_KEEPIDLE: 60,
socket.TCP_KEEPINTVL: 10,
socket.TCP_KEEPCNT: 3,
}
)
r = redis.Redis(connection_pool=pool)
六、Redis 6.0+ 多线程 I/O:io-threads
Redis 6.0 引入了一个被广泛期待的特性——多线程 I/O。理解这个设计的前提:Redis 的命令执行仍保持单线程,多线程仅用于网络 I/O 的读取和写入解析。
6.1 为什么不是"多线程 Redis"?
单线程执行命令是 Redis 数据一致性的基石。如果多个线程同时操作同一个 Hash 或 ZSet,就需要引入锁机制(如读写锁、细粒度锁),这会极大增加代码复杂度,且在高竞争下锁开销会吞噬多线程带来的收益。
因此 Redis 6.0 的设计是IO 线程 + 单线程命令处理器的混合模式:
+-------------------------------------------------------+
| Redis 主线程 |
| 1. epoll_wait 等待就绪事件 |
| 2. 将读就绪的 client 分发给 IO 线程队列 |
| 3. 等待所有 IO 线程完成读取和协议解析 |
| 4. 主线程顺序执行所有 client 的命令 |
| 5. 将写就绪的 client 分发给 IO 线程队列 |
| 6. 等待 IO 线程完成回复写出 |
+-------------------------------------------------------+
| |
v v
+------------+ +------------+
| IO Thread 1| | IO Thread 2|
| read/parse | | read/parse |
| write/reply| | write/reply|
+------------+ +------------+
... ...
+------------+
| IO Thread N|
+------------+
6.2 IO 线程的工作内容
每个 IO 线程处理以下任务:
- 读取阶段:从 fd 读取网络数据到
client.querybuf - 解析阶段:解析 RESP 协议,生成
client.argv/client.argc - 写出阶段:将
client->buf/client->reply链表的回复数据写入 fd
第一阶段和第三阶段是并行的(各个线程处理不同 client 的 I/O),命令执行阶段是串行的(主线程独占数据结构)。
6.3 启用 IO 多线程
# redis.conf - IO 多线程配置示例
# 启用 IO 多线程
io-threads-do-reads yes
io-threads 4
# 注意:主线程本身也是一个线程
# io-threads 4 表示 1 个主线程 + 3 个 IO 线程 = 4 个线程处理 I/O
配置建议:
| 场景 | io-threads | 说明 |
|---|---|---|
| 4 核以下 | 2 | 够用即可,避免线程调度浪费 |
| 4-8 核 | 4 | 推荐配置 |
| 8 核以上 | 8 | 上限不推荐超过 8,CPU 核心数的 1/2 |
| 纯写入负载 | 关闭或不启用 reads | 若 write 比 read 多,io-threads 优化有限 |
注意:
io-threads是全局配置,不能针对单个客户端调整。Redis 7.0 引入了CLIENT NO-EVICT和更精细的控制,但 IO 线程数仍是全局的。
6.4 IO 线程的性能增益
根据 Redis 官方测试,在以下场景 IO 线程收益最明显:
- 大流量写入:Pipeline 批量写入,每次 write 的数据量很大
- 大量小请求:连接数 > 1000,每个请求的数据量小,但网络 I/O 密集
- 长连接场景:短连接(频繁 connect/disconnect)下 IO 线程无显著收益
测试数据表明:8 核服务器、4 个 IO 线程、Pipeline=16 的场景下,吞吐量可从单线程的 80k QPS 提升到 240k QPS(约 3 倍)。但纯 GET 小键场景提升约 1.5 倍,因为命令执行本身已经是主线程瓶颈。
6.5 源码视角的 IO 线程分配
// networking.c - handleClientsWithPendingReadsUsingThreads
// 将待读取的客户端均匀分配给 IO 线程
int item_id = 0;
listRewind(server.clients_pending_read, &li);
while ((ln = listNext(&li))) {
client *c = listNodeValue(ln);
int target_id = item_id % server.io_threads_num;
listAddNodeTail(io_threads_list[target_id], c);
item_id++;
}
// 设置 IO 线程操作类型并开始
io_threads_op = IO_THREADS_OP_READ;
for (int j = 1; j < server.io_threads_num; j++) {
int count = listLength(io_threads_list[j]);
setIOPendingCount(j, count);
}
// 主线程也参与处理第 0 号列表
// ...
Redis 简单使用 item_id % io_threads_num 的轮询策略分配 client,没有基于 fd 亲缘性或 NUMA 感知的高级调度。这已足够,因为命令解析是无状态的,不需要考虑缓存一致性。
七、TLS 加密开销与配置
Redis 6.0 引入了对 TLS/SSL 的原生支持。启用 TLS 后,所有网络通信被加密,但这会带来 CPU 开销和延迟增长。
7.1 TLS 的握手开销
TLS 1.2 握手流程(完整握手):
Client: ClientHello
Server: ServerHello + Certificate + ServerKeyExchange
Client: ClientKeyExchange + ChangeCipherSpec + Finished
Server: ChangeCipherSpec + Finished
--- 2-RTT ---
TLS 1.3 握手流程:
Client: ClientHello + KeyShare
Server: ServerHello + EncryptedExtensions + Certificate + Finished
Client: Finished
--- 1-RTT ---
7.2 性能损耗对比
| 指标 | 明文 | TLS 1.2 | TLS 1.3 | 影响 |
|---|---|---|---|---|
| 吞吐量(QPS) | 100% | 85-90% | 90-95% | 加解密消耗 CPU |
| 首次连接延迟 | RTT | RTT + 2-RTT | RTT + 1-RTT | TLS 握手开销 |
| CPU 占用 | 基准 | +15-30% | +10-20% | 对称/非对称加解密 |
| 内存占用 | 基准 | 略增 | 略增 | TLS 上下文结构 |
长连接场景中,握手的单点开销被稀释。若应用使用连接池保持长连接,TLS 的总体性能损失可控制在 10% 以内。短连接场景(每次请求新建连接)则损失显著。
7.3 Redis TLS 配置
# redis.conf - TLS 最小配置
# 监听端口(若 port=0 则仅启用 TLS)
port 6379
tls-port 6380
# 证书配置
tls-cert-file /etc/redis/certs/redis.crt
tls-key-file /etc/redis/certs/redis.key
tls-ca-cert-file /etc/redis/certs/ca.crt
# 协议和密码套件
tls-protocols "TLSv1.2 TLSv1.3"
tls-ciphers DEFAULT:!MEDIUM
# 客户端证书验证(双向 TLS)
tls-auth-clients optional
# 集群/副本通信启用 TLS
tls-replication yes
tls-cluster yes
# TLS 会话缓存(减少握手次数)
tls-session-caching yes
tls-session-cache-size 2048
tls-session-cache-timeout 300
详细的 TLS 证书生成和客户端连接示例,可参考 Redis 生产安全指南 中"TLS/SSL 加密"一章。
7.4 TLS 性能优化建议
- 使用 TLS 1.3:1-RTT 握手,零往返恢复(0-RTT)在 Redis 场景下不适用(无重复会话语义),但仍比 TLS 1.2 快。
- 优先使用 AES-GCM:现代 CPU(Intel/AMD 2013+,ARMv8)支持 AES-NI 指令集,AES-GCM 加解密几乎零额外 CPU 开销。
- 保持连接复用:通过连接池避免频繁 TLS 握手。对于 go-redis、redis-py 等客户端,启用连接池即可自动复用。
- 硬件卸载:在极高吞吐场景(>50 万 QPS),可考虑支持 TLS offloading 的代理或 NIC 网卡(如 SmartNIC)。
7.5 对比明文流量抓包
# 明文 Redis 的 tcpdump 输出(可直接读取)
tcpdump -i eth0 port 6379 -A -n | grep "GET\|SET"
# 输出: .GET key1 (# 虽然是文本,但需要 RESP 解析)
# TLS Redis 的 tcpdump 输出(加密)
tcpdump -i eth0 port 6380 -A -n
# 输出: ...加密二进制乱码... 无法直接识别命令
对于安全合规要求高的场景(金融、政务、医疗),TLS 是不可选项。此时应将 Redis 部署在物理隔离的内网中,TLS 用于防止内部横向渗透,而不是公网暴露的直接防护。
八、网络基准测试:redis-benchmark
性能优化必须基于量化指标。Redis 自带的 redis-benchmark 工具是验证网络调优效果的利器。
8.1 基础用法
# 基本压力测试(10 万请求、50 并发连接)
redis-benchmark -h localhost -p 6379 -n 100000 -c 50
# 输出示例:
# ====== SET ======
# 100000 requests completed in 0.82 seconds
# 50 parallel clients
# 3 bytes payload
# keep alive: 1
# 99.99% <= 1 milliseconds
# 121951.22 requests per second
| 参数 | 说明 | 常用值 |
|---|---|---|
-h | 主机 | localhost |
-p | 端口 | 6379 / 6380 |
-n | 总请求数 | 100000 |
-c | 并发连接数 | 50 / 100 / 500 |
-d | 数据大小(字节) | 3 / 100 / 1024 / 4096 |
-t | 测试的命令集 | set,get,lrange,lpush |
-P | Pipeline 批量数 | 1 / 16 / 100 |
-k | keepalive | 1 (长连接) / 0 (短连接) |
--csv | CSV 格式输出 | 用于对比分析 |
--tls | 启用 TLS | 测试 TLS 场景 |
8.2 Pipeline 性能对比
# 无 Pipeline
redis-benchmark -n 100000 -c 50 -t set,get -P 1
# ~80,000 requests per second
# Pipeline=16
redis-benchmark -n 100000 -c 50 -t set,get -P 16
# ~1,200,000 requests per second (提升 15 倍!)
# Pipeline=100
redis-benchmark -n 100000 -c 50 -t set,get -P 100
# ~2,500,000 requests per second
注意:极高的 Pipeline 值会导致服务端输出缓冲区膨胀,实测时客户端和服务端都需合理配置。
8.3 大 Key 场景测试
# 测试 1KB 数据的读写性能
redis-benchmark -n 100000 -c 50 -d 1024 -t set,get
# 测试 10KB 数据
redis-benchmark -n 10000 -c 50 -d 10240 -t set,get
# 测试 100KB 数据(注意网络带宽瓶颈)
redis-benchmark -n 10000 -c 10 -d 102400 -t set,get
8.4 TLS 性能对比测试
# 明文性能基准
redis-benchmark -h localhost -p 6379 -n 100000 -c 50 -t set,get --csv > plain.csv
# TLS 性能测试
redis-benchmark -h localhost -p 6380 -n 100000 -c 50 -t set,get \
--tls \
--cacert /etc/redis/certs/ca.crt \
--cert /etc/redis/certs/client.crt \
--key /etc/redis/certs/client.key \
--csv > tls.csv
# 对比
diff plain.csv tls.csv
8.5 自定义脚本与 CPU 亲和力
# 绑定 Redis 到特定 CPU 核心,避免上下文切换
# redis-server 启动时
taskset -c 0 redis-server /etc/redis/redis.conf
# 绑定 benchmark 到另一颗核心
taskset -c 1 redis-benchmark -h localhost -n 1000000 -c 100 -t set,get -P 16
8.6 结果分析模板
测试完后重点观察这些指标:
# 综合吞吐量
redis-benchmark -n 1000000 -c 100 -P 16 -t set,get,lpush,lrange
# 关注长尾延迟:99th percentile 应 < 5ms
# 若 p99 显著高于 p95,说明存在偶发阻塞(如 AOF fsync、大 Key、内存碎片整理)
# INFO 命令查看服务端状态
redis-cli INFO stats
# keyspace_hits / (keyspace_hits + keyspace_misses) = 命中率
# total_commands_processed / uptime_in_seconds = 实际 QPS
# instantaneous_ops_per_sec = 瞬态 QPS
8.7 IO 线程效果验证
# 单线程基准
redis-cli CONFIG SET io-threads 1
redis-benchmark -n 500000 -c 100 -P 16 -t set,get > single_thread.txt
# 多线程基准
redis-cli CONFIG SET io-threads 4
redis-cli CONFIG SET io-threads-do-reads yes
redis-benchmark -n 500000 -c 100 -P 16 -t set,get > multi_thread.txt
# 对比差异
diff single_thread.txt multi_thread.txt
九、生产环境网络优化检查清单
以下是基于本文内容的 Redis 网络性能检查清单,可用于上线前或定期巡检:
9.1 操作系统层面
-
net.core.somaxconn >= 65535且redis.conf的tcp-backlog已同步 -
fs.file-max>maxclients的 2 倍以上 -
ulimit -n已设置为足够大的值(如 65535) - TCP 保活已配置(
tcp-keepalive+ 内核参数) - 禁用了 iptables conntrack 对 Redis 端口的追踪(高连接数下 conntrack 表会溢出)
- 若使用 NUMA 架构,已将 Redis 绑定到单一 NUMA 节点的核心
9.2 Redis 配置层面
-
io-threads设置为 CPU 核心数的 1/2(建议 2-8) -
io-threads-do-reads已在 I/O 密集型场景启用 -
maxclients已根据实际并发调整,且不超过系统 fd 上限 -
client-output-buffer-limit已根据 Pipeline 场景配置 - 若启用 TLS,
tls-session-caching yes已配置以减少握手开销 -
timeout按需设置(0 表示永不超时,长连接场景推荐)
9.3 应用层面
- 客户端使用连接池,禁止每个请求新建连接
- Pipeline 批量写入时,每次命令数控制在 500-1000 以内
- 大批量命令优先使用原生批量 API(MGET/MSET/HMSET/UNLINK 等)
- 高并发读场景已考虑读写分离或 Cluster 多节点 分散压力
- 跨可用区访问已启用连接池 + Pipeline,减少 RTT 影响
- 定期进行
redis-benchmark基准测试,建立性能基线
9.4 监控告警指标
| 指标 | 告警阈值 | 意义 |
|---|---|---|
instantaneous_ops_per_sec | < 基线 × 70% | 吞吐量突降 |
connected_clients | > maxclients × 80% | 连接数接近上限 |
rejected_connections | > 0 | 连接被拒绝 |
client_longest_output_list | > 10MB | 某个客户端输出缓冲区膨胀 |
used_memory_rss / used_memory | > 1.5 | 内存碎片严重 |
| TCP retransmits/sec | > 10 | 网络拥塞或丢包 |
| CPU iowait | > 10% | 磁盘 I/O 阻塞(TLS 或持久化导致) |
十、总结
Redis 的网络模型是一个从操作系统多路复用、协议层 RESP 到应用层事件循环深度协同的系统工程。理解它的核心在于把握几个关键点:
单线程事件循环 是 Redis 高性能的根基。没有上下文切换、没有锁竞争,主线程一心一意处理就绪事件。
ae.c的简洁设计(注册回调 ->epoll_wait-> 顺序执行)是千万级并发的起点。多路复用选型 上,Linux 生产环境必须用 epoll,macOS 和 FreeBSD 用 kqueue。select 和 poll 只在老旧系统兜底用。理解 epoll 的红黑树 + 就绪链表设计,才能明白为什么它能支撑 C100K。
RESP 协议 虽然是文本协议,但足够紧凑且解析简单。RESP3 的强类型支撑了现代 Redis 模块和客户端缓存。了解 wire-level 格式有助于自定义客户端和故障排查。
Pipeline 是提升吞吐量的第一手段。它能将 RTT 从"每个命令一次"压缩到"一批命令一次"。生产环境中应养成批量写入的习惯,同时要控制批量大小避免缓冲区溢出。
TCP Backlog 和内核参数 在高并发连接爆发时常常成为瓶颈。
somaxconn、tcp_max_syn_backlog、fd 上限这三个参数必须与 Redis 的maxclients和tcp-backlog联合调优。Redis 6.0+ IO 多线程 不改变命令执行的单线程本质,但将网络 I/O 的解析和序列化分发到多个线程。在 4-8 核服务器上能获得 1.5-3 倍的吞吐量提升,但超过 8 个 IO 线程收益递减。
TLS 加密 让 Redis 满足安全合规要求,但代价是 10-30% 的 CPU 开销和额外的 RTT。长连接 + 会话缓存 + TLS 1.3 能将损失控制在可接受范围。安全要求与性能不可兼得时,优先用私有子网 + 防火墙降低加密需求。
redis-benchmark 是验证一切调优效果的最终裁判。建立性能基线、对比不同配置下的吞吐量与延迟、关注长尾指标(p99/p99.9),才是数据驱动的 Redis 运维。
网络是 Redis 性能的放大器,也是瓶颈的放大器。把本文的检查清单融入到日常运维中,定期使用 redis-benchmark 验证假设,你的 Redis 将在高并发网络环境中始终保持稳定与高效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。