解决分布式事务

一、分布式事务问题

  以前只有一个数据库,涉及到多个表之间的操作的时候,如果某一个发生错误,那么可以回滚。但是在数据量越来越大的情况下,采用了分库分表策略。在多个数据库之间操作,事务就比较麻烦。

  1、在分布式系统中,我们经常听到 CAP 原理这个词,它是什么意思呢?其实和C、A、P 3个字母有关。

    C - Consistent,一致性。具体是指,操作成功后,所有的节点,在同一时间,看到的数据都是完全一致的。所以,一致性,就是数据一致性。

    A - Availability,可用性。指服务一致可用,在规定的时间内完成响应。

    P - Partition tolerance,分区容错性。指分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供服务。

  

     CAP 原理支出,这3个指标不能同时满足,最多只能满足其中的两个。

  2、详解

    我们之所以使用分布式系统,就是为了在某个节点不可用的情况下,整个服务对外还是可用的,这正是满足 P(分区容错性)。如果我们的服务不满足 P ,那么我们的系统也就不是分布式系统了,所以在分布式系统中 P 总是成立的。那么 A(可用性)、C(一致性) 能不能同时满足呢?

    

     A 和 B 是两个数据节点,A 向 B 同步数据,并且作为一个整体对外提供服务。由于我们的系统保证了 P(分区容错性),那么 A 和 B 的同步,我们允许出现故障。接下来我们再保证 A(可用性),也就是说 A 和 B 同步出现问题时,客户端还能访问我们的系统,那么客户端既可能访问 A 也能访问 B,这时,A 和 B 的数据是不一致的,所以 C(一致性) 不能满足。

    如果我们满足 C(一致性),也就是说客户端无论访问 A 还是访问 B,得到的结果都是一样的,那么现在 A 和 B 的数据不一致,需要等到 A 和 B 的数据一致后,也就是同步恢复以后,才可对外提供服务。这样我们虽然满足了 C(一致性),却不能满足 A(可用性)。

    所以,我们的系统在满足 P(分区容错性) 的同时,只能在 A(可用性) 和 C(一致性) 当中选择一个。不能 CAP 同时满足。我们的分布式系统只能是 AP 或者 CP。

  3、ACID 与 BASE

    在关系同数据库中,最大的特点就是事务处理,也就是 ACID。ACID 是事务处理的4个特性。

      A - Atomicity(原子性),事务中的操作要么都做,要么都不做。

      C - Consistency(一致性),系统必须始终处在强一致状态下。

      I - Isolation(隔离性),一个事务的执行不能被其他事务所干扰。

      D - Durability(持久性),一个已提交的事务对数据库中数据的改变是永久的。

    ACID 强调的是强一致性,要么全做,要么全不做,所有的用户看到的都是一致的数据。传统的数据库都有 ACID 特性,他们在 CAP 原理中,保证的是 CA。但是分布式系统大行其道的今天,满足 CA 特性的系统很难生存下去。ACID 也逐渐的向 BASE 转换。那么什么事 BASE呢?

      Basically Available,基本可用是指分布式系统在出现故障的时候,允许损失部分可用性,即保证核心可用。电商大促时,为了应对访问量激增,部分用户可能会被引导到降级页面,服务层也可能只提供降级服务。这就是损失部分可用性的体现。

      软状态(Soft State),是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据至少会有两到三个副本,允许不同节点间副本同步的延时就是软状态的体现。mysql replication 的异步复制也是一种体现。

      最终一致性(Eventual Consistency),是指系统中的所有数据副本经过一定时间后,最终能够达到的状态。弱一致性和强一致性,最终一致性是弱一致性的一种特殊情况。

    BASE 模型是传统 ACID 模型的反面,不同于 CAID,BASE 强调牺牲高一致性,从而获得可用性,数据允许在一段时间内的不一致,只要保证最终一致就可以了。

    在分布式事务的解决方案中,它们都是依赖了 ACID 或者 BASE 的模型而实现的。像基于 XA 协议的两阶段提交和事务补偿机制就是基于 ACID 实现的。而基于本地消息表和基于 MQ 的最终一致方案就是通过 BASE 原理实现的。

二、解决方案

  1、XA 协议的两阶段提交

    XA是由X/Open组织提出的分布式事务的规范

    由一个事务管理器(TM)和多个资源管理器(RM)组成

    提交分为两个阶段:prepare 和 commit

      第一阶段

      

        左边是一个事务管理器(TM),右边对应的是资源管理器(RM)有两个。事务管理器也就是程序中的数据源,它在提交事务的时候,第一阶段先要进行准备。事务管理器告诉(prepare)所有的数据源,你要进行准备,然后资源管理器告诉事务管理器准备好了(ready),如果这个时候没有返回 ready 没有准备好,或者 prepare 过程中有问题,那么它会返回一个错误的信息,这个时候事务管理器就会发出回滚通知。前一个事务也进行回滚。

      第二阶段

      

 

         事务管理器 分别向两个资源管理器发出提交通知指令,然后两个资源管理器分别执行事务提交,提交完以后将 committed 状态返回给事务管理器,事务管理器在统一返回应用程序。如果在 commit 阶段出现问题,那么事务管理器会收到未知的状态,上面的事务提交成功,下面的事务提交出现问题,那么前面的事务也没法进行回滚,这个时候就要人工的介入事务管理器来处理。

    保证数据的强一致性

    commit 阶段出现问题,事务出现不一致,就需要人工处理

    效率低下,性能与本地事务相差 10倍

    MySQL5.7 及以上均支持 XA 协议

    MySQL Connector/J 5.0以上 支持 XA 协议

    Java系统中,数据源采用 Atomikos(充当事务管理器)    

    1、代码实现

      准备数据库,一张表

         

         

       新建项目

        

         maven配置

<dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
<!--            <scope>runtime</scope>-->
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--事务管理器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jta-atomikos</artifactId>
        </dependency>

        配置数据源

 DB203Config:

@Configuration
@MapperScan(value = "com.xa.xademo.mapper.xa203", sqlSessionFactoryRef = "sqlSessionFactoryBean203")
public class DB203Config {

    @Bean("db203")
    public DataSource db203() {
        MysqlXADataSource mysqlXADataSource = new MysqlXADataSource();
        mysqlXADataSource.setUser("root");
        mysqlXADataSource.setPassword("root");
        mysqlXADataSource.setUrl("jdbc:mysql://192.168.1.203:3306/xa203?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true");

        // 使用 atomikos 数据源进行统一管理
        AtomikosDataSourceBean atomikosDataSourceBean = new AtomikosDataSourceBean();
        atomikosDataSourceBean.setXaDataSource(mysqlXADataSource);
        atomikosDataSourceBean.setUniqueResourceName("db203");

        return atomikosDataSourceBean;
    }

    @Bean("sqlSessionFactoryBean203")
    public SqlSessionFactoryBean sqlSessionFactoryBean(@Qualifier("db203") DataSource dataSource) {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
        try {
            sqlSessionFactoryBean.setMapperLocations(resourceResolver.getResources("mapper/xa203/*.xml"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return sqlSessionFactoryBean;
    }

    /**
     * 创建事务管理器
     * 创建一个即可,另外两个数据源相当于资源管理器
     * @return
     */
    @Bean("jtaTransactionManager")
    public JtaTransactionManager jtaTransactionManager() {
        UserTransaction userTransaction = new UserTransactionImp();
        UserTransactionManager userTransactionManager = new UserTransactionManager();
        return new JtaTransactionManager(userTransaction, userTransactionManager);
    }

}

  DB204Config:

@Configuration
@MapperScan(value = "com.xa.xademo.mapper.xa204", sqlSessionFactoryRef = "sqlSessionFactoryBean204")
public class DB204Config {

    @Bean("db204")
    public DataSource db204() {
        MysqlXADataSource mysqlXADataSource = new MysqlXADataSource();
        mysqlXADataSource.setUser("root");
        mysqlXADataSource.setPassword("root");
        mysqlXADataSource.setUrl("jdbc:mysql://192.168.1.204:3306/xa204?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8");

        // 使用 atomikos 数据源进行统一管理
        AtomikosDataSourceBean atomikosDataSourceBean = new AtomikosDataSourceBean();
        atomikosDataSourceBean.setXaDataSource(mysqlXADataSource);
        atomikosDataSourceBean.setUniqueResourceName("db204");

        return atomikosDataSourceBean;
    }

    @Bean("sqlSessionFactoryBean204")
    public SqlSessionFactoryBean sqlSessionFactoryBean(@Qualifier("db204") DataSource dataSource) {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource);
        ResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
        try {
            sqlSessionFactoryBean.setMapperLocations(resourceResolver.getResources("mapper/xa204/*.xml"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return sqlSessionFactoryBean;
    }

}

  Tuser表(id,name)两个字段

  TUserService.java

@Service
public class TUserService {
    @Resource
    private TUserMapper203 userMapper203;
    @Resource
    private TUserMapper204 userMapper204;

    /**
     * jtaTransactionManager:事务管理器
     */
    @Transactional(transactionManager = "jtaTransactionManager", rollbackFor = Exception.class)
    public void insertUser() {
        TUser user1 = new TUser() {{
            setId(12345);
            setName("张三");
        }};

        TUser user2 = new TUser() {{
            setId(12346);
            setName("李四");
        }};

        userMapper203.myInsert(user1);
        // 是否回滚操作
        int a = 1 / 0;
        userMapper204.myInsert(user2);
    }
}

  测试类测试即可:

@SpringBootTest
class XademoApplicationTests {

    @Resource
    TUserService userService;

    @Test
    void contextLoads() {
    }

    @Test
    void insertUser() {
        userService.insertUser();
    }

}

   2、MyCat 方案

    1、修改 server.xml 配置文件,默认是开启分布式事务

    

     

     只需要在代码中使用 @Transactional(rollbackFor = Exception.class) 注解,和单体应用没有区别的。

  3、Sharding-JDBC 方案

    Sharding-jdbc 是默认开启 分布式事务的。直接使用就行。

   4、基于本地消息表的最终一致方案

    采用 BASE 原理,保证事务最终一致

    在一致性方面,允许一段时间内的不一致,但最终会一致

    在实际的系统当中,要根据具体情况,判断是否采用(有些一致性要求非常高的,那么这种情况下要谨慎。要求不高的话,可以采用 BASE 原理)

    基于本地消息表的方案中,将本事务外操作,记录在消息表中(举个例子:在电商网站当中下订单要支付,那么你的系统中订单和支付就是两个事务,支付跳转到微信或者支付宝支付,那么支付状态等于是在微信和支付宝系统。而订单是在你的电商系统当中,我在微信、支付宝支付了,那么我就要生成支付记录,这条记录就要存储到了微信、支付宝系统当中,然后它要给你的系统发通知,你的订单已经支付了。

    其他事务,提供操作接口(在支付的例子中就是微信、支付宝,把消息通过接口传到你的系统中。然后你在做相应操作)

    定时任务轮询本地消息表,将未执行的消息发送给操作接口(微信支付宝轮询发送支付通知到你的接口,第一次发送接收失败,会再次发送)

    操作接口处理成功,返回成功标识,处理失败返回失败标识

    定时任务接到标识,更新消息的状态

    对于屡次失败的消息,可以设置最大失败次数

    等待人工处理

    

     右侧分为两个数据库,这两个数据库是没有办法通过事务保持统一的。上面库保存的业务表和消息表,下面保存业务表。业务表可以想象成订单和支付,上面库就是支付的数据库,业务表就是每条支付的记录了,消息表存需要通知给下面库的消息。

    一个订单支付成功,我的消息表里面肯定要有一条记录。然后我的定时任务会轮询消息表,从消息表当中取出这条消息,然后调用调用操作接口更新下面库的业务表订单状态为已支付,然后我会把这个操作结果返回给调用方。调用方发现是OK,然后将消息表中的状态更新为已发送。这样就保证支付和订单,两个数据库的支付状态是统一的。只不过他们之间有一段时间是不一致的。

    优点:避免了分布式事务,实现了最终一致性。

    缺点:要注意重试时的幂等性操作

      1、实际操作(简单 demo)

        数据库准备:

        203服务器:account_a

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for account_a
-- ----------------------------
DROP TABLE IF EXISTS `account_a`;
CREATE TABLE `account_a`  (
  `id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `money` int(11) NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

         203服务器:payment_msg

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for payment_msg
-- ----------------------------
DROP TABLE IF EXISTS `payment_msg`;
CREATE TABLE `payment_msg`  (
  `id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '消息表',
  `order_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `status` int(1) UNSIGNED NULL DEFAULT 0 COMMENT '0未发送,1发送成功,2超过最大发送次数',
  `falure_cnt` int(1) NULL DEFAULT 0 COMMENT '失败最大次数,最大5次',
  `create_time` datetime(0) NULL DEFAULT NULL,
  `update_time` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

        204服务器:t_order

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for t_order
-- ----------------------------
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order`  (
  `id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `order_status` int(1) NULL DEFAULT 0 COMMENT '0未支付,1已支付',
  `order_amount` int(11) NULL DEFAULT NULL,
  `receive_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `receive_phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
  `create_time` datetime(0) NULL DEFAULT NULL,
  `update_time` datetime(0) NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

           

         maven配置:

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.2</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <!--            <scope>runtime</scope>-->
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!--事务管理器-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jta-atomikos</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.5</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.71</version>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.8.6</version>
        </dependency>
    </dependencies>

        domian、mapper、mapper.xml 不用说,基本的东西

        OrderService:

@Service
public class OrderService {

    @Resource
    public TOrderMapper204 orderMapper204;

    /**
     * 订单回调接口
     * @param orderId
     * @return 0 不存在
     *         1 成功
     */
    public int handleOrder(String orderId){
        Optional<TOrder> optionalTOrder = Optional.ofNullable(orderMapper204.findById(orderId));
        if (!optionalTOrder.isPresent()){
            return 0;
        }
        TOrder order = optionalTOrder.get();
        order.setOrderStatus(1); // 已支付
        order.setUpdateTime(new Date());

        orderMapper204.update(order);
        return 1;
    }

}

        PaymentService:

@Service
public class PaymentService {

    @Resource
    private TAccountAMapper203 accountAMapper203;

    @Resource
    private TPaymentMsgMapper203 paymentMsgMapper203;

    /**
     * 支付接口
     *
     * @param userId
     * @param orderId
     * @param amount
     * @return 0 用户不存在
     *         -1 余额不足
     *         1 成功
     *
     */
    @Transactional(rollbackFor = Exception.class,
                    transactionManager = "jtaTransactionManager")
    public int pament(String userId, String orderId, int amount) {
        // 查询用户信息
        Optional<TAccountA> aOptional = Optional.ofNullable(accountAMapper203.findById(userId));
        if (!aOptional.isPresent()) {
            return 0;
        }
        TAccountA accountA = aOptional.get();
        if (accountA.getMoney().compareTo(amount) < 0) {
            return -1;
        }
        accountA.setMoney(accountA.getMoney() - amount);
        // 修改金额
        accountAMapper203.updateMoney(accountA);

        // 存放消息
        TPaymentMsg paymentMsg = new TPaymentMsg() {{
            setId("1554887545");
            setOrderId(orderId);
            setStatus(0); // 未发送
            setFalureCnt(0); // 重试次数
            setCreateTime(new Date());
            setUpdateTime(new Date());
        }};
        paymentMsgMapper203.insert(paymentMsg);

        return 1;
    }

}

          OrderController:

@RestController
@RequestMapping("/order")
public class OrderController {

    @Resource
    private OrderService orderService;

    /**
     * 用户下单接口
     *
     * @param orderId
     * @return
     */
    @GetMapping()
    public String payment(String orderId) {
        int i = orderService.handleOrder(orderId);
        return i == 1 ? "ok" : "no";
    }
}

          PaymentController:

@RestController
@RequestMapping("/payment")
public class PaymentController {

    @Resource
    private PaymentService paymentService;

    /**
     * 用户下单接口
     * @param userId
     * @param orderId
     * @param amount
     * @return
     */
    @GetMapping("/pay")
    public Integer payment(String userId, String orderId, Integer amount) {
        return paymentService.pament(userId, orderId, amount);
    }
}

        OrderSchedul:

@Configuration
public class OrderSchedul {

    @Resource
    TPaymentMsgMapper203 paymentMsgMapper203;

    /**
     * 定时任务 10秒 执行一次
     */
    @Scheduled(cron = "0/10 * * * * ?")
    public void orderNotify() {
        // 查询未发送的消息
        List<TPaymentMsg> paymentMsgList = Optional.ofNullable(paymentMsgMapper203.findByStatus(0)).orElseGet(ArrayList::new);
        for (TPaymentMsg paymentMsg : paymentMsgList) {
            HashMap<String, String> map = new HashMap<>();
            map.put("orderId", paymentMsg.getOrderId());
            String response = HttpUtils.sendGet("http://localhost:8080/test/order", map);
            if ("ok".equals(response)) {
                // 发送成功
                paymentMsg.setStatus(1);
            } else {
                // 调用返回错误,重试
                if (paymentMsg.getFalureCnt() >= 5) {
                    // 超过最大发送次数,设置2
                    paymentMsg.setStatus(2);
                } else {
                    paymentMsg.setFalureCnt(paymentMsg.getFalureCnt() + 1);
                }
            }
            paymentMsg.setUpdateTime(new Date());
            paymentMsgMapper203.update(paymentMsg);
        }
    }

}

        流程是用户调用下单接口(/payment/pay),扣减用户账户余额,新增 paymentMsg 记录。然后通过定时任务扫描改表,查询出未发生状态 0 的记录,调用第三方接口 (/order)更改数据库订单状态为已支付。

  5、基于 MQ 的最终一致方案

    原理、流程与本地消息表类似

      不同点:本地消息表改成 MQ(上面本地消息表中的 paymentMsg 表内容放到 MQ 中)

          定时任务改为 MQ 的消费者

      

 

       沿用上一个例子:

        业务系统1就相当于支付系统,他支付了我们要做一些数据处理,用户账户余额,将支付成功的消息投到 MQ 中。消费者拿到这条消息后,更新订单数据库将支付状态改为已支付。

    不依赖定时任务,基于 MQ 更高效、更可靠

    适合公司内的系统

    不同公司之间无法基于 MQ,本地消息表更合适(就像微信、支付宝把你的支付存储起来,定期的调用你对外的接口通知)

  

 

posted @ 2022-06-21 10:27  放手解脱  阅读(87)  评论(0)    收藏  举报