1. 什么是redis,它是做什么的?
Redis是一个基于Key-Value存储结构的Nosql开源内存数据库。 它提供了5种常用的数据类型,String
、Hash
、Set
、ZSet
、List
。 针对不同的结构,可以解决不同场景的问题。 因此它可以覆盖应用开发中大部分的业务场景,比如top10问题、好友关注列表、热点话题等。其次,由于Redis是基于内存存储,并且在数据结构上做了大量的优化所以IO性能比较好,在实际开发中,会把它作为应用与数据库之间的一个分布式缓存组件。 并且它又是一个非关系型数据的存储,不存在表之间的关联查询问题,所以它可以很好的提升应用程序的数据IO效率。 最后,作为企业级开发来说,它又提供了主从复制+哨兵、以及集群方式实现高可用在Redis集群里面,通过hash槽的方式实现了数据分片,进一步提升了性能。
2. 说说redis的基本数据类型结构
String
、Hash
、Set
、Zset
、List
、BitMap
、Geospatial
、 HyperLogLog
、Bitfield
、Streams
聊一下常用的几个数据类型的数据结构:
String
- String是Redis最基础的数据类型结构,它是二进制安全的,可以 存储图片或者系列化的对象, 值最大存储为512M
- 应用场景:共享session, 分布式锁,计数器,限流
- 内部编码有3种: int(8字节长整形)、embstr(3.2版本之前小于等于39字节字符串,之后44)、raw(3.2版本之前大于 39个字节字符串,之后44)
C语言的字符串是 char[]实现的,而 Redis 使用 SDS(simple dynamic string) 封装,sds 源码如下
struct sdshdr{
unsigned int len; // buf中已用空间的长度
unsigned int free; //buf中未使用的元素个数
char buf[]; // 存放元素
}
Redis为什么选用SDS结构?
- C语言里面只能用字符数组char[]实现字符串,使用字符数组必须先给目标变量分配足够能存,否则可能会溢出
- 如果是字符数组,那么查询长度的时候必须遍历数组,时间复杂度是O(n)
- C字符长度的变更会对字符数组做内存重新分配
- 通过从字符串开始到结尾碰到的第一个’\0’来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二级制保存的内容,二级制不安全
Hash
- 哈希类型是指 v(值)本身又是一个键值对(k-v)结构
- 使用场景:缓存用户信息等
- 内部编码:
ziplist
、hashtable
- 注意点:如果是用
getall
命令,哈希元素又比较多的话,可能导致Redis阻塞
List
- 列表类型是用来存储多个有序的字符串,一个列表最多可以存储2^32-1个元素
- 使用场景:消息队列、文章列表、栈
- 内部编码:
ziplist
、linkedlist
Set
- Set用来保存多个元素,但是每个元素不能重复
- 使用场景:随机抽奖、用户标签、社交需求
- 内部编码:
intset
、hashtable
- 注意点:
smembers
、lrange
、hgetall
都是比较中的命令,数据多的时候,会阻塞Redis,可以用sscan
来完成
ZSet
- 有序集合,集合内的元素不能同步
- 使用场景:排行榜、点赞需求
- 内部编码:
ziplist
、skiplist
Geo
- 地理位置定位,用于存储地理位置的信息,并且对地理位置进行操作
geoadd china:city 116.397 39.916 beijing
… 自行查看官网
HyperLogLog
- Redis用来做基数统计算法的数据结构,例如网站的UV
pfadd key element [element ...]
统计pdcount key[key ...]
… 自行查看官网
Bitmaps
- 用一个比特位来映射某个元素的状态,在Redis中,它的底层是基于字符串实现的,可以理解成以比特位为单位的数组
- 使用场景:用户签到、活跃用户统计、用户在线状态、布隆过滤器
3. Redis为什么这么快?
-
基于内存存储实现
-
高效的数据结构
- SDS实现字符串
- 字符串长度的处理:Redis获取字符串长度是用len,时间复杂度为O(1),而C语言中,需要遍历,时间复杂度为O(n)
- 空间预分配:字符串修改越频繁,内存分配就越频繁,性能就越差。而SDS的修改和空间扩充,会使用分配的未使用的空间,减少性能损耗
- 惰性空间释放:SDS缩短时,不会释放空间,而是free记录下多余的空间,后续有变更,会直接使用free中记录的空间,减少分配
- 字典
- Redis作为K-V型内存数据库,所有的键值就是使用字典来存储,获取值的时间复杂度就是O(1)
- 跳表
- 跳表就是在链表的基础上,增加多级索引提升查询效率
- 跳表时间复杂度平均O(logN),最坏O(n)
- SDS实现字符串
-
合理的数据编码
String
:如果存储数字的话,是用int类型的编码; 如果处理非数字,3.2版本之前是小于等于39个字节的字符串,之后是小于等于44个字节,是用embstr
,3.2版本之前大于39个字节,之后大于44个字节,是用raw编码List
:列表的元素个数如果小于512,并且每个元素小于64字节(默认的),是用ziplist
编码,否则使用linkedlist
编码Hash
:哈希类型元素个数小于512,并且每个元素小于64字节(默认的),是用ziplist
编码,否则使用hashtable
编码Set
:集合元素都是整形并且小于512,使用intset
编码,否则使用hashtable
编码Zset
:有序集合元素个数小于128,并且每个元素小于64字节,使用ziplist
编码,否则使用skiplist
编码
-
合理的线程模型
多路I/O复用技术可以让单个线程高效的处理多个请求,而Redis使用epoll
作为I/O多路复用的技术实现,
并且Redis自身的事件处理模型将epoll
中的连接、读写、关闭都转化为事件,不在I/O上浪费时间。
4. 什么是I/O多路复用
IO多路复用是一种同步的IO模型。IO多路复用的核心思想就是让单线程去监视多个连接(文件句柄),一旦某个连接就绪,也就是触发了读写事件,通知某个应用程序去获取这个就绪的连接进行读写操作,没有连接时就会阻塞应用程序,释放CPU资源。这样单个线程处理多个连接,在消耗较少的系统资源的情况下,提升服务端的连接处理数量。
在IO多路复用机制的实现原理中,客户端请求到服务端后,此时客户端在传输数据过程中,为了避免Server端在read客户端数据过程中阻塞,服务端会把该请求注册到Selector复路器上,服务端此时不需要等待,只需要启动一个线程,通过selector.select()阻塞轮询复路器上就绪的channel即可,也就是说,如果某个客户端连接数据传输完成,那么select()方法会返回就绪的channel,然后执行相关的处理就可以了。
IO可以理解为,在操作系统中,数据在用户态和内核态之间的读写操作,大部分情况下指的是网络IO
多路:大部分情况下是指多个TCP连接,也就是多个Socket或Channel
复用:复用一个或者多个线程处理多个TCP连接
常见的IO复用机制有:select
、poll
、epoll
这些都是linux系统提供的IO复用机制的现实,其中select
(数组存储句柄,几乎所有的平台都支持,但是单进程的句柄一般限制1024)和poll
(原理和select差不多,唯一的区别就是使用链表来存储句柄,所以没有句柄数量的限制)都是基于轮询的方式去获取就绪的连接,这种方式随着IO吞吐量的提高,性能会越来越差。epoll
是基于事件驱动的方式去获取连接,但是只能在Linux上工作。从性能上看,事件驱动的方式性能高于轮询的方式。
5. 什么是缓存击穿、缓存穿透、缓存雪崩
常见的缓存使用方式:读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回读取缓存
- 缓存穿透:查询一个一定不存在的数据,缓存没有命中,请求都落在数据库上
- 如何避免
- 在API入口对非法入参进行过滤
- 如果数据库查询为空,可以在缓存上设置空值
- 使用布隆过滤器快速判断数据是否存在,存在再往下查
- 如何避免
- 缓存雪崩:指缓存中大批量数据到了失效时间,而查询量巨大,每个查询都落在了数据库,引发数据库压力过大甚至down机
- 如何避免
- 缓存雪崩一般是由于大批量的数据同时过期造成的,这个可以设置离散的过期时间来解决。如:较大的固定时间+随机小时间,5h+(0-1800s)这样
- redis的故障也会引发缓存雪崩,这个时候需要采用redis的高可用方案,如主从、哨兵、集群
- 如何避免
- 缓存击穿:指热点key在某个时间失效,而这个时间正好有大量的并发请求进来,从而大量的请求打到了数据库
- 如何避免
- 互斥锁
- 永不过期方案,异步线程去更新和设置过期时间
- 做好熔断、降级
- 如何避免
6. 什么是热key问题,如何解决热key问题
我们把访问频率高的key称为热点key。如果某一热点 key 的请求到服务器主机时,由于请求量特别大,可能会导致主机资源不足,甚至宕机,从而影响正常的服务。
- 热key的产生
- 某个或某几个访问量特别大的数据,如秒杀、热点新闻等场景
- 请求分片集群中,超过单个redis服务器的性能,如固定名称的key,hash落入同一个服务器,瞬间访问量极大,超过机器瓶颈,产生热key问题
- 热key的识别
- 凭经验或者特定业务就会产生热key
- 客户端统计上报
- 服务代理层上报
- 热key的解决
- Redis的扩容,分片redis增加副本,均衡流量
- 将热key分散到不同的服务器中
- 多级缓存,比如JVM本地缓存,减少Redis的读请求
7. Redis的过期策略和内存淘汰策略
-
过期策略
-
定时过期:每个设置过期时间的key都需要创建一个定时器,到过期时间就会对key进行清除。这种策略对内存很友好,但是消耗CPU
-
惰性过期:只有当访问key的时候,才会判断是否过期,如果过期了就会对key进行清除。对内存不友好
-
定期过期:每隔一段时间,就会扫描一定数量的数据库的expires字典中的一定数量的key,并清除其中已过期的key。这是一种折中方案
expires字典会保存所有设置了过期时间的key的过期时间数据。其中key是指向键空间的某个键的指针,value是该键的毫米精度的Unix时间戳表示的过期时间。键空间是指redis集群中保存的所有键
-
Redis同时使用了惰性过期和定期过期策略。
但是呀,如果定期删除漏掉了很多过期的key,然后也没走惰性删除。就会有很多过期key 积在内存内存,直接会导致内存爆表。或 者有些时候,业务量大起来了,redis 的 key 被大量使用,内存直接不够了,运维也忘记加大内存。难道 redis 直接这样挂掉?不 会!Redis 用 8 种内存淘汰策略保护自己~
- 内存淘汰策略
- volatile-lru:内存不够时,从设置了过期时间的key中使用lru算法淘汰
- allkeys-lru:内存不够时,从所有的key中使用lru算法淘汰
- volatile-lfu:4.0版本增加,内存不够时,从设置了过期时间的key中使用lfu算法淘汰
- allkeys-lfu:4.0版本增加,内存不够时,从所有的key中使用lfu算法淘汰
- volatile-random:内存不够时,随机从设置了过期时间的key中淘汰
- allkeys-random:内存不够时,随机从所有key中淘汰
- volatile-ttl:内存不够时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的越线淘汰
- noeviction:默认策略,内存不够时,写入报错
8. 说说redis的常用场景
-
缓存
-
共享session
-
分布式锁
-
排行榜
电商网站月度销量排行榜、礼物排行榜、投票排行榜等等。可以使用
Zset
来实现这些复杂的排行榜zadd user:ranking:20221010 jay 3
zincrby user:ranking:20221010 jay 1
-
计数器
视频播放次数、网站浏览次数、新闻浏览次数,这些次数一般要求实时,如果并发量很大的话,对关系型数据库是很大的挑战。Redis天然支持计数功能,而且性能也非常好。
-
社交网络
赞/踩、粉丝、共同好友/喜好、推送、下拉刷新等是社交网站必备功能,Redis提供的数据结构比较适合保存这些
-
消息队列
一般不用redis的,有专业的消息中间件
-
位操作
用于数量上亿的场景下,例如几亿用户签到、去重登陆次数统计、用户是否在线等。例如腾讯10亿用户,要在几个毫秒内查到某个用户是否在线。原理是:redis 内构一个足够长的数组,每个数组元素只能是 0 和 1 两个值,然后这个数组的下标 index 用来表示用户id(必须是数字哈),那么很显然,这个几亿长的大数组就能通过下标和元素值(0 和 1)来构建一个记忆系统
9. Redis持久化有哪些方式?怎么选?
redis有两种持久化机制。RDB和AOF
9.1 RDB
RDB是redis默认的持久化方式,满足一定条件的时候,会将数据写入磁盘,生成dump.rdb文件。
触发方式
-
自动触发
-
根据配置文件里的配置
save 900 1 # 900s内至少有一个key被修改/新增 save 300 10 # 300s内至少有10个key被修改/新增 save 60 10000 # 60s内至少有10000个key被修改 /*文件位置*/ dir ./ /*文件名称*/ dbfilename dump.rdb /*是否是LZF压缩rdb文件*/ rdbcompression yes /*开启数据校验,使用CRC64算法来进行数据校验,但是会消耗10%的性能*/ rdbchecksum yes
-
shutdown触发
-
-
手动触发
save
(同步,会阻塞redis服务器)和bgsave
(异步,fork子线程,阻塞发生在fork时,很短)命令触发
优势和劣势
- 优势:
- RDB是非常紧凑的文件,非常适合备份和恢复
- 生成RDB的时候,是fork子线程来处理的,不会阻塞
- RDB在恢复大数据集的时候,比AOF快
- 劣势:
- 不是实时备份,会丢数据
9.2 AOF(不默认开启)
配置方式:
appendonly yes
appendfilename "appendonly.aof"
/*默认AOF的持久化策略,还有no、always*/
appendfsync everysec
AOF采用日志的方式记录每个操作,并追加到文件当中。由于操作系统的缓存机制,数据没有真正写入磁盘,而是在操作系统缓存中。
AOF的这种追加指令的方式会让aof文件越来越大,从而带来IO的性能问题,因此redis可以对aof文件进行重写,重写不是整理aof的指令,而是读取服务器现有的键值对,重写的过程中,如果有新的写指令,新的指令会写入重写缓存中,写完成之后,将重写缓存追加到新的aof文件当中去。
重写的时候,如果AOF文件被修改了,怎么办
- no-appendfsync-on-rewrite:默认为no,对延迟要求很高的应用,设置为yes,设置为yes表示rewrite期间对新的写操作不进行fsync,暂时存在内存中,等rewrite完成之后再写入。Linux系统默认的fsync策略是30s。
- aof-load-truncated:aof文件尾部可能是不完整的,这个时候设置为yes,当被截断的aof被导入的时候,会自动发送一个log给客户端,然后load,这个时候会丢失最后一条指令。如果设置成no,用户必须手动修复aof文件才行。默认为yes
优势和劣势
- 优势
- 数据安全性高,AOF提供了多种持久化方式,即使使用默认的持久化方式,Redis最多就丢失1s的数据
- 劣势
- AOF文件较大
- 在高并发的时候,RDB比AOF具有更高的性能
RDB和AOF该怎么选?
- 如果数据不能丢失,RDB和AOF混用
- 如果用于缓存,那么可以只开RDB
- 如果只用AOF,优先使用
everysec
刷盘策略
10. 怎么实现Redis的高可用(主从、哨兵、集群)
面试官经常会问redis高可用。高可用回答包括两个方面,一个是数据不丢失或减少丢失,另外一个就是服务不中断。数据减少丢失通过RDB和AOF来实现,服务不中断的话就不能让redis单点部署
10.1 主从
redis的主从模式,就是部署多个redis服务器,有主库和从库。它们之间通过主从复制保证数据一致性。
10.1.1 主从同步过程
Redis主从同步分为三个阶段:
- 第一阶段:建立连接,协商同步
- 从库发送
psync
命令给主库,告诉它要进行数据同步了 - 主库收到了
psync
命令后,响应FULLRESYNC
命令(它表示第一次同步采用全量复制),并带上主库runID和主库目前的复制进度offset
- 从库发送
- 第二阶段:主库把数据同步给从库,从库接收到数据后,完成本地加载
- 主库执行
bgsave
命令,生成RDB文件,接着将RDB发送给从库。从库收到RDB文件后,会清空当前数据,然后加载RDB文件 - 主库把数据同步给从库的过程中,新来的写操作会记录在
replication buffer
- 主库执行
- 第三阶段:发送新命令给从库
- 主库完成RDB文件给从库后,把
replication buffer
中的修改操作发给从库,从库再重新执行这些命令。
- 主库完成RDB文件给从库后,把
10.1.2 主从同步的注意点
-
主从库数据不一致
因为数据是异步复制的,如果从库滞后执行,则会造成主从数据不一致。主从数据不一致一般有2个原因
- 主从网络延迟
- 从库收到了主从命令,但是从库正在执行阻塞命令,如
hgetall
等
如何解决?
- 升级硬件配置,保证网络顺畅
- 监控主从复制的进度
-
读取过期数据
redis的删除数据的策略一般有3种:
- 惰性删除:只有当访问一个key是,才会判断是否已经过期,过期的话删除
- 定期删除:每隔一段时间,会扫描一定数量的数据库中expires字典中的一定数量的key,并清除其中已经过期的key
- 主动删除:当前使用内存超过最大限定的时候,触发主动清洗策略
如果版本低于3.2,读取从库时,并不会判断数据是否过期,会返回过期的数据。因此在主从模式下,尽量使用3.2版本之上的。
-
一主多从,全量复制的时候,主库压力大的问题
为什么压力会大?
从库很多的时候,每个从库如果从主库全量同步数据,主库压力会比较大。主库会fork子进程生成RDB文件,fork会阻塞主线程处理正常的请求。同时发送大的RDB文件给字库也会占用较大的带宽。
可以采用主-从-从的方案
-
主从网络断了怎么办?
主从库完成了全量复制后, 他们之间会维护一个网络长连接,用于主库后续收到写命令,传送到从库, 它可以避免频繁建立连接的开销。但是,如果网络断开重连后是否还需要进行一次全面复制呢?
redis2.8之前,会。redis2.8之后,会利用repl_backlog_buffer实现增量复制
redis2.8之后,主库会把断连期间收到的写命令,写入到replication buffer,同时也会把这些操作命令写入repl_backlog_buffer这个缓冲区。real_backlog_buffer是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
-
Redis如何保证主从数据一致性
Redis主从重要有3种同步方式,分别是全量同步、增量同步、指令同步。每种同步方式的时机不一样。具体见主从同步的三个阶段。
10.2 哨兵
主从模式中,一旦主节点由于故障不能提供服务,需要人工讲从节点晋升为主节点,同时还要通知应用方更新主节点的地址。显然,多数业务场景都不能接受这种故障处理方式。Redis从2.8开始正式提供了哨兵机制来解决这个问题。
10.2.1 哨兵作用
哨兵其实是一个运行在特殊模式下的Redis进程。它有三个作用:监控、选主、通知
哨兵进程在运行期间,监控所有的Redis主节点和从节点。它通过周期性给主从库发送ping
命令,检测主从库是否挂了。如果从库没有在规定时间内响应哨兵的ping
命令,哨兵就会把它标记为下线状态;如果主库没有在规定时间内响应哨兵的ping
命令,哨兵会判断主库下线,然后开始切换到选主任务。
选主就是从多个从库中,按照一定的规则,选出一个当主库。
通知就是选出主库后,哨兵把新主库的连接信息发给其他从库,让它们和新主库建立主从关系。同时,哨兵也会把新主库的连接信息通知给客户端,让它们把请求操作发送到新主库上
10.2.2 哨兵模式
哨兵之间通过发布订阅机制组成集群,同时,哨兵又通过INFO
命令获得从库连接信息,也能和从库建立连接,从而监控。
10.2.3 哨兵如何判断主库下线
首先理解两个基础概念:主观下线和客观下线
-
主观下线:哨兵进程想主库、从库发送
PING
命令,如果主库或从库没有在规定时间内响应PING
命令,哨兵就把它标记为主管下线 -
客观下线:如果主库被标记为主观下线,则正在监视这个主库的所有哨兵都要以每秒一次的频率确认主库是否真的进入主观下线。当有多数哨兵在指定的时间范围内确认主库进入了主观下线的状态,则主库会被标记为客观下线。
10.2.4 哨兵的工作模式
- 每个哨兵以每秒钟一次的频率向它所知的主库、从库及其他的哨兵实例发送
PING
命令 - 如果一个实例节点距离最后一次有效回复
PING
命令的时间超过down-after-milliseconds
的值,则这个哨兵会被标记为主观下线 - 如果主库被标记为主观下线,则正在监控这个主库的所有哨兵都要以每秒1次的频率确认主库的确进入了主观下线的状态
- 当有足够数量的哨兵(大于等于配置文件指定的值)在指定的时间范围内确认主库的确进入了主观下线状态,则主库会被标记为客观下线
- 当主库被标记为客观下线时,进入选主模式
- 若没有足够数量的哨兵同意主库已经进入主观下线,主库的主观下线状态就会被移除;若主库重新向哨兵的
PING
命令返回有效回复,主库的主观下线状态就会被移除
10.2.5 哨兵如何选主
哨兵选主包括两大过程,分别是:过滤和打分。
- 选主时,会判断从库的状态,如果已经下线,就直接过滤
- 如果从库网络不好,老是超时,也会被过滤掉。
down-after-milliseconds
,它表示我们认定主从库断连的最大连接超时时间 - 过滤掉不合适的从库后,就可以给剩下的从库打分,按找从库优先级、从库复制进度和从库ID号
- 从库优先级高,打分就越高,优先级通过
slave-priority
配置。如果优先级一样,就选与旧的主库复制进度最快的从库。如果优先级和从库复制进度都一样,从库ID号小的打分高
10.2.6 哪个哨兵执行主从切换?
一个哨兵标记主库为主观下线后,它会征求其他哨兵的意见,确认主库是否的确进入了主观下线状态。它向其他实例哨兵发送
is-master-down-by-addr
命令。
其他哨兵会根据自己和主库的连接情况,回应 Y或 N(Y 表示赞成,N 表示反对票)。如果这个哨兵获取得足够多的赞成票数(quorum配置),主库会被标记为客观下线。
标记主库客观下线的这个哨兵,紧接着想其他哨兵发送命令,再次发起投票,希望它可以来执行主从切换。这个投票过程称为Leader选举。一个哨兵想成为Leader需要满足两个条件:1. 需要拿到num(sentinels)/2+1的赞成票 2. 拿到的票数需要大于等于哨兵配置文件中的quorum
值
时间 | 哨兵A1 | 哨兵A2 | 哨兵A3 |
---|---|---|---|
t1 | 给自己1票,向A2、A3发送投票请求,表示想成为Leader | ||
t2 | 给自己1票,向A1、A2发送投票请求,表示也想成为Leader | ||
t3 | 收到A3的投票请求,回复N | ||
t4 | 收到A3的投票请求,回复Y | ||
t5 | 收到A1的投票请求,回复N,因为已经投票给A3了 | ||
T6 | 成为Leader |
- 在t1时刻,哨兵A1判断主库为客观下线,它想成为主从切换的Leader,于是先给自己投一票,然后向A2、A3发起投票命令,表示想成为Leader
- 在t2时刻,A3判断主库为客观下线,它也想成为Leader,所以也先给自己投一票,然后想A1、A2发起投票命令,表示也想成为Leader
- 在t3时刻,哨兵 A1收到了A3的Leader 投票请求。因为 A1已经把票投给自己了,所以它不能再给其他哨兵投赞成票了,所以A1投票N给A3。
- 在 t4 时刻,哨兵 A2 收到A3 的Leader 投票请求,因为哨兵 A2 之前没有投过票,它会给第一个向它发送投票请求的哨兵回复赞成票 Y。
- 在 t5 时刻,哨兵 A2 收到A1 的Leader 投票请求,因为哨兵 A2 之前已经投过赞成票给A3 了,所以它只能给 A1 投反对票 N。
- 最后t6 时刻,哨兵 A1 只收到自己的一票,而哨兵 A3 得到两张赞成票,因此哨兵 A3 成为了 Leader。
假设网络故障等原因,哨兵A3页没有收到两张票,那么这轮投票就不会产生Leader。哨兵集群会等待一段时间(一般是哨兵故障转移超时时间的2倍)再进行重新选举。
10.2.7 故障转移
假设有三个哨兵,一个主库M,两个从库S1和S2。哨兵监测到主库M出现故障,这个时候需要对集群进行故障转移。假设哨兵3被选作Leader,故障转移流程如下:
- 从库S2解除从节点身份,升级成为新的主库
- 从库S1成为新主库的从库
- 原主库恢复了,成为新主库的从库
- 通知客户端应用程序新主库的节点地址
10.3 Redis Cluster集群
哨兵模式基于主从模式,实现读写分离,还可以自动切换,可用性更高,但是它每个节点存储的数据都是一样的,浪费内存,且不好扩容。因此,Redis Cluster集群应运而生。它是在redis3.0加入的,实现了分布式存储。对数据进行分片,也就是说每台Redis节点上存储不同的内容,来解决在线扩容的问题。并且,它可以保存大量数据,即分散数据到各个Redis实例,还提供复制和故障转移的功能。
比如你一个Redis实例保存15G甚至更大的数据,响应就会很慢,这是因为Redis RDB 持久化机制导致的,Redis会fork子进程完成 RDB 持久化操作,fork执行的耗时与 Redis 数据量成正相关。
Redis Cluster的架构如下:
10.3.1 哈希槽
Redis Cluster采用哈希槽的方案来处理数据和实例的映射关系。
一个集群分片被分为16384个槽(2KB),每个进入redis的键值对,根据key进行散列,分配到16384个插槽中的一个中去。使用的哈希映射也比较简单,用CRC16算法对key计算出16bit的值,再对16384取模。
集群中的每个节点负责一部分的哈希槽,假设当前集群有A、B、C个节点,每个节点上负责的哈希槽数 =16384/3,那么可能存在的一种分配:
-
节点A负责0~5460号哈希槽
-
节点B负责5461~10922号哈希槽
-
节点C负责10923~16383号哈希槽
10.3.2 MOVED重定向和ASK重定向
客户端给一个Redis实例发送数据读写操作时,如果这个实例上并没有相应的数据,会怎么样呢?
在Redis Cluster模式下,节点对请求的处理流程如下:
-
MOVED重定向
客户端给一个Redis实例发送数据读写操作时,如果计算出来的槽不是在该节点上,这时候它会返回MOVED重定向错误,MOVED重定向错误中,会将哈希槽所在的新实例的IP和port端口带回去。这就是Redis Cluster的MOVED重定向机制。流程图如下:
-
ASK重定向
ASK重定向一般发生在集群伸缩的时候,集群伸缩会导致槽迁移,当我们去源节点访问时,此时数据已经可能已经迁移到了目标节点,使用Ask重定向可以解决此种情况。
10.3.3 Redis Cluster的通讯协议Gossip
一个Redis集群由多个节点组成,各个节点之间是怎么通信的呢?通过Gossip协议!Gossip是一种谣言传播协议,每个节点周期性地从节点列表中选择 k 个节点,将本节点存储的信息传播出去,直到所有节点信息一致,即算法收敛了。
Gossip协议基本思想:一个节点想要分享一些信息给网络中的其他的一些节点。于是,它周期性的随机选择一些节点,并把信息传递给这些节点。这些收到信息的节点接下来会做同样的事情,即把这些信息传递给其他一些随机选择的节点。一般而言,信息会周期性的传递给N个目标节点,而不只是一个。这个N被称为fanout
Redis Cluster集群通过Gossip协议进行通信,节点之间不断交换信息,交换的信息内容包括节点出现故障、新节点加入、主从节点变更信息、slot信息等等。gossip协议包含多种消息类型,包括ping,pong,meet,fail,等等。
- meet消息:通知新节点的加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
- ping消息:节点每秒会向其余的节点发送ping消息,消息中带有自己已知的两个节点的地址、槽、状态信息、最后一次通信时间等
- pong消息:当接收到meet、ping消息,作为响应消息发送给回复方确认消息正常通信。消息中也带有自己已知的两个节点信息
- fail消息:当节点确认一个集群中的一个节点下线时,会向集群广播一个fail消息,其他节点接收到这个消息后,标记对应的节点为下线状态
节点之间的通讯都是通过集群总线来通讯的,通讯时,使用特殊的端口,即对外服务端口号加10000。例如如果某个node的端口号是6379,那么它与其它nodes通信的端口号是 16379。nodes 之间的通信采用特殊的二进制协议。
10.3.4 故障转移
Redis集群实现了高可用,当集群内节点出现故障时,通过故障转移,以保证集群正常对外提供服务。
redis集群通过ping/pong消息,实现故障发现。这个环境包括主观下线和客观下线。
主观下线: 某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
客观下线: 指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。
假如节点A标记节点B为主观下线,一段时间后,节点A通过消息把节点B的状态发到其它节点,当节点C接受到消息并解析出消息体时,如果发现节点B的pfail状态时,会触发客观下线流程
当下线节点为主节点时,此时Redis Cluster集群会统计持有槽的主节点投票,看投票数是否达到一半,当下线报告统计数大于一半时,被标记为客观下线状态。
流程如下:
**故障恢复:**故障发现后,如果下线节点的是主节点,则需要在它的从节点中选一个替换它,以保证集群的高可用。流程如下:
资格检查:检查从节点是否具备替换故障主节点的条件。
准备选举时间:资格检查通过后,更新触发故障选举时间。
发起选举:到了故障选举时间,进行选举。
选举投票:只有持有槽的主节点才有票,从节点收集到足够的选票(大于一半),触发替换主节点操作
11 使用过Redis分布式锁吗?有哪些注意点呢?
分布式锁是控制分布式系统不同进程之间共同访问共享资源的一种锁的实现。秒杀下单、抢红包等业务场景都需要使用分布式锁。讨论一下Redis实现分布式锁的几种写法:
-
setnx + expire(错误)
if (jedis.setnx(key, lock_value) == 1) { // 加锁 expire(key, 100); // 设置过期时间 try { doSometing(); } catch(){} finally { jedis.del(key); // 释放锁 } }
这种写法,如果执行完了
setnx
,正要执行expire
的时候,进程crash掉或者重启了,这个锁就永远不会失效了,别的线程就永远获取不到这个锁了 -
setnx + value值是过期时间(错误)
long expires = System.currentTimeMillis() + expireTime; // 系统时间+设置的过期时间String expiresStr = String.valueOf(expires); // 如果当前锁不存在,返回加锁成功 if (jedis.setnx(key, expiresStr) == 1) { return true; } // 如果锁已经存在,获取锁的过期时间 String currentValueStr = jedis.get(key); // 如果获取到的过期时间,小于系统当前时间,表示已经过期 if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间 String oldValueStr = jedis.getSet(key, expiresStr); // 存在并发问题 if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁 return true; } } return false;
这种写法也有几个问题:
- 过期时间是客户端自己生成的,所以分布式环境下,需要保证每个客户端的时间都一致
- 没有持有者的唯一标识,可能会被其他客户端释放
- 锁过期的时候,客户端并发过来,都执行了
jedis.getSet
,最终只能有一个客户端成功,但是该客户端的锁的过期时间会被别的客户端覆盖
-
set的扩展命令(set ex px nx) (错误)
if (jedis.set(key, value, "NX", "EX", 100) == 1) { try { doSometing(); } catch(){} finally { jedis.del(key); // 释放锁 } }
这种写法也有问题:
- 锁过期释放了,但是业务还没有执行完
- 锁被其他线程误删掉
-
set ex px nx + 校验唯一随机制,再删除
if (jedis.set(key, uni_request_id, "NX", "EX", 100) == 1) { try { doSometing(); } catch(){} finally { // 判断是不是当前线程加的锁,是才释放 if (uni_request_id.equals(jedis.get(key))) { jedis.del(key) } } }
这种写法的问题是,判断是否当前线程加锁和释放锁不是原子性操作,可能在执行
jedis.del(key)
的时候,可能当前这把锁已经不属于当前客户端了,会误删别人的锁。一般会用lua脚本替代,lua脚本如下:
if redis.call('get', KEYS[1] == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
这种方式解决了原子性的问题,但是还是没有解决锁过期了,但是业务还没有处理完的问题。
我们还可以通过Redisson来解决
12 使用过Redisson吗?说说它是怎么解决锁过期了但是业务还没执行完的问题的
线程一获取锁成功后,就会启用一个watch dog看门狗,它是一个后台线程,每隔10s检查一下,如果线程一还持有锁,就会不断延长锁key的生存时间,因此Redisson就解决了锁过期释放,但是业务还没有处理完的问题。
13 什么是Redlock算法?
Redis一般都是集群部署的,假设数据在主从同步的时候,主节点挂了,那么分布式锁会有哪些问题呢?
假设两个线程同时请求锁,线程一在Redis的master节点上拿到了锁,但是这个锁还没有同步到slave节点,这个时候master挂掉了,slave就会升级成master,线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例。
RedLock实现步骤如下:
- 获取当前时间,以毫秒为单位
- 按照顺序向5个master加锁,客户端设置网络连接时间和最大超时时间,最大超时时间必须小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
- 客户端当前时间减去开始获取锁的时间,得到获取锁使用的时间。当且仅当超过一半的master节点加锁成功,并且加锁时间小于锁的失效时间(如上图 10s > 30ms + 30ms +40ms + 50ms + 50ms),加锁才算成功。
- 如果获取锁成功,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
- 如果获取锁失败,客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)
14 Redis的跳跃表
- 跳跃表是有序集合Zset的底层实现之一
- 跳跃表支持平均O(logN),最坏O(N)的复杂度查找,还可以通过顺序性操作批量处理节点
- 跳跃表实现由
zskiplist
和zskiplistNode
两个结构组成,其中zskiplist
用于保存跳跃表信息(如表头节点、表尾节点、长度),zskiplistNode
用于表示跳跃表的节点 - 跳跃表就是在链表的基础上,增加了多级索引提升查询效率
15 MySQL和Redis如何保证双写一致性
-
缓存延时双删
流程:写请求->删除缓存->更新数据库->休眠一会儿,再次删除缓存
这种休眠时间=业务读取时间+几百毫秒。这个休眠一般1s。这个方案还可以,只有休眠那一会儿可能会出现脏数据,一般业务可接受。但是如果缓存删除失败,那么缓存和数据库还是可能出现数据不一致的情况。给key设置expire过期时间,那业务还需要接受过期时间内数据不一致的情况。
-
删除缓存重试机制
这个是基于第一种方案的延续,解决第二次删除缓存失败的情况:写请求->某些原因导致删除失败->把删除失败的key发送队列->消息队列消费删除
-
读取biglog 异步删除缓存
16 为什么Redis 6.0之后改为多线程?
- Redis6.0之前,Redis在处理客户端请求的时候,包括读socket、解析、执行、写socket都是由一个顺序串行的主线程处理,这就是所谓的单线程
- Redis6.0之前为什么一致不用多线程?使用Redis的时候,CPU几乎不可能成为瓶颈,Redis基本是受限于内存和网络。例如在一个普通的Linux系统上,Redis通过使用pipelining每秒可以处理100万个请求。使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis通过AE事件模型以及IO多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。
redis使用多线程并非完全摒弃单线程,redis还是使用单线程来处理客户端的请求,只是使用多线程来处理数据的读写和协议的解析,执行命令还是使用单线程。
这样做的目的是因为redis的性能瓶颈在于网络IO而非CPU,使用多线程能提升IO读写的效率,从而提升redis整体的性能。
17 聊聊redis的事务机制
Redis通过multi
、exec
、watch
、discard
等一组命令集合来实现事务。
事务支持一次性执行多条命令,一个事务中的多条命令都会被序列化。在事务执行过程中,会按照顺序串行的执行命令,其它客户端的命令不会插入到事务的命令序列里面去。
事务就是顺序性、一次性、排他性的执行一个队列中的一系列命令。事务的执行流程如下:
- 开始事务(
multi
) - 命令入队
- 执行事务(
exec
)、撤销事务(discard
)
18 Redis的hash冲突怎么办
redis为了解决hash冲突,采用了链式哈希。链式哈希是指同一个哈希桶里面,多个元素使用一个链表来保存,它们之间依次使用指针连接。
链表上的元素只能通过指针逐一查找操作,这样数据越多,哈希冲突也就越多,链表就会越长,那查询效率就会变低。为了保证高效,redis会对哈希表进行rehash操作,也就是增加哈希桶,减少冲突。为了rehash更高效,redis还默认使用了两个全局哈希表,一个用于当前使用,一个用于扩容。
19 在生成RDB期间,Redis可以同时处理写请求吗?
可以的。Redis通过两个请求生成RDB文件,分别是save
和bgsave
save
:会阻塞,因为是主线程执行bgsave
:不会阻塞,fork一个子进程来生成RDB文件,主进程还是继续处理客户端请求
20 Redis底层,使用了什么协议?
RESP,全称Redis Serialization Protocol。它是专门为Redis设计的一套序列化协议。这个协议在版本1.2的时候就已经出现,但是在Redis2.0的时候才成为redis通讯协议的标准。
RSEP协议主要有实现简单、解析速度快、可读性好等优点
21 布隆过滤器是什么
解决缓存穿透的我们可以采用布隆过滤器,那么布隆过滤器是什么呢?
布隆过滤器是一种占用空间很小的数据结构,由一个很长的二进制向量和一组哈希映射函数组成,它用于检查某个元素是否在集合当中。
原理:利用多个哈希函数将集合里的数据映射到不同的位上,并将这些位的值设成1。当检查一个元素是否在集合中的时候,可以将这个元素进行哈希操作,得到多个哈希值,检查这些哈希值对应的位是否都位1,如果有一个不为1,那这个元素肯定不属于这个集合,如果都是1,那这个元素有可能属于这个集合,因为哈希冲突,不同元素可能映射到同一个位上了。
- 优点
- 查询时间复杂度低,增加和查询的时间复杂度为O(N),N为哈希函数的个数
- 存储空间小
- 保密性强,因为不存元素
- 缺点
- 有一定误判率
- 很难删除元素(因为可能出现哈希冲突的原因)
- 无法获取元素本身,只能判断是否存在
22 Redis存在线程安全问题吗?为什么?
- Redis Server本身是一个线程安全的K-V数据库,在Redis Server上执行的指令是线程安全的,就算是Redis6.0之后增加了多线程,但是增加的多线程只是用来处理网络IO事件,指令的执行仍然是主线程在处理。
- Redis 客户端层面来讲,如果多个客户端同时执行指令的话, 是无法保证原子性的,这个的解决方法有很多如:尽量使用原子指令、共享资源加锁、通过lua脚本实现多个指令的原子性操作。
23 请描述一下Redis的缓存淘汰策略
-
当Redis使用内存达到
maxmemory
参数配置的阈值的时候,Redis就会根据配置的内存淘汰策略淘汰内存。maxmemory默认是服务器的最大内存 -
Redis提供了8中内存淘汰策略:
- volatile-lru:内存不够时,从设置了过期时间的key中使用lru算法淘汰
- allkeys-lru:内存不够时,从所有的key中使用lru算法淘汰
- volatile-lfu:4.0版本增加,内存不够时,从设置了过期时间的key中使用lfu算法淘汰
- allkeys-lfu:4.0版本增加,内存不够时,从所有的key中使用lfu算法淘汰
- volatile-random:内存不够时,随机从设置了过期时间的key中淘汰
- allkeys-random:内存不够时,随机从所有key中淘汰
- volatile-ttl:内存不够时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的越线淘汰
- noeviction:默认策略,内存不够时,写入报错
这些策略可以在redis.conf文件中手动配置
-
我们使用Redis做缓存的时候,建议增加这些缓存的过期时间
24 Redis主从复制的原理
Redis的主从复制包括全量复制和增量复制。
全量复制是发生在slave初始化的阶段,从节点会主动想主节点发起同步请求,主节点接收到请求后会生成一份当前数据的快照,发送给从节点,从节点接收到数据后加载完成全量复制。
增量复制是发生在Master发生数据变化的过程中,后把变化的数据同步给从节点。增量复制是通过维护offset这个复制偏移量来实现的。
25 Redis的AOF重写的过程描述一下
AOF是Redis里面持久化的一直方式,它采用了指令追加的方式,会把每个数据更改的操作指令,追加存储到aof文件里面。所以这种方式容易造成AOF文件过大,导致IO性能问题。
Redis为了解决这个问题,设计了AOF重写机制,它是将AOF文件里相同的指令进行压缩,只保留最终的指令。
Redis重写过程如下:
- 读取当前Redis里面的数据,写入到新的AOF文件里面,这一个过程是后台子进程来实现的。
- 重写完成后,用新的AOF文件替换旧的AOF文件。
- 在子进程写AOF文件的时候,主进程的数据变更要写到重写缓冲区里面,等到AOF重写完成后,再把重写缓冲区的内容追加到AOF文件里面。
26 Redis的哨兵机制和集群有什么区别?
Redis的集群分为2种,一个是主从复制,一种是Redis Cluster,我觉得您可能说的是Redis Cluster。我认为哨兵和Redis Cluster有以下3点不同:
- Redis哨兵是通过主从复制来实现的,所以它可以实现读写分离,而Redis Cluster的从节点只是为了实现冷备机制,它只有在Master宕机之后才会工作。
- Redis哨兵无法实现在线扩容,所以它的并发受限于单个服务器的资源配置,Redis Cluster提供了基于slot槽的数据分片机制,可以实现在线扩容提升写数据的性能
- 从架构上来讲,哨兵是一主多从,Redis Cluster是多主多从
27 使用Redis Cluster,那我希望一个订单相关的key都到一台机器,比如订单的统计数据、订单的基本信息等等,是不同的key,会分散到不同的机器,查询就会查询多台机器,怎么解决?
使用hash tag来实现。可以在key上加{}.这样hash算法会根据{}里面的值来进行slot的计算了
如{user1000}.order和{user1000}.goods,就会分配在同一个slot里面,因为只会拿{}里面的内容来做hash