1
参考
https://blog.csdn.net/yerenyuan_pku/category_9141443.html
《Redis设计与实现》
Redis概述
Redis概述
Redis是用C语言开发的一个开源的高性能键值对数据库。它通过提供多种
键值数据类型来适应不同场景下的存储需求。Redis 的数据是存在内存中
的,所以读写速度非常快,因此 redis 被广泛应用于缓存方向,每秒可
以处理超过10万次读写操作,Redis 也经常用来做分布式锁,除此还支
持事务、持久化、LUA脚本、LRU驱动事件、多种集群方案
分布式缓存
分布式缓存主要解决的是单机缓存的容量受服务器限制并且无法保存通用
的信息。因为本地缓存只在当前服务里有效,比如如果你部署了两个相同
的服务,他们两者之间的缓存数据是无法共同的
Redis 和Memcached 的区别
共同点
- 都是基于内存的数据库,一般都用来当做缓存使用
- 都有过期策略
- 两者的性能都非常高
区别
- Redis 支持更丰富的数据类型。Redis 不仅仅支持简单的k/v 类型的
数据,同时还提供list set zset hash等数据结构的存储。Memcached
只支持最简单的 k/v 数据类型 - Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启
的时候可以再次加载进行使用,而Memecache 把数据全部存在内存之中 - Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上
- Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是
Memcached 在服务器内存使用完之后,就会直接报异常 - Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分
片写入数据;但是Redis 目前是原生支持cluster 模式的 - Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线
程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO ) - Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不
支持。并且Redis 支持更多的编程语言 - Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使
用了惰性删除与定期删除
Redis的优点
- 读写性能优异,Redis读速度是110000次/s,写的速度是81000次/s
- 支持数据持久化,支持AOF和RDB两种持久化方式
- 数据结构丰富,除了支持string类型的value外多种类型
- 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离
Redis的缺点
- 数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因
此Redis适合的场景主要局限在较小数据量的高性能操作和运算上
Redis的特点
为了解决高并发、高负载、高可扩展(也即高可用)、大数据存储问题而产生
的数据库解决方案,就是NoSQL数据库。NoSQL 泛指非关系型的数据库可以
作为关系型数据库的良好补充
- 易扩展
数据之间无关系,这样就非常容易扩展,也无形之间,在架构的层面上带来了
可扩展的能力 - 高性能
NoSQL数据库都具有非常高的读写性能,尤其在大数据量下,同样表现优秀,
这得益于它的无关系性,数据库的结构简单 - 灵活的数据模型
NoSQL无需事先为要存储的数据建立字段,随时可以存储自定义的数据格式。而
在关系型数据库里,增删字段是一件非常麻烦的事情 - 高可用
NoSQL在不太影响性能的情况,就可以方便的实现高可用的架构。比如Cassandra
、HBase模型,通过复制模型也能实现高可用 - 高并发
直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以
考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接
到缓存这里而不用经过数据库
高可用技术
除了保证提供正常服务(如主从分离、快速容灾技术),还需要考虑数据容
量的扩展、数据安全不会丢失等,实现高可用的技术主要包括持久化、复制
、哨兵和集群
- 持久化:持久化是最简单的高可用方法,主要作用是数据备份,即将数
据存储在硬盘,保证数据不会因进程退出而丢失 - 复制:复制是高可用Redis的基础,哨兵和集群都是在复制基础上实现
高可用的。复制主要实现了数据的多机备份,以及对于读操作的负载均衡
和简单的故障恢复。缺陷:故障恢复无法自动化;写操作无法负载均衡;
存储能力受到单机的限制 - 哨兵:在复制的基础上,哨兵实现了自动化的故障恢复。缺陷:写操作
无法负载均衡;存储能力受到单机的限制 - 集群:通过集群,Redis解决了写操作无法负载均衡,以及存储能力受
到单机限制的问题,实现了较为完善的高可用方案
为什么要用Redis而不用map/guava做缓存
缓存分为本地缓存和分布式缓存
- Java 自带的map 或者guava 实现的是本地缓存,最主要的特点是轻量
以及快速,生命周期随着jvm的销毁而结束,并且在多实例的情况下,每个
实例都需要各自保存一份缓存,缓存不具有一致性 - 使用redis 或memcached 之类的称为分布式缓存,在多实例的情况下,
各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持redis服务的高
可用,整个程序架构上较为复杂
Redis为什么快
- 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存
在内存中,类似于 HashMap,HashMap 的优势就是查找和操作的时间
复杂度都是O(1) - 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的
- 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程
或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁
释放锁操作,没有因为可能出现死锁而导致的性能消耗 - 使用多路 I/O 复用模型,非阻塞 IO
Redis应用场景
缓存,例如数据查询、短连接、新闻内容、商品内容等等
分布式集群架构中的session分离
聊天室的在线好友列表
任务队列,例如秒杀、抢购、12306抢票等等
应用排行榜
网站访问统计
数据过期处理(可以精确到毫秒)
计数器 可以对String 进行自增自减运算,从而实现计数器功能
缓存 将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来
保证缓存的命中率会话缓存 可以使用Redis 来统一存储多台应用服务器的会话信息。当
应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以
请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性查找表 例如DNS 记录就很适合使用Redis 进行存储。查找表和缓存类
似,也是利用了Redis 快速的查找特性。但是查找表的内容不能失效,而
缓存的内容可以失效,因为缓存不作为可靠的数据来源消息队列(发布/订阅功能) List是一个双向链表,可以通过lpush和
rpop写入和读取消息。不过最好使用Kafka、RabbitMQ 等消息中间件分布式锁实现 在分布式场景下,无法使用单机环境下的锁来对多个节
点上的进程进行同步。可以使用Redis 自带的SETNX 命令实现分布式锁
数据结构与对象
六种底层数据结构
简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表
简单动态字符串
Redis并没有使用C字符串表示,而是自己构建了一种简单动态字符串SDS
用来保存字符串值
- C字符串作为字符串字面量,用在无需修改的地方,比如打印日志
- SDS用在可以修改字符串值的情况,键值对底层由SDS实现,还可以用
作缓冲区
sdshdr结构表示一个SDS值
1 | struct sdshdr{ |
SDS相比C字符串的好处如下
- 常数时间复杂度获取字符串长度 获取SDS 字符串的长度只需要读取len
属性,C字符串经过遍历计数时间复杂度为O(N) - 杜绝缓冲区溢出 C语言中使用strcat 函数来进行两个字符串的拼接,一
旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于SDS数据类型
,在进行字符修改的时候,会首先根据记录的len 属性检查内存空间是否满
足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以
不会出现缓冲区溢出 - 减少修改字符串带来的内存重分配次数 通过未使用空间实现空间预分配
和惰性空间释放两种优化
- 空间预分配 空间扩展时额外分配未使用的空间
- 惰性空间释放 不必内存重分配回收空间,而是使用free记录字节
- 二进制安全 Redis的SDS 不光可以保存文本数据还可以保存二进制数
据,会以处理二进制的方式来处理SDS存放在buf数组里的数据,使用len
属性来判断字符串的结束而不是空字符 - S兼容部分C字符串函数 以空字符结尾可以重用部分C字符串函数,比如
strcat和strcasecmp
链表
C语言内部是没有内置这种数据结构的实现,所以Redis自己构建了链表的实
现,发布与订阅、慢查询、监视器等功能也使用到了链表
1 | typedef struct listNode{ |
多个节点构成双向链表,通过len属性获取链表长度的时间复杂度为O(1)。
多态:链表节点使用void* 指针来保存节点值,可以保存各种不同类型的值
1 | typedef struct list{ |
字典
字典又称为符号表或者关联数组、或映射(map),是一种用于保存键值对的
抽象数据结构。字典中的每一个键 key 都是唯一的,通过 key 可以对值来
进行查找或修改。C 语言中没有内置这种数据结构的实现,所以字典依然是
Redis自己构建的。Redis 的字典使用哈希表作为底层实现,redis数据库
使用字典作为底层实现
1 | typedef struct dictht{ |
哈希表是由数组table 组成,table中每个元素都是指向dict.h/dictEntry
结构,dictEntry 结构定义如下,采用链地址法来解决哈希冲突,总是添加
到表头位置。每个字典有两个hash表,一个平时使用一个rehash使用
1 | typedef struct dictEntry{ |
当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来
对哈希表进行相应的扩展或者收缩,每次扩缩都是根据原哈希表已使用的空
间扩大一倍或缩小一倍创建另一个哈希表,重新利用上面的哈希算法,计算
索引值,然后将键值对放到新的哈希表位置,所有键值对都迁徙完毕后,释
放原哈希表的内存空间。触发扩容的条件有两个,负载因子= 哈希表已保存
节点数量/哈希表大小,负载因子小于0.1会收缩
- 服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载
因子大于等于1 - 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载
因子大于等于5,执行这些命令的时候会创建子进程,采用写时复制技术优化
子进程的使用效率,所以增大负载因子尽可能避免扩展操作
渐进式rehash就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次
、渐进式完成的,字典的删除查找更新等操作可能会在两个哈希表上进行,第
一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作
,一定是在新的哈希表上进行的
跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指
向其它节点的指针,从而达到快速访问节点的目的,是有序集合键的底层实
现之一
- 由很多层结构组成
- 每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个
链表节点,分别是前面的head节点和后面的nil节点 - 最底层的链表包含了所有的元素
- 如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出
现(上一层的元素是当前层的元素的子集) - 链表中的每个节点都包含两个指针,一个指向同一层的下一个链表节点,
另一个指向下一层的同一个链表节点
Redis中跳跃表节点定义如下
1 | typedef struct zskiplistNode { |
多个跳跃表节点构成一个跳跃表
1 | typedef struct zskiplist{ |
有三个操作
- 搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下
一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个
节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则
返回,反之则返回空 - 插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正
面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确
定插入的层数k后,则需要将新元素插入到从底层到k层 - 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即
可,如果删除以后只剩下头尾两个节点,则删除这一层
整数集合
整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,是集合
键的底层实现之一
1 | typedef struct intset{ |
整数集合的每个元素都是contents 数组的一个数据项,它们按照从小到大
的顺序排列,并且不包含任何重复项。当我们新增的元素类型比原集合元素
类型的长度要大时,需要对整数集合进行升级,才能将新元素放入整数集
合中,不支持降级
- 根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间
- 将底层数组现有的所有元素都转成与新元素相同类型的元素,并将转换
后的元素放到正确的位置,放置过程中,维持整个元素顺序都是有序的 - 将新元素添加到整数集合中(保证有序)
升级的好处一是可以提升整数集合的灵活性,而是节约内存。自动升级可以
避免类型错误,升级只会在又需要的时候进行,尽量节省内存
压缩列表
压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编
码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个
节点(entry),每个节点可以保存一个字节数组或者一个整数值,是列
表键和哈希键的底层实现之一。
压缩列表的原理:压缩列表并不是对数据利用某种算法进行压缩,而是将数
据按照一定规则编码在一块连续的内存区域,目的是节省内存。组成如下
- zlbytes 4 记录占用的内存字节数
- zltail 4 记录表尾节点距离起始位置有多少字节
- zllen 2 记录节点数量,小于65535是准确的,否则必须遍历
- entryX 列表节点,长度由内容决定
- zlend 1 特殊值0xFF,标记压缩列表末端
压缩列表的每个节点构成如下:
- previous_entry_ength 记录压缩列表前一个节点的长度,可能是1个字
节或者是5个字节,如果上一个节点的长度小于254,则该节点只需要一个字
节就可以表示前一个节点的长度了,如果前一个节点的长度大于等于254,
则第一个字节为254,后面用四个字节表示当前节点前一个节点的长度。利
用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始
位置,压缩列表可以从尾部向头部遍历。这么做有效地减少了内存的浪费 - encoding 节点的encoding保存的是节点的content的内容类型以及长度
,encoding类型一共有两种,一种字节数组一种是整数,encoding区域长度
为1字节、2字节或者5字节长 - content 用于保存节点的内容,节点内容类型和长度由encoding决定
连续多次空间扩展操作称之为连锁更新
五大数据类型
键总是一个字符串,键值数据类型(指的就是键值对中值的数据类型)如下
字符串类型,散列类型(哈希对象)、列表类型、集合类型、有序集合类型。
一个键值对至少有两个对象,一个键对象一个值对象
1 | typedef struct redisObject{ |
- object encoding key 可以查看一个数据库键的值对象的编码,每种
类型至少使用两种不同编码 - type key 值对象的类型
- refcount Redis自己构建了一个内存回收机制,通过在 redisObject
结构中的refcount 属性实现,创建和使用对象时加1,不再被使用时减1。
当对象的引用计数值变为0 时,对象所占用的内存就会被释放
字符串对象
字符串对象的编码可以是int raw embstr
- int 可以用long类型保存的整数,Redis中对于浮点数类型是作为字符串
保存的,在需要的时候再将其转换成浮点数类型 - embstr 字符串长度小于等于39字节,3.2后的版本是44字节
- raw 字符串长度大于39字节,3.2后的版本是44字节
embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用
只分配一次内存空间(因此redisObject和sds是连续的),而raw 需要分配
两次内存空间。embstr 的好处在于创建时少分配一次空间,删除时少释放一
次空间,以及对象的所有数据连在一起,寻找方便。而embstr 的坏处也很明
显,如果字符串的长度增加需要重新分配内存时,整个redisObject 和sds
都需要重新分配空间,因此redis中的embstr实现为只读。需要修改时转换
为raw。
字符串常用命令
1 | set key value #赋值 |
应用场景
- 计数
由于Redis单线程的特点,我们不用考虑并发造成计数不准的问题,通过incrby
命令,我们可以正确的得到我们想要的结果 - 限制次数
比如登录次数校验,错误超过三次5分钟内就不让登录了,每次登录设置key自增
一次,并设置该key的过期时间为5分钟后,每次登录检查一下该key的值来进行
限制登录 - 缓存数据
因为string 类型是二进制安全的,可以用来存放图片,视频等内容
列表对象
列表对象的编码可以是ziplist或linkedlist
- ziplist 字符串元素长度都小于等于64字节,元素数量小于等于512
- linkedlist 不满意以上条件
列表常用命令
1 | lpush list xx #向list中添加值,从左边插入 |
应用场景
- 栈 通过命令 lpush+lpop
- 队列 命令 lpush+rpop
- 有限集合 命令 lpush+ltrim
- 消息队列 命令 lpush+brpop
集合对象
集合对象的编码可以是intset或hashtable。特点是无序不可重复
- intset 保存的元素是整数值,数量不超过512个
- hashtable 不满足以上条件,如果是哈希表的话每个键的值都是NULL
集合常用命令
1 | sadd set xx #添加元素 |
应用场景
- 利用集合的交并集特性,比如在社交领域,我们可以很方便的求出多个
用户的共同好友,共同感兴趣的领域等 - 比如在用户注册模块,判断用户名是否注册
有序集合
有序集合的编码可以是ziplist或skiplist
- ziplist 元素数量小于等于128,元素成员的长度小于等于64字节。每个
集合元素使用两个压缩列表节点保存,第一个节点保存元素的成员,第二个
节点保存元素的分值,并且压缩列表内的集合元素按分值从小到大的顺序进
行排列 - skiplist 不满足以上条件,每个跳跃表节点都保存一个集合元素,除此
还有一个字典保存成员到分值的映射。一个用来排序一个用来查找。这两种数
据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和
分值,造成内存的浪费
有序集合常用命令
1 | zadd price 8.5 apple 5.0 banana 6.0 cherry #添加值 |
应用场景和set数据结构一样,zset也可以用于社交领域的相关业务,并且
还可以利用zset 的有序特性,可以做范围查找还可以做类似排行榜的业务
哈希对象
哈希对象的编码可以是ziplist或hashtable。hash 是一个键值对集合,是
一个 string 类型的 key和 value 的映射表,key 还是key,但是value
是一个键值对
- ziplist 哈希对象保存的键值对的键和值的字符串长度都小于等于64字
节,数量小于等于512个,新的键值对组成的两个节点放在压缩列表表尾 - hashtable 不满足以上条件,使用字典
哈希对象常用命令
1 | hset key field value |
应用场景
- value 存放的是键值对,比如可以做单点登录存放用户信息
单机数据库
一个Redis实例可以包括多个数据库,客户端可以指定连接某个Redis实例
的哪个数据库,一个Redis实例最多可提供16个数据库,下标从0到15,客
户端默认连接的是第0号数据库,dict字典保存数据库中所有键值对,这
个字典称为键空间
键的生存时间
- EXPIRE和PEXPIRE命令可以为键设置生存时间
- TTL和PTTL命令可以返回这个键的剩余生存时间
- PERSIST移除一个键的过期时间
- EXPIREAT key timestamp 表示将键key的生存时间设置为timestamp
所指定的秒数时间戳。
过期键的判定
expires字典保存数据库中所有键的过期时间,这个字典是过期字典,
通过is_expire(key)或TTL判断,当key不存在时,返回-2。当key存
在但没有设置剩余生存时间时,返回-1
- 检查给定键是否存在于过期字典,如果存在则取得过期时间
- 判断当前UNIX时间戳是否大于键的过期时间
过期键删除策略
Redis中同时使用了惰性删除和定期删除两种过期策略,定期删除函数的
运行频率,默认规定每秒运行10次
- 定时删除:每个设置过期时间的key都需要创建一个定时器,到过期
时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好,
但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时
间和吞吐量 - 惰性删除:只有当访问一个key时,才会判断该key是否已过期,过期
则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情
况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量
内存 - 定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字
典中一定数量的key,并清除其中已过期的key。通过调整定时扫描的时
间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源
达到最优的平衡效果
Redis 给缓存数据设置过期时间的作用
- 因为内存是有限的,如果缓存中的所有数据都是一直保存的话,直接
Out of memory - 很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在
,比如我们的短信验证码可能只在1 分钟内有效,用户登录的token 可
能只在 1 天内有效。如果使用传统的数据库来处理的话,一般都是自己
判断过期,这样更麻烦并且性能要差很多
内存淘汰策略
在配置文件redis.conf 中,可以通过参数maxmemory来设定最大内存。
当现有内存大于maxmemory 时,便会触发redis主动淘汰内存方式,通
过设置maxmemory-policy,有如下几种淘汰方式:
- volatile-lru 利用LRU算法移除设置过过期时间的key
- allkeys-lru 利用LRU算法移除任何key(和上一个相比,删除的key
包括设置过期时间和不设置过期时间的),通常使用该方式 - volatile-random 移除设置过过期时间的随机key
- allkeys-random 无差别的随机移除。
- volatile-ttl 移除即将过期的key(minor TTL)
- noeviction 不移除任何key,只是返回一个写错误,默认选项,一般
不会选用
Redis 单线程模型
Redis 基于Reactor 模式来设计开发了自己的一套高效的事件处理模型,
这套事件处理模型对应的是 Redis 中的文件事件处理器。由于文件事件处
理器是单线程方式运行的,所以我们一般都说Redis 是单线程模型
单线程怎么监听大量的客户端连接
Redis 通过IO 多路复用程序来监听来自客户端的大量连接(或者说是监
听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中
并监听每个事件是否发生。
I/O 多路复用技术的使用让Redis 不需要额外创建多余的线程来监听
客户端的大量连接,降低了资源的消耗。文件事件处理器主要是包含4
个部分:
- 多个 socket(客户端连接)
- IO 多路复用程序(支持多个客户端连接的关键)
- 文件事件分派器(将 socket 关联到相应的事件处理器)
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
为什么不使用多线程
- 单线程编程容易并且更容易维护
- Redis 的性能瓶颈不再 CPU ,主要在内存和网络
- 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能
Redis6.0 之后为何引入了多线程
Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是
Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。
虽然 Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据
的读写这类耗时操作上使用了, 执行命令仍然是单线程顺序执行。因此
你也不需要担心线程安全问题。
Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改
redis 配置文件redis.conf
1 | io-threads-do-reads yes |
持久化机制
很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部
分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或
者是为了防止系统故障而将数据备份到一个远程位置。Redis的一种持久化
方式叫快照(RDB),另一种方式是只追加文件(AOF)。前者保存当前数
据,后者保存每次执行的命令
RDB持久化
Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的
副本。对应产生的数据文件为dump.rdb,是一个经过压缩的二进制文件。
通过配置文件中的save参数来定义快照的周期或通过以下命令手动触发
- SAVE 生成RDB文件,会阻塞服务器进程,期间不能处理任何命令请求
- BGSAVE 子进程负责创建RDB文件,父进程继续处理请求
Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器
从而创建具有相同数据的服务器副本(Redis主从结构,主要用来提高Redis
性能),还可以将快照留在原地以便重启服务器的时候使用。
快照持久化是 Redis 默认采用的持久化方式,在Redis.conf 配置文件中
默认有此下配置,只要满足条件就会执行bgsave命令
1 | save 900 1 #在900秒之后,如果至少有1个key发生变化,Redis就自动触发BGSAVE命令创建快照 |
serverCron是Redis服务器的周期性操作函数,默认每隔100ms执行一次;
该函数对服务器的状态进行维护,检查配置是否满足
- saveparams 保存一个save选项设置的保存条件
- dirty计数器 记录距离上一次成功执行SAVE命令后对数据库的修改次数
- lastsave 记录上一次成功执行SAVE命令的时间,是一个UNIX时间戳
RDB文件的载入工作是在服务器启动时自动执行的,并没有专门的命令,服务
器载入RDB文件期间处于阻塞状态,直到载入完成为止。
可以通过 od -c dump.rdb命令打印RDB文件,以ASCII编码方式
其余自动触发机制
- 在主从复制场景下,如果从节点执行全量复制操作,则主节点会执行bgsave
命令,并将rdb文件发送给从节点 - 执行shutdown命令时,自动执行rdb持久化
对过期键的处理
- 执行save命令或bgsave命令创建RDB文件时已经过期的键不会保存
- 载入RDB文件如果服务器以主服务器模式运行,载入时已过期的键不会
载入 - 载入RDB文件如果服务器以从服务器模式运行,所有键都载入
RDB优缺点
优点
- 只有一个文件dump.rdb,方便持久化
- 容灾性好,一个文件可以保存到安全的磁盘
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以
是IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何IO 操作
,保证了redis 的高性能。RDB 在恢复大数据集时的速度比AOF 的恢复速
度要快 - 比AOF 的启动效率更高
缺点 - 数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间redis
发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候 - RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都
要执行fork操作创建子进程,属于重量级操作
AOF持久化
将Redis执行的每次写命令记录到单独的日志文件中,与快照持久化相比,
AOF持久化的实时性更好优先使用。默认情况下Redis没有开启AOF方式的持
久化,可通过appendonly参数开启
1 | appendonly yes |
开启AOF 持久化后每执行一条会更改Redis 中的数据的命令,Redis 就
会将该命令写入硬盘中的AOF 文件。AOF 文件的保存位置和RDB 文件的
位置相同,都是通过dir参数设置的,默认的文件名是appendonly.aof
在Redis 的配置文件中存在三种不同的AOF 持久化方式,它们分别是:
1 | appendfsync always #每次有数据修改发生都会写入AOF文件,这样会严重降低Redis的速度 |
为了兼顾数据和写入性能,用户可以考虑appendfsync everysec 选项
,让Redis 每秒同步一次AOF 文件,Redis 性能几乎没受到任何影响。
而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。
当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度
以便适应硬盘的最大写入速度
AOF持久化的实现
AOF持久化的实现可以分为命令追加、文件写入和文件同步三个步骤
- 命令追加 服务器执行完一个写命令就会以协议格式把命令追加到服
务器状态的aof_buf缓冲区末尾 - 写入与同步 根据不同的同步策略将aof_buf中的内容同步到硬盘。
涉及到操作系统的write函数和fsync函数,write函数将数据写入文
件时通常会将数据暂存到一个内存缓冲区里,当缓冲区被填满或超过
了指定时限后,才真正将缓冲区的数据写入到硬盘里。如果计算机停
机,内存缓冲区中的数据会丢失。
因此系统同时提供了fsync、fdatasync等同步函数,可以强制操作系
统立刻将缓冲区中的数据写入到硬盘里,从而确保数据的安全性1
2
3
4
5
6#每次有数据修改发生时立即调用fsync写入AOF文件,硬盘IO成为性能瓶颈
appendfsync always
#命令写入aof_buf后调用write;fsync同步文件操作由专门的线程每秒调用一次
appendfsync everysec
#调用系统write操作,不对AOF文件做fsync同步;同步由操作系统负责,通常同步周期为30秒
appendfsync no - 文件重写 定期重写AOF文件,达到压缩的目的,AOF重写是把Redis
进程内的数据转化为写命令,同步到新的AOF文件;不会对旧的AOF文件
进行任何读取、写入操作
文件重写能压缩的原因
文件重写既可以减少文件占用的空间,也可以加快恢复速度
- 过期的数据不再写入文件
- 无效的命令不再写入文件:如有些数据被重复设值、有些数据被删除了等等
- 多条命令可以合并为一个
文件重写的触发
- 手动触发 直接调用bgrewriteaof命令,该命令的执行与bgsave有些类
似:都是fork子进程进行具体的工作,且都只有在fork时阻塞 - 自动触发 执行AOF重写时,文件的最小体积要达到64MB自动执行执行
BGREWRITEAOF命令,重写期间Redis执行的写命令,需要追加到新的AOF
文件中,为此Redis引入了aof_rewrite_buf缓存。
执行期间,Redis的写命令同时追加到aof_buf和aof_rewirte_buf两个
缓冲区,保证原有AOF机制的正确,子进程根据内存快照,按照命令合并
规则写入到新的AOF文件,使用新的AOF文件替换老文件,完成AOF重写
子进程
AOF 重写程序放到子程序中进行,这样有两个好处
- 子进程进行AOF 重写期间,服务器进程(父进程)可以继续处理其他
命令 - 子进程带有父进程的数据副本,使用子进程而不是线程,可以在避免使
用锁的情况下,保证数据的安全性
对过期键的处理
- 如果某个键已过期还未删除那么AOF文件不会因为这个过期键产生影响
- 当过期键被删除,程序会向AOF文件追加删除命令
- AOF重写时会检查,过期键不会保存
AOF优缺点
优点
- 数据安全,aof 持久化可以配置appendfsync属性always,每进行一次
命令操作就记录到aof 文件中一次,当不推荐这种,支持秒级持久化 - 通过append 模式写文件即使中途服务器宕机,可通过redis-check-aof
工具解决数据一致性问题 - AOF机制的rewrite 模式。AOF 文件没被rewrite 之前(文件过大时会
对命令进行合并重写),可删除其中的某些命令(比如误操作的flushall) - AOF 文件的格式可读性较强,这也为使用者提供了更灵活的处理方式。
例如如果我们不小心错用了 FLUSHALL 命令,在重写还没进行时,我们可
以手工将最后的FLUSHALL 命令去掉,然后再使用 AOF 来恢复数据。
缺点 - AOF 文件比RDB 文件大,且恢复速度慢
- 数据集大的时候,比rdb 启动效率低
两种持久化方式的选择
如果可以忍受一小段时间内数据的丢失,毫无疑问使用RDB 是最好的,定时生
成RDB 快照非常便于进行数据库备份, 并且RDB恢复数据集的速度也要比AOF
恢复的速度要快,而且使用RDB 还可以避免AOF 一些隐藏的 bug
- AOF文件比RDB更新频率高,优先使用AOF还原数据
- AOF比RDB更安全也更大
- RDB性能比AOF好
- 如果两个都配了优先加载AOF
RDB-AOF混合持久化
当开启混合持久化时,主进程先fork出子进程将现有内存副本全量以RDB方式写
入aof文件中,然后将缓冲区中的增量命令以AOF方式写入aof文件中,写入完成
后通知主进程更新相关信息,并将新的含有 RDB和AOF两种格式的aof文件替换
旧的aof文件。
简单来说:混合持久化方式产生的文件一部分是RDB格式,一部分是AOF格式。
事务
Redis可以通过MULTI,EXEC,DISCARD和WATCH 等命令来实现事务功能。
使用MULTI命令后可以输入多个命令。Redis不会立即执行这些命令,而是
将它们放到队列,当调用了EXEC命令将执行所有命令。过程如下
- 开始事务(MULTI)
- 命令入队(批量操作Redis 的命令,先进先出(FIFO)的顺序执行)
- 执行事务(EXEC)
Redis 是不支持roll back 的,因而不满足原子性的(而且不满足持久性)。
Redis 开发者们觉得没必要支持回滚,这样更简单便捷并且性能更好。Redis
开发者觉得即使命令执行错误也应该在开发过程中就被发现而不是生产过程中。
WATCH命令
WATCH 命令是一个乐观锁,在EXEC命令执行之前监控一个或多个键,一旦
其中有一个键被修改(或删除),之后的事务就不会执行
1 | watch "name" |
事务的ACID
Redis的事务总是具有ACID中的一致性和隔离性,当服务器运行在AOF持久
化模式下,并且appendfsync选项的值为always时,事务也具有耐久性
原子性
- 命令可能会产生语法错误,事务的所有命令都不会执行
- 事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串
键上面,该条语句会执行失败,但事务中的其他命令仍会执行
为什么不支持回滚
Redis 命令只会因为错误的语法而失败,或是命令用在了错误类型的键上
面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的
,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中
一致性
- 入队错误 服务器会拒绝执行入队过程中出现错误的事务
- 执行错误 出错的命令不会对数据库产生修改,不会对一致性造成影响
- 服务器停机 重启之后要么是空白的要么使用RDB或AOF恢复数据
隔离性
Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,
事务可以运行直到执行完所有事务队列中的命令为止。因此Redis 的事务
是总是带有隔离性的
持久性
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的
,接下来即使数据库发生故障也不应该对其有任何影响,Redis的持久化
由持久化模式决定。
- 无持久化的内存模式不能保证持久性
- RDB模式不能保证持久性
- AOF模式always,总会在执行命令后调用同步函数,具有持久性
- AOF模式everysec,每一秒同步一次命令数据到磁盘,停机可能恰好
发生在同步的那一秒内,不具有持久性 - AOF模式no,数据可能在等待同步的过程丢失,不具有持久性
持久化策略的选择
- 如果Redis中的数据完全丢弃也没有关系,可以不进行任何持久化
- 在单机环境下如果可以接受十几分钟或更多的数据丢失,选择RDB对Redis
的性能更加有利;如果只能接受秒级别的数据丢失,应该选择AOF
内存相关
如何保证redis中的数据都是热点数据
redis内存数据集大小上升到一定大小的时候,就会实行数据淘汰策略。
全局的键空间选择性移除
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除
最近最少使用的key - allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随
机移除某个key
设置过期时间的键空间选择性移除
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间
的键空间中,移除最近最少使用的key - volatile-random:当内存不足以容纳新写入数据时,在设置了过期时
间的键空间中,随机移除某个key - volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间
的键空间中,有更早过期时间的key优先移除
Redis的内存用完了会发生什么
如果达到设置的上限,Redis的写命令会返回错误信息(但是读命令还可以正
常返回。)或者你可以配置内存淘汰机制,当Redis达到内存上限时会移除掉
旧的内容
主从复制
主从复制是指将一台Redis服务器的数据,复制到其他的Redis服务器,数
据的复制是单向的,前者是主服务器,后者是从服务器。Master 以写为主
,Slave 以读为主,Master主节点更新后根据配置自动同步到从机Slave
节点
主从复制流程
- 修改配置文件,将redis.conf 配置文件复制三份,通过修改端口分别
模拟三台Redis服务器,修改端口,配置log文件和rdb文件名 - 选择6380端口和6381端口,执行命令:SLAVEOF 127.0.0.1 6379,
主从复制的开启,完全是在从节点发起的,主从服务器保存相同的数据,
称为数据库状态一致,主服务器之前的数据也保存在从服务器 - 从服务器不能执行写命令,因为 slave-read-only 的配置。主节点挂
掉之后从节点角色不会改变 - 断开复制,slaveof no one 不会删除已有的数据,只是不再接受主节
点新的数据变化,从节点又变回为主节点,这样就可以完成主从切换
主从复制功能
- 数据同步 从节点数据的初始化,从节点向主节点发送psync命令(2.8
以前是sync命令),开始同步 - 命令传播 数据同步阶段完成后,主从节点进入命令传播阶段;在这个阶
段主节点将自己执行的写命令发送给从节点,从节点接收命令并执行,从而
保证主从节点数据的一致性,命令传播是异步的过程,因此实际上主从节点
之间很难保持实时的一致性
全量复制和部分复制
在Redis2.8以前,从节点向主节点发送sync命令请求同步数据,此时的同步
方式是全量复制;在Redis2.8及以后,从节点可以发送psync命令请求同步
数据,此时根据主从节点当前状态的不同,同步方式可能是全量复制或部分
复制
全量复制
用于初次复制或其他无法进行部分复制的情况,将主节点中的所有数据都发
送给从节点
- 主节点收到全量复制的命令后,执行bgsave,在后台生成RDB文件,并
使用一个缓冲区(称为复制缓冲区)记录从现在开始执行的所有写命令 - 主节点的bgsave执行完成后,将RDB文件发送给从节点;从节点首先清
除自己的旧数据,然后载入接收的RDB文件,将数据库状态更新至主节点
执行bgsave时的数据库状态 - 主节点将前述复制缓冲区中的所有写命令发送给从节点,从节点执行这
些写命令,将数据库状态更新至主节点的最新状态 - 如果从节点开启了AOF,则会触发bgrewriteaof的执行,从而保证AOF文件
更新至主节点的最新状态
部分复制
用于网络中断等情况后的复制,只将中断期间主节点执行的写命令发送给从
节点,与全量复制相比更加高效。需要注意的是,如果网络中断时间过长,
导致主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分
复制,仍使用全量复制。部分复制的实现,依赖于三个部分
- 复制偏移量 主节点和从节点分别维护一个复制偏移量,代表的是主节
点向从节点传递的字节数;主节点每次向从节点传播N 个字节数据时,主
节点的offset增加N;从节点每次收到主节点传来的N 个字节数据时,从
节点的offset增加N。offset用于判断主从节点的数据库状态是否一致,
如果不一致找出从节点缺少的那部分数据 - 复制积压缓冲区 复制积压缓冲区是由主节点维护的、固定长度的、先进
先出(FIFO)队列,默认大小1MB;当主节点开始有从节点时创建,其作用
是备份主节点最近发送给从节点的数据。注意,无论主节点有一个还是多
个从节点,都只需要一个复制积压缓冲区。保存一部分最近传播的写命令
从节点将offset发送给主节点后,主节点根据offset和缓冲区大小决定
能否执行部分复制:
- 如果offset偏移量之后的数据,仍然都在复制积压缓冲区里,则执行
部分复制 - 如果offset偏移量之后的数据已不在复制积压缓冲区中,则执行全量
复制
- 服务器运行ID 每个Redis节点(无论主从),在启动时都会自动生成一
个随机ID(每次启动都不一样),由40个随机的十六进制字符组成;runid
用来唯一识别一个Redis节点。
主从节点初次复制时,主节点将自己的runid发送给从节点,从节点将这
个runid保存起来;当断线重连时,从节点会将这个runid发送给主节点
;主节点根据runid判断能否进行部分复制:
- 如果从节点保存的runid与主节点现在的runid相同,说明主从节点之
前同步过,主节点会继续尝试使用部分复制 - 如果从节点保存的runid与主节点现在的runid不同,说明从节点在断
线前同步的Redis节点并不是当前的主节点,只能进行全量复制
复制的实现
- 向从服务器发送slave命令,从节点服务器存储主节点的ip和port信息
- 建立套接字连接,从节点为该socket建立一个专门处理复制工作的文件
事件处理器,负责后续的复制工作,如接收RDB文件、接收命令传播。主节点
接收到从节点的socket连接后(即accept之后),为该socket创建相应的
客户端状态,并将从节点看做是连接到主节点的一个客户端,后面的步骤会
以从节点向主节点发送命令请求的形式来进行 - 从节点发送ping命令进行首次请求,目的是检查socket连接是否可用,
以及主节点当前是否能够处理请求
- 返回pong:说明socket连接正常,且主节点当前可以处理请求,复制过
程继续 - 超时:一定时间后从节点仍未收到主节点的回复,说明socket连接不可用
,则从节点断开socket连接,并重连 - 返回pong以外的结果:如果主节点返回其他结果,如正在处理超时运行的
脚本,说明主节点当前无法处理命令,则从节点断开socket连接,并重连
- 身份验证 如果从节点中设置了masterauth选项,则从节点需要向主节
点进行身份验证 - 发送从节点端口信息 从节点会向主节点发送其监听的端口号,主节点将
该信息保存到该从节点对应的客户端的slave_listening_port字段中 - 数据同步阶段 从节点向主节点发送psync命令,在数据同步阶段之前,
从节点是主节点的客户端,主节点不是从节点的客户端;而到了这一阶段及
以后,主从节点互为客户端 - 命令传播阶段 主节点将自己执行的写命令发送给从节点,除了发送写命
令,主从节点还维持着心跳机制:PING和REPLCONF ACK
心跳机制
在命令传播阶段,除了发送写命令,主从节点还维持着心跳机制:PING和
REPLCONF ACK
- ping 每隔10s的时间,主节点会向从节点发送PING命令,这个PING
命令的作用,主要是为了让从节点进行超时判断 - REPLCONF ACK 在命令传播阶段,从节点会向主节点发送该命令,频
率是每秒1次
- 实时监测主从节点网络状态,如果超过1s说明连接出现故障
- 检测命令丢失 从节点发送了自身的offset,主节点会与自己的offset
对比,如果从节点数据缺失,主节点会推送缺失的数据 - 辅助保证从节点的数量和延迟 如果从节点数量小于3个,或所有从节点
的延迟值都大于10s,则主节点拒绝执行写命令
主从复制的作用
- 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余
方式 - 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故
障恢复;实际上是一种服务的冗余 - 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服
务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据
时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过
多个从节点分担读负载,可以大大提高Redis服务器的并发量 - 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基
础,因此说主从复制是Redis高可用的基础
主从复制的问题
通过info Replication可以查看与复制相关的状态
- 读写分离及其中的问题
- 延迟与不一致问题 由于主从复制的命令传播是异步的,延迟与数据的不
一致不可避免 - 数据过期问题 从节点不会主动删除数据,而是由主节点控制从节点中过期
数据的删除,所以依然会读到过期的数据。Redis 3.2中,从节点在读取数据
时,增加了对数据是否过期的判断:如果该数据已过期,则不返回给客户端
;将Redis升级到3.2可以解决数据过期问题 - 故障切换问题 在没有使用哨兵的读写分离场景下,应用针对读和写分别连
接不同的Redis节点;当主节点或从节点出现问题而发生更改时,需要及时修
改应用程序读写Redis数据的连接;连接的切换可以手动进行,或者自己写监
控程序进行切换,但前者响应慢、容易出错,后者实现复杂,成本都不算低
- 复制超时问题
- 复制中断问题 复制缓冲区溢出
哨兵
主从复制一旦主节点挂掉之后,从节点没法担起主节点的任务,那么整个系统
也无法运行。在复制的基础上,哨兵实现了自动化的故障恢复。
哨兵模式就是不时地监控redis是否按照预期良好地运行(至少是保证主节点
是存在的),若一台主机出现问题时,哨兵会自动将该主机下的某一个从机
设置为新的主机,并让其他从机和新主机建立主从关系
搭建哨兵模式
- 部署主从节点,与普通的主从节点配置一样,并不需要做任何额外配置
- 哨兵节点本质上是特殊的Redis节点,不会载入RDB或AOF文件,因为并不
使用数据库,在配置文件目录新建sentinel.conf并进行如下配置1
2
3
4
5
6
7
8#监控的IP 端口号 名称 sentinel通过投票后认为mater宕机的数量,此处为至少2个
sentinel monitor mymaster 192.168.14.101 6379 2
#30秒ping不通主节点的信息,主观认为master宕机
sentinel down-after-milliseconds mymaster 30000
#故障转移后重新主从复制,1表示串行,>1并行
sentinel parallel-syncs mymaster 1
#故障转移开始,三分钟内没有完成,则认为转移失败
sentinel failover-timeout mymaster 180000 - 启动哨兵节点 redis-sentinel sentinel.conf,默认端口是26379
- 创建连向主服务器的网络连接 哨兵会创建两个连向主服务器的异步网络连
接,一个是命令连接一个是订阅连接 - 获取主服务器信息 每10s向被监视的服务器发送INFO命令,获取主服务器
的当前信息,获取两方面信息,一是主服务器本身的信息,包括服务器ID以及
角色,二是该主节点的从节点信息,包括IP地址和端口号。存在一个masters
字典和slaves字典 - 获取从服务器信息 创建连接到从服务器的命令连接和订阅连接,每10s向
从服务器发送INFO命令,获取运行ID、IP地址和端口号、主从服务器的连接状
态、从服务器的优先级和从服务器的偏移量 - 向主服务器和从服务器发送消息 默认每2s向所有被监视的服务器发送哨兵
的信息,包括IP、端口号、运行ID和配置纪元以及主服务器的信息。publish - 接收主服务器和从服务器的频道信息 哨兵发送和接收被监视服务器的信息
,对于监视同一个服务器的哨兵,一个哨兵发送的消息会被其他哨兵接收,用
于更新相应的主服务器的实例结构 - 更新哨兵字典 哨兵还保存了监视同一个主服务器的其它哨兵信息,包括IP
、端口、运行ID和配置纪元 - 创建连接其他哨兵的命令连接 哨兵之间相互连接形成互连的网络
- 检查主观下线状态 默认哨兵会以每秒一次的频率向所有建立连接的实例
发送ping命令,并通过实例恢复判断实例是否在线 - 检查客观下线状态 哨兵节点在对主节点进行主观下线后,会通过命令询问
其他哨兵节点该主节点的状态;如果判断主节点下线的哨兵数量达到一定数值
,则对该主节点进行客观下线并执行故障转移操作。客观下线是主节点才有的
概念
选举领头哨兵
- 选举领导者哨兵节点:当主节点被判断客观下线以后,各个哨兵节点会进行
协商,选举出一个领导者哨兵节点,并由该领导者节点对其进行故障转移操作 - 监视该主节点的所有哨兵都有可能被选为领导者,选举使用的算法是Raft算
法;Raft算法的基本思路是先到先得:即在一轮选举中,哨兵A向B发送成为领
导者的申请,如果B 没有同意过其他哨兵,则会同意A成为领导者。一般来说哨
兵选择的过程很快,谁先完成客观下线,一般就能成为领导者 - 每次进行领头选举后不管是否成功所有哨兵配置纪元都会自增一次,局部
领头一旦设置那么配置纪元就不能更改 - 每个发现客观下线的哨兵都要求其他哨兵将自己设置为局部领头哨兵
- 哨兵设置局部领头哨兵的规则是先到先得:哨兵A向B发送成为领导者的申请
,如果B没有同意过其他哨兵,则会同意A成为领导者,其余申请就会无效 - 如果某个哨兵被半数以上的哨兵设置成局部领头哨兵,那么就是领头哨兵
- 如果给定时限没有一个哨兵被选举为领头哨兵,那么会重新选举
故障转移
选举出的领导者哨兵,开始对已下线的主节点进行故障转移操作
- 在从节点中选择新的主节点:选择的原则是,首先过滤掉下线和最近5s没有
回复INFO命令和与主节点断开连接超过10倍down-after时间的从节点;
然后选择优先级最高的从节点(由slave-priority指定);如果优先级无法区
分,则选择复制偏移量最大的从节点;如果仍无法区分,则选择runid最小的
从节点 - 更新主从状态:通过slaveof no one命令,让选出来的从节点成为主节点;
并通过slaveof命令让其他节点成为其从节点 - 将已经下线的主节点设置为新的主节点的从节点,当该节点重新上线后,它
会成为新的主节点的从节点
哨兵的功能
- 监控:哨兵会不断地检查主节点和从节点是否运作正常
- 自动故障转移:当主节点不能正常工作时,哨兵会开始自动故障转移
操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其
他从节点改为复制新的主节点 - 配置提供者:客户端在初始化时,通过连接哨兵来获得当前Redis服
务的主节点地址 - 通知:哨兵可以将故障转移的结果发送给客户端
工作原理
- 定时任务:每个哨兵节点维护了3个定时任务。定时任务的功能分别
如下:每10s通过向主从节点发送info 命令获取最新的主从结构;每
2s通过发布订阅功能获取其他哨兵节点的信息;每1s通过向其他节点
发送ping命令进行心跳检测,判断是否下线 - 主观下线:在心跳检测的定时任务中,如果其他节点超过一定时间没
有回复,哨兵节点就会将其进行主观下线。顾名思义,主观下线的意思是
一个哨兵节点“主观地”判断下线;与主观下线相对应的是客观下线 - 客观下线:哨兵节点在对主节点进行主观下线后,会通过sentinel
is-master-down-by-addr命令询问其他哨兵节点该主节点的状态;如
果判断主节点下线的哨兵数量达到一定数值,对该主节点进行客观下线 - 选举领导者哨兵节点:当主节点被判断客观下线以后,各个哨兵节
点会进行协商,选举出一个领导者哨兵节点,并由该领导者节点对其
进行故障转移操作,选取也是每个哨兵向其他哨兵 节点发送我要成
为领导者的命令,超过半数哨兵节点同意 - 故障转移:选举出的领导者哨兵,开始进行故障转移操作
- 已下线主服务器属下的所有从服务器挑选一个从服务器转换为主服务器
- 已下线主服务器属下的所有从服务器改为复制新的主服务器
- 已下线主服务器设置为新的主服务器的从服务器
集群
集群通过分片来进行数据共享,并提供复制和故障转移功能。集群由多个
节点组成,Redis的数据分布在这些节点中。集群中的节点分为主节点和
从节点:只有主节点负责读写请求和集群信息的维护;从节点只进行主
节点数据和状态信息的复制
集群的作用
- 数据分区
集群将数据分散到多个节点,一方面突破了Redis单机内存大小的限制,存
储容量大大增加;另一方面每个主节点都可以对外提供读服务和写服务,
大大提高了集群的响应能力 - 高可用:集群支持主从复制和主节点的自动故障转移(与哨兵类似);
当任一节点发生故障时,集群仍然可以对外提供服务
集群的搭建
- 启动节点:将节点以集群模式启动,此时节点是独立的,并没有建立联
系,在配置文件中设置 cluster-enabled yes ,命令是 redis-server
redis.conf。cluster-config-file:该参数指定了集群配置文件的位置
,每当集群信息发生变化时(如增减节点),集群内所有节点会将最新信
息更新到该配置文件。节点启动后通过 cluster nodes 查看集群节点情
况,其中返回值第一项表示节点id,节点id 只在集群初始化时创建一次
,然后保存到集群配置文件中,以后节点重新启动时会直接在集群配置
文件中读取。每个节点都有clusterNode结构记录集群中所有节点的信
息,包括节点id、ip、port和集群状态,例如集群是上线还是下线,有
多少节点以及配置纪元 - 节点握手:让独立的节点连成一个网络,使用cluster meet ip port
命令实现,该节点会将ip和port指定的节点添加到该节点所在集群中。握手
过程如下
- 向节点A发送cluster meet ip port命令,A为节点B创建clusterNode
结构,并添加到clusterState.nodes字典 - 节点A向节点B发送一条MEET消息,节点B会为节点A创建clusterNode结
构,B向A返回PONG消息 - 节点A接收到B返回的PONG消息知道B已经成功接收了A的消息,然后A向
B发送一条PING消息,B就可以知道A已经成功接收了PONG消息。握手完成 - 之后A会将B的消息通过Gossip协议传播给集群中其他节点,让其他节
点也与B握手
- 分配槽:集群通过分片保存键值对,将整个数据库分为16384个槽,槽
是数据管理和迁移的基本单位。当数据库中的16384个槽都分配了节点时,
集群处于上线状态;如果有任意一个槽没有分配节点,则集群处于下线状
态。通过 cluster addslots {0..5461} 分配槽,每个节点都会把自己
处理的槽信息发送给集群的其它节点,使用一个槽数组可以在O(1)时间复
杂度获取哪个槽被哪个节点指定
在集群中执行命令
- 客户端向节点发送命令,接收命令的节点计算要处理的数据库键属于哪
个槽,如果这个键所在的槽属于当前节点,那么当前节点执行,否则向客
户端返回一个MOVED错误,指引客户端转向正确的节点,再次发送命令 - 根据槽分配算法计算出键属于哪个槽,可以通过cluster keyslot key
来查看给定键属于哪个槽,MOVED错误格式为 MOVED slot ip:port。
注意节点只使用0号数据库,除了将键值对保存在数据库中,还会使用跳跃
表保存槽和键之间的关系
重新分片实现原理
重新分片可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,
并且相关槽所属的键值对也会从源节点转移到目标节点,重新分片操作可以
是在线的,由集群管理软件redis-trib负责
- redis-trib向目标节点发送cluster setslot slot importing
source_id命令,让目标节点准备从源节点导入属于槽slot的键值对 - redis-trib向源节点发送cluster getkeysinslot slot count命令
,获得最多count个属于槽slot的键值对的键名 - 对于获得的每个键名,redis-trib都向源节点发送migrate target_ip
target_port key_name 0 timeout命令,将被选中的键原子地从源节点
迁移到目标节点 - 重复步骤2和3直到源节点保存的所有属于slot的键值对都被迁移到目标
节点 - redis-trib向集群中任意一个节点发送cluster setslot slot node
target_id命令,将槽slot派给目标节点,这一指派消息会发到整个集群
ASK错误
在重新分片期间,源节点向目标节点迁移一个槽的过程中,可能出现一种情
况:属于被迁移槽的一部分键值对保存在源节点中,另一部分键值对则保存
在目标节点中,当向源节点发送一个数据库键有关的命令,而这个键属于正
在被迁移的槽时
- 源节点现在自己的数据库中查找,如果找到就返回
- 如果没有找到,源节点就向目标节点发送ASK错误,指引客户端转向正确
的目标节点,这里发送AKS请求
ASK错误和MOVED错误的区别
两个错误都会导致客户端转向,区别如下
- MOVED错误代表槽的负责权从一个节点转移到另一个节点,客户端收到错
误后每次遇到槽i的命令请求都是发送到目标节点 - ASK错误只是两个节点迁移过程使用的一种临时措施,收到槽i的ASK错误
后只会在下一次命令请求发送到目标节点,而下下一次请求依然会发送到目
前处理槽i的节点,除非ASK错误再次出现
复制与故障转移
集群中的节点分为主节点和从节点:主节点用于处理槽,从节点用于复制某个
主节点,并且在主节点下线时代替主节点处理命令请求,如果下线的主节点重
新上线那么变为新的主节点的从节点
- 指定主从关系:集群中指定主从关系不再使用slaveof命令,而是使用
cluster replicate命令;参数使用节点id。通过cluster nodes获得几
个主节点的节点id后,执行下面的命令为每个从节点指定主节点:
cluster replicate id - 一个节点成为从节点,并开始复制某个主节点这一信息发送给集群中其他
节点,从节点复制主节点数据相当于单机的主从复制 - 集群中每个节点都会定期向其他节点发送PING消息来检测对方是否在线,
如果没有及时回复PONG消息那么该节点就被标记为疑似下线,半数以上处理
槽的主节点认为某个主节点疑似下线那么该主节点标记已下线 - 当从节点发现正在复制的主节点下线时开始对下线主节点进行故障转移
- 从从节点中选出一个节点,该节点会执行slaveof no one命令成为主节点
- 新的主节点会撤销所有对已下线主节点的槽节派,并将这些槽全部指派给
自己 - 新的主节点向集群广播一条PONG消息表示为新的主节点
- 新的主节点开始接收和处理槽有关的命令请求,故障转移完成
选举新的主节点
- 在故障转移阶段,需要由主节点投票选出哪个从节点成为新的主节点;从
节点选举胜出需要的票数为N/2+1;其中N为主节点数量(包括故障主节点),
但故障主节点实际上不能投票。因此为了能够在故障发生时顺利选出从节点
,集群中至少需要3个主节点(且部署在不同的物理机上) - 从节点发现自己正在复制的节点下线时会向集群广播一条消息,主节点就
会给这个从节点投票
消息类型
节点间发送的消息主要分为5种:meet消息、ping消息、pong消息、fail消息、
publish消息
- MEET消息:在节点握手阶段,当节点收到客户端的CLUSTER MEET命令时
,会向新加入的节点发送MEET消息,请求新节点加入到当前集群;新节点收
到MEET消息后会回复一个PONG消息 - PING消息:集群里每个节点每秒钟会选择部分节点发送PING消息,接收者
收到消息后会回复一个PONG消息。PING消息的内容是自身节点和部分其他节点
的状态信息;作用是彼此交换信息,以及检测节点是否在线。PING 消息使用
Gossip协议发送 - PONG消息:PONG消息封装了自身状态数据。可以分为两种:第一种是在接
到MEET/PING消息后回复的PONG消息;第二种是指节点向集群广播PONG消息
,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会
广播PONG消息 - FAIL消息:当一个主节点判断另一个主节点进入FAIL状态时,会向集群广
播这一FAIL消息;接收节点会将这一FAIL消息保存起来,便于后续的判断 - PUBLISH消息:节点收到PUBLISH命令后,会先执行该命令,然后向集群广
播这一消息,接收节点也会执行该PUBLISH命令
缓存中的工作模式
Cache-Aside
最常见的模式,可以翻译为旁路缓存或边缘缓存。缓存作为数据库(或存储)的
补充,数据的获取策略是,如果缓存中存在,则从缓存获取,如果不存在,则
从数据库获取,并写入缓存
Read-Through
把数据库藏在缓存背后,一切请求交由缓存响应。也就是说,如果命中缓存,则
直接从缓存获取,如果没有命中,则从数据库中查询,写入缓存后再由缓存返
回,应用这种模式,写入缓存的操作会阻塞请求的响应,我觉得其实大部分情
况下没有必要使用
Write-Through
请求更新数据,如果该数据在缓存中存在,则先更新缓存,再更新数据库。
Write-Back
请求更新数据,更新缓存,至于数据库什么时候更新,不一定,有机会再更新,
可以攒一波再更新,有缓存在就行。这种异步的方式一听就有数据不一致的风险
,但因为够快,所以在一些要求高并发大吞吐量的系统中比较常见。其实高并发
的一个核心解决方案就是缓存,高并发的复杂性很大程度上取决于缓存方案的
复杂性
数据一致性问题
缓存的数据与数据库由于各种原因产生差异。
一个系统,如果数据都是不变的,应用Cache-Aside 模式,可以做到缓存中
的数据永远和数据库中一致,需要考虑的就是缓存什么时候过期,或者缓存
更新的算法,做到尽可能地找出热点数据即可。
但大部分系统是要更新数据的,数据更新了缓存没有及时更新,有时候没有问
题,但在一些场景下不能容忍,比如支付宝,你买了东西一看钱没变,于是疯
狂买买买,后来突然一下钱全没了,这谁顶的住对不对。
缓存穿透
引发缓存穿透的情形一般有两种,一是大量查询一个数据库里也没有的数据,
这种数据正常不会被缓存,结果每次都要到数据库里兜一圈。那我们可以设置
一个规则,数据库没有的数据我们也缓存起来,值设置成空就行了。
另一种情形是,数据库里有这个数据,之前从没人查询过,但突然有那么一瞬
间来了一大波请求,缓存根本来不及反应,压力就全都到了数据库上。这种怎
么办?两种办法,一是限流,二是预判。
限流好理解,请求少了就反应的过来了。预判怎么预判?你怎么知道哪个数据
会被频繁访问?在请求排山倒海般到来之前,先把它填充到缓存里就完事儿
了。(这种做法通常称为缓存预热)
缓存穿透情况的处理流程
- 用户请求
- 缓存中是否存在对应的数据
- 数据库中是否存在对应的数据
缓存穿透解决方法
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返
回给客户端。比如查询的数据库id 不能小于 0、传入的邮箱格式不对的时候直
接返回错误消息给客户端等等
- 缓存无效key 如果缓存和数据库都查不到某个key的数据就写一个到Redis
中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方
式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不
同的请求 key,会导致Redis 中缓存大量无效的key 。很明显,这种方案并
不能从根本上解决此问题1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public Object getObjectInclNullById(Integer id) {
// 从缓存中获取数据
Object cacheValue = cache.get(id);
// 缓存为空
if (cacheValue == null) {
// 从数据库中获取
Object storageValue = storage.get(key);
// 缓存空对象
cache.set(key, storageValue);
// 如果存储数据为空,需要设置一个过期时间(300秒)
if (storageValue == null) {
// 必须设置过期时间,否则有被攻击的风险
cache.expire(key, 60 * 5);
}
return storageValue;
}
return cacheValue;
} - 布隆过滤器 布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常
方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断key 是
否合法,具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中
,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不
存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程
布隆过滤器
布隆过滤器:一种数据结构,是由一串很长的二进制向量组成,可以将其看成
一个二进制数组。既然是二进制,那么里面存放的不是0,就是1,但是初始默
认值都是0。布隆过滤器可以判断某个数据一定不存在但是无法判断一定存在
- 添加数据
当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出一个
值,然后将这个值所在的方格置为1 - 判断数据是否存在
通过上面自定义的几个哈希函数,分别算出各个值,然后看其对应的地方是否
都是1,如果存在一个不是1的情况,那么我们可以说,该新数据一定不存在于
这个布隆过滤器中 - 布隆过滤器优缺点
- 优点:优点很明显,二进制组成的数组,占用内存极少,并且插入和查询速
度都足够快 - 缺点:随着数据的增加,误判率会增加;还有无法判断数据一定存在;另外
还有一个重要缺点,无法删除数据
Redis实现布隆过滤器
在Redis中,Bitmaps 提供了一套命令用来操作字符串中的每一个位
- 设置值
1
setbit key offset value
- 获取值
1
gitbit key offset
- 获取位图指定范围值为1的个数,start和end指定的是字节的个数
1
bitcount key [start end]
Redisson 是用于在Java 程序中操作 Redis 的库,利用Redisson我们可
以在程序中轻松地使用Redis
1 | Config config = new Config(); |
guava 工具
1 | BloomFilter<String> bloomFilter = BloomFilter.create( |
缓存雪崩
其实本质上雪崩和穿透是一类问题,只是出现的阶段不一样,穿透是缓存已经
稳定建立起来了,雪崩是缓存突然同时过期了。当然还有一种情况,就是完全
还没有缓存的时候,一大波请求涌入。比如缓存没做持久化,结果机房断电了
,重启之后就是没有缓存的。
缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据
库短时间内承受大量请求。 这就好比雪崩一样。还有一种缓存雪崩的场景是:
有一些被大量访问数据(热点缓存)在某一时刻大面积失效,导致对应的请
求直接落到了数据库上
缓存雪崩解决办法
- 针对 Redis 服务不可用的情况:
- 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用
- 限流,避免同时处理大量的请求
- 针对热点缓存失效的情况
- 设置不同的失效时间比如随机设置缓存的失效时间
- 缓存永不失效