分布式事务

一、分布式事务

随着互联网的快速发展,用户量和并发量的快速激增,我们需要从传统的单体架构演变到 -> 集群 -> 垂直拆分 -> SOA面向服务、ESB消息总线 -> 微服务,而此时单体架构内的事务控制也分布在了不同的应用服务内。
以电商下单为例:可能这笔订单用到了积分抵扣、优惠券抵扣,还涉及到用户的余额扣减、商品的库存扣减等业务操作,如果我们电商平台是单体架构,在Spring的@Transaction内开启事务,底层基于数据库的事务支持保证了ACID特性。但是当我们将电商平台拆分为了 用户服务、订单服务、积分服务、优惠券服务等,此时每种操作分布在不同服务节点,此时如何保证事务的一致?

在这种场景下,本地事务是无法解决的,因此引入了分布式事务,所谓分布式事务是指分布式架构中多个服务的节点的数据一致性。

1、经典的X/Open DTP事务模型

X/Open DTP(X/Open Distributed Transaction Processing Reference Model)是X/Open这个组织定义的一套分布式事务的标准,也就是定义了规范和API接口,由各个厂商进行具体的实现。
这个标准提出了使用二阶段提交(2PC- Two Phase Commit)来保证分布式事务的完整性。后台J2EE也遵循了X/Open DTP规范,设计并实现了java里的分布式事务编程接口规范-JTA。

2、X/Open DTP角色

在X/Open DTP事务模型中国,定义了三个角色:

AP: application,我们的应用程序,也就是业务层。哪些操作属于一个事务,就是AP定义的。

RM: Resource Manager,资源管理器。一般是数据库,也可以是其它资源管理器,比如消息队列、文件系统

TM: Transaction Manager,事务管理器、事务协调者。负责接收来自用户程序(AP)发起的XA事务指令,并调度和协调参与事务的所有RM(数据库),确保事务正确完成。

在分布式系统中,每一个机器节点虽然都能够明确知道自己在进行事务操作过程中的结果是成功还是失败,但却无法直接获取到其他分布式节点的操作结果。因此当一个事务操作需要跨越多个分布式节点的时候,为了保持事务处理的ACID特性,就需要引入一个“协调者”(TM)(上帝视角)来统一调度所有分布式节点的执行逻辑,这些被调度的分布式节点被称为AP。
TM负责调度AP的行为,并最终决定这些AP是否要把事务真正进行提交到(RM)。
XA 是X/Open DTP定义的资源管理器和事务管理器之间的接口规范,TM用它来通知和协调相关RM事务的开始、结束、提交或回滚。
目前Oracle、Mysql、DB2都提供了对XA的支持; XA接口是双向的系统接口,在事务管理器(TM) 以及多个资源管理器之间形成通信的桥梁(XA不能自动提交)。

3、2PC

  • 第一阶段

    RM在第一阶段会做两件事:
    1.记录事务日志:redo log,undo log
    2.返回给TM信息:ok,error
    存在问题:如果第一阶段完成后TM宕机或网络出现故障了,此时RM会一直阻塞,发生了死锁。因为没有timeout机制,3PC针对此问题进行了改造,加入了timeout机制。

  • 第二阶段

    根据第一个阶段的返回结果进行提交或者回滚。

4、CAP理论

CAP含义:

  • C: Consistency 一致性 同一数据的多个副本是否实时相同
  • A: Availablity 可用性 一定时间内,系统返回一个明确的结果,则成为该系统可用
  • P: Partition tolerance 分区容错性 将同一服务分布在多个系统中,从而保证某一个系统宕机,仍然有其它系统提供相同的服务。

CAP理论告诉我们,在分布式系统中,C、A、P三个条件中我们最多只能选择两个。那么问题来了,究竟选择哪两个条件较为合适呢?
对于一个业务系统来说,可用性和分区容错性是必须要满足的两个条件,并且这两者是相辅相成的。业务系统之所以使用分布式系统,主要原因有两个:

  • 提升整体性能
    当业务量猛增,单个服务器已经无法满足我们的业务需求的时候,就需要使用分布式系统,使用多个节点提供相同的功能,从而整体上提升系统的性能,这就是使用分布式系统的第一个原因。
  • 实现分区容错性
    单一节点 或 多个节点处于相同的网络环境下,那么会存在一定的风险,万一该机房断电、该地区发生自然灾害,那么业务系统就全面瘫痪了。为了防止这一问题,采用分布式系统,将多个子系统分布在不同的地域、不同的机房中,从而保证系统高可用性。

这说明分区容错性是分布式系统的根本,如果分区容错性不能满足,那使用分布式系统将失去意义。此外,可用性对业务系统也尤为重要。在大谈用户体验的今天,如果业务系统时常出现“系统异常”、响应时间过长等情况,这使得用户对系统的好感度大打折扣,在互联网行业竞争激烈的今天,相同领域的竞争者不甚枚举,系统的间歇性不可用会立马导致用户流向竞争对手。
因此,我们只能通过牺牲一致性来换取系统的可用性分区容错

5、Base理论

CAP理论告诉我们一个悲惨但不得不接受的事实 — — 我们只能在C、A、P中选择两个条件。而对于业务系统而言,我们往往选择牺牲一致性来换取系统的可用性和分区容错性。不过这里要指出的是,所谓的“牺牲一致性”并不是完全放弃数据一致性,而是牺牲强一致性换取弱一致性

  • BA:Basic Available 基本可用 整个系统在某些不可抗力的情况下,仍然能够保证“可用性”,即一定时间内仍然能够返回一个明确的结果。只不过“基本可用”和“高可用”的区别是:
    “一定时间”可以适当延长 :当举行大促时,响应时间可以适当延长
    给部分用户返回一个降级页面 :给部分用户直接返回一个降级页面,从而缓解服务器压力。但要注意,返回降级页面仍然是返回明确结果。

  • S:Soft State:柔性状态 同一数据的不同副本的状态,可以不需要实时一致

  • E:Eventual Consisstency:最终一致性 同一数据的不同副本的状态,可以不需要实时一致,但一定要保证经过一定时间后仍然是一致的。

二、分布式事务常见解决方案

1、最大努力通知方案

2、TCC两阶段补偿方案

TCC是Try-Confirm-Cancel,比如在支付场景中,先冻结一笔资金,再去发起支付。如果支付成功,则将冻结资金进行实际扣除;如果支付失败,则取消资金冻结。

  • Try阶段
    完成所有业务检查(一致性),预留业务资源(准隔离性)
  • Confirm阶段
    确认执行业务操作,不做任何业务检查,只是用Try阶段预留的业务资源。
  • Cancel阶段
    取消Try阶段预留的业务资源。Try阶段出现异常时,取消所有业务资源预留请求。

3、关于状态机

在使用最终一致性的方案时,一定要提到的一个概念是状态机。
什么是状态机?是一种特殊的组织代码的方式,用这种方式能够确保你的对象随时都知道自己所处的状态以及所能做的操作。它也是一种用来进行对象行为建模的工具,用于描述对象在它的生命周期内所经历的状态序列,以及如何响应来自外界的各种事件。
状态机这个概念大家都不陌生,比如TCP协议的状态机。同时我们在编写相关业务逻辑的时候经常也会需要处理各种事件和状态的切换,比如switch、if/else。所以我们其实一直在跟状态机打交道,只是可能没有意识到而已。在处理一些业务逻辑比较复杂的需求时,可以先看看是否适合用一个有限状态机来描述,如果可以把业务模型抽象成一个有限状态机,那么代码就会逻辑特别清晰,结构特别规整。
比如我们来简单描述一个订单
我们以支付为例,一笔订单可能会有等待支付、支付中、已支付等状态,那么我们就可以先去把可能出现的状态以及状态的流程画出来。

状态机的作用:

  • 1.实现幂等
  • 2.通过状态驱动数据的变化
  • 3.业务流程以及逻辑更加清晰,特别是应对复杂的业务场景

4、什么是幂等

简单来说:重复调用多次产生的业务结果与调用一次产生的业务结果相同; 在分布式架构中,我们调用一个远程服务去完成一个操作,除了成功和失败以外,还有未知状态,那么针对这个未知状态,我们会采取一些重试的行为; 或者在消息中间件的使用场景中,消费者可能会重复收到消息。对于这两种情况,消费端或者服务端需要采取一定的手段,也就是考虑到重发的情况下保证数据的安全性。
一般我们常用的手段

  1. 状态机实现幂等
  2. 数据库唯一约束实现幂等
  3. 通过tokenid的方式去识别每次请求判断是否重复

5、开源的分布式事务解决方案

GTS / Seata /TX-LCN

三、TransactionProducer 事务消息

RocketMQ和其它消息中间件最大的一个区别是支持了事务消息,这也是分布式事务里面的基于消息的最终一致性方案。

1.RocketMQ消息的事务架构设计

  1. 生产者执行本地事务,修改订单支付状态,并且提交事务
  2. 生产者发送事务消息到broker上,消息发送到broker上在没有确认之前,消息对于consumer是不可见状态
  3. 生产者确认事务消息,使得发送到broker上的事务消息对于消费者可见
  4. 消费者获取到消息进行消费,消费完之后执行ack进行确认
  5. 这里可能会存在一个问题,生产者本地事务成功后,发送事务确认消息到broker上失败了怎么办?
    这个时候意味着消费者无法正常消费到这个消息。所以RocketMQ提供了消息回查机制,如果事务消息一直处于中间状态,broker会发起重试去查询broker上这个事务的处理状态。一旦发现事务处理成功,则把当前这条消息设置为可见。

A 系统先发送一个 Prepared 消息到 MQ,如果这个 Prepared 消息发送失败那么就直接取消操作别执行了,后续操作都不再执行。
如果这个消息发送成功了,那么接着执行 A 系统的本地事务,如果执行失败就告诉 MQ 回滚消息,后续操作都不再执行。
如果 A 系统本地事务执行成功,就告诉 MQ 发送确认消息。
那如果 A 系统迟迟不发送确认消息呢?此时 MQ 会自动定时轮询所有 Prepared 消息,然后调用 A 系统事先提供的接口,通过这个接口反查 A 系统的上次本地事务是否执行成功。
如果成功,就发送确认消息给 MQ;失败则告诉 MQ 回滚消息。(后续操作都不再执行)
此时 B 系统会接收到确认消息,然后执行本地的事务,如果本地事务执行成功则事务正常完成。
如果系统 B 的本地事务执行失败了咋办?基于 MQ 重试咯,MQ 会自动不断重试直到成功,如果实在是不行,可以发送报警由人工来手工回滚和补偿。

四、事务消息的实践

    <dependency>
      <groupId>org.apache.rocketmq</groupId>
      <artifactId>rocketmq-client</artifactId>
      <version>4.5.2</version>
    </dependency>

TransactionProducer

import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.TransactionMQProducer;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.remoting.common.RemotingHelper;

import java.io.UnsupportedEncodingException;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TransactionProducer {

    public static void main(String[] args) throws MQClientException, UnsupportedEncodingException, InterruptedException {
        TransactionMQProducer transactionMQProducer=new
                TransactionMQProducer("tx_producer");
        transactionMQProducer.setNamesrvAddr("192.168.13.1xxx:9876");
        ExecutorService executorService= Executors.newFixedThreadPool(10);
        transactionMQProducer.setExecutorService(executorService);
        transactionMQProducer.setTransactionListener(new TransactionListenerLocal()); //本地事务的监听

        transactionMQProducer.start();

        for(int i=0;i<20;i++){
            String orderId= UUID.randomUUID().toString();
            String body="{'operation':'doOrder','orderId':'"+orderId+"'}";
            Message message=new Message("order_tx_topic",
                    "TagA",orderId,body.getBytes(RemotingHelper.DEFAULT_CHARSET));
            transactionMQProducer.sendMessageInTransaction(message,orderId+"&"+i);
            Thread.sleep(1000);
        }

    }
}

TransactionListenerLocal


import org.apache.rocketmq.client.producer.LocalTransactionState;
import org.apache.rocketmq.client.producer.TransactionListener;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

public class TransactionListenerLocal implements TransactionListener {

    private Map<String,Boolean> results=new ConcurrentHashMap<>();

    //执行本地事务
    @Override
    public LocalTransactionState executeLocalTransaction(Message message, Object o) {
        System.out.println("开始执行本地事务:"+o.toString()); //o
        String orderId=o.toString();
        //模拟数据库保存(成功/失败)
        boolean result=Math.abs(Objects.hash(orderId))%2==0;
        results.put(orderId,result); //
        return result?LocalTransactionState.COMMIT_MESSAGE:LocalTransactionState.UNKNOW;
    }
    //提供给事务执行状态检查的回调方法,给broker用的(异步回调)
    //如果回查失败,消息就丢弃
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        String orderId=messageExt.getKeys();
        System.out.println("执行事务回调检查: orderId:"+orderId);
        boolean rs=results.get(orderId);
        System.out.println("数据的处理结果:"+rs); //只有成功/失败
        return rs?LocalTransactionState.COMMIT_MESSAGE:LocalTransactionState.ROLLBACK_MESSAGE;
    }
}

TransactionConsumer


import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
import org.apache.rocketmq.common.message.MessageExt;

import java.util.List;

public class TransactionConsumer {

    //rocketMQ 除了在同一个组和不同组之间的消费者的特性和kafka相同之外
    //RocketMQ可以支持广播消息,就意味着,同一个group的每个消费者都可以消费同一个消息

    public static void main(String[] args) throws MQClientException {
        DefaultMQPushConsumer defaultMQPushConsumer=
                new DefaultMQPushConsumer("tx_consumer");
        defaultMQPushConsumer.setNamesrvAddr("192.168.13.xxx:9876");
        defaultMQPushConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
        //subExpression 可以支持sql的表达式. or  and  a=? ,,,
        defaultMQPushConsumer.subscribe("order_tx_topic","*");
        defaultMQPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                list.stream().forEach(message->{
                    //扣减库存
                    System.out.println("开始业务处理逻辑:消息体:"+new String(message.getBody())+"->key:"+message.getKeys());
                });
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; //签收
            }
        });
        defaultMQPushConsumer.start();
    }

}

RocketMQ事务消息的三种状态

  1. ROLLBACK_MESSAGE 回滚事务
  2. COMMIT_MESSAGE 提交事务
  3. UNKNOW broker会定时的回查Producer消息状态,直到彻底成功或失败

当executeLocalTransaction方法返回 ROLLBACK_MESSAGE时,表示直接回滚事务,当返回 COMMIT_MESSAGE 提交事务。
当返回UNKNOW时,Broker会在一段时间之后回查 checkLocalTransaction,根据checkLocalTransaction返回状态执行事务的操作(回滚或提交)。

如示例中,当返回 ROLLBACK_MESSAGE时消费者不会受到消息,且不会调用回查函数;当返回 COMMIT_MESSAGE 时事务提交,消费者受到消息;当返回 UNKNOW时,在一段时间之后调用回查函数,并根据status判断返回提交或回滚状态,返回提交状态的消息将会被消费者消费,所以此时消费者可以消费部分消息。

五、消息的存储和发送

由于分布式消息队列对于可靠性的要求比较高,所以需要保证生产者将消息发送到broker之后,保证消息是不出现丢失的,因此消息队列就少不了对于可靠性存储的要求

1. MQ消息存储选择

从主流的几种MQ消息队列采用的存储方式来看,主要会有三种

  1. 分布式KV存储,比如ActiveMQ中采用的levelDB、Redis, 这种存储方式对于消息读写能力要求不高的情况下可以使用
  2. 文件系统存储,常见的比如kafka、RocketMQ、RabbitMQ都是采用消息刷盘到所部署的机器上的文件系统来做持久化,这种方案适合对于有高吞吐量要求的消息中间件,因为消息刷盘是一种高效率,高可靠、高性能的持久化方式,除非磁盘出现故障,否则一般是不会出现无法持久化的问题
  3. 关系型数据库,比如ActiveMQ可以采用mysql作为消息存储,关系型数据库在单表数据量达到千万级的情况下IO性能会出现瓶颈,所以ActiveMQ并不适合于高吞吐量的消息队列场景。总的来说,对于存储效率,文件系统要优于分布式KV存储,分布式KV存储要优于关系型数据库

2. 消息的存储结构

RocketMQ就是采用文件系统的方式来存储消息,消息的存储是由ConsumeQueue和CommitLog配合完成的。CommitLog是消息真正的物理存储文件。ConsumeQueue是消息的逻辑队列,有点类似于数据库的索引文件,里面存储的是指向CommitLog文件中消息存储的地址。
每个Topic下的每个Message Queue都会对应一个ConsumeQueue文件,文件的地址是:${store_home}/consumequeue/${topicNmae}/${queueId}/${filename}, 默认路径: /root/store在rocketMQ的文件存储目录下,可以看到这样一个结构的的而文件。

我们只需要关心Commitlog、Consumequeue、Index

  • CommitLog
    CommitLog是用来存放消息的物理文件,每个broker上的commitLog本当前机器上的所有consumerQueue共享,不做任何的区分。
    CommitLog中的文件默认大小为1G,可以动态配置; 当一个文件写满以后,会生成一个新的commitlog文件。所有的Topic数据是顺序写入在CommitLog文件中的。
    文件名的长度为20位,左边补0,剩余未起始偏移量,比如00000000000000000000 表示第一个文件, 文件大小为102410241024,当第一个文件写满之后,生成第二个文件000000000001073741824 表示第二个文件,起始偏移量为1073741824

  • ConsumeQueue
    consumeQueue表示消息消费的逻辑队列,这里面包含MessageQueue在commitlog中的起始物理位置偏移量offset,消息实体内容的大小和Message Tag的hash值。
    对于实际物理存储来说,consumeQueue对应每个topic和queueid下的文件,每个consumeQueue类型的文件也是有大小,每个文件默认大小约为600W个字节,如果文件满了后会也会生成一个新的文件

  • IndexFile
    索引文件,如果一个消息包含Key值的话,会使用IndexFile存储消息索引。Index索引文件提供了对CommitLog进行数据检索,提供了一种通过key或者时间区间来查找CommitLog中的消息的方法。
    在物理存储中,文件名是以创建的时间戳命名,固定的单个IndexFile大小大概为400M,一个IndexFile可以保存2000W个索引

  • Abort
    broker在启动的时候会创建一个空的名为abort的文件,并在shutdown时将其删除,用于标识进程是否正常退出,如果不正常退出,会在启动时做故障恢复

3. 消息存储的整体结构

RocketMQ的消息存储采用的是混合型的存储结构,也就是Broker单个实例下的所有队列公用一个日志数据文件CommitLog。这个是和Kafka又一个不同之处。
为什么不采用kafka的设计,针对不同的partition存储一个独立的物理文件呢?
这是因为在kafka的设计中,一旦kafka中Topic的Partition数量过多,队列文件会过多,那么会给磁盘的IO读写造成比较大的压力,也就造成了性能瓶颈。所以RocketMQ进行了优化,消息主题统一存储在CommitLog中。
当然,这种设计并不是银弹,它也有它的优缺点
优点在于:由于消息主题都是通过CommitLog来进行读写,ConsumerQueue中只存储很少的数据,所以队列更加轻量化。对于磁盘的访问是串行化从而避免了磁盘的竞争
缺点在于:消息写入磁盘虽然是基于顺序写,但是读的过程确是随机的。读取一条消息会先读取ConsumeQueue,再读CommitLog,会降低消息读的效率。

4. 消息发送到消息接收的整体流程

  1. Producer将消息发送到Broker后,Broker会采用同步或者异步的方式把消息写入到CommitLog。RocketMQ所有的消息都会存放在CommitLog中,为了保证消息存储不发生混乱,对CommitLog写之前会加锁,同时也可以使得消息能够被顺序写入到CommitLog,只要消息被持久化到磁盘文件CommitLog,那么就可以保证Producer发送的消息不会丢失。

  2. commitLog持久化后,会把里面的消息Dispatch到对应的Consume Queue上,Consume Queue相当于kafka中的partition,是一个逻辑队列,存储了这个Queue在CommiLog中的起始offset,log大小和MessageTag的hashCode。

  3. 当消费者进行消息消费时,会先读取consumerQueue , 逻辑消费队列ConsumeQueue保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量Offset,消息大小、和消息Tag的HashCode值

  4. 直接从consumequeue中读取消息是没有数据的,真正的消息主体在commitlog中,所以还需要从commitlog中读取消息

5. 什么时候清理物理消息文件?

消息文件到底删不删,什么时候删?
消息存储在CommitLog之后,的确是会被清理的,但是这个清理只会在以下任一条件成立才会批量删除消息文件(CommitLog):

  1. 消息文件过期(默认72小时),且到达清理时点(默认是凌晨4点),删除过期文件。
  2. 消息文件过期(默认72小时),且磁盘空间达到了水位线(默认75%),删除过期文件。
  3. 磁盘已经达到必须释放的上限(85%水位线)的时候,则开始批量清理文件(无论是否过期),直到空间充足。
    注:若磁盘空间达到危险水位线(默认90%),出于保护自身的目的,broker会拒绝写入服务。

介绍分布式事务的浅显易懂的文章
“分布式事务”,这次彻底懂了

posted @ 2019-10-11 17:08  BigShen  阅读(218)  评论(0编辑  收藏  举报