Home Redis

0 14

今天学习的时候看到了Redis的pipeline,因为对这方面不甚了解,所以特地找了相关资料,并进行个人总结

 

什么是Pipeline?

可以将其解释为管道,流水线。如果站在软件架构的角度来说,Pipeline是一种通信架构。

 

为什么要有Pipeline?

这里以Redis的Pipeline来说明。

(一)简介
  Redis 使用的是客户端-服务器(CS)模型和请求/响应协议的 TCP 服务器。这意味着通常情况下一个请求会遵循以下步骤:

  • 客户端向服务端发送一个查询请求,并监听 Socket 返回,通常是以阻塞模式,等待服务端响应。
  • 服务端处理命令,并将结果返回给客户端。
      Redis 客户端与 Redis 服务器之间使用 TCP 协议进行连接,一个客户端可以通过一个 socket 连接发起多个请求命令。每个请求命令发出后 client 通常会阻塞并等待 redis 服务器处理,redis 处理完请求命令后会将结果通过响应报文返回给 client,因此当执行多条命令的时候都需要等待上一条命令执行完毕才能执行。比如:

这里写图片描述

其执行过程如下图所示:这里写图片描述

由于通信会有网络延迟假如 client 和 server 之间的包传输时间需要0.125秒。那么上面的三个命令6个报文至少需要0.75秒才能完成。这样即使 redis 每秒能处理100个命令,而我们的 client 也只能一秒钟发出四个命令。这显然没有充分利用 redis 的处理能力。

管道(pipeline)可以一次性发送多条命令并在执行完后一次性将结果返回,pipeline 通过减少客户端与 redis 的通信次数来实现降低往返延时时间而且 Pipeline 实现的原理是队列,而队列的原理是时先进先出,这样就保证数据的顺序性。 Pipeline 的默认的同步的个数为53个,也就是说 arges 中累加到53条数据时会把数据提交。其过程如下图所示:client 可以将三个命令放到一个 tcp 报文一起发送,server 则可以将三条命令的处理结果放到一个 tcp 报文返回。
这里写图片描述

需要注意到是用 pipeline 方式打包命令发送,redis 必须在处理完所有命令前先缓存起所有命令的处理结果。打包的命令越多,缓存消耗内存也越多。所以并不是打包的命令越多越好。具体多少合适需要根据具体情况测试。

 

(二)比较普通模式与 PipeLine 模式

测试代码可参考原文链接:

https://blog.csdn.net/u011489043/article/details/78769428

 

(三)适用场景(可靠性,实时性)

有些系统可能对可靠性要求很高每次操作都需要立马知道这次操作是否成功,是否数据已经写进 redis 了,那这种场景就不适合。

还有的系统,可能是批量的将数据写入 redis,允许一定比例的写入失败,那么这种场景就可以使用了,比如10000条一下进入 redis,可能失败了2条无所谓,后期有补偿机制就行了,比如短信群发这种场景,如果一下群发10000条,按照第一种模式去实现,那这个请求过来,要很久才能给客户端响应,这个延迟就太长了,如果客户端请求设置了超时时间5秒,那肯定就抛出异常了,而且本身群发短信要求实时性也没那么高,这时候用 pipeline 最好了。

 

(四)管道(Pipelining) VS 脚本(Scripting)

管道和事务是不同的,pipeline只是表达“交互”中操作的传递的方向性,pipeline也可以在事务中运行,也可以不在。无论如何,pipeline中发送的每个command都会被server立即执行,如果执行失败,将会在此后的相应中得到信息;也就是pipeline并不是表达“所有command都一起成功”的语义,管道中前面命令失败,后面命令不会有影响,继续执行。简单来说就是管道中的命令是没有关系的,它们只是像管道一样流水发给server,而不是串行执行,仅此而已;但是如果pipeline的操作被封装在事务中,那么将有事务来确保操作的成功与失败。

使用管道可能在效率上比使用script要好,但是有的情况下只能使用script。因为在执行后面的命令时,无法得到前面命令的结果,就像事务一样,所以如果需要在后面命令中使用前面命令的value等结果,则只能使用script或者事务+watch。

 

参考链接:

1.https://blog.csdn.net/w1lgy/article/details/84455579?spm=1001.2101.3001.6650.1&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1.no_search_link&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-1.no_search_link

2.https://blog.csdn.net/fangjian1204/article/details/50585080

3.https://blog.csdn.net/u011489043/article/details/78769428

0 16

http://yangbili.co/%e4%bb%8eredis%e9%9b%86%e7%be%a4%e7%9a%84%e6%90%ad%e5%bb%ba%e6%9d%a5%e5%89%96%e6%9e%90/ (剖析Redis集群之 主从复制)这篇博文中,我提到Redis支持三种集群方案

  • 主从复制模式
  • Sentinel(哨兵)模式
  • Cluster模式

哨兵模式应该被称作哨兵机制,它与主从复制模式并非对立的,相反,它是基于主从复制模式实现的。

我们先复习一下主从复制模式的优缺点:

优点:

  • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离
  • 为了分载Master的读操作压力,Slave服务器可以为客户端提供只读操作的服务,写服务仍然必须由Master来完成
  • Slave同样可以接受其它Slaves的连接和同步请求,这样可以有效的分载Master的同步压力。
  • Master Server是以非阻塞的方式为Slaves提供服务。所以在Master-Slave同步期间,客户端仍然可以提交查询或修改请求。
  • Slave Server同样是以非阻塞的方式完成数据同步。在同步期间,如果有客户端提交查询请求,Redis则返回同步之前的数据

缺点:

  • Redis不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。
  • 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
  • Redis较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。

 

一、为什么需要哨兵机制

注意这一条:Redis不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。

那么在这个模式下,如果从库发生故障了,客户端可以继续向主库或其他从库发送请求,进行相关的操作,但是如果主库发生故障了,那就直接会影响到从库的同步,因为从库没有相应的主库可以进行数据复制操作了。

而且,如果客户端发送的都是读操作请求,那还可以由从库继续提供服务,这在纯读的业务场景下还能被接受。但是,一旦有写操作请求了,按照主从库模式下的读写分离要求,需要由主库来完成写操作。此时,也没有实例可以来服务客户端的写操作请求了,

也就是说,主从复制模式下,手动的将从库升级为主库,是需要时间的,在这段时间内有两种风险

  1. 写服务中断
  2. 从库无法进行数据同步

从故障发生到运维或者维护人员发现的这段时间里,可能已经造成了巨大的损失,因此,我们必须考虑让Redis实现主从库自动切换,而哨兵机制就是实现的关键。

 

二、主从库自动切换涉及到的三个问题

如果主库挂了,我们就需要运行一个新主库,比如说把一个从库切换为主库,把它当成主库。这就涉及到三个问题:

  1. 主库真的挂了吗?
  2. 该选择哪个从库作为主库?
  3. 怎么把新主库的相关信息通知给从库和客户端呢?

 

三、哨兵机制的工作流程

1.哨兵三大任务

哨兵其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。

监控:(哨兵需要判断主库是否处于下线状态)

监控是指哨兵进程在运行时,周期性地给所有的主从库发送 PING 命令,检测它们是否仍然在线运行。如果从库没有在规定时间内响应哨兵的 PING 命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的 PING 命令,哨兵就会判定主库下线,然后开始自动切换主库的流程。

选主:(哨兵需要决定选择哪个从库实例作为主库)

这个流程首先是执行哨兵的第二个任务,选主。主库挂了以后,哨兵就需要从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。这一步完成后,现在的集群里就有了新主库。然后,哨兵会执行最后一个任务:通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

通知:

然后,哨兵会执行最后一个任务:通知。在执行通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。

哨兵机制的三项任务与目标

 

在这三个任务中,通知任务相对来说比较简单,哨兵只需要把新主库信息发给从库和客户端,让它们和新主库建立连接就行,并不涉及决策的逻辑。但是,在监控和选主这两个任务中,哨兵需要做出两个决策:

  • 在监控任务中,哨兵需要判断主库是否处于下线状态;
  • 在选主任务中,哨兵也要决定选择哪个从库实例作为主库。

接下来,我们就先说说如何判断主库的下线状态。你首先要知道的是,哨兵对主库的下线判断有“主观下线”和“客观下线”两种。那么,为什么会存在两种判断呢?它们的区别和联系是什么呢?

2.主观下线和客观下线

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。

如果检测的是从库,那么,哨兵简单地把它标记为“主观下线”就行了,因为从库的下线影响一般不太大,集群的对外服务不会间断。

但是,如果检测的是主库,那么,哨兵还不能简单地把它标记为“主观下线”,开启主从切换。因为很有可能存在这么一个情况:那就是哨兵误判了,其实主库并没有故障。可是,一旦启动了主从切换,后续的选主和通知操作都会带来额外的计算和通信开销。

为了避免这些不必要的开销,我们要特别注意误判的情况。

首先,我们要知道啥叫误判。很简单,就是主库实际并没有下线,但是哨兵误以为它下线了。误判一般会发生在集群网络压力较大、网络拥塞,或者是主库本身压力较大的情况下。

一旦哨兵判断主库下线了,就会开始选择新主库,并让从库和新主库进行数据同步,这个过程本身就会有开销,例如,哨兵要花时间选出新主库,从库也需要花时间和新主库同步。而在误判的情况下,主库本身根本就不需要进行切换的,所以这个过程的开销是没有价值的。正因为这样,我们需要判断是否有误判,以及减少误判。

那怎么减少误判呢?在日常生活中,当我们要对一些重要的事情做判断的时候,经常会和家人或朋友一起商量一下,然后再做决定。哨兵机制也是类似的,它通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。

这节课,你只需要先理解哨兵集群在减少误判方面的作用,就行了。至于具体的运行机制,下节课我们再重点学习。在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。

为了方便你理解,我再画一张图展示一下这里的逻辑。如下图所示,Redis 主从集群有一个主库、三个从库,还有三个哨兵实例。在图片的左边,哨兵 2 判断主库为“主观下线”,但哨兵 1 和 3 却判定主库是上线状态,此时,主库仍然被判断为处于上线状态。

客观下线”的标准就是,当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。这样一来,就可以减少误判的概率,也能避免误判带来的无谓的主从库切换。(当然,有多少个实例做出“主观下线”的判断才可以,可以由 Redis 管理员自行设定)。

好了,到这里,你可以看到,借助于多个哨兵实例的共同判断机制,我们就可以更准确地判断出主库是否处于下线状态。如果主库的确下线了,哨兵就要开始下一个决策过程了,即从许多从库中,选出一个从库来做新主库。

 

梳理思路:首先我们需要明确一点,在主从库实例运行的同时,它也在运行。因为 它身负三大任务:监控,选主和通知。

哨兵进程周期性的给所有的主从库发送PING命令,如果是从库没有在规定时间内回应PING命令,则将它标记为下线;如果主库没有在规定时间响应,有两种情况,第一种是主库真的已经挂掉了,第二种情况是主库没有下线,只是因为集群网络压力大,网络拥堵,因此没有回应;

一旦哨兵判断主库下线,就会启动主从切换,可是假如是第二种情况呢?主库没挂却启动了主从切换,那么这个过程的开销就是没有价值的,而这一切都是由于哨兵误判引起的。

因此,我们应当减小哨兵误判的概率,减少误判概率的方法就是引入多个哨兵实例一起来判断,由多个哨兵一起来决策,减小误判率,我们称这种部署方式为哨兵集群。

当大多数的哨兵实例都判断主库“主观下线”后,主库才会被标记为“客观下线”,此时才会启动主从切换。

如何选定新主库?(筛选 + 打分)

一般来说,我把哨兵选择新主库的过程称为“筛选 + 打分”。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:

在刚刚的这段话里,需要注意的是两个“一定”,现在,我们要考虑这里的“一定”具体是指什么。

首先来看筛选的条件。

一般情况下,我们肯定要先保证所选的从库仍然在线运行。不过,在选主时从库正常在线,这只能表示从库的现状良好,并不代表它就是最适合做主库的。

设想一下,如果在选主时,一个从库正常运行,我们把它选为新主库开始使用了。可是,很快它的网络出了故障,此时,我们就得重新选主了。这显然不是我们期望的结果。

所以,在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。

具体怎么判断呢?你使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。

好了,这样我们就过滤掉了不适合做主库的从库,完成了筛选工作。

接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库 ID 号。只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。

第一轮:优先级最高的从库得分高。

用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,你有两个从库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。

第二轮:和旧主库同步程度最接近的从库得分高。

这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。

如何判断从库和旧主库间的同步进度呢?上节课我向你介绍过,主从库同步时有个命令传播的过程。在这个过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 这个值记录当前的复制进度。

此时,我们想要找的从库,它的 slave_repl_offset 需要最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。

就像下图所示,旧主库的 master_repl_offset 是 1000,从库 1、2 和 3 的 slave_repl_offset 分别是 950、990 和 900,那么,从库 2 就应该被选为新主库当然,如果有两个从库的 slave_repl_offset 值大小是一样的(例如,从库 1 和从库 2 的 slave_repl_offset 值都是 990),我们就需要给它们进行第三轮打分了。

第三轮:ID 号小的从库得分高。

每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。到这里,新主库就被选出来了,“选主”这个过程就完成了。

我们再回顾下这个流程。首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID 号大小再对剩余的从库进行打分,只要有得分最高的从库出现,就把它选为新主库。

小结

这节课,我们一起学习了哨兵机制,它是实现 Redis 不间断服务的重要保证。具体来说,主从集群的数据同步,是数据可靠的基础保证;而在主库发生故障时,自动的主从切换是服务不间断的关键支撑。

Redis 的哨兵机制自动完成了以下三大功能,从而实现了主从库的自动切换,可以降低 Redis 集群的运维开销:

  1. 监控主库运行状态,并判断主库是否客观下线;
  2. 在主库客观下线后,选取新主库;
  3. 选出新主库后,通知从库和客户端。

为了降低误判率,在实际应用时,哨兵机制通常采用多实例的方式进行部署,多个哨兵实例通过“少数服从多数”的原则,来判断主库是否客观下线。一般来说,我们可以部署三个哨兵,如果有两个哨兵认定主库“主观下线”,就可以开始切换过程。当然,如果你希望进一步提升判断准确率,也可以再适当增加哨兵个数,比如说使用五个哨兵。

但是,使用多个哨兵实例来降低误判率,其实相当于组成了一个哨兵集群,我们会因此面临着一些新的挑战,例如:

  • 哨兵集群中有实例挂了,怎么办,会影响主库状态判断和选主吗?
  • 哨兵集群多数实例达成共识,判断出主库“客观下线”后,由哪个实例来执行主从切换呢?

要搞懂这些问题,就不得不提哨兵集群了,下节课,我们来具体聊聊哨兵集群的机制和问题。

每课一问

按照惯例,我给你提个小问题。这节课,我提到,通过哨兵机制,可以实现主从库的自动切换,这是实现服务不间断的关键支撑,同时,我也提到了主从库切换是需要一定时间的。所以,请你考虑下,在这个切换过程中,客户端能否正常地进行请求操作呢?如果想要应用程序不感知服务的中断,还需要哨兵或需要客户端再做些什么吗?欢迎你在留言区跟我交流讨论,也欢迎你能帮我把今天的内容分享给更多人,帮助他们一起解决问题。我们下节课见。

回答:

哨兵在操作主从切换的过程中,客户端能否正常地进行请求操作?

如果客户端使用了读写分离,那么读请求可以在从库上正常执行,不会受到影响。但是由于此时主库已经挂了,而且哨兵还没有选出新的主库,所以在这期间写请求会失败,失败持续的时间 = 哨兵切换主从的时间 + 客户端感知到新主库 的时间。

如果不想让业务感知到异常,客户端只能把写失败的请求先缓存起来或写入消息队列中间件中,等哨兵切换完主从后,再把这些写请求发给新的主库,但这种场景只适合对写入请求返回值不敏感的业务,而且还需要业务层做适配,另外主从切换时间过长,也会导致客户端或消息队列中间件缓存写请求过多,切换完成之后重放这些请求的时间变长。

哨兵检测主库多久没有响应就提升从库为新的主库,这个时间是可以配置的(down-after-milliseconds参数)。配置的时间越短,哨兵越敏感,哨兵集群认为主库在短时间内连不上就会发起主从切换,这种配置很可能因为网络拥塞但主库正常而发生不必要的切换,当然,当主库真正故障时,因为切换得及时,对业务的影响最小。如果配置的时间比较长,哨兵越保守,这种情况可以减少哨兵误判的概率,但是主库故障发生时,业务写失败的时间也会比较久,缓存写请求数据量越多。

应用程序不感知服务的中断,还需要哨兵和客户端做些什么?

当哨兵完成主从切换后,客户端需要及时感知到主库发生了变更,然后把缓存的写请求写入到新库中,保证后续写请求不会再受到影响,具体做法如下:

哨兵提升一个从库为新主库后,哨兵会把新主库的地址写入自己实例的pubsub(switch-master)中。客户端需要订阅这个pubsub,当这个pubsub有数据时,客户端就能感知到主库发生变更,同时可以拿到最新的主库地址,然后把写请求写到这个新主库即可,这种机制属于哨兵主动通知客户端。

如果客户端因为某些原因错过了哨兵的通知,或者哨兵通知后客户端处理失败了,安全起见,客户端也需要支持主动去获取最新主从的地址进行访问。

所以,客户端需要访问主从库时,不能直接写死主从库的地址了,而是需要从哨兵集群中获取最新的地址(sentinel get-master-addr-by-name命令),这样当实例异常时,哨兵切换后或者客户端断开重连,都可以从哨兵集群中拿到最新的实例地址。

一般Redis的SDK都提供了通过哨兵拿到实例地址,再访问实例的方式,我们直接使用即可,不需要自己实现这些逻辑。当然,对于只有主从实例的情况,客户端需要和哨兵配合使用,而在分片集群模式下,这些逻辑都可以做在proxy层,这样客户端也不需要关心这些逻辑了,Codis就是这么做的。

另外再简单回答下哨兵相关的问题:

1、哨兵集群中有实例挂了,怎么办,会影响主库状态判断和选主吗?(拜占庭将军问题)

这个属于分布式系统领域的问题了,指的是在分布式系统中,如果存在故障节点,整个集群是否还可以提供服务?而且提供的服务是正确的?

这是一个分布式系统容错问题,这方面最著名的就是分布式领域中的“拜占庭将军”问题了,“拜占庭将军问题”不仅解决了容错问题,还可以解决错误节点的问题,虽然比较复杂,但还是值得研究的,有兴趣的同学可以去了解下。

简单说结论:存在故障节点时,只要集群中大多数节点状态正常,集群依旧可以对外提供服务。具体推导过程细节很多,大家去查前面的资料了解就好。

2、哨兵集群多数实例达成共识,判断出主库“客观下线”后,由哪个实例来执行主从切换呢?(共识算法)

哨兵集群判断出主库“主观下线”后,会选出一个“哨兵领导者”,之后整个过程由它来完成主从切换。

但是如何选出“哨兵领导者”?这个问题也是一个分布式系统中的问题,就是我们经常听说的共识算法,指的是集群中多个节点如何就一个问题达成共识。共识算法有很多种,例如Paxos、Raft,这里哨兵集群采用的类似于Raft的共识算法。

简单来说就是每个哨兵设置一个随机超时时间,超时后每个哨兵会请求其他哨兵为自己投票,其他哨兵节点对收到的第一个请求进行投票确认,一轮投票下来后,首先达到多数选票的哨兵节点成为“哨兵领导者”,如果没有达到多数选票的哨兵节点,那么会重新选举,直到能够成功选出“哨兵领导者”。

 

以上内容整理来自极客时间 《Redis核心技术与实战》

0 20

在项目中用到了 Redis,为了水平扩展Redis 的处理能力(单机上的Redis能力受限于内存),保证Redis的高可用,所以需要搭建Redis集群

虽然之前已经系统的学习过Redis 的相关知识(极客时间的 Redis 课程),但对Redis的实际使用还有所欠缺,正好通过本次搭建集群整合Redis知识点,图中理论和图片来自极客时间。

 

 

关于Redis的介绍可以参考另一篇博文http://yangbili.co/wp-admin/post.php?post=1575&action=edit

 

使用Redis的什么情况下需要集群?

这个标题看上去有点好笑,Redis集群当然是在需要使用Redis集群的情况下使用;话虽如此说,但其实这句话包含很多东西。

第一,Redis不是只有一种模式的,也就是说Redis可以集群,也可以不集群

第二,你必须要使用Redis集群才去考虑它的集群(这句话用于劝告跟我一样的初学者,初学者有可能学习某项知识点,却不知道为什么要学习,或者说不知道学习了应该如何在实际中应用它)

一、实现Redis的水平扩展

好了,那么到底什么情况下需要使用Redis的集群呢?得从Redis的特点说起:redis是基于内存的,高性能,key-value,Nosql数据库;因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。基于内存却也有一个很大的缺点:数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。

因此,我的理解是,当单个Redis实例(或服务)无法满足系统的读写要求,需要进行水平扩展的时候,采用Redis集群的策略。更形象一点就是,我们需要使用Redis存储1000万的数据,而单个的Redis实例因为内存限制无法存入这么多,所以我们使用Redis集群模式,启用多个RedisServer实例以满足需求。

二、强化Redis的读写能力,实现高可靠,保证某一个Redis服务实例挂掉不会影响系统运行

Redis的高可靠有两层含义,一是数据尽量少丢失,二是服务尽量少中断;AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是增加副本冗余量,将一份数据同时保存在多个实例上。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。

结合实际来说,在我的项目中使用到了Redis进行数据的读写,刚开始我采用的是单个Redis服务实例,这种情况下,如果该Redis服务实例挂掉了,整个系统就会因此出现问题,也就是整个系统都不可用了。这样的情况在实际开发中是绝不允许的,在设计系统架构的时候就要充分考虑到尽力的保证系统的高可用性,也就是系统运行不中断。为了达到这个目的,我开始考虑使用Redis集群的策略。

Redis集群中有两种类型的节点:主节点(Master)、从节点(Slave)。

可以把集群理解为策略,而集群方案则是这个策略的具体实现。

 

Redis的集群方案(主从复制模式,哨兵模式,Cluster模式)

Redis支持三种集群方案

  • 主从复制模式
  • Sentinel(哨兵)模式
  • Cluster模式

一、主从复制模式

1.主从复制模式介绍

我们已知Redis通过增加副本冗余量将一份数据同时保存在多个实例上,这样即使一个实例出故障,其他实例也可以对外提供服务,不会影响业务使用。

多实例保存同一份数据,听起来很不错,但是我们必须要考虑一个问题就是:这么多副本,它们之间的数据如何保持一致呢?数据读写操作可以发给所有的实例呢?

实际上,Redis提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。

读操作:主库、从库都可以接收;

写操作:首先到主库执行,然后,主库将写操作同步给从库。

2.那么,为什么要采用读写分离的方式呢?

你可以设想一下,如果在上图中,不管是主库还是从库,都能接收客户端的写操作,那么,一个直接的问题就是:如果客户端对同一个数据(例如 k1)前后修改了三次,每一次的修改请求都发送到不同的实例上,在不同的实例上执行,那么,这个数据在这三个实例上的副本就不一致了(分别是 v1、v2 和 v3)。在读取这个数据的时候,就可能读取到旧的值。

如果我们非要保持这个数据在三个实例上一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销,当然是不太能接受的。

而主从库模式一旦采用了读写分离,所有数据的修改只会在主库上进行,不用协调三个实例。主库有了最新的数据后,会同步给从库,这样,主从库的数据就是一致的。

 

3.主丛库间如何进行同步?(详细可看 https://time.geekbang.org/column/article/272852

具体工作机制为:

  • slave启动后,向master发送SYNC命令,master接收到SYNC命令后通过bgsave保存快照(RDB持久化),并使用缓冲区记录保存快照这段时间内执行的写命令
  • master将保存的快照文件发送给slave,并继续记录执行的写命令
  • slave接收到快照文件后,加载快照文件,载入数据(此三步为全量复制)
  • master快照发送完后开始向slave发送缓冲区的写命令,slave接收命令并执行,完成复制初始化
  • 此后master每次执行一个写命令都会同步发送给slave,保持master与slave之间数据的一致(后两步为增量复制)

总结来说,从库和主库建立连接,第一次复制采用全量复制,主库生成RDB文件(BGsave),同时记录在生成RDB期间执行的写命令,然后发送给从库,从库收到RDB文件后,首先清空数据库,在本地加载快照文件完成数据加载,因为RDB文件中不存在生成RDB期间执行的写命令,所以主库在发送完RDB文件后还需要发送这些写命令,从库接收这些写命令并重新执行这些操作,如此主从库实现同步。

三个阶段:建立连接,同步RDB文件,同步写命令

部署示例:https://www.zhihu.com/search?

type=content&q=Redis%E9%9B%86%E7%BE%A4

4.主从级联模式分担全量复制时的主库压力(主-从-从)

通过分析主从库间第一次数据同步的过程,你可以看到,一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件。

如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。(fork完后的子线程不会阻塞,但是fork出子线程的这个动作会阻塞)

fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。那么,有没有好的解决方法可以分担主库压力呢?

其实是有的,这就是“主 – 从 – 从”模式

在刚才介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过“主 – 从 – 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行如下命令,让它们和刚才所选的从库,建立起主从关系。

replicaof 所选从库的IP 6379

这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

级联的“主-从-从”模式

好了,到这里,我们了解了主从库间通过全量复制实现数据同步的过程,以及通过“主 – 从 – 从”模式分担主库压力的方式。那么,一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

听上去好像很简单,但不可忽视的是,这个过程中存在着风险点,最常见的就是网络断连或阻塞。如果网络断连,主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致了,客户端就可能从从库读到旧数据。

接下来,我们就来聊聊网络断连后的解决办法。

 

5.主从库间网络断了怎么办?

 

 

6. 主从复制的优缺点

优点:

  • master能自动将数据同步到slave,可以进行读写分离,分担master的读压力
  • master、slave之间的同步是以非阻塞的方式进行的,同步期间,客户端仍然可以提交查询或更新请求

缺点:

  • 不具备自动容错与恢复功能,master或slave的宕机都可能导致客户端请求失败,需要等待机器重启或手动切换客户端IP才能恢复(哨兵可解决)
  • master宕机,如果宕机前数据没有同步完,则切换IP后会存在数据不一致的问题
  • 难以支持在线扩容,Redis的容量受限于单机配置

 

个人总结:

Redis集群是为了水平扩展Redis的服务能力,而主从复制可以说是Redis集群实现的方案,我现在更加觉得Cluster集群和哨兵以及主从库复制不是相互对立的,而是一个整体的不同部分,这些不同的部分实现了Redis的高可靠性,高可用性;当然,你可以只实现其中某一部分。

本篇着重讲解的就是其中一个部分—主从复制,后续会讲到的哨兵模式是基于主从复制模式的。

主从复制,是通过增加副本冗余量来实现Redis的服务尽量少终端,以达到高可靠性;实现了副本冗余,就需要考虑各个副本分别负责什么,为了避免客户端对同一个数据进行修改,而不同的修改请求都发送到不同的实例上,在不同的实例上执行而导致的在不同实例上的副本不一样的情况,Redis的主从复制采用读写分离的分工;

读操作,主从都可以接收

写操作,由主库执行,再将写操作同步给从库

那怎么进行同步呢?

先发送RDB,再发送记录的写操作(第一次全量之后是增量,增量复制的时候采用长连接),同时为了减轻主库生成RDB和传输RDB的压力,可以采用 主-从-从的级联模式

那么如果在发送过程中网络中断了怎么办呢?

repl_backlog_buffer环形缓冲区,主库记录写的位置,从库记录自己读到的位置,相减再同步

 

补充:

 

提问:

主从库间的数据复制同步使用的是 RDB 文件,前面我们学习过,AOF 记录的操作命令更全,相比于 RDB 丢失的数据更少。那么,为什么主从库间的复制不使用 AOF 呢?

回答:

1、RDB文件内容是经过压缩的二进制数据(不同数据类型数据做了针对性优化),文件很小。而AOF文件记录的是每一次写操作的命令,写操作越多文件会变得很大,其中还包括很多对同一个key的多次冗余操作。在主从全量数据同步时,传输RDB文件可以尽量降低对主库机器网络带宽的消耗,从库在加载RDB文件时,一是文件小,读取整个文件的速度会很快,二是因为RDB文件存储的都是二进制数据,从库直接按照RDB协议解析还原数据即可,速度会非常快,而AOF需要依次重放每个写命令,这个过程会经历冗长的处理逻辑,恢复速度相比RDB会慢得多,所以使用RDB进行主从全量同步的成本最低。

2、假设要使用AOF做全量同步,意味着必须打开AOF功能,打开AOF就要选择文件刷盘的策略,选择不当会严重影响Redis性能。而RDB只有在需要定时备份和主从全量同步数据时才会触发生成一次快照。而在很多丢失数据不敏感的业务场景,其实是不需要开启AOF的。

 

参考资料:

https://www.zhihu.com/search?type=content&q=Redis%E9%9B%86%E7%BE%A4

Redis哨兵、复制、集群的设计原理与区别 – 知乎 (zhihu.com)

https://blog.csdn.net/shenjianxz/article/details/59775212

https://time.geekbang.org/column/article/272852

基于内存保存消息

最近开发基于Bio的Socket项目的时候,想把简单的聊天室往消息队列的方向靠拢,因此在考虑怎么记录每个客户端所发送的消息(即实现聊天记录的保存功能)

客户端发送消息— 服务器端接收 转发 存储—-目标客户端

服务器接收转发我使用的是Read线程转发即可,那么存储呢?一开始的想法是,要不存储在内存中,即创建HashMap<senderName,msgList>来保存

具体如下:

<橙汁,List<“消息一”,“消息二”…>>

<小阮,List<“消息一”,”消息二”…>>

代码如下:

这样的实现好处在于:简单,明了。客户端将消息发送给服务器,服务器做转发,同时存入msgMemoryMap(全局变量);由于我的网络通信模型是使用的Bio,服务器循环监听,每来一个客户端就创建一个对应的读线程,而这些步骤也是在读线程里面完成的

也就是说 客户端A的消息存入 和客户端B的消息存入不是同一个线程实现的(当然已经使用了线程池进行优化,此处是有隐患的,如果客户端太多那么服务器就要创建很多线程),因为效率上不用太过担心

更高效的序列化

在存入msgMemoryMap之前,我将原来的Serialize序列化方式改为了Protostuff序列化,因为存入消息实在太占用内存了…

如果使用Serialize序列化,平均每条消息要占用300个字节(300B)这还得期望聊天用户发送的消息都是短消息,如果长消息那更恐怖,我计算了一下,如果同时有1000个用户在聊天,每人发送1条,也就是1*1000*300 = 300Kb!这个内存占用实在是太可怕了,因此我不得不考虑更高效的序列化方式,即Protostuff,关于Protostuff的文章会在之后写出,目前只需要知道它的序列化更高效且生成的byte数组大小更小,差不多是Serialize生成的十分之一,那么接下来直接看测试代码:

 

这样仿佛解决了写入的消息占用内存过大的问题?其实还可以进一步优化,比如再优化MessageRedis类的字段,或者先压缩再存入msgMemoryMap,等需要拿出来使用的时候再解压,也就是“时间换空间”的想法,听上去好像使用msgMemoryMap只要解决了内存占用问题就好了,其实并不然,因为内存具有掉电即失的特性。

任何实际开发的项目都不可能将数据简单的写在内存,必须要进行持久化,不然你的用户使用你的聊天室向其他人发送重要的资料和文件,等到后面他需要取查看这些消息的时候,却发现居然全丢了。持久化,欸,会做啊,我写入到数据库去不就不会丢失了嘛。

写入数据库有两种策略:

1.每来一条消息,服务器做转发后,将其写入到数据库;

2.每来一条消息,服务器将其写入到数据库后,再做转发;

我们来分析一下这两种策略,假设是 用户 橙汁 发送给 小阮的一条消息 ;

          策略1 :

           服务器先转发,那挺好,小阮可以立刻收到橙汁所发送的消息,然后橙汁的这条消息写入数据库,完美保存;橙汁第二条消息来的时候重复这个逻辑,因为橙汁发送消息中间是有间隔的,也就是不可能一直不停的发(假设消息有意义),那么这个间隔时间足够橙汁将第一条消息写入数据库了,到此,消息既转发了又保存了,服务器完成一次操作的时间为(T转发 + T存入)。

           似乎万事大吉?其实不然,现在都追求高可用,高并发,高可靠,我们的系统也不能落下。很显然目前的策略没有满足高并发和高可用,因为如果在服务器收到消息并转发后,断电了怎么办?消息并没有被写入数据库,如果小阮收到了消息,比如是 “明天一起去吃饭吧” 然后橙汁把这事忘了,第二天小阮来算账,说 “你昨天说的今天一起吃饭啊”;橙汁说:“噢 是吗? 我看看聊天记录”,结果消息记录居然真的没有!橙汁确实说了这句话,也就是说我们的系统会出现很多很多比这更复杂的问题,那么怎么解决呢?

                 策略2:

                  服务器先写入,再转发,如果写入失败则重试(或者其他策略),直到成功后再转发;这样的话,如果小阮收到了这条消息,一定和数据库中 的是一样的;同样的,如果写入数据库之后断电了,消息没被转发怎么办?只需要标记消息是否转发成功,如果没有的话就重新转发,可以从数据库去获取,不怕数据丢了,当然这里设计还可以详细展开。

分析了两种策略,如果是更追求消息一致性的话,优先选择策略2,当然策略2只是一个简版(悄咪咪的告诉你,这两个策略借鉴了Redis和Mysql的设计思路)

在上述策略的基础上继续思考,写入数据库是可以实现,但是不可忽略数据库的压力

数据库OS:对啊,你想的倒是好,每个线程都写数据库,如果采用策略2还需要从我身上拉取信息,线程压力小了,我数据库的压力大了喂,快找点人替我分担

 

是的,为了缓解数据库的压力,同时又可以完成我们的持久化功能,同时操作起来还快

缓存中间件:你他妈直接报我身份证得了

 

因此,我们可以采用Redis 来实现我们的需求—在转发消息的同时持久化保存消息

一样的:

1.每来一条消息,服务器做转发后,将其写入到Redis;

2.每来一条消息,服务器将其写入到Redis后,再做转发;

            策略1:服务器转发后,写入到Redis,Redis操作起来更快,对其上述写入数据库的策略1来说,就是T保存(保存需要的时间)更短了。如果转发完成后,断电了,没有写入到Redis,这里也会有问题,先按下不表

            策略2:先写入Redis,再转发,因为T保存的时间更短,写入Redis后转发失败的可能性就更小了,同样的,可以在接收消息的客户端设置一个接收反馈,接收到了反馈,没接收到的话这样服务器也知道,可以重发;

Redis的持久化功能保证了即使Redis所在的服务器机器掉电了,也不会丢失太多数据,丢失的数量取决了我们所采用的Redis持久化策略,比如是使用AOF还是使用RDB,其参数设置,以及是否做了Redis集群等等。

好啦,先介绍到这里啦~

作者:明月照我心
链接:https://zhuanlan.zhihu.com/p/73807097
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

为什么用分布式锁?在讨论这个问题之前,我们先来看一个业务场景。

图片来自 Pexels

为什么用分布式锁?

系统 A 是一个电商系统,目前是一台机器部署,系统中有一个用户下订单的接口,但是用户下订单之前一定要去检查一下库存,确保库存足够了才会给用户下单。

由于系统有一定的并发,所以会预先将商品的库存保存在 Redis 中,用户下单的时候会更新 Redis 的库存。

此时系统架构如下:

但是这样一来会产生一个问题:假如某个时刻,Redis 里面的某个商品库存为 1。

此时两个请求同时到来,其中一个请求执行到上图的第 3 步,更新数据库的库存为 0,但是第 4 步还没有执行。

而另外一个请求执行到了第 2 步,发现库存还是 1,就继续执行第 3 步。这样的结果,是导致卖出了 2 个商品,然而其实库存只有 1 个。

很明显不对啊!这就是典型的库存超卖问题。此时,我们很容易想到解决方案:用锁把 2、3、4 步锁住,让他们执行完之后,另一个线程才能进来执行第 2 步。

按照上面的图,在执行第 2 步时,使用 Java 提供的 Synchronized 或者 ReentrantLock 来锁住,然后在第 4 步执行完之后才释放锁。

这样一来,2、3、4 这 3 个步骤就被“锁”住了,多个线程之间只能串行化执行。

但是好景不长,整个系统的并发飙升,一台机器扛不住了。现在要增加一台机器,如下图:

增加机器之后,系统变成上图所示,我的天!假设此时两个用户的请求同时到来,但是落在了不同的机器上,那么这两个请求是可以同时执行了,还是会出现库存超卖的问题。

为什么呢?因为上图中的两个 A 系统,运行在两个不同的 JVM 里面,他们加的锁只对属于自己 JVM 里面的线程有效,对于其他 JVM 的线程是无效的。

因此,这里的问题是:Java 提供的原生锁机制在多机部署场景下失效了,这是因为两台机器加的锁不是同一个锁(两个锁在不同的 JVM 里面)。

那么,我们只要保证两台机器加的锁是同一个锁,问题不就解决了吗?此时,就该分布式锁隆重登场了。

分布式锁的思路是:在整个系统提供一个全局、唯一的获取锁的“东西”,然后每个系统在需要加锁时,都去问这个“东西”拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁。

至于这个“东西”,可以是 Redis、Zookeeper,也可以是数据库。文字描述不太直观,我们来看下图:

通过上面的分析,我们知道了库存超卖场景在分布式部署系统的情况下使用 Java 原生的锁机制无法保证线程安全,所以我们需要用到分布式锁的方案

那么,如何实现分布式锁呢?接着往下看!

基于 Redis 实现分布式锁

上面分析为啥要使用分布式锁了,这里我们来具体看看分布式锁落地的时候应该怎么样处理。

①常见的一种方案就是使用 Redis 做分布式锁

使用 Redis 做分布式锁的思路大概是这样的:在 Redis 中设置一个值表示加了锁,然后释放锁的时候就把这个 Key 删除。

具体代码是这样的:

// 获取锁 
// NX是指如果key不存在就成功,key存在返回false,PX可以指定过期时间 
SET anyLock unique_value NX PX 30000 
 
 
// 释放锁:通过执行一段lua脚本 
// 释放锁涉及到两条指令,这两条指令不是原子性的 
// 需要用到redis的lua脚本支持特性,redis执行lua脚本是原子性的 
if redis.call("get",KEYS[1]) == ARGV[1] then 
return redis.call("del",KEYS[1]) 
else 
return 0 
end 

这种方式有几大要点:

  • 一定要用 SET key value NX PX milliseconds 命令。如果不用,先设置了值,再设置过期时间,这个不是原子性操作,有可能在设置过期时间之前宕机,会造成死锁(Key 永久存在)
  • Value 要具有唯一性。这个是为了在解锁的时候,需要验证 Value 是和加锁的一致才删除 Key。

这时避免了一种情况:假设 A 获取了锁,过期时间 30s,此时 35s 之后,锁已经自动释放了,A 去释放锁,但是此时可能 B 获取了锁。A 客户端就不能删除 B 的锁了。

除了要考虑客户端要怎么实现分布式锁之外,还需要考虑 Redis 的部署问题。

Redis 有 3 种部署方式:

  • 单机模式
  • Master-Slave+Sentinel 选举模式
  • Redis Cluster 模式

使用 Redis 做分布式锁的缺点在于:如果采用单机部署模式,会存在单点问题,只要 Redis 故障了。加锁就不行了

采用 Master-Slave 模式,加锁的时候只对一个节点加锁,即便通过 Sentinel 做了高可用,但是如果 Master 节点故障了,发生主从切换,此时就会有可能出现锁丢失的问题。

基于以上的考虑,Redis 的作者也考虑到这个问题,他提出了一个 RedLock 的算法。

这个算法的意思大概是这样的:假设 Redis 的部署模式是 Redis Cluster,总共有 5 个 Master 节点。

通过以下步骤获取一把锁:

  • 获取当前时间戳,单位是毫秒。
  • 轮流尝试在每个 Master 节点上创建锁,过期时间设置较短,一般就几十毫秒。
  • 尝试在大多数节点上建立一个锁,比如 5 个节点就要求是 3 个节点(n / 2 +1)。
  • 客户端计算建立好锁的时间,如果建立锁的时间小于超时时间,就算建立成功了。
  • 要是锁建立失败了,那么就依次删除这个锁。
  • 只要别人建立了一把分布式锁,你就得不断轮询去尝试获取锁。

但是这样的这种算法还是颇具争议的,可能还会存在不少的问题,无法保证加锁的过程一定正确。

②另一种方式:Redisson

此外,实现 Redis 的分布式锁,除了自己基于 Redis Client 原生 API 来实现之外,还可以使用开源框架:Redission。

Redisson 是一个企业级的开源 Redis Client,也提供了分布式锁的支持。我也非常推荐大家使用,为什么呢?

回想一下上面说的,如果自己写代码来通过 Redis 设置一个值,是通过下面这个命令设置的:

SET anyLock unique_value NX PX 30000 

这里设置的超时时间是 30s,假如我超过 30s 都还没有完成业务逻辑的情况下,Key 会过期,其他线程有可能会获取到锁。

这样一来的话,第一个线程还没执行完业务逻辑,第二个线程进来了也会出现线程安全问题。

所以我们还需要额外的去维护这个过期时间,太麻烦了~我们来看看 Redisson 是怎么实现的?

先感受一下使用 Redission 的爽:

Config config = new Config(); 
config.useClusterServers() 
.addNodeAddress("redis://192.168.31.101:7001") 
.addNodeAddress("redis://192.168.31.101:7002") 
.addNodeAddress("redis://192.168.31.101:7003") 
.addNodeAddress("redis://192.168.31.102:7001") 
.addNodeAddress("redis://192.168.31.102:7002") 
.addNodeAddress("redis://192.168.31.102:7003"); 
 
RedissonClient redisson = Redisson.create(config); 
 
 
RLock lock = redisson.getLock("anyLock"); 
lock.lock(); 
lock.unlock(); 

就是这么简单,我们只需要通过它的 API 中的 Lock 和 Unlock 即可完成分布式锁,他帮我们考虑了很多细节:

  • Redisson 所有指令都通过 Lua 脚本执行,Redis 支持 Lua 脚本原子性执行。
  • Redisson 设置一个 Key 的默认过期时间为 30s,如果某个客户端持有一个锁超过了 30s 怎么办?
  • Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s。

这样的话,就算一直持有锁也不会出现 Key 过期了,其他线程获取到锁的问题了

  • Redisson 的“看门狗”逻辑保证了没有死锁发生。(如果机器宕机了,看门狗也就没了。此时就不会延长 Key 的过期时间,到了 30s 之后就会自动过期了,其他线程可以获取到锁)

这里稍微贴出来其实现代码:

// 加锁逻辑 
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) { 
    if (leaseTime != -1) { 
 return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG); 
    } 
    // 调用一段lua脚本,设置一些key、过期时间 
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); 
    ttlRemainingFuture.addListener(new FutureListener<Long>() { 
        @Override 
 public void operationComplete(Future<Long> future) throws Exception { 
            if (!future.isSuccess()) { 
 return; 
            } 
 
            Long ttlRemaining = future.getNow(); 
            // lock acquired 
            if (ttlRemaining == null) { 
                // 看门狗逻辑 
                scheduleExpirationRenewal(threadId); 
            } 
        } 
    }); 
 return ttlRemainingFuture; 
} 
 
 
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { 
    internalLockLeaseTime = unit.toMillis(leaseTime); 
 
 return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command, 
 "if (redis.call('exists', KEYS[1]) == 0) then " + 
 "redis.call('hset', KEYS[1], ARGV[2], 1); " + 
 "redis.call('pexpire', KEYS[1], ARGV[1]); " + 
 "return nil; " + 
 "end; " + 
 "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + 
 "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + 
 "redis.call('pexpire', KEYS[1], ARGV[1]); " + 
 "return nil; " + 
 "end; " + 
 "return redis.call('pttl', KEYS[1]);", 
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); 
} 
 
 
 
// 看门狗最终会调用了这里 
private void scheduleExpirationRenewal(final long threadId) { 
    if (expirationRenewalMap.containsKey(getEntryName())) { 
 return; 
    } 
 
    // 这个任务会延迟10s执行 
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { 
        @Override 
 public void run(Timeout timeout) throws Exception { 
 
            // 这个操作会将key的过期时间重新设置为30s 
            RFuture<Boolean> future = renewExpirationAsync(threadId); 
 
            future.addListener(new FutureListener<Boolean>() { 
                @Override 
 public void operationComplete(Future<Boolean> future) throws Exception { 
                    expirationRenewalMap.remove(getEntryName()); 
                    if (!future.isSuccess()) { 
                        log.error("Can't update lock " + getName() + " expiration", future.cause()); 
 return; 
                    } 
 
                    if (future.getNow()) { 
                        // reschedule itself 
                        // 通过递归调用本方法,无限循环延长过期时间 
                        scheduleExpirationRenewal(threadId); 
                    } 
                } 
            }); 
        } 
 
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); 
 
    if (expirationRenewalMap.putIfAbsent(getEntryName(), new ExpirationEntry(threadId, task)) != null) { 
        task.cancel(); 
    } 
} 

另外,Redisson 还提供了对 Redlock 算法的支持,它的用法也很简单:

RedissonClient redisson = Redisson.create(config); 
RLock lock1 = redisson.getFairLock("lock1"); 
RLock lock2 = redisson.getFairLock("lock2"); 
RLock lock3 = redisson.getFairLock("lock3"); 
RedissonRedLock multiLock = new RedissonRedLock(lock1, lock2, lock3); 
multiLock.lock(); 
multiLock.unlock(); 

小结:本节分析了使用 Redis 作为分布式锁的具体落地方案以及其一些局限性,然后介绍了一个 Redis 的客户端框架 Redisson,这也是我推荐大家使用的,比自己写代码实现会少 Care 很多细节。

基于 Zookeeper 实现分布式锁

常见的分布式锁实现方案里面,除了使用 Redis 来实现之外,使用 Zookeeper 也可以实现分布式锁。

在介绍 Zookeeper(下文用 ZK 代替)实现分布式锁的机制之前,先粗略介绍一下 ZK 是什么东西:ZK 是一种提供配置管理、分布式协同以及命名的中心化服务。

ZK 的模型是这样的:ZK 包含一系列的节点,叫做 Znode,就好像文件系统一样,每个 Znode 表示一个目录。

然后 Znode 有一些特性:

  • 有序节点:假如当前有一个父节点为 /lock,我们可以在这个父节点下面创建子节点,ZK 提供了一个可选的有序特性。

例如我们可以创建子节点“/lock/node-”并且指明有序,那么 ZK 在生成子节点时会根据当前的子节点数量自动添加整数序号。

也就是说,如果是第一个创建的子节点,那么生成的子节点为 /lock/node-0000000000,下一个节点则为 /lock/node-0000000001,依次类推。

  • 临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,ZK 会自动删除该节点。
  • 事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,ZK 会通知客户端。

当前 ZK 有如下四种事件:

  • 节点创建
  • 节点删除
  • 节点数据修改
  • 子节点变更

基于以上的一些 ZK 的特性,我们很容易得出使用 ZK 实现分布式锁的落地方案:

  • 使用 ZK 的临时节点和有序节点,每个线程获取锁就是在 ZK 创建一个临时有序的节点,比如在 /lock/ 目录下。
  • 创建节点成功后,获取 /lock 目录下的所有临时节点,再判断当前线程创建的节点是否是所有的节点的序号最小的节点。
  • 如果当前线程创建的节点是所有节点序号最小的节点,则认为获取锁成功。
  • 如果当前线程创建的节点不是所有节点序号最小的节点,则对节点序号的前一个节点添加一个事件监听。

比如当前线程获取到的节点序号为 /lock/003,然后所有的节点列表为[/lock/001,/lock/002,/lock/003],则对 /lock/002 这个节点添加一个事件监听器。

如果锁释放了,会唤醒下一个序号的节点,然后重新执行第 3 步,判断是否自己的节点序号是最小。

比如 /lock/001 释放了,/lock/002 监听到时间,此时节点集合为[/lock/002,/lock/003],则 /lock/002 为最小序号节点,获取到锁。

整个过程如下:

具体的实现思路就是这样,至于代码怎么写,这里比较复杂就不贴出来了。

Curator 介绍

Curator 是一个 ZK 的开源客户端,也提供了分布式锁的实现。它的使用方式也比较简单:

InterProcessMutex interProcessMutex = new InterProcessMutex(client,"/anyLock"); 
interProcessMutex.acquire(); 
interProcessMutex.release(); 

其实现分布式锁的核心源码如下:

private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception 
{ 
    boolean  haveTheLock = false; 
    boolean  doDelete = false; 
    try { 
        if ( revocable.get() != null ) { 
            client.getData().usingWatcher(revocableWatcher).forPath(ourPath); 
        } 
 
        while ( (client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock ) { 
            // 获取当前所有节点排序后的集合 
            List<String>        children = getSortedChildren(); 
            // 获取当前节点的名称 
            String              sequenceNodeName = ourPath.substring(basePath.length() + 1); // +1 to include the slash 
            // 判断当前节点是否是最小的节点 
            PredicateResults    predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases); 
            if ( predicateResults.getsTheLock() ) { 
                // 获取到锁 
                haveTheLock = true; 
            } else { 
                // 没获取到锁,对当前节点的上一个节点注册一个监听器 
                String  previousSequencePath = basePath + "/" + predicateResults.getPathToWatch(); 
                synchronized(this){ 
                    Stat stat = client.checkExists().usingWatcher(watcher).forPath(previousSequencePath); 
                    if ( stat != null ){ 
                        if ( millisToWait != null ){ 
                            millisToWait -= (System.currentTimeMillis() - startMillis); 
                            startMillis = System.currentTimeMillis(); 
                            if ( millisToWait <= 0 ){ 
                                doDelete = true;    // timed out - delete our node 
                                break; 
                            } 
                            wait(millisToWait); 
                        }else{ 
                            wait(); 
                        } 
                    } 
                } 
                // else it may have been deleted (i.e. lock released). Try to acquire again 
            } 
        } 
    } 
    catch ( Exception e ) { 
        doDelete = true; 
        throw e; 
    } finally{ 
        if ( doDelete ){ 
            deleteOurPath(ourPath); 
        } 
    } 
 return haveTheLock; 
} 

其实 Curator 实现分布式锁的底层原理和上面分析的是差不多的。这里我们用一张图详细描述其原理:

小结:本节介绍了 ZK 实现分布式锁的方案以及 ZK 的开源客户端的基本使用,简要的介绍了其实现原理。

两种方案的优缺点比较

学完了两种分布式锁的实现方案之后,本节需要讨论的是 Redis 和 ZK 的实现方案中各自的优缺点。

对于 Redis 的分布式锁而言,它有以下缺点:

  • 它获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
  • 另外来说的话,Redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。锁的模型不够健壮。
  • 即便使用 Redlock 算法来实现,在某些复杂场景下,也无法保证其实现 100% 没有问题,关于 Redlock 的讨论可以看 How to do distributed locking。
  • Redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。

但是另一方面使用 Redis 实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”。

所以使用 Redis 作为分布式锁也不失为一种好的方案,最重要的一点是 Redis 的性能很高,可以支撑高并发的获取、释放锁操作。

对于 ZK 分布式锁而言:

  • ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
  • 如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。

但是 ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大。

小结:综上所述,Redis 和 ZK 都有其优缺点。我们在做技术选型的时候可以根据这些问题作为参考因素。

一些建议

通过前面的分析,实现分布式锁的两种常见方案:Redis 和 ZK,他们各有千秋。应该如何选型呢?

就个人而言的话,我比较推崇 ZK 实现的锁:因为 Redis 是有可能存在隐患的,可能会导致数据不对的情况。但是,怎么选用要看具体在公司的场景了。

如果公司里面有 ZK 集群条件,优先选用 ZK 实现,但是如果说公司里面只有 Redis 集群,没有条件搭建 ZK 集群。

那么其实用 Redis 来实现也可以,另外还可能是系统设计者考虑到了系统已经有 Redis,但是又不希望再次引入一些外部依赖的情况下,可以选用 Redis。这个是要系统设计者基于架构来考虑了

作者:不秃顶的程序员
链接:https://zhuanlan.zhihu.com/p/111354065
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

正文-开门见山

谈起redis锁,下面三个,算是出现最多的高频词汇:

  • setnx
  • redLock
  • redisson

setnx

其实目前通常所说的setnx命令,并非单指redis的setnx key value这条命令。

一般代指redis中对set命令加上nx参数进行使用, set这个命令,目前已经支持这么多参数可选:

SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

当然了,就不在文章中默写Api了,基础参数还有不清晰的,可以蹦到官网。

上图是笔者画的setnx大致原理,主要依托了它的key不存在才能set成功的特性,进程A拿到锁,在没有删除锁的Key时,进程B自然获取锁就失败了。

那么为什么要使用PX 30000去设置一个超时时间?

是怕进程A不讲道理啊,锁没等释放呢,万一崩了,直接原地把锁带走了,导致系统中谁也拿不到锁。

就算这样,还是不能保证万无一失。

如果进程A又不讲道理,操作锁内资源超过笔者设置的超时时间,那么就会导致其他进程拿到锁,等进程A回来了,回手就是把其他进程的锁删了,如图:

还是刚才那张图,将T5时刻改成了锁超时,被redis释放。

进程BT6开开心心拿到锁不到一会,进程A操作完成,回手一个del,就把锁释放了。

进程B操作完成,去释放锁的时候(图中T8时刻):

找不到锁其实还算好的,万一T7时刻有个进程C过来加锁成功,那么进程B就把进程C的锁释放了。 以此类推,进程C可能释放进程D的锁,进程D….(禁止套娃),具体什么后果就不得而知了。

所以在用setnx的时候,key虽然是主要作用,但是value也不能闲着,可以设置一个唯一的客户端ID,或者用UUID这种随机数。

当解锁的时候,先获取value判断是否是当前进程加的锁,再去删除。伪代码

String uuid = xxxx;
// 伪代码,具体实现看项目中用的连接工具
// 有的提供的方法名为set 有的叫setIfAbsent
set Test uuid NX PX 3000
try{
// biz handle....
} finally {
    // unlock
    if(uuid.equals(redisTool.get('Test')){
        redisTool.del('Test');
    }
}

这回看起来是不是稳了。

相反,这回的问题更明显了,在finally代码块中,get和del并非原子操作,还是有进程安全问题。

为什么有问题还说这么多呢?

第一,搞清劣势所在,才能更好的完善。

第二点,其实上文中最后这段代码,还是有很多公司在用的。

大小项目悖论:大公司实现规范,但是小司小项目虽然存在不严谨,可并发倒也不高,出问题的概率和大公司一样低。 — 鲁迅

那么删除锁的正确姿势之一,就是可以使用lua脚本,通过redis的eval/evalsha命令来运行:

-- lua删除锁:
-- KEYS和ARGV分别是以集合方式传入的参数,对应上文的Test和uuid。
-- 如果对应的value等于传入的uuid。
if redis.call('get', KEYS[1]) == ARGV[1] 
    then 
	-- 执行删除操作
        return redis.call('del', KEYS[1]) 
    else 
	-- 不成功,返回0
        return 0 
end

通过lua脚本能保证原子性的原因说的通俗一点:

就算你在lua里写出花,执行也是一个命令(eval/evalsha)去执行的,一条命令没执行完,其他客户端是看不到的。

那么既然这么麻烦,有没有比较好的工具呢? 就要说到redisson了。

介绍redisson之前,笔者简单解释一下为什么现在的setnx默认是指set命令带上nx参数,而不是直接说是setnx这个命令。

因为redis版本在2.6.12之前,set是不支持nx参数的,如果想要完成一个锁,那么需要两条命令:

1. setnx Test uuid
2. expire Test 30

即放入Key和设置有效期,是分开的两步,理论上会出现1刚执行完,程序挂掉,无法保证原子性。

但是早在2013年,也就是7年前,Redis就发布了2.6.12版本,并且官网(set命令页),也早早就说明了“SETNX, SETEX, PSETEX可能在未来的版本中,会弃用并永久删除”。

笔者曾阅读过一位大佬的文章,其中就有一句指导入门者的面试小套路,具体文字忘记了,大概意思如下:

说到redis锁的时候,可以先从setnx讲起,最后慢慢引出set命令的可以加参数,可以体现出自己的知识面。

如果有缘你也阅读过这篇文章,并且学到了这个套路,作为本文的笔者我要加一句提醒:

请注意你的工作年限!首先回答官网表明即将废弃的命令,再引出set命令七年前的“新特性”,如果是刚毕业不久的人这么说,面试官会以为自己穿越了。

你套路面试官,面试官也会套路你。 — vt・沃兹基硕德

redisson

Redisson是java的redis客户端之一,提供了一些api方便操作redis。

但是redisson这个客户端可有点厉害,笔者在官网截了仅仅是一部分的图:

这个特性列表可以说是太多了,是不是还看到了一些JUC包下面的类名,redisson帮我们搞了分布式的版本,比如AtomicLong,直接用RedissonAtomicLong就行了,连类名都不用去新记,很人性化了。

锁只是它的冰山一角,并且从它的wiki页面看到,对主从,哨兵,集群等模式都支持,当然了,单节点模式肯定是支持的。

本文还是以锁为主,其他的不过多介绍。

Redisson普通的锁实现源码主要是RedissonLock这个类,还没有看过它源码的盆友,不妨去瞧一瞧。

源码中加锁/释放锁操作都是用lua脚本完成的,封装的非常完善,开箱即用。

这里有个小细节,加锁使用setnx就能实现,也采用lua脚本是不是多此一举? 笔者也非常严谨的思考了一下:这么厉害的东西哪能写废代码?

其实笔者仔细看了一下,加锁解锁的lua脚本考虑的非常全面,其中就包括锁的重入性,这点可以说是考虑非常周全,我也随手写了代码测试一下:

的确用起来像jdk的ReentrantLock一样丝滑,那么redisson实现的已经这么完善,redLock又是什么?

RedLock

redLock的中文是直译过来的,就叫红锁

红锁并非是一个工具,而是redis官方提出的一种分布式锁的算法

就在刚刚介绍完的redisson中,就实现了redLock版本的锁。也就是说除了getLock方法,还有getRedLock方法。

笔者大概画了一下对红锁的理解:

如果你不熟悉redis高可用部署,那么没关系。redLock算法虽然是需要多个实例,但是这些实例都是独自部署的,没有主从关系。

RedLock作者指出,之所以要用独立的,是避免了redis异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,就挂了。

有些人是不是觉得大佬们都是杠精啊,天天就想着极端情况。 其实高可用嘛,拼的就是99.999…% 中小数点后面的位数。

回到上面那张简陋的图片,红锁算法认为,只要2N + 1个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁。 流程为:

  1. 顺序向五个节点请求加锁
  2. 根据一定的超时时间来推断是不是跳过该节点
  3. 三个节点加锁成功并且花费时间小于锁的有效期
  4. 认定加锁成功

也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。

这只是举个例子,实际上并不应该等每个节点那么长时间,就像官网所说的那样,假设有效期是10,那么单个redis实例操作超时时间,应该在5到50毫秒(注意时间单位)

还是假设我们设置有效期是30秒,图中超时了两个redis节点。 那么加锁成功的节点总共花费了3秒,所以锁的实际有效期是小于27秒的。

即扣除加锁成功三个实例的3秒,还要扣除等待超时redis实例的总共时间。

看到这,你有可能对这个算法有一些疑问,那么你不是一个人。

回头看看Redis官网关于红锁的描述

就在这篇描述页面的最下面,你能看到著名的关于红锁的神仙打架事件

即Martin Kleppmann和antirez的redLock辩论. 一个是很有资历的分布式架构师,一个是redis之父。

官方挂人,最为致命。

开个玩笑,要是质疑能被官方挂到官网,说明肯定是有价值的。

所以说如果项目里要使用红锁,除了红锁的介绍,不妨要多看两篇文章,即:

  1. Martin Kleppmann的质疑贴
  2. antirez的反击贴

总结

看了这么多,是不是发现如何实现,都不能保证100%的稳定。

程序就是这样,没有绝对的稳定,所以做好人工补偿环节也是重要的一环,毕竟:

技术不够,人工来凑~

作者:节操泛滥的程序员
链接:https://zhuanlan.zhihu.com/p/59256821
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

普通实现

说道Redis分布式锁大部分人都会想到:setnx+lua,或者知道set key value px milliseconds nx。后一种方式的核心实现命令如下:

- 获取锁(unique_value可以是UUID等)
SET resource_name unique_value NX PX 30000

- 释放锁(lua脚本中,一定要比较value,防止误解锁)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这种实现方式有3大要点(也是面试概率非常高的地方):

  1. set命令要用set key value px milliseconds nx
  2. value要具有唯一性;
  3. 释放锁时要验证value值,不能误解锁;

事实上这类琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:

  1. 在Redis的master节点上拿到了锁;
  2. 但是这个加锁的key还没有同步到slave节点;
  3. master故障,发生故障转移,slave节点升级为master节点;
  4. 导致锁丢失。

正因为如此,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock。笔者认为,Redlock也是Redis所有分布式锁实现方式中唯一能让面试官高潮的方式。

Redlock实现

antirez提出的redlock算法大概是这样的:

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

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

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

Redlock源码

redisson已经有对redlock算法封装,接下来对其用法进行简单介绍,并对核心源码进行分析(假设5个redis实例)。

  • POM依赖
<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.3.2</version>
</dependency>

用法

首先,我们来看一下redission封装的redlock算法实现的分布式锁用法,非常简单,跟重入锁(ReentrantLock)有点类似:

Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
        .setMasterName("masterName")
        .setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
// 还可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
    isLock = redLock.tryLock();
    // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
    isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    if (isLock) {
        //TODO if get lock success, do something;
    }
} catch (Exception e) {
} finally {
    // 无论如何, 最后都要解锁
    redLock.unlock();
}

唯一ID

实现分布式锁的一个非常重要的点就是set的value要具有唯一性,redisson的value是怎样保证value的唯一性呢?答案是UUID+threadId。入口在redissonClient.getLock(“REDLOCK_KEY”),源码在Redisson.java和RedissonLock.java中:

protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
    return id + ":" + threadId;
}

获取锁

获取锁的代码为redLock.tryLock()或者redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS),两者的最终核心源码都是下面这段代码,只不过前者获取锁的默认租约时间(leaseTime)是LOCK_EXPIRATION_INTERVAL_SECONDS,即30s:

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    // 获取锁时向5个redis实例发送的命令
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
              // 首先分布式锁的KEY不能存在,如果确实不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)
              "if (redis.call('exists', KEYS[1]) == 0) then " +
                  "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间
              "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                  "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                  "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                  "return nil; " +
              "end; " +
              // 获取分布式锁的KEY的失效时间毫秒数
              "return redis.call('pttl', KEYS[1]);",
              // 这三个参数分别对应KEYS[1],ARGV[1]和ARGV[2]
                Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

获取锁的命令中,

  • KEYS[1]就是Collections.singletonList(getName()),表示分布式锁的key,即REDLOCK_KEY;
  • ARGV[1]就是internalLockLeaseTime,即锁的租约时间,默认30s;
  • ARGV[2]就是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId:

释放锁

释放锁的代码为redLock.unlock(),核心源码如下:

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    // 向5个redis实例都执行如下命令
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 如果分布式锁KEY不存在,那么向channel发布一条消息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            // 如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            // 如果就是当前线程占有分布式锁,那么将重入次数减1
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            // 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            "else " +
                // 重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
            // 这5个参数分别对应KEYS[1],KEYS[2],ARGV[1],ARGV[2]和ARGV[3]
            Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));

}

0 34

一、悲观锁与乐观锁

锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁(Java中没有哪个Lock实现类就叫PessimisticLock或OptimisticLock),而是在并发情况下的两种不同策略。

悲观锁(Pessimistic Lock), 就是很悲观,每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿数据就被挡住,直到悲观锁被释放。

乐观锁(Optimistic Lock), 就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作)。

悲观锁阻塞事务,乐观锁回滚重试,它们各有优缺点,不要认为一种一定好于另一种。像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

二、乐观锁的基础——CAS

说到乐观锁,就必须提到一个概念:CAS

什么是CAS呢?Compare-and-Swap,即比较并替换,也有叫做Compare-and-Set的,比较并设置

1、比较:读取到了一个值A,在将其更新为B之前,检查原值是否仍为A(未被其他线程改动)。

2、设置:如果是,将A更新为B,结束。[1]如果不是,则什么都不做。

上面的两步操作是原子性的,可以简单地理解为瞬间完成,在CPU看来就是一步操作。

有了CAS,就可以实现一个乐观锁

data = 123; // 共享数据

/* 更新数据的线程会进行如下操作 */
flag = true;
while (flag) {
    oldValue = data; // 保存原始数据
    newValue = doSomething(oldValue); 

    // 下面的部分为CAS操作,尝试更新data的值
    if (data == oldValue) { // 比较
        data = newValue; // 设置
        flag = false; // 结束
    } else {
	// 啥也不干,循环重试
    }
}
/* 
   很明显,这样的代码根本不是原子性的,
   因为真正的CAS利用了CPU指令,
   这里只是为了展示执行流程,本意是一样的。
*/

这是一个简单直观的乐观锁实现,它允许多个线程同时读取(因为根本没有加锁操作),但是只有一个线程可以成功更新数据,并导致其他要更新数据的线程回滚重试。 CAS利用CPU指令,从硬件层面保证了操作的原子性,以达到类似于锁的效果。

Java中真正的CAS操作调用的native方法

因为整个过程中并没有“加锁”和“解锁”操作,因此乐观锁策略也被称为无锁编程。换句话说,乐观锁其实不是“锁”,它仅仅是一个循环重试CAS的算法而已!

案例:

无锁状态下多线程修改数据所带来的错误:

这个例子很简单:我们定义了一个变量datanum,初始值是0,然后使用2个线程去增加,每个线程增加20,按道理来说2个线程一共增加了40,但是运行一下就知道答案不到40,原因就在于里面那个加一操作:datanum++;

对于datanum++的操作,其实可以分解为3个步骤。

(1)从主存中读取datanum的值

(2)对datanum进行加1操作

(3)把datanum重新刷新到主存

比如说有的线程已经把datanum进行了加1操作,但是还没来得及重刷入到主存,其他的线程就重新读取了旧值。这才造成了错误

 

比如说有的线程已经把datanum进行了加1操作,但是还没来得及重刷入到主存,其他的线程就重新读取了旧值。这才造成了错误。解决办法就可以使用AtomicInteger:

现在我们使用AtomicInteger定义datanum,然后使用incrementAndGet进行自增操作,最后的结果就总是40了。

源码分析:incrementAndGet方法,这里我们可以看到自增操作主要是使用了unsafe的getAndAddInt方法

unsafe的getAndAddInt方法调用了CAS方法

CAS方法是Native方法,保证了操作的原子性

所以 我们使用AtomicInteger定义datanum,然后使用incrementAndGet进行自增操作,最后的结果就总是40了。

补充:

(1)Unsafe:Unsafe是位于sun.misc包下的一个类,Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力。也就是说我们直接操作了内存空间进行了加1操作。

(2) unsafe.getAndAddInt:其内部又调用了Unsafe.compareAndSwapInt方法。这个机制叫做CAS机制,

CAS 即比较并替换,实现并发算法时常用到的一种技术。CAS操作包含三个操作数——内存位置、预期原值及新值。执行CAS操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。

CAS会带来ABA,详解可搜索我的博客ABA问题

 

乐观锁的实现:

乐观锁可以由CAS机制+版本机制来实现。

实现测试: