03 - Redis 架构原理
Redis 架构原理
3.1 整体架构
┌─────────────────────────────────────────────────────┐
│ Redis Server │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 网络层 │──→│ 事件循环 │──→│ 命令执行器 │ │
│ │ (I/O) │ │ (Event Loop) │ │ (Command) │ │
│ └──────────┘ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ │ │ │
│ ┌──────▼──────┐ ┌──────▼──────┐ │
│ │ 时间事件 │ │ 文件事件 │ │
│ │ (Timer) │ │ (I/O Event) │ │
│ └─────────────┘ └─────────────┘ │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 内存数据存储 │ │
│ │ String / List / Hash / Set / ZSet / ... │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ 持久化 │ │ 复制 │ │ 客户端管理 │ │
│ │ RDB/AOF │ │ Replicas │ │ Client Mgmt │ │
│ └──────────┘ └──────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────┘
Redis 的核心设计理念可以用三个词概括:单线程 + 事件驱动 + 内存优先。
3.2 单线程模型
为什么是单线程?
Redis 的核心命令执行模块是单线程的。这看起来违反直觉——单线程怎么能这么快?原因如下:
| 因素 | 说明 |
|---|---|
| 纯内存操作 | 数据在内存中,读写速度纳秒级,远快于磁盘 |
| 避免锁开销 | 单线程无需加锁,省去了锁竞争和死锁的风险 |
| 避免上下文切换 | 多线程的 CPU 上下文切换是性能杀手 |
| I/O 多路复用 | 使用 epoll/kqueue 同时监听大量连接 |
| 高效数据结构 | SDS、ziplist、quicklist 等内部结构经过深度优化 |
单线程的瓶颈与 Redis 6.0 的改进
单线程在以下场景会遇到瓶颈:
网络 I/O ← 瓶颈所在
│
▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 读取请求 │───→│ 执行命令 │───→│ 写入响应 │
│ (单线程) │ │ (单线程) │ │ (单线程) │
└──────────┘ └──────────┘ └──────────┘
Redis 6.0 引入多线程 I/O(Threaded I/O),将网络读写拆分到多个线程:
# redis.conf
# I/O 线程数(0 表示禁用,使用传统单线程)
# 建议设置为 CPU 核心数的一半,不超过 8
io-threads 4
# I/O 线程是否执行写操作
io-threads-do-reads yes
Redis 6.0+ 多线程 I/O 模型:
Thread-1 ──→ 读请求 ─┐
Thread-2 ──→ 读请求 ─┤
Thread-3 ──→ 读请求 ─┼──→ 单线程执行命令 ──→ 多线程写响应
Thread-4 ──→ 读请求 ─┘ │
│
(命令执行仍是单线程)
⚠️ 注意:Redis 6.0 的多线程只用于 网络 I/O,命令执行仍然是单线程。这保证了线程安全,无需加锁。
3.3 事件驱动模型
Redis 使用 Reactor 模式处理事件,核心是一个无限循环(Event Loop):
// 伪代码:Redis 事件循环
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
// 1. 计算最近的定时事件时间
int timeout = aeCalcTimer(eventLoop);
// 2. 调用 epoll_wait / kqueue / select 等待 I/O 事件
aeApiPoll(eventLoop, timeout);
// 3. 处理文件事件(I/O 事件)
processFileEvents();
// 4. 处理时间事件(定时任务)
processTimeEvents();
}
}
文件事件(File Events)
由网络 I/O 触发的事件:
新连接到来 ──→ 连接应答处理器(connAcceptHandler)
客户端写入 ──→ 命令请求处理器(readQueryFromClient)
客户端读取 ──→ 命令回复处理器(sendReplyToClient)
时间事件(Time Events)
周期性触发的定时任务:
| 事件 | 频率 | 作用 |
|---|---|---|
serverCron | 10 次/秒(默认) | 统计信息、过期键删除、触发 RDB/AOF、主从同步 |
clusterCron | 1 次/秒 | 集群心跳检测、故障判断 |
replCron | 1 次/秒 | 主从复制状态管理 |
I/O 多路复用
Redis 会根据操作系统选择最高效的 I/O 多路复用实现:
| 平台 | 实现 | 说明 |
|---|---|---|
| Linux | epoll | O(1) 事件通知,最高效 |
| macOS/BSD | kqueue | 类似 epoll |
| Solaris | evport | Solaris 原生事件端口 |
| 其他 | select | 通用实现,O(n) 性能较差 |
# 查看 Redis 使用的 I/O 多路复用机制
redis-cli INFO server | grep "multiplexing_api"
# multiplexing_api:epoll
3.4 内存管理
jemalloc 内存分配器
Redis 默认使用 jemalloc 作为内存分配器(而非 glibc 的 malloc):
# 查看使用的内存分配器
redis-cli INFO memory | grep mem_allocator
# mem_allocator:jemalloc-5.3.0
| 分配器 | 特点 |
|---|---|
| jemalloc | 默认,内存碎片率低,多线程友好 |
| tcmalloc | Google 开发,并发性能好 |
| libc | 标准 C 库,碎片率较高 |
Redis 对象编码(Object Encoding)
Redis 中每种数据类型底层可能使用不同的编码方式,以在性能和内存之间取得平衡:
# 查看 Key 的底层编码
redis-cli OBJECT ENCODING mykey
| 数据类型 | 小数据编码 | 大数据编码 | 转换阈值 |
|---|---|---|---|
| String | int / embstr | raw | 44 字节 |
| List | listpack(旧版 ziplist) | quicklist | 元素数 > 128 或值 > 64 字节 |
| Hash | listpack | hashtable | 字段数 > 128 或值 > 64 字节 |
| Set | intset(全整数时) | hashtable | 元素数 > 128 或含非整数 |
| ZSet | listpack | skiplist + hashtable | 元素数 > 128 或值 > 64 字节 |
Redis 7.0+ 使用 listpack 替代了旧版的 ziplist,解决了 ziplist 的级联更新(cascade update)问题。
SDS(Simple Dynamic String)
Redis 不使用 C 原生字符串,而是自研了 SDS:
// SDS 结构体(sdshdr8 示例)
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已使用长度
uint8_t alloc; // 分配的总长度
unsigned char flags; // 类型标志
char buf[]; // 实际数据(以 '\0' 结尾,兼容 C 字符串函数)
};
SDS 相比 C 字符串的优势:
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) 直接读 len |
| 缓冲区溢出 | 可能 | 不可能(自动扩展) |
| 二进制安全 | ❌(遇 \0 截断) | ✅(按 len 读取) |
| 内存预分配 | 无 | 减少 realloc 次数 |
| 惰性释放 | 无 | 缩短时不立即回收 |
跳表(Skip List)
Sorted Set(ZSet)在元素较多时使用 跳表 + 哈希表 的双结构:
Level 3: HEAD ─────────────────────→ 9 ─────→ NIL
Level 2: HEAD ──────→ 4 ───────────→ 9 ─────→ NIL
Level 1: HEAD ──→ 2 ──→ 4 ──→ 7 ──→ 9 ──→ NIL
跳表的查询时间复杂度为 O(log N),与平衡树相当,但实现更简单,范围查询更高效。
3.5 RESP 协议
RESP(REdis Serialization Protocol)是 Redis 客户端与服务端之间的通信协议。
RESP 数据类型
| 类型 | 前缀 | 示例 | 说明 |
|---|---|---|---|
| Simple String | + | +OK\r\n | 简单状态回复 |
| Error | - | -ERR unknown command\r\n | 错误回复 |
| Integer | : | :1000\r\n | 整数回复 |
| Bulk String | $ | $5\r\nhello\r\n | 二进制安全的字符串 |
| Array | * | *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n | 数组 |
手动发送 RESP 协议
# 使用 telnet 直接与 Redis 通信
telnet localhost 6379
# 发送 SET hello world(RESP 编码)
*3\r\n$3\r\nSET\r\n$5\r\nhello\r\n$5\r\nworld\r\n
RESP3(Redis 6.0+)
Redis 6.0 引入了 RESP3 协议,增加了更多数据类型:
# 切换到 RESP3
redis-cli HELLO 3
# RESP3 新增类型
# - Double: ,3.14\r\n
# - Boolean: #t\r\n / #f\r\n
# - Blob Error: !4\r\nERR\r\n
# - Map: %2\r\n...
# - Set: ~2\r\n...
# - Push: >2\r\n...
3.6 命令处理流程
一个完整的命令从客户端到服务端的处理流程:
客户端
│
│ 1. 发送命令(RESP 编码)
▼
网络层(epoll 监听可读事件)
│
│ 2. 读取数据到输入缓冲区
▼
命令解析器
│
│ 3. 解析 RESP 协议,提取命令和参数
▼
命令执行器
│
│ 4. 查找命令处理函数
│ 5. 执行前的准备工作(权限检查、参数校验等)
│ 6. 调用命令处理函数
│ 7. 执行后续工作(传播到 AOF/从节点、慢查询记录等)
▼
输出缓冲区
│
│ 8. 将响应写入输出缓冲区
▼
网络层(epoll 监听可写事件)
│
│ 9. 发送响应给客户端
▼
客户端
代码验证
# 启动 Redis 服务端(前台模式,可看到日志)
redis-server --loglevel verbose
# 另一个终端执行命令
redis-cli SET test "hello"
redis-cli GET test
# 在服务端日志中可以看到完整处理流程
3.7 单线程 vs 多线程 vs 多实例
| 方案 | 优点 | 缺点 |
|---|---|---|
| 单线程(Redis 默认) | 无线程安全问题,代码简单 | 单核 CPU 上限 |
| 多线程 I/O(Redis 6.0+) | 网络 I/O 性能提升 1-2 倍 | 命令执行仍是单线程 |
| 多实例部署 | 充分利用多核,实例间隔离 | 运维复杂,数据分散 |
# 验证:多线程 I/O 效果对比
# 关闭 I/O 线程
redis-cli CONFIG SET io-threads 1
redis-benchmark -t set,get -n 100000 -c 256 -q
# 开启 4 个 I/O 线程
redis-cli CONFIG SET io-threads 4
redis-benchmark -t set,get -n 100000 -c 256 -q
💡 技巧:如果单实例 CPU 达到瓶颈,推荐使用 Redis Cluster 多分片方案,而非在单实例上启用多线程。
📌 业务场景
场景一:高并发计数
利用单线程的原子性特性,INCR 命令天然线程安全。在电商秒杀中使用 DECR 做库存扣减,无需加锁:
# 初始化库存
SET stock:sku001 1000
# 扣减库存(原子操作)
DECR stock:sku001
# 返回值 >= 0 表示扣减成功,< 0 表示库存不足
场景二:Pipeline 批量写入
单线程模型下,网络往返(RTT)是性能瓶颈。使用 Pipeline 批量发送命令:
import redis
r = redis.Redis()
pipe = r.pipeline()
for i in range(10000):
pipe.set(f"key:{i}", f"value:{i}")
pipe.execute() # 一次性发送所有命令
场景三:大 Key 避免阻塞
单线程意味着一个慢命令会阻塞所有请求。避免使用 KEYS *、大 Key 的 LRANGE 等:
# ❌ 危险:可能阻塞数秒
KEYS user:*
# ✅ 安全:增量遍历
SCAN 0 MATCH user:* COUNT 100