本地事务和分布式事务

本文探讨了本地事务和分布式事务的区别,重点在于分布式事务的挑战,如网络波动和分布式系统的特性。介绍了分布式事务的解决方案,如2PC、TCC事务补偿型、最大努力通知型和可靠消息+最终一致性方案,并特别提到了SEATA作为分布式事务的解决框架。同时,文章提到了RabbitMQ延时队列在处理库存解锁和订单失败场景的应用,以避免并发问题和提高系统稳定性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本地事务和分布式事务

在这里插入图片描述

远程服务假失败:
远程服务其实成功了,由于网络故障等原因没有返回导致服务回滚,数据库却发生改变
远程服务成功,其他方法出现问题,导致已执行的远程请求无法回滚

  • @Transactional本地事务在分布式系统中,只能控制自己的回滚,控制不了其他事务的回滚
  • 分布式事务:最大原因由于网络波动问题(无法确认远程调用是否是真失败还是假失败)+分布式机器(不操作一个数据库无法回滚)

注意:直接调用同一个类里面的事务方法会导致事务失效(即使设置事务的传播级别),因为aop的原因
解决无法同一个对象内互调事务失效问题
依赖版本由spring-boot控制

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

使用aop中的aspectj进行代理
开启aspectj代理功能,即使没有接口也可以创建代理

@EnableAspectJAutoProxy(exposeProxy = true)//暴露代理对象

代码实现
使用自定义名进行代理

需要代理的类名 自定义名 = (需要代理的类名)AopContext.currentProxy();

分布式事务

分布式中aop会根据raft算法进行机器领导选举(心跳机制,领导节点会一直和随从节点保持联系,随从节点只要接收到领导节点的信息就重置自旋时间,如果有两个节点同时自旋结束并且票数一致,就所有节点重新自旋),日志复制

分区展示:(分区最好为奇数)当分区间的联系中断,一个分区为2一个分区为3,有老领导的就继续跟着老领导,其他分区就开始选举新领导,当有客户开始像2个节点的分区提交数据的时候,领导开始日志复制,但是领导没有得到大多数节点的回复,数据就一直无法提交,而当有客户端向3个节点的分区就行提交数据的时候,3个分区的领导开始日志复制,由于3个分区的节点得到了大多数的回复,就开始保存数据,如果这时候两个分区之间的联系恢复,两个领导就会看谁的选举次数高就会让谁继续当新的领导然后其余节点同步新领导中的数据信息。如果分区为偶数,分区联系中断,这时客户端保持数据,集群为不可用状态

动画展示http://thesecretlivesofdata.com/raft/

cap原理中只可取cp或者ap但是某种业务下又必须要保证数据的一致性,于是对cap进行延伸BASE理论也就是弱一致性,即最终一致性。

分布式事务的解决方案

  • 2PC模式
  • 柔性事务-TCC事务补偿型
  • 柔性事务-最大努力通知型方案
  • 柔性事务-可靠消息+最终一致性方案(异步确保型)

问题:当我们在微服务事务状态中使用feign远程调用,当出现异常时,会出现本地事务回滚,远程调用事务不回滚,导致数据库信息异常,这个时候我们可以使用AEATA作为分布式事务的解决方案。
SEATA:https://seata.io/zh-cn/docs/overview/what-is-seata.html

在每一个微服务数据库创建一个回滚日志表

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,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

导入SEATA依赖

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-seata</artifactId>
            <version>2.1.1.RELEASE</version>
        </dependency>

下载的SEATA的版本要和图片中的版本一致
在这里插入图片描述
配置seata-server-0.9.0\seata\conf\registry.conf

  • 选择注册中心,修改type,例如nacos
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"
  • 指定nacos地址,例如本地地址
serverAddr = "localhost:8848"
  • SEATA的配置都在那里
  # file、nacos 、apollo、zk、consul、etcd3
  type = "file"
  • SEATA的书屋日志储存到哪里(file.conf)
  ## store mode: file、db
  mode = "file"

启动SEATA

seata-server-0.9.0\seata\bin\seata-server.bat

然后会在nacos中出现一个服务叫serverAddr
在代码中添加全局事务注解

@GlobalTransactional
@Transactional

注入SEATA代理数据源

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DruidDataSource druidDataSource() {
        return new DruidDataSource();
    }

    /**
     * 需要将 DataSourceProxy 设置为主数据源,否则事务无法回滚
     *
     * @param druidDataSource The DruidDataSource
     * @return The default datasource
     */
    @Primary
    @Bean("dataSource")
    public DataSource dataSource(DruidDataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);
    }
}

这个代理器在spring低版本还可以使用,但是在2.0版本以上就会出现循环版本依赖问题
HikariDataSource.class如果这个类不存在就说明该数据源没有导入,需要导入依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>

版本由spring控制

我们在这里可以自己创建一个代理数据源

import io.seata.rm.datasource.DataSourceProxy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;

//配置类
@Configuration
public class MySeataConfig {
    @Autowired
    DataSourceProperties dataSourceProperties;

    @Bean
    public DataSource dataSource(DataSourceProperties dataSourceProperties){
        HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        if (StringUtils.hasText(dataSourceProperties.getName())){
            dataSource.setPoolName(dataSourceProperties.getName());
        }
        return new DataSourceProxy(dataSource);
    }
}

将seata-server-0.9.0\seata\conf中registry.conf和file.conf的配置文件复制到resources下(jpa的前后端分离需要使用,其他不需要复制)
事务的服务分组修改
在 org.springframework.cloud:spring-cloud-starter-alibaba-seata的org.springframework.cloud.alibaba.seata.GlobalTransactionAutoConfiguration类中,默认会使用 ${spring.application.name}-fescar-service-group作为服务名注册到 Seata Server上,如果和file.conf中的配置不一致,会提示 no available server to connect错误
方法一

  vgroup_mapping.(当前应用名)-fescar-service-group = "default"

方法二

spring.cloud.alibaba.seata.tx-service-group=

问题:使用SEATA会造成多种锁问题导致并发下降,所以不考虑使用SEATA,我们可以使用柔性事务-最大努力通知型方案
方法一:我们可以在数据库中专门创建出两张表专门用于锁库存,当出现锁成功,但是某些原因导致数据回滚,这是库存需要解锁,我们可以设置一个定时任务,每隔10分钟扫描数据库,查看哪些信息已经回滚但是锁还没有解锁,我们就把他重新解锁。
方法二:延时队列,比如库存所成功,我们害怕订单失败,我们库存需要自己解锁,比较麻烦。我们可以吧所成功的消息发送给消息队列,但是我们可以让消息队列暂时别发出去,暂存30分钟,因为我们的事务可能是成功也可能是失败,无论成功与失败30分钟以后锁肯定都会关的,30分钟以后我们再发给给所库存服务,解锁库存服务一看,这个事务没有了,这是我们就可以把当时的锁解锁一下。

RabbitMQ延时队列

我们也可以使用定时任务,但是定时任务比较消耗内存,增加了数据库的压力,存在较大的时间误差。
定时任务的时效性问题?

当我们使用定时任务,每隔30分钟进行一次扫描一次数据库,当有一个事务此时在第一分钟执行,当定时任务的30分钟等待结束后开始处理超过30分钟的事务,但是此时第一个事务是在第一分钟进来的,条件没有达成所以我们没法处理此事务,就得等下一次定时任务等待30分钟执行,但是当第一个事务30分钟时间结束后,这个事务没有被处理,超时了,这是我们就需要定时任务来处理进行解锁,但是此时定时任务刚执行完毕,我们必须等下一个定时任务。

所以我们采用rabbitmq的消息队列和死信Exchange结合
延时队列工作流程

比如下订单,订单一下成功,我们就给消息队列发送一条消息,这个消息队列的消息最大特点就是他们在30分钟之后才可以被人收到,如果我们有一个服务专门监听这个队列,那们这些消息只有30分钟之后才可以被这个服务收到,这个时候监听者拿到这个订单,一查30分钟已经到了但是你还没有支付,这个时候我们就可以进行处理,(锁库存也是如此)

什么是TTL
在这里插入图片描述
什么是死信(DLX)
在这里插入图片描述
延时队列两中实现方法

1.(推荐)对队列设置过期时间,消息过期之后不要丢弃,交给死信路由,进行处理
2.对单个消息设置过期时间,消息过期之后不要丢弃,交给死信路由,进行处理,(缺点:懒检查机制,就是队列(queue)先进先出的道理。第一个信息5分钟之后过期,第二个信息1秒之后过期,rabbitmq一看第一个消息5分钟之后过期,就会等5分钟之后再来将第一个消息交给死信路由,而此时第二个消息已经过期很久,其他的都过期的直接交给死信路由,但是其他消息已经过期很久才交给死信了路由的。)

延时队列和死信的工作流程
在这里插入图片描述

生产者§给rabbitmq发消息使用的路由键是order.create.order,发消息先发给交换机(order-event-exchange)然后根据路由键将消息发送给order.delay.queue队列,此队列没有任何人监听。当消息过期后,此队列又将消息根据最新的路由键发送给交换机(order-event-exchange),再又交换机(order-event-exchange)根据路由键将消息发送给死信队列(order.release.order.queue),这个死信队列由释放服务监听

导入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    <version>2.4.6</version>
</dependency>

配置类

import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class MyMQConfig {
    //json序列化机制
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }
    /**
     * 容器中的Queue,Exchange,Binding都会自动创建(rabbit中没有的情况)
     * rabbitmq只要有属性也不会发生覆盖
     * */
    //队列
    @Bean
    public Queue delayQueue(){
        /**String name  队列名字
         * boolean durable  队列是否持久化
         * boolean exclusive    队列是否排他
         * boolean autoDelete   队列是否为自动删除
         * Map<String, Object> arguments  队列的参数   */
        /**死信队列配置
         *
         * */
        Map<String,Object> arguments = new HashMap<>();
        //交换机名字
        arguments.put("x-dead-letter-exchange","event-exchange");
        //死信队列路由键
        arguments.put("x-dead-letter-routing-key","release-queue");
        //消息过期时间
        arguments.put("x-message-ttl",60000);

        Queue queue = new Queue("queue.name", true, false, false,arguments);
        return queue;
    }
    //死信队列
    @Bean
    public Queue releaseQueue(){
        Queue queue = new Queue("release.queue.name", true, false, false);
        return queue;
    }
    //交换机
    @Bean
    public Exchange eventExchange(){
        /**String name 交换机名字
         * boolean durable  是否持久化
         * boolean autoDelete   是否自动删除
         * Map<String, Object> arguments   队列的参数  */
        TopicExchange topicExchange = new TopicExchange("event-exchange", true, false);
        return topicExchange;
    }
    //绑定关系
    @Bean
    public Binding bindingOne(){
        /**String destination 目的地
         * DestinationType destinationType  目的地的类型
         * String exchange  交换机名字
         * String routingKey   路由键
         * Map<String, Object> arguments 队列参数*/
        Binding binding = new Binding("queue.name", Binding.DestinationType.QUEUE,
                "event-exchange", "queue.routing.key", null);
        return binding;
    }
    //绑定死信队列
    @Bean
    public Binding bindingTwo(){
        Binding binding = new Binding("release.queue.name", Binding.DestinationType.QUEUE,
                "event-exchange", "release.queue.routing.key", null);
        return null;
    }
    //监听死信队列中的数据
    @RabbitListener(queues = "release.queue.name")
    public void listenerQueue(Message message){
        
    }
}

在事务的时候我们就可以给数据库发送一个消息,告诉我们要锁库存信息,然后我们给rabbitmq发送锁信息。
当锁定成功,将当前商品锁定了的信息发送给mq。
为了保证高并发,需要回滚的服务自己回滚。库存锁定失败,库存回滚,这种情况无需解锁。
当锁定失败,前面保存的信息就会回滚,发送出去的信息,即使要解锁,由于去数据库查不到id,所以就不用解锁,在此处只发id不可取,防止回滚之后找不到数据。
当下单成功库存锁定成功,但是后面的业务调用失败,导致订单回滚,之前锁定的库存就要自动解锁。
消息拒绝之后重新放回队列,让别人继续消费解锁
完结撒花

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值