分布式高级篇4 —— 商城业务(2)

  • 一、订单服务
    • 1、订单基本概念
    • 2、订单基本构成
    • 3、订单状态
    • 4、订单流程
    • 5、配置拦截器拦截订单请求
    • 6、订单确认页模型抽取
    • 7、订单确认页vo封装
    • 8、Feign 远程调用请求头丢失问题
    • \*\*\*\*\* 惨痛教训
    • 9、Feign 异步调用请求头丢失问题
    • 10、查看库存状态
    • 11、模拟计算运费
    • 12、接口幂等性
      • (1)什么是接口幂等性
      • (2)哪些情况要防止接口幂等性
      • (3)什么情况下需要幂等性
      • (4)幂等性解决方案
        • token 机制
        • 各种锁机制
          • \- 数据库悲观锁
          • \- 数据库乐观锁
          • \- 业务层分布式锁
        • 各种唯一约束
          • \- 数据库唯一约束
          • \- redis防重
          • \- 防重表
          • \- 全局唯一id
    • 13、订单确认页防重令牌
    • 14、提交订单
  • 二、分布式事务
    • 1、本地事务
      • (1)事务的基本性质
      • (2)事务的隔离级别
      • (3)事务的传播行为
      • SpringBoot 中事务的坑
    • 2、分布式事务
      • (1)为什么出现分布式事务?
      • (2)CAP 理论
      • (3)面临的额问题
      • (4)BASE 理论
      • (5) 分布式事务的几种解决方案
        • 2PC模式
        • 柔性事务-TCC事务补偿型方案
        • 柔性事务- 最大努力型通知方案
        • 柔性事务- 可靠消息 + 最终一致性方案(异步确保型)
    • 3、Seata
      • (1)1.4.2 Seata + Nacos 搭建使用
        • seata 服务端配置
        • seata 客户端配置
      • (2)0.9 版本 Seata 使用
  • 三、使用延迟队列自动解锁库存
    • 解决 远程调用问题
  • 四、定时关闭订单
  • 五、支付服务
    • 1、一些概念说明
    • 2、沙箱环境测试
    • 3、内网穿透
    • 4、整合阿里云支付服务
    • 5、支付成功同步回调
    • 6、异步通知内网穿透环境搭建
    • 7、支付完成
    • 8、收单
  • 六、秒杀
    • 1、定时任务与Cron表达式
    • 2、秒杀商品上架
    • 3、秒杀上架幂等性问题
    • 4、查询所有的秒杀商品
      • 时区BUG
    • 5、查询某一个秒杀 商品
    • 6、秒杀系统设计
    • 7、秒杀流程
  • 七、Sentinel
    • 1、熔断降级限流
    • 2、SpringBoot整合Sentinel
    • 3、实时监控
    • 4、自定义sentinel的返回信息
    • 5、RabbitTemplate 与 MyRabbitConfig 循环依赖问题
    • 6、熔断降级
    • 7、开启自定义的受保护资源
    • 8、网关流控
    • 9、自定义网关流控回调
  • 总结

一、订单服务

视频来源: 【Java项目《谷粒商城》Java架构师 | 微服务 | 大型电商项目】
完整代码 yangzhaoguang/gilimall: 尚硅谷——谷粒商城项目 (github.com)
笔记对应视频集数:P261~P338

1、订单基本概念

电商系统涉及到 3 流,分别时信息流,资金流,物流,而订单系统作为中枢将三者有机的集 合起来。 订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这 些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通

2、订单基本构成

分布式高级篇4 —— 商城业务(2)_第1张图片

1、用户信息

用户信息包括用户账号、用户等级、用户的收货地址、收货人、收货人电话等组成,用户账 户需要绑定手机号码,但是用户绑定的手机号码不一定是收货信息上的电话。用户可以添加 多个收货信息,用户等级信息可以用来和促销系统进行匹配,获取商品折扣,同时用户等级 还可以获取积分的奖励等

2、订单基础信息

订单基础信息是订单流转的核心,其包括订单类型、父/子订单、订单编号、订单状态、订 单流转的时间等。 (1)订单类型包括实体商品订单和虚拟订单商品等,这个根据商城商品和服务类型进行区 分。

(2)同时订单都需要做父子订单处理,之前在初创公司一直只有一个订单,没有做父子订 单处理后期需要进行拆单的时候就比较麻烦,尤其是多商户商场,和不同仓库商品的时候, 父子订单就是为后期做拆单准备的。

(3)订单编号不多说了,需要强调的一点是父子订单都需要有订单编号,需要完善的时候 可以对订单编号的每个字段进行统一定义和诠释。

(4)订单状态记录订单每次流转过程,后面会对订单状态进行单独的说明。

(5)订单流转时间需要记录下单时间,支付时间,发货时间,结束时间/关闭时间等等

3、商品信息

商品信息从商品库中获取商品的 SKU 信息、图片、名称、属性规格、商品单价、商户信息 等,从用户下单行为记录的用户下单数量,商品合计价格等。

4.、优惠信息

优惠信息记录用户参与的优惠活动,包括优惠促销活动,比如满减、满赠、秒杀等,用户使 用的优惠券信息,优惠券满足条件的优惠券需要默认展示出来,具体方式已在之前的优惠券 篇章做过详细介绍,另外还虚拟币抵扣信息等进行记录。

为什么把优惠信息单独拿出来而不放在支付信息里面呢?

因为优惠信息只是记录用户使用的条目,而支付信息需要加入数据进行计算,所以做为区分。

5、支付信息

(1)支付流水单号,这个流水单号是在唤起网关支付后支付通道返回给电商业务平台的支 付流水号,财务通过订单号和流水单号与支付通道进行对账使用。

(2)支付方式用户使用的支付方式,比如微信支付、支付宝支付、钱包支付、快捷支付等。 支付方式有时候可能有两个——余额支付+第三方支付。

(3)商品总金额,每个商品加总后的金额;运费,物流产生的费用;优惠总金额,包括促 销活动的优惠金额,优惠券优惠金额,虚拟积分或者虚拟币抵扣的金额,会员折扣的金额等 之和;实付金额,用户实际需要付款的金额。 用户实付金额=商品总金额+运费-优惠总金额

6.、物流信息

物流信息包括配送方式,物流公司,物流单号,物流状态,物流状态可以通过第三方接口来 获取和向用户展示物流每个状态节点。

3、订单状态

1、待付款

用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支 付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超 时后将自动取消订单,订单变更关闭状态。

2、已付款/待发货

用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到 WMS 系统,仓库进行调拨,配货,分拣,出库等操作。

3、待收货/已发货

仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物 品物流状态

4.、已完成

用户确认收货后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态

6、售后中

用户在付款后申请退款,或商家发货后用户申请退换货。 售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待 商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单 状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。

4、订单流程

订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的 产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单 的流程,线上实物订单与 O2O 订单等,所以需要根据不同的类型进行构建订单流程。 不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正 向流程就是一个正常的网购步骤:订单生成–>支付订单–>卖家发货–>确认收货–>交易成功。 而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-832bKPJ4-1675935821383)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/%E7%94%B5%E5%95%86%E8%AE%A2%E5%8D%95%E6%B5%81%E7%A8%8B%E5%9B%BE.png)]

1、订单创建与支付

(1) 、订单创建前需要预览订单,选择收货信息等

(2) 、订单创建需要锁定库存,库存有才可创建,否则不能创建

(3) 、订单创建后超时未支付需要解锁库存

(4) 、支付成功后,需要进行拆单,根据商品打包方式,所在仓库,物流等进行拆单

(5) 、支付的每笔流水都需要记录,以待查账

(6) 、订单创建,支付成功等状态都需要给 MQ 发送消息,方便其他系统感知订阅

2、逆向流程

(1) 、修改订单,用户没有提交订单,可以对订单一些信息进行修改,比如配送信息, 优惠信息,及其他一些订单可修改范围的内容,此时只需对数据进行变更即可。

(2) 、订单取消,用户主动取消订单和用户超时未支付,两种情况下订单都会取消订 单,而超时情况是系统自动关闭订单,所以在订单支付的响应机制上面要做支付的 限时处理,尤其是在前面说的下单减库存的情形下面,可以保证快速的释放库存。 另外需要需要处理的是促销优惠中使用的优惠券,权益等视平台规则,进行相应补 回给用户。

(3) 、退款,在待发货订单状态下取消订单时,分为缺货退款和用户申请退款。如果是 全部退款则订单更新为关闭状态,若只是做部分退款则订单仍需进行进行,同时生 成一条退款的售后订单,走退款流程。退款金额需原路返回用户的账户。

(4) 、发货后的退款,发生在仓储货物配送,在配送过程中商品遗失,用户拒收,用户 收货后对商品不满意,这样情况下用户发起退款的售后诉求后,需要商户进行退款 的审核,双方达成一致后,系统更新退款状态,对订单进行退款操作,金额原路返 回用户的账户,同时关闭原订单数据。仅退款情况下暂不考虑仓库系统变化。如果 发生双方协调不一致情况下,可以申请平台客服介入。在退款订单商户不处理的情 况下,系统需要做限期判断,比如 5 天商户不处理,退款单自动变更同意退款。

5、配置拦截器拦截订单请求

在购物车中点击 ‘去结算’ 跳转到 订单确认页面,再次之前增加拦截器,判断用户是否登录,如果没有登录跳转到登录界面进行登录。

public class OrderInterceptor implements HandlerInterceptor {
    public static ThreadLocal<MemberRespVo> threadLocal = new ThreadLocal<>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        MemberRespVo memberRespVo = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
        if (memberRespVo == null) {
            request.getSession().setAttribute("msg","请先登录");
            // 没有进行登录,跳转到登录界面登录
            response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }else{
            threadLocal.set(memberRespVo);
            return true;
        }
    }
}
@Configuration
public class OrderWebConfig implements WebMvcConfigurer {
    /*
    * 增加拦截器
    * */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new OrderInterceptor()).addPathPatterns("/**");
    }
}

6、订单确认页模型抽取

修改一个BUG:

在购物车页面应该只有勾选的商品才加到总价格中

分布式高级篇4 —— 商城业务(2)_第2张图片

过滤出勾选的商品即可。

分布式高级篇4 —— 商城业务(2)_第3张图片

订单确认页模型抽取

1、首先是收货人信息。有更多地址,即有多个收货地址,其中有一个默认收货地址

分布式高级篇4 —— 商城业务(2)_第4张图片

2、支付方式这里不做过多的设计,使用固定的支付方式

分布式高级篇4 —— 商城业务(2)_第5张图片

3、订单购物项,这里的购物项是购物车中勾选的商品

分布式高级篇4 —— 商城业务(2)_第6张图片

4、优惠信息,这里只使用京豆优惠 ——> 会员的积分

分布式高级篇4 —— 商城业务(2)_第7张图片

5、订单的总金额 和 支付的金额

分布式高级篇4 —— 商城业务(2)_第8张图片

最终的订单确认Vo :

public class OrderConfirmVo {

    // 收货地址
    @Getter @Setter
    List<MemberAddressVo> address;

    // 订单购物项
    @Getter @Setter
    List<OrderItemVo> items;

    // 会员积分
    @Getter @Setter
    Integer integration;

    // 订单总金额
    BigDecimal total ;

    // 订单支付的总金额
    BigDecimal payPrice;
    
    // 订单令牌:防止重复提交订单
    String orderToken;

    // 订单总金额  = 每个购物项价格累加起来
    // 每个购物项的价格 = 每个商品价格 * 商品数量
    public BigDecimal getTotal() {
        return (items != null && items.size() > 0)
                ? items.stream().map(item ->item.getPrice().multiply(new BigDecimal(item.getCount()))).reduce(BigDecimal::add).get()
                : new BigDecimal(0);
    }

    public BigDecimal getPayPrice() {
        return getTotal();
    }
}
@Data
public class MemberAddressVo {
    @TableId
    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;
}

@Data
public class OrderItemVo {
    private Long  skuId;
    private String title;
    private String defaultImage;
    private BigDecimal price ;
    private Integer count = 1;
    private BigDecimal totalPrice ;
    private List<String> skuAttr;
}

7、订单确认页vo封装

OrderWebController

@Controller
public class OrderWebController {

    @Autowired
    OrderService orderService;

    @GetMapping("/toTrade")
    public String toTrade(Model model) {
        OrderConfirmVo vo = orderService.orderConfirm();
        // 保存订单确认vo
        model.addAttribute("orderConfirmData",vo);
        return "confirm";
    }
}

OrderServiceImpl

    @Autowired
    MemberFeignService memberFeignService;
    @Autowired
    private CartFeignService cartFeignService;

    @Override
    public OrderConfirmVo orderConfirm()
    {
        OrderConfirmVo orderConfirmVo = new OrderConfirmVo();
        MemberRespVo memberRespVo = OrderInterceptor.threadLocal.get();
        // 1、查询用户的收货地址 —— 远程调用
        List<MemberAddressVo> address = memberFeignService.getMemberAddresses(memberRespVo.getId());

        // 2、查询订单的购物项 —— 远程调用
        List<OrderItemVo> items = cartFeignService.getUserCartItems();

        // 3、积分信息
        orderConfirmVo.setIntegration(memberRespVo.getIntegration());

        // 总价格、应付价格自动封装...

        // TODO 订单防重复令牌

        return orderConfirmVo;
    }

MemberFeignService

@FeignClient("gulimall-member")
public interface MemberFeignService {

    /*
     * 获取用户的收货地址
     * */
    @GetMapping("member/memberreceiveaddress/{memberId}/getMemberAddresses")
    public List<MemberAddressVo> getMemberAddresses(@PathVariable("memberId") Long memberId);

}

CartFeignService

@FeignClient("gulimall-cart")
public interface CartFeignService {

    /*
     * 获取用户所有勾选的购物项
     * */
    @GetMapping("/getUserCartItems")
    public List<OrderItemVo> getUserCartItems();
}

CartController


    /*
    * 获取用户所有勾选的购物项
    * */
    @GetMapping("/getUserCartItems")
	@ResponseBody
    public List<CartItem> getUserCartItems() {
        return cartService.getUserCartItems();
    }

CartServiceImpl

    @Override
    public List<CartItem> getUserCartItems() {

        UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
        if (userInfoTo.getUserId() != null) {
            String cartKey = CART_TYPE_PREFIX + userInfoTo.getUserId();
            // 获取购物车中所有的购物项
            List<CartItem> cartItems = getCartItems(cartKey);
            // 过滤掉未勾选的商品。并且商品的价格应该从数据库中查询
            assert cartItems != null;
            List<CartItem>  collect= cartItems.stream().filter(CartItem::isCheck).map(cartItem -> {
                // 从数据库中查询最新的商品价格
                R skuInfo = productFeignService.getSkuInfo(cartItem.getSkuId());
                SkuInfoVo data = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {});
                cartItem.setPrice(data.getPrice());
                return cartItem;
            }).collect(Collectors.toList());
            return collect;
        }else {
            return null;
        }
    }

MemberReceiveAddressController

/*
* 获取用户的收货地址
* */
@GetMapping("/{memberId}/getMemberAddresses")
public List<MemberReceiveAddressEntity> getMemberAddresses(@PathVariable("memberId") Long memberId) {

    return memberReceiveAddressService.getMemberAddresses(memberId);
}

MemberReceiveAddressServiceImpl

    @Override
    public List<MemberReceiveAddressEntity> getMemberAddresses(Long memberId) {
        return this.list(new QueryWrapper<MemberReceiveAddressEntity>().eq("member_id",memberId));
    }

8、Feign 远程调用请求头丢失问题

由于我们 Order 服务调用 Cart 服务获取购物项时,并没有传入用户id,是 cart 服务自己去判断并获取的。这就会导致一个问题:

用户 id 是从 session 中获取的,并且这个 session 是和 GULISESSION 的 cookie相关联的,如果请求中没有携带这个 cookie,那么服务器就会新创建一个 session,自然就没有用户信息了。也就是说使用 Feign 远程调用会出现 请求头丢失的问题。

分布式高级篇4 —— 商城业务(2)_第9张图片

在 Feign 远程调用之前,会遍历所有的 RequestInterceptor ,如果我们自己定义一个 RequestInterceptor 并且在里面设置请求头,问题就迎刃而解了。

分布式高级篇4 —— 商城业务(2)_第10张图片

@Configuration
public class GuliFeignConfig {

    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor(){

        return new RequestInterceptor(){
            @Override
            public void apply(RequestTemplate requestTemplate) {
                // 获取老请求。和 Controller 中传入的 HttpServletRequest 一样
                ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                HttpServletRequest request = attributes.getRequest();
                requestTemplate.header("Cookie",request.getHeader("Cookie"));
            }
        };
    }
}

RequestContextHolder 就是利用了 ThreadLocal 共享了同一个线程中的request变量。

image-20230201224319575

***** 惨痛教训

Controller 中的方法被远程调用时,一定要加上 @ResponseBody 或者在 Controller 上加 @RestController !!!!!!

否则就会报:

org.thymeleaf.exceptions.TemplateInputException: Error resolving template

9、Feign 异步调用请求头丢失问题

将查询订单确认数据改成异步的方式:

    @Override
    public OrderConfirmVo orderConfirm() throws ExecutionException, InterruptedException {
        OrderConfirmVo orderConfirmVo = new OrderConfirmVo();
        MemberRespVo memberRespVo = OrderInterceptor.threadLocal.get();

        CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
            // 1、查询用户的收货地址 —— 远程调用
            List<MemberAddressVo> address = memberFeignService.getMemberAddresses(memberRespVo.getId());
            orderConfirmVo.setAddress(address);
        }, threadPoolExecutor);


        CompletableFuture<Void> orderItemsFuture = CompletableFuture.runAsync(() -> {
            // 2、查询订单的购物项 —— 远程调用
            // Feign在远程调用之前,会构造请求。
            List<OrderItemVo> items = cartFeignService.currentUserCartItems();
            orderConfirmVo.setItems(items);
        }, threadPoolExecutor);
        
        // 3、积分信息
        orderConfirmVo.setIntegration(memberRespVo.getIntegration());

        CompletableFuture.allOf(addressFuture,orderItemsFuture).get();
        // 总价格、应付价格自动封装...

        // TODO 订单防重复令牌

        return orderConfirmVo;
    }

此时又会出现一个问题,当使用 Feign 远程调用执行拦截器时,当获取老请求时会出现空指针异常。

分布式高级篇4 —— 商城业务(2)_第11张图片

原因就是:当没有使用异步任务的时候,无论是Controller、Service、Dao、拦截器都由一个线程执行。因此可以共享 ThreadLocal 中的数据。

而使用异步任务由多个线程处理,不会共享 ThreadLocal 中的数据。

分布式高级篇4 —— 商城业务(2)_第12张图片

因此解决办法就是在每个异步任务里都放一个request对象。

分布式高级篇4 —— 商城业务(2)_第13张图片

10、查看库存状态

WareFeignService

@FeignClient("gulimall-ware")
public interface WareFeignService {
    /*
     * 查询sku是否有库存
     * */
    @PostMapping("ware/waresku/hasStock")
    public HashMap<Long, Boolean> hasStock(@RequestBody List<Long> skuIds);
}

OrderServiceImpl

分布式高级篇4 —— 商城业务(2)_第14张图片

11、模拟计算运费

1、将收货地址与运费封装成一个 Vo

@Data
public class FareVo {
    private MemberAddressVo address;
    private BigDecimal fare ;
}

2、WareInfoController

    /*
    * 计算运费
    * */
    @GetMapping("/fare")
    public R fare(@RequestParam("addrId")Long  addrId) {
        FareVo fare =  wareInfoService.getFare(addrId);
        return  R.ok().setData(fare);
    }

3、计算运费的方式:手机号的最后一位(如有需要可对接顺丰、圆通…等各开放平台)。

    @Override
    public FareVo getFare(Long addrId) {

        R r = memberFeignService.info(addrId);
        MemberAddressVo data = r.getData("memberReceiveAddress", new TypeReference<MemberAddressVo>() {});
        if (data != null) {
            FareVo fareVo = new FareVo();
            String phone = data.getPhone();
            // 使用手机号最后一位作为运费。
            String fareString = phone.substring(phone.length() - 1, phone.length());
            BigDecimal fare = new BigDecimal(fareString);

            fareVo.setFare(fare);
            fareVo.setAddress(data);

            return fareVo;
        }
        return null;
    }

4、MemberFeignService

@FeignClient("gulimall-member")
public interface MemberFeignService {

    @RequestMapping("member/memberreceiveaddress/info/{id}")
    public R info(@PathVariable("id") Long id);
}

12、接口幂等性

(1)什么是接口幂等性

接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因 为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结 果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结 果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口 的幂等性。

(2)哪些情况要防止接口幂等性

用户多次点击按钮

用户页面回退再次提交

微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制

其他业务情况

(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 不是主键,可以重复,那上面业务多次操

作,数据都会新增多条,不具备幂等性。

(4)幂等性解决方案

token 机制

1、服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。

2、然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。

3、服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业 务。

4、如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样 就保证了业务代码,不被重复执行

危险性

1、先删除 token 还是后删除 token;

(1) 先删除可能导致,业务确实没有执行,重试还带上之前 token,由于防重设计导致, 请求还是不能执行。

(2) 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除 token,别 人继续重试,导致业务被执行两边

(3) 我们最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。

2、Token 获取、比较和删除必须是原子性

(1) redis.get(token) 、token.equals、redis.del(token)如果这两个操作不是原子,可能导 致,高并发下,都 get 到同样的数据,判断都成功,继续业务并发执行

(2) 可以在 redis 使用 lua 脚本完成这个操作

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
各种锁机制
- 数据库悲观锁

select * from xxxx where id = 1 for update;

悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。 另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会 非常麻烦。

- 数据库乐观锁

这种方法适合在更新的场景中,

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 条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。 乐观锁主要使用于处理读多写少的问题

- 业务层分布式锁

如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数 据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断 这个数据是否被处理过

各种唯一约束
- 数据库唯一约束

插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。

我们在数据库层面防止重复。

这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键 的要求不是自增的主键,这样就需要业务生成全局唯一的主键。 如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要 不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。

- redis防重

很多数据需要处理,只能被处理一次,比如我们可以计算数据的 MD5 将其放入 redis 的 set, 每次处理数据,先看这个 MD5 是否已经存在,存在就不处理。

- 防重表

使用订单号 orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且 他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避 免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个 事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。 之前说的 redis 防重也算

- 全局唯一id

调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。

可以使用 nginx 设置每一个请求的唯一 id;

proxy_set_header X-Request-Id $request_id;

13、订单确认页防重令牌

在 购物车 点击 去结算 ——> 服务器发送令牌给浏览器一份,redis存一份 ——>跳转到 订单确认页 在提交订单时,我们就可以判断这个令牌防止幂等性。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mU4EZDFU-1675935821387)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/%E8%AE%A2%E5%8D%95%E7%A1%AE%E8%AE%A4%E9%A1%B5%E6%B5%81%E7%A8%8B.png)]

        // TODO 订单防重复令牌
        String token = UUID.randomUUID().toString().replace("-","");
        orderConfirmVo.setOrderToken(token);
        redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberRespVo.getId(),token);

14、提交订单

提交订单的整体流程:

  1. 收集订单确认面的数据,为创建订单为准备
  2. 验证令牌是否是重复的请求。【获取令牌、验证令牌必须保证原子性操作。可使用LUA脚本或者Redisson】、
  3. 验证令牌成功,就是 创建订单的数据。包括:订单信息【用户信息、收货人信息、积分信息、优惠信息】,订单中每个订单项信息【订单信息、spu信息、sku信息、优惠信息、积分信息、价格信息】,运费、订单总金额
  4. 订单创建成功,进行验价,比较订单支付的金额与提交订单的金额是否一致。如果不一致说明价格发生变化,提醒用户确认
  5. 订单、验价完成,就是锁定库存,判断仓库中是否有足够的库存。

以上每一步失败都应该保证事务的回滚。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t6LYPAfq-1675935821387)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6(41)].png)

锁定库存流程:

分布式高级篇4 —— 商城业务(2)_第15张图片

1、收集订单确认页的数据

@Data
public class OrderSubmitVo {
    // 收货地址id
    private Long  addrId;
    // 支付类型。目前只使用在线支付
    private Integer patType;

    // 商品信息无需在这里封装,在购物车中重新查询一次

    // 用户信息可以直接从 session 中获取

    // 应付金额 —— 验证价格
    private BigDecimal payPrice;
    // 防重令牌
    private String orderToken;

    // 备注
    private String notes;
}

2、构建完订单,返回支付页面的vo

@Data
public class OrderSubmitResponseVo {

    // 订单信息
    private OrderEntity order;

    // 订单提交失败 —— 错误状态码
    // 0 提交成功
    private Integer Code = 0;
}

3、锁定库存vo

@Data
public class WareSkuLockVo {

    // 订单号
    private String orderSn;

    // 需要锁定的库存信息
    private List<OrderItemVo> locks;
}

4、库存不足的异常、放到 common 模块下

public class NoStockException extends RuntimeException{

    private Long skuId;

    public NoStockException(Long skuId) {
        super(skuId + " 商品,没有足够的库存");
    }

    public NoStockException() {
        super("商品没有足够的库存");
    }

    public Long getSkuId() {
        return skuId;
    }

    public void setSkuId(Long skuId) {
        this.skuId = skuId;
    }
}

5、库存不足响应码、响应信息

NO_STOCK_EXCEPTION(21000,"商品库存不足"),

6、 controller 层,处理提交订单的请求

    /*
    * 提交订单
    * */
    @PostMapping("/submitOrder")
    public String submitOrder(OrderSubmitVo vo, Model model, RedirectAttributes attributes) {
        try {
            OrderSubmitResponseVo responseVo = orderService.submitOrder(vo);
            if (responseVo.getCode() == 0) {
                // 创建订单成功,跳转到支付页面
                model.addAttribute("submitOrderResp",responseVo);
                return  "pay";
            }else {
                // 创建失败、回到订单页面
                String msg = "下单失败";
                switch (responseVo.getCode()) {
                    case 1 : msg = "订单信息过期,请重新提交..." ; break;
                    case 2 : msg = "订单商品发生变化,请确认后再次提交"; break;
                    case 3 : msg = "商品库存不足";break;
                }
                attributes.addFlashAttribute("msg",msg);
                return "redirect:http://order.gulimall.com/toTrade";
            }
        } catch (Exception e) {
            if (e instanceof NoStockException) {
                String message = ((NoStockException)e).getMessage();
                attributes.addFlashAttribute("msg",message);
            }
            return "redirect:http://order.gulimall.com/toTrade";
        }
    }

7、验证令牌、创建订单、验证价格、锁定库存

 @Override
    @Transactional(rollbackFor = NoStockException.class) // 保证事务回滚
    public OrderSubmitResponseVo submitOrder(OrderSubmitVo vo) throws NoStockException{
        OrderSubmitResponseVo responseVo = new OrderSubmitResponseVo();
        MemberRespVo memberRespVo = OrderInterceptor.threadLocal.get();
        // 1、验证令牌【必须保证获取令牌、删除令牌的原子性】
        String orderToken = vo.getOrderToken();
        // LUA 脚本: 0代表删除失败,1 代表删除成功
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        // 执行 LUA 脚本
        Long execute = redisTemplate.execute(
                new DefaultRedisScript<Long>(script, Long.class),
                Collections.singletonList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()),
                orderToken);
        if (execute != null && execute == 0) {
            // 令牌验证失败
            responseVo.setCode(1);
            return  responseVo;
        }else{
            // 令牌验证成功: 创建订单、验证价格、锁定库存....
           //  1、创建订单
           CreatedOrderTo orderTo =  createOrder(vo);
           // 2、验证价格
            BigDecimal payPrice = vo.getPayPrice();
            BigDecimal payAmount = orderTo.getOrder().getPayAmount();
            if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
                // 3、验价成功,保存订单
                saveOrder(orderTo);
                // 4、锁定库存,库存不足抛出异常,并回滚事务
                WareSkuLockVo wareSkuLockVo = new WareSkuLockVo();
                List<OrderItemVo> locks = createOrderItems(orderTo.getOrder().getOrderSn()).stream().map(item -> {
                    OrderItemVo orderItemVo = new OrderItemVo();
                    orderItemVo.setCount(item.getSkuQuantity());
                    orderItemVo.setSkuId(item.getSkuId());
                    orderItemVo.setTitle(item.getSkuName());
                    return orderItemVo;
                }).collect(Collectors.toList());

                wareSkuLockVo.setOrderSn(orderTo.getOrder().getOrderSn());
                wareSkuLockVo.setLocks(locks);
                R r = wareFeignService.orderLockStock(wareSkuLockVo);
                if (r.getCode() ==0) {
                    // 锁定成功
                    responseVo.setOrder(orderTo.getOrder());
                    return responseVo;
                }else {
                    // 锁定失败.抛出异常
                    responseVo.setCode(3);
                    throw new NoStockException();
                }
            }else {
                // 验价失败
                responseVo.setCode(2);
                return responseVo;
            }
        }
        // 这种方式不能保证原子性。应该使用 LUA脚本或者Redisson分布式锁
        /* String redisToken = (String) redisTemplate.opsForValue().get(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId());
        if (orderToken != null && orderToken.equals(redisToken)) {
            // 令牌验证通过
        }else {
            // 令牌验证失败
        }*/
    }

    /*
    * 保存订单、订单项
    * */
    private void saveOrder(CreatedOrderTo orderTo) {
        OrderEntity order = orderTo.getOrder();
        order.setCreateTime(new Date());
        // 保存订单
        this.save(order);

        // 保存订单项
        orderItemService.saveBatch(orderTo.getItems());

    }

    /*
    * 创建 CreatedOrderTo
    * */
    private CreatedOrderTo createOrder(OrderSubmitVo vo) {
        CreatedOrderTo orderTo = new CreatedOrderTo();

        // 1、构建订单
        OrderEntity orderEntity = createOrderEntity(vo);
        // 创建订单号: MyBatis-Plus 中自动生成
        String orderSn = IdWorker.getTimeId();
        orderEntity.setOrderSn(orderSn);

        // 2、构建订单项
        List<OrderItemEntity> items = createOrderItems(orderSn);

        // 3、计算订单价格
        assert items != null;
       computePrice(items,orderEntity);

        orderTo.setOrder(orderEntity);
        orderTo.setItems(items);
        return  orderTo;
    }

    /*
    * 计算订单的价格
    * */
    private void computePrice(List<OrderItemEntity> items, OrderEntity orderEntity) {

        // 总价
        BigDecimal total = new BigDecimal("0.0");
        // 优惠券优惠分解金额
        BigDecimal coupon = new BigDecimal("0.0");
        // 积分优惠分解金额
        BigDecimal integration = new BigDecimal("0.0");
        // 商品促销分解金额
        BigDecimal promotion = new BigDecimal("0.0");

        // 积分、成长值
        Integer integrationTotal = 0;
        Integer growthTotal = 0;

        // 循环叠加每一个订单项的优惠价格、订单总价格、积分、成长信息
        for (OrderItemEntity orderItem : items) {

            // 优惠价格
            coupon = coupon.add(orderItem.getCouponAmount());
            promotion = promotion.add(orderItem.getPromotionAmount());
            integration = integration.add(orderItem.getIntegrationAmount());
            // 订单总价格
            total = total.add(orderItem.getRealAmount());
            //积分信息和成长值信息
            integrationTotal += orderItem.getGiftIntegration();
            growthTotal += orderItem.getGiftGrowth();

        }

        // 设置订单的总价格、优惠价格、积分信息
        orderEntity.setTotalAmount(total);
        orderEntity.setPayAmount(total.add(orderEntity.getFreightAmount()));
        orderEntity.setCouponAmount(coupon);
        orderEntity.setPromotionAmount(promotion);
        orderEntity.setIntegrationAmount(integration);
        orderEntity.setIntegration(integrationTotal);
        orderEntity.setGrowth(growthTotal);

        //设置删除状态(0-未删除,1-已删除)
        orderEntity.setDeleteStatus(0);
    }

    /*
     * 构建 OrderEntity
     * */
    @NotNull
    private OrderEntity createOrderEntity(OrderSubmitVo vo) {
        // 一、构建订单数据
        OrderEntity orderEntity = new OrderEntity();

        // 1、订单收货信息、运费
        R r = wareFeignService.fare(vo.getAddrId());
        if (r.getCode() == 0) {
            FareVo fareVo = r.getData("data", new TypeReference<FareVo>() {});
            orderEntity.setFreightAmount(fareVo.getFare());
            orderEntity.setMemberId(fareVo.getAddress().getMemberId());
            orderEntity.setReceiverPhone(fareVo.getAddress().getPhone());
            orderEntity.setReceiverCity(fareVo.getAddress().getCity());
            orderEntity.setReceiverName(fareVo.getAddress().getName());
            orderEntity.setReceiverDetailAddress(fareVo.getAddress().getDetailAddress());
            orderEntity.setReceiverPostCode(fareVo.getAddress().getPostCode());
            orderEntity.setReceiverProvince(fareVo.getAddress().getProvince());
            orderEntity.setReceiverRegion(fareVo.getAddress().getRegion());
        }
        // 2、设置订单相关的状态信息
        orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
        orderEntity.setAutoConfirmDay(7);
        orderEntity.setConfirmStatus(0);
        return orderEntity;
    }

    /*
    *  createdOrderItems:构建所有的订单项
    * */
    private List<OrderItemEntity> createOrderItems(String orderSn) {
        // 购物车中的所有购物项
        List<OrderItemVo> orderItemVos = cartFeignService.currentUserCartItems();
        if ( orderItemVos != null && orderItemVos.size() > 0 ) {
            // 将 orderItemVo ——> orderItemEntity
            List<OrderItemEntity> items = orderItemVos.stream().map(orderItemVo -> {
                // 构建订单项
                OrderItemEntity orderItemEntity = createOrderItem(orderItemVo);
                orderItemEntity.setOrderSn(orderSn);
                return orderItemEntity;
            }).collect(Collectors.toList());
            return items;
        }
        return  null ;
    }

    /*
    * orderItemVo 构建每个订单项
    * */
    private OrderItemEntity createOrderItem(OrderItemVo orderItemVo) {
        OrderItemEntity orderItemEntity = new OrderItemEntity();
        // 1、订单信息 √
        // 2、spu信息 —— 远程调用
        R r = productFeignService.getSpuInfoBySkuId(orderItemVo.getSkuId());
        if (r.getCode() ==0) {
            SpuInfoVo spuInfoVo = r.getData("data", new TypeReference<SpuInfoVo>(){});
            orderItemEntity.setSpuId(spuInfoVo.getId());
            orderItemEntity.setSpuName(spuInfoVo.getSpuName());
            orderItemEntity.setCategoryId(spuInfoVo.getCatelogId());
            orderItemEntity.setSpuBrand(spuInfoVo.getBrandId().toString());
        }
        // 3、sku信息
        orderItemEntity.setSkuId(orderItemVo.getSkuId());
        orderItemEntity.setSkuName(orderItemVo.getTitle());
        orderItemEntity.setSkuPic(orderItemVo.getDefaultImage());
        // collectionToDelimitedString spring提供的,可以将list集合按照指定字符拼接成string字符串
        orderItemEntity.setSkuAttrsVals(StringUtils.collectionToDelimitedString(orderItemVo.getSkuAttr(),";"));
        orderItemEntity.setSkuQuantity(orderItemVo.getCount());
        orderItemEntity.setSkuPrice(orderItemVo.getPrice());
        // 4、优惠信息 —— 省略
        // 5、积分信息
        orderItemEntity.setGiftIntegration(orderItemVo.getPrice().intValue());
        orderItemEntity.setGiftGrowth(orderItemVo.getPrice().intValue());
        // 6、每个订单项的价格 = sku价格 * sku数量
        // 商品促销分解金额
        orderItemEntity.setPromotionAmount(BigDecimal.ZERO);
        // 优惠券优惠分解金额
        orderItemEntity.setCouponAmount(new BigDecimal("0.0"));
        // 积分优惠分解金额
        orderItemEntity.setIntegrationAmount(new BigDecimal("0.0"));

        // 订单项最终的价格
        BigDecimal initPrice = orderItemEntity.getSkuPrice().multiply(new BigDecimal(orderItemEntity.getSkuQuantity()));
        BigDecimal finalPrice = initPrice
                .subtract(orderItemEntity.getPromotionAmount())
                .subtract(orderItemEntity.getCouponAmount())
                .subtract(orderItemEntity.getIntegrationAmount());

        orderItemEntity.setRealAmount(finalPrice);


        return orderItemEntity;
    }

令牌key

public class OrderConstant {
    public static final String USER_ORDER_TOKEN_PREFIX = "order:token:";
}

8、远程调用接口

@FeignClient("gulimall-product")
public interface ProductFeignService {
    /*
     * 根据 skuId 获取 spu 信息
     * */
    @GetMapping("product/spuinfo/{skuId}")
    public R getSpuInfoBySkuId(@PathVariable("skuId")Long skuId);
}

@FeignClient("gulimall-ware")
public interface WareFeignService {
    /*
     * 查询sku是否有库存
     * */
    @PostMapping("ware/waresku/hasStock")
    public HashMap<Long, Boolean> hasStock(@RequestBody List<Long> skuIds);

    /*
     * 计算运费
     * */
    @GetMapping("ware/wareinfo/fare")
    public R fare(@RequestParam("addrId")Long  addrId);


    /*
    * 锁定库存
    * */
    @PostMapping("ware/wareinfo/lock/order")
    public R orderLockStock(@RequestBody WareSkuLockVo vo);
}

9、product 模块对应的远程调用方法

    /*
    * 根据 skuId 获取 spu 信息
    * */
    @GetMapping("/{skuId}")
    public R getSpuInfoBySkuId(@PathVariable("skuId")Long skuId) {
        SpuInfoEntity spuInfo =  spuInfoService.getSpuInfoBySkuId(skuId);
        return R.ok().setData(spuInfo);
    }
    @Override
    public SpuInfoEntity getSpuInfoBySkuId(Long skuId) {
        SkuInfoEntity skuInfo = skuInfoService.getById(skuId);
        SpuInfoEntity spuInfo = this.getById(skuInfo.getSpuId());

        return spuInfo;
    }

10、ware 模块对应的远程调用方法

WareSkuController

    /*
    * 查询sku是否有库存
    * */
    @PostMapping("/hasStock")
    public HashMap<Long, Boolean> hasStock(@RequestBody List<Long> skuIds) {
        HashMap<Long, Boolean> map =  wareSkuService.getSkusHasStock(skuIds);
        return  map;
    }
    /*
    * 查询 sku 是否有库存
    * */
    @Override
    public HashMap<Long, Boolean> getSkusHasStock(List<Long> skuIds) {

        HashMap<Long, Boolean> map = new HashMap<>();
        for (Long skuId : skuIds) {
            // 查询库存: 当前库存 - 锁定库存
            // SELECT SUM(stock - stock_locked) FROM `wms_ware_sku` WHERE sku_id = 1
            Integer count = baseMapper.getSkusHasStock(skuId);
            map.put(skuId,count != null && count>0);
        }

        return map;
    }

对应的 mapper 映射文件:

    <select id="getSkusHasStock" resultType="java.lang.Integer">
        SELECT SUM(stock - stock_locked) FROM `wms_ware_sku` WHERE sku_id = #{id}
    select>

WareInfoController

    /*
    * 锁定库存
    * */

    @PostMapping("/lock/order")
    public R orderLockStock(@RequestBody WareSkuLockVo vo) {
        try {
            Boolean result = wareSkuService.orderLockStock(vo);
            return  R.ok();
        } catch (NoStockException e) {
            return R.error(BizCodeEnum.NO_STOCK_EXCEPTION.getCode(),BizCodeEnum.NO_STOCK_EXCEPTION.getMessage());
        }

    }

    }
    /*
    * 计算运费
    * */
    @GetMapping("/fare")
    public R fare(@RequestParam("addrId")Long  addrId) {
        FareVo fare =  wareInfoService.getFare(addrId);
        return  R.ok().setData(fare);
    }
    @Override
    public FareVo getFare(Long addrId) {

        R r = memberFeignService.info(addrId);
        MemberAddressVo data = r.getData("memberReceiveAddress", new TypeReference<MemberAddressVo>() {
        });
        if (data != null) {
            FareVo fareVo = new FareVo();
            String phone = data.getPhone();
            // 使用手机号最后一位作为运费。
            String fareString = phone.substring(phone.length() - 1, phone.length());
            BigDecimal fare = new BigDecimal(fareString);

            fareVo.setFare(fare);
            fareVo.setAddress(data);

            return fareVo;
        }
        return null;
    }

    /*
     * 锁定库存
     * */
    @Override
    @Transactional(rollbackFor = NoStockException.class )
    public Boolean orderLockStock(WareSkuLockVo vo) {
        // 1、找到商品在哪个仓库有库存
        List<OrderItemVo> locks = vo.getLocks();

        List<SkuWareHasStock> hasStocks = locks.stream().map(item -> {
            SkuWareHasStock stock = new SkuWareHasStock();
            stock.setSkuId(item.getSkuId());
            //  查询哪些仓库有库存。
            List<Long> wareIds = baseMapper.listWareIdHasStock(item.getSkuId());
            stock.setWareId(wareIds);
            stock.setNum(item.getCount());
            return stock;
        }).collect(Collectors.toList());


        // 2、锁定库存
        for (SkuWareHasStock hasStock : hasStocks) {
            // 标志位,表示当前商品是否被锁住
            Boolean skuStocked = false;
            Long skuId = hasStock.getSkuId();
            List<Long> wareIds = hasStock.getWareId();
            if (wareIds == null || wareIds.size() == 0) {
                // 该商品没有库存,直接抛出异常
                throw new NoStockException(skuId);
            }
            for (Long wareId : wareIds) {
                // 锁定库存,成功返回 1,失败返回0
                Long count = baseMapper.lockSkuStock(skuId,wareId,hasStock.getNum());
                if (count == 1){
                    // 当前商品锁定成功
                    skuStocked = true;
                    break;
                }else {
                    // 当前商品锁定失败,
                }
            }
            // 当前商品所有仓库都没有锁定成功
            if (!skuStocked) {
                throw new NoStockException(skuId);
            }
        }

        return true;
    }

}

/*
*   哪个仓库有对应的商品
* */
@Data
class SkuWareHasStock {
    private Long skuId;
    // 锁多少件
    private Integer num ;
    private List<Long> wareId;
}

对应的 Mapper 映射文件:

    
    <update id="lockSkuStock">
        UPDATE `wms_ware_sku` SET stock_locked = stock_locked + #{num}
        WHERE sku_id = #{skuId} AND ware_id = #{wareId} AND stock - stock_locked > #{num}
    update>

    
    <select id="listWareIdHasStock" resultType="java.lang.Long">
        SELECT ware_id FROM `wms_ware_sku` WHERE sku_id =#{skuId} AND stock - stock_locked > 0
    select>

二、分布式事务

在提交订单中使用本地事务进行事务回滚,但是在分布式下可能会出现一些问题:

1、远程服务假失败:

​ 远程服务其实成功了,由于网络故障等没有返回

​ 导致:订单回滚,库存却扣减

2、远程服务执行完成,下面的其他方法出现问题

​ 导致:已执行的远程请求,肯定不能回滚

本地事务只能保证在同一个服务,同一个数据库中的回滚事务,无法感知其他服务中出现的异常。

1、本地事务

(1)事务的基本性质

数据库事务的几个特性:原子性(Atomicity )、一致性( Consistency )、隔离性或独立性( Isolation) 和持久性(Durabilily),简称就是 ACID;

  • 原子性:一系列的操作整体不可拆分,要么同时成功,要么同时失败
  • 一致性:数据在事务的前后,业务整体一致。
    • 转账。A:1000;B:1000; 转 200 事务成功; A:800 B:1200
  • 隔离性:事务之间互相隔离。
  • 持久性:一旦事务成功,数据一定会落盘在数据库。

在以往的单体应用中,我们多个业务操作使用同一条连接操作不同的数据表,一旦有异常, 我们可以很容易的整体回滚;

分布式高级篇4 —— 商城业务(2)_第16张图片

Business:我们具体的业务代码

Storage:库存业务代码;扣库存

Order:订单业务代码;保存订单

Account:账号业务代码;减账户余额

比如买东西业务,扣库存,下订单,账户扣款,是一个整体;必须同时成功或者失败

一个事务开始,代表以下的所有操作都在同一个连接里面;

(2)事务的隔离级别

  • READ UNCOMMITTED(读未提交)
    • 该隔离级别的事务会读到其它未提交事务的数据,此现象也称之为脏读。
  • READ COMMITTED(读提交)
    • 一个事务可以读取另一个已提交的事务,多次读取会造成不一样的结果,此现象称为不可重 复读问题,Oracle 和 SQL Server 的默认隔离级别
  • REPEATABLE READ(可重复读)
    • 该隔离级别是 MySQL 默认的隔离级别,在同一个事务里,select 的结果是事务开始时时间 点的状态,因此,同样的 select 操作读到的结果会是一致的,但是,会有幻读现象。MySQL 的 InnoDB 引擎可以通过 next-key locks 机制(参考下文"行锁的算法"一节)来避免幻读。
  • SERIALIZABLE(序列化)
    • 在该隔离级别下事务都是串行顺序执行的,MySQL 数据库的 InnoDB 引擎会给读操作隐式 加一把读共享锁,从而避免了脏读、不可重读复读和幻读问题。

(3)事务的传播行为

1、PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务, 就加入该事务,该设置是最常用的设置。

2、PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当 前不存在事务,就以非事务执行。

3、PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果 当前不存在事务,就抛出异常。

4、PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。

5、PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当 前事务挂起。

6、PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。

7、PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务, 则执行与 PROPAGATION_REQUIRED 类似的操作。

    // b 和 a 共享一个事务。c是一个新事物
    @Transactional(timeout = 20)
    public void a() {
        b();
        c();
    }

    // 由于 b 和 a 共享一个事务,因此 b 事务中的所有配置都没有用。
    @Transactional(propagation = Propagation.REQUIRED,timeout = 2)
    public void b() {

    }
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void c() {

    }

SpringBoot 中事务的坑

在同一个类里面,编写两个方法,内部调用的时候,会导致事务设置失效。原因是没有用到 代理对象的缘故。

比如: a、b、c都在一个类中,b和c事务的设置不会起作用。相当于将 b、c 中的代码都合并到 a中


    @Transactional(timeout = 20)
    public void a() {
        b();
        c();
    }

    @Transactional(propagation = Propagation.REQUIRED,timeout = 2)
    public void b() {
        System.out.println("b");
    }
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void c() {
        System.out.println("c");
    }

======================================  等价于
    @Transactional(timeout = 20)
    public void a() {
        System.out.println("b");
        System.out.println("c");
    }

解决:使用动态代理调用方法

0)、导入 spring-boot-starter-aop

1)、@EnableTransactionManagement(proxyTargetClass = true)

2)、@EnableAspectJAutoProxy(exposeProxy=true)

3)、AopContext.currentProxy()


    @Transactional(timeout = 20)
    public void a() {
        // 可直接转换为任意对象
        OrderServiceImpl orderService = (OrderServiceImpl) AopContext.currentProxy();
        orderService.b();
        orderService.c();
    }


    @Transactional(propagation = Propagation.REQUIRED,timeout = 2)
    public void b() {
        System.out.println("b");
    }
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void c() {
        System.out.println("c");
    }

2、分布式事务

(1)为什么出现分布式事务?

分布式系统经常出现的异常

机器宕机、网络异常、消息丢失、消息乱序、数据错误、不可靠的 TCP、存储数据丢失…

分布式事务是企业集成中的一个技术难点,也是每一个分布式系统架构中都会涉及到的一个 东西,特别是在微服务架构中,几乎可以说是无法避免。

(2)CAP 理论

CAP 原则又称 CAP 定理,指的是在一个分布式系统中

一致性(Consistency)

在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访 问同一份最新的数据副本)

可用性(Availability)

在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据 更新具备高可用性)

分区容错性(Partition tolerance)

大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。 分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务 器放在美国,这就是两个区,它们之间可能无法通信。

CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾

分布式高级篇4 —— 商城业务(2)_第17张图片

一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们, 剩下的 C 和 A 无法同时做到。

分布式高级篇4 —— 商城业务(2)_第18张图片

如果想要达到一致性,就必须让C节点不可用

分布式高级篇4 —— 商城业务(2)_第19张图片

分布式事务下如何保证数据的一致性:的 raft 算法、paxos算法

以 raft 算法为例:

raft 算法主要是通过 领导选举、日志复制 的方式来达到一致性。节点的三种状态:随从者、候选者、领导者

在所有节点中选取一个 领导者 , 选举的方式主要是通过 投票选举 , 领导者将客户端发送来的数据保存在日志当中, 向其他节点发送 日志 保存数据。

1、如何选举领导者?

所有的节点在初始状态下都是 随从者,如果随从者在 指定的时间 内没有监听到 领导者 发送的信息,就会认为没有领导者,其中一个随从者就会变成候选者, 候选者会向其他随从者送选票请求,票数超过一半,该候选者就会变成 领导者。所有系统中的请求都是通过领导者,然后领导者发送给其他随从者。

2、raft 中的俩种超时时间

第一种:选举超时时间,随从者等待变成候选者的超时时间,默认是 150~300ms。

第二种:心跳超时时间,领导者向随从者发送日志的间隔时间。

如果有多个候选者,就会重新进行选举机制。

raft 动画演示:http://thesecretlivesofdata.com/raft/

(3)面临的额问题

对于多数大型互联网应用的场景,主机众多、部署分散,而且现在的集群规模越来越大,所 以节点故障、网络故障是常态,而且要保证服务可用性达到 99.99999%(N 个 9),即保证 P 和 A,舍弃 C。

(4)BASE 理论

是对 CAP 理论的延伸,思想是即使无法做到强一致性(CAP 的一致性就是强一致性),但可 以采用适当的采取弱一致性,即最终一致性。

BASE 理论是指

基本可用(Basically Available)

基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、 功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系 统不可用。

响应时间上的损失:正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的 查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询 结果的响应时间增加到了 1~2 秒。

功能上的损失:购物网站在购物高峰(如双十一)时,为了保护系统的稳定性, 部分消费者可能会被引导到一个降级页面。

软状态( Soft State)

软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布 式存储中一般一份数据会有多个副本,允许不同副本同步的延时就是软状态的体 现。mysql replication 的异步复制也是一种体现。

最终一致性( Eventual Consistency)

最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状 态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。

(5) 分布式事务的几种解决方案

2PC模式

数据库支持的 2PC【2 phase commit 二阶提交】,又叫做 XA Transactions。 MySQL 从 5.5 版本开始支持,SQL Server 2005 开始支持,Oracle 7 开始支持。

其中,XA 是一个两阶段提交协议,该协议分为以下两个阶段:

第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是 否可以提交.

第二阶段:事务协调器要求每个数据库提交数据。 其中,如果有任何一个数据库否决此次提交,那么所有数据库都会被要求回滚它们在此事务 中的那部分信息

分布式高级篇4 —— 商城业务(2)_第20张图片

  • XA 协议比较简单,而且一旦商业数据库实现了 XA 协议,使用分布式事务的成本也比较 低。
  • XA 性能不理想,特别是在交易下单链路,往往并发量很高,XA 无法满足高并发场景
  • XA 目前在商业数据库支持的比较理想, mysql 数据库中支持的不太理想,mysql 的 XA 实现,没有记录 prepare 阶段日志,主备切换回导致主库与备库数据不一致。
  • 许多 nosql 也没有支持 XA,这让 XA 的应用场景变得非常狭隘。
  • 也有 3PC,引入了超时机制(无论协调者还是参与者,在向对方发送请求后,若长时间 未收到回应则做出相应处理)
柔性事务-TCC事务补偿型方案

刚性事务:遵循 ACID 原则,强一致性。

柔性事务:遵循 BASE 理论,最终一致性;

与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。

分布式高级篇4 —— 商城业务(2)_第21张图片

一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。

二阶段 commit 行为:调用 自定义 的 commit 逻辑。

二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。

所谓 TCC 模式,是指支持把 自定义 的分支事务纳入到全局事务的管理中。

分布式高级篇4 —— 商城业务(2)_第22张图片

柔性事务- 最大努力型通知方案

按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接口进行核对。这种 方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。这种 方案也是结合 MQ 进行实现,例如:通过 MQ 发送 http 请求,设置最大通知次数。达到通 知次数后即不再通知。

案例:银行通知、商户通知等(各大交易业务平台间的商户通知:多次通知、查询校对、对 账文件),支付宝的支付成功异步回调

柔性事务- 可靠消息 + 最终一致性方案(异步确保型)

实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送消息,实时消息服务只 记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确 认发送。只有在得到确认发送指令后,实时消息服务才会真正发送。

3、Seata

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

Transaction Coordinator (TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚;

Transaction Manager ™:控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议;

Resource Manager (RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚

TC 负责全局的事务,TM 是控制所有的RM。RM 是每个服务中的本地事务。

首先 Business 服务会向 TC 请求开启一个大事务,就着 Business 远程调用 Stock 服务,Stock 服务会将自己的分支事务注册到 TC,并实时通知 TC 自己事务的状态。调用 Order 服务同样也会通知 TC 自己的事务状态。

当调用Account 服务,出现错误回滚时,TC 会通知 Stock、Order 都进行回滚。

每个服务的数据库都应该有 undo_log 表,用来记录本服务的数据库操作记录,TC 也会根据这个日志,进行回滚。

分布式高级篇4 —— 商城业务(2)_第23张图片

总结分布式事务的执行过程

  1. TM 开启分布式事务(TM 向 TC 注册全局事务记录);
  2. 按业务场景,编排数据库、服务等事务内资源(RM 向 TC 汇报资源准备状态 );
  3. TM 结束分布式事务,事务一阶段结束(TM 通知 TC 提交/回滚分布式事务);
  4. TC 汇总事务信息,决定分布式事务是提交还是回滚;
  5. TC 通知所有 RM 提交/回滚 资源,事务二阶段结束。

(1)1.4.2 Seata + Nacos 搭建使用

seata 与 SpringCloudAlibaba、SpringBoot、SpringCloud 版本对照说明:

分布式高级篇4 —— 商城业务(2)_第24张图片

分布式高级篇4 —— 商城业务(2)_第25张图片

seata 服务端配置

1、下载seata-server 1.4.2 :Releases · seata/seata (github.com)

分布式高级篇4 —— 商城业务(2)_第26张图片

2、解压进入到 conf 目录,会看到俩个核心配置文件:file.confregistry.conf

registry.conf :

配置 seata 的注册中心 : 支持 file 、nacos 、eureka、redis、zk、consul、etcd3、sofa

配置 seata 的配置中心,支持 file、nacos 、apollo、zk、consul、etcd3

使用 nacos 作为配置中心,注册中心

// 注册中心使用Nacos
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"

  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "default"
    username = "nacos"
    password = "nacos"
  }
// 配置中心使用 Nacos、自1.4.2版本开始,seata支持使用 dataId的方式获取配置
config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "nacos"

  nacos {
    serverAddr = "127.0.0.1:8848"
    // 配置文件命名空间,默认是PUBLIC
    namespace = ""
    // 配置文件组名
    group = "SEATA_GROUP"
    username = "nacos"
    password = "nacos"
    // 配置文件的 dataId
    dataId = "seataServer.properties"
  }

file.conf : seata 的服务端配置,只有 配置中心的类型是 file 时才会用用到 ,在 registry.conf 中已经配置了nacos作为配置中心。此文件不修改。

3、seata Server 端的存储模式有三种: file、db、redis , 我们使用 Mysql 存储

(1)建数据库,建表

sql 获取地址:seata/mysql.sql at develop · seata/seata · GitHub

-- -------------------------------- The script used when storeMode is 'db' --------------------------------
-- the table to store GlobalSession data
CREATE DATABASE IF NOT EXISTS seata;
USE seata;
CREATE TABLE IF NOT EXISTS `global_table`
(
    `xid`                       VARCHAR(128) NOT NULL,
    `transaction_id`            BIGINT,
    `status`                    TINYINT      NOT NULL,
    `application_id`            VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name`          VARCHAR(128),
    `timeout`                   INT,
    `begin_time`                BIGINT,
    `application_data`          VARCHAR(2000),
    `gmt_create`                DATETIME,
    `gmt_modified`              DATETIME,
    PRIMARY KEY (`xid`),
    KEY `idx_status_gmt_modified` (`status` , `gmt_modified`),
    KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`
(
    `branch_id`         BIGINT       NOT NULL,
    `xid`               VARCHAR(128) NOT NULL,
    `transaction_id`    BIGINT,
    `resource_group_id` VARCHAR(32),
    `resource_id`       VARCHAR(256),
    `branch_type`       VARCHAR(8),
    `status`            TINYINT,
    `client_id`         VARCHAR(64),
    `application_data`  VARCHAR(2000),
    `gmt_create`        DATETIME(6),
    `gmt_modified`      DATETIME(6),
    PRIMARY KEY (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`
(
    `row_key`        VARCHAR(128) NOT NULL,
    `xid`            VARCHAR(128),
    `transaction_id` BIGINT,
    `branch_id`      BIGINT       NOT NULL,
    `resource_id`    VARCHAR(256),
    `table_name`     VARCHAR(32),
    `pk`             VARCHAR(36),
    `status`         TINYINT      NOT NULL DEFAULT '0' COMMENT '0:locked ,1:rollbacking',
    `gmt_create`     DATETIME,
    `gmt_modified`   DATETIME,
    PRIMARY KEY (`row_key`),
    KEY `idx_status` (`status`),
    KEY `idx_branch_id` (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

CREATE TABLE IF NOT EXISTS `distributed_lock`
(
    `lock_key`       CHAR(20) NOT NULL,
    `lock_value`     VARCHAR(20) NOT NULL,
    `expire`         BIGINT,
    primary key (`lock_key`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);

(2)在 Nacos 中创建seataServer.properties配置文件,分组名、dataId、命名空间 要和上面在 registry.conf 中配置 保持一致。

seata使用1.4.2版本,新建的data id文件类型选择properties。若是使用seata1.4.2之前的版本,以下的每个配置项在nacos中就是一个条目,需要使用script/config-center/nacos/下的nacos-config.sh(linux或者windows下装git)或者nacos-config.py(python脚本)执行上传注册

配置文件获取:seata/config.txt at develop · seata/seata · GitHub

#For details about configuration items, see https://seata.io/zh-cn/docs/user/configurations.html
#Transport configuration, for client and server
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableTmClientBatchSendRequest=false
transport.enableRmClientBatchSendRequest=true
transport.enableTcServerBatchSendResponse=false
transport.rpcRmRequestTimeout=30000
transport.rpcTmRequestTimeout=30000
transport.rpcTcRequestTimeout=30000
transport.threadFactory.bossThreadPrefix=NettyBoss
transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker
transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler
transport.threadFactory.shareBossWorker=false
transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector
transport.threadFactory.clientSelectorThreadSize=1
transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread
transport.threadFactory.bossThreadSize=1
transport.threadFactory.workerThreadSize=default
transport.shutdown.wait=3
transport.serialization=seata
transport.compressor=none

# service 配置
# 分布式事务组的名称:default_tx_group
service.vgroupMapping.default_tx_group=default 
#If you use a registry, you can ignore it
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false

#Transaction rule configuration, only for the client
client.rm.asyncCommitBufferLimit=10000
client.rm.lock.retryInterval=10
client.rm.lock.retryTimes=30
client.rm.lock.retryPolicyBranchRollbackOnConflict=true
client.rm.reportRetryCount=5
client.rm.tableMetaCheckEnable=true
client.rm.tableMetaCheckerInterval=60000
client.rm.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
client.rm.sagaJsonParser=fastjson
client.rm.tccActionInterceptorOrder=-2147482648
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.defaultGlobalTransactionTimeout=60000
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
client.tm.interceptorOrder=-2147482648
client.undo.dataValidation=true
client.undo.logSerialization=jackson
client.undo.onlyCareUpdateColumns=true
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
client.undo.logTable=undo_log
client.undo.compress.enable=true
client.undo.compress.type=zip
client.undo.compress.threshold=64k
#For TCC transaction mode
tcc.fence.logTableName=tcc_fence_log
tcc.fence.cleanPeriod=1h

#Log rule configuration, for client and server
log.exceptionRate=100

# 存储模式配置。
# 存储模式改为 db
store.mode=db
store.lock.mode=file
store.session.mode=file
#Used for password encryption
store.publicKey=

# 如果存储模式是file,在这里配置
store.file.dir=file_store/data
store.file.maxBranchSessionSize=16384
store.file.maxGlobalSessionSize=512
store.file.fileWriteBufferCacheSize=16384
store.file.flushDiskMode=async
store.file.sessionReloadReadSize=100

# 如果存储模式是db,在这里配置
store.db.datasource=druid
store.db.dbType=mysql
# 如果是 mysql5.7 使用 com.mysql.jdbc.Driver
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://your ip:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=your mysql username
store.db.password=your mysql password
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.distributedLockTable=distributed_lock
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000

# 如果存储模式是redis,在这里配置
store.redis.mode=single
store.redis.single.host=127.0.0.1
store.redis.single.port=6379
store.redis.sentinel.masterName=
store.redis.sentinel.sentinelHosts=
store.redis.maxConn=10
store.redis.minConn=1
store.redis.maxTotal=100
store.redis.database=0
store.redis.password=
store.redis.queryLimit=100

# server 端配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackRetryTimeoutUnlockEnable=false
server.distributedLockExpireTime=10000
server.xaerNotaRetryTimeout=60000
server.session.branchAsyncQueueSize=5000
server.session.enableBranchAsyncRemove=false
server.enableParallelRequestHandle=false

# Metrics configuration, only for the server
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898

(3)双击 /bin/seata-server.bat 启动,启动之前确保 nacos 启动成功。

启动成功,seara-server 会注册到 nacos 中。

seata 客户端配置

1、在使用分布式事务的数据库中,创建 undo_log , 用来记录数据库操作,进行回滚

-- for AT mode you must to init this sql for you business database. the seata server not need it.
CREATE TABLE IF NOT EXISTS `undo_log`
(
    `branch_id`     BIGINT       NOT NULL COMMENT 'branch transaction id',
    `xid`           VARCHAR(128) NOT NULL COMMENT 'global transaction id',
    `context`       VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
    `rollback_info` LONGBLOB     NOT NULL COMMENT 'rollback info',
    `log_status`    INT(11)      NOT NULL COMMENT '0:normal status,1:defense status',
    `log_created`   DATETIME(6)  NOT NULL COMMENT 'create datetime',
    `log_modified`  DATETIME(6)  NOT NULL COMMENT 'modify datetime',
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  DEFAULT CHARSET = utf8 COMMENT ='AT transaction mode undo table';
  

2、引入依赖, seata-spring-boot-starter 版本 要与 seata-server 版本一致

        
        <dependency>
            <groupId>com.alibaba.cloudgroupId>
            <artifactId>spring-cloud-starter-alibaba-seataartifactId>
            <exclusions>
                
                <exclusion>
                    <groupId>io.seatagroupId>
                    <artifactId>seata-spring-boot-starterartifactId>
                exclusion>
            exclusions>
        dependency>
        <dependency>
            <groupId>io.seatagroupId>
            <artifactId>seata-spring-boot-starterartifactId>
            <version>1.4.2version>
        dependency>

3、yaml 中配置

# seata 客户端配置
seata:
    enabled: true
    enable-auto-data-source-proxy: true  #是否开启数据源自动代理,默认为true
    tx-service-group: default_tx_group     #要与 seataServer.properties 配置文件中的 vgroupMapping 一致
    registry:  #registry根据seata服务端的registry配置
        type: nacos #默认为 file
        nacos:
            application: seata-server #配置自己的seata服务
            server-addr: localhost:8848
            username: "nacos"
            password: "nacos"
            namespace: "PUBLIC"
            group: "SEATA_GROUP"
            cluster: default # 配置自己的seata服务cluster, 默认为 default
    config:
        type: nacos     #默认file,如果使用 file不配置下面的nacos,直接配置 seata.service
        nacos:
            server-addr: localhost:8848 #配置自己的nacos地址
            group: "SEATA_GROUP" #配置自己的dev
            username: "nacos"
            password: "nacos"
            namespace: "PUBLIC"
            dataId: seataServer.properties#
            #配置自己的dataId,由于搭建服务端时把客户端的配置也写在了seataServer.properties,所以这里用了和服务端一样的配置文件,实际客户端和服务端的配置文件分离出来更好

4、在启动类上使用自动代理

@EnableAutoDataSourceProxy // seata自动数据源代理

5、在业务的方法入口,使用 @GlobalTransactiona 。哪个微服务使用这个注解,哪个服务就是 TM

  • name:给定全局事务实例的名称,随便取,唯一即可
  • rollbackFor:当发生什么样的异常时,进行回滚
  • noRollbackFor:发生什么样的异常不进行回滚。

每个 RM,也就是事务的参与者使用 @Transactional 注解

(2)0.9 版本 Seata 使用

该项目使用的是 0.9 版本的 seata

1、服务端: 使用 file 方式存储,file.conf 作为配置中心,仅需要修改 registry.conf 中的注册中心地址。

分布式高级篇4 —— 商城业务(2)_第27张图片

2、客户端引入依赖

        <!--seata分布式事务-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
            <exclusions>
                <exclusion>
                    <artifactId>seata-all</artifactId>
                    <groupId>io.seata</groupId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>io.seata</groupId>
            <artifactId>seata-all</artifactId>
            <version>0.9.0</version>
        </dependency>

3、使用分布式事务的服务,拷贝 conf 目录下的 file.conf、registry.conf 到服务的resources 目录下

4、配置分布式事务组名

# 配置seata事务组名称,需要和 seata-sever中的file.conf中对应
spring.cloud.alibaba.seata.tx-service-group=my_test_tx_group

5、手动配置数据源,seata1.4.2直接使用注解即可,低版本需要手动注入

@Configuration
public class MySeataConfig {

    @Autowired
    private DataSourceProperties properties;

    /*
    * 在 seata 0.9版本之前:使用seata必须配置数据源
    * */
    @Bean
    public DataSource dataSource(DataSourceProperties properties) {
        HikariDataSource dataSource = properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        if (StringUtils.hasText(properties.getName())) {
            dataSource.setPoolName(properties.getName());
        }
        return new DataSourceProxy(dataSource);
    }
}

8、在业务的方法入口,使用全局事务注解:@GlobalTransactiona

再看 TC、TM、RM 三大组件

  • TC:seata服务器; (我们电脑上启动的seata )
  • TM:事物的发起者,业务的入口。 哪个微服务使用了**@GlobalTransactional**哪个就是TM
  • RM:事务的参与者,一个数据库就是一个RM。

三、使用延迟队列自动解锁库存

在上面虽然使用 seata 的 AT模式 解决了 订单的事务问题,但是在高并发下 seata 的性能并不是很高,seata 的 AT 使用加锁方式,使业务变成串行化,比较适用于后台管理系统这种并发性不高的分布式事务问题。

通常的方式使用 消息延迟队列,达到最终的一致的性。

当库存锁定成功时,向交换机发送一条消息【订单号、锁定库存状态、锁了几个库存…】,根据 路由键,交换机将订单消息转发到 延迟队列中,到达指定 TTL 之后,会判断订单状态是否需要解锁库存。需要解锁库存将消息转发到解锁库存的服务即可

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pyQ51jiU-1675935821391)(https://images-1313160403.cos.ap-beijing.myqcloud.com/MarkDown/%E8%AE%A2%E5%8D%95%E6%B5%81%E7%A8%8B%E5%9B%BE.png)]

1、在 gulimall-ware 服务中整合 RabbitMQ

2、结合上图、创建出交换机、队列、绑定关系

@Configuration
public class MyRabbitConfig {
    /*
     * 自定义消息转换器
     * */
    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }

    @RabbitListener(queues = "stock.release.stock.queue")
    public void consumer(){

    }

    /*
    * 创建交换机、队列、绑定关系....
    * */
    @Bean
    public Exchange stockEventExchange() {
        return  new TopicExchange("stock-event-exchange",true,false,null);
    }

    @Bean
    public Queue stockReleaseStockQueue() {
        return  new Queue("stock.release.stock.queue",true,false,false,null);
    }

    @Bean
    public Queue stockDelayQueue() {
        HashMap<String, Object> arguments = new HashMap<>();
        // 设置与队列相连的死信交换机
        arguments.put("x-dead-letter-exchange","stock-event-exchange");
        // 转发死信的 路由键
        arguments.put("x-dead-letter-routing-key","stock.release");
        // 设置队列的 TTL。超过30s就表示未支付订单,准备关闭
        arguments.put("x-message-ttl",12000);
        return  new Queue("stock.delay.queue",true,false,false,arguments);
    }

    @Bean
    public Binding stockFinish() {
        return new Binding(
                "stock.delay.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.locked",
                null);
    }

    @Bean
    public Binding stockRelease() {
        return new Binding(
                "stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.release.#",
                null);
    }
}

3、在锁定库存时,需要保存库存的工作单,表示该库存操作的状态,是否成功,是否需要回滚。

分布式高级篇4 —— 商城业务(2)_第28张图片

锁定库存成功后,向 MQ 发送工作单详情信息。

 /*
     * 锁定库存
     *  库存解锁场景:
     *  1、成功创建订单,超过指定时间未支付或者用户手动取消订单
     *  2、成功创建订单,锁定库存,但是由于接下来的业务出现异常,需要自动解锁库存,。
     * */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Boolean orderLockStock(WareSkuLockVo vo) {
        // 保存工作单信息
        WareOrderTaskEntity taskEntity = new WareOrderTaskEntity();
        taskEntity.setOrderSn(vo.getOrderSn());
        wareOrderTaskService.save(taskEntity);

        // 1、找到商品在哪个仓库有库存
        List<OrderItemVo> locks = vo.getLocks();

        List<SkuWareHasStock> hasStocks = locks.stream().map(item -> {
            SkuWareHasStock stock = new SkuWareHasStock();
            stock.setSkuId(item.getSkuId());
            //  查询哪些仓库有库存。
            List<Long> wareIds = baseMapper.listWareIdHasStock(item.getSkuId());
            stock.setWareId(wareIds);
            stock.setNum(item.getCount());
            return stock;
        }).collect(Collectors.toList());


        // 2、锁定库存
        for (SkuWareHasStock hasStock : hasStocks) {
            // 标志位,表示当前商品是否被锁住
            Boolean skuStocked = false;
            Long skuId = hasStock.getSkuId();
            List<Long> wareIds = hasStock.getWareId();
            if (wareIds == null || wareIds.size() == 0) {
                // 该商品没有库存,直接抛出异常
                throw new NoStockException(skuId);
            }
            for (Long wareId : wareIds) {
                // 锁定库存,成功返回 1,失败返回0
                Long count = baseMapper.lockSkuStock(skuId, wareId, hasStock.getNum());
                if (count == 1) {
                    // 当前商品锁定成功
                    skuStocked = true;
                    // TODO  3、库存锁定成功,向MQ发送消息
                    // 保存工作单详情信息
                    WareOrderTaskDetailEntity taskDetailEntity = new WareOrderTaskDetailEntity();
                    taskDetailEntity.setWareId(wareId);
                    taskDetailEntity.setSkuId(skuId);
                    taskDetailEntity.setSkuNum(hasStock.getNum());
                    taskDetailEntity.setTaskId(taskEntity.getId());
                    taskDetailEntity.setLockStatus(1);
                    wareOrderTaskDetailService.save(taskDetailEntity);

                    // 向MQ发送工作单详情信息
                    StockLockedTo stockLockedTo = new StockLockedTo();
                    stockLockedTo.setTaskId(taskEntity.getId());
                    // 只封装 TaskDetailId 是不行,因为是一件商品锁定成功,发送一次消息。
                    // 如果一共有三件商品,前俩件锁定成功,第三件锁定失败。那么本地事务是会将这三件库存都会回滚。因此如果只保存id,查不到任何信息。
                    // stockLockedTo.setTaskDetailId(detailEntity.getId());
                    StockDetailLockedTo stockDetailLockedTo = new StockDetailLockedTo();
                    BeanUtils.copyProperties(taskDetailEntity, stockDetailLockedTo);
                    stockLockedTo.setTaskDetail(stockDetailLockedTo);

                    rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", stockLockedTo);

                    break;
                } else {
                    // 当前商品锁定失败,
                }
            }
            // 当前商品所有仓库都没有锁定成功
            if (!skuStocked) {
                throw new NoStockException(skuId);
            }
        }

        return true;
    }

4、MQ 会将 工作单详情信息保存到 延迟队列 中,经过指定的 TTL 需要有消费者接收并进行自动解锁。并且使用手动确认机制,解锁库存失败重新将消息放回队列。等待 解锁库存服务解锁。

@Service
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {

    @Autowired
    private WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
        try {
            System.out.println("收到库存解锁通知...");
            wareSkuService.unLock(to);
            // 手动确认
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            // 有任何异常,都是解锁失败
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
            e.printStackTrace();
        }
    }
}
    /**
     * 消费者
     * @description 自动释放库存
     * @date 2023/2/4 22:51
     * @param
     * @return void
     *  1、根据工作单ID查询工作单详情信息
     *      (1)有:还需要根据订单号。查询订单
     *          - 如果没有这个订单,需要解锁:有可能订单创建成功后,库存锁定成功,接着创建订单又调用其他方法把自己搞回滚了。
     *          - 如果有订单,还需要判断订单的支付状态。如果支付成功,也无需解锁。支付失败或者取消支付进行解锁。
     *      (2)没有这个工作单:说明库存锁定失败,已经自动回滚了,无需解锁
     *
     * 应该使用手动确认机制,解锁失败重新将信息放回队列。
     */
    @Override
    public void unLock(StockLockedTo to) {
        StockDetailLockedTo taskDetail = to.getTaskDetail();
        // 查询是否有工作单详情信息
        WareOrderTaskDetailEntity orderTaskDetailEntity = wareOrderTaskDetailService.getById(taskDetail.getId());
        if (orderTaskDetailEntity != null) {
            //查出wms_ware_order_task工作单的信息
            WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(to.getTaskId());
            //  获取订单号查询订单状态
            R r = orderFeignService.getOrderByOrderSn(orderTaskInfo.getOrderSn());
            if (r.getCode() == 0) {
                OrderVo order = r.getData("data", new TypeReference<OrderVo>() {
                });
                if (order == null || order.getStatus() == OrderStatusEnum.CANCLED.getCode()) {
                    // 当前工作单锁定状态为已锁定,但是未解锁,才可以解锁
                    if (orderTaskDetailEntity.getLockStatus() == 1) {
                        // 没有订单或者取消订单,需要自动解锁
                        unLockStock(taskDetail.getSkuId(), taskDetail.getWareId(),taskDetail.getSkuNum(),taskDetail.getId());
                    }
                }
            }else {
                // 解锁失败
                throw new RuntimeException("解锁失败...重新入队");
            }
        }
        // 如果没有这个工作单,无需自动解锁
    }

    /**
     * @description
     * @date 2023/2/5 8:52
     * @param skuId
     * @param wareId
     * @param num
     * @param detailId
     * @return void 库存解锁
     */
    private void unLockStock(Long skuId, Long wareId, Integer num, Long detailId) {
        baseMapper.unLockStock(skuId, wareId, num);
        // 库存解锁后,更新库存工作单状态
        WareOrderTaskDetailEntity orderTaskDetailEntity = new WareOrderTaskDetailEntity();
        orderTaskDetailEntity.setId(detailId);
        // 变为已解锁
        orderTaskDetailEntity.setLockStatus(2);
        wareOrderTaskDetailService.updateById(orderTaskDetailEntity);
    }

mapper 映射文件:

    <update id="unLockStock">
        UPDATE`wms_ware_sku` SET stock_locked = stock_locked - #{num}
        WHERE sku_id = #{skuId} AND ware_id = #{wareId}
    update>

开启手动确认机制:

# 手动确认
spring.rabbitmq.listener.simple.acknowledge-mode=manual

解决 远程调用问题

在解锁库存时,远程调用查询订单信息会报错:响应体无法转换为 R 对象。

 R r = orderFeignService.getOrderByOrderSn(orderTaskInfo.getOrderSn());

Could not extract response: no suitable HttpMessageConverter found for response type [class com.atguigu.common.utils.R] and content type [text/html;charset=UTF-8]

这时因为远程调用给我们响应了一个 login.html 页面。而我们在 order 中设置了一个登录拦截器,想要访问订单都必须登录才可以,而我们远程调用无需登录。

分布式高级篇4 —— 商城业务(2)_第29张图片

老师的解决方案:在 Order服务中的登录拦截器中,做一个匹配映射,如果是远程调用的请求,直接放行。

        // 解决远程调用 order/order/status 需要登录问题
        String requestURI = request.getRequestURI();
        // 匹配映射
        boolean match = new AntPathMatcher().match("order/order/status/**", requestURI);
        if (match) {
            return true;
        }

我的解决方案:不知道为什么我按照老师的解决方案没有管用,依然报这个错误,配置AntPathMatcher没有起到作用,因此我直接在注册拦截器时将这个路径排除了。

分布式高级篇4 —— 商城业务(2)_第30张图片

四、定时关闭订单

1、创建的交换机、路由、绑定关系

@Configuration
public class MyMQConfig {



    /*
    * 使用 @Bean 的方式创建 Exchange、Queue、Binding...服务启动会自动向RabbitMQ创建。
    * 前提是RabbitMQ中没有这些  Exchange、Queue、Binding... 如果存在,即使配置不一样也不会重新创建。
    * */


    // 延迟队列
    @Bean
    public Queue orderDelayQueue() {
        // String name, boolean durable, boolean exclusive, boolean autoDelete, Map arguments
        HashMap<String, Object> arguments = new HashMap<>();
        // 设置与队列相连的死信交换机
        arguments.put("x-dead-letter-exchange","order-event-exchange");
        // 转发死信的 路由键
        arguments.put("x-dead-letter-routing-key","order.release.order");
        // 设置队列的 TTL。超过1min就表示未支付订单,准备关闭
        arguments.put("x-message-ttl",60000);

        return new Queue("order.delay.queue",true,false,false,arguments);
    }

    // 普通队列
    @Bean
    public Queue orderReleaseOrderQueue() {
        return new Queue("order.release.order.queue",true,false,false,null);
    }

    // 交换机
    @Bean
    public TopicExchange orderEventExchange() {
        //String name, boolean durable, boolean autoDelete)
        return new TopicExchange("order-event-exchange",true,false);
    }

    // 设置绑定关系: order-event- exchange ——》order.delay.queue
    @Bean
    public Binding orderCreateOrder() {
        //String destination, DestinationType destinationType, String exchange, String routingKey,Map arguments
        // 绑定目的地-绑定的队列,绑定类型【交换机 OR 队列】,交换机,路由键,其他参数信息
        return new Binding(
                "order.delay.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.create.order",
                null);
    }

    // 设置绑定关系: order-event- exchange ——》order.release.order.queue
    @Bean
    public Binding orderReleaseOrder() {
        return new Binding(
                "order.release.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.order",
                null);
    }
}

2、创建订单成功,向MQ发送消息

                    // TODO 创建订单成功,向RabbitMQ发送消息
                    rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",orderTo.getOrder());

3、监听器接受消息

@RabbitListener(queues = "order.release.order.queue")
@Component
public class OrderCloseListener {

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void consumer(OrderEntity order, Message message, Channel channel) throws IOException {
        System.out.println("订单超时未支付,即将关闭订单: " + order.getOrderSn());
        try {
            orderService.closeOrder(order);
            // 手动确认
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
            e.printStackTrace();
        }
    }
}

4、

    /*
    * 关闭订单
    * */
    @Override
    public void closeOrder(OrderEntity order) {
        Long orderId = order.getId();
        // 为了保险起见,在重新查一遍订单信息
        OrderEntity orderEntity = this.getById(order);
        if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()) {
            // 订单为代付款时,允许取消订单
            OrderEntity entity = new OrderEntity();
            entity.setId(orderEntity.getId());
            // 设置订单状态为取消
            entity.setStatus(OrderStatusEnum.CANCLED.getCode());
            this.updateById(entity);
        }
    }

当超过指定时间后,修改订单的状态,由于 关闭订单与 解锁库存 存在一定的时间差,正常情况下,在订单创建成功后,会隔一段时间去检查订单状态解锁库存

分布式高级篇4 —— 商城业务(2)_第31张图片

但是这种方式还有可能会出现问题,假如订单解锁有延迟,解锁库存先执行完,订单就不会被释放了。

分布式高级篇4 —— 商城业务(2)_第32张图片

因此可以再订单解锁时,在向 MQ 发送一条消息,通知 MQ 订单解锁完成,让它解锁库存。

分布式高级篇4 —— 商城业务(2)_第33张图片

5、创建新的绑定关系

    // 设置绑定关系: order-event- exchange ——》stock.release.stock.queue
    @Bean
    public Binding orderReleaseOther() {
        return new Binding(
                "stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }

6、关闭订单后,向MQ在发送一次消息

    /*
    * 关闭订单
    * */
    @Override
    public void closeOrder(OrderEntity order) {
        Long orderId = order.getId();
        // 为了保险起见,在重新查一遍订单信息
        OrderEntity orderEntity = this.getById(order);
        if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()) {
            // 订单为代付款时,允许取消订单
            OrderEntity entity = new OrderEntity();
            entity.setId(orderEntity.getId());
            // 设置订单状态为取消
            entity.setStatus(OrderStatusEnum.CANCLED.getCode());
            this.updateById(entity);
            // TODO 关闭订单后,再次向MQ发送一条消息
            OrderTo orderTo = new OrderTo();
            BeanUtils.copyProperties(orderEntity,orderTo);
            rabbitTemplate.convertAndSend("order-event-exchange","order.release.other",orderTo);
        }
    }

7、增加监听器接收消息

    /*
    * 收到订单关闭通知,解锁库存
    * */
    @RabbitHandler
    public void handleOrderCloseRelease(OrderTo order, Message message, Channel channel) throws IOException {
        try {
            System.out.println("订单关闭,即将解锁库存....");
            wareSkuService.unLock(order);
            // 手动确认
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            // 有任何异常,都是解锁失败
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
            e.printStackTrace();
        }
    }

8、库存解锁:解锁时需要判断工作单的锁定状态,防止重复解锁

    /**
     * @description
     * @date 2023/2/5 15:59
     * @param orderEntity
     * @return void 订单关闭,解锁库存
     */
    @Override
    public void unLock(OrderTo orderEntity) {
        // 根据订单号查询工作单
        WareOrderTaskEntity taskEntity = wareOrderTaskService.getOrderTaskByOrderSn(orderEntity.getOrderSn());
        // 只查询锁定的工作单详情,防止重复解锁。
        List<WareOrderTaskDetailEntity> taskDetailEntityList = wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>()
                .eq("task_id", taskEntity.getId())
                .eq("lock_status", 1));

        for (WareOrderTaskDetailEntity orderTaskDetailEntity : taskDetailEntityList) {
            // 解锁库存
            unLockStock(orderTaskDetailEntity.getSkuId(), orderTaskDetailEntity.getWareId(),orderTaskDetailEntity.getSkuNum(),orderTaskDetailEntity.getId());
        }
    }

五、支付服务

1、一些概念说明

什么是公钥、私钥、加密、签名和验签

公钥私钥

公钥和私钥是一个相对概念 ,它们的公私性是相对于生成者来说的。 一对密钥生成后,保存在生成者手里的就是私钥, 生成者发布出去大家用的就是公钥

加密

  • 我们使用一对公私钥中的一个密钥来对数据进行加密,而使用另一个密钥来进行解 密的技术。
  • 公钥和私钥都可以用来加密,也都可以用来解密。
  • 公钥 和私钥必须是一对的才能进行加密、解密

加密的目的是

  • 为了确保数据传输过程中的不可读性,就是不想让别人看到。

签名

  • 给我们将要发送的数据,做上一个唯一签名(类似于指纹)
  • 用来互相验证接收方和发送方的身份;

验签

一个数据对应一个唯一的签名,如果数据被修改,签名也会更改,验签就是验证原数据是否被修改过。

分布式高级篇4 —— 商城业务(2)_第34张图片

2、沙箱环境测试

Demo测试下载:https://docs.open.alipay.com/270/106291/

3、内网穿透

内网穿透功能可以允许我们使用外网的网址来访问主机;

正常的外网需要访问我们项目的流程是:

1、买服务器并且有公网固定 IP

2、买域名映射到服务器的 IP

3、域名需要进行备案和审核

使用内网穿透,只需要安装内网穿透服务商的软件,别人即可访问我们的电脑网站

分布式高级篇4 —— 商城业务(2)_第35张图片

1、下载 花生壳客户端: https://dl-cdn.oray.com/hsk/windows/HskDDNS_8.6.0.48614.exe

2、增加映射

分布式高级篇4 —— 商城业务(2)_第36张图片

3、搭建好,可进行诊断

分布式高级篇4 —— 商城业务(2)_第37张图片

4、没有问题就可以进行使用了

分布式高级篇4 —— 商城业务(2)_第38张图片

4、整合阿里云支付服务

1、增加依赖

com.alipay.sdk : alipay-sdk-java - Maven Central Repository Search

<dependency>
  <groupId>com.alipay.sdkgroupId>
  <artifactId>alipay-sdk-javaartifactId>
  <version>4.35.37.ALLversion>
dependency>

2、拷贝阿里云支付模板

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {

    //在支付宝创建的应用的id
    private   String app_id = "2016092200568607";

    // 商户私钥,您的PKCS8格式RSA2私钥
    private  String merchant_private_key = "MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCSgX/nTQ0lD+S8ObaM5LGZ1hiz18GXnNpqPLhJCym4xOpn35FNPHrPkDGEoMKrZ5LJeA4cZulckD8AtpvBCpeyIkrj/i1WVmSg10hVX67MlVets4UecCHZv2hKAN0/iId76kozdqrd7Csp/YgXPquN9Np0NFotggTrmiBANk+vcpTF9SCGrDq/isOoCvClfbvVJjApfLLOel3yECe5K/SZ8puiWILVm1NxEXAqJ8z0ipPZVGrXsT6Bo0pEyCPcEL0SqaC9WT0zdWQzdUknCzZV9W2wKjEXBJG9hqxay5kPaKm9leBatSkDAaDxH/N5g36HRfY7BmklwRZsp17lHinxAgMBAAECggEAfnnfck35WBKFc90a9D0F+Xlzr+ZGEV3uzKIIsb46UXFlrzC5HoVkvEWOCiJCjHiIpvbGr8xED43TZgk/IwLC/JxQLM0kVJGWo6fWoSVOIP2YSLNe620APBvaq3BdkFiMJfSYBB+g2J7mkIR39SE8Nvu3j3QWmYzSNJbE2spINnwTzNBL1OPaB5h3hSjyI07KaUcOjhTBF0EZl83NlBDsxmQvy0NmuOIWAcIXXvGoIbwkA774J3LhwL+VS4W2FpQj4FlxvDlPu24GeNWN7oO66T3Jp9bweO120ObhuKwZQosDGkJq0975zVSJX5QtUWHMM/QDPO8Pk24n2AoPcACQcQKBgQDS6kqD+sK8dDBpkmxYopA1gJJATnur0RHFZJb5webOhnEZnePhB1hhhGvKFcrdY2hcYeQiUZkHMsnWItNUe9E9ccp4++m6KKG0iV/BQda7zx1zMTTZUMvSbO282Q31YnQu7Yz6BSk4f/U5Qbu61AK53Tv1ejSAgQhXt1Pwq8KD7QKBgQCx0pkqW4+53tY2o4iPqFGjKYI2yk5bAH5etmOvW51OZ4Slsq/aUJKBVG6fOpRVKkiXulHhrp5csZH0/C7kaj4Hy7TjgUKSWvwlv7i7jgN0dq/bhVJz82y+N9pENWvy5J0I8Kt67XH+6JDEGWjlV58auifMRSx5mRJNn5pM6qrFlQKBgFyZWm/JV1fv1xVyoLjlXlTvBsbO7kMH/jpgqFwtAk1n/x3VEShJ1kayIbTOjotWSopMvCFJG9tqM+0cyxWLatkELXWifAIsNpqRuYWah1FbZD2fu+kxLNtM0a+YyCUUvZeg2cUnIOraWupxbp9e13eMpvdmWMiWXfhM18CRWEwdAoGAUwT0l076EhgUQJwm1JML0jY94eCfpmLbnNJgRe1qysEPr+B1s2IslA7cOqC5we0kyRmmwsuoibQpZYwbRG7JmRAk2pZtgzDRSbpxv7a0rDoBLmbXMOU0Hraqw2+Bf3v2SMc79/9FWnIvrC4EyBYZZPwGOpsNAZRSdEUQX9qrceUCgYB99OOtFFt1ixzyTCyUj3Fuiw7BsPhdI3nuMSoNTPIDNpzRBp/KFXyv/FNJ2CjTAsX3OR3D6KmEYihqUfrYeb0P5zoybcQLMxbXxK+ec6F2o6U2iqFIq0MKwHUqsb9X3pj4qE0ZHbFgRtIHnL2/QGV5PFJdmIZIBKZcvB8fW6ztDA==";
    // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
    private  String alipay_public_key = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyQQceVUChTJGtF/a8SXufhSxDTKporieTq9NO7yDZSpDlAX1zVPT/nf0KWAlxq1TYappWMIYtyrOABhJyn6flNP6vuSBiM5lYsepHvYrtRHqlFiJruEkiaCgEZBKL5aCfBHYj0oqgQn9MpNV/PEH4cBYAVaiI4+VX8CBUQfeEGjgN6OkpLULZ3X0JUkmSnVvCNJ1m3PD68IIlbOfEZXJUKCqmZhzprGR5VWswjxA+g87cMwvijL4gdkSy/daG62Bz5vApcmmMkuX1k1fMWP4ajZCASVw8HD+MSLRhd8We9F97gd8CW0TavzbdR+mTS5H4yEgO8F9HRAsbkhV9yu0yQIDAQAB";
    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    private  String notify_url;

    // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    private  String return_url;

    // 签名方式
    private  String sign_type = "RSA2";

    // 字符编码格式
    private  String charset = "utf-8";

    // 支付宝网关; https://openapi.alipaydev.com/gateway.do
    private  String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";

    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 +"\","
                + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

        String result = alipayClient.pageExecute(alipayRequest).getBody();

        //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
        System.out.println("支付宝的响应:"+result);

        return result;

    }
}

私钥、公钥、网关、改成自己的

支付宝开放平台 (alipay.com)

分布式高级篇4 —— 商城业务(2)_第39张图片

返回 Vo;

@Data
public class PayVo {
    private String out_trade_no; // 商户订单号 必填
    private String subject; // 订单名称 必填
    private String total_amount;  // 付款金额 必填
    private String body; // 商品描述 可空
}

3、创建controller 处理支付请求

@Controller
public class PayWebController {

    @Autowired
    AlipayTemplate alipayTemplate;
    @Autowired
    OrderService orderService;

    @ResponseBody
    @GetMapping("/payOrder")
    public String payOrder(@RequestParam("orderSn")String orderSn){

        PayVo payVo =  orderService.getOrderPay(orderSn);
        try
        {
            // 支付
            alipayTemplate.pay(payVo);
        } catch (AlipayApiException e) {
            e.printStackTrace();
        }
        return  null;
    }

}
/**
     * @description
     * @date 2023/2/5 20:22
     * @param orderSn
     * @return com.atguigu.gulimall.order.vo.PayVo 支付订单
     */
    @Override
    public PayVo getOrderPay(String orderSn) {

        OrderEntity orderEntity = this.getOrderByOrderSn(orderSn);
        PayVo payVo = new PayVo();
        payVo.setOut_trade_no(orderSn);// 订单号

        List<OrderItemEntity> orderItemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
        payVo.setSubject(orderItemEntities.get(0).getSpuName());// 订单标题
        // 指定后面2位小数
        payVo.setTotal_amount(orderEntity.getPayAmount().setScale(2).toString());
        payVo.setBody(orderItemEntities.get(0).getSpuName()); // 备注

        return payVo;
    }

响应的信息是一个支付页面。

支付宝的响应:<form name="punchout_form" method="post" action="https://openapi.alipaydev.com/gateway.do?charset=utf-8&method=alipay.trade.page.pay&sign=f0ALyx8f46iNFZjfTjpFgSQRrEwbXjMy5LDVKbBVylxvr%2F8V%2FhOyTisGAtu7%2Bpo6RjVOi3NzbX%2FfAHRmjegly52mninirGTBLN5FntlPn4PGXa7Isi0sWGgAvfnb%2BoQ3IiefoN6Pt3BY7QdXywoE2BHfoz8bXkV%2F%2BfjZFjhi2W5uZeDaoIlS%2BBogF5B%2FbwcCM0AUrtjIoHmrngvzoPFj0exFQ2PP2FE4xrqJyfZxoEh7tcaUzDa37u4KgG7%2BU4luio9CZryuS29jLU%2B4rKOWE7LKKw2L5v1GvIBC6sGlpyNs%2Bbxj9LCGMaa5363EjecgudDHOdm2cVirvxb2u4wUnw%3D%3D&return_url=http%3A%2F%2Fmember.gulimall.com%2FmemberOrder.html¬ify_url=http%3A%2F%2F63i857228t.goho.co%2Fpayed%2Fnotify&version=1.0&app_id=2021000122615169&sign_type=RSA2×tamp=2023-02-05+20%3A31%3A56&alipay_sdk=alipay-sdk-java-4.35.37.ALL&format=json">
<input type="hidden" name="biz_content" value="{"out_trade_no":"202302052031434361622211365567483906","total_amount":"104484.00","subject":"华为Mate30 Pro","body":"华为Mate30 Pro","product_code":"FAST_INSTANT_TRADE_PAY"}">
<input type="submit" value="立即支付" style="display:none" >
</form>
<script>document.forms[0].submit();</script>

修改 controller

@Controller
public class PayWebController {

    @Autowired
    AlipayTemplate alipayTemplate;
    @Autowired
    OrderService orderService;

    // produces = MediaType.TEXT_HTML_VALUE 表示该方法返回的是一个 html 页面
    @ResponseBody
    @GetMapping(value = "/payOrder", produces = MediaType.TEXT_HTML_VALUE)
    public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {

        PayVo payVo = orderService.getOrderPay(orderSn);

        // 支付
        String pay = alipayTemplate.pay(payVo);

        return pay;
    }

}

5、支付成功同步回调

1、修改支付成功回调

image-20230205214237524

2、整合 member 服务

  • 整合 springsession
  • 整合登录拦截器 —— 同样会有远程调用需要登录的问题,直接放行即可。
        // 解决远程调用  需要登录问题
        String requestURI = request.getRequestURI();
        // 匹配映射
        AntPathMatcher pathMatcher = new AntPathMatcher();
        boolean match = pathMatcher.match("/member/**", requestURI);
        if (match) {
            return true;
        }

3、创建 Controller 处理请求

@Controller
public class MemberWebController {

    @GetMapping("/memberOrder.html")
    public String memberOrderPage() {

        return "orderList";
    }
}

6、异步通知内网穿透环境搭建

支付宝在支付成功之后会向我们设置的异步通知地址 notify_url,通过 POST 请求的形式将支付结果作为参数通知。异步通知说明 - 支付宝文档中心 (alipay.com)

1、修改异步通知回调

    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    // http://63i857228t.goho.co/alipay.trade.page.pay-JAVA-UTF-8/notify_url.jsp
    private  String notify_url = "https://4287b772c5.imdo.co/payed/notify";

2、修改 Nginx配置文件。由于使用 IP:80 访问 Nginx,HOST请求头不匹配找不到对应的服务网,因此手动精确匹配如果是 /payed/ 下的 请求 直接转发到 order.gulimall.com

	    location /payed/ {
		# 发送请求时携带host信息
		proxy_set_header Host order.gulimall.com;
		proxy_pass http://gulimall;
    }

3、通过外网访问订单服务:POST 请求:https://4287b772c5.imdo.co/payed/notify

4、由于订单服务的所有请求都需要登录,因此需要将 /payed/notify 排除掉

        registry.addInterceptor(new LoginUserInterceptor()).addPathPatterns("/**").excludePathPatterns("/order/order/status/**").excludePathPatterns("/payed/notify");

7、支付完成

1、先验签、在支付

@RestController
public class OrderPayListener {

    @Autowired
    private OrderService orderService;
    @Autowired
    private AlipayTemplate alipayTemplate;

    @PostMapping("/payed/notify")
    public String AliPayed(PayAsyncVo vo, 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(vo);
            return result;
        } else {
            System.out.println("签名验证失败...");
            return "error";
        }
    }
}
/*
    * 支付成功异步回调通知
    * */
    @Override
    public String handlePayResult(PayAsyncVo vo) {
        // 1、保存交易流水
        PaymentInfoEntity paymentInfo = new PaymentInfoEntity();
        paymentInfo.setOrderSn(vo.getOut_trade_no());
        paymentInfo.setAlipayTradeNo(vo.getTrade_no());
        paymentInfo.setTotalAmount(new BigDecimal(vo.getBuyer_pay_amount()));
        paymentInfo.setSubject(vo.getBody());
        paymentInfo.setPaymentStatus(vo.getTrade_status());
        paymentInfo.setCreateTime(new Date());
        paymentInfo.setCallbackTime(vo.getNotify_time());
        // 添加到数据库中
        this.paymentInfoService.save(paymentInfo);

        // 修改订单状态
        // 获取当前状态
        String tradeStatus = vo.getTrade_status();

        if (tradeStatus.equals("TRADE_SUCCESS") || tradeStatus.equals("TRADE_FINISHED")) {
            //支付成功状态
            String orderSn = vo.getOut_trade_no(); //获取订单号
            this.updateOrderStatus(orderSn,OrderStatusEnum.PAYED.getCode());
        }

        return "success";
    }

    /**
     * 修改订单状态
     * @param orderSn
     * @param status 订单状态
     */
    private void updateOrderStatus(String orderSn, Integer status) {

        this.baseMapper.updateOrderStatus(orderSn,status);
    }

mapper 映射

    <update id="updateOrderStatus">
        update `oms_order` set `status` = #{status} where order_sn = #{orderSn}
    update>

8、收单

订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态改为已支付了,但是库 存解锁了

使用支付宝自动收单功能解决。只要一段时间不支付,就不能支付了。

增加一个 timeout_express 参数,表示支付过期时间

分布式高级篇4 —— 商城业务(2)_第40张图片

在配置文件中配置:

# 支付订单超时时间
alipay.timeOut=1m

六、秒杀

1、定时任务与Cron表达式

语法:秒 分 时 日 月 周 年(Spring 不支持)

http://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html

分布式高级篇4 —— 商城业务(2)_第41张图片

cron表达式在线生成: Cron - 在线Cron表达式生成器 (ciding.cc)

特殊字符:

,:枚举;

​ (cron=“7,9,23 * * * * ?”):任意时刻的 7,9,23 秒启动这个任务;

-:范围:

​ (cron=“7-20 * * * * ?”):任意时刻的 7-20 秒之间,每秒启动一次

*:任意;

​ 指定位置的任意时刻都可以

/:步长;

​ (cron=“7/5 * * * * ?”):第 7 秒启动,每 5 秒一次;

​ (cron=“*/5 * * * * ?”):任意秒启动,每 5 秒一次;

?:(出现在日和周几的位置):为了防止日和周冲突,在周和日上如果要写通配符使 用?

​ (cron=“* * * 1 * ?”):每月的 1 号,启动这个任务;

L:(出现在日和周的位置)”,

​ last:最后一个

​ (cron=“* * * ? * 3L”):每月的最后一个周二

W:Work Day:工作日

​ (cron=“* * * W * ?”):每个月的工作日触发

​ (cron=“* * * LW * ?”):每个月的最后一个工作日触发

#:第几个

​ (cron=“* * * ? * 5#2”):每个月的第 2 个周 4

SpringBoot 使用 定时任务:

@EnableScheduling
@Component
@Slf4j
@EnableAsync
public class HelloScheduled {

    /*
    * 1、只允许6位:秒分时日周月
    * 2、周的位置上 1-7、MON-SUN,代表周一到周日
    * 3、定时任务不应该阻塞。
    *   (1)如果需要阻塞,使用 CompletableFuture 异步的方式,提交到线程池中
    *   (2)定时任务线程池. 配置 spring.task.scheduling.pool.size=5 ,有的版本不管用
    *   (4)使用异步任务注解
    *           @EnableAsync 开启异步任务
    *           @Async 希望异步执行的方法上增加该注解
    *           配置项都在 TaskExecutionProperties 中,可配置 corePool,MaxPool....
    *           spring.task.execution....
    * */
    @Scheduled(cron = "*/1 * * * * *")
    @Async
    public void printHello() {
        try {Thread.sleep(3000);} catch (InterruptedException e) {e.printStackTrace();}
        log.info("hello");
    }
}

2、秒杀商品上架

上架最近三天的所有秒杀商品、上架流程:

分布式高级篇4 —— 商城业务(2)_第42张图片

1、查询最近三天的所有秒杀场次

/*
* 查询最近三天的秒杀场次
* */
@GetMapping("/findLatest3DaysSessions")
public R findLatest3DaysSessions() {
   List<SeckillSessionEntity> list =  seckillSessionService.findLatest3DaysSessions();
   return R.ok().setData(list);
}

=============================================================================================
    @Override
    public List<SeckillSessionEntity> findLatest3DaysSessions() {
        // 查询最近三天的秒杀场次 2023-02-06 00:00:00 2023-02-08 11:59:59
        return this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time",startTime(),endTime()));
    }


    // 秒杀场次开始时间
    private String startTime() {
        // 获取当前日期 2023-02-06
        LocalDate localDate = LocalDate.now();
        // 获取时分秒: 00:00:00
        LocalTime now = LocalTime.MIN;
        // 2023-02-06 00:00:00
        LocalDateTime dateTime = LocalDateTime.of(localDate, now);
        // 指定时间格式格式化
      return   dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    // 秒杀场次结束时间
    private String endTime(){
        // 获取结束日期 2023-02-08
              LocalDate localDate = LocalDate.now().plusDays(2);
        // 获取时分秒: 11:59:59
        LocalTime now = LocalTime.MAX;
        // 2023-02-08 11:59:59
        LocalDateTime dateTime = LocalDateTime.of(localDate, now);
        // 指定时间格式格式化
        return   dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

2、

(1)封装最新的秒杀商品信息保存到 redis 中

  • 保存秒杀的场次信息
  • 保存秒杀商品的详细信息

(2)设置商品的随机码,防止恶意攻击

(3)使用分布式锁的信号量 (秒杀的商品数量) 保存到 redis 中,避免由于大量请求去访问数据库造成数据库压力过大。

@Service
public class SeckillServiceImpl implements SeckillService {

    @Autowired
    CouponFeignService couponFeignService;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductFeignService productFeignService;
    @Autowired
    private RedissonClient redissonClient;

    public  final String SESSION_CACHE_PREFIX = "seckill:sessions:";
    public  final String SKUKILL_CACHE_PREFIX = "seckill:skus";
    public  final String SEMAPHORE_CACHE_PREFIX = "seckill:stocks:";

    @Override
    public void UploadSeckillSkuLatest3Days() {
        // 1、远程调用,查询出最近三天的秒杀场次 2023-02-06 00:00:00 2023-02-08 11:59:59
        R r = couponFeignService.findLatest3DaysSessions();
        if (r.getCode() == 0) {
            List<SeckillSessionsWithSkus> sessionsWithSkus = r.getData("data", new TypeReference<List<SeckillSessionsWithSkus>>() {});
            if (sessionsWithSkus!= null && sessionsWithSkus.size() > 0) {
                // 2、保存秒杀场次信息到 redis 中
                this.saveSessionsInfos(sessionsWithSkus);
                // 3、保存秒杀商品信息到 redis 中
                this.saveSessionsSkusInfos(sessionsWithSkus);
            }else {
                System.out.println("没有秒杀场次");
            }

        }
    }


    /*
    * 保存秒杀场次信息到redis
    * */
    private void saveSessionsInfos( List<SeckillSessionsWithSkus> sessionsWithSkus){
        sessionsWithSkus.stream().forEach(item ->{
            // 保存的key
            long start = item.getStartTime().getTime();
            long end = item.getEndTime().getTime();
            String key = SESSION_CACHE_PREFIX + start + "_" +end;
            // 保存的value
            List<String> skuIds = item.getRelationSkus().stream().map(sku -> sku.getSkuId().toString()).collect(Collectors.toList());
            redisTemplate.opsForList().leftPushAll(key,skuIds);
        });
    }

    /*
    * 保存秒杀商品的详细信息
    * */
    private void saveSessionsSkusInfos( List<SeckillSessionsWithSkus> sessionsWithSkus){
        sessionsWithSkus.stream().forEach(item ->{
            // 准备hash操作
            BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);

            item.getRelationSkus().stream().forEach(relationSku ->{
                // 将商品信息保存到redis中
                SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();
                // 1、保存秒杀商品的秒杀信息
                BeanUtils.copyProperties(relationSku,seckillSkuRedisTo);
                // 2、保存秒杀商品的详细信息
                R r = productFeignService.info(relationSku.getSkuId());
                if (r.getCode() == 0) {
                    SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVo>() {});
                    seckillSkuRedisTo.setSkuInfoVo(skuInfo);
                }
                // 3、保存秒杀的开始、结束时间
                seckillSkuRedisTo.setStartTime(item.getStartTime().getTime());
                seckillSkuRedisTo.setEndTime(item.getEndTime().getTime());
                // 4、保存秒杀商品的随机码
                String token = UUID.randomUUID().toString().replace("-", "");
                seckillSkuRedisTo.setRandomCode(token);
                // 保存到 redis 中
                ops.put(relationSku.getSkuId().toString(), JSON.toJSONString(seckillSkuRedisTo));

                // 5、设置秒杀商品的信号量 —— 秒杀的数量. 当大量请求秒杀时,不可能实时去查询数据库,这样会给数据库造成很大的压力
                // 通过将redis中设置信号量来限制秒杀商品的数量
                RSemaphore semaphore = redissonClient.getSemaphore(SEMAPHORE_CACHE_PREFIX + token);
                // 将秒杀的数量作为信号量
                semaphore.trySetPermits(relationSku.getSeckillCount().intValue());
            });
        });
    }
}

公共返回类:

/ SeckillSkuRedisTo: 保存到redis中的秒杀商品信息
@Data
public class SeckillSkuRedisTo {
    private Long id;
    /**
     * 活动id
     */
    private Long promotionId;
    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;
    /**
     * 秒杀总量
     */
    private Integer seckillCount;
    /**
     * 每人限购数量
     */
    private Integer seckillLimit;
    /**
     * 排序
     */
    private Integer seckillSort;

    /*
    * sku的详细信息
    * */
    private SkuInfoVo skuInfoVo;

    // 秒杀的开始、结束时间
    private Long startTime;
    private Long endTime;

    // 秒杀商品的随机码
    private String randomCode;
}


/ SeckillSessionsWithSkus:保存秒杀的场次信息以及每场包含的skuId
@Data
public class SeckillSessionsWithSkus {

    /**
     * id
     */
    @TableId
    private Long id;
    /**
     * 场次名称
     */
    private String name;
    /**
     * 每日开始时间
     */
    private Date startTime;
    /**
     * 每日结束时间
     */
    private Date endTime;
    /**
     * 启用状态
     */
    private Integer status;
    /**
     * 创建时间
     */
    private Date createTime;

    List<SeckillSkuVo> relationSkus;
}

/ SeckillSkuVo:保存商品的秒杀信息

@Data
public class SeckillSkuVo {
    @TableId
    private Long id;
    /**
     * 活动id
     */
    private Long promotionId;
    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;
    /**
     * 秒杀总量
     */
    private Integer seckillCount;
    /**
     * 每人限购数量
     */
    private Integer seckillLimit;
    /**
     * 排序
     */
    private Integer seckillSort;
}


/ SkuInfoVo: 保存商品的sku基本信息
@Data
public class SkuInfoVo {
    private Long skuId;
    /**
     * spuId
     */
    private Long spuId;
    /**
     * sku名称
     */
    private String skuName;
    /**
     * sku介绍描述
     */
    private String skuDesc;
    /**
     * 所属分类id
     */
    private Long catelogId;
    /**
     * 品牌id
     */
    private Long brandId;
    /**
     * 默认图片
     */
    private String skuDefaultImg;
    /**
     * 标题
     */
    private String skuTitle;
    /**
     * 副标题
     */
    private String skuSubtitle;
    /**
     * 价格
     */
    private BigDecimal price;
    /**
     * 销量
     */
    private Long saleCount;
}

3、秒杀上架幂等性问题

如果定时任务在分布式的情况下,可能会造成商品重复上架等问题。因此在上架商品时使用分布式锁解决,保证同一时间只有一个服务执行定时任务

分布式高级篇4 —— 商城业务(2)_第43张图片

1、使用分布式锁

@Service
@Slf4j
public class SeckillSkuScheduled {

    @Autowired
    private SeckillService seckillService;
    @Autowired
    private RedissonClient redissonClient;

    private  final String UPLOAD_LOCK = "skuKill:upload:lock";
    /**
     * @description
     * @date 2023/2/7 11:29
     * @param
     * @return void
     * 上架最近三天的秒杀商品
     */
    @Scheduled(cron = "*/5 * * * * ?")
    public void UploadSeckillSkuLatest3Days() {
        // TODO: 保证幂等性,重复上架商品。 解决:分布式锁、并保证每个存储操作都是幂等性的
        RLock lock = redissonClient.getLock(UPLOAD_LOCK);

        lock.lock(10, TimeUnit.SECONDS);
        try {
            log.info("准备上架商品...");
            seckillService.UploadSeckillSkuLatest3Days();
            log.info("上架成功...");
        } finally {
            lock.unlock();
        }
    }

}

2、保证各个保存操作的幂等性

@Service
public class SeckillServiceImpl implements SeckillService {

    @Autowired
    CouponFeignService couponFeignService;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private ProductFeignService productFeignService;
    @Autowired
    private RedissonClient redissonClient;

    public final String SESSION_CACHE_PREFIX = "seckill:sessions:";
    public final String SKUKILL_CACHE_PREFIX = "seckill:skus";
    public final String SKU_STOCK_SEMAPHORE = "seckill:stocks:";

    @Override
    public void UploadSeckillSkuLatest3Days() {
        // 1、远程调用,查询出最近三天的秒杀场次 2023-02-06 00:00:00 2023-02-08 11:59:59
        R r = couponFeignService.findLatest3DaysSessions();
        if (r.getCode() == 0) {
            List<SeckillSessionsWithSkus> sessionsWithSkus = r.getData("data", new TypeReference<List<SeckillSessionsWithSkus>>() {
            });
            if (sessionsWithSkus != null && sessionsWithSkus.size() > 0) {
                // 2、保存秒杀场次信息到 redis 中
                this.saveSessionsInfos(sessionsWithSkus);
                // 3、保存秒杀商品信息到 redis 中
                this.saveSessionsSkusInfos(sessionsWithSkus);
            } else {
                System.out.println("没有秒杀场次");
            }

        }
    }


    /*
     * 保存秒杀场次信息到redis
     * */
    private void saveSessionsInfos(List<SeckillSessionsWithSkus> sessionsWithSkus) {
        sessionsWithSkus.stream().forEach(item -> {
            // 保存的key
            long start = item.getStartTime().getTime();
            long end = item.getEndTime().getTime();
            String key = SESSION_CACHE_PREFIX + start + "_" + end;
            // TODO 防止重复。保证幂等性
            if (!redisTemplate.hasKey(key)) {
                // 保存的value: sessionId_skuId
                List<String> skuIds = item.getRelationSkus().stream().map(sku -> item.getId() + "_" + sku.getSkuId().toString()).collect(Collectors.toList());
                redisTemplate.opsForList().leftPushAll(key, skuIds);
            }
        });
    }

    /*
     * 保存秒杀商品的详细信息
     * */
    private void saveSessionsSkusInfos(List<SeckillSessionsWithSkus> sessionsWithSkus) {
        sessionsWithSkus.stream().forEach(item -> {
            // 准备hash操作
            BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);

            item.getRelationSkus().stream().forEach(relationSku -> {
                // 商品随机码
                String token = UUID.randomUUID().toString().replace("-", "");

                // TODO: 防止重复上架,保证幂等性
                if (!ops.hasKey(relationSku.getPromotionSessionId()+"_"+relationSku.getSkuId().toString())){
                    // 将商品信息保存到redis中
                    SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();
                    // 1、保存秒杀商品的秒杀信息
                    BeanUtils.copyProperties(relationSku, seckillSkuRedisTo);
                    // 2、保存秒杀商品的详细信息
                    R r = productFeignService.info(relationSku.getSkuId());
                    if (r.getCode() == 0) {
                        SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVo>() {
                        });
                        seckillSkuRedisTo.setSkuInfoVo(skuInfo);
                    }
                    // 3、保存秒杀的开始、结束时间
                    seckillSkuRedisTo.setStartTime(item.getStartTime().getTime());
                    seckillSkuRedisTo.setEndTime(item.getEndTime().getTime());
                    // 4、保存秒杀商品的随机码
                    seckillSkuRedisTo.setRandomCode(token);
                    // 保存到 redis 中。key=sessionId_skuId
                    ops.put(relationSku.getPromotionSessionId()+"_"+relationSku.getSkuId().toString(), JSON.toJSONString(seckillSkuRedisTo));

                    // 5、设置秒杀商品的信号量 —— 秒杀的数量. 当大量请求秒杀时,不可能实时去查询数据库,这样会给数据库造成很大的压力
                    // 通过将redis中设置信号量来限制秒杀商品的数量
                    RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                    // 将秒杀的数量作为信号量
                    semaphore.trySetPermits(relationSku.getSeckillCount().intValue());
                }
            });
        });
    }
}

4、查询所有的秒杀商品

1、

@RestController
public class SeckillController {

    @Autowired
    private SeckillService seckillService;

    /*
    * 获取当前时间的秒杀商品
    * */
    @GetMapping("/getCurrentSeckillSkus")
    public R getCurrentSeckillSkus(){
      List<SeckillSkuRedisTo> list  =  seckillService.getCurrentSeckillSkus();
      return  R.ok().setData(list);
    }
}
    /*
     * 获取当前时间的秒杀商品
     * */
    @Override
    public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
        // 1、判断当前时间属于哪个秒杀场次
        long time = System.currentTimeMillis();
        // 获取所有的场次key
        Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");
        for (String key : keys) {
            // seckill:sessions:1675844252000_1675958400000 需要分割
            String[] s = key.replace(SESSION_CACHE_PREFIX, "").split("_");
            Long start = Long.parseLong(s[0]);
            Long end =  Long.parseLong(s[1]);
            if (start <= time &&  end>= time) {
                // 获取商品信息的value [2_11,1_10]
                List<String> range = redisTemplate.opsForList().range(key, -100, 100);
                // k1:Map
                BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
                // 2、获取该场次下的所有商品信息。
                // 由于场次信息的value与商品信息的key是一致的.可以通过场次信息的value作为商品信息的key取出value
                List<String> skus = hashOps.multiGet(range);

                if (skus != null && skus.size() > 0) {
                    List<SeckillSkuRedisTo> result = skus.stream().map(item -> {
                        SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject(item, SeckillSkuRedisTo.class);
                        // seckillSkuRedisTo.setRandomCode(""); 秒杀已经开始,需要随机码秒杀。可以带着。
                        return seckillSkuRedisTo;
                    }).collect(Collectors.toList());

                    return  result;
                }
                break;
            }
        }

        return null;
    }

时区BUG

从数据库封装到java中的实体类时,时间可能会有差距,数据库默认采用UTC,建议改成东八区

SELECT NOW() # 查询当前时间,是否与系统时间相吻合
show variables like '%time_zone%'; # 查询当前mysql的时区
临时配置
set global time_zone = '+8:00'; # 临时配置,重启mysql之后还会改成原来的
永久配置:
找到 /mydata/mysql/conf/my.cnf,默认是在 /etc/mysql/my.cnf ,追加以下:
default-time_zone = '+8:00'

分布式高级篇4 —— 商城业务(2)_第44张图片

5、查询某一个秒杀 商品

1、

    /*
     * 查询某一个秒杀商品
     * */
    @Override
    public SeckillSkuRedisTo getSkuSeckillInfo(Long skuId) {
        BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
        // 1、获取所有的key
        Set<String> keys = hashOps.keys();
        if (keys != null && keys.size() > 0) {
            // 使用正则表达式判断某个key是否包含skuId
            String regx = "\\d_" + skuId;
            for (String key : keys) {
                // 2、将所有的key与skuId逐个匹配
                if (Pattern.matches(regx,key)) {
                    String data = hashOps.get(key);
                    SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject(data, SeckillSkuRedisTo.class);
                    
                    // 如果该商品正在秒杀,就将随机码发送给前端页面。如果没有,就无需发送
                    Long start = seckillSkuRedisTo.getStartTime();
                    Long end = seckillSkuRedisTo.getEndTime();
                    long current = System.currentTimeMillis();
                    if (!(current >= start && current <= end)) {
                        seckillSkuRedisTo.setRandomCode("");
                    }
                    return seckillSkuRedisTo;
                }
            }
        }
        return null;
    }

2、controller

    /*
    * 查询某一个秒杀商品
    * */
    @GetMapping("/sku/seckill/{skuId}")
    public R getSkuSeckillInfo(@PathVariable Long skuId){
      SeckillSkuRedisTo seckillSkuRedisTo =   seckillService.getSkuSeckillInfo(skuId);
    return  R.ok().setData(seckillSkuRedisTo);
    }
}

3、product 远程调用,查询商品详情时,判断该商品是否是秒杀商品

@FeignClient("gulimall-seckill")
public interface SeckillFeignService {

    /*
    * 查询某一个商品的秒杀信息
    * */
    @GetMapping("/sku/seckill/{skuId}")
    public R getSkuSeckillInfo(@PathVariable Long skuId);

}

4、SkuInfoServiceImpl

        CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
            // 6、查询该商品是否是秒杀商品
            R r = seckillFeignService.getSkuSeckillInfo(skuId);
            if (r.getCode() == 0) {
                SeckillInfoVo data = r.getData("data", new TypeReference<SeckillInfoVo>() {
                });
                skuItemVo.setSeckillInfo(data);
            }
        }, threadPoolExecutor);

6、秒杀系统设计

分布式高级篇4 —— 商城业务(2)_第45张图片

分布式高级篇4 —— 商城业务(2)_第46张图片

登录拦截器

@Component
public class LoginUserInterceptor implements HandlerInterceptor {
    public static ThreadLocal<MemberRespVo> threadLocal = new ThreadLocal<>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        MemberRespVo memberRespVo = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
        if (memberRespVo == null) {
            request.getSession().setAttribute("msg","请先登录");
            // 没有进行登录,跳转到登录界面登录
            response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }else{
            threadLocal.set(memberRespVo);
            return true;
        }
    }
}

注册拦截器,只拦截秒杀请求即可

@Configuration
public class SeckillWebConfig implements WebMvcConfigurer {
    /*
    * 增加拦截器
    * */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginUserInterceptor()).addPathPatterns("/kill");
    }
}

7、秒杀流程

秒杀到创建订单,没有操作数据库,只是向MQ发送一条消息,数据库的压力是非常轻松的。

分布式高级篇4 —— 商城业务(2)_第47张图片

controller

    /*
     * 秒杀
     * http://seckill.gulimall.com/kill?killId=2-11&key=f7bde6d931e34b7f8677b3395157d426&num=1
     * */
    @GetMapping("/kill")
    public R kill(
            @RequestParam("killId") String killId,
            @RequestParam("key") String key,
            @RequestParam("num") Integer num) {

        String orderSn = seckillService.kill(killId,key,num);
        return R.ok().setData(orderSn);
    }

SeckillServiceImpl

 /*
    * 秒杀
    * */
    @Override
    public String kill(String killId, String key, Integer num) {

        // 登录用户信息
        MemberRespVo respVo = LoginUserInterceptor.threadLocal.get();

        BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
        String json = hashOps.get(killId);
        if (!StringUtils.isEmpty(json)) {
            SeckillSkuRedisTo skuRedisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);

            Long startTime = skuRedisTo.getStartTime();
            Long endTime = skuRedisTo.getEndTime();
            long currentTime = System.currentTimeMillis();
            // 1、对秒杀时间进行校验
            if (currentTime >= startTime && currentTime <= endTime) {
                // 随机码
                String randomCode = skuRedisTo.getRandomCode();
                // 商品信息的key
                String redisSkuKey = skuRedisTo.getPromotionSessionId().toString() + "_" + skuRedisTo.getSkuId().toString();
                // 2、对随机码、对应关系进行校验
                if (randomCode.equals(key) && killId.equals(redisSkuKey)){
                    // 3、验证购买数量是否超出限制
                    if (skuRedisTo.getSeckillLimit() >= num){
                        // 4、判断该用户是否秒杀过 ,如果没有秒杀过就去 redis 中占个位。
                        String redisKey =  respVo.getId().toString() + "_" + skuRedisTo.getSkuId().toString();
                        // 设置过期时间: 秒杀结束就过期
                        long expir = endTime - currentTime;
                        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), expir, TimeUnit.MILLISECONDS);
                        if (aBoolean){
                            // 如果为true说明存储成功,用户没有买过
                            // 5、使用信号量扣减库存
                            RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + key);
                            try {
                                // tryAcquire 会尝试获取,不成功返回 false
                                boolean b = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
                                if (b){
                                    // 6、抢购成功,创建订单号并返回
                                    return  IdWorker.getTimeId();
                                }
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }

                    }
                }
            }
        }
        return null;
    }

订单创建完毕,向 MQ 发送消息:

image-20230208151227104

1、创建队列、绑定关系

    /**
     * 商品秒杀队列
     * @return
     */
    @Bean
    public Queue orderSecKillOrrderQueue() {
        Queue queue = new Queue("order.seckill.order.queue", true, false, false);
        return queue;
    }

    @Bean
    public Binding orderSecKillOrrderQueueBinding() {
        //String destination, DestinationType destinationType, String exchange, String routingKey,
        // 			Map arguments
        Binding binding = new Binding(
                "order.seckill.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.seckill.order",
                null);

        return binding;
    }

2、秒杀成功向MQ发送消息

  /*
    * 秒杀
    * */
    @Override
    public String kill(String killId, String key, Integer num) {

        long s1 = System.currentTimeMillis();
        // 登录用户信息
        MemberRespVo respVo = LoginUserInterceptor.threadLocal.get();

        BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
        String json = hashOps.get(killId);
        if (!StringUtils.isEmpty(json)) {
            SeckillSkuRedisTo skuRedisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);

            Long startTime = skuRedisTo.getStartTime();
            Long endTime = skuRedisTo.getEndTime();
            long currentTime = System.currentTimeMillis();
            // 1、对秒杀时间进行校验
            if (currentTime >= startTime && currentTime <= endTime) {
                // 随机码
                String randomCode = skuRedisTo.getRandomCode();
                // 商品信息的key
                String redisSkuKey = skuRedisTo.getPromotionSessionId().toString() + "_" + skuRedisTo.getSkuId().toString();
                // 2、对随机码、对应关系进行校验
                if (randomCode.equals(key) && killId.equals(redisSkuKey)){
                    // 3、验证购买数量是否超出限制
                    if (skuRedisTo.getSeckillLimit() >= num){
                        // 4、判断该用户是否秒杀过 ,如果没有秒杀过就去 redis 中占个位。
                        String redisKey =  respVo.getId().toString() + "_" + skuRedisTo.getSkuId().toString();
                        // 设置过期时间: 秒杀结束就过期
                        long expir = endTime - currentTime;
                        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), expir, TimeUnit.MILLISECONDS);
                        if (aBoolean){
                            // 如果为true说明存储成功,用户没有买过
                            // 5、使用信号量扣减库存
                            RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + key);
                            try {
                                // tryAcquire 会尝试获取,不成功返回 false
                                boolean b = semaphore.tryAcquire(num);
                                if (b){
                                    // 6、抢购成功,创建订单号并返回
                                    String timeId = IdWorker.getTimeId();
                                    SeckillOrderTo orderTo = new SeckillOrderTo();
                                    orderTo.setOrderSn(timeId);
                                    orderTo.setMemberId(respVo.getId());
                                    orderTo.setNum(num);
                                    orderTo.setPromotionSessionId(skuRedisTo.getPromotionSessionId());
                                    orderTo.setSkuId(skuRedisTo.getSkuId());
                                    orderTo.setSeckillPrice(skuRedisTo.getSeckillPrice());
                                    // TODO 向MQ发送消息
                                    rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order",orderTo);
                                    long s2 = System.currentTimeMillis();
                                    log.info("耗时..." + (s2 - s1));
                                    return  timeId;
                                }
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                        }

                    }
                }
            }
        }
        return null;
    }

3、SeckillOrderTo

@Data
public class SeckillOrderTo {
    /**
     * 订单号
     */
    private String orderSn;

    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;

    /**
     * 购买数量
     */
    private Integer num;

    /**
     * 会员ID
     */
    private Long memberId;

}

4、消费者监听队列,创建订单

@Slf4j
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class OrderSeckillListener {

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void listener(SeckillOrderTo orderTo, Channel channel, Message message) throws IOException {

        log.info("准备创建秒杀单的详细信息...");

        try {
            orderService.createSeckillOrder(orderTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }

    }

}

5、

   /*
     * 创建秒杀订单
     * */
    @Override
    public void createSeckillOrder(SeckillOrderTo orderTo) {
        //TODO 保存订单信息
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setOrderSn(orderTo.getOrderSn());
        orderEntity.setMemberId(orderTo.getMemberId());
        orderEntity.setCreateTime(new Date());
        BigDecimal totalPrice = orderTo.getSeckillPrice().multiply(BigDecimal.valueOf(orderTo.getNum()));
        orderEntity.setPayAmount(totalPrice);
        orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());

        //保存订单
        this.save(orderEntity);

        //保存订单项信息
        OrderItemEntity orderItem = new OrderItemEntity();
        orderItem.setOrderSn(orderTo.getOrderSn());
        orderItem.setRealAmount(totalPrice);

        orderItem.setSkuQuantity(orderTo.getNum());

        //保存订单项数据
        orderItemService.save(orderItem);
    }

七、Sentinel

1、熔断降级限流

服务熔断

服务熔断会导致服务降级,但是和服务降级的区别就是服务熔断会恢复调用链路。

在互联网系统中,当下游服务因访问压力过大而响应变慢或失败,上游服务为了保护系统整体的可用性,可以暂时切断对下游服务的调用。 这种牺牲局部,保全整体的措施就叫做熔断。

分布式高级篇4 —— 商城业务(2)_第48张图片

一旦下游服务C因某些原因变得不可用,积压了大量请求,服务B的请求线程也随之阻塞。线程资源逐渐耗尽,使得服务B也变得不可用。紧接着,服务 A也变为不可用,整个调用链路被拖垮。

因此,需要服务熔断来确保整个系统的可用性

服务降级

整个网站处于流量高峰期,服务器压力剧增,根据当前业务情况及流量,对一些服务和 页面进行有策略的降级[停止服务,所有的调用直接返回降级数据]。以此缓解服务器资源的 的压力,以保证核心业务的正常运行,同时也保持了客户和大部分客户的得到正确的相应。

相同点

1、为了保证集群大部分服务的可用性和可靠性,防止崩溃,牺牲小我

2、用户最终都是体验到某个功能不可用

不同点

1、熔断是被调用方故障,触发的系统主动规则

2、降级是基于全局考虑,停止一些正常服务,释放资源

限流

对打入服务的请求流量进行控制,使服务能够承担不超过自己能力的流量压力

2、SpringBoot整合Sentinel

sentinel官方文档: 主流框架的适配 · alibaba/Sentinel Wiki · GitHub

分布式高级篇4 —— 商城业务(2)_第49张图片

使用sentinel主要分为几个步骤:

  1. 定义资源
  2. 定义规则
  3. 检验规则是否生效

1、引入依赖

        
        <dependency>
            <groupId>com.alibaba.cloudgroupId>
            <artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
        dependency>

2、下载控制台:Releases · alibaba/Sentinel (github.com)

3、配置服务与控制台的连接地址,以及每个服务与控制台交互数据的端口

#sentinel
# 控制台的地址
spring.cloud.sentinel.transport.dashboard=localhost:8333
# 服务与控制台交互数据的端口
spring.cloud.sentinel.transport.port=8719

3、在控制台中配置规则

分布式高级篇4 —— 商城业务(2)_第50张图片

增加流控规则

分布式高级篇4 —— 商城业务(2)_第51张图片

3、实时监控

在使用 Endpoint 特性之前需要在 Maven 中添加 spring-boot-starter-actuator 依赖,并在配置中允许 Endpoints 的访问。

  • Spring Boot 1.x 中添加配置 management.security.enabled=false。暴露的 endpoint 路径为 /sentinel
  • Spring Boot 2.x 中添加配置 management.endpoints.web.exposure.include=*。暴露的 endpoint 路径为 /actuator/sentinel

1、每一个微服务都导入 spring-boot-starter-actuator,而不是在 common 模块

        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-actuatorartifactId>
        dependency>

2、暴露端点

# actuator
management.endpoints.web.exposure.include=*

4、自定义sentinel的返回信息

默认的返回信息:

分布式高级篇4 —— 商城业务(2)_第52张图片

自定义响应信息:

分布式高级篇4 —— 商城业务(2)_第53张图片

@Configuration
public class SeckillSentinelConfig {

    /*
     * 自定义sentinel流控失败信息, 俩种配置方式
     * */
    // public SeckillSentinelConfig() {
    //     WebCallbackManager.setUrlBlockHandler(new UrlBlockHandler() {
    //         @Override
    //         public void blocked(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, BlockException e) throws IOException {
    //             // 响应编码
    //             httpServletResponse.setCharacterEncoding("UTF-8");
    //             // 数据格式
    //             httpServletResponse.setContentType("application/json");
    //             R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getMessage());
    //             // 响应到页面
    //             httpServletResponse.getWriter().write(JSON.toJSONString(error));
    //
    //         }
    //     });
    //
    // }

    @Bean
    public UrlBlockHandler urlBlockHandler(){
        UrlBlockHandler urlBlockHandler = new UrlBlockHandler() {
            @Override
            public void blocked(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, BlockException e) throws IOException {
                // 响应编码
                httpServletResponse.setCharacterEncoding("UTF-8");
                // 数据格式
                httpServletResponse.setContentType("application/json");
                R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getMessage());
                // 响应到页面
                httpServletResponse.getWriter().write(JSON.toJSONString(error));
            }
        };
        WebCallbackManager.setUrlBlockHandler(urlBlockHandler);
        return urlBlockHandler;
    }
}

5、RabbitTemplate 与 MyRabbitConfig 循环依赖问题

原代码:

@Configuration
public class MyRabbitConfig {

    /*
    * 这里直接注入会有循环依赖的问题
    *   rabbitConfig 需要rabbitTemplate,而rabbitTemplate又要使用到rabbitConfig 形成了循环依赖
    * */
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }

    /*
     * 设置发布确认机制
     *   1、ConfirmCallback,只要 Broker 接收到消息就会执行此回调
     *      spring.rabbitmq.publisher-confirms=true
     *   2、ReturnCallback 只有交换机将消息转发到Queue失败时,才会调用此回调
     *      # 开启发送端确认机制。 Exchange --> Queue
     *      spring.rabbitmq.publisher-returns=true
     *      # 只要消息成功发送到Queue,就优先异步调用 ReturnCallback
     *      spring.rabbitmq.template.mandatory=true
     * */
    @PostConstruct // MyRabbitConfig初始化之后执行
    public void InitRabbitTemplate() {
        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            /**
             * @description
             * @date 2023/1/31 18:55
             * @param correlationData 保存消息的id以及相关信息,可在发送消息时指定 new CorrelationData()
             * @param ack 消息是否发送失败。true:Broke接收到消息, false:Broker没有接收到消息
             * @param cause 消息发送失败的原因
             * @return void
             */
            @Override
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                if (ack) {
                    System.out.println("Broker接收消息成功, correlationData: " + correlationData + " ack:" + ack + " cause:" + cause);
                } else {
                    System.out.println("Broker接收消息失败, correlationData: " + correlationData + " ack:" + ack + " cause:" + cause);
                }
            }
        });

        rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
            /**
             * @description
             * @date 2023/1/31 22:25
             * @param message 投递失败的消息
             * @param replyCode 回复的状态码
             * @param replyText 回复的文本
             * @param exchange  投递失败的交换机
             * @param routingKey    投递失败消息的 routing-key
             * @return void
             */
            @Override
            public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                System.out.println("message: " + message + " replyCode: " + replyCode + " replyText: " + replyText + " exchange: " + exchange + " routingKey: " + routingKey);
            }
        });
    }
}

报错:

image-20230208215810598

原因

构建 RabbitTemplate 时需要 MessageConverter,而MessageConverter由依赖于 MyRabbitConfig,MyRabbitConfig 中又注入了 RabbitTemplate ,造成了循环依赖。

解决办法:将 MessageConverter 移到外部类

@Configuration
public class MyRabbitConfig {


    @Autowired
    private RabbitTemplate rabbitTemplate;

    /*
     * 这里直接注入MessageConverter会有循环依赖的问题
     *   构建 RabbitTemplate 时需要 MessageConverter,
     *   而MessageConverter由依赖于 MyRabbitConfig,MyRabbitConfig 中又注入了 RabbitTemplate ,造成了循环依赖。
     * */
    // @Bean
    // public MessageConverter messageConverter() {
    //     return new Jackson2JsonMessageConverter();
    // }

    /*
     * 设置发布确认机制
     *   1、ConfirmCallback,只要 Broker 接收到消息就会执行此回调
     *      spring.rabbitmq.publisher-confirms=true
     *   2、ReturnCallback 只有交换机将消息转发到Queue失败时,才会调用此回调
     *      # 开启发送端确认机制。 Exchange --> Queue
     *      spring.rabbitmq.publisher-returns=true
     *      # 只要消息成功发送到Queue,就优先异步调用 ReturnCallback
     *      spring.rabbitmq.template.mandatory=true
     * */
    @PostConstruct // MyRabbitConfig初始化之后执行
    public void InitRabbitTemplate() {
        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            /**
             * @description
             * @date 2023/1/31 18:55
             * @param correlationData 保存消息的id以及相关信息,可在发送消息时指定 new CorrelationData()
             * @param ack 消息是否发送失败。true:Broke接收到消息, false:Broker没有接收到消息
             * @param cause 消息发送失败的原因
             * @return void
             */
            @Override
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                if (ack) {
                    System.out.println("Broker接收消息成功, correlationData: " + correlationData + " ack:" + ack + " cause:" + cause);
                } else {
                    System.out.println("Broker接收消息失败, correlationData: " + correlationData + " ack:" + ack + " cause:" + cause);
                }
            }
        });

        rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
            /**
             * @description
             * @date 2023/1/31 22:25
             * @param message 投递失败的消息
             * @param replyCode 回复的状态码
             * @param replyText 回复的文本
             * @param exchange  投递失败的交换机
             * @param routingKey    投递失败消息的 routing-key
             * @return void
             */
            @Override
            public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                System.out.println("message: " + message + " replyCode: " + replyCode + " replyText: " + replyText + " exchange: " + exchange + " routingKey: " + routingKey);
            }
        });
    }
}

@Configuration
class messageConverterConfig{
    /*
     * 自定义消息转换器
     * */
    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }
}

6、熔断降级

文档:熔断降级 · alibaba/Sentinel Wiki · GitHub

现代微服务架构都是分布式的,由非常多的服务组成。不同服务之间相互调用,组成复杂的调用链路。复杂链路上的某一环不稳定,就可能会层层级联,最终导致整个链路都不可用。因此我们需要对不稳定的弱依赖服务调用进行熔断降级,暂时切断不稳定调用,避免局部不稳定因素导致整体的雪崩。熔断降级作为保护自身的手段,通常在**客户端(调用端)**进行配置。

熔断策略

分布式高级篇4 —— 商城业务(2)_第54张图片

开启 Feign 的熔断降级

1、引入 spring-cloud-starter-openfeignspring-cloud-starter-alibaba-sentinel 依赖

2、配置文件打开 Sentinel 对 Feign 的支持

feign.sentinel.enabled=true

3、简单实例 —— 远程服务被降级, 就会触发此回调

@FeignClient(value = "gulimall-seckill",fallback = SeckillFeignFallback.class)
public interface SeckillFeignService {

    /*
    * 查询某一个商品的秒杀信息
    * */
    @GetMapping("/sku/seckill/{skuId}")
    public R getSkuSeckillInfo(@PathVariable Long skuId);

}

@Component
@Slf4j
public class SeckillFeignFallback implements SeckillFeignService {

    @Override
    public R getSkuSeckillInfo(Long skuId) {
        log.info("进入到熔断降级....");
        return R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.VALID_EXCEPTION.getMessage());
    }
}

7、开启自定义的受保护资源

1、代码方式:可以对任意资源进行保护,可以进行流控、熔断…

// 1.5.0 版本开始可以利用 try-with-resources 特性(使用有限制)
// 资源名可使用任意有业务语义的字符串,比如方法名、接口名或其它可唯一标识的字符串。
try (Entry entry = SphU.entry("resourceName")) {
  // 被保护的业务逻辑
  // do something here...
} catch (BlockException ex) {
  // 资源访问阻止,被限流或被降级
  // 在此处进行相应的处理操作
}

2、注解方式

分布式高级篇4 —— 商城业务(2)_第55张图片

分布式高级篇4 —— 商城业务(2)_第56张图片

8、网关流控

只需要增加一个依赖即可

        
        <dependency>
            <groupId>com.alibaba.cloudgroupId>
            <artifactId>spring-cloud-alibaba-sentinel-gatewayartifactId>
            <version>2.1.0.RELEASEversion>
        dependency>

分布式高级篇4 —— 商城业务(2)_第57张图片

9、自定义网关流控回调

分布式高级篇4 —— 商城业务(2)_第58张图片

/**
 * 网关限流回调
 * @author YZG
 * @date 2023/2/9
 */
@Configuration
public class SentinelGatewayConfig {

    public SentinelGatewayConfig() {
        GatewayCallbackManager.setBlockHandler(new BlockRequestHandler() {
            //网关限流了请求,就会调用此回调
            @Override
            public Mono<ServerResponse> handleRequest(ServerWebExchange exchange, Throwable t) {

                R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getMessage());
                String errorJson = JSON.toJSONString(error);

                Mono<ServerResponse> body = ServerResponse.ok().body(Mono.just(errorJson), String.class);
                return body;
            }
        });
    }

}

总结

高并发三宝:

分布式高级篇4 —— 商城业务(2)_第59张图片

你可能感兴趣的:(#,谷粒商城,分布式,java)