1.页面环境搭建
1.1 静态资源导入nginx
等待付款 ———>detail
订单页 ———>list
结算页 ———>confirm
收银页 ———> pay
1.2 配置host
# gulimall
192.168.157.128 gulimall.com
# search
192.168.157.128 search.gulimall.com
# item 商品详情
192.168.157.128 item.gulimall.com
#商城认证
192.168.157.128 auth.gulimall.com
#购物车
192.168.157.128 cart.gulimall.com
#订单
192.168.157.128 order.gulimall.com
#单点登录
127.0.0.1 ssoserver.com127.0.0.1 client1.com127.0.0.1 client2.com
1.3 配置网关
gulimall-gateway/src/main/resources/application.yml
#订单- id: gulimall_order_routeuri: lb://gulimall-orderpredicates:- Host=order.gulimall.com
1.4 开启注册发现
@EnableDiscoveryClient
1.5 新增依赖
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
1.6 修改各个页面的静态资源路径
src=" ===>src="/static/order/xxx/
herf=" ===>herf="/static/order/xxx/
1.7 测试
1.7.1 订单确认页
确认页前端代码:https://gitee.com/zhourui815/gulimall/blob/master/gulimall-order/src/main/resources/templates/confirm.html
order.gulimall.com/confirm.html
1.7.2 订单列表页
订单列表页前端代码:https://gitee.com/zhourui815/gulimall/blob/master/gulimall-order/src/main/resources/templates/list.html
谷粒商城订单 (gulimall.com)
1.7.3 订单详情页
订单详情页前端代码:https://gitee.com/zhourui815/gulimall/blob/master/gulimall-order/src/main/resources/templates/detail.html
order.gulimall.com/detail.html
1.7.4 订单支付页
订单支付页前端代码:https://gitee.com/zhourui815/gulimall/blob/master/gulimall-order/src/main/resources/templates/pay.html
order.gulimall.com/pay.html
2. 整合Spring Session
2.1 导入依赖
<dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId></dependency><!--redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId><exclusions><exclusion><groupId>io.lettuce</groupId><artifactId>lettuce-core</artifactId></exclusion></exclusions></dependency><!--jedis,redis客户端--><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId></dependency>
2.2 开启Spring Session
@EnableRedisHttpSession //整合Redis作为session存储
2.3 配置Spring Session存储方式
redis:host: 192.168.157.128session:store-type: redis
2.4 SpringSession 自定义
package site.zhourui.gulimall.order.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;/*** @author zr* @date 2021/12/12 10:29*/
@Configuration
public class GulimallSessionConfig {@Beanpublic CookieSerializer cookieSerializer() {DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();//放大作用域cookieSerializer.setDomainName("gulimall.com");cookieSerializer.setCookieName("GULISESSION");cookieSerializer.setCookieMaxAge(60*60*24*7);return cookieSerializer;}//session存储对象方式json,默认jdk@Beanpublic RedisSerializer<Object> springSessionDefaultRedisSerializer() {return new GenericJackson2JsonRedisSerializer();}
}
2.5 整合后效果
可以实现登录成功后用户信息共享
3. 整合线程池
3.1 自定义线程池配置
gulimall-order/src/main/java/site/zhourui/gulimall/order/config/MyThreadConfig.java
package site.zhourui.gulimall.order.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;/*** @author zr* @date 2021/11/28 10:12*/@Configuration
public class MyThreadConfig {@Beanpublic ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool) {return new ThreadPoolExecutor(pool.getCoreSize(),pool.getMaxSize(),pool.getKeepAliveTime(),TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());}
}
gulimall-order/src/main/java/site/zhourui/gulimall/order/config/ThreadPoolConfigProperties.java
package site.zhourui.gulimall.order.config;import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;@ConfigurationProperties(prefix = "gulimall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {private Integer coreSize;private Integer maxSize;private Integer keepAliveTime;
}
3.2 配置
gulimall:thread:core-size: 20max-size: 200keep-alive-time: 10
4. 订单中心(理论)
电商系统涉及到 3 流, 分别时信息流, 资金流, 物流, 而订单系统作为中枢将三者有机的集合起来。订单模块是电商系统的枢纽, 在订单这个环节上需求获取多个模块的数据和信息, 同时对这些信息进行加工处理后流向下个环节, 这一系列就构成了订单的信息流通。
4.1 订单的构成
4.1.1 用户信息
用户信息包括用户账号、 用户等级、 用户的收货地址、 收货人、 收货人电话等组成, 用户账户需要绑定手机号码, 但是用户绑定的手机号码不一定是收货信息上的电话。 用户可以添加多个收货信息, 用户等级信息可以用来和促销系统进行匹配, 获取商品折扣, 同时用户等级还可以获取积分的奖励等
4.1.2 订单基础信息
订单基础信息是订单流转的核心, 其包括订单类型、 父/子订单、 订单编号、 订单状态、 订单流转的时间等。
(1) 订单类型包括实体商品订单和虚拟订单商品等, 这个根据商城商品和服务类型进行区分。
(2) 同时订单都需要做父子订单处理, 之前在初创公司一直只有一个订单, 没有做父子订单处理后期需要进行拆单的时候就比较麻烦, 尤其是多商户商场, 和不同仓库商品的时候,父子订单就是为后期做拆单准备的。
(3) 订单编号不多说了, 需要强调的一点是父子订单都需要有订单编号, 需要完善的时候可以对订单编号的每个字段进行统一定义和诠释。
(4) 订单状态记录订单每次流转过程, 后面会对订单状态进行单独的说明。
(5) 订单流转时间需要记录下单时间, 支付时间, 发货时间, 结束时间/关闭时间等等
4.1.3 商品信息
商品信息从商品库中获取商品的 SKU 信息、 图片、 名称、 属性规格、 商品单价、 商户信息等, 从用户下单行为记录的用户下单数量, 商品合计价格等。
4.1.4 优惠信息
优惠信息记录用户参与的优惠活动, 包括优惠促销活动, 比如满减、 满赠、 秒杀等, 用户使用的优惠券信息, 优惠券满足条件的优惠券需要默认展示出来, 具体方式已在之前的优惠券篇章做过详细介绍, 另外还虚拟币抵扣信息等进行记录。
4.1.4.1为什么把优惠信息单独拿出来而不放在支付信息里面呢?
因为优惠信息只是记录用户使用的条目, 而支付信息需要加入数据进行计算, 所以做为区分。
4.1.5 支付信息
( 1) 支付流水单号, 这个流水单号是在唤起网关支付后支付通道返回给电商业务平台的支付流水号, 财务通过订单号和流水单号与支付通道进行对账使用。
( 2) 支付方式用户使用的支付方式, 比如微信支付、 支付宝支付、 钱包支付、 快捷支付等。支付方式有时候可能有两个——余额支付+第三方支付。
( 3) 商品总金额, 每个商品加总后的金额; 运费, 物流产生的费用; 优惠总金额, 包括促销活动的优惠金额, 优惠券优惠金额, 虚拟积分或者虚拟币抵扣的金额, 会员折扣的金额等之和; 实付金额, 用户实际需要付款的金额。用户实付金额=商品总金额+运费-优惠总金额
4.1.6 物流信息
物流信息包括配送方式, 物流公司, 物流单号, 物流状态, 物流状态可以通过第三方接口来获取和向用户展示物流每个状态节点。
4.2 订单状态
- 待付款
用户提交订单后, 订单进行预下单, 目前主流电商网站都会唤起支付, 便于用户快速完成支付, 需要注意的是待付款状态下可以对库存进行锁定, 锁定库存需要配置支付超时时间, 超时后将自动取消订单, 订单变更关闭状态。 - 已付款/待发货
用户完成订单支付, 订单系统需要记录支付时间, 支付流水单号便于对账, 订单下放到 WMS系统, 仓库进行调拨, 配货, 分拣, 出库等操作。 - 待收货/已发货
仓储将商品出库后, 订单进入物流环节, 订单系统需要同步物流信息, 便于用户实时知悉物品物流状态 - 已完成
用户确认收货后, 订单交易完成。 后续支付侧进行结算, 如果订单存在问题进入售后状态 - 已取消
付款之前取消订单。 包括超时未付款或用户商户取消订单都会产生这种订单状态。 - 售后中
用户在付款后申请退款, 或商家发货后用户申请退换货。售后也同样存在各种状态, 当发起售后申请后生成售后订单, 售后订单状态为待审核, 等待商家审核, 商家审核通过后订单状态变更为待退货, 等待用户将商品寄回, 商家收货后订单状态更新为待退款状态, 退款到用户原账户后订单状态更新为售后成功。
4.3 订单流程
订单流程是指从订单产生到完成整个流转的过程, 从而行程了一套标准流程规则。 而不同的产品类型或业务类型在系统中的流程会千差万别, 比如上面提到的线上实物订单和虚拟订单的流程, 线上实物订单与 O2O 订单等, 所以需要根据不同的类型进行构建订单流程。不管类型如何订单都包括正向流程和逆向流程, 对应的场景就是购买商品和退换货流程, 正向流程就是一个正常的网购步骤: 订单生成–>支付订单–>卖家发货–>确认收货–>交易成功。而每个步骤的背后, 订单是如何在多系统之间交互流转的, 可概括如下图
4.3.1 订单创建与支付 (重点)
- 订单创建前需要预览订单, 选择收货信息等
- 订单创建需要锁定库存, 库存有才可创建, 否则不能创建
- 订单创建后超时未支付需要解锁库存
- 支付成功后, 需要进行拆单, 根据商品打包方式, 所在仓库, 物流等进行拆单
- 支付的每笔流水都需要记录, 以待查账
- 订单创建, 支付成功等状态都需要给 MQ 发送消息, 方便其他系统感知订阅
4.3.2 逆向流程
- 修改订单, 用户没有提交订单, 可以对订单一些信息进行修改, 比如配送信息,优惠信息, 及其他一些订单可修改范围的内容, 此时只需对数据进行变更即可。
- 订单取消, 用户主动取消订单和用户超时未支付, 两种情况下订单都会取消订单, 而超时情况是系统自动关闭订单, 所以在订单支付的响应机制上面要做支付的限时处理, 尤其是在前面说的下单减库存的情形下面, 可以保证快速的释放库存。另外需要需要处理的是促销优惠中使用的优惠券, 权益等视平台规则, 进行相应补回给用户。
- 退款, 在待发货订单状态下取消订单时, 分为缺货退款和用户申请退款。 如果是全部退款则订单更新为关闭状态, 若只是做部分退款则订单仍需进行进行, 同时生成一条退款的售后订单, 走退款流程。 退款金额需原路返回用户的账户。
- 发货后的退款, 发生在仓储货物配送, 在配送过程中商品遗失, 用户拒收, 用户收货后对商品不满意, 这样情况下用户发起退款的售后诉求后, 需要商户进行退款的审核, 双方达成一致后, 系统更新退款状态, 对订单进行退款操作, 金额原路返回用户的账户, 同时关闭原订单数据。 仅退款情况下暂不考虑仓库系统变化。 如果发生双方协调不一致情况下, 可以申请平台客服介入。 在退款订单商户不处理的情况下, 系统需要做限期判断, 比如 5 天商户不处理, 退款单自动变更同意退款。
5. 订单中心(代码)
5.1 订单登录拦截
因为订单系统必然涉及到用户信息,因此进入订单系统的请求必须是已经登录的,所以我们需要通过拦截器对未登录订单请求进行拦截
gulimall-order/src/main/java/site/zhourui/gulimall/order/interceptor/LoginUserInterceptor.java
package site.zhourui.gulimall.order.interceptor;/*** @author zr* @date 2021/12/21 22:04*/import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import site.zhourui.common.constant.AuthServerConstant;
import site.zhourui.common.vo.MemberResponseVo;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.PrintWriter;import static site.zhourui.common.constant.AuthServerConstant.LOGIN_USER;/*** 登录拦截器* 从session中获取了登录信息(redis中),封装到了ThreadLocal中*/
@Component
public class LoginUserInterceptor implements HandlerInterceptor {public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {HttpSession session = request.getSession();MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);if (memberResponseVo != null) {loginUser.set(memberResponseVo);return true;}else {session.setAttribute("msg","请先登录");response.sendRedirect("http://auth.gulimall.com/login.html");return false;}}
}
gulimall-order/src/main/java/site/zhourui/gulimall/order/config/OrderWebConfig.java
package site.zhourui.gulimall.order.config;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import site.zhourui.gulimall.order.interceptor.LoginUserInterceptor;/*** @author zr* @date 2021/12/21 22:05*/
@Configuration
public class OrderWebConfig implements WebMvcConfigurer {@Autowiredprivate LoginUserInterceptor loginUserInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");}
}
5.2 订单确认页
5.2.1 模型抽取
确认页提交数据
gulimall-order/src/main/java/site/zhourui/gulimall/order/vo/OrderConfirmVo.java
package site.zhourui.gulimall.order.vo;import lombok.Getter;
import lombok.Setter;import java.math.BigDecimal;
import java.util.List;
import java.util.Map;/*** 订单确认页需要用的数据* @author zr* @date 2021/12/21 22:22*/
public class OrderConfirmVo {@Getter@SetterList<MemberAddressVo> memberAddressVos;/** 会员收获地址列表 **/@Getter @SetterList<OrderItemVo> items; /** 所有选中的购物项【购物车中的所有项】 **/@Getter @Setterprivate Integer integration;/** 优惠券(会员积分) **//** TODO 防止重复提交的令牌 幂等性**/@Getter @Setterprivate String orderToken;@Getter @SetterMap<Long,Boolean> stocks;public Integer getCount() {Integer count = 0;if (items != null && items.size() > 0) {for (OrderItemVo item : items) {count += item.getCount();}}return count;}/** 总商品金额 **///BigDecimal total;//计算订单总额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;}/** 应付总额 **///BigDecimal payPrice;public BigDecimal getPayPrice() {return getTotal();}
}
确认页提交数据模型还需要地址信息
gulimall-order/src/main/java/site/zhourui/gulimall/order/vo/MemberAddressVo.java
package site.zhourui.gulimall.order.vo;import lombok.Data;/*** 地址信息* @author zr* @date 2021/12/21 22:24*/
@Data
public class MemberAddressVo {private Long id;/*** member_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;}
确认页提交数据模型还需要订单行信息
gulimall-order/src/main/java/site/zhourui/gulimall/order/vo/OrderItemVo.java
package site.zhourui.gulimall.order.vo;import lombok.Data;import java.math.BigDecimal;
import java.util.List;/*** 购物项内容* @author zr* @date 2021/12/21 22:23*/
@Data
public class OrderItemVo {private Long skuId; // skuIdprivate 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");// 商品重量
}
5.2.2 提交确认订单
5.2.2.1 订单确认页流程
1、远程调用:获取所有收货地址【member-ums表】
2、远程调用:所有选中的商品(最新价格-远程调用)【cart-redis中】【product-查询最新价格】
3、查询用户积分【session的用户信息中】
4、订单总额【根据所有选中的价格之和 求得】
5、应付总额【暂时跟订单总额相等】【优惠卡等功能不做,直接用积分】
6、查询每个商品是否有货【批量查询ware服务】
7、收货地址高亮【选中地址调用ajax直接远程调用ware计算运费【远程调用会员服务member传入addrId获取详细地址】
WareInfoController /fare
接口返回运费信息,和地址信息
8、防重令牌【防止用户多次 提交订单】【点击提交订单后,数据库只保存一条订单信息(幂等性,提交1次和多次结果是一致的)】
5.2.2.2 去到订单确认页面
返回订单确认页所需要的数据OrderConfirmVo
gulimall-order/src/main/java/site/zhourui/gulimall/order/web/OrderWebController.java
/*** 去结算确认页* @param model* @param request* @return* @throws ExecutionException* @throws InterruptedException*/@GetMapping(value = "/toTrade")public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {OrderConfirmVo confirmVo = orderService.confirmOrder();model.addAttribute("confirmOrderData",confirmVo);//展示订单确认的数据return "confirm";}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/OrderService.java
OrderConfirmVo confirmOrder();
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
- 判断用户登录信息
- 远程查询所有的收获地址列表
- 远程查询购物车所有选中的购物项
- 远程查询商品库存信息
- 查询用户积分
- 价格数据自动计算
- 防重令牌(防止表单重复提交)
/*** 订单确认页返回需要用的数据* @return*/@Overridepublic OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {//构建OrderConfirmVoOrderConfirmVo confirmVo = new OrderConfirmVo();//获取当前用户登录的信息MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();//TODO :获取当前线程请求头信息(解决Feign异步调用丢失请求头问题)RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();//开启第一个异步任务CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {//每一个线程都来共享之前的请求数据RequestContextHolder.setRequestAttributes(requestAttributes);//1、远程查询所有的收获地址列表List<MemberAddressVo> address = memberFeignService.getAddress(memberResponseVo.getId());confirmVo.setMemberAddressVos(address);}, threadPoolExecutor);//开启第二个异步任务CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> {//每一个线程都来共享之前的请求数据【解决异步ThreadLocal 无法共享数据】RequestContextHolder.setRequestAttributes(requestAttributes);//2、远程查询购物车所有选中的购物项List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();confirmVo.setItems(currentCartItems);//feign在远程调用之前要构造请求,调用很多的拦截器}, threadPoolExecutor).thenRunAsync(() -> {List<OrderItemVo> items = confirmVo.getItems();//获取全部商品的idList<Long> skuIds = items.stream().map((itemVo -> itemVo.getSkuId())).collect(Collectors.toList());//3、远程查询商品库存信息R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);List<SkuStockVo> skuStockVos = skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {});if (skuStockVos != null && skuStockVos.size() > 0) {//将skuStockVos集合转换为mapMap<Long, Boolean> skuHasStockMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));confirmVo.setStocks(skuHasStockMap);}},threadPoolExecutor);//4、、查询用户积分Integer integration = memberResponseVo.getIntegration();confirmVo.setIntegration(integration);//5、、价格数据自动计算//TODO 5、防重令牌(防止表单重复提交)//为用户设置一个token,三十分钟过期时间(存在redis)String token = UUID.randomUUID().toString().replace("-", "");redisTemplate.opsForValue().set(USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(),token,30, TimeUnit.MINUTES);confirmVo.setOrderToken(token);CompletableFuture.allOf(addressFuture,cartInfoFuture).get();return confirmVo;}
5.2.2.2.1 远程查询所有的收获地址列表
模拟创建两条该用户地址信息
gulimall-member/src/main/java/site/zhourui/gulimall/member/service/MemberReceiveAddressService.java
List<MemberReceiveAddressEntity> getAddress(Long memberId);
gulimall-member/src/main/java/site/zhourui/gulimall/member/service/impl/MemberReceiveAddressServiceImpl.java
@Overridepublic List<MemberReceiveAddressEntity> getAddress(Long memberId) {List<MemberReceiveAddressEntity> addressList = this.baseMapper.selectList(new QueryWrapper<MemberReceiveAddressEntity>().eq("member_id", memberId));return addressList;}
gulimall-member/src/main/java/site/zhourui/gulimall/member/controller/MemberReceiveAddressController.java
/*** 根据会员id查询会员的所有地址* @param memberId* @return*/@GetMapping(value = "/{memberId}/address")public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memberId") Long memberId) {List<MemberReceiveAddressEntity> addressList = memberReceiveAddressService.getAddress(memberId);return addressList;}
gulimall-order/src/main/java/site/zhourui/gulimall/order/feign/MemberFeignService.java
package site.zhourui.gulimall.order.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import site.zhourui.gulimall.order.vo.MemberAddressVo;import java.util.List;/*** @author zr* @date 2021/12/23 15:05*/
@FeignClient("gulimall-member")
public interface MemberFeignService {/*** 查询当前用户的全部收货地址* @param memberId* @return*/@GetMapping(value = "/member/memberreceiveaddress/{memberId}/address")List<MemberAddressVo> getAddress(@PathVariable("memberId") Long memberId);
}
5.2.2.2.2 远程查询购物车所有选中的购物项
gulimall-cart/src/main/java/site/zhourui/gulimall/cart/service/CartService.java
/*** 获取当前用户的购物车所有商品项* @return*/List<CartItemVo> getUserCartItems();
gulimall-cart/src/main/java/site/zhourui/gulimall/cart/service/Impl/CartServiceImpl.java
/*** 远程调用:订单服务调用【更新最新价格】* 获取当前用户购物车所有选中的商品项check=true【从redis中取】*/@Overridepublic List<CartItemVo> getUserCartItems() {List<CartItemVo> cartItemVoList = new ArrayList<>();//获取当前用户登录的信息UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();//如果用户未登录直接返回nullif (userInfoTo.getUserId() == null) {return null;} else {//获取购物车项String cartKey =CartConstant.CART_PREFIX + userInfoTo.getUserId();//获取所有的List<CartItemVo> cartItems = getCartItems(cartKey);if (cartItems == null) {throw new CartExceptionHandler();}//筛选出选中的cartItemVoList = cartItems.stream().filter(items -> items.getCheck()).map(item -> {//更新为最新的价格(查询数据库)// redis中的价格不是最新的BigDecimal price = productFeignService.getPrice(item.getSkuId());item.setPrice(price);return item;}).collect(Collectors.toList());}return cartItemVoList;}
gulimall-cart/src/main/java/site/zhourui/gulimall/cart/controller/CartController.java
/*** 订单服务调用:【购物车页面点击确认订单时】* 返回所有选中的商品项【从redis中取】* 并且要获取最新的商品价格信息,而不是redis中的数据** 获取当前用户的购物车所有商品项*/@GetMapping(value = "/currentUserCartItems")@ResponseBodypublic List<CartItemVo> getCurrentCartItems() {List<CartItemVo> cartItemVoList = cartService.getUserCartItems();return cartItemVoList;}
gulimall-order/src/main/java/site/zhourui/gulimall/order/feign/CartFeignService.java
package site.zhourui.gulimall.order.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import site.zhourui.gulimall.order.vo.OrderItemVo;import java.util.List;/*** @author zr* @date 2021/12/23 15:06*/
@FeignClient("gulimall-cart")
public interface CartFeignService {/*** 查询当前用户购物车选中的商品项* @return*/@GetMapping(value = "/currentUserCartItems")List<OrderItemVo> getCurrentCartItems();}
5.2.2.2.3 远程查询商品库存信息
gulimall-order/src/main/java/site/zhourui/gulimall/order/vo/SkuStockVo.java
返回库存信息的vo
package site.zhourui.gulimall.order.vo;import lombok.Data;/*** 库存vo* @author zr* @date 2021/12/23 15:53*/
@Data
public class SkuStockVo {private Long skuId;private Boolean hasStock;}
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareSkuService.java
/*** 判断是否有库存*/List<SkuHasStockVo> getSkusHasStock(List<Long> skuIds);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareSkuServiceImpl.java
/*** 检查sku 是否有库存*/@Overridepublic List<SkuHasStockVo> getSkusHasStock(List<Long> skuIds) {List<SkuHasStockVo> vos = skuIds.stream().map(skuId -> {SkuHasStockVo vo = new SkuHasStockVo();// 1、不止一个仓库有,多个仓库都有库存 sum// 2、锁定库存是别人下单但是还没下完Long count = baseMapper.getSkuStock(skuId);vo.setSkuId(skuId);vo.setHasStock(count == null ? false : count > 0);return vo;}).collect(Collectors.toList());return vos;}
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/controller/WareSkuController.java
/*** 查询sku是否有库存*/@PostMapping("/hasstock")// @RequiresPermissions("ware:waresku:list")public R getSkusHasStock(@RequestBody List<Long> skuIds){// sku_id stockList<SkuHasStockVo> vos = wareSkuService.getSkusHasStock(skuIds);return R.ok().setData(vos);}
gulimall-order/src/main/java/site/zhourui/gulimall/order/feign/WmsFeignService.java
package site.zhourui.gulimall.order.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import site.zhourui.common.utils.R;import java.util.List;/*** @author zr* @date 2021/12/23 15:06*/
@FeignClient("gulimall-ware")
public interface WmsFeignService {/*** 查询sku是否有库存*/@PostMapping(value = "/ware/waresku/hasstock")R getSkuHasStock(@RequestBody List<Long> skuIds);}
5.2.2.2.4 多线程异步编排
之前在章节3
整合过线程池了,只需导入
@Autowiredprivate ThreadPoolExecutor threadPoolExecutor;
5.2.2.3 feign远程调用丢失请求头
原因:feign发送请求时构造的RequestTemplate没有请求头(该请求头为空),请求参数等信息【cookie没了】
导致在cart服务中,拦截器拦截获取session中的登录信息,获取不到userId【没有cookie】
解决:同步新、老请求(老请求就是/toTrade请求,带有Cookie数据)的cookie
原理: feign在远程调用之前要构造请求,调用很多的拦截器(DEBUG,查看到会调用 拦截器)
gulimall-order/src/main/java/site/zhourui/gulimall/order/config/GuliFeignConfig.java
package site.zhourui.gulimall.order.config;/*** @author zr* @date 2021/12/23 17:03*/import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;/*** feign拦截器功能* 解决feign 远程请求头丢失问题**/
@Configuration
public class GuliFeignConfig {@Bean("requestInterceptor")public RequestInterceptor requestInterceptor() {RequestInterceptor requestInterceptor = new RequestInterceptor() {@Overridepublic void apply(RequestTemplate template) {System.out.println("feign远程调用,拦截包装请求头");//1、使用RequestContextHolder拿到刚进来的请求数据ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if (requestAttributes != null) {HttpServletRequest request = requestAttributes.getRequest();//老请求if (request != null) {//2、同步请求头的数据(主要是cookie)//把老请求的cookie值放到新请求上来,进行一个同步String cookie = request.getHeader("Cookie");template.header("Cookie", cookie);}}}};return requestInterceptor;}}
5.2.2.4 Feign异步情况丢失上下文问题
导致拦截器中 空指针异常
1、先在主线程的ThreadLocal中获取 请求头数据
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
2、再在新线程给ThreadLocal设置 请求头数据【否则获取不到数据,不是同一个线程】
//每一个线程都来共享之前的请求数据【解决异步ThreadLocal 无法共享数据】RequestContextHolder.setRequestAttributes(requestAttributes);
5.2.2.5 模拟运费效果
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareInfoService.java
/*** 获取运费和收货地址信息* @param addrId* @return*/FareVo getFare(Long addrId);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareInfoServiceImpl.java
模拟运费,真实情况下需要计算得出
/*** 计算运费* @param addrId* @return*/@Overridepublic FareVo getFare(Long addrId) {FareVo fareVo = new FareVo();//收获地址的详细信息R addrInfo = memberFeignService.info(addrId);MemberAddressVo memberAddressVo = addrInfo.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {});if (memberAddressVo != null) {String phone = memberAddressVo.getPhone();//截取用户手机号码最后一位作为我们的运费计算//1558022051String fare = phone.substring(phone.length() - 1);BigDecimal bigDecimal = new BigDecimal(fare);fareVo.setFare(bigDecimal);fareVo.setAddress(memberAddressVo);return fareVo;}return null;}
需要获取用户选择的地址信息(远程调用)
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/feign/MemberFeignService.java
/*** 根据id获取用户地址信息* @param id* @return*/@RequestMapping("/member/memberreceiveaddress/info/{id}")R info(@PathVariable("id") Long id);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/controller/WareInfoController.java
/*** 获取运费信息,订单服务远程调用* @return*/@GetMapping(value = "/fare")public R getFare(@RequestParam("addrId") Long addrId) {FareVo fare = wareInfoService.getFare(addrId);return R.ok().setData(fare);}
前端页面当用户地址切换时,查询出运费及订单总金额为用户展示
测试需要创建两条地址信息数据
5.2.2.6 创建防重令牌
令牌前缀常量
package site.zhourui.gulimall.order.constant;/*** @author zr* @date 2021/12/23 22:05*/
public class OrderConstant {public static final String USER_ORDER_TOKEN_PREFIX = "order:token";}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
//TODO 5、防重令牌(防止表单重复提交)//为用户设置一个token,三十分钟过期时间(存在redis)String token = UUID.randomUUID().toString().replace("-", "");redisTemplate.opsForValue().set(USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(),token,30, TimeUnit.MINUTES);confirmVo.setOrderToken(token);
5.2.2.7 提交订单
注意:创建的订单号很长,注意将oms_order
与oms_order_item
数据库表中的order_sn
字段调大至50,否则会报错
- 下单:去创建订单,验令牌,验价格,锁库存
- 提交订单成功,则携带返回数据转发至支付页面
- 提交订单失败,则携带错误信息重定向至确认页
gulimall-order/src/main/java/site/zhourui/gulimall/order/web/OrderWebController.java
@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.gulimall.com/toTrade";}}catch (Exception e){if (e instanceof NoStockException){String msg = "下单失败,商品无库存";attributes.addFlashAttribute("msg", msg);}return "redirect:http://order.gulimall.com/toTrade";}}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 提交订单* @param vo* @return*/// @Transactional(isolation = Isolation.READ_COMMITTED) 设置事务的隔离级别// @Transactional(propagation = Propagation.REQUIRED) 设置事务的传播级别// @GlobalTransactional(rollbackFor = Exception.class)@Transactional@Overridepublic SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {confirmVoThreadLocal.set(vo);SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();//去创建、下订单、验令牌、验价格、锁定库存...//获取当前用户登录的信息MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();responseVo.setCode(0);//1、验证令牌是否合法【令牌的对比和删除必须保证原子性】String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";String orderToken = vo.getOrderToken();//通过lure脚本原子验证令牌和删除令牌Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),Arrays.asList(USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),orderToken);if (result == 0L) {//令牌验证失败responseVo.setCode(1);return responseVo;} else {//令牌验证成功//1、创建订单、订单项等信息OrderCreateTo order = createOrder();//2、验证价格BigDecimal payAmount = order.getOrder().getPayAmount();BigDecimal payPrice = vo.getPayPrice();if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {//金额对比//TODO 3、保存订单saveOrder(order);//4、库存锁定,只要有异常,回滚订单数据//订单号、所有订单项信息(skuId,skuNum,skuName)WareSkuLockVo lockVo = new WareSkuLockVo();lockVo.setOrderSn(order.getOrder().getOrderSn());//获取出要锁定的商品数据信息【order里面存储的是Entity】List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {OrderItemVo orderItemVo = new OrderItemVo();orderItemVo.setSkuId(item.getSkuId());orderItemVo.setCount(item.getSkuQuantity());orderItemVo.setTitle(item.getSkuName());return orderItemVo;}).collect(Collectors.toList());lockVo.setLocks(orderItemVos);//TODO 调用远程锁定库存的方法//出现的问题:扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,库存事务不回滚(解决方案:seata)//为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,可以发消息给库存服务R r = wmsFeignService.orderLockStock(lockVo);if (r.getCode() == 0) {//锁定成功responseVo.setOrder(order.getOrder());//int i = 10/0;//TODO 订单创建成功,发送消息给MQ
// rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());//删除购物车里的数据
// redisTemplate.delete(CART_PREFIX+memberResponseVo.getId());return responseVo;} else {//锁定失败String msg = (String) r.get("msg");throw new NoStockException(msg);// responseVo.setCode(3);// return responseVo;}} else {responseVo.setCode(2);return responseVo;}}}
5.2.2.7.1 验证防重令牌
//1、验证令牌是否合法【令牌的对比和删除必须保证原子性】String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";String orderToken = vo.getOrderToken();//通过lure脚本原子验证令牌和删除令牌Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),Arrays.asList(USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),orderToken);if (result == 0L) {//令牌验证失败responseVo.setCode(1);return responseVo;} else {//令牌验证成功//1、创建订单、订单项等信息//2、验证价格//3、保存订单//4、库存锁定,只要有异常,回滚订单数据}
5.2.2.7.2 创建订单、订单项等信息
需要远程调用获取SPU信息
gulimall-order/src/main/java/site/zhourui/gulimall/order/feign/ProductFeignService.java
package site.zhourui.gulimall.order.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import site.zhourui.common.utils.R;/*** @author zr* @date 2021/12/24 9:58*/
@FeignClient("gulimall-product")
public interface ProductFeignService {/*** 根据skuId查询spu的信息* @param skuId* @return*/@GetMapping(value = "/product/spuinfo/skuId/{skuId}")R getSpuInfoBySkuId(@PathVariable("skuId") Long skuId);}
gulimall-product/src/main/java/site/zhourui/gulimall/product/app/SpuInfoController.java
/*** 提交订单,远程接口* 根据skuId查询spu的信息*/@GetMapping(value = "/skuId/{skuId}")public R getSpuInfoBySkuId(@PathVariable("skuId") Long skuId) {SpuInfoEntity spuInfoEntity = spuInfoService.getSpuInfoBySkuId(skuId);return R.ok().setData(spuInfoEntity);}
gulimall-product/src/main/java/site/zhourui/gulimall/product/service/SpuInfoService.java
/*** 根据skuId查询spu的信息* @param skuId* @return*/SpuInfoEntity getSpuInfoBySkuId(Long skuId);
gulimall-product/src/main/java/site/zhourui/gulimall/product/service/impl/SpuInfoServiceImpl.java
/*** 根据skuId查询spu的信息* @param skuId* @return*/@Overridepublic SpuInfoEntity getSpuInfoBySkuId(Long skuId) {//先查询sku表里的数据SkuInfoEntity skuInfoEntity = skuInfoService.getById(skuId);//获得spuIdLong spuId = skuInfoEntity.getSpuId();//再通过spuId查询spuInfo信息表里的数据SpuInfoEntity spuInfoEntity = baseMapper.selectById(spuId);//查询品牌表的数据获取品牌名BrandEntity brandEntity = brandService.getById(spuInfoEntity.getBrandId());spuInfoEntity.setBrandName(brandEntity.getName());return spuInfoEntity;}
gulimall-product/src/main/java/site/zhourui/gulimall/product/entity/SpuInfoEntity.java
需要加上品牌名称
/*** 品牌名*/@TableField(exist = false)private String brandName;
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 创建订单*/private OrderCreateTo createOrder() {OrderCreateTo createTo = new OrderCreateTo();//1、生成订单号String orderSn = IdWorker.getTimeId();// 构建订单数据【封装价格】OrderEntity orderEntity = builderOrder(orderSn);//2、获取到所有的订单项【封装价格】List<OrderItemEntity> orderItemEntities = builderOrderItems(orderSn);//3、验价(计算价格、积分等信息)computePrice(orderEntity, orderItemEntities);createTo.setOrder(orderEntity);createTo.setOrderItems(orderItemEntities);return createTo;}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 构建订单数据* @param orderSn* @return*/private OrderEntity builderOrder(String orderSn) {//获取当前用户登录信息MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();OrderEntity orderEntity = new OrderEntity();orderEntity.setMemberId(memberResponseVo.getId());orderEntity.setOrderSn(orderSn);orderEntity.setMemberUsername(memberResponseVo.getUsername());OrderSubmitVo orderSubmitVo = confirmVoThreadLocal.get();//远程获取收货地址和运费信息R fareAddressVo = wmsFeignService.getFare(orderSubmitVo.getAddrId());FareVo fareResp = fareAddressVo.getData("data", new TypeReference<FareVo>() {});//获取到运费信息BigDecimal fare = fareResp.getFare();orderEntity.setFreightAmount(fare);//获取到收货地址信息MemberAddressVo address = fareResp.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.setAutoConfirmDay(7);orderEntity.setConfirmStatus(0);return orderEntity;}
订单状态枚举
gulimall-order/src/main/java/site/zhourui/gulimall/order/enume/OrderStatusEnum.java
package site.zhourui.gulimall.order.enume;/*** @author zr* @date 2021/12/24 9:52*//*** 订单状态枚举*/public enum OrderStatusEnum {CREATE_NEW(0,"待付款"),PAYED(1,"已付款"),SENDED(2,"已发货"),RECIEVED(3,"已完成"),CANCLED(4,"已取消"),SERVICING(5,"售后中"),SERVICED(6,"售后完成");private Integer code;private String msg;OrderStatusEnum(Integer code, String msg) {this.code = code;this.msg = msg;}public Integer getCode() {return code;}public String getMsg() {return msg;}
}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 构建所有订单项数据* @return*/public List<OrderItemEntity> builderOrderItems(String orderSn) {List<OrderItemEntity> orderItemEntityList = new ArrayList<>();//最后确定每个购物项的价格List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();if (currentCartItems != null && currentCartItems.size() > 0) {orderItemEntityList = currentCartItems.stream().map((items) -> {//构建订单项数据OrderItemEntity orderItemEntity = builderOrderItem(items);orderItemEntity.setOrderSn(orderSn);return orderItemEntity;}).collect(Collectors.toList());}return orderItemEntityList;}
5.2.2.7.3 验价
将页面提交的价格和后台计算的价格进行对比,若不同则提示用户商品价格发生变化
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 计算价格的方法* @param orderEntity* @param orderItemEntities*/private void computePrice(OrderEntity orderEntity, List<OrderItemEntity> orderItemEntities) {//总价BigDecimal total = new BigDecimal("0.0");//优惠价BigDecimal coupon = new BigDecimal("0.0");BigDecimal intergration = new BigDecimal("0.0");BigDecimal promotion = new BigDecimal("0.0");//积分、成长值Integer integrationTotal = 0;Integer growthTotal = 0;//订单总额,叠加每一个订单项的总额信息for (OrderItemEntity orderItem : orderItemEntities) {//优惠价格信息coupon = coupon.add(orderItem.getCouponAmount());promotion = promotion.add(orderItem.getPromotionAmount());intergration = intergration.add(orderItem.getIntegrationAmount());//总价total = total.add(orderItem.getRealAmount());//积分信息和成长值信息integrationTotal += orderItem.getGiftIntegration();growthTotal += orderItem.getGiftGrowth();}//1、订单价格相关的orderEntity.setTotalAmount(total);//设置应付总额(总额+运费)orderEntity.setPayAmount(total.add(orderEntity.getFreightAmount()));orderEntity.setCouponAmount(coupon);orderEntity.setPromotionAmount(promotion);orderEntity.setIntegrationAmount(intergration);//设置积分成长值信息orderEntity.setIntegration(integrationTotal);orderEntity.setGrowth(growthTotal);//设置删除状态(0-未删除,1-已删除)orderEntity.setDeleteStatus(0);}
5.2.2.7.4 保存订单
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 保存订单所有数据*/private void saveOrder(OrderCreateTo orderCreateTo) {//获取订单信息OrderEntity order = orderCreateTo.getOrder();order.setModifyTime(new Date());order.setCreateTime(new Date());//保存订单this.baseMapper.insert(order);//获取订单项信息List<OrderItemEntity> orderItems = orderCreateTo.getOrderItems();//批量保存订单项数据orderItemService.saveBatch(orderItems);}
5.2.2.7.5 库存锁定
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
//TODO 调用远程锁定库存的方法//出现的问题:扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,库存事务不回滚(解决方案:seata)//为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,可以发消息给库存服务R r = wmsFeignService.orderLockStock(lockVo);
gulimall-order/src/main/java/site/zhourui/gulimall/order/feign/WmsFeignService.java
- 找出所有库存大于商品数的仓库
- 遍历所有满足条件的仓库,逐个尝试锁库存,若锁库存成功则退出遍历
/*** 锁定库存*/@PostMapping(value = "/ware/waresku/lock/order")R orderLockStock(@RequestBody WareSkuLockVo vo);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/controller/WareSkuController.java
/*** 下订单时锁库存* @param* @return*/@RequestMapping("/lock/order")public R orderLockStock(@RequestBody WareSkuLockVo lockVo) {try {Boolean lock = wareSkuService.orderLockStock(lockVo);return R.ok();} catch (NoStockException e) {return R.error(BizCodeEnume.NO_STOCK_EXCEPTION.getCode(), BizCodeEnume.NO_STOCK_EXCEPTION.getMsg());}}
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareSkuService.java
/*** 锁定库存*/boolean orderLockStock(WareSkuLockVo vo);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareSkuServiceImpl.java
/*** 为某个订单锁定库存*/@Transactional(rollbackFor = Exception.class)@Overridepublic boolean orderLockStock(WareSkuLockVo vo) {/*** 保存库存工作单详情信息* 追溯* 如果没有库存,就不会发送消息给mq* 【不会进入save(WareOrderTaskDetailEntity)逻辑,也不会发送消息给mq,也不会锁定库存,也不会监听到解锁服务】*/WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();wareOrderTaskEntity.setOrderSn(vo.getOrderSn());wareOrderTaskEntity.setCreateTime(new Date());wareOrderTaskService.save(wareOrderTaskEntity);//1、按照下单的收货地址,找到一个就近仓库,锁定库存//2、找到每个商品在哪个仓库都有库存List<OrderItemVo> locks = vo.getLocks();List<SkuWareHasStock> collect = locks.stream().map((item) -> {SkuWareHasStock stock = new SkuWareHasStock();Long skuId = item.getSkuId();stock.setSkuId(skuId);stock.setNum(item.getCount());//查询这个商品在哪个仓库有库存 stock-锁定num > 0List<Long> wareIdList = wareSkuDao.listWareIdHasSkuStock(skuId);stock.setWareId(wareIdList);return stock;}).collect(Collectors.toList());//2、锁定库存for (SkuWareHasStock hasStock : collect) {boolean skuStocked = false;Long skuId = hasStock.getSkuId();List<Long> wareIds = hasStock.getWareId();if (CollectionUtils.isEmpty(wareIds)) {//没有任何仓库有这个商品的库存throw new NoStockException(skuId);}//1、如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ//2、锁定失败。前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所有就不用解锁for (Long wareId : wareIds) {//锁定成功就返回1,失败就返回0Long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());// count==1表示锁定成功if (count == 1) {skuStocked = true;
// WareOrderTaskDetailEntity taskDetailEntity = WareOrderTaskDetailEntity.builder()
// .skuId(skuId)
// .skuName("")
// .skuNum(hasStock.getNum())
// .taskId(wareOrderTaskEntity.getId())
// .wareId(wareId)
// .lockStatus(1)
// .build();
// wareOrderTaskDetailService.save(taskDetailEntity);
//
// //TODO 告诉MQ库存锁定成功
// StockLockedTo lockedTo = new StockLockedTo();
// lockedTo.setId(wareOrderTaskEntity.getId());
// StockDetailTo detailTo = new StockDetailTo();
// BeanUtils.copyProperties(taskDetailEntity,detailTo);// 这里直接存entity。但是应该存id更好,数据最好来自DB
// lockedTo.setDetailTo(detailTo);
// rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",lockedTo);// 锁定成功返回break;} else {//当前仓库锁失败,重试下一个仓库}}if (skuStocked == false) {//当前商品所有仓库都没有锁住throw new NoStockException(skuId);}}//3、肯定全部都是锁定成功的return true;}
5.2.2.8 订单提交的问题 (本地事务在分布式情况下出现的问题)
分布式情况下,可能出现一些服务事务不一致的情况
- 远程服务假失败
- 远程服务执行完成后,下面其他方法出现异常
库存扣减成功但是订单业务执行出错,订单业务可以回滚但远程调用的库存服务是办法回滚的
5.2.2.9 使用seata解决分布式事务
有多种模式:AT、TCC、SAGA 和 XA
doc:http://seata.io/zh-cn/docs/overview/what-is-seata.html
1、TC不会控制各RM回滚,而是调用补偿方案,AT模式是根据 回滚日志表【每个数据库都创建一个回滚日志表】
2、而TCC模式的回滚是根据补偿方法 来回滚
AT模式:Auto Transiaction:自动事务模式,根据回滚日志表自动回滚
TCC模式:就是根据自己手写的事务补偿方法 来回滚
Seata术语
TC (Transaction Coordinator) – 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。
TM (Transaction Manager) – 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
RM (Resource Manager) – 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
5.2.2.9.1 AT模式实现步骤【创建订单+锁定库存】【不推荐使用】
不适用高并发场景,适用于商品服务,保存商品的那个接口 SpuInfoController
/save
1、保存spu pms_spu_info
2、保存attr
3、保存描述图片 pms_spu_info_desc
4、保存图片集 pms_spu_images
5、保存当前spu对应的所有sku信息
6、优惠券信息【远程调用】分布式事务【并发不高,可以使用AT模式,@GlobalTransactional】
7、保存积分信息【远程调用】分布式事务【并发不高,可以使用AT模式,@GlobalTransactional】
seata官方文档:https://github.com/seata/seata-samples/blob/master/doc/quick-integration-with-spring-cloud.md
1.创建 UNDO_LOG 表
-- 注意此处0.3.0+ 增加唯一索引 ux_undo_log
CREATE TABLE `undo_log` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`branch_id` bigint(20) NOT NULL,`xid` varchar(100) NOT NULL,`context` varchar(128) NOT NULL,`rollback_info` longblob NOT NULL,`log_status` int(11) NOT NULL,`log_created` datetime NOT NULL,`log_modified` datetime NOT NULL,`ext` varchar(100) DEFAULT NULL,PRIMARY KEY (`id`),UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
2.导入依赖
<!--seata 分布式事务--><!--不使用的模块要排除掉--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-seata</artifactId><exclusions><exclusion><groupId>io.seata</groupId><artifactId>seata-all</artifactId></exclusion></exclusions></dependency>
<!-- 使用与事务协调器版本相同的--><dependency><groupId>io.seata</groupId><artifactId>seata-all</artifactId><version>0.7.1</version></dependency>
下载安装事务协调器:seate-server0.7.1Release v0.7.1 · seata/seata · GitHub
版本与seata-all版本对应
3.配置seata的注册中心 registry.conf
4.启动D:\environment\seata-server-0.7.1\bin\seata-server.bat
seata各种属性配置 file.conf
5.所有想要用到分布式事务的微服务使用seata DataSourceProxy 代理自己的数据源
package site.zhourui.gulimall.order.config;/*** @author zr* @date 2021/12/28 10:57*/import com.zaxxer.hikari.HikariDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.util.StringUtils;import javax.sql.DataSource;/*** seata分布式事务* 配置代理数据源*/
//@Configuration
public class MySeataConfig {@AutowiredDataSourceProperties dataSourceProperties;/*** 自动配置类,如果容器中存在数据源就不自动配置数据源了*/@Beanpublic DataSource dataSource(DataSourceProperties dataSourceProperties) {HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();if (StringUtils.hasText(dataSourceProperties.getName())) {dataSource.setPoolName(dataSourceProperties.getName());}return new DataSourceProxy(dataSource);}
}
6.每个使用分布式事务的微服务都需要导入 file.conf registry.conf
注意file.conf:
需要注意的是 service.vgroup_mapping
这个配置,在 Spring Cloud 中默认是${spring.application.name}-fescar-service-group
,可以通过指定application.properties
的 spring.cloud.alibaba.seata.tx-service-group
这个属性覆盖,但是必须要和 file.conf
中的一致,否则会提示 no available server to connect
7.加注解
- 给分布式大事务的入口标注@GlobalTransactional gulimall-order服务
每一个远程的小事务用@Trabsactional gulimall-ware服务
重启服务测试
测试完成后关闭seata
GlobalTransactional
,排除依赖 gulimall-order,gulimall-ware
5.2.2.10 最终一致性库存解锁逻辑:基于消息队列的分布式事务+分布式表【库存自动解锁】
5.2.2.10.1 为库存模块创建业务交换机,队列,绑定(整合Rabbitmq)
导入依赖
gulimall-ware/pom.xml
<!--rabbitmq--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency>
配置Rabbitmq地址端口(虚拟主机,确认发送,抵达确认,手动ack)
spring:rabbitmq:host: 192.168.157.128port: 5672virtual-host: /#开启发送端确认publisher-confirms: true# 开启发送端消息抵达Queue确认publisher-returns: true# 只要消息抵达Queue,就会异步发送优先回调returnfirmtemplate:mandatory: true# 使用手动ack确认模式,关闭自动确认【消息丢失】listener:simple:acknowledge-mode: manual
开启RabbitMQ
主启动类加上
@EnableRabbit
配置确认回调,失败回调
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/config/MyRabbitConfig.java
package site.zhourui.gulimall.ware.config;import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import javax.annotation.PostConstruct;/*** @author zr* @date 2021/12/15 9:56*/
@Configuration
public class MyRabbitConfig {@AutowiredRabbitTemplate rabbitTemplate;@Beanpublic MessageConverter messageConverter(){return new Jackson2JsonMessageConverter();}/*** 定制RabbitTemplate* 1、服务收到消息就会回调* 1、spring.rabbitmq.publisher-confirms: true* 2、设置确认回调* 2、消息正确抵达队列就会进行回调* 1、spring.rabbitmq.publisher-returns: true* spring.rabbitmq.template.mandatory: true* 2、设置确认回调ReturnCallback** 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)**/@PostConstruct //MyRabbitConfig对象创建完成以后,执行这个方法public void initRabbitTemplate() {/*** 1、只要消息抵达Broker就ack=true* correlationData:当前消息的唯一关联数据(这个是消息的唯一id)* ack:消息是否成功收到* cause:失败的原因*///设置确认回调rabbitTemplate.setConfirmCallback((correlationData,ack,cause) -> {System.out.println("confirm...correlationData["+correlationData+"]==>ack:["+ack+"]==>cause:["+cause+"]");});/*** 只要消息没有投递给指定的队列,就触发这个失败回调* message:投递失败的消息详细信息* replyCode:回复的状态码* replyText:回复的文本内容* exchange:当时这个消息发给哪个交换机* routingKey:当时这个消息用哪个路邮键*/rabbitTemplate.setReturnCallback((message,replyCode,replyText,exchange,routingKey) -> {System.out.println("Fail Message["+message+"]==>replyCode["+replyCode+"]" +"==>replyText["+replyText+"]==>exchange["+exchange+"]==>routingKey["+routingKey+"]");});}
}
创建库存解锁延时队列及交换机,绑定
package site.zhourui.gulimall.ware.config;/*** @author zr* @date 2021/12/28 16:47*/import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import java.io.IOException;
import java.util.HashMap;/*** 创建队列,交换机,延迟队列,绑定关系 的configuration* 不会重复创建覆盖* 1、第一次使用队列【监听】的时候才会创建* 2、Broker没有队列、交换机才会创建*/
@Configuration
public class MyRabbitMQConfig {@RabbitListener(queues = "stock.release.stock.queue")public void listen( Channel channel, Message message) throws IOException {System.out.println("收到库存解锁消息,准备解锁库存:------>");channel.basicNack(message.getMessageProperties().getDeliveryTag(),false,false);}/*** 库存服务默认的交换机* @return*/@Beanpublic Exchange stockEventExchange() {//String name, boolean durable, boolean autoDelete, Map<String, Object> argumentsTopicExchange topicExchange = new TopicExchange("stock-event-exchange", true, false);return topicExchange;}/*** 普通队列* @return*/@Beanpublic Queue stockReleaseStockQueue() {//String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> argumentsQueue queue = new Queue("stock.release.stock.queue", true, false, false);return queue;}/*** 延迟队列* @return*/@Beanpublic Queue stockDelay() {HashMap<String, Object> arguments = new HashMap<>();arguments.put("x-dead-letter-exchange", "stock-event-exchange");arguments.put("x-dead-letter-routing-key", "stock.release");// 消息过期时间 2分钟arguments.put("x-message-ttl", 120000);Queue queue = new Queue("stock.delay.queue", true, false, false,arguments);return queue;}/*** 交换机与普通队列绑定* @return*/@Beanpublic Binding stockLocked() {//String destination, DestinationType destinationType, String exchange, String routingKey,// Map<String, Object> argumentsBinding binding = new Binding("stock.release.stock.queue",Binding.DestinationType.QUEUE,"stock-event-exchange","stock.release.#",null);return binding;}/*** 交换机与延迟队列绑定* @return*/@Beanpublic Binding stockLockedBinding() {return new Binding("stock.delay.queue",Binding.DestinationType.QUEUE,"stock-event-exchange","stock.locked",null);}}
启动测试
向
stock.locked
路邮键发送队列,并监听死信队列,两分钟后监听到消息说明成功了
5.2.2.10.2 库存解锁
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareSkuService.java
/*** 解锁库存* @param to*/void unlockStock(StockLockedTo to);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareSkuServiceImpl.java
此处需要调用订单服务远程服务根据订单号查询订单信息
/*** 解锁库存*/@Overridepublic void unlockStock(StockLockedTo to) {//库存工作单的idStockDetailTo detail = to.getDetailTo();Long detailId = detail.getId();/*** 解锁* 1、查询数据库关于这个订单锁定库存信息* 有:证明库存锁定成功了* 解锁:订单状况* 1、没有这个订单,必须解锁库存* 2、有这个订单,不一定解锁库存* 订单状态:已取消:解锁库存* 已支付:不能解锁库存*/WareOrderTaskDetailEntity taskDetailInfo = wareOrderTaskDetailService.getById(detailId);if (taskDetailInfo != null) {//查出wms_ware_order_task工作单的信息Long id = to.getId();WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(id);//获取订单号查询订单状态String orderSn = orderTaskInfo.getOrderSn();//远程查询订单信息R orderData = orderFeignService.getOrderStatus(orderSn);if (orderData.getCode() == 0) {//订单数据返回成功OrderVo orderInfo = orderData.getData("data", new TypeReference<OrderVo>() {});//判断订单状态是否已取消或者支付或者订单不存在// 1、订单不存在:解锁// 2、订单存在,且订单状态是取消状态:解锁if (orderInfo == null || orderInfo.getStatus() == 4) {// 工作单状态必须是 已锁定 才可以解锁【因为解锁方法没有加事务】if (taskDetailInfo.getLockStatus() == 1) {unLockStock(detail.getSkuId(),detail.getWareId(),detail.getSkuNum(),detailId);}}} else {//消息拒绝以后重新放在队列里面,让别人继续消费解锁//远程调用服务失败throw new RuntimeException("远程调用服务失败");}} else {//无需解锁【回滚状态】}}
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/feign/OrderFeignService.java
package site.zhourui.gulimall.ware.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import site.zhourui.common.utils.R;/*** @author zr* @date 2021/12/29 15:29*/
@FeignClient("gulimall-order")
public interface OrderFeignService {@GetMapping(value = "/order/order/status/{orderSn}")R getOrderStatus(@PathVariable("orderSn") String orderSn);}
远程订单服务
gulimall-order/src/main/java/site/zhourui/gulimall/order/controller/OrderController.java
/*** 根据订单编号查询订单状态* @param orderSn* @return*/@GetMapping(value = "/status/{orderSn}")public R getOrderStatus(@PathVariable("orderSn") String orderSn) {OrderEntity orderEntity = orderService.getOrderByOrderSn(orderSn);return R.ok().setData(orderEntity);}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/OrderService.java
/*** 按照订单号获取订单信息* @param orderSn* @return*/OrderEntity getOrderByOrderSn(String orderSn);
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 按照订单号获取订单信息* @param orderSn* @return*/@Overridepublic OrderEntity getOrderByOrderSn(String orderSn) {OrderEntity orderEntity = this.baseMapper.selectOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));return orderEntity;}
5.2.2.10.3 监听库存解锁
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/entity/WareOrderTaskDetailEntity.java
增加两个字段 仓库id,锁定状态使用
@Builder
package site.zhourui.gulimall.ware.entity;import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;import java.io.Serializable;
import java.util.Date;import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;/*** 库存工作单** @author zr* @email 2437264464@qq.com* @date 2021-09-28 15:47:50*/
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Data
@TableName("wms_ware_order_task_detail")
public class WareOrderTaskDetailEntity implements Serializable {private static final long serialVersionUID = 1L;/*** 仓库id*/private Long wareId;/*** 锁定状态* 1-已锁定 2-已解锁 3-已扣减*/private Integer lockStatus;/*** id*/@TableIdprivate Long id;/*** sku_id*/private Long skuId;/*** sku_name*/private String skuName;/*** 购买个数*/private Integer skuNum;/*** 工作单id*/private Long taskId;}
锁定库存时向库存延时队列发送一条库存工作单记录
库存工作单
gulimall-common/src/main/java/site/zhourui/common/to/mq/StockLockedTo.java
package site.zhourui.common.to.mq;/*** 锁定库存成功,往延时队列存入 工作单to 对象* wms_ware_order_task* @author zr* @date 2021/12/29 15:07*/import lombok.Data;/***/
@Data
public class StockLockedTo {/** 库存工作单的id **/private Long id;/** 库存单详情 wms_ware_order_task_detail**/private StockDetailTo detailTo;
}
库存详情单
gulimall-common/src/main/java/site/zhourui/common/to/mq/StockDetailTo.java
package site.zhourui.common.to.mq;/*** 库存单详情* wms_ware_order_task_detail* @author zr* @date 2021/12/29 15:07*/import lombok.Data;@Data
public class StockDetailTo {private Long id;/*** sku_id*/private Long skuId;/*** sku_name*/private String skuName;/*** 购买个数*/private Integer skuNum;/*** 工作单id*/private Long taskId;/*** 仓库id*/private Long wareId;/*** 锁定状态* 1-锁定 2-解锁 3-扣减*/private Integer lockStatus;}
监听库存死信队列,解锁库存
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/listener/StockReleaseListener.java
package site.zhourui.gulimall.ware.listener;import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import site.zhourui.common.to.mq.StockLockedTo;
import site.zhourui.gulimall.ware.service.WareSkuService;import java.io.IOException;/*** 监听死信队列,解锁库存* @author zr* @date 2021/12/29 15:22*/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Service
public class StockReleaseListener {@Autowiredprivate WareSkuService wareSkuService;/*** 这个是监听死信消息* 1、库存自动解锁* 下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁** 2、订单失败* 库存锁定失败** 只要解锁库存的消息失败,一定要告诉服务解锁失败*/@RabbitHandlerpublic void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {System.out.println("******收到解锁库存的延时信息******,准备解锁" + to.getDetailTo().getId());try {//当前消息是否被第二次及以后(重新)派发过来了// Boolean redelivered = message.getMessageProperties().getRedelivered();//解锁库存wareSkuService.unlockStock(to);// 手动删除消息channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);} catch (Exception e) {// 解锁失败 将消息重新放回队列,让别人消费channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}/*** 客户取消订单,监听到消息*/@RabbitHandlerpublic void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {System.out.println("******收到订单关闭,准备解锁库存的信息******订单号:" + orderTo.getOrderSn());try {wareSkuService.unlockStock(orderTo);// 手动删除消息channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);} catch (Exception e) {// 解锁失败 将消息重新放回队列,让别人消费channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}
}
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareSkuService.java
/*** 解锁库存* @param to*/void unlockStock(StockLockedTo to);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareSkuServiceImpl.java
此处需要调用订单服务远程查询订单信息
/*** 解锁库存*/@Overridepublic void unlockStock(StockLockedTo to) {//库存工作单的idStockDetailTo detail = to.getDetailTo();Long detailId = detail.getId();/*** 解锁* 1、查询数据库关于这个订单锁定库存信息* 有:证明库存锁定成功了* 解锁:订单状况* 1、没有这个订单,必须解锁库存* 2、有这个订单,不一定解锁库存* 订单状态:已取消:解锁库存* 已支付:不能解锁库存*/WareOrderTaskDetailEntity taskDetailInfo = wareOrderTaskDetailService.getById(detailId);if (taskDetailInfo != null) {//查出wms_ware_order_task工作单的信息Long id = to.getId();WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(id);//获取订单号查询订单状态String orderSn = orderTaskInfo.getOrderSn();//远程查询订单信息R orderData = orderFeignService.getOrderStatus(orderSn);if (orderData.getCode() == 0) {//订单数据返回成功OrderVo orderInfo = orderData.getData("data", new TypeReference<OrderVo>() {});//判断订单状态是否已取消或者支付或者订单不存在// 1、订单不存在:解锁// 2、订单存在,且订单状态是取消状态:解锁if (orderInfo == null || orderInfo.getStatus() == 4) {// 工作单状态必须是 已锁定 才可以解锁【因为解锁方法没有加事务】if (taskDetailInfo.getLockStatus() == 1) {unLockStock(detail.getSkuId(),detail.getWareId(),detail.getSkuNum(),detailId);}}} else {//消息拒绝以后重新放在队列里面,让别人继续消费解锁//远程调用服务失败throw new RuntimeException("远程调用服务失败");}} else {//无需解锁【回滚状态】}}/*** 解锁库存的方法【设计DB,没加事务】*/public void unLockStock(Long skuId,Long wareId,Integer num,Long taskDetailId) {// 1、库存解锁wareSkuDao.unLockStock(skuId,wareId,num);// 2、更新工作单的状态 为已解锁 2WareOrderTaskDetailEntity taskDetailEntity = new WareOrderTaskDetailEntity();taskDetailEntity.setId(taskDetailId);taskDetailEntity.setLockStatus(2);wareOrderTaskDetailService.updateById(taskDetailEntity);}
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/feign/OrderFeignService.java
远程查询订单信息
package site.zhourui.gulimall.ware.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import site.zhourui.common.utils.R;/*** @author zr* @date 2021/12/29 15:29*/
@FeignClient("gulimall-order")
public interface OrderFeignService {@GetMapping(value = "/order/order/status/{orderSn}")R getOrderStatus(@PathVariable("orderSn") String orderSn);}
gulimall-order/src/main/java/site/zhourui/gulimall/order/controller/OrderController.java
/*** 根据订单编号查询订单状态* @param orderSn* @return*/@GetMapping(value = "/status/{orderSn}")public R getOrderStatus(@PathVariable("orderSn") String orderSn) {OrderEntity orderEntity = orderService.getOrderByOrderSn(orderSn);return R.ok().setData(orderEntity);}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/OrderService.java
/*** 按照订单号获取订单信息* @param orderSn* @return*/OrderEntity getOrderByOrderSn(String orderSn);
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 按照订单号获取订单信息* @param orderSn* @return*/@Overridepublic OrderEntity getOrderByOrderSn(String orderSn) {OrderEntity orderEntity = this.baseMapper.selectOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));return orderEntity;}
5.2.2.10.4 远程服务order订单服务登录拦截跳转login.html
gulimall-order/src/main/java/site/zhourui/gulimall/order/interceptor/LoginUserInterceptor.java
将该请求放行
String uri = request.getRequestURI();AntPathMatcher antPathMatcher = new AntPathMatcher();boolean match = antPathMatcher.match("/order/order/status/**", uri);return match;
5.2.2.11 最终一致性库存解锁逻辑:基于消息队列的分布式事务+分布式表【订单自动关单】
5.2.2.11.1 为订单模块创建业务交换机,队列绑定
在谷粒商城--消息队列--高级篇笔记十
的6.6 延时队列定时关单模拟
已经创建了交换机队列,绑定
https://blog.csdn.net/qq_31745863/article/details/122212434
5.2.2.11.2 订单关闭
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/OrderService.java
/*** 关闭订单* @param orderEntity*/void closeOrder(OrderEntity orderEntity);
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 关闭订单*/@Overridepublic void closeOrder(OrderEntity orderEntity) {//关闭订单之前先查询一下数据库,判断此订单状态是否已支付OrderEntity orderInfo = this.getOne(new QueryWrapper<OrderEntity>().eq("order_sn",orderEntity.getOrderSn()));if (orderInfo.getStatus().equals(OrderStatusEnum.CREATE_NEW.getCode())) {//代付款状态进行关单OrderEntity orderUpdate = new OrderEntity();orderUpdate.setId(orderInfo.getId());orderUpdate.setStatus(OrderStatusEnum.CANCLED.getCode());this.updateById(orderUpdate);// 发送消息给MQOrderTo orderTo = new OrderTo();BeanUtils.copyProperties(orderInfo, orderTo);try {//TODO 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息//TODO 定期扫描数据库,重新发送失败的消息rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo);} catch (Exception e) {}}}
5.2.2.11.3 监听订单自动关单
gulimall-order/src/main/java/site/zhourui/gulimall/order/listener/OrderCloseListener.java
package site.zhourui.gulimall.order.interceptor;import com.rabbitmq.client.Channel;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import site.zhourui.gulimall.order.entity.OrderEntity;
import site.zhourui.gulimall.order.service.OrderService;import java.io.IOException;/*** @author zr* @date 2021/12/29 17:22*/
/*** 定时关闭订单**/
@RabbitListener(queues = "order.release.order.queue")
@Service
public class OrderCloseListener {@Autowiredprivate OrderService orderService;@RabbitHandlerpublic void listener(OrderEntity orderEntity, Channel channel, Message message) throws IOException {System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn());try {orderService.closeOrder(orderEntity);// 手动调用支付宝收单【这里省略了,可以参照demo中的代码】channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);} catch (Exception e) {channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}}
5.2.2.12 测试自动关单,自动解锁库存
清空之前的订单与库存锁定,库存工作单(也可以不清,但需要记住提交此时的状态,这样好看一点)
清空mq消息
下单
mq的订单延时队列(1分钟),库存延时队列(2分钟)
一分钟之内数据库状态
大于一分钟小于两分钟,自动关单
其他数据库表与一分钟一致
大于两分钟,库存自动解锁
5.2.2.13 订单卡顿导致的库存无法解锁
防止订单服务卡顿,导致订单状态消息一直改不了,库存优先到期,查订单状态新建,什么都不处理
导致卡顿的订单,永远都不能解锁库存
解决方案
再往订单死信队列发送消息时,同时也往库存死信队列发送相同消息,通知库存解锁
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/listener/StockReleaseListener.java
/*** 客户取消订单,监听到消息*/@RabbitHandlerpublic void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {System.out.println("******收到订单关闭,准备解锁库存的信息******订单号:" + orderTo.getOrderSn());try {wareSkuService.unlockStock(orderTo);// 手动删除消息channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);} catch (Exception e) {// 解锁失败 将消息重新放回队列,让别人消费channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);}}
重载解锁库存
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareSkuService.java
/*** 解锁订单*/void unlockStock(OrderTo orderTo);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareSkuServiceImpl.java
/*** 防止订单服务卡顿,导致订单状态消息一直改不了,库存优先到期,查订单状态新建,什么都不处理* 导致卡顿的订单,永远都不能解锁库存* @param orderTo*/@Transactional(rollbackFor = Exception.class)@Overridepublic void unlockStock(OrderTo orderTo) {String orderSn = orderTo.getOrderSn();//查一下最新的库存解锁状态,防止重复解锁库存WareOrderTaskEntity orderTaskEntity = wareOrderTaskService.getOrderTaskByOrderSn(orderSn);//按照工作单的id找到所有 没有解锁的库存,进行解锁Long id = orderTaskEntity.getId();List<WareOrderTaskDetailEntity> list = wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>().eq("task_id", id).eq("lock_status", 1));for (WareOrderTaskDetailEntity taskDetailEntity : list) {unLockStock(taskDetailEntity.getSkuId(),taskDetailEntity.getWareId(),taskDetailEntity.getSkuNum(),taskDetailEntity.getId());}}
在库存解锁前查一下最新的库存解锁状态,防止重复解锁库存
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/WareOrderTaskService.java
WareOrderTaskEntity getOrderTaskByOrderSn(String orderSn);
gulimall-ware/src/main/java/site/zhourui/gulimall/ware/service/impl/WareOrderTaskServiceImpl.java
@Overridepublic WareOrderTaskEntity getOrderTaskByOrderSn(String orderSn) {WareOrderTaskEntity orderTaskEntity = this.baseMapper.selectOne(new QueryWrapper<WareOrderTaskEntity>().eq("order_sn", orderSn));return orderTaskEntity;}
5.2.2.14 如何保证消息可靠性
5.2.2.14.1 消息丢失
- 消息发送出去,由于网络问题没有抵达服务器
- 做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机制,可记录到数据库,采用定期扫描重发的方式
- 做好日志记录,每个消息状态是否都被服务器收到都应该记录
- 做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进行重发
- 消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚未持久化完成,宕机。
- publisher也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。
- 自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机
- 一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重新入队
5.2.2.14.2 消息重复
-
消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息
重新由unack变为ready,并发送给其他消费者 -
消息消费失败,由于重试机制,自动又将消息发送出去
-
成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送
-
消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志
-
使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,处理过就不用处理
CREATE TABLE `mq_message`(`message_id` char(32) not null ,`content` text, #json`to_exchange` char(255) default null ,`routing_key` char(255) default null ,`class_type` char(255) default null ,`message_status` int(1) default '0' comment '0-新建 1-已发送 2-错误抵达 3-已抵达',`create_time` datetime default null ,`update_time` datetime default null )
-
rabbitMQ的每一个消息都有redelivered字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的
-
5.2.2.14.3 消息积压
- 消费者宕机积压
- 消费者消费能力不足积压
- 发送者发送流量太大
- 上线更多的消费者,进行正常消费
- 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理
5.3 订单支付页
5.3.1 加密
5.3.1.1 对称加密
加密与解密用的秘钥都是一样的
5.3.1.2 非对称加密
加密与解密用到的秘钥不一致
5.3.2 支付宝加密原理
- 支付宝加密采用RSA非对称加密,分别在商户端和支付宝端有两对公钥和私钥
- 在发送订单数据时,直接使用明文,但会使用
商户私钥
加一个对应的签名,支付宝端会使用商户公钥
对签名进行验签,只有数据明文和签名对应的时候才能说明传输正确 - 支付成功后,支付宝发送支付成功数据之外,还会使用
支付宝私钥
加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥
延签,成功后才能确认
5.3.2.1 什么是公钥、 私钥、 加密、 签名和验签?
5.3.2.1.1 公钥私钥
公钥和私钥是一个相对概念
它们的公私性是相对于生成者来说的。
一对密钥生成后, 保存在生成者手里的就是私钥,生成者发布出去大家用的就是公钥
5.3.2.1.2 加密和数字签名
- 加密是指:
- 我们使用一对公私钥中的一个密钥来对数据进行加密, 而使用另一个密钥来进行解密的技术。
- 公钥和私钥都可以用来加密, 也都可以用来解密。
- 但这个加解密必须是一对密钥之间的互相加解密, 否则不能成功。
- 加密的目的是:
- 为了确保数据传输过程中的不可读性, 就是不想让别人看到。
- 签名:
- 给我们将要发送的数据, 做上一个唯一签名(类似于指纹)
- 用来互相验证接收方和发送方的身份;
- 在验证身份的基础上再验证一下传递的数据是否被篡改过。 因此使用数字签名可以用来达到数据的明文传输。
- 验签
支付宝为了验证请求的数据是否商户本人发的,
商户为了验证响应的数据是否支付宝发的
5.3.3 支付宝官方demo测试
官方demo下载地址:https://opendocs.alipay.com/open/54/106682
5.3.3.1 使用支付宝沙箱环境进行测试:https://open.alipay.com/platform/appDaily.htm?tab=account
5.3.3.2 自定义秘钥,点击rsa2秘钥后面的设置并启用
5.3.3.3 利用秘钥工具生成自己的公钥,私钥,拿到支付宝公钥
5.3.3.4 配置demo中的AlipayConfig
5.3.3.5 启动demo
配置web目录
添加archive
配置Tomcat
添加依赖
配置字符集
启动Tomcat,测试
能够字符成功的话就说明测试成功了
测试账号密码https://open.alipay.com/platform/appDaily.htm?tab=account
5.3.4 支付宝支付流程
5.3.5 内网穿透
5.3.5.0 为什么使用内网穿透?
支付宝需要回调我们的接口进行异步通知
5.3.5.1 简介
内网穿透功能可以允许我们使用外网的网址来访问主机;
正常的外网需要访问我们项目的流程是:
- 买服务器并且有公网固定 IP
- 买域名映射到服务器的 IP
- 域名需要进行备案和审核
5.3.5.2 使用场景
1、 开发测试(微信、 支付宝)
2、 智慧互联
3、 远程控制
4、 私有云
5.3.5.3 内网穿透的几个常用软件
1、 natapp: https://natapp.cn/ 优惠码: 022B93FD(9 折) [仅限第一次使用]
2、 续断: www.zhexi.tech 优惠码: SBQMEA(95 折) [仅限第一次使用]
3、 花生壳: https://www.oray.com/
5.3.5.4 natapp内网穿透
官方文档NATAPP1分钟快速新手图文教程 – NATAPP-内网穿透 基于ngrok的国内高速内网映射工具
-
注册,实名认证
-
购买免费隧道后拿到authtoken
-
window启动命令
natapp -authtoken=你的authtoken
5.3.6 整合支付
5.3.6.1 导入依赖
gulimall-order/pom.xml
<!-- 支付宝sdk --><!-- https://mvnrepository.com/artifact/com.alipay.sdk/alipay-sdk-java --><dependency><groupId>com.alipay.sdk</groupId><artifactId>alipay-sdk-java</artifactId><version>4.10.111.ALL</version></dependency>
5.3.6.2 抽取阿里云支付模板
gulimall-order/src/main/java/site/zhourui/gulimall/order/config/AlipayTemplate.java
package site.zhourui.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 lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;/*** @author zr* @date 2021/12/31 10:26*/
@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {// 应用ID,您的APPID,收款账号既是您的APPID对应支付宝账号public String app_id;// 商户私钥,您的PKCS8格式RSA2私钥public String merchant_private_key;// 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。public String alipay_public_key;// 服务器[异步通知]页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问// 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息public String notify_url;// 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问//同步通知,支付成功,一般跳转到成功页public String return_url;// 签名方式private String sign_type;// 字符编码格式private String charset;//订单超时时间private String timeout = "1m";// 支付宝网关; https://openapi.alipaydev.com/gateway.dopublic String gatewayUrl;public String pay(PayVo vo) throws AlipayApiException {//AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);//1、根据支付宝的配置生成一个支付客户端AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,app_id, merchant_private_key, "json",charset, alipay_public_key, sign_type);//2、创建一个支付请求 //设置请求参数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("支付宝响应:登录页面的代码\n"+result);return result;}@Datapublic static class PayVo {private String out_trade_no; // 商户订单号 必填private String subject; // 订单名称 必填private String total_amount; // 付款金额 必填private String body; // 商品描述 可空}
}
5.3.6.3 配置模板所需相关配置
alipay:alipay_public_key: xxxapp_id: 2021000117672854charset: utf-8gatewayUrl: https://openapi.alipaydev.com/gateway.domerchant_private_key: xxxx#此处先使用demo的回调接口页面notify_url: http://4wa8cx.natappfree.cc/alipay_trade_wap_pay_java_utf_8_Web_exploded/notify_url.jspreturn_url: http://4wa8cx.natappfree.cc/alipay_trade_wap_pay_java_utf_8_Web_exploded/return_url.jspsign_type: RSA2
5.3.6.4 支付宝支付接口
gulimall-order/src/main/java/site/zhourui/gulimall/order/web/PayWebController.java
package site.zhourui.gulimall.order.web;import com.alipay.api.AlipayApiException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import site.zhourui.gulimall.order.config.AlipayTemplate;
import site.zhourui.gulimall.order.service.OrderService;/*** @author zr* @date 2021/12/31 10:57*/
@Slf4j
@Controller
public class PayWebController {@Autowiredprivate AlipayTemplate alipayTemplate;@Autowiredprivate OrderService orderService;/*** 用户下单:支付宝支付* 1、让支付页让浏览器展示* 2、支付成功以后,跳转到用户的订单列表页* @param orderSn* @return* @throws AlipayApiException*/@ResponseBody@GetMapping(value = "/aliPayOrder",produces = "text/html")public String aliPayOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {AlipayTemplate.PayVo payVo = orderService.getOrderPay(orderSn);// 支付宝返回一个页面【支付宝账户登录的html页面】String pay = alipayTemplate.pay(payVo);System.out.println(pay);return pay;}
}
获取当前订单的支付信息
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/OrderService.java
/*** 获取当前订单的支付信息* @param orderSn* @return*/AlipayTemplate.PayVo getOrderPay(String orderSn);
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 获取当前订单的支付信息*/@Overridepublic AlipayTemplate.PayVo getOrderPay(String orderSn) {AlipayTemplate.PayVo payVo = new AlipayTemplate.PayVo();OrderEntity orderInfo = this.getOrderByOrderSn(orderSn);//保留两位小数点,向上取值BigDecimal payAmount = orderInfo.getPayAmount().setScale(2, BigDecimal.ROUND_UP);payVo.setTotal_amount(payAmount.toString());payVo.setOut_trade_no(orderInfo.getOrderSn());//查询订单项的数据List<OrderItemEntity> orderItemInfo = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));OrderItemEntity orderItemEntity = orderItemInfo.get(0);payVo.setBody(orderItemEntity.getSkuAttrsVals());payVo.setSubject(orderItemEntity.getSkuName());return payVo;}
5.3.6.5 前端支付页面
https://gitee.com/zhourui815/gulimall/blob/master/gulimall-order/src/main/resources/templates/pay.html
5.3.6.6 支付测试
支付成功后跳转页面,正常情况下应该跳转到订单列表页面
5.3.6.7 订单列表页渲染
5.3.6.7.1 静态资源上传
5.3.6.7.2 配置host
5.3.6.7.3 配置网关
gulimall-gateway/src/main/resources/application.yml
- id: gulimall_member_routeuri: lb://gulimall-memberpredicates:- Host=member.gulimall.com
5.3.6.7.3 前端页面
前端页面https://gitee.com/zhourui815/gulimall/blob/master/gulimall-member/src/main/resources/templates/orderList.html
5.3.6.7.4 回调页面接口
gulimall-member/src/main/java/site/zhourui/gulimall/member/web/MemberWebController.java
package site.zhourui.gulimall.member.web;import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import site.zhourui.common.utils.R;
import site.zhourui.gulimall.member.feign.OrderFeignService;import java.util.HashMap;
import java.util.Map;/*** @author zr* @date 2021/12/31 15:23*/
@Controller
public class MemberWebController {@Autowiredprivate OrderFeignService orderFeignService;@GetMapping(value = "/memberOrder.html")public String memberOrderPage(@RequestParam(value = "pageNum",required = false,defaultValue = "0") Integer pageNum,Model model) {//获取到支付宝给我们转来的所有请求数据//request,验证签名//查出当前登录用户的所有订单列表数据Map<String,Object> page = new HashMap<>();page.put("page",pageNum.toString());//远程查询订单服务订单数据R orderInfo = orderFeignService.listWithItem(page);System.out.println(JSON.toJSONString(orderInfo));model.addAttribute("orders",orderInfo);return "orderList";}}
需要调用订单远程服务
gulimall-member/src/main/java/site/zhourui/gulimall/member/feign/OrderFeignService.java
package site.zhourui.gulimall.member.feign;import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import site.zhourui.common.utils.R;import java.util.Map;/*** @author zr* @date 2021/12/31 15:24*/
@FeignClient("gulimall-order")
public interface OrderFeignService {/*** 分页查询当前登录用户的所有订单信息*/@PostMapping("/order/order/listWithItem")R listWithItem(@RequestBody Map<String, Object> params);}
gulimall-order/src/main/java/site/zhourui/gulimall/order/controller/OrderController.java
/*** member远程调用:分页查询当前登录用户的所有订单信息*/@PostMapping("/listWithItem")//@RequiresPermissions("order:order:list")public R listWithItem(@RequestBody Map<String, Object> params){PageUtils page = orderService.queryPageWithItem(params);return R.ok().put("page", page);}
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/OrderService.java
/*** 查询当前用户所有订单数据* @param params* @return*/PageUtils queryPageWithItem(Map<String, Object> params);
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
此处需要设置订单行信息
/*** 查询当前用户所有订单数据*/@Overridepublic PageUtils queryPageWithItem(Map<String, Object> params) {MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();IPage<OrderEntity> page = this.page(new Query<OrderEntity>().getPage(params),new QueryWrapper<OrderEntity>().eq("member_id",memberResponseVo.getId()).orderByDesc("create_time"));//遍历所有订单集合List<OrderEntity> orderEntityList = page.getRecords().stream().map(order -> {//根据订单号查询订单项里的数据List<OrderItemEntity> orderItemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", order.getOrderSn()));order.setOrderItemEntityList(orderItemEntities);return order;}).collect(Collectors.toList());page.setRecords(orderEntityList);return new PageUtils(page);}
为OrderEntity 新增属性
@TableField(exist = false)private List<OrderItemEntity> orderItemEntityList;
5.3.6.8 会员服务整合spring session(需要登录后查看订单信息)
5.3.6.8.1 配置拦截器
gulimall-member/src/main/java/site/zhourui/gulimall/member/interceptor/LoginUserInterceptor.java
新增拦截器,放行member/**,远程调用接口
package site.zhourui.gulimall.member.interceptor;import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import site.zhourui.common.constant.AuthServerConstant;
import site.zhourui.common.vo.MemberResponseVo;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.PrintWriter;/*** @author zr* @date 2021/12/31 15:34*/
@Component
public class LoginUserInterceptor implements HandlerInterceptor {public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String uri = request.getRequestURI();boolean match = new AntPathMatcher().match("/member/**", uri);if (match) {return true;}HttpSession session = request.getSession();//获取登录的用户信息MemberResponseVo attribute = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);if (attribute != null) {//把登录后用户的信息放在ThreadLocal里面进行保存loginUser.set(attribute);return true;} else {//未登录,返回登录页面response.setContentType("text/html;charset=UTF-8");PrintWriter out = response.getWriter();out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");// session.setAttribute("msg", "请先进行登录");// response.sendRedirect("http://auth.gulimall.com/login.html");return false;}}
}
gulimall-member/src/main/java/site/zhourui/gulimall/member/config/MemberWebConfig.java
package site.zhourui.gulimall.member.config;import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import site.zhourui.gulimall.member.interceptor.LoginUserInterceptor;/*** @author zr* @date 2021/12/31 15:36*/
@Configuration
public class MemberWebConfig implements WebMvcConfigurer {@Autowiredprivate LoginUserInterceptor loginUserInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");}}
5.3.6.8.2 整合springsession
依赖
<dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId></dependency><!--redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId><exclusions><exclusion><groupId>io.lettuce</groupId><artifactId>lettuce-core</artifactId></exclusion></exclusions></dependency><!--jedis,redis客户端--><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId></dependency>
配置文件
spring:application:name: gulimall-memberredis:port: 6379host: 192.168.157.128jackson:date-format: yyyy-MM-dd HH:mm:sssession:store-type: redis
session自定义配置
gulimall-member/src/main/java/site/zhourui/gulimall/member/config/GulimallSessionConfig.java
package site.zhourui.gulimall.member.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.web.http.CookieSerializer;
import org.springframework.session.web.http.DefaultCookieSerializer;/*** @author zr* @date 2021/12/12 10:29*/
@Configuration
public class GulimallSessionConfig {@Beanpublic CookieSerializer cookieSerializer() {DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();//放大作用域cookieSerializer.setDomainName("gulimall.com");cookieSerializer.setCookieName("GULISESSION");cookieSerializer.setCookieMaxAge(60*60*24*7);return cookieSerializer;}@Beanpublic RedisSerializer<Object> springSessionDefaultRedisSerializer() {return new GenericJackson2JsonRedisSerializer();}
}
解决feign远程调用请求头丢失问题
gulimall-member/src/main/java/site/zhourui/gulimall/member/config/GuliFeignConfig.java
package site.zhourui.gulimall.member.config;import feign.RequestInterceptor;
import feign.RequestTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;/*** @author zr* @date 2021/12/31 15:36*/
@Configuration
public class GuliFeignConfig {@Bean("requestInterceptor")public RequestInterceptor requestInterceptor() {RequestInterceptor requestInterceptor = new RequestInterceptor() {@Overridepublic void apply(RequestTemplate template) {//1、使用RequestContextHolder拿到刚进来的请求数据ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if (requestAttributes != null) {//老请求HttpServletRequest request = requestAttributes.getRequest();if (request != null) {//2、同步请求头的数据(主要是cookie)//把老请求的cookie值放到新请求上来,进行一个同步String cookie = request.getHeader("Cookie");template.header("Cookie", cookie);}}}};return requestInterceptor;}
}
5.3.6.9 设置支付成功回调接口
修改return_url地址为member服务的订单列表页请求地址
gulimall-order/src/main/resources/application.yaml
return_url: http://member.gulimall.com/memberOrder.html
5.3.6.10 获取支付宝异步通知
- 订单支付成功后支付宝会回调商户接口,这个时候需要修改订单状态
- 由于同步跳转可能由于网络问题失败,所以使用异步通知
- 支付宝使用的是最大努力通知方案,保障数据一致性,隔一段时间会通知商户支付成功,直到返回
success
5.3.6.10.1 接收支付宝异步通知接口
gulimall-order/src/main/java/site/zhourui/gulimall/order/listener/OrderPayedListener.java
package site.zhourui.gulimall.order.listener;import com.alipay.api.AlipayApiException;
import com.alipay.api.internal.util.AlipaySignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import site.zhourui.gulimall.order.config.AlipayTemplate;
import site.zhourui.gulimall.order.service.OrderService;
import site.zhourui.gulimall.order.vo.PayAsyncVo;import javax.servlet.http.HttpServletRequest;
import java.io.UnsupportedEncodingException;
import java.util.HashMap;
import java.util.Map;/*** @author zr* @date 2021/12/31 17:30*/
@RestController
public class OrderPayedListener {@Autowiredprivate OrderService orderService;@Autowiredprivate AlipayTemplate alipayTemplate;@PostMapping(value = "/payed/notify")public String handleAlipayed(PayAsyncVo asyncVo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {// 只要收到支付宝的异步通知,返回 success 支付宝便不再通知// 获取支付宝POST过来反馈信息//TODO 需要验签Map<String, String> params = new HashMap<>();Map<String, String[]> requestParams = request.getParameterMap();for (String name : requestParams.keySet()) {String[] values = 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()); //调用SDK验证签名if (signVerified) {System.out.println("签名验证成功...");//去修改订单状态String result = orderService.handlePayResult(asyncVo);return result;} else {System.out.println("签名验证失败...");return "error";}}}
处理支付宝支付结果
gulimall-order/src/main/java/site/zhourui/gulimall/order/service/impl/OrderServiceImpl.java
/*** 处理支付宝的支付结果**/@Transactional(rollbackFor = Exception.class)@Overridepublic String handlePayResult(PayAsyncVo asyncVo) {//保存交易流水信息PaymentInfoEntity paymentInfo = new PaymentInfoEntity();paymentInfo.setOrderSn(asyncVo.getOut_trade_no());paymentInfo.setAlipayTradeNo(asyncVo.getTrade_no());paymentInfo.setTotalAmount(new BigDecimal(asyncVo.getBuyer_pay_amount()));paymentInfo.setSubject(asyncVo.getBody());paymentInfo.setPaymentStatus(asyncVo.getTrade_status());paymentInfo.setCreateTime(new Date());paymentInfo.setCallbackTime(asyncVo.getNotify_time());//添加到数据库中this.paymentInfoService.save(paymentInfo);//修改订单状态//获取当前状态String tradeStatus = asyncVo.getTrade_status();if (tradeStatus.equals("TRADE_SUCCESS") || tradeStatus.equals("TRADE_FINISHED")) {//支付成功状态String orderSn = asyncVo.getOut_trade_no(); //获取订单号this.updateOrderStatus(orderSn,OrderStatusEnum.PAYED.getCode(), PayConstant.ALIPAY);}return "success";}/*** 修改订单状态* @param orderSn* @param code*/private void updateOrderStatus(String orderSn, Integer code,Integer payType) {this.baseMapper.updateOrderStatus(orderSn,code,payType);}
修改订单状态
gulimall-order/src/main/java/site/zhourui/gulimall/order/dao/OrderDao.java
/*** 修改订单状态* @param orderSn* @param code* @param payType*/void updateOrderStatus(@Param("orderSn") String orderSn,@Param("code") Integer code,@Param("payType") Integer payType);
gulimall-order/src/main/resources/mapper/order/OrderDao.xml
<update id="updateOrderStatus">UPDATE oms_orderSET `status` = #{code},modify_time = NOW(),pay_type = #{payType},payment_time = NOW()WHERE order_sn = #{orderSn}</update>
5.3.6.11 设置支付宝异步通知接口地址
修改notify_url地址为订单服务的回调接口地址
gulimall-order/src/main/resources/application.yaml
notify_url: http://4wa8cx.natappfree.cc/payed/notify
5.3.6.12 是异步通知接口不被拦截
gulimall-order/src/main/java/site/zhourui/gulimall/order/interceptor/LoginUserInterceptor.java
package site.zhourui.gulimall.order.interceptor;/*** @author zr* @date 2021/12/21 22:04*/import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import site.zhourui.common.constant.AuthServerConstant;
import site.zhourui.common.vo.MemberResponseVo;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.PrintWriter;import static site.zhourui.common.constant.AuthServerConstant.LOGIN_USER;/*** 登录拦截器* 从session中获取了登录信息(redis中),封装到了ThreadLocal中*/
@Component
public class LoginUserInterceptor implements HandlerInterceptor {public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String uri = request.getRequestURI();AntPathMatcher antPathMatcher = new AntPathMatcher();boolean match = antPathMatcher.match("/order/order/status/**", uri);boolean match1 = antPathMatcher.match("/payed/notify", uri);if (match || match1) {return true;}HttpSession session = request.getSession();MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);if (memberResponseVo != null) {loginUser.set(memberResponseVo);return true;}else {session.setAttribute("msg","请先登录");response.sendRedirect("http://auth.gulimall.com/login.html");return false;}}
}
5.3.6.13 内网穿透设置异步通知地址
- 将外网映射到本地的
order.gulimall.com:80
- 由于回调的请求头不是
order.gulimall.com
,因此nginx转发到网关后找不到对应的服务,所以需要对nginx进行设置
修改内网穿透接口
测试
nginx 配置域名转发
listen 80;server_name gulimall.com *.gulimall.com *.natappfree.cc;#server_name search.gulimall.com;#charset koi8-r;#access_log /var/log/nginx/log/host.access.log main;location /static/ {root /usr/share/nginx/html;}location /payed/ {proxy_set_header Host order.gulimall.com;proxy_pass http://gulimall;}location / {proxy_set_header Host $host;proxy_pass http://gulimall;}
拦截器放行通知接口
gulimall-order/src/main/java/site/zhourui/gulimall/order/interceptor/LoginUserInterceptor.java
@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String uri = request.getRequestURI();AntPathMatcher antPathMatcher = new AntPathMatcher();boolean match = antPathMatcher.match("/order/order/status/**", uri);boolean match1 = antPathMatcher.match("/payed/notify", uri);if (match || match1) {return true;}HttpSession session = request.getSession();MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);if (memberResponseVo != null) {loginUser.set(memberResponseVo);return true;}else {session.setAttribute("msg","请先登录");response.sendRedirect("http://auth.gulimall.com/login.html");return false;}}
5.3.6.13.1 日期格式问题
Field error in object 'payAsyncVo' on field 'notify_time': rejected value [2022-01-02 10:50:06]; codes [typeMismatch.payAsyncVo.notify_time,typeMismatch.notify_time,typeMismatch.java.util.Date,typeMismatch]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [payAsyncVo.notify_time,notify_time]; arguments []; default message [notify_time]]; default message [Failed to convert property value of type 'java.lang.String' to required type 'java.util.Date' for property 'notify_time'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.util.Date] for value '2022-01-02 10:50:06'; nested exception is java.lang.IllegalArgumentException]]
解决方案
spring:mvc:date-format: yyyy-MM-dd HH:mm:ss
订单号长度报错
修改oms_payment_info 的订单号长度
5.3.6.9 支付测试
付款成功后自动跳转到订单列表页
5.3.6.10 收单
-
订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态改为已支付了,但是库存解锁了。
-
使用支付宝自动收单功能解决。只要一段时间不支付,就不能支付了。
-
效果
-
-
由于时延等问题。订单解锁完成,正在解锁库存的时候,异步通知才到
- 订单解锁,手动调用收单
-
网络阻塞问题,订单支付成功的异步通知一直不到达
- 查询订单列表时,ajax获取当前未支付的订单状态,查询订单状态时,再获取一下支付宝此订单的状态
-
其他各种问题
- 每天晚上闲时下载支付宝对账单,一一进行对账
6 接口幂等性
6.1 什么是幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的, 不会因为多次点击而产生了副作用; 比如说支付场景, 用户购买了商品支付扣款成功, 但是返回结果的时候网络异常, 此时钱已经扣了, 用户再次点击按钮, 此时会进行第二次扣款, 返回结果成功, 用户查询余额返发现多扣钱了, 流水记录也变成了两条. . . ,这就没有保证接口的幂等性。
6.2 哪些情况需要防止
- 用户多次点击按钮
- 用户页面回退再次提交
- 微服务互相调用, 由于网络问题, 导致请求失败。 feign 触发重试机制
- 其他业务情况
6.3 什么情况下需要幂等
以 SQL 为例, 有些操作是天然幂等的。
- SELECT * FROM table WHER id=?, 无论执行多少次都不会改变状态, 是天然的幂等。
- UPDATE tab1 SET col1=1 WHERE col2=2, 无论执行成功多少次状态都是一致的, 也是幂等操作。
- delete from user where userid=1, 多次操作, 结果一样, 具备幂等性
- insert into user(userid,name) values(1,‘a’) 如 userid 为唯一主键, 即重复操作上面的业务, 只会插入一条用户数据, 具备幂等性。
- UPDATE tab1 SET col1=col1+1 WHERE col2=2, 每次执行的结果都会发生变化, 不是幂等的。
- insert into user(userid,name) values(1,‘a’) 如 userid 不是主键, 可以重复, 那上面业务多次操作, 数据都会新增多条, 不具备幂等性。
6.4 幂等解决方案
6.4.1 token 机制 (本次使用)
- 服务端提供了发送 token 的接口。 我们在分析业务的时候, 哪些业务是存在幂等问题的,就必须在执行业务前, 先去获取 token, 服务器会把 token 保存到 redis 中。
- 然后调用业务接口请求时, 把 token 携带过去, 一般放在请求头部。
- 服务器判断 token 是否存在 redis 中, 存在表示第一次请求, 然后删除 token,继续执行业务。
- 如果判断 token 不存在 redis 中, 就表示是重复操作, 直接返回重复标记给 client, 这样就保证了业务代码, 不被重复执行。
危险性:
-
删除 token 还是后删除 token;
- 先删除可能导致, 业务确实没有执行, 重试还带上之前 token, 由于防重设计导致,请求还是不能执行。
- 后删除可能导致, 业务处理成功, 但是服务闪断, 出现超时, 没有删除 token, 别人继续重试, 导致业务被执行两边
- 我们最好设计为先删除 token, 如果业务调用失败, 就重新获取 token 再次请求。
-
Token 获取、 比较和删除必须是原子性
- redis.get(token) 、 token.equals、 redis.del(token)如果这两个操作不是原子, 可能导致, 高并发下, 都 get 到同样的数据, 判断都成功, 继续业务并发执行
- 可以在 redis 使用 lua 脚本完成这个操作
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
6.4.2 各种锁机制
6.4.2.1 数据库悲观锁
select * from xxxx where id = 1 for update;
悲观锁使用时一般伴随事务一起使用, 数据锁定时间可能会很长, 需要根据实际情况选用。另外要注意的是, id 字段一定是主键或者唯一索引, 不然可能造成锁表的结果, 处理起来会非常麻烦。
6.4.2.2 数据库乐观锁
这种方法适合在更新的场景中,
update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1根据 version 版本, 也就是在操作库存前先获取当前商品的 version 版本号, 然后操作的时候带上此 version 号。 我们梳理下, 我们第一次操作库存时, 得到 version 为 1, 调用库存服务version 变成了 2; 但返回给订单服务出现了问题, 订单服务又一次发起调用库存服务, 当订单服务传如的 version 还是 1, 再执行上面的 sql 语句时, 就不会执行; 因为 version 已经变为 2 了, where 条件就不成立。 这样就保证了不管调用几次, 只会真正的处理一次。乐观锁主要使用于处理读多写少的问题
6.4.2.3 业务层分布式锁
如果多个机器可能在同一时间同时处理相同的数据, 比如多台机器定时任务都拿到了相同数据处理, 我们就可以加分布式锁, 锁定此数据, 处理完成后释放锁。 获取到锁的必须先判断这个数据是否被处理过。
6.4.3 各种唯一约束
6.4.3.1 数据库唯一约束
插入数据, 应该按照唯一索引进行插入, 比如订单号, 相同的订单就不可能有两条记录插入。我们在数据库层面防止重复。
这个机制是利用了数据库的主键唯一约束的特性, 解决了在 insert 场景时幂等问题。 但主键的要求不是自增的主键, 这样就需要业务生成全局唯一的主键。
如果是分库分表场景下, 路由规则要保证相同请求下, 落地在同一个数据库和同一表中, 要不然数据库主键约束就不起效果了, 因为是不同的数据库和表主键不相关。
6.4.3.2 redis set 防重
很多数据需要处理, 只能被处理一次, 比如我们可以计算数据的 MD5 将其放入 redis 的 set,每次处理数据, 先看这个 MD5 是否已经存在, 存在就不处理。
6.4.4 防重表
使用订单号 orderNo 做为去重表的唯一索引, 把唯一索引插入去重表, 再进行业务操作, 且他们在同一个事务中。 这个保证了重复请求时, 因为去重表有唯一约束, 导致请求失败, 避免了幂等问题。 这里要注意的是, 去重表和业务表应该在同一库中, 这样就保证了在同一个事务, 即使业务操作失败了, 也会把去重表的数据回滚。 这个很好的保证了数据一致性。之前说的 redis 防重也算
6.4.5 全局请求唯一 id
调用接口时, 生成一个唯一 id, redis 将数据保存到集合中(去重) , 存在即处理过。可以使用 nginx 设置每一个请求的唯一 id;
proxy_set_header X-Request-Id $request_id;
7 本地事务与分布式事务
7.1 本地事务
7.1.1 事务的基本性质
数据库事务的几个特性: 原子性(Atomicity )、 一致性( Consistency )、 隔离性或独立性( Isolation)和持久性(Durabilily), 简称就是 ACID;
- 原子性: 一系列的操作整体不可拆分, 要么同时成功, 要么同时失败
- 一致性: 数据在事务的前后, 业务整体一致。
- 转账。 A:1000; B:1000; 转 200 事务成功; A: 800 B: 1200
- 隔离性: 事务之间互相隔离。
- 持久性: 一旦事务成功, 数据一定会落盘在数据库。
在以往的单体应用中, 我们多个业务操作使用同一条连接操作不同的数据表, 一旦有异常,我们可以很容易的整体回滚;
7.1.2 事务的隔离级别
- READ UNCOMMITTED(读未提交)
该隔离级别的事务会读到其它未提交事务的数据, 此现象也称之为脏读。 - READ COMMITTED( 读提交)
一个事务可以读取另一个已提交的事务, 多次读取会造成不一样的结果, 此现象称为不可重复读问题, Oracle 和 SQL Server 的默认隔离级别。 - REPEATABLE READ( 可重复读)
该隔离级别是 MySQL 默认的隔离级别, 在同一个事务里, select 的结果是事务开始时时间点的状态, 因此, 同样的 select 操作读到的结果会是一致的, 但是, 会有幻读现象。 MySQL的 InnoDB 引擎可以通过 next-key locks 机制( 参考下文"行锁的算法"一节) 来避免幻读。 - SERIALIZABLE( 序列化)
在该隔离级别下事务都是串行顺序执行的, MySQL 数据库的 InnoDB 引擎会给读操作隐式加一把读共享锁, 从而避免了脏读、 不可重读复读和幻读问题。
7.1.3 事务的传播行为
- PROPAGATION_REQUIRED: 如果当前没有事务, 就创建一个新事务, 如果当前存在事务,就加入该事务, 该设置是最常用的设置。
- PROPAGATION_SUPPORTS: 支持当前事务, 如果当前存在事务, 就加入该事务, 如果当前不存在事务, 就以非事务执行。
- PROPAGATION_MANDATORY: 支持当前事务, 如果当前存在事务, 就加入该事务, 如果当前不存在事务, 就抛出异常。
- PROPAGATION_REQUIRES_NEW: 创建新事务, 无论当前存不存在事务, 都创建新事务。
- PROPAGATION_NOT_SUPPORTED: 以非事务方式执行操作, 如果当前存在事务, 就把当前事务挂起。
- PROPAGATION_NEVER: 以非事务方式执行, 如果当前存在事务, 则抛出异常。
- PROPAGATION_NESTED: 如果当前存在事务, 则在嵌套事务内执行。 如果当前没有事务,则执行与 PROPAGATION_REQUIRED 类似的操作。
7.1.4 SpringBoot 事务关键点
7.1.4.1 事务的自动配置
TransactionAutoConfiguration
7.1.4.2 本地事务的坑
在同一个类里面, 编写两个方法, 内部调用的时候, 会导致事务设置失效。 原因是没有用到代理对象的缘故。
解决办法
- 导入 spring-boot-starter-aop
- @EnableTransactionManagement(proxyTargetClass = true)
- @EnableAspectJAutoProxy(exposeProxy=true)
- AopContext.currentProxy() 调用方法
示例:
1、如果方法a、b、c都在同一个service里面,事务传播行为不生效,共享一个事务原理:事务是用代理对象来控制的,内部调用b(),c(),就相当于直接调用没有经过事务【绕过了代理对象】解决:不能使用this.b();也不能注入自己【要使用代理对象来调用事务方法】@Transactional(timeout=30)
public void a() {b();// a事务传播给了b事务,并且b事务的设置失效c();// c单独创建一个新事务
}@Transactional(propagation = Propagation.REQUIRED, timeout=2)
public void b() {}@Transactional(propagation = Propagation.REQUIRES_NEW)
public void c() {}
解决步骤
具体步骤:
1、引入aop依赖<!-- 引入aop,解决本地事务失效问题 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>2、开启动态代理【默认使用jdk动态代理,需要有接口】
@EnableAspectJAutoProxy(exposeProxy = true) //开启了aspect动态代理模式,对外暴露代理对象
好处:cglib继承的方式完成动态代理
exposeProxy = true:对外暴露代理对象3、获取动态代理对象
OrderServiceImpl orderService = (OrderServiceImpl)AopContext.currentProxy();
orderService.b();
orderService.c();
7.2 分布式事务
7.2.1 为什么会有分布式事务?
分布式系统经常出现的异常机器宕机、 网络异常、 消息丢失、 消息乱序、 数据错误、 不可靠的 TCP、 存储数据丢失…
分布式事务是企业集成中的一个技术难点, 也是每一个分布式系统架构中都会涉及到的一个东西, 特别是在微服务架构中, 几乎可
以说是无法避免。
7.2.2 CAP定理与base理论
7.2.2.1 CAP定理
CAP 原则又称 CAP 定理, 指的是在一个分布式系统中
- 一致性(Consistency) :
- 在分布式系统中的所有数据备份, 在同一时刻是否同样的值。 (等同于所有节点访问同一份最新的数据副本)
- 可用性(Availability)
- 在集群中一部分节点故障后, 集群整体是否还能响应客户端的读写请求。 (对数据更新具备高可用性)
- 分区容错性(Partition tolerance)
- 大多数分布式系统都分布在多个子网络。 每个子网络就叫做一个区(partition) 。分区容错的意思是, 区间通信可能失败。 比如, 一台服务器放在中国, 另一台服务器放在美国, 这就是两个区, 它们之间可能无法通信。
CAP 原则指的是, 这三个要素最多只能同时实现两点, 不可能三者兼顾。
一般来说, 分区容错无法避免, 因此可以认为 CAP 的 P 总是成立。 CAP 定理告诉我们,剩下的 C 和 A 无法同时做到(CA没有P就是单体应用,没有必要)。
如果满足P,此时要满足A(所有机器都可用包括通信故障那台【数据未同步】),就不能保证一致性【同步数据的通信线故障,无法同步】
如果满足P,此时要满足C,那网络通信故障的节点就不应该继续提供服务(因为他的数据不一致)【宕机的那台机器数据 无法同步】
AP:容易,就算未同步的数据也可用
CP:牺牲可用性
1、算法:raft和paxos算法:http://thesecretlivesofdata.com/raft/【raft算法演示】
7.2.2.2 raft算法
7.2.2.2.1 领导选举机制
- 集群所有节点启动默认都是随从状态,在此期间每个随从都会自旋,如果该节点满足了自旋时间,那么该节点就会成为候选者,领导者需要在一定的时间内为所有随从者响应,告知自己还活着(心跳),随从者收到响应就会终止本次自旋开始新一轮的自旋
- 如果没有监听到到领导者的心跳,满足自旋时间的节点变成候选者,同时向其他节点发送投票请求(同时终止其自旋),终止其他节点自旋,成为候选者的可能有多个,此时候选者发起投票,直到有一个候选者获胜
- 最终成为领导
具体步骤:
1、选举超时 election timeout
随从变成候选者的时间【150ms and 300ms随机的】【自旋时间,如果没有收到领导的命令变成候选者】
例如:启动集群,3个节点获得随机自旋时间,自旋时间到了就成为候选节点
2、成为候选节点,并给自己投票1,然后给其他随从节点发送选举请求【随从节点的票可能投给更快的候选者】
随从节点的票一旦投出便重新自旋
3、心跳时间(heartbeat timeout):每隔一段时间发送一个心跳,然后随从节点刷新自旋时间【小于300ms,否则大家都成为候选者了】
此时领导网络延时,自旋结束产生候选者,产生新领导
4、有多个候选者,并且票数一样,就自旋重新投
7.2.2.2.2 领导日志复制(可保证数据一致性)
所有节点修改数据,都要通过领导来修改
- 客户端通知领导修改一个数据,领导先创建一个 节点日志
- 领导将这条日志 发送给所有所有随从节点【随从节点收到并返回确认消息给领导】
- 3、领导等待大多数随从节点的确认消息,领导提交数据,然后通知随从节点可以提交了
- 随从节点也提交数据。最后领导节点给请求返回提交成功
具体步骤:
1、领导收到后并不会马上给随从节点发送 日志,等待下一次心跳时发送日志
2、然后领导提交并马上返回请求提交成功。然后跟随下一个心跳发送随从 告诉其提交
3、可保证数据一致性【例如选出来两个领导,不同机房。2个和3个组成两个群】
demo:此时2个的那个客户端发请求,一直保存失败,因为不是大多数人成功【所以数据未提交】,但是另外一边3个节点组成的集群可以保存成功【大多数节点】
如果此时两个集群恢复了数据通信,旧领导退位,并且跟着旧领导未提交的数据需要回滚【低轮领导退位,新领导上位】
然后匹配上新领导的日志
7.2.2.3 CP的缺点
对于多数大型互联网应用的场景, 主机众多、 部署分散, 而且现在的集群规模越来越大, 所以节点故障、 网络故障是常态, 而且要保证服务可用性达到 99.99999%(N 个 9) , 即保证P 和 A, 舍弃 C。
7.2.2.4 BASE 理论
是对 CAP 理论的延伸, 思想是即使无法做到强一致性(CAP 的一致性就是强一致性) , 但可以采用适当的采取弱一致性, 即最终一致性。
BASE 是指
- 基本可用(Basically Available)
- 基本可用是指分布式系统在出现故障的时候, 允许损失部分可用性(例如响应时间、功能上的可用性) , 允许损失部分可用性。 需要注意的是, 基本可用绝不等价于系统不可用。
- 响应时间上的损失: 正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的查询结果, 但由于出现故障(比如系统部分机房发生断电或断网故障) , 查询结果的响应时间增加到了 1~2 秒。
- 功能上的损失: 购物网站在购物高峰(如双十一) 时, 为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。
- 软状态( Soft State)
- 软状态是指允许系统存在中间状态, 而该中间状态不会影响系统整体可用性。 分布式存储中一般一份数据会有多个副本, 允许不同副本同步的延时就是软状态的体现。 mysql replication 的异步复制也是一种体现。
- 最终一致性( Eventual Consistency)
- 最终一致性是指系统中的所有数据副本经过一定时间后, 最终能够达到一致的状态。 弱一致性和强一致性相反, 最终一致性是弱一致性的一种特殊情况。
7.2.2.5 强一致性、 弱一致性、 最终一致性
从客户端角度, 多进程并发访问时, 更新过的数据在不同进程如何获取的不同策略, 决定了不同的一致性。 对于关系型数据库, 要求更新过的数据能被后续的访问都能看到, 这是强一致性。 如果能容忍后续的部分或者全部访问不到, 则是弱一致性。 如果经过一段时间后要求能访问到更新后的数据, 则是最终一致性
7.2.3 分布式事务几种方案
7.2.3.1 2PC 模式
数据库支持的 2PC【 2 phase commit 二阶提交】 , 又叫做 XA Transactions。MySQL 从 5.5 版本开始支持, SQL Server 2005 开始
支持, Oracle 7 开始支持。其中, XA 是一个两阶段提交协议, 该协议分为以下两个阶段:
第一阶段: 事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作, 并反映是否可以提交.
第二阶段: 事务协调器要求每个数据库提交数据。
其中, 如果有任何一个数据库否决此次提交, 那么所有数据库都会被要求回滚它们在此事务中的那部分信息。
- XA 协议比较简单, 而且一旦商业数据库实现了 XA 协议, 使用分布式事务的成本也比较低。
- XA 性能不理想, 特别是在交易下单链路, 往往并发量很高, XA 无法满足高并发场景
- XA 目前在商业数据库支持的比较理想, 在 mysql 数据库中支持的不太理想, mysql 的XA 实现, 没有记录 prepare 阶段日志, 主备切换回导致主库与备库数据不一致。
- 许多 nosql 也没有支持 XA, 这让 XA 的应用场景变得非常狭隘。
- 也有 3PC, 引入了超时机制( 无论协调者还是参与者, 在向对方发送请求后, 若长时间未收到回应则做出相应处理)
7.2.3.2 柔性事务-TCC 事务补偿型方案 (seata)
刚性事务: 遵循 ACID 原则, 强一致性。
柔性事务: 遵循 BASE 理论, 最终一致性;
与刚性事务不同, 柔性事务允许一定时间内, 不同节点的数据不一致, 但要求最终一致。
一阶段 prepare 行为: 调用 自定义 的 prepare 逻辑。
二阶段 commit 行为: 调用 自定义 的 commit 逻辑。
二阶段 rollback 行为: 调用 自定义 的 rollback 逻辑。
所谓 TCC 模式, 是指支持把 自定义 的分支事务纳入到全局事务的管理中(seata)。
实现:
将业务代码拆成三部分。
1、try锁库存
2、confirm提交数据
3、事务补偿逻辑:一旦出现异常执行cancel来回滚【取消锁定库存】
其实就是2PC的手动实现
7.2.3.3 柔性事务-最大努力通知型方案【支付宝支付】【多,高并发场景】【基于消息服务mq】
按规律进行通知, 不保证数据一定能通知成功, 但会提供可查询操作接口进行核对。 这种方案主要用在与第三方系统通讯时, 比如: 调用微信或支付宝支付后的支付结果通知。 这种方案也是结合 MQ 进行实现, 例如: 通过 MQ 发送 http 请求, 设置最大通知次数。 达到通知次数后即不再通知。
案例: 银行通知、 商户通知等( 各大交易业务平台间的商户通知: 多次通知、 查询校对、 对账文件) , 支付宝的支付成功异步回调
例如支付宝支付成功,往MQ发送消息【隔几秒发一个】
订单订阅topic,一旦订单确认消息,给支付宝发送确认,支付宝就不再通知了