分布式相关面试题
1. 分布式有哪些理论
CAP 、BASE,分布式 CAP 理论,任何一个分布式系统都无法同时满足 Consistency(一致性)、Availability(可用性)、Partition tolerance(分区容错性) 这三个基本需求。最多只能满足其中两项,而 Partition tolerance(分区容错性) 是必须的,因此一般是 CP ,或者 AP。
1.1 说下你所理解的CAP
现在网络中有两个节点服务A和服务B,他们之间网络可以连通,服务A中有一个应用程序A,和一个数据库V,服务B也有一个应用程序B和一个数据库V,现在,A和B是分布式系统的两个部分,V是分布式系统的两个子数据库。 突然有两个用户张三和李四分别同时访问了服务A和服务B,我们理想中的操作是下面这样的
- 张三访问服务A节点,李四访问服务B节点,并且是同时访问的。
- 张三把服务A节点的数据V0变成了V1。
- 服务A节点一看自己的数据有变化,立马执行M操作,告诉了服务B节点。
- 李四读取到的就是最新的数据,也是正确的数据。
1.1.1 一致性
一致性指的是所有节点在同一时间的数据完全一致,就好比刚刚举得例子中,张三李四读取的都是正确的数据,对他们用户来说,就好像是操作了同一个数据库的同一个数据一样。
因此对于一致性,也可以分为从客户端和服务端两个不同的视角来理解。
客户端 从客户端来看,一致性主要指的是高并发访问时更新过的数据如何获取的问题,也就是张三和李四同时访问,如何获取更新的最新的数据。 服务端 从服务端来看,则是更新如何分布到整个系统,以保证数据最终一致,也就是服务器A节点和服务器B节点如何通信保持数据的一致。
对于一致性,一致的程度不同大体可以分为强、弱、最终一致性三类。
** 强一致性** 对于关系型数据库,要求更新过的数据能被后续的访问都能看到,这是强一致性。比如张三更新V0到V1,那么李四读取的时候也应该是V1 弱一致性 如果能容忍后续的部分或者全部访问不到,则是弱一致性,比如张三更新V0到V1,可以容忍那么李四读取的时候是V0。 最终一致性 如果经过一段时间后要求能访问到更新后的数据,则是最终一致性,比如张三更新V0到V1,可以使得李四在一段时间之后读取的时候是V0。
1.1.2 可用性
可用性指服务一直可用,而且是正常响应时间。就好比刚刚的服务器A和服务器B节点,不管什么时候访问,都可以正常的获取数据值,而不会出现问题。 好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。
1.1.3 分区容错性
分区容错性指在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务,就好比是服务器A节点和服务器B节点出现故障,但是依然可以很好地对外提供服务,而不满足分布容错性的服务就是一个服务集群,一台服务器出现问题,整个集群就无法提供服务了。
1.1.4 可能出现的问题
在理想情况下,没有出现任何错误的时候,这三条应该都是满足的。但是天有不测风云。系统总是会出现各种各样的问题。下面来分析一下为什么说CAP理论只能满足两条。 服务器A节点更新了V0到V1,想在也想把这个消息通过M操作告诉服务器B节点,却发生了网络故障。这时候张三和李四都要同时访问这个数据,怎么办呢?现在我们依然想要我们的系统具有CAP三个特性,我们分析一下会发生什么。
- 系统网络发生了故障,但是系统依然可以访问,因此具有容错性。
- 张三在访问节点服务器的时候更改了V0到V1,想要李四访问节点服务器B的V数据库的时候是V1,因此需要等网络故障恢复,将服务器B节点的数据库进行更新才可以。
- 在网络故障恢复的这段时间内,想要系统满足可用性,是不可能的,因为可用性要求随时随地访问系统都是正确有效的,这就出现了矛盾。
1.1.5 如何取舍
正是这个矛盾所以CAP三个特性肯定不能同时满足。既然不能满足,那我们就进行取舍。
牺牲分区容错性 容错性一般是硬件层面的,当我们网络出现问题了,我们的整个服务器集群将不可用,不能为我们提供一致性和可用性的服务,这个是必须要满足的
牺牲数据一致性 牺牲数据一致性,也就是张三看到的衣服数量是100,买了一件应该是99了,但是李四看到的依然是100
牺牲可用性 也就是张三看到的衣服数量是100,买了一件应该是99了,但是李四想要获取的最新的数据的话,那就一直等待阻塞,一直到网络故障恢复。
1.1.6 CAP特性的取舍
我们分析一下既然可以满足两个,那么舍弃哪一个比较好呢?
- 满足CA舍弃P,也就是满足一致性和可用性,舍弃容错性*。但是这也就意味着你的系统不是分布式**的了,因为涉及分布式的想法就是把功能分开,部署到不同的机器上。
- 满足CP舍弃A,也就是满足一致性和容错性,舍弃可用性。如果你的系统允许有段时间的访问失效等问题,这个是可以满足的。就好比多个人并发买票,后台网络出现故障,你买的时候系统就崩溃了。
- 满足AP舍弃C,也就是满足可用性和容错性,舍弃一致性。这也就是意味着你的系统在并发访问的时候可能会出现数据不一致的情况。
1.2 说一下BASE理论
BASE全称:Basically Available(基本可用),Soft state(软状态),和 Eventually consistent(最终一致性) CAP 不可能同时满足,而分区容错是对于分布式系统而言又是必须的。 Base 理论是对 CAP 中一致性和可用性权衡的结果,是基于 CAP 定理逐步演化而来的。其核心思想是:既是无法做到强一致性(Strong consistency),但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
基本可用
什么是基本可用呢?假设系统,出现了不可预知的故障,但还是能用,相比较正常的系统而言:响应时间上的损失:正常情况下的一个请求 0.5 秒即返回给用户结果,而基本可用的请求可以在 1 秒作用返回结果。 功能上的损失:在一个电商网站上,正常情况下,用户可以顺利完成每一笔订单,但是到了大促期间,为了保护购物系统的稳定性,可以关闭一些不重要的功能或者部分消费者可能会被引导到一个降级页面。
软状态
什么是软状态呢?相对于原子性而言,要求多个节点的数据副本都是一致的,这是一种 “硬状态”。 软状态指的是:允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性,即允许系统在多个不同节点的数据副本存在数据延时。
最终一致性
系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问最终都能够获取到最新的值。 上面说软状态,然后不可能一直是软状态,必须有个时间期限。在期限过后,应当保证所有副本保持数据一致性。从而达到数据的最终一致性。这个时间期限取决于网络延时,系统负载,数据复制方案设计等等因素。
2. 什么是分布式锁,常用的分布式锁有哪些
对于一个单机的系统,我们可以通过synchronized或者ReentrantLock等这些常规的加锁方式来实现,然而对于一个分布式集群的系统而言,单纯的本地锁已经无法解决问题,所以就需要用到分布式锁了,通常我们都会引入三方组件或者服务来解决这个问题,比如数据库、Redis、Zookeeper等。
2.1 数据库悲观锁
利用 select … where … for update 排他锁
BEGIN;
SELECT id FROM t_account WHERE id = '869159378052186112' FOR UPDATE;
UPDATE t_account SET username = 'xxx' WHERE id = '869159378052186112';
COMMIT;
需要注意锁的级别,MySQL InnoDB 默认行级锁,行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。
2.2 数据库乐观锁
乐观锁是基于CAS思想,是不具有互斥性,不会产生锁等待而消耗资源,操作过程中认为不存在并发冲突,只有 update version 失败后才能觉察到。
UPDATE
t_account
SET
username = 'xxx',
REVISION = REVISION + 1
WHERE id = '869159378052186112'
AND REVISION = 2;
2.3 Redis分布式锁
Redis分布式锁加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。
2.3.1 加锁
SET lock_key random_value NX PX 5000
random_value 是客户端生成的唯一的字符串。 NX 代表只在键不存在时,才对键进行设置操作。 PX 5000 设置键的过期时间为5000毫秒。
2.3.2 锁超时问题
使用锁续期,用于防止业务执行超时或宕机而引起的业务被重复执行。 业务执行时间的影响因素太多了,无法确定一个准确值,只能是一个估值。无法百分百保证业务执行期间,锁只能被一个线程占有。
比如客户端A加锁同时设置超时时间是3秒,结果3s之后程序逻辑还没有执行完成,锁已经释放。客户端B此时也来尝试加锁,那么客户端B也会加锁成功。 这样的话,就导致了并发的问题,如果代码幂等性没有处理好,就会导致问题产生。
如想保证的话,可以在创建锁的同时创建一个守护线程,同时定义一个定时任务每隔一段时间去为未释放的锁增加过期时间,当业务执行完,释放锁后,再关闭守护线程。 这种实现思想可以用来解决锁续期。
2.3.3 锁误删除
还是类似的问题,客户端A加锁同时设置超时时间3秒,结果3s之后程序逻辑还没有执行完成,锁已经释放。客户端B此时也来尝试加锁,这时客户端A代码执行完成,执行释放锁,结果释放了客户端B的锁。 为了保证解锁操作的原子性,我们用LUA脚本完成这一操作,先判断当前锁的字符串是否与传入的值相等,是的话就删除Key,解锁成功。 解锁的过程就是将Key键删除,但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉,这时候random_value 的作用就体现出来。
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
2.3.4 RedLock了解吗
因为在Redis的主从架构下,主从同步是异步的,如果在Master节点加锁成功后,指令还没有同步到Slave节点,此时Master挂掉,Slave被提升为Master,新的Master上并没有锁的数据,其他的客户端仍然可以加锁成功。 可以采用红锁的机制来解决单点故障,redlock是一种基于多节点redis实现分布式锁的算法,可以有效解决redis单点故障的问题,官方建议搭建五台redis服务器对redlock算法进行实现。
2.4 zookeeper分布式锁
其实现思想是当某个线程要对方法加锁时,首先会在zookeeper中创建一个与当前方法对应的父节点,接着每个要获取当前方法的锁的线程,都会在父节点下创建一个临时有序节点,因为节点序号是递增的,所以后续要获取锁的线程在zookeeper中的序号也是逐次递增的。 根据这个特性,当前序号最小的节点一定是首先要获取锁的线程,因此可以规定序号最小的节点获得锁。 在并发下,每个线程都会在对应方法节点下创建属于自己的临时节点,且每个节点都是临时且有序的。
3 如何来生产唯一主键
在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。
3.1 唯一主键的要求
全局唯一 不能出现重复的ID号,既然是唯一标识,这是最基本的要求。 趋势递增 在MySQL的InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用BTree的数据结构来存储索引数据,因此在主键的选择上我们应该尽量使用有序的主键保证写入性能。 单调递增 下一个ID一定大于上一个ID,例如事务版本号,IM增量消息、排序等特殊需求。 信息安全 如果ID是连续的,恶意扒取用户工作就非常容易做了,直接按照顺序下载指定的URL即可;如果是订单号就更危险了,竞争对手可以直接知道我们一天的单量。所以在一些应用场景下,需要ID无规则。 含时间戳 这样就能够在开发中快速了解分布式ID的生成时间。
3.2 生成方案
3.2.1 数据库自增序列
实现简单,不适用于分库场景,不适用于特高并发场景
3.2.2 UUID
生成简单,不连续
3.2.3 雪花算法
不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。可以根据自身业务特性分配bit位,非常灵活
3.2.4 雪花算法生成的ID可能会产生什么问题
依赖机器时钟,如果机器时钟回拨,会导致重复ID生成,可能在单机上是递增的,但是由于涉及到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况(此缺点可以忽略, 一般分布式ID只要求趋势递增,并不会严格要求递增, 90%的需求都只要求趋势递增)
4. 长链接和短链接如何互相转换?
生成短的URL的方式的步骤如下:
- 利用放号器,初始值为0,对于每一个短链接生成请求,都递增放号器的值,再将此值转换为62进制(a-zA-Z0-9),然后对来一个新的URL就对我们的发号器进行累加,然后用我们的短域名拼接上去
- 将短链接服务器域名与放号器的62进制值进行字符串连接,即为短链接的URL,比如itcast.cn/1
- 利用Redis保存映射关系,将 itcast.cn/1 -> URL 做一个映射
- 重定向过程:生成短链接之后,需要存储短链接到长链接的映射关系,即1-> URL,浏览器访问短链接服务器时,根据URL Path取到原始的链接,然后进行302重定向。
4.1如何提高系统的并发能力?
发号器优化 如果单个发号器可能存在性能问题,而多个发号器又涉及到一致性问题,这里我们可以采用单双号发号器,比如有两个发号器服务器,一个发单号,一个发双号 使用LRU本地缓存 使用固定大小的LRU缓存,存储最近N次的映射结果,这样,如果某一个链接生成的非常频繁,则可以在LRU缓存中找到结果直接返回,这是存储空间和性能方面的折中。 使用布隆过滤器 为了防止黑客恶意的进行访问不存在的短连接,造成大量的缓存穿透,可用使用布隆过滤器来解决这个问题。 可伸缩和高可用 如果将短链接生成服务单机部署,缺点一是性能不足,不足以承受海量的并发访问,二是成为系统单点,如果这台机器宕机则整套服务不可 用,为了解决这个问题,可以将系统集群化,进行“分片”。 如果发号器用Redis实现,则Redis是系统的瓶颈与单点,因此,利用数据库分片的设计思想,可部署多个发号器实例,每个实例负责特定号段的发号,比如部署10台Redis,每台分别负责号段尾号为0-9的发号,注意此时发号器的步长则应该设置为10。 也可将长链接与短链接映射关系的存储进行分片,由于没有一个中心化的存储位置,因此需要开发额外的服务,用于查找短链接对应的原始链接的存储节点,这样才能去正确的节点上找到映射关系。 (Redis-Cluster)
5. 你怎么理解分布式事务?分布式事务的协议有哪些?
分布式事务是指会涉及到操作多个数据库的事务,目的是为了保证分布式系统中的数据一致性,分布式事务类型:二阶段提交 2PC ,三阶段提交 3PC以及TCC协议。
5.1 什么是2PC
二阶段提交的算法思路可以概括为: 参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
二阶段是指: 第一阶段 - 请求阶段(表决阶段) 第二阶段 - 提交/回滚阶段(执行阶段)
请求阶段 事务协调者通知每个参与者准备提交或取消事务,然后进入表决过程,参与者要么在本地执行事务,写本地的redo和undo日志,但不提交,到达一种"万事俱备,只欠东风"的状态。请求阶段,参与者将告知协调者自己的决策: 同意(事务参与者本地作业执行成功)或取消(本地作业执行故障)
提交阶段 在该阶段,写调整将基于第一个阶段的投票结果进行决策: 提交或取消 当且仅当所有的参与者同意提交事务,协调者才通知所有的参与者提交事务,否则协调者将通知所有的参与者取消事务
两阶段提交的缺点 同步阻塞问题 执行过程中,所有参与节点都是事务阻塞型的 当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态 单点故障 由于协调者的重要性,一旦协调者发生故障,参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题) 数据不一致 在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。 而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。
5.2 什么是3PC
三阶段提交协议在协调者和参与者中都引入超时机制,并且把两阶段提交协议的第一个阶段分成了两步: 询问,然后再锁资源,最后真正提交。
请求阶段 3PC的canCommit阶段其实和2PC的准备阶段很像,协调者向参与者发送canCommit请求,参与者如果可以提交就返回yes响应,否则返回no响应,这一阶段主要做SQL校验。 预提交阶段 协调者根据参与者canCommit阶段的响应来决定是否可以继续事务的preCommit操作,根据响应情况,有下面两种可能: 反馈成功 协调者从所有参与者得到的反馈都是yes 那么进行事务的预执行,协调者向所有参与者发送preCommit请求,并进入prepared阶段。参与者接收到preCommit请求后会执行事务操作,并将undo和redo信息记录到事务日志中,如果一个参与者成功地执行了事务操作,则返回ACK响应,同时开始等待最终指令
反馈失败 协调者从所有参与者得到的反馈有一个是No或是等待超时之后协调者都没收到响应 那么就要中断事务,协调者向所有的参与者发送abort请求。参与者在收到来自协调者的abort请求,或超时后仍未收到协调者请求,执行事务中断。
提交阶段 协调者根据参与者preCommit阶段的响应来决定是否可以继续事务的doCommit操作,根据响应情况,有下面两种可能:
反馈成功 阶段2所有参与者均反馈ack响应,执行真正的事务提交 协调者接收到参与者发送的ACK响应,那么它将从预提交状态进入到提交状态,并向所有参与者发送doCommit请求,参与者接收到doCommit请求后,执行正式的事务提交,并在完成事务提交之后释放所有事务资源,并向协调者发送haveCommitted的ACK响应。那么协调者收到这个ACK响应之后,完成任务。 注意:进入阶段3后,无论协调者出现问题,或者协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的do Commit请求或abort请求。此时,参与者都会在等待超时之后,继续执行事务提交。
5.3 说下2PC和3PC以及他们的区别
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit,而不会一直持有事务资源并处于阻塞状态。但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
5.4 讲一下 TCC
TCC是Try,Confirm,Cancel三个词语得缩写,TCC要求每个分支事务实现三个操作:预处理Try,确定Confirm,撤销Cancel。Try操作做业务检查及资源预留,Confirm做业务确认操作,Cancel实现一个与Try相反的操作即回滚操作。 TM首先发起所有的分支事务的try操作,任何一个分支事务的try操作执行失败,TM将会发起所有分支事务的Cancel操作,若try操作全部成功,TM将会发起所有分支事务的Confirm操作,其中Confirm/Cancel操作若执行失败,TM会进行重试。
TCC详细文章
5.4.1 锁定资源
从执行阶段来看,与传统事务机制中业务逻辑相同,但从业务角度来看,却不一样,TCC机制中的Try仅是一个初步操作,它和后续的确认一起才能真正构成一个完整的业务逻辑,这个阶段主要完成:
- 完成所有业务检查( 一致性 )
- 预留必须业务资源( 准隔离性 )
比如订单服务先把自己的状态修改为:OrderStatus.UPDATING。也就是说,在pay()那个方法里,你别直接把订单状态修改为已支付!你先把订单状态修改为UPDATING,也就是修改中的意思。这个状态是个没有任何含义的这么一个状态,代表有人正在修改这个状态。 然后,库存服务直接提供的那个reduceStock()接口里,也别直接扣减库存啊,你可以是冻结掉库存。 举个例子,本来你的库存数量是100,你别直接100 - 2 = 98,扣减这个库存! 你可以把可销售的库存:100 - 2 = 98,设置为98没问题,然后在一个单独的冻结库存的字段里,设置一个2。也就是说,有2个库存是给冻结了。
TCC事务机制以初步操作(Try)为中心的,确认操作(Confirm)和取消操作(Cancel)都是围绕初步操作(Try)而展开。因此,Try阶段中的操作,其保障性是最好的,即使失败,仍然有取消操作(Cancel)可以将其执行结果撤销。 假设商品库存为100,购买数量为2,这里检查和更新库存的同时,冻结用户购买数量的库存,同时创建订单,订单状态为待确认。
5.4.2 确认阶段
根据Try阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。 Confirm和Cancel操作满足幂等性,如果Confirm或Cancel操作执行失败,将会不断重试直到执行完成。 Confirm:确认 当Try阶段服务全部正常执行, 执行确认业务逻辑操作 这里使用的资源一定是Try阶段预留的业务资源。在TCC事务机制中认为,如果在Try阶段能正常的预留资源,那Confirm一定能完整正确的提交。Confirm阶段也可以看成是对Try阶段的一个补充,Try+Confirm一起组成了一个完整的业务逻辑。 Cancel:取消 当Try阶段存在服务执行失败, 进入Cancel阶段 Cancel取消执行,释放Try阶段预留的业务资源,上面的例子中,Cancel操作会把冻结的库存释放,并更新订单状态为取消。
5.4.3 优缺点
优点 性能提升 具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。 数据最终一致性 基于Confirm和Cancel的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。 可靠性 解决了XA协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
缺点 TCC的Try、Confirm和Cancel操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
5.5 TCC 常见的异常有哪些
在分布式系统中,随时随地都需要面对网络超时,网络重发和服务器宕机等问题。所以分布式事务框架作为搭载在分布式系统之上的一个框架型应用也绕不开这些问题。
- 幂等处理
- 空回滚
- 资源悬挂
- 业务数据可见性控制
- 业务数据并发访问控制
幂等处理
因为网络抖动等原因,分布式事务框架可能会重复调用同一个分布式事务中的一个分支事务的二阶段接口。所以分支事务的二阶段接口Confirm/Cancel需要能够保证幂等性,如果二阶段接口不能保证幂等性,则会产生严重的问题,造成资源的重复使用或者重复释放,进而导致业务故障。
应对策略 对于幂等类型的问题,通常的手段是引入幂等字段进行防重放攻击,对于分布式事务框架中的幂等问题,同样可以祭出这一利器。我们可以通过增加一张事务状态控制表来实现,这个表的关键字段有以下几个:
- 主事务ID
- 分支事务ID
- 分支事务状态
其中1和2构成表的联合主键来唯一标识一笔分布式事务中的一条分支事务,3用来标识该分支事务的状 态,一共有3种状态: - INIT - 初始化
- CONFIRMED- 已提交
- ROLLBACKED- 已回滚
幂等记录的插入时机是参与者的Try方法,此时的分支事务状态会被初始化为INIT,然后当二阶段的Confirm/Cancel执行时会将其状态置为CONFIRMED/ROLLBACKED。 当TC重复调用二阶段接口时,参与者会先获取事务状态控制表的对应记录查看其事务状态,如果状态已经为CONFIRMED/ROLLBACKED,那么表示参与者已经处理完其分内之事,不需要再次执行,可以直接返回幂等成功的结果给TC,帮助其推进分布式事务。
空回滚
当没有调用参与方Try方法的情况下,就调用了二阶段的Cancel方法,Cancel方法需要有办法识别出此时Try有没有执行,如果Try还没执行,表示这个Cancel操作是无效的,即本次Cancel属于空回滚;如果Try已经执行,那么执行的是正常的回滚逻辑。 首先发起方在调用参与者之前,会向TC申请开始一笔分布式事务。然后发起方调用参与者的一阶段方法,在调用实际发生之前,一般会有切面拦截器感知到此次Try调用,然后写入一条分支事务记录,紧接着,在实际调用参与者的Try方法时发生了异常,异常原因可以是发起方宕机,网络抖动等。
出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。 当Try方法没有执行成功,然而此时这笔分布式事务和分支事务已经落库,有两种情况会触发分布式事务的回滚:
- 发起方认为当前分布式事务无法成功,主动通知TC回滚
- TC发现分布式事务超时,被动触发回滚
触发回滚操作后,TC会对该分布式事务关联的分支事务调用其二阶段Cancel,在执行Cancel时,Try还未执行成功,触发空回滚,如果不对空回滚加以防范的话,可能会造成资源的无效释放。即在没有预留资源的情况下就释放资源,造成故障。 应对策略 可以发现,要应对空回滚的问题,就需要让参与者在二阶段的Cancel方法中有办法识别到一阶段的Try是否已经执行。 很显然,可以继续利用事务状态控制表来实现这个功能。 前面提到过为了保证幂等性,当Try方法被成功执行后,会插入一条记录,标识该分支事务处于INIT状态。所以后续当二阶段的Cancel方法被调用时,可以通过查询控制表的对应记录进行判断。如果记录存在且状态为INIT,就表示一阶段已成功执行,可以正常执行回滚操作,释放预留的资源;如果记录不存在则表示一阶段未执行,本次为空回滚,不释放任何资源。
资源悬挂
悬挂,顾名思义,是有一些资源被悬挂起来后续无法处理了,那么什么场景下才会出现这种现象呢? 但是考虑一种极端情况,当分布式事务到终态后,参与者的一阶段Try才被执行,此时参与者会根据业务需求预留相关资源,预留资源只有当前事务才能使用,然而此时分布式事务已经走到终态,后续再没有任何手段能够处理这些预留资源,至此,就形成了资源悬挂。 这种一阶段比二阶段执行的还晚的情况看似不可能,但是仔细考虑RPC调用的时序,其实这种情况在复杂多变的网络中是完全可能的,下面的时序展示了这种可能性:
- 发起方通过RPC调用参与者一阶段Try,但是发生网络阻塞导致RPC超时
- RPC超时后,TC会回滚分布式事务(可能是发起方主动通知TC回滚或者是TC发现事务超时后回滚),调用已注册的各个参与方的二阶段Cancel
- 参与方空回滚后,发起方对参与者的一阶段Try才开始执行,进行资源预留从而形成悬挂
应对策略 资源悬挂的本质原因在于,一阶段和二阶段的执行顺序没有被严格地保证,所以相应的解决方案还是通过读取事务状态控制表的事务状态。 由于悬挂的产生背景是一阶段方法根本就未执行,所以此时事务控制记录是不存在的,需要在二阶段中处理ROLLBACK的情况(因为超时后触发回滚不可能存在二阶段为CONFIRM)。 处理方案为在判断为空回滚的场景下(体现在对应一阶段事务控制记录不存在),插入一条状态为ROLLBACKED的控制记录。 那么下次当一阶段Try抵达执行的时候,首先会尝试插入状态为INIT的事务控制记录。如果插入失败,表示当前分支事务的记录已经存在,Try无需继续执行。有几种可能性会导致此情形:
- 一阶段Try重复请求,网络抖动情况可能发生,可以理解为命中幂等
- 二阶段插入了防悬挂记录,一阶段不可继续执行
6. 缓存一致性问题如何解决
如何解决缓存一致性的问题,先删缓存还是先删除数据库
6.1 常见的解决方案
6.1.1 先删除缓存在更新数据库
在多线程环境下,当一个线程把缓存删掉之后,另一个线程读缓存,读不到缓存就会直接读库,读到数据后就会更新缓存,先前的线程呢,才更新数据库,会造成缓存脏读的情况,很容易产生缓存脏读。
6.1.2 更新数据库再删缓存
在多线程情况下,当一个线程删除数据库,另一个线程读取缓存数据,读到的是缓存的数据,当先前一个线程删完数据库后就会更新缓存,这是缓存就正常了,产生了一次脏读。
6.1.3 延时双删
先删缓存,再更新数据库,然后等待一段时间,在删除缓存,等待数据完全落盘后删除缓存完成同步操作
sleep的时间要对业务读写缓存的时间做出评估,sleep时间大于读写缓存的时间即可。
流程如下:
- 线程1删除缓存,然后去更新数据库
- 线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
- 线程1,根据估算的时间,sleep,由于sleep的时间大于线程2读数据+写缓存的时间,所以缓存被再次删除
- 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值
6.1.4 Canal订阅binlog MQ同步
应用直接写数据到数据库中。 数据库更新binlog日志。 利用Canal中间件读取binlog日志。 Canal借助于限流组件按频率将数据发到MQ中。 应用监控MQ通道,将MQ的数据更新到Redis缓存中。
6.2为什么是删除,而不是更新缓存?
我们以先更新数据库,再删除缓存来举例。
如果是更新的话,那就是先更新数据库,再更新缓存。
举个例子:如果数据库1小时内更新了1000次,那么缓存也要更新1000次,但是这个缓存可能在1小时内只被读取了1次,那么这1000次的更新有必要吗?
反过来,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载。
|