解决分布式事务
一、分布式事务问题
以前只有一个数据库,涉及到多个表之间的操作的时候,如果某一个发生错误,那么可以回滚。但是在数据量越来越大的情况下,采用了分库分表策略。在多个数据库之间操作,事务就比较麻烦。
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,本地消息表更合适(就像微信、支付宝把你的支付存储起来,定期的调用你对外的接口通知)