Redis
2024-08-01 18:37:58 14 举报
AI智能生成
Redis是一个开源的内存中键值数据存储系统,通常用于缓存、消息队列和分布式锁等场景。它支持多种数据结构,包括字符串、哈希、列表、集合和有序集合。Redis的键值对可以存储各种类型的数据,例如JSON、二进制数据和字符串。它提供了丰富的操作命令,例如GET、SET、DEL、LPUSH、RPOP等。Redis通过master-slave复制和哨兵机制实现了高可用性和故障恢复。此外,Redis还支持Lua脚本、事务、持久化和发布/订阅等特性,使其成为一个功能强大的NoSQL数据库。
作者其他创作
大纲/内容
<font color="#ec7270">遗留问题</font>
1.把Redis当作MQ来使用和传统的MQ消息中间件有什么区别?<br>
1.持久化<br>如果使用Redis作为MQ的话,会有持久化的问题,可能会有消息丢失,<br>如果使用RDB进行持久化,可能会丢失一段周期内的命令修改<br>如果使用AOF进行持久化,选择每条命令都进行持久化,那么持久化则可以得到保证,否则选择每秒同步,则会丢失1秒钟的数据<br>反观传统MQ,MQ如果选择了持久化模式,则会先进行持久化操作,甚者还会利用Kafka、RocketMQ零拷贝的原理进行写入,速度更快<br>2.消费者组<br>Redis对消费者组支持不太好,对于List来说,原生上不支持一个消息发到一个消费者组里面,在高版本中Stream方式是可以支持消费者组,<br>可以让整个消费者组对消息进行负载均衡,增强消息的消费能力。而在传统的MQ中,是可以原生支持消费者组的,虽然Redis的订阅发布可以<br>通过多个客户端订阅同一个频道实现"伪消费者组",但是需要配合AOF持久化才能保证消息不丢失<br>3.生产者组<br>Redis是不支持生产者组的,在RocketMQ中,支持生产者组的设置,因为这样可以保证在同一个组里的生产者逻辑是相同的,在事务消息回查<br>的时候,即便发送事务消息的生产者挂掉了,也可以对同一个组里的其他生产者进行消息回查。<br>4.事务消息、消费模式<br>事务消息:Redis不支持,RocketMQ支持<br>消费模式:广播模式,比较难以支持,因为配置一旦生效的,不容易更改。集群模式,需要在客户端进行设计
2.LRU Java版本实现
4.Redis的使用场景有哪些
缓存
计数器。<br>可以对String进行自增自减运算,从而实现计数器功能。Redis这种内存型数据库的读写性能非常高,<br>很适合存储频繁读写的计数量
分布式ID生成。<br>利用自增特性,一次请求一个大一点的步长如incr 2000,缓存 在本地使用,用完再请求
海量数据统计。<br>位图(bitmap):存储是否参加过某次活动,是否已读某篇文章,用户是否为会员,日活统计
会话缓存。<br>可以使用Redis来统计存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,<br>也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用以及可伸缩性
分布式队列/阻塞队列.<br>List是一个双向链表,可以通过lpush/rpush和rpop/lpop写入和读取消息。<br>可以通过使用brpop/blpop来实现阻塞队列
分布式锁实现。<br>在分布式场景下,无法使用基于进程的锁来对多个节点上的进程进行同步,可以使用Redis自带的SETNX<br>命令实现分布式锁
热点数据存储。<br>最新品论、最新文章列表,使用list存储,ltrim取出热点数据,删除老数据
社交类需求。<br>Set可以实现交集,从而实现共同好友功能,Set通过求差集,可以进行好友推荐,文章推荐
排行榜。<br>sorted_set可以实现有序性操作,从而实现排行榜等功能
延迟队列。<br>使用sorted_set使用【当前时间戳+需要延迟的时长】做socre,消息内容作为元素,<br>调用zadd来生产消息,消费者使用zrangebyscore获取当前时间之前的数据做轮询处理,<br>消费完再删除任务rem key member
疑惑的问题
Redis中AOF持久化方式有使用写时复制技术嘛?<br>Redis的AOF(Append Only File)持久化方式并不会使用写时复制(Copy-on-Write)技术。AOF持久化是通过记录每一个写操作命令到日志文件中,在Redis服务器重启时,通过重新执行这些命令来恢复数据。<br><br>Redis使用写时复制技术的是在它的另一种持久化方式——RDB(Redis Database File)快照中。当Redis创建一个新的RDB快照时,它会使用写时复制技术来创建当前数据集的一个子进程。子进程负责创建快照,而主进程则继续处理客户端的请求。如果在子进程创建快照的过程中有写入操作,那么这些操作将会被复制到新的内存区域中,从而保证了快照的完整性。<br><br>总结来说,AOF持久化是通过追加命令到日志文件,而RDB快照持久化才使用了写时复制技术。
Redis的单线程和高性能
Redis是单线程吗?<br>Redis的单线程主要是指Redis的网络IO和键值对读写是由一个线程来完成的,这也是<br>Redis对外提供键值存储的主要流程。但Redis的其他功能,比如持久化、异步删除、<br>集群数据同步等,其实是由额外的线程执行的<br>
Redis单线程为什么还能这么块?<br>因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了<br>多线程的切换性能损耗问题。正因为Redis是单线程,所以要小心使用Redis指令,对于<br>那些耗时的指令(比如keys *)一定要谨慎使用,一不小心就可能会导致Redis卡顿<br>
Redis单线程如何处理那么多的并发客户端连接?<br>Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,<br>依次放到文件事件分派器,事件分派器将事件分发给事件处理器<br>#查看Redis支持最大连接数,在redis.conf文件中可修改 # maxclients 100000<br>CONFIG GET maxclients<br>
开发规范与性能优化
1.键值设计
1.key名设计<br>
a.【建议】:可读性和可管理性<br>以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id<br>```c<br>trade order:1<br>```<br>
b.【建议】:简洁性<br>保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如<br>```c<br>user:{uid}:friends:messages:{mid} 简化为u:{uid}:fr:m:{mid}<br>```<br>
c.【强制】:不要包含特殊字符<br>反例:包含空格、换行、单双引号以及其他转义字符<br>
2.value设计
a.【强制】:拒绝bigkey(防止网卡流量、慢查询)<br>在Redis中,一个字符串最大512MB,一个二级数据结构(例如hash、list、set、zset)<br>可以存储大约40亿个(2^32 -1)个元素,但实际中如果下面两种情况,就会认为是bigkey<br>1.字符串类型,它的big体现在单个value值很大,一般认为超过10KB就是bigkey<br>2.非字符串类型:哈希、列表、集合、有序集合,它们的big体现在元素个数太多。<br>一般来说,string类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000<br>反例:一个包含200万个元素的list.<br><br>非字符串的bigkey,不要用del删除,使用hscan、sscan、zscan方式渐进式删除,同时要<br>注意防止bigkey过期时间自动删除问题(例如一个200万的zset设置1小时过期,会触发del<br>操作,造成阻塞)<br>
bigkey的危害:<br>1.导致redis阻塞<br>2.网络拥塞<br>bigkey也就意味着每次获取要产生的网络流量较大,假设一个bigkey为1MB,客户端每秒访问量<br>为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器<br>来说简直就是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey<br>可能会对其他示例也造成影响,其后果不堪设想<br>3.过期删除<br>有个bigkey,它安分守己(只执行简单的命令,例如hget、lpop、zscore等),但它设置了过期时间,<br>当它过期后,会被删除,如果没有使用Redis4.0的过期异步删除(lazyfree-lazy-expire yes),就会存在<br>阻塞Redis的可能性<br>
bigkey的产生:<br>一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,例子如下:<br>1.社交类:粉丝列表,如果某些明星或者大V不精心设计下,必是bigkey<br>2.统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey<br>3.缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意,<br>第一,是不是有必要把所有字段都缓存<br>第二,有没有相关关联的数据,有的同学为了图方便把相关数据都存一一个key下,产生bigkey<br>
如何优化bigkey<br>1.拆<br>biglist: list1、list2、.....listN<br>big hash: 可以将数段分段存储,比如一个大的key,假设存了100万的用户数据,可以拆分成200个key,<br>每个key下面存放5000个用户数据<br>2.如果bigkey不可避免,也要思考以下要不要每次把所有元素都取出来(例如有时候仅仅需要hmget,而不是<br>hgetall),删除也是一样,尽量使用优雅的方式来处理<br>
b.【推荐】选择合适的数据类型。<br>例如:实体类型(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)<br>反例:<br>```c<br>set user:1:name tom<br>set user:1:age 19<br>set user:1:favor football<br>```<br>正例:<br>```c<br>hmset user:1 name tom age 19 favor football<br>```<br>
3.【推荐】:控制key 的生命周期,redis不是垃圾桶<br>建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期)<br>
2.命令使用
1.【推荐】O(N)命令关注N的数量<br>例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。<br>有遍历的需求可以使用hscan、sscan、zscan代替<br>
2.【推荐】禁用命令<br>禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的<br>方式渐进式处理<br>
3.【推荐】合理使用select<br>redis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际<br>还是单线程处理,会有干扰<br>
4.【推荐】使用批量操作提供效率<br>```c<br>原生命令:例如mget、mset<br>非原生命令:可以使用pipeline提高效率<br>```<br>但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)<br>注意两者不同:<br>```c<br>原生命令是原子操作,pipeline是非原子操作<br>pipeline可以打包不同的命令,原生命令做不到<br>pipeline需要客户端和服务端同时支持<br>```<br>
5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代
3.客户端使用
1.【推荐】避免多个应用使用一个Redis示例<br>正例:不相干的业务拆分,公共数据库做服务化<br>
2.【推荐】使用带有连接池的数据库,可以有效控制链接,同时提高效率,<br>标准使用方式如代码所示<br>
连接池参数含义:<br>
优化建议:<br>1.maxTotal:最大连接数,早期的版本叫maxActive实际上这个是一个很难回答的问题,<br>考虑的因素比较多:<br>1.1 业务希望Redis并发量<br>1.2 客户端执行命令时间<br>1.3 Redis资源:例如nodes(例如应用个数) * maxTotal是不能超过redis的最大连接数maxclients<br>1.4 资源开销:例如虽然希望控制空闲连接(连接池此可可马上使用的连接),但是不希望因为连接池<br>的频繁释放创建连接造成不必要开销<br>
举个例子:假设<br>1.一次命令时间(borrow|return resource + Jedis执行命令(含网络))的平均耗时<br>约为1ms,一个链接的QPS大约是1000<br>2.业务期望的QPS是50000<br>那么理论上需要的资源池大小是50000 / 1000 = 50 个。但事实上这是个理论值,<br>还要考虑到要比理论值预留一些资源,通常来讲maxTotal可以比理论值大一些。<br>但这个值不是越大越好,一方面连接太多占用客户端和服务端资源,另一方面对于<br>Redis这种高QPS的服务器,一个大命令的阻塞即时设置再大资源池仍然会无济于事。<br>
优化建议:<br>2.maxIdle和minIdle<br>maxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出雨量,所以maxIdle不要设置过小,<br>否则会有new Jedis(新连接)开销<br>连接池的最佳性能是maxTotal = maxIdle,这样就避免连接池伸缩带来的性能干扰,到那时如果并发<br>量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为上面的<br>业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍。<br>minIdle()最小空闲连接数,与其说是最小空闲连接数,不如说是"至少需要保持的空闲连接数",在使用<br>连接的过程中如果连接数超过了minIdle那么继续建立连接,如果超过了maxIdle,当超过的连接执行完<br>业务后会慢慢被移除连接池释放掉,如果系统启动完马上就会有很多的请求过来,那么可以给redis连接池<br>做预热,比如快速的创建一些redis连接,执行简单命令,类似ping(),快速地将连接池里地空闲连接提升到<br>minIdle地数量<br>
3.【建议】高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
4.【推荐】设置合理的密码,如有必要可以使用SSL加密访问
5.【建议】Redis对于过期键有三种清除策略:<br>5.1 被动删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key<br>5.2 主动删除:由于惰性删除策略无法保证冷数据被即时删除,所以Redis会定期主动淘汰一批<br>已过期的key<br>5.3 当前医用内存超过maxmemory限定时,触发主动清理策略<br><br>主动清理策略在Redis4.0之前以供实现了6中内存淘汰策略,在4.0之后又加了2种策略,总共8种:<br>a.针对设置了过期时间的key做处理<br>a.1 volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早<br>过期的越先被删除<br>a.2 volatile-random 就像它的名称一样,在设置了过期时间的键值对种,进行随机删除<br>a.3 volatile-lru 会使用LRU算法筛选设置了过期时间的键值对删除<br>a.4 volatile-lfu 会使用LFU算法筛选设置了过期时间的键值对删除<br>b.针对所有的key做处理<br>b.1 allkeys-random:从所有键值对中随机选择并删除数据<br>b.2 allkeys-lru 使用LRU算法在所有数据中进行筛选删除<br>b.3 allkeys-lfu 使用LFU算法在所有数据中进行筛选删除<br>c.不处理<br>c.1 noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息error<br>OOM command not allowed when used memory,此时Redis只相应读操作<br><br>当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,<br>缓存污染情况比较严重。这时使用LFU可能更好点.<br><br>根据自身业务类型,配置好maxmemory-policy(默认时noeviction)推荐使用volatile-lru.如果不设置<br>最大内存,当Redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换(swap)会让<br>redis的性能急剧下降。当Redis运行在主从模式时,只有主节点才会执行过期删除策略,然后把删除<br>操作"del key"同步到从节点删除数据<br>
LRU算法(least Recently Used,最近最少使用)<br>淘汰很久没有被访问过的数据,以最近一次访问时间作为参考<br>
LFU算法(Least Frequently Used,最不经常使用)<br>淘汰最近一段时间被访问次数最少的数据,以次数作为参考<br>
4.系统内核参数优化
vm.swapiness。<br>swap对于操作系统来说比较重要,当物理内存不足时可以将一部分内存页进行swap到硬盘上,<br>以解燃眉之急。但世界上没有免费午餐,swap空间由硬盘提供,对于需要高并发、高吞吐的应用<br>来说,磁盘IO通常会称为系统瓶颈。在Linux中,并不是等到所有物理内存都使用完才会使用到swap,<br>系统参数swappniess会决定操作系统使用swap的倾向程度。swappiness的取只范围是0~100,<br>swappiness的值越大,说明操作系统可能使用swap的概率越高,swappiness值越低,表示操作系统<br>更加倾向于使用物理内存。swappiness的取值越大,说明操作系统可能使用swap的概率越高,越低<br>则越倾向于使用物理内存。<br>如果linux内核版本小于3.5 那么wappiness设置为0,这样系统宁愿swap也不会oom killer(杀掉进程)<br>如果linux内核版本>=3.5,那么swappiness设置为1,这样系统宁愿swap也不会oom killer<br>一般需要保证redis不会被kill掉<br>```c<br>cat /proc/version # 查看linux内核版本<br>echo 1> /proc/sys/vm/swappiness<br>echo vm.swappiness = 1 >> /etc/sysctl.cnf<br>```<br>PS:OOM killer机制是指Linux操作系统发现可用内存不足时,强制杀死一些用户进程(非内核进程),<br>来保证系统有足够的可用内存进行分配<br>
vm.overcommit_memory(默认0).<br>0:表示内核将检查是否有足够的可用物理内存(实际不一定用满)供应用进程使用;如果有足够的可用物理<br>内存,内存申请允许;否则,内存申请失败,并把错误返回给应用进程<br>1:表示内核允许分配所有的物理内存,而不管当前的内存状态如何,如果是0的化,可能导致类似fork等<br>操作执行失败,申请不倒足够的内存空间<br>Redis建议把这个值设置为1,就是为了让fork操作能够在低内存下也能执行成功<br>```c<br>cat /proc/sys/vm/overcommit_memory<br>echo "vm.overcoimmit_memory=1" >> /etc/sysctl.conf<br>sysctl vm.overcommit_memory =1<br>```<br>
合理设置文件句柄数。<br>操作系统进程试图打开一个文件(或者叫句柄),但是现在进程打开的句柄数已经达到了上限,<br>继续打开会报错"Too many open files"<br>```c<br>ulimit -a # 查看系统文件句柄数,看open files选项<br>ulimt -n 65535 # 设置系统文件句柄数<br>```<br>
慢查询日志:slowlog<br>Redis满日志命令说明<br>config get slow* #查看有关满日志的配置信息<br>config set slowlog-log-slower-than 20000 #设置满日志使用时间阈值,此处为20毫秒,即超过20毫秒<br>的操作都会记录下来,生产环境建议设置1000,也就是1ms,这样理论上redis并发至少达到1000,如果要求<br>单机并发达到1万以上,这个值可能设置为100<br><br>config set slowlog-max-len 1024 # 设置慢日志记录保存数量,如果保存数量已满,会删除最早的记录,最新<br>的日志追加进来,记录慢查询日志时Redis会对长命令做截断操作,并不会占用大量内存,建议设置稍大些,防止<br>丢失日志<br>config rewrite #将服务器当前所使用的配置保存到redis.conf<br>slowlog len #获取慢查询日志列表的当前长度<br>slowlog get 5 # 获取最新的5条慢查询日志,慢查询日志由四个属性组成:标识ID,发生时间戳,命令耗时,执行命令<br>和参数<br>slowlog reset #重置慢查询<br>
其他高级命令
keys:全量遍历。<br>用来列出所有满足特定正则字符串规则的key,当redis数量比较大时,性能比较查,要避免使用<br>
scan:渐进式遍历键<br>SCAN cursor [MATCH pattern] [COUNT count]<br>
参数解析。<br>scan参数提供了三个参数,<br>第一个是cursor整数值(hash桶的索引值),<br>第二个是key的正则模式,<br>第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。<br>第一次遍历时,cursor值为0,然后将返回结果中第一个整数值作为下一次遍历的cursor.一直遍历<br>到返回的cursor值为0时结束。可以简单理解为每次遍历多少个元素,根据测试,推荐Count大小为1W<br>
底层原理分析。<br><font color="#ec7270">内部原理见https://www.lixueduan.com/posts/redis/redis-scan/</font><br>
概述。<br>由于Redis是单线程再处理用户的命令,而Keys命令会一次性遍历所有key,于是在命令<br>执行过程中,无法执行其他命令。这就导致如果Redis中的key比较多,那么Keys命令执行<br>时间就会比较长,从而阻塞Redis,所以推荐使用Scan命令来代替Keys,因为Scan可以限制<br>每次遍历的key数量。<br>Keys的缺点:<br>1.没有limit,我们只能一次性获取所有符合条件的key,如果结果有上百万挑,那么等待的就是<br>"无穷无尽"的字符串输出<br>2.keys命令是遍历算法,时间复杂度是O(N)。这个命令非常容易导致Redis服务卡顿,要尽量<br>避免在生产环境使用该命令。<br>相比于keys命令,Scan命令有两个比较明显的优势:<br>1.Scan命令的时间复杂度虽然也是O(N),但它是分次进行的,不会阻塞线程<br>2.Scan命令提供了count参数,可以控制每次遍历的集合数<br><br><font color="#ec7270">可以理解为Scan是渐进式的keys.</font><br><br>大致用法如下:<br>SCAN命令是基于游标的,每次调用后,都会返回一个游标,用于下一次迭代。当游标返回0时,<br>表示迭代结束。<br><font color="#ec7270">第一次Scan时指定游标为0,表示开启新的一轮迭代,然后Scan命令返回一个新的游标,作为第二次<br>Scan时的游标值继续迭代,一直到Scan返回游标为0,表示本轮迭代结束<br></font><br>通过这个就可以看出,Scan完成一次迭代,需要和Redis进行多次交互。<br>
Scan使用案例。<br>使用Scan命令,Count参数指定1000,Redis命中几百万Key.<br>这里会出现一个问题。Scan命令中的Count指定一次扫描多少key,这里指定为1000,<br>几百万key就需要几千次迭代,即和Redis交互几千次,再加上网络连接中的数据传输<br>开销和延迟,将会导致耗时比较长。这就需要将Count参数调大后,减少了交互次数。<br><br>Count参数越大,Redis阻塞时间也会越长,需要取舍。如果我们极端一点的话,<br>Count参数和总Key数一致时,Scan命令就和Keys效果一样了<br>
Count大小和Scan总耗时的关系如图所示,<br>可以发现Count越大,总耗时就越短,不过后面提升就越不明显了<br><font color="#ec7270">所以推荐的Count大小为1W左右.<br></font>如果不考虑Redis的阻塞,其实Keys比Scan会快很多,毕竟是一次性处理,<br>省去了多余的交互<br>
Scan原理。<br>Redis使用了Hash表作为底层实现,原因不外乎高校且实现简单。类似于HashMap那样数组+链表的结构.<br>其中第一维的数组大小为2n(n>=0),每次扩容数组长度扩大一倍。Scan命令就是对这个一维数组进行遍历。<br>每次返回的游标值也都是这个数组的索引,Count参数表示遍历多少个数组的元素,将这些元素下挂接的符合<br>条件的结果都返回。因为每隔元素下挂接的链表大小不同,所以每次返回的结果数量也就不同。<br>
如代码所示,SCAN的命令额度遍历顺序是0->2->1->3<br>这个顺序看起来有些奇怪,把它转换成二进制:00->10->01->11,可以看到这个序列是最高位加1,<br><font color="#ec7270">普通二进制的加法,是从右往左3相加、进位。而这个序列是从左往右相加、进位的</font><br><br>相关源码:<br>```c<br>v = rev(v);<br>v++;<br>v = rev(v);<br>```<br>
reverse binary iteration.<br>Redis Scan命令最终使用的是reverse binnary iteration算法,大概可以翻译为逆二进制迭代。<br>这个算法简单来说就是:<br><font color="#ec7270">依次从高位(有效位)开始,不断尝试将当前高位设置为1,然后变动更高位为不同组合,依次来扫描<br>整个字典数组<br></font>其最大的优势在于,从高位扫描的时候,如果槽位是2^N个,扫描的临近的2个元素都是与2^(N-1)<br>相关的就是说同模的,比如槽位8时,0%4 == 4%4, 1%4 == 5%4。因此想到其实hash的时候,<br>跟模是很相关的。<br><br>比如当整个字典大小只有4的时候,一个元素计算出的整数为5,那么计算它的hash值需要模4,也就是<br>hash(n) == 5 % 4 == 1,元素放在第一个槽位中。当字典进行扩容的时候,字典大小变为8,此时<br>计算hash的时候为 5 % 8 == 5,该元素从1号slot迁移到了5号,1和5是对应的,我们称之为同模或者对应。<br>同模的槽位的元素最容易出现合并或者拆分了。因此在迭代的时候只要及时地扫描这些相关地槽位,<br>这样就不会造成大面积的重复扫描。<br>
迭代时的三种情况。<br>迭代哈希表时,有以下三种情况:<br>1.从迭代开始到结束,哈希表不Rehash<br>2.从迭代开始到结束,哈希表Rehash,但每次迭代,哈希要么不开始Rehash,要么已经结束Rehash<br>3.从依次迭代开始到结束,哈希表在依次或多次迭代中Rehash,即再Rehash过程中,执行Scan命令,<br>这时数据可能只迁移了一部分<br>
第一种情况比较简单。假设redis的hash表大小为4,第一个游标为0,读取第一个bucket<br>的数据,然后游标返回2,下次读取bucket 2,依次遍历<br>
第二种情况更复杂。假设redis的hash表为4,如果rehash后大小变成8.如果如上返回游标<br>(即返回2),则显示如图所示。<br>假设bucket 0读取后返回到cursor 2,当客户端再次Scan cursor 2时,hash表已经被rehash,<br>大小翻倍到8,redis计算一个key bucket如下:<br>```c<br>hash(key) & (size -1)<br>```<br>即如果大小为4,hash(key) & 11(3),如果大小为8,hash(key) & 111(7).所以当size从4扩大<br>8时,2号bucket中的原始数据会被分散到2(010)和6(110)这两个bucket中<br><br>从二进制来看,size为4时,在hash(key)之后,取低两位,即hash(key) & 11,如果size为8,<br>bucket位置为hash(key) & 111,即取低三位,所以不会出现漏掉数据的情况<br>
第三种情况,如果返回游标2时正在进行rehash,则Hash表1的bucket2中的一些数据可能已经rehash到了<br>Hash表2的bucket[2]或bucket[6],那么必须完全遍历哈希表2的bucket2和6,否则可能会丢失数据。<br><br>Redis全局有两个Hash表,扩容时会渐进式地将表1地数据迁移到表2,查询时程序会先在ht[0]里面查找,如果<br>没找到地话,就会继续到ht[1]里面进行查找<br>
游标计算。<br>Scan命中的游标,其实就是Redis内部地bucket<br>
计算过程如图所示.<br>大小为4时,游标状态转换为0-2-13<br>当大小为8时,游标转台转换为0-4-2-6-1-5-3-7.<br>当size由小变大时,所有原来的游标都能在大HashTable中找到对应的位置,<br>并且顺序一致,不会重复读取,也不会被遗漏。<br>总结:redis在rehash扩容的是时候,不会重复或者漏掉数据。但缩容,可能会<br>造成重复,但不会漏掉数据<br>
缩容处理。<br>之所以会出现重复数据,其实就是为了保证缩容后数据不丢。<br>假设当前hash大小为8:<br>1.第一次先遍历了bucket[0],返回游标为4<br>2.准备遍历bucket[4],然后此时发生了缩容,bucket[4]的元素也进到了bucket[0]<br>3.但是bucket[0]之前已经被遍历过了,此时会丢失数据吗?<br>具体计算方法<br>```c<br>v = (((v |m0) + 1) & (~m0) | (v & m0)<br>```<br>
总结。<br>1.Scan Count参数限制的是遍历的bucket数,而不是限制的返回的元素个数<br>由于不同bucket中的元素个数不同,其中满足条件的个数也不同,所以每次<br>Scan返回元素也不一定相同<br>2.Count越大,Scan总耗时越短,但是单次耗时越大,即阻塞Redis时间变长<br>2.1 推荐Count大小为1W左右<br>2.2 当Count = Redis Key总数时,Scan和Keys效果一致<br>3.Scan采用逆二进制发来计算游标,主要为了兼容Rehash的情况<br>4.Scan为了兼容缩容后不漏掉数据,会出现重复遍历。需要客户端做去重处理<br><br>核心就是逆二进制迭代法,比较复杂,而且算法作者也没有具体证明,为什么<br>这样就能实现,只是测试发现没有问题,各种情况都能兼容<br>
注意事项。<br>注意:但是scan并非完美无瑕,如果在scan的过程中如果有键的变化(增加、删除、修改),那么遍历<br>效果可能会碰到如下问题:新增的键可能没有遍历到,遍历出了重复的键等情况,也就是说scan并不能<br>保证完整地遍历出来所有的键。<br><br>1.返回的结果可能会有重复,需要客户端去重复,这点非常重要<br>2.遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的<br>3.单词返回的结果是空的,并不意味着遍历结束,而要看返回的游标值是否为零<br>
info:查看redis服务运行信息,分为9大块,每个块都有非常多的参数。<br>
常用参数:<br>connected_clients #正在连接的客户端数量<br>instantaneous_ops_per_sec #每秒执行多少指令<br><font color="#ec7270">used_memory<br>#含义:Redis分配的内存总量(byte),包含redis进程内部的开销和<br>数据占用的内存<br># 衡量标准:此指标包括Redis分配的所有内存,但不考虑该内存是否被操作系统回收,<br>因此它表示Redis分配的总内存大小,而不一定是实际占用的内存</font><br>used_memory_human #Redis分配的内存总量(Kb,human会展示出单位)<br><font color="#ec7270">used_memory_rss_human</font><br><font color="#ec7270">#含义:表示Redis进程实际使用的物理内存(Resident Set Size)包括了被分配但未使用</font><br><font color="#ec7270">的内存和共享的库和数据</font><br><font color="#ec7270"># 衡量标准:该指标考虑了Redis进程实际占用的内存,包括了数据、缓存、连接等,</font><br><font color="#ec7270">以及被操作系统回收但仍保留在内存中的部分,这提供了一个更准确的关于Redis</font><br><font color="#ec7270">实际内存占用的视图</font><br><font color="#000000">在一些情况下,used_memory_rss可能会比used_memory大,因为它包含了未使用的<br></font>内存和被操作系统回收但仍然在内存中的部分,其他情况下,它们可能非常接近,具体<br>取决于Redis实例的工作负载和操作系统的内存管理。这两个指标都可以用于监控Redis<br>的内存使用情况,但used_memory_rss提供了更接近实际占用的内存的信息<br>used_memory_peak #redis的内存消耗峰值(byte)<br>used_memory_peak_human #redis的内存消耗峰值(KB)<br><br>maxmemory # 配置中设置的最大可使用内存值(byte),默认0,不限制<br>maxmemory_human # 配置中设置的最大可使用内存值<br>maxmemory_policy:noeviction # 当达到maxmemory时的淘汰策略<br>
Server服务器运行的环境参数<br>
Clients客户端相关信息
Memory服务器运行内存统计数据
Persistence持久化信息
Stats通用统计数据
Replication主从复制相关信息
CPU CPU使用情况
Cluster集群信息
KeySpace键值对统计数量信息
Redis6.0新特性
多线程
概述。<br>redis6.0提供了多线程的支持,redis6以前的版本,严格来说也是多线程,只不过执行用户命令的<br>请求是单线程模型,还有一些线程用来执行后台任务,比如unlink删除大key,rdb持久化等<br><br>redis6.0提供了多线程的读写IO,但是最终执行用户命令的线程依然是单线程的,这样,就没有多线程<br>数据的竞争关系,依然很高效<br>
redis6.0以前线程执行模式,如下操作再一个线程中执行完成
redis6.0线程执行模式:<br>可以通过如下参数配置多线程模型:<br>```c<br>io-threads 4 // 这里说 有三个IO线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作<br>```<br>默认情况下,如上配置,有三个IO线程,这三个IO线程只会执行IO中的write操作,也就是说,read和命令执行<br>都由main线程执行,最后多线程讲数据写回客户端。<br>
开启了如下参数:<br>```c<br>io-threadas-do-reads yes // 将支持IO线程执行 读写任务<br>```<br>
常见问题
<b>1.Redis6.0之前的版本真的是单线程吗?</b><br>Redis在处理客户端的请求是,包括获取(socket读)、解析、执行、内容返回(socket 写)等都有一个<br>顺序串行的主线程处理,这就是所谓的"单线程"。但如果严格来讲并不是单线程,除了主线程外,它<br>也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大key的删除等等<br>
<b>2.Redis6.0之前为什么一直不使用多线程?</b><br>官方曾做过类似问题的回复:使用Redis时,几乎不存在CPU成为瓶颈的情况,Redis主要受限于内存和<br>网络。例如在一个普通的Linux系统上,Redis通过使用pipelining每秒可以处理100w个请求,所以如果<br>应用程序主要使用O(N)或O(log(N))的命令,它几乎不会占用太多CPU.<br>使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的<br>不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同事还可能存在线程切换、甚至加锁解锁、<br>死锁造成的性能损耗。Redis通过AE事件模型以及IO多路复用等技术,处理性能非常高,因此没有必要使用<br>多线程。单线程机制使得Redis内部实现的复杂度大大降低,Hash的惰性、Rehash、Lpush等等"线程不安全"<br>的命令都可以无锁进行<br>
<b>3.Redis6.0为什么要引入多线程呢?</b><br>Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,对于小数据包,Redis服务器可以处理8w~10w QPS,<br>这也是Redis处理的极限了,对于80%的公司来说,单线程的Redis已经足够使用了。但随着越来越复杂的业务场景,有些<br>公司动不动就上亿的交易量,因此需要更大的QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,<br>但该方案有非常大的缺点,例如要管理的Redis服务器太多,维护代价大;某些适用于单个Redis服务器的命令不适用于数据<br>分区;数据分区无法解决热点读/写问题;数据倾斜,重新分配和放大/缩小变得更加复杂等等。<br><br>从Redis自身角度来说,因为读/写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的<br>IO消耗,优化主要有两个方向:<br>1.提高网络IO性能,典型的实现比如使用DPDK来替代内核网络栈的方式<br>2.使用多线程充分利用多核,典型的实现比如Memcached<br>协议栈优化的这种方式跟Redis关系不大,支持多线程是一种最有效最便捷的操作方式,所以总结起来,Redis支持多线程<br>就是两个原因:<br>1.可以充分利用服务器的CPU资源,,目前主线程只能利用一个核<br>2.多线程任务可以分摊Redis同步IO读写负荷<br>
<b>4.Redis6.0默认是否开启了多线程?</b><br>Redis6.0的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf配置文件<br>```c<br>io-threads 4<br><br>io-threads-do-reads no<br>```<br>开启多线程后,还需要设置线程数,否则是不生效的。<br>关于线程数的设置,官方有一个建议:<br>4核的机器建议设置为2或3个线程,<br>8核的建议设置6个线程,线程数一定要小于机器核数,因为Redis还有预留出其他线程做后台任务<br>比如备份、删除过期键等操作,还需要注意的是,线程数并不是越大越好,<br>官方认为超过了8个基本就没什么意义了,因为主线程命令执行会存在一个瓶颈点,如果达到瓶颈,即便IO线程<br>设置得再多,也是空转<br>
<b>5.Redis6.0采用多线程后,性能的提升效果如何?</b><br>Redis作者antirez在RedisConf2019分享时曾提到:Redis6引入的多线程IO特性对性能提升<br>至少是一倍以上。国内也有大牛曾使用unstable版本在阿里云ESC进行过测试,GET/SET命令<br>在4线程IO时性能相比单线程是几乎翻倍了。如果开启多线程,至少要4核的机器,且Redis实例<br>已经占用相当大的CPU的耗时的时候才建议使用,否则使用多线程没有意义<br>
<b>6.Redis60多线程的实现机制?<br></b>流程简述:<br>1.主线程负责接收建立连接请求,获取socket放入全局等待读处理队列<br>2.主线程处理完读事件之后,通过RR(Round Robin)将这些连接分配给这些IO线程<br>3.主线程阻塞等待IO线程读取socket完毕<br>4.主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行回写socket<br>5.主线程阻塞等待IO线程将数据回写socket完毕<br>6.解除绑定,清空等待队列<br><br>该设计有如下特定:<br>1.IO线程要么同时在读socket,要么同时在写,不会同时读或写<br>2.IO线程只负责读写socket解析命令,不负责命令处理<br>
<b>7.开启多线程后,是否会存在线程并发安全问题?</b><br>Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。<br>所以我们不需要去考虑控制key、lua、事务,LPUSH/LPOP等等的并发及线程安全问题<br>
<b>8.Redis6.0的多线程和Memcached多线程模型进行对比</b><br>Memcached服务器采用master-worker模式进行工作。服务端采用socket与客户端通讯。<br>主线程、工作线程采用pipe管道进行通讯。主线程采用libevent监听listen、accept的读事件,<br>事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带<br>连接信息分发出去,响应的线程利用连接描述符建立与客户端的socket连接并进行后续的存取<br>数据操作<br>相同点:都采用了master线程-worket线程的模型<br>不同点:Memcached执行主逻辑也是在worker线程里,模型更加简单,实现了真正的线程隔离,<br>符合我们对线程隔离的常规理解。而Redis吧处理逻辑交还给master线程,虽然一定程度上增加了<br>模型复杂度,但也解决了线程并发安全等问题<br>
Client side caching(客户端缓存)
概述。<br>redis6提供了服务端追踪key的变化,客户端缓存数据的特性,这需要客户端实现<br>
执行流程为,当客户端访问某个key时,服务端将记录key和client,客户端拿到数据后,进行客户端缓存,<br>这时,当key再次被访问时,key将被直接返回,避免了与redis服务器的再次交互,节省服务端资源,当数据<br>被其他请求修改时,服务端将主动通知客户端失效的key,客户端进行本地失效,下次请求时,重新获取最新数据<br>目前只有lettuce对其进行了支持:<br>```xml<br><dependency><br><groupId>io.lettuce</groupId><br><artifactId>lettuce‐core</artifactId><br><version>6.0.0.RELEASE</version><br></dependency><br>```<br>```java<br>public class Main {<br> public static void main(String[] args) {<br> RedisClient redisClient = RedisClient.create("redis://127.0.0.1");<br> <br> Map<String, String> clientCache = new ConcurrentHashMap<>();<br> <br> StatefulRedisConnection<String, String> myself = redisClient.connect();<br> <br> CacheFrontend<String, String> frontend = <br> ClientSideCaching.enable(CacheAccessor.forMap(clientCache), <br> myself,<br> TrackingArgs.Builder.enabled().noloop));<br> <br> String key = "csk";<br> <br> int count = 0;<br> <br> while (true) {<br> System.out.println(frontend.get(key));<br> <br> TimeUnit.SECONDS.sleep(3);<br> if (count++ == Integer.MAX_VALUE) {<br> myself.close();<br> redisClient.shutdown();<br> }<br> }<br> }<br>}<br><br>```<br>
Redis高级数结构HyperLogLog
概述。<br>HyperLogLog并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,<br>通过HyperLogLog可以利用极小的内存空间完成独立总数的统计,数据集可以是<br>IP、Email、ID等<br>如果你负责开发维护一个大型的网站,有一天产品经理要网站每个网页每天的UV<br>(独立用户访问数, Unique Visitor)数据,你会如何实现?<br>如果统计PV(页面访问量,即Page View)那非常好办,给每个网页一个独立的Redis计数器就可以了,<br>这个计数器的key后缀加上当天的日期,这样来一个请求,incrby一次,最终就可以统计出所有的<br>PV数据。但是UV不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求<br>每一个网页请求都需要带上用户的ID,无论是登录用户还是未登录用户都需要一个唯一的ID来标识。<br>一个简单的方案,那就是为每一个页面一个独立的set集合来存储所有当天访问过此页面的用户ID。<br>当一个请求过来时,我们使用SADD将用户ID塞进去就可以了。通过SCARD可以去除这个集合的大小,<br>这个数字就是这个页面的UV数据。<br>但是,如果页面访问量非常大,比如一个爆款页面几千万的UV,你需要一个很大的set集合来统计,这就<br>非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多<br>的存储空间,不是很值得,其实需要的数据又不需要太精确,1050w和1060w这两个数字对于老板来说<br>并没有多大区别,所以我们可以有更好的解决方案。<br>这就是HyperLogLog的用武之地,Redis提供了HyperLogLog数据结构就是用来解决这种统计问题的。<br>HyperLogLog提供不精确的去重计数方案,虽然不精确到那时也不是非常不精确。Redis给出的标准<br>误差是0.81%,这样的精确度已经可以满足上面的UV统计需求了<br>
操作命令。<br>HyperLogLog提供了3个命令:pfadd、pfcount、pfmerge.<br>例如0815的访问用户是u1、u2、u3、u4, 08-16的访问用户是u4、u5、u6、u7<br>1.pfadd<br>pfadd key element [elment ...]<br>pfadd用户向HyperLogLog添加元素,如果添加成功返回1<br>```c<br>127.0.0.1:6379> pfadd 08-15:u:id "u1" "u2" "u3" "u4"<br>(integer) 1<br>```<br>2.pfcount<br>pfcount key [key ...]<br>pfcount用于计算一个或多个HyperLogLog的独立总数,例如08-15:u:id的独立总数为4<br>```c<br>127.0.0.1:6379> pfcount 08-15:u:id<br>(integer) 4<br>```<br>如果此时向其中插入u1、u2、u3、u90,结果会是5<br>```c<br>127.0.0.1:6379> pfadd 08-15:u:id "u1" "u2" "u3" "u90"<br>(integer) 1<br>127.0.0.1:6379> pfcount 08-15:u:id<br>(integer) 5<br>```<br>如果继续往里面插入苏剧,比如插入100万条用户记录。内存增加非常少,但是pfcount的<br>统计结果会出现误差。<br>以使用集合类型和HyperLogLog统计百万级用户访问次数的占用空间对比:(见图所示)<br>可以看到,HyperLogLog内存占用量小的惊人,但是用如此小空间来估算如此巨大的数据,<br>必然不是100%的正确,其中一定存在误差率。0.81%的误差率<br>3.pfmerge<br>pfmerge destkey sourcekey [sourcekey ...]<br>pfmerge可以求出多个HyperLogLog的并集并赋值给destkey<br>
对比
3.原理概述。<br>
数据原理。<br>HyperLogLog基于概率论中伯努利试验并结合了极大似然估算方法,并作了分桶优化。实际上<br>目前还没有发现更好的在大数据场景中准确计算基数的高效算法,因此在不追求绝对准确的情况下,<br>使用概率算法是一个不错的解决方案。概率算法不直接存储数据集合本身,通过一定的概率统计<br>方法预估值,这种方法可以大大节省内存,同时保证误差控制在一定范围内。目前用于基数基数的<br>概率算法包括:<br>Linear Counting(LC):早期的基数估计算法,LC在空间复杂度方面并不算优秀<br>LogLog Counting(LLC):LogLog Counting相比LC更加节省内存,空间复杂度更低<br>HyperLogLog Counting(HLL): HyperLogLog Counting是基于LLC的优化和改进,在同样空间复杂度<br>情况下,能够比LLC的基数估计误差更小。
举个例子,用户A和用户B抛硬币,规则是用户B负责抛硬币,每次抛的硬币可能正面,<br>可能反面。每当抛到正面为一回合,用户B可以自己决定进行几个回合。最后需要告诉<br>用户A最长的那个回合抛了多少次以后出现了正面,再由用户A来猜用户B一共进行了<br>几个回合.<br>如图所示,进行了n次,<br>第一次:抛了3次才出现正面,此时k=3, n = 1<br>第二次:抛了2次才出现正面,此时k=2, n = 2<br>第三次:抛了4次才出现正面,此时k=4, n = 3<br>.........<br>第n次试验:抛了7次才出现正面,此时我们估算,k = 7, n = n<br>k是每回合抛到1(硬币的正面)所用的次数,,我们已知的是最大的k值,可以用k_max表示。<br>由于每次抛硬币的结果只有0和1两种情况,因此,能够推测出k_max在任意回合出现的概率,<br>并由kmax结合极大似然估计的方法推测出n的次数n=2^(k_max).概率学把这种问题叫做<br>伯努利实验。现在用户B已经完成了n个回合,并且告诉用户A最长的一次抛了4次,用户A此时<br>胸有成竹,马上根据公式得出的结果是16,然后用户B却只抛了3次。所以这种预估方法存在较大误差,<br>为了改善误差情况,HLL中引入了发呢同平均的概念。<br>
同样举抛硬币的例子,如果只有一组抛硬币实验,显然根据公式推导得到的实验次数的估计误差较大,<br>如果100个组同时进行抛硬币实验,样本数变大,受运气影响的概率就很低了,每组分别进行多次抛<br>硬币实验,并上报各自实验过程中抛到正面的抛掷次数的最大值,就能根据100组的平均值预估整体的<br>实验次数了。分桶平均的基本原理是将统计数据划分为m个桶,每个桶分别统计各自的k_max,并能得到<br>各自的基数预估值,最终对这些基数预估值求平均得到整体的基数预估值。LLC中使用几何平均数预估<br>整体的基数值,但是当统计数据量较小时误差较大:HLL在LLC基础上做了改进,采用调和平均数过滤掉<br>不健康的统计值。<br>
什么是调和平均数呢?<br>举个例子。求平均工资:A的是1000/月,B的是30000/月。采用平均数的方式就是(1000+30000)/2=15500<br>采用调和平均数的方式就是2/(1/1000 + 1/30000)约等于1935.484.<br>可见调和平均数比平均数的好处就是不容易受到大的数据的影响,比平均数的效果是要好的。<br>
结合实例理解实现原理。以统计网页每天的UV数据为例。<br>1.转为比特串。<br>通过哈希函数,将数据转为比特串,例如输入5,便转为101,字符串也是一样,这样转化的原因在于<br>要和抛硬币对应上,比特串中0代表了反面,1代表了正面,如果一个数据最终被转换了10010000<br>那么从右往左,从低位往高位看,可以认为,首次出现1的时候,就是正面.那么基于上面的估算结论,<br>我们可以通过多次抛硬币实验的最大抛到正面的次数来预估总共进行了多少次实验,同样也就可以根据<br>存入数据中转化后出现1的最大的位置k_max来估算存入了多少数据.<br>2.分桶。<br>分桶就是分多少轮。抽象到计算机存储中去,存储的是一个长度为L的位(bit)大数组S,将S平均分为m组,<br>这个m组就是对应多少轮,然后每组所占用的比特个数是平均的,设为P,容易得出下面的关系:<br>```c<br>L = S.length<br>L = m * p<br>以K为单位,S所占用的内存 = L/8/1024<br>```<br>3.对应。<br>假设访问用户id为,idn, n->0,1,2,3....<br>在这个统计问题中,不同的用户id表示了一个用户,那么我们可以把用户的id作为被hash的输入。<br>即:hash(id)=比特串<br>不同用户的id,拥有不同的比特串。每一个比特串,也必然会至少出现一次1的位置。我们类比每一个比特串<br>为一次伯努利实验。现在要分轮,也就是分桶。所以我们可以设定,每个比特串的前多少位转为10进制后,<br>其值就对应于所在桶的标号。假设比特串的低两位用来计算桶下标志。总共有4个桶,此时有一个用户的id的<br>比特串是:1001011000011.它的所在桶下标位1*2^1 +1*2^0=3,处于第三个桶中,即第3轮中。<br>上面的例子中,计算出桶号后,剩下的比特串是:10010110000,从低位到高位看,第一次出现1的位置是5。<br>也就是说,此时第3个桶中,k_max=5,5对应的二进制是101,将101存入第3个桶。<br>模仿上面的流程,多个不同的用户id,就被粉散到不同的桶中去了,且每个桶有其k_max.然后当要统计出<br>某个页面有多少用户点击量的时候,就是一次估算,最终结合所有桶中的k_max带入估算公式,便能得出<br>估算值<br>
Redis中的HyperLogLog实现。<br>Redis的实现中,HyperLogLog占据12KB(占用内存为16384 * 6 / 8 /1024 = 12K)的大小,共设有16384个桶,<br>即:2^14=16384,每个桶有6位,每个桶可以表达的最大数字是2^5+2^4+.........+2^0=63,二进制为:111 111。<br>对于命令:pfadd key value。<br>在存入时,value会被hash成64位,即64bit的比特字符串,前14位用来分桶,剩下50位用来记录第一个1出现的<br>位置。之所以选14位,来表达桶编号是因为分了16384个桶,而2^14=16384,刚好地,最大的时候可以把桶利用完,<br>不造成浪费。假设一个字符串的前14位时:00 0000 0000 0010(从右往左看),其十进制值为2.那么value对应转化后的<br>值放到编号为2的桶。<br>index的转化规则:<br>首先因为完整的value比特串是64位形式,减去14后,剩下50位,假设极端情况,出现1的位置,是在第50位,<br>即位置是50。此时,index=50.此时先将index转为2进制,它是110010.因为16384个桶中,每个桶是6bit组成的。<br>于是110010就被设置到了第2号桶中去了,50已经是最坏的情况,且它都被容纳进去了。那么其他的不用想也肯定<br>能被容纳进去。<br>因为pfadd的key可以设置多个value.如下<br>```<br>pfadd lgh golang<br>pfadd lgh python<br>pfadd lgh java<br>```<br>根据上面的做法,不同的value会被设置到不同桶中去,如果出现了在同一个桶的,即前14位值是一样的,但是后面出现<br>1的位置不一样。那么比较原来的index是否比新index大。是,则替换,否则不变<br>最终地,一个key所对应的16384个桶都设置了很多的value了,每个桶有一个k_max。此时调用pfcount时,按照<br>调和平均数进行估算,同时加以偏差修正,便可以计算出key的设置了多少次value,也就是统计值,具体的估算公式如下:<br>
value被转换为64位的比特串,最终被按照上面的做法记录到每个桶中去。<br>64位转十进制就是2^64,HyperLogLog仅仅用了16384 *6/8/1024=12K<br>存储空间久能统计多达2^64个数,同时在具体的算法实现上,HLL还有一个<br>分阶段偏差修正算法<br>
缓存设计
缓存穿透。<br>缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常处于容错的考虑,<br>如果从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层<br>去查询,失去了缓存保护后端存储的意义。造成缓存穿透的基本原因有两个:<br>1.自身业务或者数据出现问题<br>2.一些恶意攻击、爬虫等造成大量空命中。<br>
解决方案
1.缓存空对象<br>
2.布隆过滤器<br>对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,<br>对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当<b>布隆过滤器说某个值存在<br>时,这个值可能不存在;当它说不存在时,那就肯定不存在.<br></b>布隆过滤器就是<b>一个大型的位数组和几个不一样的无偏hash函数</b>。所谓无偏就是能够把元素的hash值<br>算的比较均匀。向布隆过滤器中添加key时,会使用多个hash函数都会算得一个不同的位置。再把位数组<br>的这几个位置都置为1,就完成了add操作。向布隆过滤器询问key是否存在时,跟add一样,也会把hash<br>的几个位置都算出来,看看位数组中这几个位置是否都为1,只要有一个位为0,那么说明布隆过滤器中这个<br>key不存在。如果都是1,这并不能说明这个key就一定存在,只是极有可能存在,因为这些位置为1可能是<br>因为其他的key存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个<br>概率就会很低。这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,<br>代码维护较为复杂,但是缓存空间占用很少<br>
布隆过滤器使用示例,需要引入Redisson依赖<br>```xml<br><dependency><br> <groupId>org.redisson</groupId><br> <artifactId>redisson</artifactId><br> <version>3.6.5</version><br> </dependency><br>```<br>
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,<br>布隆过滤器缓存过滤伪代码:<br>注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据<br>
缓存失效(击穿)。<br>由于大批量缓存存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库<br>瞬间压力过大甚至挂掉,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为<br>一个时间段内的不同时间。<br>
缓存雪崩。<br>缓存雪崩指的是缓存曾支撑不住或宕掉后,流量会像奔逃的野牛一样,打向后端存储层。由于缓存层<br>承载着大量请求,有效地保护了存储层,到那时如果缓存层由于某些原因不能提供服务(比如超大并发<br>过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发<br>急剧下降),于是大量请求都会打到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。<br>
解决方案。<br>预防和解决缓存雪崩问题,可以从三个方面着手:<br>1.保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster<br>2.依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件<br>比如服务降级,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是<br>非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,<br>而是直接返回预定义的默认降级信息、控制或是错误提示信息;当业务应用访问的是<br>核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过<br>数据库读取<br>3.提前演练。在项目上线前,演练缓存层宕机后,应用以及后端的负载情况以及可能出现的<br>问题,在此基础上做一些预案设定<br>
热点缓存key重建优化。<br>开发人员使用"缓存+过期时间"的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能<br>够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:<br>1.当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大<br>2.重建缓存不能在短时间内完成,可能是一个复杂计算。例如复杂的SQL、多次IO、多个依赖等<br><br>在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。要解决这个<br>问题主要就是要避免大量线程同时重建缓存。<br>我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,<br>重新从缓存获取数据即可。<br>
<b>缓存与数据库双写不一致。</b>
<b>版本1</b>
解决方案:<br>1.对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会<br>发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可<br>2.就算并发很高,如果业务上能容忍短时间内的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上<br>过期时间依然可以解决大部分业务对于缓存的要求<br>3.如果不能容忍缓存数据不一致,可以通过加<b>分布式读写锁</b>保证并发读写或写写的时候按顺序排好队,<b>读读<br>的时候相当于无锁<br></b>4.也可以阿里开源的canal通过监听数据库的binlog日志即时地去修改缓存,但是引入了新地中间件,增加了<br>系统地复杂度<br>
<b>版本2</b>
<b>先更新数据库,还是先更新缓存?</b><br>1.先更新数据库,再更新缓存<br>2.先更新缓存,再更新数据库<br>
1.<b>先更新数据库,再更新缓存</b><br>举个例子,比如【请求A】和【请求B】两个请求,同时更新【同一条】数据,<br>则可能出现图中的顺序:<br>【请求A】先将数据库的数据更新为1,然后在更新缓存前,【请求B】将数据库的数据<br>更新为2,紧接着把缓存更新为2,然后【请求A】更新缓存为1.此时,数据库中的数据<br>是2,而缓存中的数据却是1,出现了缓存和数据库中的数据不一致的现象<br>
2.先更新缓存,再更新数据库。<br>举个例子,【请求A】和【请求B】两个请求,同时更新【同一条】数据,<br>则可能出现这样的顺序:<br>【请求A】先将缓存的数据更新为1,然后在更新数据库前,【请求B】来了,<br>将缓存的数据更新为2,紧接着把把数据库更新为2,然后【请求A】将数据库<br>的数据更新为1.此时,数据库中的数据是1,而缓存中的数据却是2,出现了<br>缓存和数据库中的数据不一致的现象<br>
<b>所以,无论是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,<br>这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现<br>缓存和数据库中的数据不一致的现象</b><br>
Cache Aside(旁路缓存)策略,该策略可以细分为【读策略】和【写策略】<br>写策略的步骤:<br>1.更新数据库中的数据;<br>2.删除缓存中的数据<br><br>读策略的步骤:<br>1.如果读取的数据命中了缓存,则直接返回数据<br>2.如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入<br>到缓存,并且返回给用户<br><br>但是【写策略】中的数据库和缓存操作又有不同的顺序:<br>1.先删除缓存,再更新数据库<br>2.先更新数据库,再删除缓存<br>
<b>1.先删除缓存,再更新数据库。</b><br>举个例子,以用户表的场景来分析。<br>假设某个用户的年龄是20,请求A要更新用户年龄为21,所以它会删除<br>缓存中的内容。这时,另一个请求B要读取这个用户的年龄,它查询缓存<br>发现未命中后,会从数据库中读取到年龄为20,并且写入到缓存中,然后<br>请求A继续更改数据库,将用户的年龄更新为21.<br><br>最终,该用户年龄在缓存中是20(旧值),在数据库中是21(新值),缓存和<br>数据库的数据不一致。<br>可以看到,先删除缓存,再更新数据库,在【读+写】并发的时候,还是会<br>出现缓存和数据库的数据不一致的问题<br>
解决方案:<br>针对【先删除缓存,再更新数据库】方法在【读+写】并发请求<br>而造成缓存不一致的解决办法是【延迟双删】:<br>伪代码示例。加了个睡眠时间,主要是为了确保请求A在睡眠的时候,请求B能够在这一段时间内<br>完成【从数据库读取数据,再把缺失的缓存写入缓存】的操作,然后请求A睡眠完,再删除缓存。<br>所以请求A的睡眠时间就需要大于请求B【从数据库读取数据+写入缓存】的时间。<br>但是具体睡眠多久其实我们是没法准确预估的,需要进行统计,所以这个方案尽可能保证一致性<br>而已,极端情况下,依然也会出现缓存不一致的现象,<br>因此,还是比较建议用【先更新数据库,再删除缓存】的方案<br>
<b>2.先更新数据库,再删除缓存</b><br>继续用【读+写】请求的并发的场景来分析。<br>假如某个用户数据在缓存中不存在,请求A读取读取数据时从数据库中查询到<br>年龄为20,在未写入缓存中时另一个请求B更新数据。它更新数据库中的年龄<br>为21,并且清空缓存。这时请求A把数据库中读到的年龄为20的数据写入到缓存中。<br>最终,该用户年龄在缓存中是20,数据库中是21,缓存和数据库数据不一致。<br>
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,<br><b>但是在实际中,这个问题出现的概率并不高</b>。<b>因为缓存的写入通常要远远快于数据库的<br>写入</b>,所以在实际中很难出现请求B已经更新了数据库并且删除了缓存,请求A才更新完<br>缓存的情况。<br>而一旦请求A早于请求B删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中<br>而从数据库中重新读取数据,所以不会出现这种不一致的情况。<br>所以,【先更新数据库+再删除缓存】的方案,是可以保证数据一致性的,再加上一个<br>【过期时间】,就算在这期间存在缓存数据不一直,有过期时间来兜底,这样也能达到<br>最终一致。<br>
<b>【先更新数据库,再删除缓存】存在的问题:<br></b>前面的分析都是建立再这两个操作都能同时执行成功的情况下,如果在删除缓存(第二个操作)<br>的时候失败了,导致缓存中的数据是旧值,如果没有前面的过期时间兜底的话,后续的请求<br>就会一直是缓存中的就数据<br><br>【先更新数据库,再删除缓存】的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据<br>的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。<br>所以,如果业务对缓存命中率有很高的要求,可以采用【更新数据库+更新缓存】的方案,因为<br>更新缓存并不会出现缓存未命中的情况,但是这个方案,前面提到,在两个更新请求并发执行的<br>时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,我们又没有<br>对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据不一致<br>需要增加一些手段来解决这个问题,有两种做法<br>1.在更新缓存前先加个分布式锁,保证同一时间之运行一个请求更新缓存,就不会产生并发问题了,<br>但是引入锁之后,对于写入性能就会带来影响<br>2.在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快<br>过期,对业务来说也可以接受<br>
<b>如何保证【先更新数据库,再删除缓存】这两个操作能执行成功?<br>举个例子:<br>应用要把数据X的值从1更新为2,先成功更新了数据库,然后在Redis缓存<br>中删除X的缓存,但是这个操作却失败了,这个时候数据库中的X的新值为2,<br>Redis中的X的缓存值为1,出现了数据库和缓存数据不一致的问题。<br>那么后续有访问数据X的请求,会先在Redis中查询,因为缓存中并没有删除,<br>所以缓存命中,但是读到的却是旧值1.其实不管先操作数据库,还是先操作缓存,<br>只要第二个操作失败都会出现数据不一致的问题,解决方案有两种:<br></b><b>1.重试机制<br>2.订阅MySQL binlog,再操作缓存<br></b>
1.重试机制。<br>我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,<br>由消费者来操作数据。<br>1.1 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,<br>这个就是重试机制。当然,如果重试超过一定的次数,还是没有成功,就需要向<br>业务层发送报错消息了<br>1.2 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试<br>
2.订阅MySQL binlog,再操作缓存<br>【先更新数据库,再删除缓存】的策略第一步是更新数据库,那么更新数据库成功,就会<br>产生一条变更日志,记录在binlog里。于是我们就可以通过订阅binlog日志,拿到具体要<br>操作的数据,然后再执行缓存删除,阿里开源的Cannal中间件就是基于这个实现的。<br><br>Cannal模拟MySQL主从复制的交互协议,把自己伪装成一个MySQL的从节点,向MySQL<br>主节点发送dump请求,MySQL收到请求后,就会开始推送binlog给Cannal,Cannal解析<br>binlog字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用.<br>
所以如果要想保证【先更新数据库,再删除缓存】策略第二个操作能执行成功,我们可以使用<br>【消息队列来重试缓存的删除】,或者【订阅MySQL binlog再操作缓存】,这两种方法有一个<br>共同的特点,都是采用异步操作缓存<br>
<b>为什么是删除缓存,而不是更新缓存?<br>删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。在实际业务中,<br>缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。<br>比如商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格<br>字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,<br>这个操作会非常耗时。从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的<br>缓存可能会长事件不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除<br>缓存,等到下次查询命中再填充缓存,是一个更好的方案。<br>系统设计中有一个设计叫Lazy Loading,适用于那些加载代价大的操作,删除缓存而不是<br>更新缓存,就是懒加载思想的一个应用<br></b>
总结:<br>以上我们针对地都是<b>读多写少</b>的情况加入缓存提高性能,如果<b>写多读多</b>的情况又不能容忍缓存数据<br>不一致,那就没必要加缓存了,可以直接操作数据库。当然,如果数据库扛不住压力,还可以把缓存<br>作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。<br><br>放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证<br>绝对的一致性做大量的过度设计和控制,增加系统复杂性<br>
Redis分布式锁
Redisson加分布式锁机制
RedisCluster集群
Redis集群方案比较
哨兵模式。<br>在Redis3.0以前的版本要实现集群一般是借助哨兵sentinel工具来监控master节点的状态,<br>如果master节点异常,则会做主从切换,将某一台slave作为master,哨兵的配置略微复杂,<br>并且性能和高可用等各方面表现一般,特别是在主从切换的瞬间存在访问瞬断的情况,而且<br>哨兵模式只有一个主节点对外提供服务,没法支持很高的并发,且单个主节点内存也不宜<br>设置得过大,否则会导致持久化文件过大,影响数据恢复或主从同步的效率<br>
哨兵leader选举流程。<br>当一个master服务器被某sentinel视为下线状态后,该sentinel会与其他sentinel协商选出sentinel的leader<br>进行故障转移工作。每个发现master服务器进入下线的sentinel都可以要求其他sentitnel选自己为sentinel的<br>leader,选举是先到先得。同时每个sentinel每次选举都会自增配置纪元(选举周期),每个纪元中只会选择一个<br>sentinel的leader,如果所有超过一般的sentitnel选举某sentinel作为leader,之后该sentinel进行故障转移<br>操作,从存活的slave中选举出新的master,这个选举过程跟集群的master选举很类似。<br>哨兵集群只有一个哨兵节点,redis的主从也能正常运行以及选举master,如果master挂了,那唯一的那个哨兵<br>就是哨兵leader了,可以正常选举新master。不过为了高可用一般都推荐至少部署三个哨兵节点。<br>
高可用集群模式。<br>redis集群是一个由多个主从节点组成的分布式服务器集群,它具有复制、高可用和分片特性。<br>Redis集群不需要sentinel哨兵,也能完成节点移除和故障转移的功能。需要将每隔节点设置<br>成模式,这种集群模式没有中心节点,可水平扩展,根据官方文档称可以线性扩展到上万个<br>节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,<br>且集群配置非常简单。Redis集群至少需要3个master<br>
Redis集群原理分析。<br>RedisCluster将所有数据划分为16384个slots(槽位),每隔节点负责其中一部分槽位。<br>槽位的信息存储于每个节点中,当Redis Cluster的客户端来连接集群时,它也会得到<br>一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端查找某个key时,<br>可以直接定位到目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的<br>情况,还需要纠正机制来实现槽位信息的校验调整<br>
<b>为什么槽位只有16384个?</b>
CRC16的算法原理。<br>1.根据CRC16的标准选择初值CRCIn的值<br>2.将数据的第一个字节与CRCIn高8位异或<br>3.判断最高位,若该位为0左移一位,若为1左移一位再与多项式Hex码异或<br>4.重复3至9位全部移位计算结束<br>5.重复将所有输入数据操作完成以上步骤,所得16位数即16位CRC校验码<br>
CRC16算法最大值。<br>CRC16算法,产生的哈希值有16bit位,可以产生65535(2^16)个值,也就是说<br>值分布在0~65535之间,<b>这个时候疑问就来了,槽位总数为什么是16384?65536不可以吗?<br></b><br>Antirez(Redis作者)大神做了回复,归纳起来就是:<br>1.正常的心跳数据包携带节点携带节点的完整配置,它能以幂等方式来更新配置,如果采用16384<br>个插槽,占用空间为2KB(16384 / 8 / 1024 = 2KB),如果采用65536个插槽,占用空间8KB<br>(65536 / 8 / 1024=8KB)<br>2.Redis Cluster不太可能扩展到超过1000个主节点,太多可能导致网络拥堵<br>3.16384个插槽范围比较合适,当集群扩展到1000个节点时,也能确保每个master节点有足够的插槽<br>
8KB的心跳包看似不大,但是这个是心跳包每秒都要将本节点的信息同步给其他集群节点。<br>比起16384个插槽,头大小增加了4倍,ping消息的消息头太大了,浪费带宽。<br><br>Redis主节点的哈希槽配置信息是通过bitmap来保存的,也就是位数组,元素的值为0或1.<br>在传输过程中,会对bigmap进行压缩,bitmap的填充率越低,压缩率越高。<br><b>bitmap填充率 = slots / N(N表示节点数)</b><br>所以插槽数偏低的话,填充率就会降低,压缩率会升高<br>综合下来,从心跳包的大小、网络带宽、心跳并发、压缩率等维度考虑,16384个插槽更有<br>优势且能满足业务需求<br>
<b>为什么bitmap填充率越低,压缩率就越高?</b><br>在Redis中,对bit数组进行压缩时,压缩率与填充的数(或者说是1的数量)的关系是成反比的,<br>因为在压缩过程中,Redis使用的是基于运行长度编码(Run-Length-Encoding,RLE)的压缩算法。<br>RLE是一种基本的压缩算法,它通过识别重复出现的连续数据来减少存储空间。如果数据中存在<br>大量的连续重复字符,RLE算法的随机效果会非常好,反之,如果数据中的字符分布较为随机,<br>没有出现太多连续的重复字符,那么RLE的压缩效果就不明显,甚至可能使数据变大<br>
RLE算法示例。<br>```c<br>AAABBBCCDDEEEEEFF<br>```<br>按照RLE算法进行压缩:<br>1.扫描到连续的3个A,记录为(A,3)<br>2.接下来是连续的3个B,记录为(B,3)<br>3.然后是2个C,记录为(C,2)<br>4.接着是2个D,记录为(D,2)<br>5.然后是4个E,记录为(E,4)<br>6.最后是3个F,记录为(F,4)<br><br>压缩后的数据为:<br>```c<br>(A,3)(B,3)(C,2)(D,2)(E,4)(F,3)<br>```<br>
master节点间心跳数据包格式:<br>消息格式分为:消息头+消息体。消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取<br>到发送节点的相关数据<br>相关代码在src/cluster.h文件中以5.0版本为例,如代码所示,消息头中有一个myslots的char类型数组,<br><b>unsinged char myslots[CLUSTER_SLOTES/8];</b><font color="#000000">数组长度为16384/8=2048.底层存储其实是一个<br></font>bitmap,每一位代表一个插槽,如果该位为1,表示这个插槽是属于这个节点的。<br>消息体中,会携带一定数量的其他节点信息用于交换,约为集群总节点数量的1/10,节点数量越多,<br>消息体内容越大。10个节点的消息体大小约为1kb,char 在C语言中占用一个字节<br>
master节点间心跳通讯。<br>Redis集群采用Gossip(流言)协议,Gossip协议工作原理就是节点彼此不断通信交换信息,一段时间后所有<br>的节点都会知道集群完整的信息,类似流言传播<br><br>具体规则如下:<br>1.每秒会随机选取5个节点,找出最久没有通信的节点发送ping消息<br>2.每隔100ms都会扫描本地节点列表,如果发现节点最近一次接收pong消息的时间大于cluster-node-timeout/2,<br>则立即发送ping消息<br>
槽位定位算法。<br>Cluster默认会对key值使用CRC16算法进行hash得到一个整数值,然后用这个整数值对<br>16384进行取模来得到具体槽位。<br>HASH_SLOT = CRC16(Key) mod 16384<br>
跳转重定向。<br>当客户端向一个错误的节点发出了指令,该节点会发现指令的key所在的槽位并不归自己<br>管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端<br>去连这个节点获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新<br>纠正本地的槽位映射表缓存,后续所有key将使用新的槽位映射表<br>
Redis集群节点间的通信机制。<br>redis cluster节点间采取<font color="#e74f4c">Gossip协议</font>进行通信,维护集群的元数据(集群节点信息,主从角色,<br>节点数量,个节点共享的数据等)有两种方式:集中式和Gossip<br><br>Goosip协议的优点在于元数据的更新比较分散,不是集中在一个地方,更新请求回陆陆续续,<br>达到所有节点上去更新,有一定的延时,降低了压力,缺点在于元数据更新有延时可能导致<br>集群的一些操作会有一些滞后。<br>
集中式。<br>优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即回更新到<br>集中式的存储中,其他节点读取的时候立即就可以感知到;不足在于所有的元数据<br>的更新压力全部集中在一个地方,导致元数据的存储压力。很多中间件都会借助<br>Zookeeper集中式存储元数据<br>
Gossip:gossip协议包含多种消息,包括ping,pong,meet,fail等等。<br>1.meet:某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会<br>开始与其他节点进行通信;<br>2.ping:每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的<br>集群元数据,互相通过ping交换元数据(类似自己感知到的集群节点增加和移除,hash slot信息等)<br>3.pong:对ping和meet消息的返回,包含自己的状态和其他信息,也可以用于信息广播和更新<br>4.fail:某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了<br>
Gossip通信的10000端口。<br>每个节点都有一个专门用于节点间Gossip通信的端口,就是自己提供服务的端口号+10000,<br>比如7001,那么用于节点间通信的就是17001端口,每个节点每隔一段时间都会往另外几个<br>节点(<b>每秒随机抽取5个节点,找出最久没有通信的节点</b>)发送ping消息,<br>同时其他节点接收到ping消息之后返回pong消息<br>
网络抖动。<br>真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络<br>抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。<br>为解决这种问题,Redis Cluster提供了一种选项<font color="#e74f4c">cluster-node-timeout</font><font color="#000000">,表示当某个节点持续<br></font>timeout的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,<br>网络抖动回导致主从频繁切换(数据的重新复制)。<br>
Redis集群选举原理分析。<br>当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的<br>master可能会有多个slave,从而存在多个slave竞争成为master节点的过程,其过程如下:<br>1.slave发现自己的master变为FAIL<br>2.将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST信息<br>3.其他节点收到该消息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对<br>每一个epoch只发送一次ACK<br>4.尝试failover的slave收集master返回的FAILOVER_AUTH_ACK<br>5.slave收到<font color="#e74f4c">超过半数的master的ack</font>后变成新Master(这里解释了集群为什么至少需要三个主节点,如果<br>只有两个,当其中一个挂了,只剩一个主节点是不能选举成功的)<br>6.slave广播Pong消息通知其他集群节点<br><br>从节点并不是在主节点一进入FAIL状态就马上尝试发起选举的,而是有一定延迟,一定的延迟确保我们<br>等待FAIL状态在集群中传播,slave如果立即尝试选举,其他master或许尚未意识到FAIL状态,可能会<br>拒绝投票<br>延迟计算公式<br><font color="#e74f4c">DELAY= 500ms + random(0~500ms) + SLAVE_RANK * 1000ms</font><br>SLAVE_RANK表示此slave已经从master复制数据的总量的rank.Rank越小代表已复制的数据越新。这种<br>方式下,持有最新数据的slave将会首先发起选举(理论上)<br>
集群脑裂数据丢失问题。<br>Redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个主节点对外提供写服务,一旦网络分区恢复,<br>会将其中一个主节点变为从节点,这时会有大量数据丢失。<br>规避方法可以在Redis配置里加上参数(这种方法不可能百分百避免数据丢失)<br>```c<br>min-replicas-to-write 1//写数据成功最少同步的slave数量,这个数量可以模仿大于半数机制配置,比如<br>集群总共三个节点可以配置1,加上leader就是2,超过了半数<br>```<br>注意:这个配置在一定程度上会影响集群的可用性,比如slave要是少于1个,这个集群就算leader正常也不能提供<br>服务了,需要具体场景权衡选择<br>
集群是否完整才能对外提供服务。<br>当redis.conf配置的cluster-require-full-coverage为no时,表示当负责一个插槽的主库下线且没有相应的从库<br>进行故障恢复时,集群仍然可用,如果为yes则集群不可用<br>
Redis集群为什么至少需要三个master节点,并且推荐节点数为奇数?<br>因为新master的选举需要大于半数的集群master节点同一才能选举成功,如果只有两个master节点,当其中一个<br>挂了,是达不到选举新master的条件的。奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如<br>三个master节点和四个master节点的集群相比,大家如果都挂了一个master节点都能选举新master节点,如果挂了<br>两个master节点都没法选举新master节点了,所以奇数的master节点更多的是从节省机器资源角度出发说的<br>
Redis集群对批量操作命令的支持。<br>对于类似mset,mget这样的多个key的原生批量操作命令,redis集群只支持所有key落在同一slot的情况,如果<br>有多个key一定要用mset命令在Redis集群上操作,则可以在key的前面加上{XX},这样参数数据分片hash计算的<br>只会是大括号里的值,这样能确保不同的key能落到同一slot里去,示例如下<br>```c<br>mset {user1}:1:name cover {user1}:1:age 18<br>```<br>假设name和age计算的hash slot值不一样,但是这条命令在集群下执行,redis只会用大括号里的user1做hash slot<br>计算,所以算出来的slot值肯定相同,最后都能落在同一slot.<br>
Redis主从架构
架构图。<br>
Redis主从工作原理。<br>如果为master配置了一个slave,不管这个slave是否是第一次连接上Master,它都会发送一个PSYNC命令<br>给master请求复制数据。master受到PSYNC命令,会在后台进行数据持久化通过bgsave生成最新的<br>RDB快照文件,持久化期间,master会继续接收客户端的请求,它会把这些可能修改数据集的请求缓存<br>在内存中,当持久化进行完毕以后,master会把这份RDB文件数据即发送给slave,slave会把接收到的<br>数据进行持久化生成RDB,然后再加载到内存中。然后master再将之前缓存在内存中的命令发送给slave/<br>当master与slave之间的连接由于某些原因断开时,slave能够自动重连Master,如果master收到了多个<br>slave并发连接请求,它只会进行一次持久化,而不是一个连接一次,然后再把这一份持久化的数据发送<br>给多个并发连接的slave.<br>
主从复制(全量复制)流程图:
数据部分复制。<br>当master和slave断开重连后,一般都会对整份数据进行复制。但从Redis2.8版本开始,<br>Redis改用可以支持部分数据复制的命令PSYNC去master同步数据,slave与master能够在<br>网络断开重连后只进行部分数据复制(断点续传).master会在其内存中创建一个复制数据用的<br>缓存队列,缓存最近一段时间的数,master和它所有的slave都维护了复制的数据下表offset<br>和master的进程id,因此,当网络连接断开后,slave会请求master继续进行未完成的复制,<br>从所记录的数据下标开始。如果master进程id变化了,后者从节点数据下标offset太旧,<br>已经不在master的缓存队列里了,那么将会进行一次全量数据的复制<br>
如果有很多从节点,为了缓解主从复制风暴(多个从节点同时复制主节点导致主节点压力<br>过大),可以做如下架构
Redis哨兵高可用
Redis哨兵高可用架构。<br>sentinel哨兵是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点。<br>哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点<br>不会每次都通过sentinel代理访问redis的主节点,当redis的主节点发生变化,哨兵<br>会第一时间感知到,并将新的redis主节点通知给client端(redis的client端一般都实现<br>了订阅功能,订阅sentinel发布的节点变动消息)<br>
redis哨兵搭建步骤:<br>1.sentinel集群都启动完毕后,会将烧饼集群的元数据信息写入所有sentinel的配置文件里去<br>(追加在文件的最下面)<br>2.当Redis主节点如果挂了,哨兵集群会重新选举出新的redis主节点,同时会修改所有sentinel<br>节点配置文件的集群元数据信息,比如6379的redis如果挂了,假设选举出的新主节点是6380,<br>则sentinel文件里的集群元数据信息会变成如下所示:<br>```c<br>sentinel known-replica mymaster 192.168.0.60 6379 #代表主节点的从节点信息<br>sentinel known-replica mymaster 192.168.0.60 6381 #代表主节点的从节点信息<br>```<br>3.同时还会修改sentinel文件里之前配置的mymaster对应的6379端口,改为6380<br>```c<br>sentinel monitor mymaster 192.168.6380 2<br>```<br>4.当6379的redis实例再次启动时,哨兵集群根据元数据信息就可以将6379端口的redis节点作为<br>从节点加入集群<br>
亿级流量电商网站微服务架构<br><div></div>
1.LVS是Linux virtual server的缩写,为linux虚拟服务器,是一个虚拟的服务器集群系统。<br>LVS简单工作原理为用户请求LVS VIP,LVS根据转发方式和算法,将请求转发给后端服务器,<br>后端服务器接收到请求,返回给用户。对于用户来说,看不到Web后端具体的应用。
数据结构与对象
数据结构
简单动态字符串
概述。<br>Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,简称C字符串),<br>而是自己构建了一种名为简单动态字符串(Simple Dynamic String, SDS)的后向类型,<br>并将SDS用作Redis的默认字符串表示。在Redis里面C字符串只会作为字符串字面量<br>(string literal)用在一些无须对字符串值进行修改的地方。比如打印日志。<br><br>当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串时,Redis就会<br>使用SDS来表示字符串值,比如在Redis的数据库里面,包含字符串值的键值对在底层都是<br>由SDS来实现的<br><br>除了用来保存数据库中的字符串值之外,SDS还被用作缓冲区(buffer):AOF模块中的AOF<br>缓冲区、以及客户端状态中的输入缓冲区,都是由SDS实现的<br>
定义。<br>每个sds.h/sdshdr结构表示一个SDS值如图.<br><br>SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,<br>并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数<br>自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例<br>的好处是,SDS可以直接重用一部分C字符串函数库里的函数,例如printf函数,printf("%s", s->buf)<br>来打印SDS保存的字符串值"Redis",而无需为SDS编写专门的打印函数<br>
1.free属性的值为0,表示这个SDS没有分配任何未使用空间<br>2.len属性的值为5,表示这个SDS保存了一个5字节长的字符串<br>3.buf属性是一个char类型的数组,数组的前5个字节分为保存了<br>'R'、'e'、'd'、'i'、's'五个字符,最后一个字节则保存了空字符'\0'<br>
这个SDS和上面的SDS的区别在于,这个SDS为buf数组分配了5字节未使用空间,<br>所以它的free属性的值为5
SDS与C字符串的区别。<br>根据传统,C语言使用长度为N+1的字符串数组来表示长度为N的字符串,并且字符数组的<br>最后一个元素总是空字符'\0',如图所示。<br>C语言使用的这种简单的字符串表示方式,并不能满足Redis对字符串在安全性、效率性以及<br>功能方面的要求<br>
1.常数复杂度获取字符串长度。<br>因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序<br>必须遍历整个字符串,对遇到的每个字符进行计数,知道遇到代表字符串结尾的<br>空字符为止,这个操作的复杂度为O(N)。<br>和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS<br>长度的复杂读仅为O(1).设置和更新SDS长度的工作是由SDS的API在执行时自动完成,<br>使用SDS无须进行任何手动修改长度的工作,通过SDS而不是C字符串,Redis将获取<br>字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会<br>成为Redis的性能瓶颈。例如,因为字符串键在底层使用SDS来实现,所以即使我们对<br>一个非常长的字符串键反复执行STRLEN命令,也不会对系统性能造成任何影响,因为<br>STRLEN命令的复杂度仅为O(1)<br>
2.杜绝缓冲区溢出。<br>除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题是<br>容易造成缓冲区溢出(buffer overflow).例如<string.h>/strcat函数可以将src字符串<br>中的内容拼接到dest字符串的末尾:<br>char *strcat(char *dest,const char *src);<br>因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest<br>分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,<br>就会产生缓冲区溢出。<br>与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API<br>需要对SDS进行修改时,API会先检查SDS的空间是否满足所需的要求,如果不满足的<br>话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,<br>所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题<br>
例如,假设程序里有两个在内存中紧邻着的C字符串s1和s2,<br>其中s1保存了字符串"Redis",而s2则保存了字符串"MongoDB",如图所示
如果一个程序决定通过执行:<br>strcat(s1, " Cluster");<br>将S1的内容修改为"Redis Cluster",但粗心地却忘了在执行strcat之前为s1<br>分配足够的空间,那么在strcat函数执行之后,S1的数据将溢出到了S2所在<br>的空间中,导致S2保存的内容被意外地修改,如图所示<br>
举个例子,SDS的API里面也有一个用于执行拼接操作的sdscat函数,它可以<br>将一个C字符串拼接到给定SDS所保存的字符串的后面,但是在执行拼接操作之前,<br>sdscat会先检查给定SDS的空间是否足够,如果不够的话,sdscat就会先扩展SDS的<br>空间,然后才执行拼接操作,例如执行,sdscat(s, " Cluster"),SDS值如图所示,<br>那么sdscat将在执行拼接操作之前检查s的长度是否足够,在发现s目前的空间不足以<br>拼接" Cluster"之后,sdscat就会先扩展s的空间,然后再执行拼接" Cluster"的操作,<br>
拼接之后的SDS结构如图.<br>注意,sdscat不仅对这个SDS进行了拼接操作,它还为SDS分配了13字节的未使用空间,<br>并且拼接之后的字符串也正好是13字节长,这种现象既不是bug也不是巧合,它和SDS的<br>空间分配策略有关<br>
3.减少修改字符串时带来的内存重分配次数。<br>因为C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,<br>这个C字符串的底层实现总是一个N+1个字符长的数组(额外的一个字符空间用于保<br>存空字符)。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次<br>增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重<br>分配操作:<br>1.如果程序执行的是增长字符串的操作,比如拼接操作(append),那么再执行这个操作<br>之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就<br>会产生缓冲区溢出<br>2.如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,<br>程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就<br>会产生内存泄漏<br>
例如,如果我们持有一个值为"Redis"的C字符串s,那么为了将s的值改为"Redis Cluster",<br>在执行:strcat(s," Cluster");之前,我们需要先使用内存重分配操作,扩展s的空间,之后,<br>如果我们又打算将s的值从"Redis Cluster"改为"Redis Cluster Tutorial",那么再执行:<br>strcat(s, " Tutorial");之前,我们需要再次使用内存重分配扩展s的空间,注入此类。<br>因为内存重分配设计复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:<br>1.在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配<br>也是可以接受的<br>2.但是Redis作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改<br>字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改<br>字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响<br>
为了避免C字符串的这种缺陷,SDS通过未使用空间接触了字符串长度和底层数组长度<br>之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含<br>未使用的字节,而这些字节的数量就由SDS的free属性记录.<br>通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略<br>
空间预分配。<br>空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,<br>并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的<br>空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间数量<br>由以下公式决定:<br>1.如果对SDS进行修改之后,SDS的长度(也即是len属性的长度)将小于1MB,<br>那么程序分配和len属性同样大小的未使用空间,这时SDSlen属性的值将和free<br>属性的值相同。<br>2.如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB<br>的未使用空间。<br><br>通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重<br>分配次数。<br>
例如,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的<br>未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节<br>用于保存空字符)<br>
例如,入宫进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未<br>使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte<br>
例如,对于如图所示的SDS值s来说,如果执行sdscat(s, " Cluster"),<br>那么sdscat将执行一次内存重分配操作,将SDS的长度修改为13字节,<br>并将SDS的未使用空间同样修改为13字节<br>
如果这时,我们再对s执行:sdscat(s, " Tutorial");那么这次sdscat将不需要执行内存重分配,<br>因为未使用空间里面的13字节足以保存9字节的" Tutorial",执行sdscat之后的SDS,如下图所示<br>
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,<br>API就会直接使用未使用空间,而无须执行内存重分配。<br>通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定<br>N次降低为最多N次<br>
惰性空间释放。<br>惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存<br>的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是<br>使用free属性将这些字节的数量记录起来,并等待将来使用<br>
例如,sdstrim函数接受一个SDS和一个C字符串作为参数,从SDS左右两端分别移除所有<br>在C字符串中出现过的字符.比如,对如图所示的SDS值s来说,执行<br>sdstrim(s,"XY")//移除SDS字符串中的所有"X"和"Y",会将SDS修改成如下图<br>
注意执行sdstrim之后的SDS并没有释放多出来的8字节空间,而是将这些8字节空间作为<br>未使用空间保留在了SDS里面,如果将来要对SDS进行增长操作的话,这些未使用空间<br>就可能派上用场。<br>
例如,现在对s执行sdscat(s," Redis");那么完成这次sdscat操作将不需要执行内存重<br>分配:因为SDS里面预留的8字节空间已经足以拼接6个字节常的" Redis".<br>通过惰性空间释放策略,SDS避免了缩短字符串所需的内存重分配操作,并为将来可能<br>有的增长操作提供了优化,与此同时,SDS也提供了相应的API,让我们可以在有需要时<br>真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费<br>
4.二进制安全。<br>C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里<br>面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制<br>使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的<br>二进制数据。<br>虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,<br>因此,为了确保Redis可以适用于各种不同的场景,SDS的API都是二进制安全的(binary-safe),<br>所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对<br>其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是<br>什么样的。这也是将SDS的buf属性称为字节数组的原因——Redis不是用这个数组来保存<br>字符,而是用它来保存一系列二进制数据.<br><br>通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以<br>保存任意格式的二进制数据<br>
举个例子,如果有一种使用空字符来分割多个单词的特殊数据格式,<br>如图所示,那么这种格式就不能使用C字符串来保存,因为C字符串所用<br>的函数只会识别出其中的"Redis",而忽略之后的"Cluster"<br>
例如,使用SDS来保存之前提到的特殊数据格式就没有任何问题,因为<br>SDS使用len属性的值而不是空字符来判断字符串是否结束,如图所示<br>
5.兼容部分C字符串函数。<br>虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:<br>这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时<br>多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<br><string.h>库定义的函数<br>
举个例子,如图所示,如果有一个保存文本数据的SDS值sds,那么我们就可以重用<br><string.h>/strcasecmp函数,使用它来对比SDS保存的字符串和另一个C字符串:<br>strcasecmp(sds->buf, "hello world");<br>这样Redis就不用自己专门去写一个函数来对比SDS值和C字符串值了。<br>与此类似,还可以将一个保存文本数据的SDS作为strcat函数的第二个参数,将<br>SDS保存的字符串追加到一个C字符串的后面:<br>strcat(c_string, sds->buf);<br>这样Redis就不用专门编写一个将SDS字符串追加到C字符串之后的函数了。<br>通过遵循C字符串以空字符结尾的惯例,SDS可以在有需要时重用<string.h>函数库,<br>从而避免了不必要的代码重复<br>
6.总结。<br>
链表
概述。<br>链表提供了高效的节点重拍能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地<br>调整链表的长度。作为一种常用的数据结构,链表内置在很多高级的变成语言里面,因为Redis<br>使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。链表在Redis中的应用<br>非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者<br>列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。<br><br>除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用<br>链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)<br>
举个例子,以下展示的integers列表键包含了从1到1024共1024个整数:<br>integers列表键的底层实现就是一个链表,链表中的每个节点都保存了一个整数值。<br>
链表和链表节点的实现。<br>每个链表节点使用一个adlist.h/listNode结构来表示:<br>
多个listNode可以通过prev和next指针组成双端链表,如图
虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/list来持有链表的话,<br>操纵起来会更方便。list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,<br>而dup、free和match成员则是用于实现堕胎链表所需的类型特定函数:<br>1.dup函数用于复制链表节点所保存的值<br>2.free函数用于释放链表节点所保存的值<br>3.match函数则用于对比链表节点所保存的值和另一个输入值是否相等<br>
如图是由一个list结构和三个listNode结构组成的链表
Redis的链表实现的特性可以总结如下:<br>1.双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)<br>2.无环:表头节点的prev指针和表尾节点next指针都指向NULL,对链表的访问以NULL为终点<br>3.带有表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和<br>表尾节点的复杂度为O(1)<br>4.带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取<br>链表中节点数量的复杂度为O(1)<br>5.多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match<br>三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值<br>
字典
概述。<br>字典又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种<br>保存键值对(key-value pair)的抽象数据结构,在字典中,一个键(key)可以和一个值(value)<br>进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。字典中的每个键都是<br>独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者<br>根据键来删除整个键值对等等<br><br>字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有<br>内置这种数据结构,因此Redis构建了自己的字典实现。字典在Redis中的应用相当广泛,<br>比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查操作也是构建<br>在对字典的操作之上的。<br><br>除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对<br>比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键<br>的底层实现。<br><br>除了用来实现数据库和哈希键之外,Redis的不少功能也用到了字典<br>
举个例子,当我们执行代码中的命令之后,会在数据库中创建一个键为"msg",值为"hello world"<br>的键值对,这个键值对就是保存在代表数据库的字典里面的。<br><br>
举个例子,website是一个包含3个键值对的哈希键,这个哈希键的键都是一些数据库的名字,<br>而键的值就是数据库的主页网址:如代码所示。website键的底层实现就是一个字典,字典中包含了<br>3个键值对,例如:Redis-Redis.io、MariaDB-MariaDB.org、MongoDB-MongoDB.org<br>
字典的实现。<br>Redis的字典使用哈希表作为子层实现,一个哈希表里面可以有多个哈希表节点,而每个<br>哈希表节点就保存了字典中的一个键值对<br>
哈希表。<br>Redis字典所使用的哈希表由dict.h/dictht结构定义:如代码所示<br>table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry<br>结构保存着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录<br>了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定<br>一个键应该被放到table数组的哪个索引上面。<br>
这是一个大小为4的空哈希表(没有包含任何键值对)
哈希表节点。<br>哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对,如代码所示.<br>key属性保存着键值对中的键,而v属性保存着键值对中的值,其中键值对的值可以是一个指针,<br>或者是一个uint64_t整数,又或者是一个int64_t整数。<br>next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,<br>以此来解决键冲突(collision)的问题<br>
举个例子,该图就是通过next指针,将两个索引值相同的键K1和K0连接在一起的
字典。<br>Redis中的字典由dict.h/dict结构表示:如代码所示。<br>type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:<br>1.type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对<br>的函数,Redis会为用途不同的字典设置不同的类型特定函数。<br>2.而privdata属性则保存了需要传给那些类型特定函数的可选参数。<br>
ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只<br>使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。<br>除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果<br>目前没有在进行rehash,那么它的值为-1.<br>
展示了一个普通状态下(没有进行rehash)的字典
哈希算法。<br>当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据<br>索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。<br>Redis计算哈希值和索引值的方法如下:<br>#使用字典设置的哈希函数,计算键key的哈希值<br>hash = dict->type->hashFunction(key);<br># 使用哈希表的sizemask属性和哈希值,计算出索引值<br># 根据情况不同,ht[x]可以是ht[0]或者ht[1]<br>index = hash & dict->ht[x].sizemask;<br><br>当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的<br>哈希值的,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且<br>算法的计算速度也非常快。<br>
举个例子,如果要将一个键值对K0和V0添加到字典里面,<br>那么程序会使用语句<br>hash = dict->type->hashFunction(k0);<br>计算k0的哈希值.假设计算得出的哈希值为8,那么程序会继续使用语句:<br>index = hash & dict->ht[0].sizemask = 8 & 3 = 0;<br>计算出键K0的索引值0,这表示包含键值对K0和V0的节点应该被放置到<br>哈希表数组真的索引0位置上,如图所示<br>
解决键冲突。<br>当有两个或以上数量的键被分配到了一个哈希表数组的同一个索引上面,我们称这些键发生了冲突(collision)。<br>Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表<br>节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就<br>解决了键冲突的问题。<br>
举个例子,假设程序要将键值对K2和V2添加到图中的哈希表中,<br>并且计算得出K2的索引值为2,那么K1和K2将产生冲突,而解决<br>冲突的办法就是使用next指针将键K2和K1所在的节点连接起来<br>
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了<br>速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),<br>排在其他已有节点的前面<br>
rehash。<br>随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持<br>在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展<br>或者收缩。扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash<br>的步骤如下:<br>1.为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量<br>(也即是ht[0].used属性的值):<br>1.1 如果执行的是扩展操纵,那么ht[1]的大小为第一个大于等于ht[0].used * 2的2 ^ n(2的n次幂)<br>1.2 如果执行的收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2^n<br>2.将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置<br>到ht[1]哈希表的指定位置上。<br>3.当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建<br>一个空白哈希表,为下一次rehash做准备。<br>
举个例子,假设程序要对图中字典的ht[0]进行扩展操作,<br>程序将执行如下步骤
1.ht[0].used当前的值为4,4 * 2 = 8,而8(2^3)恰好是第一个大于等于4的2的n次方,<br>所以程序会将ht[1]哈希表的大小设置为8.<br>
2.将ht[0]包含的四个键值对都rehas到ht[1],如图所示
3.释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如图所示。<br>至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4改为了<br>现在的8<br>
哈希表的扩展与收缩。<br>当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:<br>1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1<br>2.服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5<br>其中哈希表的负载因子可以通过公式:<br>#负载因子 = 哈希表已保存节点数量 / 哈希表大小<br>load_factor = ht[0].used / ht[0].size<br><br>根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不<br>相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程<br>的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在<br>子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间<br>进行哈希表扩展操作,这可以避免不必要地内存写入操作,最大限度地节约内存。<br>另一方面,当哈希表地负载因子小于0.1时,程序自动开始对哈希表执行收缩操作<br>
例如,对于一个大小为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为<br>load_factor = 4 / 4 = 1;<br>
例如,对于一个大小为512,包含256个键值对的哈希表来说,这个哈希表的负载因子是:<br>load_factor = 256 / 512 = 0.5<br>
渐进式rehash。<br>扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面,但是,这个rehash动作并不是一次性、<br>集中式地完成,而是分多次、渐进式地完成的。这样做的原因在于,如果ht[0]里只保存着四个键值对,那么<br>服务器可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,<br>而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量<br>可能会导致服务器在一段时间内停止服务。因此为了避免rehash对服务器性能造成影响,服务器不是一次性<br>将ht[0]里面地所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面地键值对慢慢地rehash到ht[1]<br><br>哈希表渐进式rehash的详细步骤:<br>1.为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表<br>2.在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始<br>3.在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,<br>还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将<br>rehashidx属性的值增一<br>4.随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx<br>属性的至设为-1,表示rehash操作已完成。<br><br>渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对每个添加、删除、查找、<br>更新操作上,从而避免了集中式rehash而带来的庞大计算量<br><br>渐进式rehash执行期间的哈希表操作<br>因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典<br>的删除、查找、更新等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会在ht[0]里面进行<br>查找,如果没有找到的话,就会继续到ht[1]里面进行查找,诸如此类。<br>另外,在渐进式rehash执行操作期间,新添加到的字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何<br>添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表<br>
跳跃表
概述。<br>跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而<br>达到快速访问节点的目的。<br>跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。<br>在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为<br>简单,所以有不少程序都是用跳跃表来代替平衡树。Redis使用跳跃表作为有序集合键的底层实现之一,<br>如果有一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串<br>时,Redis就会使用跳跃表来作为有序集合键的底层实现。<br><br>和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现<br>有序集合键,另一个是在集群节点中用作内部数据结构,除此之外,跳跃表在Redis里面没有其他用途。<br>
举个例子,fruit-price 是一个有序集合键,这个有序集合以水果名为成员,水果价钱为分值,<br>保存了130款水果的价钱。fruit-price有序集合的所有数据都保存在一个跳跃表里面,其中每个<br>跳跃表节点(node)都保存了一款水果的价钱信息,所有水果按价钱的高低从低到高在跳跃表里面<br>排序。<br>
跳跃表的实现。<br>Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于<br>表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头<br>节点和表尾节点的指针等等。<br>
举个例子,图中展示了一个跳跃表示例,位于图片最左边的是zskiplist结构,该结构包含以下属性:<br>1.header指向跳跃表的表头节点<br>2.tail指向跳跃表的表尾节点<br>3.level记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)<br>4.length记录跳跃表的长度,也即是,跳跃表目前包含的节点数量(表头节点不计算在内)<br>位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:<br>1.层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,依此类推。<br>每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了<br>前进指针所指向节点和当前节点的距离。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字<br>就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行<br>2.后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退<br>指针在程序从表尾向表头遍历时使用<br>3.分值(score):各个节点中的1.0、2.0、3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值<br>从小到大排列<br>4.成员对象(obj):各个节点中的o1、o2、o3是节点所保存的成员对象。<br><br>注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些<br>属性都不会被用到,所以途中省略了这些部分,只显示了表头节点的各个层。<br>
跳跃表节点。<br>跳跃表节点的实现由redis.h/zskiplistNode结构定义<br>
1.层。<br>跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过<br>这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。<br>每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机<br>生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的"高度",图中分别展示了三个<br>高度为1层、3层和5层的节点,因为C语言的数组索引总是从0开始,所以节点的第一层是level[0]<br>
2.前进指针。<br>每个层都由一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点。<br>如图所示,用虚线表示出了程序从表头向表尾方向,遍历跳跃表中所有节点的路径:<br>1.迭代程序首先访问跳跃表的第一个节点(表头),然后从第四层的前进指针移动到表中的第二个节点<br>2.在第二个节点时,程序沿着第二层的前进指针移动到表中的第三个节点<br>3.在第三个节点时,程序同样沿着第二层的前进指针移动到表中的第四个节点<br>4.当程序再次沿着第四个节点的前进指针移动时,它碰到一个NULL,程序知道这时已经到达了跳跃表<br>的表尾,于是结束这次遍历<br>
3.跨度。<br>层的跨度(level[i].span属性)用于记录两个节点之间的距离<br>1.两个节点之间的跨度越大,它们相距得就越远<br>2.指向NULL的所有前进指针的跨度都为0.因为它们没有连向任何节点。<br>遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的;<br>在查找某个节点的过程中,将沿途访问的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表<br>中的排位<br>
举个例子,图中用虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:<br>查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3<br>
再举个例子,图中用虚线标记了在跳跃表中查找分值为2.0、成员对象为o2的节点时,<br>沿途经历的层:在查找节点的过程中,程序经过了两个跨度为1的节点,也因此可以<br>计算出,目标节点在跳跃表中的排位为2<br>
4.后退指针。<br>节点的后退指针(backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进<br>指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点,图中用虚线展示了<br>如果从表尾向表头遍历跳跃表中的所有节点:程序首先通过跳跃表的tail指针访问表尾节点,然后通过<br>后退指针访问倒数第二个节点,之后再沿着后退指针访问倒数第三个节点,再之后遇到指向NULL的<br>后退指针,于是访问结束<br>
5.分值和成员。<br>节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。<br>节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。<br>在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的<br>分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面<br>(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)<br>
举个例子,在图中的跳跃表中,三个跳跃表节点都保存了相同的分值10086.0,<br>但保存成员对象的o1节点却排在成员对象o2和o3的节点之前,而保存成员对象<br>o2的节点又排在保存成员对象o3的节点之前,由此可见o1、o2、o3三个成员<br>对象中的排序为o1<=o2<=o3<br>
跳跃表。<br>仅靠多个跳跃表节点就可以组成一个跳跃表,如图所示。<br>但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,<br>比如快速访问跳跃表地表头节点和表尾节点,或者快速地获取跳跃表节点地数量(也即是<br>跳跃表的长度)等信息<br>
zskiplist结构的定义如下:<br>
header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位<br>表头节点和表尾节点的复杂度为O(1).<br>通过使用length属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。<br>level属性则用于在O(1)复杂度内后去跳跃表中层高最大的那个节点的层数量,注意,<br>表头节点的层高并不计算在内<br>
整数集合
概述。<br>整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个结合的元素数量<br>不多时,Redis就会使用整数集合作为集合键的底层实现。<br>
举个例子,如果创建一个只包含五个元素的集合键,并且集合中的元素都是整数值,<br>那么这个集合键的底层实现就会是整数集合<br>
整数集合的实现。<br>整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、<br>int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。每个intset.h/intset结构<br>表示一个整数集合:<br>contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),<br>各个项在数组中按值得大小从小到大有序地排列,并且数组中不包含任何重复项<br>length属性记录了整数集合包含地元素数量,也即是contents数组地长度.<br>虽然intset结构将contents属性声明为int8_t类型地数组,但实际上contents数组并不保存任何<br>int8_t类型的值,contents数组的真正类型取决于encoding属性的值:<br>1.如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组<br>里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767,正负2的16-1次方)<br>2.如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,<br>数组里的每个项都是一个int32_t类型的整数值(最大值、最小值为正负2的32-1次方)<br>3.如果encoding属性为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的<br>每个项都是一个int64_t类型的整数值(最大值最小值为,2的64-1次方-1,2的64-1次方)<br>
举个例子,<br>1.encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,<br>而集合保存的都是int16_t类型的整数值<br>2.length属性的值为5,表示整数集合包含五个元素<br>3.contents数组按从小到大的顺序保存着集合中的五个元素<br>4.因为每个集合元素都是int16_t类型的整数值,所以contents数组的大小等于<br>sizeof(int16_t) * 5 = 16 * 5 = 80位<br>
另一个整数集合示例:<br>1.encoding属性的值位INTSET_ENC_INT64,表示整数集合的底层实现为int64_t类型的数组,<br>而数组中保存的都是int64_t类型的整数值<br>2.length属性的值为4,表示整数集合包含四个元素<br>3.contents数组按从小到大的顺序保存着集合中的四个元素<br>4.因为每个集合元素都是int64_t类型的整数值,所以contents数组的大小为<br>sizeof(int64_t) * 4 = 64 * 4 = 256位<br><br>虽然contents数组保存的四个整数值中,只有-2675256175807981027是真正需要用int64_t<br>类型来保存的,而其他的1、3、5三个值都可以用int16_t类型来保存,不过根据整数集合的升级<br>规则,当向一个底层位int16_t数组的整数集合添加一个int64_t类型的整数值时,整数集合已有<br>的所有元素都会被转换成int64_t类型,所以contents数组保存的四个整数值都是int64_t类型的,<br>不仅仅是-2675256175807981027<br>
升级。<br>每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型<br>都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。<br>升级整数集合并添加新元素共分为三步进行:<br>1.根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间<br>2.将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素防止到正确的位上,<br>而且在放置元素的过程中,需要继续维持底层数组的有序性质不变<br>3.将新元素添加到底层数组里面<br>
举个例子。假设现在有一个INTSET_ENC_INT16编码的整数集合,集合中包含三个int16_t类型的元素。<br>如图所示。因为每个元素都占用16位空间,所以整数集合底层数组的大小为16*3=48位,<br>
现在,假设要将类型为int32_t的整数值65535添加到整数集合里面,因为65535的类型int32_t比<br>整数集合当前所有的元素的类型都要长,所以在将65535添加到整数集合之前,程序需要先对整数集合<br>进行升级。<br>升级首先要做的是,根据新类型的长度,以及集合元素的数量(包括要添加的新元素在内),<br>对底层数组进行空间重分配。<br>整数集合目前有三个元素,再加上新元素65535,整数集合需要分配四个元素的空间,因为每个int32_t<br>整数值需要占用32位空间,所以在空间重分配之后,底层数组的大小将是32 * 4 = 128位,<br>
虽然程序对底层数组进行了空间重分配,但数组原有的三个元素1、2、3<br>仍然是int16_t类型,这些元素还保存在数组的前48位里面,所以程序接下来<br>要做的就是将这三个元素转换为int32_t类型,并将转换后的元素放置到正确<br>的位上面,而且在防止元素的过程中,需要维持底层数组的有序性质不变<br>
首先,因为3在1、2、3、65535四个元素中排名第三,所以它将被转移到<br>contents数组的索引2位置上,也即是数组64位至96位的空间内,如图<br>
接着,因为2在1、2、3、65535四个元素中排名第二,所以它将被移动到contents<br>数组中索引1位置上,也即是32位至63位的空间内,如图<br>
之后,因为元素1在1、2、3、65535四个元素中排名第一,所以它将被移动到<br>contents数组的索引0位置上,即数组的0位至31位的空间内,如图<br>
然后,因为元素65535在1、2、3、65535四个元素中排名第四,所以它将被<br>移动到contents数组的索引3位置上,也即是数组的96位至127位的空间内<br>
最后,程序将整数集合encoding属性的值从INTSET_ENC_INT16改为INTSET_ENC_INT32,<br>并将length属性的值从3改为4,设置完成之后的整数集合如图。<br>因为每次向整数集合添加元素都可能会引起升级,而每次神各级都需要对底层数组中已有的<br>所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。<br>其他类型的升级操作,比如INTSET_ENC_INT16编码升级为INTSET_ENC_INT64编码,<br>或者从INTSET_ENC_INT32编码升级为INTSET_ENC_64编码<br>
升级之后新元素的摆放位置。<br>因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的<br>值要么就大于所有现有元素,要么就小于所有现有元素。<br>1.在新元素小于所有现有元素的情况下,新元素会被放置在底层 数组的最开头(索引0)<br>2.在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)<br>
升级的好处。<br>
提升整数集合的灵活性。<br>因为C语言是静态类型语言,为了避免类型错误,通常不会讲两种不同类型的值放在<br>同一个数据结构里面。例如,一般只使用int16_t类型的数组来保存int16_t类型的值,<br>只使用int32_t类型的数组来保存int32_t类型的值。<br>但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以可以随意地将int16_t、<br>int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误<br>
尽可能地节约内存。<br>要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的<br>做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,<br>即使添加到整数集合里面的都是int16_t或者int32_t类型的值,数组都需要使用<br>int64_t类型的空间去保存它们,从而出现浪费内存的情况。<br>而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级<br>操作只会在有需要的时候进行,这可以尽量节省内存<br>
例如,如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现<br>就会一直是int16_t类型的数组,只有在我们要将int32_t类型或者int64_t类型的值添加<br>到集合时,程序才会对数组进行升级<br>
降级。<br>整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态<br>
举个例子,即使将集合里唯一一个真正需要使用int64_t类型来保存的元素<br>4294967295删除了,整数集合的编码仍然会维持INTSET_ENC_INT64<br>底层数组也仍然会是int64_t类型<br>
压缩列表
概述。<br>压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么<br>就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。<br>
例如,执行以下命令将创建一个压缩列表实现的列表键,列表键里面包含的都是1、3、5、10086<br>这样的小整数值,以及"hello"、"world"这样的短字符串。另外,当一个哈希键只包含少量键值对,<br>并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用<br>压缩列表来做哈希键的底层实现。<br>
举个例子,执行以下命令将创建一个压缩列表实现的哈希键:<br>哈希键里面包含的所有键和值都是小整数值或者短字符串。<br>
压缩列表的构成。<br>压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)<br>数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。<br>
1.列表zlbytes属性的值为0x50(十进制80),表示压缩列表的总长为80字节<br>2列表zltail属性的值为0x3c(十进制60),这表示如果我们有一个指向压缩<br>列表起始地址的指针P,那么只要用指针P加上偏移量60,就可以计算出表尾<br>节点entry3的地址<br>3.列表zllen属性为0x3(十进制3),表示压缩列表包含三个节点<br>
1.列表zlbytes属性的值为0xd2(十进制210),这表示压缩列表的总长为210字节<br>2.列表zltail属性的值为0xb3(十进制179),这表示如果我们有一个指向压缩列表<br>起始地址的P,那么只要用指针P加上偏移量179,就可以计算出表尾节点entry5<br>的地址<br>3.列表zllen属性的值为0x5(十进制5),表示压缩列表包含五个节点<br>
压缩列表节点的构成。<br>每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度之一:<br>1.长度小于等于63(2 ^ 6 - 1)字节的字节数组<br>2.长度小于等于16383(2 ^ 14 - 1)字节的字节数组<br>3.长度小于等于4294967295(2 ^ 32 - 1)字节的字节数组<br>而整数值则可以是以下六种长度之一:<br>1.4位长,介于0至12之间的无符号整数<br>2.1字节长的有符号整数<br>3.3字节长的有符号整数<br>4.int16_t类型整数<br>5.int32_t类型整数<br>6.int64_t类型整数<br>每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成<br>
previous_entry_length。<br>节点的previous_entry_length属性以字节为单位,记录了压缩列表前一个节点的长度。<br>previous_entry_length属性的长度可以是1字节或者5字节:<br>1.如果前一节点的长度小于254字节,那么previous_entry_length属性的长度为1字节:<br>前一节点的长度保存在这一个字节里面<br>2.如果潜移节点的长度大于等于254字节,那么previous_entry_length属性的长度为5字节:<br>其中属性的第一字节会被设置为0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度<br><br>因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,<br>根据当前节点的起始地址来计算出前一个节点的起始地址<br>
图中展示了一个包含一字节长previous_entry_length属性的压缩列表节点,<br>属性 的值为0x05,表示前一节点的长度为5字节<br>
图中展示了一个包含五字节长previous_entry_length属性的压缩节点,<br>属性的值为0xFE00002766,其中值的最高位0xFE表示这是一个五字节长的<br>previous_entry_length属性,而之后的四字节0x00002766(十进制10086)<br>才是前一节点的实际长度<br>
举个例子,如果有一个指针当前节点的起始地址的指针c,那么只要用指针c<br>减去当前节点previous_entry_length属性的值,就可以得出一个指向前<br>一个节点起始地址的指针p,如图所示<br>
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要拥有了一个指向<br>某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length<br>属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。<br><br>1.首先,拥有指向压缩列表表尾节点entry4起始地址的指针p1(指向表尾节点的指针可以<br>通过指向压缩列表起始地址的指针加上zltai属性的值得出)<br>2.通过p1减去entry4节点previous_entry_length属性的值,得到一个指向entry4前一个<br>节点entry3起始地址的指针p2<br>3.通过p2减去entry3节点previous_entry_length属性的值,得到一个指向entry3前一节点<br>entry2起始地址的指针p3<br>4.通过用p3减去entry2节点previous_entry_length属性的值,得到一个指向entry2<br>前一节点<br>最终,从表尾节点向表头节点遍历了整个列表<br>
encoding。<br>节点的encoding属性记录了节点的content属性所保存数据的类型及长度:<br>1.一字节、两字节或者五字节长,值得最高位为00、01或者10的是字节数组编码:<br>这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的<br>其他位记录<br>2.一字节长,值得最高位以11开头的是整数编码:这种编码表示节点的content属性保存着<br>整数值,整数值的类型和长度由编码出去最高两位之后的其他位记录<br>
content。<br>节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型<br>和长度由节点的encoding属性决定。<br>
举个例子,图中展示了一个保存字节数组的节点示例<br>1.编码的最高两位00表示节点保存的是一个字节数组<br>2.编码的后六位001011记录了字节数组的长度11;<br>3.content属性保存着节点值"hello world"<br>
另一个例子。图中展示了一个保存整数值的节点示例<br>1.编码11000000表示节点保存的是一个int16_t类型的整数值<br>2.content属性保存着节点的值10086<br>
连锁更新。<br>每个节点的previous_entry_length属性都记录了前一个节点的长度:<br>1.如果前一节点的长度小于254字节,那么previous_entry_length属性需要用1字节长的空间来<br>保存这个长度值<br>2.如果前一节点的长度大于等于254字节,那么previous_entry_length属性需要用5字节长的空间<br>来保存这个长度值<br>现在,考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的<br>节点e1至eN,如图所示。因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度<br>只需要1字节长的previous_entry_length属性,换句话说,e1至eN的所有节点的previous_entry_length<br>属性都是1字节长。<br>这时,如果将一个长度大于等于254字节的新节点new设置为压缩列表的表头节点,那么new将称为e1的<br>前置节点<br>
因为e1的previous_entry_length属性仅长1字节,它没有办法保存新节点new的长度,<br>所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性<br>从原来的1字节长扩展为5字节长。<br>现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之间,在为previous_entry_length<br>属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度<br>使用1字节长的previous_entry_length属性是没法保存的。<br>因此,为了让e2的previous_entry_length属性可以记录下e1的长度,程序需要再次对压缩列表<br>执行空间重分配操作,并将e2节点的previous_entry_length属性从原来的1字节长扩展为5字节长.<br>正如扩展e1引发对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展...<br>为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对<br>压缩列表执行空间重分配操作,直到eN为止<br>
除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新
如图所示的压缩列表,如果e1至eN都是大小介于250字节至253字节的节点。big节点的长度大于等于254字节<br>(需要5字节的previous_entry_length来保存),而small节点的长度小于254字节(只需要1字节的<br>previous_entry_length来保存),那么当我们将small节点从压缩列表中删除之后,为了让e1的previous_entry_length<br>属性可以记录big节点的长度,程序将扩展e1的空间,并由此引发之后的连锁更新。<br>因为连锁更新在最坏情况下需要第压缩列表执行N次空间重分配操纵,而每次空间重分配的最坏复杂度为O(N),所以<br>连锁更新的最坏复杂度为O(N^2).<br>要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:<br>1,首先,压缩列表里要恰好有多个连续的,长度介于250字节至253字节之间的节点,连锁更新才有可能触发,在实际<br>情况中,这种情况并不多见<br>2.其次,即时出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响:比如说,对三五个节点进行连锁<br>更新是绝对不会影响性能的<br><br>因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,可以放心地使用这些函数,而不必担心连锁更新会<br>影响压缩列表地性能<br>
对象
概述。<br>Redis并没有直接使用简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等这些数据结构<br>来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、<br>哈希对象、集合对象和有序集合对象这五种类型的对象,,Redis可以在执行命令之前,根据对象的类型<br>来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为<br>对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。<br>除此之外,Redis的对象系统还实现了<font color="#ec7270">基于引用计数计数的内存回收机制</font>,当程序不再使用某个对象的时候,<br>这个对象所占用的内存就会被自动释放;另外,Redis还通过引用计数计数实现了对象共享机制,这一机制<br>可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。<br><br>最后,Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转市场,在服务启用了<br>maxmemory功能的情况下,空转市场较大的那些键可能会优先被服务器删除<br>
对象的类型与编码。<br>
概述。<br>Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新建一个键值对时,我们至少会<br>创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象).<br><br>Redis中的每个对象都由一个redisObject结构表示,该结构中的保存数据有关的<br>三个属性分别是type属性、encoding属性和ptr属性<br>
举个例子,以下SET命令在数据库中创建了一个新的键值对,其中键值对的键<br>是一个包含了字符串值"msg"得对象,而键值对得值则是一个包含了字符串值<br>"hello world"得对象:<br>```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>```<br>
类型。<br>对象的types属性记录了对象的类型,这个属性的值可以是图中列出的常量中的其中一个。<br>对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、<br>列表对象、哈希对象、集合对象或者有序对象的其中一种。<br>1.当称呼一个数据库键为"字符串键"时,指的是"这个数据库键所对应的值为字符串对象";<br>2.当称呼一个键为"列表键"时,指的是"这个数据库键所对应的值为列表对象"<br>
TYPE命令的实现方式也与此类似,当对一个数据库键执行TYPE命令时,命令返回的结果<br>为数据库键对应的值对象的类型,而不是键对象的类型:例子如代码所示
图中列出了TYPE命令在面对不同类型的值对象时所产生的输出
编码的底层实现。<br>对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。<br>encoding属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的<br>底层实现,这个属性的值可以是图中列出的常量的其中一个<br>
每种类型的对象都至少使用了两种不同的编码,图中列出了每种类型的对象可以使用的编码。<br>
使用OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码
图中列出了不同编码的对象所对应的OBJECT ENCODING命令输出。
通过encoding属性来设定对象所使用的编码,而不是为特定类型的对象关联一种固定的编码,<br>极大地提升了Redis地灵活性和效率,因为Redis可以根据不同的使用场景来为一个对象设置<br>不同的编码,从而优化对象在某一场景下的效率。<br>举个例子,在列表对象包含的元素较少时,Redis使用压缩列表作为列表对象的底层实现。<br>1.因为压缩列表比双端链表更节约内存,并且在元素数量较少时,在内存中以连续块方式保存的<br>压缩列表比起双端链表可以更快被载入到缓存中<br>2.随着列表对象包含的元素越来越多,使用压缩列表来保存元素的优势逐渐消失时,对象就会<br>将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;<br><br>其他类型的对象也会通过使用多种不同的编码来进行类似的优化。<br>
<font color="#ec7270">String对象为什么把大于39字节或者44字节的字符串编码为raw,小于的时候编码为embstr?</font><br><font color="#000000">在Redis3.2以前的版本中,SDS作为字符串类型中存储字符串内容的结构,源码如下:<br></font><br>一个字符串对象不仅仅包含SDS结构,还包含了RedisObject(Redis对象头),这时每个Redis对象<br>都要携带的一种结构,它的结构如下<br><br>由于操作系统使用jmalloc和tmalloc进行内存的分配,而内存分配的单位都是2的N次方,所以是<br>2,4,8,16,32,64,如果Redis采取32字节分配的化,那么32-16(RedisObject)-9(3.2版本的SDS)=7,<br>相当于可使用字节数为7字节,Redis认为太过于小了,所以Redis采取分配的是64字节,即64-25=39。<br><br>在Redis之后的版本中,为了进一步优化字符串对象在一次操作系统的内存分配中扩大可使用的空间,又将<br>sdshdr分为了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64结构如下,<br><br>有人可能会问既然分出来这么多的结构,如果用sdshdr5的结构,那么64-16-1-1=46个字节,跟平常说的<br>44个字节不一样,如果我们用sdshdr5的结构,那么这个结构的flags中只有5个bit可以让我们使用,<br>表示的空间地址就是2^5=32个长度,表示的空间太小了,所以我们得用sdshdr8的结构那么可以表示的空间地址<br>将会是2^8=256,但实际上,在Redis内部中,键是使用sdshdr5的结构,因为键不大可能会更新,而值会经常<br>更新,所以干脆直接sdshdr8来表示值对象<br>
```c<br>struct sdshdr {<br> // 记录buf数组中已使用字节的数量<br> // 等于SDS保存字符串的长度 4byte<br> int len;<br> <br> // 记录buf数组中未使用字节的数量 4byte<br> int free;<br> // 字节数组,用于保存字符串 字节\0结尾的字符串占用了1byte<br> char buf[];<br>}<br>3.2版本SDS结构<br>
```c<br>// Redis对象<br>typedef struct redisObject {<br> // 类型 4bits; 即【String、List、Hash、Set、Zset】中的一个<br> unsigned type:4<br> // 编码方式 4 bits, encoding表示对象底层所使用的编码<br> unsigned encoding:4;<br> // LRU时间(相对于server.lrulock) 24bits;<br> unsigned lru:24;<br> // 引用计数 Redis里面的数据可以通过引用计数进行共享 32bits<br> int refcount;<br> // 指向对象的值 64bit<br> void* ptr;<br>} robj; // 16bytes<br>```
```c<br>struct __attribute__ ((__packed__)) sdshdr5 {<br> unsigned char flags; /* 3 lsb of type, and 5 msb of string length */<br> char buf[];<br>};<br>struct __attribute__ ((__packed__)) sdshdr8 {<br> uint8_t len; /* used */<br> uint8_t alloc; /* excluding the header and null terminator */<br> unsigned char flags; /* 3 lsb of type, 5 unused bits */<br> char buf[];<br>};<br>struct __attribute__ ((__packed__)) sdshdr16 {<br> uint16_t len; /* used */<br> uint16_t alloc; /* excluding the header and null terminator */<br> unsigned char flags; /* 3 lsb of type, 5 unused bits */<br> char buf[];<br>};<br>struct __attribute__ ((__packed__)) sdshdr32 {<br> uint32_t len; /* used */<br> uint32_t alloc; /* excluding the header and null terminator */<br> unsigned char flags; /* 3 lsb of type, 5 unused bits */<br> char buf[];<br>};<br>struct __attribute__ ((__packed__)) sdshdr64 {<br> uint64_t len; /* used */<br> uint64_t alloc; /* excluding the header and null terminator */<br> unsigned char flags; /* 3 lsb of type, 5 unused bits */<br> char buf[];<br>};<br>// 一些变量的定义 <br>#define SDS_TYPE_5 0<br>#define SDS_TYPE_8 1<br>#define SDS_TYPE_16 2<br>#define SDS_TYPE_32 3<br>#define SDS_TYPE_64 4<br>```<br><br>
sdshdr5的结构如图
sdshdr8的结构如图
字符串对象。<br>字符串对象的编码可以是int、raw或者embstr。<br>
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值<br>保存在字符串对象结构的ptr属性里面(将void *转换成long),并且将字符串对象的编码设置为int<br>举个例子,如果执行以下SET命令,那么服务器将创建一个如图所示的int编码的字符串对象作为number键的值:<br>```c<br>127.0.0.1:6379> SET number 10086<br>OK<br>127.0.0.1:6379> OBJECT ENCODING number<br>"int"<br>```<br>
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于(3.2版本之前是39字节,之后变成44字节,<br>后面再分析),那么字符串对象将使用简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw.<br>举个例子,如果执行以下命令,那么服务器将创建一个如图所示的字符串对象作为story键的值:<br>```c<br>127.0.0.1:6379> SET stroy "Long, long, long, ago, there, lived, a, king, 123456789987654321"<br>OK<br>127.0.0.1:6379> STRLEN stroy<br>(integer) 65<br>127.0.0.1:6379> OBJECT ENCODING stroy<br>"raw"<br>```<br>
如果字符串对象的保存的是一个字符串值,并且这个字符串值的长度小于等于(39或44字节),那么字符串对象<br>将使用embstr编码的方式来保存这个字符串值。embstr编码是专门用于保存短字符串的一种优化编码方式,<br>这种编码和raw编码一样,都是用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次<br>内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配<br>一块连续的空间,空间中依次包含redisObject和sdshdr两个结构,如图所示。<br><br>embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果时相同的,<br>但使用eembstr编码将创建字符串对象来保存短字符串值有以下好处:<br>1.embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。<br>2.释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数<br>3.因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这中编码的字符串对象比起raw编码的<br>字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。<br>
举个例子,以下命令创建了一个embstr编码的字符串对象作为msg键的值,值对象的样子,<br>```c<br>127.0.0.1:6379> SET msg "hello"<br>OK<br>127.0.0.1:6379> OBJECT ENCODING msg<br>"embstr"<br>```<br>
还可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果要保存一个浮点到字符串对象里面,<br>那么程序会先将这个f浮点数转换成字符串值,然后再保存转换所得的字符串值。<br>
举个例子,执行以下代码将创建一个包好3.14的字符串表示"3.14"的字符串对象:<br>```c<br>127.0.0.1:6379> SET pi 3.14<br>OK<br>127.0.0.1:6379> OBJECT ENCODINg pi<br>"embstr"<br>```<br>在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的<br>浮点数值转换回字符串值,并继续保存在字符串对象里面.
举个例子,如果执行以下代码:<br>```c<br>127.0.0.1:6379> INCRBYFLOAT pi 2.0<br>"5.140000000000001"<br>127.0.0.1:6379> OBJECT ENCODING pi<br>"embstr"<br>```<br>那么程序首先会取出字符串对象里面保存的字符串值"3.14",将它转化回浮点数值3.14,然后再把3.14和2.0相加得出的值<br>5.14转换成字符串"5.14",并将这个"5.14"保存到字符串对象里面。
编码的转换。<br>int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的对象。<br>对于int编码的字符串对象来说,如果向对象执行了一些命令,使得这个对象保存的不再是整数值,<br>而是一个字符串值。那么字符串对象的编码将从int变为raw.<br><br>另外,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和<br>raw编码的字符串对象有这些程序),所以embstr编码的字符串对象实际上是只读的。当对embstr编码<br>的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。<br>因为这个原因,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。<br>
举个例子,通过APPEND命令,向一个保存整数值的字符串对象追加了一个字符串值,<br>因为追加操作只能对字符串值执行,所以程序会先将之前保存的整数值10086转换为<br>字符串值"10086",然后再执行追加操作,操作的执行结果就是一个raw编码的、保存了<br>字符串值的字符串对象<br>```c<br>127.0.0.1:6379> SET number 10086<br>OK<br>127.0.0.1:6379> OBJECT ENCODING number<br>"int"<br>127.0.0.1:6379> APPEND number " is a good number!"<br>(integer) 23<br>127.0.0.1:6379> GET number<br>"10086 is a good number!"<br>127.0.0.1:6379> OBJECT ENCODING number<br>"raw"<br>```<br>
以下代码展示了一个embstr编码<br>```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>127.0.0.1:6379> OBJECT ENCODING msg<br>"embstr"<br>127.0.0.1:6379> APPEND msg " again!"<br>(integer) 18<br>127.0.0.1:6379> OBJECT ENCODING msg<br>"raw"<br>```<br>
列表对象。<br>列表对象的编码可以是ziplist或者linkedlist.<br>
概述。<br>ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。<br>例子如下。如果numbers键的值对象使用的是ziplist编码,这个这个值对对象将会是如图所示的样子。<br><br>另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了<br>一个字符串对象,而每个字符串对象都保存了一个列表元素。<br><br>注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的<br>行为在哈希对象、集合对象、有序集合对象中都会出现,字符串对象是Redis五种类型的对象中唯一一种<br>会被其他四种对象嵌套的对象<br>
举个例子,如果执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值<br>```c<br>127.0.0.1:6379> RPUSH numbers 1 "three" 5<br>(integer) 3<br>```<br>
举个例子,如果上面的number键创建按的列表对象使用的不是ziplist编码,而是linkedlist编码,<br>那么numbers键的值对象将会是如图所示<br>
注意:为了简化字符串对象的表示,在上面的图中使用了一个带有StringObject字样的格子来表示<br>一个字符串对象,而StringObject字样下面的是字符串对象所保存的值。比如说,如图所示的就是<br>一个包含了字符串值"three"的字符串对象<br>
编码转换。<br>当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:<br>1.列表对象保存的所有字符串元素的长度都小于64字节<br>2.列表对象保存的元素数量小于512个;<br>不能满足这两个条件的列表对象需要使用linkedlist编码<br><br>对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件<br>的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在<br>压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码<br>也会从ziplist变为linkedlist<br>
注意:<br>以上两个条件的上限值时可以修改的,具体看配置文件中关于<br>list-max-ziplist-value选项和list-max-ziplist-entries选项
举个例子,代码展示了列表对象因为保存了长度太大的元素而进行编码转换的情况<br>
哈希对象。<br>哈希对象的编码可以是ziplist或者hashtable。<br>
概述。<br>ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入<br>到哈希对象时,程序会先将保存了键的压缩列表推入到压缩列表表尾,然后再<br>将保存了值的压缩列表节点推入到压缩列表表尾,因此:<br>1.保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的<br>节点在后。<br>2.先添加到哈希对象中的键值对会被放在压缩列表的表头方向(相对于值对象来说),<br>而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向<br><br>另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个<br>键值对都使用一个字典键值对来保存:<br>1.字典的每个键都是一个字符串对象,对象中保存了键值对的键<br>2.字典的每个值都是一个字符串对象,对象中保存了键值对的值<br>
举个例子,如果执行以下HSET命令,那么服务器将创建一个列表对象作为profile键的值,<br>如果profile键的值对象使用的是ziplist编码,那么这个值对象将会是图中的结构,其中对象<br>所使用的压缩列表<br>
举个例子,前面profile键创建的不是ziplist编码的哈希对象,而是hashtable编码的哈希对象,<br>那么这个哈希对象应该会是t图中的结构<br>
编码转换。<br>当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:<br>1.哈希对象保存的所有键值对的键和值的字符串长度都小于64字节<br>2.哈希对象保存的键值对数量小于512个<br>不能满足这两个条件的哈希对象需要使用hashtable编码<br><br>对于使用ziplist编码的列表对象,当使用ziplist编码所需的两个条件任意<br>一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩<br>列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist<br>变为hashtable.<br>
注意。<br>这两个条件的上限值时可以修改的,具体要看配置文件中关于<br>hash-max-ziplist-value选项和hash-max-ziplist选项说明<br>
代码展示了哈希对象因为键值对的键长度太大而引起编码转换的情况
除了键的长度太大会引起编码转换之外,值得长度太大也会引起编码转换
最后,代码展示了哈希对象因为包含的键值对数量过多而引起编码转换的情况,
集合对象。<br>集合对象的编码可以是intset或者hashtable.<br>
概述。<br>intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在<br>整数集合里面。<br><br>另一方面,hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串<br>对象,每个字符串对象包含了一个集合对象,而字典的值则全部被设置为NULL.<br>
举个例子。以下代码创建一个如图所示的intset编码集合对象<br>```c<br>127.0.0.1:6379> SADD numbers 1 3 5<br>(integer) 3<br>```<br>
举个例子,以下代码创建一个如图所示的hashtable编码集合对象<br>```c<br>127.0.0.1:6379> SADD fruits "apple" "banana" "cherry"<br>(integer) 3<br>```<br>
编码的转换。<br>当集合对象可以同时满足以下两个条件时,对象使用intset编码:<br>1.集合对象保存的所有元素都是整数值<br>2.集合对象保存的元素不超过512个<br>不能满足中两个条件的集合对象需要使用hashtable编码<br><br>对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,<br>就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,<br>并且对象的编码也会从intset变为hashtable<br>
注意:<br>第二个条件的上限值是可以修改的,具体请看配置文件中关于set-max-intset-entries选项的说明<br>
举个例子,以下代码创建了一个只包含整数的集合对象,该对象的编码为intset:<br>```c<br>127.0.0.1:6379> SADD numbers 1 3 5<br>(integer) 3<br>```<br>不过,只要我们向这个只包含整数元素的集合对象添加一个字符串元素,集合对象的<br>编码转移操作就会被执行:<br>```c<br>127.0.0.1:6379> SADD numbers "seven"<br>(integer) 1<br>127.0.0.1:6379> OBJECT ENCODING numbers<br>"hashtable"<br>```<br><br>除此之外,如果我们创建一个包含512个整数元素的集合对象,那么对象的编码应该会是<br>intset:<br>```c<br>127.0.0.1:6379> EVAL "for i=1, 512 do redis.call('SADD', KEYS[1], i) end" 1 integers<br>(nil)<br>127.0.0.1:6379> SCARD integers<br>(integer) 512<br>```<br>但是我们再向集合添加一个新的元素,使得整个元素的元素数量变成513,那么对象的编码<br>转换操作就会被执行:<br>```c<br>127.0.0.1:6379> SADD integers 513<br>(integer) 1<br>127.0.0.1:6379> OBJECT ENCODING integers<br>"hashtable"<br>```<br>
有序集合对象。<br>有序集合的编码可以是ziplist或者skiplist。<br>
概述。<br>
ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表<br>节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score).压缩列表<br>内的集合元素按分值从小到大进行排序,分值较小的元素被放置在表头的位置(相对分值节点来说),而<br>分值较大的元素则被放置在靠近表尾的位置。
举个例子,如果执行以下ZADD命令,那么服务器将创建一个有序集合作为price键的值:<br>```c<br>127.0.0.1:6379> ZADD price 8.5 apple 50 banana 6.0 cherry<br>(integer) 3<br>```<br>如果price键的值对象使用的是ziplist编码,那么这个值对象将会是如图所示的样子,<br>对象使用的压缩列表则会是如图所示的样子<br>
skiplist编码的有序集合对象使用zset结构作为底层实现,,一个zset结构同时包含一个字典和一个<br>跳跃表<br>```c<br>typedef struct zset{<br> zskiplist *zsl;<br> dict * dict;<br>}zset<br>```<br>zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:<br>跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个<br>跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK,ZRANGE等命令就是基于跳跃表API来实现的<br><br>除此之外,zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了<br>一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)<br>复杂度查找给定成员的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部<br>用到了这一特性。<br>有序集合每个元素都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,<br>虽然zset结构同时使用跳跃表和字典来保存有序集合的元素,但这两种数据结构都会通过指针来共享相同的成员<br>和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或分值,也不会因此而浪费额外的内存
为什么有序集合需要同时使用跳跃表和字典来实现?<br>在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,<br>在性能上对比起同时使用字典和跳跃表都会有所降低。<br>举个例子,如果只是用字典来实现有序集合,虽然以O(1)的复杂度查找成员的分值这一特性会被保留,但是字典<br>以无须的方式来保存集合元素,所以每次在执行范围型操作——比如ZRANK,ZRANGE等命令时,程序都需要<br>对字典保存的所有元素进行排序,完成这种排序需要至少O(NlogN)时间复杂度,以及额外的O(N)内存空间(因为<br>要创建一个数组来保存排序后的元素)。<br>另一方面,如果只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了<br>字典,所以根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)。因为以上原因,为了让有序集合的查找<br>范围和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合<br>
举个例子,如果前面price键创建的不是ziplist编码的有序集合对象,而是skiplist编码的有序集合对象,<br>那么这个有序集合对象将会如图所示,而对象所使用的zset结构也将如图所示<br>
注意:<br>为了展示方便,在字典和跳跃表中重复展示了各个元素的成员和分值,但在实际中,字典<br>和跳跃表会共享元素的成员和分值,所以并不会造成任何数据重复,也不会因此而浪费任何<br>内存。<br>
编码的转换。<br>当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码<br>1.有序集合保存的元素数量小于128个<br>2.有序集合保存的所有元素成员的长度都小于64字节<br>不能满足以上两个条件的有序集合对象将使用skiplist编码<br><br>对于使用ziplist编码的有序集合对象来说,当使用ziplist比那吗所需的两个条件中<br>的任意一个不能被满足时,程序就会执行编码转换操作,将原本储存在压缩列表里面<br>的所有集合元素转移到zset里面,并将对象的编码从ziplist改为skiplist<br>
注意:<br>以上两个条件的上限值是可以修改的,具体请看配置文件中关于zset-max-ziplist-entries<br>和zset-max-ziplist-value选项的说明<br>
举个例子,以下代码展示了有序集合因为包含了过多元素而引发编码转换的情况:<br>```c<br>// 对象包含了128个元素<br>127.0.0.1:6379> EVAL "for i=1, 128 do redis.call('ZADD', KEYS[1],i,i) end" 1 numbers<br>(nil)<br>127.0.0.1:6379> ZCARD numbers<br>(integer) 128<br>127.0.0.1:6379> OBJECT ENCODING numbers<br>"ziplist"<br>// 再添加一个新元素<br>127.0.0.1:6379> ZADD numbers 3.14 pi<br>(integer) 1<br>// 对象数量变为129个<br>127.0.0.1:6379> ZCARD numbers<br>(integer) 129<br>// 编码已改变<br>127.0.0.1:6379> OBJECT ENCODING numbers<br>"skiplist"<br>```<br>
以下代码则展示了有序集合对象因为元素的成员过长而引起编码转换的情况<br>```c<br>// 向有序集合添加一个成员只有三字节长的元素<br>127.0.0.1:6379> ZADD blah 1.0 www<br>(integer) 1<br>127.0.0.1:6379> OBJECT ENCODING blah<br>"ziplist"<br>// 向有序集合添加一个成员为66字节长的元素<br>ZADD blah 2.0 ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo<br>// 编码已改变<br>127.0.0.1:6379> OBJECT ENCODING blah<br>"skiplist"<br>```<br>
类型检查与命令多态。
概述。<br>redis中用于操作键的命令基本上可以分为两种类型。<br>其中一种命令可以对任何类型的键执行,比如说DEL命令、EXPIRE命令、RENAME命令、<br>TYPE命令、OBJECT命令等.<br><br>而另一种命令只能对特定类型的键执行,比如说<br>1.SET、GET、APPEND、STRLEN等命令只能对字符串键执行;<br>2.HDEL、HSET、HGET、HLEN等命令只能对哈希键执行<br>3.RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行<br>4.SADD、SPOP、SINTER、SCARD等命令只能对集合键执行<br>5.ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行<br>
举个例子,以下代码就展示了使用DEL命令来删除三种不同类型的键:<br>```c<br>// 字符串键<br>127.0.0.1:6379> SET msg "hello"<br>OK<br>// 列表键<br>127.0.0.1:6379> RPUSH numbers 1 2 3<br>(integer) 3<br>// 集合键<br>127.0.0.1:6379> SADD fruits apple banana cherry<br>(integer) 3<br><br>127.0.0.1:6379> DEL msg<br>(integer) 1<br>127.0.0.1:6379> DEL numbers<br>(integer) 1<br>127.0.0.1:6379> DEL fruits<br>(integer) 1<br>```<br>
举个例子,我们可以用SET命令创建一个字符串键,然后用GET命令和APPEND命令操作这个键,<br>但如果我们试图对这个键执行只有列表键才能执行的LLEN命令,那么Redis将向我们返回一个类型<br>错误<br>```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>127.0.0.1:6379> GET msg<br>"hello world"<br>127.0.0.1:6379> APPEND msg " again!"<br>(integer) 18<br>127.0.0.1:6379> GET msg<br>"hello world again!"<br>127.0.0.1:6379> LLEN msg<br>(error) WRONGTYPE Operation against a key holding the wrong kind of value<br>```<br>
类型检查的实现。<br>Redis为了确保只有指定类型的键可以执行某些特定的命令,在执行一个类型特定的命令<br>之前,Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。<br>类型特定命令所进行的类型检查是通过redisObject结构的type属性来实现的:<br>1.在执行一个类型特定命令之前,服务器会先检查输入数据库键的值对象是否为执行命令所需<br>的类型,如果是的话,服务器就对键执行指定的命令;<br>2.否则,服务器将拒绝执行命令,并向客户端返回一个类型错误<br>
举个例子,对于LLEN命令来说:<br>1.在执行LLEN命令之前,服务器会先检查输入数据库键的之对象是否为列表类型,<br>也即是,检查值对象redisObject结构type属性的值是否为REDIS_LIST.如果是的话,<br>服务器就对键执行LLEN命令<br>2.否则的话,服务器就拒绝执行命令并向客户端返回一个类型错误。<br>检查过程如图<br>
多态命令的实现。<br>Redis除了会根据值对象的类型来判断是否能够执行指定命令之外,还会根据值对象的编码方式,<br>选择正确的命令实现代码来执行命令。<br><br>现在,考虑这样一个情况,如果对一个键执行LLEN命令,那么服务器除了要确保执行命令的是<br>列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现:<br>1.如果列表对象的编码为ziplist,那么说明列表对象的实现为压缩列表,程序将使用ziplistLen函数<br>来返回列表的长度<br>2.如果列表对象的编码为linkedlist,那么说明列表对象的实现为双端链表,程序将使用listLength<br>函数来返回双端链表的长度<br><br>用面向对象的术语来说,可以认为LLEN命令是多态的,只要执行LLEN命令的是列表键,那么无论值<br>对象使用的是ziplist编码还是linkedlist编码,命令都可以正常执行<br><br>实际上,可以将DEL、EXPIRE、TYPE等命令也称多态命令,因为无论输入的键是什么类型,这些<br>命令都可以正确地执行,。<br><br><b>DEL、EXPIRE等命令和LLEN等命令地区别在于,前者是基于类型地多态——一个命令可以同时用于<br>处理多种不同类型地键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码</b><br>
举个例子,列表对象有ziplist和linkedlist两种编码可用,其中前者使用压缩列表API<br>来实现列表命令,而后者则使用双端链表API来实现列表命令。<br>
如图展示了LLEN命令从类型检查到根据编码选择实现函数的整个执行过程,<br>其他类型特定命令的执行过程也是类似<br>
内存回收。<br>
概述。<br>因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个<br>引用计数(reference counting)技术实现的内存回收机制,通过这一机制,<br>程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并<br>进行内存回收。每个对象的引用计数信息由redisObject结构的refcount属性记录:<br>```c<br>typedef struct redisObject {<br> // ...<br> <br> // 引用计数<br> int refcount;<br> // ...<br>} robj;<br>```<br>对象的引用计数信息会随着对象的使用状态而不断变化:<br>1.在创建一个新对象时,引用计数的值会被初始化为1<br>2.当对象被一个新程序使用时,它的引用计数值会被增一<br>3.当对象不再被一个程序使用时,它的引用计数值会被减一<br>4.当对象的引用计数值变为0时,对象所占用的内存会被释放<br>
对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。<br>举个例子,以下代码展示了一个字符串对象从创建到释放的整个过程<br>```c<br>// 创建一个字符串对象s,对象的引用计数为1<br>robj *s = createStringObject(....);<br><br>// 对象s执行各种操作...<br><br>// 将对象s的引用计数减一,使得对象的引用计数变为0<br>// 导致对象s被释放<br>decrRefCount(s);<br>```<br>其他不同类型的对象也会经历类似的过程<br>
对象共享。<br>
概述。<br>除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。<br><br>在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:<br>1.将数据库键的值指针指向一个现有的值对象<br>2.将被共享的值对象的引用计数增一<br><br>目前来说,Redis在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999<br>的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些<br>共享对象,而不是新创建对象<br>
举个例子,假设键A创建了一个包含整数值100的字符串对象作为值对象,<br>如图所示<br>
如果这时键B也要创建一个同样保存了整数100的字符串对象作为之对象,<br>那么服务器有以下两种做法:<br>1.为键B新创建一个包含整数值100的字符串对象<br>2.让键A和键B共享同一个字符串<br>以上两种方法明显是第二种方法更节约内存<br>
举个例子,如图所示展示了包含整数值100的字符串对象同时被键A和键B<br>共享之后的样子,可以看到,除了对象的引用计数从之前的1变成了2之外,<br>其他属性都没有变化,共享对象机制对于节约内存非常有帮助,数据库中<br>保存的相同的值对象越多,对象共享机制就能节约越多的内存<br>
例如,假设数据库中保存了整数值100的键不只有键A和键B两个,而是有一百个,<br>那么服务器只需要用一个字符串对象的内存就可以保存原本需要使用一百个字符串<br>对象的内存才能保存的数据。<br>
举个例子,如果创建一个值为100的键A,并使用OBJECT REFCOUNT命令查看<br>键A的值对象的引用计数,就会发现值对象的引用计数为2<br>```c<br>127.0.0.1:6379> SET A 100<br>OK<br>127.0.0.1:6379> OBJECT REFCOUNT A<br>(integer) 2<br>```<br>引用这个值对象的两个程序分别时持有这个之对象的服务器程序,以及共享这个值对象<br>的键A,如图所示。如果此时再创建一个值为100的键B,那么键B也会指向包含整数值100<br>的共享对象,使得共享对象的引用计数值变为3,如图所示<br>```c<br>127.0.0.1:6379> SET B 100<br>OK<br>127.0.0.1:6379> OBJECT REFCOUNT A<br>(integer) 3<br>127.0.0.1:6379> OBJECT REFCOUNT B<br>(integer) 3<br>```<br>
注意:创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS<br>常量来修改<br>
另外,这些共享对象不仅只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的<br>对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hastable编码的集合对象,<br>以及zset编码的有序集合对象)都可以使用这些共享对象。<br>
<b>为什么Redis不共享包含字符串的对象?</b><br>当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和<br>键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序<br>才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享目标和目标对象<br>是否完全相同所需的复杂度就会越高,消耗的CPU时间也会越多:<br>1.如果共享对象是保存整数值的字符串,那么一年挣操作的复杂度为O(1)<br>2.如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)<br>3.如果共享对象是包含了多个值(或者对象)的对象,比如列表对象或者哈希对象,那么验证操作<br>的复杂度为O(N^2)<br>因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含<br>整数值的字符串对象进行共享<br>
对象的空转时长。
概述。<br>redisObject除了type、encoding、ptr和refcount四个属性之外,还包含最后一个属性lru属性,<br>该属性记录了对象最后一次被命令程序访问的时间<br>```c<br>typedef struct redisObject {<br> // ...<br> <br> unsigned lru:22;<br> <br> // ....<br> <br>} robj;<br>```<br>OBJECT IDLETIME命令可以打印出给定键的空转市场,这一空转时长就是通过将当前时间减去键<br>的之对象的lru时间计算得出的:<br>
```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>// 等待一小段时间<br>127.0.0.1:6379> OBJECT IDLETIME msg<br>(integer) 11<br>// 等待一阵子<br>127.0.0.1:6379> OBJECT IDLETIME msg<br>(integer) 16<br>// 访问msg键的值<br>127.0.0.1:6379> GET msg<br>"hello world"<br>// 键处于活跃状态,空转时长为0<br>127.0.0.1:6379> OBJECT IDLETIME msg<br>(integer) 5<br>```
注意:<br>OBJECT IDLETIME命令的实现比较特殊,这个命令在访问键的值对象时,不会修改值对象的lru属性<br>
除了可以被OBJECT IDLETIME命令打印出来之外,键的空转时长还有另外一项作用:<br>如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者<br>allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,<br>空转时长较高的那部分键会优先被服务器释放,从而回收内存。<br>
设置键的生存时间或过期时间
概述。<br>通过EXPIRE命令或者PEXIPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间<br>(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键:<br>```c<br>127.0.0.1:6379> SET key value<br>OK<br>127.0.0.1:6379> EXPIRE key 5<br>(integer) 1<br>// 5秒之内<br>127.0.0.1:6379> GET key<br>"value"<br>// 5秒之后<br>127.0.0.1:6379> GET key<br>(nil)<br>```<br>注意:<br>SETEX命令可以在设置一个字符串键的同时为键设置过期时间,因为这个命令是一个类型限定的命令<br>(只能用于字符串键)<br>与EXPIRE命令和PEXPIRE命令类似,客户端可以通过EXPIREAT命令或PEXPIREAT命令,以秒或者毫秒<br>精度给数据库中的某个键设置过期时间(expire time)<br>过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键<br>```c<br>127.0.0.1:6379> SET key value<br>OK<br>127.0.0.1:6379> EXPIREAT key 1710944249<br>(integer) 1<br>// 过期时间之前<br>127.0.0.1:6379> GET key<br>"value"<br>// 过期时间之后<br>127.0.0.1:6379> GET key<br>(nil)<br>```<br>
TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,<br>也就是,返回距离这个键被服务器自动删除还有多长时间<br>```c<br>127.0.0.1:6379> SET key value<br>OK<br>127.0.0.1:6379> EXPIRE key 1000<br>(integer) 1<br>127.0.0.1:6379> TTL key<br>(integer) 996<br>```<br>
读写键空间时的维护操作。<br>当使用Redis命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作还会执行一些额外的维护操作,其中包括<br>1.在读取一个键之后(读操作和写操作都要对键进行读取),服务器会根据键是否存在来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数,这两个值可以在INFO stats命令的keyspace_hits属性和keyspace_misses属性中查看<br>2.在读取一个键之后,服务器会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的闲置时间,使用OBJECT idle time key命令可以查看键的闲置时间。<br>3.如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键然后才执行余下的操作<br>4.如果又客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务程序注意到这个键已经被修改过<br>5.服务器每次修改一个键之后,都会对脏(dirty)键计数器的值增1,这个计数器会触发服务器的持久化以及复制操作<br>6.如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知<br>
设置过期时间。<br>Redis有四个不同的命令可以用于设置键key的生存时间(键可以存在多久)或过期时间(键什么时候会被删除)<br>1.EXPIRE <key> <ttl> 命令用于将键key的生存时间设置为ttl秒<br>2.PEXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl毫秒<br>3.EXPIREAT<key><timestamp> 命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳<br>4.PEXIREAT<key><timestamp>命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳<br>虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXIPIRE、EXPIREAT三个命令都是使用<br>PEXIPREAT命令来实现的:无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果<br>都和执行PEXIPIREAT命令一样<br>首先,EXPIRE命令可以转换成PEXIPIRE命令<br>```c<br>def EXPIRE(key, ttl_in_sec):<br>#将TTL从秒转换成毫秒<br>ttl_in_ms = sec_to_ms(ttl_in_ec)<br>```<br>接着,PEXIRE命令又可以转换成PEXIREAT命令:<br>```c<br>def PEXIPRE(key, ttl_in_ms):<br># 获取以毫秒计算的当前UNIX时间戳<br>now_ms = get_curent_unix_timestamp_in_ms();<br># 当前时间加上TTL,得出毫秒格式的键过期时间<br>PEXIREAT(key,now_ms+ttl_in_ms)<br>```<br>并且,EXPIREAT命令也可以转换成PEXIREAT命令:<br>```c<br>def EXPIREAT (key, expire_time_in_sec):<br># 将过期时间从秒转换为毫秒<br>expire_time_in_ms = sec_to_ms(expire_time_in_sec)<br>PEXPIREAT(key, expire_tiime_in_ms);<br>```<br>最终,EXPIRE、PEXPIRE和EXPIREAT三个命令都会转化成PEXIREAT命令来执行<br>
保存过期时间。<br>redisDb结构的expire字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:<br>1.过期字典的键是一个指针,这个指针指向键是一个指针,这个指针指向键空间的某个键对象<br>(也即是某个数据库键)<br>2.过期字典的值是一个long long类型的整数。这个整数保存了键所指向的数据库键的过期时间<br>——一个毫秒精度的UNIX时间戳。<br>```c<br>typedef struct redisDb {<br> // ..<br> <br> // 过期字典,保存着键的过期时间<br> dict *expire;<br> <br> // ...<br>}redisDb;<br>```<br>
举个例子,图中的过期字典保存了两个键值对:<br>1.第一个键值对的键为alphabet键对象,值为1385877900000,这表示数据库键<br>alphabet的过期时间为1385877600000(2013年12月)<br>2.第二个键值对的键为book键对象,值为1388556000000,这表示数据库键book<br>的过期时间为1388556000000(2014年1月1日零时)<br>当客户端执行PEXPIREAT命令(或者其他三个会转换成PEXIPREAT命令的命令)为一个<br>数据库键设置过期时间时,服务器会在数据库的过期字典中关联给定的数据库和过期时间<br><div></div>
过期键删除策略。
概述。<br>数据库键的过期时间都保存在过期字典中,并且知道根据过期时间去判断一个键是否过期,<br>剩下的问题是:如果一个键过期了,那么它什么时候会被删除呢?<br>这个问题有三种可能的答案,它们分别代表了三种不同的删除策略:<br>1.定时删除:在是个照顾键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期<br>时间来临时,立即执行对键的删除操作<br>2.惰性删除:放任键过期不管,每次从键空间中获取键时,检查取得的键是否过期,如果过期的<br>话,就删除该键;如果没有过期,就返回该键<br>3.定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除<br>多少过期键,以及要检查多少个数据库,则由算法决定<br>在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略<br>
定时删除。<br>定时删除策略对内存时最优化的:通过使用定时器,定时删除策略可以保证过期键会尽快地被删除,<br>并释放过期键所占用地内存。另一方面,定时删除策略的缺点就是,它是对CPU时间最不友好的,<br>在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,在内存不紧张<br>但是CPU时间非常紧张的情况下,将CPU时间用在删除和当前任务无关的过期键上,无疑会对服务器<br>的响应时间和吞吐量造成影响。<br>
例如,如果正有大量的命令请求在等待服务器处理,并且服务器当前不缺少内存,那么服务器应该<br>优先将CPU时间用在处理客户端的命令请求上面,而不是用在删除过期键上面<br>
除此之外,创建一个定时器需要用到Redis服务器中的时间事件,而当前事件事件的实现方式——<br>无序链表,查找一个事件的事件复杂度为O(N)——并不能高效地处理大量时间事件。<br>因此,要让服务器创建大量的定时器,从而实现定时删除策略,在现阶段来说并不现实<br>
惰性删除。<br>惰性删除策略对CPU事件来说是最友好的:程序只会在取出键时才对键进行过期检查,这可以保证<br>删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限当前处理的键,这个策略<br>不会在删除其他无关的键上花费任何CPU事件。<br>惰性删除的缺点是,它对内存是最不友好的,如果一个键已经过期,而这个键又仍然保留在数据库<br>中,那么只要这个过期键不被删除,它所占用的内存就不会释放。<br>在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,<br>那么它们也许永远不会被删除(除非用户手动执行FLUSHDB),我们甚至可以将这种情况看作是一种<br>内存泄漏——无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态<br>非常依赖于内存的Redis服务器来说,肯定不是一个好消息<br>
举个例子,对于一些和时间相关的数据,比如日志(log),在某个时间点之后,对他们的访问就会<br>大大减少,甚至不再访问,如果这类过期数据大量地积压在数据库中,用户以为服务器已经自动<br>将它们删除了,但实际上这些键仍然存在,而且键所占用地内存也没有释放,那么造成的后果肯定<br>是非常严重的。<br>
定期删除。<br>从定时删除和惰性删除分析来看,这两种删除方式在单一使用时都有明显的缺陷:<br>1.定时删除占用太多CPU时间,影响服务器的响应时间和吞吐量<br>2.惰性删除浪费太多内存,有内存泄漏的危险<br>定期删除策略是前两种策略的一种整合折中:<br>1.定期删除策略每隔一段时间执行一次删除过期操作,并通过限制删除操作执行的时长和频率<br>来减少删除操作对CPU时间的影响<br>2.除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费<br>定期删除策略的难点是确定删除操作执行的时长和频率<br>1.如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,<br>以至于将CPU时间过多地消耗在删除过期键上面<br>2.如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,<br>出现浪费内存的情况<br>因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作地执行时长和<br>执行频率<br>
Redis的过期键删除策略。
概述。<br>Redis服务器实际使用地是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,<br>服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡<br>
惰性删除策略的实现。<br>过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在<br>执行之前都会调用expireIfNeed函数对输入键进行检查:<br>1.如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除<br>2.如果输入键未过期,那么expireIfNeeded函数不做动作<br>另外,因为每个被访问的键都可能<br>存在过期而被expireIfNeeded函数删除,所以每个命令的实现函数都必须同时处理<br>键存在以及键不存在这两种情况:<br>1.当键存在时,命令按照键存在的情况执行<br>2.当键不存在或者因为过期而被expireIfNeeded函数删除时,命令按照键不存在的<br>情况执行<br>
命令调用expireIfNeeded函数的过程。<br>expireIfNeeded函数就像一个过滤器,它可以在命令真正执行之前,过滤掉<br>过期的输入键,从而避免命令接触到过期键。<br>
举个例子,如图所示展示了GET命令的执行过程,在这个执行过程中,命令需要<br>判断键是否存在以及键是否过期,然后根据判断来执行合适的动作<br>
定期删除策略的实现。<br>过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期<br>性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的<br>时间内分多次调用服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的<br>过期时间,并删除其中的过期键。<br>
```c<br># 默认每次检查的数据库数量<br>DEFAULT_DB_NUMBERS = 16<br># 默认每个数据库检查的键数据量<br>DEFAULT_KEY_NUMBERS = 20;<br><br># 全局变量,记录检查进度<br>current_db= 0<br><br>def activeExpireCycle() :<br> # 初始化要检查的数据库数量<br> # 如果服务器的数据库数量比DEFAULT_DB_NUMBERS药效<br> # 那么以服务器的数据库数量为准<br> if server.dbnum < DEFAULT_DB_NUMBERS:<br> db_numbers = server.dbnum<br> else:<br> db_numbers = DEFAULT_DB_NUMBERS<br> <br> <br> # 遍历各个数据库<br> for i in range(db_numbers):<br> # 如果current_db的值等于服务器的数据库数量<br> # 这表示检查程序已经遍历了服务器的所有数据库一次<br> # 将current_db重置为0,开始新的一轮遍历<br> if current_db = server.dbnum<br> current_db = 0<br> <br> # 获取当前要处理的数据库<br> redisDb = server.db[current_db]<br> <br> # 将数据库索引增1,指向下一个要处理的数据库<br> current_db += 1<br> <br> # 检查数据库键<br> for j in range(DEFAULT_KEY_NUMBERS):<br> # 如果数据库中没有一个键带有过期时间,那么跳过这个数据库<br> if redisDb.expires.size() == 0: break<br> # 随机获取一个带有过期时间的键<br> key_with_ttl = redisDb.expires.get_random_key()<br> <br> # 检查键是否过期,如果过期就删除它<br> if is_expired(key_with_ttl):<br> delete_key(key_with_ttl)<br> # 已达到时间上线,停止处理<br> if reach_time_limit() :return<br>```<br>伪代码实现<br>
activeExpireCycle函数的工作模式可以总结如下:<br>1.函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键<br>2.全局变量current_db会记录当前activeExpireCycle函数检查的进度,在下一次activeExpireCycle函数<br>被调用时,接着上一次的进度进行处理,比如说,如果当前activeExpireCycle函数在遍历10号数据库时<br>返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键<br>3.随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将<br>current_db变量重置为0,然后再次开始新一轮的检查工作<br>
AOF、RDB和复制功能对过期键的处理。
生成RDB文件。<br>在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的<br>键进行检查,已过期的键不会被保存到新创建的RDB文件中。<br>
举个例子,如果数据库中包含三个键k1、k2、k3,并且k2已经过期,那么当执行SAVE命令<br>或者BGSAVE命令时,程序只会将k1和k3的数据保存到RDB文件中,而k2则会被忽略。因此,<br>数据库中包含过期键不会对新生成的RDB文件造成影响。<br>
载入RDB文件。<br>在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:<br>1.如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行<br>检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件<br>的主服务器不会造成影响。<br>2.如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否<br>过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的<br>数据就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响<br>
举个例子,如果数据库中包含三个键k1、k2、k3,并且k2已经过期,那么当服务器<br>启动时:<br>1.如果服务器以主服务器模式运行,那么程序只会将k1和k3载入到数据库,k2会被忽略<br>2.如果服务器以从服务器模式运行,那么k1、k2和k3都会被载入到数据库<br>
AOF文件写入。<br>当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但他还没有被<br>惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。<br>当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,<br>来显式地记录该键已被删除。<br>
举个例子。如果客户端使用GET message命令,试图访问过期的message键,那么服务器<br>将执行以下三个动作:<br>1.从数据库中删除message键<br>2.追加一条DEL message命令到AOF文件<br>3.向执行GET命令的客户端返回空回复<br>
AOF重写。<br>和生成RDB文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,<br>已过期的键不会被保存到重写后的AOF文件中。<br>
举个例子,如果数据库中包含三个键,k1、k2、k3,并且k2已经过期,那么在进行重写工作时,<br>程序只会对k1和k3进行重写,而k2则会被忽略。因此,数据库中包含过期键不会对AOF重写<br>造成影响。<br>
复制。<br>当服务器运行在复制模式下,从服务器的过期删除动作由主服务器控制:<br>1.主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知<br>从服务器删除这个过期键<br>2.从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是<br>继续像处理未过期的键一样来处理过期键<br>3.从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键<br><br>通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,<br>也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在<br>从服务器里的复制品也会继续存在<br>
举个例子,有一对主从服务器,它们的数据库中都保存着同样的三个键message、xxx和yyy,<br>其中message位过期键<br>
如果这时有客户端向从服务器发送命令GET message,那么从服务器将发现message键已经过期,<br>但从服务器并不会删除message键,而是继续将message键的值返回给客户端,就好像message<br>键并没有过期一样<br>
假设在此之后,有客户端向主服务器发送命令GET message,那么主服务器将发现键message已经过期:<br>主服务器会删除message键,向客户端返回空回复,并向从服务器发送DEL message命令<br>
从服务器在接收到主服务器发来的DEL message命令之后,也会从数据库中删除message键,在这<br>之后,主从服务器都不再保存过期键message了<br>
LRU(Least Recently Used,最近最少使用)
概述。<br>Redis作为缓存使用时,一些场景下要考虑内容的空间消耗问题。Redis会删除过期键以释放空间,过期键的删除策略<br>有两种:<br>1.惰性删除:每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回<br>该键。<br>2.定期删除:每隔一段事件,程序就对数据库进行一次检查,删除里面的过期键。<br>Redis也可以开启LRU功能来自动淘汰一些键值对。<br>
举个例子,图中的A每隔5s访问一次,B每2s访问一次,C与D每10s访问一次,| 代表计算空闲时间<br>的截止点。可以看到,LRU对ABC工作的很好,完美预测了将来被访问到的概率B>A>C,但对于D<br>却预测了最少的空闲时间,但总体来说,LRU算法已经是一个性能足够好的算法了<br>
LRU配置参数。<br>Redis配置中和LRU有关的有三个:<br>1.maxmemory:配置Redis存储数据时指定限制的内存大小,比如100m。当缓存消耗的内存超过这个数值时,将触发数据<br>淘汰。该数据配置为0时,表示缓存的数据量没有限制,即LRU功能不生效。64位的系统默认值为0,32位的系统默认内存<br>限制为3GB<br>2.maxmemory_policy:触发数据淘汰后的淘汰策略<br>3.maxmemory_samples:随机采样的精度,也就是随机取出key的数目。该数值配置越大,越接近真实的LRU算法,但是<br>数值越大,相应消耗也变高,对性能有一定影响,样本值默认为5.<br>
淘汰策略。<br>淘汰策略即maxmemory_policy的赋值有以下几种:<br>1.noeviction:如果缓存数据超过了maxmemory限定值,并且客户端正在执行的命令(大部分的写入指令,但DEL和几个指令例外)<br>会导致内存分配,则向客户端返回错误相应<br>2.allkeys-lru:对所有的键都采取LRU淘汰<br>3.volatile-lru:仅对设置了过期时间的键采取LRU淘汰<br>4.allkeys-random:随机回收所有的键<br>5.volatile-random:随机回收设置过期时间的键<br>6.volatile-ttl:仅淘汰设置了过期时间的键---淘汰生存时间TTL(Time To Live)更小的键<br>volatile-lru,volatile-random和volatile-ttl这三个淘汰策略使用的不是全量数据,有可能无法淘汰出足够的内存空间,在没有过期<br>键或者没有设置超时属性的键的情况下,这三种策略和noeviction差不多。<br>一般的经验规则:<br>1.使用allkeys-lru策略:当预期请求符合一个幂次分布(二八法则等),比如一部分的子集元素比其他元素被访问的更多时,可以选择<br>这个策略<br>2.使用allkeys-random:循环连续的访问所有的键时,或者与其请求分布平均(所有元素被访问的概率都差不多)<br>3.使用volatile-ttl:要采取这个策略,缓存对象的TTL值最好有差异<br><br>volatile-lru和volatile-random策略,当你想要使用单一的Redis实例来同时实现缓存淘汰和持久化一些经常使用的键集合时很有用。<br>未设置过期时间的键进行持久化保存,设置了过期时间的键参与缓存淘汰。不过一般运行两个实例是解决这个问题的更好办法<br><br>为键设置过期时间是需要消耗内存的,,所以使用allkeys-lru这种策略更加节省空间,因为这种策略可以不为键设置过期时间。<br>
近似LRU算法。<br>我们知道,LRU算法需要一个双向链表来记录数据的最近被访问顺序,但是处于节省内存的考虑,Redis的LRU算法并非完整的实现。<br>Redis并不会选择最久未被访问的键进行回收,相反它会尝试运行一个近似LRU的算法,通过对少量键进行取样,然后回收其中最久未被<br>访问的键,通过调整每次回收的采样杨数量maxmemory-samples,可以实现调整算法的精度。根据Redis作者的说法,每个RedisObject<br>可以挤出24bits,但24bits是不够存储两个指针的,而存储一个低位时间戳是足够的,RedisObject以秒为单位存储了对象新建或者更新时的<br>unix time,也就是LRU clock,24bits 能表示的最大数值为2^24-1,即16777215秒,16777215 / 86400(一天的秒数) = 194.56 ,因此得出<br>最长的时间跨度是约194天而缓存的数据更新非常频繁,已经足够了。<br>Redis的键空间是放在一个哈希表中的,要从所有的键中选出一个最久未被访问的键,需要另外一个数据结构存储这些源信息,这显然不划算,<br>最初,Redis只是随机的选3个key,然后从中淘汰,后来算法改进到了N个key,默认是5个。Redis3.0之后又改善了算法的性能,会提供一个<br>待淘汰候选key的pool,里面默认有16个key,按照空闲事件排好序。更新时从Redis键空间随机选择N个key,分别计算它们的空闲事件idle,<br>key只会在pool不满或者空闲时间大于pool里最小的时候,才会进入pool,然后从pool中选择空闲时间最大的key淘汰掉。<br>
真实LRU算法与近似LRU的算法可以通过下面的图像对比。<br>浅灰色是已经被淘汰的对象,灰色带是没有被淘汰的对象。可以看出,maxmemory-samples值为5时,<br>Redis3.0效果要比Redis2.0要好,使用10个采样大小的Redis3.0的近似LRU算法已经非常接近理论的性能,<br>数据访问模式非常接近幂次分布时,也就是大部分的访问集中于部分键时,LRU近似算法会处理得很好。<br>在模拟实验的过程中,我们发现如果使用幂次分布的访问模式,真实LRU算法和近似LRU算法几乎没有差别<br>
LRU源码分析。<br>
Redis中的键与值都是redisObject对象<br>```c<br>typedef struct redisObject {<br> // 4位<br> unsigned type:4;<br> // 4位<br> unsigned encoding:4;<br> // 24位<br> // LRU time (相对于全局的lru_clock) or<br> // LFU data(低8位是频率,高16位是访问时间)<br> unsigned lru:LRU_BITS; <br> // 4个字节 32位<br> int refcount;<br> // 8个字节 64位<br> void *ptr;<br>} robj;<br>```<br>unsigned的低24bits的lru记录了 redisObj的LRU time
Redis命令访问缓存的数据,均会调用函数的lookupkey:<br>```c<br>robj *lookupKey(redisDb *db, robj *key, int flags) {<br> dictEntry *de = dictFind(db->dict,key->ptr);<br> if (de) {<br> robj *val = dictGetVal(de);<br><br> /* Update the access time for the ageing algorithm.<br> * Don't do it if we have a saving child, as this will trigger<br> * a copy on write madness. */<br> if (server.rdb_child_pid == -1 &&<br> server.aof_child_pid == -1 &&<br> !(flags & LOOKUP_NOTOUCH))<br> {<br> if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {<br> updateLFU(val);<br> } else {<br> val->lru = <font color="#e74f4c"><b>LRU_CLOCK()</b></font>;<br> }<br> }<br> return val;<br> } else {<br> return NULL;<br> }<br>}<br>```<br>该函数在策略为LRU(非LFU)时会更新对象的lru值,设置为LRU_CLOCK()值:<br>
```c<br>/* Return the LRU clock, based on the clock resolution. This is a time<br> * in a reduced-bits format that can be used to set and check the<br> * object->lru field of redisObject structures. */<br>unsigned int getLRUClock(void) {<br> return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;<br>}<br><br>/* This function is used to obtain the current LRU clock.<br> * If the current resolution is lower than the frequency we refresh the<br> * LRU clock (as it should be in production servers) we return the<br> * precomputed value, otherwise we need to resort to a system call. */<br>unsigned int LRU_CLOCK(void) {<br> unsigned int lruclock;<br> if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {<br> atomicGet(server.lruclock,lruclock);<br> } else {<br> lruclock = getLRUClock();<br> }<br> return lruclock;<br>}<br>```<br>LRU_CLOCK():取决于LRU_CLOCK_RESOLUTION(默认值1000),LRU_CLOCK_RESOLUTION<br>代表了LRU算法的精度,即一个LRU的单位是多长。server.hz代表服务器刷新的频率,如果服务器<br>的时间更新精度比LRU的精度值要小,LRU_CLOCK()直接使用服务器的时间,减少开销。<br>
Redis处理命令的入口是processCommand:<br>```<br>int processCommand(client *c) {<br><br> /* Handle the maxmemory directive.<br> *<br> * Note that we do not want to reclaim memory if we are here re-entering<br> * the event loop since there is a busy Lua script running in timeout<br> * condition, to avoid mixing the propagation of scripts with the<br> * propagation of DELs due to eviction. */<br> if (server.maxmemory && !server.lua_timedout) {<br> int out_of_memory = <b><font color="#e74f4c">freeMemoryIfNeededAndSafe(</font></b>) == C_ERR;<br> /* freeMemoryIfNeeded may flush slave output buffers. This may result<br> * into a slave, that may be the active client, to be freed. */<br> if (server.current_client == NULL) return C_ERR;<br><br> /* It was impossible to free enough memory, and the command the client<br> * is trying to execute is denied during OOM conditions or the client<br> * is in MULTI/EXEC context? Error. */<br> if (out_of_memory &&<br> (c->cmd->flags & CMD_DENYOOM ||<br> (c->flags & CLIENT_MULTI && c->cmd->proc != execCommand))) {<br> flagTransaction(c);<br> addReply(c, shared.oomerr);<br> return C_OK;<br> }<br> }<br>}<br>```<br>
freeMemoryIfNeededAndSafe()为释放内存的函数,源码见附件<br>几种淘汰策略maxmemory_policy就是在这个函数里面实现的。当采用LRU时,可以看到,从0号数据库开始(默认16个)<br>根据不同的策略,选择redisDb的dict(全部键)或者expires(有过期时间的键),,用来更新候选键池子pool,pool更新策略<br>是evictionPoolPopulate()<br>
<font color="#e74f4c"><b>evictionPoolPopulate函数</b></font><br>
Redis随机选择maxmemory_samples数量的key,然后计算这些key的空闲时间idle time,当满足条件时(比pool中的某些<br>键的空闲时间还大)就可以进pool。pool更新之后,就淘汰pool中空闲时间最大的键<br>estimateObjectIdleTime用来计算Redis对象的空闲时间:<br>```c<br>/* Given an object returns the min number of milliseconds the object was never<br> * requested, using an approximated LRU algorithm. */<br>unsigned long long estimateObjectIdleTime(robj *o) {<br> unsigned long long lruclock = LRU_CLOCK();<br> if (lruclock >= o->lru) {<br> return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;<br> } else {<br> return (lruclock + (LRU_CLOCK_MAX - o->lru)) *<br> LRU_CLOCK_RESOLUTION;<br> }<br>}<br>```<br>空闲时间基本就是对象的lru和全局的LRU_CLOCK()的差值乘以精度LRU_CLOCK_RESOLUTION,将秒转化为了毫秒<br>
Redis持久化
RDB快照(snapshot).<br>在默认情况下,Redis将内存数据库快照保存在名字为dump.rdb的二进制文件中.<br>Redis可以进行设置,让它在"N秒内数据集至少有M个改动"这一条件被满足时,自动<br>保存一次数据集。比如说,以下设置会让Redis在满足"60秒内有至少1000个键被改动"<br>这一条件时,自动保存一次数据集:<br># save 60 1000 // 关闭RDB只需要将所有的save保存策略注释掉即可<br>还可以手动执行命令生成RDB快照,进入redis客户端执行命令save或bgsave可以生成<br>dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并<br>覆盖原有rdb快照文件。<br>
bgsave的写时复制(COW)机制。<br>Redis借助操作系统提供的写时复制技术(Copy-On-Write, COW),在生成快照的同时,<br>依然可以正常处理写命令。简单来说,bgsave子进程是由主线程fork生成的,可以共享<br>主线程的所有内存数据。bgsave子进程运行后,开始读取主线程的数据,并把它们写入<br>RDB文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和bgsave子进程<br>互不影响,但是,如果主线程要修改一块数据,那么,这块数据就会被复制一份,生成<br>该数据的副本。然后,bgsave子进程会把这个副本数据写入RDB文件,而在这个过程中,<br>主线程仍然可以直接修改原来的数据。<br>配置自动生成RDB文件后台使用的是bgsave方式<br>
redis.conf文件中有示例值,如图所示。<br>Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器<br>进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行<br>BGSAVE命令<br>
RDB文件的创建与载入。<br>有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE.<br>SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器<br>不能处理任何命令请求:<br>```c<br>127.0.0.1:6379> save<br>OK<br>```<br>和SAVE命令直接阻塞服务器进程的做法不同,BGSAVE命令会派生出一个子进程,然后由子进程负责<br>创建RDB文件,服务器进程(父进程)继续处理命令请求:<br>```c<br>127.0.0.1:6379> BGSAVE<br>Background saving started<br>```<br>
SAVE和BGSAVE的区别。<br>创建RDB文件的实际工作由rdb.c/rdbSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用这个<br>函数,通过以下伪代码可以明显地看出这两个命令之间的区别:<br>```c<br>def SAVE():<br> # 创建RDB文件<br> rdbSave()<br> <br>def BGSAVE():<br> # 创建子进程<br> pid = fork()<br> <br> if pid == 0:<br> # 子进程负责创建RDB文件<br> rdbSave()<br> <br> # 完成之后向父进程发送信号<br> signal_parent();<br> elif pid > 0:<br> # 父进程继续处理命令请求,并通过轮询等待子进程的信号<br> handle_request_and_wait_signal()<br> else:<br> # 处理出错情况<br>```<br>
文件载入。<br>和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载入工作是在服务器启动自动执行的,<br>所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会<br>自动z载入RDB文件。以下时Redis服务器启动时打印的日志记录,其中第二条日志DB loaded from disk....<br>就是服务器在成功RDB文件之后打印的:<br>```c<br>[95568] 24 Mar 12:01:29.565 # Server started, Redis version 3.0.504<br>[95568] 24 Mar 12:01:29.565 * DB loaded from disk: 0.000 seconds<br>[95568] 24 Mar 12:01:29.566 * The server is now ready to accept connections on port 6379<br>```<br>
注意.<br>值得一提的是,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:<br>1.如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态<br>2.只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。<br>服务器判断该用哪个文件来还原数据库状态的流程如图所示。<br><br>服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入共工作完成为止。<br>
载入RDB文件的实际工作由rdb.c/rdbLoad函数完成,这个函数和rdbSave函数之间的关系<br>可以用图表示
SAVE命令执行时的服务器状态.<br>当SAVE命令执行时,Redis服务器会被阻塞,所以当SAVE命令正在执行时,客户端发送的<br>所有命令请求都会被阻塞。只有在服务器执行完SAVE命令、重新开始接受命令请求之后,客户端<br>发送的命令才会被处理<br>
BGSAVE命令执行时的服务器状态。<br>因为BGSAVE的保存工作是由子进程执行的,所以在子进程创建RDB文件的过程中,Redis服务器<br>仍然可以继续处理客户端的命令请求,但是,在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、<br>BGREWRITEAOF三个命令的方式会和平时有所不同.<br>首先,在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE<br>命令执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件。<br>其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE<br>会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件,代码如下<br>```c<br>127.0.0.1:6379> BGSAVE<br>Background saving started<br>127.0.0.1:6379> BGSAVE<br>(error) ERR Background save already in progress<br>```<br>最后,BGREWRITEAOF和BGSAVE两个命令不能同时执行:<br>1.如果BGSAVE命令正在z执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕<br>之后执行<br>2.如果BGREWRITEAOF和BGSAVE两个命令的实际工作都由子进程执行,所以这两个命令在操作方面并没有<br>什么冲突的地方,不能同时执行它们只是一个性能方面的考虑——并发出两个子进程,并且这两个子进程都<br>同时执行大量的磁盘写入操作<br>
自动间隔保存。<br>当Redis服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save选项,<br>如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件:<br>```c<br>save 900 1<br>save 300 10<br>save 60 10000<br>```<br>接着,服务器程序会根据save选项所设置的保存条件,设置服务器状态redisServer结构的saveparams<br>属性:<br>```c<br>struct redisServer {<br> // ...<br> <br> // 记录了保存条件的数组<br> struct saveparam *saveparams;<br> <br> // ...<br>};<br>```<br>saveparams属性是一个数组,数组中的每隔元素是一个saveparam结构,每隔saveparam结构都保存了<br>一个save选项设置的保存ll额一个save选项设置的保存条件:<br>```c<br>struct saveparam {<br> <br> // 秒数<br> time_t seconds;<br> <br> // 修改<br> int changes;<br>};<br>```<br>
举个例子,比如说,如果save选项的值为以下条件<br>```c<br>save 900 1<br>save 300 10<br>save 60 10000<br>```<br>那么服务器状态中的saveparams数组将会是如图所示<br>
dirty计数器和lastsave属性。<br>服务器除了维护saveparams数组之外,还维持着一个dirty计数器,以及一个lastsave属性:<br>1.dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)<br>进行了多少次修改(包括写入、删除、更新等操作)<br>2.lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间<br>```c<br>struct redisServer {<br> // ...<br> <br> // 修改计数器<br> long long dirty;<br> // 上一次执行保存的时间<br> time_t lastsave;<br> // ...<br>}<br>```<br>当服务器成功执行一个数据库修改命令之后,程序就会对dirty计数器进行更新:命令修改了多少次数据库,dirty计数器的<br>值就增加多少。<br>
例如。如果我们为一个字符串键设置值:<br>```c<br>127.0.0.1:6379> SET message "hello"<br>OK<br>```<br>那么程序会将dirty计数器的值增加1。<br>
又例如,如果像一个集合键增加三个新元素:<br>```c<br>127.0.0.1:6379> SADD database Redis MongoDB MariaDB<br>(integer) 3<br>```<br>那么程序会将dirty计数器的值增加3。<br>
如图所示,该图展示了服务器状态中包含的dirty计数器和lastsave属性,<br>说明如下:<br>1.dirty计数器的值为123,表示服务器在上次保存之后对数据库状态共进行了123次修改<br>2.lastsave属性则记录了服务器上次执行保存的时间1378270800<br>
检查保存条件是否满足。<br>以下伪代码z展示了serverCron函数检查保存条件的过程:<br>```python<br>def serverCron():<br> # ...<br> <br> # 遍历所有保存条件<br> for saveparam in servr.saveparams:<br> # 计算距离上次执行保存操作有多少秒<br> save_interval = unixtime_now() - server.lastsave<br> <br> # 如果数据库状态的修改次数超过条件所设置的次数<br> # 并且距离上次保存的时间超过条件设置的时间<br> # 那么执行保存操作<br> if server.dirty >= saveparam.changes and <br> save_interval > saveparam.seconds:<br> <br> BGSAVE()<br>```<br>程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,<br>那么服务器就会执行BGSAVE命令。<br>
举个例子,如果Redis服务器的当前状态如图所示.<br>那么当时间来到1378271101,也即是1378270800的301秒之后,<br>服务器将自动执行一次BGSAVE命令,因为saveparams数组的第二个保存条件<br>——300秒之内有至少10次修改——已经被满足。<br>
假设BGSAVE在执行5秒之后完成,那么如图所示的服务器状态将更新为如图所示。<br>其中dirty计数器已经被重置为0,而lastsave属性也被更新为1378271106<br>
AOF(append-only file)<br>快照功能并不是非常耐久(durable):如果Redis因为某些原因而造成故障停机,那么服务器<br>将丢失最近写入、且仍未保存到快照中的那些数据,从1.1版本开始,Redis增加了一种完全<br>耐久的持久化方式:AOF持久化,将修改的每一条指令记录进文件appendonly.aof中(先写入<br>OS Cache,每隔一段时间fsync到磁盘)<br>
比如执行命令"set aaaaa 666",aof文件会记录如下数据,这是一种resp协议格式数据,星号后面的数字<br>代表命令有多少个参数,$号后面的数字代表这个参数有几个字符。<br>注意,如果执行带有过期时间的set命令,aof文件里记录的并不是执行的原始命令,而是记录key过期的<br>时间戳<br>
比如执行"set aaaaaa 888 ex 1000",对应aof文件里记录如下<br>
可以通过修改配置文件来打开AOF功能:<br># appendonly yes<br>从现在开始,每当Redis执行一个改变数据集的命令时(比如SET),这个命令就会被追加到AOF文件<br>的末尾。这样的话,当Redis重新启动时,程序就可以通过重新执行AOF文件中的命令来达到重建<br>数据集的目的。还可以配置Redis多久才将数据fsync到磁盘一次.<br>推荐(并且也是默认)的措施为每秒fsync一次,这种fysnc策略可以兼顾速度和安全性<br>
AOF重写。<br>AOF文件可能有太多没用指令,所以AOF会定期根据内存的最新数据生成AOF文件,例如,<br>执行了如下几条命令:incre readcount<br>
重写后AOF文件里变成
如下两个配置可以控制AOF自动重写频率,当然AOF还可以手动重写,进入redis客户端执行命令<br>bgrewriteaof重写AOF,注意,AOF重写redis会fork出一个子进程去做(与bgsave命令类似),不会对<br>redis正常命令处理有太多影响<br>
AOF文件重写的实现。<br>虽然Redis将生成新AOF文件替换旧AOF文件的功能命名为"AOF文件重写",但实际上,AOF文件<br>重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的<br>数据库状态来实现的。<br>
举个例子,如果对服务器对list键执行了以下命令:<br>```c<br>127.0.0.1:6379> RPUSH list "A" "B" // ["A", "B"]<br>(integer) 2<br>127.0.0.1:6379> RPUSH list "C" // ["A", "B", "C"]<br>(integer) 3<br>127.0.0.1:6379> RPUSH list "D" "E" // ["A", "B", "C", "D", "E"]<br>(integer) 5<br>127.0.0.1:6379> LPOP list // ["B", "C", "D", "E"]<br>"A"<br>127.0.0.1:6379> LPOP list // ["C", "D", "E"]<br>"B"<br>127.0.0.1:6379> RPUSH list "F" "G" // ["C", "D", "E", "F", "G"]<br>(integer) 5<br>```<br>那么服务器为了保存当前list键的状态,必须在AOF文件中写入六条命令。<br>如果服务器想要用尽量少的命令来记录list键的状态,那么最简单高效的办法<br>不是去读取和分析现有的AOF文件的内容,而是直接从数据库中读取键list的值,<br>然后用一条RPUSH list "C" "D" "E" "F" "G"命令来代替保存在AOF文件中的六条命令<br>这样旧可以将保存list键所需的命令从六条减少为一条了<br>
注意:<br>在实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、<br>哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键所包含的元素<br>数量,如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的<br>值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。<br>REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值为64,这也就是说,如果一个集合键<br>包含了超过64个元素,那么重写程序会用多条SADD命令来记录这个集合并且每条命令<br>设置的元素数量也为64个:<br>```c<br>SADD <set-key> <elem1><elem2>....<elem64><br>SADD <set-key> <elem65><elem66>...<elem128><br>SADD <set-key> <elem129><elem130>...<elem192><br>```<br>另一方面如果一个列表键包含了超过64个项,那么重写程序会用多条RPUSH命令来保存这个集合,<br>并且每条命令设置的项数量也为64个<br>```c<br>RPUSH <list-key> <item1><item2>...<item64><br>RPUSH <list-key> <item65><item66>...<item128><br>RPUSH <list-key> <item129><item130>...<item192><br>```<br>重写程序使用类似的方法处理包含多个元素的有序集合键,以及包含多个键值对的哈希表键<br>
AOF后台重写。<br>虽然AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务,但是,因为这个<br>函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因为Redis服务器使用单个<br>线程来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,<br>服务器将无法处理客户端发来的命令请求。<br>很明显,作为一种辅佐性的维护手段,Redis不希望AOF重写造成服务器无法处理请求,所以Redis决定<br>将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:<br>1.子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求<br>2.子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的<br>安全性。<br>不过,使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要继续处理<br>命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后<br>的AOF文件所保存的数据库状态不一致。<br>
数据不一致问题。<br>为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在<br>服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个<br>写命令发送给AOF缓冲区和AOF重写缓冲区,如图所示。。<br>这也就是说,在子进程执行AOF重写期间,服务器进程需要执行以下三个工作:<br>1.执行客户端发来的命令<br>2.将执行后的写命令追加到AOF缓冲区<br>3.将执行后的写命令追加到AOF重写缓冲区<br>这样一来可以保证:<br>1.AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行<br>2.从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面<br>当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会<br>调用一个信号处理函数,并执行以下共做:<br>1.将AOF重写缓冲区中的所有内容写入到新的AOF文件中,这时新AOF文件所保存的数据库状态<br>将和服务器当前的数据库状态一致<br>2.对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧数据两个AOF文件的替换<br>这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了。<br>
阻塞问题。<br>在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候<br>AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。<br>
举个例子,图中展示i了一个AOF文件后台重写的执行过程:<br>1.当子进程开始重写时,服务器进程(父进程)的数据库中只有一个k1的键,当子进程<br>完成AOF文件重写之后,服务器进程的数据库中已经多处了k2、k3、k4三个新键<br>2.在子进程向服务器发送信号之后,服务器进程会将保存在AOF重写缓冲区里面记录<br>的k2/k3/k4三个键的命令追加到新AOF文件的末尾,然后用新的AOF文件替换旧文件<br>完成AOF文件后台重写操作<br>
AOF文件的写入与同步。<br>Redis服务器进程就是一个时间循环(loop),这个循环中的文件时间负责接收客户端的命令请求,<br>以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这样需要定时运行的函数。<br>因为服务器在处理文件事件时可能会执行些命令,使得一些内容被追加到aof_buf缓冲区里面,所以<br>在服务器每次结束一个事件循环之前,它都会调用flushAppendOnlyFile函数,考虑是否将aof_buf<br>缓冲区中的内容写入和保存到AOF文件里面,这个过程可以用以下伪代码表示<br>```c<br>def eventLoop():<br> while True :<br> # 处理文件事件,接收命令请求以及发送命令回复<br> # 处理命令请求时可能会有新内容被追加到aof_buf缓冲区中<br> processFileEvents()<br> <br> # 处理时间事件<br> processTimeEvents()<br> # 考虑是否要将aof_buf中的内容写入和保存到AOF文件里面<br> flushAppendOnlyFile()<br>```<br>flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定,各个不同的值<br>产生的行为也不同,如果用户没有主动为appendfsync选项设置值,那么appendfsync选项的<br>默认值为everysec<br>
文件的写入和同步。<br>为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,<br>操作系统通产会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满,或者超过了指定<br>的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。这种做法虽然提高了效率,但也为写入数据带来<br>了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失。为此,系统提供<br>了fsync和fdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而<br>确保写入数据的安全性<br>
AOF持久化的效率和安全性。<br>服务器配置appendfsync选项的值直接决定AOF持久化功能的效率和安全性。<br>1.当appendfsync的值为always时,服务器在每个事件循环都要讲aof_buf缓冲区中的所有内容写入到AOF文件<br>并且同步AOF文件,所以always的效率时appendfsync选项三个当中最慢的一个,但从安全性来说,always也是<br>最安全的,因为即使出现故障停机,AOF持久化也只会丢失一个事件循环中所产生的命令数据<br>2.当appendfsync的值为everysec时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件<br>并且每隔一秒就要在子线程中对AOF文件进行一次同步。从效率上来讲,everysec模式足够快,并且就算出现故障<br>停机,数据库也只丢失一秒钟的命令数据<br>3.当appendfsync的值为no时,服务器在每隔事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,至于<br>何时对AOF文件进行同步,则由操作系统控制。因为处于no模式下的flushAppendOnlyFile调用无须执行同步操作,<br>所以该模式下的AOF文件写入速度总是最快的,不过因为这种模式会在系统缓存中积累一段时间的写入数据,所以<br>该模式的单次同步时长通常是三种模式中时间最长的。从平摊操作的角度来看,no模式和everysec模式的效率类似,<br>当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据<br>
AOF文件的载入与数据还原。<br>因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍<br>AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。<br>Redis读取AOF文件并还原数据库状态的详细步骤如下:<br>1.创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而<br>载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有<br>网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端<br>执行命令的效果完全一样。<br>2.从AOF文件中分析并读取出一条写命令<br>3.使用伪客户端执行被读出的写命令<br>4.执行步骤2和步骤3,直到AOF文件中的所有写命令被处理完毕为止<br>当完成以上步骤之后,AOF文件所保存的数据库状态就会被万丈地还原出来,整个过程如图<br>
例如,对于以下AOF文件来说<br>```c<br>*2<br>$6<br>SELECT<br>$1<br>0<br>*5<br>$4<br>SADD<br>$6<br>fruits<br>$6<br>banana<br>$6<br>cherry<br>$5<br>apple<br>*3<br>$3<br>SET<br>$3<br>msg<br>$5<br>hello<br>*5<br>$5<br>RPUSH<br>$7<br>numbers<br>$3<br>128<br>$3<br>256<br>$3<br>512<br>```<br>
服务器首先读入并执行SELECT 0命令,之后是SET msg hello命令,在之后是SADD fruits apple banana cherry命令,<br>最后是RPUSH numbers 128 256 512 命令,当这些命令都执行完毕之后,服务器的数据库就被还原到之前的状态了,<br>整个重写过程可以用以下伪代码表示:<br>```c<br>def aof_rewrite(new_aof_file_name):<br> # 创建新的AOF文件<br> f = create_file(new_aof_file_name)<br> # 遍历数据库<br> for db in redisServr.db:<br> # 忽略空数据<br> if db.is_empth(): continue<br> # 写入SELECT命令,指定数据库索引<br> f.write_command("SELECT" + db.id)<br> <br> # 遍历数据库中的所有键<br> for key in db:<br> # 忽略已过期的键<br> if key.is_expired(): continue<br> <br> # 根据键的类型对键进行重写<br> if key.type == String:<br> rewrite_string(key)<br> elif key.type == List:<br> rewrite_list(key)<br> elif key.type == Hash:<br> rewrite_hash(key)<br> elif key.type == Set:<br> rewrite.set(key)<br> elif key.type == SortedSet:<br> rewrite_sorted_set(key)<br> <br> # 如果键带有过期时间,那么过期时间也要被重写<br> if key.have_expire_time():<br> rewrite_expir_time(key)<br># 写入完毕,关闭文件<br>f.close()<br><br>def rewrite_string(key):<br> # 使用GET命令获取字符串键的值<br> value = GET(key)<br> <br> # 使用SET命令重写字符串键<br> f.write_command(SET,key,value)<br> <br>def rewrite_list(key):<br> # 使用LRANGE命令获取列表键包含的所有元素<br> item1, item2, item3, ....itemN = LRANGE(key, 0, -1)<br> <br> # 使用RPUSH命令重写列表键<br> f.write_command(RPUSH, key, item1, item2, item3,...,itemN)<br> <br>def rewrite_hash(key):<br> # 使用HGETALL命令后去哈希键包含的所有键值对<br> field1, value1, field2, value2, fieldN, valueN = HGETALL(key)<br> # 使用HMSET命令重写哈希键<br> f.write_command(HMSET, key, field1, value1, field2,value2,...,fieldN, valueN)<br> <br>def rewrite_set(key):<br> # 使用SMEMBERS命令获取集合键包含的所有元素<br> elem1, elem2, elem3,....,elemN = SMEMBERS(key)<br> # 使用SADD命令重写集合键<br> f.write_command(SADD, key, elem1, elem2,...elemN)<br> <br>def rewrite_sorted_set(key):<br> # 使用ZRANGE命令获取有序集合键包含的新元素<br> member1,score1, member2,score2,..., memberN, scoreN = ZRANGE(key, 0, -1, "WOTJSCPRES")<br> <br> # 使用ZADD命令重写有序集合键<br> f.write_command(ZADD, key, score1, memeber1, score2, member2, ...., scoreN, memberN)<br><br>def rewrite_expire_time(key):<br> # 获取毫秒精度的键过期时间戳<br> timestamp = get_expire_time_in_unixstamp(key)<br> <br> # 使用PEXIREAT命令重写键的过期时间<br> f.write_command(PEXIREAT, key, timestamp<br>)<br> <br>```<br>
因为aof_rewrite函数生成的AOF文件只包含还原当前数据库状态所必须的命令,所以新AOF文件不会浪费任何硬盘空间
RDB和AOF比较。<br>生产环境可以都启用,redis启动时如果既有rdb文件又有aof文件则优先选择aof文件恢复数据,<br>因为aof一般来说数据更全一点<br>
Redis 4.0混合持久化。<br>重启Redis时,我们很少使用RDB来恢复内存状态,因为会丢失大量数据。我们通常使用AOF<br>日志重放,到那时重放AOF日志性能相对RD来说要慢很多,这样在Redis实例很大的情况下,<br>启动需要花费很长的时间。Redis4.0为了解决这个问题,带来一个新的持久化选项——混合持久化。<br>通过配置可以开启混合持久化(必须先开启aof):<br># aof-use-rdb-preamble yes<br>如果开启了混合持久化,AOF在重写时,不再是单纯地将内存数据转换为RESP写入AOF文件,而是<br>将重写这一刻之前地内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令<br>存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件<br>才会进行改名,覆盖原有的AOF文件,完成两个AOF文件的替换。于是在Redis重启的时候,可以先<br>加载RDB的内容,然后再重放增量AOF日志就可以完全替代之前的AOF全量文件重放,因此重启效率<br>大幅得到提升。<br>
Redis数据备份策略。<br>1.写crontab定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近<br>48小时的备份<br>2.每条都保留一份当日的数据备份到一个目录中去,可以保留最近一个月的备份<br>3.每次copy备份的时候,都把太旧的备份给删了<br>4.每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏<br>
管道和Lua脚本
管道(pipeline)<br>客户端可以一次性发送多个请求而不用等待服务器的相应,待所有命令都发送完后<br>再一次性读取服务的响应,这样可以极大地降低多条命令执行的网络传输开销,管道<br>执行多条命令的网络开销实际上只相当于一次命令执行的网络开销,需要注意到是用<br>pipeline方法打包命令发送,redis必须再处理完所有命令前先缓存起所有命令的处理<br>结果。打包的命令越多,缓存消耗内存也越多,所以并不是打包的命令越多越好。<br>pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在<br>此后的响应中得到信息;也就是pipeline并不是表达"所有command都一起成功"的<br>语义,管道中前面命令失败,后面命令不会有影响,继续执行<br>
Lua脚本<br>Redis在2.6推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行,使用<br>脚本的好处如下:<br>1.减少网络开销:本来5次网络请求的操作,可以用一个请求完成,原先5次请求的逻辑放在<br>Redis服务器上完成,使用脚本,减少了网络往返时延,跟管道类似<br>2.源自操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入,管道不是原子的,<br>redis的批量操作命令(类似mset)是原子的<br>3.替代redis的事务功能:redis自带的事务功能很鸡肋,而redis的lua脚本几乎实现了常规的事务<br>功能,官方推荐如果要使用redis的事务功能可以用redis lua替代<br>A Redis script is transactional by definition, so everything you can do with a Redis t<br>ransaction, you can also do with a script,and usually the script will be both simpler and faster.<br>
Redis事务
概述。<br>事务表示一组动作,要么全部执行,要么全部不执行。例子如下。<br>Redis提供了简单的事务功能,讲一组需要一起执行的命令放到multi和exec<br>两个命令之间。multi命令代表事务开始,exec命令代表事务结束,如果要<br>停止事务的执行,可以使用discard命令代替exec命令即可。它们之间的命令<br>是原子执行的<br>
例如在社交网站上用户A关注了用户B,那么需要在用户A的关注表中加入用户B,并且在用户B的粉丝表中<br>添加用户A,这两个行为要么全部执行,要么全部不执行,否则会出现数据不一致的情况<br><br>例如下面代码实现了用户关注问题<br>```c<br>127.0.0.1:6379> SADD u:a:follow ub<br>QUEUED<br>127.0.0.1:6379> SADD u:b:fans ua<br>QUEUED<br>```<br>可以看到SADD命令此时的返回结果是QUEUED,代表命令并没有真正执行,<br>而是暂时保存在Redis中的一个缓存队列(所以discard也只是丢弃这个缓存队列<br>中的未执行命令,并不会回滚已经操作过的数据,这一点要和关系型数据库的<br>Rollback操作区分开)。如果此时另一个客户端执行下方代码,返回结果应该是0<br>```c<br>127.0.0.1:6379> SISMEMBER u:a:follow ub<br>(integer) 0<br>```<br>只有当exec执行后,用户A关注用户B的行为才算完成,如下所示exec返回的两个<br>结果对应SADD命令<br>```c<br>127.0.0.1:6379> multi<br>OK<br>127.0.0.1:6379> SADD u:a:follow ub<br>QUEUED<br>127.0.0.1:6379> SADD u:b:fans ua<br>QUEUED<br>127.0.0.1:6379> exec<br>1) (integer) 1<br>2) (integer) 1<br>```<br>另一个客户端:<br>```c<br>127.0.0.1:6379> SISMEMBER u:a:follow ub<br>(integer) 0<br>127.0.0.1:6379> SISMEMBER u:a:follow ub<br>(integer) 1<br>```<br>
错误处理机制。<br>如果事务中的命令出现错误,Redis的处理机制也不尽相同<br>
1.命令错误<br>例如下面操作将set写成了SETT,属于语法错误,会造成整个事务无法执行,<br>```c<br>127.0.0.1:6379> set txkey hello<br>OK<br>127.0.0.1:6379> set txcount 100<br>OK<br>127.0.0.1:6379> mget txkey txcount<br>1) "hello"<br>2) "100"<br>127.0.0.1:6379> multi<br>OK<br>127.0.0.1:6379> sett txkey world<br>(error) ERR unknown command `sett`, with args beginning with: `txkey`, `world`,<br>127.0.0.1:6379> incr txcount<br>QUEUED<br>127.0.0.1:6379> exec<br>(error) EXECABORT Transaction discarded because of previous errors.<br>127.0.0.1:6379> mget txkey txcount<br>1) "hello"<br>2) "100"<br>```
2.运行时错误。<br>例如用户B在添加粉丝列表时,误把SADD命令(针对集合)写成了ZADD命令(针对有序集合),<br>这种就是运行时命令,因为语法时正确的:<br>```c<br>127.0.0.1:6379> SADD u:b:fans ua<br>(integer) 1<br>127.0.0.1:6379> multi<br>OK<br>127.0.0.1:6379> SADD u:c:follow ub<br>QUEUED<br>127.0.0.1:6379> ZADD u:b:fans 1 uc<br>QUEUED<br>127.0.0.1:6379> exec<br>1) (integer) 1<br>2) (error) WRONGTYPE Operation against a key holding the wrong kind of value<br>127.0.0.1:6379> sismember u:c:follow ub<br>(integer) 1<br>```<br>可以看到Redis并不支持回滚功能,SADD u:c:follow ub命令已经执行成功,开发人员需要自己修复<br>这类问题。<br>
3.WATCH机制<br>有些应用场景需要在事务之前,确保事务中的key没有key没有被其他客户端修改过,才执行事务,<br>否则不执行(类似乐观锁)。Redis提供了watch命令来解决这类问题。<br>客户端1<br>```c<br>127.0.0.1:6379> set testwatch java<br>OK<br>127.0.0.1:6379> watch testwatch<br>OK<br>127.0.0.1:6379> multi<br>OK<br>```<br>客户端2<br>```c<br>127.0.0.1:6379> get testwatch<br>"java"<br>127.0.0.1:6379> append testwatch python<br>(integer) 10<br>```<br>客户端1继续:<br>```c<br>127.0.0.1:6379> append testwatch jedis<br>QUEUED<br>127.0.0.1:6379> exec<br>(nil)<br>127.0.0.1:6379> get testwatch<br>"javapython"<br>```<br>可以看到客户端1在执行multi之前执行了watch命令,客户端2在客户端1执行exec之前修改了key值,<br>造成可客户端1事务没有执行(exec结果为nil)<br>
Pipeline和事务的区别
1.pipeline是客户端的行为,对于服务器来说是透明的,可以认为服务器无法<br>区分客户端发送来的查询命令是以普通命令的形式还是以pipeline的形式发送到服务器的;<br>
2.而事务则是实现在服务器端的行为,用户执行MULTI命令时,服务器会将对应这个用户的<br>客户端对象设置为一个特殊的状态,在这个状态下后续用户执行管的查询命令不会被真的执行,<br>而是被服务器缓存起来,直到用户执行EXEC命令为止,服务器会将这个用户对应的客户端对象<br>中缓存的命令按照提交的顺序依次执行<br>
3.应用pipeline可以提高服务器的吞吐能力,并提高Redis处理查询请求的能力。<br>但是这里存在一个问题,当通过pipeline提交的查询命令数据较少,可以被内核<br>缓冲区所容纳时,Redis可以保证这些命令执行的原子性。然而一旦数据量过大,<br>超过了内核缓冲区的接收大小,那么命令的执行将会被打断,原子性也就无法得到<br>保证。因此pipeline只是一种提升服务器吞吐能力的机制,如果想要命令以事务的<br>方式原子性地被执行,还是需要事务机制,或者使用更高级的脚本功能以及模块功能<br>
4.可以将事务和pipeline结合起来使用,减少事务地命令在网络上的传输时间,将多次<br>网络IO缩减为一次网络IO.<br>
Redis提供了简单的事务,之所以说它简单,主要时因为它不支持事务中的回滚特性,同时<br>无法实现命令之间的逻辑关系计算,当然也体现了Redis的"keep it simple"的特性<br>
事件
概述。<br>Redis服务器是一个事件驱动程序:服务器需要处理以下两类事件:<br>1.文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件<br>就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器<br>则通过监听并处理这些事件来完成一系列网络通信操作<br>2.时间事件(time event):Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间<br>事件就是服务器对这类定时操作的抽象<br>
文件事件。<br>Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler):<br>1.文件事件处理器使用I/O多路复用(multiplexing)程序l来同时监听多个套接字,并根据套接字目前执行的任务<br>来为套接字关联不同的事件处理器<br>2.当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相<br>对应的文件事件就会产生,这时文件处理器就会调用套接字之前关联好的事件处理器来处理这些事件<br>虽然文件事件处理器以但单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既<br>实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他以单线程方式运行的模块进行对接,这保持<br>了Redis内部单线程设计的简单性<br>
文件事件处理器的构成.<br>图中展示了文件事件处理器的四个组成部分,它们分别是套接字、IO多路复用程序、<br>文件事件分派器(dispatcher),以及事件处理器。<br>文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、写入、<br>读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,<br>所以多个文件事件可能会并发地出现。<br>IO多路复用程序负责监听多个套接字并向文件事件分派器传送那些产生了事件的套接字。<br>尽管d多个文件事件可能会并发地出现,但IO多路复用程序总是会将所有产生事件地套接字<br>放入到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、<br>每次一个套接字的方式向我呢见事件分派器传送套接字。当上一个套接字产生的事件被<br>处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),IO多路复用程序才会继续向<br>文件事件分派器传送下要给套接字。<br>
文件事件分派器接收IO多路复用程序传来的套接字,并根据套接字产生的事件<br>的类型,调用相应的事件处理器。<br>服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,<br>它们定义了某个事件发生时,服务器应该执行的动作。<br>
IO多路复用程序的实现。<br>Redis的IO多路复用程序的所有功能都是都是通过包装常见的select、epoll、evport和kqueue<br>这些IO多路复用函数库来实现的,每个IO多路复用函数库在Redis源码中都对应一个单独的文件,<br>比如ae_select.c、ae_epoll.c、ae_kqueue.c,诸如此类。<br>因为Redis为每个IO多路复用函数库都实现了相同的API,所以IO多路复用程序的底层实现是可以互换的。<br>如图所示。<br>Redis在IO多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统<br>中性能最高的IO多路复用函数库来作为Redis的IO多路复用程序的底层实现:<br>```c<br>/*Include the best multiplexing layer suported by this system.<br>* The follwing should be ordered by performance,descending<br>*/<br># ifdef HAVE_EVPORT<br># include "ae_evport.c"<br># else<br> # ifdef HAVE_EPOLL<br> # include "ae_epoll,c"<br> # else<br> # ifdef HAVE_KQUEUE<br> # include "ae_kqueue.c"<br> # else<br> # include "ae_select.c"<br> # endif<br> # endif<br># endif<br>```<br>
事件的类型。<br>IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件<br>和套接字之间的对应关系如下:<br>1.当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答(acceptable)<br>套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件。<br>2.当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件<br>IO多路复用程序允许服务器同时监听套接字的AE_REABLE事件和AE_WRITABLE事件,如果一个套接字同时<br>产生了这两种这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理<br>完之后,才处理AE_WRITABLE事件。这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,<br>后写套接字。<br>
文件事件的处理器。<br>Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:<br>1.为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器<br>2.为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器<br>3.为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器。<br>4.当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器<br>在这些事件处理器里面,服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器<br>和命令回复处理器<br>
连接应答处理器。<br>networking.c/acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对<br>连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。<br>当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的<br>AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听<br>套接字的时候,套接字就会产生AE_READABLE事件,引发连接应答处理器执行,并执行<br>相应的套接字应答操作,,如图所示<br>
命令请求处理器。<br>networking.c/readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责<br>从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。<br>当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的<br>AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,<br>套接字就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入<br>操作<br>
命令回复处理器。<br>networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将<br>服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write<br>函数的包装。当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的<br>AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令时,<br>就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。<br>当命令回复发送完毕之后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE<br>事件之间的关联。<br>
一次完整的客户端与服务器连接事件示例。<br>假设一个Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该处于监听<br>状态之下,而该事件所对应的处理器为连接应答处理器。如果这时有一个Redis客户端向服务器发起<br>连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。处理器会对客户端的<br>连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的AE_READABLE事件<br>与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。之后,假设客户端向主服务器<br>发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器<br>读取客户端的命令内容,然后传给相关程序去执行。<br>执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE<br>事件与命令回复处理器进行关联。当客户端尝试读取命令回复的时候,客户端套接字将产生AE_WRITABLE事件,<br>触发命令回复处理器执行,当命令回复处理器将命令回复全部写入到套接字之后,服务器就会解除客户端<br>套接字的AE_WRITABLE事件与命令回复处理器之间的关联<br>
时间事件。<br>Redis的时间时间分为以下两类:<br>1.定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序X在当前时间的30毫秒之后zhixingyici <br>2.周期性事件:让一段程序每隔指定事件就执行一次。比如说,让程序Y每隔30毫秒就执行一次。<br>一个时间事件主要由以下三个属性组成:<br>id:服务器为事件事件创建的全局唯一ID(标识号),ID号从小到大的顺序递增,新事件的ID号比旧事件的ID号要大<br>when:毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间<br>timeProc:时间处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件<br><br>一个时间事件是定时事件还是周期性事件取决于时间处理器的返回值:<br>1.如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件:该事件在达到一次之后就会被删除,<br>之后不再到达<br>2.如果事件处理器返回一个非AE_NOMORE得整数值,那么这个事件为周期性事件:<br>当一个时间事件到达之后,服务器会根据时间事件处理器返回得值,对时间事件的when属性进行更新,让<br>这个事件在一段时间之后到达,并以这种方式一直更新并运行下去。比如说,一个时间事件的处理器返回整数<br>值30,那么服务器应该对这个时间事件进行更新,让这个事件在30毫秒之后再次到达。<br>目前Redis只使用了周期性事件,而没有使用定时事件<br>
实现。<br>服务器将所有时间事件都放在一个无须链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有<br>已到达的时间事件,并调用相应的事件处理器。<br><br>注意,我们说保存时间事件的链表为无序链表,指的不是链表不按ID排序,而是说,该链表<br>不按when属性的大小排序。正因为链表没有按照when属性进行排序,所以当时间事件执行器<br>运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件<br>都会被处理。<br>无序链表并不影响事件处理器的性能。<br>正常模式下的Redis服务器只使用serverCron一个时间事件,而在benchmark模式下,服务器也只使用两个<br>时间事件。在这种情况下,服务器几乎是将无须链表退化成一个指针来使用,所以使用无须链表来保存时间<br>事件,并不影响事件执行的性能<br>
举个例子。链表中包含了三个不同的时间事件:因为新的时间事件总是i插入到链表的表头,<br>所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID<br>为1.<br><br>
时间事件应用实例:serverCron函数。<br>持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地<br>运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:<br>1.更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等<br>2.清理数据库中的过期键值对<br>3.关闭和清理连接失效的客户端<br>4.尝试进行AOF或者RDB持久化操作<br>5.如果服务器是主服务器,那么对从服务器进行定期同步<br>6.如果处于集群模式,对集群进行定期同步和连接测试<br><br>Redis服务器以周期性时间的方式来运行servrCron函数,在服务器运行期间,每隔一段时间(100毫秒),<br>serverCron就会执行一次,直到服务器关闭为止,Redis2.6版本中,服务器默认规定serverCron每秒运行10次,<br>平均每隔100毫秒运行一次。从Redis2.8开始,用户可以通过修改hz选项来调整serverCron的每秒执行次数。<br>
事件的调度与执行。<br>因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定<br>何时应该处理文件事件,何时有应该处理时间事件,以及花多少事件来处理它们等等。<br>事件的调度和执行由ae.c/aeProcessEvents函数负责,伪代码表示如下:<br>```python<br>def aeProcessEvents():<br> # 获取到达时间离当前时间最接近的时间事件<br> time_event = aeSearchNearestTimer()<br> <br> # 计算最接近的时间事件距离到达还有多少毫秒<br> remaind_ms = time_event.when - unix_ts_now()<br> <br> # 如果事件已到达,那么remaind_ms可能为负数,将它设定为0<br> if remaind_ms < 0:<br> remaind_ms = 0<br> # 根据remaind_ms的值,创建timeval结构<br> timeval = create_timeval_with_ms(remaind_ms)<br> <br> # 阻塞并等待文件事件产生,最大阻塞事件由传入的timeval结构决定<br> # 如果remaind_ms的值为0,那么aeApiPoll调用之后马上返回,不阻塞<br> aeApiPoll(timeval)<br> <br> # 处理所有易产生的文件事件<br> processFileEvents()<br> <br> # 处理所有已到达的时间事件<br> processTimeEvents()<br>```<br>
将aeProcessEvents函数置于一个循环里面,加上初始化和清理函数,这就构成了Redis服务器的<br>主函数,以下是该函数的伪代码表示:<br>```python<br>def main():<br> # 初始化服务器<br> init_server()<br> <br> # 一直处理事件,直到服务器关闭为止<br> while server_is_not_shutdown():<br> aeProcessEvents()<br> <br> # 服务器关闭,执行清理操作<br> clean_server()<br>```<br>从事件处理的角度来看,Redis服务器的运行流程可以用流程图来表示<br>
事件的调度和执行规则:<br>1.aeApiPoll函数的最大阻塞事件由到达时间最接近当前时间的时间事件决定,这个方法既可以避免<br>服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间<br>2.因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,<br>那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置<br>的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了<br>3.对文件事件和时间事件的处理都是同步的、有序、原子地执行的,服务器不会中途中断事件处理,<br>也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可能地<br>减少程序地阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿地可能性。比如说,在命令<br>回复处理器将一个命令回复写入到客户端套接字时,如果写入字节数超过了一个预设常量的话,命令回复<br>处理器就会主动用break跳出写入循环,将余下的数据留到下次再写;另外时间事件也会将非常耗时的<br>持久化操作放到子线程或者子进程执行<br>4.因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理事件,通常<br>回避时间事件设定的到达时间晚一些<br>
举个例子,事件执行过程凸显了上面的规则,<br>1.因为时间事件尚未到达,所以在处理时间事件之前,服务器已经等待并处理了两次文件事件<br>2.因为处理事件的过程中不会出现抢占,所以实际处理时间事件的时间比预定的100毫秒慢了30毫秒<br>
重点。<br>1.Redis服务器是一个事件驱动程序,服务器处理的事件分为时间事件和文件事件两类。<br>2.文件事件处理器是基于Reactor模式实现的网络通信程序<br>3.文件事件是对套接字操作的抽象:每次套接字变得可应答(acceptable)、可写(writable)或者可读(readale)时,<br>相应的文件事件就会产生<br>4.文件事件分为AE_READABLE事件(读事件)和AE_WRITABLE事件(写事件)两类<br>5.时间事件分为定时事件和周期性事件:定时事件只在指定的事件到达一次,而周期性事件则每隔一段时间到达一次<br>6.服务器在一般情况下只执行serverCron函数一个时间事件,并且这个事件是周期性事件<br>7.文件事件和时间事件之间是合作关系,服务器会轮流处理这两种事件,并且处理事件的流程中也不会出现抢占<br>8.时间事件的实际处理时间通常回避设定的到达时间要晚一些<br>
事件的类型。<br>IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类<br>事件和套接字操作之间的对应关系如下:<br>1.当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作)或者有新的可应答(acceptable)<br>套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生READABLE事件<br>2.当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件。<br>IO多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件,如果一个套接字<br>同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完<br>之后,才处理AE_WRITABLE事件。也就是说,如果一个套接字又可读又可写的话,那么服务器将优先读套接字<br>后写套接字<br>
客户端
概述。<br>Redis服务器是典型的一对多服务器程序:一个服务器可以与多个客户端建立网络连接,每个客户端<br>可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令<br>回复。通过使用由IO多路复用技术实现的文件事件管理器,Redis服务器使用单线程单进程的方式<br>来处理命令请求,并与多个客户端进行网络通信。对于每个与服务器进行连接的客户端,服务器都<br>为这些客户端建立了相应的redis.h/redisClient结构(客户端状态),这个结构保存了客户端当前的<br>状态信息,以及执行相关功能时需要用到的数据结构,其中包括:<br>1.客户端的套接字描述符<br>2.客户端的名字<br>3.客户端的标志值(falg)<br>4.指向客户端正在使用的数据库的指针,以及该数据库的号码<br>5.客户端当前要执行的命令、命令的参数、命令参数的个数、以及指向命令实现函数的指针<br>6.客户端的输入缓冲区和输出缓冲区<br>7.客户端的复制状态信息,以及进行复制所需的数据结构<br>8.客户端执行BRPOP、BLPOP等列表阻塞命令时使用的数据结构<br>9.客户端的事务状态。以及执行WATCH命令时用到的数据结构<br>10.客户端执行发布与订阅功能时用到的数据结构<br>11.客户端的身份验证标志<br>12.客户端的创建事件,客户端和服务器最后一次通信的时间,以及客户端的输出缓冲区大小超出<br>软性限制(soft limit)的时间<br>Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态<br>结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成:<br>```c<br>struct redisServer {<br> // ...<br> <br> // 一个链表,保存了所有客户端状态<br> list *clients;<br> <br> // ...<br>}<br>```<br>
举个例子,如图展示了一个与三个客户端进行连接的服务器,
如图展示了这个服务器的clients链表的样子
客户端属性。<br>客户端状态包含的属性可以分为两类:<br>1.一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工作,它们都要<br>用到这些属性<br>2.l另外一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时<br>需要用到的mstate属性,以及执行WATCH命令时需要用到的watched_keys属性等等。<br>
套接字描述符。<br>客户端状态的fd属性记录了客户端正在使用的套接字描述符:<br>```c<br>typedef struct redisClient {<br> // ...<br> int fd;<br> // ...<br>}redisClient;<br>```<br>根据客户端类型的不同,fd属性的值可以是-1或者大于-1的整数:<br>1.伪客户端(fake client)的fd属性为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,<br>而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis<br>服务器会在两个地方用到伪客户端,一个用于载入AOF我呢见并还原数据库状态,而另一个则<br>用于执行Lua脚本中包含的Redis命令<br>2.普通客户端的fd属性的值大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器<br>会用fd属性来记录客户端套接字的描述符。因为合法的套接字描述符不能是-1,所以普通客户端的<br>套接字描述符的值必然是大于-1的整数<br><br>执行CLIENT list命令可以列出目前所有连接到服务器的普通客户端,命令输出中的fd域显示了服务器<br>连接客户端所使用的套接字描述符:<br>```c<br>127.0.0.1:6379> client list<br>id=4 addr=127.0.0.1:61899 fd=9 name= age=27 idle=22 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client<br>id=5 addr=127.0.0.1:61921 fd=10 name= age=9 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client<br>```<br>
名字。<br>在默认情况下,一个连接到服务器的客户端是没有名字的。<br>比如在下面展示的CLIENT list命令示例中,两个客户端的name域都是空白的:<br>```c<br>127.0.0.1:6379> client list<br>id=4 addr=127.0.0.1:61899 fd=9 name= age=27 idle=22 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client<br>id=5 addr=127.0.0.1:61921 fd=10 name= age=9 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client<br>```<br>使用CLIENT setname 命令可以为客户端设置一个名字,让客户端的身份变得更清晰。<br>```c<br>127.0.0.1:6379> CLIENT setname testName1<br>OK<br>```<br>```c<br>127.0.0.1:6379> client list<br>id=4 addr=127.0.0.1:61899 fd=9 name=testName1 age=439 idle=6 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client<br>id=5 addr=127.0.0.1:61921 fd=10 name= age=421 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client<br>```<br>其中第一个客户端的名字设置为了testName1,第二个客户端没有进行设置。客户端的名字记录在客户端状态的name属性里面:<br>```c<br>typedef struct redisClient {<br> // ...<br> robj *name;<br> // ...<br>}redisClient<br>```<br>如果客户端没有为自己设置名字,那么相应客户端状态的name属性指向NULL指针,相反地,如果客户端为自己设置了名字,那么name<br>属性将指向一个字符串对象,而该对象就保存着客户端的名字。<br>
举个例子。如图展示了一个客户端状态示例,根据name属性显示,<br>客户端的名字为"testName1"<br>
标志。<br>客户端的标志属性flags记录了客户端的角色(role),以及客户端目前所处的状态:<br>```c<br>typedef struct redisClient {<br> // ...<br> int flags;<br> // ...<br>}redisClient<br>```<br>flags属性的值可以是单个标志:flags = <flag>,<br>也可以是多个标志的二进制或,比如:flags = <flag1> | <flag2> | ...<br>每个标志使用一个常量表示,一部分标志记录了客户端的角色:<br>1.在主从服务器进行复制操作时,主服务器会称为从服务器的客户端,而从服务器也会成为主服务器的客户端。REDIS_MASTER标志表示客户端代表的是一个<br>主服务器,REDIS_SLAVE标志客户端代表的是一个从服务器。<br>2.REDIS_PRE_PSYNC标志表示kk而护短代表的是一个版本低于Redis2.8的从服务器,主服务器不能使用PYSNC命令与这个从服务器进行同步,这个标志只能<br>在REDIS_SLAVE标志处于打开状态时使用<br>3.REDIS_LUA_CLIENT标识表示客户端是专门用于处理Lua脚本里面包含的Redis的伪客户端。<br><br>而另一部分标志则记录了客户端目前所处的状态:<br>1.REDIS_MONITOR标志表示客户端正在执行MONITOR命令<br>2.REDIS_UNIX_SOCKET标志表示服务器使用UNIX套接字来连接客户端<br>3.REDIS_BLOCKED标志表示客户端正在被BRPOP、BLPOP等命令阻塞<br>4.REDIS_UNBLOCKED标志表示客户端已经从REDIS_BLOCKED标志所表示的阻塞状态中脱离出来,不再阻塞。REDIS_UNBLOCKED标志只能在REDIS_BLOCKED标志已经打开的情况下使用<br>5.REDIS_MULTI标志表示客户端正在执行事务<br>6.REDIS_DIRTY_CAS标志表示事务使用WATCH命令监视的数据库键已经被修改了<br>7.REDIS_DIRTY_EXEC标志表示事务在命令入队时出现了错误,以上两个标志都表示事务的安全性已经被破坏,只要这两个标记中的任意一个被打开,EXEC命令必然会执行失败。这两个标志只能在客户端打开了REDIS_MULTI标志的情况下使用<br>8.REDIS_CLOSE_ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围,服务器会在下一次执行serverCron函数时关闭这个客户端,以免服务器<br>的稳定性受到这个客户端影响。积存在输出缓冲区中的所有内容会直接被释放,不会返回给客户端。<br>9.REDIS_CLOSE_AFTER_REPLY标志表示有用户对这个客户端执行了CLIENT KILL命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存z在输出缓冲区中的所有内容发送给客户端,然后关闭客户端<br>10.REDIS_FORCE_AOF标志强制服务器将当前执行的命令写入到AOF文件里面,REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。执行PUBSUB命令会使客户端打开REDIS_FORCE_AOF标志,执行SCRIPT LOAD命令会使客户端打开REDIS_FORCE_AOF标志和REDIS_FORCE_REPL标志<br>11.在主从服务器进行命令传播期间,从服务器需要向主服务器发送REPLICATION ACK命令,在发送这个命令之前,从服务器必须打开主服务器对应的客户端的REDIS_MASTER_FORCE_REPLY标志,否则发送操作会被拒绝执行<br>
PUBSUB命令和SCRIPT LOAD命令的特殊性。<br>通常情况下,Redis只会将那些对数据库进行了修改的命令写入到AOF文件,并复制到各个从服务器。<br>如果一个命令没有对数据库j进行任何修改,那么它就会被认为是只读命令,这个命令不会被写入到AOF<br>文件,也不会被复制到从服务器。以上规则适用于绝大部分Redis命令,但PUBSUB命令和SCRIPT LOAD<br>命令是其中的例外。PUBSUB命令虽然没有修改数据库,但PUBSUB命令向频道的所有订阅者发送消息<br>这一行为带有副作用,接收到消息的所有客户端的状态都会因为这个命令而改变。因此,服务器需要使用<br>REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,这样在将来载入AOF文件时,服务器就可以再次<br>执行相同的PUBSUB命令,并产生相同的副作用。SCIRPT LOAD命令的情况与PUBSUB命令类似:<br>虽然SCRIPT LOAD命令没有修改数据库,但它修改了服务器状态,所以它是一个带有副作用的命令,服务器<br>需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,使得将来在载入AOF文件时,服务器可以<br>产生相同的副作用。另外,为了让主服务器和从服务器都可以正确地载入SCRIPT LOAD命令指定的脚本,<br>服务器需要使用REDIS_FORCE_REPL标志,强制将SCIRPT LOAD命令复制给所有从服务器<br>
举个flags属性的例子:<br>```c<br># 客户端是一个主服务器<br>REDIS_MASTER<br># 客户端正在被列表命令阻塞<br>REDIS_BLOCKED<br># 客户端正在执行事务,但事务的安全性已被破坏<br>REDIS_MULTI | REDIS_DIRTY_CAS<br># 客户端是一个从服务器,并且版本低于Redis2.8<br>REDIS_SLAVE | REDIS_PRE_PSYNC<br># 这是专门用于执行Lua脚本包含的Redis命令的伪客户端<br># 它强制服务器将当前的命令写入AOF文件,并复制给从服务器<br>REDIS_LUA_CLIENT | REDIS_FORCE_AOF | REDIS_FORCE_REPL<br>```<br>
输入缓冲区。<br>客户端状态的输入缓冲区用于保存客户端发送的命令请求:<br>```c<br>typedef struct redisClient {<br> // ...<br> sds querybuf;<br> <br> // ...<br>}redisClient;<br>```<br>
举个例子,如果客户端向服务器发送了以下命令请求:<br>```c<br>SET key value<br>```<br>那么客户端状态的qureybuf属性将是一个包含以下内容的SDS值<br>```c<br>*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n<br>```<br>如图所示占了这个SDS值以及querybuf属性的样子。输入缓冲区的大小会<br>根据输入内容动态地缩小或者扩大,但它的最大大小不能超过1GB,否则服务器<br>将关闭这个客户端<br>
命令与命令参数。<br>在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,<br>服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数<br>分别保存到客户端状态的argv属性和argc属性:<br>```c<br>typedef struct redisClient {<br> // ...<br> robj **argv;<br> <br> int argc;<br> // ...<br>}redisClient<br>```<br>argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要<br>执行的命令,而之后的其他项则是传给命令的参数。<br>argc属性则是负责记录argv数组的长度。<br>
举个例子,图中展示的客户端状态中,argc属性的值伪3,而不是2,<br>因为命令的名字"SET"本身也是一个参数<br>
命令的实现函数。<br>当服务器从协议内容中分析并得出argv属性和argc属性的值之后,服务器将根据项argv[0]的值,<br>在命令表中查找命令所对应的命令实现函数。<br>图中展示了一个命令表示例,该表是一个字典,字典的键是一个SDS结构,保存了命令的名字,<br>字典的值是命令所对应的rediCommand结构,这个结构保存了命令的实现函数、命令的标志、<br>命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息。<br>当程序在命令表中成功找到argv[0]所对应的redisCommand结构时,它会将客户端状态的cmd<br>指针指向这个结构:<br>```c<br>typedef struct redisClient {<br> // ...<br> struct redisCommand *cmd;<br> // ...<br>}redisClient<br>```<br>之后,服务器就可以使用cmd属性所指向的redisCommand结构,以及argv、argc属性中保存的<br>命令参数信息,调用命令实现函数,执行客户端指定的命令<br>
图中演示了服务器在argv[0]为"SET"时,查找命令表并将客户端状态的<br>cmd指针指向目标redisCommand结构的整个过程。<br>针对命令表的查找操作不区分输入字母的大小写,所以无论argv[0]是"SET"、<br>"set" 、或者"Set"等等,查找的结构都是相同的。<br>
输出缓冲区。<br>z执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区<br>可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的:<br>1.固定大小的缓冲区用于保存那些长度比较小的回复,比如OK、间段的字符串值、整数值、错误回复等等<br>2.可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值,一个由很多项组成的<br>列表,一个包含了很多元素的集合等等。<br>客户端的固定大小缓冲区由buf和bufpos两个属性组成:<br>```c<br>typedef struct redisClient {<br> // ...<br> char buf[REDIS_REPLY_CHUNK_BYTS];<br> <br> int bufpos;<br> // ...<br>}redisClient;<br>```<br>buf是一个大小为REDIS_REPLY_CHUNK_BYTES字节的字节数组,而bufpos属性则记录了buf数组目前<br>已使用的字节数量。REDIS_REPLY_CHUNK_BYTES常量目前的默认值为16*1024,也就是说,buf数组<br>的默认大小为16KB.<br>如图展示了一个使用固定大小缓冲区来保存返回值+OK\r\n的例子。<br>当buf数组的空间已经用完,或者回复因为太大而没办法放进buf数组里面时,服务器就会开始使用可变<br>大小缓冲区。可变大小缓冲区由reply链表和一个或多个字符串对象组成:<br>```c<br>typedef struct redisClient {<br> // ...<br> list *reply;<br> // ...<br>}redisClient<br>```<br>通过使用链表l来连接多个字符串对象,服务器可以为客户端保存一个非常长的命令回复,而不必受到固定<br>大小缓冲区16KB大小的限制。<br>
如图展示了一个包含三个字符串对象的reply链表
身份验证。<br>客户端状态的authenticated属性用于记录客户端是否通过了身份验证:<br>```c<br>typedef struct redisClient {<br> // ...<br> <br> int authenticated;<br> <br> // ...<br>} redisClient;<br>```<br>如果authnticated的值为0,那么表示客户端未通过身份验证;如果authenticated的值为1,那么表示客户端<br>已经通过了身份验证<br>
举个例子,对于一个尚未进行身份验证的客户端来说,客户端状态的authenticated的属性如图所示.<br>当客户端authenticated属性的值为0时,除了AUTH命令之外,客户端发送的所有其他命令都会被<br>服务器拒绝执行:(前提是你需要设置密码requirepass)<br>redis.conf<br>```c<br>requirepass 1234<br>```<br>```c<br>(error) NOAUTH Authentication required.<br>127.0.0.1:6379> SET msg "hello world"<br>(error) NOAUTH Authentication required.<br>```<br>
当客户端通过AUTH命令成功进入身份验证之后,客户端状态authenticated属性的值就会从0变为1,<br>如图所示,这时客户端就可以像往常一样向服务器发送命令请求了:<br>```c<br>127.0.0.1:6379> AUTH 1234<br>OK<br>127.0.0.1:6379> PING<br>PONG<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>```<br>authenticated属性仅在服务器启用了身份验证功能时使用,如果服务器没有启用身份验证功能的话,<br>那么即使authenticated属性的值为0(这是默认值),服务器也不会拒绝执行客户端发送的命令请求。<br>
时间。<br>```c<br>typedef struct redisClient {<br> // ...<br> time_t ctime;<br> <br> time_t lastinteraction;<br> <br> time_t obuf_soft_limit_reached_time;<br> // ...<br>}redisClient;<br>```<br>1.ctime属性记录了创建客户端的时间,这个时间可以用来计算客户端与服务器已经连接了多少秒,<br>CLIENT list命令的age域记录了这个秒数<br>```c<br>127.0.0.1:6379> CLIENT list<br>id=3 addr=127.0.0.1:58185 fd=10 name= age=741 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client<br>```<br>2.lastinteraction属性记录了客户端与服务器最后一次进行互动(interaction)的时间,这里的互动可以是客户端向服务器发送命令请求,也可以是服务器向<br>客户端发送命令回复。lastinteraction属性可以用来计算客户端的空转(idle)时间,也即是,距离客户端与服务器最后一次进行互动以来,已经过去了多少秒,<br>CLIENT list命令的idle域记录了这个秒数<br>```c<br>id=3 addr=127.0.0.1:58185 fd=10 name= age=1371 idle=27 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client<br>id=4 addr=127.0.0.1:60838 fd=9 name= age=8 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=client<br>```<br>3.obuf_soft_limit_reached_time属性记录了输出缓冲区第一次到达软性限制(soft limit)的时间<br>
客户端的创建与关闭。<br>服务器使用不同的方式来创建和关闭不同类型的客户端<br>
创建普通客户端。<br>如果客户端是通过网络连接与服务器进行连接的普通客户端,那么在客户端使用connect函数连接<br>到服务器时,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并将这个新的客户端<br>状态添加到服务器状态结构clients链表的末尾。<br>
举个例子。假设当前有c1和c2两个普通客户端正在连接服务器,那么当一个新的<br>普通客户端c3连接到服务器之后,服务器会将c3所对应的客户端状态添加到clients<br>链表的末尾,如图所示,其中用虚线包围的就是服务器为c3新创建的客户端状态<br>
关闭普通客户端。<br>一个普通客户端可以因为多种原因而被关闭:<br>1.如果客户端退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭<br>2.如果客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端也会被服务器关闭<br>3.如果客户端成为了CLIENT KILL命令的目标,那么它也会被关闭<br>4.如果用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout选项设置的值时,<br>客户端将被关闭。不过timeout选项有一些例外情况:如果客户端是主服务器(打开了REDIS_MASTER标志),<br>从服务器(打开了REDIS_SLAVE标志),正在被BLPOP等命令阻塞(打开了REDIS_BLOCKED标志),或者正在<br>执行SUBSCRIBE、PSUBSCRIBE等订阅命令,那么即使客户端的空转事件超过了timeout选项的值,客户端<br>也不会被服务器关闭。<br>5.如果客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1GB),那么这个客户端会被服务器<br>关闭<br>6.如果要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小,那么这个客户端会被服务器关闭<br><br>可变大小缓冲区由一个链表和任意多个字符串对象组成,理论上来说,这个缓冲区可以保存任意长度的命令<br>回复。但是,为了避免客户端的回复过大,占用过多的服务器资源,服务器会时刻检查客户端的输出缓冲区<br>的大小,并在缓冲区的大小超出范围时,执行相应的限制操作。<br>服务器使用两种模式来限制客户端输出缓冲区的大小:<br>1.硬性限制(hard limit):如果输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器立即关闭客户端<br>2.软性限制(soft limit):如果输出缓冲区的大小超过了软性限制所设置的大小,但还没有超过硬性限制,那么<br>服务器将使用客户端状态结构的obuf_soft_limit_reached_time属性记录下客户端到达软性限制的起始时间;<br>之后服务器会继续监视客户端,如果输出缓冲区的大小一直超出软限制,并且持续时间冲过服务器设定的时长<br>那么服务器将关闭客户端;相反地,如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么客户端<br>就不会被关闭,并且obuf_soft_limit_reached_time属性的值也会被清零。<br>使用client-output-buffer-limit选项可以为普通客户端、从服务器客户端、执行发布与订阅功能的客户端分别<br>设置不同的软性限制和硬性限制,该选项的格式为:<br>client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds><br>
举个例子,<br>```c<br>client-output-buffer-limit normal 0 0 0<br>client-output-buffer-limit slave 256mb 64mb 64<br>client-output-buffer-limit pubsub 32mb 8mb 60<br>```<br>第一行设置将普通客户端的硬性限制和软性限制都设置为0,表示不限制客户端的输出缓冲区大小<br>第二行设置将从服务器客户端的硬性限制设置为256MB,而软性限制设置为64MB,软性限制的时长为60s<br>第三行设置将执行发布与订阅功能的客户端的硬性限制设置为32MB,软性限制设置为8MB,软性限制的时长为60s<br><br>
Lua脚本的伪客户端。<br>服务器会在初始化时创建执行Lua脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构<br>的lua_client属性中:<br>```c<br><br>typedef struct redisServer {<br> // ...<br> redisClient *lua_client;<br> // ...<br> <br>};<br>```<br>lua_client伪客户端在服务器运行的整个生命周期中会一直存在,只有服务器被关闭时,这个客户端才会被关闭<br>
AOF文件的伪客户端。<br>服务器在载入AOF文件时,会创建用于执行AOF文件包含的Redis命令的伪客户端,并在载入完成之后,关闭<br>伪客户端<br>
服务器
概述。<br>Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令<br>所产生的数据,并通过资源管理来维持服务器自身的运转<br>
命令请求的执行过程。<br>一个命令请求从发送到获得回复的过程中,客户端和服务器需要完成一系列操作。<br>
举个例子。如果我们使用客户端执行以下命令:<br>```c<br>127.0.0.1:6379> SET KEY VALUE<br>OK<br>```<br>那么客户端发送SET KEY VALUE命令到获得回复OK期间,客户端和服务器共需要执行以下操作:<br>1.客户端向服务器发送命令请求SET KEY VALUE<br>2.服务器接收并处理客户端发来的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK<br>3.服务器将命令回复OK发送给客户端<br>4.客户端接收服务器返回的命令回复OK,并将这个回复打印给用户观看<br>
发送命令请求。<br>Redis服务器的命令请求来自Redis客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令<br>转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。如图所示。<br>
读取命令请求。<br>当客户端与服务器之间的套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:<br>1.读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面<br>2.对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将<br>参数和参数个数保存到客户端状态的argv和argc属性里面。<br>3.调用命令执行器,执行客户端指定的命令<br><br>分析程序将对输入缓冲区中的协议进行分析:<br>```c<br>*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n<br>```<br>并将的出的分析结果保存到客户端状态的argv属性和argc属性里面,如图所示<br>
之后,服务器将通过命令执行器来完成执行命令所需的余下步骤
命令执行器(1):查找命令实现。<br>命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数所指定的<br>命令,并将找到的命令保存到客户端状态的cmd属性里面。命令表是一个字典,字典的键是一个个命令名字,比如"set"、<br>"get"、"del"等等;而字典的值则是一个个redisCommand结构,每个redisCommand结构记录了一个Redis命令的实现信息<br>
如表所示,sflags属性可以使用的标识值。
如图所示命令表,并以SET和GET命令作为例子。<br>1.SET命令的名字为"set",实现函数为setCommand;m命令的参数个数为-3,表示命令接受<br>三个或以上数量的参数;命令的参数为"wm",表示SET命令式一个写入命令,并且在执行这个<br>命令之前,服务器应该对占用内存状况进行检查,因为这个命令可能会占用大量内存。<br>2.GET命令的名字为"get",实现函数为getCommand函数;命令的参数个数为2,表示命令<br>的标识为"r",表示这是一个只读命令。<br><br>继续之前SET命令的例子,当程序以图中的argv[0]作为输入,在命令表中进行查找时,命令表<br>将返回"set"键所对应的redisCommand结构,客户端状态的cmd指针会指向这个redisCommand<br>结构<br>
命令执行器(2):执行预备操作。<br>服务器已经将执行命令所需的命令实现函数(保存在客户端状态的cmd属性)、参数(保存在客户端状态的argv属性)、<br>参数个数(保存在客户端状态的argc属性)都收集器了,但是在真正执行命令之前,程序还需要进行一些预备操作,从而<br>确保命令可以正确、顺利地被执行,这些操作包括:<br>1.检查客户端状态的cmd指针是否指向NULL,如果是的话,那么说明用户输入的命令名字找不到相应的命令实现,<br>服务器不再执行后续步骤,并向客户端返回一个错误<br>2.根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确,当参数个数<br>不正确时,不再执行后续步骤,直接向客户端返回要给错误。比如说,如果redisCommand结构的arity属性的值为-3,<br>那么用户输入的命令参数个数必须大于等于3个才行。<br>3.检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令,如果未通过身份验证的客户端<br>试图执行除AUTH命令之外的其他命令,那么服务器将向客户端返回一个错误<br>4.如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存<br>回收,从而使得接下来的命令可以顺利地执行如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误<br>5.如果服务器上一次执行BGSAVE命令时出错,并且服务器打开了stop-writes-on-bgsave-error功能,而且服务器即将<br>要执行地命令是一个写命令,那么服务器将拒绝执行这个命令,并向客户端返回一个错误<br>6.如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行客户端<br>发来的SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE四个命令,其他命令都会被服务器拒绝<br>7.如果服务器正在进行数据载入,那么客户但发送的命令必须带有l标识(比如INFO、SHUTDOWN、PUBLISH等等)才会<br>被服务器执行,其他命令都会被服务器拒绝<br>8.如果服务器因为执行Lua脚本而超时并进行阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和<br>SCRIPT KILL命令,其他命令都会被服务器拒绝<br>9.如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、MULTI、WATCH四个命令,其他命令<br>都会被放进事务队列中<br>10.如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。<br>完成了以上的预备操作之后,服务器就可以开始真正执行命令了<br>
命令执行器(3):调用命令的实现函数。<br>在前面的操作中,服务器已经将要执行命令的实现保存到了客户端状态的cmd属性里面,并将命令的参数和参数个数分别<br>保存到了客户端状态的argv属性和argc属性里卖弄。当服务器决定要执行命令时,它只要执行以下语句就可以了:<br>```c<br>// client是指向客户端状态的指针<br>client -> cmd -> proc(client)<br>```<br>因为执行命令所需的实际参数都已经保存到了客户端状态的argv属性里面了,所以命令的实现函数只需要一个指向客户端<br>状态的指针作为参数即可。继续以之前的SET命令为例子,图中展示了客户端包含命令实现、参数和参数个数的样子。<br>对于这个例子来说,执行语句<br>```c<br>client->cmd->proc(client);<br>```<br>等于执行语句:<br>setCommand(client);<br>被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里面<br>(buf属性和reply属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端<br>对于前面SET命令的例子来说,函数调用setCommand(client)将产生一个"+OK\r\n"回复,这个回复会被保存到客户端状态<br>的buf属性里面,如图所示<br>
命令执行器(4):执行后续工作。<br>在执行完实现函数之后,服务器还需要执行一些后续工作:<br>1.如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志<br>2.根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的millseconds属性,并将命令的redisCommand结构<br>的calls计数器的值增一。<br>3.如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区里面<br>4.如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器<br>当以上操作都执行完之后,服务器对于当前命令的执行就完成了,之后服务器就可以继续从文件事件处理器中取出并处理下一个<br>命令请求了<br>
将命令回复发送给客户端。<br>命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并未客户端的套接字关联命令回复处理器,当客户端套接字变为<br>可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。当命令回复发送完毕<br>之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。当客户端的套接字变为可写状态时,<br>命令回复处理器会将协议格式的命令回复"+OK\r\n"发送给客户端<br>
客户端接收并打印命令回复。<br>当客户端接收到协议格式的命令回复粥,它会将这些回复转化成人类可读的格式,并打印给用户观看<br>如图s所示<br>
serverCron函数。<br>Redis服务器中的serverCron函数默认每隔100毫秒执行一次,这个函数负责管理服务器的资源,<br>并保持服务器自身的良好运转。<br>
更新服务器时间缓存。<br>Redis服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要<br>执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的unixtime属性和mstime<br>属性被用作当前时间的缓存:<br>```c<br>struct redisServer {<br> // ...<br> // 保存了秒级精度的系统当前UNIX时间戳<br> time_t unixtime;<br> // 保存了毫秒级精度的系统当前UNIX时间戳<br> long long mstime;<br> // ....<br>};<br>```<br>因为serverCron函数默认会以每100毫秒一次的频率更新unixtime属性和mstime属性,所以<br>这两个属性记录的时间的精确度并不高:<br>1.服务器只会在打印日志、更新服务器的LRU时钟、决定是否执行持久化任务、计算服务器上线<br>时间(uptime)这类对事件精确度要求不高的功能上"使用unixtime属性和mstime属性"。<br>2.对于为键设置过期事件、添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再<br>执行系统调用,从而获得最准确的系统当前时间<br>
更新LRU时钟。<br>服务器状态中的lruclock属性保存了服务器的LRU时钟,这个属性和unixtime属性、mstime属性一样,<br>都是服务器时间缓存的一种:<br>```c<br>struct redisServer {<br> // ...<br> // 默认每10秒更新一次的时钟缓存<br> // 用于计算键的空转(idle)时长<br> unsigned lruclock:22;<br> // ...<br>};<br>```<br>每个Redis对象都会有一个lru属性,这个lru属性保存了对象最后一次被命令访问的时间:<br>```c<br>typedef struct redisObject {<br> // ...<br> unsigned lru:22;<br> //...<br>} robj;<br>```<br>当服务器要计算一个数据库键的空转时间(也即是数据库键对应的值对象的空转时间),程序会用服务器的<br>lruclock属性记录的时间减去对象的lru属性记录的时间,得出的计算结果就是这个对象的空转时间:<br>```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br># 等待一小段时间<br>127.0.0.1:6379> OBJECT IDLETIME msg<br>(integer) 13<br># 等待一阵子<br>127.0.0.1:6379> OBJECT IDLETIME msg<br>(integer) 19<br># 访问msg键的值<br>127.0.0.1:6379> GET msg<br>"hello world"<br># 键处于活跃状态,空转时长为2<br>127.0.0.1:6379> OBJECT IDLETIME msg<br>(integer) 2<br>```<br>serverCron函数默认会以每10秒一次的频率更新lruclock属性的值,因为这个时钟不是实时的,所以根据这个<br>属性计算出来的LRU时间实际上只是一个模糊的估算值。lruclock时钟的当前值可以通过INFO server命令的<br>lru_clock域查看:<br>
```c<br>127.0.0.1:6379> info server<br># Server<br>redis_version:3.0.504<br>redis_git_sha1:00000000<br>redis_git_dirty:0<br>redis_build_id:a4f7a6e86f2d60b3<br>redis_mode:standalone<br>os:Windows<br>arch_bits:64<br>multiplexing_api:WinSock_IOCP<br>process_id:5512<br>run_id:87544bbfd0b6ddf6c7168be02719f23b94c97a96<br>tcp_port:6379<br>uptime_in_seconds:95307<br>uptime_in_days:1<br>hz:10<br>lru_clock:581331<br>config_file:E:\redis\redis.windows-service.conf<br>```<br>
更新服务器每秒执行命令次数。<br>serverCron函数中的trackOperationPerSecond函数会以每100毫秒一次的频率执行,这个函数的功能是以抽样<br>计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过INFO stats命令 的<br>instantaneous_ops_per_sec域查看:<br>
```c<br>127.0.0.1:6379> info stats<br># Stats<br>total_connections_received:3<br>total_commands_processed:16<br>instantaneous_ops_per_sec:0<br>total_net_input_bytes:542<br>total_net_output_bytes:2417<br>instantaneous_input_kbps:0.00<br>instantaneous_output_kbps:0.00<br>rejected_connections:0<br>sync_full:0<br>sync_partial_ok:0<br>sync_partial_err:0<br>expired_keys:0<br>evicted_keys:0<br>keyspace_hits:1<br>keyspace_misses:0<br>pubsub_channels:0<br>pubsub_patterns:0<br>latest_fork_usec:69502<br>migrate_cached_sockets:0<br>```<br>上面命令的结果显示中,在最近的一秒钟内,服务器没有处理命令。<br>trackOperationPerSecond函数和服务器状态中四个ops_sec开头的属性有关:<br>```c<br>struct redisServer {<br> // ...<br> // 上一次进行抽样的时间<br> long long ops_sec_last_sample_time;<br> // 上一次抽样时,服务器已执行命令的数量<br> long long ops_sec_last_sample_ops;<br> // REDIS_OPS_SEC_SAMPLE 大小(默认值为16)的环形数组<br> long long ops_sec_sample[REDIS_OPS_SEC_SAMPLES];<br> // ops_sec_sample数组的索引值<br> // 每次抽样后将值增一<br> // 再值等于16时重置为0<br> // 让ops_sec_samples数组构成一个唤醒数组<br> int opts_sec_ids;<br> // ...<br>}<br>```<br>trackOperationsPerSecond函数每次运行,都回根据ops_sec_last_sample_time记录的上一次抽样时间和服务器<br>的当前时间,以及ops_sec_last_sample_ops记录的上一次抽样的已执行命令数量和服务器当前的已执行命令数量,<br>计算出两次trackOperationsPerSecond调用之间,服务器平均每一毫秒处理了多少个命令请求,然后将这个平均值<br>乘以1000,这就得到了服务器在一秒钟内处理多少个命令请求的估计值,这个估计值会被作为一个新的数组项被放<br>进ops_sec_samples唤醒数组里面。当客户端执行INFO命令时,服务器就会调用getOperationsPerSecond函数,<br>根据ops_sec_samples唤醒数组中的抽样结果,计算出instantaneous_ops_per_sec属性的值,<br>
以下是getOperationsPerSecond<br>函数的实现代码:<br>```c<br>long long getOperationsPerSecond(void) {<br> int j;<br> long long sum = 0;<br> // 计算所有取样值综合<br> for (j = 0; j < REDIS_OPS_SEC_SAMPLES; j++) {<br> sum += server.ops_sec_samples[j];<br> }<br> // 计算取样的平均值<br> return sum / REDIS_OPS_SEC_SAMPLES;<br>}<br>```<br>根据getOperationsPerSeoncd函数的定义可以看出,instantaneous_ops_per_sec<br>属性的值是通过计算最近REDIS_OPS_SEC_SAMPLES次取样的平均值来计算得出的,它只是<br>一个估算值。<br>
更新服务器内存锋值记录。<br>服务器状态中的stat_peak_memory属性记录了服务器的内存锋值大小:<br>```c<br>struct redisServer {<br> // ...<br> // 已使用内存锋值<br> size_t stat_peak_memory;<br> // ..<br>};<br>```<br>每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory保存的数值<br>进行比较,如果当前使用的内存数量比stat_peak_memory属性记录的值要大,那么程序就将当前使用的内存<br>数量记录到stat_peak_memory属性里面。INFO memory命令的used_memory_peak和used_memory_peak_human<br>两个域分别以两种格式记录了服务器的内存锋值<br>```c<br>used_memory:714200<br>used_memory_human:697.46K<br>used_memory_rss:677272<br>used_memory_peak:715040<br>used_memory_peak_human:698.28K<br>used_memory_lua:36864<br>mem_fragmentation_ratio:0.95<br>mem_allocator:jemalloc-3.6.0<br>```<br>
处理SIGTERM信号。<br>在启动服务器时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数,这个信号处理器负责在服务器<br>接到SIGTERM信号时,打开服务器状态的shutdown_asap标识:<br>```c<br>// SIGTERM信号的处理器<br>static void sigtermHandler(int sig) {<br> // 打印日志<br> redisLogFromHandler(REDIS_WARNING, "Received SIGTERM, scheduling shutdown ...");<br> // 打开关闭标识<br> server.shutdown_assap = 1;<br>}<br>```<br>每次serverCron函数运行时,程序都会对服务器状态的shutdown_asap属性进行检查,并根据属性的值决定是否关闭服务器:<br>```c<br>struct redisServer {<br> // ...<br> // 关闭服务器的标识<br> // 1 关闭服务器<br> // 0 不做动作<br> int shutdown_asap;<br> // ...<br>}<br>```<br>服务器在关闭自身之前会进行RDB持久化操作,这也是服务器拦截SIGTERM信号的原因,如果服务器一接到SIGTERM信号就<br>立即关闭,那么它就没办法执行持久化操作了<br>
管理客户端资源。<br>serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行以下两个检查:<br>1.如果客户端与服务器之间的连接已经超时(很长一段时间里客户端和服务器都没有互动),那么程序释放这个客户端<br>资源<br>2.如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入<br>缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存<br>
管理数据库资源。<br>serverCron函数每次执行都会调用databasesCron函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,<br>并在有需要时,对字典进行收缩操作<br>
执行被延迟的BGREWRITEAOF。<br>在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令的<br>执行时间延迟到BGSAVE命令执行完毕之后。服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF<br>命令:<br>```c<br>struct redisServer {<br> // ...<br> // 如果值为1,那么表示有 BGREWRITEAOF命令被延迟了<br> int aof_rewrite_scheduled;<br> // ...<br>}<br>```每次serverCron函数执行时,函数都会检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行,如果这两个命令都没在<br>执行,并且aof_rewrite_scheduled属性的值为1,那么服务器就会执行之前被推延的BGREWRITEAOF命令。<br>
检查持久化操作的运行状态。<br>服务器状态使用rdb_child_pid属性和aof_child_pio属性记录执行BGSAVE命令和BGREWIRTEAOF命令的子进程的ID,这两个<br>属性也可以用于检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行:<br>```c<br>struct redisServer {<br> // ...<br> // 记录执行BGSAVE命令的子进程的ID<br> // 如果服务器没有在执行BGSAVE<br> // 那么这个属性的值为-1<br> pid_t rdb_child_pid // PID of RDB saving child<br> <br> // 记录执行BGREWRITEAOF命令的子进程的ID:<br> // 如果服务器没有在执行BGREWRITEAOF<br> // 那么这个属性的值为-1<br> pid_t aof_child_pid // PID if rewriting process<br>}<br>```<br>每次serverCron函数执行时,程序都会检查rdb_child_pid和aof_child_pid两个属性的值,只要其中一个属性的值不为-1,程序<br>就会执行一次wait3函数,检查子进程是否有信号发来服务器进程:<br>1.如果有信号到达,那么表示新的RDB文件已经生成完毕(对于BGSAVE命令来说),或者AOF文件已经重写完毕(对于BGREWRITEAOF<br>命令来说),服务器需要进行相应命令的后续操作,比如用新的RDB文件替换现有的RDB文件,或者用重写后的AOF文件替换现有的<br>AOF文件<br>2.如果信号没有到达,那么表示持久化操作未完成,程序不做动作<br>另一方面,如果rdb_child_pid和aof_child_pid两个属性的值都为-1,那么表示服务器没有在进行持久化操作,在这种情况下,程序<br>执行三个检查:<br>1.查看是否有BGREWRITEAOF被延迟了,如果有的话,那么开始一次新的BGREWRITEAOF操作<br>2.检查服务器的自动保存条件是否已经被满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器开始一次新的BGSAVE<br>操作(因为条件1可能会引发一次BGREWRITEAOF,所以在这个检查中,程序会再次确认服务器是否已经在执行持久化操作了)<br>3.检查服务器设置的AOF重写条件是否满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器将开始一次新的<br>BGREWIRTEAOF操作(因为条件1和2都可能会引起新的持久化操作,所在这个检查中,我们要再次确认服务器是否已经在执行持久化操作)<br>
整个检查过程如图所示。<br><br>
将AOF缓冲区中的内容写入AOF文件。<br>如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区中的内容<br>写入到AOF文件里面<br>
增加cronloops计数器的值。<br>服务器状态的cronloops属性记录了serverCron函数执行的次数<br>```c<br>struct redisServer {<br> // ...<br> // serverCron函数的运行次数计数器<br> // serverCron函数每执行一次,这个属性的值就增1<br> int cronloops;<br> // ...<br>}<br>```<br>cronloops属性目前在服务器中的唯一作用,就是在复制模块中实现"每执行serverCron函数N次就执行一次指定代码"的功能<br>方法如以下伪代码所示<br>```c<br>if cronloops % N == 0:<br> # 执行指定代码...<br>```<br>
初始化服务器。<br>一个Redis服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程,比如初始化服务器<br>状态,接受用户指定的服务器配置,创建相应的数据结构和网络连接等等<br>
初始化服务器状态结构。<br>初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态,<br>并为结构中的各个属性设置默认值。初始化server变量的工作由redis.c/initServerConfig函数完成,<br>```c<br>void initServerConfig(void) {<br> <br> // 设置服务器的运行id<br> getRandomHexChars(seerver.runid, REDSI_RUN_ID_SIZE);<br> <br> // 为运行id加上结尾字符<br> server.runid[REDIS_RUN_ID_SIZE] = '\0';<br> <br> // 设置默认配置文件路径<br> server.configfile = NULL;<br> <br> // 设置默认服务器频率<br> server.hz = REDIS_DEFAULT_HZ;<br> <br> // 设置服务器的运行架构<br> server.arch_bits = (sizeof(long) == 8) ? 64 :32;<br> <br> // 设置默认服务器端口号<br> server.port = REDIS_SERVERPORT;<br> <br> // ...<br>}<br>```<br>以下是initServerConfig函数完成的主要工作:<br>1.设置服务器的运行ID<br>2.设置服务器的默认运行频率<br>3.设置服务器的默认配置文件路径<br>4.设置服务器的运行结构<br>5.设置服务器的默认端口号<br>6.设置服务器的默认RDB持久化条件和AOF持久化条件<br>7.初始化服务器的LRU时钟<br>8.创建命令表<br>initServerConfig函数设置的服务器状态属性基本都是一些整数、浮点数、或者字符串属性,除了命令表<br>之外,initServerConfig函数没有创建服务器状态的其他数据结构,数据库、慢查询日志、Lua环境、<br>共享对象这些数据结构在之后的步骤才会被创建出来。当initServerConfig函数执行完毕之后,服务器就<br>可以进入初始化的第二个节点——载入配置选项<br>
载入配置选项。<br>在启动服务器时,用户可以通过给定配置参数或者指定配置文件来修改服务器的默认配置。举个例子,<br>如果我们在终端输入:<br>```c<br>redis-server --port 10086<br>```<br>那么我们就通过给定配置参数的方式,修改了服务器的运行端口号。另外,如果在终端输入:<br>```c<br>redis-server redis.conf<br>```<br>并且redis.conf文件中b包含以下内容:<br>```c<br># 将服务器的数据库数量设置为32个<br>databases 32<br># 关闭RDB文件的压缩功能<br>rdbcompression no<br>```<br>那么我们就通过指定配置文件的方式修改了服务器的数据库数量,以及RDB持久化模块的压缩功能。<br>服务器在用initServerConfig函数初始化server变量之后,就会开始载入用户给定的配置参数和配置文件,<br>并根据用户设定的配置,对server变量相关属性的值进行修改。例如,在初始化server变量时,程序会为<br>决定服务器端口号的port属性设置默认值:<br>```c<br>void initServerConfig(void) {<br> <br> // ...<br> <br> // 默认值为6379<br> server.port = REDIS_SERVERPORT;<br>}<br>```<br>
不过,如果用户在启动服务器时为配置选项port指定了新值10086,那么server.port属性的值就会被更新为10086,<br>这将使得服务器的端口号从默认的6379变为yoghurt指定的10086.<br>例如,在初始化server变量时,程序会为决定数据库数量的dbnum属性设置默认值:<br>```c<br>void initServerConfig(void) {<br> <br> // ...<br> <br> // 默认值为16<br> server.dbnum = REDIS_DEFUALT_DBNUM;<br>}<br>```<br>不过,如果用户在启动服务器时为选项databases设置了值32,那么server.dbnum属性的值就会被更新为32,这将<br>使得服务器的数据库数量从默认的16个变为用户指定的32个。<br>其他配置选项相关的服务器状态属性的情况与上面列举的port属性和dbnum属性一样:<br>1.如果用户为这些属性的相应选项指定了新的值,那么服务器就使用用户指定的值来更新相应的属性<br>2.如果用户没有为属性的相应选项设置新的值,那么服务器就沿用之前initServerConfig函数为属性的默认值。<br>服务器在载入用户指定的配置选项,并对server状态进行更新之后,服务器就可以进入初始化的第三个阶段——<br>初始化服务器数据结构<br>
初始化服务器数据结构。<br>在之前执行initServerconfig函数初始化server状态时,程序只创建了命令表一个数据结构,不过除了<br>命令表之外,服务器状态还包括其他数据结构,比如:<br>1.server.clients链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每隔节点都包含了<br>一个redisClient结构实例<br>2.server.db数组,数组中包含了服务器的所有数据库<br>3.用于执行Lua脚本的Lua环境server.lua<br>4.用于保存慢查询日志的server.slowlog属性<br>当初始化服务器进行到这一步,服务器将调用initServer函数,为以上提到的数据结构分配内存,并在有<br>需要时,为这些数据结构设置或者关联初始化值。<br>服务器到现在才初始化数据结构的原因在于,服务器必须先载入用户指定的配置选项,然后才能正确地对<br>数据结构进行初始化。如果在执行initServerConfig函数时就对数据结构进行初始化,那么一旦用户通过<br>配置选项修改了和数据有关地服务器状态属性,服务器就要重新调整和修改已创建地数据结构。为了避免<br>出现这种麻烦的情况,服务器选择了将server状态的初始化分为两步进行,initServerConfig函数主要负责<br>初始化一般属性,而initServer函数主要负责初始化数据结构。<br>除了初始化数据,initServer还进行了一些非常重要的设置操作,其中包括:<br>1.为服务器设置进程信号处理器<br>2.创建共享对象,这些对象包含Redis服务器经常用到的一些值,比如包含"OK"回复的字符串对象,包含<br>"ERR"回复的字符串对象,包含整数1到10000的字符串对象等等,服务器通过重用这些共享对象来避免反复<br>创建相同的对象<br>3.打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的<br>连接<br>4.为serverCron函数创建时间事件,等待服务器正式运行时执行serverCron函数<br>5.如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的<br>AOF文件,为AOF写入做好准备<br>6.初始化服务器的后台IO模块(bio),为将来的IO操作做好准备。<br>当initServer函数执行完毕之后,服务器将用ASCII字符在日志中打印出Redis的图标,以及Redis的版本号信息<br>
```c<br>file use E:\redis\redis-server.exe /path/to/redis.conf<br> _._<br> _.-``__ ''-._<br> _.-`` `. `_. ''-._ Redis 3.0.504 (00000000/0) 64 bit<br> .-`` .-```. ```\/ _.,_ ''-._<br> ( ' , .-` | `, ) Running in standalone mode<br> |`-._`-...-` __...-.``-._|'` _.-'| Port: 6379<br> | `-._ `._ / _.-' | PID: 20212<br> `-._ `-._ `-./ _.-' _.-'<br> |`-._`-._ `-.__.-' _.-'_.-'|<br> | `-._`-._ _.-'_.-' | http://redis.io<br> `-._ `-._`-.__.-'_.-' _.-'<br> |`-._`-._ `-.__.-' _.-'_.-'|<br> | `-._`-._ _.-'_.-' |<br> `-._ `-._`-.__.-'_.-' _.-'<br> `-._ `-.__.-' _.-'<br> `-._ _.-'<br> `-.__.-'<br><br>[20212] 01 Apr 21:06:44.205 # Server started, Redis version 3.0.504<br>[20212] 01 Apr 21:06:44.210 * DB loaded from disk: 0.005 seconds<br>[20212] 01 Apr 21:06:44.210 * The server is now ready to accept connections on port 6379<br>```<br>
还原数据库状态。<br>在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的<br>内容来还原服务器的数据库状态。根据服务器是否启用了AOF持久化功能,服务器载入数据时所使用的目标文件<br>会有所不同:<br>1.如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库装填。<br>2.相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库装填。<br>当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费地时长:<br>```c<br>[7256] 01 Apr 21:07:11.795 * DB loaded from disk: 0.000 seconds<br>```<br>
执行事件循环。<br>在初始化地最后一步,服务器将打印出以下日志:<br>```c<br>[7256] 01 Apr 21:07:11.795 * The server is now ready to accept connections on port 6379<br>```<br>并开始执行服务器的事件循环(loop).至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,<br>并处理客户端发来的命令请求了<br>
复制
概述。<br>在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)<br>另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被<br>称为从服务器(slave),如图所示。<br>
举个例子。假设现在有两个Redis服务器,地址分别为127.0.0.1:6379和127.0.0.1:12345,如果我们向<br>服务器127.0.0.1:12345发送以下命令:<br>```c<br>127.0.0.1:12345> SLAVEOF 127.0.0.1 6379<br>OK<br>```<br>那么服务器127.0.0.1:12345将称为127.0.0.1:6379的从服务器,而服务器6379则会称为12345的主服务器。<br>进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象乘坐为"数据库状态一致",或者<br>简称"一致"。<br>比如说,在主服务器上执行以下命令:<br>```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>```<br>那么我们应该既可以在主服务器上获取msg键的值:<br>```c<br>127.0.0.1:6379> GET msg<br>"hello world"<br>```<br>又可以在从服务器上获取msg键的值:<br>```c<br>127.0.0.1:12345> GET msg<br>"hello world"<br>```<br>另一方面,如果我们在主服务器中删除了键msg:<br>```c<br>127.0.0.1:6379> DEL msg<br>(integer) 1<br>```<br>那么不仅主服务器上的msg键会被删除<br>```c<br>127.0.0.1:6379> EXISTS msg<br>(integer) 0<br>```<br>从服务器上的msg键也应该会被删除:<br>```c<br>127.0.0.1:12345> EXISTS msg<br>(integer) 0<br>```<br>
旧版复制功能的实现。<br>Redis的复制宫嗯那个分为同步(sync)和命令传播(command propagate)两个操作:<br>1.同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态<br>2.命令传播则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态<br>出现不一致时,让主从服务器的数据库重新回到一致性<br>
同步。<br>当客户端向从服务器发送SLAVEOF命令,要求从服务器复制主服务器时,从服务器首先需要<br>执行同步操作,也即是,将从服务器的数据库状态更新至主服务器当前所处的数据库状态。<br>从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,以下是SYNC命令的<br>执行步骤:<br>1.从服务器向主服务器发送SYNC命令<br>2.收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录<br>从现在开始执行的所有写命令<br>3.当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB文件发送给从服务器,<br>从服务器接收并载入这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库<br>状态<br>4.主服务器将记录在缓冲区里面的所有写ing零发送给从服务器,从服务器执行这些写命令,将自己的<br>数据库状态更新至主服务器数据库当前所处的状态<br>
举个例子。<br>
命令传播。<br>在同步操作执行完毕之后,主从服务器两者的数据库将达到一致状态,但这种一致并不是一成不变的,<br>每当主服务器执行客户端发送的写命令时,主服务器的数据库就有可能会被修改,并导致主从服务器<br>状态不再一致。<br>
举个例子,假设一个主服务器和一个从服务器刚刚完成同步操作,它们的数据库都保存了<br>相同的五个键k1至k5,如图所示,如果这是,客户端向主服务器发送命令DEL k3,那么主服务器<br>在执行完这个DEL命令之后,主从服务器的数据库将出现不一致:主服务器的数据库已经不再包含键<br>k3,但这个键却仍然包含在从服务器的数据库里面,如图所示.<br>为了让z主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作:主服务器会将<br>自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器<br>执行了相同的写命令之后,主从服务器将再次回到y一致状态。<br><br>在上面的例子中,主服务器因为执行了命令DEL k3而导致主从服务器不一致,所以主服务器将向从服务器<br>发送相同的命令DEL k3。当从服务器执行完这个命令之后,主从服务器将再次回到一致状态,现在主从<br>服务器两者的数据库都不再包含键k3,<br>
处于不一致状态的主从服务器
主服务器向从服务器发送命令
旧版复制功能的缺陷。<br>在Redis2.8以前,从服务器对主服务器的复制可以分为以下两种情况:<br>1.初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器<br>和上一次复制的主服务器不同.<br>2.断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器<br>通过自动重连接重新连上了主服务器,并继续复制主服务器。<br>对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版<br>复制功能虽然也能让主服务器重新回到一致状态,但效率却非常低。<br>
举个例子。在时间10091,从服务器终于重新连接上主服务器,因为这是主从服务器的状态<br>已经不再一致,所以从服务器将向主服务器发送SYNC命令,而主服务器会将包含键k1至<br>键k10089的RDB文件发送给从服务器,从服务器通过接收和载入这个RDB文件来将自己的<br>数据库更新至主服务器数据库当前所处的状态。<br><br>虽然再次发送SYNC命令可以让主从服务器重新回到一致状态,但如果仔细研究过这个断线<br>重复制过程,就会发现传送RDB文件这一步实际上并不是非做不可的:<br>1.主从服务器在时间T0至T10086中一致处于一致状态,这连个服务器保存的数据大部分<br>都是相同的。<br>2.从服务器想要将自己更新至主服务器当前所处的状态,真正需要的是主从服务器连接中断<br>期间,主服务器新添加的k10086/k10088/k10089三个键的数据<br>3.可惜的是,旧版复制功能并没有利用以上列举的两点条件,而是继续让主服务器生成并向<br>从服务器发送包含键k1至键k10089的RDB文件,但实际上RDB文件包含的键1至键k10086<br>的数据对于从服务器来说都是不必要的。在主从服务器断线期间,主服务器执行的写命令<br>可能会有成百上千之多,而不仅仅是两三个写命令。但总的来说,主从服务器断开的时间<br>越短,主服务器在断线期间执行的写命令就越少,而执行少量写命令所产生的数据量通常比<br>整个数据库的数据量要少的多,在这种情况下,为了让从服务器不足一小部分缺失的数据,<br>却要让主从服务器重新再执行一次SYNC命令,这种做法无疑是非常低效的。<br>
注意。<br>SYNC命令是一个非常耗费资源的操作。每次执行SYNC命令主从服务器需要执行以下动作:<br>1.主服务器需要执行BGSAVE命令来生成RDB文件,这个生成操作会耗费主服务器大量的CPU、<br>内存和磁盘IO资源<br>2.主服务器需要将自己生成的RDB文件发送给从服务器,这个发送操作会耗费主从服务器大量的<br>网络资源(带宽和流量),并对主服务器相应命令请求的时间产生影响<br>3.接收到RDB文件的从服务器需要载入主服务器发来的RDB文件,并且在载入期间,从服务器会<br>因为阻塞而么没办法处理命令请求。<br>因为SYNC命令是一个如此耗费资源的操作,所以Redis有必要保证在真正有需要时才执行SYNC命令<br>
新版复制功能的实现。<br>为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis从2.8版本开始,使用<br>PSYNC命令代替SYNC命令来执行复制时的同步操作。<br>PYSNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)<br>两种模式:<br>1.其中完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,<br>它们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令<br>来进行同步<br>2.而部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件<br>允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收<br>并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。<br><br>PSYNC命令的部分重同步模式解决了旧版复制功能在处理断线后重复制时出现的低效情况,<br>
举个例子,如表所示,展示了如何使用SYNC命令高效地处理断线后复制情况,<br>对比以下SYNC命令和PSYNC命令处理断线重复制的方法,不难看出,虽然<br>SYNC命令和PSYNC命令都可以让断线的主从服务器重新回到一致状态,但<br>执行部分重同步所需的资源比起执行SYNC命令所需的资源要少得多,完成<br>同步的速度也快得多。执行SYNC命令需要生成、传送和载入整个RDB文件,<br>而部分重同步只需要将服务器缺少的写命令发送给从服务器执行就可以了。<br>
如图所示,展示了主从服务器在执行部分重同步时的通信过程。
部分重同步的实现。<br>部分重同步功能由以下三个部分构成:<br>1.主服务器的复制偏移量(replication offset)和从服务器的复制偏移量<br>2.主服务器的复制积压缓冲区(replication backlog)<br>3.服务器的运行ID(run ID)<br>
复制偏移量。<br>执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:<br>1.主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N<br>2.从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N<br><br>通过对比主从服务器的复制偏移量,程序可以很容易地直到主从服务器是否处于一致状态:<br>1.如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的<br>2.相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态<br>
举个例子。主从服务器的复制偏移量的值都为10086。<br>如果这时主服务器向三个从服务器传播长度为33字节的数据,那么主服务器的复制偏移量将<br>更新为10086+33=10119,而三个服务器在接收到主服务器传播的数据之后,也会将偏移量<br>更新为10119,如图所示<br>
举个例子,如果在向从服务器传播33字节数据之前,上图中的从服务器A断线了,那么主服务器传播的<br>数据将只有从服务器B和从服务器C能收到,在这之后,主服务器、从服务器B和从服务器C三个服务器的<br>复制偏移量都将更新为10119,而断线的从服务器A的复制偏移量仍然停留在10086,着说明从服务器A<br>与主服务器并不一致,如图所示.<br>假设从服务器A在断线之后就立即重新连接主服务器,并且成功,那么接下来,从服务器将向主服务器发送<br>PSYNC命令,报告从服务器A当前的复制偏移量为10086,那么这时,主服务器应该对从服务器执行完整<br>重同步还是部分重同步呢?如果执行部分重同步的话,主服务器又如何补偿从服务器A在断线期间丢失的那<br>部分数据呢?以上问题的答案都和复制积压缓冲区有关。<br>
复制积压缓冲区。<br>复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)的先进先出(FIFO)队列,默认大小<br>为1MB。<br><br>当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制<br>积压缓冲区里面,如图所示。<br><br>因此主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积积压缓冲区<br>会为队列中的每隔字节记录相应的复制偏移量,如表所示<br><br>当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset发送给主服务器,<br>主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:<br>1.如果offset偏移量之后的数据(也即是偏移量offset+1开始的数据)仍然存在于复制积压缓冲区里面,那么<br>主服务器对从服务器执行部分重同步操作<br>2.相反,如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整<br>重同步操作<br>
固定长度先进先出队列。<br>固定长度先进先出队列的入队和出队规则跟普通的先进先出队列一样:新元素从一边进入队列,而旧元素<br>从另一边弹出队列。和普通先进先出队列随着元素的增加和减少而动态调整长度不同,固定长度先进先出<br>队列的长度是固定的,当入队元素的数量大于队列长度时,最先入队的元素会被弹出,而新元素会被放入<br>队列。<br>
举个例子。如果我们要将'h'、'e'、'l'、'l'、'o'五个字符放进一个长度为3的<br>固定长度先进先出队列里面,那么'h'、'e'、'l'三个字符将首先被放入队列:<br>['h'、'e'、'l']<br>但是当后一个'l'字符要进入队列时,队首的'h'字符将被弹出,队列变成:<br>['e'、'l'、'l']<br>接着['l'、'l'、'o']<br>
举个例子。如上图中的断线后重连接:<br>1.当从服务器A断线之后,它立即重新连接主服务器,并向主服务器发送PSYNC命令,报告自己的复制<br>偏移量为10086.<br>2.主服务器收到从服务器发来的PSYNC命令以及偏移量10086之后,主服务器将检查偏移量10086之后的<br>数据是否存在于复制积压缓冲区里面,结果发现这些数据仍然存在,于是主服务器向从服务器发送+CONTINUE<br>回复,表示数据同步将以部分重同步模式来进行<br>3.接着主服务器会将复制积压缓冲区10086偏移量之后的所有数据(偏移量为10087至10119)都发送给从服务器<br>4.从服务器只要接收这33字节的缺失数据,就可以回到与主服务器一致的状态。如图所示<br>
根据需要调整复制积压缓冲区的大小。<br>Redis为复制积压缓冲区设置的默认大小为1MB,如果主服务器需要执行大量写命令,又或者主从服务器断线后<br>重连接所需的时间比较长,那么这个大小也许并不合适。如果复制积压缓冲区的大小设置得不恰当,那么PSYNC<br>命令得复制重同步模式就不能正常发挥作用,因此,正确估算和设置复制积压缓冲区的大小非常重要。<br>复制积压缓冲区的最小大小可以根据公式seoncd * write_size_per_second来估算:<br>1.其中second为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)<br>2.而write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)<br><br>例如,如果主服务器平均每秒产生1MB的写数据,而从服务器断线之后平均要5秒才能重新连接上服务器,那么<br>复制积压缓冲区的大小就不能低于5MB.<br>为了安全期间,可以将复制积压缓冲区的大小设为2 * second * write_size_per_second,这样可以保证绝大部分<br>断线情况都能用部分重同步来处理,至于复制积压缓冲区大小的修改方法,可以参考配置文件中关于<br>```c<br>repl-backlog-size<br>```
服务器运行ID。<br>除了复制偏移量和复制积压缓冲区之外,实现部分重同步还需要用到服务器运行ID(run ID):<br>1.每隔Redis服务器,不论主服务器还是从服务,都会有自己的运行ID<br>2.运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成,例如<br>9a4739e8d2bb8bbc95096b04df58af419ec8033f<br><br>当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从<br>服务器则会将这个运行ID保存起来。当从服务器断线并重新脸上一个主服务器时,从服务器将<br>向当前连接的主服务器发送之前保存的运行ID:<br>1.如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前<br>复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作<br>2.相反地,如果从服务器保存的运行ID和当前连接的主服务器的运行ID并不相同,那么说明从<br>服务器断线之前的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整<br>重同步操作
举个例子。假设从服务器原本正在复制一个运行ID为1的主服务器,那么在网络断开,从服务器<br>重新连接上主服务器之后,从服务器将向主服务器发送这个运行ID,主服务器根据自己的运行ID<br>是否为1来判断是否执行部分重同步还是执行完整重同步
PSYNC命令的实现。<br>PSYNC命令的调用方法有两种:<br>1.如果从服务器以前没有复制过任何主服务器,或者之前执行过SLAVEOF no one命令,<br>那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1 命令,主动请求主<br>服务器进行完整重同步(因为这时不可能执行部分重同步)<br>2.相反地,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时<br>将向主服务器发送PSYNC <runid> <offset>命令:其中runid是上一次复制的主服务器的<br>运行ID,而offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这<br>两个参数来判断应该对从服务器执行哪种同步操作。<br>根据情况,接收到PSYNC命令的主服务器会向从服务器返回以下三种回复的其中一种:<br>1.如果主服务器返回+FULLRESYNC <runid> <offset>回复,那么表示主服务器将与从<br>服务器执行完整重同步操作:其中runid是这个主服务器的运行ID,从服务器会将这个ID保存<br>起来,在下一次发送PSYNC命令时使用:而offset则是主服务器当前的复制偏移量,从服务器<br>会将这个值作为自己的初始化偏移量<br>2.如果主服务器返回+CONITNUE回复,那么表示主服务器将与从服务器执行部分重同步操作,<br>从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了<br>3.如果主服务器返回-ERR回复,那么表示主服务器的版本低于Redis2.8,它识别不了PSYNC命令,<br>从服务器将向主服务器发送SYNC命令,并与主服务器执行完整同步操作<br><br>流程图总结了PSYNC命令执行完整重同步和部分重同步时可能遇上的情况<br>
举个例子。例如完整的复制——网络中断——重复制。(假设都在127.0.0.1)<br>首先,假设有两个Redis服务器,它们的版本都是Redis2.8,其中主服务器的地址为<br>6379,从服务求的地址为12345.<br>1.如果客户端向从服务器发送民工SLAVEOF 127.0.0.1 6379,并且假设从服务器时第一次执行<br>复制操作,那么从服务器将向主服务器发送PSYNC ? -1命令,请求主服务器执行完整重同步操作。<br>2.主服务器在收到完整重同步操作请求之后,将在后台执行BGSAVE命令,并向从服务器返回<br>+FULLRESYNC 1(主服务器runid) 10086回复,其中10086<br>则是主服务器当前的复制偏移量。假设完整同步成功执行,并且主从服务器在一段时间之后仍然<br>保持一致,但是在复制偏移量为20000的时候,主从服务器之间的网络连接断了,这是从服务器<br>将重新连接主服务器,并再次对主服务器进行复制。<br>3.因为之前曾经对主服务器进行过复制,所以从服务器将向主服务器发送命令PSYNC 1 20000,<br>请求进行部分重同步。<br>4.主服务器在接收到从服务器的PSYNC命令之后,首先对比从服务求传来的运行ID 和主服务器自身<br>的运行ID,结果显示该ID和主服务器的运行ID相同,于是主服务器继续读取从服务器传来的偏移量<br>20000,检查偏移量为20000之后的数据是否存在于复制积压缓冲区立案,结果发现数据仍然存在。<br>5.确认运行ID相同并且数据存在之后,主服务器将向从服务器返回+CONTINUE回复,表示将与从<br>服务器执行部分重同步操作,之后主服务器会将保存在复制积压缓冲区20000偏移量之后的所有数据<br>发送给从服务器,主从服务器将再次回到一致状态
复制的实现。<br>通过向从服务器发送SLAVEOF命令,可以实现让一个从服务器去复制一个主服务器:<br>```c<br>SLAVEOF <master_ip> <master_port><br>```<br>以从服务器接收到127.0.0.1:12345接收到命令:<br>```c<br>SLAVEOF 127.0.0.1 6379<br>```<br>为例,分析详细实现步骤
步骤1:设置主服务器的地址和端口。<br>当客户端向从服务器发送以下命令时:<br>```c<br>127.0.0.1:12345> SLAVEOF 127.0.0.1 6379<br>OK<br>```<br>从服务器首先要做的就是将客户端给定的主服务器IP地址127.0.0.1以及端口6379保存到<br>服务器状态的masterhost属性和masterport属性里面:<br>```c<br>struct redisServer {<br>// ...<br><br>// 主服务器地址<br>char *masterhost;<br><br>// 主服务器的端口<br>int masterport;<br><br>// ...<br>}<br>```<br>如图所示,展示了SLAVEOF命令执行之后,从服务器的服务器状态。<br>SLAVEOF命令是一个异步命令,在完成masterhost属性和masterport属性的设置工作<br>之后,从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制指令已经被接收,而<br>实际的复制工作将在OK返回之后才真正开始执行
步骤2:建立套接字连接。<br>在SLAVEOF命令执行之后,从服务器将根据命令所设置的IP地址和端口,创建连向主服务器的<br>套接字连接,如图所示。如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从<br>服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责<br>执行后续的复制工作,比如接收RDB文件,以及接收主服务器传播来的写命令,诸如此类。<br>而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,<br>并将从服务器看作是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器(server)<br>和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回<br>命令回复,如图所示
步骤3:发送PING命令。<br>从服务器称为主服务器的客户端之后,做的第一件事就是向主服务器发送一个PING命令,如图所示.<br>这个PING命令有两个作用:<br>1.虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送PING<br>命令可以检查套接字的读写状态是否正常。<br>2.因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送<br>PING命令可以检查主服务器能否正常处理命令请求。<br>从服务器在发送PING命令之后将遇到以下三种情况中的其中一种:<br>1.如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限(timeout)内读取出命令<br>回复的内容,那么表示主从服务器之间的网络连接状态不佳,不能继续执行复制工作的后续步骤,当出现<br>这种情况时,从服务器断开并重新创建连向主服务器的套接字。<br>2.如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能<br>继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。<br>比如说,如果主服务器正在处理一个超时运行的脚本,那么当从服务器向主服务器发送PING命令时,从<br>服务器将收到主服务器返回的BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE错误<br>3.如果从服务器读取到PONG回复,那么表示主从服务器之间的网络连接正常,并且主服务器可以正常处理从服务器(客户端)<br>发送的命令请求,在这种情况下,从服务器可以继续执行复制工作的下个步骤
流程图总结。<br>
步骤4:身份验证。<br>从服务器在收到主服务器返回的"PONG"回复之后,下一步要做的就是决定是否进行身份验证:<br>1.如果从服务器设置了masterauth选项,那么进行身份验证<br>2.如果从服务器没有设置masterauth选项,那么不进行身份验证<br>在需要进行身份验证的情况下,从服务器将向主服务器发送一条AUTH命令,命令的参数为从<br>服务器masterauth选项的值.<br><br>所有错误情况都会令从服务器中止目前的复制工作,并从创建套接字开始重新执行复制,直到<br>身份验证通过,或者从服务器放其执行复制为止。<br>
举个例子。<br>如果从服务器masterauth选项的值为100086,那么从服务器将向主服务器发送命令<br>AUTH 10086,如图所示。<br>从服务器在身份验证阶段可能遇到的情况有以下集中:<br>1.如果主服务器没有设置requirepass选项,并且从服务器也没有设置masteratuh选项,<br>那么主服务器将继续执行从服务器发送的命令,复制工作可以继续执行<br>2.如果从服务器通过AUTH命令发送的密码和主服务器requirepass选项所设置的密码相同,<br>那么主服务器将继续执行从服务器发送的命令,复制工作可以继续执行<br>3.如果主服务器设置了requirepass选项,但从服务器却没有设置masterauth选项,那么<br>主服务器将返回一个NOAUTH错误。另一方面,如果主服务器没有设置requirepass选项,<br>但从服务器却设置了masterauth选项,那么主服务器将返回一个no password is set错误。<br>
流程图总结。<br>
步骤5:发送端口信息。<br>在身份验证步骤之后,从服务器将执行命令REPLCONF listenning-port <port-number>,<br>向主服务器发送从服务器的监听端口号。<br>例如,从服务器的监听端口为12345,那么从服务器将向主服务器发送命令REPLCONF listening-port 12345,<br>如图所示。主服务器在接收到这个命令之后,会将端口号记录在从服务器所对应的客户端状态的salve_listening_port<br>属性中:<br>```c<br>typedef struct redisClient {<br>// ...<br>// 从服务器的监听端口号<br>int slave_listening_port;<br><br>// ...<br>} redisClient;<br>```<br>slave_listening_port属性目前唯一的作用就是在主服务器执行INFO replication命令时打印出从服务器的端口号。<br>以下时客户端向主服务器发送INFO replication命令时得到的回复,其中slave0行的port域显示的就是从服务器所<br>对应客户端状态的slave_listening_port属性的值:<br>```c<br>127.0.0.1:6379> INFO replication<br># Replication<br>role:master<br>connected_slaves:1<br>slave0:ip=127.0.0.1,port=12345,state=online,offset=9286,lag=0<br>master_repl_offset:9286<br>repl_backlog_active:1<br>repl_backlog_size:1048576<br>repl_backlog_first_byte_offset:8853<br>repl_backlog_histlen:434<br>```
如图所示展示了客户端状态设置slave_listening_port属性之后的样子
步骤6:同步。<br>从服务器将从主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的<br>状态。值得一提的是,在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,<br>主服务器也会称为从服务器的客户端:<br>1.如果PSYNC命令执行的是完整重同步操作,那么主服务器需要称为从服务器的客户端,才能将保存在缓冲区<br>里面的写命令发送给从服务器执行。<br>2.如果PSYNC命令执行的是部分重同步操作,那么主服务器需要称为从服务器的客户端,才能向从服务器发送<br>保存在复制积压缓冲区里面的写命令<br>因此,在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相<br>向对方发送命令请求,或者互相向对方返回命令回复,如图所示。<br>正因为主服务器称为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,<br>不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础
步骤7:命令传播。<br>当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给<br>从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主服务器一直保持一致了
心跳检测。<br>在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:<br>```c<br>REPLCONF ACK < replication_offset ><br>```<br>其中replication_offset是从服务器当前的复制偏移量。<br>发送REPLCONF ACK命令对于主从服务器有三个作用:<br>1.检测主从服务器的网络连接状态<br>2.辅助实现min-slaves选项<br>3.检测命令丢失
检测主从服务器的网络连接状态。<br>主从服务器可以通过发送和接收REPLCONF ACK命令来检查两者之间的<br>网络连接是否正常:如果主服务器超过一秒钟没有收到从服务器发来的REPLCONF ACK命令,<br>那么主服务器旧知道主从服务器之间的连接出现问题了。通过向主服务器发送INFO replication<br>命令,在列出的从服务器列表的lag一栏中可以看到相应从服务器最后一次向主服务器发送REPLCONF<br>ACK 命令距离现在过了多少秒:<br>```c<br>127.0.0.1:6379> INFO replication<br># Replication<br>role:master<br>connected_slaves:1<br># 刚刚发送过REPLCONF ACK命令<br>slave0:ip=127.0.0.1,port=12345,state=online,offset=11596,lag=1<br>master_repl_offset:11596<br>repl_backlog_active:1<br>repl_backlog_size:1048576<br>repl_backlog_first_byte_offset:8853<br>repl_backlog_histlen:2744<br>```<br>在一般情况下,lag的值应该在0秒或者1秒之间跳动,如果超过1秒,那么说明主从服务器之间的连接<br>出现了故障。
辅助实现min-slaves配置选项。<br>Redis的min-slaves-to-write和min-slaves-max-lag两个选项可以防止主服务器在不安全的情况下执行<br>写命令。<br>
举个例子。我们向主服务器提供以下配置:<br>```c<br>min-slaves-to-write 3<br>min-slaves-max-lag 10<br>```<br>那么在从服务器的数量少于3个,或者三个从服务器的延迟(lag)值<br>都大于或等于10秒时,主服务器将拒绝执行写命令,这里的延迟值就是<br>INFO replication命令的lag值
检测命令丢失。<br>如果因为网络故障,主服务器传播个i从服务器的写命令在半路丢失,那么当从服务器向主服务器发送<br>REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器<br>就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据<br>重新送给从服务器。
举个例子。假设有两个处于一致状态的主从服务器,它们的复制偏移量都是200,如图所示..<br>如果这时主服务器执行了命令SET key value(协议格式的长度为33字节),将自己的复制偏移量<br>更新到了233,并尝试向从服务器传播命令SET key value,但这条命令却因为网络故障而在<br>传播的途中丢失,那么主从服务器之间的复制偏移量就会出现不一致,主服务器的偏移量会被<br>更新为233,而从服务器的复制偏移量仍然为200,如图所示。<br>在这之后,当从服务器向主服务器发送REPLCONF ACK命令的时候,主服务器会察觉从服务器<br>的复制偏移量依然为200,而自己的复制偏移量为233,这说明复制积压缓冲区里面复制偏移量<br>为201至233的数据(也即是命令SET key value)在传播过程中丢失了,于是主服务器会再次向<br>从服务器传播命令SET key value,从服务器通过接收并执行这个命令,可以将自己更新至<br>主服务器当前所处的状态,如图所示。
注意。<br>主服务器向从服务器补发缺失数据这一操作的原理和部分重同步操作的原理非常相似,这两个操作<br>的区别在于,补发缺失数据操作在主从服务器没有断线的情况下进行,而部分重同步操作则在主从<br>服务器断线并重连之后执行。<br><br>Redis2.8版本以前的命令丢失。<br>REPLCONF ACK命令和复制积压缓冲区都是Redis2.8版本新增的,在Redis2.8版本以前,即是命令<br>在传播过程中丢失,主服务器和从服务器都不会注意到,主服务器更不会向从服务器补发丢失的数据,<br>所以为了保证复制时主从服务器的数据一致性,最好使用Redis2.8版本以上的
Sentinel
概述。<br>Sentinel(哨岗、哨兵)是Redis的高可用性(high availability)解决方案:由一个或多个Sentinel实例(instance)<br>组成的Sentinel系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被<br>监视的主服务器进入下线状态时,自动将下线主服务器属性的某个从服务器升级为新的主服务器,然后由新<br>的主服务器代替已下线的主服务器继续处理命令请求。
举个例子。图中展示了一个Sentinel系统监视服务器的例子其中:<br>1.用双环图案表示的是当前的主服务器server1<br>2.用单环图表示的是主服务器的三个从服务器server2、server3<br>以及server4<br>3.server2、server3、server4三个从服务器正在复制主服务器server1,<br>而Sentinel系统则在监视所有四个服务器
假设这时,主服务器server1进入下线状态,那么从服务器server2、<br>server3、server4对主服务器的复制操作将被中止,并且Sentinel系统<br>会察觉到server1已下线,如图所示(下线的服务器用虚线表示)
当server1的下线时长超过用户设定的下线时长上限时,Sentinel系统就会对<br>server1执行故障转义操作:<br>1.首先,Sentinel系统会挑选server1属下的其中一个从服务器,并将这个被选中的<br>从服务器升级为新的主服务器<br>2.之后,Sentinel系统会向server1属性的所有从服务器发送新的复制指令,让它们<br>称为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障<br>转移操作执行完毕<br>3.另外,Sentinel还会继续监视已下线的server1,并在它重新上线时,将它设置为新的<br>主服务器的从服务器<br><br>如图所示。Sentinel将系统2升级为新的主服务器,并让服务器server3和server4成为<br>sever2的从服务器的过程,之后,如果server1重新上线的话,它将被Sentinel系统降级<br>为server2的从服务器
启动并初始化Sentinel。<br>```c<br>redis-sentinel /path(你自己的路径)/sentinel.conf<br>```<br>或者<br>```c<br>redis-server /path(你自己的路径)/sentinel.conf -- sentinel<br>```<br>这两个命令的效果完全相同。<br>当一个Sentinel启动时,他需要执行以下步骤:<br>1.初始化服务器<br>2.将普通Redis服务器使用的代码替换成Sentinel专用代码<br>3.初始化Sentinel状态<br>4.根据给定的配置文件,初始化Sentinel的监视主服务器列表。}<br>5.创建连向主服务器的网络连接
初始化服务器。<br>首先,因为Sentinel本质上只是一个运行在特殊模式下的Redis服务器,所以启动Sentinel的第一步,<br>就是初始化一个普通的Redsi服务器,不过因为Sentinel执行的工作和普通Redis服务器执行的工作不同,<br>所以Sentinel的初始化过程和普通Redis服务器的初始化过程并不完全相同。<br>例如,普通服务器在初始化时会通过载入RDB文件或者AOF文件来还原数据库状态,但是因为Sentinel<br>并不使用数据库,所以初始化Sentinel时就不会载入RDB文件或者AOF文件
使用Sentinel专用代码。<br>启动Sentinel的第二个步骤就是将一部分普通Redis服务器使用的代码替换成Sentinel专用代码。<br>比如说,普通Redis服务器使用redis.h/REDIS_SERVERPORT常量的值作为服务器端口:<br>```c<br>#define REDIS_SERVERPORT 6379<br>```<br>而Sentinel则使用sentinel.c/REDIS_SENTINEL_PORT常量的值作为服务器端口:<br>```c<br>#define REDIS_SENTINEL_PORT 26379<br>```<br>除此之外,普通Redis服务器使用redis.c/redisCommandTable作为服务器的命令表:<br><br>而Sentinel则使用sentinel.c/sentinelcmds作为服务器的命令表,并且其中的INFO命令会使用<br>Sentinel模式下的专用实现sentinel.c/sentinelInfoCommand函数,而不是普通Redis服务器使用的<br>实现redis.c/infoCommand函数:<br><br>sentinel命令表也解释了为什么在Sentinel模式下Redis服务器不能执行诸如SET、DBSIZE、EVAL等等<br>这些命令,因为服务器根本没有在命令表中载入这些命令。PING、SENTINEL、INFO、SUBSCRIBE、<br>UNSUBSCRIBE、PSUBSCRIBE和PUNSUBSCRIBE这七个命令就是客户端可以对Sentinel执行的全部命令了
```c<br>struct redisCommand redisCommandTable[] = {<br> {"get",getCommand,2,"read-only fast @string",0,NULL,1,1,1,0,0,0},<br>{"getex",getexCommand,-2,"write fast @string",0,NULL,1,1,1,0,0,0},<br>{"set",setCommand,-3,"write use-memory @string",0,NULL,1,1,1,0,0,0},<br>{"setnx",setnxCommand,3,<br> "write use-memory fast @string",0,NULL,1,1,1,0,0,0},<br>{"setex",setexCommand,4,"write use-memory @string",0,NULL,1,1,1,0,0,0},<br>{"append",appendCommand,3,"write use-memory fast @string",0,NULL,1,1,1,0,0,0},<br>{"strlen",strlenCommand,2,"read-only fast @string",0,NULL,1,1,1,0,0,0},<br>{"del",delCommand,-2,"write @keyspace",0,NULL,1,-1,1,0,0,0},<br>{"unlink",unlinkCommand,-2,"write fast @keyspace",0,NULL,1,-1,1,0,0,0},<br>{"exists",existsCommand,-2,"read-only fast @keyspace",0,NULL,1,-1,1,0,0,0},<br>{"mget",mgetCommand,-2,"read-only fast @string",0,NULL,1,-1,1,0,0,0},<br>// ....<br>}<br>```<br>redis.c/redisCOmmandTable命令表
```c<br>struct redisCommand sentinelcmds[] = {<br> {"ping",pingCommand,1,"fast @connection",0,NULL,0,0,0,0,0},<br> {"sentinel",sentinelCommand,-2,"admin",0,NULL,0,0,0,0,0},<br> {"subscribe",subscribeCommand,-2,"pub-sub",0,NULL,0,0,0,0,0},<br> {"unsubscribe",unsubscribeCommand,-1,"pub-sub",0,NULL,0,0,0,0,0},<br> {"psubscribe",psubscribeCommand,-2,"pub-sub",0,NULL,0,0,0,0,0},<br> {"punsubscribe",punsubscribeCommand,-1,"pub-sub",0,NULL,0,0,0,0,0},<br> {"publish",sentinelPublishCommand,3,"pub-sub fast",0,NULL,0,0,0,0,0},<br> {"info",sentinelInfoCommand,-1,"random @dangerous",0,NULL,0,0,0,0,0},<br> {"role",sentinelRoleCommand,1,"fast read-only @dangerous",0,NULL,0,0,0,0,0},<br> {"client",clientCommand,-2,"admin random @connection",0,NULL,0,0,0,0,0},<br> {"shutdown",shutdownCommand,-1,"admin",0,NULL,0,0,0,0,0},<br> {"auth",authCommand,-2,"no-auth fast @connection",0,NULL,0,0,0,0,0},<br> {"hello",helloCommand,-1,"no-auth fast @connection",0,NULL,0,0,0,0,0},<br> {"acl",aclCommand,-2,"admin",0,NULL,0,0,0,0,0,0},<br> {"command",commandCommand,-1, "random @connection", 0,NULL,0,0,0,0,0,0}<br>};<br>```<br>sentinelcmds命令表
初始化Sentinel状态。<br>在应用了Sentinel的专用代码之后,接下来,服务器会初始化一个sentinel.c/sentinelState结构<br>(简称Sentinel状态),这个结构<br>保存了服务器中所有和Sentinel功能有关的状态(服务器的一般状态仍然由redis.h/redisServer保存);<br>```c<br>struct sentinelState {<br>// 当前纪元,用于实现故障转移<br>unit64_t current_epoch;<br>// 保存了所有被这个sentinel监视的主服务器<br>// 字典的键是主服务器的名字<br>// 字典的值则是一个指向sentinelRedisInstance结构的指针<br>dict *masters;<br>// 是否进入了TILT模式?<br>int tilt;<br>// 目前正在执行的脚本的数量<br>int running_scripts;<br>// 进入TILT模式的时间<br>mstime_t tilt_start_time;<br>// 最后一次执行时间处理器的时间<br>mstime_t previous_time;<br>// 一个FIFO队列,包含了所有需要执行的用户脚本<br>list *scripts_queue;<br>} sentinel;<br>```
初始化Sentinel状态的masters属性。<br>Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息,其中:<br>1.字典的键是被监视主服务器的名字<br>2.而字典的值则是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构。<br><br>每个sentinelRedisInstance结构(简称实例结构)代表一个被Sentinel监视的Redis服务器实例<br>(instance),这个实例可以是主服务器、从服务器,或者另外一个Sentinel。实例结构包含的<br>属性非常多,下方代码展示了实例结构在表示主服务器时使用的其中一部分属性<br><br>sentinelRedisInstance.addr属性是一个指向sentinel.c/sentinelAddr结构的指针,这个结构<br>保存着实例的IP地址和端口号:<br>```c<br>typedef struct sentinelAddr {<br>char *ip;<br>int port;<br>} sentinelAddr;<br>```<br>对Sentinel状态的初始化将引发对masters字典的初始化,而masters字典的初始化是根据被<br>载入的Sentinel配置文件来进行的。
```c<br>typedef struct sentinelRedisInstance {<br>// 标识值,记录了实例的类型,以及该实例的当前状态<br>int flags;<br>// 实例的名字<br>// 主服务器的名字由用户在配置文件中设置<br>// 从服务器以及Sentinel的名字由Sentinel自动设置<br>// 格式为ip:port 例如"127.0.0.1:26379"<br>char *name;<br><br>// 实例的运行ID<br>char *runid;<br><br>// 配置纪元,用于实现故障转移<br>uint64_t coding_epoch;<br>// 实例的地址<br>sentinelAddr *addr;<br>// SENTINEL down-after-milliseconds选项设定的值<br>// 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)<br>mstime_t down_after_period;<br><br>// SENTINEL monitor <master-name> <IP> <port> <quorum>选项中的quorum参数<br>// 判断这个实例为客观下线(objectively down)所需的支持投票数量<br>int quorum;<br><br>// SENTINEL parallel-syncs <master-name> <number>选项的值<br>// 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量<br>int parallel_syncs;<br>// SENTINEL failover-timeout <master-name> < ms> 选项的值<br>// 刷新故障迁移状态的最大时限<br>mstime_t failover_timeout;<br>// ....<br>} sentinelRedisInstance;<br>```<br>sentinelRedisInstance结构
举个例子。如果用户在启动Sentinel时,指定了包含以下内容的配置文件:<br>```c<br># master1 configure<br>sentinel monitor master1 127.0.0.1 6379 2<br>sentinel down-after-milliseconds master1 3000<br>sentinel parallel-syncs master1 1<br>sentinel failover-timeout master1 900000<br>```<br>```c<br># master2 configure<br>sentinel monitor master2 127.0.0.1 12345 5<br>sentinel down-after-milliseconds master2 50000<br>sentinel parallel-syncs master2 5<br>sentinel failover-timeout master2 450000<br>```<br>那么Sentinel将为主服务器master1创建如图所示的实例结构,并未主服务器master2<br>创建如图所示的实例结构,而这两个实例结构又会被保存到Sentinel状态的masters<br>字典中
master1
master2
Sentinel状态以及masters字典
创建连向主服务器的网络连接。<br>初始化Sentinel的最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为<br>主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。<br>对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步<br>网络连接:<br>1.一个是命令连接,这个链接专门用于向主服务器发送命令,并接收命令回复<br>2.另一个是订阅连接,这个连接专门用于订阅主服务器的_sentinel_:hello频道<br><br>如图所示,展示了一个Sentinel向被它监视的两个主服务器master1和master2创建<br>命令连接和订阅连接的例子
为什么有两个连接?<br>在Redis目前的发布与订阅功能中,被发送的信息都不会保存在Redis服务器里面,<br>如果在信息发送时,想要接收信息的客户端不在线或者断线,那么这个客户端就会<br>丢失这条信息。因此,为了不丢失_sentinel_:hello频道的任何信息,Sentinel必须专门<br>用一个订阅连接来接收该频道的信息。<br>另一方面,除了订阅频道之外,Sentinel还必须向主服务器发送命令,以此来与主服务器<br>进行通信,所以Sentinel还必须向主服务器创建命令连接。因为Sentinel需要与多个实例<br>创建多个网络连接,所以Sentinel使用的是异步连接<br><br>补充<br>通过这两个连接,Sentinel 可以实时监控 Redis 实例的状态,并在发生故障时迅速做出反应。<br>此外,通过使用两个连接而不是一个,可以增加系统的健壮性,一旦其中一个连接出现问题,<br>Sentinel 仍然可以通过另一个连接与 Redis 进行通信,以继续执行监视和管理操作。<br>总之,Sentinel 向 Redis 主服务器创建两个连接是为了增强监视的可靠性和鲁棒性,<br>确保 Sentinel 能够及时感知到 Redis 实例的状态变化并采取相应的措施。<br>
获取主服务器信息。<br>Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,<br>并通过分析INFO命令的回复来获取主服务器的当前信息。<br><br>通过分析主服务器返回的INFO命令回复,Sentinel可以获取以下两方面的信息:<br>1.一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的<br>服务器角色<br>2.另一方面是关于主服务器属下所有从服务器的信息,每个从服务器都由一个"slave"字符串<br>开头的行记录,每行的ip=域记录了从服务器的IP地址,而port=域记录了从服务器的端口号.<br>根据这些IP地址和端口号,Sentinel无须用户提供从服务器的地址信息,就可以自动发现从服务器。<br><br>根据run_id域和role域记录的信息,Sentinel将对主服务器的实例结构进行更新。例如,主服务器<br>重启之后,它的运行ID就会和实例结构之前保存的运行ID不同,Sentinel检测到这一情况之后,<br>就会对实例结构的运行ID进行更新。<br>至于主服务器返回的从服务器信息,则会被用于更新主服务器实例结构的slaves字典,这个字典<br>记录了主服务器属下从服务器的名单:<br>1.字典的键是由Sentinel自动设置的从服务器名字,格式为ip:port: 如对于IP地址为127.0.0.1,端口<br>号为11111的从服务器来说,Sentinel为它设置的名字就是127.0.0.1:11111<br>2.至于字典的值则是从服务器对应的实例结构:比如说,如果键是127.0.0.1:11111,那么这个键的值<br>就是IP地址为127.0.0.1,端口号为11111的从服务器的实例结构<br>Sentinel在分析INFO命令中包含的从服务器信息时,会检查从服务器对应的实例结构是否已经存在<br>于slaves字典:<br>1.如果从服务器对应的实例结构已经存在,那么Sentinel对从服务器的实例结构进行更新<br>2.如果从服务器对应的实例结构不存在,那么说明这个从服务器是新发现的从服务器,Sentinel会在<br>slaves字典中为这个从服务器新创建一个实例结构<br>
举个例子,假设如图所示,主服务器master有三个从服务器slave0、slave1、slave2,<br>并且一个Sentinel正在连接主服务器,那么Sentinel将持续地向主服务器发送INFO命令,<br>```c<br># Server<br>....<br>run_id:9a4739e8d2bb8bbc95096b04df58af419ec8033f<br>....<br># Replication<br>role:master<br>connected_slaves:1<br>slave0:ip=127.0.0.1,port=12345,state=online,offset=22139,lag=0<br>master_repl_offset:22139<br>repl_backlog_active:1<br>repl_backlog_size:1048576<br>repl_backlog_first_byte_offset:22126<br>repl_backlog_histlen:14<br># Clients<br>connected_clients:1<br>....<br># Other sections<br>```<br>
在上述例子中,Sentinel将分别为三个从服务器创建它们各自的实例结构,并将这些结构<br>保存到主服务器实例结构的slaves字典里面,如图所示。<br>注意比对图中主服务器实例结构和从服务器实例结构之间的区别:<br>1.主服务器实例结构的flags属性的值为SRI_MASTER,而从服务器实例结构的flags属性的值<br>为SRI_SLAVE<br>2.主服务器实例结构的name属性的值是用户使用Sentinel配置文件设置的,而从服务器的<br>实例结构的name属性的值则是Sentinel根据从服务器的IP地址和端口号自动设置的
获取从服务器信息。<br>当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会为这个新的从服务器创建相应的<br>实例结构之外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。
举个例子,对于图中所示的主从服务器关系来说,Sentinel将对slave0/1/2三个从服务器分别创建<br>命令连接和订阅连接,如图所示。在创建命令连接之后,Sentinel在默认情况下,会以每十秒一次<br>的频率通过命令连接向从服务器发送INFO命令并获得类似以下内容的回复:<br>```c<br># Server<br>// ....<br>run_id:a5bd47a1e569ed14567eca650de57f9d83301638<br># Replication<br>role:slave<br>master_host:127.0.0.1<br>master_port:6379<br>master_link_status:up<br>master_last_io_seconds_ago:9<br>master_sync_in_progress:0<br>slave_repl_offset:25177<br>slave_priority:100<br>slave_read_only:1<br>connected_slaves:0<br>master_repl_offset:0<br>repl_backlog_active:0<br>repl_backlog_size:1048576<br>repl_backlog_first_byte_offset:0<br>repl_backlog_histlen:0<br># ...<br>```<br>根据INFO命令的回复,Sentinel会提取出以下信息:<br>1.从服务器的运行ID run_id<br>2.从服务器的角色role<br>3.主服务器的IP地址master_host,以及主服务器的端口号master_port<br>4.主从服务器的连接状态master_link_status<br>5.从服务器的优先级slave_priority<br>6.从服务器的复制偏移量slave_repl_offset<br>根据这些信息,Sentinel会对从服务器的实例结构进行更新,如中展示了Sentinel根据上面的INFO<br>命令回复对从服务器的实例结构进行更新之后,实例结构的样子
从服务器实例结构
向主服务器和从服务器发送信息。<br>在默认情况下,Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器<br>和从服务器发送以下格式的命令:<br>```c<br>PUBLISH _sentinel_:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<br><m_epoch>"<br>```<br>这条命令向服务器的_sentinel_:hello频道发送了一条信息,信息的内容由多个参数组成:<br>1.其中以s_开头的参数记录的是Sentinel本身的信息,各个参数的意义表示<br>1.1 s_ip:Sentinel的IP地址<br>1.2 s_port:Sentinel的端口号<br>1.3 s_runid:Sentinel的运行ID<br>1.4 s_epoch:Sentinel当前的配置纪元(configuration epoch)<br>2.而以m_开头的参数记录的则是主服务器的信息,各个参数的意义表示。如果Sentinel正在监视<br>的是主服务器,那么这些参数记录的就是主服务器的信息;如果Sentinel正在监视的是从服务器,<br>那么这些参数记录的就是从服务器正在复制的主服务器的信息<br>2.1 m_name:主服务器的名i在<br>2.2 m_ip:主服务器的IP地址<br>2.3 m_port:主服务器的端口号<br>2.4 m_epoch:主服务器当前的配置纪元<br>
举个例子。<br>以下是一条Sentinel通过PUBLISH命令向主服务器发送的信息:<br>"127.0.0.1,26379,a5bd47a1e569ed14567eca650de57f9d83301635,0,mymaster,127.0.0.1,6379,0"<br>这个示例包含了以下信息:<br>1.Sentinel的IP地址为127.0.0.1,端口号为26379,运行ID为a5bd47a1e569ed14567eca650de57f9d83301635,<br>当前的配置纪元为0<br>2.主服务器的名字为mymaster,IP地址为127.0.0.1,端口号为6379,当前的配置纪元为0
接收来自主服务器和从服务器的频道消息。<br>当Sentinel与一个主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器<br>发送以下命令:<br>```c<br>SUBSCRIBE _sentinel_:hello<br>```<br>Sentinel对_sentinel_:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止。这也就是说,<br>对于每个与Sentinel连接的服务器,Sentinel既通过命令连接向服务器的_sentinel_:hello频道发送消息,<br>又通过订阅连接从服务器的_sentinel_:hello频道接收消息,如图所示。<br>对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被i其他Sentinel接收到,这些<br>信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器<br>的认知
举个例子。假设现在有sentinel1/2/3三个Sentinel在监视同一个服务器,那么当sentinel1向服务器的<br>_sentinel_:hello频道发送一条信息时,所有订阅了_sentinel_:hello频道的Sentinel(包括sentinel1自己在内)<br>都会收到这条信息,如图所示。<br>当一个Sentinel从_sentinel_:hello频道收到一条信息时,Sentinel会对这条信息进行分析,提取出信息中的<br>Sentinel IP地址、Sentinel端口号、Sentinel运行ID等八个参数,并进行以下检查:<br>1.如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID相同,那么说明这条信息时Sentinel自己<br>发送的,Sentinel将丢弃这条信息,不做进一步处理<br>2.相反地,如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID不相同,那么说明这条信息<br>是监视同一个服务器的其他Sentinel发来的,接收信息的Sentinel将根据信息中的各个参数,对相应主服务器<br>的实例结构进行更新
更新sentinels字典。<br>Sentinel为主服务器创建的实例结构中的sentinels字典保存了除了Sentinel本身之外,所有同样监视这个主服务器<br>的其他Sentinel的资料:<br>1.sentinels字典的键是其中一个Sentinel的名字,格式为ip:port,比如对于IP地址为127.0.0.1,端口号为26379的<br>Sentinel来说,这个Sentinel在sentinels字典中的键就是"127.0.0.1:26379"<br>2.sentinels字典的值则是键所对应的Sentinel的实例结构,比如对于键"127.0.0.1:26379"来说,这个键在sentinels<br>字典中的值就是IP为1270.0.01,端口号为26379的Sentinel的实例结构<br><br>当一个Sentinel接收到其他Sentinel发来的信息时(我们称呼发送信息的Sentinel为源Sentinel,接收信息的Sentinel<br>为目标Sentinel),目标Sentinel会从信息中分析并提取出以下两方面参数:<br>1.与Sentinel有关的参数:源Sentinel的IP地址、端口号、运行ID和配置纪元<br>2.与主服务器有关的参数:源Sentinel正在监视的主服务器的名字、IP地址、端口号和配置纪元<br><br>根据信息中提取出主服务器参数,目标Sentinel会在自己的Sentinel状态的masters字典中查找相应的主服务器实例<br>结构,然后根据提取出的Sentinel参数,检查主服务器实例结构的sentinels字典中,源Sentinel的实例结构是否存在:<br>1.如果源Sentinel的实例结构已经存在,那么对源Sentinel的实例结构进行更新<br>2.如果源Sentinel的实例结构不存在,那么说明源Sentinel是刚刚开始监视主服务器的新Sentinel,目标Sentinel会为<br>源Sentinel创建一个新的实例结构,并将这个结构添加到sentinels字典里面
举个例子。假设分别有127.0.0.1:26379、127.0.0.1:26380、1270.0.1:26381三个Sentinel正在<br>监视主服务器127.0.0.1:6379,那么当127.0.0.1:26379这个Sentinel接收到以下信息时:<br>```c<br>1."message"<br>2."_sentinel_:hello"<br>3."127.0.0.1,26379,a5bd47a1e569ed14567eca650de57f9d83301638,0,mymaster,127.0.0.1,6379,0"<br><br>1."message"<br>2."_sentinel_:hello"<br>3."127.0.0.1,26381,a5bd47a1e569ed14567eca650de57f9d83301637,0,mymaster,127.0.0.1,6379,0"<br><br>1."message"<br>2."_sentinel_:hello"<br>3."127.0.0.1,26380,a5bd47a1e569ed14567eca650de57f9d83301636,0,mymaster,127.0.0.1,6379,0"<br>```<br>Sentinel将执行以下动作:<br>1.第一条信息的发送者127.0.0.1:26379自己,这条信息会被忽略。<br>2.第二条信息的发送者为127.0.0.1:26381,Sentinel会根据这条信息中提取出的内容,对sentinels字典中的<br>127.0.0.1:26381对应的实例进行更新<br>3.第三条信息的发送者为127.0.0.1:26380,Sentinel会根据这条信息提取出的内容,对sentinels字典中的<br>127.0.0.1:26380所对应的实例结构进行更新<br>图中展示了Sentinel127.0.0.1:26379为主服务器127.0.0.1:6379创建的实例结构,以及结构中的sentinels字典<br>和127.0.0.1:26379一样,其他两个Sentinel也会创建类似于图中所示的sentinels字典,区别在于字典中保存的<br>Sentinel信息不同:<br>1.127.0.0.1:26380创建的sentinels字典会保存127.0.0.1:26379和127.0.0.1:26381两个Sentinel的信息<br>2.而127.0.0.1:23681创建的sentinels字典则会保存127.0.0.1:26379和127.0.0.1:26380两个Sentinel的信息<br><br>因为一个Sentinel可以通过分析接收到的频道信息来获知其他Sentinel的存在,并通过发送频道信息来让其他<br>Sentinel知道自己的存在,所以用户在使用Sentinel的时候并不需要提供各个Sentinel的地址信息,监视同一个<br>主服务器的多个Sentinel可以自动发现对方
创建连向其他的Sentinel的命令连接。<br>当Sentinel通过频道信息发现一个新的Sentinel时,它不仅会为新Sentinel在sentinels字典中创建相应的实例结构,<br>还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一<br>主服务器将形成相互连接的网络:SentinelA有连向SentinelB的命令连接,而SentinelB也有连向SentinelA的命令连接。<br>如图所示,三个监视同一主服务器的Sentinel之间是如何互相连接的。<br>使用命令连接的各个Sentinel可以通过向其他Sentinel发送命令请求来进行信息交换
Sentinel之间不会创建订阅连接。<br>Sentinel在连接主服务器或者从服务器时,会同时创建命令连接和订阅连接,但是在连接其他Sentinel时,<br>却只会创建命令连接,而不创建订阅连接。这是因为Sentinel需要通过接收主服务器或者从服务器发来的<br>频道信息来发现未知的新Sentinel,所以才需要建立订阅连接,而相互已知的Sentinel只要使用命令连接<br>来进行通信就足够了
检测主观下线状态。<br>在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)<br>发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。如图所示,带箭头的连线显示了Sentinel1和Sentinel2<br>是如何向实例发送PING命令的:<br>1.Sentinel1将向Sentinel2、主服务器master、从服务器slave1和slave2发送PING命令<br>2.Sentinel2将向Sentinel1、主服务器master、从服务器slave1和slave2发送PING命令<br><br>实例对PING命令的回复可以分为以下两种情况:<br>1.有效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复的其中一种<br>2.无效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复之外的其他回复,或者在指定时间内没有返回任何回复<br><br>Sentinel配置文件中的down-after-milliseconds选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在<br>down-after-milliseconds毫秒内,连续向Sentinel返回无效回复,那么Sentinel会修改这个实例所对应的实例结构,在结构的<br>flags属性中打开SRI_S_DOWN标识,以此来表示这个实例已经进入主观下线状态。以上图展示的情况为例,如果配置文件指定<br>Sentinel1的down-after-milliseconds选项的值为50000毫秒,那么当主服务器master连续50000毫秒都向Sentinel1返回无效<br>回复时,Sentinel1就会将master标记为主观下线,并在master所对应的实例结构的flags属性中打开SRI_S_DOWN标识,<br>如图所示
主观下线时长选项的作用范围。<br>用户设置的down-after-milliseconds选项的值,不仅会被Sentinel用来判断主服务器的<br>主观下线状态,还会被用于判断主服务器属下的所有从服务器,以及所有同样监视这个<br>主服务器的其他Sentinel的主观下线状态。举个例子,如果用户向Sentinel设置了以下配置:<br>```c<br>sentinel monitor master 127.0.0.1 6379 2<br>sentinel down-after-milliseconds master 50000<br>```<br>那么50000毫秒不仅会成为Sentinel判断master进入主观下线的标准,还会成为Sentinel判断<br>master属下所有从服务器,以及所有同样监视master的其他Sentinel进入主观下线的标准。
多个Sentinel设置的主观下线时长可能不同。<br>down-after-milliseconds选项的另一个需要注意的地方是,对于监视同一个主服务器的多个<br>Sentinel来说,这些Sentinel所设置的down-after-milliseconds选项的值也可能不同,因此,<br>当一个Sentinel将主服务器判断为主观下线时,其他Sentinel可能仍然会认为主服务器处于在线<br>状态。举个例子,如果Sentinel1载入了以下配置<br>```c<br>sentinel monitor master 127.0.0.1 6379 2<br>sentinel down-after-milliseconds master 50000<br>```<br>而Sentinel2则载入了以下配置:<br>```c<br>sentinel monitor master 127.0.0.1 6379 2<br>sentinel down-after-milliseconds master 10000<br>```<br>那么当master的断线时长超过10000毫秒之后,Sentinel2会将master判断为主观下线,而Sentinel1<br>却认为master仍然在线。只有当master的断线时长超过50000毫秒之后,Sentinel1和Sentinel2才会<br>都认为master进入了主观下线状态
检查客观下线状态。<br>当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他<br>Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel<br>那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移
发送SENTINEL is-master-down-by-addr命令。<br>Sentinel使用:<br>```c<br>SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid><br>```<br>命令询问其他Sentinel是否同意主服务器已下线,命令中的各个参数的意义如表所示<br>
举个例子,如果被Sentinel判断为主观下线的主服务器的IP为127.0.0.1,端口号为6379,<br>并且Sentinel当前的配置纪元为0,那么Sentinel将向其他Sentinel发送以下命令:<br>```c<br>SENTINEL is-master-down-by-addr 127.0.0.1 6379 0 *<br>```
接收SENTINEL is-master-down-by-addr命令。<br>当一个Sentinel(目标Sentinel)接收到另一个Sentinel(源Sentinel)发来的SENTINEL<br>is-master-down-by-addr 命令时,目标Sentinel会分析并取出命令请求中包含的<br>各个参数,并根据其中的主服务器IP和端口号,检查主服务器是否已经下线,然后<br>向源Sentinel返回一条包含三个参数的Multi Bulk回复作为SENTINEL is-master-down-by<br>命令的回复:<br>```c<br>1.<down_state><br>2.<leader_runid><br>3.<leader_epoch><br>```<br>这三个参数表示的含义,如表所示
举个例子。如果一个Sentinel返回以下回复作为SENTINEL is-master-down-by-addr命令的回复:<br>```c<br>1.1<br>2.*<br>3.0<br>```<br>那么说明Sentinel也同意主服务器已下线
接收SENTINEL is-master-down-by-addr命令的回复。<br>根据其他Sentinel发回的SENTIENL is-master-down-by-addr命令回复,Sentinel将统计<br>其他Sentinel同意主服务器已下线的数量,当这一数量到达到配置指定的判断客观下线所需<br>的数量时,Sentinel会将主服务器实例结构flags属性的SRI_O_DOWN标识打开,标识主服务器<br>已经进入客观下线状态,如图所示
客观下线状态的判断。<br>当认为主服务器已经进入下线状态的Sentinel的数量,超过Sentinel配置中设置的quorum参数的值,<br>那么该Sentinel就会认为主服务器已经进入客观下线的状态。比如说,<br>```c<br>sentinel monitor master 127.0.0.1 6379 2<br>```<br>那么包括当前Sentinel在内,只要总共有两个Sentinel认为主服务器已经进入下线状态,那么当前Sentinel<br>就将主服务器判断为客观下线,又比如说,如果Sentinel在启动时载入了以下配置:<br>```c<br>sentinel monitor master 127.0.0.1 6379 5<br>```<br>那么包括当前Sentinel在内,总共要有五个Sentinel都认为主服务器已经下线,当前Sentinel才会将主服务器<br>判断为客观下线
不同Sentinel判断客观下线的条件可能不同。<br>对于监视同一个主服务器的多个Sentinel来说,它们将主服务器判断为客观下线的条件可能也不同:<br>当一个Sentinel将主服务器判断为客观下线时,其他Sentinel可能并不是那么认为的。比如说,对于<br>监视同一个主服务器的五个Sentinel来说,如果Sentinel1在启动时载入了以下配置:<br>```c<br>sentinel monitor master 127.0.0.1 6379 2<br>```<br>那么当五个Sentinel中有两个Sentinel认为u主服务器已经下线时,Sentinel就会将主服务器判断为客观<br>下线。而对于载入了以下配置的Sentinel2来说<br>```c<br>sentinel monitor master 127.0.0.1 6379 5<br>```<br>仅有两个Sentinel1认为主服务器已下线,并不会令Sentinel2将主服务器判断为客观下线。
选举领头Sentinel.<br>当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头<br>Sentinel对下线主服务器执行故障转移操作。以下是Redis选举领头Sentinel的规则和方法:<br>1.所有在线的Sentinel都由被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都<br>有可能成为领头Sentinel。<br>2.每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元(configuration epoch)的值都会自增一次。<br>配置纪元实际上就是一个计数器,并没有什么特别的。<br>3.在一个配置纪元里面,所有的Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,在<br>这个配置纪元里面就不能再更改。<br>4.每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel<br>5.当一个Sentinel(源Sentinel)向另一个Sentinel(目标Sentinel)发送SENTINEL is-master-down-by-addr命令,并且命令中的<br>runid不是*符号而是源Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel<br>6.Sentinel设置局部林构图Sentinel的规则是先到先得:最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部<br>领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝<br>7.目标Sentinel在接收到SENTINEL is-master-down-by-addr命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid<br>参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元<br>8.源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中的leader_epoch参数的值和自己的配置纪元是否相同,如果<br>相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID一直,那么表示<br>目标Sentinel将源Sentinel设置成了局部领头Sentinel<br>9.如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。举个例子,在一个由<br>10个Sentinel组成的Sentinel系统里面,只要有大于等于10/2+16个Sentinel将某个Sentinel设置为局部领头Sentinel,那么被设置<br>的那个Sentinel就会成为领头Sentinel<br>10.因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部领头Sentinel,<br>所以在一个配置纪元里面,只会出现一个领头Sentinel<br>11.如果在给定时限内,没有一个Sentinel被选举出领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头<br>Sentinel为止<br>
举个例子。<br>假设现在有三个Sentinel正在监视同一个主服务器,并且这三个Sentinel之前已经通过<br>SENTINEL is-master-down-by-addr命令确认主服务器进入了客观下线状态,如图所示,<br>那么为了选出领头Sentinel,三个Sentinel将再次向其他Sentinel 发送SENTINEL is-master-down-by-addr<br>命令。如图所示。<br>和检测客观下线状态时发送的SENTINEL is-master-down-by-addr命令不同,Sentinel这次发送的命令会<br>带有Sentinel自己的运行ID,例如<br>```c<br>SENTINEL is-master-down-by-addr 127.0.0.1 6379 0 a5bd47a1e569ed14567eca650de57f9d83301638<br>```<br>如果接收到这个命令的Sentinel还没有设置局部领头Sentinel的话,他就会将运行ID为a5bd47a1e569ed14567eca650de57f9d83301638<br>的sentinel设置为自己的局部领头Sentinel,并返回类似以下的命令回复<br>```c<br>1.1<br>2.a5bd47a1e569ed14567eca650de57f9d83301638<br>3.0<br>```<br>然后接收到命令回复的Sentinel就可以根据这一回复,统计出有多少个Sentinel将自己设置成了局部领头Sentinel.<br>根据命令请求发送的先后顺序不同,可能会有某个Sentinel的SENTINEL is-master-down-by-addr命令比起其他Sentinel发送的<br>相同命令都更快到达,并最终胜出领头Sentinel的选举,然后这个领头Sentinel就可以开始对主服务器执行故障转移操作了
故障转移。<br>在选举产生出领头Sentinel之后,领头Sentinel将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:<br>1.在已下线的主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器<br>2.让已下线主服务器属下的所有从服务器改为复制新的主服务器<br>3.将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的<br>从服务器
选出新的主服务器。<br>故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个<br>状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将<br>这个从服务器转换为主服务器。<br>
新的主服务器是怎样挑选出来的。<br>领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:<br>1.删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线<br>2.删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器<br>3.删除所有与已下线主服务器连接断开超过down-after-milliseconds * 10 毫秒的从服务器:down-after-milliseconds选项<br>指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds * 10 毫秒的从服务器,则可以保证列表<br>中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。<br>之后,领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。如果<br>有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器<br>进行排序,并选出其他偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。最后,如果有多个<br>优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的服务器
举个例子。如图展示了在一次故障转移操作中,领头Sentinel向选中的从服务器server2发送SLAVEOF no one命令的情形。<br>在发送SLAVEOF no one命令之后,领头Sentinel会以每秒一次的频率(平时是每十秒一次),向被升级的从服务器发送INFO<br>命令,并观察命令回复中的角色(role)信息,当被升级服务器的role从原来的slave变为master时,领头Sentinel就知道被选中<br>的从服务器已经顺利升级为主服务器了
如果在上图的例子中,领头Sentinel会一直向server2发送INFO命令,当server2返回的命令回复从:<br>```c<br># Replcation<br>role:slave<br>...<br># Other sections<br>...<br>```<br>变为:<br>```c<br># Replication<br>role:master<br>...<br># Other sections<br>...<br>```<br>的时候,领头Sentinel就知道server2已经成功升级为了主服务器了。<br>当server2升级成功之后,各个服务器和领头Sentinel如图所示
修改从服务器的复制目标。<br>当新的主服务器出现之后,领头Sentinel下一步要做的就是,让已下线主服务器属下的<br>所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。<br>图中展示了在故障转移过程中,领头Sentinel向已下线主服务器server1的两个从服务器server3<br>和server4发送SLAVEOF命令,让它们复制新的主服务器server2的例子.<br><br>当server3和server4成为server2的从服务器之后,各个服务器以及领头Sentinel的样子如图所示
server3和server4成为server2的从服务器
将旧的主服务器变为从服务器。<br>故障转移操作最后要做的是,将已下线的主服务器设置为新的主服务器的从服务器,比如说,图中<br>就展示了被领头Sentinel设置为从服务器之后,服务器server1的样子,当server1重新上线之后,<br>就成为了server2的从服务器,如图所示
集群
概述。<br>Redis集群是Redis提供的<b><font color="#ec7270">分布式数据库</font></b>方案,集群通过分片(sharding)来进行数据共享,并提供复制<br>和故障转移功能
节点。<br>一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于<br>一个只包含自己的集群当中,要组建一个真正可工作的集群,则必须将各个独立的节点连接起来,构成<br>一个包含多个节点的集群。连接各个节点的工作可以使用CLUSTER MEET命令来完成,该命令的格式如下:<br>```c<br>CLUSTER MEET <ip> <port><br>```<br>向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手(handshake),<br>当握手成功时,node节点就会将ip和port所指定的节点添加到node节点当前所在的集群中。
举个例子。假设现在有三个独立的节点127.0.0.1:7000、127.0.0.1:7001、127.0.0.1:7002,<br>首先使用客户端连接上节点7000,通过发送CLUSTER NODES命令可以看到,集群目前只包含<br>7000自己一个节点<br>```c<br>E:\redis>redis-cli -c -p 7000<br>127.0.0.1:7000> CLUSTER NODES<br>851197a1532c90ca60d91be5a6b95d3efc5a966f :7000 myself,master - 0 0 0 connected 246 5305 7629 11537 12291<br>```<br>通过向节点7000发送以下命令,我们可以将节点7001添加到节点7000所在的集群里面:<br>```c<br>a29240fa8cb8b2ba154e9cb81faf5a3070c71966 127.0.0.1:7001 slave 352302bf0156372441c069247f984427cff506ff 0 1712586652533 1 connected<br>352302bf0156372441c069247f984427cff506ff 127.0.0.1:7000 myself,master - 0 0 1 connected 246 5305 7629 11537 12291<br>```<br>继续向节点7000发送以下命令,我们可以将节点7002也添加到节点7000和节点7001所在的集群里面:<br>```c<br>127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7002<br>OK<br>127.0.0.1:7000> CLUSTER NODES<br>ceb9e23e93e0aae13e5333f50d39336a97e5cba3 127.0.0.1:7002 slave 352302bf0156372441c069247f984427cff506ff 0 1712586757161 1 connected<br>a29240fa8cb8b2ba154e9cb81faf5a3070c71966 127.0.0.1:7001 slave 352302bf0156372441c069247f984427cff506ff 0 1712586756148 1 connected<br>352302bf0156372441c069247f984427cff506ff 127.0.0.1:7000 myself,master - 0 0 1 connected 246 5305 7629 11537 12291<br>```<br>现在,这个集群里面包含了7000、7001和7002三个节点,握手流程如图。
1.三个独立的节点
2.节点7000和7001进行握手
3.握手成功的7000与7001处于同一个集群
4.节点7000与节点7002进行握手
5.握手成功的三个节点处于同一个集群<br>
启动节点。<br>一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enable配置选项是否<br>为yes来决定是否开启服务器的集群模式,如图所示。<br>节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件,比如说:<br>1.节点会继续使用文件事件处理器来处理命令请求和返回命令回复<br>2.节点会继续使用时间事件处理器来执行serverCron函数。而serverCron函数又会调用集群模式特有的clusterCron<br>函数。clusterCron函数负责执行在集群模式下需要执行的常规操作,例如向集群中的其他节点发送Gossip消息,<br>检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。<br>3.节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象<br>4.节点会继续使用RDB持久化模块和AOF持久化模块来执行持久化工作<br>5.节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令<br>6.节点会继续使用复制模块来进行节点的复制工作<br>7.节点会继续使用Lua脚本来执行客户端输入的Lua脚本。<br>除此之外,节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态,至于<br>那些只有在集群模式下才会用到的数据,节点将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,<br>以及cluster.h/clusterState结构里面
集群数据结构。<br>cclusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址<br>和端口号等等。每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)<br>都创建一个相应的clusterNode结构,以此来记录其他节点的状态:结构如子主题<br><br>clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区<br>和输出缓冲区:结构如子主题<br>最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线<br>还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:结构如子主题<br>
```c<br>struct clusterNode {<br>// 创建节点的时间<br>mstime_t ctime;<br><br>// 节点的名字,由40个十六进制字符组成<br>// 例如ceb9e23e93e0aae13e5333f50d39336a97e5cba3<br>char name[REDIS_CLUSTER_NAMELEN];<br><br>// 节点标识<br>// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点)<br>// 以及节点目前所处的状态(比如在线或者下线)<br>int flags;<br><br>// 节点当前的配置纪元,用于实现故障转移<br>uint64_t configEpoch;<br><br>// 节点的IP地址<br>char ip[REDIS_IP_STR_LEN];<br><br>// 节点的端口号<br>int port;<br><br>// 保存连接节点所需的有关信息<br>clusterLink *link;<br>// ...<br>};<br>```<br>clusterNode结构
```c<br>typedef struct clusterLink {<br>// 连接的创建时间<br>mstime_t ctime;<br>// TCP 套接字描述符<br>int fd;<br>// 输出缓冲区,保存着等待发送给其他节点的消息(message)<br>sds sndbuf;<br>// 输入缓冲区,保存着从其他节点接收到的消息<br>sds rcvbuf;<br>// 与这个连接相关联的节点,如果没有的话就为NULL<br>struct clusterNode *node;<br>}clusterLink;<br>```<br>clusterLink结构
```c<br>typedef struct clusterState {<br>// 指向当前节点的指针<br>clusterNode *myself;<br><br>// 集群当前的配置纪元,用于实现故障转移<br>uint64_t currentEpoch;<br><br>// 集群当前的状态:是在线还是下线<br>int state;<br><br>// 集群中至少处理着一个槽的节点的数量<br>int size;<br><br>// 集群节点名单(包括myself节点)<br>// 字典的键为节点的名字,字典的值为节点对应的clusterNode结构<br>dict *nodes;<br><br>// ...<br>}clusterState;<br>```<br>clusterState结构
redisClient结构和clusterLink结构的相同和不同之处。<br>redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区,这两个结构的区别在于,<br>redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区是用于<br>连接节点的。
举个例子。以前面介绍的7000、7001、7002三个节点为例,如图展示了节点7000创建的clusterState结构,<br>这个结构从节点7000的角度记录了集群以及集群包含的三个节点的当前状态:<br>1.结构的currentEpoch属性的值为0,表示集群当前的配置纪元为0<br>2.结构的size属性的值为0,表示集群目前没有任何节点在处理槽,因此结构的state属性的值为REDIS_CLUSTER_FAIL,<br>这表示集群目前处于下线状态<br>3.结构的nodes字典记录了集群目前包含的三个节点,这三个节点分别由clusterNode结构表示,其中myself指针指向<br>代表节点7000的clusterNode结构,而字典中的另外两个指针则分别指向节点7001和代表节点7002的clusterNode<br>结构,这两个节点是节点7000已知的在集群中的其他节点<br>4.三个节点的clusterNode结构的flags属性都是REDIS_NODE_MASTER,说明三个节点都是主节点。<br>节点7001和节点7002也会创建类似的clusterState结构:<br>1.不过在节点7001创建的clusterState结构中,myself指针将指向代表节点7001的clusterNode结构,而节点7000和<br>节点7002则是集群中的其他节点.<br>2.而在节点7002创建的clusterState结构中,myself指针将指向代表节点7002的clusterNode结构,而节点7000和节点<br>7001则是集群中的其他节点。
CLUSTER MEET命令的实现。<br>通过向节点A发送CLUSTER MEET命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群里面:<br>```c<br>CLUSTER MEET <ip> <port><br>```<br>收到命令的节点A将与节点B进行握手(handshake),以此来确认彼此的存在,并未将来的进一步通信打好基础:<br>1.节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面<br>2.之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message)<br>3.如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到<br>自己的clusterState.nodes字典里面<br>4.之后,节点B将向节点A返回一条PONG消息<br>5.如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了<br>自己发送地MEET消息<br>6.之后,节点A将向节点B返回一条PING消息<br>7.如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己<br>返回的PONG消息,握手完成<br><br>如图展示了握手过程。之后,节点A会将节点B的信息通过Goossip协议传播给集群中的其他节点,让其他节点也与节点B进行<br>握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识
槽指派。<br>Redis集群通过分片的方式来保存数据库的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的<br>每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。当数据库中的<br>16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,<br>那么集群处于下线状态(fail),在前面,我们使用CLUSTER MEET命令将7000、7001、7002三个节点连接到<br>了同一个集群里面,不过这个集群目前仍然处于下线状态,因为集群中的三个节点都没有在处理任何槽:<br>```c<br>127.0.0.1:7000> cluster info<br>cluster_state:fail<br>cluster_slots_assigned:9<br>cluster_slots_ok:9<br>cluster_slots_pfail:0<br>cluster_slots_fail:0<br>cluster_known_nodes:3<br>cluster_size:1<br>cluster_current_epoch:1<br>cluster_my_epoch:1<br>cluster_stats_messages_sent:2481<br>cluster_stats_messages_received:2478<br>```<br>通过向节点发送CLUSTER ADDSLOTS命令,可以将一个或者多个槽指派给节点负责<br>```c<br>CLUSTER ADDSLOTS <slot> [slot ...]<br>```
记录节点的槽指派信息。<br>clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽:<br>```c<br>struct clusterNode {<br>// ...<br>unsigned char slots[16384/8];<br><br>int numslots;<br>// ...<br>}<br>```<br>slots属性是一个二进制位数组(bit array),这个数组的长度位16384/8=2048个字节,共包含16384个二进制位。<br>Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位<br>的值来判断节点是否负责处理槽i:<br>1.如果slots数组在索引i上的二进制位的值位1,那么表示节点负责处理槽i<br>2.如果slots数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i<br><br>因为取出和设置slots数组中的任意一个二进制位的值的复杂度仅为O(1),所以对于一个给定节点的slots数组来说,<br>程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是O(1).<br>至于numslots属性则记录节点负责处理的槽的数量,也即是slots数组中值位1的二进制位的数量。比如说,例子中的节点<br>处理的槽数量分别为8、6
举个例子。如图展示了一个slots数组的示例:这个数组索引0至索引7上的二进制位的值都为1,<br>其余所有二进制位的值都为0,这表示节点负责处理槽0至槽7
如图展示了另一个slots数组示例:这个数组索引1、3、5、8、9、10上的二进制位的值都为1,<br>而其余所有二进制位的值都为0,这表示节点负责处理1、3、5、8、9、10
传播节点的槽指派信息。<br>一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,他还会将自己的slots数组<br>通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽.
举个例子,对于前面展示的7000、7001、7002三个节点的集群来说:<br>1.节点7000会通过消息向节点7001和节点7002发送自己的slots数组,以此来告知两个节点,自己负责<br>处理槽0至槽5000,如图所示<br>2.节点7001会通过消息向节点7000和节点7002发送自己的slots数组,以此来告知两个节点,自己负责<br>处理槽5001至槽10000,如图所示<br>3.节点7002会通过消息向节点7000和节点7001发送自己的slots数组,以此来告知两个节点,自己负责<br>处理槽10001至槽16383,如图所示
记录集群所有槽的指派消息。<br>clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息:<br>```c<br>typedef struct clusterState{<br>// ...<br>clusterNode *slots[16384];<br>// ...<br>} clusterState;<br>```<br>slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:<br>1.如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点<br>2.如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点<br><br>如果只将指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效地解决的问题,而clusterState.slots<br>数组的存在解决了这些问题:<br>1.如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了<br>哪个节点,程序需要遍历clusterState.nodes字典中所有的clusterNode结构,检查这些结构的slots数组,直到找到<br>负责处理槽i的节点为止,这个过程的复杂度为O(N),其中N为clusterState.nodes字典保存的clusterNode结构的数量<br>2.而通过将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理<br>槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度仅为O(1)<br>
举个例子,对于7000、7001、7002三个节点来说,它们的clusterState结构的slots数组将会是如图所示的样子:<br>1.数组项slots[0]至slots[5000]的指针都指向7000的clusterNode结构,表示槽0至5000都指派给了节点7000<br>2.数组项slots[5001]至slots[10000]的指针都指向代表节点7001的clusterNode结构,表示槽5001至槽10000都<br>指派给了节点7001<br>3.数组项slots[10001]至slots[16383]的指针都指向代表节点7002的clusterNode结构,表示槽10001至槽16383<br>都指派给了节点7002<br>
举个例子,对于上图中的slots数组来说,如果程序需要直到槽10002被指派给了哪个节点,那么只需要访问<br>数组项slots[10002],就可以马上直到槽10002被指派给了节点7002,如图所示
要说明的一点是,虽然slutserState.slots数组记录了集群中素有槽的指派信息,但使用clusterNode结构的slots<br>数组来记录单个节点的槽指派信息仍然是有必要的:<br>1.因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的clusterNode.slots<br>数组整个发送出去就可以了<br>2.另一方面,如果Redis不使用clusterNode.slots数组,而单独使用clusterNode.slots数组的话,那么每次要将节点A<br>的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后才能<br>发送节点A的槽指派信息,这比直接发哦是那个clusterNode.slots数组要麻烦和低效得多。<br>clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的<br>节点的槽指派信息,这时两个slots数组的关键区别所在
CLUSTER ADDSLOTS命令的实现。<br>CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:<br>```c<br>CLUSTER ADDSLOTS <slot> [slot ...]<br>```<br>CLUSTER ADDSLOTS命令的实现可以用以下伪代码来表示:<br>```c<br>def CLUSTER_ADDSLOTS(*all_input_slots):<br># 遍历所有输入槽,检查它们是否都是未指派槽<br>for i in all_input_slots:<br># 如果有哪怕一个槽已经被指派给了某个节点<br># 那么向客户端返回错误,并终止命令执行<br>if clusterState.slots[i] != null:<br>reply_error()<br>return<br><br># 如果所有输入槽都是未指派槽<br># 那么再次遍历所有输入槽,将这些槽指派给当前节点<br>for i in all_input_slots:<br># 设置clusterState结构的slots数组<br># 将slots[i]的指针指向代表当前节点的clusterNode结构<br>clusterState.slots[i] = clusterState.myself<br><br># 访问代表当前节点的clusterNode结构的slots数组<br># 将数组在索引i上的二进制位设置为1<br>setSlotBit(clusterState.myself.slots, i);<br>```
举个例子。如图展示了一个节点的clusterState结构,clusterState.slots数组中的所有指针都指向NULL,<br>并且clusterNode.slots数组中的所有二进制位的值都是0,这说明当前节点没有被指派任何槽,并且集群<br>中的所有槽都是未指派的。当客户端对如图所示的节点执行命令:<br>```c<br>CLUSTER ADDSLOTS 1 2<br>```<br>将槽1和槽2指派给节点之后,节点的clusterState结构将被更新为如图所示的样子:<br>1.clusterState.slots数组在索引1和索引2上的指针指向了代表当前节点的clusterNode<br>2.并且clusterNode.slots数组在索引1和索引2上的为止被设置成了1<br><br>最后,在CLUSTER ADDSLOTS命令执行完毕之后,节点会通过发送消息告知集群中的其他节点,自己目前<br>正在处理哪些槽
在集群中执行命令。<br>在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的<br>节点发送数据命令了,当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要<br>处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:<br>1.如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令<br>2.如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向<br>(redirect)至正确的节点,并再次发送之前想要执行的命令,如图所示
举个例子。如果在之前提到的由7000、7001、7002三个节点组成的集群中,用客户端连上节点7000,<br>并发送以下命令,那么命令会直接被节点7000执行:<br>```c<br>127.0.0.1:7000> SET date "2024-04-10"<br>OK<br>```<br>因为键date所在的槽2022正式由节点7000负责处理的。但是,如果执行以下命令,那么客户端会先被转向<br>至节点7001,然后再执行命令<br>```c<br>127.0.0.1:7000> SET msg "happy new year!"<br>-> Redirected to slot[6257] located at 127.0.0.1:7001<br>OK<br><br>127.0.0.1:7001> GET msg<br>"happy new year!"<br>```<br>这是因为msg所在的槽6257是由节点7001负责处理的,而不是由最初接收命令的节点7000负责处理:<br>1.当客户端第一次向节点7000发送SET命令的时候,节点7000会向客户端返回MOVED错误,指引客户端<br>转向至节点7001<br>2.当客户端转向到节点7001之后,客户端重新向节点7001发送SET命令,这个命令会被节点7001成功执行
计算键属于哪个键。<br>节点使用以下算法来计算给定键key属于哪个槽:<br>```c<br>def slot_number(key):<br>return CRC16(key) & 16383<br>```<br>其中CRC16(key)语句用于计算键key的CRC-16校验和,而& 16383语句则用于计算出一个介于0~16383之间的整数<br>作为键key的槽号。可以使用CLUSTER KEYSLOT <key>命令可以查看一个给定键属于哪个槽:<br>```c<br>127.0.0.1:7000> CLUSTER KEYSLOT "date"<br>(integer) 2022<br>127.0.0.1:7000> CLUSTER KEYSLOT "msg"<br>(integer) 6257<br>127.0.0.1:7000> CLUSTER KEYSLOT "name"<br>(integer) 5798<br>127.0.0.1:7000> CLUSTER KEYSLOT "fruits"<br>(integer) 14943<br>```<br>CLUSTER KEYSLOT命令就是通过调用上面给出的槽分配算法来实现的,以下是该命令的伪代码实现:<br>```c<br>def CLUSTER_KEYSLOT(key):<br># 计算槽号<br>slot = slot_number(key)<br><br># 将槽号返回给客户端<br>reply_client(slot)<br>```
判断槽是否由当前节点负责处理。<br>当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责:<br>1.如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令<br>2.如果clusterState.slots[i]不等于clusterState.myself,那么说明槽i并非由当前节点负责,节点会根据clusterState.slots[i]<br>指向的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点
举个例子。假设如图所示为节点7000的clusterState结构:<br>1.当客户端向节点7000发送命令SET date "2024-04-10"的时候,节点首先计算出键date属于槽2022,<br>然后检查得出clusterState.slots[2022]等于clusterState.myself,这说明槽2022正是由节点7000负责,<br>于是节点7000直接执行这个SET命令,并将结果返回发送命令的客户端<br>2.当客户端向节点7000发送命令SET msg "happy new year!"的时候,节点首先计算出键msg属于槽6257,<br>然后检查clusterState.slots[6257]是否等于clusterState.myself,结果发现两者并不相等:这说明槽6257并非<br>由节点7000负责处理,于是节点7000访问clusterState.slots[6257]所指向的clusterNode结构,并根据<br>结构中记录的IP地址127.0.0.1和端口号7001,向客户端返回错误MOVED 6257 127.0.0.1:7001,指引节点<br>转向至正在负责处理槽6257的节点7001
MOVED错误。<br>当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个MOVED错误,指引客户端转向至正在<br>负责槽的节点,MOVED错误的格式为:<br>```c<br>MOVED <slot> <ip>:<port><br>```<br>其中slot为键所在的槽,而ip和port则是负责处理槽slot的节点的IP地址和端口号。例如错误:<br>```c<br>MOVED 10086 127.0.0.1:7002<br>```<br>表示槽10086正由IP地址为127.0.0.1,端口号为7002的节点负责,又例如错误:<br>```c<br>MOVED 789 127.0.0.1:7000<br>```<br>表示槽789正由IP地址为127.0.0.1,端口号为7000的节点负责。<br>当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地址和端口号,转向至负责处理槽slot<br>的节点,并向该节点重新发送之前想要执行的命令。以前面的客户端从节点7000转向至7001的情况作为例子:<br>```c<br>127.0.0.1:7000> SET msg "happy new year!"<br>-> Redirected to slot[6257] located at 127.0.0.1:7001<br>OK<br>```<br>
如图所示展示了客户端向节点7000发送SET命令,并获得MOVED错误的过程
客户端根据MOVED错误,转向至节点7001,并重新发送SET命令的过程
一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向<br>实际上就是换一个套接字来发送命令。如果客户端尚未与想要转向的节点创建套接字连接,<br>那么客户端会先根据MOVED错误提供的IP地址和端口号来连接节点,然后再进行转向
被隐藏的MVOED错误。<br>集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错误,而是根据<br>MOVED错误自动进行节点转向,并打印出转向信息,所以是看不见节点返回的MOVED错误的<br>```c<br>redis-cli -c -p 7000 #集群模式<br><br>127.0.0.1:7000> SET msg "happy new year!"<br>-> Redirected to slot[6257] located at 127.0.0.1:7001<br>OK<br>```<br>但是如果,使用单机(stand alone)模式的redis-cli客户端,再次向节点7000发送相同的命令,那么<br>MOVED错误就会被打印出来:<br>```c<br>redis-cli -p 7000 # 单机模式<br>127.0.0.1:7000> SET msg "happy new year!"<br>(error) MOVED 6257 127.0.0.1:7001<br>```<br>这是因为单机模式的redis-cli客户端不清除MOVED错误的作用,所以它只会直接将MOVED错误直接<br>打印出来,而不会进行自动转向。
节点数据库的实现。<br>集群节点保存键值对以及键值对过期时间的方式,与单机模式下保存键值对以及对过期时间的方式完全相同。<br>节点和单机服务器在数据库方面的一个区别是,节点只能使用0号数据库,而单机Redis服务器则没有这一限制。<br><br>slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员(member)都是一个数据库键:<br>1.每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys跳跃表<br>2.当节点删除数据库中某个键值对时,节点就会在slots_to_keys跳跃表接触被删除键与槽号的关联<br>
举个例子。如图展示了节点7000的数据库状态,数据库中包含列表键"lst",哈希键"book",以及字符串键"date",<br>其中键"lst"和键"book"带有过期时间。另外除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的<br>slots_to_keys跳跃表来保存槽和键之间的关系:<br>```c<br>typedef struct clusterState {<br>//...<br>zskiplist *slot_to_keys;<br>// ...<br>}clusterState;<br>```<br>
举个例子。对于上图所示的数据库,节点7000将创建类似如图所示的slots_to_keys跳跃表:<br>1.键"book"所在跳跃表节点的分值为1337.0,这表示键"book"所在的槽位1337<br>2.键"date"所在跳跃表节点的分值位2022.0,这表示键"date"所在的槽为2022<br>3.键"lst"所在跳跃表节点的分值为3347.0,这表示键"lst"所在的槽为3347<br><br>通过在slots_to_keys跳跃表中记录各个数据库键所属的槽,节点可以很方便地对属于某个或<br>某些槽的所有数据库进行批量操作,例如命令CLUSTER GETKEYSINSLOT <slot><count>命令<br>可以返回最多count个属于槽slot的数据库键,而这个命令就是通过遍历slots_to_keys跳跃表来实现的
重新分片。<br>Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),<br>并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片操作可以在线(online)进行,在重新分片的<br>过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
举个例子。对于之前提到的7000、7001、7002三个节点的集群来说,我们可以向这个集群中<br>添加一个IP为127.0.0.1,端口号为7003的节点<br>```c<br>redis-cli -c -p 7000<br>127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7003<br>OK<br>```<br>可以用CLUSTER NODES进行查看各个节点之前所负责的槽的区间
重新分片的实现原理。<br>Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片<br>所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。<br>redis-trib对集群的单个槽slot进行重新分片的步骤如下:<br>1.redis-trib对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点<br>准备好从源节点导入(import)属于槽slot的键值对<br>2.redis-trib对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好<br>将属于槽slot的键值对迁移(migrate)至目标节点<br>3.redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令,获得最多count个属于槽<br>slot的键值对的键名(key name)<br>4.对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> - <timeout><br>命令,将被选中的键原子地从源节点迁移至目标节点<br>5.重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。每次迁移的过程如图所示<br>6.redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽slot指派给目标节点,<br>这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会直到槽slot已经指派给了目标节点<br><br>如果重新分片涉及多个槽,那么redis-trib将对每个给定的槽分别执行上面给出的步骤,流程图如图所示
ASK错误。<br>在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分<br>键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。当客户端向源节点发送一个与数据库有关的<br>命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:<br>1.源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令<br>2.相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点<br>没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK<br>错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令
举个例子。如图展示了源节点判断是否需要向客户端发送ASK错误的整个过程,<br>假设节点7002正在向节点7003迁移槽16198,这个槽包含"is"和"love"两个键,<br>其中键"is"还留在节点7002,而键"love"已经被迁移到了节点7003,如果我们<br>向节点7002发送关于键"is"的命令,那么这个命令会直接被节点7002执行:<br>```c<br>127.0.0.1:7002>GET "is"<br>"you get the key 'is'"<br>```<br>而如果我们向节点7002发送关于键"love"的命令,那么客户端会先被转向至节点7003,<br>然后再次执行命令:<br>```c<br>127.0.0.1:7002> GET "love"<br>-> Redirected to slot [16198] located at 127.0.0.1:7003<br>"you get the key 'love'"<br>127.0.0.1:7003><br>```
被隐藏的ASK错误。<br>和接到MOVED错误时的情况类似,集群模式的redis-cli在接到ASK错误时也不会打印错误,而是自动根据错误<br>提供的IP地址和端口进行转向动作。如果想看到节点发送的ASK错误的话,可以使用单机模式的redis-cli客户端<br>```c<br>redis-cli -p 7002<br>127.0.0.1:7002> GET "love"<br>(error) ASK 16198 127.0.0.1:7003<br>```
CLUSTER SETSLOT IMPORTING命令的实现。<br>clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽:<br>```c<br>typedef struct clusterState {<br>// ...<br>clusterNode *importing_slots_from[16384];<br>// ...<br>} clusterState;<br>```<br>如果importing_slots_from[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在从<br>clusterNode所代表的节点导入槽[i].在对集群进行重新分片的时候,向目标节点发送命令<br>```c<br>CLUSTER SETSLOT <i> IMPORTING <source_id><br>```<br>可以将目标节点clusterState.importing_slots_from[i]的值设置为source_id所代表节点的clusterNode结构。
举个例子。<br>如果客户端向节点7003发送以下命令:<br>```c<br># b4348045d1eadca5abd96b8d0d13b0309ed117e1 是节点7002的id<br>127.0.0.1:7003> CLUSTER SETSLOT 16198 IMPORTING b4348045d1eadca5abd96b8d0d13b0309ed117e1<br>OK<br>```<br>那么节点7003的clusterState.importing_slots_from数组将变成如图所示的样子。
CLUSTER SETSLOT MIGRATING命令的实现。<br>clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽:<br>```c<br>typedef struct clusterState {<br>// ...<br>clusterNode *migrating_slots_to[16384]<br>// ...<br>} clusterState;<br>```<br>如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点<br>正在将槽[i]迁移至clusterNode所代表的节点。<br>在对集群进行重新分片的时候,向源节点发送命令:<br>```c<br>CLUSTER SETSLOT <i> MIGRATING <target_id><br>```<br>可以将源节点clusterState.migrating_slots_to[i]的值设置为target_id所代表节点的clusterNode结构。
举个例子。如果客户端向节点7002发送以下命令:<br>```c<br># b4348045d1eadca5abd96b8d0d13b0309ed117e1 是节点7003的ID<br>127.0.0.1:7002>CLUSTER SETSLOT 16198 MIGRATING b4348045d1eadca5abd96b8d0d13b0309ed117e1<br>``` <br>那么节点7002的clusterstate.migrating_slots_to数组将变成如图所示的样子<br>
ASK错误。<br>如果节点收到一个关于键key的命令请求,并且键key所属的槽i正好就指派给了这个节点,那么节点会<br>尝试在自己的数据库里查找键key,如果找到了的话,节点就直接执行客户端发送的命令。与此相反,<br>如果节点没有在自己的数据库里找到键key,那么系欸但会检查自己的clusterState.migrating_slots_to[i],<br>看键key所属的槽i是否正在进行迁移,如果槽i的确在进行迁移的话,那么节点会向客户端发送一个ASK错误,<br>引导客户端正在导入槽i的节点去查找键key。
举个例子。假设在节点7002向节点7003迁移槽16198期间,有一个客户端向节点发送命令:<br>```c<br>GET "love"<br>```<br>因为键"love"正好属于槽16198,所以节点7002会首先在自己的数据库中查找键"love",但并没有<br>找到,通过检查自己的clusterState.migrating_slots_to[16198],节点7002发现自己正在将槽16198<br>迁移至节点7003,于是它向客户端返回错误:<br>```c<br>ASK 16198 127.0.0.1:7003<br>```<br>这个错误表示客户端可以尝试到IP为127.0.0.1,端口号为7003的节点去执行和槽16198有关的操作,<br>如图所示.接到ASK错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节点,<br>然后首先向目标节点发送一个ASKING命令,之后再重新发送原本想要执行的命令。<br>当客户端接收到节点7002返回的以下错误时:<br>```c<br>ASK 16198 127.0.0.1:7003<br>```<br>客户端会转向至节点7003,首先发送命令:<br>```c<br>ASKING<br>```<br>然后再次发送命令:<br>```c<br>GET "love"<br>```<br>并获得回复:<br>```c<br>"you get the key 'love'"<br>```
流程如图所示
ASKING命令。<br>ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识,以下是该命令的伪代码实现:<br>```c<br>def ASKING():<br># 打开标识<br>client.flags |= REDIS_ASKING<br><br># 向客户端返回OK回复<br>reply("OK")<br>```<br>在一般情况下,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将<br>向客户端返回一个MOVED错误;但是,如果节点的clusterState.importing_slots_from[i]显示节点正在导入<br>槽i,并且发送命令的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次,<br>过程如图所示。当客户端接收到ASK错误并转向至正在导入槽的节点时,客户端会先向节点发送一个ASKING命令,<br>然后才重新发送想要执行的命令,这是因为如果客户端不发送ASKING命令,而直接发送想要执行的命令的话,<br>那么客户端发送的命令将被节点拒绝执行,并返回MOVED错误。
举个例子,我们可以使用普通模式的redis-cli客户端,向正在导入槽16198的节点7003发送以下命令:<br>```c<br>./redis-cli -p 7003<br>127.0.0.11:7003> GET "love"<br>(error) MOVED 16198 127.0.0.1:7002<br>```<br>虽然节点7003正在导入槽16198,但槽16198目前仍然是指派给了节点7002,所以节点7003会向客户端<br>返回MOVED错误,指引客户端转向节点7002.但是,如果我们在发送GET命令之前,先向节点发送一个<br>ASKING命令,那么这个GET命令就会被节点7003执行:<br>```c<br>127.0.0.1:7003> ASKING<br>OK<br>127.0.0.1:7003> GET "love"<br>"you get the key 'love'"<br>```<br>另外要注意的是,客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING标识<br>的客户端发送的命令之后,客户端的REDIS_ASKING标识就会被移除。
举个例子。如果我们在成功执行GET命令之后,再次向节点7003发送GET命令,那么第二次发送的GET命令将执行<br>失败,因为这时客户端的REDIS_ASKING标识已经被移除:<br>```c<br>127.0.0.1:7003> ASKING # 打开REDIS_ASKING标识<br>OK<br>127.0.0.1:7003> GET "love" # 移除REDIS_ASKING标识<br>"you get the key 'love'"<br>127.0.0.1:7003> GET 'love' # REDIS_ASKING 标识未打开,执行失败<br>(error) MOVED 16198 127.0.0.1:7002<br>```
ASK错误和MOVED错误的区别。<br>ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:<br>1.MOVED错误代表槽的负责全已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,<br>客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点<br>就是目前负责槽i的节点<br>2.与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,<br>客户端只会在接下来的一次命令请求中将关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求<br>发送至目前负责处理槽i的节点,除非ASK错误再次出现
复制与故障转移。<br>Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个<br>主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求
举个例子。对于包含7000、7001、7002、7003四个主节点的集群来说,我们可以将7004、7005<br>两个节点添加到集群里面,并将这两个节点设定为节点7000的从节点,如图所示(双圆形代表主节点,<br>单圆形表示从节点),如表所示记录了集群各个节点的当前装填,以及它们正在做的工作。<br><br>如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点<br>——节点7004和节点7005中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责<br>处理的槽,并继续处理客户端发送的命令请求。<br><br>例如,如果节点7004被选中为新的主节点,那么节点7004将接管原来由节点7000负责处理的槽0至槽5000,<br>节点7005也会从原来的复制节点7000改为复制节点7004,如图所示<br><br>另一张表记录了在对节点7000进行故障转移之后,集群各个节点的当前状态,以及它们正在做的工作.<br>如果在故障转移完成之后,下线的节点7000重新上线,那么它将成为节点7004的从节点,如图所示
节点7004成为新的主节点
设置从节点。<br>向一个节点发送命令:<br>```c<br>CLUSTER REPLICATE <node_id><br>```<br>可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:<br>1.接收到该命令的节点会首先会在自己的clusterState.nodes字典中找到node_id所对应节点的clusterNode结构,<br>并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点:<br>```c<br>struct clusterNode {<br>// ...<br><br>// 如果这时一个从节点,那么指向主节点<br>struct clusterNode *slaveof;<br><br>// ...<br>}'<br>```<br>2.然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE<br>标识,标识这个节点已经由原来的主节点变成了从节点<br>3.最后节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构所保存的IP地址和端口号,对主节点进行复制。<br>因为节点的复制功能和单机Redis服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令<br>```<br>SLAVEOF <master_ip> <master_port><br>```<br>如图所示节点7004在复制节点7000时的clusterState结构:<br>1.clusterState.myself.flags属性的值为REDIS_NODE_SLAVE,表示节点7004是一个从节点。<br>2.clusterState.myself.slaveof指针指向代表节点7000的结构,表示节点7004正在复制的主节点为7000节点<br><br>一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点<br>正在复制某个主节点。集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点<br>的从节点名单:<br>```c<br>struct clusterNode {<br>// ...<br>// 正在复制这个主节点的从节点数量<br>int numslaves;<br><br>// 一个数组<br>// 每个数组项指向一个正在复制这个主节点的从节点的clusterNode结构<br>struct clusterNode **slaves;<br><br>// ...<br>}<br>```
举个例子。图中记录了节点7004和节点7005成为节点7000的从节点之后,集群中的各个节点为节点7000<br>创建的clusterNode结构的样子:<br>1.代表节点7000的clusterNode结构的numslaves属性的值为2,这说明有两个从节点正在复制节点7000<br>2.代表节点7000的clusterNode结构的slaves数组的两个项分别指向代表节点7004和代表节点7005的clusterNode结构<br>这说明节点7000的两个从节点分别是节点7004和节点7005
故障检测。<br>集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的<br>时间内,向发送PING消息的节点返回PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线(probale fail, PFAIL)<br><br>集群中的各个节点会通过互相发送消息的方式来交换集群中各个节点的状态信息,例如某个节点是处于在线状态、疑似下线状态(PFAIL)还是<br>已下线状态(FAIL).当一个主节点A通过消息得知主节点B认为主节点C进入了疑似下线状态时,主节点A会在自己的clusterState.nodes字典中<br>找到主节点C所对应的clusterNode结构,并将主节点B的下线报告(failure report)添加到clusterNode结构的fail_reports链表里面:<br>```c<br>struct clusterNode {<br>//...<br><br>// 一个链表,记录了所有其他节点对该节点的下线报告<br>list *fail_reports;<br><br>// ...<br>}<br>```<br>每个下线报告由一个clusterNodeFailReport结构表示:<br>```c<br>struct clusterNodeFailReport {<br>// 报告目标节点已经下线的节点<br>struct clusterNode *node;<br><br>// 最后以此从node节点收到下线报告的时间<br>// 程序使用这个时间戳来检查下线报告是否过期<br>// (与当前时间相差太久的下线报告会被删除)<br>mstime_t time;<br>}typedef clusterNodeFailReport;<br>```<br><br>如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将<br>主节点x标记为已下线会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。<br>
举个例子。如果节点7001向节点7000发送了一条PING消息,但是节点7000没有在规定的时间内,向节点7001返回一条PONG消息,<br>那么节点7001就会在自己的clusterState.nodes字典中找到7000所对应的clusterNode结构,并在结构的flags属性中打开REDIS_NODE_PFAIL标识,<br>以此表示节点7000进入了疑似下线状态,如图所示
举个例子。如果主节点7001在收到主节点7002、主节点7003发送的消息后得知,主节点7002和主节点7003都认为主节点7000进入了<br>疑似下线状态,那么主节点7001将为主节点7000创建如图所示的下线报告.<br>
举个例子,对于上图所示的下线报告来说,主节点7002和主节点7003都认为主节点7000进入了下线状态,并且主节点70001也认为主节点7000<br>进入了疑似下线状态(代表主节点7000的结构打开了REDIS_NODE_PFAIL标识),综合起来在集群四个负责处理槽的主节点里面,有三个主节点都<br>将主节点标记为下线,数量已经超过了半数,所以主节点7001会将主节点7000标记为已下线,并向集群广播一条关于主节点7000的FAIL消息,<br>如图所示
故障转移。<br>当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:<br>1.复制下线主节点的所有从节点里面,会有一个从节点被选中<br>2.被选中的从节点会执行SLAVEOF no one命令,成为新的主节点<br>3.新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己<br>4.新的主节点会向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且<br>这个主节点已经接管了原本由已下线节点负责处理的槽<br>5.新的主节点开始接收和自己负责处理的槽的有关的命令请求,故障转移完成
选举新的主节点。<br>新的主节点是通过选举产生的。以下是集群选举新的主节点的方法:<br>1.集群的配置纪元是一个自增计数器,它的初始值为0<br>2.当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值就会被增一<br>3.对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而的哥向主节点要求投票的从节点<br>将获得主节点的投票<br>4.当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST<br>消息,要求所有收到这条消息,并且具有投票权的主节点向这个从节点投票<br>5.如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条<br>CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的主节点<br>6.每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,并根据自己收到了多少条这种消息来统计自己获得了<br>多少主节点的支持<br>7.如果集群里有N个具有投票权的主节点,那么当一个从节点的收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点<br>8.因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N个主节点进行投票,那么具有大于等于N/2+1张支持<br>票的从节点只会有一个,这确保了主节点只会有一个<br>9.如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,知道选出新的主节点为止<br><br>这个选举主节点的方法和选举领头Sentinel的方法非常相似,因为两者都是基于Raft算法的领头选举(leader election)方法来实现的
消息。<br>集群中的各个节点通过发送和接收消息(message)来进行通信,我们称发送消息的节点为发送者(sender),接收消息<br>的节点成为接收者,如图所示。节点发送的消息主要有以下五种:<br>1.MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者发送MEET消息,请求接收者加入到发送者<br>当前所处的集群里面<br>2.PING消息:集群里每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送<br>过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线。除此之外,如果节点A最后一次收到节点B发送的PONG<br>消息的时间,距离当前时间已经超过了节点A的cluster-node-timeout选项设置时长的一般,那么节点A也会向节点B发送PING消息,<br>这可以防止节点A因为长事件没有随机选中节点B作为PING消息的发送对象而导致对象节点B的信息更新滞后<br>3.PONG消息,当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已经到达,<br>接收者会向发送者返回一条PONG消息。另外,<font color="#ec7270">一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新<br>关于这个节点的认识</font>,例如当一次故障转移操作成功执行之后,新的主节点会向集群广播一条PONG消息,以此来让集群中的其他<br>节点立即知道这个节点已经变成了主节点,并且接管了已下线节点负责的槽<br>4.FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条<br>消息的节点都会立即将节点B标记为已下线<br>5.PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH<br>消息的节点都会执行相同的PUBLISH命令<br><br>一条消息由消息头(header)和消息正文(data)组成
消息头。<br>节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息,<br>因为这些信息也会被消息接收者用到,所以严格来讲,可以认为消息头本身也是消息的一部分。<br>每个消息头都由一个cluster.h/clusterMsg结构表示<br>clusterMsg.data属性指向联合cluster.h/clusterMsgData,这个联合就是消息的正文:<br>clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身的节点信息,接收者会根据这些信息<br>在自己的clusterState.nodes字典里找到发送者对应的clusterNode结构,并对结构进行更新<br><br>举个例子,通过对比接收者为发送者记录的槽指派信息,以及发送者在消息头的myslots属性记录的槽指派信息,<br>接收者可以知道发送者的槽指派信息是否发生了变化,又或者说,通过对比接收者为发送者记录的标识值,以及<br>发送者在消息头的flags属性记录的标识值,接收者可以知道发送者的状态和角色是否发生了变化,例如节点状态<br>由原来的在线变成了下线,或者由主节点变成了从节点等等
```c<br>typedef struct {<br>// 消息的长度(包括这个消息头的长度和消息正文的长度)<br>uint32_t totlen;<br><br>// 消息的类型<br>uint16_t type;<br><br>// 消息正文包含的节点信息数量<br>// 只在发送MEET、PING、PONG这三种Gossip协议信息时使用<br>uint16_t count;<br><br>// 发送者所处的纪元<br>uint64_t currentEpoch;<br><br>// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元<br>// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元<br>uint64_t configEpoch;<br><br>// 发送者的名字(id)<br>char sender[REDIS_CLUSTER_NAMELEN];<br><br>// 发送者目前的槽指派信息<br>unsigned char myslots[REDIS_CLUSTER_SLOTS/8];<br>// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字<br>// 如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAME<br>char slaveof[REDIS_CLUSTER_NAMELEN];<br>// 发送者的端口号<br>uint16_t port;<br>// 发送者的标识值<br>uint16_t flags;<br><br>// 发送者所处集群的状态<br>unsigned char state;<br>// 消息的正文(或者说,内容)<br>unio clusterMsgData data;<br>}clusterMsg;<br>```<br>clusterMsg结构表示<br>
```c<br>union clusterMsgData {<br>struct {<br>// MEET、PING、PONG消息都包含两个clusterMsgDataGossip结构<br>clusterMsgDataGossip gossip[1];<br>} ping;<br><br>// FAIL消息的正文<br>struct {<br>clusterMsgDataFail abount;<br>}fail;<br><br>// PUBLISH消息的正文<br>struct {<br>clusterMsgDataPublish msg;<br>}publish;<br><br>// 其他消息正文...<br>}<br>```<br>clusterMsgData表示:
MEET、PING、PONG消息的实现。<br>Redis集群中的各个节点通过Gossip协议来交换各自不同节点的状态信息,其中Gossip协议由MEET、PING、PONG<br>三种消息实现,这三种消息的正文都由两个cluster.h/clusterMsgDataGossip结构组成:<br>```c<br>union clusterMsgData {<br>// ...<br>// MEET、PING和PONG消息的正文<br>struct {<br>// 每条MEET、PING、PONG消息都包含两个<br>// clusterMsgDataGossip结构<br>clusterMsgDataGossip gossip[1];<br>}ping<br>};<br>```<br>因为MEET、PING、PONG三种消息都由相同的消息正文,所以节点通过消息头的type属性来判断一条消息是MEET消息、<br>PING消息和PONG消息。每次发送MEET、PING、PONG消息时,发送者都从自己的已知节点列表中随机选出两个节点<br>(可以是这个主节点或者从节点),并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip结构里面。<br>clusterMsgDataGossip结构记录了被选中节点的名字,发送者与被选中节点最后一次发送和接收PING消息和PONG消息<br>的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值:<br>```c<br>typedef struct {<br>// 节点的名字<br>char nodename[REDIS_CLUSTER_NAMELEN];<br>// 最后一次向该节点发送PING消息的时间戳<br>uint32_t ping_sent;<br>// 最后一次从该节点接收到PONG消息的时间戳<br>uint32_t pong_received;<br><br>// 节点的IP地址<br>char ip[16];<br>// 节点的端口号<br>uint16_t port;<br>// 节点的标识值<br>uint16_t flags;<br>}clsterMsgDataGossip<br>```<br>当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个clusterMsgDataGossip结构,并根据自己是否<br>认识clusterMsgDataGossip结构中记录的被选中节点来选择进行哪种操作:<br>1.如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的<br>IP地址和端口号等信息,与被选中节点进行握手.<br>2.如果被选中节点已经存在与接收者的已知节点列表,那么说明接收者之前已经被选中节点进行过接触,接收者将根据clusterMsgDataGossip<br>结构记录的信息,对被选中节点所对应的clusterNode结构进行更新
举个发送PING消息和返回PONG消息的例子,假设在一个包含A、B、C、D、E、F六个节点的集群里:<br>1.节点A向节点D发送PING消息,并且消息里面包含了节点B和节点C信息,当节点D接收到这条信息,<br>它将更新自己对节点B和节点C的认知<br>2.之后,节点D将向节点A返回一条PONG消息,并且消息里面包含了节点E和节点F的消息,当节点A接收到<br>这条PONG消息时,它将更新自己对节点E和节点F的认知。
FAIL消息的实现。<br>当集群里的主节点A将主节点B标记为已下线(FAIL)时,主节点A将向集群广播一条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将<br>主节点B标记为已下线。在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,因为<br>Gossip协议消息通常许需要一段时间才能传播至整个集群,而发送FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判断是否<br>需要将集群标记为下线,又或者对下线主节点进行故障转移。FAIL消息的正文由cluster.h/clusterMsgDataFail结构表示,这个结构只包含一个nodename<br>属性,该属性记录了已下线节点的名字:<br>```c<br>typedef struct {<br>char nodename[REDIS_CLUSTER_NAMELEN];<br>}clusterMsgDataFail;<br>```<br>因为集群里的所有节点都有一个独一无二的名字,所以FAIL消息里面只需要保存下线节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点<br>下线了。
举个例子。对于包含7000、7001、7002、7003四个主节点的集群来说:<br>1.如果主节点7001发现主节点7000已下线,那么主节点7001将向主节点7002和主节点7003发送FAIL消息,<br>其中FAIL消息中包含的节点名字为主节点7000的名字,以此来表示主节点7000已下线<br>2.当主节点7002和主节点7003都接收到主节点7001发送的FAIL消息时,它们也会将主节点7000标记为已下线<br>3.因为这时集群已经有超过一半的主节点认为主节点7000已下线,所以集群剩下的几个主节点可以判断是否需要<br>将集群标记为下线,又或者开始对主节点7000进行故障转移
图中展示了节点发送和接收FAIL消息的整个过程
PUBLISH消息的实现。<br>当客户端向集群中的某个节点发送命令:<br>```c<br>PUBLISH <channel> <message><br>```<br>的时候,接收到PUBLISH命令的节点不仅会向channel频道发送消息message,他还会向集群广播一条PUBLISH消息,所有接收<br>到这条PUBLISH消息的节点都会向channel频道发送message消息。换句话说,向集群中的某个节点发送命令:<br>```c<br>PUBLISH <channel> <message><br>```<br><br>PUBLISH消息的正文由cluster.h/clusterMsgDataPublish结构表示:<br>```c<br>typedef struct {<br>uint32_t channel_len;<br>uint32_t message_len;<br><br>// 定义为8字节只是为了对齐其他消息结构<br>// 实际的长度由保存的内容决定<br>unsigned char bulk_data[8];<br>}clusterMsgDataPublish;<br>```<br>clusterMsgDataPublish结构的bulk_data属性是一个字节数组,这个字节数组保存了客户端通过PUBLISH命令发送给节点的<br>channel参数和message参数,而结构的channel_len和message_len则分别保存channel参数的长度和message参数的长度:<br>1.其中bulk_data的0字节至channel_len -1字节保存的是channel参数<br>2.而bulk_data的channel_len字节至channel_len + message_len - 1字节保存的则是message参数
举个例子。对于包含7000、7001、7002、7003四个节点的集群来说,如果节点7000收到了<br>客户端发送的PUBLISH命令,那么节点7000将向7001、7002、7003三个节点发送PUBLISH消息,<br>如图所示
举个例子。如果节点收到的PUBLISH命令为:<br>```c<br>PUBLISH "news.it" "hello"<br>```<br>那么节点发送的PUBLISH消息的clusterMsgDataPublish结构将如图所示:<br>其中bulk_data数组的前七个字节保存了channel参数的值"news.it"而bulk_data数组的后五个字节<br>则保存了message参数的值"hello"
为什么不直接向节点广播PUBLISH命令?<br>实际上,要让集群的所有节点都执行相同的PUBLISH命令,最简单的方法就是向所有节点广播相同的PUBLISH命令,<br>这也是Redis在复制PUBLISH命令时所使用的方法,不过因为这种做法并不符合Redis集群的"各个节点通过发送和接收消息<br>来进行通信"这一规则,所以节点没有采取广播PUBLISH命令的做法
发布与订阅
概述。<br>Redis的发布与订阅功能由PUBLISH、SUBSCRIBE、PSUBSCRIBE等命令组成。<br>通过执行SUBSCRIBER命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscribe):<br>每当有其他客户端向被订阅的频道发送消息(message)时,频道的所有订阅者都会收到这条消息.<br><br>除了订阅频道之外,客户端还可以通过执行PSUBSCRIBE命令订阅一个或多个模式,从而成为这些模式的<br>订阅者:每当有其他客户端向某个频道发送消息时,消息不仅会被发送给这个频道的所有订阅者,它还会<br>被发送给所有与这个频道相匹配的模式的订阅者
举个例子。假设A、B、C三个客户端都执行了命令:<br>```c<br>SUBSCRIBE "news.it"<br>```<br>那么这三个客户端就是"news.it"频道的订阅者,如图所示。<br>如果这时某个客户端执行命令<br>```c<br>PUBLISH "news.it" "hello"<br>```<br>向"news.it"频道发送消息"hello",那么"news.it"的三个订阅者都将收到这条消息,如图所示.
举个例子。假设如图所示:<br>1.客户端A正在订阅频道"news.it"<br>2.客户端B正在订阅频道"news.et"<br>3.客户端C和客户端D正在订阅与"news.it"频道和"news.et"频道相匹配的模式"news.[ie]t".<br>如果这时某个客户端执行命令<br>```c<br>PUBLISH "news.it" "hello"<br>```<br>"news.it"频道发送消息"hello",那么不仅正在订阅"news.it"频道的客户端A会受到消息,客户端C和客户端D也会收到消息,<br>因为这两个客户端正在订阅匹配"news.it"频道的"news.[ie]t"模式,如图所示,<br><br>与此类似,如果某个客户端执行命令<br>```c<br>PUBLISH "news.et" "world"<br>```<br>"news.et"频道发送消息"world",那么不仅正在订阅"news.et"频道的客户端B会收到消息,客户端C和客户端D也同样会收到消息。<br>因为这两个客户端正在订阅匹配"news.et"频道的"news.[ie]t"模式,如图所示
Redis中的发布订阅会进行持久化吗?<br>在Redis中,发布订阅(Pub/Sub)模式本身并不会进行消息的持久化。当消息被发布到频道时,它们会被发送给当前<br>处于订阅状态的客户端,并在这些客户端中进行传递,但不会被持久化到磁盘上。<br>如果需要对消息进行持久化,可以考虑以下几种方法:<br>1.使用Redis Streams:Redis5.0引入了Streams数据结构,它提供更丰富的消息传递功能,并且支持消息的持久化。<br>可以将消息发送到RedisStreams中,然后消费者可以按需读取消息,并且这些消息会持久化到Redis中<br>2.将消息存储到数据库中:在订阅者接收到消息后,可以将消息存储到数据库中进行持久化。这样可以确保即使在Redis<br>发布订阅模式中没有持久化消息的情况下,仍然可以通过数据库来获取消息历史记录<br>3.使用Redis AOF持久化:如果仍然希望使用Redis发布订阅模式,并且希望对消息进行持久化,可以启用Redis的AOF<br>(Append-Only File)持久化功能。AOF记录了Redis服务器接收到的所有写命令,包括发布消息到频道的命令。通过<br>启用AOF持久化,可以确保即使Redis服务器重启,也不会丢失消息<br><br>综上所述,Redis发布订阅模式本身并不提供消息持久化功能,但可以通过其他方式来实现消息的持久化,来满足需求<br><br><br>
补充:<br>1.RDB文件中的每个key_value_pairs部分都保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对<br>的过期时间也会被保存在内。不带过期时间的键值对在RDB文件中由TYPE、key、value三部分组成.<br>1.1 TYPE记录了value的类型,长度为1字节,值可以是以下常量的其中一个:<br>REDIS_RDB_TYPE_STRING<br>REDIS_RDB_TYPE_LIST<br>REDIS_RDB_TYPE_SET<br>REDIS_RDB_TYPE_ZSET<br>REDIS_RDB_TYPE_HASH<br>REDIS_RDB_TYPE_LIST_ZIPLIST<br>REDIS_RDB_TYPE_SET_INTSET<br>REDIS_RDB_TYPE_ZSET_ZIPLIST<br>REDIS_RDB_TYPE_HASH_ZIPLIST<br><br>以上列出的每个TYPE常量都代表了一种对象类型或者底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE<br>的值来决定如何读入和解释value的数据
AOF的话,像SELECT 1这样的命令都会被记录在AOF文件中,PUBLISH也会被记录进去
频道的订阅与退订。<br>当一个客户端执行SUBSCRIBE命令订阅某个或某些频道的时候,这个客户端与被订阅频道之间建立起了一种订阅关系。<br>Redis将所有频道的订阅关系都保存在服务器状态的pubsub_channels字典里面,这个字典的键是某个被订阅的频道,而<br>键的值则是一个链表,链表里面记录了所有订阅这个频道的客户端:<br>```c<br>struct redisServer {<br>// ...<br><br>// 保存所有频道的订阅关系<br>dict *pubsub_channels:<br><br>// ...<br>};<br>```<br>
举个例子。如图展示了一个pubsub_channels字典示例,这个字典记录了以下信息:<br>1.client-1、client-2、client-3三个客户端正在订阅"news.it"频道<br>2.客户端client-4正在订阅"news.sport"频道<br>3.client-5和client-6两个客户端正在订阅"news.business"频道<br>
订阅频道。<br>每当客户端执行SUBSCRIBE命令订阅某个或某些频道的时候,服务器都会将客户端与被订阅的频道<br>在pubsub_channels字典中进行关联。<br>根据频道是否已经有其他订阅者,关联操作分为两种情况执行:<br>1.如果频道已经有其他订阅者,那么它在pubsub_channels字典中必然有相应的订阅者链表,程序唯一要做的就是将客户端添加到<br>订阅者链表的末尾<br>2.如果频道还未有任何订阅者,那么它必然不存在于pubsub_channels字典,程序首先要在pubsub_channels字典中为频道创建<br>一个键,并将这个键的值设置为空链表,然后再将客户端添加到链表,成为链表的第一个元素
举个例子。假设服务器pubsub_channels字典的当前状态如图所示,那么当客户端client-10086执行命令<br>```c<br>SUBSCRIBE "news.sport" "news.movie"<br>```<br>之后,pubsub_channels字典将更新至如图所示的状态,其中用虚线包围的是新添加的节点:<br>1.更新后的pubsub_channels字典新增了"news.movie"键,该键对应的链表值只包含一个client-10086节点,<br>表示目前只有client-10086一个客户端在订阅"news.movie"频道<br>2.至于原本就已经有客户端在订阅的"news.sport"频道,client-10086的节点放在了频道对应链表的末尾,<br>排在client-4节点的后面<br>
SUBSCRIBE命令的实现可以用以下伪代码来描述:<br>```c<br>def subscribe(*all_input_channels):<br># 遍历输入的所有频道<br>for channel in all_input_channels:<br># 如果channel不存在于pubsub_channels字典(没有任何订阅者)<br># 那么在字典中添加channel键,并设置它的值为空链表<br>if channel not in server.pubsub_channels:<br>server.pubsub_channels[channel] = []<br><br># 将订阅者添加到频道所对应的链表的末尾<br>server.pubsub_channels[channel].append(client)<br>```
退订频道。<br>UNSUBSCRIBE命令的行为和SUBSCRIBE命令的行为正好相反,当一个客户端退订某个或某些频道的时候,服务器将从pubsub_channels<br>中解除客户端与被退订频道之间的关联:<br>1.程序会根据被退订频道的名字,在pubsub_channels字典中找到频道对应的订阅者链表,然后从订阅者链表删除退订客户端的信息<br>2.如果删除退订客户端之后,频道的订阅者链表变成了空链表,那么说明这个频道已经没有任何订阅者了,程序将从pubsub_channels<br>字典中删除频道对应的键。
举个例子,假设pubsub_channels的当前状态如图所示,那么当客户端client-10086执行命令:<br>```c<br>UNSUBSCRIBE "news.sport" "news.movie"<br>```<br>之后,图中用虚线包围的两个节点将被删除如图所示<br>1.在pubsub_channels字典更新之后,client-10086的信息已经从"news.sport"频道和"news.movie"频道<br>的订阅者链表中被删除了<br>2.另外,因为删除client-10086之后,频道"news.movie"已经没有任何订阅者,因此键"news.movie"也从<br>字典中被删除了
UNSUBSCRIBE命令的实现可以用以下伪代码来描述:<br>```c<br>def unsubscribe(*all_input_channels):<br># 遍历要退订的所有频道<br>for channel in all_input_channels:<br># 在订阅者链表中删除退订<br>server.pubsub_channels[channel].remove(client);<br><br># 如果频道已经没有任何订阅者(订阅者链表为空)<br># 那么将频道从字典中删除<br>if len(server.pubsub_channels[channel]) == 0:<br>server.pubsub_channels.remove(channel)<br>```
模式的订阅与退订。<br>服务器将所有频道的订阅关系都保存在服务器状态的pubsub_channels属性里面,与此类似,服务器也将所有模式的订阅<br>都保存在服务器状态的pubsub_patterns属性里面:<br>```c<br>struct redisServer {<br>// ...<br><br>// 保存所有模式订阅关系<br>list *pubsub_patterns;<br><br>// ...<br>};<br>```<br>pubsub_patterns属性是一个链表,链表中的每个节点都包含着一个pubsubPatttern结构,这个结构的pattern属性记录<br>了被订阅的模式,而client属性则记录了订阅模式的客户端:<br>```c<br>typedef struct pubsubPattern{<br>// 订阅模式的客户端<br>redisClient *client;<br><br>// 被订阅的模式<br>robj *pattern;<br>} pubsubPattern;<br>```<br>
举个例子。如图所示是一个pubsubPattern结构示例,它显示客户端client-9正在订阅模式"news.*".<br>图中展示了pubsub_patterns链表示例,这个链表记录了以下信息:<br>1.客户端client-7正在订阅模式"music.*"<br>2.客户端client-8正在订阅模式"book.*"<br>3.客户端client-9正在订阅模式"news.*"
订阅模式。<br>每当客户端执行PSUBSCIRBE命令订阅某个或某些模式的时候,服务器会对每个被订阅的模式执行以下<br>两个操作:<br>1.新键一个pubsubPattern结构,将结构的pattern属性设置为被订阅的模式,client属性设置为订阅模式的客户端<br>2.将pubsubPattern结构添加到pubsub_patterns链表的表尾。
举个例子,假设服务器中的pubsub_pattern链表的当前状态如图所示。<br>当客户端client-9执行命令:<br>```c<br>PSUBSCRIBE "news.*"<br>```<br>之后,pubsub_patterns链表将更新至如图所示的状态,其中用虚线包围的是新添加的pubsubPattern结构
PSUBSCRIBE命令的实现原理可以用以下伪代码来描述<br>```c<br>def psubscribe(*all_input_patterns):<br># 遍历输入的所有模式<br>for pattern in all_input_patterns:<br># 创建新的pubsubPattern结构<br># 记录被订阅的模式,以及订阅模式的客户端<br>pubsubPattern = create_new_pubsubPattern()<br>pubsubPattern.client = client<br>pubsubPattern.pattern = pattern<br><br># 将新的pubsubPattern追加到pubsub_patterns链表末尾<br>server.pubsub_patterns.append(pubsubPattern)<br>```
退订模式。<br>模式的退订模式PUNSUBSCRIBE是PSUBSCRIBE命令的反操作:当一个客户端退订某个或某些模式的时候,服务器<br>将在pubsub_patterns链表中查找并删除那些pattern属性为退订模式,并且client属性为执行退订命令的客户端<br>的pubsubPattern结构
举个例子。假设服务器pubsub_patterns链表的当前状态如图所示,当客户端client-9执行命令:<br>```c<br>PUNSUBSCRIBE "news.*"<br>```<br>之后,client属性为client-9,pattern属性为"news.*"的pubsubPattern结构将被删除,pubsub_patterns链表<br>将更新至如图所示的样子
PUNSUBSCRIBE命令的实现原理可以用以下伪代码来描述<br>```c<br>def punsubscribe(*all_input_patterns):<br># 遍历所有要退订的模式<br>for pattern in all_input_patterns:<br># 遍历pubsub_patterns<br>for pubsubPattern in server.pubsub_patterns:<br># 如果当前客户端和pubsubPattern记录的客户端相同<br># 并且要退订的模式也和pubsubPattern记录的模式相同<br>if client == pubsubPattern.client and \ pattern == pubsubPattern.pattern:<br><br># 那么将这个pubsubPattern从链表删除<br>server.pubsub_patterns.remove(pubsubPattern)<br>```
发送消息。<br>当一个Redis客户端执行PUBLISH <channel> <message>命令将消息message发送给频道channel的时候,服务器需要执行以下<br>两个动作:<br>1.将消息message发送给channel频道的所有订阅者<br>2.如果一个或多个模式pattern与频道channel相匹配,那么将消息message发送给pattern模式的订阅者
将消息发送给频道订阅者。<br>因为服务器状态中的pubsub_channels字典记录了所有频道的订阅关系,所以为了将消息发送给channel频道<br>的所有订阅者,PUBLISH命令要做的就是在pubsub_channels字典里找到频道channel的订阅者名单(一个链表),<br>然后将消息发送给名单上的所有客户端
举个例子。假设服务器pubsub_channels字典当前的状态如图所示,<br>如果这时某个客户端执行命令<br>```c<br>PUBLISH "news.it" "hello"<br>```<br>那么PUBLISH命令将在pubsub_channels字典中查找键"news.it"对应的链表值,并<br>通过遍历链表将消息"hello"发送给"news.it"频道的三个订阅者:client-1、client-2、client-3<br>
PUBLISH命令将消息发送给频道订阅者的方法可以用以下伪代码来描述:<br>```c<br>def channel_publish(channel, message):<br># 如果channel键不存在于pubsub_channels字典中<br># 那么说明channel频道没有任何订阅者<br># 程序不做发送动作,直接返回<br>if channel not in server.pubsub_channels:<br>return<br><br># 运行到这里,说明channel频道至少有一个订阅者<br># 程序遍历channel频道的订阅者链表<br># 将消息发送给所有订阅者<br>for subscriber in server.pubsub_channels[channel]:<br>send_message(subscriber, message)<br>```
将消息发送给模式订阅者。<br>因为服务器状态中的pubsub_patterns链表记录了所有模式的订阅关系,所以为了将消息发送给所有与channel<br>频道相匹配的模式的订阅者,PUBLISH命令要做的就是遍历整个pubsub_patterns链表,查找那些与channel频道<br>相匹配的模式,并将消息发送给订阅了这些模式的客户端。
举个例子。假设pubsub_patterns链表的当前状态如图所示。<br>如果这时客户端执行命令<br>```c<br>PUBLISH "news.it" "hello"<br>```<br>那么PUBLISH命令会首先将消息"hello"发送给"news.it"频道的所有订阅者,<br>然后开始在pubsub_patterns链表中查找是否有被订阅的模式与"news.it"频道相匹配,<br>结果发现"news.it"频道和客户端client-9订阅的"news.*"频道匹配,于是命令将消息"hello"<br>发送给客户端client-9<br>
PUBLISH命令将消息发送给模式订阅者的方法可以用以下伪代码来描述:<br>```c<br>def pattern_publish(channel, message):<br># 遍历所有模式订阅消息<br>for pubsubPattern in server.pubsub_patterns:<br># 如果频道和模式相匹配<br>if match(channel, pubsubPattern.pattern):<br># 那么将消息发送给订阅该模式的客户端<br>send_message(pubsubPattern.client, message)<br>```<br>最后,PUBLISH命令的实现可以用以下伪代码来描述:<br>```c<br>def publish(channel, message):<br># 将消息发送给channel频道的所有订阅者<br>channel_publish(channel, message)<br><br># 将消息发送给所有和channel频道相匹配的模式的订阅者<br>pattern_publish(channel, message)<br>```
查看订阅信息。<br>PUBSUB命令是Redis2.8新增加的命令之一,客户端可以通过这个命令来查看频道或者模式的相关信息,比如某个频道目前有多少订阅者,<br>又或者某个模式目前有多少订阅者,诸如此类
PUBSUB CHANNELS。<br>PUBSUB CHANNELS [pattern]子命令用于返回服务器当前被订阅的频道,其中pattern参数是可选的:<br>1.如果不给顶patter参数,那么命令返回服务器当前被订阅的所有频道<br>2.如果给定pattern参数,那么命令返回服务器当前被订阅的频道中那些与pattern模式相匹配的频道<br>这个子命令是通过遍历服务器pubsub_channels字典的所有键(每个键都是一个被订阅的频道),然后记录<br>并返回所有符合条件的频道来实现额,这个过程可以用以下伪代码来描述<br>```c<br>def pubsub_channels(pattern=None):<br># 一个列表,用于记录所有符合条件的频道<br>channel_list = []<br><br># 遍历服务器中的所有频道<br># (也即是pubsub_channels字典的所有键)<br>for channel in server.pubsub_channels:<br># 当以下两个条件的任意一个满足时,将频道添加到链表里面<br># 1.用户没有指定pattern参数<br># 2.用户指定了pattern参数,并且channel和pattern匹配<br>if (patter is None) or match (channel, pattern):<br>channel_list.append(channel)<br><br># 向客户端返回频道列表<br>return channel_list<br>```
举个例子。对于图中所示的pubsub_channels字典来说,执行PUBSUB CHANNELS命令<br>将返回服务器目前订阅的四个频道:<br>```c<br>1."news.it"<br>2."news.sport"<br>3."news.business"<br>4."news.movie"<br>```<br>另一方面,执行PUBSUB CHANNELS "news.[is]*"命令将返回"news.it"和"news.sport"两个频道,<br>因为只有这两个频道和"news[is]*"模式相匹配<br>```c<br>1."news.it"<br>2."news.sport"<br>```
PUBSUB NUMSUB。<br>PUBSUB NUMSUB [channel-1 channel-2... channel-n]子命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量。<br>这个子命令是通过pubsub_channels字典中找到频道对应的订阅者链表,然后返回订阅者链表的长度来实现的(订阅者链表的长度<br>就是频道订阅者的数量),这个过程可以用以下伪代码来描述:<br>```c<br>def pubsub_numsub(*all_input_channels):<br># 遍历输入的所有频道<br>for channel in all_input_channels:<br># 如果pubsub_channels字典中没有channel这个键<br># 那么说明channel频道没有任何订阅者<br>if channel not in server.pubsub_channels:<br># 返回频道名<br>reply_channel_name(channel)<br># 订阅者数量为0<br>reply_subscribe_count(0)<br><br># 如果pubsub_channels字典中存在channel键<br># 那么说明channel频道至少有一个订阅者<br>else:<br># 返回频道名<br>reply_channel_name(channel)<br># 订阅者链表的长度就是订阅者数量<br>reply_subscribe_count(len(server.pubsub_channels(channel)))<br>```
举个例子。对于图中所示的pubsub_channels字典来说,对字典中的四个频道执行PUBSUB NUMSUB<br>命令将获得以下回复<br>```c<br>redis>PUBSUB NUMSUB news.it news.sport news.business news.movie<br>1)."news.it"<br>2)."3"<br>3)."news.sport"<br>4)."2"<br>5)."news.business"<br>6)."2"<br>7)."news.movie"<br>8)."1"<br>```
PUBSUB NUMPAT。<br>PUBSUB NUMPAT子命令用于返回服务器当前被订阅模式的数量。这个子命令是通过返回pubsub_patterns链表的长度来实现的,<br>因为这个链表的长度就是服务器被订阅模式的数量,这个过程可以用以下伪代码来描述:<br>```c<br>def pubsub_numpat():<br># pubsub_patterns链表的长度即是被订阅模式的数量<br>reply_pattern_count(len(server.pubsub_patterns))<br>```
举个例子。对于图中所示的pubsub_patterns链表来说,执行PUBSUB NUMPAT命令将返回3:<br>```c<br>redis>PUBSUB NUMPAT<br>(integer) 3<br>```
事务
概述。<br>Redis通过MULTI、EXEC、WATCH等命令来实现事务(transaction)功能。事务提供了一种将多个命令请求打包,<br>然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的<br>命令请求,它会将事务中的所有命令都执行完毕,然后采取执行其他客户端的命令请求。
举个例子。事务首先以一个MULTI命令为开始,接着将多个命令放入事务当中,最后由EXEC命令<br>将这个事务提交(commit)给服务器执行:<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET "name" "Practical Common Lisp"<br>QUEUED<br>127.0.0.1:6379> GET "name"<br>QUEUED<br>127.0.0.1:6379> SET "author" "Peter Seibel"<br>QUEUED<br>127.0.0.1:6379> GET "author"<br>QUEUED<br>127.0.0.1:6379> EXEC<br>1) OK<br>2) "Practical Common Lisp"<br>3) OK<br>4) "Peter Seibel"<br>```
事务的实现。<br>一个事务从开始到结束通常会经历以下三个阶段:<br>1.事务开始<br>2.命令入队<br>3.事务执行
事务开始。<br>MULTI命令的执行标志着事务的开始:<br>```c<br>redis> MULTI<br>OK<br>```<br>MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开<br>REDIS_MULTI标识来完成的,MULTI命令的实现可以用以下伪代码来表示:<br>```c<br>def MULTI():<br># 打开事务表示<br>client.flags |= REDIS_MULTI<br><br># 返回OK回复<br>replyOK()<br>```
命令入队。<br>当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行:<br>```c<br>127.0.0.1:6379> SET "name" "Practical Common Lisp"<br>OK<br>127.0.0.1:6379> GET "name"<br>"Practical Common Lisp"<br>127.0.0.1:6379> SET "author" "Peter Seibel"<br>OK<br>127.0.0.1:6379> GET "author"<br>"Peter Seibel<br>```<br>与此不同的是,当一个客户端切换到事务状态之后,服务器会根据这个客户端法拉的不同命令执行不同的操作:<br>1.如果客户端发送的命令为EXEC、DISCARD、WATCH、MULTI四个命令的其中一个,那么服务器立即执行这个命令<br>2.与此相反,如果客户端发送的命令是EXEC、DISCARD、WATCH、MULTI四个命令以外的其他命令,那么服务器<br>并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回QUEUED回复。<br><br>服务器判断命令是该入队还是该立即执行的过程可以用流程图来描述
事务队列。<br>每个Redis客户端都由自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面:<br>```c<br>typedef struct redisClient {<br>// ...<br><br>// 事务状态<br>multiState mstate; // MULTI/EXEC state<br><br>// ...<br>}redisClient;<br>```<br>事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度):<br>```c<br>typedef struct multiState {<br>// 事务队列, FIFO顺序<br>multiCmd *commands;<br><br>// 已入队命令计数<br>int count;<br>} multiState;<br>```<br>事务队列是一个multiCmd类型的数组,数组中的每个multiCmd结构都保存了一个已入队命令的<br>相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量:<br>```c<br>typedef struct multiCmd {<br>// 参数<br>robj **argv;<br><br>// 参数数量<br>int argc;<br><br>// 命令指针<br>struct redisCommand *cmd;<br>}multiCmd;<br>```<br>事务队列以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的前面,而较后入队<br>的命令则会被放到数组的后面
举个例子。如果客户端执行以下命令:<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET "name" "Practical Common Lisp"<br>QUEUED<br>127.0.0.1:6379> GET "name"<br>QUEUED<br>127.0.0.1:6379> SET "author" "Peter Seibel"<br>QUEUED<br>127.0.0.1:6379> GET "author"<br>QUEUED<br>```<br>那么服务器将为客户端创建如图所示的事务状态:<br>1.最先入队的SET命令被放在了事务队列的索引0位置上<br>2.第二入队的GET命令被放在了事务队列的索引1位置上<br>3.第三入队的另一个SET命令被放在了事务队列的索引2位置上<br>4.最后入队的另一个GET命令被放在了事务队列的索引3位置上
执行事务。<br>当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行,服务器会遍历<br>这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。
举个例子。对于如图所示的事务队列来说,服务器会先执行命令:<br>```c<br>SET "name" "Practical Common Lisp"<br>```<br>之后执行命令:<br>```c<br>GET "name"<br>```<br>在之后执行命令:<br>```c<br>GET "author"<br>```<br>最后,服务器会将执行这四个命令所得的回复返回给客户端:<br>```c<br>127.0.0.1:6379> EXEC<br>1) OK<br>2) "Practical Common Lisp"<br>3) OK<br>4) "Peter Seibel"<br>```
EXEC命令的实现原理可以用以下伪代码来描述:<br>```c<br>def EXEC():<br># 创建空白的回复队列<br>reply_queue =[]<br># 遍历事务队列中的每个项<br># 读取命令的参数,参数的个数,以及要执行的命令<br>for argv, argc, cmd in client.mstate.commands:<br># 执行命令,并取得命令的返回值<br>reply = execute_command(cmd, argv, argc)<br><br># 将返回值追加到回复队列末尾<br>reply_queue.append(reply)<br><br># 移除REDIS_MULTI标识,让客户端回到非事务状态<br>client.flags &= ~REDIS_MULTI<br><br># 清空客户端的事务状态,包括:<br># 1.清零入队命令计数器<br># 2.释放事务队列<br>client.mstate.count = 0<br>release_transaction_queue(client.mstate.commands)<br><br># 将事务的执行结果返回给客户端<br>send_reply_to_client(client, reply_queue)<br>```
WATCH命令的实现。<br>WATCH命令是一个乐观锁(optimistic locking),它可以在EXEC命令执行之前,监视任意数量的数据库键,<br>并在EXEC命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,<br>并向客户端返回代表事务执行失败的空回复。
举个例子。<br>```c<br>127.0.0.1:6379> flushall<br>OK<br>127.0.0.1:6379> WATCH "name"<br>OK<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET "name" "vinpink"<br>QUEUED<br>127.0.0.1:6379> EXEC<br>(nil)<br>```<br>如表展示了上述的事务是如何失败的。在时间T4,客户端B修改了"name"键的值,当客户端A<br>在T5执行EXEC命令时,服务器会发现WATCH监视的键"name"已经被修改,因此服务器拒绝执行<br>客户端A的事务,并向客户端A返回空回复
使用WATCH命令监视数据库键。<br>每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个<br>链表,链表中记录了所有监视相应数据库键的客户端:<br>```c<br>typedef struct redisDb {<br>// ...<br><br>// 正在被WATCH命令监视的键<br>dict *watched_keys;<br><br>// ...<br>}redisDb;<br>```<br>通过watched_keys字典,服务器可以清楚地知道哪些数据库键正在被监视,以及哪些客户端正在监视这些数据库键。<br>1.客户端c1和c2正在监视键"name"<br>2.客户端c3正在监视键"age"<br>3.客户端c2和c4正在监视键"address"<br>通过执行WATCH命令,客户端可以在watched_keys字典中与被监视的键进行关联
举个例子。如果当前客户端为c10086,那么客户端执行以下WATCH命令之后:<br>```c<br>redis>WATCH "name" "age"<br>OK<br>```<br>上图展示的wathced_keys字典将被更新为如图所示的状态,其中用虚线包围的两个<br>c10086节点就是由刚刚执行的WATCH命令添加到字典中的。
监视机制的触发。<br>所有对数据库进行修改的命令,比如SET、LPUSH、SADD、ZREM、DEL、FLUSHDB等等,在执行之后都会调用multi.c/touchWatchKey<br>函数对watched_keys字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey函数<br>会将监视被修改键的客户端的REDIS_DIRTY_CAS标识打开,标识客户端的事务安全性已经被破坏。<br>touchWatchKey函数的定义额可以用以下伪代码来描述<br>```c<br>def touchWatchKey(db, key) :<br># 如果键key存在于数据库的watched_keys字典中<br># 那么说明至少有一个客户端在监视这个key<br>if key in db.watched_keys:<br># 遍历所有监视键key的客户端<br>for client in db.watched_keys[key]:<br># 打开标识<br>client.flags |= REDIS_DIRTY_CAS<br>```
举个例子。如图所示的watched_keys字典来说:<br>1.如果键"name"被修改,那么c1、c2、c10086三个客户端的REDIS_DIRTY_CAS标识将被打开<br>2.如果键"age"被修改,那么c3和c10086两个客户端的REDIS_DIRTY_CAS标识将被打开<br>3.如果键"address"被修改,那么c2和c4两个客户端的REDIS_DIRTY_CAS标识将被打开
判断事务是否安全。<br>当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识<br>来决定是否执行事务:<br>1.入股客户端的REDIS_DIRTY_CAS标识已经被打开,那么说明客户端所监视的键当中,至少有一个键已经被修改过了,<br>在这种情况下,客户端提交的事务已经不再安全,所以服务器会拒绝执行客户端提交的事务<br>2.如果客户端的REDIS_DIRTY_CAS标识没有被打开,那么说明客户端监视的所有键都没有被修改过(或者客户端没有监视<br>任何键),事务仍然是安全的,服务器将执行客户端提交的这个事务。<br><br>这个判断是否执行事务的过程可以用流程图来描述
举个例子。对于上图的watched_keys字典来说,如果某个客户端对"name"进行了修改(比如执行SET "name" "cover"),<br>那么c1、c2、c10086三个客户端的REDIS_DIRTY_CAS标识将被打开,当这三个客户端向服务器发送EXEC命令的时候,<br>服务器会拒绝执行它们提交的事务,以此来保证事务的安全性
一个完整的WATCH事务执行过程。<br>假设当前服务端为c10086,而数据库watched_keys字典的当前状态如图所示,那么当c10086执行以下WATCH命令之后<br>```c<br>c10086> WATCH "name"<br>OK<br>```<br>watched_keys字典将更新如图所示的状态。接下来客户端c10086继续向服务器发送MULTI命令,并将一个SET命令放入<br>事务队列:<br>```c<br>c10086> MULTI<br>OK<br><br>c10086> SET "name" "peter"<br>QUEUED<br>```<br>就在这时,另一个客户端c999向服务器发送了一条SET命令,将"name"键的值设置成了"john":<br>```c<br>c999>SET "name" "john"<br>OK<br>```<br>c999执行的这个SET命令会导致正在监视"name"键的所有客户端的REDIS_DIRTY_CAS标识被打开,其中包括客户端c10086.<br>之后,当c10086向服务器发送EXEC命令的时候,因为c10086的REDIS_DIRTY_CAS标志已经被打开,所以服务器将拒绝执行<br>它提交的事务:<br>```c<br>c10086>EXEC<br>(nil)<br>```
事务的ACID性质。<br>在传统的关系式数据库中,常常用ACID性质来检验事务功能的可靠性和安全性。<br>在Redis中,事务总是具有原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation),并且当Redis运行在某种<br>特定的持久化模式下,事务也具有耐久性(Durability)
原子性。<br>事务具有原子性指的是,数据库将事务中的多个操作当作一个整体来执行,服务器要么就执行事务中的所有操作,要么就<br>一个操作也不执行。对于Redis的事务功能来说,事务队列中的命令要么就全部都执行,要么就一个都不执行,因此,Redis<br>的事务是具有原子性的。<br><br>Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback),即使事务队列中的某个命令在<br>执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止
举个例子。以下展示了一个成功执行的事务,事务中的所有命令都会被执行<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET msg "hello"<br>QUEUED<br>127.0.0.1:6379> GET msg<br>QUEUED<br>127.0.0.1:6379> EXEC<br>1) OK<br>2) "hello"<br>```<br>
举个例子。与此相反,以下展示了一个执行失败的事务,这个事务因为命令入队出错而被<br>服务器拒绝执行,事务中的所有命令都不会被执行:<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET msg "hello"<br>QUEUED<br>127.0.0.1:6379> GET<br>(error) ERR wrong number of arguments for 'get' command<br>127.0.0.1:6379> GET msg<br>QUEUED<br>127.0.0.1:6379> EXEC<br>(error) EXECABORT Transaction discarded because of previous errors.<br>```
举个例子。在下面的例子中,即使RPUSH命令在执行期间出现了错误,事务的后续命令也会继续<br>执行下去,并且之前执行的命令也不会有任何影响<br>```c<br>127.0.0.1:6379> SET msg "hello"<br>OK<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SADD fruit "apple" "banana" "cherry"<br>QUEUED<br>127.0.0.1:6379> RPUSH msg "good bye" " bye bye"<br>QUEUED<br>127.0.0.1:6379> SADD alphabet "a" "b" "c"<br>QUEUED<br>127.0.0.1:6379> EXEC<br>1) (integer) 3<br>2) (error) WRONGTYPE Operation against a key holding the wrong kind of value<br>3) (integer) 3<br>127.0.0.1:6379> SCARD fruit<br>(integer) 3<br>```<br>Redis的作者在事务功能的文档中解释说,不支持事务回滚是因为这种复杂的功能和Redis追求简单高效<br>的设计主旨不相符,并且他认为,Redis的事务的执行时错误通常都是编程错误产生的,这种错误通常<br>只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为Redis开发事务回滚功能
一致性。<br>事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库<br>也仍然是一致的。"一致"指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。Redis通过谨慎<br>的错误检测和简单的设计来保证事务的一致性
入队错误。<br>如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等情况,那么Redis将拒绝执行这个事务。<br>在以下的示例中,因为客户端尝试向事务入队一个不存在的命令YAHOOOO,所以客户端提交的事务会被服务器拒绝执行:<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET msg "hello"<br>QUEUED<br>127.0.0.1:6379> YAHOOOO<br>(error) ERR unknown command 'YAHOOOO'<br>127.0.0.1:6379> GET msg<br>QUEUED<br>127.0.0.1:6379> EXEC<br>(error) EXECABORT Transaction discarded because of previous errors.<br>```<br>因为服务器会拒绝执行入队过程中出现错误的事务,所以Redis事务的一致性不会被带有入队错误的事务影响。
Redis2.6.5以前的入队错误处理。<br>在Redis2.6.5以前的版本,即使有命令在入队过程中发生了错误,事务一样可以执行,不过被执行的命令只包括<br>那些正确入队的命令。以下代码是在Redis2.6.4版本上测试的,可以看到,事务可以正常执行,但只有成功入队<br>的SET命令和GET命令被执行了,而错误的YAHOOOO则被忽略了<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET msg "hello"<br>QUEUED<br>127.0.0.1:6379> YAHOOOO<br>(error) ERR unknown command 'YAHOOOO'<br>127.0.0.1:6379> GET msg<br>QUEUED<br>127.0.0.1:6379> EXEC<br>1).OK<br>2)."hello"<br>```<br>因为错误的命令不会被入队,所以Redis不会尝试去执行错误的命令,因此,即使在2.6.5以前的版本中,Redis事务<br>的一致性也不会被入队错误影响
执行错误。<br>除了入队时可能发生错误以外,事务还可能在执行的过程中发生错误。关于这种错误有两个需要说明的地方:<br>1.执行过程中发生的错误都是一些不能入队时被服务器发现的错误,这些错误只会在命令实际执行时被触发<br>2.即使在事务的执行过程中发生了错误,服务器也不会中断事务的执行,它会继续执行事务中余下的其他命令,<br>并且已执行的命令(包括执行命令所产生的结果)不会被出错的命令影响<br>对数据库键执行了错误类型的操作是事务执行期间最常见的错误之一。<br>在下面的示例中,首先用SET命令将键"msg"设置成了一个字符串键,然后在事务里面尝试对"msg"键执行只能用于<br>列表键的RPUSH命令,这将引发一个错误,并且这种错误只能在事务执行(也即是命令执行)期间被发现:<br>```c<br>127.0.0.1:6379> SET msg "hello"<br>OK<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SADD fruit "apple" "banana" "cherry"<br>QUEUED<br>127.0.0.1:6379> RPUSH msg "good bye" " bye bye"<br>QUEUED<br>127.0.0.1:6379> SADD alphabet "a" "b" "c"<br>QUEUED<br>127.0.0.1:6379> EXEC<br>1) (integer) 3<br>2) (error) WRONGTYPE Operation against a key holding the wrong kind of value<br>3) (integer) 3<br>```<br>因为在事务执行的过程中,出错的命令会被服务器识别出来,并进行相应的错误处理,所以这些出错命令不会对数据库<br>做任何修改,也不会对事务的已执行产生影响
服务器停机。<br>如果Redis服务器在执行事务的过程中停机,那么根据服务器所使用的持久化模式,可能有以下情况出现:<br>1.如果服务器运行在无持久化的内存模式下,那么重启之后的数据库将是空白的,因此数据总是一致的。<br>2.如果服务器运行在RDB模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的RDB<br>文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的RDB文件,那么重启之后的<br>数据库将是空白的,而空白数据库总是一致的<br>3.如果服务器运行在AOF模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的AOF<br>文件,从而将数据库还原到一个一致的状态。如果找不到可供使用的AOF文件,那么重启之后的数据库将是<br>空白的,而空白数据库总是一致的。<br>综上所述,无论Redis服务器运行在哪种持久化模式下,事务执行中途发生的停机都不会影响数据库的一致性
隔离性。<br>事务的隔离性指的是,即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响,并且在并发<br>状态下执行的事务和串行执行的事务产生的结果完全相同。因为Redis使用单线程的方式来执行事务(以及事务<br>队列中的命令),并且服务器保证在执行事务期间不会对事务进行中断,因此,Redis的事务总是以串行的方式<br>运行的。并且事务也总是具有隔离性
耐久性.<br>事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,<br>即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失。因为Redis的事务不过是简单地使用队列包裹起了<br>一组Redis命令,Redis并没有为事务提供任何额外的持久化功能,所以Redis事务的耐久性由Redis所使用的持久化模式决定:<br>1.当服务器在无持久化的内存模式下运作时,事务不具有耐久性:一旦服务器停机,包括事务数据在内的所有服务器数据都将<br>丢失<br>2.当服务器在RDB持久化模式下运作时,服务器只会在特定的保存条件被满足时才会执行BGSAVE命令,对数据库进行保存<br>操作,并且异步执行的BGSAVE不能保证事务数据第一时间保存到硬盘里面,因此RDB持久化模式下的事务也不具有耐久性<br>3.当服务器运行在AOF持久化模式下,并且appendfsync选项的值为everysec时,程序会每秒同步一次命令数据到硬盘,因为<br>停机可能恰好发生在等待同步的那一秒钟之内,这可能会造成事务数据丢失,所以这种配置下的事务不具有耐久性<br>4.当服务器运行在AOF持久化模式下,并且apendfsync选项的值为no时,程序会交由操作系统来决定何时将命令数据同步到<br>硬盘。因为事务数据可能在等待同步的过程中丢失,所以这种配置下的事务不具有耐久性<br><br>不论Redis在什么模式下运作,在一个事务的最后加上SAVE命令总可以保证事务的耐久性:<br>```c<br>127.0.0.1:6379> MULTI<br>OK<br>127.0.0.1:6379> SET msg "hello"<br>QUEUED<br>127.0.0.1:6379> SAVE<br>QUEUED<br>127.0.0.1:6379> EXEC<br>1) OK<br>2) OK<br>```<br>不过这种做法的效率太低,所以不具有实用性
Lua脚本<br>
概述。<br>Redis从2.6版本开始引入对Lua脚本的支持,通过在服务器中嵌入Lua环境,Redis客户端可以使用Lua脚本,直接在服务器端<br>原子地执行多个Redis命令。其中使用EVAL命令可以直接对输入的脚本进行求值:<br>```c<br>127.0.0.1:6379> EVAL "return 'hello world'" 0<br>"hello world"<br>```<br>而使用EVALSHA命令则可以根据脚本的SHA1校验和来对脚本进行求值,但这个命令要求校验和对应的脚本必须至少被EVAL命令执行过<br>一次:<br>```c<br>127.0.0.1:6379> EVAL "return 1+1" 0<br>(integer) 2<br>127.0.0.1:6379> EVALSHA "a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9" 0 // 上一个脚本的校验和<br>(integer) 2<br>```<br>或者这个校验和对应的脚本曾经被SCRIPT LOAD命令载入过:<br>```c<br>127.0.0.1:6379> SCRIPT Load "return 2*2"<br>"4475bfb5919b5ad16424cb50f74d4724ae833e72"<br>127.0.0.1:6379> EVALSHA "4475bfb5919b5ad16424cb50f74d4724ae833e72" 0<br>(integer) 4<br>```
创建并修改Lua环境。<br>为了在Redis服务器中执行Lua脚本,Redis在服务器内嵌了一个Lua环境(evnironment),并对这个Lua环境进行了一系列修改,从而<br>确保这个Lua环境可以满足Redis服务器的需要。<br>Redis服务器创建并修改Lua环境的整个过程由以下步骤组成:<br>1.创建一个基础的Lua环境,之后的所有修改都是针对这个环境进行的。<br>2.载入多个函数库到Lua环境里面,让Lua脚本可以使用这些函数库来进行数操作<br>3.创建全局表格redis,这个表格包含了对Redis进行操作的函数,比如用于在Lua脚本中执行Redis命令的redis.call函数<br>4.使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,从而避免在脚本中引入副作用<br>5.创建排序辅助函数,Lua环境使用这个辅佐函数来对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性<br>6.创建redis.pcall函数的错误报告辅助函数,这个函数可以提供更详细的出错信息<br>7.对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全局变量添加到Lua环境中<br>8.将完成修改的Lua环境保存到服务器状态的Lua环境中,等待执行服务器传来的Lua脚本,
创建Lua环境。<br>服务器首先调用Lua的C API函数lua_open,创建一个新的Lua环境。因为lua_open函数创建的只是一个基本的<br>Lua环境,为了让这个Lua环境可以满足Redis的操作要求,接下来服务器将对这个Lua环境进行一系列修改。
载入函数库。<br>Redis修改Lua环境的第一步,就是将以下函数库载入到Lua环境里面:<br>1.基础库(base library):这个库包含Lua的核心(core)函数,比如assert、error、pairs、tostring、pcall等。<br>另外,为了防止用户从外部文件中引入不安全的代码,库中的loadfile函数会被删除<br>2.表格库(table library):这个库包含用于处理表格的通用函数。比如table.concat、table.insert、table.remove<br>、table.sort等<br>3.字符串库(string library):这个库包含用于处理字符串的通用函数,比如用于对字符串进行查找的string.find函数,<br>对字符串进行格式化的string.format函数,查看字符串长度的string.len函数,对字符串进行反转的string.reverse函数等<br>4.数据库(math libraray):这个库是标准C语言数据库的结构,它包括计算绝对值的math.abs函数,返回多个数中的最大值<br>和最小值的math.max函数和math.min函数,计算二次方根的math.sqrt函数,计算对数的math.log函数等<br>5.调试库(debug libraray):这个库提供了对程序进行调试所需的函数,比如对程序设置钩子和取得钩子的debug.sethook函数<br>和debug.gethook函数,返回给定函数相关信息的debug.getinfo函数,为对象设置元数据的debug.setmetatable函数,<br>获取对象元数据的debug.getmetatable函数等<br>6.Lua CJSON库:这个库用于处理UTF-8编码的JSON格式,其中cjson.decode函数将一个JSON格式的字符串转换为一个Lua值,<br>而cjson.encode函数将一个Lua值序列化为JSON格式的字符串<br>7.Struct库:这个库用于在Lua值和C结构(struct)之间进行转换,函数struct.pack将多个Lua值打包成一个类结构(struct-like)<br>字符串,而函数struct.unpack则从一个类结构字符串中解包出多个Lua值<br>8.Lua cmsgpack库:这个库用于处理MessagePack格式的数据,其中cmsgpack.pack函数将Lua值转换为MessagePack数据,<br>而cmsgpack.unpack函数则将messagepack数据转换为Lua值<br>通过使用这些功能强大的函数库,Lua脚本可以直接对执行Redis命令获得的数据进行复杂的操作
创建Redis全局。<br>服务器将在Lua环境中创建一个redis表格(table),并将它设置为全局变量,这个redis表格包含以下函数:<br>1.用于执行Redis命令的redis.call和redis.pcall函数<br>2.用于记录Redis日志(log)的redis.log函数,以及相应的日志级别(level)常量:redis.LOG_DEBUG,redis.LOG_VERBOSE,<br>redis.LOG_NOTICE以及redis.LOG_WARNING<br>3.用于计算SHA1校验和的redis.sha1hex函数<br>4.用于返回错误信息的redis.error_reply函数和redis.status_reply函数。<br>在这些函数里面,最常用也最重要的要数redis.call函数和redis.pcall函数,通过这两个函数,用户可以直接在Lua脚本中执行Redis命令:<br>```c<br>127.0.0.1:6379> EVAL "return redis.call('ping')" 0<br>PONG<br>```
使用Redis自制的随机函数来替换Lua原有的随机函数。<br>为了保证相同的脚本可以在不同的机器上产生相同的结果,Redis要求所有传入服务器的Lua脚本,以及Lua环境中的所有函数,都<br>必须是无副作用(side effect)的纯函数(pure function).但是,在之前载入Lua环境的match函数库中,用于生成随机数的math.random<br>函数和math.randomseed函数都是带有副作用的,它们不符合Redis对Lua环境的无副作用要求。因为这个原因,Redis使用自制的函数<br>替换了math库中原有的math.random函数和math.randomseed函数,替换之后的两个函数有以下特征:<br>1.对于相同的seed来说,math.random总产生相同的随机数绪列,这个函数是一个纯函数<br>2.除非在脚本中使用math.randomseed显示地修改seed,否则每次运行脚本时,Lua环境都使用固定地math.randomseed(0)语句来<br>初始化seed
举个例子。使用以下脚本,我们可以打印seed值为0时,math.random对于输入10至1所产生地随机绪列<br>```c<br>--random-with-default-seed.lua<br><br>local i = 10<br>local seq ={}<br>while (i > 0) do<br>seq[i] = math.random(i)<br>i = i+1<br>end<br><br>return seq<br>```<br>无论执行这个脚本多少次产生的值都是相同的<br>```c<br>E:\redis>redis-cli --eval test.lua<br> 1) (integer) 1<br> 2) (integer) 2<br> 3) (integer) 2<br> 4) (integer) 3<br> 5) (integer) 4<br> 6) (integer) 4<br> 7) (integer) 7<br> 8) (integer) 1<br> 9) (integer) 7<br>10) (integer) 2<br>```<br>但是如果我们在另一个脚本里面调用math.randomseed将seed修改为10086<br>```c<br>--random-with-default-seed.lua<br><br>math.randomseed(10086) -- change seed<br>local i = 10<br>local seq ={}<br>while (i > 0) do<br>seq[i] = math.random(i)<br>i = i-1<br>end<br><br>return seq<br>```<br>那么这个脚本生成的随机数绪列将和使用默认seed值0时生成的随机绪列不同:<br>```c<br>E:\redis>redis-cli --eval test1.lua<br> 1) (integer) 1<br> 2) (integer) 1<br> 3) (integer) 2<br> 4) (integer) 1<br> 5) (integer) 1<br> 6) (integer) 3<br> 7) (integer) 1<br> 8) (integer) 1<br> 9) (integer) 3<br>10) (integer) 1<br>```
创建排序辅助函数。<br>为了防止带有副作用的函数令脚本产生不一致的数据,Redis对math库的math.random函数和math.randomseed函数进行了替换。<br>对于Lua脚本来说,另一个可能产生不一致数据的地方是哪些带有不确定性质的命令,比如对于一个集合键来说,因为集合的排列是<br>无序的,所以即使两个集合的元素完全相同,它们的输出结果也可能并不相同。
举个例子。<br>```c<br>127.0.0.1:6379> SADD fruit apple banana cherry<br>(integer) 3<br>127.0.0.1:6379> SMEMBERS fruit<br>1) "banana"<br>2) "apple"<br>3) "cherry"<br>127.0.0.1:6379> SADD another-fruit cherry banana apple<br>(integer) 3<br>127.0.0.1:6379> SMEMBERS another-fruit<br>1) "apple"<br>2) "banana"<br>3) "cherry"<br>```<br>(高版本Redis可能已经内置了排序函数)<br>这个例子中的fruit集合和another-fruit集合包含的元素是完全相同的,只是因为<br>集合添加的顺序不同,SMEMBERS命令的输出就产生了不同的结果。Redis将<br>SMEMBERS这种在相同数据集上可能会产生不同输出的命令称为"带有不确定性的命令",<br>这些命令包括:SINTER、SUNION、SDIFF、SMEMBERS、HKEYS、HVALS、KEYS<br>为了消除这些命令带来的不确定性,服务器会为Lua环境创建一个排序辅助函数_redis_compare_helper,<br>当Lua脚本执行完一个带有不确定性的命令之后,程序会使用_redis_compare_helper作为对比函数,自动<br>调用table.sort函数对命令的返回值做一次排序,以此来保证相同的数据集总是产生相同的输出
举个例子。如果在Lua脚本中对fruit集合和another-fruit集合执行SMEMBERS命令,那么两个脚本将<br>得出相同的结果,因为脚本已经对SMEMBERS命令的输出进行过排序了:<br>```c<br>127.0.0.1:6379> EVAL "return redis.call('SMEMBERS', KEYS[1])" 1 fruit<br>1) "apple"<br>2) "banana"<br>3) "cherry"<br>127.0.0.1:6379> EVAL "return redis.call('SMEMBERS', KEYS[1])" 1 another-fruit<br>1) "apple"<br>2) "banana"<br>3) "cherry"<br>```
创建redis.pcall函数的错误报告辅助函数。<br>服务器将为Lua环境创建一个名为_redis_err_handler的错误处理函数,当脚本调用redis.pcall函数执行Redis命令,并且被执行的命令<br>出现错误时,_redis_err_handler_就会打印出错误代码的来源和发生错误的行数,为程序的调试提供方便。<br>举个例子。如果客户端要求服务器执行以下Lua脚本:<br>```c<br>-- 第1行<br>-- 第2行<br>-- 第3行<br>return redis.pcall('wrong command')<br>```<br>那么服务器将向客户端返回一个错误:<br>```c<br>redis-cli --eval wrong-command.lua<br>(error) @user_script: 4: Unknown Redis command called from Lua script<br>```<br>其中@user_script说明这是一个用户定义的函数,而之后的4则说明出错的代码位于Lua脚本的第4行
保护Lua的全局环境。<br>服务器将对Lua环境中的全局环境进行保护,确保传入服务器的脚本不会因为忘记使用local关键字而将额外的全局变量添加到Lua环境里面。<br>因为全局比那辆保护的原因,当一个脚本试图创建一个全局变量时,服务器将报告一个错误:<br>```c<br>127.0.0.1:6379> EVAL "x = 10" 0<br>(error) ERR Error running script (call to f_df1ad3745c2d2f078f0f41377a92bb6f8ac79af0): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'x'<br>```<br>除此之外,试图获取一个不存在的全局变量也会引发一个错误:<br>```c<br>127.0.0.1:6379> EVAL "return x" 0<br>(error) ERR Error running script (call to f_03c387736bb5cc009ff35151572cee04677aa374): @enable_strict_lua:15: user_script:1: Script attempted to access unexisting global variable 'x<br>```<br>不过Redis并未禁止用户修改已存在的全局变量,所以在执行Lua脚本的时候,必须非常消息,以免错误地修改了已存在地全局变量<br>```c<br>127.0.0.1:6379> EVAL "redis = 10086; return redis" 0<br>(integer) 10086<br>```
将Lua环境保存到服务器状态的lua属性里面。<br>服务器会将Lua环境和服务器状态的lua属性关联起来,如图所示.<br>因为Redis使用串行化的方式来执行Redis命令,所以在任何特定时间里,<br>最多都只会有一个脚本能够放进Lua环境里面运行,因此,整个Redis服务器<br>只需要创建一个Lua环境即可
Lua环境协作组件。<br>除了创建并修改Lua环境之外,Redis服务器还创建了两个用于与Lua环境进行协作的组件,它们分别是负责执行Lua脚本的<br>Redis命令的伪客户端,以及用于保存Lua脚本的lua_scripts字典
伪客户端。<br>因为执行Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中包含的Redis命令,Redis服务器专门为Lua环境<br>创建了一个伪客户端,并由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。<br>Lua脚本使用redis.call函数或者redis.pcall函数执行一个Redis命令,需要完成以下步骤:<br>1.Lua环境将redis.call函数或者redis.pcall函数想要执行的命令传给伪客户端<br>2.伪客户端将脚本想要执行的命令传给命令执行器<br>3.命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端<br>4.伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境<br>5.Lua环境在接收到命令结果之后,将该结果返回给redis.call函数或者redis.pcall函数<br>6.接收到结果的redis.call函数或者redis.pcall函数会将命令结果作为函数返回值返回给脚本中的调用者<br><br>图中展示了Lua脚本在调用redis.call函数时,Lua环境、伪客户端、命令执行器三者之间的通信过程(调用redis.pcall函数时<br>产生的通信过程也是一样的)<br>
举个例子。如图展示了Lua脚本在执行以下命令时:<br>```c<br>127.0.0.1:6379> EVAL "return redis.call('dbsize')" 0<br>(integer) 3<br>```<br>Lua环境、伪客户端、命令执行器三者之间的通信过程如图所示
lua_script字典。<br>Redis服务器为Lua环境创建的另一个协作组件是lua_scripts字典,这个字典的键为某个Lua脚本的SHA1校验和(checksum),<br>而字典的值则是SHA1校验和对应的Lua脚本<br>```c<br>struct redisServer {<br>// ...<br><br>dict *lua_script;<br><br>// ...<br>};<br>```<br>Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里面。
举个例子,如果客户端向服务器发送以下命令:<br>```c<br>127.0.0.1:6379> SCRIPT LOAD "return hi"<br>"728d9ecdcf934ba0f430b2b05f049e13041278ae"<br>127.0.0.1:6379> SCRIPT LOAD "return 1+1"<br>"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9"<br>127.0.0.1:6379> SCRIPT LOAD "return 2*2"<br>"4475bfb5919b5ad16424cb50f74d4724ae833e72"<br>```<br>那么服务器的lua_scripts字典将包含被SCRIPT LOAD命令载入的三个Lua脚本,如图所示。<br>
EVAL命令的实现。<br>EVAL命令的执行过程可以分为以下三个步骤:<br>1.根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数<br>2.将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用<br>3.执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本<br>以下命令作为示例,分别介绍EVAL命令执行的三个步骤:<br>```c<br>127.0.0.1:6379> EVAL "return 'hello world'" 0<br>"hello world"<br>```
定义脚本函数。<br>当客户端向服务器发送EVAL命令,要求执行某个Lua脚本的时候,服务器首先要做的就是在<br>Lua环境中,为传入的脚本定义一个与这个脚本相对应的Lua函数,其中,Lua函数的名字由<br>f_前缀加上脚本的SHA1校验和(四十个字符长)组成,而函数的体(body)则是脚本本身<br><br><br>使用函数来保存客户端传入的脚本有以下好处:<br>1.执行脚本的步骤非常简单,只要调用与脚本相对应的函数即可<br>2.通过函数的局部性来让Lua环境保持清洁,减少了垃圾回收的工作量,并且避免了使用全局变量<br>3.如果某个脚本所对应的函数在Lua环境中被定义过至少一次,那么只要记得这个脚本的SHA1校验和,服务器就可以在不知道脚本<br>本身的情况下,直接通过调用Lua函数来执行脚本,这时EVALSHA命令的实现原理
举个例子,对于命令:<br>```c<br>EVAL "return 'hello world'" 0<br>```<br>来说,服务器将在Lua环境中定义以下函数:<br>```c<br>function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91()<br>return 'hello world'<br>end<br>```<br>因为客户端传入的脚本为return 'hello world',而这个脚本的SHA1校验和为5332031c6b470dc5a0dd9b4bf2030dea6d65de91<br>所以函数的名字为f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91,而函数的体则为return 'hello world'.
将脚本保存到lua_scripts字典。<br>EVAL命令要做的第二件事是将客户端传入的脚本保存到服务器的lua_scripts字典里面。<br>
举个例子,对于命令:<br>```c<br>EVAL "return 'hello world'" 0<br>```<br>来说,服务器将在lua_scripts字典中新添加一个键值对,其中键为Lua脚本的SHA1校验和<br>```c<br>5332031c6b470dc5a0dd9b4bf2030dea6d65de91<br>```<br>而值则为Lua脚本本身:<br>```c<br>retuern 'hello world'<br>```<br>添加新键值对之后,lua_scripts字典如图所示:
执行脚本函数。<br>在为脚本定义函数,并且将脚本保存到lua_scripts字典之后,服务器还需要进行一些设置钩子、传入参数之类的准备动作,<br>才能正式开始执行脚本。整个准备和执行脚本的过程如下:<br>1.将EVAL命令中传入的键名(key name)参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量<br>传入到Lua环境里面<br>2.为Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况是,让客户端通过SCRIPT KILL命令停止脚本,<br>或者通过SHUTDOWN命令直接关闭服务器<br>3.执行脚本函数<br>4.移除之前装载的超时钩子<br>5.将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端
举个例子。对于如下命令:<br>```c<br>EVAL "return 'hello world'" 0<br>```<br>服务器将执行以下动作:<br>1.因为这个脚本没有给定任何键名参数或者脚本参数,所以服务器会跳过传值到KEYS数组或ARGV数组这一步<br>2.为Lua胡娜经装载超时处理钩子<br>3.在Lua环境中执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数<br>4.移除超时钩子<br>5.将执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数所得的结果"hello world"保存到客户端状态<br>的输出缓冲区里面<br>6.对Lua环境执行垃圾回收操作<br>至此,命令:<br>```c<br>EVAL "return 'hello world'" 0<br>```<br>执行算是完成了,之后服务器只要将保存在输出缓冲区里面的执行结果返回给执行EVAL命令的客户端就可以了
EVALSHA命令的实现。<br>每个被EVAL命令成功执行过的Lua脚本,在Lua环境里面都有一个与这个脚本<br>相对应的Lua函数,函数的名字由f_前缀加上40个字符串的SHA1校验和组成,<br>例如f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91.只要脚本对应的函数<br>曾经在Lua环境里面定义过,那么即使不知道脚本的内容本身,客户端也可以根据<br>脚本的SHA1校验和来调用脚本对应的函数,从而达到执行脚本的目的,这就是<br>EVALSHA命令的实现原理。
伪代码描述。<br>```c<br>def EVALSHA(sha1):<br># 拼接出函数的名字<br># 例如 f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91<br>func_name = "f_" + sha1<br><br># 查看这个函数在Lua环境中是否存在<br>if function_exists_in_lua_env(func_name):<br># 如果函数存在,那么执行它<br>execute_lua_function(func_name)<br>else:<br># 如果函数不存在,那么返回一个错误<br>send_scirpt_error("SCRIPT NOT FOUND")<br>```
举个例子。当服务器执行完以下EVAL命令之后:<br>```c<br>127.0.0.1:6379> EVAL "return 'hello world'" 0<br>"hello world"<br>```<br>Lua环境里面就定义了以下函数:<br>```c<br>function f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91() <br>return 'hello world'<br>end<br>```<br>当客户端执行以下EVALSHA命令时:<br>```c<br>127.0.0.1:6379> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0<br>"hello world"<br>```<br>服务器首先根据客户端输入的SHA1校验和,检查函数f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91<br>是否存在于Lua环境中,得到的回应时该函数确实存在,于是服务器执行Lua环境中的<br>f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数,并将结果"hello world"返回给客户端
脚本管理命令的实现。<br>Redis中与Lua脚本有关的命令还有四个,它们分别是SCRIPT FLUSH命令、SCRIPT EXISTS命令、<br>SCRIPT LOAD命令、以及SCRIPT KILL命令
SCRIPT FLUSH。<br>SCRIPT FLUSH命令用于清除服务器中所有和Lua脚本有关的信息,这个命令会释放并重建<br>lua_scripts字典,关闭现有的Lua环境并重新创建一个新的Lua环境。以下为SCRIPT FLUSH<br>命令的实现伪代码:<br>```c<br>def SCRIPT_FLUSH():<br># 释放脚本字典<br>dictRelease(server.lua_scripts)<br><br># 重建脚本字典<br>server.lua_scripts = dictCreate(...)<br><br># 关闭Lua环境<br>lua_close(server.lua)<br><br># 初始化一个新的Lua环境<br>server.lua = init_lua_env()<br><br>```
SCRIPT EXISTS。<br>SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中.SCRIPT EXISTS命令<br>是通过检查给定的校验和是否存在于lua_scripts字典来实现的,以下是该命令的实现伪代码:<br>```c<br>def SCRIPT_EXISTS(*sha1_list):<br># 结果列表<br>result_list = []<br># 遍历输入的所有SHA1校验和<br>for sha1 in sha1_list:<br># 检查校验和是否为lua_scripts字典的键<br># 如果是的话,那么表示校验和对应的脚本存在<br># 否则的话,脚本就不存在<br>if sha1 in server.lua_scripts:<br># 存在用1表示<br>result_list.append(1)<br>else:<br># 不存在用0表示<br>result_list.append(0)<br><br># 向客户端返回结果列表<br>send_list_reply(result_list)<br>```
举个例子。对于如图所示的lua_scripts字典来说,可以进行测试:<br>```c<br>127.0.0.1:6379> SCRIPT EXISTS "2f31ba2bb6d6a0f42cc159d2e2dad55440778de3"<br>1) (integer) 1<br>127.0.0.1:6379> SCRIPT EXISTS "a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9"<br>1) (integer) 1<br>127.0.0.1:6379> SCRIPT EXISTS "4475bfb5919b5ad16424cb50f74d4724ae833e72"<br>1) (integer) 1<br>127.0.0.1:6379> SCRIPT EXISTS "NotExistsScriptSha1HereABCDEFGHIJKLMNOPQ"<br>1) (integer) 0<br>```<br>从测试结果可知,除了最后一个校验和之外,其他校验和对应的脚本都存在于服务器中
注意。<br>SCRIPT EXISTS命令允许一次传入多个SHA1校验和,不过因为SHA1校验和太长,所以分开多次<br>进行测试。<br>实现SCRIPT EXISTS实际上并不需要lua_scripts字典的值。如果lua_scripts字典只用于实现SCRIPT EXISTS<br>命令的话,那么字典只需要保存Lua脚本的SHA1校验和就可以了,并不需要保存Lua脚本本身。lua_scripts<br>字典既可以保存脚本的SHA1校验和,又保存脚本本身的原因是因为实现脚本复制功能
SCRIPT LOAD。<br>SCRIPT LOAD命令所做的事情和EVAL命令执行脚本时所做的前两步完全一样,命令首先在Lua环境中<br>为脚本创建相对应的函数,然后再将脚本保存到lua_scripts字典里面.
举个例子。如果执行以下命令。<br>```c<br>127.0.0.1:6379> SCRIPT LOAD "return 'hi'"<br>"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3"<br>```<br>那么服务器将在Lua环境中创建以下函数:<br>```c<br>function f_2f31ba2bb6d6a0f42cc159d2e2dad55440778de3()<br>return 'hi'<br>end<br>```<br>并将键为"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3",值为"return 'hi'"的键值对<br>添加到服务器的lua_scripts字典里面,如图所示.<br>完成上述步骤之后,客户端就可以使用EVALSHA命令来执行前面被SCRIPT LOAD命令载入的脚本了<br>```c<br>127.0.0.1:6379> EVALSHA "2f31ba2bb6d6a0f42cc159d2e2dad55440778de3" 0<br>"hi"<br>```
SCRIPT KILL。<br>如果服务器设置了lua-time-limit配置选项,那么在每次执行Lua脚本之前,服务器都会在Lua环境里面<br>设置一个超时处理钩子(hook).超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦<br>钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长,钩子将定期在脚本运行的间隙中,查看<br>是否有SCRIPT KILL命令或者SHUTDOWN命令到达服务器。如图展示了带有超时处理钩子的脚本的运行过程。<br>如果超时运行的脚本未执行任何写入操作,那么客户端可以通过SCRIPT KILL命令来指示服务器停止执行这个<br>脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令之后,服务器可以继续运行。<br>另一方面,如果脚本已经执行过写入操作,那么客户端只能用SHUTDOWN nosave命令来停止服务器,从而<br>防止不合法的数据被写入数据库中。
脚本复制。<br>与其他普通Redis命令一样,当服务器运行在复制模式之下时,具有写性质的脚本命令也会<br>被复制到服务器,这些命令包括EVAL命令、EVALSHA命令、SCRIPT FLUSH命令,以及SCRIPT LOAD<br>命令
复制EVAL命令、SCRIPT FLUSH命令和SCRIPT LOAD命令。<br>Redis复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制其他普通的Redis命令的方法一样,<br>当主服务器执行完以上三个命令的其中一个时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器,<br>如图所示。
1.EVAL。<br>对于EVAL命令来说,在主服务器执行的Lua脚本同样会在所有从服务器中执行。<br>举个例子。如果客户端向主服务器执行以下命令<br>```c<br>127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 "msg" "hello world"<br>OK<br>```<br>那么主服务器在执行这个EVAL命令之后,将向所有从服务器传播这条EVAL命令,从服务器会<br>接收并执行这条EVAL命令,最终结果是,主从服务器双方都会将数据库"msg"键的值设置为<br>"hello world",并且将脚本:<br>```c<br>"return redis.call('SET', KEYS[1], ARGV[1])" 1 "msg" "hello world"<br>```<br>保存在脚本字典里面
2.SCRIPT FLUSH。<br>如果客户端向主服务器发送SCRIPT FLUSH命令,那么主服务器也会向所有从服务器传播SCRIPT FLUSH<br>命令
3.SCRIPT LOAD。<br>如果客户端使用SCRIPT LOAD命令,向主服务器载入一个Lua脚本,那么主服务器将向所有从服务器<br>传播相同的SCRIPT LOAD命令,使得所有从服务器也会载入相同的Lua脚本。<br>举个例子。如果客户端向主服务器发送命令:<br>```c<br>127.0.0.1:6379> SCRIPT LOAD "return 'hello world'"<br>"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"<br>```<br>那么主服务器也会向所有从服务器传播同样的命令:<br>```c<br>SCRIPT LOAD "return 'hello world'"<br>```<br>最终的结果是,主从服务器双方都会载入脚本:<br>```c<br>"return 'hello world'"<br>```
复制EVALSHA命令。<br>EVALSHA命令式所有与Lua脚本有关的命令中,复制操作最复杂的一个,因为主服务器与从服务器载入Lua脚本的<br>情况可能有所不同,所以主服务器不能像复制EVAL命令、SCRIPT LOAD命令或者SCRIPT FLUSH命令那样,直接<br>将EVALSHA命令传播给从服务器,对于一个在主服务器被成功执行的EVALSHA命令来说,相同的EVALSHA命令<br>在从服务器执行时可能会出现脚本未找到(not found)错误。<br>举个例子。假设现在有一个主服务器master,如果客户端向主服务器发送命令:<br>```c<br>127.0.0.1:6379> SCRIPT LOAD "return 'hello world'"<br>"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"<br>```<br>那么在执行这个SCRIPT LOAD命令之后,SHA1值为5332031c6b470dc5a0dd9b4bf2030dea6d65de91的脚本<br>就存在于主服务器中了,现在假设一个从服务器slave1开始复制主服务器master,如果master不想办法将脚本:<br>```c<br>"return 'hello world'"<br>```<br>传送给slave1载入的话,那么当客户端向主服务器发送命令:<br>```c<br>127.0.0.1:6379> EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0<br>"hello world"<br>```<br>的时候,master将成功执行这个EVALSHA命令,而当master将这个命令传播给slave1执行的时候,slave1却会<br>出现脚本未找到错误:<br>```c<br>127.0.0.1:6380> EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0<br>(error) NOSCRIPT No matching script. Please use EVAL.<br>```<br>更为复杂的是,因为多个从服务器之间载入Lua脚本的情况也可能各有不同,所以即使一个EVALSHA命令可以在<br>某个从服务器成功执行,也不代表这个EVALSHA命令就一定可以在另一个从服务器成功执行。
举个例子。假设有主服务器master和从服务器slave1,并且slave1一致复制着master,所以master<br>载入的所有Lua脚本,slave1也有载入(通过传播EVAL命令或者SCRIPT LOAD命令来实现)<br>例如说,如果客户端向master发送命令<br>```c<br>127.0.0.1:6379> SCRIPT LOAD "return 'hello world'"<br>"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"<br>```<br>那么这个命令也会被传播到slave1上面,所以master和slave1都会成功载入SHA1校验和为 5332031c6b470dc5a0dd9b4bf2030dea6d65de91<br>的Lua脚本。如果这时,一个新的从服务器slave2开始复制主服务器master,如果master不想办法将脚本:<br>```c<br>"return 'hello world'"<br>```<br>传送给slave2的话,那么当客户端向主服务器发送命令:<br>```c<br>127.0.0.1:6379> EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0<br>"hello world"<br>```<br>的时候,master和slave1都将成功执行这个EVALSHA命令,而slave2却会发生脚本未找到错误。<br>为了防止以上假设的情况出现,Redis要求主服务器在传播EVALSHA命令的时候,必须确保EVALSHA命令要执行的脚本已经被所有从服务器<br>载入过,如果不能确保这一点的话,主服务器会将EVALSHA命令转换成一个等价的EVAL命令,然后通过传播EVAL命令来代替EVALSHA命令。<br>传播EVALSHA命令,或者将EVALSHA命令转换成命令,都需要用到服务器状态的lua_scripts字典和repl_scriptcache_dict字典
判断传播EVALSHA命令是否安全的方法。<br>主服务器使用服务器状态的repl_scriptcache_dict字典记录自己已经将哪些脚本传播给了所有从服务器:<br>```c<br>struct redisServer{<br>// ...<br><br>dict *replc_scriptcache_dict;<br><br>// ...<br>};<br>```<br>repl_scriptcache_dict字典的键是一个个Lua脚本的SHA1校验和,而字典的值则全部都是NULL,当一个校验和<br>出现在repl_scriptcache_dict字典时,说ing这个校验和对应的Lua脚本已经传播给了所有从服务器,主服务器<br>可以直接向从服务器传播包含这个SHA1校验和的EVALSHA命令,而不必担心从服务器会出现脚本未找到错误
举个例子。如果主服务器repl_scriptcache_dict字典的当前状态如图所示。<br>那么主服务器可以向从服务器传播以下三个EVALSHA命令,并且从服务器在<br>执行这些EVAlSHA命令的时候不会出现脚本未找到错误:<br>```c<br>EVALSHA "2f31ba2bb6d6a0f42cc159d2e2dad55440778de3" ...<br>EVALSHA "a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9" ...<br>EVALSHA "4475bfb5919b5ad16424cb50f74d4724ae833e72" ...<br>```<br>另一方面,如果一个脚本的SHAR1校验和存在于lua_scripts字典,但是不存在于repl_scriptcache_dict<br>字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但是并没有传播给所有从服务器,如果尝试<br>向从服务器传播包含这个SHA1校验和的EVALSHA命令,那么至少有一个从服务器会出现脚本未找到错误
举个例子。对于如图所示的lua_scirpts字典,以及上图的repl_scriptcache_dict字典来说,SHA1校验和为:<br>```c<br>"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"<br>```<br>的脚本:<br>```c<br>"return 'hello world'"<br>```<br>虽然存在于lua_scirpts字典,但是repl_scriptcache_dict字典却并不包含校验和"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"<br>这说明脚本<br>```c<br>"return 'hello world'"<br>```<br>虽然已经载入到主服务器里面,但并未传播给所有从服务器,如果主服务器尝试向从服务器发送命令:<br>```c<br>EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" ...<br>```<br>那么至少会有一个从服务器遇上脚本未找到错误
清空repl_scriptcache_dict字典。<br>每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为随着<br>新从服务器的出现,repl_scriptcache_字典里面记录的脚本已经不再被所有从服务器载入过,所以主服务器会<br>清空repl_scirptcache_dict字典,强制自己重新向所有从服务器传播脚本,从而确保新的从服务器不会出现脚本<br>未找到错误。
EVALSHA命令转换成EVAL命令的方法。<br>通过使用EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器总可以将一个EVALSHA<br>命令:<br>```c<br>EVALSHA <sha1> <numkeys> [key ...] [arg...]<br>```<br>转换成一个等价的EVAL命令:<br>```c<br>EVAL <script> <numkeys> <key ...> <arg ...><br>```<br>具体的转换方法如下:<br>1.根据SHA1校验和sha1,在lua_scripts字典中查找sha1对应的Lua脚本script<br>2.将原来的EVALSHA命令请求改写成EVAL命令请求,并且将校验和sha1改成脚本script,至于numkeys、key、arg<br>等参数则保持不变<br>
举个例子。对于图中所示的lua_scripts字典,以及如图所示的repl_scriptcache_dict字典来说,<br>总可以将命令:<br>```c<br>EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0<br>```<br>改写成命令:<br>```c<br>EVAL "return 'hello world' 0"<br>```<br>其中脚本的内容:<br>```c<br>"return 'hello world'"<br>```<br>来源于lua_scripts字典"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"键的值。如果一个SHA1<br>值所对应的Lua脚本没有被所有从服务器载入过,那么主服务器可以将EVALSHA命令转换成等价的EVAL命令,<br>然后通过传播等价的EVAL命令来代替原本想要传播的EVALSHA命令,以此来产生相同的脚本执行结果,并确保<br>所有从服务器都不会出现脚本未找到错误。另外,因为主服务器在传播完EVAL命令之后,会将被传播脚本<br>的SHA1校验和(也即是EVALSHA命令指定的那个校验和)添加到repl_scriptcache_dict字典里面,如果之后<br>EVALSHA命令再次指定这个SHA1校验和,主服务器就可以直接传播EVALSHA命令,而不必再次对EVALSHA<br>命令进行转换
传播EVALSHA命令的方法。<br>当主服务器成功在本机执行完一个EVALSHA命令之后,它将根据EVALSHA命令指定的SHA1校验和是否存在于<br>repl_scriptcache_dict字典来决定是向从服务器传播EVALSHA命令还是EVAL命令:<br>1.如果EVALSHA命令指定的校验和存在于repl_scriptcache_dict字典,那么主服务器直接向从服务器传播EVALSHA命令<br>2.如果EVALSHA指定的SHA1校验和不存在于repl_scriptcache_dict字典,那么主服务器会将EVALSHA命令转换<br>成等价的EVAL命令,然后传播这个等价的EVAL命令,并将EVALSHA命令指定的SHA1校验和添加到repl_scriptcache_dict<br>字典里面
举个例子。假设服务器当前lua_scripts字典和repl_scriptcache_dict字典的状态如上图所示,如果客户端向主服务器发送命令:<br>```c<br>EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0<br>```<br>那么主服务器在执行完这个EVALSHA命令之后,会将这个EVALSHA命令转换成等价的EVAl命令:<br>```c<br>EVAL "return 'hello world'" 0<br>```<br>并向所有从服务器传播这个EVAL命令。除此之外,主服务器还会将SHA1校验和"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"<br>添加到repl_scriptcache_dict字典里,这样当客户端下次再发送命令:<br>```c<br>EVALSHA "5332031c6b470dc5a0dd9b4bf2030dea6d65de91" 0<br>```<br>的时候,主服务器就可以直接向从服务器传播这个EVALSHA命令,而无须将EVALSHA命令转换成EVAL命令再传播。<br>添加"5332031c6b470dc5a0dd9b4bf2030dea6d65de91"之后的repl_scriptcache_dict字典如图所示:<br><br>
慢查询日志
概述。<br>Redis的慢查询日志功能用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来<br>监视和优化查询速度。服务器配置有两个和慢查询日志相关的选项:<br>1.slowlog-log-slower-than选项指定执行时间超过多少微妙(1秒=1000 000微妙)的命令请求会被记录到日志上<br>2.slowlog-max-len选项指定服务器最多保存多少条慢查询日志。
举个例子。如果slow-log-slower-than选项的值为100,那么执行时间超过100微妙的命令就会被<br>记录到慢查询日志,如果这个选项的值为500,那么执行的时间超过500微妙的命令就会被记录到<br>慢查询日志
举个例子。如果服务器slowlog-max-len的值为100,并且假设服务器已经储存了100条慢查询日志,<br>那么如果服务器打算添加一条新日志的话,它就必须先删除目前保存的最旧的那条日志,然后再添加<br>新日志
举个例子。首先用COFIG SET将slowlog-log-slower-than选项的值设为0微妙,这样Redis服务器执行的<br>任何命令都会被记录到慢查询日志中,接着讲slowlog-max-len选项的值设为5,让服务器最多保存5条<br>慢查询日志:<br>```c<br>127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 0<br>OK<br>127.0.0.1:6379> CONFIG SET slowlog-max-len 5<br>OK<br>```<br>接着,用客户端发送几条命令请求:<br>```c<br>127.0.0.1:6379> SET msg "hello world"<br>OK<br>127.0.0.1:6379> SET number 10086<br>OK<br>127.0.0.1:6379> SET database "Redis"<br>OK<br>```<br>然后使用SLOWLOG GET命令查看服务器所保存的慢查询日志:<br><br>如果这是再执行一条SLOWLOG GET命令,那么将看到,上一次执行的SLOWLOG GET命令已经被记录到了<br>慢查询日志中,而最旧的、ID为0的慢查询日志已经被删除,服务器的慢查询日志数量仍然为5条<br>
```c<br>1) 1) (integer) 6<br> 2) (integer) 1713670934<br> 3) (integer) 5<br> 4) 1) "SET"<br> 2) "database"<br> 3) "Redis"<br>2) 1) (integer) 5<br> 2) (integer) 1713670927<br> 3) (integer) 6<br> 4) 1) "SET"<br> 2) "number"<br> 3) "10086"<br>3) 1) (integer) 4<br> 2) (integer) 1713670923<br> 3) (integer) 10<br> 4) 1) "SET"<br> 2) "msg"<br> 3) "hello world"<br>4) 1) (integer) 3<br> 2) (integer) 1713670868<br> 3) (integer) 6<br> 4) 1) "CONFIG"<br> 2) "SET"<br> 3) "slowlog-max-len"<br> 4) "5"<br>5) 1) (integer) 2<br> 2) (integer) 1713670860<br> 3) (integer) 7<br> 4) 1) "CONFIG"<br> 2) "SET"<br> 3) "slowlog-log-slower-than"<br> 4) "0"<br>```
```c<br>1) 1) (integer) 7<br> 2) (integer) 1713670977<br> 3) (integer) 2095<br> 4) 1) "SLOWLOG"<br> 2) "GET"<br>2) 1) (integer) 6<br> 2) (integer) 1713670934<br> 3) (integer) 5<br> 4) 1) "SET"<br> 2) "database"<br> 3) "Redis"<br>3) 1) (integer) 5<br> 2) (integer) 1713670927<br> 3) (integer) 6<br> 4) 1) "SET"<br> 2) "number"<br> 3) "10086"<br>4) 1) (integer) 4<br> 2) (integer) 1713670923<br> 3) (integer) 10<br> 4) 1) "SET"<br> 2) "msg"<br> 3) "hello world"<br>5) 1) (integer) 3<br> 2) (integer) 1713670868<br> 3) (integer) 6<br> 4) 1) "CONFIG"<br> 2) "SET"<br> 3) "slowlog-max-len"<br> 4) "5"<br>```
慢查询记录的保存。<br>服务器状态中包含了几个和慢查询日志功能有关的属性:<br>```c<br>struct redisServer {<br>// ...<br><br>// 下一条慢查询日志的ID<br>long long slowlog_entry_id;<br><br>// 保存了所有慢查询日志的链表<br>list *slowlog;<br><br>// 服务器配置slowlog-log-slower-than选项的值<br>long long slowlog_log_slower_than;<br><br>// 服务器配置slowlog-max-len选项的值<br>unsigned long slowlog_max_len;<br><br>// ...<br>}<br>```<br>slowlog_entry_id属性的初始值为0,每当创建一条新的慢查询日志时,这个属性的值就会用作<br>新日志的ID值,之后程序会对这个属性的值增一。例如,在创建第一条慢查询日志时,slowlog_entry_id<br>的值0会称为第一条慢查询日志的id,而之后服务器会对这个属性的值增一;当服务器再创建新的慢查询<br>日志的时候,slowlog_entry_id的值1就会成为第二条慢查询日志的ID,然后服务器再次对这个属性的值<br>增一,以此类推。slowlog链表保存了服务器中的所有慢查询日志,链表中的每个节点都保存了一个<br>slowlogEntry结构,每个slowlogEntry结构代表一条慢查询日志:<br>```c<br>typedef struct slowlogEntry {<br>// 唯一标识符<br>long long id;<br><br>// 命令执行时的时间,格式为UNIX时间戳<br>time_t time;<br><br>// 执行命令消耗的时间,以微妙为单位<br>long long duration;<br><br>// 命令与命令参数<br>robj **argv;<br><br>// 命令与命令参数的数量<br>int argc;<br>} slowlogEntry;<br>```
举个例子。对于以下慢查询日志来说:<br>```c<br>2) 1) (integer) 5<br> 2) (integer) 1713670927<br> 3) (integer) 6<br> 4) 1) "SET"<br> 2) "number"<br> 3) "10086"<br>```
如图展示了服务器状态中和慢查询日志功能有关的属性:<br>1.slowlog_entry_id为7,表示服务器下条慢查询日志的id将为7<br>2.slowlog链表包含了id为6至2的慢查询日志,最新的6号日志排在链表的表头,<br>而最旧的2好日志排在链表的表尾,这表明slowlog链表是使用插入到表头的方式来<br>添加新日志的。<br>3.slowlog_log_slower_than记录了服务器配置slowlog-log-slower-than选项的值0,<br>表示任何执行的时间超过0微妙的命令都会被慢查询日志记录<br>4.slowlog-max-属性记录了服务器配置slowlog-max-len选项的值为5,表示服务器最多<br>储存5条慢查询日志
慢查询日志的阅览和删除。<br>SLOWLOG GET命令的伪代码实现:<br>```c<br>def SLOWLOG_GET(number=None):<br># 用户没有给定number参数<br># 那么打印服务器包含的全部慢查询日志<br>if number is None;<br>number = SLOWLOG_LEN()<br><br># 遍历服务器中的慢查询日志<br>for log in redisServer.slowlog:<br>if number <= 0:<br># 打印的日志数量已经足够,跳出循环<br>break<br>else:<br># 继续打印,将计数器的值减一<br>number -= 1<br># 打印日志<br>printLog(log)<br>```<br>查看日志数量的SLOWLOG LEN命令可以用以下伪代码来定义<br>```c<br>def SLOWLOG_LEN():<br># slowlog链表的长度就是慢查询的条目数量<br>retuern len(redisServer.slowlog)<br>```<br>另外用于清除所有慢查询日志的SLOWLOG RESET命令可以用以下伪代码来定义<br>```c<br>def SLOWLOG_RESET():<br># 遍历服务器中的所有慢查询的日志<br>for log in redisServer.slowlog:<br># 删除日志<br>deleteLog(log)<br>```
添加新日志。<br>在每次执行命令的之前和之后,程序都会记录微妙格式的当前UNIX时间戳,这两个时间戳之间的差就是<br>服务器执行命令所耗费的时长,服务器会将这个时长作为参数之一传给slowlogPushEntryIfNeeded函数,<br>而slowlogPushEntryIfNeeded函数则负责检查是否需要为这次执行的命令创建慢查询日志。
伪代码过程:<br>```c<br># 记录执行命令前的时间<br>before = unixtime_now_in_us()<br># 执行命令<br>execute_command(argv, argc, client)<br># 记录执行命令后的时间<br>after = unixtime_now_in_us()<br># 检查是否需要创建新的慢查询日志<br>slowlogPushEntryIfNeeded(argv, argc, after - before)<br>```
slowlogPushEntryIfNeeded函数的作用有两个:<br>1.检查命令的时长是否超过slowlog-log-slower-than选项设置的时间, 如果是的话,就为命令创建一个新的日志,<br>并将新日志添加到slowlog链表的表头<br>2.检查慢查询日志的长度是否超过slowlog-max-len选项所设置的长度,如果是的话,那么将多出来的日志从slowlog<br>链表中删除掉
slowlogPushEntryIfNeeded函数的实现代码:<br>```c<br>void slowlogPushEntryIfNeeded(robj **argv, int argc, long long duration) {<br>// 慢查询功能未开启,直接返回<br>if (server.slowlog_log_slower_than < 0) return ;<br><br>// 如果执行时间超过服务器设置的上限,那么将命令添加到慢查询日志<br>if (duration >= server.slowlog_log_slower_than)<br>// 新日志添加到链表表头<br>listAddNodeHead(server.slowlog, slowlogCreateEntry(argv, argc, duration));<br><br>// 如果日志数量过多,那么进行删除<br>while (listLength(server.slowlog) > server.slowlog_max_len)<br>listDelNode(server.slowlog, listLast(server.slowlog))<br><br>}<br>```<br>该函数根据传入的参数,创建一个新的慢查询日志,并将redisServer.slowlog_entry_id<br>的值增1
举个例子。假设服务器当前保存的慢查询日志如图所示,如果执行以下命令:<br>```c<br>127.0.0.1:6379> EXPIRE msg 10086<br>(integer) 1<br>```<br>服务器在执行完这个EXPIRE命令之后,就会调用slowlogPushEntryIfNeeded函数,<br>函数将未EXPIRE命令创建一条id为7的慢查询日志,并将这条新日志添加到slowlog链表<br>的表头如图所示.注意,除了slowlog链表发生了变化之外,slowlog_entry_id的值也从<br>7变为8了,之后,slowlogPushEntryIfNeeded函数发现,服务器设定的最大慢查询日志<br>数目为5条,而服务器目前保存的慢查询日志数目为6条,于是服务器将id为2的慢查询<br>日志删除,让服务器的慢查询日志数量回到设定好的5条
监视器
概述。<br>通过执行MONITOR命令,客户端可以将自己变为一个监视器,实时地接收并打印出服务器当前<br>处理的命令请求的相关信息:<br>```c<br>127.0.0.1:6379> MONITOR<br>OK<br>1713790637.787549 [0 127.0.0.1:60753] "PING"<br>1713790641.908992 [0 127.0.0.1:60753] "SET" "k1" "v1"<br>1713790645.044945 [0 127.0.0.1:60753] "SET" "k2" "v2"<br>```<br>每当一个客户端服务器发送一条命令请求时,服务器除了会处理这条命令请求之外,还会将关于<br>这条命令请求的信息发送给所有监视器,如图所示
成为监视器。<br>发送MONITOR命令可以让一个普通客户端变为一个监视器,该命令的实现原理可以用以下伪代码来实现:<br>```c<br>def MONITOR():<br># 打开客户端的监视器状态<br>client.flags |= REDIS_MONITOR<br><br># 将客户端添加到服务器状态的monitors链表的末尾<br>server.monitor.append(client)<br><br># 向客户端返回OK<br>send_reply("OK")<br>```
举个例子,如果客户端c10086向服务器发送MONITOR命令,那么这个客户端的REDIS_MONITOR标志<br>会被打开,并且这个客户端本身会被添加到monitors链表的表尾。假设客户端c10086发送MONITOR之前,<br>monitors链表的状态如图所示,那么在服务器执行客户端c10086发送的MONITOR命令之后,monitors链表<br>将被更新为如图所示的状态
向监视器发送命令信息。<br>服务器在每次处理命令请求之前,都会调用replicationFeedMonitors函数,由这个函数将被处理的命令请求<br>的相关信息发送给各个监视器。以下是replicationFeedMonitors函数的伪代码定义,函数首先根据传入的<br>参数创建信息,然后将信息发送给所有监视器:<br>```c<br>def replicationFeedMOnitors(client, monitors, dbid,argv, argc):<br># 根据执行命令的客户端、当前数据库的号码、命令参数、命令参数个数等参数<br># 创建要发送给各个监视器的信息<br>msg = create_message(client, dbid, argv, argc)<br><br># 遍历所有监视器<br>for monitor in monitors:<br># 将信息发送给监视器<br>send_message(monitor, msg)<br>```
举个例子,假设服务器在时间1713791641.329412,根据IP为127.0.0.1、端口号为56604的客户端<br>发送的命令请求,对0号数据库执行命令KEYS*,那么服务器将创建以下信息:<br>```c<br>1713791641.329412 [0 127.0.0.1:56604] "KEYS" "*"<br>```<br>如果服务器monitors链表的当前状态如图上如c10086执行命令之后所示,那么服务器会分别将信息<br>发送给c128、c256、c512和c10086四个监视器,如图所示
0 条评论
下一页