💂 个人主页: Java程序鱼

💬 如果文章对你有帮助,欢迎关注、点赞、收藏(一键三连)和订阅专栏

👤 微信号:hzy1014211086,想加入技术交流群的小伙伴可以加我好友,群里会分享学习资料、学习方法


序号内容链接地址
1Java基础知识面试题https://blog.csdn.net/qq_35620342/article/details/119636436
2Java集合容器面试题https://blog.csdn.net/qq_35620342/article/details/119947254
3Java并发编程面试题https://blog.csdn.net/qq_35620342/article/details/119977224
4Java异常面试题https://blog.csdn.net/qq_35620342/article/details/119977051
5JVM面试题https://blog.csdn.net/qq_35620342/article/details/119948989
6Java Web面试题https://blog.csdn.net/qq_35620342/article/details/119642114
7Spring面试题https://blog.csdn.net/qq_35620342/article/details/119956512
8Spring MVC面试题https://blog.csdn.net/qq_35620342/article/details/119965560
9Spring Boot面试题https://blog.csdn.net/qq_35620342/article/details/120333717
10MyBatis面试题https://blog.csdn.net/qq_35620342/article/details/119956541
11Spring Cloud面试题待分享
12Redis面试题https://blog.csdn.net/qq_35620342/article/details/119575020
13MySQL数据库面试题https://blog.csdn.net/qq_35620342/article/details/119930887
14RabbitMQ面试题待分享
15Dubbo面试题待分享
16Linux面试题待分享
17Tomcat面试题待分享
18ZooKeeper面试题待分享
19Netty面试题待分享
20数据结构与算法面试题待分享


第一期我给大家准备了12道高频面试题,大家可以自查,哪块知识点不明白的可以细看。
在这里插入图片描述

1.Redis和Memcached相比,有哪些优势?

①Redis数据结构更丰富,支持string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)等数据结构存储,Memcached仅支持String数据类型。

②Redis支持数据的持久化,可以把内存中的数据持久化到硬盘中,而Memcached不支持持久化,数据只能存在内存中,重启后数据就没了。

③Memcached没有原生的集群模式,需要依靠客户端自己实现集群分片,而Redis原生支持集群模式。

④Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路 IO 复用模型。

2.Redis为什么要把数据放到内存中?

Redis 为了达到最快的读写速度将数据都读到内存中,并通过异步的方式将数据刷回磁盘。所以 Redis 具有性能好和数据持久化的特征。 如果不将数据放在内存中,磁盘 I/O 速度会严重
影响 Redis 的性能。在内存越来越便宜的今天, Redis 越来越受欢迎。

3.Redis为什么这么快?

大家都知道,Redis是单线程的,为什么采用单线程的Redis也会如此之快呢?接下来我们分析其中缘由。

严格来说,Redis Server是多线程的, 只是它的请求处理整个流程是单线程处理的。 这一点我们一定要清楚了解到,不要单纯地认为Redis Server是单线程的。

Redis的性能非常之高,每秒可以承受10W+的QPS,它如此优秀的性能主要取决于以下几个方面:

  • Redis大部分操作在内存完成
  • 采用IO多路复用机制
  • 非CPU密集型任务
  • 单线程的优势

(1)纯内存操作

Redis是一个内存数据库,它的数据都存储在内存中,这意味着我们读写数据都是在内存中完成,这个速度是非常快的。

Redis底层采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。

(2)采用IO多路复用机制

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

(3)非CPU密集型任务

采用单线程的缺点很明显,无法使用多核CPU。Redis作者提到,由于Redis的大部分操作并不是CPU密集型任务,而Redis的瓶颈在于内存和网络带宽。

在高并发请求下,Redis需要更多的内存和更高的网络带宽,否则瓶颈很容易出现在内存不够用和网络延迟等待的情况。

当然,如果你觉得单个Redis实例的性能不足以支撑业务,Redis作者推荐部署多个Redis节点,组成集群的方式来利用多核CPU的能力,而不是在单个实例上使用多线程来处理。

(4)单线程的优点

基于以上特性,Redis采用单线程已足够达到非常高的性能,所以Redis没有采用多线程模型。

另外,单线程模型还带了以下好处:

  • 避免多线程上下文切换导致的性能损耗
  • 避免多线程访问共享资源加锁导致的性能损耗

所以Redis正是基于有以上这些优点,所以采用了单线程模型来完成请求处理的工作。

(5)单线程的缺点

单线程处理最大的缺点就是,如果前一个请求发生耗时比较久的操作,那么整个Redis都会被阻塞,其他请求也无法进来,直到这个耗时久的操作处理完成并返回,其他请求才能被处理到。

我们平时遇到Redis响应变慢或长时间阻塞的问题,大部分都是因为Redis处理请求是单线程这个原因导致的。

所以,我们在使用Redis时,一定要避免非常耗时的操作,例如使用时间复杂度过高的方式获取数据、一次性获取过多的数据、大量key集中过期导致Redis淘汰key压力变大等等,这些场景都会阻塞住整个处理线程,直到它们处理完成,势必会影响业务的访问。

(6)多线程优化

Redis Server是多线程的,除了请求处理流程是单线程处理之外,Redis内部还有其他工作线程在后台执行,它负责异步执行某些比较耗时的任务,例如AOF每秒刷盘、AOF文件重写都是在另一个线程中完成的。

而在Redis 4.0之后,Redis引入了lazyfree的机制,提供了unlink、flushall aysc、flushdb async等命令和lazyfree-lazy-eviction、lazyfree-lazy-expire等机制来异步释放内存,它主要是为了解决在释放大内存数据导致整个redis阻塞的性能问题。

在删除大key时,释放内存往往都比较耗时,所以Redis提供异步释放内存的方式,让这些耗时的操作放到另一个线程中异步去处理,从而不影响主线程的执行,提高性能。

到了Redis 6.0,Redis又引入了多线程来完成请求数据的协议解析,进一步提升性能。它主要是解决高并发场景下,单线程解析请求数据协议带来的压力。请求数据的协议解析由多线程完成之后,后面的请求处理阶段依旧还是单线程排队处理。

可见,Redis并不是保守地认为单线程有多好,也不是为了使用多线程而引入多线程。Redis作者很清楚单线程和多线程的使用场景,针对性地优化。

4.Redis数据类型有哪些?分别应用于哪些场景?

Redis 有 5 种基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。

Redis 所有的数据结构都是一个key对应一个value,不同类型的数据结构之间的差异就在于value的结构不同,例如string数据类型,他的value就是一个字符串,list数据类型,他的value是一个链表。

string

字符串 string 是 Redis 最简单的数据结构,可以存储字符串、整数或者浮点数。最常见的应用场景就是对象缓存,例如缓存用户信息,key是"userInfo"+#{用户ID},value是用户信息对象的json字符串。

案例:

key:userInfo123

value:{“gender”:1,“nickname”:“java程序鱼”,“userId”:123}

(1)基本操作

127.0.0.1:6379> set name hzy # 设置
OK
127.0.0.1:6379> get name # 获取
"hzy"
127.0.0.1:6379> exists name  # 判断是否存在
(integer) 1
127.0.0.1:6379> del name # 删除
(integer) 1
127.0.0.1:6379> get key
(nil)

(2)批量操作

可以批量对多个字符串进行读写,节省网络耗时开销。

127.0.0.1:6379> mset name1 xiaoming name2 xiaohong # 批量设置
OK
127.0.0.1:6379> mget name1 name2 # 批量获取
1) "xiaoming"
2) "xiaohong"

(3)计数

如果 value 值是一个整数,我们可以对它进行自增长操作。自增长是有范围的,它的范围是 signed long 的最大最小值,超过了这个值,Redis 会报错。

127.0.0.1:6379> incr likenum # 自增1
(integer) 1
127.0.0.1:6379> get likenum
"1"
127.0.0.1:6379> decr likenum # 减1
(integer) 0
127.0.0.1:6379> get number
"0"

(4)过期

127.0.0.1:6379> expire name 60 # 设置超时时间
(integer) 1
127.0.0.1:6379> setex name 60 value # 等价于 setex + expire
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 11

字符串是由多个字节组成,每个字节又是由 8 个 bit 组成

(5)应用场景

  • 缓存:像我们平时开发,经常会把一个对象转成json字符串,然后放到redis里缓存
  • 计数器:像博客文章的阅读量、评论数、点赞数等等
  • 分布式系统生成自增长ID

list

Redis 的列表相当于 Java 语言里面的 LinkedList。

LinkedList优点:插入性能高,不管是从末尾插入还是中间插入

LinkedList缺点:随机读性能差,例如LinkedList.get(10),这种操作,性能就很低,因为他需要遍历这个链表,从头开始遍历这个链表,直到找到index = 10的这个元素为止。

(1)通过list实现队列

右边进左边出

127.0.0.1:6379> rpush apps qq # 将元素插入到列表的尾部(最右边)
(integer) 1
127.0.0.1:6379> rpush apps wechat taobao # 将多个元素插入到列表的尾部(最右边)
(integer) 3
127.0.0.1:6379> lpop apps # 移除并返回列表的第一个元素(最左边)
"qq"
127.0.0.1:6379> lrange apps 0 1 # 返回列表中指定区间内的元素,0表示第一个,1表示第二个,-1表示最后一个
1) "wechat"
2) "taobao"
127.0.0.1:6379> lrange apps 0 -1 # -1表示倒数第一
1) "wechat"
2) "taobao"

注意:当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收

(2)通过list实现栈

先进先出,右边进右边出

127.0.0.1:6379> rpush apps qq wechat taobao
(integer) 3
127.0.0.1:6379> rpop apps # 移除列表的最后一个元素,返回值为移除的元素
"taobao"

(3)应用场景

  • 异步队列
  • 任务轮询(RPOPLPUSH)
  • 文章列表(lrange key 0 9)

hash

Redis的hash结构相当于Java语言的HashMap,内部实现结构上与JDK1.7的HashMap一致,底层通过数据+链表实现。

(1)常用命令

127.0.0.1:6379> hmset userInfo name "hzy" age "24" sex "1" 
OK
127.0.0.1:6379> hexists userInfo name # 相当于HashMap的containsKey()
(integer) 1
127.0.0.1:6379> hget userInfo name # 相当于HashMap的get()
"hzy"
127.0.0.1:6379> hget userInfo age
"24"
127.0.0.1:6379> hgetall userInfo # 数据量大时,谨慎使用!获取在哈希表中指定 key 的所有字段和值
1) "name"
2) "hzy"
3) "age"
4) "24"
5) "sex"
6) "1"
127.0.0.1:6379> hkeys userInfo # 数据量大时,谨慎使用!获取 key 列表
1) "name"
2) "age"
3) "sex"
127.0.0.1:6379> hvals userInfo # 获取 value 列表
1) "hzy"
2) "24"
3) "1"
127.0.0.1:6379> hset userInfo name "test" # 修改某个字段对应的值
127.0.0.1:6379> hget userInfo name
"test"

(2)应用场景

记录整个博客的访问人数(数据量大会考虑HyperLogLog,但是这个数据结构存在很小的误差,如果不能接受误差,可以考虑别的方案)

记录博客中某个博主的主页访问量、博主的姓名、联系方式、住址

set

Redis的set集合相当于Java的HashSet。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。

补充:HashSet就是基于HashMap来实现的,HashSet,他其实就是说一个集合,里面的元素是无序的,他里面的元素不能重复的,HashMap的key是无顺序的,你插入进去的顺序,跟你迭代遍历的顺序是不一样的,而且HashMap的key是没有重复的,HashSet直接基于HashMap实现的。

(1)常用命令

127.0.0.1:6379> sadd apps wechat qq # 添加元素
(integer) 2
127.0.0.1:6379> sadd apps qq # 重复
(integer) 0
127.0.0.1:6379> smembers apps # 注意:查询顺序和插入的并不一致,因为 set 是无序的
1) "qq"
2) "wechat"
127.0.0.1:6379> scard apps # 获取长度
(integer) 2
127.0.0.1:6379> sismember apps qq # 谨慎使用!检查某个元素是否存在set中,只能接收单个元素
(integer) 1
127.0.0.1:6379> sadd apps2 wechat qq
(integer) 2
127.0.0.1:6379> sinterstore apps apps apps2 # 获取 apps 和 apps 的交集并存放在 apps3 中
(integer) 1
127.0.0.1:6379> smembers app3
1) "qq"
2) "wechat"

注意: 当集合中最后一个元素移除之后,数据结构自动删除,内存被回收

(2)应用场景

  • 微博抽奖:如果数据量不是特别大的时候,可以使用spop(移除并返回集合中的一个随机元素)或srandmember(返回集合中一个或多个随机数)

  • QQ标签:一个用户多个便签

  • 共同关注(交集)

  • 共同好友(交集)

sorted set

sorted set 有序集合,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。使得它类似于Java的TreeSet和HashMap的结合体。

(1)常用命令

127.0.0.1:6379> zadd apps 3.0 qq # 添加元素到 sorted set 中,3.0是score的值
(integer) 1
127.0.0.1:6379> zadd apps 2.0 wechat 1.0 aliyun # 一次添加多个元素
(integer) 2
127.0.0.1:6379> zcard apps # 查看 sorted set 中的元素数量
(integer) 3
127.0.0.1:6379> zscore apps wechat # 查看某个 value 的权重
"2.0"
127.0.0.1:6379> zrange apps 0 -1 # 通过索引区间返回有序集合指定区间内的成员,0 -1 表示输出所有元素
1) "aliyun"
2) "wechat"
3) "qq"
127.0.0.1:6379> zrange apps 0 1 # 通过索引区间返回有序集合指定区间内的成员
1) "aliyun"
2) "wechat"
127.0.0.1:6379> zrevrange apps 0 1 # 相当于zrange的反转
1) "qq"
2) "wechat"

注意: sorted set 中最后一个 value 被移除后,数据结构自动删除,内存被回收

(2)应用场景

  • 排行榜

  • 订单支付超时(下单时插入,member为订单号,score为订单超时时间戳,然后写个定时任务每隔一段时间执行zrange)

5.Redis的过期策略有哪些?大量key集中过期导致卡顿如何解决?

如果我们对key设置了失效时间1分钟,1分钟后,Redis 是如何对这个 key 进行删除的呢?

Redis过期策略采用的是惰性删除+定期删除策略。

惰性删除

当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除。

定期删除

当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除(这种是被动删除);但有一些键只访问一次或者是冷数据,因此需要主动删除,默认情况下Redis每秒检测10次,检测的对象是所有设置了过期时间的键集合,每次从这个集合中随机检测20个键查看他们是否过期,如果过期就直接删除,如果过期的key比例超过1/4,那就把上述操作重复一次(贪心算法)。同时为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时间的上限,默认不会超过25ms。

如果一个大型的 Redis 实例中所有的 key 在同一时间过期了,会引发什么问题?

这个主动过期 key 的定时任务,是在 Redis 主线程中执行的,也就是说如果在执行主动过期的过程中,出现了需要大量删除过期 key 的情况,那么此时应用程序在访问 Redis 时,必须要等待这个过期任务执行结束,Redis 才可以服务这个客户端请求。此时就会出现,应用访问 Redis 延时变大。

有小伙伴问,扫描不是有 25ms 的时间上限了么,怎么会导致卡顿呢?

假如有 1001 个客户端同时将请求发过来了,然后前 1000 个请求的执行时间都是 25ms,那么第 1001 个指令需要等待多久才能执行?25000ms,25秒,这个就是客户端的卡顿时间,是由服务器不间断的小卡顿积少成多导致的。

大量key集中过期导致卡顿如何解决?

方案一:在设置 key 的过期时间时,增加一个随机时间

redis.expireat(key, expire_time + random(300))

这样一来,Redis 在处理过期时,不会因为集中删除过多的 key 导致压力过大,从而避免阻塞主线程。

方案二:Redis 4.0 以上版本,开启 lazy-free 机制

lazyfree-lazy-expire yes

另外,除了业务层面的优化和修改配置之外,你还可以通过运维手段及时发现这种情况。

运维层面,你需要把 Redis 的各项运行状态数据监控起来,在 Redis 上执行 INFO 命令就可以拿到这个实例所有的运行状态数据。

在这里我们需要重点关注 expired_keys 这一项,它代表整个实例到目前为止,累计删除过期 key 的数量。

你需要把这个指标监控起来,当这个指标在很短时间内出现了突增,需要及时报警出来,然后与业务应用报慢的时间点进行对比分析,确认时间是否一致,如果一致,则可以确认确实是因为集中过期 key 导致的延迟变大。

从库的过期策略:从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。

6.Redis内存淘汰策略有哪些?他们有什么区别?

当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap)。 交换会让 Redis 的性能急剧下降,对于访问量比较频繁的 Redis 来说,这样低速的存取效率基本上等于不可用。

在生产环境中我们是不允许 Redis 出现交换行为的,为了限制最大使用内存,Redis 提供了配置参数 maxmemory 来限制内存超出期望大小。

淘汰策略

当实际内存超出 maxmemory 时,Redis 提供了6种可选策略 (maxmemory-policy) 来让用户自己决定该如何腾出新的空间以继续提供读写服务。

  • noeviction:不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
  • volatile-lru:尝试淘汰设置了过期时间的 key,通过LRU算法驱逐最近最少使用的key。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。
  • volatile-random:尝试淘汰设置了过期时间的 key,在设置了过期时间的key集合中随机选择数据淘汰。
  • volatile-ttl:尝试淘汰设置了过期时间的 key,在设置了过期时间的key集合中优先淘汰ttl小的。
  • allkeys-lru:和volatile-lru的区别在于要淘汰的key对象是全体key集合而不只是设置了过期时间的key,其他都一样。
  • allkeys-random:和volatile-random的区别在于要淘汰的key对象是全体key集合而不只是设置了过期时间的key,其他都一样。

Redis4.0后新增了两个策略:

volatile-lfu:尝试淘汰设置了过期时间的 key,通过LFU算法驱逐使用频率最少的key。没有设置过期时间的 key 不会被淘汰。

allkeys-lfu:和volatile-lfu的区别在于要淘汰的key对象是全体key集合而不只是设置了过期时间的key,其他都一样。

LRU算法

Redis LRU使用的是近似LRU算法,它跟 LRU 算法还不太一样。之所以不使用 LRU 算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。近似 LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU 算法非常近似的效果。

Redis 为实现近似 LRU 算法,它给每个 key 增加了一个额外的小字段,这个字段长度24位,存的是最后一次被访问的时间戳,当Redis执行写操作时,发现内存超出我们配置的 m a x m e m o r y , 就 会 执 行 一 次 L R U 淘 汰 算 法 , 随 机 采 样 出 {maxmemory},就会执行一次LRU淘汰算法,随机采样出 maxmemoryLRU{maxmemory_samples}个样本,默认值为5,然后淘汰掉最旧的key,如果淘汰后内存还超出 m a x m e m o r y , 那 就 继 续 随 机 采 样 淘 汰 , 直 到 内 存 低 于 {maxmemory},那就继续随机采样淘汰,直到内存低于 maxmemory{maxmemory}为止。

采样数越大,近似LRU算法的效果越接近严格LRU算法,通过样本数量调整算法的精度

淘汰池是一个数组,它的大小是${maxmemory_samples},在每次淘汰循环中,新随机出的key列表会淘汰池中的key列表进行融合,淘汰掉最旧的一个key之后,保留剩余较旧的key列表放入淘汰池等待下一个循环。

7.持久化方式有哪些?有什么区别?

Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。

什么是持久化?
就是把内存里的数据保存到硬盘上。

必须使用数据持久化吗?
Redis的数据持久化机制是可以关闭的。如果你只把Redis作为缓存服务使用,Redis中存储的所有数据都不是该数据的主体而仅仅是同步过来的备份,那么可以关闭Redis的数据持久化机制。

但通常来说,仍然建议至少开启RDB方式的数据持久化,因为:

①数据量不是非常大时,RDB方式的持久化几乎不损耗Redis本身的性能,因为Redis父进程持久化时只需要fork一个子进程,这个子进程可以共享主进程的所有内存数据,子进程会去读取主进程的内存数据,并把它们写入RDB文件。

②Redis无论因为什么原因发送故障,重启时能够自动恢复到上一次RDB快照中记录的数据(自动加载RDB文件)。这省去了手工从其他数据源(如数据库)同步数据的过程,而且要比其他任何的数据恢复方式都要快

③服务器的硬盘都是T级别的,几个G的数据影响忽略不计

Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持三种不同的持久化策略。

RDB

Redis提供了两个命令来生成 RDB 文件:

  • save:在主进程中执行,会导致写请求阻塞。
  • bgsave:fork一个子进程,专门用于写入 RDB 文件,避免了主进程的阻塞。

为了快照而阻塞写请求,这是系统无法接受的,因此Redis借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。

Redis在执行持久化时,会fork出一个bgsave子进程,这个子进程可以共享主进程的所有内存数据,bgsave子进程运行后,会去读取主进程的内存数据,并把它们写入RDB文件。

有小伙伴问,为什么要fork一个子线程?

redis是单线程程序,若单线程同时在服务线上的请求还需要进行文件IO操作,这不仅影响性能而且还会阻塞线上业务,因此这里主进程fork出一个进程,fork出的这个进程去完成快照操作。

快照持久化是 Redis 默认采用的持久化方式,我们可以根据业务需求配置下面参数:

save 900 1    #每900秒(15分钟)至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

save 300 10   #每300秒(5分钟)至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

save 60 10000 #每60秒(1分钟)至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

key发生变化(key数据添加、修改、删除)

触发快照的几种方式:

①服务器正常关闭时,会照一次快照 ./bin/redis-cli shutdown

②key满足一定条件,会照一次快照(通过上述Redis.conf配置)

③通过BGSAVE命令(在redis中执行)手动触发RDB快照保存

优点:

①RDB文件紧凑,体积小,网络传输快,适合全量复制

②与AOF方式相比,通过RDB文件恢复数据比较快更快

③RDB最大化了Redis的性能,因为Redis父进程持久化时只需要fork一个子进程,这个子进程可以共享主进程的所有内存数据,子进程会去读取主进程的内存数据,并把它们写入RDB文件。

缺点:

①快照是定期生成的,所有在 Redis 故障时或多或少会丢失一部分数据

②当数据量比较大时,fork 的过程是非常耗时的,fork 子进程时是会阻塞的,在这期间 Redis 是不能响应客户端的请求的。

AOF

Redis会把每一个写请求都记录在一个日志文件里,在Redis重启时,会把AOF文件中记录的所有写操作顺序执行一遍,确保数据恢复到最新。

Redis 会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令文本存储到 AOF 日志中,也就是先存到磁盘,然后再执行指令。这样即使遇到突发宕机,已经存储到 AOF 日志的指令进行重放一下就可以恢复到宕机前的状态。

日志文件太大怎么办?

AOF 日志在长期的运行过程中会变的很大,Redis重启时需要加载 AOF 日志进行指令重放,此时这个过程就会非常耗时。 所以需要定期进行AOF 重写,给 AOF 日志进行瘦身。

AOF如何重写?

Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。每次执行重写时,主进程 fork 出一个bgrewriteaof 子进程,会把主进程的内存拷贝一份给 bgrewriteaof 子进程,对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。

Redis提供了AOF rewrite功能,可以重写AOF文件,只保留能够把数据恢复到最新状态的最小写操作集。

AOF 重写可以通过bgrewriteaof命令(在redis里执行)触发,也可以配置Redis定期自动进行:

## Redis在每次AOF rewrite时,会记录完成rewrite后的AOF日志大小,当AOF日志大小在该基础上增长了100%后,自动进行AOF rewrite。同时如果增长的大小没有达到64mb,则不会进行rewrite。
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

AOF默认是关闭的,如果需要开启,需要在redis.conf配置文件中配置

appendonly  yes

AOF提供三种fsync配置,always/everysec/no,通过配置项appendfsync指定,默认是everysec。

appendfsync always    # 每写入一条日志就进行一次fsync操作,数据安全性最高,但速度最慢(每次有数据修改发生时都会写入AOF文)
appendfsync everysec  # 折中的做法,交由后台线程每秒fsync一次(每秒钟同步一次,该策略为AOF的缺省策略)
appendfsync no        # 不进行fsync,将flush文件的时机交给OS决定,速度最快(从不同步。高效但是数据不会被持久化)

优点:

①数据安全性高,可以根据业务需求配置fsync策略

②AOF文件易读,可修改,在进行了某些错误的数据清除操作后,只要AOF文件没有rewrite,就可以把AOF文件备份出来,把错误命令删除,然后恢复数据

缺点:

①AOF方式生成的日志文件太大,即使通过AFO重写,文件体积仍然很大

②数据恢复速度比RDB慢

混合持久化

如果我们采用 RDB 持久化会丢失一段时间数据。如果我们采用 AOF 持久化,AOF日志较大,重放比较慢。

Redis 4.0 为了解决这个问题,支持混合持久化。将 RDB 文件的内容和增量的 AOF 日志文件存在一起。

混合持久化同样也是通过 bgrewriteaof 完成的,不同的是当开启混合持久化时,fork出的子进程先将共享的内存副本全量的以 RDB 方式写入 AOF 文件,然后在将重写缓冲区的增量命令以 AOF 方式写入到文件,写入完成后通知主进程更新统计信息,并将新的含有RDB格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。简单的说:新的AOF文件前半段是RDB格式的全量数据后半段是AOF格式的增量数据。

于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。

实战经验

快照需要fork子进程的方式进行的,它是一个比较耗资源的操作。(当数据量非常大时,fork会很耗时,需要大概几百毫秒甚至1秒,fork时父进程是阻塞的,不能正常服务redis读写请求)

AOF 的 fsync 是一个耗时的 IO 操作,它会降低 Redis 性能,同时也会增加系统 IO 负担

所以通常 Redis 的主节点是不会进行持久化操作,持久化操作主要在从节点进行。从节点是备份节点,没有来自客户端请求的压力,它的操作系统资源往往比较充沛。

但是如果出现网络分区,从节点长期连不上主节点,就会出现数据不一致的问题,特别是在网络分区出现的情况下又不小心主节点宕机了,那么数据就会丢失,所以在生产环境要做好实时监控工作,保证网络畅通或者能快速修复。另外还应该再增加一个从节点以降低网络分区的概率,只要有一个从节点数据同步正常,数据也就不会轻易丢失。

8.怎么实现 Redis 的高可用?主从架构主节点和从节点数据怎么同步?

如果 Redis 的读写请求量很大,那么单个 Redis 实例很有可能承担不了这么大的请求量,如何提高Redis的性能呢?我们可以部署多个副本节点,业务采用读写分离的方式,把读请求分担到多个副本节点上,提高访问性能。要实现读写分离,就必须部署多个副本,每个副本需要实时同步主节点的数据。

单可用区(节点全部在一个可用区):无法应对机房级别的故障

如果上海可用区机房出现故障,整个Redis服务全部瘫痪,所以我们在平时部署时,需要把节点分散在不同的可用区,如果有小伙伴公司对可用性要求极高,可以研究下异地多活方案,在这里我就不展开了。

主从复制的三种方式:

①全量复制

②增量复制

③无盘复制

全量复制

假设我们有两个节点,A节点是 Master 节点,B节点是 Slave 节点。

当我们在节点B上执行slaveof命令后,节点B会与节点A建立一个TCP连接,然后发送psync ${runid} ${offset}命令,告知节点A需要开始同步数据。

参数介绍:

  • runid:每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例
  • offset:偏移量,slave需要从哪个位置开始同步数据

由于是第一次同步,Slave 节点不知道 Master节点的runid,所以 Slave 节点会发送psync ? -1,表示需要全量同步数据。

Master 节点在收到 Slave 节点发来的psync后,会给slave回复+fullresync ${runid} ${offset},这个runid就是master的唯一标识,slave会记录这个runid,用于后续断线重连同步请求。

Master 执行 bgsave 命令,生成 RDB 文件,接着将文件发给 Slave。Slave 接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为Slave在通过 replicaof 命令开始和 Master 同步前,可能保存了其他数据。为了避免之前数据的影响,Slave 需要先把当前数据库清空。

在 Master 将数据同步给 Slave 的过程中,Master 不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主 Slave 的数据一致性,Master 会在内存中用 repl_backlog_buffer 记录 RDB 文件生成后收到的所有写操作。

最后,Master 会把 repl_backlog_buffer数据再发送给从库。这样一来,主从库就实现同步了。

全量复制的开销:

主节点:生成RDB文件会占用内存、硬盘资源,网络传输RDB的时候会占用一定的网络带宽资源

从节点:清空数据,若数据量大,需要消耗一定的时间,加载RDB也需要一定的时间

增量同步

在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,从库就会和主库重新进行一次全量复制,开销非常大。

在Redis在这方面进行了改进,在2.8版本之后,Redis支持增量同步

主从因为故障断开,故障恢复后,他们重新建立连接,Slave 节点向 Master 节点发送数据 同步请求:psync ${runid} ${offset},Master 收到psync命令之后,检查slave发来的runid与自身的runid一致,如果一致,说明之前已经同步过数据,这次只需要同步部分数据即可。

这里分为两种情况:
①如果offset在repl_backlog_buffer范围内,那么 Master 节点给 Slave 节点回复+continue,表示这次只同步部分数据。之后 Master 节点把复制缓冲区offset之后的数据给 Slave 节点,接下来 Slave 节点执行这些命令后就与 Master 数据一致了。

②如果offset不在repl_backlog_buffer范围内,说明断开连接很久了,如果offset在repl_backlog_buffer的内容已经被新的内容覆盖了,此时只能触发全量数据同步。

无盘复制

通常,全量复制需要在磁盘上创建RDB文件,然后加载到内存中,Redis支持无盘复制,生成的RDB文件不保存到磁盘而是直接通过网络发送给从节点。无盘复制适用于主节点所在机器磁盘性能较差但网络宽带较充裕的场景。需要注意的是,无盘复制目前依然处于实验阶段

9.怎么做自动故障转移?哨兵如何监控节点?哨兵集群如何选主?故障迁移后如何通知客户端?

Redis 除了具有非常高的性能之外,还需要保证高可用,在故障发生时,尽可能地降低故障带来的影响,Redis提供了哨兵模式,来进行故障恢复。

哨兵主要负责做三件事:

①监控,监控主、从节点是否正常运行

②选主,Sentinel集群需要选择一个Leader来进行主从切换。

③通知,选主完成后,需要把新主库的连接信息通知给从库和客户端。

状态感知

哨兵启动后只指定了master的地址,要想知道整个集群中完整的拓扑关系怎么做呢?

哨兵每隔10秒会向每个master节点发送info命令,info命令返回的信息中,包含了主从拓扑关系,其中包括每个slave的地址和端口号。有了这些信息后,哨兵就会记住这些节点的拓扑信息,在后续发生故障时,选择合适的slave节点进行故障恢复。

那么有小伙伴会问,哨兵之间是如何通信的呢?

基于Redis提供的发布(pub)/订阅(sub)机制完成的。哨兵节点不会直接与其他哨兵节点建立连接,而是首先会和主库建立起连接,然后向一个名为"sentinel:hello"频道发送自己的信息(IP 和端口),其他订阅了该频道的哨兵节点就会获取到该哨兵节点信息,从而哨兵节点之间互知。

心跳检测

每一秒,每个 Sentinel 对 Master、Slave、其他哨兵节点执行PING命令,检测它们是否仍然在线运行,如果有节点在规定时间内没有响应PING命令,那么该哨兵节点认为此节点"主观下线"。

主观下线和客观下线

为什么需要客观下线机制?

因为当前哨兵节点探测对方没有得到响应,很有可能这两个机器之间的网络发生了故障,而 Master 节点本身没有任何问题,此时就认为 Master 故障是不正确的。

为了解决上述问题,客观下线应运而生,Sentinel一般会集群部署,引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。

假设我们有N个哨兵实例,如果有N/2+1个实例判断主库“主观下线”,此时把节点标记为“客观下线”,此时就可以做主从切换了。

选举哨兵领导者?

假设Sentinel 判断主库“主观下线”后,就会给其他 Sentinel 实例发送 is-master-down-by-addr 命令,接着,其他 Sentinel 实例会根据自己和主库的连接情况,做出赞成和反对决定。

假设我们有N个哨兵实例,如果有#{quorum}个实例赞成,此时这个 Sentinel 就会给其他 Sentinel 发送主从切换请求,其他 Sentinel 会进行投票,如果投票通过,这个 Sentinel 就可以进行主从切换了,这个投票过程被称为Leader 选举。其实整体思想和Zookeeper一样的。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

quorum一般我们都会配置成实例数量/2+1

此时会有小伙伴问,如果所有Sentinel都想成为Leader执行主从切换怎么办?

哨兵选举领导者的过程类似于Raft算法,每个哨兵都设置一个随机超时时间,超时后向其他哨兵发送申请成为领导者的请求,把超时时间都分散开来,在大多数情况下只有一个服务器节点先发起选举,而不是同时发起选举,这样就能减少因选票瓜分导致选举失败的情况

后期我会专门写一个专栏为大家介绍所有一致性算法,例如:Paxos、Raft、Gossip、ZAB等,到时候具体给大家讲解Sentinel是如何解决上述问题的。

谁来做新的Master?

选择新master过程也是有优先级的,在多个slave的场景下,优先级按照:slave-priority配置 > 数据完整性 > runid较小者进行选择。

用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。如果所有从节点的 slave-priority 值一致,那就看谁的数据更完整。

如何判断谁的数据更完整呢?

主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。此时,哪个从库的 slave_repl_offset 最接近 master_repl_offset。那么谁就可以作为新主库。

如果 slave_repl_offset都一致,那就比 runid,选择runid最小的 Slave 节点作为新主库。

选择出新主库,哨兵 Leader 会给该节点发送slaveof no one命令,让该节点成为 Master。之后,哨兵 Leader会给故障节点的所有 Slave 发送slaveof $newmaster命令,让这些 Slave 成为新 Master 的从节点,开始从新的Master 上同步数据(这里会进行全量复制)。最后哨兵 Leader 把故障节点降级为 Slave,并写入到自己的配置文件中,待这个故障节点恢复后,则自动成为新 Master 节点的 Slave。至此,整个故障切换完成。

如何通知客户端?

上面已经介绍了完整的故障切换全流程,故障切换后,主节点变化了,客户端如何感知呢?

基于Redis提供的发布(pub)/订阅(sub)机制完成的,客户端可以从哨兵订阅消息,故障转移后,客户端会收到订阅消息。

10.能说说集群原理吗?客户端如何能感知到槽位的变化?MOVED、Asking知道是干嘛的吗?

在大数据高并发场景下,Sentinel存在一些问题,写请求全部落在 Master 节点,Master 节点就一个实例,存储容量、CPU、内存、IO都存在瓶颈,如果我们扩容内存,会导致RDB文件过大,从而fork子进程时会阻塞较长时间。此时Redis 集群方案应运而生。

数据如何分片?

Redis Cluster 采用的是虚拟槽分区,一个集群共有 16384 个哈希槽,Redis Cluster会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N个。

Redis Cluster 会对 key 进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。

扩容机制

如图所示,6379、6380、6381三个主节点,6382是6379的从节点,6383是6380的从节点,6384是6381的从节点,现在因为业务发展过快,需要进行扩容,我们新增一个主节点6385和一个从节点6386。

步骤一:首先需要为新节点指定槽的迁移计划,也就是将哪些节点的哪些槽迁移到新节点中。并且迁移计划要确保每个节点负责相似数量的槽,从而保证各节点的数据均匀。槽迁移计划确定后开始逐个把槽内数据从源节点迁移到目标节点中。

如上图所示,6379准备把自己的4097-5460槽迁移给新节点6385,6380准备把自己的9558-10921槽迁移给新节点6385,6381准备把自己的15019-16383槽迁移给新节点6385。

步骤二:迁移数据数据迁移过程是逐个槽进行的,每个槽迁移的流程如下流程说明:

①对目标节点发送cluster setslot {slot} importing {sourceNodeId}命令,让目标节点准备导入槽数据。

②对源节点发送cluster setslot {slot} migrating {targetNodeId}命令,让源节点准备迁出槽数据。

③源节点循环执行cluster getkeysinslot {slot} {count}命令,获取count个数据槽{slot}的键。

④在源节点上执行migrate {targetIp} {targetPort} key 0 {timeout} 命令把指定key迁移

注意:Redis3.2.8后,使用pipeline传输

⑤重复步骤3、4直到槽下所有的键值数据迁移到目标节点。

⑥向集群内所有主节点发送cluster setslot {slot} node {targetNodeId}命令,通知槽分配给目标节点。

缩容机制

缩容的三种情况:

①下线迁移槽

②忘记节点

③关闭节点

槽迁移和扩容一样

MOVED

当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个 MOVED 指令并携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。

客户端收到 MOVED 指令后,要立即纠正本地的槽位映射表。后续所有 key 将使用新的槽位映射表。

案例:

首先计算出哈希值,然后取模16384,得到槽

计算哈希值命令:

127.0.0.1:6379>cluster keyslot "hello world"

命中

未命中

MOVED命令里包含两个信息,一个是槽的位置,一个是目标节点地址。

Asking

集群的伸缩(扩容/缩容)的时候,当我们去源节点访问时,发现key已经迁移到目标节点,会回复ask转向异常,收到异常后,先是执行asking命令,然后给目标节点再次发送命令,然后就会返回结果。

假如我们执行一个get key命令,这个key原来是在A节点,现在迁移到B节点,然后会给我们返回ASK转向异常,当我们收到ASK转向异常后,需要执行一条Asking命令给目标节点,然后在发送get命令。

ASK与MOVED共同点:两者都是重定向
ASK与MOVED不同点:

  • MOVED:槽已经确定
  • Asking:槽在迁移过程,key有可能在source节点有可能在target节点

槽位迁移感知

如果集群中某个槽位正在迁移或者已经迁移完了,客户端如何能感知到槽位的变化?

客户端保存了槽位和节点的映射关系表,当客户端收到moved指令的时候,他会去刷新槽位映射关系表,获取到最新的映射关系。当收到ask转向异常时,不会刷新槽位映射关系表,因为它是临时纠正。

容错

Redis Cluster 可以为每个主节点设置若干个从节点,单主节点故障时,集群会自动将其中某个从节点提升为主节点。如果某个主节点没有从节点,那么当它发生故障时,集群将完全处于不可用状态。不过 Redis 也提供了一个参数cluster-require-full-coverage可以允许部分节点故障,其它节点还可以继续提供对外访问。

11.缓存雪崩、击穿、穿透如何解决?

缓存雪崩

什么是缓存雪崩?

缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到数据库,导致数据库压力激增,从而崩溃。

导致缓存雪崩的两个原因:

①缓存中有大量数据采用了相同的过期时间,从而同时过期,导致大量请求无法在Redis命中

解决方案:给这些数据的过期时间增加一个较小的随机数

②Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。

方案一:开启限流

方案二:针对热点数据,可以做多级缓存,可以用Guava在本地构建一个缓存,但需要考虑内存因素

第二个问题在生产环境中很少遇到,我们线上环境采用的是Redis集群架构,并且每个主节点都配备了两个从节点,而且服务器都分散在全国各地,几乎不会出现大面积Redis故障。

缓存击穿

对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。

缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从数据库读取数据并设置到缓存,这个时候大并发的请求可能会瞬间把数据库压垮。

解决方案:采用互斥锁/分布式锁,让一个线程去查询就行,其他线程等待。

缓存穿透

什么是缓存穿透?

恶意请求缓存中不存在的数据,这导致缓存无法命中,每次请求都会查数据库(穿透到后端数据库进行查询),这个时候就出现穿透问题。

解决方案:

①如果数据库查不到,那缓存就设置null,并设置过期时间

②使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。

③假设我们用户ID是有规律的(例如长度是20),当请求过来,我们先判断这个ID是否符合我们的规律,如果不符合可以直接拦截(例如传过来的ID只有18位)

12.分布式锁如何实现?

为什么需要分布式锁?

public synchronized void test() {
    System.out.println("获取到锁");
}
public void test2() {
     synchronized (Test.class) {
          System.out.println("获取到锁");
     }
}

假设我们把上述代码部署到多台服务器上,这个互斥锁还能生效吗?答案是否定的,这时分布式锁应运而生。

Redis分布式锁?

接下来我给大家讲解完整的演变过程,让大家更深刻的理解分布式锁。

Redis setnx

线程1申请加锁,这时没有人持有锁,加锁成功:

127.0.0.1:6379> setnx lock 1
(integer) 1

线程2申请加锁,此时发现有人持有锁未释放,加锁失败:

127.0.0.1:6379> setnx lock 1
(integer) 0

线程1执行完成业务逻辑后,执行DEL命令释放锁:

127.0.0.1:6379> del lock
(integer) 1

存在问题:

①假设线程1执行到一半,系统挂了,这时锁还没释放,就会造成死锁。

②如果Redis加锁后,Master还没同步给Slave就挂了,会导致有两个客户端获取到锁

解决方案:setnx expire

Redis setnx expire

为了解决上述死锁问题,我们在setnx后,给这个key加上失效时间。

此时线程1加锁的代码改成:

127.0.0.1:6379> setnx lock 1 ## 加锁
(integer) 1
127.0.0.1:6379> expire lock 3 ## 设置 key 3秒失效
(integer) 1

存在问题:

①假设setnx lock 1执行成功了,但是expire lock 3执行失败了,还是会存在死锁问题,这两个命令需要保证原子性

②失效时间是我们写死的,不能自动续约,如果业务执行时间超过失效时间,会出现线程1还在执行,线程2就加锁成功了,并有没达到互斥效果。

③如果Redis加锁后,Master还没同步给Slave就挂了,会导致有两个客户端获取到锁

解决方案:RedissonLock

RedissonLock

上述两个问题,RedissonLock都解决了,我通过源码给大家剖析,看RedissonLock是如何解决的,基础好的小伙伴可以好好读读源码,其实RedissonLock源码也不难。

我先写结论,基础较弱的小伙伴,只要记得结论就行:

①RedisssonLock底层使用的是lua脚本执行的redis指令,lua脚本可以保证加锁和失效指令的原子性。

②RedisssonLock底层有个看门狗机制,加锁成功后,会开启一个定时调度任务,每隔10秒去检查锁是否释放,如果没有释放,把失效时间刷新成30秒。这样锁就可以一直续期,不会释放。

我看的是3.12.5版本源码,不同版本实现上可能存在一些差异。

应用程序加锁代码:

RLock lock = redissonLock.getLock("anyLock");
lock.lock();

RedissonLock加锁核心代码:

RedissonLock获取锁核心代码:

底层加锁逻辑:

KEYS[1] = anyLock,锁的名称。

ARGV[1] = 30000,失效时间,通过lockWatchdogTimeout配置。

ARGV[2] = c1b51ddb-1505-436c-a308-b3b75b4bd407:1,他是ConnectionManager的ID,我们可以简单的把它理解为一个客户端的一个线程对应的唯一标志性。

RedissonLock解锁核心代码:

存在问题:如果redis是单节点,存在单节点故障问题;如果做主从架构,Redis加锁后,Master还没同步给Slave就挂了,会导致有两个客户端获取到锁

有小伙伴问我,如果这里我用集群会存在这个问题吗?集群的本质是分片,这个key最终还是会落到某个具体的节点,这个节点要么是单独存在,要么是主从架构,所以还是会存在上述问题。

解决方案:RedLock
补充:虽然RedLock可以解决上述问题,但是在生产环境中我们很少使用,因为它部署成本很高,相比RedissonLock性能也略微有所下降​。
如果业务能接受极端情况下存在互斥失败问题,并且对性能要求比较高,我们会选择RedissonLock,并做好响应​的兜底方案。
如果业务对数据要求绝对正确,​我们会采用Zookeeper来做分布式锁。​

Redlock

我们假设有5个完全相互独立的Redis Master单机节点,所以我们需要在5台机器上面运行这些实例,如下图所示(请注意这张图中5个Master节点完全相互独立)

为了取到锁,客户端应该执行以下操作:

①获取当前Unix时间,以毫秒为单位。

②依次尝试从N个Master实例使用相同的key和随机值获取锁(假设这个key是LOCK_KEY)。当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。

③客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。

④如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。

⑤如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

缺点:像我们系统,并发量比较大,生产环境必须要做分片才能扛住并发,像上述方案,我们需要准备5个Redis集群,这种机器成本是非常高的。

Zookeeper分布式锁

如果有一把锁,被多个人给竞争,此时多个人会排队,第一个拿到锁的人会执行,然后释放锁,后面的每个人都会去监听排在自己前面的那个人创建的node上,一旦某个人释放了锁,排在自己后面的人就会被zookeeper给通知,一旦被通知了之后,就ok了,自己就获取到了锁,就可以执行代码了。

为了帮助大家理解,我暂时不用框架,通过手写代码带大家理解Zookeeper锁:

此时有小伙伴问,如果业务执行一半,系统宕机了怎么办?

zk创建的是临时节点,客户端获取到锁执行业务,执行到一半突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉,其他客户端自动获取锁,不会存在死锁问题。

一般生产环境我们都会使用Curator来完成分布式锁编码,他提供了可重入锁、非可重入锁、Semaphore、可重入读写锁、MultiLock等各种分布式锁。

13.如何保证Redis命令的原子性?

原子性:多个命令要么全部成功,要么全部失败。
通过Lua脚本实现。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐