单元素操作是基础
单元素操作,是指每一种集合类型对单个数据实现增删改查
- 例如,Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等
- 这些操作的复杂度由集合采用的数据结构决定,例如,HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1)
- Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)
注意点
- 集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素
- 此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的
- 例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了
范围操作非常耗时
范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据
- 比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据
- 比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE
- 这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免
优化点
- Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN)
- 这类操作实现了渐进式遍历,每次只返回有限数量的数据
- 这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞
统计操作通常高效
统计操作是指集合类型对集合中所有元素个数的记录
- 例如 LLEN 和 SCAR,这类操作复杂度只有 O(1)
- 这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时
- 这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作
例外情况只有几个
例外情况,是指某些数据结构的特殊记录
- 例如压缩列表和双向链表都会记录表头和表尾的偏移量
- 这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说
- 它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作
Server性能问题
问题描述
- 任意一个请求在server中一旦发生耗时,都会影响整个server的性能
- 也就是说后面的请求都要等待前面这个耗时请求处理完成,自己才能被处理
例子
- 操作bigkey
- 写入一个bigkey在分配内存时需要消耗更多时间
- 同样,删除bigkey释放内存同样会产生耗时
- 使用复杂度过高的命令
- 例如 SORT/SUNION/ZUNIONSTORE,或者O(N)命令,但是N很大
- 例如 lrange key 0 -1 一次查询全量数据
- 大量key集中过期
- Redis的过期机制也是在主线程中执行的,大量key集中过期会导致处理一个请求时,耗时都在删除过期key,耗时变长
- 淘汰策略
- 淘汰策略也是在主线执行的,当内存超过Redis内存上限后,每次写入都需要淘汰一些key,也会造成耗时变长
- AOF刷盘开启always机制
- 每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis的性能
- 主从全量同步生成RDB
- 虽然采用fork子进程生成数据快照
- 但fork这一瞬间也是会阻塞整个线程,实例越大,阻塞时间越久
并发问题
问题描述
- 并发量非常大时,单线程读写客户端IO数据产生性能瓶颈,虽然采用IO多路复用机制
- 但是读写客户端依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核
问题解决方案
- 需要业务人员规避
- Redis 4.0推出了lazy-free机制
- 把bigkey释放内存的耗时操作放在异步线程中执行,降低对主线程的影响
- Redis 6.0 推出了多线程
- 可以在高并发场景下利用CPU多核多线程读写客户端数据,进一步提升server性能
- 当然,只是针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的
Redis采用fork子进程重写AOF文件时的潜在阻塞风险
fork子进程可能阻塞主进程
- fork子进程,fork这个瞬间一定是会阻塞主线程的(注意,fork时并不会一次性拷贝所有内存数据给子进程)
- fork采用操作系统提供的写时复制(Copy On Write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成的长时间阻塞问题
- 但fork子进程需要拷贝进程必要的数据结构,其中有一项就是拷贝内存页表(虚拟内存和物理内存的映射索引表)
- 这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的
- 阻塞时间取决于整个实例的内存大小,实例越大,内存页表越大,fork阻塞时间越久
- 拷贝内存页表完成后,子进程与父进程指向相同的内存地址空间,也就是说此时虽然产生了子进程,但是并没有申请与父进程相同的内存大小
- 那什么时候父子进程才会真正内存分离呢?
写时复制
顾名思义,就是在写发生时,才真正拷贝内存真正的数据- 这个过程中,父进程也可能会产生阻塞的风险,就是下面介绍的场景
父子进程的“写时复制”
- fork出的子进程指向与父进程相同的内存地址空间,此时子进程就可以执行AOF重写,把内存中的所有数据写入到AOF文件中
- 但是此时父进程依旧是会有流量写入的
- 如果父进程操作的是一个已经存在的key,那么这个时候父进程就会真正拷贝这个key对应的内存数据,申请新的内存空间,这样逐渐地,父子进程内存数据开始分离,父子进程逐渐拥有各自独立的内存空间
- 因为内存分配是以页为单位进行分配的,默认4k,如果父进程此时操作的是一个bigkey,重新申请大块内存耗时会变长,可能会产阻塞风险
- 另外,如果操作系统开启了内存大页机制(Huge Page,页面大小2M),那么父进程申请内存时阻塞的概率将会大大提高
- 所以在Redis机器上需要关闭Huge Page机制
- Redis每次fork生成RDB或AOF重写完成后,都可以在Redis log中看到父进程重新申请了多大的内存空间
为什么要关闭Huge Page机制
- Huge page对提升TLB命中率比较友好
- 因为在相同的内存容量下,使用huge page可以减少页表项,TLB就可以缓存更多的页表项,能减少TLB miss的开销
- 但是,这个机制对于Redis这种喜欢用fork的系统来说,的确不太友好,尤其是在Redis的写入请求比较多的情况下
- 因为fork后,父进程修改数据采用写时复制,复制的粒度为一个内存页
- 如果只是修改一个256B的数据,父进程需要读原来的内存页,然后再映射到新的物理地址写入
- 一读一写会造成读写放大。如果内存页越大(例如2MB的大页),那么读写放大也就越严重,对Redis性能造成影响
Huge page在实际使用Redis时是建议关掉的
Redis 写读比例差不多在 8:2 左右, RDB 做持久化有什么风险吗?
问题
- 使用一个 2 核 CPU、4GB 内存、500GB 磁盘的云主机运行 Redis,Redis 数据库的数据量大小差不多是 2GB,我们使用了 RDB 做持久化保证
- 当时 Redis 的运行负载以修改操作为主,写读比例差不多在 8:2 左右,也就是说,如果有 100 个请求,80 个请求执行的是修改操作
- 在这个场景下,用 RDB 做持久化有什么风险吗?你能帮着一起分析分析吗?
解答
内存资源风险
- Redis fork 子进程做RDB持久化,由于写的比例为80%,那么在持久化进程中, "写时复制"会重新分配整个实例80%的内存副本
- 大约需要重写分配1.6GB内存空间,这样整个系统的内存使用接近饱和,如果此时父进程又有大量新key写入,很快机器内存就会被吃光
- 如果机器开启了Swap机制,那么Redis会有一部分数据被换到磁盘上
- 当Redis访问这部分在磁盘上的数据时候,性能会急剧下降,已经达不到高性能的标准(可以理解为武功被废)
- 如果机器没有开启Swap,会直接触发OOM,父子进程会面临被系统kill掉风险
CPU资源风险
- 虽然子进程在做RDB持久化,但生成RDB快照过程会消耗大量的CPU资源,虽然Redis处理请求是单线程
- 但Redis Server还有其他线程在后台工作,
- 例如AOF每秒刷盘
- 异步关闭文件描述符这些操作
- 由于机器只有2核CPU,这也意味着父进程占用了超过一半的CPU资源,此时子进程做RDB持久化,可能会产生CPU竞争
- 导致的结果就是父进程处理请求延迟增长,子进程生成RDB快照的时间也会变长,整个Redis Server性能下降
其他
还有Redis进程是否绑定了CPU,如果绑定了CPU,那么子进程会继承父进程的CPU亲和性属性
- 子进程必然会与父进程争夺同一个CPU资源,整个Redis Server的性能必然会受到影响!
- 所以如果Redis需要开启定时RDB和AOF重写,进程一定不要绑定CPU
主从全量同步使用RDB而不使用AOF的原因
原因1
- RDB文件内容是经过压缩的二进制数据(不同数据类型数据做了针对性优化),文件很小
- 而AOF文件记录的是每一次写操作的命令,写操作越多,文件会变得很大,其中还包括很多对同一个key的多次冗余操作
- 在主从全量数据同步时,传输RDB文件可以尽量降低对主库机器网络带宽的消耗
- 从库在加载RDB文件时
- 一是文件小,读取整个文件的速度会很快
- 二是因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快
- 而AOF需要依次重访每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比RDB会慢很多
- 所以使用RDB进行主从全量同步的成本最低
原因2
- 假设要使用AOF做全量同步,意味着必须打开AOF功能,打开AOF就要选择文件刷屏的策略,选择不当会严重影响Redis性能
- 而RDB只有在需要定时备份和主从全量同步数据时才会触发生成一次快照
- 而在很多丢失数据不敏感的业务场景,其实是不需要开启AOF的
主从库切换的时候,客户端能够正常请求吗?
失败时间
- 如果客户端使用读写分离,那么读请求可以在从库上正常执行,不会受到影响
- 但是由于此时主库已经挂了,而且哨兵还没有选出新的主库,所以在这期间写请求会失败,
失败持续的时间 = 哨兵切换主从的时间 + 客户端感知到新主库
的时间
业务处理
- 如果不想让业务感知异常,客户端只能把写失败的请求先缓存起来写入消息队列中间件,等哨兵切换完主从后,再把这些写请求发给新的主库
- 但这种场景只适合对写入请求返回值不敏感的业务,而且还需要业务层做适配
- 另外主从切换时间过长,也会导致客户端或消息队列中间件缓存写请求过多,切换完成之后重放这些请求的时间变长
哨兵检测主库的配置
- 哨兵检测主库多久没有响应就提升从库为新的主库,这个时间是可以配置的(down-after-milliseconds参数)
- 配置的时间越短,哨兵越敏感,哨兵集群认为主库在短时间内连不上就会发起主从切换,这种配置很可能因为网络拥塞但主库正常而发生不必要的切换
- 当然,当主库真正故障时,因为切换得及时,对业务的影响最小
- 如果配置的时间比较长,哨兵越保守,这种情况可以减少哨兵误判的概率,但是主库故障发生时,业务写失败的时间也会比较久,缓存写请求数据量越多
哨兵集群问题
问题1
- Redis 1主4从,5个哨兵,哨兵配置quorum为2,如果3个哨兵故障,当主机宕机时,哨兵能否判断主库
客观下线
- 能否自动切换?
解答
回答
- 哨兵集群可以判定主库
主观下线
- 由于quorum=2,所以当一个哨兵判断主库
主观下线
后,询问另外一个哨兵后也会得到同样的结果,2个哨兵都判定主观下线
- 达到了quorum的值,因此,哨兵集群可以判定主库为
客观下线
- 由于quorum=2,所以当一个哨兵判断主库
- 哨兵不能完成主从切换
- 哨兵标记主库的
客观下线
后,选举哨兵领导者
时,一个哨兵必须拿到超过多数的选票(5/2 + 1 = 3票) - 但是目前只有2个哨兵活着,无论怎么投票,一个哨兵最多只拿到2张票,永远无法达到多数选票的结果
- 哨兵标记主库的
回答解析
投票选举过程
场景a
- 哨兵A先判定主库
主观下线
,然后马上询问哨兵B(注意: 此时哨兵B只是被动接受询问,并没有去询问A,也就是它还没有进入判断客观下线
的流程) - 哨兵B回复主库已
主观下线
,达到quorum=2后。哨兵A此时可以判定主库客观下线
- 此时哨兵A马上可以向其他哨兵发起成为
哨兵领导者
的投票,哨兵B收到投票请求后,由于自己还没有询问哨兵A进入客观下线
的流程 - 所以哨兵B是可以给哨兵A投票确认的,这样哨兵A就已经拿到2票了
- 等稍后哨兵B也判定
主观下线
后想成为领导者,因为它已经给人投过票了,所以这一轮自己就不能再成为领导者
场景b
- 哨兵A和哨兵B同时判定主库
主观下线
,然后同时询问对方后得到可以客观下线
的结论 - 此时它们各自给自己投上1票,然后向其他哨兵发起投票请求,但是因为各自都给自己投票了,因此,各自都拒绝了对方的投票请求
- 这样2个哨兵各自持有1票
总结
- 场景a是1个哨兵拿到2票,场景b是2个哨兵各自有1票,这2种情况都不满足大多数选票(3票)的结果,因此无法完成主从切换
- 经过测试发现,场景b发生的概率非常小,只有2个哨兵同时进入判定
主观下线
的流程时才可以发生
问题2
- 哨兵实例是不是越多越好?
解答
- 并不是,哨兵在判定
主观下线
和选举哨兵领导者
时,都需要和其他节点进行通信交换信息 - 哨兵实例越多,通信的次数也就越多,而且部署多个哨兵时,会分布不同机器上,节点越多带来的机器故障风险也会越大
- 这些问题都会影响到哨兵的通信和选举,出问题时也就意味者选择的事件会变成,切换主从的时间变久
- 调大down-after-milliseconds值,对减少误判是不是有好处?
- 是有好处的,适当调大down-after-milliseconds值,当哨兵与主库之间网络存在短时波动时,可以降低误判的概率
- 但是调大down-after-milliseconds值也意味着主从切换的时间会变长
- 对业务的影响时间越久,我们需要根据实际场景进行权衡,设置合理的阈值
Redis Cluster不采用把key直接映射到实例的方式,而采用哈希槽的方式原因
无法估计存储key的数量
- 整个集群存储key的数据是无法估计的,key的数量非常多时,直接记录每个key对应的实例映射关系,这个映射表会非常庞大
- 这个映射表无论是存储在服务端还是客户端都占用非常大的内存空间
节点路由
- Redis Cluster采用无中心化的模式(无proxy,客户端与服务端直连),客户端在某个节点访问一个key,如果这个key不在这个节点上
- 这个节点需要有纠正客户路由到正确节点的能力(MOVED响应),这就需要节点之间相互交换路由表,每个节点拥有整个集群完成的路由关系
- 如果存词的都是key与实例的对应关系,节点之间交换信息也会变得非常庞大,消耗过多的网络资源
- 而且就算交换完成,相当于每个节点都需要额外存储其他节点的路由表,内存占用过大造成资源浪费
数据迁移
- 当集群在扩容、缩容、数据均衡时,节点之间会发生数据迁移,迁移时需要修改每个key的映射关系,维护成本高
- 而在中间增加一层哈希槽,可以把数据和节点解藕,key通过Hahs计算,只需要关系映射到那个哈希槽
- 然后再通过哈希槽和节点的映射表找到节点,相当于消耗了很少的CPU资源,不但让数据分布更均匀,还可以让这个映射表更小,利于客户端和服务端保存,节点之间交换信息时也会变得轻量
- 当集群在扩容、缩容、数据均衡时,节点之间的操作例如数据迁移,都以哈希槽为基本单位进行操作,简化了节点扩容、缩容的难度,便于集群的维护和管理
Redis集群相关理解
Redis集群的作用
- Redis使用集群就是为了解决单个节点数据量大、写入量产生的性能瓶颈的问题
- 多个节点组成一个集群,可以提高集群的性能和可靠性,但随之而来的是集群的管理问题
集群的管理问题
请求路由
一般都是采用哈希槽的映射关系表找到指定节点,然后在这个节点上操作的方案
- Redis Cluster在每个节点记录完整的映射关系(便于纠正客户端的错误路由请求),同时也发给客户端让客户端缓存一份
- 便于客户端直接找到指定节点
- 客户端与服务端配合完成数据的路由,这需要业务在使用Redis Cluster时,必须升级为集群版的SDK才支持客户端和服务端的协议交互
- 其他Redis集群化方案例如Twemproxy、Codis都是中心化模式(增加Proxy层)
- 客户端通过Proxy对整个集群进行操作,Proxy后面可以挂N多个Redis实例,Proxy层维护了路由的转发逻辑
- 操作Proxy就像是操作一个普通Redis一样,客户端也不需要更换SDK,而Redis Cluster是把这些路由逻辑做在了SDK中
- 当然,增加一层Proxy也会带来一定的性能损耗
数据迁移(扩容/缩容/数据平衡)
当集群节点不足以支撑业务需求时,就需要扩容节点,扩容就意味着节点之间的数据需要做迁移,而迁移过程中是否会影响到业务,这也是判定一个集群方案是否成熟的标准
- Twemproxy不支持在线扩容,它只解决了请求路由的问题,扩容时需要停机做数据重新分配
- 而Redis Cluster和Codis都做到了在线扩容(不影响业务或对业务的影响非常小),重点就是在数据迁移过程中,客户端对于正在迁移的key进行操作时,集群如何处理?还要保证响应正确的结果?
- Redis Cluster和Codis都需要服务端和客户端/Proxy层互相配合
- 迁移过程中,服务端针对正在迁移的key,需要让客户端或Proxy去新节点访问(重定向)
- 这个过程就是为了保证业务在访问这些key时依旧不受影响,而且可以得到正确的结果
- 由于重定向的存在,所以这个期间的访问延迟会变大。等迁移完成之后,Redis Cluster每个节点会更新路由映射表,同时也会让客户端感知到,更新客户端缓存
- Codis会在Proxy层更新路由表,客户端在整个过程中无感知
- 除了访问正确的节点之外,数据迁移过程中还需要解决异常情况(迁移超时、迁移失败)、性能问题(如何让数据迁移更快、bigkey如何处理)
- Redis Cluster的数据迁移是同步的,迁移一个key会同时阻塞源节点和目标节点,迁移过程中会有性能问题
- 而Codis提供了异步迁移数据的方案,迁移速度更快,对性能影响最小,当然,实现方案也比较复杂