Files
cs-note/hhs/Redis/00-Redis架构与线程模型.md
T
2026-05-26 13:52:08 +08:00

21 KiB
Raw Blame History

tags, create time
tags create time
Redis
缓存
架构
线程模型
IO多路复用
2026-05-26 10:33

Redis 架构与线程模型

概述

Redis 是一个基于内存的数据结构服务器(data structure server),支持 String、Hash、List、Set、Sorted Set 等多种结构,并提供持久化、发布订阅、Lua 脚本、Stream 等高级能力。

本文聚焦于一个核心问题:Redis 的内部架构长什么样?它是怎么做到单线程还这么快的?

[!question] 单线程 → 慢?这是直觉误区 提到"单线程",大多数人的第一反应是"性能差"。但 Redis 单实例可达 10 万+ QPS,延迟在微秒级。为什么?因为对于常规 KV 操作,瓶颈通常不在 CPU,而在网络 IO 和内存。单线程执行命令反而省去了锁竞争和上下文切换的开销——这是 Redis 架构设计中最反直觉、也最关键的一个洞察。

整体架构

先从进程视角看 Redis 的内部模块组成:

flowchart TB
    subgraph Redis进程
        direction TB
        NET["网络层<br/>epoll/kqueue + IO多路复用"]
        EVENT["事件循环<br/>单线程处理所有命令"]
        ENGINE["存储引擎<br/>内存数据结构"]
        BIO["后台线程池<br/>BIO lazyfree / AOF fsync"]
        RDB_CHILD["子进程<br/>BGSAVE / AOF rewrite"]
    end

    CLIENT["客户端"] -->|"TCP 连接"| NET
    NET --> EVENT
    EVENT --> ENGINE
    ENGINE --> BIO
    ENGINE -->|"fork()"| RDB_CHILD
    RDB_CHILD -->|"持久化文件落盘"| DISK["磁盘"]

    style EVENT fill:#f9d,stroke:#96c
    style ENGINE fill:#dfd,stroke:#6c6
    style NET fill:#bbf,stroke:#66c

[!tip] 一句话理解 Redis Redis = 单线程事件循环 + IO 多路复用 + 纯内存操作。这三个要素共同造就了它的高性能。

为什么单线程能这么快?

这是面试高频问题,但大多数回答只停留在"避免了锁"。让我们从根上拆解。

快的根本原因

因素 说明
纯内存操作 数据全部驻留在内存中,读写延迟 ~100ns 级,比磁盘快 10 万倍
单线程无锁 不存在线程安全问题,无需加锁/解锁,省去上下文切换开销
IO 多路复用 一个线程同时监听成千上万个连接(epoll),不会被某个慢连接阻塞
高效数据结构 SDS、ziplist、quicklist、skiplist 等结构针对内存和性能做了极致优化
通信协议简单 RESP(Redis Serialization Protocol)结构极简,解析无状态、无复杂序列化,开销远低于 JSON/XML 类协议

[!question] 那为什么 MySQL 不用单线程? 因为瓶颈不同。MySQL 的查询可能涉及磁盘 IO(随机读写)、复杂 SQL 解析、事务锁管理——这些操作耗时从微秒到毫秒不等,单线程会被磁盘 IO 阻塞,无法处理其他请求。多线程能让一个线程等磁盘时,其他线程继续处理请求。

Redis 也有 IO——网络 IO(与客户端之间的 socket 读写),但 IO 多路复用(epoll)已经用单线程 + 事件驱动高效地解决了这个问题。真正执行命令的环节是纯内存操作,耗时在亚微秒级,CPU 几乎不会空闲,所以单线程足矣。

简单记:瓶颈在阻塞型 IO(如磁盘 IO)→ 多线程有意义,避免单线程被阻塞时无法响应其他请求;内存操作足够快、IO 已被事件驱动解决 → 单线程省去锁和上下文切换的开销,反而更高效。(当然,Redis 6.0 仍对网络 IO 读写做了多线程优化,说明高并发下网络 IO 本身也会成为瓶颈——详见后文。)

单线程的局限

单线程也意味着 Redis 的单个实例无法利用多核 CPU。如果你的命令执行时间过长(如 KEYS *、大集合的 SORT),会阻塞后续所有请求——这就是为什么 Redis 社区反复强调避免慢命令。

flowchart LR
    CMD1["正常命令<br/>SET key val<br/>1us"] --> QUEUE["命令队列"]
    CMD2["正常命令<br/>GET key<br/>1us"] --> QUEUE
    CMD3["慢命令<br/>KEYS *<br/>500ms"] --> QUEUE
    QUEUE --> EXEC["单线程串行执行"]

    CMD3 -.->|"阻塞后面所有命令"| BLOCK["后续请求全部等待 500ms"]

    style CMD3 fill:#fdd,stroke:#c00
    style BLOCK fill:#fdd,stroke:#c00

网络模型:IO 多路复用

Redis 单线程能同时处理上万连接的秘密在于 IO 多路复用(IO Multiplexing)。

传统阻塞 IO vs IO 多路复用

sequenceDiagram
    participant C1 as "客户端1"
    participant C2 as "客户端2"
    participant C3 as "客户端3"
    participant S as "服务端线程"

    Note over S: "传统阻塞IO 一个连接一个线程"
    par 三个线程分别阻塞等待
        C1->>S: "读请求"
        S-->>C1: "阻塞等待数据..."
        C2->>S: "读请求"
        S-->>C2: "阻塞等待数据..."
        C3->>S: "读请求"
        S-->>C3: "阻塞等待数据..."
    end
sequenceDiagram
    participant C1 as "客户端1"
    participant C2 as "客户端2"
    participant C3 as "客户端3"
    participant EP as "epoll"
    participant S as "单线程事件循环"

    Note over S: "IO多路复用 一个线程监控所有连接"
    C1->>EP: "注册监听"
    C2->>EP: "注册监听"
    C3->>EP: "注册监听"
    EP->>S: "通知 C2 有数据可读"
    S->>C2: "读取并处理"
    EP->>S: "通知 C1 有数据可读"
    S->>C1: "读取并处理"

[!info] epoll 的核心思想 epoll 是 Linux 内核提供的 IO 多路复用机制。简单来说:不是"一个线程傻等一个连接",而是"一个线程同时盯住成千上万个连接,谁有数据来了就处理谁"。Redis 用 ae_epoll.c 封装了这套机制。

类比:你是一个餐厅服务员——传统模式是你站在 A 桌旁等他看完菜单(阻塞),而 IO 多路复用是你同时巡视所有桌,谁举手了就去服务(事件驱动)。

Redis 对不同平台做了适配:

平台 底层实现 说明
Linux epoll 最常用,性能最优
macOS / BSD kqueue FreeBSD / macOS 原生机制
Solaris evport Solaris 事件端口
其他 select 通用回退方案,连接数受限(FD_SETSIZE,默认 1024)

ae 事件库

Redis 自己实现了一个轻量级事件库(ae.c),将 IO 事件和定时事件统一抽象:

flowchart TD
    AE["ae 事件库<br/>ae.c"] --> IOMULTI["IO 多路复用适配层"]
    IOMULTI --> EP["epoll - Linux"]
    IOMULTI --> KQ["kqueue - macOS BSD"]
    IOMULTI --> EV["evport - Solaris"]
    IOMULTI --> SEL["select - 通用回退"]

    AE --> TIMER["定时事件<br/>serverCron 等"]
    AE --> IO["文件事件<br/>客户端连接读写"]

    style AE fill:#bbf,stroke:#66c

[!tip] 事件循环的核心逻辑(伪代码)

while (server.running) {
    // 1. beforeSleep 钩子:每轮循环开始前的"预处理"
    beforeSleep();         // 处理过期 key 惰性删除、AOF 缓冲刷新等
    // 2. aeProcessEvents 内部依次完成:
    //    a. epoll_wait 阻塞等待(直到有 IO 事件或超时)
    //    b. 遍历就绪事件,执行对应的命令读取/响应写出
    aeProcessEvents();
    // 3. 处理到期的定时事件(如 serverCron,每 100ms 一次)
    processTimeEvents();
}

这就是 Redis 口中的「主线程事件循环」——一个永不停歇的 while 循环,依次处理 IO 事件和定时事件。

[!info] beforeSleep——容易被忽略的关键钩子 beforeSleep() 在每轮事件循环进入阻塞等待(aeProcessEvents)之前被调用,承担了多项"顺手"的清理工作:

beforeSleep 中的任务 说明
过期 key 惰性删除 执行命令时发现 key 过期了,在这里统一回收
AOF 缓冲区刷新 如果 AOF 配置为 everysec,这里尝试将缓冲区写入内核
处理被阻塞的客户端 有 key 变更时唤醒 BLPOP 等阻塞命令的客户端
集群 failover 辅助 Cluster 模式下处理故障转移相关的清理

简单说,beforeSleep 是事件循环"睡着前"的收尾动作,保证每轮循环结束后状态干净。

RESP 通信协议

客户端与 Redis 之间的通信基于 RESP(Redis Serialization Protocol),这也是它高效的原因之一。

[!question] 为什么不用 HTTP? HTTP 是为浏览器设计的文本协议,头部冗余大、解析复杂、无状态但需要反复握手。而 RESP 的设计目标是:简单、无状态、可逐行流式解析——正好匹配 Redis "命令 → 响应"的交互模型。

RESP2 是 Redis 自诞生以来一直使用的默认协议(Redis 6.0+ 仍默认使用 RESP2,客户端需通过 HELLO 命令主动升级到 RESP3)。RESP2 定义了 5 种数据类型:

前缀 类型 示例 说明
+ 简单字符串 +OK\r\n 状态回复
- 错误 -ERR unknown command\r\n 错误回复
: 整数 :1000\r\n 整数回复(如 INCR 结果)
$ 批量字符串 $5\r\nhello\r\n 二进制安全,支持最大 512MB
* 数组 *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n 命令和多元素结果均用此格式

一个 SET name hello 命令在 RESP2 中的编码:

*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nhello\r\n

[!info] RESP3(Redis 6.0+) RESP3 在 RESP2 基础上新增了 Map、Set、布尔、浮点数、Null 等原生类型,以及 Attribute、Push 消息等。客户端启用 HELLO 3 后可使用 RESP3,返回值语义更丰富(如 HGETALL 直接返回 Map 而非扁平数组)。但协议格式依然保持流式解析的特性。

Redis 6.0+ 多线程 IO

[!question] 既然单线程够用,为什么 Redis 6.0 又搞了多线程? 单线程事件循环处理命令确实很快,但网络 IO 读写(从 socket 读取请求、向 socket 写回响应)随着并发连接数增长,逐渐成为瓶颈。尤其是当响应数据量很大时(如 LRANGE 返回几千个元素),write 系统调用的耗时会显著拖慢事件循环。

Redis 6.0(2020 年发布)引入了 IO 多线程(Threaded IO),但请注意:

[!warning] 关键区分:IO 多线程 ≠ 命令多线程 Redis 6.0 的多线程只用于网络 IO 的读写,命令的解析和执行仍然是单线程。这保证了数据操作的线程安全性,无需加锁。

IO 多线程工作流程

sequenceDiagram
    participant M as "主线程"
    participant T1 as "IO线程1"
    participant T2 as "IO线程2"
    participant T3 as "IO线程3"

    M->>M: "1 接受新连接,放入待处理队列"
    M->>T1: "2 分配连接,让IO线程从socket读取请求"
    M->>T2: "2 分配连接"
    M->>T3: "2 分配连接"
    Note over T1,T3: "并行从各自负责的连接中读取数据"
    T1-->>M: "3 返回读取到的命令"
    T2-->>M: "3 返回读取到的命令"
    T3-->>M: "3 返回读取到的命令"
    M->>M: "4 主线程串行执行所有命令"
    M->>T1: "5 分配响应,让IO线程写回socket"
    M->>T2: "5 分配响应"
    M->>T3: "5 分配响应"
    Note over T1,T3: "并行向各自负责的连接写回响应"

[!tip] 一个形象的比喻 想象一个厨师(主线程):

  • 6.0 之前:厨师自己洗菜(读 IO)、自己炒菜(执行命令)、自己装盘(写 IO)
  • 6.0 之后:雇了几个帮厨(IO 线程)帮忙洗菜和装盘,但炒菜(执行命令)还是厨师一个人

炒菜仍然是瓶颈,但洗菜装盘并行化后,整体吞吐量提升了。

开启方式(Redis 6.0+)

# redis.conf
io-threads 4              # IO 线程数(建议 CPU 核心数的一半,最少 2)
io-threads-do-reads yes   # 是否对读操作也使用多线程(默认 no)

[!warning] 版本要求 io-threads 和 io-threads-do-reads 是 Redis 6.0 新增的配置项,6.0 之前的版本不支持,配置后会报错。

配置 说明
io-threads 线程数,包含主线程自身。设 4 表示 1 个主线程 + 3 个 IO 线程
io-threads-do-reads 默认只对写响应做多线程;设为 yes 后读请求也并行化

[!question] 什么时候该开 IO 多线程? 以下条件同时满足时才值得开启:

  1. 并发连接数很高(数千以上)
  2. 单次响应数据量较大(大 Key 读取、大范围查询)
  3. CPU 核心数充足(至少 4 核)

如果你的场景是大量小 Key 的 GET/SET(典型缓存场景),瓶颈在命令执行本身而非网络 IO,IO 多线程的收益可能并不明显。

后台线程:BIO

除了 IO 线程,Redis 还维护了一组 BIO(Background IO)后台线程,专门执行那些不适合在主线程中做的耗时操作,避免阻塞事件循环。

flowchart TD
    MAIN["主线程事件循环"] -->|"提交任务"| QUEUE["BIO 任务队列"]
    QUEUE --> T1["BIO 线程 1<br/>文件关闭"]
    QUEUE --> T2["BIO 线程 2<br/>AOF fsync"]
    QUEUE --> T3["BIO 线程 3<br/>lazyfree 异步删除"]

    style MAIN fill:#f9d,stroke:#96c
    style T1 fill:#bbf,stroke:#66c
    style T2 fill:#bbf,stroke:#66c
    style T3 fill:#bbf,stroke:#66c

BIO 线程池在 Redis 启动时就已创建(默认 3 个线程),各自负责不同类型的任务:

BIO 线程 职责 触发场景
close 异步关闭文件描述符 AOF 重写完成后关闭旧文件
fsync 异步刷盘 appendfsync everysec 模式下每秒调用
lazyfree 异步释放大对象内存 UNLINK、FLUSHDB ASYNC、过期 Key 惰性删除

[!question] DEL 和 UNLINK 有什么区别?

  • DEL:在主线程中同步释放内存。如果 Key 是一个包含 100 万元素的 Hash,主线程会被阻塞几毫秒甚至更久
  • UNLINK:仅在主线程中断开 Key 的引用(O(1)),实际内存释放交给 BIO 的 lazyfree 线程异步完成——主线程几乎不受影响

生产环境中,删除大 Key 务必使用 UNLINK(需 Redis 4.0+,4.0 以下版本只能用 DEL,此时应考虑分批删除或在从库执行)。

连接管理

每个客户端连接在 Redis 内部都对应一个 redisClient 结构体(存储连接状态、当前数据库、输入/输出缓冲区等),连接数直接影响内存占用和事件循环效率。

flowchart LR
    C1["客户端1"] -->|"TCP"| EP["epoll 就绪队列"]
    C2["客户端2"] -->|"TCP"| EP
    CN["客户端N"] -->|"TCP"| EP
    EP -->|"事件循环处理"| MAIN["主线程"]

    style EP fill:#bbf,stroke:#66c
    style MAIN fill:#f9d,stroke:#96c

关键配置

配置项 默认值 说明
maxclients 10000 最大同时连接数,超过后新连接被拒绝
tcp-backlog 511 内核 TCP 连接队列长度,需与系统 somaxconn 对齐
timeout 0(不超时) 客户端空闲多久后断开,0 表示不主动断开
tcp-keepalive 300(Redis 3.2+) TCP 心跳探测间隔(秒),及时发现死连接;3.2 之前默认为 0(不检测),易导致连接泄漏

[!warning] 连接数暴涨的隐患 连接数过多不仅消耗内存(每个连接约占 ~1KB 结构体 + 输出缓冲区),更重要的是 epoll 事件通知量增大,每轮循环需要遍历更多就绪文件描述符,主线程在 IO 上的时间占比上升,直接影响命令处理吞吐量。

生产建议:在客户端使用连接池(如 go-redis 的 PoolSize),而非每次请求新建连接。

完整线程模型总览

把所有线程类型放在一起看:

flowchart TB
    subgraph Redis进程
        direction TB
        subgraph "单线程:事件循环"
            EVENT["主线程<br/>命令解析 + 命令执行 + 定时任务"]
        end
        subgraph "IO 多线程 (Redis 6.0+)"
            IOT1["IO 线程 1<br/>网络读写"]
            IOT2["IO 线程 2<br/>网络读写"]
            IOTN["IO 线程 N..."]
        end
        subgraph "BIO 后台线程"
            BIO1["close 线程"]
            BIO2["fsync 线程"]
            BIO3["lazyfree 线程"]
        end
        subgraph "子进程 (fork)"
            FORK["RDB BGSAVE<br/>AOF Rewrite"]
        end
    end

    CLIENT["客户端"] --> IOT1
    IOT1 -->|"读取命令"| EVENT
    EVENT -->|"写响应"| IOT2
    EVENT -->|"提交异步任务"| BIO1
    EVENT -->|"触发持久化"| FORK

    style EVENT fill:#f9d,stroke:#96c
    style IOT1 fill:#bbf,stroke:#66c
    style IOT2 fill:#bbf,stroke:#66c
    style IOTN fill:#bbf,stroke:#66c
    style BIO1 fill:#dfd,stroke:#6c6
    style BIO2 fill:#dfd,stroke:#6c6
    style BIO3 fill:#dfd,stroke:#6c6
    style FORK fill:#ffd,stroke:#c90

[!tip] 记忆口诀

  • 主线程:命令执行的"唯一话事人",保证了无锁设计
  • IO 线程:主线程的"快递员",只负责搬数据不负责处理
  • BIO 线程:主线程的"后勤兵",处理脏活累活(关文件、刷盘、删大 Key)
  • 子进程:持久化的"外包队",fork 出来做 RDB 快照和 AOF 重写

serverCron 定时任务

主线程事件循环中,除了处理客户端命令,还会周期性执行 serverCron(默认每秒执行 hz 次,默认 hz=10,即每 100ms 一次)。这是 Redis 内部的"心跳":

任务 频率 说明
过期 Key 扫描 每次 serverCron 随机抽样检查,惰性删除已过期的 Key
增量 rehash 每次 serverCron 对正在扩容的 Hash 表做渐进式迁移
AOF 缓冲区刷新 每次 serverCron 检查 aof_buf 是否需要写入/同步
RDB / AOF 持久化检查 每次 serverCron 检查是否满足 BGSAVE / BGREWRITEAOF 触发条件
集群心跳 每次 serverCron Cluster 模式下发送 Gossip 消息
统计信息更新 每次 serverCron 更新 INFO 中的 QPS、内存使用等指标
客户端超时断开 每次 serverCron 检查 idle 超时的客户端连接

[!question] 为什么 hz 不能调太高? serverCron 在主线程中执行,如果频率过高(如 hz=1000),定时任务本身会占用更多 CPU 时间片,反而影响命令处理吞吐量。官方建议保持默认 hz=10,除非你有明确的调优需求。

fork 子进程与 COW 内存风险

Redis 的 RDB 持久化和 AOF 重写都依赖 fork() 创建子进程。fork() 本身是 O(1) 的(Linux 使用 Copy-On-Write),但后续的内存增长可能非常危险。

flowchart LR
    P["父进程<br/>10GB 数据"] -->|"fork()"| C["子进程<br/>共享同一份物理页"]
    C -->|"写入触发 COW"| NEW["复制物理页<br/>额外消耗内存"]

    style P fill:#f9d,stroke:#96c
    style C fill:#ffd,stroke:#c90
    style NEW fill:#fdd,stroke:#c00

[!warning] fork 后内存可能翻倍 子进程刚 fork 时与父进程共享物理内存页(COW)。但如果父进程在子进程做 RDB/AOF 期间持续写入大量数据,每次写操作都会触发 COW——被修改的内存页会被复制一份。

最坏情况:fork 瞬间实际可用内存需要为当前数据量的 2 倍。如果你的 Redis 实例用了 10GB,系统剩余内存不足 10GB,fork 后可能触发 OOM Killer,导致 Redis 进程被系统杀死。

生产建议:

  • 设置 maxmemory(如 maxmemory 8gb),并将其控制在物理内存的 60%~70%,为 fork COW 预留空间
  • 监控 latest_fork_usec 指标,超过 1ms 说明数据量大、fork 开销高
  • 写入密集时避免同时触发 RDB 和 AOF rewrite

Redis 7.0+ 架构演进

Redis 7.0(2022 年发布)在持久化和线程模型方面带来了重要改进:

Multi-Part AOF

Redis 7.0 重新设计了 AOF 机制,将单一 AOF 文件拆分为多个部分:

flowchart LR
    BASE["base AOF<br/>RDB 格式全量快照"] -->|"append"| INCR["incr AOF<br/>增量命令日志"]
    INCR -->|"rewrite 时合并"| NEW_BASE["新 base AOF"]

    style BASE fill:#dfd,stroke:#6c6
    style INCR fill:#bbf,stroke:#66c
特性 6.x 及之前 7.0+
AOF 文件结构 单文件,rewrite 时写临时文件再 rename base(RDB)+ incr(AOF 增量),独立管理
rewrite 风险 rewrite 期间出错可能损坏原 AOF base 和 incr 分离,rewrite 失败不影响已有数据
加载速度 全量回放 AOF 命令 先加载 base(RDB 快),再回放 incr(少量日志)

IO 线程模型改进

Redis 7.0 对 IO 线程做了性能优化:

  • 更精细的负载均衡:主线程根据各连接的待处理数据量分配 IO 线程,避免某些线程"吃不饱"
  • 减少锁竞争:IO 线程与主线程之间的队列同步机制优化,降低临界区长度

[!tip] 版本选择建议 如果是新项目,直接使用 Redis 7.2+(稳定版)。Multi-Part AOF 解决了老版 AOF rewrite 的数据安全问题,IO 线程优化也让高并发场景下的表现更稳定。

关联笔记