谷粒商城の订单服务分布式事务
文章目录
- 前言
- 一、防重复提交方案
- 1、什么是重复提交
- 2、幂等性
- 3、如何防重复提交
- 二、Feign使用中的坑
- 三、分布式事务理论
- 1、CAP理论
- 2、BASE理论
- 3、Raft算法
- 3.1、基本概念
- 3.2、执行流程
- 3.3、关键点
- 3、网络分区问题
- 4、Spring Cloud Alibaba Seata
前言
本篇介绍订单服务和分布式事务的理论和实践。其中订单服务重点介绍防重复下单解决方案和使用feign进行模块间调用,请求头丢失的问题。分布式事务介绍CAP,BASE理论,强一致性和最终一致性,raft算法,以及seata AT模式的运用。限于篇幅只讲重点技术实现,关于业务逻辑的具体实现可参考教学视频或其他博文。
对应视频P262-P291
一、防重复提交方案
1、什么是重复提交
重复提交是指用户在短时间内多次提交相同的请求,导致服务器执行相同操作多次。用户可能因为网络延迟、误操作或者刷新页面等原因,导致重复发送提交请求。例如购物网站点击下单,同一时间点击了两次下单按钮,或是点击下单后,后端服务器需要进行一系列的校验,有些校验还涉及到调用第三方接口,导致返回较慢,用户见页面没有反应就再次点击下单。
这样会导致同样的数据可能被多次写入数据库,例如,用户付款时多次提交,可能导致用户多次付款,并且还有可能导致数据不一致的问题。
2、幂等性
从重复提交中,可以引出幂等性
的概念,幂等性原本是数学上的概念,体现在软件设计上,简单来说,如果对于接口多次操作的结果和单次操作的结果相同,那么这样的接口是具有幂等性的。查询
和删除
的操作是天然具有幂等性的。查询
是读取操作,多次查询不会对数据造成影响,而删除
操作,根据条件删除了某些数据后,后续再次根据相同的条件进行删除,操作是没有实际效果的。
3、如何防重复提交
最简单的方式,提交表单时,前端临时禁用提交按钮,或者加转圈直到后端返回响应信息,防止用户误点击多次。但是这种方式无法防止恶意请求,所以最好还是在后端进行校验。常见后端校验的方式是使用redis+token
:
- 每次加载下单页面时,后端都生成一个随机的token存入redis中,key可以是固定前缀+每个用户的唯一标识,value则是生成的不重复的随机token。并且需要加上过期时间。
//id是用户的id
private String makeOrderToken(Long id) {
String uuid = UUID.randomUUID().toString().replace("-", "");
stringRedisTemplate.opsForValue().set(ORDER_TOKEN + id, uuid, 30, TimeUnit.MINUTES);
return uuid;
}
- 前端请求下单页面时,后端返回token,前端进行临时记录,并且点击提交订单时,将token传递给后端
<form action="http://order.gulimall.com/submitOrder" method="post">
<input name="orderToken" th:value="${orderConfirmData.orderToken}" type="hidden">
<input id="payPriceInput" name="payPrice" type="hidden">
<input id="addrIdInput" name="addrId" type="hidden">
<button class="tijiao">提交订单</button>
</form>
- 后端接收到下单请求,拿到token后进行校验:
private Long checkIdempotency(SubmitOrderDTO dto) {
MemberRespVO memberRespVO = LoginInterceptor.threadLocal.get();
//前端传递的orderToken
String orderToken = dto.getOrderToken();
//把比较和删除放在一条命令里,保证原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
return stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(ORDER_TOKEN + memberRespVO.getId()), orderToken);
}
为什么需要使用Lua脚本,把比较和删除放在一条命令里?可以首先分析一下,关于删除和比较,有两种执行顺序:
- 先删除后比较:这种方案存在严重的问题,如果A和B两个线程同时进入方法,A先执行删除操作,成功删除了orderToken。B随后执行删除操作,发现orderToken已经不存在,但因为还没有比较orderToken,系统没有意识到这是重复的请求。请求A和请求B都执行了后续逻辑,因为请求B在删除操作后进行比较时,已经无法确认是否这个orderToken已经被其他请求处理过。
最终的结果是系统认为A和B的请求都合法,都会执行后续的业务逻辑。
- 先比较后删除:如果比较和删除是分为了两步,也会存在线程安全问题,假设A和B两个线程同时进入了方法:A发现orderToken存在,通过了比较,但是尚未删除orderToken。这时B也发现orderToken存在,通过了比较。即使A在下一步删除了orderToken,B此时也通过了比较。
最终的结果是系统认为A和B的请求都合法,都会执行后续的业务逻辑。
而使用Lua脚本,因为Redis是单线程的,当A和B两个线程几乎同时执行这个Lua脚本时,Redis会确保每个脚本的执行是原子且独立的。只有一个线程(先执行的那个,如A)能够成功比较并删除orderToken,而另一个线程(如B)会因为orderToken已经被删除而无法再次执行相同的操作。
二、Feign使用中的坑
问题描述:在渲染订单确认页面时,需要远程调用购物车模块,查询信息:
List<CartItem> currentUserCartItems = cartRemoteServiceClient.getCurrentUserCartItems();
而被远程调用的方法中,需要从请求头中获取信息:(UserInfoVO 是在CartInterceptor拦截器中,从浏览器请求头中根据key:loginUser取出的session信息,经过处理封装而成的)
@Override
public List<CartItem> getCurrentUserCartItems() {
UserInfoVO userInfoVO = CartInterceptor.threadLocal.get();
ArrayList<CartItem> cartItems = this.getCartItems(CART_PREFIX + userInfoVO.getUserId());
if (!CollectionUtils.isEmpty(cartItems)) {
//筛选出选中的商品,并且重新计算价格为最新的价格
return cartItems.stream().filter(CartItem::getCheck).map(cartItem -> {
Long skuId = cartItem.getSkuId();
//根据skuId查询最新的价格 远程调用product服务
BigDecimal price = productRemoteServiceClient.getPrice(skuId);
cartItem.setPrice(price);
return cartItem;
}
).collect(Collectors.toList());
}
return null;
}
发现远程调用cart模块,进入拦截器时,请求头中的信息丢失了
跟进feign远程调用的关键部位的源码,会发现:
feign.RequestTemplate#from
:会新创建一个请求模版
新创建的请求模版中,是没有请求头信息的:
而在feign.SynchronousMethodHandler#targetRequest
中,首先会执行所有实现了RequestInterceptor
接口的拦截器,执行其中的逻辑
这也是一个扩展点,如果需要将原生的请求头通过feign的请求模版传递,就要自定义一个实现了RequestInterceptor
接口的拦截器:
/**
* 设置feign的过滤规则
*/
@Configuration
public class FeignConfig implements RequestInterceptor {
/**
* Called for every request. Add data using methods on the supplied {@link RequestTemplate}.
*
* @param template
*/
@Override
public void apply(RequestTemplate template) {
//得到原生的请求头数据
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
//给feign的模版的header中放入cookie
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
进行上面的扩展,在单线程进行feign远程调用时,请求头丢失的问题就解决了,但是在异步编排的场景下,依旧会存在问题:
RequestContextHolder
的底层是ThreadLocal
,只在当前线程下有效。如果远程调用+异步编排,则需要在子线程中再调用RequestContextHolder
的.setRequestAttributes
方法,将主线程的RequestAttributes
再次设置进去:
//RequestContextHolder 是线程隔离的,如果在主线程中,其他线程是获取不到的
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> addressAsync = CompletableFuture.runAsync(() -> {
log.info("当前addressAsync线程:{}", Thread.currentThread().getName());
//所以需要在每个子线程中单独塞入requestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
//收货地址 远程查询会员服务
//得到用户id
Long id = memberRespVO.getId();
R r = memberRemoteServiceClient.infoList(id);
ObjectMapper objectMapper = new ObjectMapper();
List<LinkedHashMap> addressEntities = (List<LinkedHashMap>) r.get("addressEntities");
List<MemberAddressVo> skuSaleAttrValueList = addressEntities.stream()
.map(map -> objectMapper.convertValue(map, MemberAddressVo.class))
.collect(Collectors.toList());
vo.setAddresses(skuSaleAttrValueList);
}, threadPoolExecutor);
三、分布式事务理论
在介绍分布式事务之前,首先来看一段提交订单的业务代码:
/**
* 提交订单
* @param dto 前端传递的订单信息
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)//这里的事务仅仅能保证订单数据入表,不能保证远程查库存
// @GlobalTransactional
public SubmitOrderResponseVO submitOrder(SubmitOrderDTO dto) {
//...这一块是进行校验,创建订单的本地逻辑
//远程调用ware服务,进行库存锁定
R r = wareRemoteServiceClient.stockLock(stockLockVO);
return vo;
}
这一段的业务代码,分为了本地+远程操作。本地和远程操作,必须是要么同时成功,要么同时失败的。锁库存出了问题,生成的订单数据也要回滚。如果使用@Transactional
注解,是可以控制submitOrder
本地方法中,出现异常,订单服务
回滚的,而库存服务
需要回滚,就需要在自己模块的stockLock方法上加入@Transactional
注解。
根据要求,库存服务出了问题,订单服务也要回滚,那么库存服务应该如何通知订单服务同步回滚?最简单的方式就是在库存服务的业务代码中抛出异常,然后由订单服务进行捕获,再抛出异常触发订单服务本地事务。但是这样做存在很大的问题:
- 假设不是因为库存服务的代码问题造成的报错,比如feign远程调用返回结果超时,库存服务的业务逻辑已经执行成功。
- 假设生成订单,远程调用库存服务,之后的业务代码又出了问题,那么本地事务只能回滚订单服务,已经远程调用的库存服务是无法回滚的,又造成了
订单回滚,但是库存已经扣减的问题
。
所以分布式事务是微服务架构下无法避免的问题。
1、CAP理论
CAP是分布式系统下的三种特性,C代表一致性
(Consistency),A代表可用性
(Availability)、P代表分区容忍性
(Partition Tolerance)。
- 一致性要求所有的节点,在某一时刻看到的所有数据都是相同的。
- 可用性要求系统在任何时候都能够处理请求并返回响应,即每次请求都会在有限的时间内得到响应(即使返回的可能是旧数据)
- 分区容错性要求分区容忍性指的是系统能够在网络分区的情况下继续运行。网络分区是指分布式系统中节点之间的通信中断(如网络延迟或故障),导致系统被分为多个不能互相通信的部分。
在分布式系统的设计下,目前是无法满足三者共存的,只能满足AP,CP。分区容忍性是必须要满足的,因为没有任何一个系统可以保证网络不出任何问题。
为什么CAP不能同时满足?假设某个集群中有三台机器,A是主节点,B,C是从节点, 客户端发来一个消息,A接收到了需要同步给B和C。这时候C网络通信异常了,如果需要满足一致性和分区容错性,必然C是不可用的(如果使用C那么返回的数据是和A,B不一致的)。如果需要满足可用性和分区容错性,那么C的数据必然和A和B是不一致的(因为网络通信C没有同步到最新的数据)
2、BASE理论
BASE理论全称为Basically Available, Soft state, Eventually consistent,即基本可用
、软状态
、最终一致性
,强调的是一种在分布式系统中放松一致性以换取系统的高可用性和容错性的设计理念。
- 基本可用意味着即使系统遇到故障或压力增加,也能保证系统的核心功能可用,但允许某些非核心功能的下降或部分损失。例如某些电商平台在秒杀活动时,可能会关闭一些不重要的功能(如推荐系统),以保证核心的购买流程可用。
- 软状态指的是系统允许存在中间状态,而不要求每次写操作立即同步到所有节点。数据在传播过程中,可能会出现短暂的不一致。例如数据的同步过程可以是异步的,因此在不同节点上查询到的数据可能会有所不同,但最终这些数据会达到一致。
- 最终一致性是指系统不需要在每次操作后立即保证一致性,但经过一段时间后(通常是系统稳定后),系统中的所有数据副本将最终达到一致状态。例如社交媒体的点赞数显示,可能会有短暂的延迟,但最终所有用户看到的点赞数是一致的。
传统本地事务的ACID是追求的强一致性,而BASE理论是相比较于ACID理论的一种妥协,放弃了强一致性,追求的是可用性和最终一致性:
3、Raft算法
3.1、基本概念
它的主要目标是确保在多个节点间保持一致的状态,即使在某些节点发生故障的情况下,整个系统仍能继续正常运行。
在Raft算法的概念中,服务器通常可能处于候选状态
,领导状态
,随从状态
:
- Leader(领导者):负责处理客户端请求,并将日志条目复制到其他节点(称为追随者)。
- Follower(追随者):被动接受领导者的日志复制和指令。如果它们在选定时间内没有收到领导者的心跳信号,就会发起选举。
- Candidate(候选者):当追随者没有从领导者那里接收到心跳信号时,会发起领导者选举,并将自己转换为候选者。
3.2、执行流程
- 如果集群中没有领导,或是所有节点一段时间内都没有接收到领导节点的心跳消息,就会进行自旋,最先自旋成功的节点会成为候选者。
- 候选者会向其他所有节点发送投票请求。如果获得了集群中过半节点的投票(包括自己),则成为领导者。
- 客户端会向领导者发送消息,领导将其作为日志记录,并且发送给其他节点。
- 当过半数的节点接受到了日志记录并反馈给领导节点,领导节点和其他节点才会真正将数据写入。
3.3、关键点
Raft的几个关键点:
- 心跳机制:领导者定期向追随者发送“心跳”信号,防止它们发起选举。心跳的目的是告诉追随者领导者仍然存活,且没有新的日志需要同步。
- 集群中的故障恢复:Raft允许多个节点在任期中出现故障,但只要集群中仍有过半数节点存活,系统就可以继续正常工作。丢失的日志可以通过从领导者那里重新同步来恢复。
- 日志冲突解决:如果追随者的日志与领导者不一致,Raft会通过回退追随者的日志到匹配的点,然后从领导者复制缺失的日志条目。这确保了所有节点的日志保持一致。
同时,通常集群中的节点数量应是单数,如果是双数,可能会导致一种问题:在无领导者的情况下,两个节点同时自旋成功成为候选者,即如果有4个节点的集群,候选者需要至少3票才能当选领导者。如果有两个节点同时成为候选者,他们会分别向集群中的其他节点发送投票请求,每个节点只能投票给其中一个候选者。节点A和节点B分别获得自己和另外一个追随者的投票,导致投票结果为2票对2票。由于4个节点中没有一个候选者获得过半数的票数(至少3票),本轮选举失败。
为了解决这样的问题,Raft算法引入了选举重试机制
:
- 每个候选者在等待投票的过程中都有一个随机的选举超时计时器。(同时每个节点的自旋时间也是不一样的)
- 如果某个候选者在超时之前没有成功成为领导者(如选票僵局),它将重新发起选举,并递增当前的任期(Term)。
- 其他追随者会根据任期号,响应新的选举并重新投票。
3、网络分区问题
假设我们有一个5节点的Raft集群(如A、B、C、D、E),并且发生了网络分区,导致集群被分成两部分:(假设在分区发生之前,节点A是领导者。)
- 子集群1:A、B
- 子集群2:C、D、E
子集群1的状态:在子集群1(A、B)中,领导者A无法与子集群2中的C、D、E通信。A会继续发送心跳给B,B仍然会接受这些心跳信号,认为A是当前的领导者。然而,A无法与子集群2通信,因此无法将日志条目复制到多数节点(至少3个节点)
,无法提交新的日志条目。
子集群2的状态:C、D、E无法接收到A的心跳信号,因此这些节点会认为领导者A已经失联。由于没有领导者,C、D、E中的一个节点会在选举超时时间到期后发起新的选举
。当C、D、E节点中的某个节点(假设是C)发起选举,它会请求D和E投票。如果C获得了过半节点的选票(包括D和E的投票,共3票),C会成为新的领导者。然后C会继续在子集群2中执行领导者角色,处理客户端请求并在C、D、E之间进行日志复制。
此时集群1和集群2都产生了各自的领导,当网络分区恢复后,子集群1和子集群2能够再次相互通信时,Raft会通过以下机制确保系统最终达到一致性:
- 任期检查:恢复后,子集群1中的节点(A、B)会发现子集群2中已经选出了一个新的领导者C。Raft的选举机制中,
领导者的任期号是递增的。
如果子集群1中的A发现C的任期号比自己大,它会自动降级为追随者
,并放弃自己的领导者地位。B也会放弃对A的支持,转而支持新的领导者C。 - 日志复制:领导者C会向A、B发送心跳并进行日志同步。Raft通过日志索引和任期号确保所有节点的日志达到一致性。如果A或B的日志比C落后,C会发送缺失的日志条目给它们进行补齐,直到所有节点的日志完全同步。
4、Spring Cloud Alibaba Seata
Seata是 Spring Cloud Alibaba的组件,提供了一个成熟的分布式事务解决方案,关于seata的配置和使用,详见分布式事务控制方案seata理论与实践
Seata常用的AT模式是存在局限性的,需要在每个本地事务中记录回滚日志、执行二阶段提交、获取全局锁,这些操作都会对系统性能产生影响,不适用于高并发的场景。AT模式的好处在于,回滚是全自动的,不需要手动编写回滚的逻辑,只需要在方法上加入@GlobalTransactional
注解。而本项目的业务逻辑,使用的也是Rabbit MQ 死信+延迟队列,实现的最终一致性方案。
下一篇:RabbitMQ高级篇&最终一致性解决方案。