Redis 单线程真的是单线程吗?源码角度全面解析
Redis 单线程真的是单线程吗?源码角度全面解析
想必大家伙在面试的时候被问到:“Redis为什么快?”的时候,总是能回答道:“因为是内存操作和单线程的” 。Redis 是单线程的——这句话流传太广了,以至于很多人真的以为 Redis 就一个线程在跑。但实际上,如果你用top 命令看一眼正在运行的 Redis 进程,会发现线程数不止一个。如下:
1root@192-168-2-94:~# top -H -p 1804816
2
3 PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
41804816 root 20 0 48580 12700 5176 S 0.7 0.1 98:20.57 redis-server
51804926 root 20 0 48580 12700 5176 S 0.0 0.1 0:00.00 bio_close_file
61804927 root 20 0 48580 12700 5176 S 0.0 0.1 0:00.00 bio_aof
71804928 root 20 0 48580 12700 5176 S 0.0 0.1 0:00.00 bio_lazy_free
81804929 root 20 0 48580 12700 5176 S 0.0 0.1 0:00.43 jemalloc_bg_thd
91804930 root 20 0 48580 12700 5176 S 0.0 0.1 0:00.02 jemalloc_bg_thd
到底怎么回事?这篇文章从源码角度把这个问题彻底说清楚。
先说结论
Redis 的"单线程"指的是:命令处理的主逻辑是单线程的。
但 Redis 进程里实际上有:
- 主线程:处理网络请求、执行命令、事件循环
- 3 个后台线程:异步处理关闭文件、AOF fsync、惰性释放
- 子进程:RDB 持久化、AOF 重写时 fork 出来的
所以 Redis 不是严格意义上的单线程,而是"命令处理单线程"。
后台线程:bio.c
打开 bio.c,文件开头的注释写得很清楚:
This file implements operations that we need to perform in the background. Currently there is a single operation, that is a background close(2) system call.
说"currently a single operation"是早期版本,现在已经扩展了。看 bio.h 的定义:
1#define BIO_CLOSE_FILE 0 // 异步关闭文件
2#define BIO_AOF_FSYNC 1 // 异步 AOF fsync
3#define BIO_LAZY_FREE 2 // 异步释放内存
4#define BIO_NUM_OPS 3 // 共 3 种后台任务
Redis 启动时会创建 3 个后台线程:
1void bioInit(void) {
2 // 初始化锁、条件变量、任务队列
3 for (j = 0; j < BIO_NUM_OPS; j++) {
4 pthread_mutex_init(&bio_mutex[j],NULL);
5 pthread_cond_init(&bio_newjob_cond[j],NULL);
6 pthread_cond_init(&bio_step_cond[j],NULL);
7 bio_jobs[j] = listCreate();
8 bio_pending[j] = 0;
9 }
10
11 // 创建 3 个线程
12 for (j = 0; j < BIO_NUM_OPS; j++) {
13 if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) {
14 serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs.");
15 exit(1);
16 }
17 bio_threads[j] = thread;
18 }
19}
每个线程负责一种任务类型,有自己的任务队列。主线程通过 bioCreateBackgroundJob 提交任务:
1void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) {
2 struct bio_job *job = zmalloc(sizeof(*job));
3 job->time = time(NULL);
4 job->arg1 = arg1;
5 job->arg2 = arg2;
6 job->arg3 = arg3;
7
8 pthread_mutex_lock(&bio_mutex[type]);
9 listAddNodeTail(bio_jobs[type],job);
10 bio_pending[type]++;
11 pthread_cond_signal(&bio_newjob_cond[type]); // 唤醒对应线程
12 pthread_mutex_unlock(&bio_mutex[type]);
13}
后台线程的工作循环:
1void *bioProcessBackgroundJobs(void *arg) {
2 unsigned long type = (unsigned long) arg;
3
4 while(1) {
5 pthread_mutex_lock(&bio_mutex[type]);
6
7 // 没任务就等着
8 if (listLength(bio_jobs[type]) == 0) {
9 pthread_cond_wait(&bio_newjob_cond[type],&bio_mutex[type]);
10 continue;
11 }
12
13 // 取任务
14 listNode *ln = listFirst(bio_jobs[type]);
15 job = ln->value;
16 pthread_mutex_unlock(&bio_mutex[type]);
17
18 // 执行任务
19 if (type == BIO_CLOSE_FILE) {
20 close((long)job->arg1);
21 } else if (type == BIO_AOF_FSYNC) {
22 redis_fsync((long)job->arg1);
23 } else if (type == BIO_LAZY_FREE) {
24 if (job->arg1)
25 lazyfreeFreeObjectFromBioThread(job->arg1);
26 else if (job->arg2 && job->arg3)
27 lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
28 }
29
30 pthread_mutex_lock(&bio_mutex[type]);
31 listDelNode(bio_jobs[type],ln);
32 bio_pending[type]--;
33 pthread_mutex_unlock(&bio_mutex[type]);
34 }
35}
典型的生产者-消费者模型。
为什么需要这些后台线程?
BIO_CLOSE_FILE:close() 系统调用在某些情况下会阻塞。比如关闭一个大文件,或者 NFS 文件系统。主线程阻塞会导致所有客户端都卡住,所以放到后台线程做。
BIO_AOF_FSYNC:AOF 持久化需要定期 fsync。这是个磁盘 IO 操作,可能很慢。appendfsync everysec 配置就是每秒做一次 fsync,交给后台线程处理。
BIO_LAZY_FREE:UNLINK、FLUSHDB ASYNC、FLUSHALL ASYNC 这些命令用到的。删除大 key(比如包含几百万元素的 hash)会阻塞主线程,所以放到后台线程慢慢删。这是 Redis 4.0 引入的特性。
子进程:持久化
RDB 快照和 AOF 重写会 fork() 子进程:
1// rdb.c
2if ((childpid = fork()) == 0) {
3 /* Child process */
4 closeListeningSockets(0);
5 redisSetProcTitle("redis-rdb-bgsave");
6 // 执行持久化...
7 exitFromChild(0);
8}
1// aof.c
2if ((childpid = fork()) == 0) {
3 /* Child process */
4 closeListeningSockets(0);
5 redisSetProcTitle("redis-aof-rewrite");
6 // 执行 AOF 重写...
7 exitFromChild(0);
8}
为什么用 fork() 而不是线程?因为 fork 出来的子进程有父进程内存的完整副本(写时复制),可以安全地遍历所有数据做持久化,不用担心主线程同时修改。如果是多线程,就要加各种锁,复杂度飙升。
但 fork 有代价:父进程内存越大,fork 越慢。
主线程为什么是单线程的
回到核心问题:处理命令的主逻辑为什么用单线程?
几个原因:
1. 没锁的代价
多线程意味着共享数据要加锁。Redis 数据结构复杂,加锁会带来:
- 锁竞争开销
- 死锁风险
- 代码复杂度上升
单线程完全避免这些问题。
2. 瓶颈不在 CPU
Redis 大部分操作是内存操作,速度极快。瓶颈通常在:
- 网络带宽
- 客户端连接数
- 大 key 操作
多线程不一定能提升性能,反而增加复杂度。
3. 事件循环模型
Redis 用 epoll/kqueue 做多路复用,一个线程就能处理成千上万的并发连接。这种 IO 模型本身就是单线程友好的,Nginx 也是类似设计。
那些"慢"操作怎么办?
单线程最大的问题是:一个操作慢了,后面所有请求都得等。
Redis 的应对策略:
1. 把操作拆细
比如 KEYS * 会遍历所有 key,很慢。Redis 后来加了 SCAN,每次只遍历一小部分,用游标续传。
2. 扔给后台线程
惰性删除(lazy free)就是这个思路。UNLINK 命令异步删除大 key:
1void unlinkCommand(client *c) {
2 if (server.lazyfree_lazy_server_del) {
3 // 异步删除
4 bioCreateBackgroundJob(BIO_LAZY_FREE, NULL, NULL, key);
5 } else {
6 // 同步删除(旧版本行为)
7 dbDelete(c->db, key);
8 }
9}
3. 用子进程
持久化交给 fork 出来的子进程。
4. 直接禁止
KEYS 命令在生产环境不建议用,DEBUG SLEEP 也是调试用的。
那 Redis 6.0 的多线程 IO 是什么?
Redis 6.0 引入了多线程来处理网络 IO(读写 socket),但命令执行还是单线程。
这个特性的代码在 networking.c 里,主要解决的是网络带宽瓶颈问题。当客户端数据量很大时,读写 socket 成了瓶颈,可以用多个线程并行处理。
但核心的数据结构操作、命令执行,依然是单线程。