Skip to content

04 线程模型与高性能原理 ​

"Redis 是单线程的,为什么还这么快?"——这是 Redis 面试最高频的问题之一,也是理解 Redis 性能边界的关键。本章深入剖析 Redis 的线程模型、IO 多路复用、RESP 协议,以及 6.0 的多线程改造。

4.1 单线程模型的本质 ​

4.1.1 严格意义上的"单线程" ​

text
Redis 核心处理流程(6.0 前):

  客户端连接 ─┐
  客户端连接 ─┼─→ [事件循环 Reactor] ─→ [单线程命令队列] ─→ [命令执行] ─→ 响应
  客户端连接 ─┘

  整个过程只有一个线程在跑:
    · 网络读写(socket recv/send)
    · 协议解析(RESP 解码)
    · 命令查找与执行
    · 响应构造
    · 持久化 fork 之外的所有操作

为什么选单线程?

Redis 的作者 antirez 在《Redis 为什么使用单线程》一文中给出的理由:

原因说明
CPU 不是瓶颈Redis 的瓶颈通常是内存容量或网络带宽,而非 CPU。单线程下也能把网卡打满
避免锁竞争多线程访问共享数据结构必须加锁,锁竞争、死锁、上下文切换的代价往往比"并行收益"更大
避免上下文切换线程切换有 CPU 开销(Cache 失效、寄存器保存),单线程没有这个问题
实现简单不需要考虑并发问题,代码更简洁、bug 更少、更易维护
天然原子性每个命令都是原子的,用户无需操心并发问题(分布式锁、INCR 都基于此)

核心洞察: Redis 的设计哲学是"让每个命令都快"——通过内存操作 + 高效数据结构 + 避免锁开销,单线程也能达到单机 10 万+ QPS。这比"多线程但每条命令都慢"更好。

4.1.2 单线程的两个推论 ​

推论 1:每个命令都是原子的

bash
# INCR 在高并发下仍然正确,不需要加锁
# 1000 个客户端同时 INCR counter,最终一定是 1000
INCR counter

原因:命令执行过程不会被其他命令打断,所有命令按到达顺序串行执行。这就是为什么 Redis 能用 SET NX 实现分布式锁、用 INCR 做原子计数。

推论 2:慢命令会阻塞一切

bash
# 一个慢命令会卡住所有后续命令(包括其他客户端的!)
KEYS *                    # O(N),N 是库中所有 key 数量,大库下可能秒级
HGETALL user:huge_hash    # O(N),N 是字段数,万级字段下秒级
SMEMBERS big_set          # O(N),N 是集合元素数
LRANGE mylist 0 -1        # O(N),N 是列表长度
FLUSHALL                  # 删除所有 key

铁律:生产环境禁止执行复杂度 O(N) 且 N 很大的命令。

  • 用 SCAN 替代 KEYS
  • 用 HSCAN 替代 HGETALL(大 hash 场景)
  • 用 UNLINK 替代 DEL(大 key 异步删除)
  • 用 lazyfree-lazy-* 配置让淘汰、过期、删除都异步

4.2 IO 多路复用:单线程管理万级连接 ​

4.2.1 多路复用的本质 ​

text
单线程同时管理成千上万个 socket 连接:

传统模型(每个连接一个线程):
  Thread-1 ←→ Socket-1
  Thread-2 ←→ Socket-2
  Thread-3 ←→ Socket-3
  ...
  Thread-10000 ←→ Socket-10000
  问题:1 万个线程的创建、切换、内存占用是灾难

多路复用模型(Redis 采用):
                ┌─── Socket-1 (有数据可读)
  单个线程 ←→ ──┼─── Socket-2 (可写)
                └─── Socket-3 (有异常)

  用 epoll/kqueue/select 等机制,
  一个线程"监听"所有 socket 的事件,
  哪个 socket 有事件就处理哪个,不为每个连接开线程

4.2.2 Redis 的 Reactor 模式 ​

text
Redis 的 Reactor 实现(基于 epoll):

       ┌─────────────────────────────────────────┐
       │        fileEvent 表                      │
       │  (每个 socket 注册的事件:读/写/异常)    │
       └─────────────────────────────────────────┘
                          ↓
       ┌─────────────────────────────────────────┐
       │        aeEventLoop(事件循环)             │
       │  1. epoll_wait 等待事件                  │
       │  2. 遍历触发的事件                       │
       │  3. 调用对应的 handler                   │
       └─────────────────────────────────────────┘
                          ↓
       ┌─────────────────────────────────────────┐
       │  事件类型                                │
       │  · AE_READABLE  →  读处理器              │
       │  · AE_WRITABLE  →  写处理器              │
       │  · AE_BARRIER   →  屏障(写先于读)        │
       └─────────────────────────────────────────┘
c
// Redis 事件循环的简化伪代码(ae.c)
void aeProcessEvents(aeEventLoop *loop) {
    // 1. 计算需要等待的时间(最近的定时器事件)
    int timeout = aeComputeNearestTimer(loop);

    // 2. 调用多路复用 API 等待事件(epoll_wait)
    int numevents = aeApiPoll(loop, timeout);

    // 3. 处理就绪的 socket 事件
    for (int j = 0; j < numevents; j++) {
        aeFileEvent *fe = get FiredEvent(loop, j);

        // 读事件:读取客户端命令
        if (fe->mask & AE_READABLE) {
            fe->rfileProc(loop, fe->fd, fe->clientData);
        }
        // 写事件:向客户端发送响应
        if (fe->mask & AE_WRITABLE) {
            fe->wfileProc(loop, fe->fd, fe->clientData);
        }
    }

    // 4. 处理时间事件(如过期 key 定期清理、持久化等)
    aeProcessTimeEvents(loop);
}

4.2.3 各平台的多路复用实现 ​

Redis 自动选择平台最优的多路复用 API:

平台API性能说明
Linuxepoll⭐⭐⭐⭐⭐O(1) 事件通知,无 fd 数量限制,Linux 默认
macOS / BSDkqueue⭐⭐⭐⭐⭐性能与 epoll 相当
Solarisevent ports⭐⭐⭐⭐—
通用select⭐⭐O(N) 遍历,fd 上限 1024,兜底方案
通用poll⭐⭐类似 select,无 1024 上限

Redis 在 Linux 上默认用 epoll,在 macOS 上用 kqueue,性能都能轻松支持数万并发连接。maxclients 默认 10000,可根据需求调整(同时需要调整内核 somaxconn 与 tcp-backlog)。

4.2.4 为什么多路复用能让单线程高效 ​

能力传统 select/pollepoll/kqueue
检测活跃连接O(N) 遍历所有 fdO(1) 只返回有事件的 fd
最大连接数1024(select)/ 无限制无限制(受内存与 fd 限制)
事件通知方式水平触发(level-triggered)默认边缘触发(edge-triggered)
内核开销每次都要拷贝 fd 集合共享内存(epoll)
text
假设 10000 个连接,其中 10 个活跃:
  select:每次遍历 10000 个 fd,找到 10 个活跃的 → O(10000)
  epoll:直接返回 10 个活跃 fd 的列表 → O(10)

在高并发但实际活跃连接少的场景(大多数 Web 应用),
epoll 的性能优势极为显著

4.3 一次命令的完整执行流程 ​

text
客户端执行 SET key value 的完整流程:

① 客户端发送:SET key value\r\n
   ↓ socket 写
② 服务端 socket 接收缓冲区
   ↓ epoll 检测到 AE_READABLE 事件
③ readQueryFromClient():
   · read() 读数据到输入缓冲区
   · 解析 RESP 协议,得到命令名与参数
   ↓
④ processCommand():
   · 查找命令表(dict)找到 setCommand 函数
   · 权限检查、参数校验
   ↓
⑤ setCommand():
   · 调用 setKey() 把 key-value 存入 dict
   · 触发 keyspace 通知(如果有)
   · 记录 AOF(如果开启)
   · 更新 key 的 LRU 时间
   ↓
⑥ 把响应 "+OK\r\n" 写入输出缓冲区
   ↓ epoll 检测到 AE_WRITABLE 事件
⑦ sendReplyToClient():
   · write() 把响应发送给客户端
   · 清空缓冲区
   ↓
⑧ 返回事件循环,处理下一个事件

整个流程中:

  • 没有锁竞争
  • 没有线程切换
  • 没有复杂的调度
  • 单线程把所有 CPU 时间都用在"干活"上

4.4 为什么单线程仍然这么快(五大原因总结) ​

原因详细
① 纯内存操作数据在内存,读写都是纳秒级;内存访问比磁盘随机 IO 快 10 万倍
② 单线程无锁开销没有 mutex/condition variable、没有上下文切换、没有死锁可能
③ IO 多路复用epoll 让单线程同时高效管理数万连接,活跃连接少的场景性能极佳
④ 高效数据结构每种类型底层都针对性优化(SDS、skiplist、listpack、hashtable)
⑤ 简洁的 RESP 协议文本协议,解析快;没有 SQL 解析、没有优化器、没有执行计划
text
性能实测(redis-benchmark):
  redis-benchmark -t set,get -n 1000000 -q

  SET: 141043.83 requests per second
  GET: 146198.83 requests per second

  单核单线程,QPS 轻松突破 10 万
  瓶颈在网卡(千兆网卡约 12 万 packet/s)而非 CPU

4.5 Redis 6.0 的多线程改造 ​

4.5.1 为什么引入多线程 ​

text
Redis 6.0 之前的瓶颈分析:

  Redis 的"瓶颈"通常不是 CPU,而是:
    · 内存容量
    · 网络带宽
    · 客户端响应延迟

  但在极高并发场景(数万连接、每秒百万包)下,
  【网络 IO 的读写与协议解析】开始占用大量 CPU 时间
  —— 单线程既要读 socket、又要解析 RESP、又要执行命令,忙不过来

  解决方案:把"读 socket + 解析 RESP"和"写 socket + 编码 RESP"
  交给专门的 IO 线程,命令执行仍由主线程负责

4.5.2 6.0 的线程模型 ​

text
Redis 6.0 的线程分工:

                     ┌─ IO-Thread-1:读 socket A + 解码 ─┐
  客户端连接 ───────→├─ IO-Thread-2:读 socket B + 解码 ─┼─→ [命令队列]
                     └─ IO-Thread-N:读 socket C + 解码 ─┘
                                                              ↓
                     ┌───────────────────────────────────┐   │
                     │ 主线程(单线程):                     │   ↓
                     │   · 串行执行命令                    │←──┘
                     │   · 持久化、复制、集群等             │
                     └───────────────────────────────────┘
                                                              ↓
                     ┌─ IO-Thread-1:编码响应 + 写 socket A
  客户端 ←───────────├─ IO-Thread-2:编码响应 + 写 socket B
                     └─ IO-Thread-N:编码响应 + 写 socket C

核心要点:
  · IO 线程只负责:网络数据读写 + RESP 协议解析/编码
  · 主线程仍然串行执行命令 → 命令执行保持原子性
  · IO 线程与主线程通过【无锁队列】协作
  · 默认关闭,需要手动开启

4.5.3 开启多线程 IO ​

ini
# redis.conf(6.0+)
io-threads-do-reads yes    # 读也用多线程(默认 no)
io-threads 4               # IO 线程数

# 建议值:CPU 核数的 3/4,不超过 8
# 4 核机器:io-threads 3~4
# 8 核机器:io-threads 4~6
# 16 核机器:io-threads 8

性能对比(典型场景):

场景单线程多线程 IO提升
SET/GET 小包高并发10 万 QPS15~20 万 QPS+50%~100%
大 value(MGET 多 key)受网卡限制受网卡限制几乎无提升
复杂命令(单命令耗时)——无提升(命令执行仍单线程)

何时开启多线程 IO?

  • ✅ 高并发(> 10 万 QPS)、小包、网卡/CPU 瓶颈
  • ❌ 数据量大、命令复杂、并发低 → 开了反而有线程协调开销
  • ❌ 命令执行本身就是瓶颈(慢查询)→ 多线程救不了

大多数场景不需要开:Redis 6.0 默认关闭,说明作者也认为多数场景单线程够用。

4.5.4 命令执行仍然单线程(关键辨析) ​

text
常见误解:"Redis 6.0 是多线程了,命令可以并行执行"

事实:
  · 命令执行仍然是单线程串行执行
  · 多线程只在【命令执行前后】的网络 IO 阶段
  · 命令队列是单线程处理的,保证命令执行的原子性不变

  为什么不全并行?
  ① 命令间有数据依赖(如 MULTI/EXEC、WATCH、Lua)
  ② 命令并行需要细粒度锁,得不偿失
  ③ 破坏"单命令原子性"这一核心优势(分布式锁就失效了)
bash
# 验证:即使开启多线程 IO,下面两条命令的执行仍然是串行的
# 客户端 A:
MULTI
INCR counter
INCR counter
EXEC
# 返回一定是 [1, 2] 或 [2, 3] 等连续值,不会交错

# 客户端 B 同时:
INCR counter
# 最终结果一定与"串行执行所有命令"等价

4.6 命令处理流程中的关键数据结构 ​

4.6.1 全局命令表 ​

c
// server.c 中的全局命令表(dict)
struct redisServer {
    dict *commands;          // 命令表:key 是命令名,value 是 redisCommand 结构
    dict *orig_commands;     // 命令别名表
    ...
};

struct redisCommand {
    char *name;              // 命令名(如 "set")
    redisCommandProc *proc;  // 命令处理函数指针(如 setCommand)
    int arity;               // 参数个数(-2 表示至少 2 个)
    char *sflags;            // 标志位("r"读、"w"写、"F"快速...)
    ...
};

// 启动时加载几百个命令到 dict
// 命令查找是 O(1) 的 dict 查询,极快

4.6.2 全局 key 空间 ​

c
struct redisServer {
    redisDb *db;             // 数据库数组(db0 ~ db15)
    int dbnum;               // 数据库数量
    ...
};

struct redisDb {
    dict *dict;              // key 空间:所有 key-value 都在这里
    dict *expires;           // 过期表:key → 过期时间戳
    dict *blocking_keys;     // BLPOP 等阻塞命令的等待列表
    dict *ready_keys;        // 已就绪的阻塞 key
    dict *watched_keys;      // WATCH 监控的 key(MULTI 事务用)
    ...
};

// dict 是 Redis 的核心数据结构:
// · 哈希表实现,拉链法解决冲突
// · 渐进式 rehash(避免一次性迁移卡顿)
// · 负载因子超过阈值自动扩容

4.6.3 客户端连接结构 ​

c
typedef struct client {
    uint64_t id;             // 客户端唯一 ID
    int fd;                  // socket 文件描述符(-1 表示伪客户端)
    redisDb *db;             // 当前选择的数据库
    sds name;                // 客户端名(CLIENT SETNAME 设置)
    robj *argv;              // 当前命令的参数数组
    int argc;                // 参数个数
    sds querybuf;            // 输入缓冲区(客户端发来的原始数据)
    list *reply;             // 输出缓冲区(准备发给客户端的响应)
    int flags;               // 客户端标志(主/从/Lua/AOF 加载中等)
    time_t ctime;            // 创建时间
    time_t lastinteraction;  // 最后交互时间(timeout 用)
    ...
} client;

// 客户端信息可通过 CLIENT LIST 查看
// 客户端 ID 可用于 CLIENT KILL ID xxx 断开指定连接

4.7 RESP 协议:Redis 的通信语言 ​

RESP(REdis Serialization Protocol)是 Redis 客户端与服务端通信的文本协议。

4.7.1 RESP2(默认,兼容所有版本) ​

text
五种数据类型:
  ① 简单字符串(Simple Strings):+OK\r\n
  ② 错误(Errors):             -ERR unknown command\r\n
  ③ 整数(Integers):           :1000\r\n
  ④ 批量字符串(Bulk Strings): $6\r\nfoobar\r\n  (长度+内容)
  ⑤ 数组(Arrays):             *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n

  \r\n 是行分隔符(CRLF)
text
示例:SET key value 的传输

客户端发送:
  *3\r\n          ← 数组长度 3
  $3\r\nSET\r\n   ← 元素 1:"SET"(长度 3)
  $3\r\nkey\r\n   ← 元素 2:"key"
  $5\r\nvalue\r\n ← 元素 3:"value"

服务端响应:
  +OK\r\n         ← 简单字符串 "OK"

4.7.2 RESP3(6.0+,可选) ​

text
RESP3 新增数据类型:
  · Null:             _\r\n
  · Double:           ,1.23\r\n
  · Boolean:          #t\r\n 或 #f\r\n
  · Blob error:       !21\r\nSYNTAX invalid syntax\r\n
  · Verbatim string:  =15\r\ntxt:Some string\r\n
  · Big number:       (3492890328409238509324850943850943825024385\r\n
  · Array:            *2\r\n... (同 RESP2)
  · Map:              %2\r\n... (键值对)
  · Set:              ~5\r\n... (无序集合)
  · Attribute:        |1\r\n... (元信息)
  · Push:             >2\r\n... (服务端主动推送)

RESP3 让客户端能直接拿到强类型数据,不必在客户端自己转换
bash
# 开启 RESP3(客户端协商)
HELLO 3 [AUTH username password] [SETNAME myname]
# 切换到 RESP3,返回服务器信息

4.7.3 为什么选文本协议 ​

文本协议(RESP)二进制协议(Thrift/Protobuf)
✅ 人类可读,调试方便❌ 需要解码工具
✅ 实现简单,跨语言容易⚠️ 需生成代码
⚠️ 略占空间✅ 紧凑
⚠️ 类型弱(都是字符串)✅ 强类型

Redis 选 RESP 是典型的工程取舍:简单 > 极致效率。实际场景中,协议本身占用的额外空间相比 value 本身通常可以忽略。

4.8 与多线程数据库的对比 ​

数据库线程模型锁机制并发能力适用场景
Redis单线程命令执行(6.0 IO 多线程)无单机 10 万+ QPS缓存、KV、计数器
MySQL每连接一线程 + InnoDB 多线程行锁 + MVCC数万 QPS关系型 OLTP
PostgreSQL每连接一进程 + 多工作进程行锁 + MVCC数万 QPS关系型 + 复杂查询
MongoDB多线程 + WiredTiger 并发文档级锁数万 QPS文档型
Memcached多线程分段锁数十万 QPS纯缓存

Redis vs Memcached: Memcached 也是 KV 缓存,但是多线程。Memcached 在高核数机器上 QPS 更高,但 Redis 数据结构更丰富、支持持久化与集群。Redis 已经基本取代 Memcached 成为缓存首选。

4.9 单线程下的性能优化建议 ​

4.9.1 应用层 ​

python
# ① Pipeline 减少 RTT(见 [[后端/数据库/Redis/Redis数据类型与命令]])
pipe = r.pipeline()
for i in range(1000):
    pipe.set(f'key{i}', f'value{i}')
pipe.execute()

# ② MGET/MSET 批量操作
r.mset({'a': 1, 'b': 2, 'c': 3})
values = r.mget(['a', 'b', 'c'])

# ③ 避免大 key 操作
# ❌ HGETALL big_hash  → ✅ HSCAN big_hash
# ❌ SMEMBERS big_set  → ✅ SSCAN big_set
# ❌ KEYS *            → ✅ SCAN

# ④ 用 UNLINK 替代 DEL(大 key 异步删除)
r.unlink('bigkey')

4.9.2 配置层 ​

ini
# 启用异步删除(推荐)
lazyfree-lazy-eviction yes      # 淘汰时异步
lazyfree-lazy-expire yes        # 过期时异步
lazyfree-lazy-server-del yes    # 隐式删除异步
lazyfree-lazy-user-del yes      # DEL = UNLINK

# 调整慢查询阈值
slowlog-log-slower-than 10000   # 10ms
slowlog-max-len 256

# 禁用危险命令
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""

# 高并发场景可开启 IO 多线程
io-threads-do-reads yes
io-threads 4

4.9.3 系统层 ​

bash
# 关闭 THP(Transparent Huge Pages),避免 fork 卡顿
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 调整内核参数
sysctl -w vm.overcommit_memory=1     # 允许 fork 时超额提交
sysctl -w net.core.somaxconn=2048    # 增大 TCP 队列
sysctl -w vm.swappiness=0            # 禁用 swap(Redis 数据换出 = 灾难)

# 绑定 CPU 亲和性(避免 Redis 主线程被调度到不同核)
taskset -c 0 redis-server /etc/redis/redis.conf

4.10 常见问题与最佳实践 ​

  1. Redis 单线程指的是命令执行单线程,6.0 起网络 IO 多线程,但命令执行仍单线程
  2. 快的五大原因:内存 + 单线程无锁 + epoll 多路复用 + 高效数据结构 + 简洁协议
  3. 单命令原子性是核心优势:分布式锁、INCR 计数、Lua 脚本都基于此
  4. 慢命令是性能杀手:禁止 KEYS *、HGETALL(大 hash)、SMEMBERS(大 set)
  5. IO 多线程默认关闭,只在高并发小包场景(>10 万 QPS)才考虑开启
  6. 命令执行仍然串行:多线程不能解决慢命令、不能并行执行命令
  7. 生产开启 lazyfree 系列:让淘汰、过期、删除都异步,避免主线程卡顿
  8. 调整内核参数:somaxconn、overcommit_memory、关闭 swap
  9. RESP3 提供强类型(6.0+),但多数客户端仍默认 RESP2
  10. Redis 不是万能的:CPU 密集的计算、大事务、复杂查询不适合,让 MySQL/ES 来做

本章小结 ​

  • Redis 6.0 前是严格单线程:网络 IO + 命令执行都在一个线程
  • 6.0 起网络 IO 多线程、命令执行仍单线程:解决高并发下的网卡/CPU 瓶颈
  • 单线程快的五大原因:内存操作、无锁无切换、epoll 多路复用、高效数据结构、简洁 RESP
  • Reactor 模式 + epoll/kqueue 让单线程管理数万连接
  • 命令表(dict)+ key 空间(dict)+ 过期表(dict)是 Redis 内部的核心数据结构
  • 慢命令会阻塞整个实例:KEYS *、HGETALL(大 hash)、SMEMBERS(大 set)必须避免
  • RESP 协议简洁高效,RESP3(6.0+)增加强类型与主动推送
  • 开启多线程 IO 的判定:QPS > 10 万、小包、网卡/CPU 瓶颈,否则单线程足够
  • 性能优化三层:应用层(Pipeline/批量/避免大 key)+ 配置层(lazyfree)+ 系统层(内核参数)
  • 单命令原子性是 Redis 的核心优势,多线程改造没有破坏这一特性

练习:把「04 线程模型与高性能原理」变成可验证能力 ​

  1. 从本文任选一个示例,在可丢弃环境重跑;记录运行时/服务版本、输入、关键输出和清理动作。
  2. 保持其他条件不变,只制造一个边界:空值、重复请求、并发、超时、断连、权限不足或资源耗尽。
  3. 用四句话复盘:现象是什么、原理在哪一层、证据是什么、生产中如何止损。

验收清单

  • [ ] 示例能从空环境重复执行,必要依赖和版本已写明。
  • [ ] 同时保留成功路径与至少一个失败路径,不用“应该没问题”冒充证据。
  • [ ] 结论写明适用边界,并能链接回对应学习路线的能力域。
  • [ ] 练习结束后已清理临时数据、凭据、端口或进程。

对照 数据库练习答案与复盘尺。先交自己的证据再翻答案;答案是镜子,不是传送门。

用心记录每一步成长