555
社区成员
发帖
与我相关
我的任务
分享很多初学者会认为Redis是单线程的,实际上这个说法并不准确。准确的说法是:Redis处理客户端命令请求的核心逻辑是单线程的,但Redis进程本身并不只有这一个线程。
一个典型的Redis Server进程包含多个线程:
redis-server主线程:负责处理命令请求、解析协议、执行命令、响应客户端,以及执行常规的维护任务(如过期键清理、rehash等)。bio_close_file:关闭文件描述符(如AOF重写后的旧文件)。bio_aof_fsync:将AOF缓冲区数据刷盘(fsync)。bio_lazy_free:惰性释放大对象内存(如删除大key时,避免阻塞主线程)。io_thd_\*):从Redis 6.0开始引入,用于分担网络读写的CPU开销(后面会详细讲解)。所以,Redis并不是单线程的,只是命令执行阶段被设计为单线程。那么问题来了:为什么命令处理要单线程?单线程不会成为瓶颈吗?
Redis选择单线程处理命令,主要基于以下几点考虑:
正是这些设计,使得单线程的Redis在大多数场景下依然能达到每秒十万甚至百万级别的QPS。
虽然命令执行是单线程,但Redis却能同时处理成千上万个客户端连接,这得益于 IO多路复用 机制。Redis在6.0版本之前,网络读写也是单线程的,但它通过操作系统提供的IO多路复用接口实现了非阻塞网络IO。
简单来说,主线程会维护一个事件循环,将所有客户端的套接字注册到epoll中。当某个套接字可读或可写时,epoll会通知主线程,主线程再依次处理这些就绪的事件。这种模型下,一个线程就能高效地管理海量连接,完全不需要为每个连接创建单独的线程。
Redis 6.0引入IO多线程后,网络数据的读取和解析才被交给多个线程并行处理,但事件监听和命令执行依然在主线程完成。
除了单线程和IO多路复用,Redis的高效还体现在对各类任务的精细优化上:
Redis之所以支持丰富的数据类型(字符串、列表、哈希、集合、有序集合),是因为底层为每种类型提供了多种编码方式,根据数据特征动态切换,以在时间和空间上达到最佳平衡。
在深入了解各类型之前,需要先认识Redis中的通用对象结构——redisObject。每个键值对的值都被包装成一个redisObject,其定义如下(简化):
typedef struct redisObject {
unsigned type:4; // 对象类型(string, list, hash, set, zset)
unsigned encoding:4; // 底层编码(int, embstr, raw, hashtable, ziplist...)
unsigned lru:24; // LRU时间戳或LFU计数器
int refcount; // 引用计数,用于共享对象和内存回收
void *ptr; // 指向底层数据结构的指针
} robj;
redisObject固定占用16字节(4+4+24位共4字节,加上4字节refcount和8字节ptr)。下面我们分别看每种类型的编码方式。
字符串的编码分为三种:
redisObject和字符串内容分配在连续的内存块中(一次内存分配),并且充分利用CPU缓存行(64字节)。redisObject和SDS分开分配。为什么分界线是44字节?
这与内存分配器、缓存行和SDS结构有关。
redisObject固定占用16字节(4位类型+4位编码+24位LRU+4字节引用计数+8字节指针)。sdshdr8等类型,占用3字节头部(len、alloc、flags),加上字符串末尾的'\0'占1字节,可以更好的适配c的字符串函数。64 - 16 (redisObject) - 3 (sdshdr8) - 1 ('\0') = 44,恰好是64字节缓存行内能容纳的最大字符串长度,且只需一次内存分配。SDS的优势:除了常数时间的获取长度、预分配内存减少重分配外,SDS是二进制安全的,可以存储任意数据(包括'\0'),并能兼容C字符串函数。
Redis 3.2之前,列表使用ziplist(压缩列表)或linkedlist(双向链表)。3.2及之后版本统一使用 quicklist 编码。quicklist是双向链表和压缩列表的结合体:每个节点是一个ziplist,节点之间用指针连接。
list-max-ziplist-size配置(默认为-2,表示单个ziplist不超过8KB)。这样既保留了链表插入删除快的特性,又通过ziplist减少了指针开销,提升了内存效率。哈希有两种编码:
集合也有两种编码:
有序集合同样有两种编码:
跳表是Redis实现有序集合(编码为skiplist时)的关键数据结构。理解跳表有助于深入掌握ZSET的各种操作性能。

跳表是一种多层级的有序链表,它通过在原始链表上建立若干级“索引”来实现近似二分查找的效果。平均时间复杂度为O(logn),最坏情况O(n),但通过随机化层高,实际性能接近平衡树。
跳表的性质:
Redis对经典跳表做了以下定制:
最大层高限制为32(ZSKIPLIST_MAXLEVEL)。这是经过权衡的:2^32 > 40亿,对于最多容纳数亿个元素的有序集合完全足够,同时避免了过高的内存开销。
层高随机概率为1/4(而不是常见的1/2)。每次创建新节点时,随机生成层高,P=0.25。这意味着平均每个节点有1/(1-0.25)=1.33个指针,内存占用更低,同时查找效率依然接近O(logn)(实际常数略大,但可接受)。
每个节点除了前进指针外,还包含一个后退指针(backward),用于实现反向遍历(如ZREVRANGE)。后退指针只存在于第0层。
同时使用哈希表:为了支持O(1)的按成员查找分数(如ZSCORE),Redis在跳表之外还维护了一个字典(dict),键为成员,值为分数和跳表节点的指针(或直接存储分数)。两者通过指针共享同一个节点对象。
为什么有序集合不选用B+树?
ZRANGE、ZREVRANGE),只需找到起始节点然后向后遍历即可。B+树同样支持范围查询,但需要额外维护叶子节点的链表指针。综上,跳表在Redis的场景下是一个简单、高效、内存友好的选择。
查找:从最高层开始,比较当前节点的下一个节点的分数与目标分数。若下一个节点分数小于目标,则继续向右移动;若大于目标或为NULL,则降一层。重复直到第0层,再向右找到目标。
插入:先随机生成新节点的层高(1~32)。然后从最高层向下查找,记录每一层插入位置的前驱节点。最后新建节点,调整每一层前驱节点的前进指针,使其指向新节点,新节点指向原来后继。同时更新后退指针。
删除:类似插入,找到每层的前驱节点,然后逐个修改指针。如果删除的节点是最高层中唯一节点,可能需要降低跳表的最大层高。
跳表的性质:
Redis将所有键值对存储在一个全局哈希表中,这个哈希表的结构由dict.h/dict定义:
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两个哈希表,用于渐进式rehash
long rehashidx; // rehash进度,-1表示未进行
int16_t pauserehash; // 是否暂停rehash
} dict;

ht[0]是主哈希表,ht[1]用于rehash时的备用表。dictEntry(键值对+链表指针),用于解决哈希冲突。当多个键经过哈希函数计算后映射到同一个数组槽位时,就发生了哈希冲突。Redis采用链地址法解决:每个槽位对应一个链表,冲突的节点通过next指针串联。
负载因子 = used / size,其中used是已存储元素个数,size是哈希表数组长度。
Redis的负载因子阈值设计如下:
扩容和缩容都需要重新计算所有键的哈希值并迁移到新数组,这个过程如果一次性完成,会导致Redis长时间阻塞。因此Redis采用渐进式rehash:
ht[0]和ht[1],将ht[1]扩展为指定大小。rehashidx从0开始,表示当前迁移到ht[0]的哪个桶。ht[0]在rehashidx位置上的整个链表迁移到ht[1],然后rehashidx++。ht[1],保证ht[0]只减不增。ht[0]所有元素迁移完毕,将ht[0]指向ht[1],ht[1]重置,rehashidx置为-1。注意:渐进式rehash期间,查找操作需要同时查两个表,插入只插入ht[1],删除和更新也需要在两个表中进行。但整个过程中不会再次触发新的rehash。
尽管Redis命令执行是单线程,但随着硬件性能提升(万兆网卡),单线程处理网络读写可能成为瓶颈。Redis 6.0引入了IO多线程,将网络数据的读取和解析、结果返回等操作交给多个线程并行处理,而命令执行仍然由主线程单线程完成,从而保持了无锁设计的简单性。

整个过程是典型的Reactor模型与多线程Worker的结合。需要注意的是,IO多线程只适用于多连接场景,如果只有一个连接,多线程反而会增加开销。
io-threads:设置IO线程数(默认4,最大128)。io-threads-do-reads:是否开启读线程(默认no,因为读操作通常较轻量,实测开启后提升有限)。建议开启IO线程处理写操作(io-threads-do-reads yes),因为写数据量大时,压缩协议并发送给客户端会消耗较多CPU。
Redis的高性能源于多方面精巧的设计:
理解这些底层原理,不仅能帮助我们在使用Redis时做出更合理的设计(例如预估内存、选择合适的编码阈值),还能在遇到性能问题时快速定位瓶颈。