分布式事务
理论知识
事务的四个特性:ACID
- Atomic 原子性:一个事务中的所有操作,要么全部完成,要么全部不完成
- Consistency 一致性:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。完整性包括外键约束、应用定义等约束不会被破坏
- Isolation 隔离性:防止多个事务并发执行时由于交叉执行而导致数据的不一致
- Durability 持久性:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失
分布式系统三个特性:CAP
- Consistency 一致性:集群执行某个操作后,所有副本节点的状态都相同,那么这样的系统就被认为具有强一致性
- Available 可用性:集群一部分节点故障后,还能对外提供服务
- Partition tolerance 分区容忍性:狭义上是集群节点之间是否能正常通信,更广义的是对通信的时限要求,系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况
对于互联网的场景来说,一般选择AP:因为现在集群规模越来越大,主机众多、部署分散,所以节点故障、网络故障是常态,而且要保证服务可用性达到N个9,即保证P和A,舍弃C。
分布式的BASE理论:柔性事务,对CAP的权衡
- Basically Available 基本可用:系统在出现不可预知故障的时候,允许损失部分可用性,但这绝不等价于系统不可用
- Soft state 软状态:允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性
- Eventually consistent 最终一致性:经过一段时间的同步后,最终能够达到一个一致的状态
NewSQL的分布式事务:
以Spanner、TiDB为代表的NewSQL,在内部集群多节点间,实现了ACID的事务,即提供给用户的事务接口与普通本地事务无差别,但是在内部,一个事务是支持多个节点多条数据的写入,此时无法采用本地ACID的MVCC技术,而是会采用一套复杂的分布式MVCC来做到ACID。属于满足CP的系统,同时接近于满足A,称之为CP+HA。但是NewSQL和BASE的系统之间,性能上差异可能是巨大的。
跨库跨服务的分布式事务:这类分布式事务部分遵循 ACID
- 原子性:严格遵循
- 一致性:事务完成后的一致性严格遵循;事务中的一致性可适当放宽
- 隔离性:并行事务间不可影响;事务中间结果可见性允许安全放宽
- 持久性:严格遵循
现有的分布式事务方案都无法做到强一致,但是有强弱之分:XA事务 > TCC > 二阶段消息 > SAGA(一般情况下)。具体为:
- XA:XA虽然不是强一致,但是XA的一致性是多种分布式事务中,一致性最好的,因为他处于不一致的状态时间很短,只有一部分分支开始commit,但还没有全部commit的这个时间窗口,数据是不一致的。因为数据库的commit操作耗时,通常是10ms内,因此不一致的窗口期很短。
TCC:理论上,TCC可以用XA来实现,例如Try-Prepare,Confirm-Commit,Cancel-Rollback。但绝大多数时候,TCC会在业务层自己实现Try Confirm Cancel,因此Confirm操作耗时,通常高于XA中的Commit,不一致的窗口时间比XA长 - MSG:二阶段消息型事务在第一个操作完成后,在所有操作完成之前,这个时间窗口是不一致的,持续时长一般比前两者更久。
- SAGA:SAGA的不一致窗口时长与消息接近,但是如果发生回滚,而子事务中正向操作修改的数据又会被用户看到,这部分数据就是错误数据,容易给用户带来较差的体验,因此一致性是最差的。
2PC
两阶段提交流程如下:
- 准备阶段:RM去开启事务并执行操作,但是不提交事务,此时相关资源都被锁定,第三方不能访问这些资源。返回操作结果给TM
- 提交阶段:若所有RM在准备阶段都操作成功,TM将发出请求让所有RM提交事务,否则发出请求让所有RM回滚
存在问题:
- 同步阻塞:从准备阶段的锁定资源,直到事务提交/回滚的过程中,资源都处于被RM锁定的状态,第三方访问会被阻塞。
- 单点故障:2PC的推进重度依赖TM,TM若发生故障,RM会被阻塞。
- 数据不一致:若提交阶段部分RM无法与TM正常通信,导致一部分子事务提交了,而发生异常的RM没提交,全局事务发生不一致。
3PC
3PC提交将2PC的准备阶段再次拆分,加入检查阶段,如果检查失败的话马上abort,减少了2PC在失败情况下白白浪费资源,并且引入RM的超时机制(2PC只有TM超时机制),如果在提交阶段TM超时,直接提交事务释放资源。3PC流程如下:
- 检查阶段:TM 询问 RM,是否具备执行事务的条件,RM 进行自身事务必要条件的检查
- 预提交阶段:TM 通知 RM 进行事务的预提交
- 提交阶段:TM 根据预提交阶段 RM 的反馈结果通知 RM 是否进行事务提交或是进行事务回滚
RM超时自动提交是因为此时已经进入了提交阶段,说明RM知道检查阶段已经成功(但是RM不知道预提交阶段是否成功),认为第三阶段很大概率也可以成功。但很明显,如果第二步存在RM失败了,即预提交失败,因此在第三步中,TM发出回滚请求,但此时又有另外一个RM超时并自动提交了事务(超时时间内收不到回滚请求),就会出现不一致。
综上,3PC相比2PC减小了资源锁定导致阻塞的时间,提高了系统可用性,但依然没解决数据一致性。
二阶段消息
这个是DTM框架提出的全新分布式事务方案,适用于无需回滚的业务。用户只需要定义好本地事务逻辑+回查逻辑
使用转账例子进行说明,转账包含转出和转入,转出是扣款,会有余额不足的情况,只要转出成功,那么转入一定成功,即转入不会发生回滚行为。
- 成功流程:
AP提交本地事务后宕机:从AP主动通知TM事务执行完成,变成TM超时后主动查询AP的事务状态
- AP执行本地事务(转出)后宕机,没执行submit通知TM本地事务执行完成
- TM发现全局事务超时之后调用回查分支,发现AP的本地事务已经提交
- 后续步骤与执行成功一样
- AP提交本地事务前宕机:数据库由于与AP断连,自动进行回滚。并且后续TM超时调用回查接口发现事务未执行完成,也会让全局事务直接失败
综上,本质上是依赖于TM对事务的状态进行回查,使得在AP宕机后继续推进全局事务。那么重点就在于回查的实现。这个实现在我看来可谓是非常巧妙,强迫症狂喜…下面来看看DTM是如何实现的回查的。
先放出二阶段消息的核心业务代码:
1
2
3
4
5
6
7
8
// 对应prepare,将gid和分支事务注册到dtm中
msg := dtmcli.NewMsg(DtmServer, gid).
Add(busi.Busi+"/TransIn", &TransReq{Amount: 30})
// 注册本地事务对应的回查操作,并且执行本地事务
err := msg.DoAndSubmitDB(busi.Busi+"/QueryPreparedB", db, func(tx *sql.Tx) error {
// 这里是执行本地事务的逻辑
return busi.SagaAdjustBalance(tx, busi.TransOutUID, -req.Amount, "SUCCESS")
})
而对于回查逻辑,实际上就是根据你事务涉及到的数据库,调用DTM提供相应的回查函数,没有其他业务逻辑(我认为实际上dtm可以设计成通过配置文件的方式,指定使用的数据库,这样就不用每次都copy这段代码):
1
2
3
app.GET(BusiAPI+"/QueryPreparedB", dtmutil.WrapHandler2(func(c *gin.Context) interface{} {
return MustBarrierFromGin(c).QueryPrepared(dbGet())
}))
这样一来,AP就会执行本地事务busi.SagaAdjustBalance
- 如果返回成功的话,会通知dtm服务端,dtm随后就会去调用之前注册好的
/TransIn
分支去执行转入操作,最终完成全局事务 - 如果返回了错误,也就是AP执行本地事务失败的话,dtm就会终止全局事务
- 如果本地事务超时,dtm就会调用之前注册好的回查操作
QueryPreparedB
,并根据查询结果推进全局事务
回到我们的回查原理实现:
- 当AP执行执行本地事务前,dtm客户端(AP端)会使用该事务
tx
往本地数据库的dtm_barrier.barrier
表中插入gid
以及commited
来标识该事务的状态 - 当触发回查时,dtm客户端会往
dtm_barrier.barrier
中insert ignore该gid
以及状态rollbacked
,然后用该gid
再查询这个表,如果状态为committed
,说明事务已经提交,否则如果为rollbacked
,说明事务已回滚
于是,由于回查是由于AP迟迟没submit而触发的,要么是因为AP宕机了,要么是因为AP本地事务还在执行中。如果是因为AP宕机,那么本地事务已经被自动回滚,rollbacked
插入成功,此时dtm将全局事务置为失败;如果只是因为事务还没执行完成,那么由于本地事务已开启但未提交,因此插入rollbacked
的这条SQL会被阻塞,等本地事务成功或者回滚之后,也能获取到结果。
因此在业务无感知的情况下,dtm偷偷将插入committed
这个操作塞入到了本地事务中,实现了当事务未执行完成之前,回查操作阻塞等待事务执行完成/回滚的效果,避免了TM需要轮询回查的开销(应该一般来说想到的都是轮询这种方式),另一方面省区了确定轮询间隔时间的麻烦(但需要确定本地事务的超时时间,超时时间太长,在AP宕机后较长一段时间后才会事务才被推进;超时时间太短,又会触发大量不必要的回查)。
事务消息
与二阶段消息类似,二阶段消息是受到RocketMQ的事务消息启发后提出的新架构。
- 生产者(AP)发送半事务消息到rocketmq服务端,并且不会被消费者消费
- 生产者执行本地事务
- 发送commit或者rollback给rocketmq,前者会将半事务消息投递给消费者,后者会清除该消息
- 半事务消息超时后,rocketmq主动向生产者发起回查
二阶段消息相比于事务消息,无需MQ,仅依赖本地数据库自身事务支持,就能完成类似事务消息的功能‘
XA
XA是2PC具体实现规范,目前主流数据库都基本支持XA事务,比如mysql在XA中是RM的角色。
本地数据库支持XA:
1
2
3
4
5
6
XA start '4fPqCNTYeSG' -- 开启一个 xa 事务
UPDATE `user_account` SET `balance`=balance + 30,`update_time`='2021-06-09 11:50:42.438' WHERE user_id = '1'
XA end '4fPqCNTYeSG'
XA prepare '4fPqCNTYeSG' -- 此调用之前,连接断开,那么事务会自动回滚
-- 当所有的参与者完成了prepare,就进入第二阶段 提交
xa commit '4fPqCNTYeSG'
XA事务的特点是:
- 简单易理解
- 开发较容易,回滚之类的操作,由底层数据库自动完成
- 与2PC的缺点一样,对资源进行了长时间的锁定,并发度低,不适合高并发的业务
AT
AT 这种事务模式是阿里开源的seata主推的事务模式,与XA进行对比:
NPC问题与子事务屏障
分布式系统最大的敌人可能就是NPC了,在这里它是
- Network Delay 网络延迟:涉及到分布式就一定会涉及到网络通信,就必然会有网络通信带来的延迟
- Process Pause 进程暂停:比如gc stw、运维暂停主机
- Clock Drift 时钟漂移:受物理影响每台机器的时钟都不是精确相同的,就算通过NTP协议与时间服务器同步,也会带来网络延迟等原因
分布式事务既然是分布式的系统,自然也有NPC问题。并且除非业务上有时间戳同步的需求,否则一般不涉及时间戳,因此带来的困扰主要是NP。
以TCC为例子,看看NP带来的影响。
- 空补偿:Cancel 提前到达,此时需要忽略这个 Cancel 对应的业务数据更新并返回
- 悬挂:Try 延迟到达,此时也一样需要忽略这个 Try 对应的业务数据更新并返回
- 幂等:出现重复请求,需要保证最终结果都一样
这三种请求都属于乱序问题,其中悬挂和空补偿是成对出现的
TCC空补偿+悬挂+幂等案例:
- 处理4的空补偿
- 处理6的幂等cancel
- 处理8的悬挂
各云厂商,各开源项目,他们给出在业务层的解决方案大多类似如下:
- 空补偿:没有找到要补偿的业务主键时,返回补偿成功,并将原业务主键记录下来,标记该业务流水已补偿成功
- 防悬挂:检查当前业务主键是否已经在空补偿记录下来的业务主键中存在,如果存在则要拒绝执行该笔服务,以免造成数据不一致
两种情况的处理本质上都是“先查后改”,即根据之前的状态来决定下一步行为。但“查+改”若不小心设计成非原子操作,或者根本难以保证原子性,就容易导致 Try 和 Cancel 的“查”和“改”操作穿插执行:
- Try 查,发现没有空补偿记录的主键
- Cancel 查,发现没有要补偿的主键
- Cancel 改,标记业务补偿成功
- Try 改,由于 Cancel 已经完成,产生悬挂
DTM框架面对这三种情况(空补偿、悬挂、幂等),在框架层提出了统一解决方案,即子事务屏障。想象有一个屏障,将重复请求、悬挂请求、空补偿请求都通通过滤,相当于遇到这些请求的话不执行任何业务,只有正常请求才会执行业务逻辑:
实现的关键就在于:识别一个请求是否属于正常请求,是的话就执行相应的业务逻辑代码,否则就被拒之门外直接返回。
原理如下:在本地数据库,建立分支操作状态表dtm_barrier,唯一键为全局事务id-分支id-分支操作(try | confirm | cancel) |
- 开启本地事务
- 对于请求op,插入一条gid-branchid-op,如果插入不成功就返回,否则继续
- 如果op是cancel,那么再插入一条gid-branchid-try,如果插入成功则返回,否则继续
- 执行业务逻辑,返回执行成功与否给DTM
解决三个问题的关键点:
- 幂等:在第2步中,每个op都会对应一条唯一键,借助唯一键保证操作幂等
- 空补偿:如果 Cancel 比 Try 先到,即第3步插入成功,直接返回
- 悬挂:同样地,Cancel 比 Try 先到,Try 到来时在第2步插入失败,直接返回
场景:秒杀
问题背景:为了支持高并发,通常把库存放在Redis中,收到订单请求时,在Redis中进行库存扣减。这种的设计,导致创建订单和库存扣减不是原子操作,如果两个操作中间,遇到进程crash等问题,就会导致数据不一致。就算不用redis,而是直接更新数据库,不一致问题也通常是存在的。业务系统为了模块化,减少耦合,会将库存服务与订单服务分开。只要是分开的服务,那么数据不一致的情况就是无法避免的。
扩展:分布式锁
etcd实现方案:类似redis的setnx,通过设置键值对来表示加锁,并且etcd支持watch功能,即未成功获取锁的用户可以通过监听键值对的删除事件来得知锁被释放,然后重新尝试获取锁。但是在高并发场景下会有多个线程都监听锁释放,一旦释放会引发惊群效应,改进策略为:
- 对于同一把锁,使用锁前缀进行标识
- 对于每个想要获取锁的线程来说,设置锁的key为锁前缀+自身id标识(比如进程号+线程号)
- 在设置key时,会获得该锁前缀下的唯一自增revision
- 设置成功后还需要获取该锁前缀下的所有revision,判断自身的revision是否最小,最小的才能获取到锁,未获取到锁的线程会watch前一个revision对应的key删除事件,这样的话,当锁释放的时候就只会唤醒下一个线程,而不会全部唤醒。