一、前言
黑马商城的交易微服务,会同时远程调用商品微服务和购物车微服务,假设一个场景:有两个用户同时使用微服务,当同一个商品的库存为1时,一个用户进入下单界面了,但是另一个用户已经下单成功了,那么此时库存肯定就不够了,那么结算界面的用户点击下单时肯定会下单失败,但是这个时候会发现,下单虽然失败了,但是购物车却被清空了,这个场景显然是不便于正常用户体验的,所以我们需要解决这个问题。
这个问题的核心是,购物车微服务和交易微服务被同时远程调用,当交易微服务报错时,购物车微服务已经将事务提交了,所以无法恢复到下单前的购物车状态,此时我们发现我们需要一个全局的事务管理来控制整个下单的事务。
二、Seata
Seata是由阿里开发的分布式事务管理组件,可以用于全局管理微服务的事务。
1.环境搭建
(1)导入数据库
首先要导入seata的数据库:

(2)配置文件
将配置文件拷到虚拟机的根目录中:

(3)部署
在docker中部署seata:
bash
docker run --name seata \
-p 8099:8099 \
-p 7099:7099 \
-e SEATA_IP=192.168.150.101 \
-v ./seata:/seata-server/resources \
--privileged=true \
--network hm-net \
-d \
seataio/seata-server:1.5.2
2.微服务集成Seata
首先需要在微服务中导Seata的包:
XML
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
同时需要在项目中添加seata的配置,由于许多微服务都要集成seata,所以我们不妨将关于seata的配置写到nacos的共享配置中,这样不仅便于热更新,同时还可以简化项目文件中的配置书写。
bash
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 192.168.111.111:8848 # nacos地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-server # seata服务名称
username: nacos
password: nacos
tx-service-group: hmall # 事务组名称
service:
vgroup-mapping: # 事务组与tc集群的映射关系
hmall: "default"
data-source-proxy-mode: XA
于是在项目中就只需要声明共享配置即可:

然后我们在这里需要用注解@GlobalTransactional标记事务入口:

3.XA模式
在刚刚的seata共享配置中,我们可以看到有一行配置:
bash
data-source-proxy-mode: XA
这一行配置其实就是在选择seata的模式,seata由XA和AT两种模式,在这里我们先讲XA模式,首先需要明确,为啥之前我们创建订单会出现问题,再回顾一下问题:
当同一个商品的库存为1时,一个用户进入下单界面了,但是另一个用户已经下单成功了,那么此时库存肯定就不够了,那么结算界面的用户点击下单时肯定会下单失败,但是这个时候会发现,下单虽然失败了,但是购物车却被清空了。
这是因为流程的不同步,OrderService中调用的itemClient和cartClient是去调用的两个不同的微服务,由于这两个微服务是不在同一个模块中的,所以是无法进行常规事务管理的,所以当扣减库存抛异常时,购物车已经被清空了,并且由于没有事务,购物车无法回滚,清空的购物车也就不会恢复了。
此时XA模式就提供了一个解决办法,就是将购物车清空的行为不提交,等待扣减库存的行为完成后同时提交,如果扣减的时候抛出异常了,就回滚事务。
这个模式会出现一个问题,就是等待的时间问题,等待时会占用线程,所以使用XA模式时,整个事务的处理效率会比较低。
4.AT模式
只需要在共享配置中将这个改成AT即可:
bash
data-source-proxy-mode: AT
AT模式的原理和XA又不相同了,AT是通过保存快照来解决事务问题的,当cartClient调用时,首先给购物车保存一个快照,然后直接提交cartClient的清空行为,当扣减库存抛出异常时,回滚事务,将购物车恢复成之前保存的快照的状态。
AT模式就将等待的时间给消除了,使用快照来保存原始状态,可以说是使用空间换时间了。
值得一提的是,Seata默认的模式是AT。
5.番外篇
经过我的测试,其实这里是不一定要使用分布式事务的,如果将扣减库存和清空购物车的代码交换位置,那么只需要一个普通事务就可以解决这个问题了。
因为本身ItemService的deductStock方法是有事务的,也就是说如果这个远程调用抛出异常,在Item-sercice的微服务中,这个库存数目是会回滚的,同时,在orderSercice中,由于库存不足抛出了异常,这个方法肯定会被终止,自然也就执行不到后面清空购物车的代码了,所以购物车根本不会改变。整体从运行效果上也不会出现什么问题。
也就是如下代码就可以解决这个问题。
java
@Override
@Transactional
public Long createOrder(OrderFormDTO orderFormDTO) {
// 1.订单数据
Order order = new Order();
// 1.1.查询商品
List<OrderDetailDTO> detailDTOS = orderFormDTO.getDetails();
// 1.2.获取商品id和数量的Map
Map<Long, Integer> itemNumMap = detailDTOS.stream()
.collect(Collectors.toMap(OrderDetailDTO::getItemId, OrderDetailDTO::getNum));
Set<Long> itemIds = itemNumMap.keySet();
// 1.3.查询商品
List<ItemDTO> items = itemClient.queryItemByIds(itemIds);
if (items == null || items.size() < itemIds.size()) {
throw new BadRequestException("商品不存在");
}
// 1.4.基于商品价格、购买数量计算商品总价:totalFee
int total = 0;
for (ItemDTO item : items) {
total += item.getPrice() * itemNumMap.get(item.getId());
}
order.setTotalFee(total);
// 1.5.其它属性
order.setPaymentType(orderFormDTO.getPaymentType());
order.setUserId(UserContext.getUser());
order.setStatus(1);
// 1.6.将Order写入数据库order表中
save(order);
// 2.保存订单详情
List<OrderDetail> details = buildDetails(order.getId(), items, itemNumMap);
iOrderDetailService.saveBatch(details);
// 4.扣减库存(交换)
try {
itemClient.deductStock(detailDTOS);
} catch (Exception e) {
throw new RuntimeException("库存不足!");
}
// 3.清理购物车商品(交换)
cartClient.removeByItemIds(itemIds);
return order.getId();
}
当然,这是基于这个事务还是比较简单的基础上的,当事务的逻辑变得更复杂时,使用分布式事务绝对是更好的选择。