我们都是架构师!
关注架构师(JiaGouX),添加“星标”
获取每天技术干货,一起成为牛逼架构师
技术群请加若飞:1321113940 进架构师群
投稿、合作、版权等邮箱:admin@137x.com
三万字长文,建议收藏,方便查阅
基础篇
问题:什么是redis
?
答:Redis
是一个基于内存的高性能key-value
数据库。
redis
可以用在哪些业务上?答:redis
可以做很多事情,比如:
Redis
有哪些数据结构?答:Redis
是一种Key-Value
的模型,key
是字符串类型,而常说的数据结构一般是指value
的数据结构,一般包含以下类型。最普通常见的,字符串(String
),字典(Hash
),列表(List
),集合(Set
),有序集合(SortedSet
)。
高级数据结构,HyperLogLog
,Geo
,bitmap
更高级用户可能还知道Redis Module
,像 BloomFilter
,RedisSearch
,Redis-ML
。
Redis
有哪些好处?答:
速度快,因为数据存在内存中,类似于HashMap
,HashMap
的优势就是查找和操作的时间复杂度都是O(1)
支持丰富数据类型,支持string
,list
,set
,sorted
set
,hash
支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行
丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除。
redis
相比memcached
有哪些优势?答:优势如下
memcached
所有的值均是简单的字符串,redis
作为其替代者,支持更为丰富的数据类型
redis
的速度比memcached
快很多
redis
可以持久化其数据
redis
有什么缺点?答:主要有以下四点缺点:
缓存和数据库双写一致性问题
缓存雪崩问题
缓存击穿问题
缓存的并发竞争问题
Redis
提供了几种数据淘汰策略?该怎么选择?答:
volatile-lru
:从已经设置过期时间的数据集中,挑选最近最少使用的数据淘汰。
volatile-ttl
:从已经设置过期时间的数据集中,挑选即将要过期的数据淘汰。
volatile-random
:从已经设置过期时间的数据集中,随机挑选数据淘汰。
volatile-lfu
:从已经设置过期时间的数据集中,会使用LFU算法选择设置了过期时间的键值对。
allkeys-lru
:从所有的数据集中,挑选最近最少使用的数据淘汰。
allkeys-random
:从所有的数据集中,随机挑选数据淘汰。
no-enviction
:禁止淘汰数据,如果redis
写满了将不提供写请求,直接返回错误。
注意这里的6种机制,volatile
和allkeys
规定了是对已设置过期时间的数据集淘汰数据还是从全部数据集淘汰数据,后面的lru、ttl
以及random
是三种不同的淘汰策略,再加上一种no-enviction
永不回收的策略。
使用策略规则:
如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用allkeys-lru
如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用allkeys-random
redis
需要把所有数据放到内存中?答:Redis
为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据写入磁盘。所以redis
具有快速和数据持久化的特征。如果不将数据放在内存中,磁盘I/O速度为严重影响redis
的性能。在内存越来越便宜的今天,redis
将会越来越受欢迎。
Redis
是单线程的吗?答:Redis
是单线程的,主要是指Redis
的网络IO
和键值对读写是由一个线程来完成的,这也是Redis
对外提供键值存储服务的主要流程。但Redis
的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
Redis6.0
之前为什么一直不使用多线程?答:官方曾做过类似问题的回复:使用Redis时,几乎不存在CPU成为瓶颈的情况, Redis主要受限于内存和网络。例如在一个普通的Linux系统上,Redis通过使用pipelining每秒可以处理100万个请求,所以如果应用程序主要使用O(N)或O(log(N))的命令,它几乎不会占用太多CPU。
使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis通过AE事件模型以及IO多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。
Redis6.0
为什么要引入多线程呢?答:Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,对于小数据包,Redis服务器可以处理80,000到100,000 QPS,这也是Redis处理的极限了,对于80%的公司来说,单线程的Redis已经足够使用了。
但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如要管理的Redis服务器太多,维护代价大;某些适用于单个Redis服务器的命令不适用于数据分区;数据分区无法解决热点读/写问题;数据偏斜,重新分配和放大/缩小变得更加复杂等等。
从Redis自身角度来说,因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:
提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式
使用多线程充分利用多核,典型的实现比如 Memcached。
协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,redis支持多线程主要就是两个原因:
可以充分利用服务器 CPU 资源,目前主线程只能利用一个核
多线程任务可以分摊 Redis 同步 IO 读写负荷
答:Redis6.0
的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf
配置文件:io-threads-do-reads yes
答:开启多线程后,还需要设置线程数,否则是不生效的。同样修改redis.conf
配置文件
关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。
答:Redis 作者 antirez 在 RedisConf 2019分享时曾提到:Redis 6 引入的多线程 IO 特性对性能提升至少是一倍以上。国内也有大牛曾使用unstable版本在阿里云esc进行过测试,GET/SET 命令在4线程 IO时性能相比单线程是几乎是翻倍了。
测试环境:Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge 测试结果:
详见:zhuanlan.zhihu.com/p/76788470
说明1:这些性能验证的测试并没有针对严谨的延时控制和不同并发的场景进行压测。数据仅供验证参考而不能作为线上指标。
说明2:如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU耗时的时候才建议采用,否则使用多线程没有意义。所以估计80%的公司开发人员看看就好。
答:如图
流程简述如下:
主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
主线程阻塞等待 IO 线程读取 socket 完毕
主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行
主线程阻塞等待 IO 线程将数据回写 socket 完毕
解除绑定,清空等待队列
该设计有如下特点:
IO 线程要么同时在读 socket,要么同时在写,不会同时读或写
IO 线程只负责读写 socket 解析命令,不负责命令处理
答:从上面的实现机制可以看出,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。
Redis
线程中经常提到IO
多路复用,如何理解?redis
如何做到高可用?答:redis
具备的高可用,其实包含两层含义:一是数据尽量少丢失,二是服务尽量少中断。对于前者redis
使用AOF
和RDB
两种持久化方式保证,对于后者Redis
的做法就是增加副本冗余量,将一份数据同时保存在多个实例上。
高级内容
redis
的并发竞争问题如何解决?答:Redis
为单进程单线程模式,采用队列模式将并发访问变为串行访问。Redis
本身没有锁的概念,Redis
对于多个客户端连接并不存在竞争,但是在Jedis
客户端对Redis
进行并发访问时会发生连接超时、数据转换错误、阻塞、客户端关闭连接等问题,这些问题均是由于客户端连接混乱造成。
对此有两种解决方法:
客户端角度,为保证每个客户端间正常有序与Redis
进行通信,对连接进行池化,同时对客户端读写Redis
操作采用内部锁synchronized
。
服务器角度,利用setnx
实现锁。
注:对于第一种,需要应用程序自己处理资源的同步,可以使用的方法比较通俗,可以使用synchronized
也可以使用lock
;
第二种需要用到Redis
的setnx
命令,但是需要注意一些问题。
redis
过期键的删除策略?答:
定时删除:在设置键的过期时间的同时,创建一个timer
,让定时器在键的过期时间到达时,立即执行对键的删除操作。(主动删除)
对内存友好,但是对cpu
时间不友好,有较多过期键的而情况下,删除过期键会占用相当一部分cpu
时间。
惰性删除:放任过期键不管,但是每次从键空间中获取键时,都检查取到的键是否过去,如果过期就删除,如果没过期就返回该键。(被动删除)
对cpu
时间友好,程序只会在取出键的时候才会对键进行过期检查,这不会在删除其他无关过期键上花费任何cpu
时间,但是如果一个键已经过期,而这个键又保留在数据库中,那么只要这个过期键不被删除,他所占用的内存就不会释放,对内存不友好。
定期删除:每隔一段时间就对数据库进行一次检查,删除里面的过期键。(主动删除)
采用对内存和cpu
时间折中的方法,每个一段时间执行一次删除过期键操作,并通过限制操作执行的时长和频率来减少对cpu
时间的影响。难点在于,选择一个好的策略来设置删除操作的时长和执行频率。
答:哨兵是对redis
进行实时的监控,主要有两个功能。
监测主数据库和从数据库是否正常运行。
当主数据库出现故障的时候,可以自动将一个从数据库转换为主数据库,实现自动切换。
答:哨兵监控也是有集群的,会有多个哨兵进行监控,当判断发生故障的哨兵达到一定数量的时候才进行修复。一个健壮的部署至少需要三个哨兵实例。
每个Sentinel
以每秒钟一次的频率向它所知的Master
,Slave
以及其他 Sentinel
实例发送一个 PING
命令
如果一个实例(instance
)距离最后一次有效回复 PING
命令的时间超过 down-after-milliseconds
选项所指定的值, 则这个实例会被 Sentinel
标记为主观下线。
如果一个Master
被标记为主观下线,则正在监视这个Master
的所有 Sentinel
要以每秒一次的频率确认Master
的确进入了主观下线状态。
当有足够数量的 Sentinel
(大于等于配置文件指定的值)在指定的时间范围内确认Master
的确进入了主观下线状态, 则Master
会被标记为客观下线
在一般情况下, 每个 Sentinel
会以每 10 秒一次的频率向它已知的所有Master
,Slave
发送 INFO
命令
当Master
被 Sentinel
标记为客观下线时,Sentinel
向下线的 Master
的所有 Slave
发送 INFO
命令的频率会从 10 秒一次改为每秒一次
若没有足够数量的 Sentinel
同意 Master
已经下线, Master
的客观下线状态就会被移除。若 Master
重新向 Sentinel
的 PING
命令返回有效回复, Master
的主观下线状态就会被移除。
redis
常见性能问题和解决方案?答:
Master
写内存快照,save
命令调度rdbSave
函数,会阻塞主线程的工作,当快照比较大时对性能影响是非常大的,会间断性暂停服务,所以Master
最好不要写内存快照。
Master AOF
持久化,如果不重写AOF
文件,这个持久化方式对性能的影响是最小的,但是AOF
文件会不断增大,AOF
文件过大会影响Master
重启的恢复速度。Master
最好不要做任何持久化工作,包括内存快照和AOF
日志文件,特别是不要启用内存快照做持久化,如果数据比较关键,某个Slave
开启AOF
备份数据,策略为每秒同步一次。
Master
调用BGREWRITEAOF
重写AOF
文件,AOF
在重写的时候会占大量的CPU
和内存资源,导致服务load
过高,出现短暂服务暂停现象。
Redis
主从复制的性能问题,为了主从复制的速度和连接的稳定性,Slave
和Master
最好在同一个局域网内
答:官方给出答案是因为Redis
是基于内存的操作,CPU
不是Redis
的瓶颈,Redis
的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU
不会成为瓶颈,那就顺理成章地采用单线程的方案了。
答:
Redis是纯内存数据库,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在IO上,所以读取速度快。
再说一下IO,Redis使用的是非阻塞IO,IO多路复用,使用了单线程来轮询描述符,将数据库的开、关、读、写都转换成了事件,减少了线程切换时上下文的切换和竞争。
Redis采用了单线程的模型,保证了每个操作的原子性,也减少了线程的上下文切换和竞争。
另外,数据结构也帮了不少忙,Redis全程使用hash结构,读取速度快,还有一些特殊的数据结构,对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如,跳表,使用有序的数据结构加快读取的速度。
还有一点,Redis采用自己实现的事件分离器,效率比较高,内部采用非阻塞的执行方式,吞吐能力比较大。
Redis
持久化方式是什么?答:两种持久化方式即AOF日志和RDB快照。
AOF
和RDB
,如果同时使用AOF
和RDB
,redis
重启会使用哪个构建数据?答:AOF
和RDB
都是redis
持久化方案。RDB
持久化机制,对redis
中的数据执行周期性的持久化。AOF
机制对每条写入命令作为日志,以append-only
的模式写入一个日志文件中,在redis
重启的时候,可以通过回放AOF
日志中的写入指令来重新构建整个数据集。
如果我们想要redis
仅仅作为纯内存的缓存来用,那么可以禁止RDB
和AOF
所有的持久化机制。
如果同时使用RDB
和AOF
两种持久化机制,那么在redis
重启的时候,会使用AOF
来重新构建数据,因为AOF
中的数据更加完整。
RDB
持久化机制的优缺点?答:优缺点如下。
优点:
(1)RDB
会生成多个数据文件,每个数据文件都代表了某一个时刻中redis
的数据,这种多个数据文件的方式,非常适合做冷备,可以将这种完整的数据文件发送到一些远程的安全存储上去,比如说Amazon
的S3
云服务上去,在国内可以是阿里云的ODPS
分布式存储上,以预定好的备份策略来定期备份redis
中的数据
(2)RDB
对redis
对外提供的读写服务,影响非常小,可以让redis
保持高性能,因为redis
主进程只需要fork
一个子进程,让子进程执行磁盘IO
操作来进行RDB
持久化即可
(3)相对于AOF
持久化机制来说,直接基于RDB
数据文件来重启和恢复redis
进程,更加快速
缺点:
(1)如果想要在redis
故障时,尽可能少的丢失数据,那么RDB
没有AOF
好。一般来说,RDB
数据快照文件,都是每隔5分钟,或者更长时间生成一次,这个时候就得接受一旦redis
进程宕机,那么会丢失最近5分钟的数据
(2)RDB
每次在fork
子进程来执行RDB
快照数据文件生成的时候,如果数据文件特别大,可能会导致对客户端提供的服务暂停数毫秒,或者甚至数秒
AOF
持久化机制的优缺点?答:优缺点如下:
优点:
(1)AOF
可以更好的保护数据不丢失,一般AOF
会每隔1秒,通过一个后台线程执行一次fsync
操作,最多丢失1秒钟的数据
(2)AOF
日志文件以append-only
模式写入,所以没有任何磁盘寻址的开销,写入性能非常高,而且文件不容易破损,即使文件尾部破损,也很容易修复
(3)AOF
日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。因为在rewrite log
的时候,会对其中的指导进行压缩,创建出一份需要恢复数据的最小日志出来。再创建新日志文件的时候,老的日志文件还是照常写入。当新的merge
后的日志文件ready
的时候,再交换新老日志文件即可。
(4)AOF
日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall
命令清空了所有数据,只要这个时候后台rewrite
还没有发生,那么就可以立即拷贝AOF
文件,将最后一条flushall
命令给删了,然后再将该AOF
文件放回去,就可以通过恢复机制,自动恢复所有数据
缺点:
(1)对于同一份数据来说,AOF
日志文件通常比RDB
数据快照文件更大
(2)AOF
开启后,支持的写QPS
会比RDB
支持的写QPS
低,因为AOF
一般会配置成每秒fsync
一次日志文件,当然,每秒一次fsync
,性能也还是很高的
(3)以前AOF
发生过bug,就是通过AOF
记录的日志,进行数据恢复的时候,没有恢复一模一样的数据出来。所以说,类似AOF
这种较为复杂的基于命令日志/merge/
回放的方式,比基于RDB
每次持久化一份完整的数据快照文件的方式,更加脆弱一些,容易有bug。不过AOF
就是为了避免rewrite
过程导致的bug,因此每次rewrite
并不是基于旧的指令日志进行merge的,而是基于当时内存中的数据进行指令的重新构建,这样健壮性会好很多。
AOF
日志是如何实现的?答:AOF
日志是一种写后日志,“写后”的意思是Redis
是先执行命令,把数据写入内存,然后才记录日志,如下图所示: Redis
收到“set testkey testvalue”
命令后记录的日志为例。
先执行命令,如果命令出错了就报错,而写入日志的命令肯定是正确的,避免出现记录错误命令的情况。
写日志是在命令执行后才记录日志,所以不会阻塞当前命令的写操作。
AOF
的日志何时写入磁盘呢?AOF日志写入磁盘是比较影响性能的,为了平衡性能与数据安全,开发了三种机制,也就是AOF配置项appendfsync的三个可选值:
Always
,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
同步写回可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能。
Everysec
,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
每秒写回采用一秒写回一次的频率,避免了“同步写回”的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中
No
,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
操作系统控制的写回在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在Redis
手中了,只要AOF
记录没有写回磁盘,一旦宕机对应的数据就丢失了;
所以想要获得高性能,就选择No
策略;如果想要得到高可靠性保证,就选择Always
策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择Everysec
策略。
AOF
写日志方式会有什么问题?答:随着接收的写命令越来越多,AOF
文件会越来越大,这事就会带来性能问题,性能问题主要是三个方面:
文件系统本身对文件大小有限制,无法保存过大的文件。
如果文件太大,之后再往里面追加命令记录的话,效率也会变低。
如果发生宕机,AOF
中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到Redis
的正常使用。
所以就需要使用AOF
重写机制重写日志。
AOF
重写机制?答:AOF
在重写时,Redis
根据数据库现状创建一个新的AOF
文件,假如数据库中有键值对:"test":"hello"
,那么重写机制会这样记录:set test hello
。
由上可知AOF
重写机制可以把日志变少。因为在旧日志多条命令,在重写之后就变成一条命令了。以下画图解释:
重写时还有个问题就是重写会不会阻塞主线程?
和AOF
日志由主线程写回不同,重写过程是由后台线程bgrewriteaof
来完成的,这也是为了避免阻塞主线程。
整个重写过程是这样的:
主线程fork
出后台的bgrewriteaof
子进程,fork
会把主线程内存拷贝一份给bgrewriteaof
子进程,这就是数据库此时最新数据,bgrewriteaof
子进程会在不影响主线程的情况下逐一把拷贝的数据写成操作命令,记录在重写日志里。(拷贝)
主线程未阻塞,可以处理新命令,处理之后将命令写入AOF
日志缓冲区,保证AOF
日志完整。(确保AOF
日志完整)
主线程处理的新命令也需要写入AOF
重写日志,保证AOF
重写日志不丢新数据。(确保AOF
重写日志完整)
总的来说,整个过程就是:一处拷贝,两处日志完整。
AOF
何时重写日志呢?或者说什么时候会触发重写 AOF
重写?答:主要有两种触发方式:
手动执行 bgrewriteaof
触发 AOF
重写
在 redis.conf
文件中配置重写的条件,如:
auto-aof-rewrite-min-size 64MB // 当文件小于64M时不进行重写
auto-aof-rewrite-min-percenrage 100 // 当文件比上次重写后的文件大100%时进行重写
复制代码
AOF
日志重写的时候,是由bgrewriteaof
子进程来完成的,不用主线程参与,非阻塞是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?AOF
重写也有一个重写日志,AOF
本身也有一个日志,它为什么不把两个日志共享呢?redis
持久化机制RDB
是怎么样的?答:首先,RDB
出现的原因由于使用AOF
方法进行故障恢复的时候,如果日志特别多,Redis
就会恢复得很缓慢,影响到正常使用。所以出现了RDB
。RDB
叫做内存快照,就是Redis DataBase的缩写。它记录的是某一时刻的数据,保存在磁盘的dump.rdb文件中。所以,在做数据恢复时,我们可以直接把RDB
文件读入内存,很快地完成恢复。
对于使用RDB
来说有几个关键的地方:
它给哪些数据做快照?
为了提供所有数据可靠性,它执行的是全量快照。也就是所有数据做快照。redis
提供了两个命令,save
和bgsave
。
save在主线程中执行,会导致阻塞;
bgsave:创建一个子进程,专门用于写入RDB文件,避免了主线程的阻塞,这也是Redis RDB文件生成的默认配置。
快照时数据能修改吗?
如果redis
在快照时数据不能修改,那无疑是给业务造成损失。也许你会说可以使用bgsave
来避免阻塞,但是注意,避免阻塞和正常处理写操作并不是一回事。如果数据不能修改,虽然没有阻塞,但是主线程只能接受读请求,而写请求不能执行。这肯定不能接受,所以,Redis
就会借助操作系统提供的写时复制技术(Copy-On-Write, COW
),在执行快照的同时,正常处理写操作。bgsave
互不影响。如果修改数据C,这个数据就复制一份,生成数据副本。bgsave
子进程把副本写入RDB
文件,主线程修改数据C不影响。
多久做一次快照?如果想要数据尽可能少丢失,那快照间隔的时间就要变得很短。虽然bgsave
执行时不阻塞主线程,但是,如果频繁地执行全量快照,也会带来两方面的开销。
所以,我们要做增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。那如何记住后续的修改操作呢?redis4.0
提出混合使用AOF日志和内存快照的方法。就是内存快照以一定的频率执行,在两次快照之间,使用AOF日志记录这期间的所有命令操作。这样快照不需要频繁执行,AOF
只需要记录两次快照间的操作。所以AOF
文件也不会过大。
一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
另一方面,bgsave
子进程需要通过fork操作从主线程创建出来。fork
这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。
redis
主从库如何实现数据一致?答:如何实现数据一致,这里分以下几个问题说明。
多个实例如何形成主从?
当启动多个redis
实例时,它们通过replicaof
命令(Redis 5.0
之前使用slaveof
)形成主从关系。比如有两个实例:实例1(ip: 172.16.19.3)和实例2(ip: 172.16.19.5),在实例2上执行replicaof 172.16.19.3 6379
命令,实例2变成实例1的重库,并从实例1上复制数据。
主从之间第一次数据同步过程
runID,是每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的runID,所以将runID设为“?”。
offset,此时设为-1,表示第一次复制。
第一阶段,段建立连接,协商同步。从库给主库发送psync
命令表示数据同步,主库根据psync
命令的参数来启动复制。参数主要包含主库的runID和复制进度offset。主库收到psync
命令后,会用FULLRESYNC响应命令带上两个参数:主库runID和主库目前的复制进度offset,返回给从库。从库收到响应后,会记录下这两个参数。
注意:
FULLRESYNC
响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。
主库执行
bgsave
命令,生成RDB
文件,接着将文件发给从库,从库接收RDB
文件后清空数据库,然后加载RDB
文件。主库数据同步过程中,仍然可以接收请求,这些请求没有记录在刚刚生成的RDB
文件中,所以主库在内存中有一个replication buffer
用于记录RDB
文件生成后收到的所有写操作。
第三阶段,主库会把replication buffer
中的命令发送给从库,从库重新执行操作,这样主从就一致了。
主-从-从模式下,数据如何同步?
如果存在多个从数据库都需要从主数据库中同步数据,那主redis
就忙这fork子进程生成RDB
文件了,fork
操作会阻塞主线程的,而且传输RDB
文件也会占用主库网络带宽。所以就有了另一种办法:主从级联模式分担全量复制时的主库压力,什么意思呢?就是再选择一个从库,将这个从库作为其他从库的主库。那么就由这个从库为其他从库同步数据。
如果主从库之间网路断了怎么办?
redis2.8
之前,重新全量复制。
redis2.8
之后采用增量复制,下面说说增量复制过程。
主从断了之后,主库会把这段时间收到的写命令写入replication buffer
和repl_backlog_buffer
。增量复制所有操作都是基于repl_backlog_buffer
的。它是一个环形缓冲区,主库会记录自己写到的位置(偏移量 master_repl_offset
),从库则会记录自己已经读到的位置(偏移量 slave_repl_offset
)。 刚开始,master_repl_offset
和slave_repl_offset
在一起,然后主库写,从库读,所以一般slave_repl_offset
要小于master_repl_offset
。
这里有个注意的地方,
repl_backlog_buffer
是环形的,所以它会覆盖掉之前写的。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。这种情况可以调整repl_backlog_size
参数大小。
计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 * 2,这也就是repl_backlog_size的最终值。
综上,整个redis
数据同步问题就解释清楚了。
答:主从集群方式一般是一主多从的模式,主库可接收读/写请求,并把数据同步给从库,从库只能接收读请求。那么这里就有个问题,如果主库挂了呢?那么就无法接收写请求了。
答:因为主从集群模式,如果主库挂了,就无法提供正常的写功能了。这里涉及了三个问题:
主库真的挂了吗?(是否误判)
该选择哪个从库作为主库?(如何选主)
怎么把新主库的相关信息通知给从库和客户端呢?(怎么通知)
所以,哨兵机制就是解决上面这三个问题而出现的,哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。就围绕这三个任务来看一下哨兵机制流程。
监控
监控是指哨兵进程会周期性的使用PING
命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果发现PING
命令响应超时,哨兵就会把它标记为主观下线。为了防止哨兵误判
哨兵误判就是主库并没有故障,误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下,而选主的代价比较高。
一般都会会采用多实例组成的集群模式进行部署,这也就是哨兵集群。当多数哨兵标记主库为主观下线时,那么主库就会被标记为客观下线,也就是表示主库下线是客观事实的。判断的原则就是少数服从多数。下面再给张图帮助理解:
选主 哨兵选主的过程可以概括为筛选+打分。
第一轮:优先级最高的从库得分高
可以通过slave-priority
配置从库优先级,如果你有台从库实例,配置比较高,你就可以手动设置它优先级,那么在哨兵选主的时候,它就会胜出。
第二轮:和旧主库同步程度最接近的从库得分高
同步接近程度,也就是repl_backlog_buffer
这里的数据同步程度,可以通过从库slave_repl_offset
与旧主库的master_repl_offset
值是否相近来判断接近程度。如下图,从库2就胜出称为新主库。
第三轮:ID号小的从库得分高。
每个实例都有一个ID,在优先级和复制进度都相同的情况下,ID号最小的从库得分最高,会被选为新主库
筛选
筛选时,哨兵除了要检查从库的当前在线状态,还要判断它之前的网络连接状态(因为如果它刚选上就网络不好挂了,那这次选主不就白费了么)。如果从库总是和主库断连而且超过了阈值,那这个从库就被排除了。
具体可以通过
down-after-milliseconds * 10
配置,down-after-milliseconds是我们认定主从库断连的最大连接超时时间。如果在down-after-milliseconds毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了10次,就说明这个从库的网络状况不好,不适合作为新主库。
总的来说,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库。
打分
打分按照三个规则依次进行三轮打分,主要某个库从在某一轮胜出,那么它就是主库,选举结束,规则分别是从库优先级、从库复制进度以及从库ID号。
通知
在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行replicaof
命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。
答:哨兵互相之间的发现,是通过redis
的pub/sub
系统实现的,每个哨兵都会往__sentinel__:hello
这个channel
里发送一个消息,这时候所有其他哨兵都可以消费到这个消息,并感知到其他的哨兵的存在,每隔两秒钟,每个哨兵都会往自己监控的某个master+slaves
对应的__sentinel__:hello channel
里发送一个消息,内容是自己的host、ip
和runid
还有对这个master
的监控配置,每个哨兵也会去监听自己监控的每个master+slaves
对应的__sentinel__:hello channel
,然后去感知到同样在监听这个master+slaves
的其他哨兵的存在,每个哨兵还会跟其他哨兵交换对master
的监控配置,互相进行监控配置的同步。
redis
要注意哪些影响性能的潜在因素?答:主要要注意下面这些因素:
Redis
内部的阻塞式操作;
CPU
核和NUMA
架构的影响;
Redis
关键系统配置;
Redis
内存碎片;
Redis
缓冲区
redis
实例有哪些阻塞点?答:可从redis
实例的交互对象来分析。redis
实例会和以下对象发生交互。
客户端
网络IO,不是阻塞点
网络IO比较慢,但是redis
使用了IO多路复用,避免了主线程一直等待网络连接或者请求到来的状态。所以网络IO它不是一个阻塞点。
集合全量查询和聚合操作,阻塞点
键值对的操作是redis
和客户端的主要交互对象,那么复杂度高的操作一定会阻塞redis
主线程,所以要留意时间复杂度为O(n)的操作,所以涉及集合的操作通常都是O(n),以及集合间的聚合操作。例如:HGETALL
、SMEMBERS
、LRANGE
。
所以集合全量查询和聚合操作:可以使用SCAN命令,分批读取数据,再在客户端进行聚合计算;
删除bigKey
,阻塞点
删除操作是要释放键值对占用的内存空间,把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程很费时,所以在删除大量元素的集合时肯定会阻塞,也就是bigkey
删除也是一个阻塞点。
** 可以异步处理来优化。**
清空数据库,阻塞点
FLUSHDB和FLUSHALL操作也是会阻塞的,因为它需要删除和释放所有的键值对。
可以异步处理来优化。
磁盘
生成RDB
快照文件采用fork
子进程的方式不会阻塞主线程,不是阻塞点
AOF
日志重写操作也是采用子进程方式不会阻塞,不是阻塞点
AOF
日志同步写,阻塞点AOF
日志同步写,会根据不同的写回策略对数据做落盘保存。一个同步写磁盘的操作的耗时大约是1~2ms,如果有大量的写操作需要记录在AOF
日志中,并同步写回的话,就会阻塞主线程了。
所以从库加载RDB文件:把主库的数据量大小控制在2~4GB左右,以保证RDB文件能以较快的速度加载。
主从节点
主库生成RDB
快照文件,并把文件传输给从库,这是fork子进程做的不会阻塞主线程,但是从库收到主库快照文件时,需要清空本地数据,加载RDB
文件这个过程对于从库来说是阻塞的。
切片集群实例 当部署redis
集群切片时,redis
实例上分配的哈希槽信息需要在不同实例之间传递,以及负载均衡,实例增删时,数据会在不同实例间传递。不过哈希槽信息不大,而数据迁移是渐进式的,所以说不会阻塞。
综上,redis
实例的阻塞点在集合全量查询和聚合操作、bigkey
删除、清空数据库、AOF
日志同步写、从库加载RDB
文件。
redis
阻塞点中哪些可以异步操作的?怎么来异步操作呢?答:redis
实例的阻塞点在集合全量查询和聚合操作、bigkey
删除、清空数据库、AOF
日志同步写、从库加载RDB
文件。
异步操作,也就是它不是主线程的关键操作。
集合全量查询和聚合操作,是客户端的读请求,客户端发送了读操作之后,就会等待读取的数据返回,以便进行后续的数据处理。所以它不能异步操作。
bigkey
删除,删除操作并不需要给客户端返回具体的数据结果,所以可以异步操作。
清空数据库,同样删除操作可以异步。
AOF
日志同步写,为了保证数据可靠性,Redis实例需要保证AOF日志中的操作记录已经落盘,这个操作虽然需要实例等待,但它并不会返回具体的数据结果给实例,可以异步。
从库加载RDB
文件,从库要想对客户端提供数据读取服务,就必须把RDB
文件加载完成。所以,这个操作也属于关键路径上的操作,必须让从库的主线程来执行。
异步的子线程机制redis
主线程启动后,会通过操作系统pthread_create函数创建3个子线程,分别负责AOF日志写操作、键值对删除以及文件关闭的异步执行。
主线程通过一个链表形式的任务队列和子线程交互。主线程收到客户端请求时,比如删除命令,主线程会把删除操作封装成任务放入任务队列,然后回复客户端完成,但实际上,这个时候删除还没有执行,等到后台子线程从任务队列中读取任务后再删除。执行机制如下图
答:redis
释放的内存空间由内存分配器管理,不会立刻返回给操作系统。
答:redis
内存碎片化和JVM内存碎片化是一样的,就是虽然操作系统剩余的内存总量足够,但是应用申请一块连续的空间,发现操作系统中无法申请一块连续的空间,因为没有这么大的连续空间,那么这些就是内存碎片化了。redis
出现内存碎片化主要是两个方面。
一个是内存分配器的分配策略造成的,内存分配器是按照固定大小分配的,而不是按照实际申请的大小分配的。比如申请20字节内存,但是分配器分配是按照8字节、16字节、32字节...分配,你申请的20字节,那么内存分配器会给你分配32字节的。那么多出部分就是碎片。
另一个是redis
键值对删除之后会释放部分空间带来的内存碎片。
可以通过INFO memory
来判断是否有内存碎片
INFO memory
# Memory
used_memory:1073741736
used_memory_human:1024.00M
used_memory_rss:1997159792
used_memory_rss_human:1.86G
…
mem_fragmentation_ratio:1.86
复制代码
这里有一个mem_fragmentation_ratio
的指标,它表示的就是Redis当前的内存碎片率。这个指标就是上面的命令中的两个指标used_memory_rss(操作系统实际分配内存)
和used_memory(redis申请的内存)
相除的结果。对于这个指标:
mem_fragmentation_ratio
大于1但小于1.5。这种情况是合理的。这是因为,刚才我介绍的那些因素是难以避免的。毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由Redis负载决定,也无法限制。所以,存在内存碎片也是正常的。
mem_fragmentation_ratio
大于 1.5 。这表明内存碎片率已经超过了50%。一般情况下,这个时候,我们就需要采取一些措施来降低内存碎片率了。
清理内存碎片方法:
重启Redis实例
开启redis自动内存碎片清理功能
答:缓存雪崩是指的部分缓存节点不可以用,导致大量请求无法在redis
中处理,进而大量请求打在数据库上,导致数据库层压力激增。缓存雪崩一般有两个原因:
第一、缓存中大量数据同时过期,导致大量请求无法处理 针对这种情况导致缓存雪崩,一般有以下解决方案:
1.对缓存Key的过期时间做微调,避免同时过期,假如需要同时过期的情况,可以在过期时间上加一点随机数(例如,增加个1~3分钟)这样避免大量缓存Key同时过期。
2.服务降级,对于非核心业务,在发生缓存雪崩时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;对于核心业务允许查询缓存以及数据库,这样快速失败(failfast)处理牺牲部分功能,保全整个系统。
3.应用中
第二、redis
实例发生故障宕机了(比如缓存节点过载被打死了),一般也有两种解决方案:
可以使用服务熔断或者请求限流机制 服务熔断是指缓存发生雪崩时,为了防止连锁的数据库崩溃,甚至是整个系统不可用情况,而关闭部分功能的方案。这种方案会使用部分应用不可用(一般时非重点业务)。也可以使用请求限流机制,将请求一点点的访问数据库,保证良好的运行。
redis
集群增加多个副本,分摊请求,或者一台机器挂了,备用机器提供服务,保证缓存服务器的高可用
应用可以采用多级缓存的机制,当然这会带来多级缓存一致性的问题。
所以,针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
答:缓存击穿是指热点Key
在redis
中过期,此时大量请求访问热点数据在缓存中无法命中,导致大量请求直接访问数据库,数据库压力激增。
解决方式就是对于访问特别频繁的热点数据,我们就不设置过期时间了。所以,针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
答:缓存穿透是指访问了一个不存在的数据,缓存中没有,数据库中也没有,这样会每次请求都会打到数据库,如果大量请求访问不存在的Key
,就会导致数据库压力激增。应对这样的解决方案一般以下几种:
缓存空值或缺省值。当没有这个数据时,可以缓存一个空值,那么下次请求就会在redis
中命中。但是这种方式也有个问题,就是如果后面这个数据在数据库中有值,那么redis
中还是空值,所以一般设置空值的过期时间短一点。
使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
所以,针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。
答:布隆过滤器在我看来其实使用的就是位图+哈希实现的。(在我看来和HashMap部分机制很像)
它首先会初始化一个值都是0的bit的字节数组,当需要对某个数据X做标记时,先使用N个哈希函数计算得到哈希值,然后用哈希值对数组取模后找到对应的数组位置,将这个位置标记为1。这样布隆过滤器就设置好了。
正是基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样一来,即使发生缓存穿透了,大量请求只会查询Redis
和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用Redis
实现,本身就能承担较大的并发访问压力。
举一个存储用户信息例子:
它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中。主要是 Hash
算法的问题。因为布隆过滤器是由一个二进制数组和一个 Hash
算法组成的,Hash
算法存在着一定的碰撞几率。Hash
碰撞的含义是不同的输入值经过 Hash
运算后得到了相同的 Hash
结果。这样就导致了数据A和数据B运算之后是同一个结果,假如A把数组值标记为A,而B没有标记,但是它算出来的也是这个位置,导致误判。
不支持删除元素。 布隆过滤器不支持删除元素的缺陷也和 Hash 碰撞有关。举一个例子,假如两个元素 A 和 B 都是集合中的元素,它们有相同的 Hash 值,它们就会映射到数组的同一个位置。这时我们删除了 A,数组中对应位置的值也从 1 变成 0,那么在判断 B 的时候发现值是 0,也会判断 B 是不在集合中的元素,就会得到错误的结论。
redis
哨兵主备切换时数据会丢失吗?答:在主备切换过程中有两种情况会丢失数据:
异步复制导致的数据丢失
发生原因
因为master -> slave
的复制是异步的,所以可能有部分数据还没复制到slave
,master
就宕机了,此时这些部分数据就丢失了。
如何检查
如果是这种情况的数据丢失,我们可以通过比对主从库上的复制进度差值来进行判断,也就是计算master_repl_offset
和slave_repl_offset
的差值。如果从库上的slave_repl_offset
小于原主库的master_repl_offset
,那么,我们就可以认定数据丢失是由数据同步未完成导致的。
脑裂导致的数据丢失
发生原因
脑裂,也就是说,某个master
所在机器突然脱离了正常的网络,跟其他slave
机器不能连接,但是实际上master
还运行着,此时哨兵可能就会认为master
宕机了,然后开启选举,将其他slave
切换成了master
,这个时候,集群里就会有两个master
,也就是所谓的脑裂,此时虽然某个slave
被切换成了master
,但是可能client
还没来得及切换到新的master
,还继续写向旧master
的数据可能也丢失了,因此旧master
再次恢复的时候,会被作为一个slave
挂到新的master
上去,自己的数据会清空,重新从新的master
复制数据,这样也就导致写给原master
的数据丢失了。
如何检查
这个情况可以排查客户端的操作日志时,看看在主从切换后的一段时间内,有没有客户端仍然在和原主库通信,并没有和升级的新主库进行交互,如果有那就表示出现了脑裂。
解决数据丢失问题 redis
提供了两个参数配置来限制主库的请求处理
min-slaves-to-write:这个配置项设置了主库能进行数据同步的最少从库数量;
min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送ACK消息的最大延迟(以秒为单位)。
复制代码
min-slaves-to-write
和min-slaves-max-lag
这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为N和T。这两个配置项组合后的要求是,主库连接的从库中至少有N个从库,和主库进行数据复制时的ACK
消息延迟不能超过T秒,否则,主库就不会再接收客户端的请求了。
举个例子,
假设我们将min-slaves-to-write
设置为1,把min-slaves-max-lag
设置为12s,把哨兵的down-after-milliseconds
设置为10s,主库因为某些原因卡住了15s,导致哨兵判断主库客观下线,开始进行主从切换。同时,因为原主库卡住了15s,没有一个从库能和原主库在12s内进行数据复制,原主库也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
Redis
主从同步与故障切换,有哪些坑?答:主要会遇到以下坑
1.主从数据不一致
1.建议在硬件环境配置方面,尽量保证主从库间的网络连接状况良好。(比如部署在同一个机房,或者是避免把网络通信密集的应用(例如数据分析应用)和Redis
主从库部署在一起)
2.开发一个外部程序监控从库复制进度(Redis
的INFO replication
命令可以查看主库接收写命令的进度信息(master_repl_offset
)和从库复制写命令的进度信息(slave_repl_offset
),所以可以开发程序监控),一旦从库复制进度超过阈值(主库的master_repl_offset
减去从库的slave_repl_offset
所得到的值),不让客户端连接从库。
主从库间的网络可能会有传输延迟,所以从库不能及时地收到主库发送的命令,从库上执行同步命令的时间就会被延后。
即使从库及时收到了主库的命令,但是,也可能会因为正在处理其它复杂度高的命令(例如集合操作命令)而阻塞。此时,从库需要处理完当前的命令,才能执行主库发送的命令操作,这就会造成主从数据不一致。
主要原因
是主从库间的命令复制是异步进行的,那导致数据不一致主要是以下原因
解决方案
2.读取过期数据
这个问题主要是由于Redis
的过期数据删除策略引起,redis
提供两种策略,惰性删除策略和定期删除策略。这两种策略都会使得删除的数据不会立刻从redis
中删除,而是还会在缓存中。那么从库还是会同步这个数据的,但是从库不会触发删除操作的,所以当客户端读取过期的数据时,如果redis
是3.2之前的版本,那么从库就会返回过期数据,如果是3.2以后的版本,从库会判断是否过期了,过期了就会返回空值。
那么redis
3.2版本就一定安全了呢?不是的,还有一种情况,这种情况就和主从复制有延迟有关了,如果我们设置过期时间使用EXPIRE
和PEXPIRE
命令,表示多长时间以后过期。由于主从延迟导致读取过期数据。下面举个例子:
比如,主库执行60s以后过期,此时是9.03分,那么过期时间是9.04,由于延迟从库获取命令时间是9.04分,执行之后,过期时间是9.05分,这样这个数据就延迟了1分钟。所以建议使用EXPIREAT和PEXPIREAT命令,将过期时间设置某个时间点。
3.不合理配置项导致的服务挂掉
这里涉及到的配置项有两个,分别是protected-mode
和cluster-node-timeout
。
protected-mode
配置项
这个配置项的作用是限定哨兵实例能否被其他服务器访问。当这个配置项设置为
yes
时,哨兵实例只能在部署的服务器本地进行访问。当设置为no
时,其他服务器也可以访问这个哨兵实例。
所以,如果protected-mode
被设置为yes
,而其余哨兵实例部署在其它服务器,那哨兵节点就无法通信了。protected-mode
这个配置项应该是no
,让其他服务器都能访问,但是要将bind
配置项设置为其它哨兵实例的IP地址。这样只有绑定了IP的哨兵才可以访问,这样保证了安全又保证了哨兵间的通信。
cluster-node-timeout
配置项
这个配置项设置了Redis Cluster
中实例响应心跳消息的超时时间。当我们在Redis Cluster
集群中为每个实例配置了“一主一从”模式时,如果主实例发生故障,从实例会切换为主实例,受网络延迟和切换操作执行的影响,切换时间可能较长,就会导致实例的心跳超时(超出cluster-node-timeout
)。实例超时后,就会被Redis Cluster
判断为异常。而Redis Cluster
正常运行的条件就是,有半数以上的实例都能正常运行。
所以,如果执行主从切换的实例超过半数,而主从切换时间又过长的话,就可能有半数以上的实例心跳超时,从而可能导致整个集群挂掉。所以,我建议你将cluster-node-timeout
调大些(例如10到20秒)。
综上,遇到的坑总结:
答:直接引用网上文章 不懂Redis Cluster原理,我被同事diss了!、深度图解Redis Cluster原理、
Redis 设计篇
redis
设计实现一个功能,统计每天网站的新客用户和第二日留存用户?(新客表示今天新增用户,留存表示老用户)答:redis
知识分析,对于这题,可以使用redis
集合的统计模式,redis
集合一般常见四种统计模式:聚合统计、排序统计、二值统计、基数统计。对于设计这个功能可以使用聚合统计。常见的聚合统计包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)。
题目分析,要获取新客用户和留存用户刚好可以使用聚合统计。我们可以用一个集合记录所有登录过网站的用户ID,同时,用另一个集合记录每一天登录过网站的用户ID。比如累计用户key
使用user:login
,value
就是一个Set
集合,记录所有用户ID,而每日用户登录也使用一个Set
集合存储,假如key
使用user:login:20110412
,每日登录key
里面包含时间信息。
那么新客用户就可以使用以下命令统计:
SDIFFSTORE user:new:20110412 user:login:20110412 user:login
复制代码
第二日留存用户,就是第二天用户集合与前一天用户集合的交集:
SINTERSTORE user:login:rem user:login:20110412 user:login:20110413
复制代码
但是注意,Set的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致Redis实例阻塞。所以,把数据读取到客户端,在客户端来完成聚合统计。
还有一点注意,如果是在集群模式使用多个key聚合计算的命令,一定要注意,因为这些key可能分布在不同的实例上,多个实例之间是无法做聚合运算的,这样操作可能会直接报错或者得到的结果是错误的!
当数据量非常大时,使用这些统计命令,因为复杂度较高,可能会有阻塞Redis的风险,建议把这些统计数据与在线业务数据拆分开,实例单独部署,防止在做统计操作时影响到在线业务
答:先说第一个问题,签到打卡场景其实只有两种行为,签到或者未签到。这是典型的二值状态。在redis
中有一种统计二值状态的数据结构,位图(Bitmap
)。
Bitmap
本身是用String
类型作为底层数据结构实现的一种统计二值状态的数据类型。String
类型是会保存为二进制的字节数组,所以,Redis
就把字节数组的每个bit
位利用起来,用来表示一个元素的二值状态。你可以把Bitmap
看作是一个bit
数组。
Bitmap提供了GETBIT/SETBIT
操作,偏移值offset
从0开始,对这一位的读写。还有一个BITCOUNT
命令,用来统计这个bit
数组中所有“1”的个数。
那么一个月签到我们可以这么设计。
用户ID:1000在2021年4月1号签到了
SETBIT uid:sign:1000:202104 0 1
复制代码
用户ID:1000在2021年4月2号未签到
SETBIT uid:sign:1000:202104 1 0
复制代码
统计签到次数
BITCOUNT uid:sign:1000:202104
复制代码
对于第二个问题,统计10天连续签到。Bitmap
支持用BITOP
命令对多个Bitmap
按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的Bitmap
中。以按位与操作为例,下图可以看到,三个Bitmap
bm1
、bm2
和bm3
,对应bit
位做“与”操作,结果保存到了一个新的Bitmap
中。 key
,每个key
对应一个1亿位的Bitmap
,每一个bit
对应一个用户当天的签到情况。然后对10个Bitmap
做按位与操作,每一位与操作结果表示一个用户10天签到的情况,得到一个新的Bitmap
,这个Bitmap
就代表了1亿用户10天签到情况了,1表示10天连续签到,0表示未连续签到,再使用BITCOUNT
获取1的数量就可以了。
内存消耗情况,每天使用1个1亿位的Bitmap
,大约占12MB的内存(10^8/8/1024/1024),10天的Bitmap
的内存开销约为120MB,内存压力不算太大。不过,在实际应用时,最好对Bitmap
设置过期时间,让Redis
自动删除不再需要的签到记录,以节省内存开销。
答:reids
提供了GEO
数据类型,严格来说它不是一种新的数据类型,它的底层是使用Sort set
数据结构的。在GEO
中我们可以用到两个命令,分别是GEOADD
和GEORADIUS
。
GEOADD
命令:用于把一组经纬度信息和相对应的一个ID记录到GEO
类型集合中;
GEORADIUS
命令:会根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。当然,我们可以自己定义这个范围。
可以把加油站经纬度位置是(116.034579,39.030452)通过GEOADD
命令加入集合中,如
GEOADD gasstation:locations 116.034579 39.030452
复制代码
假如要获取用户(经纬度信息:116.054579,39.030452 )附近5公里的加油站,就可以使用以下命令:
GEORADIUS gasstation:locations 116.054579 39.030452 5 km ASC COUNT 10
复制代码
以上命令中可以使用ACS
选项,让返回的加油站按照距离这个中心位置从近到远的方式来排序,以方便选择最近的加油站;还可以使用COUNT
选项,指定返回的加油站的数量。
redis
适合做消息队列吗?如何使用它做消息队列?答:分析redis
是否适合做消息队列那就需要知道消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。 那就看看redis
的两种数据类型,List
和Streams
如何做消息队列。
基于List
的消息队列解决方案List
本身就是按照先进先出的顺序存取数据的,所以它本身就是有序的。生产者使用LPUSH
把消息写入队列,消费者使用RPOP
把消息从队列中读取出来。如下图,生产者发送消息5,3。消费者依次取出5,3。 List
方式有性能风险。因为List
没有等待唤醒机制,也就是说生产者将消息写入队列,消息消费者并不能及时知晓,想要及时消费消息,那么消息消费者就要不停的获取消息,也就是在while
循环里不停的使用RPOP
获取消息。这会十分消耗CPU资源,所以,redis
提供了BRPOP
命令。``BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据
,节省CPU开支。
消息不重复处理保证
保证消息不重复消费就是要保证幂等性。redis
中的List
并没有这种机制保证,所以需要生产者和消费者自己保证。比如,一方面生产者可以在每个消息上携带全局唯一的ID,另一方面,消费者需要记录自己消费了的全局唯一ID来判断下个消息是否重复。
消息可靠性保证
消费者从List
中获取一条消息后,List
不会再有这条消息的留存了,但是如果消费者获取了消息,但是还没来得及处理消费者就宕机了,那么这条消息就丢失了。为了解决留存问题。List
类型提供了BRPOPLPUSH
命令,这个命令的作用是让消费者程序从一个List
中读取消息,同时,Redis
会把这个消息再插入到另一个List
(可以叫作备份List
)留存。这样消息就不会丢失了。下面画图解释List
是满足消息队列的三个条件的。但是它也有缺点,就是如果生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致List
中的消息越积越多,给Redis
的内存带来很大压力,我们希望能启动多个消费者组成一个消费者组来消费数据,但是可惜List
并没有提供。但是redis5.0
提供了Streams
。
有序性保证
基于Streams
的消息队列解决方案Streams
是Redis
专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。
如果让group1
消费组里的消费者consumer1
从mqstream
中读取所有消息,其中,命令最后的参数“>
”,如,XREADGROUP group group1 consumer1 streams mqstream >
。使用消费者consumer1读取。需要注意的是,消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了。
如果要让组中每个消费者读取一条消息,如,XREADGROUP group group2 consumer1 count 1 streams mqstream >
,XREADGROUP group group2 consumer2 count 1 streams mqstream >
。
XADD
:插入消息,消息格式是键值对形式,保证有序,可以自动生成全局唯一ID;
例如,执行以下命令,往**消息队列名称为mqstream
**中插入数据,键为test
,值hello
。其中消息队列名称后面的*
,表示让Redis为插入的数据自动生成一个全局唯一的ID,也可以自己设置把*
替换成自己的ID。
XADD mqstream * test hello
"1599203861727-0"
复制代码
返回结果是1599203861727-0
,分为两个部分,1599203861727
表示插入数据时毫秒值,0
表示这个时间点的第1条消息。
XREAD
:用于读取消息,可以按ID读取数据;使用XREAD
读取消息,命令: XREAD BLOCK 1000 STREAMS mqstream 1599203861727-0
,获取消息IID:1599203861727-0
的消息。消费者也可以在调用XRAED时设定block配置项,实现类似于BRPOP的阻塞读取操作。当消息队列中没有消息时,一旦设置了block
配置项,XREAD
就会阻塞,阻塞的时长可以在block
配置项进行设置,上面那条命令中1000表示等待1秒钟,如果想读取最新消息,则把ID换成$
,XREAD block 10000 streams mqstream $
。
XREADGROUP
:按消费组形式读取消息;Streams特有的功能消费者组,可以使用XGROUP创建消费者组,使用XREADGROUP
读取消费者组消息。如,XGROUP create mqstream group1 0
,创建一个名为group1
的消费组,这个消费组消费的消息队列是mqstream
。
XPENDING和XACK
:XPENDING
命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而XACK
命令用于向消息队列确认消息处理已完成。
为了保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息,Streams
会自动使用内部队列(也称为PENDING List
)留存消费组里每个消费者读取的消息,直到消费者使用XACK
命令通知Streams
“消息已经处理完成”。如果消费者没有成功处理消息,它就不会给Streams
发送XACK
命令,消息仍然会留存。此时,消费者可以在重启后,用XPENDING
命令查看已读取、但尚未确认处理完成的消息。
两种方式比较
HDFS
中,以便后续进行历史查询),你会使用Redis
的什么数据类型来解决这个问题呢?JedisCluster
的工作原理?答:在JedisCluster
初始化的时候,就会随机选择一个node
,初始化hashslot -> node
映射表,同时为每个节点创建一个JedisPool
连接池。
每次基于JedisCluster
执行操作,首先JedisCluster
都会在本地计算key
的hashslot
,然后在本地映射表找到对应的节点。
如果那个node
正好还是持有那个hashslot
,那么就ok
; 如果说进行了reshard
这样的操作,可能hashslot
已经不在那个node
上了,就会返回moved
。
如果JedisCluter API
发现对应的节点返回moved
,那么利用该节点的元数据,更新本地的hashslot -> node
映射表缓存。
重复上面几个步骤,直到找到对应的节点,如果重试超过5次,那么就报错,JedisClusterMaxRedirectionException
jedis
老版本,可能会出现在集群某个节点故障还没完成自动切换恢复时,频繁更新hash slot
,频繁ping
节点检查活跃,导致大量网络IO
开销。
jedis
最新版本,对于这些过度的hash slot
更新和ping
,都进行了优化,避免了类似问题。
内部原理篇
Redis
字符串内部实现是什么样的?答:Redis
的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java
的 ArrayList
,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字符串实际分配的空间 capacity
一般要高于实际字符串长度 len
。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
Redis
的底层数据结构?答:String(字符串)、List(列表)、Hash(哈希)、Set(集合)和Sorted Set(有序集合)分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。
Redis
键和值本身是用什么数据结构呢?答:为了实现从键到值的快速访问,Redis
使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。而且哈希桶中保存的也不是值数据,而是数据的指针。所以可以通过O(1)的时间复杂度很快找到键值对。
Redis
键值存储使用哈希表,时间复杂度为O(1),那为什么写入大量数据后,操作会变慢呢?答:那是因为写入大量数据后,会带来一些问题,那就是哈希表的冲突问题和rehash
可能带来的操作阻塞。redis
键值的存储和 java
中的 Map
类似,都是通过"数组 + 链表"的形式。哈希桶的个数通常要少于key
的数量,这也就是说,难免会有一些key
的哈希值对应到了同一个哈希桶中。 rehash
操作。所以,当数据量大是,redis
的操作就会变慢一点。
其一是由于冲突后每个桶元素过多,只能通过指针逐一查找,这个过程时间复杂度是O(n),也就是随着元素越多,越耗时。
其二是当数据量特别多时redis
会做rehash
操作。
Redis
是如何做rehash
操作的?答:Redis
中rehash
是一种渐进式rehash
。为了使rehash
操作更高效,Redis
默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据逐步增多,Redis
开始执行rehash
,这个过程分为三步:
给哈希表2分配更大的空间,例如是当前哈希表1大小的两倍;
把哈希表1中的数据重新映射并拷贝到哈希表2中;
释放哈希表1的空间。
而redis
为了让rehash
的时候阻塞客户端请求,所以采用渐进式rehash
。也就是在rehash
的时候也可以处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有entries拷贝到哈希表2中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的entries。
Redis
集合数据结构(List、Set、Hash...)数据操作效率和String
类型数据结构操作效率有何不同?答:Redis
键值存储是通过哈希表实现的,那么字符串类型的操作效率就是相当于获取哈希桶中元素的效率,也就是O(1) + O(n),O(1)是通过key
找到哈希桶的效率,O(n)是找到哈希桶后获取元素的效率。
而集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。
那么首先集合的操作效率就与集合的底层数据结构有关,例如,使用哈希表实现的集合(Hash),要比使用链表(List)实现的集合访问效率更高。
第二,操作效率和这些操作本身的执行特点有关,比如读写一个元素的操作要比读写所有元素的效率高。
Redis
底层数据结构时间复杂度对比?答:redis
除了简单动态字符串之外,还有整数数组、双向链表、哈希表、压缩列表和跳表等底层数据结构。整数数组、双向链表、哈希表这三种很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是O(N),操作效率比较低。
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段zlbytes
、zltail
和zllen
,分别表示列表长度、列表尾的偏移量和列表中的entry个数;压缩列表在表尾还有一个zlend
,表示列表结束。
跳表是在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位。如下图:
以下看一下每种数据结构底层结构的时间复杂度:
答:一般可以分为以下几种。
单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。这类操作一般与数据结构有关,也就是数据结构的时间复杂度,但是集合类型支持同时对多个元素进行增删改查,例如Set
类型的SADD
支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET
增加M个元素时,复杂度就从O(1)变成O(M)了。
范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如Hash
类型的HGETALL
和Set
类型的SMEMBERS
,或者返回一个范围内的部分数据,比如List
类型的LRANGE
和ZSet
类型的ZRANGE
。这类操作的复杂度一般是O(N),比较耗时,我们应该尽量避免。
统计操作,是指集合类型对集合中所有元素个数的记录,例如LLEN
和SCARD
。这类操作复杂度只有O(1)。
例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于List
类型的LPOP、RPOP、LPUSH、RPUSH
这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有O(1),可以实现快速操作。
Redis
中GEO结构是新的数据结构吗?它是如何实现的?答:Geo
不是一种新的数据结构,它的底层是通过Sorted set
和GeoHash编码
来实现的,Sorted Set
很熟悉就是有序集合,一般是这样使用的,Sorted Set
的key
存储地理位置的编号,比如某个公司的地理位置,那么这个key
就是公司名称或者公司编号,总之可以定位一家公司的标识。而value
也就是权重分数是经纬度信息。 Sorted Set
元素的权重分数是一个浮点数(float
类型),而一组经纬度包含的是经度和纬度两个值,是没法直接保存为一个浮点数的,所以GeoHash编码
就登上舞台了。GeoHash
的编码方法基本原理就是二分区间,区间编码。对一组经纬度进行GeoHash
编码时,要先对经度和纬度分别编码,然后再把经纬度各自的编码组合成一个最终编码。经度范围是[-180,180],GeoHash
编码会把一个经度值编码成一个N位的二进制值,我们来对经度范围[-180,180]做N次的二分区操作。如果当前的经度在范围的左区间,用0表示,在右区间用1表示。举例:假设对经纬度[116.37,39.86],做5次二分,先看经度116.37,第一次分出范围是[-180,0]和[0,180],而116.37在[0,180]区间,所以用1表示,继续对[0,180]做二分,第二次分出[0,90]和[90,180],很明显116.37还在右区间,所以还是用1表示,下面依次二分。如下图:
经纬度(116.37,39.86)
的各自编码值是11010
和10111
,组合之后,第0位是经度的第0位1,第1位是纬度的第0位1,第2位是经度的第1位1,第3位是纬度的第1位0,以此类推,就能得到最终编码值1110011101
。 GeoHash编码
,就可以使用最后编码值表示浮点数的权重分数了。
实操篇
答:
(1)先仅仅打开RDB
,写入一些数据,然后kill -9
杀掉redis
进程,接着重启redis
,发现数据没了,因为RDB
快照还没生成
(2)打开AOF
的开关,启用AOF
持久化
(3)写入一些数据,观察AOF
文件中的日志内容
其实你在appendonly.aof
文件中,可以看到刚写的日志,它们其实就是先写入os cache
的,然后1秒后才fsync
到磁盘中,只有fsync
到磁盘中了,才是安全的,要不然光是在os cache
中,机器只要重启,就什么都没了
(4)kill -9
杀掉redis
进程,重新启动redis
进程,发现数据被恢复回来了,就是从AOF
文件中恢复回来的
redis
进程启动的时候,直接就会从appendonly.aof
中加载所有的日志,把内存中的数据恢复回来。
AOF rewrite
重写如何配置?答:redis 2.4
之前,还需要手动,开发一些脚本,比如定时任务crontab
,通过BGREWRITEAOF
命令去执行AOF rewrite
,但是redis 2.4
之后,会自动进行rewrite
操作
在redis.conf
中,可以配置rewrite
策略
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
复制代码
比如说上一次AOF rewrite
之后,是128mb
然后就会接着128mb
继续写AOF
的日志,如果发现增长的比例,超过了之前的100%
,已经达到256mb
,就可能会去触发一次rewrite
。但是此时还要去跟min-size
,64mb
去比较,因为256mb > 64mb
,所以才会去触发rewrite
。
重写步骤:
(1)redis fork
一个子进程。
(2)子进程基于当前内存中的数据,构建日志,开始往一个新的临时的AOF
文件中写入日志。
(3)redis
主进程,接收到client
新的写操作之后,在内存中写入日志,同时新的日志也继续写入旧的AOF
文件。
(4)子进程写完新的日志文件之后,redis
主进程将内存中的新日志再次追加到新的AOF
文件中。
(5)用新的日志文件替换掉旧的日志文件。
如喜欢本文,请点击右上角,把文章分享到朋友圈
如有想了解学习的技术点,请留言给若飞安排分享
·END·
相关阅读:
作者:一个没有追求的技术人
来源:juejin.cn/post/6949105099398774798
版权申明:内容来源网络,仅供分享学习,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢!
我们都是架构师!
关注架构师(JiaGouX),添加“星标”
获取每天技术干货,一起成为牛逼架构师
技术群请加若飞:1321113940 进架构师群
投稿、合作、版权等邮箱:admin@137x.com