本文将详细介绍云商城的订单服务。云商城完整项目介绍见文章 【Project】云商城
订单服务
电商系列涉及到 3 流,分别为信息流、资金流、物流,而订单系统作为中枢将三者有机的集合起来。
订单模块是电商系统的枢纽,在订单这个模块上获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息疏通
本项目的订单服务 mall-order
需要实现的功能:
- 订单服务登录拦截
- 用户在购物车页点击【去结算】,将购物车内商品信息封装成订单确认页数据
OrderConfirmVo
,并跳转到订单确认页 confirm.html
- 用户在订单确认页确定订单信息后点击【提交订单】,将根据前端传来的订单确认页数据创建出订单实体对象,并持久化到数据库中。30 分钟后关闭失败订单
- 之后远程调用库存服务锁定库存。并在 50 分钟后进行失败订单的库存解锁
- 用户点击【支付订单】后,使用支付宝支付服务完成订单支付
在订单服务中使用消息队列保证整体事务一致性(订单和库存事务一致)。其他要求并发性不高的场景可以使用 Seata(例如后台管理系统中的分布式事务)
完整的订单中心依次需要流程:
订单创建与支付:
- 订单创建前需要预览订单,选择收货信息等
- 订单创建需要锁定库存,库存有才可创建,否则不能创建
- 订单创建后超时未支付需要解锁库存
- 支付成功后,需要进行拆单,根据商品打包方式,所在仓库,物流等进行拆单
- 支付的每笔流水都需要记录,以待查账
- 订单创建,支付成功等状态都需要给MQ发送消息,方便其他系统感知订阅
逆向流程:
- 修改订单,用户没有提交订单,可以对订单一些信息进行修改,比如配送信息,优惠信息,及其他一些订单可修改范围的内容,此时只需对数据进行变更即可。
- 订单取消,用户主动取消订单和用户超时未支付,两种情况下订单都会取消订单,而超时情况是系统自动关闭订单,所以在订单支付的响应机制上面要做支付的
订单中心涉及的信息
1. 用户信息
用户信息包括是用户账号、用户等级、用户的收货地址、收货人、收货人电话、用户账号需要绑定手机号码,但是用户绑定的手机号码不一定是收货信息上的电话。用户可以添加多个收货信息,用户等级信息可以用来和促销系统进行匹配,获取商品折扣,同时用户等级还可以获取积分的奖励等。
2. 订单基础信息
订单基础信息是订单流转的核心,其包括订单类型,父/子订单、订单编号、订单状态、订单流转时间等。
3. 商品信息
商品信息从商品数据库中获取商品的 SKU 信息、图片、名称、属性规格、商品单价、商户信息等,从用户下单行为记录的用户下单数量,商品合计价格等。
4. 优惠信息
优惠信息记录用户参与过的优惠活动,包括优惠促销活动,比如满减、满赠、秒杀等,用户使用的优惠卷信息,优惠卷满足条件的优惠卷需要展示出来,另外虚拟币抵扣信息等进行记录
为什么把优惠信息单独拿出来而不放在支付信息里面呢?因为优惠信息只是记录用户使用的条目,而支付信息需要加入数据进行计算,所以做为区分。
5. 支付信息
- 支付流水单号:这个流水单号是在唤起网关支付后支付通道返回给电商业务平台的支付流水号,财务通过订单号和流水单号与支付通道进行对账使用
- 支付方式:用户使用的支付方式,比如微信支付、支付宝支付、钱包支付、快捷支付等。支付方式有时候可能有两个一-余额支付+第三方支付
- 商品总金额:每个商品加总后的金额
- 运费:物流价格
- 优惠总金额:包括促销活动的优惠金额
- 优惠券优惠金额,虚拟积分或者虚拟币抵扣的金额,会员折扣的金额等之和
- 实付金额:用户实际需要付款的金额
用户实付金额 = 商品总金额 + 运费 - 优惠总金额
6. 物流信息
物流信息包括配送方式,物流公司,物流单号,物流状态,物流状态可以通过第三方接口来
获取和向用户展示物流每个状态节点。
订单状态
1. 待付款
用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支
付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超
时后将自动取消订单,订单变更关闭状态。
2. 已付款 / 代发货
用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调动、配货、分拣,出库等操作
3. 待收货 / 已发货
仓库将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时熟悉商品的物流状态
4. 已完成
用户确认收货后吗,订单交易完成,后续支付则进行计算,如果订单存在问题进入售后状态
5. 已取消
付款之前取消订单,包括超时未付款或用户商户取消订单都会产生这种订单状态
6. 售后中
用户在付款后申请退款,或商家发货后用户申请退货。
售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。
数据模型设计
订单确认项 OrderConfirmVo
一个订单 OrderConfirmVo
中有多个订单项 OrderItemVo
。对应着一个购物车 CartVo
中有多个购物项 CartItemVo
。
OrderConfirmVo
是在点击购物车页面的【去结算】按钮后进行查询构建的。一个订单 OrderConfirmVo
对象包含了所有选中的购物项(订单项)、会员收货地址、优惠券、防重令牌、订单项是否有库存、订单项总数、订单总金额。其中订单项总数和订单总金额需要自动计算。
完整定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| public class OrderConfirmVo { @Getter @Setter private List<OrderItemVo> items;
@Getter @Setter private List<MemberAddressVo> memberAddressVos;
@Getter @Setter private Integer integration;
@Getter @Setter private String orderToken;
@Getter @Setter Map<Long, Boolean> stocks;
public Integer getCount() { Integer count = 0; if (items != null && items.size() > 0) { for (OrderItemVo item : items) { count += item.getCount(); } } return count; }
public BigDecimal getTotal() { BigDecimal totalNum = BigDecimal.ZERO; if (items != null && items.size() > 0) { for (OrderItemVo item : items) { BigDecimal itemPrice = item.getPrice().multiply(new BigDecimal(item.getCount().toString())); totalNum = totalNum.add(itemPrice); } } return totalNum; }
public BigDecimal getPayPrice() { return getTotal(); } }
|
订单页面的展示效果:
订单项 OrderItemVo
订单项 OrderItemVo
是一个订单中的重要部分。一个订单 OrderConfirmVo
由多个订单项 OrderItemVo
组成。
订单项 OrderItemVo
的获取是通过远程调用购物车服务获取到的,因此订单项和购物项的内容是相同的,只不过抽取成了不同的名称而已。订单项 OrderItemVo
的完整定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Data public class OrderItemVo { private Long skuId;
private Boolean check = true;
private String title;
private String image;
private List<String> skuAttrValues;
private BigDecimal price;
private Integer count;
private BigDecimal totalPrice;
private BigDecimal weight = new BigDecimal("0.085"); }
|
会员收货地址 MemberAddressVo
会员收货地址 MemberAddressVo
信息需要远程调用会员服务进行查询。完整定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| @Data public class MemberAddressVo { private Long id;
private Long memberId;
private String name;
private String phone;
private String postCode;
private String province;
private String city;
private String region;
private String detailAddress;
private String areacode;
private Integer defaultStatus; }
|
这些 VO 对象都是和前端进行交互时传递的数据结构。实际保存到数据库中的是订单实体类和订单项为 OrderEntity
和 OrderItemEntity
。
订单实体类 OrderEntity
订单实体类保存了订单的价格、优惠、积分等信息,同时也保存了收货人信息、收货地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175
| @Data @TableName("oms_order") public class OrderEntity implements Serializable { private static final long serialVersionUID = 1L;
@TableId private Long id;
private Long memberId;
private String orderSn;
private Long couponId;
private Date createTime;
private String memberUsername;
private BigDecimal totalAmount;
private BigDecimal payAmount;
private BigDecimal freightAmount;
private BigDecimal promotionAmount;
private BigDecimal integrationAmount;
private BigDecimal couponAmount;
private BigDecimal discountAmount;
private Integer payType;
private Integer sourceType;
private Integer status;
private String deliveryCompany;
private String deliverySn;
private Integer autoConfirmDay;
private Integer integration;
private Integer growth;
private Integer billType;
private String billHeader;
private String billContent;
private String billReceiverPhone;
private String billReceiverEmail;
private String receiverName;
private String receiverPhone;
private String receiverPostCode;
private String receiverProvince;
private String receiverCity;
private String receiverRegion;
private String receiverDetailAddress;
private String note;
private Integer confirmStatus;
private Integer deleteStatus;
private Integer useIntegration;
private Date paymentTime;
private Date deliveryTime;
private Date receiveTime;
private Date commentTime;
private Date modifyTime; }
|
订单项实体类 OrderItemEntity
订单项实体类同时包含了当前商品的 SKU 信息和 SPU 信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
| Data @TableName("oms_order_item") public class OrderItemEntity implements Serializable { private static final long serialVersionUID = 1L;
@TableId private Long id;
private Long orderId;
private String orderSn;
private Long spuId;
private String spuName;
private String spuPic;
private String spuBrand;
private Long categoryId;
private Long skuId;
private String skuName;
private String skuPic;
private BigDecimal skuPrice;
private Integer skuQuantity;
private String skuAttrsVals;
private BigDecimal promotionAmount;
private BigDecimal couponAmount;
private BigDecimal integrationAmount;
private BigDecimal realAmount;
private Integer giftIntegration;
private Integer giftGrowth; }
|
订单汇总 OrderCreateTo
订单汇总类 OrderCreateTo
负责封装订单实体类 OrderEntity
和订单项实体类 List<OrderItemEntity>
。
1 2 3 4 5 6 7 8 9 10 11 12
| @Data public class OrderCreateTo { private OrderEntity order;
private List<OrderItemEntity> orderItems;
private BigDecimal payPrice;
private BigDecimal fare; }
|
库存服务核心数据模型
库存服务中的数据模型分析见 库存服务数据模型。
订单服务登录拦截
首先为订单服务增加登录拦截器,在每个访问订单服务的请求访问前进行拦截:
- 允许路径匹配
/order/order/infoByOrderSn/**
与 /payed/**
的请求直接放行。因为这些远程调用请求是和消息队列相关的定时任务(定时删单和定时支付)。这些请求不包含用户信息,因此无法验证身份,可以直接放行
- 从 Redis 中查找当前用户的 Session(订单服务同样需要配置 Spring Session)
- 若存在,则查出其会员信息
MemberResponseVo
,将该会员信息 MemberResponseVo
保存到 ThreadLocal
中,其将被 Service 层读取
- 若不存在,则当前用户未登录,重定向到登录页面令其登录
代码:
- 自定义登录拦截器,若用户未登录则跳转到登录页面;若已登录则将用户信息存入
ThreadLocal
中在 Service 层获取:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
public class LoginInterceptor implements HandlerInterceptor { public static ThreadLocal<MemberResponseVo> loginUserThreadLocal = new ThreadLocal<>();
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI = request.getRequestURI(); AntPathMatcher matcher = new AntPathMatcher(); boolean match1 = matcher.match("/order/order/infoByOrderSn/**", requestURI); boolean match2 = matcher.match("/payed/**", requestURI); if (match1 || match2) return true;
HttpSession session = request.getSession(); MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER); if (memberResponseVo != null) { loginUserThreadLocal.set(memberResponseVo); return true; }else { session.setAttribute("msg","请先登录"); response.sendRedirect("http://auth.yunmall.com/login.html"); return false; } }
@Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
} }
|
- 将其注册到容器中:
1 2 3 4 5 6 7
| @Configuration public class MyMallWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**"); } }
|
【去结算】:生成订单确认页数据
【去结算】业务将根据购物车中各种信息生成订单确认页数据 OrderConfirmVo
对象,并发送给前端展示
在购物车页点击【去结算】按钮后,将发出请求 http://order.yunmall.com/toTrade 访问订单服务。
跳转后,订单服务将完成:
- 先进行拦截判断用户是否登录。登录后从
ThreadLocal
中获取用户的会员信息(为了防止 ThreadLocal
内存泄漏,需要在获取用户信息后手动清除数据,从 ThreadLocalMap
中移除该 ThreadLocal
及其保存的会员数据)
- 异步远程调用购物车服务的
getCheckedItems()
方法,查询用户勾选上的购物项信息
- 在上一步的基础上远程调用库存服务的
getSkuHasStock()
方法,查询每个购物项的库存信息
- 异步远程调用会员服务的
getAddressByUserId()
方法,查询当前会员的所有收货地址
- 设置防重令牌。随机生成一个 UUID 作为令牌保存到 Redis 中,并且保存到
OrderConfirmVo
对象中一并返回给客户端。之后,用户在点击【提交订单】时将带上该令牌保证同一个订单只能提交一次(幂等性)
最终生成订单确认页数据返回给前端渲染展示。
流程图:
设置防重令牌是为了解决幂等性问题
订单确认页的渲染效果:
业务代码
Contoller 层:
1 2 3 4 5 6
| @RequestMapping("/toTrade") public String toTrade(Model model) { OrderConfirmVo confirmVo = orderService.confirmOrder(); model.addAttribute("confirmOrder", confirmVo); return "confirm"; }
|
Service 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| @Override public OrderConfirmVo confirmOrder() { MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get(); LoginInterceptor.loginUserThreadLocal.remove();
OrderConfirmVo confirmVo = new OrderConfirmVo(); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> itemAndStockFuture = CompletableFuture.supplyAsync(() -> { RequestContextHolder.setRequestAttributes(requestAttributes); List<OrderItemVo> checkedItems = cartFeignService.getCheckedItems(); confirmVo.setItems(checkedItems); return checkedItems; }, executor).thenAcceptAsync((items) -> { List<Long> skuIds = items.stream().map(OrderItemVo::getSkuId).collect(Collectors.toList()); Map<Long, Boolean> hasStockMap = wareFeignService.getSkuHasStock(skuIds).stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId, SkuHasStockVo::getHasStock)); confirmVo.setStocks(hasStockMap); }, executor);
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> { List<MemberAddressVo> addressByUserId = memberFeignService.getAddressByUserId(memberResponseVo.getId()); confirmVo.setMemberAddressVos(addressByUserId); }, executor);
confirmVo.setIntegration(memberResponseVo.getIntegration());
String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId(), token, 30, TimeUnit.MINUTES); confirmVo.setOrderToken(token);
try { CompletableFuture.allOf(itemAndStockFuture, addressFuture).get(); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } return confirmVo; }
|
该方法执行完毕后,即可构建出订单对象 OrderConfirmVo
。
Feign 远程调用丢失请求头问题
在远程调用其他服务时会出现 Feign 远程调用丢失请求头问题:在远程调用购物车服务 cartFeignService
时,会发现 Feign 并没有把当前服务的请求头加到远程请求中。
这是因为 Feign 在创建 cartFeignService
接口的代理对象时创建了一个新的 HttpRequest
对象,并且没有给该对象添加订单服务的请求头,从而在远程调用到购物车服务时,因缺少请求头内的 Cookie 信息导致购物车服务的登陆拦截器判定此请求没有登录(无法获取到登录用户信息),从而无法获取到购物车项信息。
解决该问题的方法:向容器中注入自定义的请求拦截器,在 Feign 发出远程调用前先执行该拦截器内的方法,将原始请求中的 Cookie 放到请求头里。
使用拦截器而非过滤器是因为:拦截器是 Spring 的组件,被 Spring 容器管理,可以实现自动注入等功能。
自定义请求拦截器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Configuration public class MyFeignConfig {
@Bean public RequestInterceptor requestInterceptor() { return new RequestInterceptor() {
@Override public void apply(RequestTemplate template) { ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (requestAttributes != null) { HttpServletRequest request = requestAttributes.getRequest(); if (request != null) { String cookie = request.getHeader("Cookie"); template.header("Cookie", cookie); } } } }; } }
|
其中,RequestContextHolder
中保存的数据也是存在 ThreadLocal
中的,可以在同一个线程内共享 HttpServletRequest
对象。
CartFeignService
是一个接口。在 @Autowire
注入时没有实例化代理对象,只有在调用其方法时才会基于代理模式创建代理对象,并执行所有拦截器 RequestInterceptor
中的方法,然后才执行远程调用
这样在远程调用购物车服务时,就会把 Cookie 放到请求头中,从而购物车服务也就有了登录用户信息。
【提交订单】:创建订单
用户在跳转到 confirm.html
页面后,点击【提交订单】发送 /submitOrder
请求,并将上一步生成的订单确认页数据 OrderSubmitVo
从前端传递过来,后端将根据该信息构建出完整的订单信息(包括订单项、会员信息、收货地址、优惠信息、价格等信息),并且完成订单持久化、库存锁定以及清空购物车。
submitOrder
请求处理器依次完成以下逻辑:
- 原子验证防重令牌:
- 从前端传递过来的
OrderSubmitVo
信息中获取前端此次请求携带的防重令牌
- 根据用户 id 到 Redis 中查询该用户对应的防重令牌(
order:token:userId - uuid
)
- 对比二者是否相等。若不相等,则校验不通过,当前提交的订单是重复订单,驳回。若相等,则校验通过,继续下面业务
- 汇总订单信息
OrderCreateTo
:根据前端传来的 OrderSubmitVo
信息创建出订单 OrderEntity
与订单项 OrderItemEntity
实体类对象(这些对象将存储到数据库中)并封装到 OrderCreateTo
对象中
- 价格校验:验证后台计算出的价格和前台传来的价格是否一致。若不一致则直接返回
- 订单持久化:保存订单信息到数据库中
- 库存锁定:远程调用库存服务锁定这些订单项的库存(详细分析见【提交订单】库存锁定)
- 若库存锁定失败,则整个本地事务回滚(将导致前面持久化的订单信息回滚)
- 若库存锁定成功,则持久化库存数据并发送库存工作单详情消息到库存服务的延迟队列中等待 50 分钟后的库存解锁判断(详细分析见【提交订单】库存解锁)
- 定时关单:库存锁定成功后,将订单实体数据
OrderEntity
发送到延迟队列中,在定时 30 分钟后被消费者监听,判断该订单的状态(详细分析见【提交订单】定时关单):
- 如果订单状态仍为新创建
CREATE_NEW
,代表该订单超过三十分钟未支付,那么就关闭该订单(将订单状态设置为取消 CANCLED
)。手动回复 Ack 。同时发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了。例如:1. 库存服务可以解锁库存了。2. 优惠券服务可以返回优惠券了
- 如果该订单的状态不是新建,代表该订单已经支付成功,不再进行关单
- 如果该过程出现异常,则手动回复 Reject,令该消息重新入队,等待其他消费者再次消费,避免消息丢失
- 清空购物车:库存锁定成功后清空购物车信息
其中,原子验证防重令牌的原因见幂等性问题
流程图:
业务主线代码
Controller 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @RequestMapping("/submitOrder") public String submitOrder(OrderSubmitVo submitVo, Model model, RedirectAttributes attributes) { try { SubmitOrderResponseVo responseVo = orderService.submitOrder(submitVo); Integer code = responseVo.getCode(); if (code == 0) { model.addAttribute("order", responseVo.getOrder()); return "pay"; } else { String msg = "下单失败;"; switch (code) { case 1: msg += "防重令牌校验失败"; break; case 2: msg += "商品价格发生变化"; break; } attributes.addFlashAttribute("msg", msg); return "redirect:http://order.yunmall.com/toTrade"; } } catch (Exception e) { System.out.println(e); if (e instanceof NoStockException) { String msg = "下单失败,商品无库存"; attributes.addFlashAttribute("msg", msg); } return "redirect:http://order.yunmall.com/toTrade"; } }
|
Service 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128
|
@Override public SubmitOrderResponseVo submitOrder(OrderSubmitVo submitVo) { SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo(); responseVo.setCode(0); MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get(); String orderToken = submitVo.getOrderToken(); String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()), orderToken); if (result == 0L) { responseVo.setCode(1); return responseVo; }
OrderCreateTo order = createOrder(memberResponseVo, submitVo);
BigDecimal payAmount = order.getOrder().getPayAmount(); BigDecimal payPrice = submitVo.getPayPrice(); if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) { saveOrder(order);
List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> { OrderItemVo orderItemVo = new OrderItemVo(); orderItemVo.setSkuId(item.getSkuId()); orderItemVo.setCount(item.getSkuQuantity()); return orderItemVo; }).collect(Collectors.toList()); WareSkuLockVo lockVo = new WareSkuLockVo(); lockVo.setOrderSn(order.getOrder().getOrderSn()); lockVo.setLocks(orderItemVos); R r = wareFeignService.orderLockStock(lockVo); if (r.getCode() == 0) { responseVo.setOrder(order.getOrder()); responseVo.setCode(0);
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(CartConstant.CART_PREFIX + memberResponseVo.getId()); for (OrderItemEntity orderItem : order.getOrderItems()) { ops.delete(orderItem.getSkuId().toString()); } return responseVo; } else { String msg = (String) r.get("msg"); throw new NoStockException(msg); } } else { responseVo.setCode(2); }
return responseVo; }
private void compute(OrderEntity entity, List<OrderItemEntity> orderItemEntities) { BigDecimal total = BigDecimal.ZERO; BigDecimal promotion = new BigDecimal("0.0"); BigDecimal integration = new BigDecimal("0.0"); BigDecimal coupon = new BigDecimal("0.0"); Integer integrationTotal = 0; Integer growthTotal = 0;
for (OrderItemEntity orderItemEntity : orderItemEntities) { total = total.add(orderItemEntity.getRealAmount()); promotion = promotion.add(orderItemEntity.getPromotionAmount()); integration = integration.add(orderItemEntity.getIntegrationAmount()); coupon = coupon.add(orderItemEntity.getCouponAmount()); integrationTotal += orderItemEntity.getGiftIntegration(); growthTotal += orderItemEntity.getGiftGrowth(); }
entity.setTotalAmount(total); entity.setPromotionAmount(promotion); entity.setIntegrationAmount(integration); entity.setCouponAmount(coupon); entity.setIntegration(integrationTotal); entity.setGrowth(growthTotal);
entity.setPayAmount(entity.getFreightAmount().add(total));
entity.setDeleteStatus(0); }
private void saveOrder(OrderCreateTo orderCreateTo) { OrderEntity order = orderCreateTo.getOrder(); order.setCreateTime(new Date()); order.setModifyTime(new Date()); this.save(order); orderItemService.saveBatch(orderCreateTo.getOrderItems()); }
|
汇总订单信息 OrderCreateTo
其中,汇总订单信息 OrderCreateTo
的逻辑:
- 使用
IdWorker.getTimeId()
生成订单号 orderSn
(该字段在数据库中添加了唯一索引 UNIQUE
,保证了数据库层面的幂等性)
buildOrder()
:构建订单实体类 OrderEntity
(包含订单号、用户信息、收货地址信息以及订单状态等信息)
buildOrderItems()
:构建订单项实体类 List<OrderItemEntity>
(包含订单号、SKU信息、SKU销售属性、SPU信息、积分信息、价格信息等信息)
- 计算价格
- 将订单
OrderEntity
和订单项信息 List<OrderItemEntity>
封装到 OrderCreateTo
对象中
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
|
private OrderCreateTo createOrder(MemberResponseVo memberResponseVo, OrderSubmitVo submitVo) { String orderSn = IdWorker.getTimeId(); OrderEntity entity = buildOrder(memberResponseVo, submitVo, orderSn); List<OrderItemEntity> orderItemEntities = buildOrderItems(orderSn); compute(entity, orderItemEntities); OrderCreateTo createTo = new OrderCreateTo(); createTo.setOrder(entity); createTo.setOrderItems(orderItemEntities); return createTo; }
private OrderEntity buildOrder(MemberResponseVo memberResponseVo, OrderSubmitVo submitVo, String orderSn) { OrderEntity orderEntity = new OrderEntity(); orderEntity.setOrderSn(orderSn);
orderEntity.setMemberId(memberResponseVo.getId()); orderEntity.setMemberUsername(memberResponseVo.getUsername());
FareVo fareVo = wareFeignService.getFare(submitVo.getAddrId()); BigDecimal fare = fareVo.getFare(); orderEntity.setFreightAmount(fare); MemberAddressVo address = fareVo.getAddress(); orderEntity.setReceiverName(address.getName()); orderEntity.setReceiverPhone(address.getPhone()); orderEntity.setReceiverPostCode(address.getPostCode()); orderEntity.setReceiverProvince(address.getProvince()); orderEntity.setReceiverCity(address.getCity()); orderEntity.setReceiverRegion(address.getRegion()); orderEntity.setReceiverDetailAddress(address.getDetailAddress());
orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode()); orderEntity.setConfirmStatus(0); orderEntity.setAutoConfirmDay(7);
return orderEntity; }
private List<OrderItemEntity> buildOrderItems(String orderSn) { List<OrderItemVo> checkedItems = cartFeignService.getCheckedItems(); List<OrderItemEntity> orderItemEntities = checkedItems.stream().map((item) -> { OrderItemEntity orderItemEntity = buildOrderItem(item); orderItemEntity.setOrderSn(orderSn); return orderItemEntity; }).collect(Collectors.toList()); return orderItemEntities; }
private OrderItemEntity buildOrderItem(OrderItemVo item) { OrderItemEntity orderItemEntity = new OrderItemEntity(); Long skuId = item.getSkuId(); orderItemEntity.setSkuId(skuId); orderItemEntity.setSkuName(item.getTitle()); orderItemEntity.setSkuAttrsVals(StringUtils.collectionToDelimitedString(item.getSkuAttrValues(), ";")); orderItemEntity.setSkuPic(item.getImage()); orderItemEntity.setSkuPrice(item.getPrice()); orderItemEntity.setSkuQuantity(item.getCount());
R r = productFeignService.getSpuBySkuId(skuId); if (r.getCode() == 0) { SpuInfoTo spuInfo = JSON.parseObject(JSON.toJSONString(r.get("spuInfo")), SpuInfoTo.class); orderItemEntity.setSpuId(spuInfo.getId()); orderItemEntity.setSpuName(spuInfo.getSpuName()); orderItemEntity.setSpuBrand(spuInfo.getBrandName()); orderItemEntity.setCategoryId(spuInfo.getCatalogId()); }
orderItemEntity.setGiftGrowth(item.getPrice().multiply(new BigDecimal(item.getCount())).intValue()); orderItemEntity.setGiftIntegration(item.getPrice().multiply(new BigDecimal(item.getCount())).intValue());
orderItemEntity.setPromotionAmount(BigDecimal.ZERO); orderItemEntity.setCouponAmount(BigDecimal.ZERO); orderItemEntity.setIntegrationAmount(BigDecimal.ZERO);
BigDecimal origin = orderItemEntity.getSkuPrice().multiply(new BigDecimal(orderItemEntity.getSkuQuantity())); BigDecimal realPrice = origin.subtract(orderItemEntity.getPromotionAmount()) .subtract(orderItemEntity.getCouponAmount()) .subtract(orderItemEntity.getIntegrationAmount()); orderItemEntity.setRealAmount(realPrice);
return orderItemEntity; }
|
【提交订单】:库存锁定
在订单持久化后,订单服务将远程调用库存服务锁定这些订单项的库存,其将传入一个 WareSkuLockVo
对象,封装了订单号 order_sn
和订单项 OrderItemVo
信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> { OrderItemVo orderItemVo = new OrderItemVo(); orderItemVo.setSkuId(item.getSkuId()); orderItemVo.setCount(item.getSkuQuantity()); return orderItemVo; }).collect(Collectors.toList());
WareSkuLockVo lockVo = new WareSkuLockVo(); lockVo.setOrderSn(order.getOrder().getOrderSn()); lockVo.setLocks(orderItemVos);
R r = wareFeignService.orderLockStock(lockVo);
|
库存锁定逻辑
- 首先库存服务收到订单服务的远程调用后,将先创建一个
WareOrderTaskEntity
对象,其代表 "订单 & 库存 - 工作单" 信息,其主要保存每个订单的订单号 order_sn
信息,方便库存解锁时回溯找到问题订单(因为问题订单可能已经回滚了,如果不保存工作单就找不到需要解锁的订单了)
- 接着遍历订单服务传来的所有订单项数据,为其创建 “SKU - 仓库号” 信息
SkuLockVo
,其包含当前 SKU 在哪些仓库里有库存,并且库存数量(num
)是多少
- 遍历每一个订单项,为其锁定库存:从查询出的还有库存的仓库中找出任意一个仓库,在该仓库中锁定当前商品:
- 如果每一个商品都能锁定成功,则所有商品的 "商品 & 库存 - 工作单详情" 信息将发送到库存服务的延迟队列
stock.delay.queue
中,等待 50 分钟后过期。将由 StockReleaseListener
监听后解锁(库存解锁的详细分析见【提交订单】库存解锁)
- 如果某一个商品锁定失败,就抛出异常,前面保存的工作单信息都将回滚。这样前面放到延迟队列里的工作单详情数据即使在过期后需要解锁,但由于在数据库中已经查不到该工作单详情信息(因为回滚了),所以也不会解锁这些库存
- 在数据库中锁定库存:在
wms_ware_sku
表中给 skuId
商品的 stock_locked
字段增加数量 num
,代表锁定该商品的 num
个库存
- 数据库锁定成功后,创建 "商品 & 库存 - 工作单详情" 对象
WareOrderTaskDetailEntity
,该对象中保存了订单项的 skuId
、下单数量 num
、“订单 & 库存 - 工作单” id、仓库 id 以及锁定状态(已锁定)。将其持久化到数据库表 wms_ware_order_task_detail
中。在之后解锁库存时,将去数据库中查找该工作单详情数据,并据此来进行库存解锁。
- 将封装后的 “商品 & 库存 - 工作单详情” 数据发送到
stock.release.stock.queue
库存延迟队列中。该数据将在 50 分钟后过期,被 StockReleaseListener
监听后进行库存解锁
以上只要有一个订单项的库存锁定失败,就会抛出异常,令本地事务整体回滚。数据库中不会存在这些库存工作单以及库存工作详情单,库存解锁时也就不会解锁这些库存了。
以上过程的流程图:
其中,锁定库存成功后,需要将库存工作单和工作单详情数据发送到库存服务的延迟队列 stock.delay.queue
中,并设置过期时间为 50 分钟。当消息过期之后,将该消息路由到解锁队列中,等待进行库存解锁的业务【提交订单】库存解锁
库存服务数据模型
- "订单 & 库存 - 工作单":其主要保存每个订单的订单号
order_sn
信息,方便库存解锁时回溯找到问题订单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| @Data @TableName("wms_ware_order_task") public class WareOrderTaskEntity implements Serializable { private static final long serialVersionUID = 1L;
@TableId private Long id;
private Long orderId;
private String orderSn;
private String consignee;
private String consigneeTel;
private String deliveryAddress;
private String orderComment;
private Integer paymentWay;
private Integer taskStatus;
private String orderBody;
private String trackingNo;
private Date createTime;
private Long wareId;
private String taskComment; }
|
- "商品 & 库存 - 工作单详情":该对象中保存了订单项的
skuId
、下单数量 num
、“订单 & 库存 - 工作单” id、仓库 id 以及锁定状态(已锁定)。工作单详情数据将保存到数据库中,也会被发送到库存延迟队列中。未来在进行库存解锁时将去数据库查看该工作单详情数据,并据此来判断是否需要解锁库存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| @Builder @Data @TableName("wms_ware_order_task_detail") public class WareOrderTaskDetailEntity implements Serializable { private static final long serialVersionUID = 1L;
@TableId private Long id;
private Long skuId;
private String skuName;
private Integer skuNum;
private Long taskId;
private Long wareId;
private Integer lockStatus; }
|
SkuLockVo
:封装信息,包含当前 SKU 在哪些仓库里有库存,并且库存数量(num
)是多少
1 2 3 4 5 6 7 8 9
|
@Data class SkuLockVo { private Long skuId; private Integer num; private List<Long> wareIds; }
|
库存锁定业务代码
- Controller 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
@RequestMapping("/lock/order") public R orderLockStock(@RequestBody WareSkuLockVo lockVo) { try { Boolean lock = wareSkuService.orderLockStock(lockVo); return R.ok(); } catch (NoStockException e) { return R.error(BizCodeEnum.NO_STOCK_EXCEPTION.getCode(), BizCodeEnum.NO_STOCK_EXCEPTION.getMsg()); } }
|
- Service 层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| @Transactional @Override public Boolean orderLockStock(WareSkuLockVo wareSkuLockVo) { WareOrderTaskEntity taskEntity = new WareOrderTaskEntity(); taskEntity.setOrderSn(wareSkuLockVo.getOrderSn()); taskEntity.setCreateTime(new Date()); wareOrderTaskService.save(taskEntity);
List<OrderItemVo> itemVos = wareSkuLockVo.getLocks(); List<SkuLockVo> lockVos = itemVos.stream().map((item) -> { SkuLockVo skuLockVo = new SkuLockVo(); skuLockVo.setSkuId(item.getSkuId()); skuLockVo.setNum(item.getCount()); List<Long> wareIds = baseMapper.listWareIdsHasStock(item.getSkuId(), item.getCount()); skuLockVo.setWareIds(wareIds); return skuLockVo; }).collect(Collectors.toList());
for (SkuLockVo lockVo : lockVos) { boolean lock = true; Long skuId = lockVo.getSkuId(); List<Long> wareIds = lockVo.getWareIds(); if (wareIds == null || wareIds.size() == 0) { throw new NoStockException(skuId); } else { for (Long wareId : wareIds) { Long count = baseMapper.lockWareSku(skuId, lockVo.getNum(), wareId); if (count == 0) { lock = false; } else { WareOrderTaskDetailEntity detailEntity = WareOrderTaskDetailEntity.builder() .skuId(skuId) .skuName("") .skuNum(lockVo.getNum()) .taskId(taskEntity.getId()) .wareId(wareId) .lockStatus(1).build(); wareOrderTaskDetailService.save(detailEntity);
StockLockedTo lockedTo = new StockLockedTo(); StockDetailTo detailTo = new StockDetailTo(); BeanUtils.copyProperties(detailEntity, detailTo); lockedTo.setId(taskEntity.getId()); lockedTo.setDetailTo(detailTo);
rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", lockedTo);
lock = true; break; } } } if (!lock) throw new NoStockException(skuId); } return true; }
|
库存锁定后,需要
- 锁库存的 MyBatis 配置文件:
1 2 3 4 5 6 7
| <update id="lockWareSku"> UPDATE wms_ware_sku SET stock_locked = stock_locked + #{num} WHERE sku_id = #{skuId} AND ware_id = #{wareId} AND stock - stock_locked > #{num} </update>
|
为防止高并发下出现超卖问题,即多个请求同时锁某一个库存导致库存数量超过限制,可以使用乐观锁。
例如锁库存前先查询一下目前的库存数量,然后再 UPDATE
时多加一个条件 WHERE stock - stock_locked = 查询时的数量
。这样就能保证高并发下不会出现锁库存超出限制的情况。这样会导致如果当前不匹配,则直接后面没法减库存了,需要重复多次尝试减库存,直至成功为止。
当然也可以直接添加写锁保证原子性:UPDATE xxxxx FOR UPDATE
。
【提交订单】:定时关单
如果库存锁定失败,前面持久化的订单数据就会回滚,也就不需要再关单了。所以只在库存锁定成功后才考虑定时关单问题。
定时关单:库存锁定成功后,将订单实体数据 OrderEntity
发送到延迟队列中,在定时 30 分钟后被消费者监听,判断该订单的状态:
- 如果订单状态仍为新创建
CREATE_NEW
,代表该订单超过三十分钟未支付,那么就关闭该订单(将订单状态设置为取消 CANCLED
)。手动回复 Ack 。同时发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了。例如:
- 库存服务可以解锁库存了(双重保险。确保库存一定能解锁)
- 优惠券服务可以返回优惠券了
- 如果该订单的状态不是新建,代表该订单已经支付成功,不再进行关单
- 如果该过程出现异常,则手动回复 Reject,令该消息重新入队,等待其他消费者再次消费,避免消息丢失
其中,在关单后还要发送解锁库存消息的原因是:作为一种双重保险,确保库存一定能解锁。因为可能出现:
- 库存服务在锁定库存后,还未来得及发送解锁消息到延迟队列就宕机。这时可能导致该库存一直无法解锁
- 订单服务因网络延迟导致关单的响应晚于库存服务的解锁响应,这样该库存在关单后就不会再解锁了
为防止这两种情况的发生,必须在关单后立即发送一次消息通知库存服务解锁库存,这样就能保证库存一定能解锁。
消息路由过程
- 首先,订单服务拥有一个总交换机
order-event-exchange
,订单服务的所有消息都要经过该交换机。
- 订单创建成功且库存锁定成功后, 发送订单数据
OrderEntity
到总交换机处,并且指定路由到延迟队列 order.delay.queue
中(路由键为 order.create.order
)。该队列中所有消息都将阻塞 30 分钟
- 消息过期后,将重新回到总交换机,然后指定路由到关单队列
order.release.order.queue
中(路由键为 order.release.order
)
- 设置消费者
OrderCloseListener
监听该队列,判断监听到的订单数据的状态,并决定是否需要关闭该订单
- 消费者需要手动回复消息是否确认收到,保证消息不丢失
- 在手动回复 Ack 后,订单服务会发送消息通知其他服务,本订单已经关闭了,可以进行其他处理了(路由键为
order.release.other.#
)。例如:
- 发送消息到解锁库存队列
stock.release.stock.queue
通知库存服务可以解锁库存了(双重保险。确保库存一定能解锁,防止因为订单服务的网络延迟导致晚于库存服务关单,这时再发一次消息解锁库存就能保证库存一定能解锁)
- 发送消息到返券队列
order.release.coupon.queue
通知优惠券服务可以返回优惠券了
该过程的流程图:
其中,订单服务的消息队列配置示意图:
整个订单服务只设置了一个总交换机,延迟队列和死信队列都与其绑定,通过不同的路由键实现路由到不同的队列中。
创建交换机和队列
首先创建配置类@Configuration
,为订单服务创建总交换机:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
@Bean public Exchange orderEventExchange() {
return new TopicExchange("order-event-exchange", true, false); }
|
然后创建延迟队列 order.delay.queue
和关单队列 order.release.order.queue
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
@Bean public Queue orderDelayQueue() {
HashMap<String, Object> arguments = new HashMap<>(); arguments.put("x-dead-letter-exchange", "order-event-exchange"); arguments.put("x-dead-letter-routing-key", "order.release.order"); arguments.put("x-message-ttl", 60000); return new Queue("order.delay.queue", true, false, false, arguments); }
@Bean public Queue orderReleaseQueue() { return new Queue("order.release.order.queue", true, false, false); }
|
绑定总交换机和两个消息队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
@Bean public Binding orderCreateBinding() {
return new Binding("order.delay.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.create.order", null); }
@Bean public Binding orderReleaseBinding() { return new Binding("order.release.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.order", null); }
|
绑定库存服务的库存解锁队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
@Bean public Binding orderReleaseOrderBinding() { return new Binding("stock.release.stock.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.other.#", null); }
|
之所以关单后还要再发消息解锁库存是为了做双重保险,确保库存一定能解锁,防止因为订单服务的网络延迟导致晚于库存服务关单,这时再发一次消息解锁库存就能保证库存一定能解锁)
消息生产者
在主线中的第 6 步发送消息:在库存锁定成功后,发送 OrderEntity
数据到订单延迟队列:
1 2 3
|
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());
|
消息消费者
在 listener
包下创建消息消费者,负责监听死信队列(关单队列)order.release.order.queue
中的消息,将过期的订单关闭:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
@Component @RabbitListener(queues = {"order.release.order.queue"}) public class OrderCloseListener {
@Autowired private OrderService orderService;
@RabbitHandler public void listener(OrderEntity orderEntity, Message message, Channel channel) throws IOException { System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn()); long deliveryTag = message.getMessageProperties().getDeliveryTag(); try { orderService.closeOrder(orderEntity); channel.basicAck(deliveryTag,false); } catch (Exception e){ channel.basicReject(deliveryTag, true); } } }
|
需要在配置文件中事先开启手动确认机制
closeOrder()
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
@Override public void closeOrder(OrderEntity orderEntity) { OrderEntity newOrderEntity = this.getById(orderEntity.getId()); if (newOrderEntity.getStatus().equals(OrderStatusEnum.CREATE_NEW.getCode())) { OrderEntity updateOrder = new OrderEntity(); updateOrder.setId(newOrderEntity.getId()); updateOrder.setStatus(OrderStatusEnum.CANCLED.getCode()); this.updateById(updateOrder);
OrderTo orderTo = new OrderTo(); BeanUtils.copyProperties(newOrderEntity, orderTo); rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo); } }
|
【提交订单】:库存解锁
需要解锁库存的场景:
- 【提交订单】业务全部执行成功,但成功创建订单过期没有支付,被【提交订单】定时关单自动取消;或者用户手动选择取消订单。此时需要将之前锁定的库存解锁
- 创建订单成功,库存锁定成功,但在接下来的其他代码执行失败(【提交订单】业务没有全部执行成功),导致前面创建的订单回滚,在数据库中不存在该订单。此时需要将之前锁定的库存解锁
因此,只要库存服务锁定库存成功,就需要立即发送一条消息到库存服务的延迟队列中,在 50 分钟后被消费者监听到消息后,进行库存解锁判断:
- 如果订单表里没有这个订单(说明其他代码抛异常导致订单回滚了),则必须解锁该订单的库存
- 如果订单表里有这个订单,则不一定需要解锁库存:
- 如果订单状态为已取消
CANCLED
,则该订单要么过期要么被手动取消,需要解锁库存
- 如果订单状态为已支付
PAYED
,则该订单不需要解锁库存
那么如何得知应该判断哪个订单的状态呢?如果订单数据在之前已经回滚了,那么在订单表中就找不到问题订单的信息了。因此我们需要在库存锁定后为该订单和库存信息创建 "订单 & 库存 - 工作单" 对象 WareOrderTaskEntity
,其主要保存每个订单的订单号 order_sn
信息,方便库存解锁时回溯找到问题订单(因为问题订单可能已经回滚了,如果不保存工作单就找不到需要解锁的订单了)。根据监听到的订单消息去数据库中重新查找该订单的状态标志位的值,就可判断该订单是否需要解锁库存。
这样在库存锁定成功后,就把该信息发送到延迟队列 stock.delay.queue
中,在 50 分钟后该消息过期,就会路由到 stock.release.stock.queue
队列,由库存服务的消费者 OrderCloseListener
进行解锁。
为防止库存被重复解锁,保证解锁库存的幂等性。在解锁前需要首先判断"商品 & 库存 - 工作单"中当前任务单的状态是否为已锁定 WareTaskStatusEnum.Locked
,如果不是,说明该工作单已经被解锁了,从而无需重复解锁。
库存解锁的消息队列示意图:
完整示意图:
消息生产者
库存解锁的消息生产者为【提交订单】库存锁定:
1 2 3
|
rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", lockedTo);
|
消息消费者
- 库存服务的消费负责监听两个队列,分别为库存服务之前发送的解锁库存队列以及订单服务发来的立即解锁库存队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| @Slf4j @Component @RabbitListener(queues = {"stock.release.stock.queue"}) public class StockReleaseListener {
@Autowired private WareSkuService wareSkuService;
@RabbitHandler public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException { log.info("************************ 收到库存解锁的消息 ********************************"); try { wareSkuService.unlock(stockLockedTo); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { channel.basicReject(message.getMessageProperties().getDeliveryTag(),true); } }
@RabbitHandler public void handleStockLockedRelease(OrderTo orderTo, Message message, Channel channel) throws IOException { log.info("************************ 从订单服务收到库存解锁的消息 ********************************"); try { wareSkuService.unlock(orderTo); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { channel.basicReject(message.getMessageProperties().getDeliveryTag(),true); } } }
|
- Service 层:根据监听到的订单消息去数据库中重新查找该订单的状态标志位的值,就可判断该订单是否需要解锁库存(注意一定要重新查找当前该订单的状态信息,而不能直接用消息传来的对象内的旧值):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
|
@Override public void unlock(StockLockedTo stockLockedTo) { StockDetailTo detailTo = stockLockedTo.getDetailTo(); WareOrderTaskDetailEntity detailEntity = wareOrderTaskDetailService.getById(detailTo.getId()); if (detailEntity != null) { WareOrderTaskEntity taskEntity = wareOrderTaskService.getById(stockLockedTo.getId()); R r = orderFeignService.infoByOrderSn(taskEntity.getOrderSn()); if (r.getCode() == 0) { OrderTo order = JSON.parseObject(JSON.toJSONString(r.get("orderEntity")), OrderTo.class); if (order == null || order.getStatus().equals(OrderStatusEnum.CANCLED.getCode())) { if (detailEntity.getLockStatus().equals(WareTaskStatusEnum.Locked.getCode())) { unlockStock(detailTo.getSkuId(), detailTo.getSkuNum(), detailTo.getWareId(), detailEntity.getId()); } } } else { throw new RuntimeException("远程调用订单服务失败"); } } }
@Override public void unlock(OrderTo orderTo) { String orderSn = orderTo.getOrderSn(); WareOrderTaskEntity taskEntity = wareOrderTaskService.getBaseMapper() .selectOne((new QueryWrapper<WareOrderTaskEntity>() .eq("order_sn", orderSn))); List<WareOrderTaskDetailEntity> lockDetails = wareOrderTaskDetailService.list( new QueryWrapper<WareOrderTaskDetailEntity>() .eq("task_id", taskEntity.getId()) .eq("lock_status", WareTaskStatusEnum.Locked.getCode())); for (WareOrderTaskDetailEntity lockDetail : lockDetails) { unlockStock(lockDetail.getSkuId(), lockDetail.getSkuNum(), lockDetail.getWareId(), lockDetail.getId()); } }
private void unlockStock(Long skuId, Integer skuNum, Long wareId, Long detailId) { baseMapper.unlockStock(skuId, skuNum, wareId); WareOrderTaskDetailEntity detail = WareOrderTaskDetailEntity.builder() .id(detailId) .lockStatus(2).build(); wareOrderTaskDetailService.updateById(detail); }
|
WareSkuDao.xml
1 2 3 4 5 6
| <update id="unlockStock"> UPDATE wms_ware_sku SET stock_locked = stock_locked - #{skuNum} WHERE sku_id = #{skuId} AND ware_id = #{wareId} </update>
|
【立即支付】:支付宝支付
在用户提交订单后,将跳转到支付页面:
用户需要在该页面内选择支付宝支付订单。关于支付宝支付服务的开通和使用见文章【Alibaba】支付宝支付服务
思路:【提交订单】后,将订单信息发送到支付 PayWebController
进行处理:
- 先根据订单信息封装出支付数据
PayVo
(包含订单号以及支付金额等信息)
- 然后调用支付宝的
AlipayTemplate
远程访问支付宝的服务器,获取一段表单 html(包含 PayVo
信息以及要重定向的支付宝支付页面)
- 令浏览器跳转到该 html 页面,其会立即提交一个表单,重定向去访问支付宝的支付页面
- 用户在支付宝页面支付成功后,立即跳转回会员服务,显示该用户支付成功的订单
- 同时支付宝会异步回调,返回支付成功的详细信息(例如订单号、流水号、交易状态等信息)。当支付成功后支付宝将一直发出该回调请求,返回支付成功相关信息。如果该地址无响应,则会不断发送直到对方应答(最大努力型通知)
- 会员服务监听该异步回调消息,先验签,成功后判断交易状态,若交易状态为
SUCCESS
,代表交易成功。保存订单流水,并更改订单的支付状态为已支付
环境配置
- 导入 Maven 依赖:
1 2 3 4 5
| <dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.9.28.ALL</version> </dependency>
|
- 注入
AlipayTemplate
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| package com.atguigu.gulimall.order.config;
import com.alipay.api.AlipayApiException; import com.alipay.api.AlipayClient; import com.alipay.api.DefaultAlipayClient; import com.alipay.api.request.AlipayTradePagePayRequest; import com.atguigu.gulimall.order.vo.PayVo; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "alipay") @Component @Data public class AlipayTemplate { private String app_id = "2016092200568607";
private String merchant_private_key = "XXX"; private String alipay_public_key = "XXX"; private String notify_url = "http://member.yunmall.com/memberOrder.html";
private String return_url = "http://member.yunmall.com/memberOrder.html";
private String sign_type = "RSA2";
private String charset = "utf-8"; private String timeout = "30m";
private String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";
public String pay(PayVo vo) throws AlipayApiException { AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl, app_id, merchant_private_key, "json", charset, alipay_public_key, sign_type);
AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest(); alipayRequest.setReturnUrl(return_url); alipayRequest.setNotifyUrl(notify_url); String out_trade_no = vo.getOut_trade_no(); String total_amount = vo.getTotal_amount(); String subject = vo.getSubject(); String body = vo.getBody();
alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\"," + "\"total_amount\":\""+ total_amount +"\"," + "\"subject\":\""+ subject +"\"," + "\"body\":\""+ body +"\"," + "\"timeout_express\":\"" + timeout + "\"," + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");
String result = alipayClient.pageExecute(alipayRequest).getBody();
System.out.println("支付宝的响应:"+result);
return result; } }
|
其中:
notify_url
:支付成功异步回调,返回支付成功的详细信息(例如订单号、流水号、交易状态等信息)。当支付成功后支付宝将一直发出该回调请求,返回支付成功相关信息。如果该地址无响应,则会不断发送直到对方应答(最大努力型通知)
return_url
:同步通知,支付成功后页面跳转到那里
支付数据模型
- 定义发送给支付宝的支付对象
PayVo
:
1 2 3 4 5 6 7 8 9 10
|
@Data public class PayVo { private String out_trade_no; private String subject; private String total_amount; private String body; }
|
- 定义支付宝异步回调返回的信息实体类
PayAsyncVo
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
@ToString @Data public class PayAsyncVo { private String gmt_create; private String charset; private String gmt_payment; private Date notify_time; private String subject; private String sign; private String buyer_id; private String body; private String invoice_amount; private String version; private String notify_id; private String fund_bill_list; private String notify_type; private String out_trade_no; private String total_amount; private String trade_status; private String trade_no; private String auth_app_id; private String receipt_amount; private String point_amount; private String app_id; private String buyer_pay_amount; private String sign_type; private String seller_id; }
|
订单服务收集支付信息跳转到支付页
- 编写
PayWebController
跳转到支付宝支付页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Controller public class PayWebController {
@Autowired AlipayTemplate alipayTemplate;
@Autowired OrderService orderService;
@ResponseBody @GetMapping(value = "/payOrder", produces = "text/html") public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException { PayVo payvo = orderService.payOrder(orderSn); String pay = alipayTemplate.pay(payvo); return pay; } }
|
alipayTemplate.pay(payvo)
返回的是一个 html 文本,其内容为:
它其实就是一个表单,配置了订单的数据信息。并且该表单会立即提交,带着我们传入的订单数据重定向到支付宝网关,从而重定向到支付宝的支付页面:
在该页面支付完成后,支付宝就会立即重定向到我们在 AlipayTemplate
中配置的 return_url
地址,即会员服务的订单详情页面:
Service
层封装需要支付的信息 PayVo
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
@Override public PayVo payOrder(String orderSn) { PayVo payVo = new PayVo(); OrderEntity orderEntity = this.getOrderByOrderSn(orderSn); BigDecimal decimal = orderEntity.getPayAmount().setScale(2, BigDecimal.ROUND_UP); payVo.setTotal_amount(decimal.toString()); payVo.setOut_trade_no(orderSn); List<OrderItemEntity> itemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>() .eq("order_sn", orderSn)); OrderItemEntity itemEntity = itemEntities.get(0); payVo.setSubject(itemEntity.getSkuName()); payVo.setBody(itemEntity.getSkuAttrsVals()); return payVo; }
|
支付成功后异步回调到会员服务
- 支付成功后,支付宝将异步回调到
AlipayTemplate
中配置的 notify_url
地址。先验证签名后,保存订单流水,并更改订单的支付状态为已支付:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
|
@RestController public class OrderPayedListener { @Autowired AlipayTemplate alipayTemplate;
@Autowired OrderService orderService;
@PostMapping("/payed/notify") public String handleAlipayed(PayAsyncVo vo, HttpServletRequest request) throws UnsupportedEncodingException, AlipayApiException {
Map<String,String> params = new HashMap<String,String>(); Map<String,String[]> requestParams = request.getParameterMap(); for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) { String name = (String) iter.next(); String[] values = (String[]) requestParams.get(name); String valueStr = ""; for (int i = 0; i < values.length; i++) { valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; } valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8"); params.put(name, valueStr); } boolean signVerified = AlipaySignature.rsaCheckV1(params , alipayTemplate.getAlipay_public_key() , alipayTemplate.getCharset() , alipayTemplate.getSign_type()); if (signVerified) { String result = orderService.handleAlipayed(vo); return result; } else { return "error"; } } }
|
Service
层保存订单流水,并更改订单的支付状态为已支付:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @Override public String handleAlipayed(PayAsyncVo vo) { PaymentInfoEntity infoEntity = new PaymentInfoEntity(); infoEntity.setOrderSn(vo.getOut_trade_no()); infoEntity.setAlipayTradeNo(vo.getTrade_no()); infoEntity.setPaymentStatus(vo.getTrade_status()); infoEntity.setCallbackTime(vo.getNotify_time()); paymentInfoService.save(infoEntity);
if (vo.getTrade_status().equals("TRADE_SUCCESS") || vo.getTrade_status().equals("TRADE_FINISHED")) { String outTradeNo = vo.getOut_trade_no(); this.baseMapper.updateOrderStatus(outTradeNo, OrderStatusEnum.PAYED.getCode()); } return "success"; }
|
支付宝自动收单
目前可能出现的问题:用户在支付页一直不支付,等到该订单过期后才支付。这时数据库中该订单的库存已经解锁了,订单已经取消了,显然不应该使其支付成功。
此时可以开启支付宝的自动收单功能,使用方法:在 AlipayTemplate
中设置 time_expire = 30m
,代表跳转到支付页面后 30 分钟内如果不支付,就禁止继续支付了。这样就能保证订单过期后用户无法在支付宝页面支付。
可能出现的问题
问题一:由于网络延迟等问题。订单在 30 分钟过期后完成关单,正在解锁库存的时候,支付成功的异步通知才到。此时订单已经关闭了,但是用户支付成功了,库存也即将解锁了。
- 解决方案:在关单后,手动调用支付宝
AlipayTemplate
提供的接口通知支付页面,手动关闭支付页面,提示用户已过期,取消支付。
问题二:交易争议。用户表示自己已经支付,但是系统因各种原因一直没有收到支付宝的成功回调,导致数据库中该用户的订单状态一直未未支付。
- 解决方案:对这些争议做定时对账。每晚定时调用支付宝提供的交易查询接口,提供订单号或商品号,就能查询该订单在支付宝服务中的实际支付状态,从而修改数据库中这些争议数据的订单状态
消息可靠性保证
本章将介绍如何保证消息的可靠性——消息丢失、消息重复与消息积压问题。
消息丢失
情景一:消息发送出去,由于网络问题没有抵达服务器。解决方案:
- 做好容错方案(try-catch),消息因网络原因发送失败后,要有重试机制。
- 可以在 catch 到网络异常后再重试 3 次发送请求,如果仍失败,则代表服务当前网络不可用,暂时停止发送消息,记录该消息到数据库中,之后采用定期扫描失败的消息,重新发送该消息
- 做好日志记录,每个消息的状态都应该记录到日志中(持久化到数据库)
- 做好定期重发,如果某条消息没有发送成功,则将其记录到数据库中。定期去数据库中扫描未成功发送的消息进行重发
情景二:消息顺利抵达 Broker,Broker 要将消息写入磁盘(持久化)才算接收成功。但此时 Broker 尚未完成持久化就宕机(消息到了 Broker,但是其还没持久化好消息就宕机)。解决方案:
- 消息生产者必须开启发布确认功能,在 Broker 回复成功收到消息后,在数据库中保存该消息的状态为已发送;如果未收到成功回复,则保存该消息的状态为未发送成功
- 未发送成功的消息将定时重新发送
情景三:消息顺利进入队列,但是消费者在自动 Ack 状态下还未消费成功就宕机。解决方案:
- 开启手动 Ack,消费者在消费成功再手动回复 Broker,将其从队列移除。若长时间未回复,则 Broker 会将该消息重新入队
- 确认消费成功后,修改该消息的状态为已消费
使用以上三种策略即可在一定程度上保证消息不丢失。
最大努力通知型方案
生产者在发送消息到 MQ 后需要将消息持久化到数据库中的一个消息表中,初始状态为未发送。
- 当 Broker 中交换机收到消息后,异步通知生产者(发布确认),此时将该消息状态标记为已发送
- 如果 Broker 将消息路由到队列时失败,会异步通知生产者消息路由失败(消息回退)。此时生产者需要不断重新发送消息,确保消息能成功路由到队列。
- 消费者在手动 ACK 后,将该消息状态标记为已消费,代表消息成功消费
- 定时重新投递发送失败的消息(状态不是已消费就代表发送失败)
消息重复
消息重复消费的情况:
- 消息成功被消费者消费(例如成功解锁库存),但在手动 Ack 回复 Broker 消费成功时,消费者宕机,消息由
unack
变为 ready
,Broker 又重新发送该消息给其他消费者重复解锁库存
- 消息在消费失败,由于重试机制,自动又将消息发送出去
解决方案:消费者的业务消费接口应该被设计为幂等性的。
- 比如解锁库存时要先判断库存工作单的状态标志位,如果订单状态是已取消或该订单不存在于订单表中,再进行解锁库存。否则不解锁
- 使用防重表(Redis / MySQL),发送的每一个消息都设置其业务的唯一标识,消费者在收到消息后先判断该标识是否已存在于防重表中,如果已存在就不再重复处理
- RabbitMQ 的每一个消息都有
redelivered
字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的。该方案可能存在一定隐患,例如第一次消息没有执行成功,第二次再发送时本应该再次消费,但因为 redelivered
字段表明该消息是重新投递就不再执行了,可能造成消息丢失
消息积压
可能因为消费者消费能力不足或发送者发送瞬时流量太大导致大量消息积压在消息队列中。解决方案:
- 上线更多消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,存储到数据库中,之后再慢慢处理这些消息
幂等性问题
关于幂等性问题的详细介绍和解决方案见文章【分布式】幂等性问题
订单服务可能出现多个幂等性问题:
- 页面重复提交问题:使用 Token 令牌机制解决
- Feign 远程调用问题:远程调用前先生成一个全局唯一
uuid
,并存入到 Redis 的 Set 结构中防止重复响应远程调用方法
- 数据库重复插入订单问题:为订单号添加唯一性索引
UNIQUE
,防止插入重复的订单数据
- 重复解锁库存问题:在解锁库存前需要首先判断"商品 & 库存 - 工作单"中当前任务单的状态是否为已锁定
WareTaskStatusEnum.Locked
,如果不是,说明该工作单已经被解锁了,从而无需重复解锁。
页面重复提交问题
现在分析一个问题:当用户在前端连续多次点击【提交订单】,会发出多个创建订单的请求 /submitOrder
。这会导致同一个订单被创建多次,从而不满足幂等性。因此我们必须要解决幂等性问题,使得一个用户只能同时处理一个订单,其他订单请求都失效。体现在 Redis 里就是同一时刻只能保存唯一的一个订单的 uuid(order:token:userId - uuid
)。
解决方案:使用 token(令牌)机制。在购物车页面点击【去结算】时,就在 /toTrade
业务代码中为当前用户生成一个防重令牌(防止重复提交),并保存到 Redis 中(格式为 order:token:userId - uuid
)。同时将该令牌保存到 OrderConfirmVo
对象中返回给前端保存。
代码:
1 2 3 4 5 6
|
String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId(), token, 30, TimeUnit.MINUTES); confirmVo.setOrderToken(token);
|
这样前端页面在点击【提交订单】按钮后跳转到 /submitOrder
业务时,就会将该防重令牌携带上。此时我们只需要再从 Redis 中根据该用户 id 获取到其保存在 Redis 中的令牌,并与客户端携带上来的令牌进行比较即可。流程:
- 根据用户
order:token:userId
从 Redis 中获取令牌
- 判断客户端携带的令牌和 Redis 中查询的令牌是否相等
- 如果相等,说明验证成功,从 Redis 中删除令牌
- 如果不相等,说明验证失败,当前请求是重复请求(或者用户同时开两个网页提交两个订单),拒绝访问,直接返回
这三个步骤的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get();
String orderToken = submitVo.getOrderToken();
String redisToken = redisTemplate.opsForValue().get(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId());
if (orderToken != null && orderToken.equals(redisToken)) { redisTemplate.delete(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()); } else { }
|
注意:这三个操作必须是原子性的。否则高并发场景下会出现问题。因此可以使用 LUA 脚本保证这三个过程的原子性:
1 2 3 4 5
| String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()), orderToken);
|
Feign 远程调用问题
前提:Feign 一旦远程调用失败后,就会不断重试发出相同请求。可能实际上远程服务已经收到了调用请求并且执行了业务,但是因为网络延迟等因素,Feign 认为没有调用成功,从而发出了同样的请求。
为了避免这种情况的发生,我们可以在 Feign 远程调用接口前,先生成一个唯一的 uuid
,并存入到 Redis 的 Set 结构中(例如一个用户保存一个 Set,其内保存了该用户所有远程调用请求的 uuid
,能够保证不重复)。远程服务在执行业务前先检查 Redis 中该用户的 Set 里是否存在该 uuid
,如果存在即代表当前远程请求之前就被处理过了(该请求被重复调用了),那么就不再执行了。
注意:这种方式只适用于 Feign 的远程调用防重。如果是前端页面发来的重复请求,是无法防住的(需要使用上面的令牌机制),因为即使使用 Nginx 给每个前端发来的请求添加唯一 id,点击两次前端页面也会发送两个 id 不同的请求,从而无法做到防重。但是 Nginx 添加 id 的思路可以用于实现链路追踪功能。
总结:Feign 防止重复调用可以使用这个方法。但是页面的重复提交防止不了。
在 Nginx 中为每一个请求设置一个唯一 id:
1
| proxy_set_header X-Request-Id $Request_id
|
数据库重复插入订单问题
为防止在数据库中重复插入同一条订单,可以为订单号 order_sn
添加唯一性索引 UNIQUE
,防止在数据库中插入重复的订单数据。
重复解锁库存问题
在解锁库存前,先从工作单详情表中查询当前订单相关的工作单信息,此时需要设置状态为已锁定 WareTaskStatusEnum.Locked
,那些已解锁的就无须再重复解锁了:
1 2 3 4 5
| List<WareOrderTaskDetailEntity> lockDetails = wareOrderTaskDetailService.list( new QueryWrapper<WareOrderTaskDetailEntity>() .eq("task_id", taskEntity.getId()) .eq("lock_status", WareTaskStatusEnum.Locked.getCode()));
|
在解锁库存后,需要更新该工作单的状态:
1 2 3 4 5
| WareOrderTaskDetailEntity detail = WareOrderTaskDetailEntity.builder() .id(detailId) .lockStatus(WareTaskStatusEnum.UnLocked.getCode()).build(); wareOrderTaskDetailService.updateById(detail);
|
这样就能避免重复解锁库存了。