分布式架构体系知识
文章目录
高并发下的分布式系统
分布式缓存
先提出两种数据库:memcache redis
原来做分布式缓存使用的是memcache,现在迁移使用redis完成分布式开发
原因:
- memcache支持的数据类型单一,只支持String类型,redis可支持五大数据类型(String、set、map、list、zlist)
- redis是单线程的,处理业务能力强
为什么要使用缓存
在B/S开发中,一般流程:
客户端C发起请求 ——》服务器接收请求(tomcat)——》读取/插入等操作——》数据库mysql
提出问题一
- 显然,当有大量客户端C发起请求,服务器,数据库会产生巨大压力(数据库压力会更大一些,因为存在读写操作),性能下降,客户端体验差等情况,
解决问题一
- 增加在服务器和数据库之间增加缓存层,使用redis对热点数据进行存储,当服务器访问热点数据时,redis直接返回该数据,避免服务器直接于数据库进行交互!
提出问题二
- 当热点数据量很大时,缓存层的一台redis显然不够存储时,也会造成问题一
解决问题二
- 增加redis数量,也就是redis集群
缓存处理流程
前台请求,后台先从缓存中取数据,取到直接返回结果,取不到时从数据库中取,数据库取到更新缓存,并返回结果,数据库也没取到,那直接返回空结果
redis集群
redis集群有两种模式:
- 主从模式(主从复制)
- 切片模式
主从模式
方式:存在一个master(主),许多的slave(从),master负责管理slave,如果客服端读数据,则从slave(从)进行读取,改数据则从master(主),然后master(主)将数据库同步到每一个slave(从),(因为一般客户大量操作都是读取数据)
缺点:集群所存储的数据有单台redis(主)的容量来决定,对于海量数据并不能解决(问题二)
切片模式
方式:将大量的数据进行切片,均匀的将数据分配给redis集群的每一个redis数据库,
存在问题:如何制定切片规则,能将数据均匀的分配给每个数据库呢?使数据库的增加和删除操作平缓,
切片规则一:
redis使键值对的存储方式,对key进行hash运算得到一个hash值取某一个数模
hash(key)%=[0,1,2,3,4,5,6,7,] =》数值等于0存0号redis数据库,等于1存一号redis数据库,等于2存二号redis数据库。。。。
缺点:不利于集群的扩展,当增加redis数据库时,会发生从其他已经存储的数据库大量迁移数据情况。删除某一台数据库也一样,即这样的切片规则会导致集群中的redis发生网络IO,单线程一旦发生数据库网络IO,则不能对外提供服务
切片规则二(一致性hash算法)
会存在一个hash环,这个hash环上有很多个点(比如存在2^32个)存储redis数据库
hash环的作用:组织数据存储在redis上
确定数据库位置:redis集群在实际中,每台数据库拥有自己的ip+编号,对 (ip+编号)%2^32 得到的数据映射到环上,确定了redis的分布
确定数据存储:对数据进行hash(key)%2^23,进行操作,一定能映射到hash环上的某一个点上,然后顺时针寻找redis数据库,找到即存储到该数据库上
优点:对集群的扩展非常有利,增加一台redis,进行位置确定后,假如加入在redis2和redis3之间,则只需将redis2和新增redis的之间的数据存在新增的redis上,新增redis和redis3之间的数据任然存在redis3上,即只发生了新增redis和redis3之间的数据迁移,redis1和redis3不变,删除一台redis也同理。完成了集群的改变只发生两台数据库的迁移!
问题:当存在数据倾斜问题,比如假设环上只存在两台redis,而且redis1和redis2离得特别近,此时redis2和redis1之间会存在大量的环节点,数据存储时会导致大量数据存储在redis2上,造成数据倾斜!
解决问题:可以对redis1和redis2进行虚拟,虚拟成n个分布在hash环上,如上,只虚拟了两个,可能也会存在极端情况,但是虚拟成n个呢?问题解决
数据倾斜问题:
- 在存储框架:大部分的数据存储在少量的服务器上,少量的数据存储在大量的服务器上
- 计算框架:大部分的数据由少量的服务器计算,少量的数据由大量的服务器计算
缓存穿透
描述: 缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
情景:当客户端请求一条redis集群(缓存)和数据库都不存在的数据,直接访问数据库mysql,这种情况为缓存穿透,单条数据的请求并不可怕的,但是多条大量这样的数据恶意请求,会导致大量对数据库进行IO操作(攻击数据库),使分布式缓存名存实亡!
解决问题,数据库将请求的数据(null)缓存到redis集群中
问题一:
当恶意一直使用不存在的数据,则可能会将缓存撑满
解决问题:
在redis缓存和数据库之间添加一层过滤网,该过滤网存有数据库的ID,当请求在缓存不被命中时,会先别过滤网拦截,该过滤网如果发现数据库确实存在该数据,则从数据库中取出数据,反之,打回请求!
问题二:
当存在海量请求时,该过滤网的内存占用过大,也会导致系统性能低下!
解决问题:
使用布隆算法,通过错误率来换取空间的占用
浅谈布隆算法
首先会对数据库的所有id进行hash运算,运算的值大概在一个区间内,并规定该区间的所有值对应数组的下标(该数值只存0或1,0表示不存在,1表示存在),运算相同的值放在对应数组同一个位置,当客户端访问的某一个值进行该hash运算得到得数得下标对应为1,表示数据存在,否则表示不存在。
特点:
- 数据存在,实际情况不一定存在,数据不存在,那实际情况一定不存在
存在错误率得原因:
- hash碰撞的概率太高,hash碰撞概率和什么有关?hash碰撞概率与数组长度有关,即数组越长错误率越低
降低错误率的方法
- 增加数组长度
- 增加hash函数个数
为啥增加hash函数个数也会降低概率呢,这涉及概率问题,比如只有一个hash函数,错误率都有一个hash函数确定,比如是1/100,如果是两个呢?那就是1/10000
布隆算法的应用:
提出问题:2个文件有100亿url,最高效率找出这两个文件的交集,分别给出精确算法和模糊算法
解决方法:
模糊算法:使用布隆算,将第一个文件的所有url进行hash运算并存放在0/1数组中,将第二个文件进行该hash运算,得出1,表示为交集的url,或者不是 (一次IO)
精确算法:将文件1进行hash运算(hahs()%1000,拆分为1000个子文件)拆分成若干子文件,文件2也同理拆分为若干子文件,然后对应的子文件和子文件对比 (多次IO)
总结解决方案
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
缓存击穿
描述:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决方案
- 设置热点数据永远不过期。
- 加互斥锁
//加互斥锁
public static String getData(String key) throws InterruptedException
{
//从缓存中读取数据
String result = getDataFromRedis(key);
//缓存中有数据,直接返回result结果
if(result == null){
/*
缓存中没有数据时,第1个进入的线程,会获取锁并从数据库去取数据,没有释放锁之前,其他并行进入的线程会等待100ms,再重新去缓存取数据。这样就防止都去数据库重复取数据,重复往缓存中更新数据情况出现
*/
//去获取锁,获取成功,去数据库取数据
if(reenLock.tryLock()){
//从数据库中获取数据
result = getDataFromMysql(key);
if(result != null){
setDataToCache(key,result);
}
//释放锁
reenLock.unlock();
}
//获取锁失败
else{
//暂停100ms再重新获取数据
Thread.sleep(100);
result = getData(key);
}
}
return result;
}
//这只是简化处理,理论上如果能根据key值加锁就更好了,就是线程A从数据库取key1的数据并不妨碍线程B取key2的数据,上面代码明显做不到这一点
缓存雪崩
前景:我们使用redis集群来做缓存,出现两种情况:
- redis集群集体宕机 (实际情况不常见)
- redis热点数据大量到达过期时间,
注:缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库
请求全部将打向mysql数据库,使mysql数据库那一刻将承受很高很高的并发,极有可能造成mysql宕机
解决方案:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中
- 设置热点数据永不过期
分布式微服务
分布式和微服务是两种概念
分布式:分散压力
微服务:分散能力
从概念上理解:分布式服务架构强调的是服务化以及服务的分散化,微服务则更强调服务的专业化和精细分工
从实践角度理解:微服务架构通常是分布式服务架构,反之未必成立,所以选择微服务通常意味着需要解决分布式架构的各种难题
单体应用
我们一开始就是使用
client——》tomcat——》连接池 ——》DB
这样的流程的单体业务
单体项目出现的问题
- 可靠性问题,容易出现单点故障,容易挂
- 性能问题,容易因为高并发宕机
因此优化单体业务,出现了分布式微服务
DB和tomcat之间增加缓存层(前文有介绍),增加tomcat(服务器)数量
介绍:客服端与客服端通过LVS负载均衡分发到不同tomcat,tomcat之间会有不同的session会话,这需要同步处理(paxos),但是同步处理很难做的,因此可以使用redis将所有session同一储存管理。
性能分析:
-
单笔请求性能是变低的!
-
高并发时,连接数上去了!性能变高
-
业务功能模块相对较多,比如一个服务器有购物操作、搜索操作等多种操作,LVS负载均衡分配大量耗时操作给一台服务器时(倾斜现象),性能会很慢
解决3问题:将服务器的功能模块化,一个功能对应一个服务器,这样非耗时操作就不会因为耗时操作过多等待
怎么做到模块化?
介绍:使用nginx,nginx是拥有七层的,而LVS只有4层,LVS是TCP协议,而nginx是七层的,含url地址,可使用nginx具有反向代理的过程,C找nginx代理,但是nginx不做事,反而找对应模块进行代理,这个过程叫做反向代理,当在搜索高并发量大(搜索集群时,在nginx和搜索之间再做负载均衡处理,这一段即为反向代理的负载均衡过程,并发量特别大时,可以使用多台nginx,使用LVS进行负载均衡,LVS进行三次握手链接,nginx进行七层解析,根据解析的东西再去转到不同的集群,如下
总结:在单机点单(tomcat/mysql/redis)容易发生单点故障,此时我们可以使用多台来防止此情况,但是怎么使多台数据同步,怎么分发任务呢?
tomcat:使用LVS
redis:使用读写分离(主从复制)或者使用哨兵模式
哨兵:哨兵就是从切换为主(主挂了),保证自动化,
微服务
简单来说,微服务就是很小的服务,小到一个服务只对应一个单一的功能,只做一件事,这个服务可以单独部署运行,服务之间可以通过RPC来相互交互
微服务架构
在做架构设计的时候,先做逻辑架构,再做物理架构,当你拿到需求后,估算过最大用户量和并发量后,计算单个应用服务器是否能满足需求,如果用户量只有几百人的小应用,单体应用就能搞定,即所有应用部署在一个应用服务器里,如果是很大用户量,且某些功能会被频繁访问,或者某些功能计算量很大,建议将应用拆分为多个子系统,各自负责各自功能,这就是微服务架构
分布式服务
分布式服务是指服务是分散部署在不同的机器上,一个服务可能负责几个功能,是一个种面向SOA架构的,服务之间也是通过RPC来交互或者是webservice来交互的,逻辑架构设计完后就该做物理架构设计,系统应用部署在超过一台服务器或者虚拟机上,且各分开部署的部分彼此通过各种通讯协议交互信息,就可算作分布式部署,生产环境下的微服务肯定是分布式部署的,分布式部署的应用不一定是微服务架构,比如集群部署,它是把相同应用复制到不同服务器上,但是逻辑功能上还是单体应用。
微服务相比于分布式服务来说,它的粒度更小,服务之间耦合度更低,由于每一个微服务都是由独立的小团队负责,因为它敏捷性更高,分布式服务最后都会向微服务架构演化,这是一种趋势,不过服务微服务化后带来的挑战也是显而易见的,例如服务力度小,数量大,后期运维将会很难。
分布式锁
背景:本地锁(JVM锁),当在分布式系统中,不同的机器在操作堆(各自的堆内存)里面的对象时,使用JVM锁是无法保证同步安全的,所以引入的分布式锁!
分布式锁,是控制分布式系统之间同步访问共享资源的一种锁
在分布式系统中,常常需要协调他们的动作,如果不同的系统或者同一个系统的不同主机之间共享了一个或者一组资源,那么访问需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁
在有分布式锁的情况下,能保证多机、多进程、多线程访问资源的一致性,这个时候还需要进程内部的JVM锁吗?答案是需要的!
实现方法
- 基于数据库实现
- 基于Redis实现
- 基于ZooKeeper实现
- etcd实现
两大类分布式锁
第一类
类cas自旋式分布式锁,询问的方式 尝试链接
第二类
event事件通知我后续锁的变化,轮询向外的过程
分布式事务
本小节来自:https://zhuanlan.zhihu.com/p/183753774
分布式事务顾名思义就是要在分布式系统中实现事务,它其实是由多个本地事务组合而成。
对于分布式事务而言几乎满足不了 ACID,其实对于单机事务而言大部分情况下也没有满足 ACID,不然怎么会有四种隔离级别呢?所以更别说分布在不同数据库或者不同应用上的分布式事务了。
我们先来看下 2PC。
2PC
2PC(Two-phase commit protocol),中文叫二阶段提交。 二阶段提交是一种强一致性设计,2PC 引入一个事务协调者的角色来协调管理各参与者(也可称之为各本地资源)的提交和回滚,二阶段分别指的是准备(投票)和提交两个阶段。
注意这只是协议或者说是理论指导,只阐述了大方向,具体落地还是有会有差异的。
让我们来看下两个阶段的具体流程。
准备阶段协调者会给各参与者发送准备命令,你可以把准备命令理解成除了提交事务之外啥事都做完了。
同步等待所有资源的响应之后就进入第二阶段即提交阶段(注意提交阶段不一定是提交事务,也可能是回滚事务)。
假如在第一阶段所有参与者都返回准备成功,那么协调者则向所有参与者发送提交事务命令,然后等待所有事务都提交成功之后,返回事务执行成功。
让我们来看一下流程图。
假如在第一阶段有一个参与者返回失败,那么协调者就会向所有参与者发送回滚事务的请求,即分布式事务执行失败。
那可能就有人问了,那第二阶段提交失败的话呢?
这里有两种情况。
第一种是第二阶段执行的是回滚事务操作,那么答案是不断重试,直到所有参与者都回滚了,不然那些在第一阶段准备成功的参与者会一直阻塞着。
第二种是第二阶段执行的是提交事务操作,那么答案也是不断重试,因为有可能一些参与者的事务已经提交成功了,这个时候只有一条路,就是头铁往前冲,不断的重试,直到提交成功,到最后真的不行只能人工介入处理。
大体上二阶段提交的流程就是这样,我们再来看看细节。
首先 2PC 是一个同步阻塞协议,像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。
在第二阶段协调者的没法超时,因为按照我们上面分析只能不断重试!
协调者故障分析
协调者是一个单点,存在单点故障问题。
假设协调者在发送准备命令之前挂了,还行等于事务还没开始。
假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。
假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功参与者都阻塞着。
假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。
假设协调者在发送提交事务命令之前挂了,这个不行,傻了!这下是所有资源都阻塞着。
假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。
协调者故障,通过选举得到新协调者
因为协调者单点问题,因此我们可以通过选举等操作选出一个新协调者来顶替。
如果处于第一阶段,其实影响不大都回滚好了,在第一阶段事务肯定还没提交。
如果处于第二阶段,假设参与者都没挂,此时新协调者可以向所有参与者确认它们自身情况来推断下一步的操作。
假设有个别参与者挂了!这就有点僵硬了,比如协调者发送了回滚命令,此时第一个参与者收到了并执行,然后协调者和第一个参与者都挂了。
此时其他参与者都没收到请求,然后新协调者来了,它询问其他参与者都说OK,但它不知道挂了的那个参与者到底O不OK,所以它傻了。
问题其实就出在每个参与者自身的状态只有自己和协调者知道,因此新协调者无法通过在场的参与者的状态推断出挂了的参与者是什么情况。
虽然协议上没说,不过在实现的时候我们可以灵活的让协调者将自己发过的请求在哪个地方记一下,也就是日志记录,这样新协调者来的时候不就知道此时该不该发了?
但是就算协调者知道自己该发提交请求,那么在参与者也一起挂了的情况下没用,因为你不知道参与者在挂之前有没有提交事务。
如果参与者在挂之前事务提交成功,新协调者确定存活着的参与者都没问题,那肯定得向其他参与者发送提交事务命令才能保证数据一致。
如果参与者在挂之前事务还未提交成功,参与者恢复了之后数据是回滚的,此时协调者必须是向其他参与者发送回滚事务命令才能保持事务的一致。
所以说极端情况下还是无法避免数据不一致问题。
talk is cheap 让我们再来看下代码,可能更加的清晰。以下代码取自 <<Distributed System: Principles and Paradigms>>
。
这个代码就是实现了 2PC,但是相比于2PC增加了写日志的动作、参与者之间还会互相通知、参与者也实现了超时。这里要注意,一般所说的2PC,不含上述功能,这都是实现的时候添加的。
协调者:
write START_2PC to local log; //开始事务
multicast VOTE_REQUEST to all participants; //广播通知参与者投票
while not all votes have been collected {
wait for any incoming vote;
if timeout { //协调者超时
write GLOBAL_ABORT to local log; //写日志
multicast GLOBAL_ABORT to all participants; //通知事务中断
exit;
}
record vote;
}
//如果所有参与者都ok
if all participants sent VOTE_COMMIT and coordinator votes COMMIT {
write GLOBAL_COMMIT to local log;
multicast GLOBAL_COMMIT to all participants;
} else {
write GLOBAL_ABORT to local log;
multicast GLOBAL_ABORT to all participants;
}
参与者:
write INIT to local log; //写日志
wait for VOTE_REQUEST from coordinator;
if timeout { //等待超时
write VOTE_ABORT to local log;
exit;
}
if participant votes COMMIT {
write VOTE_COMMIT to local log; //记录自己的决策
send VOTE_COMMIT to coordinator;
wait for DECISION from coordinator;
if timeout {
multicast DECISION_REQUEST to other participants; //超时通知
wait until DECISION is received; /* remain blocked*/
write DECISION to local log;
}
if DECISION == GLOBAL_COMMIT
write GLOBAL_COMMIT to local log;
else if DECISION == GLOBAL_ABORT
write GLOBAL_ABORT to local log;
} else {
write VOTE_ABORT to local log;
send VOTE_ABORT to coordinator;
}
每个参与者维护一个线程处理其它参与者的DECISION_REQUEST请求:
while true {
wait until any incoming DECISION_REQUEST is received;
read most recently recorded STATE from the local log;
if STATE == GLOBAL_COMMIT
send GLOBAL_COMMIT to requesting participant;
else if STATE == INIT or STATE == GLOBAL_ABORT;
send GLOBAL_ABORT to requesting participant;
else
skip; /* participant remains blocked */
}
至此我们已经详细的分析的 2PC 的各种细节,我们来总结一下!
2PC 是一种尽量保证强一致性的分布式事务,因此它是同步阻塞的,而同步阻塞就导致长久的资源锁定问题,总体而言效率低,并且存在单点故障问题,在极端条件下存在数据不一致的风险。
当然具体的实现可以变形,而且 2PC 也有变种,例如 Tree 2PC、Dynamic 2PC。
还有一点不知道你们看出来没,2PC 适用于数据库层面的分布式事务场景,而我们业务需求有时候不仅仅关乎数据库,也有可能是上传一张图片或者发送一条短信。
而且像 Java 中的 JTA 只能解决一个应用下多数据库的分布式事务问题,跨服务了就不能用了。
简单说下 Java 中 JTA,它是基于XA规范实现的事务接口,这里的 XA 你可以简单理解为基于数据库的 XA 规范来实现的 2PC。(至于XA规范到底是啥,篇幅有限,下次有机会再说)
接下来我们再来看看 3PC。
3PC
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。
让我们来详细看一下。
3PC 包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit
。
看起来是把 2PC 的提交阶段变成了预提交阶段和提交阶段,但是 3PC 的准备阶段协调者只是询问参与者的自身状况,比如你现在还好吗?负载重不重?这类的。
而预提交阶段就是和 2PC 的准备阶段一样,除了事务的提交该做的都做了。
提交阶段和 2PC 的一样,让我们来看一下图。
不管哪一个阶段有参与者返回失败都会宣布事务失败,这和 2PC 是一样的(当然到最后的提交阶段和 2PC 一样只要是提交请求就只能不断重试)。
我们先来看一下 3PC 的阶段变更有什么影响。
首先准备阶段的变更成不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。
而预提交阶段的引入起到了一个统一状态的作用,它像一道栅栏,表明在预提交阶段前所有参与者其实还未都回应,在预处理阶段表明所有参与者都已经回应了。
假如你是一位参与者,你知道自己进入了预提交状态那你就可以推断出来其他参与者也都进入了预提交状态。
但是多引入一个阶段也多一个交互,因此性能会差一些,而且绝大部分的情况下资源应该都是可用的,这样等于每次明知可用执行还得询问一次。
我们再来看下参与者超时能带来什么样的影响。
我们知道 2PC 是同步阻塞的,上面我们已经分析了协调者挂在了提交请求还未发出去的时候是最伤的,所有参与者都已经锁定资源并且阻塞等待着。
那么引入了超时机制,参与者就不会傻等了,如果是等待提交命令超时,那么参与者就会提交事务了,因为都到了这一阶段了大概率是提交的,如果是等待预提交命令超时,那该干啥就干啥了,反正本来啥也没干。
然而超时机制也会带来数据不一致的问题,比如在等待提交命令时候超时了,参与者默认执行的是提交事务操作,但是有可能执行的是回滚操作,这样一来数据就不一致了。
当然 3PC 协调者超时还是在的,具体不分析了和 2PC 是一样的。
从维基百科上看,3PC 的引入是为了解决提交阶段 2PC 协调者和某参与者都挂了之后新选举的协调者不知道当前应该提交还是回滚的问题。
新协调者来的时候发现有一个参与者处于预提交或者提交阶段,那么表明已经经过了所有参与者的确认了,所以此时执行的就是提交命令。
所以说 3PC 就是通过引入预提交阶段来使得参与者之间的状态得到统一,也就是留了一个阶段让大家同步一下。
但是这也只能让协调者知道该如果做,但不能保证这样做一定对,这其实和上面 2PC 分析一致,因为挂了的参与者到底有没有执行事务无法断定。
所以说 3PC 通过预提交阶段可以减少故障恢复时候的复杂性,但是不能保证数据一致,除非挂了的那个参与者恢复。
让我们总结一下, 3PC 相对于 2PC 做了一定的改进:引入了参与者超时机制,并且增加了预提交阶段使得故障恢复之后协调者的决策复杂度降低,但整体的交互过程更长了,性能有所下降,并且还是会存在数据不一致问题。
所以 2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。
我再说下 3PC 我没有找到具体的实现,所以我认为 3PC 只是纯的理论上的东西,而且可以看到相比于 2PC 它是做了一些努力但是效果甚微,所以只做了解即可。
TCC
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务,就像我前面说的分布式事务不仅仅包括数据库的操作,还包括发送短信等,这时候 TCC 就派上用场了!
TCC 指的是Try - Confirm - Cancel
。
- Try 指的是预留,即资源的预留和锁定,注意是预留。
- Confirm 指的是确认操作,这一步其实就是真正的执行了。
- Cancel 指的是撤销操作,可以理解为把预留阶段的动作撤销了。
其实从思想上看和 2PC 差不多,都是先试探性的执行,如果都可以那就真正的执行,如果不行就回滚。
比如说一个事务要执行A、B、C三个操作,那么先对三个操作执行预留动作。如果都预留成功了那么就执行确认操作,如果有一个预留失败那就都执行撤销动作。
我们来看下流程,TCC模型还有个事务管理者的角色,用来记录TCC全局事务状态并提交或者回滚事务。
可以看到流程还是很简单的,难点在于业务上的定义,对于每一个操作你都需要定义三个动作分别对应Try - Confirm - Cancel
。
因此 TCC 对业务的侵入较大和业务紧耦合,需要根据特定的场景和业务逻辑来设计相应的操作。
还有一点要注意,撤销和确认操作的执行可能需要重试,因此还需要保证操作的幂等。
相对于 2PC、3PC ,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务。
本地消息表
本地消息表其实就是利用了 各系统本地的事务来实现分布式事务。
本地消息表顾名思义就是会有一张存放本地消息的表,一般都是放在数据库中,然后在执行业务的时候 将业务的执行和将消息放入消息表中的操作放在同一个事务中,这样就能保证消息放入本地表中业务肯定是执行成功的。
然后再去调用下一个操作,如果下一个操作调用成功了好说,消息表的消息状态可以直接改成已成功。
如果调用失败也没事,会有 后台任务定时去读取本地消息表,筛选出还未成功的消息再调用对应的服务,服务更新成功了再变更消息的状态。
这时候有可能消息对应的操作不成功,因此也需要重试,重试就得保证对应服务的方法是幂等的,而且一般重试会有最大次数,超过最大次数可以记录下报警让人工处理。
可以看到本地消息表其实实现的是最终一致性,容忍了数据暂时不一致的情况。
消息事务
RocketMQ 就很好的支持了消息事务,让我们来看一下如何通过消息实现事务。
第一步先给 Broker 发送事务消息即半消息,半消息不是说一半消息,而是这个消息对消费者来说不可见,然后发送成功后发送方再执行本地事务。
再根据本地事务的结果向 Broker 发送 Commit 或者 RollBack 命令。
并且 RocketMQ 的发送方会提供一个反查事务状态接口,如果一段时间内半消息没有收到任何操作请求,那么 Broker 会通过反查接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。
如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。
可以看到通过 RocketMQ 还是比较容易实现的,RocketMQ 提供了事务消息的功能,我们只需要定义好事务反查接口即可。
可以看到消息事务实现的也是最终一致性。
最大努力通知
其实我觉得本地消息表也可以算最大努力,事务消息也可以算最大努力。
就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。
事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。
所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。
适用于对时间不敏感的业务,例如短信通知。
总结
可以看出 2PC 和 3PC 是一种强一致性事务,不过还是有数据不一致,阻塞等风险,而且只能用在数据库层面。
而 TCC 是一种补偿性事务思想,适用的范围更广,在业务层面实现,因此对业务的侵入性较大,每一个操作都需要实现对应的三个方法。
本地消息、事务消息和最大努力通知其实都是最终一致性事务,因此适用于一些对时间不敏感的业务。
三秒规则
程序员有个规定,当用一个程序用户等待实践大于三秒,使用率会变得很低
- 建立链接
- 业务逻辑涉及技术及处理速度