Redis-3-为什么是单线程,高并发快

本文深入剖析Redis的内存存储机制,高效数据结构如SDS、zipList、linkedlist等,以及单线程模型、I/O多路复用技术,揭示Redis高性能的秘密。

目录

1.完全基于内存实现

2.高效的数据结构

Redis数据库

不同数据类型对应的数据结构

SDS简单动态字符

zipList压缩列表

linkedlist双端列表

quicklist

skipList跳跃表

整数数组intset

redisObject

3.单线程模型

4.I/O多路复用模型

5.扩展:IO多路复用技术是什么?

5.1 简介

5.2 何谓“非阻塞IO”?

5.3 事件轮询(多路复用)与非阻塞IO缺点

5.4 读写文件描述符

5.5 Redis如何使用多路复用?

5.6 select函数的timeout参数


1.完全基于内存实现

Redis将数据存储在内存中,内存直接与CPU对接,读写操作不会因为磁盘的IO速度而受到限制。

2.高效的数据结构

Redis数据库

不同数据类型使用不同的数据结构来提高速度,而Redis整体就是一个哈希表来保存所有的key-value。

哈希表,本质就是一个数组,每个元素被叫做哈希桶,不管什么数据类型,每个桶里面的entry保存着实际具体值的指针。

整个数据库就是一个全局哈希表,而哈希表的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶里面的entry找到对应数据,这个也是Redis快的原因之一。

当写入 Redis 的数据越来越多的时候,哈希冲突不可避免,会出现不同的key计算出一样的哈希值。

Redis 通过链式哈希解决冲突:也就是同一个桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差,Redis 为了追求快,使用了两个全局哈希表。用rehash操作,增加现有的哈希桶数量,减少哈希冲突。

开始默认使用hash表1保存键值对数据,哈希表2此刻没有分配空间。当数据越来多触发rehash操作,则执行以下操作:

①给hash表2分配更大的空间;

②将hash表1的数据重新映射拷贝到hash表2中;

③释放hash表1的空间。

将hash表1的数据重新映射到hash表2的过程中并不是一次性的,这样会造成Redis阻塞,无法提供服务。而是采用了渐进式rehash,每次处理客户端请求的时候,先从hash表1中第一个索引开始,将这个位置的 所有数据拷贝到hash表2中,就这样将rehash 分散到多次请求过程中,避免耗时阻塞。

不同数据类型对应的数据结构

SDS简单动态字符

C语言中要获取字符串长度,要从头开始遍历,直到遇到「\0」为止,而SDS则是如下结构:

free用于标记buf中空闲空间长度

len记录buf空间中已使用空间长度

buf存储实际内容

SDS动态字符的优点如下:

时间复杂度

C语言获取字符串长度的时间复杂度为O(n),而SDS中len保存字符串长度,因此时间复杂度为O(1);

空间预分配

SDS被修改后,程序不仅会为SDS分配所需要的必须空间,还会分配额外的未使用空间,而数组没这个功能;

惰性空间释放

当对SDS进行缩短操作时,程序并不会回收多余的内存空间,而是使用free字段将这些字节数量记录下来不释放,后面如果需要 append操作,则直接使用free中未使用的空间,减少了内存的分配;

二进制安全

Redis还可以存储二进制数据,由于二进制数据并不是规则的字符串格式,其中会包含一些特殊的字符如'\0',在 C 中遇到'\0'则表示字符串的结束,但在SDS中,标志字符串结束的是len属性,而不是'\0'字符;

zipList压缩列表

压缩列表是List、hash、zset三种数据类型底层实现之一。

当一个列表只有少量数据的时候,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

ziplist是由一系列特殊编码的连续内存块组成的顺序型的数据结构,ziplist中可以包含多个entry节点,每个节点可以存放整数或者字符串。

zlbytes表示列表占用字节数

zltail 表示列表尾的偏移量

zllen表示列表中的entry 个数

entry1

entryN

zlend表示列表结束

如果定位第一个元素和最后一个元素,则复杂度是 O(1)。而查找其他元素时复杂度就是O(N);

linkedlist双端列表

不管是先进先出的队列,还是先进后出的栈,双端列表都很好的支持这些特性。

①双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。

②无环:表头节点的prev指针和表尾节点的next指针都指向 NULL,对链表的访问以NULL为终点。

③带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。

④带链表长度计数器:程序使用list结构的len属性来对list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。

⑤多态:链表节点使用void* 指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

quicklist

quicklist代替了ziplist、linkedlist。

quicklist是ziplist、linkedlist混合体,它将linkedlist按段切分,每一段都使用ziplist,ziplist之间使用双向指针串接起来;

skipList跳跃表

skiplist是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

整数数组intset

当一个集合Set只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。

Intset底层是一个数组,整数集合的每个元素都是该数组的一个数组项,各个项在数组中按值从小到大有序地排列并且数组中不包含任何重复项。length属性记录了整数集合包含的元素数量,即是该数组的长度。

redisObject

Redis使用对象(redisObject)来表示key-value中的key值,当在Redis中创建一个键值对时,至少创建2个对象:一个是用做键值对的键对象,另一个是键值对的值对象;

RedisObject的type字段表示对象的类型,包含字符串对象、列表对象、哈希对象、集合对象、有序集合对象。

对于每一种数据类型来说,底层的支持可能是多种数据结构,什么时候使用哪种数据结构,这就涉及到了编码转化的问题。

①String存储数字的话,采用int类型的编码,如果是非数字的话,采用raw编码;

②List对象的编码可以是ziplist或linkedlist,字符串长度<64字节且元素个数<512使用ziplist编码,否则转化为linkedlist 编码;

通过redis.conf 文件中的list-max-ziplist-entries 512、list-max-ziplist-value 64来修改配置;

③Hash对象的编码可以是ziplist或hashtable。当Hash对象同时满足以下2个条件时,采用ziplist编码,否则就是hashtable 编码:

Hash 对象保存的所有键值对的键和值的字符串长度均小于 64 字节;

Hash 对象保存的键值对数量小于 512 个。

④Set对象的编码可以是intset或hashtable,intset编码的对象使用整数集合作为底层实现,把所有元素都保存在一个整数集合里面。保存元素为整数且元素个数小于一定范围使用intset编码,任意条件不满足,则使用hashtable编码;

⑤Zset对象的编码可以是ziplist或zkiplist,当采用ziplist编码存储时,每个集合元素使用两个紧挨在一起的压缩列表来存储。

Ziplist压缩列表第一个节点存储元素的成员,第二个节点存储元素的分值,并且按分值大小从小到大有序排列。

当 Zset 对象同时满足一下两个条件时,采用ziplist编码,如果不满足以上条件的任意一个,ziplist 就会转化为zkiplist编码:

Zset 保存的元素个数小于128。

Zset 元素的成员长度都小于64字节。

这2个限制可以在redis.conf中进行修改:zset-max-ziplist-entries 128、zset-max-ziplist-value 64

3.单线程模型

Redis的单线程指的是 Redis 的网络IO以及键值对指令读写是由一个线程来执行的。 对于Redis的持久化、集群数据同步、异步删除等都是其他线程执行。

单线程优点如下:

①不会因为线程创建导致的性能消耗;

②避免上下文切换引起的 CPU 消耗,没有多线程切换的开销;

③避免了线程之间的竞争问题;

④代码更清晰,处理逻辑简单;

4.I/O多路复用模型

采用了epoll + 自己实现的简单的事件框架。epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性,绝不在IO上浪费一点时间。

阻塞的原因由于使用传统阻塞 IO ,也就是在执行 read、accept 、recv 等网络操作会一直阻塞等待。如下图所示:

多路指的是多个socket连接,复用指的是复用一个线程。

多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。

它的基本原理是,内核不是监视应用程序本身的连接,而是监视应用程序的文件描述符。当客户端运行时,它将生成具有不同事件类型的套接字。在服务器端,I O多路复用程序会将消息放入队列,然后通过文件事件分派器将其转发到不同的事件处理器。

简单来说:Redis 单线程情况下,内核会一直监听socket上的连接请求或者数据请求,一旦有请求到达就交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。

select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的事件处理器。所以 Redis一直在处理事件,提升 Redis的响应性能。

Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

5.扩展:IO多路复用技术是什么?

5.1 简介

多路指的是多个socket连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll,其中的epoll是最新的也是目前最好的多路复用技术;采用多路I/O复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗);

5.2 何谓“非阻塞IO”?

当我们通过socket来读写时,默认是阻塞的,当socket.read的时候,要传递一个参数n,表示最多读取n个字节后返回给调用程序,如果一个字节都没有,则调用者线程就会一直等待/阻塞,直到新的数据到来或者socket连接关闭,read方法才执行结束,返回结果,然后调用者线程才能继续走后面的代码;(这里的read,指的是读取返回结果

socket.write方法写入时,如果操作系统为socket分配的write缓冲池已经满了的话,则write一直等待,等到有缓冲区中有空间空闲出来,因此,在write等待过程中,write方法没有返回,而调用者线程也是一直在等待/阻塞;(这里的write,指的是发送请求数据

既然read/write都会等待,进而导致调用者线程也顺带着等待,影响调用者线程的后续执行,为此,非阻塞IO在socket套接字对象上提供了一个选项Non_Blocking,当启用该选项时,读写方法均不会阻塞,read能读多少就读多少,write能写多少就写多少,至于能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,同样的,能写多少也是取决于内核为套接字分配的写缓冲区的空闲空间字节数,read和write方法都是通过返回值来告诉调用者线程实际读写的字节数;

因此,调用者线程,借助非阻塞IO,在socket.read/write时,不必再阻塞等待,读写可以瞬间完成,返回结果,然后调用者线程继续执行后续代码;

5.3 事件轮询(多路复用)与非阻塞IO缺点

非阻塞IO有个逻辑缺陷,就是调用者线程如何知道read时,把该读的数据都read完,write时,写的数据都写完了,通俗来讲就是完整的read、write数据呢?

事件轮询API就是来解决这个问题的,这里最简单的事件轮询API是操作系统提供给用户的select函数,该函数参数是读写描述符列表read_fds和write_fds,而返回结果则是对应的可读可写事件,同时该函数还有一个timeout参数,如果没有任何事件到来,那么最多等待timeout时间,这段时间内select函数一直处于阻塞状态,一旦期间有任何事件到来,则立即返回,如果等待timeout时间后还是没有任何事件到来,select函数也会立即返回;

当调用者线程通过select函数拿到事件后,调用者线程继续挨个处理相应的事件,等处理完毕后,调用者线程继续调用select函数,于是调用者线程进入一个死循环,这个死循环称为事件循环,一个循环叫做一个周期;

客户端会为每个socket套接字建立一个读写文件描述符,select函数同时处理多个socket通道描述符的读写事件,这类系统调用称为多路复用API。(相当于select函数一次性把多个Socket读写文件描述符给监控了

select逐渐不再使用,其被epoll(linux系统)、kqueue(Macosx系统)取代;

至于服务端Server,其server.socket.read是调用accept接受客户端Client的新连接,至于新连接什么时候过来,Server也是通过select函数的读事件来知晓;

5.4 读写文件描述符

read events, wr 工 te events = select(read fds, write fds, timeout)
for event in read events :
handle read(event.fd)
for event in write events:
handle write(event.fd)
handle others () #处理其他事情,如定时任务等 

5.5 Redis如何使用多路复用?

Redis会为每个客户端socket套接字关联2个队列:

①与客户端请求关联的指令队列:客户端Client发送的指令通过队列来排队进行顺序处理,先到先执行;

②与客户端响应关联的晌应队列:Redis服务器通过响应队列来将指令的返回结果回复给客户端。如果队列为空,那么意昧着连接暂时处于空闲状态,不需要去获取写事件,可以将当前的客户端描述符从write_fds里面移出来。等到队列有数据了,再将描述符放进去,避免select系统调用后立即返回一个写事件,而事件没什么数据可以写,出现这种情况的线程会令 CPU消耗飘升;

5.6 select函数的timeout参数

Redis的定时任务会记录在一个被称为“最小堆”的数据结构中。在这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期里, Redis都会对最小堆里面已经到时间点的任务进行处理;

待处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是select系统调用的timeout参数。因为Redis知道未来 timeout时间内,没有其他定时任务需要处理,所以可以安心睡眠 timeout时间。

### 三级标题:Redis 单线程模型如何实现高并发 Redis 采用单线程模型来处理客户端请求,这种设计并非意味着性能受限,而是基于对实际应用场景的深入分析和优化的结果。Redis 的所有操作都是基于内存的,而内存的读写速度远高于磁盘,因此 CPU 通常不是 Redis 的性能瓶颈。这一特性使得单线程模型在处理大多数请求时不会成为性能瓶颈,同时还能减少多线程环境下的上下文切换和锁竞争开销,从而提升整体性能[^1]。 Redis 使用 I/O 多路复用技术来处理网络请求,通过一个线程监听多个客户端连接,使得单线程可以高效地处理大量并发连接。这种机制避免了为每个连接创建独立线程所带来的资源消耗,同时也能充分利用操作系统提供的高性能网络 I/O 接口(如 epoll、kqueue 等),从而实现高并发处理能力[^2]。 Redis单线程模型还简化了系统设计,避免了多线程编程中常见的死锁、线程切换、资源竞争等问题。这不仅降低了开发和维护成本,还提升了系统的稳定性和可预测性。例如,Redis 的事务和 Lua 脚本功能依赖于单线程的顺序执行特性,确保了操作的原子性和一致性[^3]。 尽管 Redis 的核心命令处理仍然是单线程的,但在某些特定场景下,如大键的删除操作,Redis 引入了多线程异步释放内存的机制,以减少对主线程的阻塞时间。此外,从 Redis 6.0 开始,网络 I/O 的读写操作也支持多线程处理,进一步提升了高并发场景下的性能表现。需要注意的是,多线程仅用于处理网络数据的读写和协议解析,真正的命令执行仍然由主线程完成,以保持系统的简单性和一致性[^4]。 ### 三级标题:Redis 单线程模型的优势与限制 Redis 单线程模型的主要优势在于其简单性和高效性。由于所有命令都在一个线程中顺序执行,Redis 能够避免多线程环境下复杂的同步机制和锁管理问题。这种设计使得 Redis 更容易实现、调试和维护,同时也减少了因线程切换带来的性能损耗。对于大多数读写密集型的应用场景,Redis单线程架构能够提供出色的性能表现。 然而,单线程模型也存在一定的局限性。在 CPU 密集型的操作(如大量计算或复杂排序)中,单线程可能成为性能瓶颈。此外,虽然 Redis 的多线程 I/O 模型可以提升网络请求的处理效率,但核心命令的执行仍然是单线程的,因此在某些极端场景下,如需要大量 CPU 运算的命令(如 `SORT`、`SUNIONSTORE` 等)可能会导致主线程长时间阻塞,影响整体响应速度[^2]。 ### 三级标题:Redis 单线程模型的适用场景 Redis单线程模型非常适合处理以读写操作为主的应用场景,例如缓存系统、计数器、分布式锁、消息队列等。这些场景通常不需要复杂的计算,而是依赖于速的内存访问和高效的网络 I/O。Redis 的设计使得它在这些场景下能够充分发挥性能优势,提供低延迟和高吞吐量的服务[^3]。 对于需要更高并发处理能力的场景,Redis 6.0 引入的多线程 I/O 模型可以在不改变命令执行顺序的前提下,显著提升网络请求的处理效率。这种设计既保留了单线程模型的简单性和一致性,又通过多线程优化了 I/O 性能,适用于高并发、多连接的环境[^5]。 ### 示例代码:Redis 多线程 I/O 配置 ```conf # Redis 6.0 及以上版本支持多线程 I/O # 设置 I/O 线程数量(默认为 1,即单线程) io-threads 4 # 设置 I/O 线程的运行模式(读或写) io-threads-do-reads no ``` 通过上述配置,Redis 可以使用 4 个 I/O 线程来处理网络请求的读写操作,从而提升并发处理能力,同时保持核心命令的执行仍由主线程完成[^5]。 --- ###
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值