Redis
2024-04-23 19:59:45 1 举报
AI智能生成
登录查看完整内容
Redis是一个开源的内存中键值数据存储系统,通常用于缓存、消息队列和分布式锁等场景。它支持多种数据结构,包括字符串、哈希、列表、集合和有序集合。Redis的键值对可以存储各种类型的数据,例如JSON、二进制数据和字符串。它提供了丰富的操作命令,例如GET、SET、DEL、LPUSH、RPOP等。Redis通过master-slave复制和哨兵机制实现了高可用性和故障恢复。此外,Redis还支持Lua脚本、事务、持久化和发布/订阅等特性,使其成为一个功能强大的NoSQL数据库。
作者其他创作
大纲/内容
1.把Redis当作MQ来使用和传统的MQ消息中间件有什么区别?
2.LRU Java版本实现
缓存
海量数据统计。位图(bitmap):存储是否参加过某次活动,是否已读某篇文章,用户是否为会员,日活统计
会话缓存。可以使用Redis来统计存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用以及可伸缩性
分布式队列/阻塞队列.List是一个双向链表,可以通过lpush/rpush和rpop/lpop写入和读取消息。可以通过使用brpop/blpop来实现阻塞队列
分布式锁实现。在分布式场景下,无法使用基于进程的锁来对多个节点上的进程进行同步,可以使用Redis自带的SETNX命令实现分布式锁
热点数据存储。最新品论、最新文章列表,使用list存储,ltrim取出热点数据,删除老数据
社交类需求。Set可以实现交集,从而实现共同好友功能,Set通过求差集,可以进行好友推荐,文章推荐
排行榜。sorted_set可以实现有序性操作,从而实现排行榜等功能
4.Redis的使用场景有哪些
遗留问题
Redis是单线程吗?Redis的单线程主要是指Redis的网络IO和键值对读写是由一个线程来完成的,这也是Redis对外提供键值存储的主要流程。但Redis的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的
Redis单线程为什么还能这么块?因为它所有的数据都在内存中,所有的运算都是内存级别的运算,而且单线程避免了多线程的切换性能损耗问题。正因为Redis是单线程,所以要小心使用Redis指令,对于那些耗时的指令(比如keys *)一定要谨慎使用,一不小心就可能会导致Redis卡顿
Redis单线程如何处理那么多的并发客户端连接?Redis的IO多路复用:redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,依次放到文件事件分派器,事件分派器将事件分发给事件处理器#查看Redis支持最大连接数,在redis.conf文件中可修改 # maxclients 100000CONFIG GET maxclients
Redis的单线程和高性能
a.【建议】:可读性和可管理性以业务名(或数据库名)为前缀(防止key冲突),用冒号分隔,比如业务名:表名:id```ctrade order:1```
b.【建议】:简洁性保证语义的前提下,控制key的长度,当key较多时,内存占用也不容忽视,例如```cuser:{uid}:friends:messages:{mid} 简化为u:{uid}:fr:m:{mid}```
c.【强制】:不要包含特殊字符反例:包含空格、换行、单双引号以及其他转义字符
1.key名设计
bigkey的产生:一般来说,bigkey的产生都是由于程序设计不当,或者对于数据规模预料不清楚造成的,例子如下:1.社交类:粉丝列表,如果某些明星或者大V不精心设计下,必是bigkey2.统计类:例如按天存储某项功能或者网站的用户集合,除非没几个人用,否则必是bigkey3.缓存类:将数据从数据库load出来序列化放到Redis里,这个方式非常常用,但有两个地方需要注意,第一,是不是有必要把所有字段都缓存第二,有没有相关关联的数据,有的同学为了图方便把相关数据都存一一个key下,产生bigkey
b.【推荐】选择合适的数据类型。例如:实体类型(要合理控制和使用数据结构,但也要注意节省内存和性能之间的平衡)反例:```cset user:1:name tomset user:1:age 19set user:1:favor football```正例:```chmset user:1 name tom age 19 favor football```
2.value设计
3.【推荐】:控制key 的生命周期,redis不是垃圾桶建议使用expire设置过期时间(条件允许可以打散过期时间,防止集中过期)
1.键值设计
1.【推荐】O(N)命令关注N的数量例如hgetall、lrange、smembers、zrange、sinter等并非不能使用,但是需要明确N的值。有遍历的需求可以使用hscan、sscan、zscan代替
2.【推荐】禁用命令禁止线上使用keys、flushall、flushdb等,通过redis的rename机制禁掉命令,或者使用scan的方式渐进式处理
3.【推荐】合理使用selectredis的多数据库较弱,使用数字进行区分,很多客户端支持较差,同时多业务用多数据库实际还是单线程处理,会有干扰
4.【推荐】使用批量操作提供效率```c原生命令:例如mget、mset非原生命令:可以使用pipeline提高效率```但要注意控制一次批量操作的元素个数(例如500以内,实际也和元素字节数有关)注意两者不同:```c原生命令是原子操作,pipeline是非原子操作pipeline可以打包不同的命令,原生命令做不到pipeline需要客户端和服务端同时支持```
5.【建议】Redis事务功能较弱,不建议过多使用,可以用lua替代
2.命令使用
1.【推荐】避免多个应用使用一个Redis示例正例:不相干的业务拆分,公共数据库做服务化
连接池参数含义:
举个例子:假设1.一次命令时间(borrow|return resource + Jedis执行命令(含网络))的平均耗时约为1ms,一个链接的QPS大约是10002.业务期望的QPS是50000那么理论上需要的资源池大小是50000 / 1000 = 50 个。但事实上这是个理论值,还要考虑到要比理论值预留一些资源,通常来讲maxTotal可以比理论值大一些。但这个值不是越大越好,一方面连接太多占用客户端和服务端资源,另一方面对于Redis这种高QPS的服务器,一个大命令的阻塞即时设置再大资源池仍然会无济于事。
优化建议:1.maxTotal:最大连接数,早期的版本叫maxActive实际上这个是一个很难回答的问题,考虑的因素比较多:1.1 业务希望Redis并发量1.2 客户端执行命令时间1.3 Redis资源:例如nodes(例如应用个数) * maxTotal是不能超过redis的最大连接数maxclients1.4 资源开销:例如虽然希望控制空闲连接(连接池此可可马上使用的连接),但是不希望因为连接池的频繁释放创建连接造成不必要开销
优化建议:2.maxIdle和minIdlemaxIdle实际上才是业务需要的最大连接数,maxTotal是为了给出雨量,所以maxIdle不要设置过小,否则会有new Jedis(新连接)开销连接池的最佳性能是maxTotal = maxIdle,这样就避免连接池伸缩带来的性能干扰,到那时如果并发量不大或者maxTotal设置过高,会导致不必要的连接资源浪费。一般推荐maxIdle可以设置为上面的业务期望QPS计算出来的理论连接数,maxTotal可以再放大一倍。minIdle()最小空闲连接数,与其说是最小空闲连接数,不如说是\"至少需要保持的空闲连接数\
2.【推荐】使用带有连接池的数据库,可以有效控制链接,同时提高效率,标准使用方式如代码所示
3.【建议】高并发下建议客户端添加熔断功能(例如sentinel、hystrix)
4.【推荐】设置合理的密码,如有必要可以使用SSL加密访问
3.客户端使用
vm.overcommit_memory(默认0).0:表示内核将检查是否有足够的可用物理内存(实际不一定用满)供应用进程使用;如果有足够的可用物理内存,内存申请允许;否则,内存申请失败,并把错误返回给应用进程1:表示内核允许分配所有的物理内存,而不管当前的内存状态如何,如果是0的化,可能导致类似fork等操作执行失败,申请不倒足够的内存空间Redis建议把这个值设置为1,就是为了让fork操作能够在低内存下也能执行成功```ccat /proc/sys/vm/overcommit_memoryecho \"vm.overcoimmit_memory=1\" >> /etc/sysctl.confsysctl vm.overcommit_memory =1```
合理设置文件句柄数。操作系统进程试图打开一个文件(或者叫句柄),但是现在进程打开的句柄数已经达到了上限,继续打开会报错\"Too many open files\"```culimit -a # 查看系统文件句柄数,看open files选项ulimt -n 65535 # 设置系统文件句柄数```
4.系统内核参数优化
开发规范与性能优化
keys:全量遍历。用来列出所有满足特定正则字符串规则的key,当redis数量比较大时,性能比较查,要避免使用
参数解析。scan参数提供了三个参数,第一个是cursor整数值(hash桶的索引值),第二个是key的正则模式,第三个是一次遍历的key的数量(参考值,底层遍历的数量不一定),并不是符合条件的结果数量。第一次遍历时,cursor值为0,然后将返回结果中第一个整数值作为下一次遍历的cursor.一直遍历到返回的cursor值为0时结束。可以简单理解为每次遍历多少个元素,根据测试,推荐Count大小为1W
概述。由于Redis是单线程再处理用户的命令,而Keys命令会一次性遍历所有key,于是在命令执行过程中,无法执行其他命令。这就导致如果Redis中的key比较多,那么Keys命令执行时间就会比较长,从而阻塞Redis,所以推荐使用Scan命令来代替Keys,因为Scan可以限制每次遍历的key数量。Keys的缺点:1.没有limit,我们只能一次性获取所有符合条件的key,如果结果有上百万挑,那么等待的就是\"无穷无尽\"的字符串输出2.keys命令是遍历算法,时间复杂度是O(N)。这个命令非常容易导致Redis服务卡顿,要尽量避免在生产环境使用该命令。相比于keys命令,Scan命令有两个比较明显的优势:1.Scan命令的时间复杂度虽然也是O(N),但它是分次进行的,不会阻塞线程2.Scan命令提供了count参数,可以控制每次遍历的集合数可以理解为Scan是渐进式的keys.大致用法如下:SCAN命令是基于游标的,每次调用后,都会返回一个游标,用于下一次迭代。当游标返回0时,表示迭代结束。第一次Scan时指定游标为0,表示开启新的一轮迭代,然后Scan命令返回一个新的游标,作为第二次Scan时的游标值继续迭代,一直到Scan返回游标为0,表示本轮迭代结束通过这个就可以看出,Scan完成一次迭代,需要和Redis进行多次交互。
Count大小和Scan总耗时的关系如图所示,可以发现Count越大,总耗时就越短,不过后面提升就越不明显了所以推荐的Count大小为1W左右.如果不考虑Redis的阻塞,其实Keys比Scan会快很多,毕竟是一次性处理,省去了多余的交互
reverse binary iteration.Redis Scan命令最终使用的是reverse binnary iteration算法,大概可以翻译为逆二进制迭代。这个算法简单来说就是:依次从高位(有效位)开始,不断尝试将当前高位设置为1,然后变动更高位为不同组合,依次来扫描整个字典数组其最大的优势在于,从高位扫描的时候,如果槽位是2^N个,扫描的临近的2个元素都是与2^(N-1)相关的就是说同模的,比如槽位8时,0%4 == 4%4, 1%4 == 5%4。因此想到其实hash的时候,跟模是很相关的。比如当整个字典大小只有4的时候,一个元素计算出的整数为5,那么计算它的hash值需要模4,也就是hash(n) == 5 % 4 == 1,元素放在第一个槽位中。当字典进行扩容的时候,字典大小变为8,此时计算hash的时候为 5 % 8 == 5,该元素从1号slot迁移到了5号,1和5是对应的,我们称之为同模或者对应。同模的槽位的元素最容易出现合并或者拆分了。因此在迭代的时候只要及时地扫描这些相关地槽位,这样就不会造成大面积的重复扫描。
计算过程如图所示.大小为4时,游标状态转换为0-2-13当大小为8时,游标转台转换为0-4-2-6-1-5-3-7.当size由小变大时,所有原来的游标都能在大HashTable中找到对应的位置,并且顺序一致,不会重复读取,也不会被遗漏。总结:redis在rehash扩容的是时候,不会重复或者漏掉数据。但缩容,可能会造成重复,但不会漏掉数据
缩容处理。之所以会出现重复数据,其实就是为了保证缩容后数据不丢。假设当前hash大小为8:1.第一次先遍历了bucket[0],返回游标为42.准备遍历bucket[4],然后此时发生了缩容,bucket[4]的元素也进到了bucket[0]3.但是bucket[0]之前已经被遍历过了,此时会丢失数据吗?具体计算方法```cv = (((v |m0) + 1) & (~m0) | (v & m0)```
游标计算。Scan命中的游标,其实就是Redis内部地bucket
Scan原理。Redis使用了Hash表作为底层实现,原因不外乎高校且实现简单。类似于HashMap那样数组+链表的结构.其中第一维的数组大小为2n(n>=0),每次扩容数组长度扩大一倍。Scan命令就是对这个一维数组进行遍历。每次返回的游标值也都是这个数组的索引,Count参数表示遍历多少个数组的元素,将这些元素下挂接的符合条件的结果都返回。因为每隔元素下挂接的链表大小不同,所以每次返回的结果数量也就不同。
总结。1.Scan Count参数限制的是遍历的bucket数,而不是限制的返回的元素个数由于不同bucket中的元素个数不同,其中满足条件的个数也不同,所以每次Scan返回元素也不一定相同2.Count越大,Scan总耗时越短,但是单次耗时越大,即阻塞Redis时间变长2.1 推荐Count大小为1W左右2.2 当Count = Redis Key总数时,Scan和Keys效果一致3.Scan采用逆二进制发来计算游标,主要为了兼容Rehash的情况4.Scan为了兼容缩容后不漏掉数据,会出现重复遍历。需要客户端做去重处理核心就是逆二进制迭代法,比较复杂,而且算法作者也没有具体证明,为什么这样就能实现,只是测试发现没有问题,各种情况都能兼容
底层原理分析。内部原理见https://www.lixueduan.com/posts/redis/redis-scan/
注意事项。注意:但是scan并非完美无瑕,如果在scan的过程中如果有键的变化(增加、删除、修改),那么遍历效果可能会碰到如下问题:新增的键可能没有遍历到,遍历出了重复的键等情况,也就是说scan并不能保证完整地遍历出来所有的键。1.返回的结果可能会有重复,需要客户端去重复,这点非常重要2.遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的3.单词返回的结果是空的,并不意味着遍历结束,而要看返回的游标值是否为零
scan:渐进式遍历键SCAN cursor [MATCH pattern] [COUNT count]
常用参数:connected_clients #正在连接的客户端数量instantaneous_ops_per_sec #每秒执行多少指令font color=\"#ec7270\
Server服务器运行的环境参数
Clients客户端相关信息
Memory服务器运行内存统计数据
Persistence持久化信息
Stats通用统计数据
Replication主从复制相关信息
CPU CPU使用情况
Cluster集群信息
KeySpace键值对统计数量信息
info:查看redis服务运行信息,分为9大块,每个块都有非常多的参数。
其他高级命令
redis6.0以前线程执行模式,如下操作再一个线程中执行完成
redis6.0线程执行模式:可以通过如下参数配置多线程模型:```cio-threads 4 // 这里说 有三个IO线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作```默认情况下,如上配置,有三个IO线程,这三个IO线程只会执行IO中的write操作,也就是说,read和命令执行都由main线程执行,最后多线程讲数据写回客户端。
开启了如下参数:```cio-threadas-do-reads yes // 将支持IO线程执行 读写任务```
1.Redis6.0之前的版本真的是单线程吗?Redis在处理客户端的请求是,包括获取(socket读)、解析、执行、内容返回(socket 写)等都有一个顺序串行的主线程处理,这就是所谓的\"单线程\"。但如果严格来讲并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大key的删除等等
2.Redis6.0之前为什么一直不使用多线程?官方曾做过类似问题的回复:使用Redis时,几乎不存在CPU成为瓶颈的情况,Redis主要受限于内存和网络。例如在一个普通的Linux系统上,Redis通过使用pipelining每秒可以处理100w个请求,所以如果应用程序主要使用O(N)或O(log(N))的命令,它几乎不会占用太多CPU.使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同事还可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis通过AE事件模型以及IO多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得Redis内部实现的复杂度大大降低,Hash的惰性、Rehash、Lpush等等\"线程不安全\"的命令都可以无锁进行
4.Redis6.0默认是否开启了多线程?Redis6.0的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf配置文件```cio-threads 4io-threads-do-reads no```开启多线程后,还需要设置线程数,否则是不生效的。关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置6个线程,线程数一定要小于机器核数,因为Redis还有预留出其他线程做后台任务比如备份、删除过期键等操作,还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了,因为主线程命令执行会存在一个瓶颈点,如果达到瓶颈,即便IO线程设置得再多,也是空转
5.Redis6.0采用多线程后,性能的提升效果如何?Redis作者antirez在RedisConf2019分享时曾提到:Redis6引入的多线程IO特性对性能提升至少是一倍以上。国内也有大牛曾使用unstable版本在阿里云ESC进行过测试,GET/SET命令在4线程IO时性能相比单线程是几乎翻倍了。如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU的耗时的时候才建议使用,否则使用多线程没有意义
6.Redis60多线程的实现机制?流程简述:1.主线程负责接收建立连接请求,获取socket放入全局等待读处理队列2.主线程处理完读事件之后,通过RR(Round Robin)将这些连接分配给这些IO线程3.主线程阻塞等待IO线程读取socket完毕4.主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行回写socket5.主线程阻塞等待IO线程将数据回写socket完毕6.解除绑定,清空等待队列该设计有如下特定:1.IO线程要么同时在读socket,要么同时在写,不会同时读或写2.IO线程只负责读写socket解析命令,不负责命令处理
7.开启多线程后,是否会存在线程并发安全问题?Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制key、lua、事务,LPUSH/LPOP等等的并发及线程安全问题
8.Redis6.0的多线程和Memcached多线程模型进行对比Memcached服务器采用master-worker模式进行工作。服务端采用socket与客户端通讯。主线程、工作线程采用pipe管道进行通讯。主线程采用libevent监听listen、accept的读事件,事件响应后将连接信息的数据结构封装起来,根据算法选择合适的工作线程,将连接任务携带连接信息分发出去,响应的线程利用连接描述符建立与客户端的socket连接并进行后续的存取数据操作相同点:都采用了master线程-worket线程的模型不同点:Memcached执行主逻辑也是在worker线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。而Redis吧处理逻辑交还给master线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题
常见问题
多线程
概述。redis6提供了服务端追踪key的变化,客户端缓存数据的特性,这需要客户端实现
执行流程为,当客户端访问某个key时,服务端将记录key和client,客户端拿到数据后,进行客户端缓存,这时,当key再次被访问时,key将被直接返回,避免了与redis服务器的再次交互,节省服务端资源,当数据被其他请求修改时,服务端将主动通知客户端失效的key,客户端进行本地失效,下次请求时,重新获取最新数据目前只有lettuce对其进行了支持:```xml<dependency><groupId>io.lettuce</groupId><artifactId>lettuce‐core</artifactId><version>6.0.0.RELEASE</version></dependency>``````javapublic class Main { public static void main(String[] args) { RedisClient redisClient = RedisClient.create(\"redis://127.0.0.1\
Client side caching(客户端缓存)
Redis6.0新特性
对比
什么是调和平均数呢?举个例子。求平均工资:A的是1000/月,B的是30000/月。采用平均数的方式就是(1000+30000)/2=15500采用调和平均数的方式就是2/(1/1000 + 1/30000)约等于1935.484.可见调和平均数比平均数的好处就是不容易受到大的数据的影响,比平均数的效果是要好的。
数据原理。HyperLogLog基于概率论中伯努利试验并结合了极大似然估算方法,并作了分桶优化。实际上目前还没有发现更好的在大数据场景中准确计算基数的高效算法,因此在不追求绝对准确的情况下,使用概率算法是一个不错的解决方案。概率算法不直接存储数据集合本身,通过一定的概率统计方法预估值,这种方法可以大大节省内存,同时保证误差控制在一定范围内。目前用于基数基数的概率算法包括:Linear Counting(LC):早期的基数估计算法,LC在空间复杂度方面并不算优秀LogLog Counting(LLC):LogLog Counting相比LC更加节省内存,空间复杂度更低HyperLogLog Counting(HLL): HyperLogLog Counting是基于LLC的优化和改进,在同样空间复杂度情况下,能够比LLC的基数估计误差更小。
3.原理概述。
Redis高级数结构HyperLogLog
1.缓存空对象
使用布隆过滤器需要把所有数据提前放入布隆过滤器,并且在增加数据时也要往布隆过滤器里放,布隆过滤器缓存过滤伪代码:注意:布隆过滤器不能删除数据,如果要删除得重新初始化数据
2.布隆过滤器对于恶意攻击,向服务器请求大量不存在的数据造成的缓存穿透,还可以用布隆过滤器先做一次过滤,对于不存在的数据布隆过滤器一般都能够过滤掉,不让请求再往后端发送。当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在.布隆过滤器就是一个大型的位数组和几个不一样的无偏hash函数。所谓无偏就是能够把元素的hash值算的比较均匀。向布隆过滤器中添加key时,会使用多个hash函数都会算得一个不同的位置。再把位数组的这几个位置都置为1,就完成了add操作。向布隆过滤器询问key是否存在时,跟add一样,也会把hash的几个位置都算出来,看看位数组中这几个位置是否都为1,只要有一个位为0,那么说明布隆过滤器中这个key不存在。如果都是1,这并不能说明这个key就一定存在,只是极有可能存在,因为这些位置为1可能是因为其他的key存在所致。如果这个位数组比较稀疏,这个概率就会很大,如果这个位数组比较拥挤,这个概率就会很低。这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用很少
解决方案
缓存穿透。缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,通常处于容错的考虑,如果从存储层查不到数据则不写入缓存层。缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。造成缓存穿透的基本原因有两个:1.自身业务或者数据出现问题2.一些恶意攻击、爬虫等造成大量空命中。
缓存失效(击穿)。由于大批量缓存存在同一时间失效可能导致大量请求同时穿透缓存直达数据库,可能会造成数据库瞬间压力过大甚至挂掉,对于这种情况我们在批量增加缓存时最好将这一批数据的缓存过期时间设置为一个时间段内的不同时间。
解决方案。预防和解决缓存雪崩问题,可以从三个方面着手:1.保证缓存层服务高可用性,比如使用Redis Sentinel或Redis Cluster2.依赖隔离组件为后端限流熔断并降级。比如使用Sentinel或Hystrix限流降级组件比如服务降级,我们可以针对不同的数据采取不同的处理方式。当业务应用访问的是非核心数据(例如电商商品属性,用户信息等)时,暂时停止从缓存中查询这些数据,而是直接返回预定义的默认降级信息、控制或是错误提示信息;当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取3.提前演练。在项目上线前,演练缓存层宕机后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定
缓存雪崩。缓存雪崩指的是缓存曾支撑不住或宕掉后,流量会像奔逃的野牛一样,打向后端存储层。由于缓存层承载着大量请求,有效地保护了存储层,到那时如果缓存层由于某些原因不能提供服务(比如超大并发过来,缓存层支撑不住,或者由于缓存设计不好,类似大量请求访问bigkey,导致缓存能支撑的并发急剧下降),于是大量请求都会打到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。
热点缓存key重建优化。开发人员使用\"缓存+过期时间\"的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现,可能就会对应用造成致命的危害:1.当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大2.重建缓存不能在短时间内完成,可能是一个复杂计算。例如复杂的SQL、多次IO、多个依赖等在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。要解决这个问题主要就是要避免大量线程同时重建缓存。我们可以利用互斥锁来解决,此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
解决方案:1.对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可2.就算并发很高,如果业务上能容忍短时间内的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求3.如果不能容忍缓存数据不一致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的时候相当于无锁4.也可以阿里开源的canal通过监听数据库的binlog日志即时地去修改缓存,但是引入了新地中间件,增加了系统地复杂度
版本1
1.先更新数据库,再更新缓存举个例子,比如【请求A】和【请求B】两个请求,同时更新【同一条】数据,则可能出现图中的顺序:【请求A】先将数据库的数据更新为1,然后在更新缓存前,【请求B】将数据库的数据更新为2,紧接着把缓存更新为2,然后【请求A】更新缓存为1.此时,数据库中的数据是2,而缓存中的数据却是1,出现了缓存和数据库中的数据不一致的现象
2.先更新缓存,再更新数据库。举个例子,【请求A】和【请求B】两个请求,同时更新【同一条】数据,则可能出现这样的顺序:【请求A】先将缓存的数据更新为1,然后在更新数据库前,【请求B】来了,将缓存的数据更新为2,紧接着把把数据库更新为2,然后【请求A】将数据库的数据更新为1.此时,数据库中的数据是1,而缓存中的数据却是2,出现了缓存和数据库中的数据不一致的现象
所以,无论是【先更新数据库,再更新缓存】,还是【先更新缓存,再更新数据库】,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象
先更新数据库,还是先更新缓存?1.先更新数据库,再更新缓存2.先更新缓存,再更新数据库
解决方案:针对【先删除缓存,再更新数据库】方法在【读+写】并发请求而造成缓存不一致的解决办法是【延迟双删】:伪代码示例。加了个睡眠时间,主要是为了确保请求A在睡眠的时候,请求B能够在这一段时间内完成【从数据库读取数据,再把缺失的缓存写入缓存】的操作,然后请求A睡眠完,再删除缓存。所以请求A的睡眠时间就需要大于请求B【从数据库读取数据+写入缓存】的时间。但是具体睡眠多久其实我们是没法准确预估的,需要进行统计,所以这个方案尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象,因此,还是比较建议用【先更新数据库,再删除缓存】的方案
1.先删除缓存,再更新数据库。举个例子,以用户表的场景来分析。假设某个用户的年龄是20,请求A要更新用户年龄为21,所以它会删除缓存中的内容。这时,另一个请求B要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为20,并且写入到缓存中,然后请求A继续更改数据库,将用户的年龄更新为21.最终,该用户年龄在缓存中是20(旧值),在数据库中是21(新值),缓存和数据库的数据不一致。可以看到,先删除缓存,再更新数据库,在【读+写】并发的时候,还是会出现缓存和数据库的数据不一致的问题
2.先更新数据库,再删除缓存继续用【读+写】请求的并发的场景来分析。假如某个用户数据在缓存中不存在,请求A读取读取数据时从数据库中查询到年龄为20,在未写入缓存中时另一个请求B更新数据。它更新数据库中的年龄为21,并且清空缓存。这时请求A把数据库中读到的年龄为20的数据写入到缓存中。最终,该用户年龄在缓存中是20,数据库中是21,缓存和数据库数据不一致。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求B已经更新了数据库并且删除了缓存,请求A才更新完缓存的情况。而一旦请求A早于请求B删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。所以,【先更新数据库+再删除缓存】的方案,是可以保证数据一致性的,再加上一个【过期时间】,就算在这期间存在缓存数据不一直,有过期时间来兜底,这样也能达到最终一致。
【先更新数据库,再删除缓存】存在的问题:前面的分析都是建立再这两个操作都能同时执行成功的情况下,如果在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,如果没有前面的过期时间兜底的话,后续的请求就会一直是缓存中的就数据【先更新数据库,再删除缓存】的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。所以,如果业务对缓存命中率有很高的要求,可以采用【更新数据库+更新缓存】的方案,因为更新缓存并不会出现缓存未命中的情况,但是这个方案,前面提到,在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据不一致需要增加一些手段来解决这个问题,有两种做法1.在更新缓存前先加个分布式锁,保证同一时间之运行一个请求更新缓存,就不会产生并发问题了,但是引入锁之后,对于写入性能就会带来影响2.在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务来说也可以接受
1.重试机制。我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。1.1 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过一定的次数,还是没有成功,就需要向业务层发送报错消息了1.2 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试
2.订阅MySQL binlog,再操作缓存【先更新数据库,再删除缓存】的策略第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在binlog里。于是我们就可以通过订阅binlog日志,拿到具体要操作的数据,然后再执行缓存删除,阿里开源的Cannal中间件就是基于这个实现的。Cannal模拟MySQL主从复制的交互协议,把自己伪装成一个MySQL的从节点,向MySQL主节点发送dump请求,MySQL收到请求后,就会开始推送binlog给Cannal,Cannal解析binlog字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用.
所以如果要想保证【先更新数据库,再删除缓存】策略第二个操作能执行成功,我们可以使用【消息队列来重试缓存的删除】,或者【订阅MySQL binlog再操作缓存】,这两种方法有一个共同的特点,都是采用异步操作缓存
如何保证【先更新数据库,再删除缓存】这两个操作能执行成功?举个例子:应用要把数据X的值从1更新为2,先成功更新了数据库,然后在Redis缓存中删除X的缓存,但是这个操作却失败了,这个时候数据库中的X的新值为2,Redis中的X的缓存值为1,出现了数据库和缓存数据不一致的问题。那么后续有访问数据X的请求,会先在Redis中查询,因为缓存中并没有删除,所以缓存命中,但是读到的却是旧值1.其实不管先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据不一致的问题,解决方案有两种:1.重试机制2.订阅MySQL binlog,再操作缓存
为什么是删除缓存,而不是更新缓存?删除一个数据,相比更新一个数据更加轻量级,出问题的概率更小。在实际业务中,缓存的数据可能不是直接来自数据库表,也许来自多张底层数据表的聚合。比如商品详情信息,在底层可能会关联商品表、价格表、库存表等,如果更新了一个价格字段,那么就要更新整个数据库,还要关联的去查询和汇总各个周边业务系统的数据,这个操作会非常耗时。从另外一个角度,不是所有的缓存数据都是频繁访问的,更新后的缓存可能会长事件不被访问,所以说,从计算资源和整体性能的考虑,更新的时候删除缓存,等到下次查询命中再填充缓存,是一个更好的方案。系统设计中有一个设计叫Lazy Loading,适用于那些加载代价大的操作,删除缓存而不是更新缓存,就是懒加载思想的一个应用
Cache Aside(旁路缓存)策略,该策略可以细分为【读策略】和【写策略】写策略的步骤:1.更新数据库中的数据;2.删除缓存中的数据读策略的步骤:1.如果读取的数据命中了缓存,则直接返回数据2.如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户但是【写策略】中的数据库和缓存操作又有不同的顺序:1.先删除缓存,再更新数据库2.先更新数据库,再删除缓存
版本2
缓存与数据库双写不一致。
总结:以上我们针对地都是读多写少的情况加入缓存提高性能,如果写多读多的情况又不能容忍缓存数据不一致,那就没必要加缓存了,可以直接操作数据库。当然,如果数据库扛不住压力,还可以把缓存作为数据读写的主存储,异步将数据同步到数据库,数据库只是作为数据的备份。放入缓存的数据应该是对实时性、一致性要求不是很高的数据。切记不要为了用缓存,同时又要保证绝对的一致性做大量的过度设计和控制,增加系统复杂性
缓存设计
Redisson加分布式锁机制
Redis分布式锁
高可用集群模式。redis集群是一个由多个主从节点组成的分布式服务器集群,它具有复制、高可用和分片特性。Redis集群不需要sentinel哨兵,也能完成节点移除和故障转移的功能。需要将每隔节点设置成模式,这种集群模式没有中心节点,可水平扩展,根据官方文档称可以线性扩展到上万个节点(官方推荐不超过1000个节点)。redis集群的性能和高可用性均优于之前版本的哨兵模式,且集群配置非常简单。Redis集群至少需要3个master
Redis集群方案比较
CRC16的算法原理。1.根据CRC16的标准选择初值CRCIn的值2.将数据的第一个字节与CRCIn高8位异或3.判断最高位,若该位为0左移一位,若为1左移一位再与多项式Hex码异或4.重复3至9位全部移位计算结束5.重复将所有输入数据操作完成以上步骤,所得16位数即16位CRC校验码
8KB的心跳包看似不大,但是这个是心跳包每秒都要将本节点的信息同步给其他集群节点。比起16384个插槽,头大小增加了4倍,ping消息的消息头太大了,浪费带宽。Redis主节点的哈希槽配置信息是通过bitmap来保存的,也就是位数组,元素的值为0或1.在传输过程中,会对bigmap进行压缩,bitmap的填充率越低,压缩率越高。bitmap填充率 = slots / N(N表示节点数)所以插槽数偏低的话,填充率就会降低,压缩率会升高综合下来,从心跳包的大小、网络带宽、心跳并发、压缩率等维度考虑,16384个插槽更有优势且能满足业务需求
master节点间心跳数据包格式:消息格式分为:消息头+消息体。消息头包含发送节点自身状态数据,接收节点根据消息头就可以获取到发送节点的相关数据相关代码在src/cluster.h文件中以5.0版本为例,如代码所示,消息头中有一个myslots的char类型数组,unsinged char myslots[CLUSTER_SLOTES/8];font color=\"#000000\
为什么槽位只有16384个?
槽位定位算法。Cluster默认会对key值使用CRC16算法进行hash得到一个整数值,然后用这个整数值对16384进行取模来得到具体槽位。HASH_SLOT = CRC16(Key) mod 16384
跳转重定向。当客户端向一个错误的节点发出了指令,该节点会发现指令的key所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有key将使用新的槽位映射表
集中式。优点在于元数据的更新和读取,时效性非常好,一旦元数据出现变更立即回更新到集中式的存储中,其他节点读取的时候立即就可以感知到;不足在于所有的元数据的更新压力全部集中在一个地方,导致元数据的存储压力。很多中间件都会借助Zookeeper集中式存储元数据
Redis集群节点间的通信机制。redis cluster节点间采取font color=\"#e74f4c\
Gossip通信的10000端口。每个节点都有一个专门用于节点间Gossip通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口,每个节点每隔一段时间都会往另外几个节点(每秒随机抽取5个节点,找出最久没有通信的节点)发送ping消息,同时其他节点接收到ping消息之后返回pong消息
网络抖动。真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。为解决这种问题,Redis Cluster提供了一种选项cluster-node-timeoutfont color=\"#000000\
集群脑裂数据丢失问题。Redis集群没有过半机制会有脑裂问题,网络分区导致脑裂后多个主节点对外提供写服务,一旦网络分区恢复,会将其中一个主节点变为从节点,这时会有大量数据丢失。规避方法可以在Redis配置里加上参数(这种方法不可能百分百避免数据丢失)```cmin-replicas-to-write 1//写数据成功最少同步的slave数量,这个数量可以模仿大于半数机制配置,比如集群总共三个节点可以配置1,加上leader就是2,超过了半数```注意:这个配置在一定程度上会影响集群的可用性,比如slave要是少于1个,这个集群就算leader正常也不能提供服务了,需要具体场景权衡选择
集群是否完整才能对外提供服务。当redis.conf配置的cluster-require-full-coverage为no时,表示当负责一个插槽的主库下线且没有相应的从库进行故障恢复时,集群仍然可用,如果为yes则集群不可用
Redis集群为什么至少需要三个master节点,并且推荐节点数为奇数?因为新master的选举需要大于半数的集群master节点同一才能选举成功,如果只有两个master节点,当其中一个挂了,是达不到选举新master的条件的。奇数个master节点可以在满足选举该条件的基础上节省一个节点,比如三个master节点和四个master节点的集群相比,大家如果都挂了一个master节点都能选举新master节点,如果挂了两个master节点都没法选举新master节点了,所以奇数的master节点更多的是从节省机器资源角度出发说的
Redis集群对批量操作命令的支持。对于类似mset,mget这样的多个key的原生批量操作命令,redis集群只支持所有key落在同一slot的情况,如果有多个key一定要用mset命令在Redis集群上操作,则可以在key的前面加上{XX},这样参数数据分片hash计算的只会是大括号里的值,这样能确保不同的key能落到同一slot里去,示例如下```cmset {user1}:1:name cover {user1}:1:age 18```假设name和age计算的hash slot值不一样,但是这条命令在集群下执行,redis只会用大括号里的user1做hash slot计算,所以算出来的slot值肯定相同,最后都能落在同一slot.
RedisCluster集群
架构图。
主从复制(全量复制)流程图:
数据部分复制。当master和slave断开重连后,一般都会对整份数据进行复制。但从Redis2.8版本开始,Redis改用可以支持部分数据复制的命令PSYNC去master同步数据,slave与master能够在网络断开重连后只进行部分数据复制(断点续传).master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数,master和它所有的slave都维护了复制的数据下表offset和master的进程id,因此,当网络连接断开后,slave会请求master继续进行未完成的复制,从所记录的数据下标开始。如果master进程id变化了,后者从节点数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制
如果有很多从节点,为了缓解主从复制风暴(多个从节点同时复制主节点导致主节点压力过大),可以做如下架构
Redis主从架构
Redis哨兵高可用架构。sentinel哨兵是特殊的redis服务,不提供读写服务,主要用来监控redis实例节点。哨兵架构下client端第一次从哨兵找出redis的主节点,后续就直接访问redis的主节点不会每次都通过sentinel代理访问redis的主节点,当redis的主节点发生变化,哨兵会第一时间感知到,并将新的redis主节点通知给client端(redis的client端一般都实现了订阅功能,订阅sentinel发布的节点变动消息)
redis哨兵搭建步骤:1.sentinel集群都启动完毕后,会将烧饼集群的元数据信息写入所有sentinel的配置文件里去(追加在文件的最下面)2.当Redis主节点如果挂了,哨兵集群会重新选举出新的redis主节点,同时会修改所有sentinel节点配置文件的集群元数据信息,比如6379的redis如果挂了,假设选举出的新主节点是6380,则sentinel文件里的集群元数据信息会变成如下所示:```csentinel known-replica mymaster 192.168.0.60 6379 #代表主节点的从节点信息sentinel known-replica mymaster 192.168.0.60 6381 #代表主节点的从节点信息```3.同时还会修改sentinel文件里之前配置的mymaster对应的6379端口,改为6380```csentinel monitor mymaster 192.168.6380 2```4.当6379的redis实例再次启动时,哨兵集群根据元数据信息就可以将6379端口的redis节点作为从节点加入集群
Redis哨兵高可用
1.LVS是Linux virtual server的缩写,为linux虚拟服务器,是一个虚拟的服务器集群系统。LVS简单工作原理为用户请求LVS VIP,LVS根据转发方式和算法,将请求转发给后端服务器,后端服务器接收到请求,返回给用户。对于用户来说,看不到Web后端具体的应用。
亿级流量电商网站微服务架构
1.free属性的值为0,表示这个SDS没有分配任何未使用空间2.len属性的值为5,表示这个SDS保存了一个5字节长的字符串3.buf属性是一个char类型的数组,数组的前5个字节分为保存了'R'、'e'、'd'、'i'、's'五个字符,最后一个字节则保存了空字符'\\0'
这个SDS和上面的SDS的区别在于,这个SDS为buf数组分配了5字节未使用空间,所以它的free属性的值为5
定义。每个sds.h/sdshdr结构表示一个SDS值如图.SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里的函数,例如printf函数,printf(\"%s\
1.常数复杂度获取字符串长度。因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,知道遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂读仅为O(1).设置和更新SDS长度的工作是由SDS的API在执行时自动完成,使用SDS无须进行任何手动修改长度的工作,通过SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈。例如,因为字符串键在底层使用SDS来实现,所以即使我们对一个非常长的字符串键反复执行STRLEN命令,也不会对系统性能造成任何影响,因为STRLEN命令的复杂度仅为O(1)
例如,如果我们持有一个值为\"Redis\
例如,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)
例如,入宫进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配。通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次
注意执行sdstrim之后的SDS并没有释放多出来的8字节空间,而是将这些8字节空间作为未使用空间保留在了SDS里面,如果将来要对SDS进行增长操作的话,这些未使用空间就可能派上用场。
例如,现在对s执行sdscat(s,\" Redis\");那么完成这次sdscat操作将不需要执行内存重分配:因为SDS里面预留的8字节空间已经足以拼接6个字节常的\" Redis\".通过惰性空间释放策略,SDS避免了缩短字符串所需的内存重分配操作,并为将来可能有的增长操作提供了优化,与此同时,SDS也提供了相应的API,让我们可以在有需要时真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费
惰性空间释放。惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用
为了避免C字符串的这种缺陷,SDS通过未使用空间接触了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录.通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
举个例子,如果有一种使用空字符来分割多个单词的特殊数据格式,如图所示,那么这种格式就不能使用C字符串来保存,因为C字符串所用的函数只会识别出其中的\"Redis\",而忽略之后的\"Cluster\"
例如,使用SDS来保存之前提到的特殊数据格式就没有任何问题,因为SDS使用len属性的值而不是空字符来判断字符串是否结束,如图所示
5.兼容部分C字符串函数。虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数
6.总结。
简单动态字符串
举个例子,以下展示的integers列表键包含了从1到1024共1024个整数:integers列表键的底层实现就是一个链表,链表中的每个节点都保存了一个整数值。
概述。链表提供了高效的节点重拍能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。作为一种常用的数据结构,链表内置在很多高级的变成语言里面,因为Redis使用的C语言并没有内置这种数据结构,所以Redis构建了自己的链表实现。链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区(output buffer)
多个listNode可以通过prev和next指针组成双端链表,如图
如图是由一个list结构和三个listNode结构组成的链表
链表和链表节点的实现。每个链表节点使用一个adlist.h/listNode结构来表示:
链表
举个例子,当我们执行代码中的命令之后,会在数据库中创建一个键为\"msg\",值为\"hello world\"的键值对,这个键值对就是保存在代表数据库的字典里面的。
举个例子,website是一个包含3个键值对的哈希键,这个哈希键的键都是一些数据库的名字,而键的值就是数据库的主页网址:如代码所示。website键的底层实现就是一个字典,字典中包含了3个键值对,例如:Redis-Redis.io、MariaDB-MariaDB.org、MongoDB-MongoDB.org
概述。字典又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种保存键值对(key-value pair)的抽象数据结构,在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对等等字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增删改查操作也是构建在对字典的操作之上的。除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。除了用来实现数据库和哈希键之外,Redis的不少功能也用到了字典
这是一个大小为4的空哈希表(没有包含任何键值对)
举个例子,该图就是通过next指针,将两个索引值相同的键K1和K0连接在一起的
哈希表节点。哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对,如代码所示.key属性保存着键值对中的键,而v属性保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突(collision)的问题
ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1.
展示了一个普通状态下(没有进行rehash)的字典
字典。Redis中的字典由dict.h/dict结构表示:如代码所示。type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的:1.type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。2.而privdata属性则保存了需要传给那些类型特定函数的可选参数。
举个例子,如果要将一个键值对K0和V0添加到字典里面,那么程序会使用语句hash = dict->type->hashFunction(k0);计算k0的哈希值.假设计算得出的哈希值为8,那么程序会继续使用语句:index = hash & dict->ht[0].sizemask = 8 & 3 = 0;计算出键K0的索引值0,这表示包含键值对K0和V0的节点应该被放置到哈希表数组真的索引0位置上,如图所示
哈希算法。当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis计算哈希值和索引值的方法如下:#使用字典设置的哈希函数,计算键key的哈希值hash = dict->type->hashFunction(key);# 使用哈希表的sizemask属性和哈希值,计算出索引值# 根据情况不同,ht[x]可以是ht[0]或者ht[1]index = hash & dict->ht[x].sizemask;当字典被用作数据库的底层实现,或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值的,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
举个例子,假设程序要将键值对K2和V2添加到图中的哈希表中,并且计算得出K2的索引值为2,那么K1和K2将产生冲突,而解决冲突的办法就是使用next指针将键K2和K1所在的节点连接起来
解决键冲突。当有两个或以上数量的键被分配到了一个哈希表数组的同一个索引上面,我们称这些键发生了冲突(collision)。Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
2.将ht[0]包含的四个键值对都rehas到ht[1],如图所示
3.释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如图所示。至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4改为了现在的8
举个例子,假设程序要对图中字典的ht[0]进行扩展操作,程序将执行如下步骤
例如,对于一个大小为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为load_factor = 4 / 4 = 1;
例如,对于一个大小为512,包含256个键值对的哈希表来说,这个哈希表的负载因子是:load_factor = 256 / 512 = 0.5
哈希表的扩展与收缩。当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于12.服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5其中哈希表的负载因子可以通过公式:#负载因子 = 哈希表已保存节点数量 / 哈希表大小load_factor = ht[0].used / ht[0].size根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要地内存写入操作,最大限度地节约内存。另一方面,当哈希表地负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
字典的实现。Redis的字典使用哈希表作为子层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对
字典
举个例子,fruit-price 是一个有序集合键,这个有序集合以水果名为成员,水果价钱为分值,保存了130款水果的价钱。fruit-price有序集合的所有数据都保存在一个跳跃表里面,其中每个跳跃表节点(node)都保存了一款水果的价钱信息,所有水果按价钱的高低从低到高在跳跃表里面排序。
举个例子,图中展示了一个跳跃表示例,位于图片最左边的是zskiplist结构,该结构包含以下属性:1.header指向跳跃表的表头节点2.tail指向跳跃表的表尾节点3.level记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)4.length记录跳跃表的长度,也即是,跳跃表目前包含的节点数量(表头节点不计算在内)位于zskiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:1.层(level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,依此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行2.后退(backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用3.分值(score):各个节点中的1.0、2.0、3.0是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列4.成员对象(obj):各个节点中的o1、o2、o3是节点所保存的成员对象。注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以途中省略了这些部分,只显示了表头节点的各个层。
跳跃表的实现。Redis的跳跃表由redis.h/zskiplistNode和redis.h/zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
举个例子,图中用虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3
3.跨度。层的跨度(level[i].span属性)用于记录两个节点之间的距离1.两个节点之间的跨度越大,它们相距得就越远2.指向NULL的所有前进指针的跨度都为0.因为它们没有连向任何节点。遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的;在查找某个节点的过程中,将沿途访问的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位
4.后退指针。节点的后退指针(backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点,图中用虚线展示了如果从表尾向表头遍历跳跃表中的所有节点:程序首先通过跳跃表的tail指针访问表尾节点,然后通过后退指针访问倒数第二个节点,之后再沿着后退指针访问倒数第三个节点,再之后遇到指向NULL的后退指针,于是访问结束
举个例子,在图中的跳跃表中,三个跳跃表节点都保存了相同的分值10086.0,但保存成员对象的o1节点却排在成员对象o2和o3的节点之前,而保存成员对象o2的节点又排在保存成员对象o3的节点之前,由此可见o1、o2、o3三个成员对象中的排序为o1<=o2<=o3
跳跃表节点。跳跃表节点的实现由redis.h/zskiplistNode结构定义
zskiplist结构的定义如下:
跳跃表。仅靠多个跳跃表节点就可以组成一个跳跃表,如图所示。但通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表地表头节点和表尾节点,或者快速地获取跳跃表节点地数量(也即是跳跃表的长度)等信息
跳跃表
举个例子,如果创建一个只包含五个元素的集合键,并且集合中的元素都是整数值,那么这个集合键的底层实现就会是整数集合
概述。整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个结合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
举个例子,1.encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值2.length属性的值为5,表示整数集合包含五个元素3.contents数组按从小到大的顺序保存着集合中的五个元素4.因为每个集合元素都是int16_t类型的整数值,所以contents数组的大小等于sizeof(int16_t) * 5 = 16 * 5 = 80位
举个例子。假设现在有一个INTSET_ENC_INT16编码的整数集合,集合中包含三个int16_t类型的元素。如图所示。因为每个元素都占用16位空间,所以整数集合底层数组的大小为16*3=48位,
虽然程序对底层数组进行了空间重分配,但数组原有的三个元素1、2、3仍然是int16_t类型,这些元素还保存在数组的前48位里面,所以程序接下来要做的就是将这三个元素转换为int32_t类型,并将转换后的元素放置到正确的位上面,而且在防止元素的过程中,需要维持底层数组的有序性质不变
首先,因为3在1、2、3、65535四个元素中排名第三,所以它将被转移到contents数组的索引2位置上,也即是数组64位至96位的空间内,如图
接着,因为2在1、2、3、65535四个元素中排名第二,所以它将被移动到contents数组中索引1位置上,也即是32位至63位的空间内,如图
之后,因为元素1在1、2、3、65535四个元素中排名第一,所以它将被移动到contents数组的索引0位置上,即数组的0位至31位的空间内,如图
然后,因为元素65535在1、2、3、65535四个元素中排名第四,所以它将被移动到contents数组的索引3位置上,也即是数组的96位至127位的空间内
最后,程序将整数集合encoding属性的值从INTSET_ENC_INT16改为INTSET_ENC_INT32,并将length属性的值从3改为4,设置完成之后的整数集合如图。因为每次向整数集合添加元素都可能会引起升级,而每次神各级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。其他类型的升级操作,比如INTSET_ENC_INT16编码升级为INTSET_ENC_INT64编码,或者从INTSET_ENC_INT32编码升级为INTSET_ENC_64编码
现在,假设要将类型为int32_t的整数值65535添加到整数集合里面,因为65535的类型int32_t比整数集合当前所有的元素的类型都要长,所以在将65535添加到整数集合之前,程序需要先对整数集合进行升级。升级首先要做的是,根据新类型的长度,以及集合元素的数量(包括要添加的新元素在内),对底层数组进行空间重分配。整数集合目前有三个元素,再加上新元素65535,整数集合需要分配四个元素的空间,因为每个int32_t整数值需要占用32位空间,所以在空间重分配之后,底层数组的大小将是32 * 4 = 128位,
升级之后新元素的摆放位置。因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素。1.在新元素小于所有现有元素的情况下,新元素会被放置在底层 数组的最开头(索引0)2.在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)
提升整数集合的灵活性。因为C语言是静态类型语言,为了避免类型错误,通常不会讲两种不同类型的值放在同一个数据结构里面。例如,一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数组来保存int32_t类型的值。但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误
尽可能地节约内存。要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存
例如,如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会一直是int16_t类型的数组,只有在我们要将int32_t类型或者int64_t类型的值添加到集合时,程序才会对数组进行升级
升级的好处。
升级。每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。升级整数集合并添加新元素共分为三步进行:1.根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间2.将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素防止到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变3.将新元素添加到底层数组里面
举个例子,即使将集合里唯一一个真正需要使用int64_t类型来保存的元素4294967295删除了,整数集合的编码仍然会维持INTSET_ENC_INT64底层数组也仍然会是int64_t类型
降级。整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态
整数集合
例如,执行以下命令将创建一个压缩列表实现的列表键,列表键里面包含的都是1、3、5、10086这样的小整数值,以及\"hello\"、\"world\"这样的短字符串。另外,当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。
举个例子,执行以下命令将创建一个压缩列表实现的哈希键:哈希键里面包含的所有键和值都是小整数值或者短字符串。
概述。压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
压缩列表的构成。压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
图中展示了一个包含一字节长previous_entry_length属性的压缩列表节点,属性 的值为0x05,表示前一节点的长度为5字节
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。1.首先,拥有指向压缩列表表尾节点entry4起始地址的指针p1(指向表尾节点的指针可以通过指向压缩列表起始地址的指针加上zltai属性的值得出)2.通过p1减去entry4节点previous_entry_length属性的值,得到一个指向entry4前一个节点entry3起始地址的指针p23.通过p2减去entry3节点previous_entry_length属性的值,得到一个指向entry3前一节点entry2起始地址的指针p34.通过用p3减去entry2节点previous_entry_length属性的值,得到一个指向entry2前一节点最终,从表尾节点向表头节点遍历了整个列表
encoding。节点的encoding属性记录了节点的content属性所保存数据的类型及长度:1.一字节、两字节或者五字节长,值得最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录2.一字节长,值得最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码出去最高两位之后的其他位记录
举个例子,图中展示了一个保存字节数组的节点示例1.编码的最高两位00表示节点保存的是一个字节数组2.编码的后六位001011记录了字节数组的长度11;3.content属性保存着节点值\"hello world\"
另一个例子。图中展示了一个保存整数值的节点示例1.编码11000000表示节点保存的是一个int16_t类型的整数值2.content属性保存着节点的值10086
content。节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
压缩列表节点的构成。每个压缩列表节点可以保存一个字节数组或者一个整数值,其中,字节数组可以是以下三种长度之一:1.长度小于等于63(2 ^ 6 - 1)字节的字节数组2.长度小于等于16383(2 ^ 14 - 1)字节的字节数组3.长度小于等于4294967295(2 ^ 32 - 1)字节的字节数组而整数值则可以是以下六种长度之一:1.4位长,介于0至12之间的无符号整数2.1字节长的有符号整数3.3字节长的有符号整数4.int16_t类型整数5.int32_t类型整数6.int64_t类型整数每个压缩列表节点都由previous_entry_length、encoding、content三个部分组成
因为e1的previous_entry_length属性仅长1字节,它没有办法保存新节点new的长度,所以程序将对压缩列表执行空间重分配操作,并将e1节点的previous_entry_length属性从原来的1字节长扩展为5字节长。现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度使用1字节长的previous_entry_length属性是没法保存的。因此,为了让e2的previous_entry_length属性可以记录下e1的长度,程序需要再次对压缩列表执行空间重分配操作,并将e2节点的previous_entry_length属性从原来的1字节长扩展为5字节长.正如扩展e1引发对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展...为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止
除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新
压缩列表
数据结构
概述。Redis并没有直接使用简单动态字符串(SDS)、双端链表、字典、压缩列表、整数集合等这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。除此之外,Redis的对象系统还实现了基于引用计数计数的内存回收机制,当程序不再使用某个对象的时候,这个对象所占用的内存就会被自动释放;另外,Redis还通过引用计数计数实现了对象共享机制,这一机制可以在适当的条件下,通过让多个数据库键共享同一个对象来节约内存。最后,Redis的对象带有访问时间记录信息,该信息可以用于计算数据库键的空转市场,在服务启用了maxmemory功能的情况下,空转市场较大的那些键可能会优先被服务器删除
举个例子,以下SET命令在数据库中创建了一个新的键值对,其中键值对的键是一个包含了字符串值\"msg\"得对象,而键值对得值则是一个包含了字符串值\"hello world\"得对象:```c127.0.0.1:6379> SET msg \"hello world\"OK```
概述。Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象).Redis中的每个对象都由一个redisObject结构表示,该结构中的保存数据有关的三个属性分别是type属性、encoding属性和ptr属性
TYPE命令的实现方式也与此类似,当对一个数据库键执行TYPE命令时,命令返回的结果为数据库键对应的值对象的类型,而不是键对象的类型:例子如代码所示
图中列出了TYPE命令在面对不同类型的值对象时所产生的输出
类型。对象的types属性记录了对象的类型,这个属性的值可以是图中列出的常量中的其中一个。对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序对象的其中一种。1.当称呼一个数据库键为\"字符串键\"时,指的是\"这个数据库键所对应的值为字符串对象\";2.当称呼一个键为\"列表键\"时,指的是\"这个数据库键所对应的值为列表对象\"
每种类型的对象都至少使用了两种不同的编码,图中列出了每种类型的对象可以使用的编码。
使用OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码
图中列出了不同编码的对象所对应的OBJECT ENCODING命令输出。
```cstruct sdshdr { // 记录buf数组中已使用字节的数量 // 等于SDS保存字符串的长度 4byte int len; // 记录buf数组中未使用字节的数量 4byte int free; // 字节数组,用于保存字符串 字节\\0结尾的字符串占用了1byte char buf[];}3.2版本SDS结构
sdshdr5的结构如图
sdshdr8的结构如图
String对象为什么把大于39字节或者44字节的字符串编码为raw,小于的时候编码为embstr?font color=\"#000000\
对象的类型与编码。
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void *转换成long),并且将字符串对象的编码设置为int举个例子,如果执行以下SET命令,那么服务器将创建一个如图所示的int编码的字符串对象作为number键的值:```c127.0.0.1:6379> SET number 10086OK127.0.0.1:6379> OBJECT ENCODING number\"int\"```
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于(3.2版本之前是39字节,之后变成44字节,后面再分析),那么字符串对象将使用简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw.举个例子,如果执行以下命令,那么服务器将创建一个如图所示的字符串对象作为story键的值:```c127.0.0.1:6379> SET stroy \
举个例子,以下命令创建了一个embstr编码的字符串对象作为msg键的值,值对象的样子,```c127.0.0.1:6379> SET msg \"hello\"OK127.0.0.1:6379> OBJECT ENCODING msg\"embstr\"```
如果字符串对象的保存的是一个字符串值,并且这个字符串值的长度小于等于(39或44字节),那么字符串对象将使用embstr编码的方式来保存这个字符串值。embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都是用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构,如图所示。embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果时相同的,但使用eembstr编码将创建字符串对象来保存短字符串值有以下好处:1.embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。2.释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数3.因为embstr编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这中编码的字符串对象比起raw编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
举个例子,执行以下代码将创建一个包好3.14的字符串表示\"3.14\"的字符串对象:```c127.0.0.1:6379> SET pi 3.14OK127.0.0.1:6379> OBJECT ENCODINg pi\"embstr\"```在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里面.
举个例子,如果执行以下代码:```c127.0.0.1:6379> INCRBYFLOAT pi 2.0\"5.140000000000001\"127.0.0.1:6379> OBJECT ENCODING pi\"embstr\"```那么程序首先会取出字符串对象里面保存的字符串值\"3.14\",将它转化回浮点数值3.14,然后再把3.14和2.0相加得出的值5.14转换成字符串\"5.14\",并将这个\"5.14\"保存到字符串对象里面。
还可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果要保存一个浮点到字符串对象里面,那么程序会先将这个f浮点数转换成字符串值,然后再保存转换所得的字符串值。
举个例子,通过APPEND命令,向一个保存整数值的字符串对象追加了一个字符串值,因为追加操作只能对字符串值执行,所以程序会先将之前保存的整数值10086转换为字符串值\"10086\
以下代码展示了一个embstr编码```c127.0.0.1:6379> SET msg \"hello world\"OK127.0.0.1:6379> OBJECT ENCODING msg\"embstr\"127.0.0.1:6379> APPEND msg \" again!\"(integer) 18127.0.0.1:6379> OBJECT ENCODING msg\"raw\"```
编码的转换。int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的对象。对于int编码的字符串对象来说,如果向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值。那么字符串对象的编码将从int变为raw.另外,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序),所以embstr编码的字符串对象实际上是只读的。当对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。因为这个原因,embstr编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。
字符串对象。字符串对象的编码可以是int、raw或者embstr。
举个例子,如果执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值```c127.0.0.1:6379> RPUSH numbers 1 \"three\" 5(integer) 3```
举个例子,如果上面的number键创建按的列表对象使用的不是ziplist编码,而是linkedlist编码,那么numbers键的值对象将会是如图所示
注意:为了简化字符串对象的表示,在上面的图中使用了一个带有StringObject字样的格子来表示一个字符串对象,而StringObject字样下面的是字符串对象所保存的值。比如说,如图所示的就是一个包含了字符串值\"three\"的字符串对象
概述。ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。例子如下。如果numbers键的值对象使用的是ziplist编码,这个这个值对对象将会是如图所示的样子。另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在哈希对象、集合对象、有序集合对象中都会出现,字符串对象是Redis五种类型的对象中唯一一种会被其他四种对象嵌套的对象
注意:以上两个条件的上限值时可以修改的,具体看配置文件中关于list-max-ziplist-value选项和list-max-ziplist-entries选项
举个例子,代码展示了列表对象因为保存了长度太大的元素而进行编码转换的情况
编码转换。当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:1.列表对象保存的所有字符串元素的长度都小于64字节2.列表对象保存的元素数量小于512个;不能满足这两个条件的列表对象需要使用linkedlist编码对于使用ziplist编码的列表对象来说,当使用ziplist编码所需的两个条件的任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面,对象的编码也会从ziplist变为linkedlist
列表对象。列表对象的编码可以是ziplist或者linkedlist.
举个例子,如果执行以下HSET命令,那么服务器将创建一个列表对象作为profile键的值,如果profile键的值对象使用的是ziplist编码,那么这个值对象将会是图中的结构,其中对象所使用的压缩列表
举个例子,前面profile键创建的不是ziplist编码的哈希对象,而是hashtable编码的哈希对象,那么这个哈希对象应该会是t图中的结构
概述。ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:1.保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后。2.先添加到哈希对象中的键值对会被放在压缩列表的表头方向(相对于值对象来说),而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:1.字典的每个键都是一个字符串对象,对象中保存了键值对的键2.字典的每个值都是一个字符串对象,对象中保存了键值对的值
注意。这两个条件的上限值时可以修改的,具体要看配置文件中关于hash-max-ziplist-value选项和hash-max-ziplist选项说明
代码展示了哈希对象因为键值对的键长度太大而引起编码转换的情况
除了键的长度太大会引起编码转换之外,值得长度太大也会引起编码转换
最后,代码展示了哈希对象因为包含的键值对数量过多而引起编码转换的情况,
编码转换。当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:1.哈希对象保存的所有键值对的键和值的字符串长度都小于64字节2.哈希对象保存的键值对数量小于512个不能满足这两个条件的哈希对象需要使用hashtable编码对于使用ziplist编码的列表对象,当使用ziplist编码所需的两个条件任意一个不能被满足时,对象的编码转换操作就会被执行,原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面,对象的编码也会从ziplist变为hashtable.
哈希对象。哈希对象的编码可以是ziplist或者hashtable。
举个例子。以下代码创建一个如图所示的intset编码集合对象```c127.0.0.1:6379> SADD numbers 1 3 5(integer) 3```
举个例子,以下代码创建一个如图所示的hashtable编码集合对象```c127.0.0.1:6379> SADD fruits \"apple\" \"banana\" \"cherry\"(integer) 3```
概述。intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。另一方面,hashtable编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合对象,而字典的值则全部被设置为NULL.
注意:第二个条件的上限值是可以修改的,具体请看配置文件中关于set-max-intset-entries选项的说明
举个例子,以下代码创建了一个只包含整数的集合对象,该对象的编码为intset:```c127.0.0.1:6379> SADD numbers 1 3 5(integer) 3```不过,只要我们向这个只包含整数元素的集合对象添加一个字符串元素,集合对象的编码转移操作就会被执行:```c127.0.0.1:6379> SADD numbers \"seven\"(integer) 1127.0.0.1:6379> OBJECT ENCODING numbers\"hashtable\"```除此之外,如果我们创建一个包含512个整数元素的集合对象,那么对象的编码应该会是intset:```c127.0.0.1:6379> EVAL \
编码的转换。当集合对象可以同时满足以下两个条件时,对象使用intset编码:1.集合对象保存的所有元素都是整数值2.集合对象保存的元素不超过512个不能满足中两个条件的集合对象需要使用hashtable编码对于使用intset编码的集合对象来说,当使用intset编码所需的两个条件的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在整数集合中的所有元素都会被转移并保存到字典里面,并且对象的编码也会从intset变为hashtable
集合对象。集合对象的编码可以是intset或者hashtable.
举个例子,如果执行以下ZADD命令,那么服务器将创建一个有序集合作为price键的值:```c127.0.0.1:6379> ZADD price 8.5 apple 50 banana 6.0 cherry(integer) 3```如果price键的值对象使用的是ziplist编码,那么这个值对象将会是如图所示的样子,对象使用的压缩列表则会是如图所示的样子
ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score).压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在表头的位置(相对分值节点来说),而分值较大的元素则被放置在靠近表尾的位置。
注意:为了展示方便,在字典和跳跃表中重复展示了各个元素的成员和分值,但在实际中,字典和跳跃表会共享元素的成员和分值,所以并不会造成任何数据重复,也不会因此而浪费任何内存。
举个例子,如果前面price键创建的不是ziplist编码的有序集合对象,而是skiplist编码的有序集合对象,那么这个有序集合对象将会如图所示,而对象所使用的zset结构也将如图所示
概述。
注意:以上两个条件的上限值是可以修改的,具体请看配置文件中关于zset-max-ziplist-entries和zset-max-ziplist-value选项的说明
以下代码则展示了有序集合对象因为元素的成员过长而引起编码转换的情况```c// 向有序集合添加一个成员只有三字节长的元素127.0.0.1:6379> ZADD blah 1.0 www(integer) 1127.0.0.1:6379> OBJECT ENCODING blah\"ziplist\"// 向有序集合添加一个成员为66字节长的元素ZADD blah 2.0 ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo// 编码已改变127.0.0.1:6379> OBJECT ENCODING blah\"skiplist\"```
编码的转换。当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码1.有序集合保存的元素数量小于128个2.有序集合保存的所有元素成员的长度都小于64字节不能满足以上两个条件的有序集合对象将使用skiplist编码对于使用ziplist编码的有序集合对象来说,当使用ziplist比那吗所需的两个条件中的任意一个不能被满足时,程序就会执行编码转换操作,将原本储存在压缩列表里面的所有集合元素转移到zset里面,并将对象的编码从ziplist改为skiplist
有序集合对象。有序集合的编码可以是ziplist或者skiplist。
对象
举个例子,以下代码就展示了使用DEL命令来删除三种不同类型的键:```c// 字符串键127.0.0.1:6379> SET msg \"hello\"OK// 列表键127.0.0.1:6379> RPUSH numbers 1 2 3(integer) 3// 集合键127.0.0.1:6379> SADD fruits apple banana cherry(integer) 3127.0.0.1:6379> DEL msg(integer) 1127.0.0.1:6379> DEL numbers(integer) 1127.0.0.1:6379> DEL fruits(integer) 1```
举个例子,我们可以用SET命令创建一个字符串键,然后用GET命令和APPEND命令操作这个键,但如果我们试图对这个键执行只有列表键才能执行的LLEN命令,那么Redis将向我们返回一个类型错误```c127.0.0.1:6379> SET msg \"hello world\"OK127.0.0.1:6379> GET msg\"hello world\"127.0.0.1:6379> APPEND msg \" again!\"(integer) 18127.0.0.1:6379> GET msg\"hello world again!\"127.0.0.1:6379> LLEN msg(error) WRONGTYPE Operation against a key holding the wrong kind of value```
概述。redis中用于操作键的命令基本上可以分为两种类型。其中一种命令可以对任何类型的键执行,比如说DEL命令、EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等.而另一种命令只能对特定类型的键执行,比如说1.SET、GET、APPEND、STRLEN等命令只能对字符串键执行;2.HDEL、HSET、HGET、HLEN等命令只能对哈希键执行3.RPUSH、LPOP、LINSERT、LLEN等命令只能对列表键执行4.SADD、SPOP、SINTER、SCARD等命令只能对集合键执行5.ZADD、ZCARD、ZRANK、ZSCORE等命令只能对有序集合键执行
举个例子,对于LLEN命令来说:1.在执行LLEN命令之前,服务器会先检查输入数据库键的之对象是否为列表类型,也即是,检查值对象redisObject结构type属性的值是否为REDIS_LIST.如果是的话,服务器就对键执行LLEN命令2.否则的话,服务器就拒绝执行命令并向客户端返回一个类型错误。检查过程如图
举个例子,列表对象有ziplist和linkedlist两种编码可用,其中前者使用压缩列表API来实现列表命令,而后者则使用双端链表API来实现列表命令。
如图展示了LLEN命令从类型检查到根据编码选择实现函数的整个执行过程,其他类型特定命令的执行过程也是类似
多态命令的实现。Redis除了会根据值对象的类型来判断是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。现在,考虑这样一个情况,如果对一个键执行LLEN命令,那么服务器除了要确保执行命令的是列表键之外,还需要根据键的值对象所使用的编码来选择正确的LLEN命令实现:1.如果列表对象的编码为ziplist,那么说明列表对象的实现为压缩列表,程序将使用ziplistLen函数来返回列表的长度2.如果列表对象的编码为linkedlist,那么说明列表对象的实现为双端链表,程序将使用listLength函数来返回双端链表的长度用面向对象的术语来说,可以认为LLEN命令是多态的,只要执行LLEN命令的是列表键,那么无论值对象使用的是ziplist编码还是linkedlist编码,命令都可以正常执行实际上,可以将DEL、EXPIRE、TYPE等命令也称多态命令,因为无论输入的键是什么类型,这些命令都可以正确地执行,。DEL、EXPIRE等命令和LLEN等命令地区别在于,前者是基于类型地多态——一个命令可以同时用于处理多种不同类型地键,而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码
类型检查与命令多态。
对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。举个例子,以下代码展示了一个字符串对象从创建到释放的整个过程```c// 创建一个字符串对象s,对象的引用计数为1robj *s = createStringObject(....);// 对象s执行各种操作...// 将对象s的引用计数减一,使得对象的引用计数变为0// 导致对象s被释放decrRefCount(s);```其他不同类型的对象也会经历类似的过程
概述。因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。每个对象的引用计数信息由redisObject结构的refcount属性记录:```ctypedef struct redisObject { // ... // 引用计数 int refcount; // ...} robj;```对象的引用计数信息会随着对象的使用状态而不断变化:1.在创建一个新对象时,引用计数的值会被初始化为12.当对象被一个新程序使用时,它的引用计数值会被增一3.当对象不再被一个程序使用时,它的引用计数值会被减一4.当对象的引用计数值变为0时,对象所占用的内存会被释放
内存回收。
举个例子,假设键A创建了一个包含整数值100的字符串对象作为值对象,如图所示
如果这时键B也要创建一个同样保存了整数100的字符串对象作为之对象,那么服务器有以下两种做法:1.为键B新创建一个包含整数值100的字符串对象2.让键A和键B共享同一个字符串以上两种方法明显是第二种方法更节约内存
概述。除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。在Redis中,让多个键共享同一个值对象需要执行以下两个步骤:1.将数据库键的值指针指向一个现有的值对象2.将被共享的值对象的引用计数增一目前来说,Redis在初始化服务器时,创建一万个字符串对象,这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象
注意:创建共享字符串对象的数量可以通过修改redis.h/REDIS_SHARED_INTEGERS常量来修改
另外,这些共享对象不仅只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist编码的列表对象、hashtable编码的哈希对象、hastable编码的集合对象,以及zset编码的有序集合对象)都可以使用这些共享对象。
为什么Redis不共享包含字符串的对象?当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享目标和目标对象是否完全相同所需的复杂度就会越高,消耗的CPU时间也会越多:1.如果共享对象是保存整数值的字符串,那么一年挣操作的复杂度为O(1)2.如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)3.如果共享对象是包含了多个值(或者对象)的对象,比如列表对象或者哈希对象,那么验证操作的复杂度为O(N^2)因此,尽管共享更复杂的对象可以节约更多的内存,但受到CPU时间的限制,Redis只对包含整数值的字符串对象进行共享
对象共享。
```c127.0.0.1:6379> SET msg \"hello world\"OK// 等待一小段时间127.0.0.1:6379> OBJECT IDLETIME msg(integer) 11// 等待一阵子127.0.0.1:6379> OBJECT IDLETIME msg(integer) 16// 访问msg键的值127.0.0.1:6379> GET msg\"hello world\"// 键处于活跃状态,空转时长为0127.0.0.1:6379> OBJECT IDLETIME msg(integer) 5```
概述。redisObject除了type、encoding、ptr和refcount四个属性之外,还包含最后一个属性lru属性,该属性记录了对象最后一次被命令程序访问的时间```ctypedef struct redisObject { // ... unsigned lru:22; // .... } robj;```OBJECT IDLETIME命令可以打印出给定键的空转市场,这一空转时长就是通过将当前时间减去键的之对象的lru时间计算得出的:
注意:OBJECT IDLETIME命令的实现比较特殊,这个命令在访问键的值对象时,不会修改值对象的lru属性
对象的空转时长。
TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,也就是,返回距离这个键被服务器自动删除还有多长时间```c127.0.0.1:6379> SET key valueOK127.0.0.1:6379> EXPIRE key 1000(integer) 1127.0.0.1:6379> TTL key(integer) 996```
保存过期时间。redisDb结构的expire字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:1.过期字典的键是一个指针,这个指针指向键是一个指针,这个指针指向键空间的某个键对象(也即是某个数据库键)2.过期字典的值是一个long long类型的整数。这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳。```ctypedef struct redisDb { // .. // 过期字典,保存着键的过期时间 dict *expire; // ...}redisDb;```
设置键的生存时间或过期时间
例如,如果正有大量的命令请求在等待服务器处理,并且服务器当前不缺少内存,那么服务器应该优先将CPU时间用在处理客户端的命令请求上面,而不是用在删除过期键上面
除此之外,创建一个定时器需要用到Redis服务器中的时间事件,而当前事件事件的实现方式——无序链表,查找一个事件的事件复杂度为O(N)——并不能高效地处理大量时间事件。因此,要让服务器创建大量的定时器,从而实现定时删除策略,在现阶段来说并不现实
定时删除。定时删除策略对内存时最优化的:通过使用定时器,定时删除策略可以保证过期键会尽快地被删除,并释放过期键所占用地内存。另一方面,定时删除策略的缺点就是,它是对CPU时间最不友好的,在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,在内存不紧张但是CPU时间非常紧张的情况下,将CPU时间用在删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。
举个例子,对于一些和时间相关的数据,比如日志(log),在某个时间点之后,对他们的访问就会大大减少,甚至不再访问,如果这类过期数据大量地积压在数据库中,用户以为服务器已经自动将它们删除了,但实际上这些键仍然存在,而且键所占用地内存也没有释放,那么造成的后果肯定是非常严重的。
惰性删除。惰性删除策略对CPU事件来说是最友好的:程序只会在取出键时才对键进行过期检查,这可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限当前处理的键,这个策略不会在删除其他无关的键上花费任何CPU事件。惰性删除的缺点是,它对内存是最不友好的,如果一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放。在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远不会被删除(除非用户手动执行FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏——无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的Redis服务器来说,肯定不是一个好消息
定期删除。从定时删除和惰性删除分析来看,这两种删除方式在单一使用时都有明显的缺陷:1.定时删除占用太多CPU时间,影响服务器的响应时间和吞吐量2.惰性删除浪费太多内存,有内存泄漏的危险定期删除策略是前两种策略的一种整合折中:1.定期删除策略每隔一段时间执行一次删除过期操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响2.除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费定期删除策略的难点是确定删除操作执行的时长和频率1.如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将CPU时间过多地消耗在删除过期键上面2.如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作地执行时长和执行频率
过期键删除策略。
概述。Redis服务器实际使用地是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡
命令调用expireIfNeeded函数的过程。expireIfNeeded函数就像一个过滤器,它可以在命令真正执行之前,过滤掉过期的输入键,从而避免命令接触到过期键。
举个例子,如图所示展示了GET命令的执行过程,在这个执行过程中,命令需要判断键是否存在以及键是否过期,然后根据判断来执行合适的动作
惰性删除策略的实现。过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeed函数对输入键进行检查:1.如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除2.如果输入键未过期,那么expireIfNeeded函数不做动作另外,因为每个被访问的键都可能存在过期而被expireIfNeeded函数删除,所以每个命令的实现函数都必须同时处理键存在以及键不存在这两种情况:1.当键存在时,命令按照键存在的情况执行2.当键不存在或者因为过期而被expireIfNeeded函数删除时,命令按照键不存在的情况执行
```c# 默认每次检查的数据库数量DEFAULT_DB_NUMBERS = 16# 默认每个数据库检查的键数据量DEFAULT_KEY_NUMBERS = 20;# 全局变量,记录检查进度current_db= 0def activeExpireCycle() : # 初始化要检查的数据库数量 # 如果服务器的数据库数量比DEFAULT_DB_NUMBERS药效 # 那么以服务器的数据库数量为准 if server.dbnum < DEFAULT_DB_NUMBERS: db_numbers = server.dbnum else: db_numbers = DEFAULT_DB_NUMBERS # 遍历各个数据库 for i in range(db_numbers): # 如果current_db的值等于服务器的数据库数量 # 这表示检查程序已经遍历了服务器的所有数据库一次 # 将current_db重置为0,开始新的一轮遍历 if current_db = server.dbnum current_db = 0 # 获取当前要处理的数据库 redisDb = server.db[current_db] # 将数据库索引增1,指向下一个要处理的数据库 current_db += 1 # 检查数据库键 for j in range(DEFAULT_KEY_NUMBERS): # 如果数据库中没有一个键带有过期时间,那么跳过这个数据库 if redisDb.expires.size() == 0: break # 随机获取一个带有过期时间的键 key_with_ttl = redisDb.expires.get_random_key() # 检查键是否过期,如果过期就删除它 if is_expired(key_with_ttl): delete_key(key_with_ttl) # 已达到时间上线,停止处理 if reach_time_limit() :return```伪代码实现
activeExpireCycle函数的工作模式可以总结如下:1.函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键2.全局变量current_db会记录当前activeExpireCycle函数检查的进度,在下一次activeExpireCycle函数被调用时,接着上一次的进度进行处理,比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键3.随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作
定期删除策略的实现。过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内分多次调用服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。
Redis的过期键删除策略。
举个例子,如果数据库中包含三个键k1、k2、k3,并且k2已经过期,那么当执行SAVE命令或者BGSAVE命令时,程序只会将k1和k3的数据保存到RDB文件中,而k2则会被忽略。因此,数据库中包含过期键不会对新生成的RDB文件造成影响。
生成RDB文件。在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
举个例子,如果数据库中包含三个键k1、k2、k3,并且k2已经过期,那么当服务器启动时:1.如果服务器以主服务器模式运行,那么程序只会将k1和k3载入到数据库,k2会被忽略2.如果服务器以从服务器模式运行,那么k1、k2和k3都会被载入到数据库
载入RDB文件。在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:1.如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响。2.如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响
举个例子。如果客户端使用GET message命令,试图访问过期的message键,那么服务器将执行以下三个动作:1.从数据库中删除message键2.追加一条DEL message命令到AOF文件3.向执行GET命令的客户端返回空回复
AOF文件写入。当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但他还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式地记录该键已被删除。
AOF重写。和生成RDB文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。
假设在此之后,有客户端向主服务器发送命令GET message,那么主服务器将发现键message已经过期:主服务器会删除message键,向客户端返回空回复,并向从服务器发送DEL message命令
从服务器在接收到主服务器发来的DEL message命令之后,也会从数据库中删除message键,在这之后,主从服务器都不再保存过期键message了
复制。当服务器运行在复制模式下,从服务器的过期删除动作由主服务器控制:1.主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键2.从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键3.从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在
AOF、RDB和复制功能对过期键的处理。
数据结构与对象
举个例子,图中的A每隔5s访问一次,B每2s访问一次,C与D每10s访问一次,| 代表计算空闲时间的截止点。可以看到,LRU对ABC工作的很好,完美预测了将来被访问到的概率B>A>C,但对于D却预测了最少的空闲时间,但总体来说,LRU算法已经是一个性能足够好的算法了
概述。Redis作为缓存使用时,一些场景下要考虑内容的空间消耗问题。Redis会删除过期键以释放空间,过期键的删除策略有两种:1.惰性删除:每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。2.定期删除:每隔一段事件,程序就对数据库进行一次检查,删除里面的过期键。Redis也可以开启LRU功能来自动淘汰一些键值对。
LRU配置参数。Redis配置中和LRU有关的有三个:1.maxmemory:配置Redis存储数据时指定限制的内存大小,比如100m。当缓存消耗的内存超过这个数值时,将触发数据淘汰。该数据配置为0时,表示缓存的数据量没有限制,即LRU功能不生效。64位的系统默认值为0,32位的系统默认内存限制为3GB2.maxmemory_policy:触发数据淘汰后的淘汰策略3.maxmemory_samples:随机采样的精度,也就是随机取出key的数目。该数值配置越大,越接近真实的LRU算法,但是数值越大,相应消耗也变高,对性能有一定影响,样本值默认为5.
真实LRU算法与近似LRU的算法可以通过下面的图像对比。浅灰色是已经被淘汰的对象,灰色带是没有被淘汰的对象。可以看出,maxmemory-samples值为5时,Redis3.0效果要比Redis2.0要好,使用10个采样大小的Redis3.0的近似LRU算法已经非常接近理论的性能,数据访问模式非常接近幂次分布时,也就是大部分的访问集中于部分键时,LRU近似算法会处理得很好。在模拟实验的过程中,我们发现如果使用幂次分布的访问模式,真实LRU算法和近似LRU算法几乎没有差别
Redis中的键与值都是redisObject对象```ctypedef struct redisObject { // 4位 unsigned type:4; // 4位 unsigned encoding:4; // 24位 // LRU time (相对于全局的lru_clock) or // LFU data(低8位是频率,高16位是访问时间) unsigned lru:LRU_BITS; // 4个字节 32位 int refcount; // 8个字节 64位 void *ptr;} robj;```unsigned的低24bits的lru记录了 redisObj的LRU time
freeMemoryIfNeeded.cpp
evictionPoolPopulate.cpp
evictionPoolPopulate函数
LRU源码分析。
redis.conf文件中有示例值,如图所示。Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令
SAVE和BGSAVE的区别。创建RDB文件的实际工作由rdb.c/rdbSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用这个函数,通过以下伪代码可以明显地看出这两个命令之间的区别:```cdef SAVE(): # 创建RDB文件 rdbSave() def BGSAVE(): # 创建子进程 pid = fork() if pid == 0: # 子进程负责创建RDB文件 rdbSave() # 完成之后向父进程发送信号 signal_parent(); elif pid > 0: # 父进程继续处理命令请求,并通过轮询等待子进程的信号 handle_request_and_wait_signal() else: # 处理出错情况```
载入RDB文件的实际工作由rdb.c/rdbLoad函数完成,这个函数和rdbSave函数之间的关系可以用图表示
注意.值得一提的是,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:1.如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态2.只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。服务器判断该用哪个文件来还原数据库状态的流程如图所示。服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入共工作完成为止。
SAVE命令执行时的服务器状态.当SAVE命令执行时,Redis服务器会被阻塞,所以当SAVE命令正在执行时,客户端发送的所有命令请求都会被阻塞。只有在服务器执行完SAVE命令、重新开始接受命令请求之后,客户端发送的命令才会被处理
BGSAVE命令执行时的服务器状态。因为BGSAVE的保存工作是由子进程执行的,所以在子进程创建RDB文件的过程中,Redis服务器仍然可以继续处理客户端的命令请求,但是,在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式会和平时有所不同.首先,在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件。其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件,代码如下```c127.0.0.1:6379> BGSAVEBackground saving started127.0.0.1:6379> BGSAVE(error) ERR Background save already in progress```最后,BGREWRITEAOF和BGSAVE两个命令不能同时执行:1.如果BGSAVE命令正在z执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行2.如果BGREWRITEAOF和BGSAVE两个命令的实际工作都由子进程执行,所以这两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑——并发出两个子进程,并且这两个子进程都同时执行大量的磁盘写入操作
RDB文件的创建与载入。有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE.SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求:```c127.0.0.1:6379> saveOK```和SAVE命令直接阻塞服务器进程的做法不同,BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求:```c127.0.0.1:6379> BGSAVEBackground saving started```
举个例子,比如说,如果save选项的值为以下条件```csave 900 1save 300 10save 60 10000```那么服务器状态中的saveparams数组将会是如图所示
自动间隔保存。当Redis服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件:```csave 900 1save 300 10save 60 10000```接着,服务器程序会根据save选项所设置的保存条件,设置服务器状态redisServer结构的saveparams属性:```cstruct redisServer { // ... // 记录了保存条件的数组 struct saveparam *saveparams; // ...};```saveparams属性是一个数组,数组中的每隔元素是一个saveparam结构,每隔saveparam结构都保存了一个save选项设置的保存ll额一个save选项设置的保存条件:```cstruct saveparam { // 秒数 time_t seconds; // 修改 int changes;};```
例如。如果我们为一个字符串键设置值:```c127.0.0.1:6379> SET message \"hello\"OK```那么程序会将dirty计数器的值增加1。
又例如,如果像一个集合键增加三个新元素:```c127.0.0.1:6379> SADD database Redis MongoDB MariaDB(integer) 3```那么程序会将dirty计数器的值增加3。
如图所示,该图展示了服务器状态中包含的dirty计数器和lastsave属性,说明如下:1.dirty计数器的值为123,表示服务器在上次保存之后对数据库状态共进行了123次修改2.lastsave属性则记录了服务器上次执行保存的时间1378270800
举个例子,如果Redis服务器的当前状态如图所示.那么当时间来到1378271101,也即是1378270800的301秒之后,服务器将自动执行一次BGSAVE命令,因为saveparams数组的第二个保存条件——300秒之内有至少10次修改——已经被满足。
假设BGSAVE在执行5秒之后完成,那么如图所示的服务器状态将更新为如图所示。其中dirty计数器已经被重置为0,而lastsave属性也被更新为1378271106
检查保存条件是否满足。以下伪代码z展示了serverCron函数检查保存条件的过程:```pythondef serverCron(): # ... # 遍历所有保存条件 for saveparam in servr.saveparams: # 计算距离上次执行保存操作有多少秒 save_interval = unixtime_now() - server.lastsave # 如果数据库状态的修改次数超过条件所设置的次数 # 并且距离上次保存的时间超过条件设置的时间 # 那么执行保存操作 if server.dirty >= saveparam.changes and save_interval > saveparam.seconds: BGSAVE()```程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,那么服务器就会执行BGSAVE命令。
比如执行命令\"set aaaaa 666\
比如执行\"set aaaaaa 888 ex 1000\",对应aof文件里记录如下
可以通过修改配置文件来打开AOF功能:# appendonly yes从现在开始,每当Redis执行一个改变数据集的命令时(比如SET),这个命令就会被追加到AOF文件的末尾。这样的话,当Redis重新启动时,程序就可以通过重新执行AOF文件中的命令来达到重建数据集的目的。还可以配置Redis多久才将数据fsync到磁盘一次.推荐(并且也是默认)的措施为每秒fsync一次,这种fysnc策略可以兼顾速度和安全性
重写后AOF文件里变成
举个例子,如果对服务器对list键执行了以下命令:```c127.0.0.1:6379> RPUSH list \"A\" \"B\" // [\"A\
注意:在实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键所包含的元素数量,如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值为64,这也就是说,如果一个集合键包含了超过64个元素,那么重写程序会用多条SADD命令来记录这个集合并且每条命令设置的元素数量也为64个:```cSADD <set-key> <elem1><elem2>....<elem64>SADD <set-key> <elem65><elem66>...<elem128>SADD <set-key> <elem129><elem130>...<elem192>```另一方面如果一个列表键包含了超过64个项,那么重写程序会用多条RPUSH命令来保存这个集合,并且每条命令设置的项数量也为64个```cRPUSH <list-key> <item1><item2>...<item64>RPUSH <list-key> <item65><item66>...<item128>RPUSH <list-key> <item129><item130>...<item192>```重写程序使用类似的方法处理包含多个元素的有序集合键,以及包含多个键值对的哈希表键
AOF文件重写的实现。虽然Redis将生成新AOF文件替换旧AOF文件的功能命名为\"AOF文件重写\",但实际上,AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。
数据不一致问题。为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,如图所示。。这也就是说,在子进程执行AOF重写期间,服务器进程需要执行以下三个工作:1.执行客户端发来的命令2.将执行后的写命令追加到AOF缓冲区3.将执行后的写命令追加到AOF重写缓冲区这样一来可以保证:1.AOF缓冲区的内容会定期被写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行2.从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下共做:1.将AOF重写缓冲区中的所有内容写入到新的AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致2.对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧数据两个AOF文件的替换这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了。
举个例子,图中展示i了一个AOF文件后台重写的执行过程:1.当子进程开始重写时,服务器进程(父进程)的数据库中只有一个k1的键,当子进程完成AOF文件重写之后,服务器进程的数据库中已经多处了k2、k3、k4三个新键2.在子进程向服务器发送信号之后,服务器进程会将保存在AOF重写缓冲区里面记录的k2/k3/k4三个键的命令追加到新AOF文件的末尾,然后用新的AOF文件替换旧文件完成AOF文件后台重写操作
阻塞问题。在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。
AOF后台重写。虽然AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务,但是,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因为Redis服务器使用单个线程来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,服务器将无法处理客户端发来的命令请求。很明显,作为一种辅佐性的维护手段,Redis不希望AOF重写造成服务器无法处理请求,所以Redis决定将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:1.子进程进行AOF重写期间,服务器进程(父进程)可以继续处理命令请求2.子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。不过,使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。
AOF重写。AOF文件可能有太多没用指令,所以AOF会定期根据内存的最新数据生成AOF文件,例如,执行了如下几条命令:incre readcount
文件的写入和同步。为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,将一些数据写入到文件的时候,操作系统通产会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满,或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失。为此,系统提供了fsync和fdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性
AOF持久化的效率和安全性。服务器配置appendfsync选项的值直接决定AOF持久化功能的效率和安全性。1.当appendfsync的值为always时,服务器在每个事件循环都要讲aof_buf缓冲区中的所有内容写入到AOF文件并且同步AOF文件,所以always的效率时appendfsync选项三个当中最慢的一个,但从安全性来说,always也是最安全的,因为即使出现故障停机,AOF持久化也只会丢失一个事件循环中所产生的命令数据2.当appendfsync的值为everysec时,服务器在每个事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件并且每隔一秒就要在子线程中对AOF文件进行一次同步。从效率上来讲,everysec模式足够快,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据3.当appendfsync的值为no时,服务器在每隔事件循环都要将aof_buf缓冲区中的所有内容写入到AOF文件,至于何时对AOF文件进行同步,则由操作系统控制。因为处于no模式下的flushAppendOnlyFile调用无须执行同步操作,所以该模式下的AOF文件写入速度总是最快的,不过因为这种模式会在系统缓存中积累一段时间的写入数据,所以该模式的单次同步时长通常是三种模式中时间最长的。从平摊操作的角度来看,no模式和everysec模式的效率类似,当出现故障停机时,使用no模式的服务器将丢失上次同步AOF文件之后的所有写命令数据
例如,对于以下AOF文件来说```c*2$6SELECT$10*5$4SADD$6fruits$6banana$6cherry$5apple*3$3SET$3msg$5hello*5$5RPUSH$7numbers$3128$3256$3512```
因为aof_rewrite函数生成的AOF文件只包含还原当前数据库状态所必须的命令,所以新AOF文件不会浪费任何硬盘空间
AOF文件的载入与数据还原。因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。Redis读取AOF文件并还原数据库状态的详细步骤如下:1.创建一个不带网络连接的伪客户端(fake client):因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。2.从AOF文件中分析并读取出一条写命令3.使用伪客户端执行被读出的写命令4.执行步骤2和步骤3,直到AOF文件中的所有写命令被处理完毕为止当完成以上步骤之后,AOF文件所保存的数据库状态就会被万丈地还原出来,整个过程如图
AOF(append-only file)快照功能并不是非常耐久(durable):如果Redis因为某些原因而造成故障停机,那么服务器将丢失最近写入、且仍未保存到快照中的那些数据,从1.1版本开始,Redis增加了一种完全耐久的持久化方式:AOF持久化,将修改的每一条指令记录进文件appendonly.aof中(先写入OS Cache,每隔一段时间fsync到磁盘)
RDB和AOF比较。生产环境可以都启用,redis启动时如果既有rdb文件又有aof文件则优先选择aof文件恢复数据,因为aof一般来说数据更全一点
Redis 4.0混合持久化。重启Redis时,我们很少使用RDB来恢复内存状态,因为会丢失大量数据。我们通常使用AOF日志重放,到那时重放AOF日志性能相对RD来说要慢很多,这样在Redis实例很大的情况下,启动需要花费很长的时间。Redis4.0为了解决这个问题,带来一个新的持久化选项——混合持久化。通过配置可以开启混合持久化(必须先开启aof):# aof-use-rdb-preamble yes如果开启了混合持久化,AOF在重写时,不再是单纯地将内存数据转换为RESP写入AOF文件,而是将重写这一刻之前地内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,覆盖原有的AOF文件,完成两个AOF文件的替换。于是在Redis重启的时候,可以先加载RDB的内容,然后再重放增量AOF日志就可以完全替代之前的AOF全量文件重放,因此重启效率大幅得到提升。
Redis数据备份策略。1.写crontab定时调度脚本,每小时都copy一份rdb或aof的备份到一个目录中去,仅仅保留最近48小时的备份2.每条都保留一份当日的数据备份到一个目录中去,可以保留最近一个月的备份3.每次copy备份的时候,都把太旧的备份给删了4.每天晚上将当前机器上的备份复制一份到其他机器上,以防机器损坏
Redis持久化
管道(pipeline)客户端可以一次性发送多个请求而不用等待服务器的相应,待所有命令都发送完后再一次性读取服务的响应,这样可以极大地降低多条命令执行的网络传输开销,管道执行多条命令的网络开销实际上只相当于一次命令执行的网络开销,需要注意到是用pipeline方法打包命令发送,redis必须再处理完所有命令前先缓存起所有命令的处理结果。打包的命令越多,缓存消耗内存也越多,所以并不是打包的命令越多越好。pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的响应中得到信息;也就是pipeline并不是表达\"所有command都一起成功\"的语义,管道中前面命令失败,后面命令不会有影响,继续执行
管道和Lua脚本
概述。事务表示一组动作,要么全部执行,要么全部不执行。例子如下。Redis提供了简单的事务功能,讲一组需要一起执行的命令放到multi和exec两个命令之间。multi命令代表事务开始,exec命令代表事务结束,如果要停止事务的执行,可以使用discard命令代替exec命令即可。它们之间的命令是原子执行的
1.命令错误例如下面操作将set写成了SETT,属于语法错误,会造成整个事务无法执行,```c127.0.0.1:6379> set txkey helloOK127.0.0.1:6379> set txcount 100OK127.0.0.1:6379> mget txkey txcount1) \"hello\"2) \"100\
2.运行时错误。例如用户B在添加粉丝列表时,误把SADD命令(针对集合)写成了ZADD命令(针对有序集合),这种就是运行时命令,因为语法时正确的:```c127.0.0.1:6379> SADD u:b:fans ua(integer) 1127.0.0.1:6379> multiOK127.0.0.1:6379> SADD u:c:follow ubQUEUED127.0.0.1:6379> ZADD u:b:fans 1 ucQUEUED127.0.0.1:6379> exec1) (integer) 12) (error) WRONGTYPE Operation against a key holding the wrong kind of value127.0.0.1:6379> sismember u:c:follow ub(integer) 1```可以看到Redis并不支持回滚功能,SADD u:c:follow ub命令已经执行成功,开发人员需要自己修复这类问题。
3.WATCH机制有些应用场景需要在事务之前,确保事务中的key没有key没有被其他客户端修改过,才执行事务,否则不执行(类似乐观锁)。Redis提供了watch命令来解决这类问题。客户端1```c127.0.0.1:6379> set testwatch javaOK127.0.0.1:6379> watch testwatchOK127.0.0.1:6379> multiOK```客户端2```c127.0.0.1:6379> get testwatch\"java\"127.0.0.1:6379> append testwatch python(integer) 10```客户端1继续:```c127.0.0.1:6379> append testwatch jedisQUEUED127.0.0.1:6379> exec(nil)127.0.0.1:6379> get testwatch\"javapython\"```可以看到客户端1在执行multi之前执行了watch命令,客户端2在客户端1执行exec之前修改了key值,造成可客户端1事务没有执行(exec结果为nil)
错误处理机制。如果事务中的命令出现错误,Redis的处理机制也不尽相同
1.pipeline是客户端的行为,对于服务器来说是透明的,可以认为服务器无法区分客户端发送来的查询命令是以普通命令的形式还是以pipeline的形式发送到服务器的;
3.应用pipeline可以提高服务器的吞吐能力,并提高Redis处理查询请求的能力。但是这里存在一个问题,当通过pipeline提交的查询命令数据较少,可以被内核缓冲区所容纳时,Redis可以保证这些命令执行的原子性。然而一旦数据量过大,超过了内核缓冲区的接收大小,那么命令的执行将会被打断,原子性也就无法得到保证。因此pipeline只是一种提升服务器吞吐能力的机制,如果想要命令以事务的方式原子性地被执行,还是需要事务机制,或者使用更高级的脚本功能以及模块功能
4.可以将事务和pipeline结合起来使用,减少事务地命令在网络上的传输时间,将多次网络IO缩减为一次网络IO.
Redis提供了简单的事务,之所以说它简单,主要时因为它不支持事务中的回滚特性,同时无法实现命令之间的逻辑关系计算,当然也体现了Redis的\"keep it simple\"的特性
Pipeline和事务的区别
Redis事务
概述。Redis服务器是一个事件驱动程序:服务器需要处理以下两类事件:1.文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作2.时间事件(time event):Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象
事件的类型。IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字之间的对应关系如下:1.当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件。2.当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件IO多路复用程序允许服务器同时监听套接字的AE_REABLE事件和AE_WRITABLE事件,如果一个套接字同时产生了这两种这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完之后,才处理AE_WRITABLE事件。这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字。
连接应答处理器。networking.c/acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候,套接字就会产生AE_READABLE事件,引发连接应答处理器执行,并执行相应的套接字应答操作,,如图所示
命令请求处理器。networking.c/readQueryFromClient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作
命令回复处理器。networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装。当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。当命令回复发送完毕之后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联。
一次完整的客户端与服务器连接事件示例。假设一个Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该处于监听状态之下,而该事件所对应的处理器为连接应答处理器。如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的AE_READABLE事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给相关程序去执行。执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联。当客户端尝试读取命令回复的时候,客户端套接字将产生AE_WRITABLE事件,触发命令回复处理器执行,当命令回复处理器将命令回复全部写入到套接字之后,服务器就会解除客户端套接字的AE_WRITABLE事件与命令回复处理器之间的关联
文件事件的处理器。Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:1.为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器2.为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器3.为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器。4.当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器在这些事件处理器里面,服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器和命令回复处理器
文件事件。Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler):1.文件事件处理器使用I/O多路复用(multiplexing)程序l来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器2.当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件处理器就会调用套接字之前关联好的事件处理器来处理这些事件虽然文件事件处理器以但单线程方式运行,但通过使用I/O多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性
举个例子。链表中包含了三个不同的时间事件:因为新的时间事件总是i插入到链表的表头,所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID为1.
实现。服务器将所有时间事件都放在一个无须链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。注意,我们说保存时间事件的链表为无序链表,指的不是链表不按ID排序,而是说,该链表不按when属性的大小排序。正因为链表没有按照when属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。无序链表并不影响事件处理器的性能。正常模式下的Redis服务器只使用serverCron一个时间事件,而在benchmark模式下,服务器也只使用两个时间事件。在这种情况下,服务器几乎是将无须链表退化成一个指针来使用,所以使用无须链表来保存时间事件,并不影响事件执行的性能
将aeProcessEvents函数置于一个循环里面,加上初始化和清理函数,这就构成了Redis服务器的主函数,以下是该函数的伪代码表示:```pythondef main(): # 初始化服务器 init_server() # 一直处理事件,直到服务器关闭为止 while server_is_not_shutdown(): aeProcessEvents() # 服务器关闭,执行清理操作 clean_server()```从事件处理的角度来看,Redis服务器的运行流程可以用流程图来表示
举个例子,事件执行过程凸显了上面的规则,1.因为时间事件尚未到达,所以在处理时间事件之前,服务器已经等待并处理了两次文件事件2.因为处理事件的过程中不会出现抢占,所以实际处理时间事件的时间比预定的100毫秒慢了30毫秒
事件的调度和执行规则:1.aeApiPoll函数的最大阻塞事件由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间2.因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了3.对文件事件和时间事件的处理都是同步的、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可能地减少程序地阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿地可能性。比如说,在命令回复处理器将一个命令回复写入到客户端套接字时,如果写入字节数超过了一个预设常量的话,命令回复处理器就会主动用break跳出写入循环,将余下的数据留到下次再写;另外时间事件也会将非常耗时的持久化操作放到子线程或者子进程执行4.因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理事件,通常回避时间事件设定的到达时间晚一些
事件的调度与执行。因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定何时应该处理文件事件,何时有应该处理时间事件,以及花多少事件来处理它们等等。事件的调度和执行由ae.c/aeProcessEvents函数负责,伪代码表示如下:```pythondef aeProcessEvents(): # 获取到达时间离当前时间最接近的时间事件 time_event = aeSearchNearestTimer() # 计算最接近的时间事件距离到达还有多少毫秒 remaind_ms = time_event.when - unix_ts_now() # 如果事件已到达,那么remaind_ms可能为负数,将它设定为0 if remaind_ms < 0: remaind_ms = 0 # 根据remaind_ms的值,创建timeval结构 timeval = create_timeval_with_ms(remaind_ms) # 阻塞并等待文件事件产生,最大阻塞事件由传入的timeval结构决定 # 如果remaind_ms的值为0,那么aeApiPoll调用之后马上返回,不阻塞 aeApiPoll(timeval) # 处理所有易产生的文件事件 processFileEvents() # 处理所有已到达的时间事件 processTimeEvents()```
重点。1.Redis服务器是一个事件驱动程序,服务器处理的事件分为时间事件和文件事件两类。2.文件事件处理器是基于Reactor模式实现的网络通信程序3.文件事件是对套接字操作的抽象:每次套接字变得可应答(acceptable)、可写(writable)或者可读(readale)时,相应的文件事件就会产生4.文件事件分为AE_READABLE事件(读事件)和AE_WRITABLE事件(写事件)两类5.时间事件分为定时事件和周期性事件:定时事件只在指定的事件到达一次,而周期性事件则每隔一段时间到达一次6.服务器在一般情况下只执行serverCron函数一个时间事件,并且这个事件是周期性事件7.文件事件和时间事件之间是合作关系,服务器会轮流处理这两种事件,并且处理事件的流程中也不会出现抢占8.时间事件的实际处理时间通常回避设定的到达时间要晚一些
事件的类型。IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:1.当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作)或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生READABLE事件2.当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件。IO多路复用程序允许服务器同时监听套接字的AE_READABLE事件和AE_WRITABLE事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完之后,才处理AE_WRITABLE事件。也就是说,如果一个套接字又可读又可写的话,那么服务器将优先读套接字后写套接字
事件
举个例子,如图展示了一个与三个客户端进行连接的服务器,
如图展示了这个服务器的clients链表的样子
套接字描述符。客户端状态的fd属性记录了客户端正在使用的套接字描述符:```ctypedef struct redisClient { // ... int fd; // ...}redisClient;```根据客户端类型的不同,fd属性的值可以是-1或者大于-1的整数:1.伪客户端(fake client)的fd属性为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis服务器会在两个地方用到伪客户端,一个用于载入AOF我呢见并还原数据库状态,而另一个则用于执行Lua脚本中包含的Redis命令2.普通客户端的fd属性的值大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。因为合法的套接字描述符不能是-1,所以普通客户端的套接字描述符的值必然是大于-1的整数执行CLIENT list命令可以列出目前所有连接到服务器的普通客户端,命令输出中的fd域显示了服务器连接客户端所使用的套接字描述符:```c127.0.0.1:6379> client listid=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=clientid=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```
举个例子。如图展示了一个客户端状态示例,根据name属性显示,客户端的名字为\"testName1\"
名字。在默认情况下,一个连接到服务器的客户端是没有名字的。比如在下面展示的CLIENT list命令示例中,两个客户端的name域都是空白的:```c127.0.0.1:6379> client listid=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=clientid=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```使用CLIENT setname 命令可以为客户端设置一个名字,让客户端的身份变得更清晰。```c127.0.0.1:6379> CLIENT setname testName1OK``````c127.0.0.1:6379> client listid=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=clientid=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```其中第一个客户端的名字设置为了testName1,第二个客户端没有进行设置。客户端的名字记录在客户端状态的name属性里面:```ctypedef struct redisClient { // ... robj *name; // ...}redisClient```如果客户端没有为自己设置名字,那么相应客户端状态的name属性指向NULL指针,相反地,如果客户端为自己设置了名字,那么name属性将指向一个字符串对象,而该对象就保存着客户端的名字。
PUBSUB命令和SCRIPT LOAD命令的特殊性。通常情况下,Redis只会将那些对数据库进行了修改的命令写入到AOF文件,并复制到各个从服务器。如果一个命令没有对数据库j进行任何修改,那么它就会被认为是只读命令,这个命令不会被写入到AOF文件,也不会被复制到从服务器。以上规则适用于绝大部分Redis命令,但PUBSUB命令和SCRIPT LOAD命令是其中的例外。PUBSUB命令虽然没有修改数据库,但PUBSUB命令向频道的所有订阅者发送消息这一行为带有副作用,接收到消息的所有客户端的状态都会因为这个命令而改变。因此,服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,这样在将来载入AOF文件时,服务器就可以再次执行相同的PUBSUB命令,并产生相同的副作用。SCIRPT LOAD命令的情况与PUBSUB命令类似:虽然SCRIPT LOAD命令没有修改数据库,但它修改了服务器状态,所以它是一个带有副作用的命令,服务器需要使用REDIS_FORCE_AOF标志,强制将这个命令写入AOF文件,使得将来在载入AOF文件时,服务器可以产生相同的副作用。另外,为了让主服务器和从服务器都可以正确地载入SCRIPT LOAD命令指定的脚本,服务器需要使用REDIS_FORCE_REPL标志,强制将SCIRPT LOAD命令复制给所有从服务器
举个flags属性的例子:```c# 客户端是一个主服务器REDIS_MASTER# 客户端正在被列表命令阻塞REDIS_BLOCKED# 客户端正在执行事务,但事务的安全性已被破坏REDIS_MULTI | REDIS_DIRTY_CAS# 客户端是一个从服务器,并且版本低于Redis2.8REDIS_SLAVE | REDIS_PRE_PSYNC# 这是专门用于执行Lua脚本包含的Redis命令的伪客户端# 它强制服务器将当前的命令写入AOF文件,并复制给从服务器REDIS_LUA_CLIENT | REDIS_FORCE_AOF | REDIS_FORCE_REPL```
举个例子,如果客户端向服务器发送了以下命令请求:```cSET key value```那么客户端状态的qureybuf属性将是一个包含以下内容的SDS值```c*3\\$3\\SET\\$3\\key\\$5\\value\\```如图所示占了这个SDS值以及querybuf属性的样子。输入缓冲区的大小会根据输入内容动态地缩小或者扩大,但它的最大大小不能超过1GB,否则服务器将关闭这个客户端
输入缓冲区。客户端状态的输入缓冲区用于保存客户端发送的命令请求:```ctypedef struct redisClient { // ... sds querybuf; // ...}redisClient;```
举个例子,图中展示的客户端状态中,argc属性的值伪3,而不是2,因为命令的名字\"SET\"本身也是一个参数
命令与命令参数。在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:```ctypedef struct redisClient { // ... robj **argv; int argc; // ...}redisClient```argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。argc属性则是负责记录argv数组的长度。
图中演示了服务器在argv[0]为\"SET\"时,查找命令表并将客户端状态的cmd指针指向目标redisCommand结构的整个过程。针对命令表的查找操作不区分输入字母的大小写,所以无论argv[0]是\"SET\"、\"set\" 、或者\"Set\"等等,查找的结构都是相同的。
如图展示了一个包含三个字符串对象的reply链表
输出缓冲区。z执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的:1.固定大小的缓冲区用于保存那些长度比较小的回复,比如OK、间段的字符串值、整数值、错误回复等等2.可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值,一个由很多项组成的列表,一个包含了很多元素的集合等等。客户端的固定大小缓冲区由buf和bufpos两个属性组成:```ctypedef struct redisClient { // ... char buf[REDIS_REPLY_CHUNK_BYTS]; int bufpos; // ...}redisClient;```buf是一个大小为REDIS_REPLY_CHUNK_BYTES字节的字节数组,而bufpos属性则记录了buf数组目前已使用的字节数量。REDIS_REPLY_CHUNK_BYTES常量目前的默认值为16*1024,也就是说,buf数组的默认大小为16KB.如图展示了一个使用固定大小缓冲区来保存返回值+OK\\的例子。当buf数组的空间已经用完,或者回复因为太大而没办法放进buf数组里面时,服务器就会开始使用可变大小缓冲区。可变大小缓冲区由reply链表和一个或多个字符串对象组成:```ctypedef struct redisClient { // ... list *reply; // ...}redisClient```通过使用链表l来连接多个字符串对象,服务器可以为客户端保存一个非常长的命令回复,而不必受到固定大小缓冲区16KB大小的限制。
举个例子,对于一个尚未进行身份验证的客户端来说,客户端状态的authenticated的属性如图所示.当客户端authenticated属性的值为0时,除了AUTH命令之外,客户端发送的所有其他命令都会被服务器拒绝执行:(前提是你需要设置密码requirepass)redis.conf```crequirepass 1234``````c(error) NOAUTH Authentication required.127.0.0.1:6379> SET msg \"hello world\"(error) NOAUTH Authentication required.```
当客户端通过AUTH命令成功进入身份验证之后,客户端状态authenticated属性的值就会从0变为1,如图所示,这时客户端就可以像往常一样向服务器发送命令请求了:```c127.0.0.1:6379> AUTH 1234OK127.0.0.1:6379> PINGPONG127.0.0.1:6379> SET msg \"hello world\
身份验证。客户端状态的authenticated属性用于记录客户端是否通过了身份验证:```ctypedef struct redisClient { // ... int authenticated; // ...} redisClient;```如果authnticated的值为0,那么表示客户端未通过身份验证;如果authenticated的值为1,那么表示客户端已经通过了身份验证
客户端属性。客户端状态包含的属性可以分为两类:1.一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工作,它们都要用到这些属性2.l另外一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的mstate属性,以及执行WATCH命令时需要用到的watched_keys属性等等。
举个例子。假设当前有c1和c2两个普通客户端正在连接服务器,那么当一个新的普通客户端c3连接到服务器之后,服务器会将c3所对应的客户端状态添加到clients链表的末尾,如图所示,其中用虚线包围的就是服务器为c3新创建的客户端状态
Lua脚本的伪客户端。服务器会在初始化时创建执行Lua脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构的lua_client属性中:```ctypedef struct redisServer { // ... redisClient *lua_client; // ... };```lua_client伪客户端在服务器运行的整个生命周期中会一直存在,只有服务器被关闭时,这个客户端才会被关闭
AOF文件的伪客户端。服务器在载入AOF文件时,会创建用于执行AOF文件包含的Redis命令的伪客户端,并在载入完成之后,关闭伪客户端
客户端的创建与关闭。服务器使用不同的方式来创建和关闭不同类型的客户端
客户端
概述。Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令所产生的数据,并通过资源管理来维持服务器自身的运转
发送命令请求。Redis服务器的命令请求来自Redis客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。如图所示。
之后,服务器将通过命令执行器来完成执行命令所需的余下步骤
读取命令请求。当客户端与服务器之间的套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:1.读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面2.对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv和argc属性里面。3.调用命令执行器,执行客户端指定的命令分析程序将对输入缓冲区中的协议进行分析:```c*3\\$3\\SET\\$3\\KEY\\$5\\VALUE\\```并将的出的分析结果保存到客户端状态的argv属性和argc属性里面,如图所示
如表所示,sflags属性可以使用的标识值。
如图所示命令表,并以SET和GET命令作为例子。1.SET命令的名字为\"set\",实现函数为setCommand;m命令的参数个数为-3,表示命令接受三个或以上数量的参数;命令的参数为\"wm\
命令执行器(1):查找命令实现。命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性里面。命令表是一个字典,字典的键是一个个命令名字,比如\"set\"、\"get\"、\"del\"等等;而字典的值则是一个个redisCommand结构,每个redisCommand结构记录了一个Redis命令的实现信息
命令执行器(2):执行预备操作。服务器已经将执行命令所需的命令实现函数(保存在客户端状态的cmd属性)、参数(保存在客户端状态的argv属性)、参数个数(保存在客户端状态的argc属性)都收集器了,但是在真正执行命令之前,程序还需要进行一些预备操作,从而确保命令可以正确、顺利地被执行,这些操作包括:1.检查客户端状态的cmd指针是否指向NULL,如果是的话,那么说明用户输入的命令名字找不到相应的命令实现,服务器不再执行后续步骤,并向客户端返回一个错误2.根据客户端cmd属性指向的redisCommand结构的arity属性,检查命令请求所给定的参数个数是否正确,当参数个数不正确时,不再执行后续步骤,直接向客户端返回要给错误。比如说,如果redisCommand结构的arity属性的值为-3,那么用户输入的命令参数个数必须大于等于3个才行。3.检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令,如果未通过身份验证的客户端试图执行除AUTH命令之外的其他命令,那么服务器将向客户端返回一个错误4.如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利地执行如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误5.如果服务器上一次执行BGSAVE命令时出错,并且服务器打开了stop-writes-on-bgsave-error功能,而且服务器即将要执行地命令是一个写命令,那么服务器将拒绝执行这个命令,并向客户端返回一个错误6.如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行客户端发来的SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE四个命令,其他命令都会被服务器拒绝7.如果服务器正在进行数据载入,那么客户但发送的命令必须带有l标识(比如INFO、SHUTDOWN、PUBLISH等等)才会被服务器执行,其他命令都会被服务器拒绝8.如果服务器因为执行Lua脚本而超时并进行阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和SCRIPT KILL命令,其他命令都会被服务器拒绝9.如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、MULTI、WATCH四个命令,其他命令都会被放进事务队列中10.如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。完成了以上的预备操作之后,服务器就可以开始真正执行命令了
命令执行器(4):执行后续工作。在执行完实现函数之后,服务器还需要执行一些后续工作:1.如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志2.根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的millseconds属性,并将命令的redisCommand结构的calls计数器的值增一。3.如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区里面4.如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器当以上操作都执行完之后,服务器对于当前命令的执行就完成了,之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了
将命令回复发送给客户端。命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并未客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。当客户端的套接字变为可写状态时,命令回复处理器会将协议格式的命令回复\"+OK\\\"发送给客户端
客户端接收并打印命令回复。当客户端接收到协议格式的命令回复粥,它会将这些回复转化成人类可读的格式,并打印给用户观看如图s所示
命令请求的执行过程。一个命令请求从发送到获得回复的过程中,客户端和服务器需要完成一系列操作。
更新服务器时间缓存。Redis服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的unixtime属性和mstime属性被用作当前时间的缓存:```cstruct redisServer { // ... // 保存了秒级精度的系统当前UNIX时间戳 time_t unixtime; // 保存了毫秒级精度的系统当前UNIX时间戳 long long mstime; // ....};```因为serverCron函数默认会以每100毫秒一次的频率更新unixtime属性和mstime属性,所以这两个属性记录的时间的精确度并不高:1.服务器只会在打印日志、更新服务器的LRU时钟、决定是否执行持久化任务、计算服务器上线时间(uptime)这类对事件精确度要求不高的功能上\"使用unixtime属性和mstime属性\"。2.对于为键设置过期事件、添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再执行系统调用,从而获得最准确的系统当前时间
```c127.0.0.1:6379> info server# Serverredis_version:3.0.504redis_git_sha1:00000000redis_git_dirty:0redis_build_id:a4f7a6e86f2d60b3redis_mode:standaloneos:Windowsarch_bits:64multiplexing_api:WinSock_IOCPprocess_id:5512run_id:87544bbfd0b6ddf6c7168be02719f23b94c97a96tcp_port:6379uptime_in_seconds:95307uptime_in_days:1hz:10lru_clock:581331config_file:E:\edis\edis.windows-service.conf```
```c127.0.0.1:6379> info stats# Statstotal_connections_received:3total_commands_processed:16instantaneous_ops_per_sec:0total_net_input_bytes:542total_net_output_bytes:2417instantaneous_input_kbps:0.00instantaneous_output_kbps:0.00rejected_connections:0sync_full:0sync_partial_ok:0sync_partial_err:0expired_keys:0evicted_keys:0keyspace_hits:1keyspace_misses:0pubsub_channels:0pubsub_patterns:0latest_fork_usec:69502migrate_cached_sockets:0```上面命令的结果显示中,在最近的一秒钟内,服务器没有处理命令。trackOperationPerSecond函数和服务器状态中四个ops_sec开头的属性有关:```cstruct redisServer { // ... // 上一次进行抽样的时间 long long ops_sec_last_sample_time; // 上一次抽样时,服务器已执行命令的数量 long long ops_sec_last_sample_ops; // REDIS_OPS_SEC_SAMPLE 大小(默认值为16)的环形数组 long long ops_sec_sample[REDIS_OPS_SEC_SAMPLES]; // ops_sec_sample数组的索引值 // 每次抽样后将值增一 // 再值等于16时重置为0 // 让ops_sec_samples数组构成一个唤醒数组 int opts_sec_ids; // ...}```trackOperationsPerSecond函数每次运行,都回根据ops_sec_last_sample_time记录的上一次抽样时间和服务器的当前时间,以及ops_sec_last_sample_ops记录的上一次抽样的已执行命令数量和服务器当前的已执行命令数量,计算出两次trackOperationsPerSecond调用之间,服务器平均每一毫秒处理了多少个命令请求,然后将这个平均值乘以1000,这就得到了服务器在一秒钟内处理多少个命令请求的估计值,这个估计值会被作为一个新的数组项被放进ops_sec_samples唤醒数组里面。当客户端执行INFO命令时,服务器就会调用getOperationsPerSecond函数,根据ops_sec_samples唤醒数组中的抽样结果,计算出instantaneous_ops_per_sec属性的值,
以下是getOperationsPerSecond函数的实现代码:```clong long getOperationsPerSecond(void) { int j; long long sum = 0; // 计算所有取样值综合 for (j = 0; j < REDIS_OPS_SEC_SAMPLES; j++) { sum += server.ops_sec_samples[j]; } // 计算取样的平均值 return sum / REDIS_OPS_SEC_SAMPLES;}```根据getOperationsPerSeoncd函数的定义可以看出,instantaneous_ops_per_sec属性的值是通过计算最近REDIS_OPS_SEC_SAMPLES次取样的平均值来计算得出的,它只是一个估算值。
更新服务器每秒执行命令次数。serverCron函数中的trackOperationPerSecond函数会以每100毫秒一次的频率执行,这个函数的功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过INFO stats命令 的instantaneous_ops_per_sec域查看:
更新服务器内存锋值记录。服务器状态中的stat_peak_memory属性记录了服务器的内存锋值大小:```cstruct redisServer { // ... // 已使用内存锋值 size_t stat_peak_memory; // ..};```每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并与stat_peak_memory保存的数值进行比较,如果当前使用的内存数量比stat_peak_memory属性记录的值要大,那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面。INFO memory命令的used_memory_peak和used_memory_peak_human两个域分别以两种格式记录了服务器的内存锋值```cused_memory:714200used_memory_human:697.46Kused_memory_rss:677272used_memory_peak:715040used_memory_peak_human:698.28Kused_memory_lua:36864mem_fragmentation_ratio:0.95mem_allocator:jemalloc-3.6.0```
管理客户端资源。serverCron函数每次执行都会调用clientsCron函数,clientsCron函数会对一定数量的客户端进行以下两个检查:1.如果客户端与服务器之间的连接已经超时(很长一段时间里客户端和服务器都没有互动),那么程序释放这个客户端资源2.如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存
管理数据库资源。serverCron函数每次执行都会调用databasesCron函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作
执行被延迟的BGREWRITEAOF。在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令,那么服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE命令执行完毕之后。服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令:```cstruct redisServer { // ... // 如果值为1,那么表示有 BGREWRITEAOF命令被延迟了 int aof_rewrite_scheduled; // ...}```每次serverCron函数执行时,函数都会检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行,如果这两个命令都没在执行,并且aof_rewrite_scheduled属性的值为1,那么服务器就会执行之前被推延的BGREWRITEAOF命令。
整个检查过程如图所示。
将AOF缓冲区中的内容写入AOF文件。如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么serverCron函数会调用相应的程序,将AOF缓冲区中的内容写入到AOF文件里面
增加cronloops计数器的值。服务器状态的cronloops属性记录了serverCron函数执行的次数```cstruct redisServer { // ... // serverCron函数的运行次数计数器 // serverCron函数每执行一次,这个属性的值就增1 int cronloops; // ...}```cronloops属性目前在服务器中的唯一作用,就是在复制模块中实现\"每执行serverCron函数N次就执行一次指定代码\"的功能方法如以下伪代码所示```cif cronloops % N == 0: # 执行指定代码...```
serverCron函数。Redis服务器中的serverCron函数默认每隔100毫秒执行一次,这个函数负责管理服务器的资源,并保持服务器自身的良好运转。
不过,如果用户在启动服务器时为配置选项port指定了新值10086,那么server.port属性的值就会被更新为10086,这将使得服务器的端口号从默认的6379变为yoghurt指定的10086.例如,在初始化server变量时,程序会为决定数据库数量的dbnum属性设置默认值:```cvoid initServerConfig(void) { // ... // 默认值为16 server.dbnum = REDIS_DEFUALT_DBNUM;}```不过,如果用户在启动服务器时为选项databases设置了值32,那么server.dbnum属性的值就会被更新为32,这将使得服务器的数据库数量从默认的16个变为用户指定的32个。其他配置选项相关的服务器状态属性的情况与上面列举的port属性和dbnum属性一样:1.如果用户为这些属性的相应选项指定了新的值,那么服务器就使用用户指定的值来更新相应的属性2.如果用户没有为属性的相应选项设置新的值,那么服务器就沿用之前initServerConfig函数为属性的默认值。服务器在载入用户指定的配置选项,并对server状态进行更新之后,服务器就可以进入初始化的第三个阶段——初始化服务器数据结构
载入配置选项。在启动服务器时,用户可以通过给定配置参数或者指定配置文件来修改服务器的默认配置。举个例子,如果我们在终端输入:```credis-server --port 10086```那么我们就通过给定配置参数的方式,修改了服务器的运行端口号。另外,如果在终端输入:```credis-server redis.conf```并且redis.conf文件中b包含以下内容:```c# 将服务器的数据库数量设置为32个databases 32# 关闭RDB文件的压缩功能rdbcompression no```那么我们就通过指定配置文件的方式修改了服务器的数据库数量,以及RDB持久化模块的压缩功能。服务器在用initServerConfig函数初始化server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的值进行修改。例如,在初始化server变量时,程序会为决定服务器端口号的port属性设置默认值:```cvoid initServerConfig(void) { // ... // 默认值为6379 server.port = REDIS_SERVERPORT;}```
初始化服务器数据结构。在之前执行initServerconfig函数初始化server状态时,程序只创建了命令表一个数据结构,不过除了命令表之外,服务器状态还包括其他数据结构,比如:1.server.clients链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每隔节点都包含了一个redisClient结构实例2.server.db数组,数组中包含了服务器的所有数据库3.用于执行Lua脚本的Lua环境server.lua4.用于保存慢查询日志的server.slowlog属性当初始化服务器进行到这一步,服务器将调用initServer函数,为以上提到的数据结构分配内存,并在有需要时,为这些数据结构设置或者关联初始化值。服务器到现在才初始化数据结构的原因在于,服务器必须先载入用户指定的配置选项,然后才能正确地对数据结构进行初始化。如果在执行initServerConfig函数时就对数据结构进行初始化,那么一旦用户通过配置选项修改了和数据有关地服务器状态属性,服务器就要重新调整和修改已创建地数据结构。为了避免出现这种麻烦的情况,服务器选择了将server状态的初始化分为两步进行,initServerConfig函数主要负责初始化一般属性,而initServer函数主要负责初始化数据结构。除了初始化数据,initServer还进行了一些非常重要的设置操作,其中包括:1.为服务器设置进程信号处理器2.创建共享对象,这些对象包含Redis服务器经常用到的一些值,比如包含\"OK\"回复的字符串对象,包含\"ERR\
还原数据库状态。在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的内容来还原服务器的数据库状态。根据服务器是否启用了AOF持久化功能,服务器载入数据时所使用的目标文件会有所不同:1.如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库装填。2.相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库装填。当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费地时长:```c[7256] 01 Apr 21:07:11.795 * DB loaded from disk: 0.000 seconds```
执行事件循环。在初始化地最后一步,服务器将打印出以下日志:```c[7256] 01 Apr 21:07:11.795 * The server is now ready to accept connections on port 6379```并开始执行服务器的事件循环(loop).至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了
初始化服务器。一个Redis服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程,比如初始化服务器状态,接受用户指定的服务器配置,创建相应的数据结构和网络连接等等
服务器
举个例子。假设现在有两个Redis服务器,地址分别为127.0.0.1:6379和127.0.0.1:12345,如果我们向服务器127.0.0.1:12345发送以下命令:```c127.0.0.1:12345> SLAVEOF 127.0.0.1 6379OK```那么服务器127.0.0.1:12345将称为127.0.0.1:6379的从服务器,而服务器6379则会称为12345的主服务器。进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象乘坐为\"数据库状态一致\
举个例子。
处于不一致状态的主从服务器
主服务器向从服务器发送命令
命令传播。在同步操作执行完毕之后,主从服务器两者的数据库将达到一致状态,但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,主服务器的数据库就有可能会被修改,并导致主从服务器状态不再一致。
旧版复制功能的实现。Redis的复制宫嗯那个分为同步(sync)和命令传播(command propagate)两个操作:1.同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态2.命令传播则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致性
旧版复制功能的缺陷。在Redis2.8以前,从服务器对主服务器的复制可以分为以下两种情况:1.初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同.2.断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器。对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主服务器重新回到一致状态,但效率却非常低。
如图所示,展示了主从服务器在执行部分重同步时的通信过程。
新版复制功能的实现。为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。PYSNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:1.其中完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,它们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步2.而部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。PSYNC命令的部分重同步模式解决了旧版复制功能在处理断线后重复制时出现的低效情况,
举个例子,如果在向从服务器传播33字节数据之前,上图中的从服务器A断线了,那么主服务器传播的数据将只有从服务器B和从服务器C能收到,在这之后,主服务器、从服务器B和从服务器C三个服务器的复制偏移量都将更新为10119,而断线的从服务器A的复制偏移量仍然停留在10086,着说明从服务器A与主服务器并不一致,如图所示.假设从服务器A在断线之后就立即重新连接主服务器,并且成功,那么接下来,从服务器将向主服务器发送PSYNC命令,报告从服务器A当前的复制偏移量为10086,那么这时,主服务器应该对从服务器执行完整重同步还是部分重同步呢?如果执行部分重同步的话,主服务器又如何补偿从服务器A在断线期间丢失的那部分数据呢?以上问题的答案都和复制积压缓冲区有关。
复制偏移量。执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:1.主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N2.从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N通过对比主从服务器的复制偏移量,程序可以很容易地直到主从服务器是否处于一致状态:1.如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的2.相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态
举个例子。如果我们要将'h'、'e'、'l'、'l'、'o'五个字符放进一个长度为3的固定长度先进先出队列里面,那么'h'、'e'、'l'三个字符将首先被放入队列:['h'、'e'、'l']但是当后一个'l'字符要进入队列时,队首的'h'字符将被弹出,队列变成:['e'、'l'、'l']接着['l'、'l'、'o']
固定长度先进先出队列。固定长度先进先出队列的入队和出队规则跟普通的先进先出队列一样:新元素从一边进入队列,而旧元素从另一边弹出队列。和普通先进先出队列随着元素的增加和减少而动态调整长度不同,固定长度先进先出队列的长度是固定的,当入队元素的数量大于队列长度时,最先入队的元素会被弹出,而新元素会被放入队列。
举个例子。如上图中的断线后重连接:1.当从服务器A断线之后,它立即重新连接主服务器,并向主服务器发送PSYNC命令,报告自己的复制偏移量为10086.2.主服务器收到从服务器发来的PSYNC命令以及偏移量10086之后,主服务器将检查偏移量10086之后的数据是否存在于复制积压缓冲区里面,结果发现这些数据仍然存在,于是主服务器向从服务器发送+CONTINUE回复,表示数据同步将以部分重同步模式来进行3.接着主服务器会将复制积压缓冲区10086偏移量之后的所有数据(偏移量为10087至10119)都发送给从服务器4.从服务器只要接收这33字节的缺失数据,就可以回到与主服务器一致的状态。如图所示
举个例子。假设从服务器原本正在复制一个运行ID为1的主服务器,那么在网络断开,从服务器重新连接上主服务器之后,从服务器将向主服务器发送这个运行ID,主服务器根据自己的运行ID是否为1来判断是否执行部分重同步还是执行完整重同步
部分重同步的实现。部分重同步功能由以下三个部分构成:1.主服务器的复制偏移量(replication offset)和从服务器的复制偏移量2.主服务器的复制积压缓冲区(replication backlog)3.服务器的运行ID(run ID)
步骤2:建立套接字连接。在SLAVEOF命令执行之后,从服务器将根据命令所设置的IP地址和端口,创建连向主服务器的套接字连接,如图所示。如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作,比如接收RDB文件,以及接收主服务器传播来的写命令,诸如此类。而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器(server)和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复,如图所示
流程图总结。
步骤3:发送PING命令。从服务器称为主服务器的客户端之后,做的第一件事就是向主服务器发送一个PING命令,如图所示.这个PING命令有两个作用:1.虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送PING命令可以检查套接字的读写状态是否正常。2.因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送PING命令可以检查主服务器能否正常处理命令请求。从服务器在发送PING命令之后将遇到以下三种情况中的其中一种:1.如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限(timeout)内读取出命令回复的内容,那么表示主从服务器之间的网络连接状态不佳,不能继续执行复制工作的后续步骤,当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。2.如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。比如说,如果主服务器正在处理一个超时运行的脚本,那么当从服务器向主服务器发送PING命令时,从服务器将收到主服务器返回的BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE错误3.如果从服务器读取到PONG回复,那么表示主从服务器之间的网络连接正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务器可以继续执行复制工作的下个步骤
步骤4:身份验证。从服务器在收到主服务器返回的\"PONG\"回复之后,下一步要做的就是决定是否进行身份验证:1.如果从服务器设置了masterauth选项,那么进行身份验证2.如果从服务器没有设置masterauth选项,那么不进行身份验证在需要进行身份验证的情况下,从服务器将向主服务器发送一条AUTH命令,命令的参数为从服务器masterauth选项的值.所有错误情况都会令从服务器中止目前的复制工作,并从创建套接字开始重新执行复制,直到身份验证通过,或者从服务器放其执行复制为止。
如图所示展示了客户端状态设置slave_listening_port属性之后的样子
步骤6:同步。从服务器将从主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。值得一提的是,在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会称为从服务器的客户端:1.如果PSYNC命令执行的是完整重同步操作,那么主服务器需要称为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行。2.如果PSYNC命令执行的是部分重同步操作,那么主服务器需要称为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令因此,在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相向对方发送命令请求,或者互相向对方返回命令回复,如图所示。正因为主服务器称为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础
步骤7:命令传播。当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主服务器一直保持一致了
复制的实现。通过向从服务器发送SLAVEOF命令,可以实现让一个从服务器去复制一个主服务器:```cSLAVEOF <master_ip> <master_port>```以从服务器接收到127.0.0.1:12345接收到命令:```cSLAVEOF 127.0.0.1 6379```为例,分析详细实现步骤
举个例子。我们向主服务器提供以下配置:```cmin-slaves-to-write 3min-slaves-max-lag 10```那么在从服务器的数量少于3个,或者三个从服务器的延迟(lag)值都大于或等于10秒时,主服务器将拒绝执行写命令,这里的延迟值就是INFO replication命令的lag值
辅助实现min-slaves配置选项。Redis的min-slaves-to-write和min-slaves-max-lag两个选项可以防止主服务器在不安全的情况下执行写命令。
举个例子。假设有两个处于一致状态的主从服务器,它们的复制偏移量都是200,如图所示..如果这时主服务器执行了命令SET key value(协议格式的长度为33字节),将自己的复制偏移量更新到了233,并尝试向从服务器传播命令SET key value,但这条命令却因为网络故障而在传播的途中丢失,那么主从服务器之间的复制偏移量就会出现不一致,主服务器的偏移量会被更新为233,而从服务器的复制偏移量仍然为200,如图所示。在这之后,当从服务器向主服务器发送REPLCONF ACK命令的时候,主服务器会察觉从服务器的复制偏移量依然为200,而自己的复制偏移量为233,这说明复制积压缓冲区里面复制偏移量为201至233的数据(也即是命令SET key value)在传播过程中丢失了,于是主服务器会再次向从服务器传播命令SET key value,从服务器通过接收并执行这个命令,可以将自己更新至主服务器当前所处的状态,如图所示。
注意。主服务器向从服务器补发缺失数据这一操作的原理和部分重同步操作的原理非常相似,这两个操作的区别在于,补发缺失数据操作在主从服务器没有断线的情况下进行,而部分重同步操作则在主从服务器断线并重连之后执行。Redis2.8版本以前的命令丢失。REPLCONF ACK命令和复制积压缓冲区都是Redis2.8版本新增的,在Redis2.8版本以前,即是命令在传播过程中丢失,主服务器和从服务器都不会注意到,主服务器更不会向从服务器补发丢失的数据,所以为了保证复制时主从服务器的数据一致性,最好使用Redis2.8版本以上的
检测命令丢失。如果因为网络故障,主服务器传播个i从服务器的写命令在半路丢失,那么当从服务器向主服务器发送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新送给从服务器。
心跳检测。在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:```cREPLCONF ACK < replication_offset >```其中replication_offset是从服务器当前的复制偏移量。发送REPLCONF ACK命令对于主从服务器有三个作用:1.检测主从服务器的网络连接状态2.辅助实现min-slaves选项3.检测命令丢失
复制
假设这时,主服务器server1进入下线状态,那么从服务器server2、server3、server4对主服务器的复制操作将被中止,并且Sentinel系统会察觉到server1已下线,如图所示(下线的服务器用虚线表示)
```cstruct redisCommand redisCommandTable[] = { {\"get\
```cstruct redisCommand sentinelcmds[] = { {\"ping\
使用Sentinel专用代码。启动Sentinel的第二个步骤就是将一部分普通Redis服务器使用的代码替换成Sentinel专用代码。比如说,普通Redis服务器使用redis.h/REDIS_SERVERPORT常量的值作为服务器端口:```c#define REDIS_SERVERPORT 6379```而Sentinel则使用sentinel.c/REDIS_SENTINEL_PORT常量的值作为服务器端口:```c#define REDIS_SENTINEL_PORT 26379```除此之外,普通Redis服务器使用redis.c/redisCommandTable作为服务器的命令表:而Sentinel则使用sentinel.c/sentinelcmds作为服务器的命令表,并且其中的INFO命令会使用Sentinel模式下的专用实现sentinel.c/sentinelInfoCommand函数,而不是普通Redis服务器使用的实现redis.c/infoCommand函数:sentinel命令表也解释了为什么在Sentinel模式下Redis服务器不能执行诸如SET、DBSIZE、EVAL等等这些命令,因为服务器根本没有在命令表中载入这些命令。PING、SENTINEL、INFO、SUBSCRIBE、UNSUBSCRIBE、PSUBSCRIBE和PUNSUBSCRIBE这七个命令就是客户端可以对Sentinel执行的全部命令了
master1
master2
Sentinel状态以及masters字典
举个例子。如果用户在启动Sentinel时,指定了包含以下内容的配置文件:```c# master1 configuresentinel monitor master1 127.0.0.1 6379 2sentinel down-after-milliseconds master1 3000sentinel parallel-syncs master1 1sentinel failover-timeout master1 900000``````c# master2 configuresentinel monitor master2 127.0.0.1 12345 5sentinel down-after-milliseconds master2 50000sentinel parallel-syncs master2 5sentinel failover-timeout master2 450000```那么Sentinel将为主服务器master1创建如图所示的实例结构,并未主服务器master2创建如图所示的实例结构,而这两个实例结构又会被保存到Sentinel状态的masters字典中
初始化Sentinel状态的masters属性。Sentinel状态中的masters字典记录了所有被Sentinel监视的主服务器的相关信息,其中:1.字典的键是被监视主服务器的名字2.而字典的值则是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构。每个sentinelRedisInstance结构(简称实例结构)代表一个被Sentinel监视的Redis服务器实例(instance),这个实例可以是主服务器、从服务器,或者另外一个Sentinel。实例结构包含的属性非常多,下方代码展示了实例结构在表示主服务器时使用的其中一部分属性sentinelRedisInstance.addr属性是一个指向sentinel.c/sentinelAddr结构的指针,这个结构保存着实例的IP地址和端口号:```ctypedef struct sentinelAddr {char *ip;int port;} sentinelAddr;```对Sentinel状态的初始化将引发对masters字典的初始化,而masters字典的初始化是根据被载入的Sentinel配置文件来进行的。
创建连向主服务器的网络连接。初始化Sentinel的最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:1.一个是命令连接,这个链接专门用于向主服务器发送命令,并接收命令回复2.另一个是订阅连接,这个连接专门用于订阅主服务器的_sentinel_:hello频道如图所示,展示了一个Sentinel向被它监视的两个主服务器master1和master2创建命令连接和订阅连接的例子
启动并初始化Sentinel。```credis-sentinel /path(你自己的路径)/sentinel.conf```或者```credis-server /path(你自己的路径)/sentinel.conf -- sentinel```这两个命令的效果完全相同。当一个Sentinel启动时,他需要执行以下步骤:1.初始化服务器2.将普通Redis服务器使用的代码替换成Sentinel专用代码3.初始化Sentinel状态4.根据给定的配置文件,初始化Sentinel的监视主服务器列表。}5.创建连向主服务器的网络连接
从服务器实例结构
举个例子,对于图中所示的主从服务器关系来说,Sentinel将对slave0/1/2三个从服务器分别创建命令连接和订阅连接,如图所示。在创建命令连接之后,Sentinel在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送INFO命令并获得类似以下内容的回复:```c# Server// ....run_id:a5bd47a1e569ed14567eca650de57f9d83301638# Replicationrole:slavemaster_host:127.0.0.1master_port:6379master_link_status:upmaster_last_io_seconds_ago:9master_sync_in_progress:0slave_repl_offset:25177slave_priority:100slave_read_only:1connected_slaves:0master_repl_offset:0repl_backlog_active:0repl_backlog_size:1048576repl_backlog_first_byte_offset:0repl_backlog_histlen:0# ...```根据INFO命令的回复,Sentinel会提取出以下信息:1.从服务器的运行ID run_id2.从服务器的角色role3.主服务器的IP地址master_host,以及主服务器的端口号master_port4.主从服务器的连接状态master_link_status5.从服务器的优先级slave_priority6.从服务器的复制偏移量slave_repl_offset根据这些信息,Sentinel会对从服务器的实例结构进行更新,如中展示了Sentinel根据上面的INFO命令回复对从服务器的实例结构进行更新之后,实例结构的样子
获取从服务器信息。当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会为这个新的从服务器创建相应的实例结构之外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。
举个例子。以下是一条Sentinel通过PUBLISH命令向主服务器发送的信息:\
向主服务器和从服务器发送信息。在默认情况下,Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:```cPUBLISH _sentinel_:hello \
举个例子。假设现在有sentinel1/2/3三个Sentinel在监视同一个服务器,那么当sentinel1向服务器的_sentinel_:hello频道发送一条信息时,所有订阅了_sentinel_:hello频道的Sentinel(包括sentinel1自己在内)都会收到这条信息,如图所示。当一个Sentinel从_sentinel_:hello频道收到一条信息时,Sentinel会对这条信息进行分析,提取出信息中的Sentinel IP地址、Sentinel端口号、Sentinel运行ID等八个参数,并进行以下检查:1.如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID相同,那么说明这条信息时Sentinel自己发送的,Sentinel将丢弃这条信息,不做进一步处理2.相反地,如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID不相同,那么说明这条信息是监视同一个服务器的其他Sentinel发来的,接收信息的Sentinel将根据信息中的各个参数,对相应主服务器的实例结构进行更新
举个例子。假设分别有127.0.0.1:26379、127.0.0.1:26380、1270.0.1:26381三个Sentinel正在监视主服务器127.0.0.1:6379,那么当127.0.0.1:26379这个Sentinel接收到以下信息时:```c1.\"message\"2.\"_sentinel_:hello\"3.\
更新sentinels字典。Sentinel为主服务器创建的实例结构中的sentinels字典保存了除了Sentinel本身之外,所有同样监视这个主服务器的其他Sentinel的资料:1.sentinels字典的键是其中一个Sentinel的名字,格式为ip:port,比如对于IP地址为127.0.0.1,端口号为26379的Sentinel来说,这个Sentinel在sentinels字典中的键就是\"127.0.0.1:26379\"2.sentinels字典的值则是键所对应的Sentinel的实例结构,比如对于键\"127.0.0.1:26379\
Sentinel之间不会创建订阅连接。Sentinel在连接主服务器或者从服务器时,会同时创建命令连接和订阅连接,但是在连接其他Sentinel时,却只会创建命令连接,而不创建订阅连接。这是因为Sentinel需要通过接收主服务器或者从服务器发来的频道信息来发现未知的新Sentinel,所以才需要建立订阅连接,而相互已知的Sentinel只要使用命令连接来进行通信就足够了
创建连向其他的Sentinel的命令连接。当Sentinel通过频道信息发现一个新的Sentinel时,它不仅会为新Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器将形成相互连接的网络:SentinelA有连向SentinelB的命令连接,而SentinelB也有连向SentinelA的命令连接。如图所示,三个监视同一主服务器的Sentinel之间是如何互相连接的。使用命令连接的各个Sentinel可以通过向其他Sentinel发送命令请求来进行信息交换
接收来自主服务器和从服务器的频道消息。当Sentinel与一个主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令:```cSUBSCRIBE _sentinel_:hello```Sentinel对_sentinel_:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止。这也就是说,对于每个与Sentinel连接的服务器,Sentinel既通过命令连接向服务器的_sentinel_:hello频道发送消息,又通过订阅连接从服务器的_sentinel_:hello频道接收消息,如图所示。对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被i其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器的认知
主观下线时长选项的作用范围。用户设置的down-after-milliseconds选项的值,不仅会被Sentinel用来判断主服务器的主观下线状态,还会被用于判断主服务器属下的所有从服务器,以及所有同样监视这个主服务器的其他Sentinel的主观下线状态。举个例子,如果用户向Sentinel设置了以下配置:```csentinel monitor master 127.0.0.1 6379 2sentinel down-after-milliseconds master 50000```那么50000毫秒不仅会成为Sentinel判断master进入主观下线的标准,还会成为Sentinel判断master属下所有从服务器,以及所有同样监视master的其他Sentinel进入主观下线的标准。
多个Sentinel设置的主观下线时长可能不同。down-after-milliseconds选项的另一个需要注意的地方是,对于监视同一个主服务器的多个Sentinel来说,这些Sentinel所设置的down-after-milliseconds选项的值也可能不同,因此,当一个Sentinel将主服务器判断为主观下线时,其他Sentinel可能仍然会认为主服务器处于在线状态。举个例子,如果Sentinel1载入了以下配置```csentinel monitor master 127.0.0.1 6379 2sentinel down-after-milliseconds master 50000```而Sentinel2则载入了以下配置:```csentinel monitor master 127.0.0.1 6379 2sentinel down-after-milliseconds master 10000```那么当master的断线时长超过10000毫秒之后,Sentinel2会将master判断为主观下线,而Sentinel1却认为master仍然在线。只有当master的断线时长超过50000毫秒之后,Sentinel1和Sentinel2才会都认为master进入了主观下线状态
检测主观下线状态。在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。如图所示,带箭头的连线显示了Sentinel1和Sentinel2是如何向实例发送PING命令的:1.Sentinel1将向Sentinel2、主服务器master、从服务器slave1和slave2发送PING命令2.Sentinel2将向Sentinel1、主服务器master、从服务器slave1和slave2发送PING命令实例对PING命令的回复可以分为以下两种情况:1.有效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复的其中一种2.无效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复之外的其他回复,或者在指定时间内没有返回任何回复Sentinel配置文件中的down-after-milliseconds选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在down-after-milliseconds毫秒内,连续向Sentinel返回无效回复,那么Sentinel会修改这个实例所对应的实例结构,在结构的flags属性中打开SRI_S_DOWN标识,以此来表示这个实例已经进入主观下线状态。以上图展示的情况为例,如果配置文件指定Sentinel1的down-after-milliseconds选项的值为50000毫秒,那么当主服务器master连续50000毫秒都向Sentinel1返回无效回复时,Sentinel1就会将master标记为主观下线,并在master所对应的实例结构的flags属性中打开SRI_S_DOWN标识,如图所示
举个例子,如果被Sentinel判断为主观下线的主服务器的IP为127.0.0.1,端口号为6379,并且Sentinel当前的配置纪元为0,那么Sentinel将向其他Sentinel发送以下命令:```cSENTINEL is-master-down-by-addr 127.0.0.1 6379 0 *```
发送SENTINEL is-master-down-by-addr命令。Sentinel使用:```cSENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>```命令询问其他Sentinel是否同意主服务器已下线,命令中的各个参数的意义如表所示
举个例子。如果一个Sentinel返回以下回复作为SENTINEL is-master-down-by-addr命令的回复:```c1.12.*3.0```那么说明Sentinel也同意主服务器已下线
接收SENTINEL is-master-down-by-addr命令的回复。根据其他Sentinel发回的SENTIENL is-master-down-by-addr命令回复,Sentinel将统计其他Sentinel同意主服务器已下线的数量,当这一数量到达到配置指定的判断客观下线所需的数量时,Sentinel会将主服务器实例结构flags属性的SRI_O_DOWN标识打开,标识主服务器已经进入客观下线状态,如图所示
客观下线状态的判断。当认为主服务器已经进入下线状态的Sentinel的数量,超过Sentinel配置中设置的quorum参数的值,那么该Sentinel就会认为主服务器已经进入客观下线的状态。比如说,```csentinel monitor master 127.0.0.1 6379 2```那么包括当前Sentinel在内,只要总共有两个Sentinel认为主服务器已经进入下线状态,那么当前Sentinel就将主服务器判断为客观下线,又比如说,如果Sentinel在启动时载入了以下配置:```csentinel monitor master 127.0.0.1 6379 5```那么包括当前Sentinel在内,总共要有五个Sentinel都认为主服务器已经下线,当前Sentinel才会将主服务器判断为客观下线
不同Sentinel判断客观下线的条件可能不同。对于监视同一个主服务器的多个Sentinel来说,它们将主服务器判断为客观下线的条件可能也不同:当一个Sentinel将主服务器判断为客观下线时,其他Sentinel可能并不是那么认为的。比如说,对于监视同一个主服务器的五个Sentinel来说,如果Sentinel1在启动时载入了以下配置:```csentinel monitor master 127.0.0.1 6379 2```那么当五个Sentinel中有两个Sentinel认为u主服务器已经下线时,Sentinel就会将主服务器判断为客观下线。而对于载入了以下配置的Sentinel2来说```csentinel monitor master 127.0.0.1 6379 5```仅有两个Sentinel1认为主服务器已下线,并不会令Sentinel2将主服务器判断为客观下线。
检查客观下线状态。当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移
新的主服务器是怎样挑选出来的。领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:1.删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线2.删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器3.删除所有与已下线主服务器连接断开超过down-after-milliseconds * 10 毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds * 10 毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。之后,领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。如果有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其他偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的服务器
举个例子。如图展示了在一次故障转移操作中,领头Sentinel向选中的从服务器server2发送SLAVEOF no one命令的情形。在发送SLAVEOF no one命令之后,领头Sentinel会以每秒一次的频率(平时是每十秒一次),向被升级的从服务器发送INFO命令,并观察命令回复中的角色(role)信息,当被升级服务器的role从原来的slave变为master时,领头Sentinel就知道被选中的从服务器已经顺利升级为主服务器了
如果在上图的例子中,领头Sentinel会一直向server2发送INFO命令,当server2返回的命令回复从:```c# Replcationrole:slave...# Other sections...```变为:```c# Replicationrole:master...# Other sections...```的时候,领头Sentinel就知道server2已经成功升级为了主服务器了。当server2升级成功之后,各个服务器和领头Sentinel如图所示
选出新的主服务器。故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将这个从服务器转换为主服务器。
server3和server4成为server2的从服务器
修改从服务器的复制目标。当新的主服务器出现之后,领头Sentinel下一步要做的就是,让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。图中展示了在故障转移过程中,领头Sentinel向已下线主服务器server1的两个从服务器server3和server4发送SLAVEOF命令,让它们复制新的主服务器server2的例子.当server3和server4成为server2的从服务器之后,各个服务器以及领头Sentinel的样子如图所示
将旧的主服务器变为从服务器。故障转移操作最后要做的是,将已下线的主服务器设置为新的主服务器的从服务器,比如说,图中就展示了被领头Sentinel设置为从服务器之后,服务器server1的样子,当server1重新上线之后,就成为了server2的从服务器,如图所示
故障转移。在选举产生出领头Sentinel之后,领头Sentinel将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:1.在已下线的主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器2.让已下线主服务器属下的所有从服务器改为复制新的主服务器3.将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器
Sentinel
概述。Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能
1.三个独立的节点
2.节点7000和7001进行握手
3.握手成功的7000与7001处于同一个集群
4.节点7000与节点7002进行握手
5.握手成功的三个节点处于同一个集群
启动节点。一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enable配置选项是否为yes来决定是否开启服务器的集群模式,如图所示。节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件,比如说:1.节点会继续使用文件事件处理器来处理命令请求和返回命令回复2.节点会继续使用时间事件处理器来执行serverCron函数。而serverCron函数又会调用集群模式特有的clusterCron函数。clusterCron函数负责执行在集群模式下需要执行的常规操作,例如向集群中的其他节点发送Gossip消息,检查节点是否断线,或者检查是否需要对下线节点进行自动故障转移等。3.节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象4.节点会继续使用RDB持久化模块和AOF持久化模块来执行持久化工作5.节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令6.节点会继续使用复制模块来进行节点的复制工作7.节点会继续使用Lua脚本来执行客户端输入的Lua脚本。除此之外,节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将它们保存到了cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里面
```cstruct clusterNode {// 创建节点的时间mstime_t ctime;// 节点的名字,由40个十六进制字符组成// 例如ceb9e23e93e0aae13e5333f50d39336a97e5cba3char name[REDIS_CLUSTER_NAMELEN];// 节点标识// 使用各种不同的标识值记录节点的角色(比如主节点或者从节点)// 以及节点目前所处的状态(比如在线或者下线)int flags;// 节点当前的配置纪元,用于实现故障转移uint64_t configEpoch;// 节点的IP地址char ip[REDIS_IP_STR_LEN];// 节点的端口号int port;// 保存连接节点所需的有关信息clusterLink *link;// ...};```clusterNode结构
```ctypedef struct clusterLink {// 连接的创建时间mstime_t ctime;// TCP 套接字描述符int fd;// 输出缓冲区,保存着等待发送给其他节点的消息(message)sds sndbuf;// 输入缓冲区,保存着从其他节点接收到的消息sds rcvbuf;// 与这个连接相关联的节点,如果没有的话就为NULLstruct clusterNode *node;}clusterLink;```clusterLink结构
```ctypedef struct clusterState {// 指向当前节点的指针clusterNode *myself;// 集群当前的配置纪元,用于实现故障转移uint64_t currentEpoch;// 集群当前的状态:是在线还是下线int state;// 集群中至少处理着一个槽的节点的数量int size;// 集群节点名单(包括myself节点)// 字典的键为节点的名字,字典的值为节点对应的clusterNode结构dict *nodes;// ...}clusterState;```clusterState结构
redisClient结构和clusterLink结构的相同和不同之处。redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区,这两个结构的区别在于,redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区是用于连接节点的。
集群数据结构。cclusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等等。每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态:结构如子主题clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区:结构如子主题最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:结构如子主题
CLUSTER MEET命令的实现。通过向节点A发送CLUSTER MEET命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群里面:```cCLUSTER MEET <ip> <port>```收到命令的节点A将与节点B进行握手(handshake),以此来确认彼此的存在,并未将来的进一步通信打好基础:1.节点A会为节点B创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面2.之后,节点A将根据CLUSTER MEET命令给定的IP地址和端口号,向节点B发送一条MEET消息(message)3.如果一切顺利,节点B将接收到节点A发送的MEET消息,节点B会为节点A创建一个clusterNode结构,并将该结构添加到自己的clusterState.nodes字典里面4.之后,节点B将向节点A返回一条PONG消息5.如果一切顺利,节点A将接收到节点B返回的PONG消息,通过这条PONG消息节点A可以知道节点B已经成功地接收到了自己发送地MEET消息6.之后,节点A将向节点B返回一条PING消息7.如果一切顺利,节点B将接收到节点A返回的PING消息,通过这条PING消息节点B可以知道节点A已经成功地接收到了自己返回的PONG消息,握手完成如图展示了握手过程。之后,节点A会将节点B的信息通过Goossip协议传播给集群中的其他节点,让其他节点也与节点B进行握手,最终,经过一段时间之后,节点B会被集群中的所有节点认识
如图展示了另一个slots数组示例:这个数组索引1、3、5、8、9、10上的二进制位的值都为1,而其余所有二进制位的值都为0,这表示节点负责处理1、3、5、8、9、10
记录节点的槽指派信息。clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽:```cstruct clusterNode {// ...unsigned char slots[16384/8];int numslots;// ...}```slots属性是一个二进制位数组(bit array),这个数组的长度位16384/8=2048个字节,共包含16384个二进制位。Redis以0为起始索引,16383为终止索引,对slots数组中的16384个二进制位进行编号,并根据索引i上的二进制位的值来判断节点是否负责处理槽i:1.如果slots数组在索引i上的二进制位的值位1,那么表示节点负责处理槽i2.如果slots数组在索引i上的二进制位的值为0,那么表示节点不负责处理槽i因为取出和设置slots数组中的任意一个二进制位的值的复杂度仅为O(1),所以对于一个给定节点的slots数组来说,程序检查节点是否负责处理某个槽,又或者将某个槽指派给节点负责,这两个动作的复杂度都是O(1).至于numslots属性则记录节点负责处理的槽的数量,也即是slots数组中值位1的二进制位的数量。比如说,例子中的节点处理的槽数量分别为8、6
举个例子,对于前面展示的7000、7001、7002三个节点的集群来说:1.节点7000会通过消息向节点7001和节点7002发送自己的slots数组,以此来告知两个节点,自己负责处理槽0至槽5000,如图所示2.节点7001会通过消息向节点7000和节点7002发送自己的slots数组,以此来告知两个节点,自己负责处理槽5001至槽10000,如图所示3.节点7002会通过消息向节点7000和节点7001发送自己的slots数组,以此来告知两个节点,自己负责处理槽10001至槽16383,如图所示
传播节点的槽指派信息。一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之外,他还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽.
举个例子,对于7000、7001、7002三个节点来说,它们的clusterState结构的slots数组将会是如图所示的样子:1.数组项slots[0]至slots[5000]的指针都指向7000的clusterNode结构,表示槽0至5000都指派给了节点70002.数组项slots[5001]至slots[10000]的指针都指向代表节点7001的clusterNode结构,表示槽5001至槽10000都指派给了节点70013.数组项slots[10001]至slots[16383]的指针都指向代表节点7002的clusterNode结构,表示槽10001至槽16383都指派给了节点7002
举个例子,对于上图中的slots数组来说,如果程序需要直到槽10002被指派给了哪个节点,那么只需要访问数组项slots[10002],就可以马上直到槽10002被指派给了节点7002,如图所示
要说明的一点是,虽然slutserState.slots数组记录了集群中素有槽的指派信息,但使用clusterNode结构的slots数组来记录单个节点的槽指派信息仍然是有必要的:1.因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的clusterNode.slots数组整个发送出去就可以了2.另一方面,如果Redis不使用clusterNode.slots数组,而单独使用clusterNode.slots数组的话,那么每次要将节点A的槽指派信息传播给其他节点时,程序必须先遍历整个clusterState.slots数组,记录节点A负责处理哪些槽,然后才能发送节点A的槽指派信息,这比直接发哦是那个clusterNode.slots数组要麻烦和低效得多。clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这时两个slots数组的关键区别所在
记录集群所有槽的指派消息。clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息:```ctypedef struct clusterState{// ...clusterNode *slots[16384];// ...} clusterState;```slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:1.如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点2.如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所代表的节点如果只将指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效地解决的问题,而clusterState.slots数组的存在解决了这些问题:1.如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历clusterState.nodes字典中所有的clusterNode结构,检查这些结构的slots数组,直到找到负责处理槽i的节点为止,这个过程的复杂度为O(N),其中N为clusterState.nodes字典保存的clusterNode结构的数量2.而通过将所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度仅为O(1)
举个例子。如果在之前提到的由7000、7001、7002三个节点组成的集群中,用客户端连上节点7000,并发送以下命令,那么命令会直接被节点7000执行:```c127.0.0.1:7000> SET date \"2024-04-10\"OK```因为键date所在的槽2022正式由节点7000负责处理的。但是,如果执行以下命令,那么客户端会先被转向至节点7001,然后再执行命令```c127.0.0.1:7000> SET msg \"happy new year!\"-> Redirected to slot[6257] located at 127.0.0.1:7001OK127.0.0.1:7001> GET msg\"happy new year!\"```这是因为msg所在的槽6257是由节点7001负责处理的,而不是由最初接收命令的节点7000负责处理:1.当客户端第一次向节点7000发送SET命令的时候,节点7000会向客户端返回MOVED错误,指引客户端转向至节点70012.当客户端转向到节点7001之后,客户端重新向节点7001发送SET命令,这个命令会被节点7001成功执行
计算键属于哪个键。节点使用以下算法来计算给定键key属于哪个槽:```cdef slot_number(key):return CRC16(key) & 16383```其中CRC16(key)语句用于计算键key的CRC-16校验和,而& 16383语句则用于计算出一个介于0~16383之间的整数作为键key的槽号。可以使用CLUSTER KEYSLOT <key>命令可以查看一个给定键属于哪个槽:```c127.0.0.1:7000> CLUSTER KEYSLOT \"date\"(integer) 2022127.0.0.1:7000> CLUSTER KEYSLOT \"msg\"(integer) 6257127.0.0.1:7000> CLUSTER KEYSLOT \"name\"(integer) 5798127.0.0.1:7000> CLUSTER KEYSLOT \"fruits\"(integer) 14943```CLUSTER KEYSLOT命令就是通过调用上面给出的槽分配算法来实现的,以下是该命令的伪代码实现:```cdef CLUSTER_KEYSLOT(key):# 计算槽号slot = slot_number(key)# 将槽号返回给客户端reply_client(slot)```
举个例子。假设如图所示为节点7000的clusterState结构:1.当客户端向节点7000发送命令SET date \"2024-04-10\
如图所示展示了客户端向节点7000发送SET命令,并获得MOVED错误的过程
客户端根据MOVED错误,转向至节点7001,并重新发送SET命令的过程
一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令。如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据MOVED错误提供的IP地址和端口号来连接节点,然后再进行转向
被隐藏的MVOED错误。集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错误,而是根据MOVED错误自动进行节点转向,并打印出转向信息,所以是看不见节点返回的MOVED错误的```credis-cli -c -p 7000 #集群模式127.0.0.1:7000> SET msg \"happy new year!\"-> Redirected to slot[6257] located at 127.0.0.1:7001OK```但是如果,使用单机(stand alone)模式的redis-cli客户端,再次向节点7000发送相同的命令,那么MOVED错误就会被打印出来:```credis-cli -p 7000 # 单机模式127.0.0.1:7000> SET msg \"happy new year!\"(error) MOVED 6257 127.0.0.1:7001```这是因为单机模式的redis-cli客户端不清除MOVED错误的作用,所以它只会直接将MOVED错误直接打印出来,而不会进行自动转向。
举个例子。如图展示了节点7000的数据库状态,数据库中包含列表键\"lst\",哈希键\"book\",以及字符串键\"date\
举个例子。对于上图所示的数据库,节点7000将创建类似如图所示的slots_to_keys跳跃表:1.键\"book\
在集群中执行命令。在对数据库中的16384个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了,当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:1.如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令2.如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令,如图所示
举个例子。对于之前提到的7000、7001、7002三个节点的集群来说,我们可以向这个集群中添加一个IP为127.0.0.1,端口号为7003的节点```credis-cli -c -p 7000127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7003OK```可以用CLUSTER NODES进行查看各个节点之前所负责的槽的区间
重新分片的实现原理。Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。redis-trib对集群的单个槽slot进行重新分片的步骤如下:1.redis-trib对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令,让目标节点准备好从源节点导入(import)属于槽slot的键值对2.redis-trib对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点3.redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令,获得最多count个属于槽slot的键值对的键名(key name)4.对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> - <timeout>命令,将被选中的键原子地从源节点迁移至目标节点5.重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。每次迁移的过程如图所示6.redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT <slot> NODE <target_id>命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所有节点都会直到槽slot已经指派给了目标节点如果重新分片涉及多个槽,那么redis-trib将对每个给定的槽分别执行上面给出的步骤,流程图如图所示
重新分片。Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。
被隐藏的ASK错误。和接到MOVED错误时的情况类似,集群模式的redis-cli在接到ASK错误时也不会打印错误,而是自动根据错误提供的IP地址和端口进行转向动作。如果想看到节点发送的ASK错误的话,可以使用单机模式的redis-cli客户端```credis-cli -p 7002127.0.0.1:7002> GET \"love\"(error) ASK 16198 127.0.0.1:7003```
举个例子。如果客户端向节点7003发送以下命令:```c# b4348045d1eadca5abd96b8d0d13b0309ed117e1 是节点7002的id127.0.0.1:7003> CLUSTER SETSLOT 16198 IMPORTING b4348045d1eadca5abd96b8d0d13b0309ed117e1OK```那么节点7003的clusterState.importing_slots_from数组将变成如图所示的样子。
举个例子。如果客户端向节点7002发送以下命令:```c# b4348045d1eadca5abd96b8d0d13b0309ed117e1 是节点7003的ID127.0.0.1:7002>CLUSTER SETSLOT 16198 MIGRATING b4348045d1eadca5abd96b8d0d13b0309ed117e1``` 那么节点7002的clusterstate.migrating_slots_to数组将变成如图所示的样子
CLUSTER SETSLOT MIGRATING命令的实现。clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽:```ctypedef struct clusterState {// ...clusterNode *migrating_slots_to[16384]// ...} clusterState;```如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在将槽[i]迁移至clusterNode所代表的节点。在对集群进行重新分片的时候,向源节点发送命令:```cCLUSTER SETSLOT <i> MIGRATING <target_id>```可以将源节点clusterState.migrating_slots_to[i]的值设置为target_id所代表节点的clusterNode结构。
流程如图所示
举个例子。假设在节点7002向节点7003迁移槽16198期间,有一个客户端向节点发送命令:```cGET \"love\"```因为键\"love\"正好属于槽16198,所以节点7002会首先在自己的数据库中查找键\"love\
ASK错误。如果节点收到一个关于键key的命令请求,并且键key所属的槽i正好就指派给了这个节点,那么节点会尝试在自己的数据库里查找键key,如果找到了的话,节点就直接执行客户端发送的命令。与此相反,如果节点没有在自己的数据库里找到键key,那么系欸但会检查自己的clusterState.migrating_slots_to[i],看键key所属的槽i是否正在进行迁移,如果槽i的确在进行迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端正在导入槽i的节点去查找键key。
举个例子,我们可以使用普通模式的redis-cli客户端,向正在导入槽16198的节点7003发送以下命令:```c./redis-cli -p 7003127.0.0.11:7003> GET \"love\"(error) MOVED 16198 127.0.0.1:7002```虽然节点7003正在导入槽16198,但槽16198目前仍然是指派给了节点7002,所以节点7003会向客户端返回MOVED错误,指引客户端转向节点7002.但是,如果我们在发送GET命令之前,先向节点发送一个ASKING命令,那么这个GET命令就会被节点7003执行:```c127.0.0.1:7003> ASKINGOK127.0.0.1:7003> GET \"love\"\"you get the key 'love'\"```另外要注意的是,客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING标识的客户端发送的命令之后,客户端的REDIS_ASKING标识就会被移除。
举个例子。如果我们在成功执行GET命令之后,再次向节点7003发送GET命令,那么第二次发送的GET命令将执行失败,因为这时客户端的REDIS_ASKING标识已经被移除:```c127.0.0.1:7003> ASKING # 打开REDIS_ASKING标识OK127.0.0.1:7003> GET \"love\" # 移除REDIS_ASKING标识\"you get the key 'love'\"127.0.0.1:7003> GET 'love' # REDIS_ASKING 标识未打开,执行失败(error) MOVED 16198 127.0.0.1:7002```
ASKING命令。ASKING命令唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识,以下是该命令的伪代码实现:```cdef ASKING():# 打开标识client.flags |= REDIS_ASKING# 向客户端返回OK回复reply(\"OK\
ASK错误和MOVED错误的区别。ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:1.MOVED错误代表槽的负责全已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点2.与此相反,ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现
ASK错误。在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。当客户端向源节点发送一个与数据库有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:1.源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令2.相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令
节点7004成为新的主节点
举个例子。对于包含7000、7001、7002、7003四个主节点的集群来说,我们可以将7004、7005两个节点添加到集群里面,并将这两个节点设定为节点7000的从节点,如图所示(双圆形代表主节点,单圆形表示从节点),如表所示记录了集群各个节点的当前装填,以及它们正在做的工作。如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点——节点7004和节点7005中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责处理的槽,并继续处理客户端发送的命令请求。例如,如果节点7004被选中为新的主节点,那么节点7004将接管原来由节点7000负责处理的槽0至槽5000,节点7005也会从原来的复制节点7000改为复制节点7004,如图所示另一张表记录了在对节点7000进行故障转移之后,集群各个节点的当前状态,以及它们正在做的工作.如果在故障转移完成之后,下线的节点7000重新上线,那么它将成为节点7004的从节点,如图所示
举个例子。图中记录了节点7004和节点7005成为节点7000的从节点之后,集群中的各个节点为节点7000创建的clusterNode结构的样子:1.代表节点7000的clusterNode结构的numslaves属性的值为2,这说明有两个从节点正在复制节点70002.代表节点7000的clusterNode结构的slaves数组的两个项分别指向代表节点7004和代表节点7005的clusterNode结构这说明节点7000的两个从节点分别是节点7004和节点7005
举个例子。如果节点7001向节点7000发送了一条PING消息,但是节点7000没有在规定的时间内,向节点7001返回一条PONG消息,那么节点7001就会在自己的clusterState.nodes字典中找到7000所对应的clusterNode结构,并在结构的flags属性中打开REDIS_NODE_PFAIL标识,以此表示节点7000进入了疑似下线状态,如图所示
举个例子。如果主节点7001在收到主节点7002、主节点7003发送的消息后得知,主节点7002和主节点7003都认为主节点7000进入了疑似下线状态,那么主节点7001将为主节点7000创建如图所示的下线报告.
故障转移。当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:1.复制下线主节点的所有从节点里面,会有一个从节点被选中2.被选中的从节点会执行SLAVEOF no one命令,成为新的主节点3.新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己4.新的主节点会向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽5.新的主节点开始接收和自己负责处理的槽的有关的命令请求,故障转移完成
复制与故障转移。Redis集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求
```ctypedef struct {// 消息的长度(包括这个消息头的长度和消息正文的长度)uint32_t totlen;// 消息的类型uint16_t type;// 消息正文包含的节点信息数量// 只在发送MEET、PING、PONG这三种Gossip协议信息时使用uint16_t count;// 发送者所处的纪元uint64_t currentEpoch;// 如果发送者是一个主节点,那么这里记录的是发送者的配置纪元// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元uint64_t configEpoch;// 发送者的名字(id)char sender[REDIS_CLUSTER_NAMELEN];// 发送者目前的槽指派信息unsigned char myslots[REDIS_CLUSTER_SLOTS/8];// 如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字// 如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAMEchar slaveof[REDIS_CLUSTER_NAMELEN];// 发送者的端口号uint16_t port;// 发送者的标识值uint16_t flags;// 发送者所处集群的状态unsigned char state;// 消息的正文(或者说,内容)unio clusterMsgData data;}clusterMsg;```clusterMsg结构表示
```cunion clusterMsgData {struct {// MEET、PING、PONG消息都包含两个clusterMsgDataGossip结构clusterMsgDataGossip gossip[1];} ping;// FAIL消息的正文struct {clusterMsgDataFail abount;}fail;// PUBLISH消息的正文struct {clusterMsgDataPublish msg;}publish;// 其他消息正文...}```clusterMsgData表示:
消息头。节点发送的所有消息都由一个消息头包裹,消息头除了包含消息正文之外,还记录了消息发送者自身的一些信息,因为这些信息也会被消息接收者用到,所以严格来讲,可以认为消息头本身也是消息的一部分。每个消息头都由一个cluster.h/clusterMsg结构表示clusterMsg.data属性指向联合cluster.h/clusterMsgData,这个联合就是消息的正文:clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身的节点信息,接收者会根据这些信息在自己的clusterState.nodes字典里找到发送者对应的clusterNode结构,并对结构进行更新举个例子,通过对比接收者为发送者记录的槽指派信息,以及发送者在消息头的myslots属性记录的槽指派信息,接收者可以知道发送者的槽指派信息是否发生了变化,又或者说,通过对比接收者为发送者记录的标识值,以及发送者在消息头的flags属性记录的标识值,接收者可以知道发送者的状态和角色是否发生了变化,例如节点状态由原来的在线变成了下线,或者由主节点变成了从节点等等
MEET、PING、PONG消息的实现。Redis集群中的各个节点通过Gossip协议来交换各自不同节点的状态信息,其中Gossip协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个cluster.h/clusterMsgDataGossip结构组成:```cunion clusterMsgData {// ...// MEET、PING和PONG消息的正文struct {// 每条MEET、PING、PONG消息都包含两个// clusterMsgDataGossip结构clusterMsgDataGossip gossip[1];}ping};```因为MEET、PING、PONG三种消息都由相同的消息正文,所以节点通过消息头的type属性来判断一条消息是MEET消息、PING消息和PONG消息。每次发送MEET、PING、PONG消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是这个主节点或者从节点),并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip结构里面。clusterMsgDataGossip结构记录了被选中节点的名字,发送者与被选中节点最后一次发送和接收PING消息和PONG消息的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值:```ctypedef struct {// 节点的名字char nodename[REDIS_CLUSTER_NAMELEN];// 最后一次向该节点发送PING消息的时间戳uint32_t ping_sent;// 最后一次从该节点接收到PONG消息的时间戳uint32_t pong_received;// 节点的IP地址char ip[16];// 节点的端口号uint16_t port;// 节点的标识值uint16_t flags;}clsterMsgDataGossip```当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个clusterMsgDataGossip结构,并根据自己是否认识clusterMsgDataGossip结构中记录的被选中节点来选择进行哪种操作:1.如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选中节点进行握手.2.如果被选中节点已经存在与接收者的已知节点列表,那么说明接收者之前已经被选中节点进行过接触,接收者将根据clusterMsgDataGossip结构记录的信息,对被选中节点所对应的clusterNode结构进行更新
举个例子。对于包含7000、7001、7002、7003四个主节点的集群来说:1.如果主节点7001发现主节点7000已下线,那么主节点7001将向主节点7002和主节点7003发送FAIL消息,其中FAIL消息中包含的节点名字为主节点7000的名字,以此来表示主节点7000已下线2.当主节点7002和主节点7003都接收到主节点7001发送的FAIL消息时,它们也会将主节点7000标记为已下线3.因为这时集群已经有超过一半的主节点认为主节点7000已下线,所以集群剩下的几个主节点可以判断是否需要将集群标记为下线,又或者开始对主节点7000进行故障转移
图中展示了节点发送和接收FAIL消息的整个过程
FAIL消息的实现。当集群里的主节点A将主节点B标记为已下线(FAIL)时,主节点A将向集群广播一条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为已下线。在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,因为Gossip协议消息通常许需要一段时间才能传播至整个集群,而发送FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。FAIL消息的正文由cluster.h/clusterMsgDataFail结构表示,这个结构只包含一个nodename属性,该属性记录了已下线节点的名字:```ctypedef struct {char nodename[REDIS_CLUSTER_NAMELEN];}clusterMsgDataFail;```因为集群里的所有节点都有一个独一无二的名字,所以FAIL消息里面只需要保存下线节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点下线了。
举个例子。对于包含7000、7001、7002、7003四个节点的集群来说,如果节点7000收到了客户端发送的PUBLISH命令,那么节点7000将向7001、7002、7003三个节点发送PUBLISH消息,如图所示
举个例子。如果节点收到的PUBLISH命令为:```cPUBLISH \"news.it\" \"hello\"```那么节点发送的PUBLISH消息的clusterMsgDataPublish结构将如图所示:其中bulk_data数组的前七个字节保存了channel参数的值\"news.it\"而bulk_data数组的后五个字节则保存了message参数的值\"hello\"
为什么不直接向节点广播PUBLISH命令?实际上,要让集群的所有节点都执行相同的PUBLISH命令,最简单的方法就是向所有节点广播相同的PUBLISH命令,这也是Redis在复制PUBLISH命令时所使用的方法,不过因为这种做法并不符合Redis集群的\"各个节点通过发送和接收消息来进行通信\"这一规则,所以节点没有采取广播PUBLISH命令的做法
集群
举个例子。假设A、B、C三个客户端都执行了命令:```cSUBSCRIBE \"news.it\"```那么这三个客户端就是\"news.it\"频道的订阅者,如图所示。如果这时某个客户端执行命令```cPUBLISH \"news.it\" \"hello\"```向\"news.it\"频道发送消息\"hello\
举个例子。假设如图所示:1.客户端A正在订阅频道\"news.it\"2.客户端B正在订阅频道\"news.et\"3.客户端C和客户端D正在订阅与\"news.it\"频道和\"news.et\"频道相匹配的模式\"news.[ie]t\".如果这时某个客户端执行命令```cPUBLISH \"news.it\" \"hello\"```\"news.it\"频道发送消息\"hello\",那么不仅正在订阅\"news.it\"频道的客户端A会受到消息,客户端C和客户端D也会收到消息,因为这两个客户端正在订阅匹配\"news.it\"频道的\"news.[ie]t\"模式,如图所示,与此类似,如果某个客户端执行命令```cPUBLISH \"news.et\" \"world\"```\"news.et\"频道发送消息\"world\",那么不仅正在订阅\"news.et\"频道的客户端B会收到消息,客户端C和客户端D也同样会收到消息。因为这两个客户端正在订阅匹配\"news.et\"频道的\"news.[ie]t\"模式,如图所示
概述。Redis的发布与订阅功能由PUBLISH、SUBSCRIBE、PSUBSCRIBE等命令组成。通过执行SUBSCRIBER命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscribe):每当有其他客户端向被订阅的频道发送消息(message)时,频道的所有订阅者都会收到这条消息.除了订阅频道之外,客户端还可以通过执行PSUBSCRIBE命令订阅一个或多个模式,从而成为这些模式的订阅者:每当有其他客户端向某个频道发送消息时,消息不仅会被发送给这个频道的所有订阅者,它还会被发送给所有与这个频道相匹配的模式的订阅者
补充:1.RDB文件中的每个key_value_pairs部分都保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对的过期时间也会被保存在内。不带过期时间的键值对在RDB文件中由TYPE、key、value三部分组成.1.1 TYPE记录了value的类型,长度为1字节,值可以是以下常量的其中一个:REDIS_RDB_TYPE_STRINGREDIS_RDB_TYPE_LISTREDIS_RDB_TYPE_SETREDIS_RDB_TYPE_ZSETREDIS_RDB_TYPE_HASHREDIS_RDB_TYPE_LIST_ZIPLISTREDIS_RDB_TYPE_SET_INTSETREDIS_RDB_TYPE_ZSET_ZIPLISTREDIS_RDB_TYPE_HASH_ZIPLIST以上列出的每个TYPE常量都代表了一种对象类型或者底层编码,当服务器读入RDB文件中的键值对数据时,程序会根据TYPE的值来决定如何读入和解释value的数据
AOF的话,像SELECT 1这样的命令都会被记录在AOF文件中,PUBLISH也会被记录进去
Redis中的发布订阅会进行持久化吗?在Redis中,发布订阅(Pub/Sub)模式本身并不会进行消息的持久化。当消息被发布到频道时,它们会被发送给当前处于订阅状态的客户端,并在这些客户端中进行传递,但不会被持久化到磁盘上。如果需要对消息进行持久化,可以考虑以下几种方法:1.使用Redis Streams:Redis5.0引入了Streams数据结构,它提供更丰富的消息传递功能,并且支持消息的持久化。可以将消息发送到RedisStreams中,然后消费者可以按需读取消息,并且这些消息会持久化到Redis中2.将消息存储到数据库中:在订阅者接收到消息后,可以将消息存储到数据库中进行持久化。这样可以确保即使在Redis发布订阅模式中没有持久化消息的情况下,仍然可以通过数据库来获取消息历史记录3.使用Redis AOF持久化:如果仍然希望使用Redis发布订阅模式,并且希望对消息进行持久化,可以启用Redis的AOF(Append-Only File)持久化功能。AOF记录了Redis服务器接收到的所有写命令,包括发布消息到频道的命令。通过启用AOF持久化,可以确保即使Redis服务器重启,也不会丢失消息综上所述,Redis发布订阅模式本身并不提供消息持久化功能,但可以通过其他方式来实现消息的持久化,来满足需求
举个例子。如图展示了一个pubsub_channels字典示例,这个字典记录了以下信息:1.client-1、client-2、client-3三个客户端正在订阅\"news.it\"频道2.客户端client-4正在订阅\"news.sport\"频道3.client-5和client-6两个客户端正在订阅\"news.business\"频道
举个例子。假设服务器pubsub_channels字典的当前状态如图所示,那么当客户端client-10086执行命令```cSUBSCRIBE \"news.sport\" \"news.movie\"```之后,pubsub_channels字典将更新至如图所示的状态,其中用虚线包围的是新添加的节点:1.更新后的pubsub_channels字典新增了\"news.movie\"键,该键对应的链表值只包含一个client-10086节点,表示目前只有client-10086一个客户端在订阅\"news.movie\"频道2.至于原本就已经有客户端在订阅的\"news.sport\"频道,client-10086的节点放在了频道对应链表的末尾,排在client-4节点的后面
SUBSCRIBE命令的实现可以用以下伪代码来描述:```cdef subscribe(*all_input_channels):# 遍历输入的所有频道for channel in all_input_channels:# 如果channel不存在于pubsub_channels字典(没有任何订阅者)# 那么在字典中添加channel键,并设置它的值为空链表if channel not in server.pubsub_channels:server.pubsub_channels[channel] = []# 将订阅者添加到频道所对应的链表的末尾server.pubsub_channels[channel].append(client)```
订阅频道。每当客户端执行SUBSCRIBE命令订阅某个或某些频道的时候,服务器都会将客户端与被订阅的频道在pubsub_channels字典中进行关联。根据频道是否已经有其他订阅者,关联操作分为两种情况执行:1.如果频道已经有其他订阅者,那么它在pubsub_channels字典中必然有相应的订阅者链表,程序唯一要做的就是将客户端添加到订阅者链表的末尾2.如果频道还未有任何订阅者,那么它必然不存在于pubsub_channels字典,程序首先要在pubsub_channels字典中为频道创建一个键,并将这个键的值设置为空链表,然后再将客户端添加到链表,成为链表的第一个元素
举个例子,假设pubsub_channels的当前状态如图所示,那么当客户端client-10086执行命令:```cUNSUBSCRIBE \"news.sport\" \"news.movie\"```之后,图中用虚线包围的两个节点将被删除如图所示1.在pubsub_channels字典更新之后,client-10086的信息已经从\"news.sport\"频道和\"news.movie\"频道的订阅者链表中被删除了2.另外,因为删除client-10086之后,频道\"news.movie\"已经没有任何订阅者,因此键\"news.movie\"也从字典中被删除了
UNSUBSCRIBE命令的实现可以用以下伪代码来描述:```cdef unsubscribe(*all_input_channels):# 遍历要退订的所有频道for channel in all_input_channels:# 在订阅者链表中删除退订server.pubsub_channels[channel].remove(client);# 如果频道已经没有任何订阅者(订阅者链表为空)# 那么将频道从字典中删除if len(server.pubsub_channels[channel]) == 0:server.pubsub_channels.remove(channel)```
退订频道。UNSUBSCRIBE命令的行为和SUBSCRIBE命令的行为正好相反,当一个客户端退订某个或某些频道的时候,服务器将从pubsub_channels中解除客户端与被退订频道之间的关联:1.程序会根据被退订频道的名字,在pubsub_channels字典中找到频道对应的订阅者链表,然后从订阅者链表删除退订客户端的信息2.如果删除退订客户端之后,频道的订阅者链表变成了空链表,那么说明这个频道已经没有任何订阅者了,程序将从pubsub_channels字典中删除频道对应的键。
频道的订阅与退订。当一个客户端执行SUBSCRIBE命令订阅某个或某些频道的时候,这个客户端与被订阅频道之间建立起了一种订阅关系。Redis将所有频道的订阅关系都保存在服务器状态的pubsub_channels字典里面,这个字典的键是某个被订阅的频道,而键的值则是一个链表,链表里面记录了所有订阅这个频道的客户端:```cstruct redisServer {// ...// 保存所有频道的订阅关系dict *pubsub_channels:// ...};```
举个例子。如图所示是一个pubsubPattern结构示例,它显示客户端client-9正在订阅模式\"news.*\".图中展示了pubsub_patterns链表示例,这个链表记录了以下信息:1.客户端client-7正在订阅模式\"music.*\"2.客户端client-8正在订阅模式\"book.*\"3.客户端client-9正在订阅模式\"news.*\"
举个例子,假设服务器中的pubsub_pattern链表的当前状态如图所示。当客户端client-9执行命令:```cPSUBSCRIBE \"news.*\"```之后,pubsub_patterns链表将更新至如图所示的状态,其中用虚线包围的是新添加的pubsubPattern结构
PSUBSCRIBE命令的实现原理可以用以下伪代码来描述```cdef psubscribe(*all_input_patterns):# 遍历输入的所有模式for pattern in all_input_patterns:# 创建新的pubsubPattern结构# 记录被订阅的模式,以及订阅模式的客户端pubsubPattern = create_new_pubsubPattern()pubsubPattern.client = clientpubsubPattern.pattern = pattern# 将新的pubsubPattern追加到pubsub_patterns链表末尾server.pubsub_patterns.append(pubsubPattern)```
订阅模式。每当客户端执行PSUBSCIRBE命令订阅某个或某些模式的时候,服务器会对每个被订阅的模式执行以下两个操作:1.新键一个pubsubPattern结构,将结构的pattern属性设置为被订阅的模式,client属性设置为订阅模式的客户端2.将pubsubPattern结构添加到pubsub_patterns链表的表尾。
举个例子。假设服务器pubsub_patterns链表的当前状态如图所示,当客户端client-9执行命令:```cPUNSUBSCRIBE \"news.*\"```之后,client属性为client-9,pattern属性为\"news.*\"的pubsubPattern结构将被删除,pubsub_patterns链表将更新至如图所示的样子
PUNSUBSCRIBE命令的实现原理可以用以下伪代码来描述```cdef punsubscribe(*all_input_patterns):# 遍历所有要退订的模式for pattern in all_input_patterns:# 遍历pubsub_patternsfor pubsubPattern in server.pubsub_patterns:# 如果当前客户端和pubsubPattern记录的客户端相同# 并且要退订的模式也和pubsubPattern记录的模式相同if client == pubsubPattern.client and \\ pattern == pubsubPattern.pattern:# 那么将这个pubsubPattern从链表删除server.pubsub_patterns.remove(pubsubPattern)```
退订模式。模式的退订模式PUNSUBSCRIBE是PSUBSCRIBE命令的反操作:当一个客户端退订某个或某些模式的时候,服务器将在pubsub_patterns链表中查找并删除那些pattern属性为退订模式,并且client属性为执行退订命令的客户端的pubsubPattern结构
模式的订阅与退订。服务器将所有频道的订阅关系都保存在服务器状态的pubsub_channels属性里面,与此类似,服务器也将所有模式的订阅都保存在服务器状态的pubsub_patterns属性里面:```cstruct redisServer {// ...// 保存所有模式订阅关系list *pubsub_patterns;// ...};```pubsub_patterns属性是一个链表,链表中的每个节点都包含着一个pubsubPatttern结构,这个结构的pattern属性记录了被订阅的模式,而client属性则记录了订阅模式的客户端:```ctypedef struct pubsubPattern{// 订阅模式的客户端redisClient *client;// 被订阅的模式robj *pattern;} pubsubPattern;```
举个例子。假设pubsub_patterns链表的当前状态如图所示。如果这时客户端执行命令```cPUBLISH \"news.it\" \"hello\"```那么PUBLISH命令会首先将消息\"hello\"发送给\"news.it\"频道的所有订阅者,然后开始在pubsub_patterns链表中查找是否有被订阅的模式与\"news.it\"频道相匹配,结果发现\"news.it\"频道和客户端client-9订阅的\"news.*\"频道匹配,于是命令将消息\"hello\"发送给客户端client-9
将消息发送给模式订阅者。因为服务器状态中的pubsub_patterns链表记录了所有模式的订阅关系,所以为了将消息发送给所有与channel频道相匹配的模式的订阅者,PUBLISH命令要做的就是遍历整个pubsub_patterns链表,查找那些与channel频道相匹配的模式,并将消息发送给订阅了这些模式的客户端。
发送消息。当一个Redis客户端执行PUBLISH <channel> <message>命令将消息message发送给频道channel的时候,服务器需要执行以下两个动作:1.将消息message发送给channel频道的所有订阅者2.如果一个或多个模式pattern与频道channel相匹配,那么将消息message发送给pattern模式的订阅者
举个例子。对于图中所示的pubsub_channels字典来说,执行PUBSUB CHANNELS命令将返回服务器目前订阅的四个频道:```c1.\"news.it\"2.\"news.sport\"3.\"news.business\"4.\"news.movie\"```另一方面,执行PUBSUB CHANNELS \"news.[is]*\"命令将返回\"news.it\"和\"news.sport\
举个例子。对于图中所示的pubsub_channels字典来说,对字典中的四个频道执行PUBSUB NUMSUB命令将获得以下回复```credis>PUBSUB NUMSUB news.it news.sport news.business news.movie1).\"news.it\"2).\"3\"3).\"news.sport\"4).\"2\"5).\"news.business\"6).\"2\"7).\"news.movie\"8).\"1\"```
PUBSUB NUMSUB。PUBSUB NUMSUB [channel-1 channel-2... channel-n]子命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量。这个子命令是通过pubsub_channels字典中找到频道对应的订阅者链表,然后返回订阅者链表的长度来实现的(订阅者链表的长度就是频道订阅者的数量),这个过程可以用以下伪代码来描述:```cdef pubsub_numsub(*all_input_channels):# 遍历输入的所有频道for channel in all_input_channels:# 如果pubsub_channels字典中没有channel这个键# 那么说明channel频道没有任何订阅者if channel not in server.pubsub_channels:# 返回频道名reply_channel_name(channel)# 订阅者数量为0reply_subscribe_count(0)# 如果pubsub_channels字典中存在channel键# 那么说明channel频道至少有一个订阅者else:# 返回频道名reply_channel_name(channel)# 订阅者链表的长度就是订阅者数量reply_subscribe_count(len(server.pubsub_channels(channel)))```
举个例子。对于图中所示的pubsub_patterns链表来说,执行PUBSUB NUMPAT命令将返回3:```credis>PUBSUB NUMPAT(integer) 3```
PUBSUB NUMPAT。PUBSUB NUMPAT子命令用于返回服务器当前被订阅模式的数量。这个子命令是通过返回pubsub_patterns链表的长度来实现的,因为这个链表的长度就是服务器被订阅模式的数量,这个过程可以用以下伪代码来描述:```cdef pubsub_numpat():# pubsub_patterns链表的长度即是被订阅模式的数量reply_pattern_count(len(server.pubsub_patterns))```
查看订阅信息。PUBSUB命令是Redis2.8新增加的命令之一,客户端可以通过这个命令来查看频道或者模式的相关信息,比如某个频道目前有多少订阅者,又或者某个模式目前有多少订阅者,诸如此类
发布与订阅
举个例子。事务首先以一个MULTI命令为开始,接着将多个命令放入事务当中,最后由EXEC命令将这个事务提交(commit)给服务器执行:```c127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET \"name\" \"Practical Common Lisp\"QUEUED127.0.0.1:6379> GET \"name\"QUEUED127.0.0.1:6379> SET \"author\" \"Peter Seibel\"QUEUED127.0.0.1:6379> GET \"author\"QUEUED127.0.0.1:6379> EXEC1) OK2) \"Practical Common Lisp\"3) OK4) \"Peter Seibel\"```
事务开始。MULTI命令的执行标志着事务的开始:```credis> MULTIOK```MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的,MULTI命令的实现可以用以下伪代码来表示:```cdef MULTI():# 打开事务表示client.flags |= REDIS_MULTI# 返回OK回复replyOK()```
命令入队。当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行:```c127.0.0.1:6379> SET \"name\" \"Practical Common Lisp\"OK127.0.0.1:6379> GET \"name\"\"Practical Common Lisp\"127.0.0.1:6379> SET \"author\" \"Peter Seibel\"OK127.0.0.1:6379> GET \"author\"\"Peter Seibel```与此不同的是,当一个客户端切换到事务状态之后,服务器会根据这个客户端法拉的不同命令执行不同的操作:1.如果客户端发送的命令为EXEC、DISCARD、WATCH、MULTI四个命令的其中一个,那么服务器立即执行这个命令2.与此相反,如果客户端发送的命令是EXEC、DISCARD、WATCH、MULTI四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回QUEUED回复。服务器判断命令是该入队还是该立即执行的过程可以用流程图来描述
举个例子。如果客户端执行以下命令:```c127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET \"name\" \"Practical Common Lisp\"QUEUED127.0.0.1:6379> GET \"name\"QUEUED127.0.0.1:6379> SET \"author\" \"Peter Seibel\"QUEUED127.0.0.1:6379> GET \"author\"QUEUED```那么服务器将为客户端创建如图所示的事务状态:1.最先入队的SET命令被放在了事务队列的索引0位置上2.第二入队的GET命令被放在了事务队列的索引1位置上3.第三入队的另一个SET命令被放在了事务队列的索引2位置上4.最后入队的另一个GET命令被放在了事务队列的索引3位置上
举个例子。对于如图所示的事务队列来说,服务器会先执行命令:```cSET \"name\" \"Practical Common Lisp\"```之后执行命令:```cGET \"name\"```在之后执行命令:```cGET \"author\"```最后,服务器会将执行这四个命令所得的回复返回给客户端:```c127.0.0.1:6379> EXEC1) OK2) \"Practical Common Lisp\"3) OK4) \"Peter Seibel\"```
执行事务。当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行,服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。
事务的实现。一个事务从开始到结束通常会经历以下三个阶段:1.事务开始2.命令入队3.事务执行
举个例子。```c127.0.0.1:6379> flushallOK127.0.0.1:6379> WATCH \"name\"OK127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET \"name\" \"vinpink\
举个例子。如果当前客户端为c10086,那么客户端执行以下WATCH命令之后:```credis>WATCH \"name\" \"age\"OK```上图展示的wathced_keys字典将被更新为如图所示的状态,其中用虚线包围的两个c10086节点就是由刚刚执行的WATCH命令添加到字典中的。
使用WATCH命令监视数据库键。每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端:```ctypedef struct redisDb {// ...// 正在被WATCH命令监视的键dict *watched_keys;// ...}redisDb;```通过watched_keys字典,服务器可以清楚地知道哪些数据库键正在被监视,以及哪些客户端正在监视这些数据库键。1.客户端c1和c2正在监视键\"name\"2.客户端c3正在监视键\"age\"3.客户端c2和c4正在监视键\"address\"通过执行WATCH命令,客户端可以在watched_keys字典中与被监视的键进行关联
举个例子。如图所示的watched_keys字典来说:1.如果键\"name\"被修改,那么c1、c2、c10086三个客户端的REDIS_DIRTY_CAS标识将被打开2.如果键\"age\"被修改,那么c3和c10086两个客户端的REDIS_DIRTY_CAS标识将被打开3.如果键\"address\"被修改,那么c2和c4两个客户端的REDIS_DIRTY_CAS标识将被打开
举个例子。对于上图的watched_keys字典来说,如果某个客户端对\"name\"进行了修改(比如执行SET \"name\" \"cover\
判断事务是否安全。当服务器接收到一个客户端发来的EXEC命令时,服务器会根据这个客户端是否打开了REDIS_DIRTY_CAS标识来决定是否执行事务:1.入股客户端的REDIS_DIRTY_CAS标识已经被打开,那么说明客户端所监视的键当中,至少有一个键已经被修改过了,在这种情况下,客户端提交的事务已经不再安全,所以服务器会拒绝执行客户端提交的事务2.如果客户端的REDIS_DIRTY_CAS标识没有被打开,那么说明客户端监视的所有键都没有被修改过(或者客户端没有监视任何键),事务仍然是安全的,服务器将执行客户端提交的这个事务。这个判断是否执行事务的过程可以用流程图来描述
一个完整的WATCH事务执行过程。假设当前服务端为c10086,而数据库watched_keys字典的当前状态如图所示,那么当c10086执行以下WATCH命令之后```cc10086> WATCH \"name\"OK```watched_keys字典将更新如图所示的状态。接下来客户端c10086继续向服务器发送MULTI命令,并将一个SET命令放入事务队列:```cc10086> MULTIOKc10086> SET \"name\" \"peter\
WATCH命令的实现。WATCH命令是一个乐观锁(optimistic locking),它可以在EXEC命令执行之前,监视任意数量的数据库键,并在EXEC命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。
举个例子。以下展示了一个成功执行的事务,事务中的所有命令都会被执行```c127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET msg \"hello\"QUEUED127.0.0.1:6379> GET msgQUEUED127.0.0.1:6379> EXEC1) OK2) \"hello\"```
举个例子。与此相反,以下展示了一个执行失败的事务,这个事务因为命令入队出错而被服务器拒绝执行,事务中的所有命令都不会被执行:```c127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET msg \"hello\"QUEUED127.0.0.1:6379> GET(error) ERR wrong number of arguments for 'get' command127.0.0.1:6379> GET msgQUEUED127.0.0.1:6379> EXEC(error) EXECABORT Transaction discarded because of previous errors.```
举个例子。在下面的例子中,即使RPUSH命令在执行期间出现了错误,事务的后续命令也会继续执行下去,并且之前执行的命令也不会有任何影响```c127.0.0.1:6379> SET msg \"hello\"OK127.0.0.1:6379> MULTIOK127.0.0.1:6379> SADD fruit \"apple\" \"banana\" \"cherry\"QUEUED127.0.0.1:6379> RPUSH msg \"good bye\" \" bye bye\"QUEUED127.0.0.1:6379> SADD alphabet \"a\" \"b\" \"c\"QUEUED127.0.0.1:6379> EXEC1) (integer) 32) (error) WRONGTYPE Operation against a key holding the wrong kind of value3) (integer) 3127.0.0.1:6379> SCARD fruit(integer) 3```Redis的作者在事务功能的文档中解释说,不支持事务回滚是因为这种复杂的功能和Redis追求简单高效的设计主旨不相符,并且他认为,Redis的事务的执行时错误通常都是编程错误产生的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为Redis开发事务回滚功能
Redis2.6.5以前的入队错误处理。在Redis2.6.5以前的版本,即使有命令在入队过程中发生了错误,事务一样可以执行,不过被执行的命令只包括那些正确入队的命令。以下代码是在Redis2.6.4版本上测试的,可以看到,事务可以正常执行,但只有成功入队的SET命令和GET命令被执行了,而错误的YAHOOOO则被忽略了```c127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET msg \"hello\"QUEUED127.0.0.1:6379> YAHOOOO(error) ERR unknown command 'YAHOOOO'127.0.0.1:6379> GET msgQUEUED127.0.0.1:6379> EXEC1).OK2).\"hello\"```因为错误的命令不会被入队,所以Redis不会尝试去执行错误的命令,因此,即使在2.6.5以前的版本中,Redis事务的一致性也不会被入队错误影响
执行错误。除了入队时可能发生错误以外,事务还可能在执行的过程中发生错误。关于这种错误有两个需要说明的地方:1.执行过程中发生的错误都是一些不能入队时被服务器发现的错误,这些错误只会在命令实际执行时被触发2.即使在事务的执行过程中发生了错误,服务器也不会中断事务的执行,它会继续执行事务中余下的其他命令,并且已执行的命令(包括执行命令所产生的结果)不会被出错的命令影响对数据库键执行了错误类型的操作是事务执行期间最常见的错误之一。在下面的示例中,首先用SET命令将键\"msg\"设置成了一个字符串键,然后在事务里面尝试对\"msg\"键执行只能用于列表键的RPUSH命令,这将引发一个错误,并且这种错误只能在事务执行(也即是命令执行)期间被发现:```c127.0.0.1:6379> SET msg \"hello\"OK127.0.0.1:6379> MULTIOK127.0.0.1:6379> SADD fruit \"apple\" \"banana\" \"cherry\"QUEUED127.0.0.1:6379> RPUSH msg \"good bye\" \" bye bye\"QUEUED127.0.0.1:6379> SADD alphabet \"a\" \"b\" \"c\"QUEUED127.0.0.1:6379> EXEC1) (integer) 32) (error) WRONGTYPE Operation against a key holding the wrong kind of value3) (integer) 3```因为在事务执行的过程中,出错的命令会被服务器识别出来,并进行相应的错误处理,所以这些出错命令不会对数据库做任何修改,也不会对事务的已执行产生影响
服务器停机。如果Redis服务器在执行事务的过程中停机,那么根据服务器所使用的持久化模式,可能有以下情况出现:1.如果服务器运行在无持久化的内存模式下,那么重启之后的数据库将是空白的,因此数据总是一致的。2.如果服务器运行在RDB模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的RDB文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的RDB文件,那么重启之后的数据库将是空白的,而空白数据库总是一致的3.如果服务器运行在AOF模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的AOF文件,从而将数据库还原到一个一致的状态。如果找不到可供使用的AOF文件,那么重启之后的数据库将是空白的,而空白数据库总是一致的。综上所述,无论Redis服务器运行在哪种持久化模式下,事务执行中途发生的停机都不会影响数据库的一致性
一致性。事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也仍然是一致的。\"一致\"指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。Redis通过谨慎的错误检测和简单的设计来保证事务的一致性
隔离性。事务的隔离性指的是,即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。因为Redis使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证在执行事务期间不会对事务进行中断,因此,Redis的事务总是以串行的方式运行的。并且事务也总是具有隔离性
耐久性.事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失。因为Redis的事务不过是简单地使用队列包裹起了一组Redis命令,Redis并没有为事务提供任何额外的持久化功能,所以Redis事务的耐久性由Redis所使用的持久化模式决定:1.当服务器在无持久化的内存模式下运作时,事务不具有耐久性:一旦服务器停机,包括事务数据在内的所有服务器数据都将丢失2.当服务器在RDB持久化模式下运作时,服务器只会在特定的保存条件被满足时才会执行BGSAVE命令,对数据库进行保存操作,并且异步执行的BGSAVE不能保证事务数据第一时间保存到硬盘里面,因此RDB持久化模式下的事务也不具有耐久性3.当服务器运行在AOF持久化模式下,并且appendfsync选项的值为everysec时,程序会每秒同步一次命令数据到硬盘,因为停机可能恰好发生在等待同步的那一秒钟之内,这可能会造成事务数据丢失,所以这种配置下的事务不具有耐久性4.当服务器运行在AOF持久化模式下,并且apendfsync选项的值为no时,程序会交由操作系统来决定何时将命令数据同步到硬盘。因为事务数据可能在等待同步的过程中丢失,所以这种配置下的事务不具有耐久性不论Redis在什么模式下运作,在一个事务的最后加上SAVE命令总可以保证事务的耐久性:```c127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET msg \"hello\"QUEUED127.0.0.1:6379> SAVEQUEUED127.0.0.1:6379> EXEC1) OK2) OK```不过这种做法的效率太低,所以不具有实用性
事务
创建Lua环境。服务器首先调用Lua的C API函数lua_open,创建一个新的Lua环境。因为lua_open函数创建的只是一个基本的Lua环境,为了让这个Lua环境可以满足Redis的操作要求,接下来服务器将对这个Lua环境进行一系列修改。
载入函数库。Redis修改Lua环境的第一步,就是将以下函数库载入到Lua环境里面:1.基础库(base library):这个库包含Lua的核心(core)函数,比如assert、error、pairs、tostring、pcall等。另外,为了防止用户从外部文件中引入不安全的代码,库中的loadfile函数会被删除2.表格库(table library):这个库包含用于处理表格的通用函数。比如table.concat、table.insert、table.remove、table.sort等3.字符串库(string library):这个库包含用于处理字符串的通用函数,比如用于对字符串进行查找的string.find函数,对字符串进行格式化的string.format函数,查看字符串长度的string.len函数,对字符串进行反转的string.reverse函数等4.数据库(math libraray):这个库是标准C语言数据库的结构,它包括计算绝对值的math.abs函数,返回多个数中的最大值和最小值的math.max函数和math.min函数,计算二次方根的math.sqrt函数,计算对数的math.log函数等5.调试库(debug libraray):这个库提供了对程序进行调试所需的函数,比如对程序设置钩子和取得钩子的debug.sethook函数和debug.gethook函数,返回给定函数相关信息的debug.getinfo函数,为对象设置元数据的debug.setmetatable函数,获取对象元数据的debug.getmetatable函数等6.Lua CJSON库:这个库用于处理UTF-8编码的JSON格式,其中cjson.decode函数将一个JSON格式的字符串转换为一个Lua值,而cjson.encode函数将一个Lua值序列化为JSON格式的字符串7.Struct库:这个库用于在Lua值和C结构(struct)之间进行转换,函数struct.pack将多个Lua值打包成一个类结构(struct-like)字符串,而函数struct.unpack则从一个类结构字符串中解包出多个Lua值8.Lua cmsgpack库:这个库用于处理MessagePack格式的数据,其中cmsgpack.pack函数将Lua值转换为MessagePack数据,而cmsgpack.unpack函数则将messagepack数据转换为Lua值通过使用这些功能强大的函数库,Lua脚本可以直接对执行Redis命令获得的数据进行复杂的操作
举个例子。使用以下脚本,我们可以打印seed值为0时,math.random对于输入10至1所产生地随机绪列```c--random-with-default-seed.lualocal i = 10local seq ={}while (i > 0) doseq[i] = math.random(i)i = i+1endreturn seq```无论执行这个脚本多少次产生的值都是相同的```cE:\edis>redis-cli --eval test.lua 1) (integer) 1 2) (integer) 2 3) (integer) 2 4) (integer) 3 5) (integer) 4 6) (integer) 4 7) (integer) 7 8) (integer) 1 9) (integer) 710) (integer) 2```但是如果我们在另一个脚本里面调用math.randomseed将seed修改为10086```c--random-with-default-seed.luamath.randomseed(10086) -- change seedlocal i = 10local seq ={}while (i > 0) doseq[i] = math.random(i)i = i-1endreturn seq```那么这个脚本生成的随机数绪列将和使用默认seed值0时生成的随机绪列不同:```cE:\edis>redis-cli --eval test1.lua 1) (integer) 1 2) (integer) 1 3) (integer) 2 4) (integer) 1 5) (integer) 1 6) (integer) 3 7) (integer) 1 8) (integer) 1 9) (integer) 310) (integer) 1```
使用Redis自制的随机函数来替换Lua原有的随机函数。为了保证相同的脚本可以在不同的机器上产生相同的结果,Redis要求所有传入服务器的Lua脚本,以及Lua环境中的所有函数,都必须是无副作用(side effect)的纯函数(pure function).但是,在之前载入Lua环境的match函数库中,用于生成随机数的math.random函数和math.randomseed函数都是带有副作用的,它们不符合Redis对Lua环境的无副作用要求。因为这个原因,Redis使用自制的函数替换了math库中原有的math.random函数和math.randomseed函数,替换之后的两个函数有以下特征:1.对于相同的seed来说,math.random总产生相同的随机数绪列,这个函数是一个纯函数2.除非在脚本中使用math.randomseed显示地修改seed,否则每次运行脚本时,Lua环境都使用固定地math.randomseed(0)语句来初始化seed
举个例子。```c127.0.0.1:6379> SADD fruit apple banana cherry(integer) 3127.0.0.1:6379> SMEMBERS fruit1) \"banana\"2) \"apple\"3) \"cherry\"127.0.0.1:6379> SADD another-fruit cherry banana apple(integer) 3127.0.0.1:6379> SMEMBERS another-fruit1) \"apple\"2) \"banana\"3) \"cherry\"```(高版本Redis可能已经内置了排序函数)这个例子中的fruit集合和another-fruit集合包含的元素是完全相同的,只是因为集合添加的顺序不同,SMEMBERS命令的输出就产生了不同的结果。Redis将SMEMBERS这种在相同数据集上可能会产生不同输出的命令称为\"带有不确定性的命令\
举个例子。如果在Lua脚本中对fruit集合和another-fruit集合执行SMEMBERS命令,那么两个脚本将得出相同的结果,因为脚本已经对SMEMBERS命令的输出进行过排序了:```c127.0.0.1:6379> EVAL \
创建排序辅助函数。为了防止带有副作用的函数令脚本产生不一致的数据,Redis对math库的math.random函数和math.randomseed函数进行了替换。对于Lua脚本来说,另一个可能产生不一致数据的地方是哪些带有不确定性质的命令,比如对于一个集合键来说,因为集合的排列是无序的,所以即使两个集合的元素完全相同,它们的输出结果也可能并不相同。
创建redis.pcall函数的错误报告辅助函数。服务器将为Lua环境创建一个名为_redis_err_handler的错误处理函数,当脚本调用redis.pcall函数执行Redis命令,并且被执行的命令出现错误时,_redis_err_handler_就会打印出错误代码的来源和发生错误的行数,为程序的调试提供方便。举个例子。如果客户端要求服务器执行以下Lua脚本:```c-- 第1行-- 第2行-- 第3行return redis.pcall('wrong command')```那么服务器将向客户端返回一个错误:```credis-cli --eval wrong-command.lua(error) @user_script: 4: Unknown Redis command called from Lua script```其中@user_script说明这是一个用户定义的函数,而之后的4则说明出错的代码位于Lua脚本的第4行
保护Lua的全局环境。服务器将对Lua环境中的全局环境进行保护,确保传入服务器的脚本不会因为忘记使用local关键字而将额外的全局变量添加到Lua环境里面。因为全局比那辆保护的原因,当一个脚本试图创建一个全局变量时,服务器将报告一个错误:```c127.0.0.1:6379> EVAL \"x = 10\" 0(error) ERR Error running script (call to f_df1ad3745c2d2f078f0f41377a92bb6f8ac79af0): @enable_strict_lua:8: user_script:1: Script attempted to create global variable 'x'```除此之外,试图获取一个不存在的全局变量也会引发一个错误:```c127.0.0.1:6379> EVAL \"return x\" 0(error) ERR Error running script (call to f_03c387736bb5cc009ff35151572cee04677aa374): @enable_strict_lua:15: user_script:1: Script attempted to access unexisting global variable 'x```不过Redis并未禁止用户修改已存在的全局变量,所以在执行Lua脚本的时候,必须非常消息,以免错误地修改了已存在地全局变量```c127.0.0.1:6379> EVAL \"redis = 10086; return redis\" 0(integer) 10086```
将Lua环境保存到服务器状态的lua属性里面。服务器会将Lua环境和服务器状态的lua属性关联起来,如图所示.因为Redis使用串行化的方式来执行Redis命令,所以在任何特定时间里,最多都只会有一个脚本能够放进Lua环境里面运行,因此,整个Redis服务器只需要创建一个Lua环境即可
创建并修改Lua环境。为了在Redis服务器中执行Lua脚本,Redis在服务器内嵌了一个Lua环境(evnironment),并对这个Lua环境进行了一系列修改,从而确保这个Lua环境可以满足Redis服务器的需要。Redis服务器创建并修改Lua环境的整个过程由以下步骤组成:1.创建一个基础的Lua环境,之后的所有修改都是针对这个环境进行的。2.载入多个函数库到Lua环境里面,让Lua脚本可以使用这些函数库来进行数操作3.创建全局表格redis,这个表格包含了对Redis进行操作的函数,比如用于在Lua脚本中执行Redis命令的redis.call函数4.使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,从而避免在脚本中引入副作用5.创建排序辅助函数,Lua环境使用这个辅佐函数来对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性6.创建redis.pcall函数的错误报告辅助函数,这个函数可以提供更详细的出错信息7.对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全局变量添加到Lua环境中8.将完成修改的Lua环境保存到服务器状态的Lua环境中,等待执行服务器传来的Lua脚本,
举个例子。如图展示了Lua脚本在执行以下命令时:```c127.0.0.1:6379> EVAL \"return redis.call('dbsize')\" 0(integer) 3```Lua环境、伪客户端、命令执行器三者之间的通信过程如图所示
伪客户端。因为执行Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中包含的Redis命令,Redis服务器专门为Lua环境创建了一个伪客户端,并由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。Lua脚本使用redis.call函数或者redis.pcall函数执行一个Redis命令,需要完成以下步骤:1.Lua环境将redis.call函数或者redis.pcall函数想要执行的命令传给伪客户端2.伪客户端将脚本想要执行的命令传给命令执行器3.命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端4.伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境5.Lua环境在接收到命令结果之后,将该结果返回给redis.call函数或者redis.pcall函数6.接收到结果的redis.call函数或者redis.pcall函数会将命令结果作为函数返回值返回给脚本中的调用者图中展示了Lua脚本在调用redis.call函数时,Lua环境、伪客户端、命令执行器三者之间的通信过程(调用redis.pcall函数时产生的通信过程也是一样的)
举个例子,如果客户端向服务器发送以下命令:```c127.0.0.1:6379> SCRIPT LOAD \"return hi\"\"728d9ecdcf934ba0f430b2b05f049e13041278ae\"127.0.0.1:6379> SCRIPT LOAD \"return 1+1\"\"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9\"127.0.0.1:6379> SCRIPT LOAD \"return 2*2\"\"4475bfb5919b5ad16424cb50f74d4724ae833e72\"```那么服务器的lua_scripts字典将包含被SCRIPT LOAD命令载入的三个Lua脚本,如图所示。
lua_script字典。Redis服务器为Lua环境创建的另一个协作组件是lua_scripts字典,这个字典的键为某个Lua脚本的SHA1校验和(checksum),而字典的值则是SHA1校验和对应的Lua脚本```cstruct redisServer {// ...dict *lua_script;// ...};```Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里面。
Lua环境协作组件。除了创建并修改Lua环境之外,Redis服务器还创建了两个用于与Lua环境进行协作的组件,它们分别是负责执行Lua脚本的Redis命令的伪客户端,以及用于保存Lua脚本的lua_scripts字典
举个例子,对于命令:```cEVAL \"return 'hello world'\" 0```来说,服务器将在Lua环境中定义以下函数:```cfunction f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91()return 'hello world'end```因为客户端传入的脚本为return 'hello world',而这个脚本的SHA1校验和为5332031c6b470dc5a0dd9b4bf2030dea6d65de91所以函数的名字为f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91,而函数的体则为return 'hello world'.
定义脚本函数。当客户端向服务器发送EVAL命令,要求执行某个Lua脚本的时候,服务器首先要做的就是在Lua环境中,为传入的脚本定义一个与这个脚本相对应的Lua函数,其中,Lua函数的名字由f_前缀加上脚本的SHA1校验和(四十个字符长)组成,而函数的体(body)则是脚本本身使用函数来保存客户端传入的脚本有以下好处:1.执行脚本的步骤非常简单,只要调用与脚本相对应的函数即可2.通过函数的局部性来让Lua环境保持清洁,减少了垃圾回收的工作量,并且避免了使用全局变量3.如果某个脚本所对应的函数在Lua环境中被定义过至少一次,那么只要记得这个脚本的SHA1校验和,服务器就可以在不知道脚本本身的情况下,直接通过调用Lua函数来执行脚本,这时EVALSHA命令的实现原理
举个例子,对于命令:```cEVAL \"return 'hello world'\" 0```来说,服务器将在lua_scripts字典中新添加一个键值对,其中键为Lua脚本的SHA1校验和```c5332031c6b470dc5a0dd9b4bf2030dea6d65de91```而值则为Lua脚本本身:```cretuern 'hello world'```添加新键值对之后,lua_scripts字典如图所示:
将脚本保存到lua_scripts字典。EVAL命令要做的第二件事是将客户端传入的脚本保存到服务器的lua_scripts字典里面。
举个例子。对于如下命令:```cEVAL \"return 'hello world'\" 0```服务器将执行以下动作:1.因为这个脚本没有给定任何键名参数或者脚本参数,所以服务器会跳过传值到KEYS数组或ARGV数组这一步2.为Lua胡娜经装载超时处理钩子3.在Lua环境中执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数4.移除超时钩子5.将执行f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数所得的结果\"hello world\
执行脚本函数。在为脚本定义函数,并且将脚本保存到lua_scripts字典之后,服务器还需要进行一些设置钩子、传入参数之类的准备动作,才能正式开始执行脚本。整个准备和执行脚本的过程如下:1.将EVAL命令中传入的键名(key name)参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里面2.为Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况是,让客户端通过SCRIPT KILL命令停止脚本,或者通过SHUTDOWN命令直接关闭服务器3.执行脚本函数4.移除之前装载的超时钩子5.将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端
EVAL命令的实现。EVAL命令的执行过程可以分为以下三个步骤:1.根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数2.将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用3.执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本以下命令作为示例,分别介绍EVAL命令执行的三个步骤:```c127.0.0.1:6379> EVAL \"return 'hello world'\" 0\"hello world\"```
伪代码描述。```cdef EVALSHA(sha1):# 拼接出函数的名字# 例如 f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91func_name = \"f_\" + sha1# 查看这个函数在Lua环境中是否存在if function_exists_in_lua_env(func_name):# 如果函数存在,那么执行它execute_lua_function(func_name)else:# 如果函数不存在,那么返回一个错误send_scirpt_error(\"SCRIPT NOT FOUND\")```
举个例子。当服务器执行完以下EVAL命令之后:```c127.0.0.1:6379> EVAL \"return 'hello world'\" 0\"hello world\"```Lua环境里面就定义了以下函数:```cfunction f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91() return 'hello world'end```当客户端执行以下EVALSHA命令时:```c127.0.0.1:6379> EVALSHA 5332031c6b470dc5a0dd9b4bf2030dea6d65de91 0\"hello world\"```服务器首先根据客户端输入的SHA1校验和,检查函数f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91是否存在于Lua环境中,得到的回应时该函数确实存在,于是服务器执行Lua环境中的f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91函数,并将结果\"hello world\"返回给客户端
EVALSHA命令的实现。每个被EVAL命令成功执行过的Lua脚本,在Lua环境里面都有一个与这个脚本相对应的Lua函数,函数的名字由f_前缀加上40个字符串的SHA1校验和组成,例如f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91.只要脚本对应的函数曾经在Lua环境里面定义过,那么即使不知道脚本的内容本身,客户端也可以根据脚本的SHA1校验和来调用脚本对应的函数,从而达到执行脚本的目的,这就是EVALSHA命令的实现原理。
SCRIPT FLUSH。SCRIPT FLUSH命令用于清除服务器中所有和Lua脚本有关的信息,这个命令会释放并重建lua_scripts字典,关闭现有的Lua环境并重新创建一个新的Lua环境。以下为SCRIPT FLUSH命令的实现伪代码:```cdef SCRIPT_FLUSH():# 释放脚本字典dictRelease(server.lua_scripts)# 重建脚本字典server.lua_scripts = dictCreate(...)# 关闭Lua环境lua_close(server.lua)# 初始化一个新的Lua环境server.lua = init_lua_env()```
举个例子。对于如图所示的lua_scripts字典来说,可以进行测试:```c127.0.0.1:6379> SCRIPT EXISTS \"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3\"1) (integer) 1127.0.0.1:6379> SCRIPT EXISTS \"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9\"1) (integer) 1127.0.0.1:6379> SCRIPT EXISTS \"4475bfb5919b5ad16424cb50f74d4724ae833e72\"1) (integer) 1127.0.0.1:6379> SCRIPT EXISTS \"NotExistsScriptSha1HereABCDEFGHIJKLMNOPQ\"1) (integer) 0```从测试结果可知,除了最后一个校验和之外,其他校验和对应的脚本都存在于服务器中
注意。SCRIPT EXISTS命令允许一次传入多个SHA1校验和,不过因为SHA1校验和太长,所以分开多次进行测试。实现SCRIPT EXISTS实际上并不需要lua_scripts字典的值。如果lua_scripts字典只用于实现SCRIPT EXISTS命令的话,那么字典只需要保存Lua脚本的SHA1校验和就可以了,并不需要保存Lua脚本本身。lua_scripts字典既可以保存脚本的SHA1校验和,又保存脚本本身的原因是因为实现脚本复制功能
SCRIPT EXISTS。SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中.SCRIPT EXISTS命令是通过检查给定的校验和是否存在于lua_scripts字典来实现的,以下是该命令的实现伪代码:```cdef SCRIPT_EXISTS(*sha1_list):# 结果列表result_list = []# 遍历输入的所有SHA1校验和for sha1 in sha1_list:# 检查校验和是否为lua_scripts字典的键# 如果是的话,那么表示校验和对应的脚本存在# 否则的话,脚本就不存在if sha1 in server.lua_scripts:# 存在用1表示result_list.append(1)else:# 不存在用0表示result_list.append(0)# 向客户端返回结果列表send_list_reply(result_list)```
举个例子。如果执行以下命令。```c127.0.0.1:6379> SCRIPT LOAD \"return 'hi'\"\"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3\"```那么服务器将在Lua环境中创建以下函数:```cfunction f_2f31ba2bb6d6a0f42cc159d2e2dad55440778de3()return 'hi'end```并将键为\"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3\
SCRIPT LOAD。SCRIPT LOAD命令所做的事情和EVAL命令执行脚本时所做的前两步完全一样,命令首先在Lua环境中为脚本创建相对应的函数,然后再将脚本保存到lua_scripts字典里面.
SCRIPT KILL。如果服务器设置了lua-time-limit配置选项,那么在每次执行Lua脚本之前,服务器都会在Lua环境里面设置一个超时处理钩子(hook).超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长,钩子将定期在脚本运行的间隙中,查看是否有SCRIPT KILL命令或者SHUTDOWN命令到达服务器。如图展示了带有超时处理钩子的脚本的运行过程。如果超时运行的脚本未执行任何写入操作,那么客户端可以通过SCRIPT KILL命令来指示服务器停止执行这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令之后,服务器可以继续运行。另一方面,如果脚本已经执行过写入操作,那么客户端只能用SHUTDOWN nosave命令来停止服务器,从而防止不合法的数据被写入数据库中。
脚本管理命令的实现。Redis中与Lua脚本有关的命令还有四个,它们分别是SCRIPT FLUSH命令、SCRIPT EXISTS命令、SCRIPT LOAD命令、以及SCRIPT KILL命令
1.EVAL。对于EVAL命令来说,在主服务器执行的Lua脚本同样会在所有从服务器中执行。举个例子。如果客户端向主服务器执行以下命令```c127.0.0.1:6379> EVAL \
2.SCRIPT FLUSH。如果客户端向主服务器发送SCRIPT FLUSH命令,那么主服务器也会向所有从服务器传播SCRIPT FLUSH命令
3.SCRIPT LOAD。如果客户端使用SCRIPT LOAD命令,向主服务器载入一个Lua脚本,那么主服务器将向所有从服务器传播相同的SCRIPT LOAD命令,使得所有从服务器也会载入相同的Lua脚本。举个例子。如果客户端向主服务器发送命令:```c127.0.0.1:6379> SCRIPT LOAD \"return 'hello world'\"\"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\"```那么主服务器也会向所有从服务器传播同样的命令:```cSCRIPT LOAD \"return 'hello world'\"```最终的结果是,主从服务器双方都会载入脚本:```c\"return 'hello world'\"```
复制EVAL命令、SCRIPT FLUSH命令和SCRIPT LOAD命令。Redis复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制其他普通的Redis命令的方法一样,当主服务器执行完以上三个命令的其中一个时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器,如图所示。
举个例子。如果主服务器repl_scriptcache_dict字典的当前状态如图所示。那么主服务器可以向从服务器传播以下三个EVALSHA命令,并且从服务器在执行这些EVAlSHA命令的时候不会出现脚本未找到错误:```cEVALSHA \"2f31ba2bb6d6a0f42cc159d2e2dad55440778de3\" ...EVALSHA \"a27e7e8a43702b7046d4f6a7ccf5b60cef6b9bd9\" ...EVALSHA \"4475bfb5919b5ad16424cb50f74d4724ae833e72\" ...```另一方面,如果一个脚本的SHAR1校验和存在于lua_scripts字典,但是不存在于repl_scriptcache_dict字典,那么说明校验和对应的Lua脚本已经被主服务器载入,但是并没有传播给所有从服务器,如果尝试向从服务器传播包含这个SHA1校验和的EVALSHA命令,那么至少有一个从服务器会出现脚本未找到错误
举个例子。对于如图所示的lua_scirpts字典,以及上图的repl_scriptcache_dict字典来说,SHA1校验和为:```c\"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\"```的脚本:```c\"return 'hello world'\"```虽然存在于lua_scirpts字典,但是repl_scriptcache_dict字典却并不包含校验和\"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\"这说明脚本```c\"return 'hello world'\"```虽然已经载入到主服务器里面,但并未传播给所有从服务器,如果主服务器尝试向从服务器发送命令:```cEVALSHA \"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\" ...```那么至少会有一个从服务器遇上脚本未找到错误
清空repl_scriptcache_dict字典。每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为随着新从服务器的出现,repl_scriptcache_字典里面记录的脚本已经不再被所有从服务器载入过,所以主服务器会清空repl_scirptcache_dict字典,强制自己重新向所有从服务器传播脚本,从而确保新的从服务器不会出现脚本未找到错误。
举个例子。对于图中所示的lua_scripts字典,以及如图所示的repl_scriptcache_dict字典来说,总可以将命令:```cEVALSHA \"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\" 0```改写成命令:```cEVAL \"return 'hello world' 0\"```其中脚本的内容:```c\"return 'hello world'\"```来源于lua_scripts字典\"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\"键的值。如果一个SHA1值所对应的Lua脚本没有被所有从服务器载入过,那么主服务器可以将EVALSHA命令转换成等价的EVAL命令,然后通过传播等价的EVAL命令来代替原本想要传播的EVALSHA命令,以此来产生相同的脚本执行结果,并确保所有从服务器都不会出现脚本未找到错误。另外,因为主服务器在传播完EVAL命令之后,会将被传播脚本的SHA1校验和(也即是EVALSHA命令指定的那个校验和)添加到repl_scriptcache_dict字典里面,如果之后EVALSHA命令再次指定这个SHA1校验和,主服务器就可以直接传播EVALSHA命令,而不必再次对EVALSHA命令进行转换
举个例子。假设服务器当前lua_scripts字典和repl_scriptcache_dict字典的状态如上图所示,如果客户端向主服务器发送命令:```cEVALSHA \"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\" 0```那么主服务器在执行完这个EVALSHA命令之后,会将这个EVALSHA命令转换成等价的EVAl命令:```cEVAL \"return 'hello world'\" 0```并向所有从服务器传播这个EVAL命令。除此之外,主服务器还会将SHA1校验和\"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\"添加到repl_scriptcache_dict字典里,这样当客户端下次再发送命令:```cEVALSHA \"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\" 0```的时候,主服务器就可以直接向从服务器传播这个EVALSHA命令,而无须将EVALSHA命令转换成EVAL命令再传播。添加\"5332031c6b470dc5a0dd9b4bf2030dea6d65de91\"之后的repl_scriptcache_dict字典如图所示:
传播EVALSHA命令的方法。当主服务器成功在本机执行完一个EVALSHA命令之后,它将根据EVALSHA命令指定的SHA1校验和是否存在于repl_scriptcache_dict字典来决定是向从服务器传播EVALSHA命令还是EVAL命令:1.如果EVALSHA命令指定的校验和存在于repl_scriptcache_dict字典,那么主服务器直接向从服务器传播EVALSHA命令2.如果EVALSHA指定的SHA1校验和不存在于repl_scriptcache_dict字典,那么主服务器会将EVALSHA命令转换成等价的EVAL命令,然后传播这个等价的EVAL命令,并将EVALSHA命令指定的SHA1校验和添加到repl_scriptcache_dict字典里面
脚本复制。与其他普通Redis命令一样,当服务器运行在复制模式之下时,具有写性质的脚本命令也会被复制到服务器,这些命令包括EVAL命令、EVALSHA命令、SCRIPT FLUSH命令,以及SCRIPT LOAD命令
Lua脚本
举个例子。如果slow-log-slower-than选项的值为100,那么执行时间超过100微妙的命令就会被记录到慢查询日志,如果这个选项的值为500,那么执行的时间超过500微妙的命令就会被记录到慢查询日志
```c1) 1) (integer) 6 2) (integer) 1713670934 3) (integer) 5 4) 1) \"SET\" 2) \"database\" 3) \"Redis\"2) 1) (integer) 5 2) (integer) 1713670927 3) (integer) 6 4) 1) \"SET\" 2) \"number\" 3) \"10086\"3) 1) (integer) 4 2) (integer) 1713670923 3) (integer) 10 4) 1) \"SET\" 2) \"msg\" 3) \"hello world\"4) 1) (integer) 3 2) (integer) 1713670868 3) (integer) 6 4) 1) \"CONFIG\" 2) \"SET\" 3) \"slowlog-max-len\" 4) \"5\"5) 1) (integer) 2 2) (integer) 1713670860 3) (integer) 7 4) 1) \"CONFIG\" 2) \"SET\" 3) \"slowlog-log-slower-than\" 4) \"0\"```
```c1) 1) (integer) 7 2) (integer) 1713670977 3) (integer) 2095 4) 1) \"SLOWLOG\" 2) \"GET\"2) 1) (integer) 6 2) (integer) 1713670934 3) (integer) 5 4) 1) \"SET\" 2) \"database\" 3) \"Redis\"3) 1) (integer) 5 2) (integer) 1713670927 3) (integer) 6 4) 1) \"SET\" 2) \"number\" 3) \"10086\"4) 1) (integer) 4 2) (integer) 1713670923 3) (integer) 10 4) 1) \"SET\" 2) \"msg\" 3) \"hello world\"5) 1) (integer) 3 2) (integer) 1713670868 3) (integer) 6 4) 1) \"CONFIG\" 2) \"SET\" 3) \"slowlog-max-len\" 4) \"5\"```
举个例子。首先用COFIG SET将slowlog-log-slower-than选项的值设为0微妙,这样Redis服务器执行的任何命令都会被记录到慢查询日志中,接着讲slowlog-max-len选项的值设为5,让服务器最多保存5条慢查询日志:```c127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 0OK127.0.0.1:6379> CONFIG SET slowlog-max-len 5OK```接着,用客户端发送几条命令请求:```c127.0.0.1:6379> SET msg \"hello world\"OK127.0.0.1:6379> SET number 10086OK127.0.0.1:6379> SET database \"Redis\"OK```然后使用SLOWLOG GET命令查看服务器所保存的慢查询日志:如果这是再执行一条SLOWLOG GET命令,那么将看到,上一次执行的SLOWLOG GET命令已经被记录到了慢查询日志中,而最旧的、ID为0的慢查询日志已经被删除,服务器的慢查询日志数量仍然为5条
概述。Redis的慢查询日志功能用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来监视和优化查询速度。服务器配置有两个和慢查询日志相关的选项:1.slowlog-log-slower-than选项指定执行时间超过多少微妙(1秒=1000 000微妙)的命令请求会被记录到日志上2.slowlog-max-len选项指定服务器最多保存多少条慢查询日志。
如图展示了服务器状态中和慢查询日志功能有关的属性:1.slowlog_entry_id为7,表示服务器下条慢查询日志的id将为72.slowlog链表包含了id为6至2的慢查询日志,最新的6号日志排在链表的表头,而最旧的2好日志排在链表的表尾,这表明slowlog链表是使用插入到表头的方式来添加新日志的。3.slowlog_log_slower_than记录了服务器配置slowlog-log-slower-than选项的值0,表示任何执行的时间超过0微妙的命令都会被慢查询日志记录4.slowlog-max-属性记录了服务器配置slowlog-max-len选项的值为5,表示服务器最多储存5条慢查询日志
举个例子。对于以下慢查询日志来说:```c2) 1) (integer) 5 2) (integer) 1713670927 3) (integer) 6 4) 1) \"SET\" 2) \"number\" 3) \"10086\"```
慢查询日志的阅览和删除。SLOWLOG GET命令的伪代码实现:```cdef SLOWLOG_GET(number=None):# 用户没有给定number参数# 那么打印服务器包含的全部慢查询日志if number is None;number = SLOWLOG_LEN()# 遍历服务器中的慢查询日志for log in redisServer.slowlog:if number <= 0:# 打印的日志数量已经足够,跳出循环breakelse:# 继续打印,将计数器的值减一number -= 1# 打印日志printLog(log)```查看日志数量的SLOWLOG LEN命令可以用以下伪代码来定义```cdef SLOWLOG_LEN():# slowlog链表的长度就是慢查询的条目数量retuern len(redisServer.slowlog)```另外用于清除所有慢查询日志的SLOWLOG RESET命令可以用以下伪代码来定义```cdef SLOWLOG_RESET():# 遍历服务器中的所有慢查询的日志for log in redisServer.slowlog:# 删除日志deleteLog(log)```
slowlogPushEntryIfNeeded函数的作用有两个:1.检查命令的时长是否超过slowlog-log-slower-than选项设置的时间, 如果是的话,就为命令创建一个新的日志,并将新日志添加到slowlog链表的表头2.检查慢查询日志的长度是否超过slowlog-max-len选项所设置的长度,如果是的话,那么将多出来的日志从slowlog链表中删除掉
举个例子。假设服务器当前保存的慢查询日志如图所示,如果执行以下命令:```c127.0.0.1:6379> EXPIRE msg 10086(integer) 1```服务器在执行完这个EXPIRE命令之后,就会调用slowlogPushEntryIfNeeded函数,函数将未EXPIRE命令创建一条id为7的慢查询日志,并将这条新日志添加到slowlog链表的表头如图所示.注意,除了slowlog链表发生了变化之外,slowlog_entry_id的值也从7变为8了,之后,slowlogPushEntryIfNeeded函数发现,服务器设定的最大慢查询日志数目为5条,而服务器目前保存的慢查询日志数目为6条,于是服务器将id为2的慢查询日志删除,让服务器的慢查询日志数量回到设定好的5条
添加新日志。在每次执行命令的之前和之后,程序都会记录微妙格式的当前UNIX时间戳,这两个时间戳之间的差就是服务器执行命令所耗费的时长,服务器会将这个时长作为参数之一传给slowlogPushEntryIfNeeded函数,而slowlogPushEntryIfNeeded函数则负责检查是否需要为这次执行的命令创建慢查询日志。
慢查询日志
概述。通过执行MONITOR命令,客户端可以将自己变为一个监视器,实时地接收并打印出服务器当前处理的命令请求的相关信息:```c127.0.0.1:6379> MONITOROK1713790637.787549 [0 127.0.0.1:60753] \"PING\"1713790641.908992 [0 127.0.0.1:60753] \"SET\" \"k1\" \"v1\"1713790645.044945 [0 127.0.0.1:60753] \"SET\" \"k2\" \"v2\"```每当一个客户端服务器发送一条命令请求时,服务器除了会处理这条命令请求之外,还会将关于这条命令请求的信息发送给所有监视器,如图所示
举个例子,如果客户端c10086向服务器发送MONITOR命令,那么这个客户端的REDIS_MONITOR标志会被打开,并且这个客户端本身会被添加到monitors链表的表尾。假设客户端c10086发送MONITOR之前,monitors链表的状态如图所示,那么在服务器执行客户端c10086发送的MONITOR命令之后,monitors链表将被更新为如图所示的状态
成为监视器。发送MONITOR命令可以让一个普通客户端变为一个监视器,该命令的实现原理可以用以下伪代码来实现:```cdef MONITOR():# 打开客户端的监视器状态client.flags |= REDIS_MONITOR# 将客户端添加到服务器状态的monitors链表的末尾server.monitor.append(client)# 向客户端返回OKsend_reply(\"OK\")```
监视器
Redis
0 条评论
回复 删除
下一页