当前位置: 首页 > 科技观察

Redis核心篇:快破才能破的秘密

时间:2023-03-13 17:11:55 科技观察

天下武功无敌,唯快破不破!学习一门技术,通常只会接触到零散的技术点,没有在脑海中建立一个完整的知识框架和架构体系,就没有系统观。这个会很吃力,而且会显得你乍一看好像知道,后来你就忘记了,一脸懵逼。跟着“码哥字节”深入了解Redis,深层次掌握Redis的核心原理和实战技巧。共同构建完整的知识框架,学会用全球视野组织整个知识体系。系统观其实很重要。从某种程度上说,在解决问题的时候,有系统的眼光,就意味着能够有理有据、有条不紊地定位和解决问题。Redis全景图的全景图可以围绕两个纬度展开,即:应用纬度:缓存使用、集群应用、数据结构的巧妙运用系统纬度:可归类为三种高性能:线程模型、网络IO模型、数据结构、持久化机制;高可用:主从复制、Sentinel集群、Clustersharding集群;高扩展:负载均衡Redis系列章节围绕以下思维导图展开,本次我们将从《Redis 唯快不破的秘密》开始探索Redis的核心知识点。了解Redis快、牢不可破的秘密65前段时间去面试996厂商,被问到“Redis为什么快?”65哥:嗯,因为是基于内存实现,单线程模型》面试官:还有呢?65哥:没了。很多人只知道实现是基于内存的,其他核心的原因却模棱两可,今天就跟着《码哥字节跳动》一起探寻真正速度的原因,做一个只能快的真汉子!从各个方面都针对高性能进行了优化,下次面试朋友的时候,面试官问为什么Redis性能这么高,不能光说单线程和内存存储,唯一牢不可破的秘密根据官方数据,Redis的QPS可以达到10万左右(每秒请求数),有兴趣的可以参考官方的benchmark程序测试《How fast is Redis?》,地址:https://redis.io/topics/benchmarksbenchmark的横轴test是连接数,纵轴是QPS,这个时候这张图体现了一个数量级,希望大家面试的时候能描述正确,别问你,你的数量级答案大相径庭!完全基于记忆。”65哥:这个我知道,Redis是一个基于内存的数据库。和磁盘数据库相比,磁盘的速度完全是吊打的,就像段誉的凌波微步一样。对于磁盘数据库来说,首先要将数据读入通过IO操作内存。”没错,读写操作都是对内存进行的。我们来对比一下内存操作和磁盘操作的区别。磁盘调用栈图内存操作内存是直接由CPU控制的,也就是内部集成的内存控制器CPU,因此内存直接与CPU相连,享有与CPU通信的最佳带宽,Redis将数据存储在内存中,读写操作不会受到磁盘IO速度的限制,所以speedfeellikeflying!最后一张图量化系统的各种延迟时间(部分数据参考BrendanGregg)高效数据结构65师兄:在学习MySQL的时候,就知道使用B+Tree数据结构来提高检索速度,所以Redis的速度快应该也和数据结构有关。”答案是正确的。这里说的数据结构并不是Redis提供的5种数据类型:String、List、Hash、Set、SortedSet。Redis中常用的五种数据类型及应用场景如下:String:缓存、计数器、分布式锁等List:链表、队列、微博关注时间线列表等Hash:用户信息、哈希表等.set:去重,likes,dislikes,mutualfriends等Zset:访问排行榜,点击排行榜等以上应该称为Redis支持的数据类型,即数据的存储形式。“码哥字节”想说的是,对于这5种数据类型,底层用了哪些高效的数据结构来支撑。65哥:怎么会有这么多数据结构?当然是为了追求速度。不同的数据类型使用不同的数据结构来提高速度。每种数据类型都由一个或多个数据结构支持。底层数据结构有6种。Redis哈希字典Redis整体上是一个哈希表,用来存储所有的键值对,无论数据类型是5种类型中的任何一种。哈希表的本质是一个数组,每个元素称为一个哈希桶,无论数据类型如何,每个桶中的条目都持有一个指向实际特定值的指针。Redis全局哈希表整个数据库就是一个全局哈希表,哈希表的时间复杂度为O(1),只需要计算每个key的哈希值就可以知道对应哈希桶的位置,定位桶中的条目查找对应的数据。这也是Redis速度快的原因之一。哈希冲突怎么办?当写入Redis的数据越来越多时,hash冲突是不可避免的,不同的key会计算出相同的hash值。Redis通过链式哈希解决冲突:即将同一个桶中的元素存储在一个链表中。但是当链表过长时,可能会导致查找性能变差,所以Redis为了追求速度使用了两个全局哈希表。用于rehash操作,增加现有哈希桶的数量,减少哈希冲突。默认情况下,哈希表1用于保存键值对数据,哈希表2暂时没有分配空间。当rehash操作被越来越多的数据触发时,执行如下操作:分配更多的空间给哈希表2;将哈希表1的数据重新映射并复制到哈希表2;释放哈希表1的空间。值得注意的是,将哈希表1中的数据重新映射到哈希表2的过程不是一次性过程,会导致Redis阻塞,无法提供服务。相反,采用渐进式重新散列。每次处理客户端请求时,从哈希表1中的第一个索引开始,将该位置的数据全部复制到哈希表2中,从而将rehash分发到多次请求过程中,避免耗时阻塞。SDS简单动态字符65哥:Redis是C语言实现的,为什么要新建一个SDS动态字符串呢?”字符串结构是应用最广泛的,通常我们用它来缓存登录后的用户信息,key=userId,value=用户信息JSON序列化成字符串,C语言获取字符串“MageByte”的长度需要从头遍历到“\0”C语言字符串结构对比图而SDS的字符串结构如下:C语言字符串与SDSSDS与C字符串的区别O(1)获取字符串长度的时间复杂度C语言字符串需要遍历boogie长度信息整个字符串的时间复杂度为O(n),遍历遇到'\0'C字符串结束.SDS中的len保存了这个字符串的长度,O(1)时间复杂度.空间预分配SDS修改后,程序不仅会分配SDS所需的空间,但还分配额外的未使用空间。分配规则如下:如果修改SDS后len的长度小于1M,程序将分配与len长度相同的未使用空间。例如len=10,重新分配后,buf的实际长度变为10(已用空间)+10(多余空间)+1(空字符)=21。如果修改SDS后len的长度大于1M,程序会分配1M的未使用空间。Lazyspacerelease当SDS被缩短时,程序不会回收多余的内存空间,而是使用free字段记录字节数,不释放。如果后面需要进行append操作,free中未使用的字节将直接使用空间,减少内存分配。二进制安全在Redis中,不仅可以存储String类型的数据,还可以存储一些二进制数据。二进制数据不是常规的字符串格式,其中包含一些特殊字符如'\0',在C中遇到'\0'表示字符串结束,但在SDS中,标志字符串结束是长度属性。zipListCompressedlist压缩列表是List、hash、sortedSet这三种数据类型的底层实现之一。当一个列表只有少量数据,并且每个列表项要么是一个小整数值,要么是一个长度比较短的字符串时,Redis会使用一个压缩列表作为列表键的底层实现。Ziplist是一种顺序数据结构,由一系列经过特殊编码的连续内存块组成。Ziplist可以包含多个入口节点,每个节点可以存储整数或字符串。ziplist在表头中有三个字段zlbytes、zltail和zllen,分别表示链表占用的字节数、链表尾部的偏移量和链表的条目数;压缩列表在表尾还有一个zlend,表示列表结束。structziplist{int32zlbytes;//整个压缩列表占用的字节数int32zltail_offset;//最后一个元素距离压缩列表起始位置的偏移量,用于快速定位最后一个节点int16zllength;//个数elementsT[]entries;//元素内容列表,一个一个紧凑存储int8zlend;//标记压缩列表的结束,值始终为0xFF}ziplist如果我们要查找并定位第一个和最后一个元素,我们可以使用三个headers字段的长度直接定位,复杂度为O(1)。在搜索其他元素时,效率不高。您只能一项一项地搜索。此时的复杂度是O(N)双端列表。RedisList数据类型通常用于微博粉丝的队列、时间线列表等场景。无论是FIFO队列还是FIFO栈,双端链表都很好的支持了这些特性。Redis的链表实现的特点可以概括如下:双端:链表节点有prev和next指针,获取一个节点的前节点和后节点的复杂度为O(1)。非循环:头节点的prev指针和尾节点的next指针都指向NULL,链表的访问以NULL结束。带头指针和尾指针:通过链表结构的头指针和尾指针,获取链表头节点和尾节点的程序复杂度为O(1)。带链表长度计数器:程序利用链表结构的len属性统计链表持有的链表节点数,程序获取链表节点数的复杂度为O(1)。多态性:链表节点使用void*指针来存储节点值,可以通过链表结构的dup、free、match属性为节点值设置类型特定的函数,所以链表可以用来存储各种类型的价值。后续版本修改了列表数据结构,使用quicklist代替了ziplist和linkedlist。Quicklist是ziplist和linkedlist的混合体。它将linkedlist分成section,每个section使用ziplist进行紧凑存储,多个ziplist使用双向指针串联起来。这就是Redis速度快的原因,不会遗漏任何可以提高性能的细节。skipListsortedset类型的排序功能是通过“跳表”数据结构实现的。跳表(skiplist)是一种有序的数据结构,它通过在每个节点中维护多个指向其他节点的指针来达到快速访问节点的目的。跳表支持平均O(logN)和最差O(N)复杂度的节点查找,也可以通过顺序操作对节点进行批量处理。跳表在链表的基础上增加了多级索引。通过索引位置的多次跳转,可以快速定位到数据,如下图:跳转表需要查找40号元素时,需要经过3次查找。整型数组(intset)当一个集合只包含整型值的元素,并且这个集合中的元素个数并不多时,Redis会使用整型集合作为集合键的底层实现。结构如下:typedefstructintset{//encodingmethoduint32_tencoding;//set中包含的元素个数uint32_tlength;//arrayint8_tcontents[];}intset;contents数组是整型集合的底层实现:整型集合是内容数组的一个数组项(item),每一项在数组中按照值的大小从小到大依次排列,数组中不包含任何重复的项。length属性记录了整数集合包含的元素个数,即contents数组的长度。合理的数据编码Redis使用对象(redisObject)来表示数据库中的键值。我们在Redis中创建键值对时,至少会创建两个对象,一个对象是作为键值对的键对象,另一个是键值对的值对象。例如,当我们执行SETMSGXXX时,键值对的key是包含字符串“MSG”的对象,键值对的值对象是包含字符串“XXX”的对象。redisObjecttypedefstructredisObject{//typeunsignedtype:4;//encodingunsignedencoding:4;//指向底层数据结构的指针void*ptr;//...}robj;其中type字段记录了对象的类型,包括string对象、List对象、hash对象、collection对象、sortedcollection对象。对于每一种数据类型,底层支持可能是多种数据结构。什么时候使用哪种数据结构,这就涉及到编码转换的问题。那么我们来看看不同的数据类型是如何编码和转换的:String:如果存储数字,使用int类型编码,如果不是数字,使用raw编码;List:List对象的编码可以是ziplist或linkedlist,字符串长度<64字节且元素个数<512使用ziplist编码,否则转成linkedlist编码;注意:这两个条件可以修改,在redis.conf中:list-max-ziplist-entries512list-max-ziplist-value64Hash:Hash对象的编码可以是ziplist或者hashtable。当Hash对象同时满足以下两个条件时,Hash对象采用ziplist编码:Hash对象中存储的所有键值对的键值串长度均小于64字节。Hash对象中存储的键值对数量小于512,否则为hashtable编码。Set:Set对象的编码可以是intset,也可以是hashtable。intset编码的对象使用整数集合作为底层实现,将所有元素存储在一个整数集合中。使用intset编码将元素保存为整数且元素个数小于一定范围。如果不满足任何条件,则使用哈希表编码;Zset:Zset对象编码可以是ziplist或zkiplist。当使用ziplist编码进行存储时,每个集合元素使用两个相邻的存储作为压缩列表。Ziplist压缩列表的第一个节点存放的是元素的成员,第二个节点存放的是元素的分数,按照分数的大小从小到大排列。当Zset对象同时满足以下两个条件时,使用ziplist编码:Zset中保存的元素个数小于128个,Zset元素的成员长度均小于64字节。如果不满足上述任何条件,ziplist将转换为zkiplist编码。注意:这两个条件是可以修改的,在redis.conf中:zset-max-ziplist-entries128zset-max-ziplist-value64单线程模型》65哥:为什么Redis是单线程而不是多线程并行执行的完全么关于使用CPU?”我们要明确一点:Redis的单线程是指Redis的网络IO和key-value对指令的读写都是一个线程执行的。对于Redis的持久化、集群数据同步、异步删除等,都是由其他线程来执行的。至于为什么要用单线程,我们先了解一下多线程的缺点。多线程的缺点使用多线程通常可以提高系统吞吐量并充分利用CPU资源。但是使用多线程后,如果没有很好的系统设计,可能会出现下图所示的场景。增加线程数会增加前期的吞吐量。当添加更多线程时,系统吞吐量几乎不会增加,甚至掉落!ThreadCountvs.Throughput在运行每个任务之前,CPU需要知道任务加载到哪里并开始运行。也就是说,系统需要帮助它预先设置CPU寄存器和程序计数器,这被称为CPU上下文。这些保存的上下文存储在系统内核中,并在重新安排任务时再次加载。这样就不会影响任务原来的状态,看起来任务一直在运行。在切换上下文的时候,我们需要完成一系列的工作,这是一个非常耗费资源的操作。另外,当多个线程并行修改共享数据时,为了保证数据的正确性,需要加锁机制会带来额外的性能开销,面临共享资源的并发访问控制问题。引入多线程开发需要使用同步原语来保护共享资源的并发读写,增加了代码复杂度和调试难度。单线程有什么好处?无线程创建带来的性能消耗;避免上下文切换带来的CPU消耗,无多线程切换开销;避免线程间的竞争问题,如加锁、释放锁、死锁等,无需考虑各种锁问题。代码更加清晰,处理逻辑简单。单线程不会充分利用CPU资源吗?官方回答:因为Redis是基于内存运行的,CPU不是Redis的瓶颈。Redis的瓶颈很可能是机器内存或网络带宽的大小。由于单线程容易实现,而且CPU不会成为瓶颈,所以采用单线程方案是顺理成章的。原文地址:https://redis.io/topics/faq。I/O多路复用模型Redis使用I/O多路复用技术来并发处理连接。采用了epoll+自己实现的简单事件框架。epoll中的read、write、close、connection都被转化为事件,然后利用epoll的多路复用特性,绝不在IO上浪费一点时间。65哥:那什么是I/O多路复用呢?”在讲解IO多路复用之前,先了解一下基本的IO操作会经过什么。基本的IO模型是一个基本的网络IO模型。在处理get请求的时候,会经过以下流程:与客户端建立accept;从socket中读取requestrecv;解析client发送的requestparse;执行get命令;响应client数据,即将数据写回socket。其中,bind/listen、accept、recv、parse、send属于网络IO处理,而get属于key-value数据操作,由于Redis是单线程的,最基本的实现就是在一个线程中依次执行以上操作。keypoints即accept和recv会被阻塞,当Redis监听到一个客户端的连接请求但是没有建立连接时,会阻塞在accept()函数中,导致其他客户端无法与Redis建立连接.类似的,whenRedis通过recv()从客户端读取数据,如果数据还没有到达,Redis会一直阻塞在recv()中。阻塞的原因是由于使用了传统的阻塞IO,即执行read、accept、recv等网络操作会一直阻塞等待。如下图所示:blockingIOIO多路复用多路复用是指多个socket连接,多路复用是指多路复用一个线程。多路复用主要有3种技术:select、poll、epoll。epoll是最新最好的多路复用技术。它的基本原理是,内核不监视应用程序本身的连接,而是监视应用程序的文件描述符。客户端在运行时,会生成不同事件类型的套接字。在server端,I/Omultiplexer(I/Omultiplexermodule)会将消息放入队列(即下图中的I/Omultiplexmultiplexedprogram的socketqueue),然后转发给不同的event通过文件事件调度程序处理程序。简单的说:在Redis单线程的情况下,内核会一直监听socket上的连接请求或者数据请求,一旦有请求到来,就交给Redis线程处理,实现了这样的效果一个Redis线程处理多个IO流。select/epoll提供了基于事件的回调机制,针对不同事件的发生调用相应的事件处理器。所以Redis一直在处理事件来提高Redis的响应性能。高性能IO多路复用Redis线程不会阻塞在特定的监听或连接的套接字上,即不会阻塞在特定的客户端请求处理上。正因为如此,Redis可以同时连接和处理多个客户端的请求,从而提高并发性。只快不破的原理总结《65哥:学完终于知道Redis快的本质原因》码哥,不说了,我来总结一下!我会点赞分享这篇文章后来,让更多人快速了解了Redis的核心原理。”纯内存操作一般都是简单的访问操作。线程占用时间比较多,花费的时间主要集中在IO上,所以读取速度快。整个Redis是一个全局哈希表,时间复杂度为O(1),为了防止哈希冲突导致链表过长,Redis会进行一次rehash操作,扩充哈希桶的个数,减少哈希冲突。并且为了防止一次性remapping数据过大造成线程阻塞,采用了progressiverehash。智能地将一次性副本分配给多个请求进程,避免阻塞。Redis使用非阻塞IO:IO多路复用,使用单线程轮询描述符,将打开、关闭、读取数据库、写入转换为事件,Redis使用自己的事件分隔符,效率更高。单线程模型保证了每个操作的原子性,减少线程上下文切换和竞争。Redis自始至终使用hash结构,读取速度快,还有一些专门优化数据存储的数据结构,比如compressedtables,压缩存储短数据,skiptables,使用有序数据结构加快读取速度.根据实际存储的数据类型选择不同的代码转载本文,请联系字节码哥公众号。