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 进程里实际上有:

  1. 主线程:处理网络请求、执行命令、事件循环
  2. 3 个后台线程:异步处理关闭文件、AOF fsync、惰性释放
  3. 子进程: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_FILEclose() 系统调用在某些情况下会阻塞。比如关闭一个大文件,或者 NFS 文件系统。主线程阻塞会导致所有客户端都卡住,所以放到后台线程做。

BIO_AOF_FSYNC:AOF 持久化需要定期 fsync。这是个磁盘 IO 操作,可能很慢。appendfsync everysec 配置就是每秒做一次 fsync,交给后台线程处理。

BIO_LAZY_FREEUNLINKFLUSHDB ASYNCFLUSHALL 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 成了瓶颈,可以用多个线程并行处理。

但核心的数据结构操作、命令执行,依然是单线程。

— END —