Redis 为什么这么快?它的线程模型是怎样的?
一句话回答
主要原因有四个:数据都在内存里;数据结构针对速度和内存做了专门设计;用 I/O 多路复用让一个线程同时处理大量连接;命令执行是单线程的,没有锁竞争和线程切换的开销。Redis 6.0 引入的多线程只用来读写网络数据和解析协议,命令仍由主线程逐个执行,单条命令依然是原子的。单线程的代价是一个慢命令会阻塞所有请求,所以生产环境要用 SCAN 代替 KEYS,避免对大 Key 做全量操作。
详细解析
快在哪里
| 原因 | 说明 |
|---|---|
| 内存存储 | 读写不经过磁盘,访问内存比访问磁盘快几个数量级;持久化在后台进行 |
| 高效的数据结构 | 字符串记录了长度,取长度是 O(1);哈希表 O(1) 查找;有序集合用跳表做到 O(log n);小数据用紧凑编码节省内存,见 数据类型和底层实现 |
| I/O 多路复用 | 一个线程通过 epoll(Linux)、kqueue(macOS)同时监听成千上万个连接,哪个连接有数据就处理哪个,不会因为某个连接没数据而干等 |
| 单线程执行命令 | 数据结构不用加锁,没有线程切换和锁竞争;Redis 的瓶颈通常在内存和网络,而不是 CPU |
| 简单的协议 | RESP 协议解析简单;客户端还可以用 Pipeline 一次发送多条命令,减少网络往返 |
线程模型
Redis 6.0 之前,网络读写、协议解析和命令执行都在一个主线程里完成,结构是一个事件循环(和 Node.js 的事件循环 思路类似):
主线程事件循环:
等待事件(epoll_wait)
├─ 有新连接:accept
├─ 连接可读:读数据 → 解析命令 → 执行命令 → 结果写入输出缓冲区
├─ 连接可写:把输出缓冲区的数据发给客户端
└─ 定时任务:清理过期 key 等
"单线程"指的是执行命令的只有一个线程,Redis 进程里还有其他线程和子进程:
- 后台线程:关闭文件、AOF 的 fsync、异步释放内存(
UNLINK、FLUSHALL ASYNC) - 子进程:生成 RDB 快照、重写 AOF 时 fork 出来,见 RDB 和 AOF
Redis 6.0 的多线程
并发连接很多时,瓶颈会出现在网络读写的系统调用和协议解析上。6.0 把这部分交给了 I/O 线程:
主线程:收集可读的连接,分给各个 I/O 线程
I/O 线程(并行):读数据、解析命令
主线程:等所有 I/O 线程解析完,按顺序逐个执行命令
I/O 线程(并行):把结果写回客户端
命令执行仍是单线程的,所以数据结构不用加锁,原有的原子性保证不变。这个功能默认关闭,需要配置 io-threads(比如设为 4)开启。6.x、7.x 里开启后默认只有写回客户端走多线程,读和解析也要走多线程,还需要打开 io-threads-do-reads;Redis 8.0 重写了 I/O 线程的实现,读和写总是都交给 I/O 线程,这个配置不再起作用。
单线程的代价
一条命令执行 100 毫秒,这 100 毫秒里所有客户端的请求都在排队。常见的慢操作:
KEYS *:遍历所有 key,key 多时会阻塞很久- 对大 Key 做全量操作:
HGETALL、SMEMBERS、LRANGE key 0 -1,以及用DEL删除一个很大的集合,见 大 Key 和热 Key - 执行时间很长的 Lua 脚本
- 同步执行的
FLUSHALL、FLUSHDB
对策:用 SCAN 代替 KEYS,用 HSCAN、SSCAN、ZSCAN 分批遍历集合,用 UNLINK 代替 DEL 删除大 Key;用 SLOWLOG GET 查看慢命令(默认记录执行超过 10 毫秒的命令);生产环境可以用 ACL 或 rename-command 禁用 KEYS 这类危险命令。
代码示例
用 SCAN 分批遍历匹配的 key(ioredis):
async function scanKeys(redis, pattern) {
const keys = new Set() // SCAN 可能返回重复的 key,用 Set 去重
let cursor = '0'
do {
// 每次只遍历一小部分,命令很快返回,不会长时间阻塞其他请求
const [next, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200)
batch.forEach((k) => keys.add(k))
cursor = next
} while (cursor !== '0')
return [...keys]
}
面试官可能追问
单线程怎么利用多核 CPU?
单个实例执行命令主要只用一个核。想用满多核,可以在一台机器上部署多个实例,或者用 Cluster 把数据分片到多个节点;6.0 之后的 I/O 线程、后台线程和持久化子进程也会用到其他核。
6.0 引入多线程后,还要担心命令的并发安全吗?
不用。多线程只负责读写网络数据和解析协议,命令仍然在主线程里排队逐个执行,单条命令、MULTI 事务和 Lua 脚本的原子性都和以前一样。需要注意的还是业务层面的竞态,比如先 GET 再 SET 是两条命令,中间可能插入别的客户端的命令。
SCAN 有哪些需要注意的地方?
SCAN 用游标分批遍历,返回的游标为 0 才算结束。遍历期间一直存在的 key 一定会被返回,但可能返回不止一次;遍历期间新增或删除的 key 可能返回也可能不返回。COUNT 只是每次遍历工作量的提示,不保证返回的条数;MATCH 是取出数据后才过滤的,所以某一次可能返回空列表但游标不为 0,这时要继续遍历。
Pipeline 为什么能提升性能?
不用 Pipeline 时,每条命令都要等上一条的响应回来才发送,大部分时间花在网络往返上。Pipeline 把多条命令一次发出去、一次读回结果,省掉了多次往返,也减少了读写的系统调用。但 Pipeline 不是原子的,中间可能穿插其他客户端的命令;一批也不宜太大,否则 Redis 要在内存里暂存大量的回复。
易错点
- Redis 的"单线程"指命令执行是单线程,进程本身有多个线程,还会 fork 子进程
- 6.0 的多线程默认是关闭的,而且只用于网络 I/O,不会并行执行命令
- Redis 快的前提是命令本身快,O(N) 的命令在数据量大时同样会拖垮整个实例
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。