分布式事务

48 阅读16分钟

分布式事务

1、分布式事务的由来

分布式事务,因为微服务的普及,一个服务会涉及多个库或者多个系统,而数据库的事务只能作用于其自身,跨数据库则无能为力。以订单支付完成这个动作为例 image.png 订单支付完成至少需要做三步动作1、扣减库存 2、增加用户积分 3、更新订单状态,但是这部操作分别在不同的系统中,必然也在不同的数据库中,此时数据库本身的事务已经无法起作用,这就是分布-式事务的缘由。

2、分布式事务的理论依据

2.1、CAP理论

  • Consistency(一致性)
    对于分布式而言,一个节点发生了数据变更,其他节点也都能读取到这个最新的数据,强一致性。
  • Availability(可用性)
    对于非故障节点的请求,总能在合理的时间内返回合理的结果。换而言之,在合理的响应时间内返回正确的结果。
  • Partition Tolerance(分区容错性)
    通俗讲就是,分布式部署的多个节点分属不同的网络分区,如果只是其中一个分区出现问题,整个集群仍可对外工作。
    CAP不可同时共存,只能尽量满足两点。只能选择CP或者AP
    CP: 追求强一致性和容灾,放弃可用性,例如:zookeeper
    AP: 放弃强一致性,保证可用和容灾,大部分分布式设计都是如此,放弃一致性,并非不要一致性,而是改为弱一致性或者最终一致性,允许短时间内各个节点数据不同,但是隔一段时间必须要保证个节点数据一致。

为什么分布式不能选择CA? 如果选择了CA,放弃了P,也就是等于放弃了分区,多节点,那这还是分布式吗?

2.2、BASE理论

  • BA Basically Available(基本可用)
    分布式系统发生故障时,允许放弃部分功能,保证核心功能可用
  • S Soft state(软状态)
    分布式各节点之间进行数据同步的过程允许存在延迟,相当于可以存在一个中间态,但是不能影响整个系统的可用性
  • E Eventually Consistent(最终一致性)
    经过一段时间内,各节点数据会保持一致

3、分布式事务的常见方案

3.1、两阶段提交(2PC)

两阶段提交顾名思义:分为两个阶段,阶段一:准备;阶段二:提交 image.png

image.png 上诉分别为两阶段提交的正常事务提交以及异常回滚流程。(红色线条为阶段一操作,黑色线条为阶段二操作)

3.1.1、准备阶段

  1. 事务管理器向各个事务参与者发送请求,询问各参与者是否具备完成事务的条件;
  2. 如果各事务参与者判断可以参与事务,则开启自己本地事务,执行事务动作,但是不提交事务\color{red}{但是不提交事务}
  3. 各事务参与者向事务管理器返回询问结果,反馈同意或者拒绝。

3.1.2、提交阶段

提交阶段分为两种情况:a、事务提交 b、事务回滚

  • a、事务提交-一阶段各事务参与者均反馈OK
  1. 事务管理器向各事务参与者发送“commit”请求
  2. 事务参与者收到commit请求,提交本地事务并释放所占资源(锁)
  3. 事务参与者向事务管理器发送“commit 完成”结果
  4. 事务管理器收到所有事务参与者的commit结果后,结束事务
  • b、事务回滚-一阶段任一事务参与者反馈NO
  1. 事务管理器向各事务参与者发送“rollback”请求
  2. 事务参与者收到“rollback”请求,回滚本地事务并释放所占资源(锁)
  3. 事务参与者向事务管理器发送“rollback”结果
  4. 事务管理器收到所有事务参与者的rollback结果后,结束事务

3.1.3、2PC的缺点

  1. 同步阻塞问题
    因为事务参与者,在一阶段之后,需要阻塞等待事务管理器的commit或者rollback通知,这个过程中如果锁定了部分公共资源对于整个业务而言,是很大的性能损耗
  2. 事务管理器(事务协调者)宕机导致事务参与者阻塞
    事务参与者需要等待事务管理器的commit或者rollback通知,才能参与二阶段的本地事务操作,如果此时事务管理器挂了,那么事务参与者只能在那阻塞等待,不知道干啥
  3. 数据不一致
    问题还是出在事务管理器向各个事务参与者发送通知的过程中,例如事务管理器发出commit请求后,此时部分事务参与者收到了commit,部分事务参与者未收到请求,这时候事务管理器和事务参与者都宕机了,重新恢复之后,极大可能出现整个事务中部分commit,部分没有,整体而言,发生数据不一致现象。

3.2、三阶段提交(3PC)

基于上诉2PC的缺点,提出了3PC,可以理解为2PC的改进版

  • 在事务管理器和事务参与者中引入超时机制,避免长时间等待带来的阻塞
  • 将阶段一细分成两部分,形成的三阶段依次为canCommit、preCommit、doCommit image.png 上诉为3PC的流程示意图

3.2.1、canCommit阶段

  1. 事务管理器(协调者)向各事务参与者发送canCommit请求
  2. 事务参与者在收到协调者发出的canCommit请求,做预检查,看是否可以参与本次事务,例如交易环节中的可用库存检查等等,
  3. 事务参与者将预检查的结果返回给事务管理器
  4. 事务管理器在拿到所有事务参与者的OK反馈,则进入下一阶段preCommit;如果某一事务参与者反馈NO或者在规定的超时时间内没有收到反馈,均发起中断事务操作(此时尚未开始事务操作,无需触发回滚)

3.2.2、preCommit阶段

  1. 事务管理器(协调者)向各事务参与者发送preCommit请求
  2. 事务参与者在收到事务管理器发出的preCommit请求后,开启各自本地事务,执行事务SQL语句,但不提交, 同时阻塞等待事务管理器的下一步动作
  3. 事务参与者向事务管理器反馈事务执行结果
  4. 事务管理器如果拿到所有事务参与者的OK反馈,则进入下一阶段doCommit;如果某一事务参与者反馈NO,或者在规定超时时间内没有收到反馈,均发起回滚中断事务

3.3.3、doCommit阶段

  1. 事务管理器(协调者)向各事务参与者发送doCommit请求
  2. 事务参与者在收到事务管理器的提交请求则提交本地事务(如果事务参与者迟迟收不到事务管理器发出的doCommit请求,在超过设置超时时间后,同样会提交本地事务)
  3. 事务参与者向事务管理器反馈事务提交结果
  4. 事务管理器如果拿到所有事务参与者的OK反馈,则完成并结束事务。则触发回滚

3.3.4、3PC的改进点及缺点

相较于2PC,3PC主要改进的是上诉2PC缺点的第一点同步阻塞问题 和 第二点事务管理器宕机导致事务参与者阻塞,具体而言:

  1. 2PC的同步阻塞 3PC将2PC的准备阶段细分为canCommit,preCommit两阶段,相当于把降低了锁粒度,优化2PC一阶段的同步阻塞问题
  2. 事务管理器宕机导致的事务参与者阻塞 3PC通过引入了超时机制来解决这一问题,如果超过超时时间仍未收到事务管理器的doCommit请求,事务参与者会自行继续处理提交事务,避免继续阻塞;
    依旧存在的问题: 数据不一致问题
    例如:在preCommit之后,判断事务管理器需要做回滚通知,但是在通知部分事务参与者后,宕机了,剩下的事务参与者在超时时间之后依旧没有收到通知,就自行决定提交事务,此时就会造成数据不一致的问题。

上诉2PC、3PC,可以认为是同一理论,而且只是仅仅是一种思想,实际的实现可能与其有部分出入。可以参见4、分布式事务开源框架的具体实现设计

3.3、本地消息表

3.3.1、整体介绍

以订单支付完成,扣减商品库存,增加会员积分为例,具体业务三步:

  1. 支付完成更改订单状态为支付完成
  2. 调用库存系统,扣减商品库存
  3. 调用积分系统,增加会员积分 image.png 本地消息表的整体流程如上图所示, 本地消息表的设计思路比较简单
    ①、事务发起者(订单系统)调用其他事务参与者(库存、积分系统)服务前先写本地消息表,并标记状态待发送;
    ②、MQ投递消息给对应事务参与者,事务参与者处理完成后,再通过MQ通知事务发起者结果,变更本地消息状态为已处理;(个人觉得这个过程也不一定非得通过MQ,RPC接口也可以)
    ③、事务发起者收到事务参与者得执行结果,再继续下一步动作,结束整个事务或者回滚
    ④、事务发起者将自身业务表处理和本地消息表处理至于同一个本地事务下,而同时本地消息又相当于事务参与者在本系统内的一个类似于代理人角色,从而将整个过程至于一个统一大事务之中,保证数据一致性。
    容错处理:
    上诉过程中,如果事务参与者没有收到消息或者事务发起者没有收到回执响应,那么都会通过定时任务定期补偿重新投递消息,直至收到对应的回执响应为止。
    如果超出最大重试次数,则根据具体业务选择人工介入还是调用各自参与者的回滚接口,进行业务回滚。

3.3.2、总结

  1. 基于数据的最终一致性原则,最大的优点就是简单易实现
  2. 另外需要注意的是,作为事务参与者提供的接口,因为后续会发生重试,需要支持幂等
  3. 弊端也很明显,不具备通用性,且本地消息表与业务表共用一个库,可能会拉低业务库的性能
  4. 本地消息表适用于对于数据一致性及时性要求不高的业务场景,如果对于数据一致性要求比较实时,则不太适用,因为如果发生网络抖动或者别的异常场景导致需要触发消息重发,必然会降低数据一致性的实时性。

个人觉得如果不介意短时间内的数据不一致,本地消息表还是比较好,容易理解实施的成本也小

3.4、MQ事务

3.5、TCC(事务补偿)

image.png 借用下seata的图及表述:
TCC模式,不依赖底层数据资源的事务支持:

  • 一阶段prepare行为:调用自定义的prepare逻辑
  • 二阶段commit行为:调用自定义的commit逻辑
  • 二阶段rollback行为:调用自定义的rollback逻辑

所谓TCC模式,是指把自定义的分支事务纳入到全局事务的管理中,将prepare、commit、rollback行为替换为自定义的逻辑

3.6、saga事务

3.6.1、定义

所谓saga事务,就是将一个长事务,切分成一个个独立的本地短事务,即每个saga事务均由一系列的sub transaction Ti组成,且每个Ti均有对应的补偿动作Ci, 用于saga事务中发生异常后撤回Ti操作。 image.png 如上图所示,正常执行的情况下该saga事务,会依次执行T1->T2->T3->……->Tn, 但是在执行到T3时发生异常,之后依次调用C3->C2->C1完成回滚。

4、分布式事务的开源框架

4.1、LCN

4.1.1、LCN

image.png LCN整体时序图如上,可以发现整个LCN的设计是基于2PC思想

4.1.2、TCC

image.png TCC整体时序图如上,可以同样基于2PC思想

综上可以发现,其实LCN和TCC的实现整体思想基本一致,唯一的差别在于

  1. LCN作用于数据库连接这层,通过对connection的代理,对于commit、rollback做假提交和假回滚处理,然后由事务管理器的通知再做真正的commit或者rollback处理;
  2. TCC则是作用于业务自定义的try、confirm、cancel三个方法,通过2PC的思想纳入一个整体事务的管理,可以理解为一个更为业务性质更加宽泛的事务管理。

4.2、Seata

4.2.1、AT

4.2.1.1、AT原理

AT原理于LCN原理类似,可以参见 4.1.1。

两者实现差异:

  1. LCN在分支事务的connect上对于commit和rollback做了假提交/回滚处理,只有在tc接收到全局事务提交/回滚时,再通知各个具体分支事务做真正的commit or rollback处理;
  2. seata AT模式 在分支事务事务上会直接提交本地事务,然后会单独记录一份undo-log(此处并不是mysql的undo-log,理解简单理解为将DML操作的每个字段的变更记录)到undo-log表中,如果发生全局事务回滚时,各个分支事务在接到通知之后会利用这份undo-log进行回滚操作;
4.2.1.2、AT 源码分析

image.png 回滚的流程大同小异

4.2.1.3、部分核心代码

AbstractDMLBaseExecutor

protected T executeAutoCommitTrue(Object[] args) throws Throwable {
    ConnectionProxy connectionProxy = statementProxy.getConnectionProxy();
    try {
        connectionProxy.changeAutoCommit();// 改为手动提交事务
        return new LockRetryPolicy(connectionProxy).execute(() -> {
                T result = executeAutoCommitFalse(args);
                connectionProxy.commit();
                return result;
        });
    } catch (Exception e) {
        // 如果发生异常,该分支事务直接回滚,继续向上抛异常,直到被TransactionalTemplate
        // 捕获发送全局事务回滚通知,TC收到通知后,再通知其他各个分支事务做回滚处理
        if (!LockRetryPolicy.isLockRetryPolicyBranchRollbackOnConflict()) {
            connectionProxy.getTargetConnection().rollback();
        }
        throw e;
    } finally {
        connectionProxy.getContext().reset();
        connectionProxy.setAutoCommit(true);
    }
}

protected T executeAutoCommitFalse(Object[] args) throws Exception {
    try {
        // 记录db中相关记录变更前的快照
        TableRecords beforeImage = beforeImage();
        // 这步执行mysql的更新操作,但是此处并不提交本地事务
        T result = statementCallback.execute(statementProxy.getTargetStatement(), args);
        // 记录db中相关记录变更之后的快照
        TableRecords afterImage = afterImage(beforeImage);
        // 准备AT的undo-log
        prepareUndoLog(beforeImage, afterImage);
        return result;
    } catch (TableMetaException e) {
        statementProxy.getConnectionProxy().getDataSourceProxy().tableMetaRefreshEvent();
            throw e;
    }
}

ConnectionProxy

private void processGlobalTransactionCommit() throws SQLException {
    try {
        // 分支事务注册到TC
        register();
    } catch (TransactionException e) {
        recognizeLockKeyConflictException(e, context.buildLockKeys());
    }
    try {
        // 将之前准备好的undo-log插入到undo-log表中    
        UndoLogManagerFactory.getUndoLogManager(this.getDbType()).flushUndoLogs(this);
        // 提交本地事务
        targetConnection.commit();
    } catch (Throwable ex) {
        report(false);
        throw new SQLException(ex);
    }
    if (IS_REPORT_SUCCESS_ENABLE) {
        // 上报分支事务状态
        report(true);
    }
    context.reset();
}

利用undo-log做回滚操作 DataSourceManager

public BranchStatus branchRollback(BranchType branchType, String xid, long branchId, String resourceId,
                                       String applicationData) throws TransactionException {
    DataSourceProxy dataSourceProxy = get(resourceId);
    ……
    try {
    // 收到分支事务回滚通知,做回滚操作
        UndoLogManagerFactory.getUndoLogManager(dataSourceProxy.getDbType()).undo(dataSourceProxy, xid, branchId);
        ……
    } catch (TransactionException te) {
        ……
    }
    return BranchStatus.PhaseTwo_Rollbacked;

}

4.2.2、TCC

4.2.2.1、seata-tcc 代码示例
public interface FirstAction {
    // 一阶段try
    @TwoPhaseBusinessAction(name="first", commitMethod="commit", rollbackMethod="")
    boolean firstTry(BusinessActionContext businessActionContext, @BusinessActionParameter("goodsCode") String goodsCode);
    // 二阶段提交
    boolean commit(BusinessActionContext businessActionContext);
    // 二阶段回滚
    boolean rollback(BusinessActionContext businessActionContext);
}

public interface SecondAction {
    // 一阶段try
    @TwoPhaseBusinessAction(name="second", commitMethod="commit", rollbackMethod="")
    boolean secondTry(BusinessActionContext businessActionContext, @BusinessActionParameter("goodsCode") String goodsCode);
    // 二阶段提交
    boolean commit(BusinessActionContext businessActionContext);
    // 二阶段回滚
    boolean rollback(BusinessActionContext businessActionContext);
}

public class BusinessService {
    // 开启全局事务
    @GlobalTransactional
    public boolean doBusiness(String goodsCode) {
        FirstAction.firstTry(null, goodsCode);
        SecondAction.secondTry(null, goodsCode);
        // 剩余业务处理
        do something;
        return true;
    }
}
4.2.2.2、seata tcc原理

与LCN的原理大体相同,参见4.1.2时序图

4.2.2.3、seata tcc源码分析

image.png image.png

4.2.3、Saga

seata的saga事务模式,一句话总结就是通过状态机引擎串联预先定义各个独立短事务节点,每个节点都有回滚的方法,状态机引擎会驱动各个节点依次执行,如果出现异常,沿着定义的回滚方法依次回滚。

4.2.3.1、saga json格式DSL
{{
  "Name": "reduceInventoryAndBalance",
  "Comment": "reduce inventory then reduce balance in a transaction",
  "StartState": "ReduceInventory",
  "Version": "0.0.1",
  "States": {
    "ReduceInventory": {
      "Type": "ServiceTask",
      "ServiceName": "inventoryAction",
      "ServiceMethod": "reduce",
      "CompensateState": "CompensateReduceInventory",
      "Next": "ChoiceState",
      "Input": [
        "$.[businessKey]",
        "$.[count]"
      ],
      "Output": {
        "reduceInventoryResult": "$.#root"
      },
      "Status": {
        "#root == true": "SU",
        "#root == false": "FA",
        "$Exception{java.lang.Throwable}": "UN"
      }
    },
    "ChoiceState": {
      "Type": "Choice",
      "Choices": [
        {
          "Expression": "[reduceInventoryResult] == true",
          "Next": "ReduceBalance"
        }
      ],
      "Default": "Fail"
    },
    "ReduceBalance": {
      "Type": "ServiceTask",
      "ServiceName": "balanceAction",
      "ServiceMethod": "reduce",
      "CompensateState": "CompensateReduceBalance",
      "Input": [
        "$.[businessKey]",
        "$.[amount]",
        {
          "throwException": "$.[mockReduceBalanceFail]"
        }
      ],
      "Output": {
        "compensateReduceBalanceResult": "$.#root"
      },
      "Status": {
        "#root == true": "SU",
        "#root == false": "FA",
        "$Exception{java.lang.Throwable}": "UN"
      },
      "Catch": [
        {
          "Exceptions": [
            "java.lang.Throwable"
          ],
          "Next": "CompensationTrigger"
        }
      ],
      "Next": "Succeed"
    },
    "CompensateReduceInventory": {
      "Type": "ServiceTask",
      "ServiceName": "inventoryAction",
      "ServiceMethod": "compensateReduce",
      "Input": [
        "$.[businessKey]"
      ]
    },
    "CompensateReduceBalance": {
      "Type": "ServiceTask",
      "ServiceName": "balanceAction",
      "ServiceMethod": "compensateReduce",
      "Input": [
        "$.[businessKey]"
      ]
    },
    "CompensationTrigger": {
      "Type": "CompensationTrigger",
      "Next": "Fail"
    },
    "Succeed": {
      "Type": "Succeed"
    },
    "Fail": {
      "Type": "Fail",
      "ErrorCode": "PURCHASE_FAILED",
      "Message": "purchase failed"
    }
  }
}

seata demo中示例json

4.2.3.2、saga 正常执行源码分析

image.png

4.2.3.3、saga 异常回滚源码分析

image.png

4.2.3.4、部分核心代码

状态机引擎
seata saga模式下,状态图DSL各个节点的驱动都是通过事件来驱动,以非异步事件为例,设计比较巧妙,DirectEventBus 截取部分核心代码

public boolean offer(ProcessContext context) throws FramworkException {
    // 获取订阅事件的所有handler, saga模式就一个ProcessCtrlEventConsumer
    List<EventConsumer> eventHandlers = getEventConsumers(context.getClass());
    ……
    // 初始化stack
    boolean isFirstEvent = false;
    Stack<ProcessContext> currentStack = (Stack<ProcessContext>)context.getVariable(VAR_NAME_SYNC_EXE_STACK);
    if (currentStack == null) {
       synchronized (context) {
           currentStack = (Stack<ProcessContext>)context.getVariable(VAR_NAME_SYNC_EXE_STACK);
           if (currentStack == null) {
               currentStack = new Stack<>();
               context.setVariable(VAR_NAME_SYNC_EXE_STACK, currentStack);
               isFirstEvent = true;
           }
       }
    }
    // 将本次的上下文加入到stack
    currentStack.push(context);
    if (isFirstEvent) {
	try {
            while (currentStack.size() > 0) {
                // 取出stack中的上下文
                ProcessContext currentContext = currentStack.pop();
                for (EventConsumer eventHandler : eventHandlers) {
                    /*
                     * 这步会不停的再递归offer方法,不断的将context加入到stack中,确保
                     * while循环再业务结束之前,可以不断地运转下去,这样就实现了状态机
                     * 引擎可以驱动整个节点依次执行下去
                     */
                    eventHandler.process(currentContext);
                }
            }
	} finally {
            context.removeVariable(VAR_NAME_SYNC_EXE_STACK);
	}
    }
    return true;
}

发生异常状态机引擎切换回滚链路
CompensationTriggerStateHandler

public void process(ProcessContext context) throws EngineExecutionException {
    ……
    /*
     * 此处取得是之前执行的ServiceTask节点
     * 在ServiceTaskHandlerInterceptor#preProcess中塞的值
     * stateMachineInstance.putStateInstance(stateInstance.getId(), stateInstance);
     */
    List<StateInstance> stateInstanceList = stateMachineInstance.getStateList();
    if (CollectionUtils.isEmpty(stateInstanceList)) {
        stateInstanceList = stateMachineConfig.getStateLogStore().queryStateInstanceListByMachineInstanceId(
		stateMachineInstance.getId());
    }

    List<StateInstance> stateListToBeCompensated = CompensationHolder.findStateInstListToBeCompensated(context,
	stateInstanceList);
    if (CollectionUtils.isNotEmpty(stateListToBeCompensated)) {
	Exception e = (Exception)context.removeVariable(DomainConstants.VAR_NAME_CURRENT_EXCEPTION);
	if (e != null) {
            stateMachineInstance.setException(e);
	}
        // 将之前执行的任务节点加入到stack中,便于后续倒序执行回滚操作
	Stack<StateInstance> stateStackToBeCompensated = CompensationHolder.getCurrent(context, true)
		.getStateStackNeedCompensation();
	stateStackToBeCompensated.addAll(stateListToBeCompensated);

	if (stateMachineInstance.getStatus() == null || ExecutionStatus.RU.equals(
		stateMachineInstance.getStatus())) {
            stateMachineInstance.setStatus(ExecutionStatus.UN);
	}
	stateMachineInstance.setCompensationStatus(ExecutionStatus.RU);
        // 这句很重要,状态机执行回滚都依赖这个变量
	context.setVariable(DomainConstants.VAR_NAME_CURRENT_COMPEN_TRIGGER_STATE, instruction.getState(context));
    } else {
	EngineUtils.endStateMachine(context);
    }
}

TaskStateRouter

public Instruction route(ProcessContext context, State state) throws EngineExecutionException {
    ……
    // 这步就是取上面设置的VAR_NAME_CURRENT_COMPEN_TRIGGER_STATE的值,有值就找回滚节点
    State compensationTriggerState = (State)context.getVariable(
	DomainConstants.VAR_NAME_CURRENT_COMPEN_TRIGGER_STATE);
    if (compensationTriggerState != null) {
	return compensateRoute(context, compensationTriggerState);
    }
    // 这步就是发生异常切换至CompensationTriggerStateHandler的关键
    // 赋值在ServiceTaskStateHandler#process中catch代码块中的
    // EngineUtils.handleException(context, state, e);
    /*
     * 对应 json对应中的catch节点
     * "Catch": [
	{
	  "Exceptions": [
		"java.lang.Throwable"
	  ],
	  "Next": "CompensationTrigger"
	}
      ],
     */ 
    String next = (String)context.getVariable(DomainConstants.VAR_NAME_CURRENT_EXCEPTION_ROUTE);
    ……
}