本文系记录对Java中分布式的学习资料,如有异议,欢迎联系我讨论修改。PS:图侵删!如果看不清图请访问:https://github.com/zx950519/zx950519.github.io/blob/master/_posts/2019-10-06-Java%E5%AD%A6%E4%B9%A0%E7%B3%BB%E5%88%97%E4%B9%8B%E5%88%86%E5%B8%83%E5%BC%8F.md
综述
分布式这块方向很多,例如分布式计算、分布式存储、分布式数据库(事务)、分布式锁等方向
分布式基本原则——CAP
CAP分别表示一致性,可用性与分区容忍性。CAP是指在分布式系统中,上述三种特性不能完全具备,只能同时满足两种。一致性表示,分布式系统中所有的备份在同一时刻是否相同;可用性表示,当分布式系统中的部分节点宕机后,是否还能继续对外提供服务;分区容忍性表示,当节点间无法通信时,便可能产生分区,此时必须在一致性和可用性之间做出选择。
CAP原则中一致性的级别
- 强一致性:指系统中某个数据更新后,后续任何对该数据的操作都将得到更新后的值
- 弱一致性:不保证每次都能获得最新的值
- 最终一致性:是弱一致性的特殊模式,在经过一段时间窗口后,最终所有访问都能得到最新的值
为什么CAP不可三者兼得?
当分布式节点间的网络断开时,对节点A进行数据更新,无法在节点B上进行同步。此时,B不能提供最新的数据,即满足了分区容忍性。此时,面临两个选择:为了拿到最新的数据,进入阻塞状态,等待节点间网络恢复并同步最新数据,但是这时的可用性就被牺牲了;为了保证服务高可用,以及用户低时延反馈,将旧数据返回给用户。
CAP如何取舍?
- CA:如果不要求满足分区容忍性,则可以保证强一致性和可用性。但是放弃保证分区,则系统放弃使用分布式系统扩展性能,有违初衷。常见的CA系统有关系数据库MySQL
- CP:如果不要求满足可用性,则每个请求都要在分布式系统的各节点间保持强一致性,这可能导致同步时间的无限延长,牺牲用户体验。常见的CP系统有分布式数据库Redis和HBASE,这些数据库对数据的一致性要求高
- AP:如果不要求强一致性,则可以提高用户体验,但是会造成一定程度上的数据不一致性
Base原则
Base分别表示基本可用,软状态,最终一致性。是CAP在一致性和可用性上权衡的结果,也是CAP原则逐步演化的结果。其核心理念是,放弃强一致性,保障最终一致性。基本可用指的是分布式系统在遭遇故障时,允许丧失部分可用性,但是不等价于不可用。例如,原来请求的反馈时间为0.1s,现在允许为0.5s。软转态是指,允许系统中的数据存在中间状态,并认为中间状态不影响系统的整体可用性。最终一致性,强调系统中所有的数据副本在经过一段时间的同步后,最终能够达到一个一致的状态。
分布式Session
分布式Session的实现一般有如下几种解决方案:
- Session全量复制,即每个节点复制所有节点的Session,实现完全冗余,但是这种情况对带宽等资源消耗过大,不是最优解
- 黏性Session,利用Nginx的路由策略实现每次访问都会转发到同一个节点,实现Session复用
- Session集群,利用Redis集群实现,虽然是一个很好的解决方案,但是可能会对系统复杂性与可用性带来挑战
分布式事务
分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。分布式事务产生的原因如下:
- 数据库分库分表:如果一个操作要分别访问A库与B库,那么为了保障数据一致性就要用到分布式事务
- 应用SOA(面向服务的架构)化:对整个网站进行拆解,分离出了订单中心、用户中心、库存中心。如果要同时对订单和库存进行操作,那么就会涉及到订单数据库和库存数据库,为了保证数据一致性,就需要用到分布式事务
柔性事务
柔性事务(遵循BASE理论)是指相对于ACID刚性事务而言的。柔性事务分为:两阶段型、补偿型、异步确保型、最大努力通知型:
- 两阶段:参考2PC
- 补偿事务TCC:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作
- 异步确保型:通过将一系列同步的事务操作变为基于消息执行的异步操作,避免了分布式事务中的同步阻塞操作的影响
- 最大努力通知型:在消息由 MQ Server 投递到消费者之后,允许在达到最大重试次数之后正常结束事务
2PC & 3PC
二阶段提交(Two-phaseCommit)是指,在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法(Algorithm)。通常,二阶段提交也被称为是一种协议(Protocol))。在分布式系统中,每个节点虽然可以知晓自己的操作时成或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
2PC存在的问题:
- 阻塞问题:二阶段提交的准备阶段中,协调者需要等待参与者的响应,如果没有接收到任意参与者的响应,这时候进入等待状态,而其他正常发送响应的参与者,将进入阻塞状态,将无法进行其他任何操作,只有等待超时中断事务,极大的限制了系统的性能。
- 单点问题:协调者处于一个中心的位置,一旦出现问题,那么整个二阶段提交将无法运转,更为严重的是,如果协调者在阶段二中出现问题的话,那么其他参与者将会一直处于锁定事务资源的状态中,将无法继续完成操作
- 在阶段二中,当协调者向参与者发送commit请求后,发生了局部网络异常或在发送commit请求过程中协调者出现了故障,导致只有一部分参与者接到了commit请求。于是整个分布式系统遍出现了数据不一致的现象,即脑裂
3PC是2PC的改进版本,将2PC的提交阶段一分为二,由CanCommit、PreCommit和doCommit三个阶段组成的事务处理协议:
- CanCommit:协调者向参与者发送commit请求,如果参与者可以提交就返回yes响应,否则返回no响应
- PreCommit:协调者根据参与者的反应情况来决定是否可以继续进行,有以下两种可能。假如协调者从所有的参与者获得的反馈都是yes响应,那么就会执行事务的预执行;假如有任意一个参与者向协调者发送no响应,或者等待超时后,协调者都没有接到参与者的响应,那么就执行事务的中断。
- DoCommit:该阶段进行真正的事务提交,主要包括:1.协调者发送提交请求;2.参与者提交事务;3.参与者响应反馈(事务提交完之后,向协调者发送ACK响应);4.协调者确定完成事务
补偿型事务TCC
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
- Try阶段主要是对业务系统做检测及资源预留
- Confirm阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行Confirm阶段时,默认Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功
- Cancel阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放
例如服务器A发起事务,服务器 B 参与事务,服务器 A 的事务如果执行顺利,那么事务 A 就先行提交,如果事务B也执行 顺利,则事务 B 也提交,整个事务就算完成。但是如果事务 B 执行失败,事务 B 本身回滚,这时 事务 A 已经被提交,所以需要执行一个补偿操作,将已经提交的事务 A 执行的操作作反操作,恢 复到未执行前事务 A 的状态。这样的 SAGA 事务模型,是牺牲了一定的隔离性和一致性的,但是 提高了 long-running 事务的可用性。
分布式一致性算法Paxos
Paxos 算法解决的问题是一个分布式系统如何就某个值(决议)达成一致。一个典型的场景是,在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点执行相同的操作序列,那么他们最后能得到一个一致的状态。为保证每个节点执行相同的命令序列,需要在每一条指令上执行一个“一致性算法”以保证每个节点看到的指令一致。
Paxos中有三种角色:
- Proposer:只要Proposer发的提案被半数以上Acceptor接受,Proposer就认为该提案里的value被选定
- Acceptor:只要Accepter接受了某个提案,Accepter就认为该提案里的value被选定
- Learner:只要Accepter告诉Learner哪个value被选定,Learner就认为哪个value被选定
Paxos算法分为两个阶段,具体如下:
阶段一,准leader确认:
- Proposer选择一个提案编号N,然后向半数以上的Acceptor发送编号为N的Prepare请求
- 如果一个Acceptor收到一个编号为N的Prepare请求,且N大于该Acceptor已经响应过的所有Prepare请求的编号,那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给Proposer,同时该Acceptor承诺不再接受任何编号小于 N 的提案
阶段二,leader确认:
- 如果Proposer收到半数以上Acceptor对其发出的编号为N的Prepare请求的响应,那么它就会发送一个针对[N,V]提案的 Accept请求给半数以上的Acceptor。注意:V就是收到的响应中编号最大的提案的value,如果响应中不包含任何提案,那么V就由Proposer自己决定
- 如果Acceptor收到一个针对编号为N的提案的Accept请求,只要该Acceptor没有对编号大于N的Prepare请求做出过响应,它就接受该提案
分布式锁
在传统单体应用单机部署的情况下,为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题。分布式锁大致分为以下几类:
- 基于数据库实现分布式锁
- 基于缓存,例如Redis实现分布式锁
- 基于ZooKeeper实现分布式锁
分布式锁应具有的特性
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行
- 高可用的获取锁与释放锁
- 高性能的获取锁与释放锁
- 具备可重入特性
- 具备锁失效机制,防止死锁
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败
基于数据库实现的分布式锁
基于表主键唯一做分布式锁。利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。但是这种方法存在以下问题:
- 锁强依赖数据库的可用性,单点数据库容易挂掉
- 锁没有失效时间,其他线程将无法获得锁
- 锁只能是非阻塞
- 锁不可重入
- 锁只能是非公平锁
- 在MySQL数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象
基于数据库排他锁做分布式锁。在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意: InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。这种方法存在如下问题:
- MySQL可能不使用索引进行查找,当使用全表扫描时,就相当于使用全表锁
- 使用排他锁来进行分布式锁的lock时,如果一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆
基于数据库实现分布式锁超时问题的解决方案:启动一个定时任务,通过计算一般我们处理任务的一般的时间,比如是5ms,那么我们可以稍微扩大一点,当这个锁超过20ms没有被释放我们就可以认定是节点挂了然后将其直接释放
优点:
- 借助数据库,容易理解
- 避免引入第三方应用,例如Redis或ZooKeeper 缺点:
- 需要自己解决一些问题,例如超时处理、加事务
- 性能比缓存Redis差一些,高并发场景表现不佳
基于Redis缓存的分布式锁
在Redis中,可以利用一些API方法实现分布式锁:
- 利用setnx()和expire()实现。调用setnx(key, value)方法时,如果key不存在,设置当前key成功,返回1;否则表明key存在设置失败。调用expire(time)用于设置超时时间。最后执行完业务代码后,通过delete命令删除key。这两个函数组合调用,可以解决分布式加锁的需求,但是一旦setncx()调用后宕机,将发生死锁
- 利用getset(key, newValue)。该方法是原子的,对key设置newValue这个值,并且返回key原来的旧值。过程如下:
1.setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。 2.get(lockkey) 获取值 oldExpireTime,并将这个value值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。 3.计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。 4.判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。 5.在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。
优点:
- 缓存服务都是集群部署的,可以避免单点问题
- 性能好,实现起来较为方便 缺点:
- 需要维护Redis集群,如果要实现RedLock那么需要维护更多的集群
- 失效时间的设置机制不灵活
ZooKeeper实现的分布式锁
ZooKeeper机制规定同一个目录下只能有一个唯一的文件名,zookeeper上的一个znode看作是一把锁,通过createznode的方式来实现。所有客户端都去创建/lock/${lock_name}_lock节点,最终成功创建的那个客户端也即拥有了这把锁,创建失败的可以选择监听继续等待,还是放弃抛出异常实现独占锁。ZooKeeper可以创建4种类型的节点,分别是:
- 持久性节点
- 持久性顺序节点
- 临时性节点
- 临时性顺序节点
持久性与临时性表示ZooKeeper客户端断开连接后是否保留节点;顺序性节点表示创建节点时ZooKeeper会自动给节点进行编号。ZooKeeper具有监听机制,客户端注册监听它关心的目录节点,让目录节点发生变化时,ZooKeeper会通知客户端。由于ZooKeeper实现分布式锁时使用的是临时节点,所以不同担心锁不释放与超时问题。此外,ZooKeeper还能有效地防止单点问题、不可重入问题,非阻塞问题,使用较为简单。
ZooKeeper加锁解锁的具体过程参考:https://blog.csdn.net/wuzhiwei549/article/details/80692278
优点:
- 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题
- 较为简单的实现 缺点:
- 性能上可能并没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能
- ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上