目录



概述

单机事务依赖于关系型数据库非常容易就实现保证了,但是现在系统基本都是分布式的,RPC 调用等,需要保证跨网络的分布式事务一致性就没那么容易了。

本质上分布式系统中要减少耗时的事务操作,因为RT过长,事务堵塞必然导致 可用性降低,我们能做的事情就是大事务拆分成小事务, 通过消息队列延长事务到达一致的时间,再通过重试对账等手段做一致性弥补,这样的好处是通过最终一致性, 提高可用性。

所谓分布式事务说到底最终保证的是事务的最终一致性,因为分布式系统基本每个业务都有自己的数据库,所有的数据不可能放在一个库中,没法使用简单的MySQL事务保证。并且基本是调用别人封装的服务,该服务是包含了操作该库表的逻辑,所谓分布式系统就是把之前的大事务拆分成多个小事务(RPC调用)的过程,通过小事务提高服务响应RT ,进而提高稳定性,所以只能通过保证事务的最终一致性。

指导原则 BASE 理论:

基本业务可用性(Basic Availability) 允许中间状态不一致,优先保证可用性

柔性状态(Soft state)不需要马上一致强一致, 允许中间结果不一致

最终一致性(Eventual consistency) 必须要达到最终一致

真实案例分析

本文以本人真实遇到的场景进行分析。发作业场景,第一版本的发作业为了保证事务强一致,是在一个大 ORACLE 库中进行操作的, 涉及到 从教材

题库中心选题,再从花名册表查询报表状态,比如若该人已经退班了则不予发送;再从 用户中心查询用户明细,整体事务非常大。在高并发发作业场景中,这些事务操作会非常重, 进而导致非常慢,整个服务RT 逐步升高,稳定性非常低。

之前的事务逻辑类型如下: 非常简单一个注解搞定

@Transactional(rollbackFor=Exception.class) 

public void fazuoye() {

querySubject();
querybaobanxinxi();
queryPassport();
insertzuoyeDetail();
insertzuoyeStatus();

}


如果系统规模较小,数据表都在一个数据库实例上,上述本地事务方式可以很好地运行,但是如果系统规模较大,比如各个系统的库显然不会在同一个数据库实例上,他们往往分布在不同的物理节点上,这时本地事务已经失去用武之地。

常用方案

分布式事务—————— 两阶段提交协议

分布式事务解决方案_协调者

两阶段提交协议把分布式事务分成两个过程,一个是准备阶段,一个是提交阶段,准备阶段和提交阶段都是由事务管理器发起的,为了接下来讲解方便,我们把事务管理器称为协调者,把资管管理器称为参与者。

  1. 准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,参与者会写redo或者undo日志(这也是前面提起的Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交
  2. 提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源
    问题:
  3. 阻塞:从上面的描述来看,对于任何一次指令必须收到明确的响应,才会继续做下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放
  4. 单点故障:如果协调者宕机,参与者没有了协调者指挥,会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果之前协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接受,并且参与者接收后也宕机,新上任的协调者无法处理这种情况
  5. 脑裂:协调者发送提交指令,有的参与者接收到执行了事务,有的参与者没有接收到事务,就没有执行事务,多个参与者之间是不一致的

上面所有的这些问题,都是需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。

3PC

三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为三个阶段:

  1. 询问阶段:协调者询问参与者是否可以完成指令,参与者只需要回答是还是不是,而不需要做真正的操作,这个阶段超时导致中止
  2. 准备阶段:如果在询问阶段所有的参与者都返回可以执行操作,协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作,但是不提交操作;如果在询问阶段任何参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的的准备阶段是相似的,这个阶段超时导致成功
  3. 提交阶段:如果每个参与者在准备阶段返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致

分布式事务解决方案_回滚_02

特点:

  1. 增加了一个询问阶段,询问阶段可以确保尽可能早的发现无法执行操作而需要中止的行为,但是它并不能发现所有的这种行为,只会减少这种情况的发生
  2. 在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,协调者和参与者都继续提交事务,默认为成功,这也是根据概率统计上超时后默认成功的正确性最大
    缺点:
    超时之后还是会发生数据不一致, 只是这种情况比较少, 好处就是至少不会堵塞和永远锁定资源。

TCC

TCC分为3个阶段

TCC与2PC、3PC一样,也是分布式事务的一种实现方案。TCC(Try-Confirm-Cancel)又称补偿事务。其核心思想是:"针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)"。

  • Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
  • Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作要求具备幂等设计,Confirm 失败后需要进行重试。
  • Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致,要求满足幂等设计。
    通常会在Try里面冻结金额,但不扣款,Confirm里面扣款,Cancel里面解冻金额,一个成功完成的TCC事务时序图如下:
    分布式事务解决方案_分布式事务_03
    TCC特点如下:
  • 并发度较高,无长期资源锁定。
  • 开发量较大,需要提供Try/Confirm/Cancel接口。
  • 一致性较好,不会发生SAGA已扣款最后又转账失败的情况
  • TCC适用于订单类业务,对中间状态有约束的业务

总结一下,两阶段提交协议、三阶段提交协议、TCC协议都能保证分布式事务的一致性,他们保证的分布式系统的一致性从强到弱,TCC达到的目标是最终一致性,其中任何一种方法都可以不同程度的解决

二阶段、三阶段确实能解决系统间一致性问题,除了这两个协议带来的自身的问题,这些协议的实现比较复杂、成本比较高,最重要的是性能并不好,相比来看,TCC协议更简单、容易实现,但是TCC协议由于每个事务都需要执行Try,再执行Confirm,略微显得臃肿,因此,在现实的系统中,底线要求仅仅需要能达到最终一致性,而不需要实现专业的、复杂的一致性协议,实现最终一致性有一些非常有效的、简单粗暴的模式。

使用消息队列来避免分布式事务

核心原则使用消息队列把大事务拆成小事务,交易过程要做记录,甚至最终通过对账重试达到最终一致性。

类比场景 : KFC 点餐的时候 是先点餐买单,然后前台发你一张小票,然后你就下去等待唤醒,点餐的时候没有说把餐做完送到你手上才开始下一个。本质上就为了提高售卖的效率。这个也可以类比大事务拆分小事务。这个小票可以理解为交易凭证。

此处对应分布式事务场景可以把小票类比为消息,只要消息存在就是让下游业务触发一定的动作。通过这个消息达到最终一致性。

接下来的阐述以 事务 A 调用 下游事务 B 场景进行阐述。

消息队列拆分事务

比如事务A 执行完毕之后,发送消息给kafka , 事务B 再从 kafka消费 , 这是最简单的解决手段, 但是会有一些问题。目前我司发作业系统就是采取这种方案实现的,由于学生的报、转、退,和插班动作,产生了很多学生收不到作业,作业错乱的客诉。 因此结合消息队列,分段提交。 并且用定时任务扫表,做各种补偿操作,解决了大部分的客诉问题。

kafka 消息保证至少一次语义,事务B 保证了消费的幂等性,此时可能出现的问题是事务A 执行完毕之后服务挂了, 消息没有发出去就丢失了就会产生事务不一致问题,我们这边的解决方案是 事务A 操作DB 和发消息是一个事务,任何一个失败就回滚。

具体操作如下图:

分布式事务解决方案_回滚_04

缺点:

  1. 虽然事务A和 发消息是一个事务,此处事务依赖于 kafka发送消息的结果那只能变成同步发送消息,这样的话效率会很差。并且要是采用异步发送消息,则就无法拿到发送消息的结果,也就没法和业务保证事务性。最极端情况,发送kafka成功了, 但是broker节点磁盘坏了, 则消息也就丢失了,事务B 永远不会执行
    因为以上问题 出现了以下方案
  2. 远程调用,结果最终可能为成功、失败、超时;而对于超时的情况,处理方最终的结果可能是成功,也可能是失败,调用方是无法知晓的;比如 发送消息队列,数据已经落盘了, 但是由于网络原因ACK失败,导致事务A 回滚还是发生了不一致

业务与消息耦合的方式(本地消息表(异步确保))

具体架构图如下:

分布式事务解决方案_分布式事务_05

引用常用的电商场景下单成功之后需要通知配送系统配送:

分布式事务解决方案_段提交协议_06

实例如下:消息表和业务数据库在一个库中,保证事务一致性;下述只要A 被操作成功则 message 表中一定会被插入记录; 该事务处理完毕之后再发送消息给下游,下游处理完毕之后通知A 事务,A事务再将 message 删除即可。同时会有周期性任务对 message进行扫描对超时没有回执的任务进行补偿重试。

优点:非常经典分布式事务模型,避免了分布式事务,实现了最终一致性,并且保证了事务A + 发送消息必须全部成功或者失败

缺点: 消息表和业务表耦合在一块,适合业务不是很复杂的场景;试想若业务很复杂,甚至后续涉及到迁移库,则该 message 表也需要变更,会比较麻烦耦合性太强。

此处详情案例见:

​https://mp.weixin.qq.com/s/aFxCsAMjzGLROsw9MXN9EQ​

特点:

  • 长事务仅需要分拆成多个任务,使用简单
  • 生产者需要额外的创建消息表
  • 每个本地消息表都需要进行轮询
  • 消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作
  • 适用于可异步执行的业务,且后续操作无需回滚的业务
Begin transaction 
update A set amount=amount-10000 where userId=1;
insert into message(userId, amount,status) values(1, 10000, 1);
End transaction
commit;


此处可以把本地表封装成一个查询服务,来向外部输出操作执行的状态; 下游服务不断询问该操作的状态, 根据不同的状态执行不同的操作

业务与消息解耦合的方式

该方案就是上述方案的改进版本,以上方案的缺点是每个业务处理都需要单独搞个消息表;所以通过以下方案把消息存储搞成一个单独的服务。

分布式事务解决方案_回滚_07

事务消息

1)支付宝在扣款事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不真正发送,只有消息发送成功后才会提交事务;

2)当支付宝扣款事务被提交成功后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才真正发送该消息;

3)当支付宝扣款事务提交失败回滚后,向实时消息服务取消发送。在得到取消发送指令后,该消息将不会被发送;

4)对于那些未确认的消息或者取消的消息,需要有一个消息状态确认系统定时去支付宝系统查询这个消息的状态并进行更新。为什么需要这一步骤,举个例子:假设在第2步支付宝扣款事务被成功提交后,系统挂了,此时消息状态并未被更新为“确认发送”,从而导致消息不能被发送。

优点: 消息数据独立存储, 降低业务系统和消息系统的耦合, 实现了最终一致性,不需要依赖本地数据库事务。

缺点:实现难度大,主流MQ不支持,RocketMQ事务消息部分代码也未开源。

那么如何解决消息重复投递的问题?

业务表存储该唯一事务ID,去重表; 也就是每次搞之前先查询排重表是否存在, 若存在就是重复消息。否则就去执行真实业务逻辑,并插入排重表。

for each msg in queue 

Begin transaction

select count(*) as cnt from message_apply where msg_id=msg.msg_id;

if cnt==0 then

update B set amount=amount+10000 where userId=1;

insert into message_apply(msg_id) values(msg.msg_id);

End transaction

commit;


简单的单机事务由于交涉方在一个DB上,所以事务时间都比较短,当涉及到跨服务的RPC 调用时,请求耗时会成倍增长。那如何解决呢 ?

大事务 = 小事务 + 异步

将大事务拆分成多个小事务异步执行,就基本能保证整体效率和单机事务效率无异, 整体流程如下图所示:

分布式事务解决方案_分布式事务_08

case 1 : 到底是先发消息 还是 先操作事务呢 ?

至于到底是发消息 和 事务A 的执行顺序,若先执行事务A ,但是A 回滚了, 消息就没法发出去;若先发消息,事务B执行了, 但是事务A 执行失败回滚了, 总之会发生各种不一致的问题。此处比较好的解决方案是 事务A 和 发送消息放在一个事务中,保证必须都得成功。但是接下来介绍更好的解决方案 :rocketMQ事务消息 。

其中整体架构图如下:

分布式事务解决方案_分布式事务_09

rocketMQ 支持事务消息 具体是咋实现的呢 ?

  1. 第一阶段 事务A 向rocketMQ 发送 Prepared消息时,会拿到消息的地址
  2. 第二阶段执行本地事务
  3. 第三阶段根据第一阶段拿到的地址去访问消息, 修改消息的状态
    此处若第一阶段失败了,则事务根本不会发生没啥影响。
    第二阶段失败了,事务会回滚并且修改消息状态为回滚, 事务B 也不会执行,整体没啥影响
    第三阶段若失败了, 才是需要考虑的问题,确认消息由于网络原因没有通知到, 那就会产生事务不一致, 实际上 rocketMQ 没有这个问题, RocketMQ会定期扫描消息集群中的事物消息,如果发现了Prepared消息,它会向消息发送端(生产者)确认,事务A 到底执行成功了没?再决定发送确认消息 还是直接回滚。

broker会有回查线程定时(默认1分钟)扫描每个存储事务状态的表格文件,如果是已经提交或者回滚的消息直接跳过,如果是prepared状态则会向Producer发起CheckTransaction请求,Producer会调用DefaultMQProducerImpl.checkTransactionState()方法来处理broker的定时回调请求,而checkTransactionState会调用我们的事务设置的决断方法来决定是回滚事务还是继续执行,最后调用endTransactionOneway让broker来更新消息的最终状态。

  1. 实现TransactionListener接口,实现executeLocalTransaction本地事务执行接口和checkLocalTransaction本地事务执行状态回查接口
public class TransactionListenerImpl implements TransactionListener {
private AtomicInteger transactionIndex = new AtomicInteger(0);
//本地事务执行状态的简单模拟,可自己存到实际的存储介质中
private ConcurrentHashMap<String, Integer> localTrans = new ConcurrentHashMap<>();

@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
int value = transactionIndex.getAndIncrement();
int status = value % 3;
localTrans.put(msg.getTransactionId(), status);
return LocalTransactionState.UNKNOW;
}

@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
Integer status = localTrans.get(msg.getTransactionId());
if (null != status) {
switch (status) {
case 0:
return LocalTransactionState.UNKNOW;
case 1:
return LocalTransactionState.COMMIT_MESSAGE;
case 2:
return LocalTransactionState.ROLLBACK_MESSAGE;
default:
return LocalTransactionState.COMMIT_MESSAGE;
}
}
return LocalTransactionState.COMMIT_MESSAGE;
}
}


  1. TransactionProducer接口发送事务消息,这里Producer设置TransactionListener和executorService,这里的线程池主要是用来执行回调任务线程的
public class TransactionProducer {
public static void main(String[] args) throws MQClientException, InterruptedException {
TransactionListener transactionListener = new TransactionListenerImpl();
TransactionMQProducer producer = new TransactionMQProducer("please_rename_unique_group_name");
ExecutorService executorService = new ThreadPoolExecutor(2, 5, 100, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2000), new ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r);
thread.setName("client-transaction-msg-check-thread");
return thread;
}
});

producer.setExecutorService(executorService);
producer.setTransactionListener(transactionListener);
producer.start();

String[] tags = new String[] {"TagA", "TagB", "TagC", "TagD", "TagE"};
for (int i = 0; i < 10; i++) {
try {
Message msg =
new Message("TopicTest1234", tags[i % tags.length], "KEY" + i,
("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
SendResult sendResult = producer.sendMessageInTransaction(msg, null);
System.out.printf("%s%n", sendResult);

Thread.sleep(10);
} catch (MQClientException | UnsupportedEncodingException e) {
e.printStackTrace();
}
}
}
}


分布式事务解决方案_分布式事务_10

case 2 : 事务B 消费消息问题 ?

涉及到消费失败 或者 消费超时

若是消费超时,则只需要不断重试即可,至于消费重复问题,上面已经介绍了

若是消费失败, 可以如下处理:

  1. 比如B系统本地回滚后,想办法通知系统A也回滚,但是此块很重,不建议
  2. 异常数据记录下来,发送报警由人工来手工回滚和补偿

最大努力通知

最大努力通知型( Best-effort delivery)是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果不影响主动方的处理结果。典型的使用场景:如银行通知、商户通知等。

事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。

最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。

Seata介绍

总结

  • 如果钱是问题,可以对廉价硬件运行的开源关系型数据库(例如:Mysql)进行分片,将相关的数据分到数据库的同一个片,仍然能够使用关系型数据库保证事务; 比如按照商户ID 分片,则对于同一个商户的 事务是可以使用数据库事务满足的。
  • 如果业务规则限制,无法将相关的数据分到同一个片,就需要实现最终一致性,通过记录事务的软状态(中间状态、临时状态),一旦处于不一致,可以通过系统自动化或者人工干预来修复不一致的情况;通过记录事件日志表,存储软状态,再通过扫描软状态表达到最终一致性的效果。