22-09-29 西安 谷粒商城(07)分布式定时任务xxl-job、Cron表达式、springTask定时任务、订单业务

定时任务技术

1.jdk自带的 timer(功能简单)
2.springTask提供的定时任务
3.quartz:经典的定时任务框架,但是配置繁琐且不能动态配置
4.xxl-job:分布式定时任务框架,基于quartz改进的可以动态配置


springTask提供的定时任务

springcontext包提供的定时任务实现方式,是声明式定时任务

1、启动类添加注解@EnableScheduling

只需要加圈的那俩个注解就可以测试了,其他的都是别的作用的 

2、方法上加@Scheduled(cron="")

@Component
public class CartTask {
    //创建定时任务
    //spring task 只支持6位的cron表达式
    //  秒  分 时 日 月 周 年
    //  秒:0/5  从0秒开始  每过5秒执行一次
    @Scheduled(cron = "0/5 * * * * ?")
    public void test(){
        System.out.println(Thread.currentThread().getName()+" .. "+new Date());
    }
}

重新启动项目后,控制台就会有效果了

总结:用起来非常方便,但是不支持多实例的分布式系统


Cron表达式

Cron Expressions是由七个子表达式组成的字符串,用于描述日程表的各个细节。这些子表达式用空格分隔,并表示:秒  分  时  日  月 周  年 

周和日通配所有时必须有一个是?,不能同时使用*

1.具体有效值:所有字段都有一组可以指定的有效值

钟的数字0到59

小时的值0到23。

日期可以是1-31的任何值,但是您需要注意在给定的月份中有多少天!

月份可以指定为1到12之间的值,或者:JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV和DEC。
星期几可以指定为1到7(1 =星期日)之间的值,或者使用字符串SUN,MON,TUE,WED,THU,FRI和SAT。

2.单个子表达式可以包含范围(-)或列表(,)

例如,可以用“MON-FRI”,“MON,WED,FRI”或甚至“MON-WED,SAT”代替如下示例中的星期几字段。

“0 0 12 ?* WED“
每个星期三下午12:00

3. * 通配符可用于说明该字段的“每个”可能的值。

因此,前一个例子的“月”字段中的“”字符仅仅是“每个月”。因此,“星期几”字段中的“*”显然意味着“每周的每一天”。

4.  /字符 可用于指定值的增量。

例如,如果在“分钟”字段中输入“0/15”,则表示“每隔15分钟,从零开始”。

如果您在“分钟”字段中使用“3/20”,则意味着“每隔20分钟,从三分钟开始” - 换句话说,它与“分钟”中的“3,23,43”相同领域。

请注意“ /35”的细微之处并不代表“每35分钟” - 这意味着“每隔35分钟,从零开始” - 或者换句话说,与指定“0,35”相同。

5. ?字符 是允许的日期和星期几字段

用于指定“无特定值”。只有在其中一个字段指定了值,另一个字段不指定时才能使用。这两个字段不能同时为*或者?

6. L字符 允许用于月日和星期几字段,相当于最后。

例如,“月”字段中的“L”表示“月的最后一天” - 1月31日,非闰年2月28日。如果在本周的某一天使用,它只是意味着“7”或“SAT”。但是如果在星期几的领域中再次使用这个值,就意味着“一个月的最后一个xxx日”,例如“6L”或“FRIL”都意味着“月的最后一个星期五”。您还可以指定从该月最后一天的偏移量,例如“L-3”,这意味着日历月份的第三个到最后一天。当使用'L'选项时,重要的是不要指定列表或值的范围,因为您会得到混乱/意外的结果。

7.W用于指定最近给定日期的工作日(星期一至星期五)。

例如,如果要将“15W”指定为月日期字段的值,则意思是:“最近的平日到当月15日”。

8.用于指定本月的“第n个”XXX工作日。

例如,“星期几”字段中的“6#3”或“FRI#3”的值表示“本月的第三个星期五”。


分布式定时任务xxl-Job

XXL-JOB支持通过 Web 页面对任务进行 CRUD 操作,支持动态修改任务状态、暂停/恢复任务,以及终止运行中任务,支持在线配置调度任务入参和在线查看调度结果。

源码参照github:GitHub - xuxueli/xxl-job: A distributed task scheduling framework.(分布式任务调度平台XXL-JOB)

码云:xxl-job: 一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

项目结构

解析下载的源码压缩包,项目结构分析

xxl-job-admin:调度中心

xxl-job-core:公共依赖

xxl-job-executor-samples:执行器Sample示例(选择合适的版本执行器,可直接使用,也可以参考其并将现有项目改造成执行器) :

        xxl-job-executor-sample-springboot:Springboot版本管理执行器推荐这种方式 

调度中心:统一管理任务调度平台上的调度任务,负责触发调度执行,并且提供任务管理平台。

执行器:负责接收“调度中心”的调度并执行;可直接部署执行器,也可以将执行器集成到现有业务项目中

XXL-JOB中“调度模块”和“任务模块”完全解耦,调度模块进行任务调度时,将会解析不同的任务参数发起远程调用,调用各自的远程执行器服务。这种调用模型类似RPC调用,调度中心提供调用代理的功能,而执行器提供远程服务的功能。


部署调度中心

1、初始化“调度数据库”

“调度数据库初始化SQL脚本” 位置为:/xxl-job/doc/db/tables_xxl_job.sql

完成后数据库效果如下:

表说明:

- xxl_job_lock:任务调度锁表。
- xxl_job_group:执行器信息表,维护任务执行器信息。
- xxl_job_info:调度扩展信息表: 用于保存XXL-JOB调度任务的扩展信息,如任务分组、任务名、机器地址、执行器、执行入参和报警邮件等等。
- xxl_job_log:调度日志表: 用于保存XXL-JOB任务调度的历史信息,如调度结果、执行结果、调度入参、调度机器和执行器等等。
- xxl_job_log_report:调度日志报表:用户存储XXL-JOB任务调度日志的报表,调度中心报表功能页面会用到。
- xxl_job_logglue:任务GLUE日志:用于保存GLUE更新历史,用于支持GLUE的版本回溯功能。
- xxl_job_registry:执行器注册表,维护在线的执行器和调度中心机器地址信息。
- xxl_job_user:系统用户表。


2、修改调度中心配置

调度中心配置文件:application.properties

主要关注以下3部分配置:端口号、jdbc数据源、报警邮箱、xxl调度中心配置

### 端口号
server.port=8080

### 调度中心JDBC链接:链接地址请保持和 调度数据库的地址一致
spring.datasource.url=jdbc:mysql://172.16.116.100:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

### 报警邮箱
spring.mail.host=smtp.qq.com
spring.mail.port=25
[email protected]
spring.mail.password=xxx
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.properties.mail.smtp.starttls.required=true
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory

### 调度中心通讯TOKEN [选填]:非空时启用;
xxl.job.accessToken=

### 调度中心国际化配置 [必填]: 默认为 "zh_CN"/中文简体, 可选范围为 "zh_CN"/中文简体, "zh_TC"/中文繁体 and "en"/英文
xxl.job.i18n=zh_CN

## 调度线程池最大线程配置【必填】
xxl.job.triggerpool.fast.max=200
xxl.job.triggerpool.slow.max=100

### 调度中心日志表数据保存天数 [必填]:过期日志自动清理;限制大于等于7时生效,否则, 如-1,关闭自动清理功能;
xxl.job.logretentiondays=30

3、启动调度中心

在浏览器访问: 任务调度中心

默认登录账号 “admin/123456”, 登录后运行界面如下图所示。

调度中心支持集群部署,提升调度系统容灾和可用性。


搭建执行器项目

执行器负责接收“调度中心”的调度并执行;在源码中作者已经贴心的给出了多种执行器项目示例(官方推荐的xxl-job-executor-sample-springboot项目为例部署),可根据你的喜好直接将其部署作为你自己的执行器,也可以将执行器集成到现有业务项目中去。

这里以集成到现有项目为例,将执行器集成到现有的项目中去

1、添加xxl-job-core依赖

<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.2.0</version>
</dependency>

2、修改执行器配置文件

配置内容如下:

# 端口号
server.port=8081
# no web
#spring.main.web-environment=false
# log config
#logging.config=classpath:logback.xml

### 调度中心部署根地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。
### 执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin

### 执行器通讯TOKEN [选填]:非空时启用;
xxl.job.accessToken=

### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
xxl.job.executor.appname=xxl-job-executor-sample
### 执行器注册[选填]:优先使用该配置作为注册地址,为空时使用内嵌服务”IP:PORT“ 作为注册地址。从而更灵活的支持容器类型执行器动态IP和动态映射端口问题。
xxl.job.executor.address=
### 执行器IP[选填]:默认为空表示自动获取IP,多网卡时可手动设置指定IP,该IP不会绑定Host仅作为通讯实用。
### 地址信息用于 "执行器注册" 和 "调度中心请求并触发任务";
xxl.job.executor.ip=
### 执行器端口号[选填]:小于等于0则自动获取;默认端口为9999,单机部署多个执行器时,注意要配置不同执行器端口;
xxl.job.executor.port=9999
### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
### 执行器日志文件保存天数[选填]:过期日志自动清理, 限制值大于等于3时生效; 否则, 如-1, 关闭自动清理功能;
xxl.job.executor.logretentiondays=30

3、添加执行器配置类

不解释,直接拷贝。

@Configuration
public class XxlJobConfig {
    private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);

    @Value("${xxl.job.admin.addresses}")
    private String adminAddresses;

    @Value("${xxl.job.accessToken}")
    private String accessToken;

    @Value("${xxl.job.executor.appname}")
    private String appname;

    @Value("${xxl.job.executor.address}")
    private String address;

    @Value("${xxl.job.executor.ip}")
    private String ip;

    @Value("${xxl.job.executor.port}")
    private int port;

    @Value("${xxl.job.executor.logpath}")
    private String logPath;

    @Value("${xxl.job.executor.logretentiondays}")
    private int logRetentionDays;


    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        logger.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
        xxlJobSpringExecutor.setAppname(appname);
        xxlJobSpringExecutor.setAddress(address);
        xxlJobSpringExecutor.setIp(ip);
        xxlJobSpringExecutor.setPort(port);
        xxlJobSpringExecutor.setAccessToken(accessToken);
        xxlJobSpringExecutor.setLogPath(logPath);
        xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

        return xxlJobSpringExecutor;
    }

    /**
     * 针对多网卡、容器内部署等情况,可借助 "spring-cloud-commons" 提供的 "InetUtils" 组件灵活定制注册IP;
     *
     *      1、引入依赖:
     *          <dependency>
     *             <groupId>org.springframework.cloud</groupId>
     *             <artifactId>spring-cloud-commons</artifactId>
     *             <version>${version}</version>
     *         </dependency>
     *
     *      2、配置文件,或者容器启动变量
     *          spring.cloud.inetutils.preferred-networks: 'xxx.xxx.xxx.'
     *
     *      3、获取IP
     *          String ip_ = inetUtils.findFirstNonLoopbackHostInfo().getIpAddress();
     */
}

4、给执行器添加任务

@Component
public class MyJobHandler {
    private static Logger logger = LoggerFactory.getLogger(MyJobHandler.class);

    @XxlJob("myJobHandler") // 注解中的值表示该任务注册到调度中心的任务名称
    public ReturnT<String> demoJobHandler(String param) {
        logger.info("param: "+ param);
        //通过 "XxlJobLogger.log()" 打印执行日志;
        XxlJobLogger.log("XXL-JOB, Hello World. : "+ param);
        return ReturnT.SUCCESS;
    }
}

5、执行器管理界面-添加执行器

点击进入”执行器管理”界面, 如下图:

"执行器列表" 中显示在线的执行器列表, 可通过"OnLine 机器"查看对应执行器的集群机器

点击新增按钮,把执行器项目添加进来

新增/编辑”执行器管理“界面:

  • AppName: 是每个执行器集群的唯一标示AppName, 执行器会周期性以AppName为对象进行自动注册。可通过该配置自动发现注册成功的执行器, 供任务调度时使用。

  • 名称: 执行器的名称,因为AppName限制字母数字等组成,可读性不强,名称为了提高执行器的可读性。

  • 注册方式:调度中心获取执行器地址的方式。

    • 自动注册:执行器自动进行执行器注册,调度中心通过底层注册表可以动态发现执行器机器地址

    • 手动录入:人工手动录入执行器的地址信息,多地址逗号分隔,供调度中心使用;

  • 机器地址:"注册方式"为"手动录入"时有效,支持人工维护执行器的地址信息。

添加完成后效果如下:


6、配置执行器任务到调度中心

去调度中心配置新增执行器任务

登录调度中心,点击下图所示“新建”按钮

参考下面截图中任务的参数配置,点击保存。

成功后如下

执行一次,后查询日志

 在idea的控制台也是打印了

你也可以点击任务右侧的”启动“按钮执行多次,真正的启动任务

此时任务状态是绿色”RUNNING“:

 再去查看调度日志,可以看到很多执行日志的记录:

 在idea控制台中


7、任务界面细说

  • 执行器:任务绑定的执行器,任务触发调度时将会自动发现注册成功的执行器, 实现任务自动发现功能;另一方面也可以方便的进行任务分组。每个任务必须绑定一个执行器, 可在 "执行器管理" 进行设置。

  • 任务描述:任务的描述信息,便于任务管理。

  • 路由策略:当执行器集群部署时,提供丰富的路由策略,包括:

    • FIRST / LAST:固定选择第一个/最后一个机器。

    • ROUND(轮询)/ RANDOM(随机)

    • CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。

    • LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举。

    • LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举。

    • FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度。

    • BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度。

    • SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务。

  • Cron:触发任务执行的Cron表达式。

  • 运行模式:

    • BEAN模式:任务以JobHandler方式维护在执行器端;需要结合 "JobHandler" 属性匹配执行器中任务。

    • GLUE模式(Java):任务以源码方式维护在调度中心;该模式的任务实际上是一段继承自IJobHandler的Java类代码并 "groovy" 源码方式维护,它在执行器项目中运行,可使用@Resource/@Autowire注入执行器里中的其他服务。

    • GLUE模式(Shell):任务以源码方式维护在调度中心;该模式的任务实际上是一段 "shell" 脚本; GLUE模式(Python):任务以源码方式维护在调度中心;该模式的任务实际上是一段 "python" 脚本; GLUE模式(PHP):任务以源码方式维护在调度中心;该模式的任务实际上是一段 "php" 脚本; GLUE模式(NodeJS):任务以源码方式维护在调度中心;该模式的任务实际上是一段 "nodejs" 脚本; GLUE模式(PowerShell):任务以源码方式维护在调度中心;该模式的任务实际上是一段 "PowerShell" 脚本。

  • JobHandler:运行模式为 "BEAN模式" 时生效,对应执行器中新开发的JobHandler类“@JobHandler”注解中自定义的value值。

  • 阻塞处理策略:调度过于密集执行器来不及处理时的处理策略。

    • 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO队列并以串行方式运行。

    • 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败。

    • 覆盖之前调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务。

  • 子任务:每个任务都拥有一个唯一的任务ID(任务ID可以从任务列表获取),当本任务执行结束并且执行成功时,将会触发子任务ID所对应的任务的一次主动调度。

  • 任务超时时间:支持自定义任务超时时间,任务运行超时将会主动中断任务。

  • 失败重试次数;支持自定义任务失败重试次数,当任务失败时将会按照预设的失败重试次数主动进行重试。

  • 报警邮件:任务调度失败时邮件通知的邮箱地址,支持配置多邮箱地址,配置多个邮箱地址时用逗号分隔。

  • 负责人:任务的负责人。

  • 执行参数:任务执行所需的参数。


订单模块

订单流程图

当用户点击结算的时候,这块我们用网关局部过滤器先判断用户是否登录,如果用户没有登录,则跳转到登陆页面,让用户去登录,登录成功之后,跳转到订单结算页面

订单确认

1、用户收货地址

收货人信息有更多地址,即有多个收货地址,其中有一个默认收货地址

    //1、收获地址列表
    CompletableFuture<Void> c1 = CompletableFuture.runAsync(() -> {
        ResponseVo<List<UserAddressEntity>> listResponseVo = umsClient.queryAddressesByUserId(userInfo.getUserId());
        List<UserAddressEntity> userAddressEntityList = listResponseVo.getData();
        //如果收货地址列表不为空
        if (!CollectionUtils.isEmpty(userAddressEntityList)) {
            orderConfirmVo.setAddresses(userAddressEntityList);
        }
    }, executor);
/**
 * 是否默认地址
 */
private Integer defaultStatus;

2、购物清单

购物车选中的购物项列表,去查询购物清单,根据购物车选中的skuId到数据库中查询

    //3、购物车选中的购物项列表:cart
    CompletableFuture<List<Cart>> c3 = CompletableFuture.supplyAsync(() -> {
        List<Cart> cartList = cartClient.queryCheckedCarts(userInfo.getUserId()).getData();
        if (CollectionUtils.isEmpty(cartList)) {
            throw new RuntimeException("未选中要购买的商品");
        }
        return cartList;
    }, executor);

    //拼装为List<OrderItemVo>
    ArrayList<OrderItemVo> orderItemVos = new ArrayList<>();
    CompletableFuture<Void> c4 = c3.thenAcceptAsync((cartList) -> {
        OrderItemVo orderItemVo = new OrderItemVo();
        //遍历选中的每一个购物项
        cartList.stream().forEach(cart -> {
            orderItemVo.setSkuId(cart.getSkuId());
            orderItemVo.setCount(cart.getCount());
            //开启子线程去查询
            CompletableFuture<Void> c5 = CompletableFuture.runAsync(() -> {
                SkuEntity skuEntity = pmsClient.querySkuById(cart.getSkuId()).getData();
                if (skuEntity != null) {
                    orderItemVo.setTitle(skuEntity.getTitle());
                    orderItemVo.setDefaultImage(skuEntity.getDefaultImage());
                    orderItemVo.setPrice(skuEntity.getPrice());
                    orderItemVo.setWeight(new BigDecimal(skuEntity.getWeight() + ""));
                }

            }, executor);

            CompletableFuture<Void> c6 = CompletableFuture.runAsync(() -> {
                //设置销售属性
                List<SkuAttrValueEntity> saleAttrs = pmsClient.querySearchAttrValueBySkuId(cart.getSkuId()).getData();
                if (!CollectionUtils.isEmpty(saleAttrs)) {
                    orderItemVo.setSaleAttrs(saleAttrs);
                }
            }, executor);

            CompletableFuture<Void> c7 = CompletableFuture.runAsync(() -> {
                //设置营销属性
                List<ItemSaleVo> sales = smsClient.querySalesBySkuId(cart.getSkuId() + "").getData();
                if (!CollectionUtils.isEmpty(sales)) {
                    orderItemVo.setSales(sales);
                }
            }, executor);

            CompletableFuture<Void> c8 = CompletableFuture.runAsync(() -> {
                //设置是否有货
                List<WareSkuEntity> wareSkuEntities = wmsClient.queryWareSkuBySkuId(cart.getSkuId()).getData();

                boolean b = wareSkuEntities.stream()
                        .anyMatch(wareSkuEntity -> wareSkuEntity.getStock() - wareSkuEntity.getStockLocked() - cart.getCount() > 0);

                orderItemVo.setStore(b);

            }, executor);

            CompletableFuture.allOf(c5, c6, c7, c8).join();
            orderItemVos.add(orderItemVo);
        });
        orderConfirmVo.setItems(orderItemVos);
    }, executor);

3、生成orderToken保证幂等性

幂等性:指并发量大的情况下,相同的参数重复点击能不能保证数据正确

    //4、生成token保证幂等性
    CompletableFuture<Void> c9 = CompletableFuture.runAsync(() -> {
        String timeId = IdWorker.getTimeId();
        orderConfirmVo.setOrderToken(timeId);
        //在redis中设置token,防止表单重复提交
        redisTemplate.opsForValue().set("order:token:" + userInfo.getUserId(), timeId, 2, TimeUnit.HOURS);
    }, executor);

    CompletableFuture.allOf(c1, c2, c3, c4, c9).join();
    return orderConfirmVo;

订单提交

1、orderToken防重复提交

当用户点击去购物车结算按钮的时候,会通过雪花算法生成一个对外暴露的一个orderToken,为防订单重复提交的

浏览器针对回退或者前进是不会重新发送http请求,只有在刷新的时候会重新发送请求,因此,
如果后端不加一控制,则为出现常见的订单重复提交。

使用LUA脚本:需要保证orderToken判断和删除操作原子性

    /**
     * 创建订单
     * @param orderSubmitVo
     * @return
     */
    @Override
    public String createOrder(OrderSubmitVO orderSubmitVo) {
        UserInfo userInfo = LoginInterceptor.getUserInfo();
        String orderToken = orderSubmitVo.getOrderToken();
        //获取redis中
        String redisOrderToken = redisTemplate.opsForValue().get("order:token:" + userInfo.getUserId());

        //1.验证表单重复提交
        if (StringUtils.isNotEmpty(redisOrderToken) && StringUtils.equals(orderToken, redisOrderToken)) {
            //有的话,就删除,防止重复提交
            Boolean delete = redisTemplate.delete("order:token:" + userInfo.getUserId());
        } else {
            //没有的话,直接返回
            throw new RuntimeException("请勿重复提交表单");
        }
    }

怎么解决多端重复提交问题?

orderToken多端共享,redis存一份即可


2、验价

前端通过提交来得订单中不同商品价格和当前数据库中存在的价格进行比对,如果相等,则验证通过,可以进行下一步,反之,验证失败,提示用户该商品价格有变化,重新提交

        //2.验价
        //查询数据库最新的价格,如果不一样,页面失效
        BigDecimal currentTotalPrice = orderSubmitVo.getItems().stream().map(orderItemVo -> {
            Integer count = orderItemVo.getCount();
            Long skuId = orderItemVo.getSkuId();
            ResponseVo<SkuEntity> skuEntityResponseVo = pmsClient.querySkuById(skuId);
            SkuEntity skuEntity = skuEntityResponseVo.getData();
            if (skuEntity != null) {
                return skuEntity.getPrice().multiply(new BigDecimal("" + count));
            }
            return new BigDecimal("0");
        }).reduce((a, b) -> a.add(b)).get();
        if(currentTotalPrice.compareTo(orderSubmitVo.getTotalPrice())!=0){
            //验价不通过
            throw new RuntimeException("页面已过期,刷新后再试!");
        }

由于涉及到金钱的业务都需要保持很强的准确性,因此在提交真正的订单前,务必需要再一次对数据库中的商品真实价格进行核算和验证,以确保相对的价钱一致性。


3、锁定库存

验库存并锁库存(防止超卖)

验库存和锁库存使用分布式锁保证原子性

    /**
     * 验库存和锁库存
     * @param lockVOS
     * @return
     */
    @Override
    public List<SkuLockVO> checkAndLock(List<SkuLockVO> lockVOS) {
        if(CollectionUtils.isEmpty(lockVOS)){
            return null;//锁定库存失败
        }
        //遍历一个一个商品锁定库存
        lockVOS.forEach(skuLockVO -> {
            checkLock(skuLockVO);
        });

        //获取锁定成功的库存
        List<SkuLockVO> lockedVos = lockVOS.stream().filter(SkuLockVO::getLock).collect(Collectors.toList());

       //有锁定失败的库存
       if(lockedVos.size()!=lockVOS.size()){

           //释放锁定成功的库存
           lockedVos.forEach(skuLockVO -> {
               baseMapper.unlockStock(skuLockVO.getWareSkuId(),skuLockVO.getCount());
           });
           //返回锁定失败
           return null;
       }

        //锁定库存成功,把该订单的锁定库存信息放入redis
        String jsonString = JSON.toJSONString(lockedVos);
        redisTemplate.opsForValue().set("store:lock:"+lockVOS.get(0).getOrderToken(),jsonString);
        return lockedVos;
    }


    //锁定库存
    private void checkLock(SkuLockVO skuLockVO){
        Long skuId = skuLockVO.getSkuId();
        //获取分布式公平锁
        RLock fairLock = this.redissonClient.getFairLock("lock:"+skuId);
        //加锁
        fairLock.lock();
        try {
            //验库存
            List<WareSkuEntity> wareSkuEntities = baseMapper.checkStock(skuLockVO.getSkuId(), skuLockVO.getCount());
            if(CollectionUtils.isEmpty(wareSkuEntities)){
                skuLockVO.setLock(false); // 库存不足,锁定失败
                return;
            }
            //列表中找一个仓库--锁定库存
            WareSkuEntity wareSkuEntity = wareSkuEntities.get(0);
            int lockStock = baseMapper.lockStock(wareSkuEntity.getId(), skuLockVO.getCount());
            if(lockStock>0){
                //锁定库存成功
                skuLockVO.setLock(true);
                skuLockVO.setWareSkuId(wareSkuEntity.getId());
            }else {
                skuLockVO.setLock(false);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //释放锁
            fairLock.unlock();
        }
    }

允许超卖:站在商户的角度出发,因为对于大多数的商户来说,都是希望自己的商品能卖出去更多,当发现库存不足时,商户会从相应的供应商渠道获取到货物,并通常都会提示三天或者七天发货。

锁库存操作完成之后还需要将锁库存的信息存储到redis中,也就redis中需要存储当前订单锁了哪些库存的详细信息,目的:万一锁库存失败,做数据回滚

如果锁库存失败,提示用户库存不足或者需要调货请耐心等待


4、提交订单信息入库

        //4.创建订单表
        //远程访问oms服务
        OrderEntity orderEntity = omsClient.saveOrder(orderSubmitVo, userInfo.getUserId()).getData();
        if (orderEntity == null) {
            //订单创建失败,立马释放库存:mq发送消息
            rabbitTemplate.convertAndSend("order.exchange", "stock.unlock", orderToken);
            throw new RuntimeException("创建订单失败");
        }

如何保证订单号唯一?

使用MybatisPlus中的主键自增策略,来保证订单号的唯一性,而默认MybatisPlus的主键策略是IdType.ASSIGN_ID策略,即雪花算法主键类型为长字符串


5、超时关单

订单的有效时间30min,利用延时队列实现定时消息发送死信队列,消费者监听死信队列拿到消息,进行订单校验,如果订单是未支付状态,把订单状态修改为关闭订单

        //延时关单
        rabbitTemplate.convertAndSend("order.exchange", "order.create", orderToken);

MQ解决超时订单处理逻辑


6、删除选中的购物项

订单保存完毕之后,需要将购物车中对应商品移除。

        //5、删除选中的购物项:mq发送消息
        //skuIds集合,userId
        HashMap<String, Object> hashMap = new HashMap<>();
        hashMap.put("userId", userInfo.getUserId());
        List<Long> skuIds = itemVos.stream().map(orderItemVo -> orderItemVo.getSkuId()).collect(Collectors.toList());
        hashMap.put("skuIds", skuIds);
        rabbitTemplate.convertAndSend("order.exchange", "cart.delete", hashMap);
        return orderToken;

支付成功后业务

MQ在项目中的经典应用场景


拆单业务

拆单,顾名思义就是客户在下单之后,为了发货和结算方便,需要对订单进行拆分。我们这里按照发货仓库拆单

由于发货仓库不同,按照商品归属的仓库进行拆单,若有多仓有货,还应按照地域时效选择仓库进行拆单。

猜你喜欢

转载自blog.csdn.net/m0_56799642/article/details/127109242