redis笔记
redis的主从复制
当存在多台redis服务器的时候,会有一台主服务器master,以及若干台从服务器slaves。一般说,都是master进行写操作,slaves进行读操作。
那么,master与slaves之间是怎么进行数据同步的呢?这就是redis主从复制的由来。
原理
通过数据复制,redis一个master可以挂载多个slave,每个slave下面还可以挂载次一级的slave,形成多级嵌套结构。所有的写操作都在master进行,master执行完成后,会将写指令分发给挂在自己下面的slave,slave会进一步分发写指令给自己次一级的slave。
因此多节点保存数据的方式,在任何一个节点异常都不会导致数据的丢失,同时N个slave节点可以提升redis的读能力N倍。这样一个master写slave读的结构能大大提高redis的读写能力。
redis存在一个复制积压缓冲,当master在分发写指令给slave时,同时将写指令复制到积压缓冲去,这样做是防止slave在短时间断开重连时,只要slave的复制位置点仍在积压缓冲中就可以继续在复制位置点之后继续复制,大大提升了复制效率。因此,redis复制分为全量复制和增量复制。
每次复制的时候会有一个复制id,master与slave之间通过复制id进行匹配,防止slave挂到错误的master。
全量复制
在redis2.8之前,只支持全量复制。全量复制时,master会将内存数据通过bgsave存入rdb中,同时会构建内存快照期间的写指令,存放到复制缓冲中,当rdb构建完成后,将rdb和复制缓冲中的数据全部发送给slave,slave会完全的重新创建一份数据。
这种复制需要的数据量大,因此对master性能损耗大,耗时长在传递rdb时会占用大量宽带,进而对整个系统性能和资源访问都产生较大的影响。
增量复制
增量复制是master只发送上次复制位置之后的写指令,不需要构建rdb,传输的内容少,因此不管是对master还是slave负荷都很小,占用的宽带也小,对系统影响几乎可以忽略。
- redis2.8-4.0
在redis2.8之后,redis引入了psync,增加了一个复制积压缓冲,在将写指令发送给slave时,同时写在复制积压缓存中去。
例子:若slave在短时断开重连后,会上报master runid以及复制偏移量,master会检测runid与自己的runid是否一致并且偏移量是否在master的复制积压缓冲中,则master进行增量同步。但是若在重启时丢失了slave或master在切换之后runid会发生变化,这时仍然会进行全量复制。
- redis4.0
针对以前的psync问题,redis引入了psync2。主从复制抛弃了runid来复制,而使用replid(复制id)作为复制判断依据。同时在构建rdb时会将replid当做辅助信息存入rdb中。重启slave时只需要加载rdb即可得到master的replid。
同时,每个redis处理拥有replid之外,还有个replid2。redis启动时,会创建一个长度为40的随机字符串,作为replid的初始值,在建立主从链接后,会用master的replid替换自己的replid,同时replid2会存储上次master的replid。这样在切换master时,若master与slave的replid不同,但只要slave的replid与master的replid2相同,同时复制偏移量仍然在复制积压缓冲中,就可以增量复制。
redis复制流程
1 | graph TD |
- slave与master建立链接,先发送ping指令,若正常则返回pong,说明master可用.若redis设置了密码,则进行密码校验.
- slave继续通过replconfpsync2进行复制版本校验;之后从库将自己的replid、复制偏移发送给master,正式开始准备数据同步。
- master收到psync指令后判断是否进行增量复制。
- 若slave的replid与master的replid或replid2相等,且复制偏移量仍在复制积压偏移中,则进行增量同步。master会发送continue响应,并返回master的replid。slave会将master的replid替换为自己的replid,并将之前的replid设置为replid2。之后master继续发送指令给slave完成数据同步。
- 对于全量复制,master会返回fullresync响应,附带replid以及复制偏移,之后master构建rdb,并将rdb与复制缓冲发送给slave。
- 全量复制的slave首先会清理嵌套复制,并关闭其所有子slave链接,清理自己的复制积压缓冲。之后slave会构建临时rdb文件,并从master连接中读取rdb的实际数据并写入自己rdb中,在接受完毕数据之后则将临时rdb文件改名为rdb的真正名字。接下来slave会清空老数据(即删除本地DB中的所有数据),并暂时停止接收数据,全力加载rdb中的数据,将其写到内存中去。当rdb加载完毕之后,slave会重新利用连接的socket与master建立client,并在此注册读事件,就可以开始接收master的写指令了。此时,slave 还会将 master 的 replid 和复制偏移设为自己的复制 id 和复制偏移 offset,并将自己的 replid2 清空,因为,slave 的所有嵌套 子slave 接下来也需要进行全量复制。最后,slave 就会打开 aof 文件,在接受 master 的写指令后,执行完毕并写入到自己的 aof 中。
读写分离以及存在的问题
在主从复制上实现的读写分离,可以实现redis的读负载均衡:由主节点提供写服务,由一个或多个从节点提供读服务(多个从节点可以提高数据冗余程度以及最大化读负载能力),在读负载较大的场景下可以大大提高redis的并发量。但在使用redis读写分离时也需要注意以下问题:
延迟与不一致
由于主从复制命令是异步传播的,那么一定会出现延迟和数据不一致情况。
若应用对延迟、不一致接受程度较低,可优化的方法:
- 优化主从节点之间的网络环境(如同机房部署);
- 监控主从节点之间的延迟(通过offset),若从节点延迟过大,则不再通过该节点读取数据;
- 使用集群,同时扩展读负载和写负载等。
在命令传播阶段以外可能数据不一致情况更加严重,如连接在数据同步阶段、从节点失去与主节点的连接时等等。从节点的slave-server-stale-data
便与此相关(其控制从节点的表现):若为yes
(默认),则从节点仍能响应客户端的命令;若为no
,则只响应客户端的info、slaveof命令。若对数据一致性要求较高,则设置为no
。
数据过期
在单机版的redis中存在两种删除策略:
- 惰性删除:服务器不会主动删除数据,只有当客户端查询某个数据时,服务器判断其是否过期,如果过期则删除。
- 定期删除:服务器会定期删除过期数据,但是考虑到内存和CPU的折中(频繁的释放内存会对内存和CPU不友好),该删除的频率和执行时间都受到了限制。
在主从复制场景下,为了数据一致性,从节点不会主动删除数据,都是主节点控制从节点中过期数据的删除。由于主节点的惰性删除和定期删除都不能保证及时的对从节点过期删除,因此客户端读取数据时很容易读取到过期的数据。
在redis3.2中,从节点读取数据时增加了对数据是否过期的判断:若该数据已过期则不返回给客户端。
故障切换
在没有使用哨兵的读写分离情况下,读写连接不同的redis节点。当主节点或从节点出现问题而发生故障时,需要及时修改应用程序读写redis的连接,连接的切换可手动执行,也可写监控程序进行切换。但前者响应慢、容易出错,后者实现复杂、成本并不低。
因此建议使用哨兵,使主从节点切换尽量自动化,并减少对应用程序的侵入。
复制超时
超时判断意义
在主从复制的连接时和连接后,主从节点都有判断连接是否超时,其意义在于:
- 主节点判断超时:若超时,主节点会释放相应的各种资源;主节点也能判断当前有效从节点个数,有助于保证数据安全。
- 从节点判断超时:若超时,从节点可以及时的与主节点重新建立连接,避免与主节点数据长期不一致。
判断机制
主从复制超时判断的核心在于repl-timeout
参数,该参数规定了超时时间的阈值(默认60s),对于主节点和从节点同时有效,其超时触发条件:
- 主节点:每秒一次调用复制定时函数
replicationCron()
,在其中判断当前时间距离上次收到各个节点REPLCONF ACK的时间,是否超过了repl-timeout值,若超过了则释放相关从节点连接。 - 从节点:从节点判断同样是在复制定时函数中判断,其如下:
- 建立连接阶段:若距离上次收到主节点信息时间已经超过repl-timeout,则释放连接;
- 数据同步阶段:收到主节点的RDB文件超时,则停止数据同步并释放连接;
- 命令传播阶段:距离上次收到主节点的PING命令或数据时间超过repl-timeout则释放连接。
注意:在进行慢查询时可能会导致的阻塞:在从节点或主节点进行了一些慢查询,导致服务阻塞,阻塞期间无法响应复制连接中对方节点的请求,可能会导致复制超时。
复制缓冲区溢出
除了复制超时会导致复制中断外,复制缓冲区溢出同样会导致复制中断。
在全量复制阶段,主节点会将执行的写命令放到复制缓冲区中,该缓冲区存放的数据包括了以下几个时间段内主节点执行的写命令:bgsave生成RDB文件、RDB文件由主节点发往从节点、从节点清空老数据并载入RDB文件中的数据。当主节点数据量较大,或者主从节点之间网络延迟较大时,可能导致该缓冲区的大小超过了限制,此时主节点会断开与从节点之间的连接;这种情况可能引起全量复制->复制缓冲区溢出导致连接中断->重连->全量复制->复制缓冲区溢出导致连接中断……的循环。
复制缓冲区的大小由client-output-buffer-limit slave {hard limit} {soft limit} {soft seconds}配置,默认值为client-output-buffer-limit slave 256MB 64MB 60,其含义是:如果buffer大于256MB,或者连续60s大于64MB,则主节点会断开与该从节点的连接。该参数是可以通过config set命令动态配置的(即不重启Redis也可以生效)。
注意:复制缓冲区是客户端输出缓冲区的一种,主节点会为每个从节点分配一个复制缓冲区;而复制积压缓冲区主节点只有一个,无论它有多少个从节点。
redis的五种对象类型
redis有五种对象类型,每种结构至少有两种编码方式。这样做的好处是:一方面接口与实现分离,当需要增加或改变内部编码时用户不会收到影响;另一方面可以根据不同场景切换内部编码,提高效率。
注意:redis内部编码转换只有在写入时完成,且转换过程不可逆,只能从小内存向大内存编码转换。
字符串
概括
字符串是redis最基础的编码,因为所有的键都是字符串类型,且字符串之外的其他集中复杂类型的元素也是字符串。
字符串长度不能超过512M。
内部编码
redis字符串内部编码根据大小不同有三种编码方式:
int
:8字节的长整型。字符串值是整型时,这个值用long整型表示。embstr
:<=39字节
的字符串。embstr与raw都是用SDS与redisObject保存数据。区别在于embstr只分配一次内存空间(因此RedisObject和sds是连续的),而raw需要分配两次内存空间(分别为RedisObject与sds分配)。好处:embstr分配和销毁时都只创建、销毁一次空间,并且对象数据连在一起方便查找。坏处:当字符串增加长度需要重新分配内存时,整个RedisObject和sds都需要重新分配空间。因此redis中的embstr实现为只读。raw
:>39字节的字符串。
可以通过以下命令查看编码类型:
1 | redis> object encoding key |
embstr与raw为什么区分是39字节呢?
RedisObject
的长度是16字节,sds
长度是9+字符串长度
,jemalloc正好可以分配64字节内存单元,所以:16 + 9 + 39 = 64 字节。
编码转换
当int数据不再是整数,或大小超过了long的范围时,自动转化为raw。
而对于embstr
,由于其实现是只读的,因此在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了39个字节。
所以当我们存储的字符串需要进行转换时可以直接指定字符串为raw减少一次字符串转换。
列表
概括
列表(list)用来存储多个有序字符串,每个字符串称为元素。一个list可以存储2^23-1
个元素。
redis中list支持两端插入和弹出,可以获得指定位置/范围的元素,可以充当数组、队列、栈等。
内部编码
list的内部编码可以是*压缩列表(ziplist)、双端链表(linkedlist)*。
双端链表:由一个list
和多个listNode
组成。其保存了表头表尾指针,并且每个节点都有指向前一指针和后一指针,链表还保存了列表的长度,还有标识保存的值的类型字段。而链表中每个节点指向的是type为字符串的RedisObject。
压缩列表:压缩列表是为了节约空间而开发的,由一系列特殊编码的连续内存块组成的顺序数据类型结构。其虽然空间节约,但是在操作增删修改时复杂度过高,所以只有在节点数量较少的情况下使用。
编码转换
只有同时满足下面两个
条件时,才会使用压缩
列表:列表中元素数量小于512
个;列表中所有字符串对象都不足64字节
。如果有一个条件不满足,则使用双端列表;且编码只可能由压缩列表转化为双端链表,反方向则不可能。
哈希
概括
哈希不光是redis对外提供的数据类型的一种,也是redis作为Key-Value数据库使用的数据结构。
在这里用内层哈希代表redis对外提供的一种数据类型,外层哈希代表redis作为K-V数据库所使用的数据结构。
内部编码
*内层的哈希(redis对外提供的五种数据结构之一)使用的内部编码为压缩列表(ziplist)和哈希表(hashtable)。外层的哈希(redis使用的key-value数据库使用的数据结构)只使用了哈希表(hashtable)*。
hashtable
:一个hashtable由一个dick
结构、两个dicktht
结构、一个dickEntry
指针数组(bucket
)和多个dickEntry
结构组成。
从底层向上依次介绍数据结构(x64):
- dictEntry
1 | typedef struct dictEntry{ |
- bucket
bucket
是一个数组
,原始是一个指向dickEntry
的指针,其大小为len
,len满足dictEntry<len<=2^n
条件取n最小值另len=2^n。例如:有1000个dickEntry,则大小len为1024。
- dictht
1 | typedef struct dictht{ |
- dict
1 | typedef struct dict{ |
ht
是一个包含两个项的数组
,每项都指向一个dictht
结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。
编码转换
在hash中只有满足:哈希元素数量小于512个 &&所有键值对的键和值都小于64byte时才可以用ziplist。否则只能使用hashtable编码。并且编码只能由ziplist转换为hashtable。
集合
概述
集合(set)与列表类似,都是用来保存多个字符串,但是其内部的元素是无序的,并且其元素不存在重复现象。
一个集合可以拥有2^32-1
个元素,并且redis还支持求交集、并集、差集。
内部编码
set内部编码为整数集合或哈希表。
set在使用hashtable
时值会被全部置为NULL
。
整数集合(intset)结构:
1 | typedef struct intset{ |
整数集合适用于集合所有元素都是整数且集合元素数量较小的时候,与哈希表相比,整数集合的优势在于集中存储,节省空间。
编码转换
使用intset条件:元素个数小于512 && 所有元素类型都是整数。
且编码只能由intset
转换为hashtable
。
有序集合
概括
与集合唯一不同的就是zset中的元素是有序的,其值也不能重复。zset其为每个元素配一个score
作为排序依据。
内部编码
zset
内部编码使用ziplist
或跳跃表(skiplist
)。
skiplist是一种有序的数据结构,通过在每个节点维持多个指向其他节点的指针,从而达到快速访问的目的。除了跳跃表,实现有序数据结构的另一种典型实现是平衡树;大多数情况下,跳跃表的效率可以和平衡树媲美,且跳跃表实现比平衡树简单很多多多多,因此redis中选用跳跃表代替平衡树。跳跃表支持平均O(logN)、最坏O(N)的复杂点进行节点查找,并支持顺序操作。Redis的跳跃表实现由zskiplist
和zskiplistNode
两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点。
编码转换
使用ziplist条件:zset中元素小于128个 && zset中的元素长度都小于64byte。
编码只能由ziplist
转换为skiplist
。
redis内存应用
估算redis内存使用量
需要了解redis内部编码以及常用的数据结构。
Redis是Key-Value
数据库,因此对每个键值对都会有一个dictEntry
,里面存储了指向Key和Value的指针;next指向下一个dictEntry,与本Key-Value无关。其中key指向sds存储键,value指向redisObject存储值。
redisObject
Redis对象有5种类型;无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。
redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持。
1 | typedef struct redisObject { |
在64位系统中,一个redisObject对象大小为16byte:4bit + 4bit + 24bit + 4byte + 8byte = 16byte
。
SDS
Redis没有直接使用C字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,而是使用了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。
1 | struct sdshdr { |
buf数组的长度=free+len+1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度+len所占长度+ buf数组的长度=4+4+free+len+1=free+len+9。
举例说明
以最简单的字符串类型进行说明:
假设有90000个键值对,key大小为7byte,每个value大小也为7byte,key与value都不是整数,那么这90000个k-v所占用的内存空间大小是多少?
可以确定其编码方式为embstr,小于39byte。
每个dictEntry占据的空间:
- 一个dictEntry占用24字节,jemalloc会分配32byte;
- 一个key7字节,SDS(key)需要7+9=16byte,jemalloc会分配16byte;
- 一个RedisObject,16字节,jemalloc会分配16byte;
- 一个value7字节,SDS(value)需要7+9=16byte,jemalloc会分配16byte
综上:一个dictEntry需要32 + 16 + 16 +16 = 80byte
。
bucket大小:小于90000的2^n最小值,为131072,每个元素为8byte(指针大小为8byte),一共需要:
90000*80 + 131072*8 = 8248576
。
作为对比将key和value的长度由7字节增加到8字节,则对应的SDS变为17个字节,jemalloc会分配32个字节,因此每个dictEntry占用的字节数也由80字节变为112字节。此时估算这90000个键值对占据内存大小为:90000*112 + 131072*8 = 11128576
。
优化内存占用
(1)利用jemalloc特性进行优化
上一小节所讲述的90000个键值便是一个例子。由于jemalloc分配内存时数值是不连续的,因此key/value字符串变化一个字节,可能会引起占用内存很大的变动;在设计时可以利用这一点。
例如,如果key的长度如果是8个字节,则SDS为17字节,jemalloc分配32字节;此时将key长度缩减为7个字节,则SDS为16字节,jemalloc分配16字节;则每个key所占用的空间都可以缩小一半。
(2)使用整型/长整型
如果是整型/长整型,Redis会使用int类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型。
(3)共享对象
利用共享对象,可以减少对象的创建(同时减少了redisObject的创建),节省内存空间。目前redis中的共享对象只包括10000个整数(0-9999);可以通过调整REDIS_SHARED_INTEGERS参数提高共享对象的个数;例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享。
考虑这样一种场景:论坛网站在redis中存储了每个帖子的浏览数,而这些浏览数绝大多数分布在0-20000之间,这时候通过适当增大REDIS_SHARED_INTEGERS参数,便可以利用共享对象节省内存空间。
(4)避免过度设计
然而需要注意的是,不论是哪种优化场景,都要考虑内存空间与设计复杂度的权衡;而设计复杂度会影响到代码的复杂度、可维护性。
如果数据量较小,那么为了节省内存而使得代码的开发、维护变得更加困难并不划算;还是以前面讲到的90000个键值对为例,实际上节省的内存空间只有几MB。但是如果数据量有几千万甚至上亿,考虑内存的优化就比较必要了。
关注内存碎片率
内存碎片率是一个重要的参数,对redis 内存的优化有重要意义。
如果内存碎片率过高(jemalloc在1.03左右比较正常),说明内存碎片多,内存浪费严重;这时便可以**考虑重启redis服务(redis安全重启)**,在内存中对数据进行重排,减少内存碎片。
如果内存碎片率小于1,说明redis内存不足,部分数据使用了虚拟内存(即swap);由于虚拟内存的存取速度比物理内存差很多(2-3个数量级),此时redis的访问速度可能会变得很慢。因此必须设法增大物理内存(可以增加服务器节点数量,或提高单机内存),或减少redis中的数据。
要减少redis中的数据,除了选用合适的数据类型、利用共享对象等,还有一点是要设置合理的数据回收策略(maxmemory-policy),当内存达到一定量后,根据不同的优先级对内存进行回收。
redis高可用
在Redis中,实现高可用的技术主要包括持久化、复制、哨兵和集群,下面分别说明它们的作用,以及解决了什么样的问题。
持久化:持久化是最简单的高可用方法(有时甚至不被归为高可用的手段),主要作用是数据备份,即将数据存储在硬盘,保证数据不会因进程退出而丢失。
复制:复制是高可用Redis的基础,哨兵和集群都是在复制基础上实现高可用的。复制主要实现了数据的多机备份,以及对于读操作的负载均衡和简单的故障恢复。缺陷:故障恢复无法自动化;写操作无法负载均衡;存储能力受到单机的限制。
哨兵:在复制的基础上,哨兵实现了自动化的故障恢复。缺陷:写操作无法负载均衡;存储能力受到单机的限制。
集群:通过集群,Redis解决了写操作无法负载均衡,以及存储能力受到单机限制的问题,实现了较为完善的高可用方案。
redis 持久化
概述
Redis持久化分为RDB持久化和AOF持久化:前者将当前数据保存到硬盘,后者则是将每次执行的写命令保存到硬盘(类似于MySQL的binlog);由于AOF持久化的实时性更好,即当进程意外退出时丢失的数据更少,因此AOF是目前主流的持久化方式,不过RDB持久化仍然有其用武之地。
RDB持久化
RDB持久化是将当前进程中的数据生成快照保存到硬盘(因此也称作快照持久化),保存的文件后缀是rdb;当Redis重新启动时,可以读取快照文件恢复数据。
触发条件
- 手动触发
save命令和bgsave命令都可以生成RDB文件。
save命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在Redis服务器阻塞期间,服务器不能处理任何命令请求。
bgsave命令会创建一个子进程,由子进程来负责创建RDB文件,父进程(即Redis主进程)则继续处理请求。
bgsave命令执行过程中,只有fork子进程时会阻塞服务器,而对于save命令,整个过程都会阻塞服务器,因此save已基本被废弃,线上环境要杜绝save的使用;后文中也将只介绍bgsave命令。此外,在自动触发RDB持久化时,Redis也会选择bgsave而不是save来进行持久化;下面介绍自动触发RDB持久化的条件。
- 自动触发
save m n
自动触发最常见的情况是在配置文件中通过save m n,指定当m秒内发生n次变化时,会触发bgsave。
save 900 1的含义是:当时间到900秒时,如果redis数据发生了至少1次变化,则执行bgsave;save 300 10和save 60 10000同理。当三个save条件满足任意一个时,都会引起bgsave的调用。
Redis的save m n,是通过serverCron函数、dirty计数器、和lastsave时间戳来实现的。
serverCron是Redis服务器的周期性操作函数,默认每隔100ms执行一次;该函数对服务器的状态进行维护,其中一项工作就是检查 save m n 配置的条件是否满足,如果满足就执行bgsave。
dirty计数器是Redis服务器维持的一个状态,记录了上一次执行bgsave/save命令后,服务器状态进行了多少次修改(包括增删改);而当save/bgsave执行完成后,会将dirty重新置为0。
save m n的原理如下:每隔100ms,执行serverCron函数;在serverCron函数中,遍历save m n配置的保存条件,只要有一个条件满足,就进行bgsave。对于每一个save m n条件,只有下面两条同时满足时才算满足:
(1)当前时间 - lastsave > m
(2)dirty >= n
- 其他触发时机
在主从复制场景下,如果从节点执行全量复制操作,则主节点会执行bgsave命令,并将rdb文件发送给从节点;
执行shutdown命令时,自动执行rdb持久化。
RDB常用设置
save m n:bgsave自动触发的条件;如果没有save m n配置,相当于自动的RDB持久化关闭,不过此时仍可以通过其他方式触发
stop-writes-on-bgsave-error yes:当bgsave出现错误时,Redis是否停止执行写命令;设置为yes,则当硬盘出现问题时,可以及时发现,避免数据的大量丢失;设置为no,则Redis无视bgsave的错误继续执行写命令,当对Redis服务器的系统(尤其是硬盘)使用了监控时,该选项考虑设置为no
rdbcompression yes:是否开启RDB文件压缩
rdbchecksum yes:是否开启RDB文件的校验,在写入文件和读取文件时都起作用;关闭checksum在写入文件和启动文件时大约能带来10%的性能提升,但是数据损坏时无法发现
dbfilename dump.rdb:RDB文件名
dir ./:RDB文件和AOF文件所在目录
AOF持久化
开启AOF
Redis服务器默认开启RDB,关闭AOF;要开启AOF,需要在配置文件中配置:
appendonly yes
执行流程
由于需要记录Redis的每条写命令,因此AOF不需要触发,下面介绍AOF的执行流程。
AOF的执行流程包括:
- 命令追加(append):将Redis的写命令追加到缓冲区aof_buf;
- 文件写入(write)和文件同步(sync):根据不同的同步策略将aof_buf中的内容同步到硬盘;
- 文件重写(rewrite):定期重写AOF文件,达到压缩的目的
命令追加
Redis先将写命令追加到缓冲区,而不是直接写入文件,主要是为了避免每次有写命令都直接写入硬盘,导致硬盘IO成为Redis负载的瓶颈。
文件:写入write与同步sync函数
为了提高文件写入效率,在现代操作系统中,当用户调用write函数将数据写入文件时,操作系统通常会将数据暂存到一个内存缓冲区里,当缓冲区被填满或超过了指定时限后,才真正将缓冲区的数据写入到硬盘里。这样的操作虽然提高了效率,但也带来了安全问题:如果计算机停机,内存缓冲区中的数据会丢失;因此系统
同时提供了fsync
、fdatasync
等同步函数,可以强制操作系统立刻将缓冲区中的数据写入到硬盘里,从而确保数据的安全性。
AOF缓存区的同步文件策略由参数appendfsync
控制,各个值的含义如下:
always
:命令写入aof_buf后立即调用系统fsync操作同步到AOF文件,fsync完成后线程返回。这种情况下,每次有写命令都要同步到AOF文件,硬盘IO成为性能瓶颈,Redis只能支持大约几百TPS写入,严重降低了Redis的性能;即便是使用固态硬盘(SSD),每秒大约也只能处理几万个命令,而且会大大降低SSD的寿命。no
:命令写入aof_buf后调用系统write操作,不对AOF文件做fsync同步;同步由操作系统负责,通常同步周期为30秒。这种情况下,文件同步的时间不可控,且缓冲区中堆积的数据会很多,数据安全性无法保证。everysec
:命令写入aof_buf后调用系统write操作,write完成后线程返回;fsync同步文件操作由专门的线程每秒调用一次。everysec是前述两种策略的折中,是性能和数据安全性的平衡,因此是Redis的默认配置,也是我们推荐的配置。
文件重写
文件重写是指定期重写AOF文件,减小AOF文件的体积。需要注意的是,AOF重写是把Redis进程内的数据转化为写命令,同步到新的AOF文件;不会对旧的AOF文件进行任何读取、写入操作!
文件重写之所以能够压缩AOF文件,原因在于:
过期的数据不再写入文件
无效的命令不再写入文件:如有些数据被重复设值(set mykey v1, set mykey v2)、有些数据被删除了(sadd myset v1, del myset)等等
多条命令可以合并为一个:如sadd myset v1, sadd myset v2, sadd myset v3可以合并为sadd myset v1 v2 v3。不过为了防止单条命令过大造成客户端缓冲区溢出,对于list、set、hash、zset类型的key,并不一定只使用一条命令;而是以某个常量为界将命令拆分为多条。这个常量在redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD中定义,不可更改,3.0版本中值是64。
文件重写的触发,分为手动触发和自动触发:
手动触发:直接调用bgrewriteaof命令,该命令的执行与bgsave有些类似:都是fork子进程进行具体的工作,且都只有在fork时阻塞。
自动触发:根据auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数,以及aof_current_size和aof_base_size状态确定触发时机。
AOF常用配置总结
appendonly no:是否开启AOF
appendfilename “appendonly.aof”:AOF文件名
dir ./:RDB文件和AOF文件所在目录
appendfsync everysec:fsync持久化策略
no-appendfsync-on-rewrite no:AOF重写期间是否禁止fsync;如果开启该选项,可以减轻文件重写时
CPU和硬盘的负载(尤其是硬盘),但是可能会丢失AOF重写期间的数据;需要在负载和安全性之间进行平衡
auto-aof-rewrite-percentage 100:文件重写触发条件之一
auto-aof-rewrite-min-size 64mb:文件重写触发提交之一
aof-load-truncated yes:如果AOF文件结尾损坏,Redis启动时是否仍载入AOF文件
RDB与AOF优缺点
RDB持久化
优点:RDB文件紧凑,体积小,网络传输快,适合全量复制;恢复速度比AOF快很多。当然,与AOF相比,RDB最重要的优点之一是对性能的影响相对较小。
缺点:RDB文件的致命缺点在于其数据快照的持久化方式决定了必然做不到实时持久化,而在数据越来越重要的今天,数据的大量丢失很多时候是无法接受的,因此AOF持久化成为主流。此外,RDB文件需要满足特定格式,兼容性差(如老版本的Redis不兼容新版本的RDB文件)。
AOF持久化
与RDB持久化相对应,AOF的优点在于支持秒级持久化、兼容性好,缺点是文件大、恢复速度慢、对性能影响大。
持久化策略选择
在介绍持久化策略之前,首先要明白无论是RDB还是AOF,持久化的开启都是要付出性能方面代价的:对于RDB持久化,一方面是bgsave在进行fork操作时Redis主进程会阻塞,另一方面,子进程向硬盘写数据也会带来IO压力;对于AOF持久化,向硬盘写数据的频率大大提高(everysec策略下为秒级),IO压力更大,甚至可能造成AOF追加阻塞问题(后面会详细介绍这种阻塞),此外,AOF文件的重写与RDB的bgsave类似,会有fork时的阻塞和子进程的IO压力问题。相对来说,由于AOF向硬盘中写数据的频率更高,因此对Redis主进程性能的影响会更大。
下面的讨论也只是作为参考,实际方案可能更复杂更具多样性。
如果Redis中的数据完全丢弃也没有关系(如Redis完全用作DB层数据的cache),那么无论是单机,还是主从架构,都可以不进行任何持久化。
在单机环境下(对于个人开发者,这种情况可能比较常见),如果可以接受十几分钟或更多的数据丢失,选择RDB对Redis的性能更加有利;如果只能接受秒级别的数据丢失,应该选择AOF。
但在多数情况下,我们都会配置主从环境,slave的存在既可以实现数据的热备,也可以进行读写分离分担Redis读请求,以及在master宕掉后继续提供服务。
在这种情况下,一种可行的做法是:
master:完全关闭持久化(包括RDB和AOF),这样可以让master的性能达到最好
slave:关闭RDB,开启AOF(如果对数据安全要求不高,开启RDB关闭AOF也可以),并定时对持久化文件进行备份(如备份到其他文件夹,并标记好备份的时间);然后关闭AOF的自动重写,然后添加定时任务,在每天Redis闲时(如凌晨12点)调用bgrewriteaof。
这里需要解释一下,为什么开启了主从复制,可以实现数据的热备份,还需要设置持久化呢?因为在一些特殊情况下,主从复制仍然不足以保证数据的安全,例如:
master和slave进程同时停止:考虑这样一种场景,如果master和slave在同一栋大楼或同一个机房,则一次停电事故就可能导致master和slave机器同时关机,Redis进程停止;如果没有持久化,则面临的是数据的完全丢失。
master误重启:考虑这样一种场景,master服务因为故障宕掉了,如果系统中有自动拉起机制(即检测到服务停止后重启该服务)将master自动重启,由于没有持久化文件,那么master重启后数据是空的,slave同步数据也变成了空的;如果master和slave都没有持久化,同样会面临数据的完全丢失。需要注意的是,即便是使用了哨兵(关于哨兵后面会有文章介绍)进行自动的主从切换,也有可能在哨兵轮询到master之前,便被自动拉起机制重启了。因此,应尽量避免“自动拉起机制”和“不做持久化”同时出现。
- 异地灾备:上述讨论的几种持久化策略,针对的都是一般的系统故障,如进程异常退出、宕机、断电等,这些故障不会损坏硬盘。但是对于一些可能导致硬盘损坏的灾难情况,如火灾地震,就需要进行异地灾备。例如对于单机的情形,可以定时将RDB文件或重写后的AOF文件,通过scp拷贝到远程机器,如阿里云、AWS等;对于主从的情形,可以定时在master上执行bgsave,然后将RDB文件拷贝到远程机器,或者在slave上执行bgrewriteaof重写AOF文件后,将AOF文件拷贝到远程机器上。一般来说,由于RDB文件文件小、恢复快,因此灾难恢复常用RDB文件;异地备份的频率根据数据安全性的需要及其他条件来确定,但最好不要低于一天一次。
fork阻塞:CPU阻塞
在Redis的实践中,众多因素限制了Redis单机的内存不能过大,例如:
- 当面对请求的暴增,需要从库扩容时,Redis内存过大会导致扩容时间太长;
- 当主机宕机时,切换主机后需要挂载从库,Redis内存过大导致挂载速度过慢;
- 以及持久化过程中的fork操作,下面详细说明。
父进程通过fork操作可以创建子进程;子进程创建后,父子进程共享代码段,不共享进程的数据空间,但是子进程会获得父进程的数据空间的副本。在操作系统fork的实际实现中,基本都采用了写时复制技术,即在父/子进程试图修改数据空间之前,父子进程实际上共享数据空间;但是当父/子进程的任何一个试图修改数据空间时,操作系统会为修改的那一部分(内存的一页)制作一个副本。
虽然fork时,子进程不会复制父进程的数据空间,但是会复制内存页表(页表相当于内存的索引、目录);父进程的数据空间越大,内存页表越大,fork时复制耗时也会越多。
在Redis中,无论是RDB
持久化的bgsave
,还是AOF
重写的bgrewriteaof
,都需要fork出子进程来进行操作。如果Redis内存过大,会导致fork操作时复制内存页表耗时过多;而Redis主进程在进行fork时,是完全阻塞的,也就意味着无法响应客户端的请求,会造成请求延迟过大。
为了减轻fork操作带来的阻塞问题,除了控制Redis单机内存的大小以外,还可以适度放宽AOF重写的触发条件、选用物理机或高效支持fork操作的虚拟化技术等,例如使用Vmware或KVM虚拟机,不要使用Xen虚拟机。
AOF追加阻塞:硬盘阻塞
在AOF中,如果AOF缓冲区的文件同步策略为everysec,则:在主线程中,命令写入aof_buf后调用系统write操作,write完成后主线程返回;fsync同步文件操作由专门的文件同步线程每秒调用一次。
如果硬盘负载过高,那么fsync操作可能会超过1s;如果Redis主线程持续高速向aof_buf写入命令,硬盘的负载可能会越来越大,IO资源消耗更快;如果此时Redis进程异常退出,丢失的数据也会越来越多,可能远超过1s。
为此,Redis的处理策略是这样的:主线程每次进行AOF会对比上次fsync成功的时间;如果距上次不到2s,主线程直接返回;如果超过2s,则主线程阻塞直到fsync同步完成。因此,如果系统硬盘负载过大导致fsync速度太慢,会导致Redis主线程的阻塞;此外,使用everysec配置,AOF最多可能丢失2s的数据,而不是1s。
集群作用
集群,即Redis Cluster。
集群由多个节点(Node)组成,Redis的数据分布在这些节点中。集群中的节点分为主节点和从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主节点数据和状态信息的复制。
作用可归纳为两点:数据分区和高可用
数据
数据分区(或称数据分片)是集群最核心的功能。
集群将数据分散到多个节点:一方面突破了Redis单机内存大小的限制,存储容量大大增加;另一方面每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。
高可用
集群支持主从复制和主节点的自动故障转移(与哨兵类似):当任一节点发生故障时,集群仍然可以对外提供服务。
集群的搭建
集群的搭建可以分为四步:
- (1)启动节点:将节点以集群模式启动,此时节点是独立的,并没有建立联系;
- (2)节点握手:让独立的节点连成一个网络;
- (3)分配槽:将16384个槽分配给主节点;
- (4)指定主从关系:为从节点指定主节点。
实际上,前三步完成后集群便可对外提供服务;但指定从节点后,集群才能够提供真正高可用的服务。
启动节点
集群节点的启动仍然是使用redis-server
命令,但需要使用集群模式启动。下面是port:7000节点的配置文件(只列出了节点正常工作关键配置,其他配置(如开启AOF)可以参照单机节点进行)
1 | redis-7000.conf |
当Redis节点以集群模式启动时,会首先寻找是否有集群配置文件,如果有则使用文件中的配置启动,如果没有,则初始化配置并将配置保存到文件中。集群配置文件由Redis节点维护,不需要人工修改。
编辑好配置文件后,使用redis-server命令启动该节点:
1 | redis-server redis-7000.conf |
需要特别注意,在启动节点阶段,节点是没有主从关系的,因此从节点不需要加slaveof配置。
节点握手
节点启动以后是相互独立的,并不知道其他节点存在;需要进行节点握手,将独立的节点组成一个网络。
节点握手使用cluster meet {ip} {port}命令实现。
分配槽
在Redis集群中,借助槽实现数据分区。
集群有16384个槽,槽是数据管理和迁移的基本单位。当数据库中的16384个槽都分配了节点时,集群处于上线状态(ok);如果有任意一个槽没有分配节点,则集群处于下线状态(fail)。
分配槽使用cluster addslots命令,执行下面的命令将槽(编号0-16383)全部分配完毕:
1 | redis-cli -p 7000 cluster addslots {0..5461} |
指定主从关系
集群中指定主从关系不再使用slaveof命令,而是使用cluster replicate命令;参数使用节点id。
通过cluster nodes获得几个主节点的节点id后,执行下面的命令为每个从节点指定主节点:
1 | redis-cli -p 8000 cluster replicate be816eba968bc16c884b963d768c945e86ac51ae |
集群设计方案
设计集群方案时,至少要考虑以下因素:
(1)高可用要求:根据故障转移的原理,至少需要3个主节点才能完成故障转移,且3个主节点不应在同一台物理机上;每个主节点至少需要1个从节点,且主从节点不应在一台物理机上;因此高可用集群至少包含6个节点。
(2)数据量和访问量:估算应用需要的数据量和总访问量(考虑业务发展,留有冗余),结合每个主节点的容量和能承受的访问量(可以通过benchmark得到较准确估计),计算需要的主节点数量。
(3)节点数量限制:Redis官方给出的节点数量限制为1000,主要是考虑节点间通信带来的消耗。在实际应用中应尽量避免大集群;如果节点数量不足以满足应用对Redis数据量和访问量的要求,可以考虑:(1)业务分割,大集群分为多个小集群;(2)减少不必要的数据;(3)调整数据过期策略等。
(4)适度冗余:Redis可以在不影响集群服务的情况下增加节点,因此节点数量适当冗余即可,不用太大。
集群的基本原理
集群最核心的功能是数据分区,因此首先介绍数据的分区规则;然后介绍集群实现的细节:通信机制和数据结构;最后以cluster meet(节点握手)、cluster addslots(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。
集群数据分区方案
数据分区有顺序分区、哈希分区等,其中哈希分区由于其天然的随机性,使用广泛;集群的分区方案便是哈希分区的一种。
哈希分区的基本思路是:对数据的特征值(如key)进行哈希,然后根据哈希值决定数据落在哪个节点。常见的哈希分区包括:哈希取余分区、一致性哈希分区、带虚拟节点的一致性哈希分区等。
衡量数据分区方法好坏的标准有很多,其中比较重要的两个因素是(1)数据分布是否均匀(2)增加或删减节点对数据分布的影响。由于哈希的随机性,哈希分区基本可以保证数据分布均匀;因此在比较哈希分区方案时,重点要看增减节点对数据分布的影响。
哈希取余分区
哈希取余分区思路非常简单:计算key的hash值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要重新计算映射关系,引发大规模数据迁移。
一致性哈希分区
一致性哈希算法将整个哈希值空间组织成一个虚拟的圆环,范围为0-2^32-1;对于每个数据,根据key计算hash值,确定数据在环上的位置,然后从此位置沿环顺时针行走,找到的第一台服务器就是其应该映射到的服务器。
一致性哈希分区的主要问题在于,当节点数量较少时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。
带虚拟节点的一致性哈希
该方案在一致性哈希分区的基础上,引入了虚拟节点的概念。Redis集群使用的便是该方案,其中的虚拟节点称为槽(slot)。槽是介于数据和实际节点之间的虚拟概念;每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。引入槽以后,数据的映射关系由数据hash->实际节点,变成了数据hash->槽->实际节点。
在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽解耦了数据和实际节点之间的关系,增加或删除节点对系统的影响很小。
在Redis集群中,槽的数量为16384。
下面这张图很好的总结了Redis集群将数据映射到实际节点的过程:
(1)Redis对数据的特征值(一般是key)计算哈希值,使用的算法是CRC16。
(2)根据哈希值,计算数据属于哪个槽。
(3)根据槽与节点的映射关系,计算数据属于哪个节点。
节点间通信机制
两个端口
在哨兵系统中,节点分为数据节点和哨兵节点:前者存储数据,后者实现额外的控制功能。在集群中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个TCP端口:
- 普通端口:即我们在前面指定的端口(7000等)。普通端口主要用于为客户端提供服务(与单机节点类似);但在节点间数据迁移时也会使用。
- 集群端口:端口号是普通端口+10000(10000是固定值,无法改变),如7000节点的集群端口为17000。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。
Gossip协议
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip协议等。重点是广播和Gossip的对比。
广播是指向集群内所有节点发送消息;优点是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
Gossip协议的特点是:在节点数量有限的网络中,每个节点都“随机”的与部分节点通信(并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip协议的优点有负载(比广播)低、去中心化、容错性高(因为通信有冗余)等;缺点主要是集群的收敛速度慢。
消息类型
集群中的节点采用固定频率(每秒10次)的定时任务进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。
节点间发送的消息主要分为5种:meet消息、ping消息、pong消息、fail消息、publish消息:
- MEET消息:在节点握手阶段,当节点收到客户端的
CLUSTER MEET
命令时,会向新加入的节点发送MEET消息,请求新节点加入到当前集群;新节点收到MEET消息后会回复一个PONG消息。 - PING消息:集群里每个节点每秒钟会选择部分节点发送PING消息,接收者收到消息后会回复一个PONG消息。PING消息的内容是自身节点和部分其他节点的状态信息;作用是彼此交换信息,以及检测节点是否在线。PING消息使用Gossip协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找5个节点,在其中选择最久没有通信的1个节点(2)扫描节点列表,选择最近一次收到PONG消息时间大于cluster_node_timeout/2的所有节点,防止这些节点长时间未更新。
- PONG消息:PONG消息封装了自身状态数据。可以分为两种:第一种是在接到MEET/PING消息后回复的PONG消息;第二种是指节点向集群广播PONG消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播PONG消息。
- FAIL消息:当一个主节点判断另一个主节点进入FAIL状态时,会向集群广播这一FAIL消息;接收节点会将这一FAIL消息保存起来,便于后续的判断。
- PUBLISH消息:节点收到
PUBLISH
命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该PUBLISH命令。
数据结构
节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……
节点为了存储集群状态而提供的数据结构中,最关键的是clusterNode
和clusterState
结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。
clusterNode
clusterNode结构保存了一个节点的当前状态,包括创建时间、节点id、ip和端口号等。每个节点都会用一个clusterNode结构记录自己的状态,并为集群内所有其他节点都创建一个clusterNode结构来记录节点状态。
下面列举了clusterNode的部分字段,并说明了字段的含义和作用:
1 | typedef struct clusterNode { |
除了上述字段,clusterNode还包含节点连接、主从复制、故障发现和转移需要的信息等。
clusterState
clusterState结构保存了在当前节点视角下,集群所处的状态。主要字段包括:
1 | typedef struct clusterState { |
除此之外,clusterState还包括故障转移、槽迁移等需要的信息。
集群命令实现
以cluster meet
(节点握手)、cluster addslots
(槽分配)为例,说明节点是如何利用上述数据结构和通信机制实现集群命令的。
假设要向A节点发送cluster meet
命令,将B节点加入到A所在的集群,则A节点收到命令后,执行的操作如下:
A为B创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
A向B发送
MEET
消息-一次握手B收到MEET消息后,会为A创建一个clusterNode结构,并将其添加到clusterState的nodes字典中
B回复A一个
PONG
消息-一次握手A收到B的PONG消息后,便知道B已经成功接收自己的MEET消息
然后,A向B返回一个PING消息-一次握手
B收到A的PING消息后,便知道A已经成功接收自己的PONG消息,握手完成
之后,A通过Gossip协议将B的信息广播给集群内其他节点,其他节点也会与B握手;一段时间后,集群收敛,B成为集群内的一个普通节点
通过上述过程可以发现,集群中两个节点的握手过程与TCP类似,都是三次握手:A向B发送MEET;B向A发送PONG;A向B发送PING。保证可靠性
cluster addslots
集群中槽的分配信息,存储在clusterNode的slots数组和clusterState的slots数组中;二者的区别在于:前者存储的是该节点中分配了哪些槽,后者存储的是集群中所有槽分别分布在哪个节点。
cluster addslots命令接收一个槽或多个槽作为参数,例如在A节点上执行cluster addslots {0..10}命令,是将编号为0-10的槽分配给A节点,具体执行过程如下:
遍历输入槽,检查它们是否都没有分配,如果有一个槽已分配,命令执行失败;方法是检查输入槽在clusterState.slots[]中对应的值是否为NULL。
遍历输入槽,将其分配给节点A;方法是修改clusterNode.slots[]中对应的比特为1,以及clusterState.slots[]中对应的指针指向A节点
A节点执行完成后,通过节点通信机制通知其他节点,所有节点都会知道0-10的槽分配给了A节点