分布式事务

一、事务相关概念

事务(Transaction)是一组操作的集合,它们被视为一个不可分割的单元,要么全部执行,要么全部不执行。事务通常用于确保数据库中的数据保持一致性和完整性。

  • AICD :ACID是事务的四个关键属性,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这些属性确保了事务的可靠性和稳定性,保证了数据在并发操作中的正确性。

    • 原子性:原子性要求事务是不可分割的操作单元,要么全部成功执行,要么全部失败回滚
    • 一致性:一致性要求事务在执行前后,数据库的状态应该保持一致。
    • 隔离性:定义了多个并发事务之间的互相隔离程度,一个事务的操作不会影响其他事务的执行。
    • 持久性:要求一旦事务成功提交,其所做的修改将被永久保存在数据库中,即使发生系统故障。
  • BASE: 是分布式系统中的一种理论,它与传统的 ACID(原子性、一致性、隔离性、持久性)事务模型形成对比。BASE 是一个缩写,代表以下三个概念:Basically Available(基本可用),Soft state(软状态),Eventually Consistent(最终一致性)

  • CAP:一致性(Consistency),可用性(Availability),分区容忍性(Partition Tolerance)不能同时兼顾;

  • 事务隔离级别: 事务管理器支持不同的事务隔离级别,用于定义多个并发事务之间的隔离程度。隔离级别包括READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ和SERIALIZABLE等。

  • 事务传播行为: 事务传播行为定义了嵌套方法之间如何共享事务。常见的传播行为包括:REQUIRED、REQUIRES_NEW、NESTED等。不同的传播行为决定了方法调用链中的事务行为。

二、本地事务

在介绍分布式事务前,我们先看本地事务的编写和实现方式。当然,前提是我们使用了支持事务的关系型数据库如mysql;

手动编写JDBC事务


public class JdbcTransactionExample {
    public static void main(String[] args) {
        try (Connection connection = DriverManager.getConnection(url, username, password)) {
	        // Disable auto-commit
            connection.setAutoCommit(false); 
            Statement statement = connection.createStatement();
            try {
                // execute some sql 
                // Commit the transaction
                connection.commit(); 
            } catch (SQLException e) {
                connection.rollback(); 
            }
        } catch (SQLException e) {
            // ignore
        }
    }
}

在spring下的声明式事务

<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> 
	<property name="dataSource" ref="dataSource" />
</bean>
@Transactional
public void transferAmount(){
	// 方法里面的逻辑是一个事务
}

spring事务管理的核心是AOP(面向切面编程)和代理模式。Spring通过AOP技术,在方法执行前后插入事务管理相关的逻辑,以实现声明式事务。

Spring事务管理使得开发人员能够将关注点从事务管理的细节中解放出来,专注于业务逻辑的实现。它提供了更高层次的抽象,简化了事务的控制,提高了代码的可维护性和可读性。

三、分布式事务

3.1 1PC

1pc就是最普通的commit/rollback模式。比如在本地连接多个数据源时,使用多数据源事务,统一提交或者回滚。比如这两个框架实现的跨库本地事务

  • taobao-pamirs-transaction
  • Spring的ChainedTransactionManager

缺点:一致性会有问题;

优点:在跨库“本地事务”等场景下,特别简单,性能好。

3.2 2PC

两阶段提交(Two-Phase Commit,2PC)是一种用于管理分布式环境中事务的协议。2PC旨在确保所有参与者(Participants)在分布式事务中要么全部提交,要么全部回滚,以保证数据的一致性。两阶段:准备阶段,提交阶段。

  1. 准备阶段:协调者向所有参与者发送准备请求,询问它们是否可以执行事务并准备好提交。协调者在收集所有参与者的响应后,结束准备阶段。
  2. 提交阶段:协调者根据准备阶段收集到的响应决定是提交还是回滚事务。如果所有参与者都准备好提交,协调者会向它们发送提交请求;如果有任何一个参与者无法提交,协调者会发送回滚请求,要求它们回滚事务。

优点:

  • 数据一致性:2PC确保在分布式环境中的数据一致性,要么所有参与者成功提交,要么全部回滚,杜绝了数据不一致的情况。
  • 可靠性:通过协调者的控制,2PC可以应对参与者崩溃和网络故障等情况,确保了事务的可靠性。

下面举例分析(db是支持aicd关系型数据库): 准备阶段:第一阶段很好理解,让参与者处理对应逻辑和事务,并告诉协调者结果; 提交阶段:

  1. 都成功:让所有参与者提交。
  2. 有一个参与者失败或者应答超时:都回滚。

从图可以看到,2pc的缺点:

  • 性能开销:由于多次网络通信和等待,2PC会引入较大的性能开销,可能导致事务延迟。
  • 阻塞问题:在提交阶段,如果一个参与者无法响应或发生故障,整个协议可能会阻塞,影响其他事务的执行。
  • 单点故障:协调者是2PC协议的中心,如果协调者故障,可能会影响整个协议的执行。
  • 数据不一致问题:
    • 提交后,部分参与者提交事务,部分参与者网络等问题无法提交。需要协调者一直重试。但如果db事务超时回滚,就没办法了。
    • 提交后,某个commit发出去的同时,协调者挂了。恢复后,协调者并不知道提交是否成功。

因此,在选择2PC协议时,需要权衡其优缺点,并根据应用的需求和性能要求进行决策。在一些场景中,可能会考虑使用其他分布式事务管理机制,如事务1PC或更高级别的分布式事务协议。不过大部分事务都是2pc,只是用其他方式来保障一致性和提升性能。下文介绍下ali开源分布式事务中间件seata(对应内部TXC)。

3.3 2PC-seata

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。下文只介绍AT模式:

模型: AT 模式(参考链接 TBD)基于 支持本地 ACID 事务 的 关系型数据库

  • 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
  • 二阶段 commit 行为:马上成功结束,自动 异步批量清理回滚日志。
  • 二阶段 rollback 行为:通过回滚日志,自动 生成补偿操作,完成数据回滚。

它的2阶段,和前面我们介绍2pc的示例就不太一样了。一阶段就提交关系型数据库的事务,并且在事务中记录回滚日志;通过回滚日志完成数据回滚,为什么不用db事务回滚而增加回滚复杂度?

本地事务的本地锁,在阶段1结束释放:

  1. 正常情况下,大部分业务都是成功的,如果Phase1提交,省去Phase2持有锁时间
  2. 本地连接提前释放,不用等待最慢的分支事务。
  3. 全局锁在协调者TC接到全部协调成功时,可以马上释放。只有在需要回滚时,才一直持有到Phase2,而大多数情况下都是全部提交的情况。

这一个核心设计思想,极大减少了分支事务对资源(数据和连接)的锁定时间,给整体并发和吞吐的提升提供了基础。

回滚 这样设计提升了性能,但回滚简单不了,引入了undo_log。回滚日志需要新建一个表来记录

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

在Phase1提交时,代理数据源解析sql,查出当前数据镜像,在同一个本地事务中提交。详细见:https://seata.io/zh-cn/docs/overview/what-is-seata.html

Phase2回滚时,通过xid和branch_id 查询回滚日志,通过回滚日志生成rollback反向sql并执行,已完成分支的回滚。 (大部分都不需要回滚,所以反向sql放在Phase2)

局限:

  • 隔离性:因为前面提到的设计,seata默认隔离级别【读未提交】,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理,所以不能说完全支持。
  • 数据库支持:需要代理sql生成回滚日志。对数据库的支持,SQL解析语法的覆盖比价重要,使用时要特别关注的点。

扩展性: seata提供了多少模型,还有比如下方提到的TCC模式,可以通过对commit/rollback的自定义实现,从而把不支持本地事务的资源也纳入事务管理。

高可用https://seata.io/zh-cn/blog/seata-analysis-java-server.html

3.4 TCC编码方案

TCC(try-confirm-cancle),是两阶段提交的变种,但完全靠业务编码实现。如下示例:

@Transactional
public void createOrderWithTCC(String orderId, String productId, int quantity) {
    // Try阶段:尝试扣减库存
    boolean tryResult = stockService.tryDeductStock(productId, quantity);
    
    if (tryResult) {
        // Confirm阶段:确认创建订单
        confirmCreateOrder(orderId, productId, quantity);
    } else {
        // Cancel阶段:取消创建订单
        cancelCreateOrder(orderId);
    }
}

因为不依赖中间件等其他协调中保障,没有全局锁等待,相比XA等2PC数据库,性能提升。不过我目前还没看到有人大规模单独使用TCC,因为缺点很明显:

  1. 浸入业务,且代码很难看和难维护。
  2. 设计不好,还是无法保障数据一致性。比如cancle出错,confirm错误后怎么重试。

3.5 最终一致性&柔性事务

概念:分布式系统中的最终一致性是一种弱一致性模型,它允许在一段时间内数据可能处于不一致的状态,但最终会达到一致状态。

最终一致性模型的核心思想是:如果在分布式系统中没有新的更新操作发生,最终所有副本的数据状态将达到一致。最终一致性不要求在每次更新操作之后立即达到一致状态,而是允许在一段时间内存在副本之间的不一致,然后通过一系列协调和同步操作最终达到一致。

上图是一个常见的AB两个系统,通过异步消息的最终一致性方案。

  1. A系统:A系统业务数据写DB后,发消息给MQ,在一个本地事务,确保两个操作的原子性;
  2. MQ:假设消息中间件是可靠,一旦投递成功,数据不会丢。且最少投递一次;
  3. B系统:幂等消费消息,处理业务逻辑写DB,如果出现消费异常,MQ再次投递。

但分析会发现一些问题:

  1. 系统A的本地事务只能有一个MQ投递且放中业务逻辑最后,否则要求MQ支持事务消息;
  2. 投递MQ,如果实际投递成功但reponse出现网络问题,导致事务回滚,会出现不一致。
  3. 强依赖MQ的可靠性;
  4. 如果B系统消费异常,比如NPE,MQ一般只会在次数内重试。很难控制重试时间间隔。快速重试可能很快用完次数,梯度增加时间可能fix后很长时间不重试导致用户体验问题,手动重试解决不了大批量数据问题(rocketmq的死信队列呢?)。

我们把方案再重构一下: 解决了哪些问题: 2. 新增本地消息持久化到db,用本地db事务保障数据一致性。 3. 新增消息恢复服务:定时扫描没有发送成功的本地消息,保障投递成功。 4. 事务结束后马上发送消息,解决恢复服务带来的实效性问题。 5. 系统B解决幂等的同时,可以通过系统提供的查询服务判断数据的有效性,解决因网络问题带来的投递成功后A系统事务回滚的情况。

其实还是没解决对MQ最少投递一次的要求,没解决B消费异常带来的问题。但很多时候,作为系统A,可能有很多下游,你只需要保障系统A的数据一定发出去。如果没有可靠MQ,可以直接请求一个通用接口,通过补偿服务来保障一定会请求成功。比如支付成功后,需要回调业务系统。

变成了系统B依赖系统A,此时系统B可以接到消息后先持久化,再返回成功。通过持久化的消息去处理自己的业务逻辑,通过查询&对账接口去保障数据的一致性。

当然,根据业务情况,系统依赖情况,你也可以放弃一定的系统解偶要求,比如简单实现:

  1. 不用本地消息表,直接在业务a中增加一个字段,用来记录系统b的处理状态;
  2. b系统只须和a系统协商幂等问题,如果处理失败了,A系统会一直重试。
  3. 甚至投递MQ都不需要,一个RPC服务即可。

优点是简单,问题是相互依赖。

前面提到过事务消息,在不需要本地消息表的情况下,可以做到写db和发消息的事务。rocketmq就支持事务消息,他的事务消息交互流程如下图所示:

  1. 生产者将消息发送至Apache RocketMQ服务端。
  2. RocketMQ服务端将消息持久化成功之后,向生产者返回Ack确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为半事务消息。
  3. 生产者开始执行本地事务逻辑。
  4. 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit或是Rollback),服务端收到确认结果后处理逻辑如下:
    • 二次确认结果为Commit:服务端将半事务消息标记为可投递,并投递给消费者。
    • 二次确认结果为Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
  5. 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。 说明 服务端回查的间隔时间和最大回查次数,请参见参数限制
  6. 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  7. 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行处理。

实现原理其实也是2pc,这里需要业务关系的主要是第5步,实现回查逻辑。

producer.setTransactionCheckListener(new TransactionCheckListener() {
    @Override
    public LocalTransactionState checkLocalTransactionState(MessageExt msg) {
        // 查询本地事务的执行状态,返回COMMIT_MESSAGE或ROLLBACK_MESSAGE
    }
});

CONTENTS