Redis 是单线程的——这句话流传太广了,以至于很多人真的以为 Redis 就一个线程在跑。但实际上,如果你 ps -ef 或者 top 看一眼正在运行的 Redis 进程,会发现线程数不止一个。
到底怎么回事?这篇文章从源码角度把这个问题彻底说清楚。
先说结论
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 的定义:- #define BIO_CLOSE_FILE 0 // 异步关闭文件
- #define BIO_AOF_FSYNC 1 // 异步 AOF fsync
- #define BIO_LAZY_FREE 2 // 异步释放内存
- #define BIO_NUM_OPS 3 // 共 3 种后台任务
复制代码 Redis 启动时会创建 3 个后台线程:- void bioInit(void) {
- // 初始化锁、条件变量、任务队列
- for (j = 0; j < BIO_NUM_OPS; j++) {
- pthread_mutex_init(&bio_mutex[j],NULL);
- pthread_cond_init(&bio_newjob_cond[j],NULL);
- pthread_cond_init(&bio_step_cond[j],NULL);
- bio_jobs[j] = listCreate();
- bio_pending[j] = 0;
- }
-
- // 创建 3 个线程
- for (j = 0; j < BIO_NUM_OPS; j++) {
- if (pthread_create(&thread,&attr,bioProcessBackgroundJobs,arg) != 0) {
- serverLog(LL_WARNING,"Fatal: Can't initialize Background Jobs.");
- exit(1);
- }
- bio_threads[j] = thread;
- }
- }
复制代码 每个线程负责一种任务类型,有自己的任务队列。主线程通过 bioCreateBackgroundJob 提交任务:- void bioCreateBackgroundJob(int type, void *arg1, void *arg2, void *arg3) {
- struct bio_job *job = zmalloc(sizeof(*job));
- job->time = time(NULL);
- job->arg1 = arg1;
- job->arg2 = arg2;
- job->arg3 = arg3;
-
- pthread_mutex_lock(&bio_mutex[type]);
- listAddNodeTail(bio_jobs[type],job);
- bio_pending[type]++;
- pthread_cond_signal(&bio_newjob_cond[type]); // 唤醒对应线程
- pthread_mutex_unlock(&bio_mutex[type]);
- }
复制代码 后台线程的工作循环:- void *bioProcessBackgroundJobs(void *arg) {
- unsigned long type = (unsigned long) arg;
-
- while(1) {
- pthread_mutex_lock(&bio_mutex[type]);
-
- // 没任务就等着
- if (listLength(bio_jobs[type]) == 0) {
- pthread_cond_wait(&bio_newjob_cond[type],&bio_mutex[type]);
- continue;
- }
-
- // 取任务
- listNode *ln = listFirst(bio_jobs[type]);
- job = ln->value;
- pthread_mutex_unlock(&bio_mutex[type]);
-
- // 执行任务
- if (type == BIO_CLOSE_FILE) {
- close((long)job->arg1);
- } else if (type == BIO_AOF_FSYNC) {
- redis_fsync((long)job->arg1);
- } else if (type == BIO_LAZY_FREE) {
- if (job->arg1)
- lazyfreeFreeObjectFromBioThread(job->arg1);
- else if (job->arg2 && job->arg3)
- lazyfreeFreeDatabaseFromBioThread(job->arg2,job->arg3);
- }
-
- pthread_mutex_lock(&bio_mutex[type]);
- listDelNode(bio_jobs[type],ln);
- bio_pending[type]--;
- pthread_mutex_unlock(&bio_mutex[type]);
- }
- }
复制代码 典型的生产者-消费者模型。
为什么需要这些后台线程?
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() 子进程:- // rdb.c
- if ((childpid = fork()) == 0) {
- /* Child process */
- closeListeningSockets(0);
- redisSetProcTitle("redis-rdb-bgsave");
- // 执行持久化...
- exitFromChild(0);
- }
复制代码- // aof.c
- if ((childpid = fork()) == 0) {
- /* Child process */
- closeListeningSockets(0);
- redisSetProcTitle("redis-aof-rewrite");
- // 执行 AOF 重写...
- exitFromChild(0);
- }
复制代码 为什么用 fork() 而不是线程?因为 fork 出来的子进程有父进程内存的完整副本(写时复制),可以安全地遍历所有数据做持久化,不用担心主线程同时修改。如果是多线程,就要加各种锁,复杂度飙升。
但 fork 有代价:父进程内存越大,fork 越慢。所以 Redis 官方建议单实例内存不要太大。
主线程为什么是单线程的
回到核心问题:处理命令的主逻辑为什么用单线程?
几个原因:
1. 没锁的代价
多线程意味着共享数据要加锁。Redis 数据结构复杂,加锁会带来:
单线程完全避免这些问题。
2. 瓶颈不在 CPU
Redis 大部分操作是内存操作,速度极快。瓶颈通常在:
多线程不一定能提升性能,反而增加复杂度。
3. 事件循环模型
Redis 用 epoll/kqueue 做多路复用,一个线程就能处理成千上万的并发连接。这种 IO 模型本身就是单线程友好的,Nginx 也是类似设计。
那些"慢"操作怎么办?
单线程最大的问题是:一个操作慢了,后面所有请求都得等。
Redis 的应对策略:
1. 把操作拆细
比如 KEYS * 会遍历所有 key,很慢。Redis 后来加了 SCAN,每次只遍历一小部分,用游标续传。
2. 扔给后台线程
惰性删除(lazy free)就是这个思路。UNLINK 命令异步删除大 key:- void unlinkCommand(client *c) {
- if (server.lazyfree_lazy_server_del) {
- // 异步删除
- bioCreateBackgroundJob(BIO_LAZY_FREE, NULL, NULL, key);
- } else {
- // 同步删除(旧版本行为)
- dbDelete(c->db, key);
- }
- }
复制代码 3. 用子进程
持久化交给 fork 出来的子进程。
4. 直接禁止
KEYS 命令在生产环境不建议用,DEBUG SLEEP 也是调试用的。
那 Redis 6.0 的多线程 IO 是什么?
Redis 6.0 引入了多线程来处理网络 IO(读写 socket),但命令执行还是单线程。
这个特性的代码在 networking.c 里,主要解决的是网络带宽瓶颈问题。当客户端数据量很大时,读写 socket 成了瓶颈,可以用多个线程并行处理。
但核心的数据结构操作、命令执行,依然是单线程。
总结
线程/进程职责主线程事件循环、命令执行bio 线程 1异步关闭文件bio 线程 2异步 AOF fsyncbio 线程 3异步惰性释放子进程RDB 持久化、AOF 重写Redis 的"单线程"是指命令处理的主流程。但像文件关闭、fsync、大 key 删除这些可能阻塞的操作,都用后台线程或子进程处理了。
这是一个务实的设计选择。单线程简单、无锁、容易维护,配合异步 IO 和后台任务,足以应付绝大多数场景。
如果真的需要更高性能,正确的做法不是改 Redis 代码,而是部署多个实例,用集群分担压力。毕竟 Redis 本身就支持集群模式。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |