Redis设计与实现
2020-11-10 22:55:32 0 举报仅支持查看
AI智能生成
Redis
Redis
模版推荐
作者其他创作
大纲/内容
概念
what
Redis(Remote Dictionary Server)是一个开源的、基于内存的<b>数据结构</b>存储器,可以用作<b>数据库、缓存和消息中间件</b>(发布订阅)<b>,</b>也经常用来做<b>分布式锁</b>
C/S架构,server为<b>单线程</b>,<b>IO多路复用模型,</b>基于<b>event-loop模式</b>处理client请求<br>
单线程的优缺点?
在 Redis 中,实现 高可用 的技术主要包括 持久化、复制、哨兵 和 集群
redis数据结构<br>
redis的数据类型及应用场景?
使用的角度
String
其实也是一种key-value形式
使用
set key value
底层实现
SDS + 字典
不能完全当成字符串
Hash
字典
hash表
使用
set key hash(key value)
底层实现
字典+hashTable
<ul><li>hash算法使用MurmurHah2。</li><li>rehash:为了维持负载因子在合理范围内,哈希表扩张或收缩</li></ul> 为ht[1]分配空间,扩展操作ht[1]的大小为第一个大于等于ht[0].used*2的2的n次幂;收缩操作ht[1]大小为第一个大于等于ht[0].used的2的n次幂;ht [0]中键值重新散列到ht[1];释放ht[0],将ht[1]设置为ht[0],在ht[1]创建一个空哈希表。<br><ul><li>满足以下条件时rehash自动执行:</li></ul> 没在执行bgsave或bgrewriteaof命令,且负载因子>=1;在执行bgsave或bgrewriteaof,且负载因子>=5(尽可能避免子进程存在时进行扩张);<br> 负载因子<0.1时,收缩。<br><ul><li>渐进式哈希(将rehash的计算量分摊到进行CRUD操作的时候,避免集中式rehash的庞大计算量):</li></ul> 为ht[1]分配空间,rehash期间字典同时持有并使用ht[0]和ht[1];字典中计数器rehashidx置为0,开始rehash;rehash期间,每次对字典CRUD时,顺 带将ht[0]中索引为rehashidx的键值对rehash到ht[1],然后rehashidx+1。(新添加的键值会保存到ht[1]);直到ht[0]为空时,rehash完成,<br> rehashidx 置为-1。<br>
List
linkedlist
rpush key value1 value2 value3 ...
底层实现
quicklist
当数据少的时候
字典+zipList
zipList
ziplist压缩列表
连锁更新问题
当数据多的时候
linkedlist
linkedlist
set
使用
hset key key value
比如:<br>hset books java "think in java"<br>hset books python "python cookboot"<br>hset books go "concurrency go"
底层
当数据少的时候
字典+ziplist
当数据多的时候
字典+hashtable
sort-set
使用
hset score key value
比如:<br>hset books 9.0"think in java"<br>hset books 8.0"python cookboot"<br>hset books 7.0 "concurrency go"
底层原理
字典+skiplist
HyperLogLog 基数统计
GEO
布隆过滤器
实现的角度
SDS
redis没有采用C字符串而是构建了SDS抽象类型。与C字符串的区别优点:<br>通过len属性实现STRLEN时间复杂度O(1);杜绝缓冲区溢出,如拼接字符串时先检查free属性判断可用空间是否足够;减少修改字符串带来的内存重分配次数。C字符串拼接或截断时如果忘记内存重分配会产生缓冲区溢出或内存泄露。而内存重分配比较耗时,redis通过free未使用空间来实现空间预分配和惰性空间释放优化策略,从而减少重分配次数;<br>二进制安全。由于字符串里面不能有空字符,C字符串不能保存二进制数据,而SDS使用len属性判断字符串是否结束;<br>兼容部分C字符串函数。遵循字符串以空字符结束。
字典
字典
hash表
Hash
HashTable
linkedlist
和java的linkedlist差不多,列表。3.2之后遗弃
ziplist
为了节省空间
quicklist
ziplist
sdlist
quicklist将sdlist和ziplist两者的优点结合起来,在时间和空间上做了一个均衡,能较大程度上提高Redis的效率。压入和弹出操作的时间复杂度都很理想。
Skiplist
skiplist是有序的数据结构,每个节点维持多个指向其他节点的指针,从而快速访问。平均O(logN)最坏O(N)的复杂度,和平衡树差不多,但比平衡树简单。用于有序集合zset。由zskiplist和zskipNode组成,zskiplist作用是可以以O(1)复杂度访问头尾节点、长度、层高最大节点的层数。<br>level:记录层数最大那个节点的层数(表头节点不算在内);<br>节点结构:<br>层level:每创建一个节点,根据幂次定律随机生成1到32间的值作为level[]数组大小,每层有两个属性,前进指针和跨度。跨度是用来计算rank的,查找某个节点过程中,将沿途访问的所有层的跨度累计起来,得到的结果就是目标节点在skiplist中的rank;<br>后退指针backward,从表尾向表尾方向访问节点;<br>分值score,double类型,节点按分值从小到大排序;<br>成员对象obj,存储指向SDS的指针。<br>
intset
intset可以保存encoding方式为int16_t. int32_t, int64_t类型的整数值在contents[]中,且从小到大排序、不会重复。<br>整数集合数组中元素三种类型的升级(不支持降级)步骤:1. 扩展底层数组空间大小;2. 元素类型转换并按序放到正确位置;3. 把新元素放到数组里面。<br>升级的好处:提升intset的灵活性;节约内存。
对象
Redis 使用对象来表示数据库中的键和值, 每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个对象, 一个对象用作键值对的键(键对象), 另一个对象用作键值对的值(值对象)。<br><br>举个例子, 以下 SET 命令在数据库中创建了一个新的键值对, 其中键值对的键是一个包含了字符串值 "msg" 的对象, 而键值对的值则是一个包含了字符串值 "hello world" 的对象:<br><br>redis> SET msg "hello world"<br>OK
redisObject
type
对象的 type 属性记录了对象的类型
对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型:
encoding + ptr
对象的 ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定。<br><br>encoding 属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现
注意:右边的图 是3.2之前的redis版本,在3.2之后,list使用的是quicklist
quicklist将sdlist和ziplist两者的优点结合起来,在时间和空间上做了一个均衡,能较大程度上提高Redis的效率。压入和弹出操作的时间复杂度都很理想。
通过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。
比如压缩列表
因为压缩列表比双端链表更节约内存, 并且在元素数量较少时, 在内存中以连续块方式保存的压缩列表比起双端链表可以更快被载入到缓存中;<br>随着列表对象包含的元素越来越多, 使用压缩列表来保存元素的优势逐渐消失时, 对象就会将底层实现从压缩列表转向功能更强、也更适合保存大量元素的双端链表上面;
redis的数据库
redisDb 结构的dict 字典保存了数据库中的所有键值对, 我们将这个字典称为键空间(key space)
<b><font color="#c41230">字典是最重要的数据结构</font></b>
数据淘汰策略
reids的8种数据淘汰策略?
Redis的过期键的删除策略?
可以使用--lru-test命令执行lru模拟
RDB、AOF以及复制功能对过期键的处理机制?
redis内存优化
Redis如何做内存优化?
redisObject对象
Redis存储的所有键、值在内部表示为redisObject结构体,每次在数据库中创建一个键值对,至少会创建两个对象。<br>type字段:对象类型;<br>encoding字段:内部编码类型,即对象使用什么数据结构作为对象的底层实现;<br>lru字段:记录最后访问时间,可以使用scan 配合 object idletime 命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理降低内存占用;<br>refcount字段:引用计数器,当创建一个新对象,初始化为1;为0表示该对象没被引用可删除,当对象为0-9999的整数时可以使用共享整数对象的方式节省内存;<br>*ptr字段:数据指针,与对象的数据内容相关。如果是long类型整数直接存储数据(为int编码方式),否则表示指向数据的指针。<br>Redis在3.0之后对值对象是字符串且长度<=39字节的数据,内部编码为embstr类型,字符串sds和redisObject一起分配,从而只要一次内存操作,而且内存上连续可以更好地利用缓存;而>39字节的字符串存储为raw类型,会调用两次内存分配。如果整数长度超出long范围,使用embstr或raw编码;浮点数使用embstr编码存储,若超出长度使用raw编码。另外,embstr是只读的,若修改则变为raw编码。
缩减键值对象
1. 减小key长度<br>2. 减小value长度。精简业务对象;业务对象序列化成二进制数组放入redis时,使用高效的序列化工具,如protostuff,kryo等;json、xml格式的对象先用通用压缩算法压缩后再放入redis,如Snappy
整数对象池
对象共享池指Redis内部维护[0-9999]的整数对象池。开启maxmemory和LRU淘汰策略后对象池无效,因为lru策略需要lru字段的最后访问时间,若整数对象共享,就不能获取每个整数的最后访问时间。
为什么只有整数对象池?<br>因为整数复用率高,其次整数判断相等性O(1),如果共享对象是保存的字符串,验证操作O(N),如果是包含了多个值的对象如列表、哈希表,则验证操作O(N^2)
字符串优化
redis自己实现的字符串SDS结构:已用长度len、未用长度free、字符数组char[]。<br>特点:O(1)时间复杂度获取:字符串长度,已用长度,未用长度;可用于保存字节数组,支持安全的二进制数据存储;内部实现空间预分配机制,降低内存再分配次数;惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留。
redis的字符串预分配机制:<br>第一次创建字符串,len等于实际大小,free=0,不做预分配;修改字符串后,若free空间不够且字符串小于1M,则预分配一倍空间;若free空间不够且字符串大于1M,则预分配1M采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝,但同样也会造成内存的浪费。<br>尽量减少字符串频繁修改操作如append,setrange, 改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。<br>
编码优化
Redis针对每种数据类型(type)可以采用至少两种编码方式来实现。通过不同编码实现效率(时间)和空间的平衡
可以使用config set命令设置编码相关参数来满足使用压缩编码的条件。对于已经采用非压缩编码类型的数据如hashtable,linkedlist等,设置参数后即使数据满足压缩编码条件,Redis也不会做转换,需要重启Redis重新加载数据才能完成转换。
ziplist编码主要目的是为了节约内存,因此所有数据都是采用线性连续的内存结构。一个线性数组通常会被CPU的缓存更好的命中(线性数组有更好的局部性),从而提升了访问的速度。结构字段含义:<br>zlbytes:所占字节长度,方便重新调整ziplist空间; zltail:记录距离尾节点的偏移量,方便尾节点弹出操作;zllen:记录压缩列表节点数量;entry:记录具体的节点,长度根据实际存储的数据而定。a) prev_entry_bytes_length:记录前一个节点所占字节数,可以根据当前节点起始地址计算前一节点的起始地址,实现表尾到表头遍历操作。b) encoding:标示当前节点content编码和长度,前两位表示编码类型:字节数组或整数,其余位表示数据长度。c) contents:保存节点的值,针对实际数据长度做内存占用优化。zlend:标记列表结尾,占用一个字节<br>特点:<br>内部表现为数据紧凑排列的一块连续内存数组。可以模拟双向链表结构,以O(1)时间复杂度入队和出队。新增删除操作涉及内存重新分配或释放,加大了操作的复杂性。读写操作涉及复杂的指针移动,最坏时间复杂度为O(n2)。适合存储小对象和长度有限的数据。<br>使用ziplist压缩编码的原则:追求空间和时间的平衡。针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元素大小控制在512字节以内。<br>命令平均耗时使用info Commandstats命令获取,包含每个命令调用次数,总耗时,平均耗时,单位微秒。<br>
intset编码是集合(set)类型编码的一种,内部表现为存储有序,不重复的整数集。intset的字段结构含义:<br>encoding:整数表示类型,根据集合内最长整数值确定类型,整数类型划分三种:int-16,int-32,int-64。length:表示集合元素个数。contents:整数数组,按从小到大顺序保存。<br>使用intset编码的集合时,尽量保持整数范围一致,如都在int-16范围内。<b><font color="#c41230">防止个别大整数触发集合升级操作</font></b>,产生内存浪费(升级操作将会导致重新申请内存空间,把原有数据按转换类型后拷贝到新数组。)。<br>
控制key的数量
通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量。<br>关于hash键和field键的设计:<br>1) 当键离散度较高时,可以按字符串位截取,把后三位作为哈希的field,之前部分作为哈希的键。如:key=1948480 哈希key=group:hash:1948,哈希field=480。<br>2) 当键离散度较低时,可以使用哈希算法打散键,如:使用crc32(key)&10000函数把所有的键映射到“0-9999”整数范围内,哈希field存储键的原始值。<br>3) 尽量减少hash键和field的长度,如使用部分键内容。<br>使用hash结构控制键的规模虽然可以大幅降低内存,但同样会带来问题,需要提前做好规避处理。如下:<br>客户端需要预估键的规模并设计hash分组规则,加重客户端开发成本。<br>hash重构后所有的键无法再使用超时(expire)和LRU淘汰机制自动删除,需要手动维护删除。<br>对于大对象,如1KB以上的对象。使用hash-ziplist结构控制键数量。<br>
数据持久化
rdb和aof工作方式
SAVE会阻塞redis进程<br>BGSAVE保存 dump.rdb 文件时, 服务器执行以下操作:<br>Redis 调用forks. 同时拥有父进程和子进程。子进程将数据集写入到一个临时 RDB 文件中(此时若有数据写入,创建其copy)。当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。<br>
savepoint
rdb文件结构。可以linux下使用od命令分析rdb文件
aof写入会把执行的命令追加、写入并同步到aof文件末尾,有3中写aof文件时fsync的方式,速度与安全成反比。aof的载入和还原过程:创建一个不带网络的伪客户端,从aof文件读取写命令并执行aof的rewrite(使用BGREWRITEAOF命令):aof文件体积过大时会重建一个新aof文件,这个文件包含重建当前数据集所需的最少命令,这样就减小了aof的体积。过程:Redis 执行 fork() 创建子进程。子进程开始将数据库的内容写入到新的aof文件。对于所有新执行的写入命令,父进程一边将它们累积到一个重写缓存区中,一边将这些改动追加aof缓冲区并写入到现有 AOF 文件的末尾,这样样即使在重写的中途发生停机,现有的 AOF 文件也还是安全的。当子进程完成重写工作时,它给父进程发送一个信号,父进程在接收到信号之后,将重写缓存区中的所有数据追加到新 AOF 文件的末尾(这时父进程阻塞)。现在 Redis 原子地用新文件替换旧文件,之后所有命令都会直接追加到新 AOF 文件的末尾。<br>
创建rdb文件和重写aof文件都利用了COW(copy on write)机制。当是写入频繁的场景,当向磁盘保存RDB文件或者改写AOF日志时,redis可能会用正常使用内存2倍的内存。额外使用的内存和保存期间写修改的内存页数量成比例,因此经常和这期间改动的键的数量成比例
两种方式的优缺点及怎么选择合适?
Redis持久化数据和缓存怎么做扩容?
持久化机制
AOF
重写
文件重写原理
AOF的方式也同时带来了另一个问题。持久化文件会变的越来越大。为了压缩aof的持久化文件。redis提供了bgrewriteaof命令。将内存中的数据以命令的方式保存到临时文件中,同时会fork出一条新进程来将文件重写 <br><br>重写aof文件的操作,并没有读取旧的aof文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的aof文件,这点和快照有点类似 <br>
触发机制
(1)每修改同步always:同步持久化 每次发生数据变更会被立即记录到磁盘 性能较差但数据完整性比较好<br><br>(2)每秒同步everysec:异步操作,每秒记录 如果一秒内宕机,有数据丢失<br><br>(3)不同no:从不同步
优
(1)AOF可以更好的保护数据不丢失,一般AOF会每隔1秒,通过一个后台线程执行一次fsync操作,最多丢失1秒钟的数据。<br><br>(2)AOF日志文件没有任何磁盘寻址的开销,写入性能非常高,文件不容易破损。<br><br>(3)AOF日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。<br><br>(4)AOF日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据 <br>
劣
(1)对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大<br><br>(2)AOF开启后,支持的写QPS会比RDB支持的写QPS低,因为AOF一般会配置成每秒fsync一次日志文件,当然,每秒一次fsync,性能也还是很高的<br><br>(3)以前AOF发生过bug,就是通过AOF记录的日志,进行数据恢复的时候,没有恢复一模一样的数据出来。
RDB
触发机制
save
该命令会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止
执行完成时候如果存在老的RDB文件,就把新的替代掉旧的。我们的客户端可能都是几万或者是几十万,这种方式显然不可取。
bgsave
执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求
具体流程如下
具体操作是Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上 Redis 内部所有的RDB操作都是采用 bgsave 命令
自动触发
自动触发是由我们的配置文件来完成的。在redis.conf配置文件中
save:这里是用来配置触发 Redis的 RDB 持久化条件,也就是什么时候将内存中的数据保存到硬盘。比如“save m n”。表示m秒内数据集存在n次修改时,自动触发bgsave。<br>默认如下配置:<br>#表示900 秒内如果至少有 1 个 key 的值变化,则保存save 900 1<br>#表示300 秒内如果至少有 10 个 key 的值变化,则保存save 300 10<br>#表示60 秒内如果至少有 10000 个 key 的值变化,则保存save 60 10000<br><br>不需要持久化,那么你可以注释掉所有的 save 行来停用保存功能
优
(1)RDB文件紧凑,全量备份,非常适合用于进行备份和灾难恢复。<br><br>(2)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。<br><br>(3)RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
劣
RDB快照是一次全量备份,存储的是内存数据的二进制序列化形式,存储上非常紧凑。当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据 <br>
应该如何选择和平衡两种方式
通常的指示是,如果您希望获得与PostgreSQL可以提供的功能相当的数据安全性,则应同时使用两种持久性方法。<br><br>如果您非常关心数据,但是在灾难情况下仍然可以承受几分钟的数据丢失,则可以仅使用RDB。<br><br>有很多用户单独使用AOF,但我们不建议这样做,因为不时拥有RDB快照对于进行数据库备份,加快重启速度以及AOF引擎中存在错误是一个好主意。<br><br>注意:由于所有这些原因,我们将来可能会最终将AOF和RDB统一为一个持久性模型(长期计划)。<br><br> ------引自官网
建议查看redis官网,持久化页面:https://redis.io/topics/persistence
AOF和RDB持久性之间的相互作用
Redis> = 2.4可以确保避免在RDB快照操作正在进行时触发AOF重写,或者在AOF重写正在进行时允许BGSAVE。这样可以防止两个Redis后台进程同时执行繁重的磁盘I / O。<br><br>当进行快照时,用户使用BGREWRITEAOF显式请求日志重写操作时,服务器将以OK状态码答复,告知用户已计划该操作,并且快照完成后将开始重写。<br><br>如果同时启用了AOF和RDB持久性,并且Redis重新启动,则AOF文件将用于重建原始数据集,因为它可以保证是最完整的。
备份Redis数据
在服务器中创建一个cron作业,在一个目录中创建RDB文件的每小时快照,在另一个目录中创建每日快照。<br><br>每次运行cron脚本时,请确保调用find命令以确保删除太旧的快照:例如,您可以在最近的48小时内每小时拍摄一次快照,而在一个或两个月内每天拍摄一次。确保使用数据和时间信息命名快照。<br><br>每天至少有一次确保将RDB快照传输到数据中心外部或至少传输到运行Redis实例的物理计算机外部。
redis线程模型
select/poll
select/epoll
kqueue
redis 6.0的多线程
redis 6.0的多线程方面是针对网络io部分的
数据库事件处理部分还是走的单线程处理
redis事务
<span style="font-size: inherit;" class="cye-lm-tag">Redis事务的三个阶段:1. 事务开始 MULTI;2. 命令入队;3. 事务执行 EXEC。事务执行过程中,如果服务端收到有EXEC、DISCARD、WATCH、MULTI之外的请求,将会把请求放入队列中排队。</span><br>
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的。Redis会将一个事务中的所有命令序列化,然后一次性地按顺序执行。<br>redis 不支持回滚;<br>如果在一个事务中的命令出现错误,那么所有的命令都不会执行;如果在一个事务中出现运行错误,那么其他正确的命令会被执行;<br>WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行;<br>MULTI命令用于开启一个事务,它总是返回OK;<br>通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
Redis的事务总是具有ACID中的一致性(指数据符合定义和要求,无无效或错误数据。通过命令入队错误、执行错误保证)和隔离性(单进程单线程而且不会中断事务),在参数appendfsyn为always时可以保证持久性,不能保证原子性
其他方法实现事务:基于Lua脚本,Redis可以保证脚本内的命令一次性、按顺序地执行
使用
常用命令
MULTI
标记一个事务块的开始
EXEC
执行所有事务块内的命令
DISCARD
取消事务,放弃执行事务块内的所有命令。
UNWATCH
取消 WATCH 命令对所有 key 的监视
WATCH key [key ...]
监视一个(或多个) key ,如果在事务执行(EXEC)之前这个(或这些) key 被其他命令所改动,那么事务将被打断
一旦执行了EXEC,之前加的监控锁都会失效
相当于java锁机制,乐观锁
事务经历阶段
开始事务
命令入队
执行事务
只支持部分事务
编译(语法错误)时出错,那么全部失败(回滚)
执行时有错误,那么继续执行,失败的失效
好处:使得Redis内部更加简单,而且运行速度更快
应用场景
金融
批量
redis集群
why
内存不足 2. 吞吐量低,导致查询慢
哈希分区方式
<ol><li>节点取余分区。hash 算法一般使用 hash(key)%N 的方式将键映射到节点上,优点简单常见于分库分表,扩容采用节点翻倍的方式可以只迁移50%的数据,缺点当增删节点时会有大量缓存重建,所以可以预分区;</li><li>一致性 hash 分区。一般把0-32^2范围的哈希值组成一个哈希环,将节点映射到哈希环上,然后key映射到哈希环上,数据存储在顺时针寻找到的第一个节点上。虚拟节点(自动负载均衡)</li><li>虚拟槽分区。如redis cluster</li></ol><br>
数据分区规则一般有哈希分区和顺序分区<br>
<ul><li><span style="font-size: inherit;">对于主进程是单线程工作的Redis,只运行一个实例就显得有些浪费。同时,管理一个巨大内存不如管理相对较小的内存高效。因此,实际使用中,通常一台机器上同时跑多个Redis实例。</span><br></li><li>hashtag。通过合理命名key,可以将一组相关联的key放入同一个Redis节点,这在避免跨节点访问相关数据时很重要。</li></ul>
redis的集群方案
redis官方集群方案 redis cluster。<br>redis cluster是查询路由方式和客户端分区方式的混合,查询随机一个实例,如果不是要访问的实例,就返回客户端正确实例的信息,客户端再重定向到正确节点。<br>节点之间使用cluster bus通信,使用gossip协议。<br>
redis sharding集群。<br><span class="cye-lm-tag" style="font-size: inherit;">客户端sharding方式,一般采用</span><b style="font-size: inherit;">一致性哈希</b><span class="cye-lm-tag" style="font-size: inherit;">算法进行分区。但是水平扩容缩容麻烦,会导致一部分键不匹配,可以采用</span><b style="font-size: inherit;">Presharding</b><span class="cye-lm-tag" style="font-size: inherit;">技术。</span><br>
中间件proxy实现大规模集群。<br>如,twemproxy、codis(支持多线程)。<br>
方案对比
节点
每个节点的状态结构:clusterNode、clusterLink、clusterState
clusterState
CLUSTER MEET命令实现
三次握手
节点只能使用0号数据库。
除了将键值对保存在数据库,节点还会用clusterState结构中的slots_to_keys跳跃表来保存槽和键之间的关系。这样可以方便地对属于某些槽的所有键批量操作。
slots_to_keys
<ul><li>集群的整个数据库被分为16384个槽slot,每个键都属于16384个槽中的一个,集群每个节点可以处理0个或最多16384个槽,CLUSTER ADDSLOTS命令将多个槽指派到某个节点。</li><li>每个clusterNode结构的slots和numslot属性记录该节点负责处理哪些槽即该节点的槽指派信息。而clusterState结构的slots[16384]数组记录所有16384个槽的指派信息(指向clusterNode结构的指针),这样取得负责处理槽 i 的节点,只需访问clusterState.slots[i],复杂度O(1)。<br></li><li><span style="font-size: inherit;" class="cye-lm-tag">在集群中执行命令:</span>使用 CRC16(key) & 16383 计算键属于哪个槽;判断槽是否由当前节点负责处理;若槽不是当前节点负责,返回MOVED错误,重定向至正确的节点。</li></ul><br>
slots数组
重新分片
复制和故障转移
<ol><li>设置从节点:CLUSTER REPLICATE <node_id> 命令将节点设置为<nodo_id>的从节点,并对主节点复制。</li><li>故障检测:当集群里半数以上主节点将某个主节点报告为疑似下线(PFAIL),那么该主节点被标记为下线状态(FAIL)。</li><li>故障转移,新主节点的选举方法和领头sentinel选举的方法相似;被选中的slave执行slaveof no one成为新master;新master将已下线master的槽指派到自己;广播PONG消息;故障转移完成。</li></ol>
主从节点
消息
各个节点间采用gossip协议通过发送和接收消息进行通信。主要5种消息:MEET、PING、PONG、FAIL、PUBLISH。一条消息由消息头和消息正文组成。
reids主从复制
why
1. 数据可用性差 2. 数据查询缓慢<br>
<ul><li>问题:一个master连接多个slave导致master复制数据工作量过大。解决:Master/slave chains的架构</li></ul>
数据同步
<ul><li><span style="font-size: inherit;" class="cye-lm-tag">replication ID和offset:主从的每一对给定的Replication ID, offset都会标识一个数据集的确切版本,可以判断主从是否一致。</span></li><li><span style="font-size: inherit;" class="cye-lm-tag">复制积压缓冲区:是一个固定长度的队列,命令传播时,同时会将命令写入该队列并记录每个字节的offset。</span></li><li><span style="font-size: inherit;" class="cye-lm-tag">增量更新:若副本断线重连,会使用PSYNC将自己的replication offset发给master,若该offset之后的数据还在backlog中就增量更新,若不在则全量更新。</span></li><li><span style="font-size: inherit;" class="cye-lm-tag">可以设置backlog的生存时间及大小(一般2*second*write_size_per_second),注意副本永远不能释放自己的超时的backlog buffer,因为它可能升级为master,这样它升级为master后也能提供增量更新给subslave。(升级后它有新的master_replid同时它会记住旧的master的Replication ID, offset,这样subslave可以依据旧的master_replid增量更新,然后设置自身的master_replid为新的master的master_replid)</span></li><li>全量更新:当副本加入或重新连接时会传输rdb文件进行<b>全量同步</b>(无法使用增量同步时),有两种传输方式disk-backed(先创建rdb文件,再加载到内存,再传输,消耗磁盘IO)、diskless(无磁盘io复制,直接传输给slave,消耗网络IO),副本可以直接从socket中读取数据也可以先存储待读取完再加载。</li></ul><br>
应该在master和slave中启用持久化,不可能启用时,例如由于非常慢的磁盘性能而导致的延迟问题,应该配置实例来避免重置后自动重启。<br>
<span style="font-size: inherit;" class="cye-lm-tag">异步复制,在master端是非阻塞的;在slave端大部分是非阻塞的,在初次同步后(接收的rdb数据存到磁盘),删除旧数据加载新数据时会阻塞 </span><br>
<b>writable slave</b>:有些场景副本可以设置readonly为false,写一些短暂的数据,例如,计算 slow Set 或者 Sorted Set 的操作并将它们存储在本地 key 中,这样当与master同步时可以很容易删除,或重启时也会丢失。注意,4.0 版本之前的 writable slaves 不能用TTL淘汰 key。自从4.0开始,writable slaves的写入只能存储在本地(不会同步到subslaves)。
<ul><li>2.8版本以前 复制功能有两个操作:同步(sync)和命令传播(command propagate)。同步将slave的状态更新至master所处的状态,命令传播将对master的更改传递到slave。问题:每次主从断线重连都需要重新sync全部数据,非常耗费资源。解决:2.8版本后使用psync(即partial resynchronization)</li></ul>
sync
复制功能的实现步骤
心跳检测
slave默认每一秒向master发送REPLCONF ACK <replication_offset>命令。作用:检测主从的网络状态;辅助实现min-slaves选项;检测命令丢失(若丢失,补发缺失的数据)
<span style="font-size: inherit;" class="cye-lm-tag">允许至少有N个副本时才能写入:<br>由于 Redis 使用异步复制,因此无法确保 slave 是否实际接收到给定的写命令,因此总会有一个数据丢失窗口。</span><br><span style="font-size: inherit;" class="cye-lm-tag">Redis slave 每秒钟都会 ping master,确认已处理的复制流的数量。Redis master 会记得上一次从每个 slave 都收到 ping 的时间。用户可以配置一个最小的 slave 数量N,使得它滞后 <= 最大秒数。</span><br><br>
主从复制
哨兵
集群
一主多从
和Mysql主从复制的原因一样,Redis虽然读取写入的速度都特别快,但是也会产生读压力特别大的情况。为了分担读压力,Redis支持主从复制,Redis的主从结构可以采用一主多从或者级联结构
读写分离
从服务器只读
从Redis 2.6开始,从服务器支持只读模式,并且是默认模式。这个行为是由Redis.conf文件中的slave-read-only 参数控制的,<br><br>可以在运行中通过CONFIG SET来启用或者禁用。<br><br>只读的从服务器会拒绝所有写命令,所以对从服务器不会有误写操作。但这不表示可以把从服务器实例暴露在危险的网络环境下,<br><br>因为像DEBUG或者CONFIG这样的管理命令还是可以运行的。不过你可以通过使用rename-command命令来为这些命令改名来增加安全性。<br><br>你可能想知道为什么只读限制还可以被还原,使得从服务器还可以进行写操作。虽然当主从服务器进行重新同步或者从服务器重启后,<br><br>这些写操作都会失效,还是有一些使用场景会想从服务器中写入临时数据的,但将来这个特性可能会被去掉。
同步策略
全量同步
Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤如下: <br><br>- 从服务器连接主服务器,发送SYNC命令; <br>- 主服务器接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的所有写命令; <br>- 主服务器BGSAVE执行完后,向所有从服务器发送快照文件,并在发送期间继续记录被执行的写命令; <br>- 从服务器收到快照文件后丢弃所有旧数据,载入收到的快照; <br>- 主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令; <br>- 从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令;
增量同步
Redis增量复制是指Slave初始化后开始正常工作时主服务器发生的写操作同步到从服务器的过程。 <br>增量复制的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。
Redis主从同步策略
主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。redis 策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步
注意事项
1)Redis使用异步复制。但从Redis 2.8开始,从服务器会周期性的应答从复制流中处理的数据量。<br>2)一个主服务器可以有多个从服务器。<br><br>3)从服务器也可以接受其他从服务器的连接。除了多个从服务器连接到一个主服务器之外,多个从服务器也可以连接到一个从服务器上,形成一个<br> 图状结构。<br><br>4)Redis主从复制不阻塞主服务器端。也就是说当若干个从服务器在进行初始同步时,主服务器仍然可以处理请求。<br><br>5)主从复制也不阻塞从服务器端。当从服务器进行初始同步时,它使用旧版本的数据来应对查询请求,假设你在redis.conf配置文件是这么配置的。<br> 否则的话,你可以配置当复制流关闭时让从服务器给客户端返回一个错误。但是,当初始同步完成后,需要删除旧的数据集和加载新的数据集,在<br> 这个短暂的时间内,从服务器会阻塞连接进来的请求。<br><br>6)主从复制可以用来增强扩展性,使用多个从服务器来处理只读的请求(比如,繁重的排序操作可以放到从服务器去做),也可以简单的用来做数据冗余。<br><br>7)使用主从复制可以为主服务器免除把数据写入磁盘的消耗:在主服务器的redis.conf文件中配置“避免保存”(注释掉所有“保存“命令),然后连接一个<br> 置为“进行保存”的从服务器即可。但是这个配置要确保主服务器不会自动重启(要获得更多信息请阅读下一段)<br>
同步的大概实现
全量同步:
master服务器会开启一个后台进程用于将redis中的数据生成一个rdb文件,与此同时,服务器会缓存所有接收到的来自客户端的写命令(包含增、删、改),当后台保存进程处理完毕后,会将该rdb文件传递给slave服务器,而slave服务器会将rdb文件保存在磁盘并通过读取该文件将数据加载到内存,在此之后master服务器会将在此期间缓存的命令通过redis传输协议发送给slave服务器,然后slave服务器将这些命令依次作用于自己本地的数据集上最终达到数据的一致性。
增量同步:
从redis 2.8版本以前,并不支持部分同步,当主从服务器之间的连接断掉之后,master服务器和slave服务器之间都是进行全量数据同步,但是从redis 2.8开<br>始,即使主从连接中途断掉,也不需要进行全量同步,因为从这个版本开始融入了部分同步的概念。部分同步的实现依赖于在master服务器内存中给每个slave服务器维护了一份同步日志和同步标识,每个slave服务器在跟master服务器进行同步时都会携带自己的同步标识和上次同步的最后位置。当主从连接断掉之后,slave服务器隔断时间(默认1s)主动尝试和master服务器进行连接,如果从服务器携带的偏移量标识还在master服务器上的同步备份日志中,那么就从slave发送的偏移量开始继续上次的同步操作,如果slave发送的偏移量已经不再master的同步备份日志中(可能由于主从之间断掉的时间比较长或者在断掉的短暂时间内master服务器接收到大量的写操作),则必须进行一次全量更新。在部分同步过程中,master会将本地记录的同步备份日志中记录的指令依次发送给slave服务器从而达到数据一致。
问题延伸
1)在上面的全量同步过程中,master会将数据保存在rdb文件中然后发送给slave服务器,但是如果master上的磁盘空间有效怎么办呢?那么此时全部同步对于master来说将是一份十分有压力的操作了。此时可以通过无盘复制来达到目的,由master直接开启一个socket将rdb文件发送给slave服务器。无盘复制一般应用在磁盘空间有限但是网络状态良好的情况下.<br><br>2)主从复制结构,一般slave服务器不能进行写操作,但是这不是死的,之所以这样是为了更容易的保证主和各个从之间数据的一致性,如果slave服务器上数据进行了修改,那么要保证所有主从服务器都能一致,可能在结构上和处理逻辑上更为负责。不过你也可以通过配置文件让从服务器支持写操作。<br><br>3)主从服务器之间会定期进行通话,但是如果master上设置了密码,那么如果不给slave设置密码就会导致slave不能跟master进行任何操作,所以如果你的master服务器上有密码,那么也给slave相应的设置一下密码吧(通过设置配置文件中的masterauth);<br><br>4)关于slave服务器上过期键的处理,由master服务器负责键的过期删除处理,然后将相关删除命令已数据同步的方式同步给slave服务器,slave服务器根据删除命令删除<br>本地的key。
当主服务器不进行持久化时复制的安全性
在进行主从复制设置时,强烈建议在主服务器上开启持久化,当不能这么做时,比如考虑到延迟的问题,应该将实例配置为避免自动重启。<br><br> <br><br>为什么不持久化的主服务器自动重启非常危险呢?<br><br>为了更好的理解这个问题,看下面这个失败的例子,其中主服务器和从服务器中数据库都被删除了。<br><br> <br><br>设置节点A为主服务器,关闭持久化,节点B和C从节点A复制数据。<br><br>这时出现了一个崩溃,但Redis具有自动重启系统,重启了进程,因为关闭了持久化,节点重启后只有一个空的数据集。<br><br>节点B和C从节点A进行复制,现在节点A是空的,所以节点B和C上的复制数据也会被删除。<br><br>当在高可用系统中使用Redis Sentinel,关闭了主服务器的持久化,并且允许自动重启,这种情况是很危险的。<br><br>比如主服务器可能在很短的时间就完成了重启,以至于Sentinel都无法检测到这次失败,那么上面说的这种失败的情况就发生了。<br><br>如果数据比较重要,并且在使用主从复制时关闭了主服务器持久化功能的场景中,都应该禁止实例自动重启。
哨兵模式
<ul><li>what:Redis Sentinel 是一个分布式系统, 你可以在一个架构中运行多个 Sentinel 进程(progress), 这些进程使用流言协议(gossip protocols)来接收关于主服务器是否下线的信息, 并使用投票协议(agreement protocols)来决定由谁执行自动故障迁移。sentinel本质是运行在特殊模式下的redis服务器。功能:监控、通知、故障转移、配置提供者</li></ul>
启动并初始化sentinel步骤
<ol><li>初始化服务器。与redis服务器不同,不载入rdb或aof;</li><li>将redis服务器使用的代码替换成sentinel专用代码。redis服务器使用redisCommandTable作为命令表,而sentinel使用sentinelcmds作为命令表;</li><li>初始化sentinel状态。创建sentinelState结构保存与sentinel相关的状态;</li><li>根据给定配置文件初始化sentinel的状态结构的masters属性。该属性为字典,键是被监视服务器名字,值是对应的sentinelRedisInstance结构;</li><li>创建连向主服务器的网络连接。对每个监视的master创建两个连接:命令连接(发送命令接收回复)和订阅连接(订阅master的__sentinel__:hello频道)</li></ol>
masters字典
获取与发送信息
<ul><li>sentinel使用每<b>10秒一次</b>发送info命令从master获取信息(包括主从)并存储在sentinelRedisInstance状态结构中。若有新slave加入,还会创建到slave的命令连接和订阅连接,也发送info命令给slave获取信息。</li><li>sentinel每<b>2秒一次</b>通过命令连接向被监视的所有服务器的__sentinel__:hello频道发送信息"PUBLISH sentinel: hello <s ip>,<s port>, <s runid>, <s epoch>, <m name>, <m ip>, <m port>, <m epoch>"。而且又通过订阅连接从服务器的__sentinel__:hello频道接受信息。即sentinels通过该频道发送和接收信息,然后更新各自sentinelRedisInstance状态结构中的sentinels字典属性。所以监视同一master的sentinels可以自动发现对方。<br></li><li>当sentinel通过频道发现其他sentinel后,还会创建连向其他sentinel的命令连接,这样sentinels之间使用命令连接通信,如 SENTINEL is-master-down-by-addr命令。</li></ul>
sentinel发送和接受信息
sentinels字典
slaves字典
主观下线和客观下线
<ul><li>主观下线(subjectively down)指的是单个 Sentinel 实例对服务器做出的下线判断;</li></ul><ul><li>默认sentinel每<b>1秒一次</b>向与它命令连接的masters、slaves、sentinels发送PING,通过回复判读是否主观下线。被监控的服务器对 PING 命令的有效回复可以是以下三种回复的其中一种:返回 +PONG ;返回 -LOADING 错误;返回 -MASTERDOWN 错误。如果服务器返回除以上三种回复之外的其他回复, 又或者在指定时间(多个sentinels设置的时长可能不同)内没有回复 PING 命令, 那么 Sentinel 认为服务器返回的回复无效(non-valid)。</li><li>当一个sentinel将master判断为SDOWN时,同时会向监视这个master的其他sentinel询问。</li></ul>
<ul><li>客观下线(objectively down)指的是多个 Sentinel 实例在对同一个服务器做出 SDOWN 判断, 并且通过 SENTINEL is-master-down-by-addr <ip> <port> <current epoch> <runid> 命令互相交流之后, 若有quorum个(不同sentinels指定的quorum可能不同)sentinels判断master下线,则为客观下线。</li><li>客观下线条件只适用于主服务器: 对于任何其他类型的 Redis 实例, Sentinel 在将它们判断为下线前不需要进行协商, 所以从服务器或者其他 Sentinel 永远不会达到客观下线条件。</li></ul>
选举领头sentinl<br>故障转移
判断master客观下线后,发现master下线的sentinels之间会互相再次发送 SENTINEL is-master-down-by-addr <ip> <port> <current epoch> <runid> 命令请求成为它的局部领头sentinel,每个sentinel只有一次投票机会,先到先得。成为领头sentinel需要半数以上sentinels投票同意,可保证每一纪元只有一个领头sentinel。若给定时间内没有选举出领头,一段时间后在新纪元再次开始选举。
领头sentinel执行故障转移操作:<br><ol><li>从slaves中选出master。不要下线或断线或最近5秒没回复领头sentinel的PING命令的slaves;不要断开连接超过down-after*10时长的slaves;然后根据优先级最高、复制偏移量最大、runID最小排序。选出后发送SLAVEOF no one命令。然后每<b>1秒一次</b>INFO命令检查是否成为master。</li><li>让slaves改为复制新的master。 发送SLAVEOF <m_ip> <m_port>命令。</li><li>将已下线的master设置为新master的slave。</li></ol>
发布与订阅信息
客户端可以将 Sentinel 看作是一个只提供了订阅功能的 Redis 服务器: 你不可以使用 PUBLISH 命令向这个服务器发送信息, 但你可以用 SUBSCRIBE 命令或者 PSUBSCRIBE 命令, 通过订阅给定的频道来获取相应的事件提醒。<br>一个频道能够接收和这个频道的名字相同的事件。 比如说, 名为 +sdown 的频道就可以接收所有实例进入主观下线(SDOWN)状态的事件。<br>通过执行 PSUBSCRIBE * 命令可以接收所有事件信息。
TILT 模式
处理 -BUSY 状态
Sentinel,Docker或其他形式的网络地址转换或端口映射应格外小心:Docker执行端口重新映射,破坏Sentinel对其他Sentinel进程的自动发现以及主副本的列表。
配置<br>
如果大多数Sentinel进程无法进行对话,则Sentinel永远不会启动故障转移。(即只有少数sentinel的情况中也没有故障转移)
quorum参数指定需要多少个sentinel同意才判定master不可达或失败。quorum仅用于判断master故障,真正进行故障转移需要大多数的sentinels的投票选举出一个sentinel来进行
down-after-milliseconds指定判断实例主观下线的时间阈值
parallel-syncs 指定可以同时被重新配置使用新master的副本数量,该值越小,故障转移花的时间越长。但是如果该值太大,会有很多副本同时进行复制过程,而复制过程中加载来自master的数据时副本进程会阻塞,导致客户端访问延迟。
部署(几种部署方式的可用性)
只有1个master1个slave,分别持有sentinel(只有2个sentinel的情况):<br>当同一机器上的master和sentinel都<b>失效或网络断开</b>,因为2个sentiinel无法交流就无法达成一致进行故障转移,所以该方案不可用<br>如果是网络断开且单边sentinel可以进行failover不需要其他sentinel同意的情况,clients就会不确定性地往这两个master写入,当网络修复,就无法确定哪个配置正确。
<span style="font-size: inherit;" class="cye-lm-tag">1个master2个slave,分别持有sentinel</span><br><span style="font-size: inherit;" class="cye-lm-tag">问题:当master失效或与slave断开连接,选举slave作为新的master,但是由于网络分割(master与slave无法交流),客户端连接的依然是旧的master,导致写入丢失。<br>解决:配置min-replicas-to-write 1 min-replicas-max-lag 10,这样当master失效不再有副本或超过max-lag副本没有发送确认消息给master,client都不会再写入。但是当2个副本都关闭了时,即使master还在工作,也不会写入,这是一个权衡。</span><br>
只有1个master和1个slave,3个clients分别持有sentinel<br>
1个master1个slave2个clients,各持有1个sentinel
数据库
事件通知
数据库通知让clients可以通过订阅给定频道或模式,来获知数据库中键的变化<br>key-space notification:关注某个键执行了什么命令,如SUBSCRIBE __keyspace@0__:message<br>key-event notification :关注某个命令被什么键执行了,如SUBSCRIBE __keyevent@0__:del
事件
redis服务器进程就是一个事件循环程序,需要处理两类事件:<br>file event:redis服务器通过套接字与client或其他服务器连接,文件事件是对套接字操作的抽象。文件事件负责接收客户端命令并回复;<br>time event:时间事件负责执行serverCron这样定时运行的函数<br>
文件事件。redis基于Reator模式开发地网络事件处理器称为文件事件处理器。使用 I/O 多路复用程序监听多个套接字的AE_READABLE或AE_WRITABLE事件,并为套接字关联不同的事件处理器。<br>
文件事件处理器
套接字事件顺序执行
时间事件。两类:定时事件和周期性事件(周期性事件就是连续执行定时事件)。三个属性:id、when(时间事件到达时间)、timeProc(时间事件处理器,一个函数)。时间事件放在链表中,时间事件执行器运行时就遍历该链表,查找到达的时间事件并调用相应的事件处理器。典型的周期性时间事件serverCron函数,默认每秒10次,可通过hz选项调整。
<ul><li>事件的调度与执行。redis是单线程的但是要处理两种事件,由aeProcessEvents函数负责调度与执行。</li><li>aeApiPoll函数阻塞直到文件事件出现,其阻塞时间由到达时间最接近当前时间的时间事件决定,可以避免服务器对时间事件频繁的轮询(忙等待),也可以确保不会阻塞太长时间。</li><li>两种事件都是同步、有序、原子地执行,所以要尽可能减小程序阻塞时间,有时需主动让出执行权以降低事件饥饿可能性。如,命令回复写入客户端套接字时,写入字数超过预设常量,命令回复器就会break,余下数据下次再写。另外,耗时的持久化操作会放到子线程执行。</li></ul>
服务器的main函数逻辑
客户端
redis服务器状态结构的clients属性是一个链表,保存了所有已连接客户端的状态结构。使用CLIENT LIST命令查看。部分client状态属性如下:<br><ol><li>querybuffer输入缓冲区,保存命令请求;</li><li>argv属性是数组,存储命令与命令参数,argc属性记录argv[]长度。当服务器从协议内容分析得出argv和argc的值后,在命令表(字典)中查找命令对应的命令实现redisCommand。找到后,将client状态的cmd属性指向该结构;</li><li>输出缓冲区;保存命令回复。有两个:固定大小输出缓冲buf,为16KB的数组;可变大小输出缓冲区reply,为字符串链表。为了避免客户端的回复过大占用过多资源,服务器使用硬性限制和软性限制来限制输出缓冲区大小,使用client-output-buffer-limit选项来配置普通client、从服务器client、执行发布与订阅功能的client。</li></ol>
server状态结构和client状态结构
lua_client伪客户端用于执行lua脚本的命令,在服务器整个生命期一直存在;aof文件伪客户端在载入aof文件时存在。<br>
服务器
命令请求的执行过程
<ol><li>client将命令发给server;</li><li>server读取命令(存入querybuffer),并分析命令参数(存入argv和argc属性)。</li><li>命令执行器根据参数查找命令的实现函数(根据argv[0],在命令表中查找指定的redisCommand,并保存在cmd属性中),然后执行函数(传入参数为client状态的指针)并得出回复(存入输出缓冲区buf或reply属性);</li><li>server将命令回复发给client</li></ol>
serverCron函数
redis周期性操作函数serverCron默认每100ms执行一次,用于对正在运行的服务器进行维护。其执行的操作有如下:<br>检查save选项所设置的条件是否满足,若满足执行BGSAVE;<br>更新服务器时间缓存。为了减少获取系统时间的系统调用,服务器状态的unixtime和mstime属性缓存当前时间,serverCron会每100ms更新这两属性;<br>更新LRU时钟。lruclock属性缓存当前时间,用于计算键的空闲时间,serverCron每10秒更新该属性;<br>更新服务器每秒执行命令次数。每100ms更新一次;<br>更新服务器内存峰值记录;<br>处理SIGTERM信号;<br>管理客户端资源。serverCron每次执行都调用clientsCron函数;<br>管理数据库资源。每次都会调用databaseCron函数;<br>执行被延迟的BGREWRITEAOF;<br>检查持久化操作的运行状态······
服务器初始化
初始化服务器状态结构—>载入配置选项—>初始化服务器的数据结构—>还原数据库状态(载入aof或rdb文件)—>执行事件循环。
发布与订阅
<ul><li>服务器状态在 pubsub channels字典保存了所有频道的订阅关系: SUBSCRIBE 命令负责将客户端和被订阅的频道关联到这个字典里面,而 UNSUBSCRIBE命令则负责解除客户端和被退订频道之间的关联。</li><li>服务器状态在 pubsub patterns链表保存了所有模式的订阅关系: PSUBSCRIBE 命令负责将客户端和被订阅的模式记录到这个链表中,而 PUNSUBSCRIBE命令则负责移除客户端和被退订模式在链表中的记录。</li><li> PUBLISH命令通过访问 pubsub channels字典来向频道的所有订阅者发送消息, 通过访问 pubsub patterns链表来向所有匹配频道的模式的订阅者发送消息</li><li> PUBSUB命令的三个子命令都是通过读取 pubsub channels字典和 pubsub patterns链表中的信息来实现的。</li></ul>
发布订阅(了解即可)
无法取代其他专门做消息中间件的地位
常用命令
PSUBSCRIBE pattern [pattern ...]
订阅一个或多个符合给定模式的频道。
PUBSUB subcommand [argument [argument ...]]
查看订阅与发布系统状态。
PUBLISH channel message
将信息发送到指定的频道。
PUNSUBSCRIBE [pattern [pattern ...]]
退订所有给定模式的频道。
SUBSCRIBE channel [channel ...]
订阅给定的一个或多个频道的信息。
UNSUBSCRIBE [channel [channel ...]]
指退订给定的频道。
巨坑无比
没什么人使用
之前做项目的时候使用了下,监听key过期,结果key过期是惰性过期,搞得很无奈。
面试题
为什么要用redis而不是map/guava做缓存?<br>
redis VS memcached
redis的应用场景?
实现hash类型的元素可以过期?
Redis集群会有写操作丢失吗?为什么?
你知道有哪些Redis分区实现方案?分区的缺点?
分布式锁的实现?
Jedis与Redisson对比有什么优缺点?
如何保证缓存与数据库双写时的数据一致性?
缓存异常
缓存雪崩指同一时刻大面积的缓存失效,比如对很多key设置了相同的过期时间、缓存服务器宕机等原因,导致突然大量请求都落在数据库上使数据库cpu内存压力过大而崩溃。解决:<br><ol><li>事前:使用集群缓存,保证缓存服务的高可用。缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。</li><li>事中:ehcache本地缓存 + Hystrix限流&降级。</li><li>事后:开启redis的持久化机制,尽快恢复缓存集群。</li></ol>
缓存击穿(和缓存雪崩相似)指大量请求查询一个key,而这个key刚好失效。<br><ol><li>使用互斥锁(分布式锁)。只有获取到锁的请求才能访问数据库获取数据,然后缓存数据,然后解锁。</li><li>设置热点数据永不过期。</li></ol>
缓存穿透指查询的数据在缓存和数据库中都不存在,每次查询都会走数据库。解决方式:<br><ol><li>接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;</li><li>缓存不存在的key和空值,设置较短的过期时间。</li><li>在查询缓存之前加一层布隆过滤器,将所有可能的key哈希到一个足够大的bitmap上,不存在的key直接返回null。布隆过滤器缺点:有一定误报率,key删除困难。</li></ol>
缓存预热<br><ol><li>直接写个缓存刷新页面,上线时手工操作一下;</li><li>数据量不大,可以在项目启动的时候自动进行加载;</li><li>定时刷新缓存;</li></ol>
应用
缓存问题的名词解释
缓存击穿
概念
一个存在的key,在缓存过期的一刻(使用缓存 + 过期时间的策略既可以加速数据读写,又保证数据的定期更新),同时有大量的线程重建缓存(构建缓存慢 造成另一线程判断时缓存取值为null),这些线程都会击穿到DB,造成瞬时DB请求量大、压力骤增。甚至可能会让应用崩溃
解决方案
互斥锁
永远不过期
从缓存的角度来看,如果你设置了永远不过期,那么就不会有海量请求数据库的情形出现。此时我们一般通过新起一个线程的方式去定时将数据库中的数据更新到缓存中,更加成熟的方式是通过定时任务去同步缓存和数据库的数据。<br><br>但这种方案会出现数据的延迟问题,也就是线程读取到的数据并不是最新的数据。但对于一般的互联网功能来说,些许的延迟还是能接受的。
缓存雪崩
概念
大量的key设置了相同的过期时间,导致在缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩
解释
产生雪崩的原因之一,比如马上就要到双十二零点,很快就会迎来一波抢购,这波商品时间比较集中的放入了缓存,假设缓存一个小时。那么到了凌晨一点钟的时候,这批商品的缓存就都过期了。而对这批商品的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。
做电商项目的时候,一般是采取不同分类商品,缓存不同周期。在同一分类中的商品,加上一个随机因子。这样能尽可能分散缓存过期时间,而且,热门类目的商品缓存时间长一些,冷门类目的商品缓存时间短一些,也能节省缓存服务的资源。
其实集中过期,倒不是非常致命,比较致命的缓存雪崩,是缓存服务器某个节点宕机或断网。因为自然形成的缓存雪崩,一定是在某个时间段集中创建缓存,那么那个时候数据库能顶住压力,这个时候,数据库也是可以顶住压力的。无非就是对数据库产生周期性的压力而已。而缓存服务节点的宕机,对数据库服务器造成的压力是不可预知的,很有可能瞬间就把数据库压垮。
解决方案<br>
加锁排队. 限流
在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,类似于自旋锁,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
数据预热
可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀
做二级缓存,或者双缓存策略
A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期。
缓存永远不过期
这里的“永远不过期”包含两层意思:<br><br> (1) 从缓存上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。<br><br> (2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期.<br><br> 从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。
缓存穿透
概念
缓存穿透,是指查询一个数据库一定不存在的数据。
解释
正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。想象一下这个情况,如果传入的参数为-1,会是怎么样?这个-1,就是一定不存在的对象。就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。即便是采用UUID,也是很容易找到一个不存在的KEY,进行攻击。
解决方案
布隆过滤
对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃。还有最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。在击穿缓存时,先查一下布隆过滤器,如果不存在,则不查db,一定程度保护了db层
适用范围
可以用来实现数据字典,进行数据的判重,或者集合求交集
基本原理及要点:对于原理来说很简单,位数组+k个独立hash函数。将hash函数对应的值的位数组置1,查找时如果发现所有hash函数对应位都是1说明存在,很明显这个过程并不保证查找的结果是100%正确的。同时也不支持删除一个已经插入的关键字,因为该关键字对应的位会牵动到其他的关键字。所以一个简单的改进就是counting Bloom filter,用一个counter数组代替位数组,就可以支持删除了。添加时增加计数器,删除时减少计数器。
缓存空对象. 将 value设为null
第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间(最长不超过五分钟),让其自动剔除。<br><br> 第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
二八定律
网站访问数据的特点大多数呈现在"二八定律":80%的业务访问集中在20%的数据上。这时为了减轻数据的压力和提高网站的数据访问速度,则可以使用缓存机制来优化网站。
热数据和冷数据
概念
比如网站的用户总数就是一个小而热的数据,但是比如每个用户的个人轨迹信息就是一个量大但是还冷热不均的数据,<br><br>防止数据无限膨胀,所以用户缓存放到内存中都要设立过期时间。<br><br>比如,论坛的最新发表列表,最新报名列表,包括比如最新激活的用户可以存在redis做最新列表的使用方式。<br><br>redis 一定要用在小而热的情况,防止数据的无限膨胀。<br><br>
建议
基于redis做冷热分离从技术上是可行的,从业务实用角度看却不一定。因为首先redis不能很好区分冷热数据,然后很难避免读取落地冷数据时的性能问题,因此肯定不如纯内存的redis性能好,而用户对KV数据库性能的期望是没有最好,只有更好。随着内存越来越大、越来越便宜,更多的数据可以直接放到redis内存,会进一步导致冷热分离成为一个无人使用的鸡肋功能。<br><br>
缓存预热
缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统,刷新过期时间。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!<br> 缓存预热解决方案:<br>(1)直接写个缓存刷新页面,上线时手工操作下;<br><br>(2)数据量不大,可以在项目启动的时候自动进行加载;<br><br>(3)定时刷新缓存;
缓存更新
除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,常见的策略有两种:<br>(1)定时去清理过期的缓存;<br><br>(2)当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。<br><br>两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,大家可以根据自己的应用场景来权衡。
缓存降级
核心就是弃车保帅,保证核心服务可用,降级可以丢弃的服务,可以让程序实施自动降级,也可以人工紧急降级。
当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。<br><br>降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。<br>在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:<br><br>(1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;<br><br>(2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;<br><br>(3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;<br><br>(4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
缓存淘汰策略LRU与LFU
概念
Redis缓存淘汰策略与Redis键的过期删除策略并不完全相同,前者是在Redis内存使用超过一定值的时候(一般这个值可以配置)使用的淘汰策略;而后者是通过定期删除+惰性删除两者结合的方式进行内存淘汰的。<br>这里参照官方文档的解释重新叙述一遍过期删除策略:当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除;<br> 但有一些键只访问一次,无法在访问该key时,去判断是否过期进行删除,因此需要主动删除,默认情况下redis每秒检测10次,检测的对象是所有设置了过期时间的键集合,每次从这个集合中随机检测20个键查看他们是否过期,如果过期就直接删除,如果删除后还有超过25%的集合中的键已经过期,那么继续检测过期集合中的20个随机键进行删除。这样可以保证过期键最大只占所有设置了过期时间键的25%。<br>
ZERO、Redis内存不足的缓存淘汰策略
noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键<br>allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键<br>volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键<br>allkeys-random:加入键的时候如果过限,从所有key随机删除<br>volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐<br>volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键<br>volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键<br>allkeys-lfu:从所有键中驱逐使用频率最少的键<br>
LRU
Java中的LRU实现方式
在Java中LRU的实现方式是使用HashMap结合双向链表,HashMap的值是双向链表的节点,双向链表的节点也保存一份key value。<br><br>新增key value的时候首先在链表结尾添加Node节点,如果超过LRU设置的阈值就淘汰队头的节点并删除掉HashMap中对应的节点。<br>修改key对应的值的时候先修改对应的Node中的值,然后把Node节点移动队尾。<br>访问key对应的值的时候把访问的Node节点移动到队尾即可。<br>
Redis中LRU的实现
Redis维护了一个24位时钟,可以简单理解为当前系统的时间戳,每隔一定时间会更新这个时钟。每个key对象内部同样维护了一个24位的时钟,当新增key对象的时候会把系统的时钟赋值到这个内部对象时钟。比如我现在要进行LRU,那么首先拿到当前的全局时钟,然后再找到内部时钟与全局时钟距离时间最久的(差最大)进行淘汰,这里值得注意的是全局时钟只有24位,按秒为单位来表示才能存储194天,所以可能会出现key的时钟大于全局时钟的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。<br>
redis算法差别
Redis中的LRU与常规的LRU实现并不相同,常规LRU会准确的淘汰掉队头的元素,但是Redis的LRU并不维护队列,只是根据配置的策略要么从所有的key中随机选择N个(N可以配置)要么从所有的设置了过期时间的key中选出N个键,然后再从这N个键中选出最久没有使用的一个key进行淘汰。<br>
LFU
概念
LFU是在Redis4.0后出现的,LRU的最近最少使用实际上并不精确,考虑下面的情况,如果在|处删除,那么A距离的时间最久,但实际上A的使用频率要比B频繁,所以合理的淘汰策略应该是淘汰B。LFU就是为应对这种情况而生的。<br>
A~~A~~A~~A~~A~~A~~A~~A~~A~~A~~~|<br>B~~~~~B~~~~~B~~~~~B~~~~~~~~~~~B |
实现
LFU把原来的key对象的内部时钟的24位分成两部分,前16位还代表时钟,后8位代表一个计数器。<br>16位的情况下如果还按照秒为单位就会导致不够用,所以一般这里以时钟为单位。而后8位表示当前key对象的访问频率,8位只能代表255,但是redis并没有采用线性上升的方式,而是通过一个复杂的公式,通过配置两个参数来调整数据的递增速度。<br>
下图从左到右表示key的命中次数,从上到下表示影响因子,在影响因子(越大计数器改变越慢)为100的条件下,经过10M次命中才能把后8位值加满到255.
lfu-log-factor 10<br><br>lfu-decay-time 1
# +--------+------------+------------+------------+------------+------------+<br># | factor | 100 hits | 1000 hits | 100K hits | 1M hits | 10M hits |<br># +--------+------------+------------+------------+------------+------------+<br># | 0 | 104 | 255 | 255 | 255 | 255 |<br># +--------+------------+------------+------------+------------+------------+<br># | 1 | 18 | 49 | 255 | 255 | 255 |<br># +--------+------------+------------+------------+------------+------------+<br># | 10 | 10 | 18 | 142 | 255 | 255 |<br># +--------+------------+------------+------------+------------+------------+<br># | 100 | 8 | 11 | 49 | 143 | 255 |<br># +--------+------------+------------+------------+------------+------------+
上面说的情况是key一直被命中的情况,如果一个key经过几分钟没有被命中,那么后8位的值是需要递减的,具体递减多少根据衰减因子lfu-decay-time来控制
上面递增和衰减都有对应参数配置,那么对于新分配的key呢?如果新分配的key计数器开始为0,那么很有可能在内存不足的时候直接就给淘汰掉了,所以默认情况下新分配的key的后8位计数器的值为5(可配置),防止因为访问频率过低而直接被删除。<br>
低8位我们描述完了,那么高16位的时钟是用来干嘛的呢?目前我的理解是用来衰减低8位的计数器的,就是根据这个时钟与全局时钟进行比较,如果过了一定时间(做差)就会对计数器进行衰减
配置
在配置文件有一行:<br><br># maxmemory-policy volatile-lru
具体实现在evict.c文件中,当redis需要通过释放缓存的key来释放空间时,将会通过ecict.c的freeMemoryIfNeeded()函数来通过设定的算法来清除key以腾出空间。
分布式锁
概念
线程锁:主要用来给方法、代码块加锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。<br><br>进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronized等线程锁实现进程锁。<br><br>分布式锁:当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。
使用场景
线程间并发问题和进程间并发问题都是可以通过分布式锁解决的,但是强烈不建议这样做!因为采用分布式锁解决这些小问题是非常消耗资源的!分布式锁应该用来解决分布式情况下的多进程并发问题才是最合适的。<br><br>有这样一个情境,线程A和线程B都共享某个变量X。<br><br>如果是单机情况下(单JVM),线程之间共享内存,只要使用线程锁就可以解决并发问题。<br><br>如果是分布式情况下(多JVM),线程A和线程B很可能不是在同一JVM中,这样线程锁就无法起到作用了,这时候就要用到分布式锁来解决。
分布式锁的实现(Redis)
概念
分布式锁实现的关键是在分布式的应用服务器外,搭建一个存储服务器,存储锁信息,这时候我们很容易就想到了Redis。首先我们要搭建一个Redis服务器,用Redis服务器来存储锁信息。
关键点
1、锁信息必须是会过期超时的,不能让一个线程长期占有一个锁而导致死锁;<br><br>2、同一时刻只能有一个线程获取到锁。
redis命令
setnx(key, value):“set if not exits”,若该key-value不存在,则成功加入缓存并且返回1,否则返回0。<br><br>get(key):获得key对应的value值,若不存在则返回nil。<br><br>getset(key, value):先获取key对应的value值,若不存在则返回nil,然后将旧的value更新为新的value。<br><br>expire(key, seconds):设置key-value的有效期为seconds秒。
LUA脚本
概念
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。
优势
1.减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;<br><br>2.原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;<br><br>3.复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.
eval的语法格式
EVAL script numkeys key [key ...] arg [arg ...]
其中:<br><br><1> script: 你的lua脚本<br><br><2> numkeys: key的个数<br><br><3> key: redis中各种数据结构的替代符号<br><br><4> arg: 你的自定义参数
示例1
eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 username age hk 20
第一个参数的字符串是script,也就是lua脚本,2表示keys的个数,KEYS[1] 就是username的占位符, KEYS[2]就是<br>age的占位符,ARGV[1]就是jk的占位符,ARGV[2]就是20的占位符,以此类推,所以最后的结果应该就是:{return username age hk 20}
示例2:执行Lua脚本文件的方式
keys.lua脚本文件
return {<br>KEYS,<br>type(KEYS),<br>'-----',<br>ARGV,<br>type(ARGV)<br>}
执行语句和结果
[root@centos1 lua]# redis-cli --eval keys.lua k1 k2 , v1 v2<br>1) 1) "k1"<br> 2) "k2"<br>2) "table"<br>3) "-----"<br>4) 1) "v1"<br> 2) "v2"<br>5) "table"
为Redis集群使用Hash
示例
假设我们查找的是”a.png”,由于有4台服务器(排除从库),因此公式为hash(a.png) % 4 = 2 ,可知定位到了第2号服务器,这样的话就不会遍历所有的服务器,大大提升了性能!
问题焦点
上述的方式虽然提升了性能,我们不再需要对整个Redis服务器进行遍历!但是,使用上述Hash算法进行缓存时,会出现一些缺陷,主要体现在服务器数量变动的时候,所有缓存的位置都要发生改变!<br><br>试想一下,如果4台缓存服务器已经不能满足我们的缓存需求,那么我们应该怎么做呢?很简单,多增加几台缓存服务器不就行了!假设:我们增加了一台缓存服务器,那么缓存服务器的数量就由4台变成了5台。那么原本hash(a.png) % 4 = 2 的公式就变成了 hash(a.png) % 5 =?
也就是说这种情况带来的结果就是当服务器数量变动时,很多缓存的位置都要发生改变!换句话说,当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端数据库请求数据,容易发生缓存雪崩
解决
一致性hash算法的神秘面纱
概念
一致性Hash算法也是使用取模的方法,只是,刚才描述的取模法是对服务器的数量进行取模,而一致性Hash算法是对2^ 32-1取模,什么意思呢简单来说,一致性Hash算法将整个Hash值控件组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0-2^32-1取模(即哈希值是一个32位无符号整型)
过程
整个空间按顺时针方向组织,圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^ 32-1,也就是说0点左侧的第一个点代表2^ 32-1, 0和2^ 32-1在零点中方向重合,我们把这个由2^32个点组成的圆环称为Hash环。<br>下一步将各个服务器使用Hash进行一个哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置
接下来使用如下算法定位数据访问到相应服务器:将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器!
一致性Hash算法的容错性和可扩展性
现假设Node C不幸宕机,可以看到此时对象A、B、D不会受到影响,只有C对象被重定位到Node D。一般的,在一致性Hash算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器,如下图中NodeC与NodeB之间的数据,图中受影响的是ObjectC)之间数据,其它不会受到影响
下面考虑另外一种情况,如果在系统中增加一台服务器Node X
此时对象Object A、B、D不受影响,只有对象C需要重定位到新的Node X !一般的,在一致性Hash算法中,如果增加一台服务器,则受影响的数据仅仅是新服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它数据也不会受到影响。
综上所述,一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。
Hash环的数据倾斜问题
一致性Hash算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜(被缓存的对象大部分集中缓存在某一台服务器上)问题,例如系统中只有两台服务器
此时必然造成大量数据集中到Node A上,而只有极少量会定位到Node B上。为了解决这种数据倾斜问题,一致性Hash算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器IP或主机名的后面增加编号来实现。
可以为每台服务器计算三个虚拟节点,于是可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点
同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到“Node A#1”、“Node A#2”、“Node A#3”三个虚拟节点的数据均定位到Node A上。这样就解决了服务节点少时数据倾斜的问题。在实际应用中,通常将虚拟节点数设置为32甚至更大,因此即使很少的服务节点也能做到相对均匀的数据分布
数据一致性
先操作 Redis,再操作数据库
问题焦点
如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据
延时双删策略
在写库前后都进行redis.del(key)操作,并且设定合理的超时时间。具体步骤是:<br> 1)先删除缓存 <br> 2)再写数据库 <br> 3)休眠500毫秒(根据具体的业务时间来定) <br> 4)再次删除缓存。<br>那么,这个500毫秒怎么确定的,具体该休眠多久呢? 需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。 当然,这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间:则在读数据业务逻辑的耗时的基础上,加上几百ms即可。比如:休眠1秒。<br>
先操作数据库,再操作 Redis
1、不做任何操作,等着Redis里的缓存数据过期后,自动从数据库同步最新的数据,此时最严重的数据不一致性周期就是在缓存过期的一段时间(考虑一下这个过期时间的范围);如果在这个时间段内,又有新的更新请求,也许这次就更新缓存成功了。<br>2、如果数据一致性要求比较高,那么 Redis 操作失败后,我们把这个操作记录下来,异步处理,用 Redis 的数据去和数据库比对,如果不一致,再次更新缓存确保缓存数据与数据库数据一致。<br>
设置缓存的过期时间
最终一致性的解决方案
所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存
强一致性、弱一致性、最终一致性
概念
从客户端角度,多进程并发访问时,更新过的数据在不同进程如何获取的不同策略,决定了不同的一致性。对于关系型数据库,要求更新过的数据能被后续的访问都能看到,这是强一致性。如果能容忍后续的部分或者全部访问不到,则是弱一致性。如果经过一段时间后要求能访问到更新后的数据,则是最终一致性。
最终一致性细分
因果一致性。
如果进程A通知进程B它已更新了一个数据项,那么进程B的后续访问将返回更新后的值,且一次写入将保证取代前一次写入。与进程A无因果关系的进程C的访问遵守一般的最终一致性规则。
读己之所写(read-your-writes)”一致性。
当进程A自己更新一个数据项之后,它总是访问到更新过的值,绝不会看到旧值。这是因果一致性模型的一个特例。
会话(Session)一致性。
这是上一个模型的实用版本,它把访问存储系统的进程放到会话的上下文中。只要会话还存在,系统就保证“读己之所写”一致性。如果由于某些失败情形令会话终止,就要建立新的会话,而且系统的保证不会延续到新的会话。
单调(Monotonic)读一致性。
如果进程已经看到过数据对象的某个值,那么任何后续访问都不会返回在那个值之前的值。
单调写一致性。
系统保证来自同一个进程的写操作顺序执行。要是系统不能保证这种程度的一致性,就非常难以编程了。
Jedis
定义
jedis就是集成了redis的一些命令操作,封装了redis的java客户端。提供了连接池管理。一般不直接使用jedis,而是在其上再封装一层,作为业务的使用
RedisTemplate
关于spring-redis
连接池自动管理,提供了一个高度封装的“RedisTemplate”类
针对jedis客户端中大量api进行了归类封装,将同一类型操作封装为operation接口
提供了对key的“bound”(绑定)便捷化操作API,可以通过bound封装指定的key,然后进行一系列的操作而无须“显式”的再次指定Key,即BoundKeyOperations
将事务操作封装,有容器控制
针对数据的“序列化/反序列化”,提供了多种可选择策略(RedisSerializer)
Collect
Get Started
Collect
Get Started
Collect
Get Started
Collect
Get Started
评论
0 条评论
下一页