相信大家对于事务问题都不陌生,在传统的单库环境下开发,咱们可依赖于MySQL
所提供的事务机制,来确保单个事务内的一组操作,要么全部执行成功,要么全部执行失败。
例如一个下单业务中,假设由「扣减库存、新增订单」两个操作组成,在单库中通过
MySQL
提供的事务机制,能够确保该事务中任意操作执行出现问题时,另一个操作变更的数据可以回滚,从而确保整库数据的一致性,避免产生库存扣了,但订单却未增加的情况出现。
在传统的单体架构中做单库开发,数据的一致性可以通过InnoDB
事务机制来保障,但当项目换到分布式架构的环境时,或者当项目换到分库分表的环境时,答案亦是如此吗?并非如此,在分布式环境下,由于每个库都维护着自己的事务机制,相互之间无法感知对方的事务,因此就会出现分布式事务问题,这也是分布式系统中头疼多年的一个棘手问题!
本章的核心则是讲清楚分布式事务问题,以及该如何去解决这种棘手问题,但实际目前对于分布式事务的解决方案已经十分成熟了,即
Spring Cloud Alibaba
中的Seata
框架,以及更早期的GTS、LCN、 Atomikos、RocketMQ、Sharding-Sphere...
等框架都能够很好的解决分布式事务问题。
也正由于分布式事务问题的解决方案已经比较完善,基本上一个注解、几行代码、几行配置的事情,就能够轻松解决分布式事务问题,因此本章并非单纯去讲述这些框架的基本使用,而是从另一个角度来思考分布式事务问题,即假设没有这些成熟的解决问题,咱们遇到这个问题时又该如何处理呢?接下来会与大家一起,手把手的自己编写一个分布式事务框架。
因为涉及到了分布式事务框架的手写,可能内容会比较偏向于底层原理的分享,我会尽量在把这些内容写的简单一点,同时对于每个核心段落也会画图示例,但本身这个题材就比较硬核,因此想要彻底读懂这章,最好具备如下基础:
- 分布式知识储备:主要是指
SpringCloud
微服务与RPC
远程调用的基本使用。 - 网络知识储备:主要是指
Netty
框架的使用、序列化知识、P2P
网络通信的原理。 Spring
相关的知识储备:主要是Transactional
事务机制原理、AOP
切面使用。Java-JUC
并发编程的知识储备:主要是ThreadLocal、Condition、Lock、
线程池技术。- 其他的知识储备:主要是指自定义注解式开发、
Maven
打包技术、MySQL
事务原理。
如若大家不具备上述基础,实则也无需担心,通篇读下来应该大致原理也能够弄懂,本章要做的是让诸位知其然并知其所以然,对技术不要停留在单纯的使用层面,而应该适当性的去参悟底层的实现原理,这才是大家与其他开发者拉开差距的核心竞争力。
因为个人还并未阅读过
Seata
框架的源码,因此本章是之前在阅读LCN
这个老牌分布式事务框架仿写的,所以很多实现是借鉴于LCN
的部分实现,但LCN
和Seata-AT
模式大致相同,因此诸位也可将本篇当做Seata-AT
模式的原理篇来阅读,在本章末尾也会提供源码实现。
一、何谓分布式事务问题?
首先来聊聊啥是分布式事务问题,因为现在的分布式/微服务系统开发中,基本上每个核心服务都会具备自己的独享库,也就是垂直分库的模式,以前面的例子来说,订单服务有订单DB
,库存服务有库存DB
,每个服务之间的数据库都是独立的。此时先回顾原本单库环境中解决事务问题的方式,如下:
// 下单服务
@Transactional
public void placeAnOrder(){
// 调用扣减库存的方法
inventoryService.minusInventory();
// 调用增加订单的方法
orderService.insertOrder();
}
复制代码
一个下单业务的伪代码如上,会先调用「扣减库存」的方法,接着再调用「新增订单」的方法,为了确保下单这组操作的数据一致性,通常会在方法上加一个@Transactional
注解,这样就会将事务托管给Spring
来负责,如下:
- 在该方法执行时,
Spring
会首先向数据库发送一条begin
开启事务的命令。 - 如果执行过程中出现异常,
Spring
会向数据库发送一条rollback
回滚事务的命令。 - 如果执行一切正常,
Spring
会向数据库发送一条commit
提交事务的命令。
Spring
注解式事务的逻辑图如下:
这种事务管理机制,在单体架构中显然十分好用,但放到分布式环境中,情况则不同,如下:
由于分布式系统都会根据业务去拆分子系统/子服务,因此不同业务之间只能通过RPC
的方式,远程调用对方所提供的API
接口,假设这里在库存服务本地的「扣减库存」方法上加一个@Transactional
注解,同时在订单服务本地的「新增订单」方法也加一个@Transactional
注解,Spring
内部的处理逻辑如下:
- 下单业务远程调用「减库存」接口时,
Spring
会先向库存DB
发送一个begin
命令开启事务。 - 当扣减库存的业务执行完成后,
Spring
会直接向库存DB
发送一个commit
命令提交事务。 - 下单业务调用本地的「新增订单」方法时,
Spring
又会向订单DB
发送begin
命令开启事务。 - 当新增订单执行出现异常时,
Spring
会向订单DB
发送一个rollback
命令回滚事务。
此时分析如上场景,下单业务理论上应该属于同一个事务,但之前《MySQL事务篇》聊到过,InnoDB
的事务机制是基于Undo-log
日志实现的,那么减库存产生的回滚记录会记录到库存DB
的Undo-log
中,而新增订单产生的回滚记录则会记录到订单DB
的Undo-log
中,此时由于服务不同、库不同,因此相互之间无法感知到对方的事务。
当后续「新增订单」的操作执行出现异常,
Spring
框架发送的rollback
命令,就只能根据订单DB
中的回滚记录去还原数据,此时前面扣减过的库存数据就无法回滚,因此导致了整体数据出现了不一致性。
1.1、分布式事务问题演示
前面简单讲述了分布式事务问题,但这样讲起来似乎有些令人费脑,那接下来直接上个案例,实际感受一下分布式事务造成的数据不一致问题,这里基于SpringCloud
快速搭建了一个微服务项目,为了节省篇幅就不带着诸位一起走简单的搭建流程了,完整的源码地址会在最后给出,其中有订单、库存两个子服务,库存服务提供了一个减库存的接口,如下:
@RestController
@RequestMapping("/inventory")
public class InventoryAPI {
// 注入本地的InventoryService
@Autowired
private InventoryService inventoryService;
@RequestMapping("/minusInventory")
public String minusInventory(Inventory inventory) {
// 根据传入的商品ID先查询库存
Inventory inventoryResult =
inventoryService.selectByPrimaryKey(inventory.getInventoryId());
// 如果库存不足则返回相应提示
if (inventoryResult.getShopCount() <= 0) {
return "库存不足,请联系卖家....";
}
// 如果商品还有剩余库存则对库存减一,接着修改数据库中的库存数量
inventoryResult.setShopCount(inventoryResult.getShopCount() - 1);
int n = inventoryService.updateByPrimaryKeySelective(inventoryResult);
System.out.println("库存信息:" + inventoryResult.toString());
// 扣减库存成功后,向客户端返回对应的提示
if (n > 0) {
return "端口:" + port + ",库存扣减成功!!!";
}
return "端口:" + port + ",库存扣减失败!!!";
}
}
// 库存服务本地的InventoryService实现类
@Service
public class InventoryServiceImpl implements InventoryService {
// 减库存会调用的修改方法,在上面添加了@Transactional注解
@Override
@Transactional
public Integer updateByPrimaryKeySelective(Inventory record) {
int i = inventoryMapper.updateByPrimaryKeySelective(record);
return i;
}
}
复制代码
而订单服务中提供了一个下单接口,如下:
@RestController
@RequestMapping("/order")
public class OrderAPI {
// 注入本地的OrderService
@Autowired
private OrderService orderService;
// 库存服务的远程调用地址
private static final String URL_PREFIX =
"http://localhost:8002/inventory/minusInventory";
// 负责远程调用的RestTemplate
@Autowired
private RestTemplate restTemplate;
// 下单接口
@RequestMapping("/placeAnOrder")
public String placeAnOrder(){
// 随便指定一个商品的ID
String inventoryId = "92b1162a-eb7a-4d72-9645-dea3fe03c8e2";
// 然后通过HttpClient调用库存服务的减库存接口
String result = HttpClient.get(URL_PREFIX +
"/minusInventory?inventoryId=" + inventoryId);
System.out.println("\n调用减库存接口后的响应结果:" + result + "\n");
// 调用减库存接口成功后,向订单库中插入一笔订单记录
String orderId = UUID.randomUUID().toString();
Order order = new Order(orderId,"黄金竹子","8888.88",inventoryId);
Integer n = orderService.insertSelective(order);
System.out.println("\n\n\n" + n + "\n\n\n");
return "下单调用成功,需要处理事物.....";
}
}
// 订单服务本地的OrderService实现类
@Service
public class OrderServiceImpl implements OrderService {
// 新增订单会调用的插入方法
@Override
@Transactional
public Integer insertSelective(Order record) {
// 刻意制造出一个异常
int i = 100 / 0;
return orderMapper.insertSelective(record);;
}
}
复制代码
要注意看,在orderService.insertSelective(order)
插入订单数据的方法中,我们通过100/0
手动制造了一个异常,以此来模拟出「扣减库存」执行成功、「新增订单」执行失败的场景,接着看看库存DB
、订单DB
中对应的库存表、订单表数据,如下:
很明显,目前订单表中还没有任何数据,而库存表中仅有一条测试数据,但要注意:这两张表分别位于db_inventory、db_order
两个不同的库中,此时「黄金竹子」的库存数量为100
,现在分别启动库存服务、订单服务来做简单模拟:
- 订单服务的下单接口:
http://localhost:8001/order/placeAnOrder
。
这里就直接用浏览器做测试,浏览器调用下单接口后,控制台的日志如下:
两个服务对应的数据库中的数据如下:
结果十分明显,此时对应商品的库存扣掉了,但由于新增订单时出现异常,所以订单却并未增加,最终造成了数据不一致问题,这也就是前面所说到的分布式事务问题,这也是分布式系统中,需要解决的一个棘手问题。
1.2、该如何解决分布式事务问题呢?
早年间分布式架构并不像如今这么主流,一般只有一些互联网大厂才会使用,因此相关的技术生态和解决方案,并不像那么成熟,而分布式事务问题,也成为了使用分布式架构不得不解决的棘手问题,在分布式事务问题被发现后,期间推出了各种各样的解决方案,但如今保留下来的主流方案共有四种:
- ①基于
Best Efforts 1PC
模式解决分布式事务问题。 - ②基于
XA
协议的2PC、3PC
模式做全局事务控制。 - ③基于
TTC
方案做事务补偿。 - ④基于
MQ
实现事务的最终一致性。
但上述四种仅是方法论,也就是一些虚无缥缈的理论,想要使用时还得根据其概念去自己落地,但如今分布式/微服务生态已经十分成熟,所以也有很多现成的落地技术,早已能够解决分布式事务问题,如Seata、GTS、LCN、 Atomikos、RocketMQ、Sharding-Sphere...
等框架,都提供了完善的分布式事务支持,目前较为主流的是引入Seata
框架解决,其内部提供了AT、TCC、XA、Seaga-XA
四种模式,主推的是AT
模式,使用起来也较为简单,大体步骤如下:
- ①引入相关的
Maven
依赖。 - ②修改相关的配置文件。
- ③在需要保障分布式事务的方法上加一个
@GlobalTransactional
注解。
经过上述三步后,就能够轻松解决早期困扰大厂多年的分布式事务问题,是不是尤为轻松?其他的分布式事务框架使用步骤也相差无几,引入依赖、修改配置、加一个注解即可。
二、手写分布式事务框架的思路分析
前面对分布式事务问题的描述讲了一大堆,但真正要解决对应问题时,似乎仅靠一个注解就够了,这这这......,到底是怎么解决的呢?相信诸多使用Seata
或其他分布式事务框架的小伙伴,心中难免都会产生这个疑惑。OK,咱们先假设现在没有这些成熟的分布式事务框架,如果自己要解决分布式事务问题,又该如何去实现呢?那么接下来就重点说说这块,真正让大家做到知其然并知其所以然!
不过做任何事情得先有规划,没有提前做好准备与计划,任何事一般都不会有太好的结果,因此先分析一下手写分布式事务框架的思路,把思路捋清楚之后,接着再去编写对应的代码实现。
前面讲的分布式事务问题,本质原因在于Spring
是依靠数据库自身的事务机制来保障数据一致性的,而两个服务对应着两个库,两个库各自都维护着自己的事务机制,同时无法感知对方的事务状况,最终造成库存服务先提交了事务,而订单服务后回滚事务的情况出现。
所以想要自己解决分布式事务问题,首先就不能依靠