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

Redis专题:高频问题核心原理透视

时间:2023-03-12 03:34:56 科技观察

为什么Redis这么快?很多人只知道它是一个K/VNoSQl内存数据库,单线程...这是因为对Redis没有完全了解,无法继续追问。这个问题是基础。我们可以从Redis中不同数据类型的底层数据结构来实现,完全基于内存,IO多路复用网络模型,线程模型,progressiverehash……到底有多快?我们可以先说它有多快?根据官方数据,Redis的QPS可以达到10万左右(每秒请求数)。有兴趣的可以参考官方的benchmark程序测试《How fast is Redis?》,地址:https://redis.io/topics/benchmarksbenchmark测试的横轴是连接数,纵轴是QPS.这张照片反映了一个数量级。通过量化,面试官感觉你看过官方文档,很严谨。基于内存的实现Redis是一个基于内存的数据库。与磁盘数据库相比,它完全比磁盘数据库快。读写操作都是在内存上进行的。下面对比一下内存操作和磁盘操作的区别。磁盘调用内存操作内存由CPU直接控制,即集成在CPU内部的内存控制器,因此内存直接与CPU相连,享有与CPU通信的最佳带宽。最后用一张图量化系统的各种延迟时间(部分数据参考BrendanGregg)高效的数据结构学习MySQL的时候知道为了提高检索速度,使用了B+Tree数据结构,所以Redis的速度之快应该也跟数据结构有关。Redis一共有5种数据类型,String、List、Hash、Set、SortedSet。底层由一种或多种数据结构支持不同的数据类型,目的是追求更快的速度。码哥寄语:我们可以分别说明每种数据类型的底层数据结构的优点。很多人只知道数据类型,说起底层的数据结构就让人眼前一亮。SDS简单动态字符串优势C语言字符串和SDS中的SDSlen保存了这个字符串的长度,O(1)的时间复杂度来查询字符串长度信息。空间预分配:修改SDS后,程序不仅会为SDS分配所需的空间,还会额外分配未使用的空间。Lazyspacerelease:在缩短SDS时,程序不会回收多余的内存空间,而是使用free字段记录字节数,不释放。如果后面需要追加操作,直接使用空闲空间中未使用的,减少内存分配。zipListCompressedlist压缩列表是List、hash、sortedSet这三种数据类型的底层实现之一。当一个列表只有少量数据,并且每个列表项要么是一个小整数值,要么是一个长度比较短的字符串时,Redis会使用一个压缩列表作为列表键的底层实现。ziplist内存紧凑,节省内存。后续版本的quicklist修改了list数据结构,使用quicklist代替了ziplist和linkedlist。Quicklist是ziplist和linkedlist的混合体。它将linkedlist分成section,每个section使用ziplist进行紧凑存储,多个ziplist使用双向指针串联起来。skipListsortedset类型的排序功能是通过“跳表”数据结构实现的。跳表(skiplist)是一种有序的数据结构,它通过在每个节点中维护多个指向其他节点的指针来达到快速访问节点的目的。跳表在链表的基础上增加了多级索引。通过索引位置的多次跳转,实现数据的快速定位,如下图所示:当一个集合只包含整数值元素时,跳转列表整型数组(intset),而当这个集合中的元素个数不大,Redis会使用整型set作为setkey的底层实现来节省内存。来自码哥的单线程模型消息:需要注意的是,Redis的单线程是指Redis的网络IO(网络IO在6.x版本之后使用多线程),键值对指令由一个线程读写。.对于Redis的持久化、集群数据同步、异步删除等,都是由其他线程来执行的。不要说Redis只有一个线程。单线程是指Redis键值对读写指令的执行是单线程的。先说官方的回答,让人觉得够严谨,而不是跟大家一样背一些博客。官方回答:因为Redis是基于内存运行的,所以CPU不是Redis的瓶颈。Redis的瓶颈很可能是机器内存或网络带宽的大小。由于单线程容易实现,而且CPU不会成为瓶颈,所以采用单线程方案是顺理成章的。原文地址:https://redis.io/topics/faq。为什么不使用多线程执行来充分利用CPU呢?在运行每个任务之前,CPU需要知道任务是从哪里加载并开始运行的。也就是说,系统需要帮助它预先设置CPU寄存器和程序计数器,这被称为CPU上下文。在切换上下文的时候,我们需要完成一系列的工作,这是一个非常耗费资源的操作。引入多线程开发需要使用同步原语来保护共享资源的并发读写,增加了代码复杂度和调试难度。单线程有什么好处?不会有线程创建带来的性能消耗;避免上下文切换带来的CPU消耗,无多线程切换开销;避免线程间的竞争问题,如加锁、释放锁、死锁等,无需考虑各种锁问题。代码更加清晰,处理逻辑简单。I/O多路复用模型Redis使用I/O多路复用技术来并发处理连接。采用了epoll+自己实现的简单事件框架。epoll中的read、write、close、connection都被转化为事件,然后利用epoll的多路复用特性,绝不在IO上浪费一点时间。高性能IO多路复用Redis线程不会阻塞在特定的监听或连接的套接字上,即不会阻塞在特定的客户端请求处理上。正因为如此,Redis可以同时连接和处理多个客户端的请求,从而提高并发性。RedisglobalhashdictionaryRedis整体上是一个哈希表,用来存储所有的键值对,无论数据类型是五种类型中的任何一种。哈希表本质上是一个数组,每个元素称为一个哈希桶。无论数据类型如何,每个桶中的条目都包含一个指向实际值的指针。Redis全局哈希表和哈希表的时间复杂度是O(1)。只需要计算每个key的哈希值就可以知道对应的哈希桶的位置,定位到桶中的表项就可以找到对应的数据。这也是Redis快的原因之一。Redis使用对象(redisObject)来表示数据库中的键值。我们在Redis中创建键值对时,至少会创建两个对象,一个对象是作为键值对使用的键对象,另一个是键值对值对象。即每一个entry都保存了“键值对”的redisObject对象,通过redisObject的指针找到对应的数据。typedefstructredisObject{//typeunsignedtype:4;//encodingunsignedencoding:4;//指向底层数据结构的指针void*ptr;//...}robj;哈希冲突怎么办?Redis通过链式哈希解决冲突:即将同一个桶中的元素存储在一个链表中。但是当链表过长时,可能会导致查找性能变差,所以Redis为了追求速度使用了两个全局哈希表。用于rehash操作,增加现有哈希桶的数量,减少哈希冲突。默认情况下,“哈希表1”用于保存键值对数据,“哈希表2”暂时没有分配空间。当越来越多的数据触发rehash操作时,执行如下操作:分配更多的空间给“哈希表2”;将“哈希表1”的数据重新映射并复制到“哈希表2”;释放“哈希表2”1”空间。值得注意的是,将哈希表1中的数据重新映射到哈希表2的过程并不是一次性的过程,会导致Redis阻塞,无法提供服务。相反,采用渐进式重新散列。每次处理客户端请求时,从“哈希表1”中的第一个索引开始,将这个位置的数据全部复制到“哈希表2”中,并将rehash分发给多个请求进程,避免耗时阻塞。Redis是如何实现持久化的?停机后如何恢复数据?Redis数据持久化采用“RDB数据快照”方式实现宕机快速恢复。但是,过于频繁地执行全量数据快照有两个严重的性能开销:频繁生成的RDB文件写入磁盘,磁盘压力过大。会出现上一个RDB还没执行完,又开始生成下一个,陷入死循环。forkbgsave子进程会阻塞主线程,主线程内存越大,阻塞时间越长。所以Redis还设计了AOF写后日志来记录修改内存的指令记录。面试官:什么是RDB内存快照?当Redis执行“write”命令时,内存数据会一直变化。所谓内存快照是指某个时刻Redis内存中数据的状态数据。时间仿佛凝固在了某个时刻。我们在拍照的时候,可以通过照片完整的记录下某个瞬间的瞬间画面。Redis跟这个类似,就是把某个时刻的数据拍下来,以文件的形式写到磁盘上。这个快照文件称为RDB文件,RDB是RedisDataBase的缩写。RDB内存快照用于数据恢复时,直接将RDB文件读入内存完成恢复。面试官:在生成RDB的时候,Redis可以同时处理写请求吗?是的,Redis利用操作系统的多进程写时复制技术COW(CopyOnWrite)来实现快照持久化,保证数据的一致性。当Redis持久化时,会调用glibc函数fork生成子进程。快照持久化完全交给子进程,父进程继续处理客户端请求。当主线程执行写命令修改数据时,会复制一份数据,bgsave子进程读取复制的数据写入RDB文件。这样既保证了快照的完整性,又可以让主线程同时修改数据,避免对正常业务造成影响。copy-on-write技术确保数据在快照期间可以被修改。面试官:什么是AOF?AOF日志记录了Redis实例创建以来所有被修改的指令序列,所以所有的指令都可以在一个空的Redis实例上依次执行,也就是“replay”,来恢复当前实例的内存数据结构的状态雷迪斯。Redis提供的AOF配置项appendfsync回写策略直接决定了AOF持久化功能的效率和安全性。always:同步回写,执行write命令后立即将aof_buf缓冲区中的内容刷新到AOF文件中。everysec:每秒回写一次。write命令执行后,日志只会写入AOF文件缓冲区,缓冲区的内容每秒都会同步到磁盘。no:受操作系统控制,写执行完成后,将日志写入AOF文件内存缓冲区,由操作系统决定何时刷新到磁盘。没有两全其美的策略,我们需要在性能和可靠性之间做出权衡。面试官:既然RDB有两个性能问题,为什么不用AOF。AOF写日志,记录了每一次“写”命令的操作。不会像RDBfullsnapshot那样造成性能损失,但是执行速度没有RDB快,日志文件大也会造成性能问题。因此Redis设计了一个杀手级的“AOF重写机制”,Redis提供了bgrewriteaof命令来瘦身AOF日志。原理是开辟一个子进程遍历内存,转换成一系列的Redis操作指令,序列化到一个新的AOF日志文件中。序列化完成后,将运行过程中产生的增量AOF日志追加到这个新的AOF日志文件中。添加后,旧的AOF日志文件会立即被替换,瘦身工作完成。AOF重写机制(三项合一)面试官:如何在兼顾性能的情况下做到尽可能少的数据丢失?在重启Redis的时候,我们很少使用rdb来恢复内存状态,因为会丢失大量的数据。我们通常使用AOF日志重放,但是重放AOF日志的性能比rdb慢很多,所以在Redis实例很大的情况下需要很长时间才能启动。为了解决这个问题,Redis4.0带来了一个新的持久化选项——混合持久化。将rdb文件的内容和增量的AOF日志文件一起存储。这里的AOF日志不再是全量日志,而是从开始持久化到持久化结束这段时间发生的增量AOF日志。通常,这部分AOF日志很小。因此,当Redis重启时,可以先加载rdb的内容,然后重放增量AOF日志,完全替代之前的AOF全量文件重放,大大提高了重启效率。Redis主从架构数据同步Redis提供了主从模式,通过主从复制,将数据冗余复制到其他Redis服务器。面试官:如何保证主从之间的数据一致性?为了保证副本数据的一致性,主从架构采用了读写分离的方式。读操作:主从库都可以执行;写操作:主库先执行,然后写操作同步到从库;Redis读写分离面试官:主从复制还有其他功能吗?故障恢复:当master节点宕机时,其他节点仍然可以提供服务;负载均衡:Master节点提供写服务,Slave节点提供读服务分担压力;高可用基石:是Sentinel和集群实现的基础,是高可用的基石。面试官:主从复制是如何实现的?同步分为三种情况:第一种是主从数据库全量复制;正常运行时主从同步;主从数据库网络断线重连同步。面试官:如何实现第一次同步?主从库的第一次复制过程大致可以分为三个阶段:连接建立阶段(即准备阶段)、主库向从库同步数据阶段、发送新的写命令阶段同步期间在从库阶段;Redis全同步建立连接:从库会与主库建立连接,从库执行replicaof并发送psync命令告诉主库即将进行同步。主库确认回复后,主从库开始同步。主库向从库同步数据:master执行bgsave命令生成RDB文件发送给从库。同时,master库为每个slave创建一个replicationbuffer,用于记录从RDB文件生成开始收到的所有写命令。在将RDB数据加载到内存之前,将RDB从库中保存并清空数据库。向从库发送RDB后收到的新写命令:RDB文件生成后的写操作没有记录到刚才的RDB文件中。为了保证主从库数据的一致性,主库会使用一个叫做replicationbuffer来记录RDB文件生成后的所有写操作。并将里面的数据发给slave。面试官:主从库之间网络断了怎么办?断开连接后是否需要重新完整复制?在Redis2.8之前,如果主从库在命令传播过程中出现网络中断,那么从库会与主库通信。图书馆再次执行完整复制,这是非常昂贵的。从Redis2.8开始,断网后,主从库会继续使用增量复制进行同步。增量复制:用于网络中断等情况后的复制。只将中断期间主节点执行的写命令发送给从节点,比全复制效率更高。断开连接和重新连接增量复制的秘密是repl_backlog_buffer缓冲区。每当master将写指令操作记录在repl_backlog_buffer中,由于内存有限,repl_backlog_buffer是一个定长的循环数组。如果数组已满,则会从头开始覆盖之前的内容。master用master_repl_offset记录自己写入的位置offset,slave用slave_repl_offset记录自己读取的offset。repl_backlog_buffer当master和slave断开重连时,slave会先向master发送psync命令,同时将自己的runID和slave_repl_offset发送给master。master只需要将master_repl_offset和slave_repl_offset之间的命令同步到从库即可。增量复制的执行流程如下图所示:Redis增量复制面试官:全量同步完成后,正常运行时如何同步数据?通过这个连接,库会把后续接连收到的命令操作重新同步到从库。这个过程也称为基于长连接的命令传播。使用长连接的目的是为了避免频繁建立连接带来的开销。Sentinel原理连续问面试官:对,知道这么多,你知道Sentinel集群的原理吗?Sentinel是Redis的一种运行模式,它专注于监控Redis实例(主节点、从节点)的运行状态,并且可以在主节点出现故障时,通过一系列的机制实现主选举和主从切换实现故障转移,保证整个Redis系统的可用性。他的架构图如下:RedisSentinelClusterRedisSentinel有以下能力:监控:持续监控master和slave是否处于预期的工作状态。自动切换主库:当Master发生故障时,sentinel启动故障自动恢复过程:从slave中选出一个作为新的master。Notification:让slave执行replicaof与新的master同步;并通知客户端与新的master建立连接。采访者:Sentinels是如何认识彼此的?Sentinels与master建立通信,利用master提供的发布/订阅机制发布自己的信息,比如身高体重,是否单身,IP,端口……master有一个__sentinel__:hello专用通道用于在Sentinels之间发布和订阅消息。这就像__sentinel__:hello微信群。哨兵通过主人建立的微信群发布自己的消息,同时关注其他哨兵发布的消息。面试官:虽然Sentinel已经建立了连接,但是还是需要和slave建立连接,否则无法监控到他们。如何知道奴隶并监视他们?关键是要用master来实现。sentinel向master发送INFO命令。宗门下自然认识所有的药师兄弟。所以master收到命令后,就把slave的列表告诉sentinel。sentinel根据master回复的slave列表信息与各个slave建立连接,并根据这个连接持续监控sentinel。INFO命令获取slave信息Cluster集群串口炮面试官:除了Sentry,还有其他高可用方式吗?有Cluster集群实现高可用,Sentinel集群监控的Redis集群是主从架构,不能水平扩展。使用RedisCluster集群主要解决大量数据存储带来的各种慢问题,也方便横向扩展。当面对百万级或者千万级的用户规模时,scale-out的Redis分片集群会是一个非常好的选择。面试官:什么是Cluster集群?Redis集群是一种分布式数据库解决方案。集群通过分片(一种“分而治之”的做法)来管理数据,并提供复制和故障转移功能。将数据分成16384个槽,每个节点负责一部分槽。槽信息存储在每个节点中。它是去中心化的。如图所示,集群由三个Redis节点组成。每个节点负责整个集群的一部分数据。每个节点负责的数据可能不同。Redis集群架构的三个节点相互连接,形成对等集群。它们通过Gossip协议相互交换集群信息。最后,每个节点保存其他节点的时隙分配。面试官:哈希槽是如何映射到Redis实例的?根据键值对的key,使用CRC16算法计算出一个16位的值;取16位值到16384得到0到16383之间的一个数,代表key对应的hashslot。根据槽位信息定位到对应的实例。键值对、数据、哈希槽和Redis实例之间的映射关系如下:数据、槽和实例的映射面试官:Cluster是如何实现故障转移的?Redis集群节点使用Gossip协议来广播自己的状态,并与整个集群认知变化。例如,如果一个节点发现一个节点断开连接(PFail),它会向整个集群广播这个信息,其他节点也能收到这个断开连接的信息。如果一个节点收到某个节点已经达到集群多数的数量(PFailCount),它可以将该节点标记为确认下线状态(Fail),然后向整个集群广播以强制其他节点也下线接收PFail计数。节点已经下线的事实,立即执行丢失节点的主从切换。面试官:客户端如何判断访问的数据分布在哪个实例上?Redis实例会通过Gossip协议将自己的hashslot信息发送给集群中的其他实例,实现hashslot分配信息的扩散。这样,集群中的每个实例都拥有了所有哈希槽与实例之间的映射关系信息。当客户端连接到任意一个实例时,实例会向客户端响应哈希槽与实例的映射关系,客户端会在本地缓存哈希槽与实例的映射信息。客户端请求时,会计算出key对应的hashslot,然后通过本地缓存的hashslot实例映射信息定位到数据所在的实例,再向对应的实例发送请求。Redis客户端定位到数据所在的节点。面试官:Redis的重定向机制是什么?由于新实例或负载均衡重新分配,哈希槽与实例之间的映射关系发生变化。客户端向实例发送请求。如果该实例没有对应的数据,Redis实例会告诉客户端将请求发送给另一个实例。Redis通过MOVED错误和ASK错误告诉客户端。MOVEDMOVED错误(负载均衡,数据已迁移到其他实例):当客户端向某个实例发送键值对操作请求,而key所在的slot不对自己负责时,该实例会返回一个MOVEDFalse重定向到负责槽的节点。同时,客户端也会更新本地缓存,正确更新slot与Redis实例的对应关系。MOVED命令要求如果某个slot的数据很多,有的迁移到新实例,有的不迁移。如果在当前节点中找到请求的密钥,则直接执行命令,否则需要ASK错误响应。当slot部分迁移未完成时,如果要访问的key所在的Slot正在从instance1迁移到instance2(如果key已经不在instance1中),instance1会返回ASK错误给客户端的消息:客户端请求的key所在的哈希槽正在迁移到实例2,你先给实例2发送一个ASKING命令,然后再发送操作命令。比如client在172.17.18.1实例上请求定位16330key="公众号:codegebyte"的slot,如果节点1找到了,则直接执行命令,否则响应ASK错误消息并引导客户端运行迁移的目标节点为172.17.18.2。ASKerror注意:ASKerror命令不会更新客户端缓存的哈希槽分配信息。未完待续本文主要梳理了Redis的核心内容,涉及数据结构、内存模型、IO模型、持久化RDB和AOF、主从复制原理、哨兵原理、集群原理。“面霸”系列将分几篇,分别从核心原理、高可用、实战、如何避坑等方面来拿下Redis。本文转载自微信公众号“码哥字节”,可通过以下二维码关注。转载本文请联系字节码哥公众号。