优惠券平台(九):开发XXL-Job定时任务执行分发数据

业务背景

继续深入探究业务,上节我们是设计了直接进行优惠券分发的任务接口,但实际业务中还有定时分发任务(例如在双11活动前一天放券),所以考虑通过消息队列执行即时分发任务,对于定时分发任务,使用 XXL-Job 定期扫描并将任务发送至消息队列。

定时执行的任务主要由定时任务监控系统扫描,找到到达执行时间的任务,然后通过 XXL-Job 分布式定时框架进行处理。

什么是 XXL-Job

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

 1. 配置并启动XXL-Job

点击 application.properties 修改其中的 MySQL 连接配置

启动 XXL- Job 服务 

访问 XXL-Job 控制台地址 http://localhost:8088/xxl-job-admin,出现控制台页面即可。

默认用户名密码:admin/123456,调度平台如下图所示

 2. 配置 XXL-Job 执行器

XXL-Job 执行器是一个运行在目标服务器上的应用程序模块,用于实际执行由调度中心下发的任务。执行器可以看作是任务的“工作节点”,负责接收调度中心发送的任务调度请求并执行具体的任务逻辑。

创建执行器,配置如下:

  • AppName:one-coupon-merchant-admin

  • 名称:后管平台

    创建成功,但此时机器地址为空,因为我们的项目还没有引入 XXL-Job,等引入后这里就有机器地址了。 

    3. 创建执行器任务 

    执行器可以看作是和我们应用系统一一对应,那执行器任务就是应用系统里里定时任务。

     点击保存按钮,页面刷新得知任务创建成功。

    我们主业务逻辑中开发XXL-Job定时执行

    1. 引入maven依赖并配置XXL-Job配置类

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

    配置 application.yaml

    xxl-job:
      access-token: default_token
      admin:
        addresses: http://localhost:8088/xxl-job-admin
      executor:
        application-name: one-coupon-merchant-admin
        ip: 127.0.0.1
        log-retention-days: 30
        port: 19999

    2. 配置 XXLJobConfiguration

    XXL-Job 没有适配 Starter,需要我们配置相关的 SpringBean 配置。

    @Configuration
    public class XXLJobConfiguration {
    ​
        @Value("${xxl-job.admin.addresses:}")
        private String adminAddresses;
    ​
        @Value("${xxl-job.access-token:}")
        private String accessToken;
    ​
        @Value("${xxl-job.executor.application-name}")
        private String applicationName;
    ​
        @Value("${xxl-job.executor.ip}")
        private String ip;
    ​
        @Value("${xxl-job.executor.port}")
        private int port;
    ​
        @Value("${xxl-job.executor.log-path:}")
        private String logPath;
    ​
        @Value("${xxl-job.executor.log-retention-days}")
        private int logRetentionDays;
    ​
        @Bean
        public XxlJobSpringExecutor xxlJobExecutor() {
            XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
            xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
            xxlJobSpringExecutor.setAppname(applicationName);
            xxlJobSpringExecutor.setIp(ip);
            xxlJobSpringExecutor.setPort(port);
            xxlJobSpringExecutor.setAccessToken(StrUtil.isNotEmpty(accessToken) ? accessToken : null);
            xxlJobSpringExecutor.setLogPath(StrUtil.isNotEmpty(logPath) ? logPath : Paths.get("").toAbsolutePath().getParent() + "/tmp");
            xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
            return xxlJobSpringExecutor;
        }
    }

    我们查看 XXL-Job 执行器地址,完成上述配置后,我们尝试启动项目,然后访问 http://localhost:8088/xxl-job-admin/jobgroup 查看执行器地址是否有值。如果正确有数据即为创建成功。

     3. 编写 XXL-Job 处理器

    @Component
    @RequiredArgsConstructor
    public class CouponTaskJobHandler extends IJobHandler {
    ​
        private final CouponTaskMapper couponTaskMapper;
        private final CouponTaskActualExecuteProducer couponTaskActualExecuteProducer;
    ​
        private static final int MAX_LIMIT = 100;
    ​
        @XxlJob(value = "couponTemplateTask")
        public void execute() throws Exception {
            long initId = 0;
            Date now = new Date();
    ​
            while (true) {
                // 获取已到执行时间待执行的优惠券定时分发任务
                List<CouponTaskDO> couponTaskDOList = fetchPendingTasks(initId, now);
    ​
                if (CollUtil.isEmpty(couponTaskDOList)) {
                    break;
                }
    ​
                // 调用分发服务对用户发送优惠券
                for (CouponTaskDO each : couponTaskDOList) {
                    distributeCoupon(each);
                }
    ​
                // 查询出来的数据如果小于 MAX_LIMIT 意味着后面将不再有数据,返回即可
                if (couponTaskDOList.size() < MAX_LIMIT) {
                    break;
                }
    ​
                // 更新 initId 为当前列表中最大 ID
                initId = couponTaskDOList.stream()
                        .mapToLong(CouponTaskDO::getId)
                        .max()
                        .orElse(initId);
            }
        }
    ​
        private void distributeCoupon(CouponTaskDO couponTask) {
            // 修改延时执行推送任务任务状态为执行中
            CouponTaskDO couponTaskDO = CouponTaskDO.builder()
                    .id(couponTask.getId())
                    .status(CouponTaskStatusEnum.IN_PROGRESS.getStatus())
                    .build();
            couponTaskMapper.updateById(couponTaskDO);
            // 通过消息队列发送消息,由分发服务消费者消费该消息
            CouponTaskExecuteEvent couponTaskExecuteEvent = CouponTaskExecuteEvent.builder()
                    .couponTaskId(couponTask.getId())
                    .build();
            couponTaskActualExecuteProducer.sendMessage(couponTaskExecuteEvent);
        }
    ​
        private List<CouponTaskDO> fetchPendingTasks(long initId, Date now) {
            LambdaQueryWrapper<CouponTaskDO> queryWrapper = Wrappers.lambdaQuery(CouponTaskDO.class)
                    .eq(CouponTaskDO::getStatus, CouponTaskStatusEnum.PENDING.getStatus())
                    .le(CouponTaskDO::getSendTime, now)
                    .gt(CouponTaskDO::getId, initId)
                    .last("LIMIT " + MAX_LIMIT);
            return couponTaskMapper.selectList(queryWrapper);
        }
    }

    execute() 这是 XXL-Job 调度任务的入口方法。当定时任务触发时,XXL-Job 框架会调用此方法。

    首先方法会初始化变量initId 用于标识已经处理过的任务的最大 ID,now 用于记录当前时间。接下来会执行 while 循环,逻辑如下所示:

    • 调用 fetchPendingTasks(initId, now) 方法获取符合条件的待执行任务列表。

    • 如果 couponTaskDOList 为空,意味着没有更多的任务需要处理,循环终止。

    • 遍历 couponTaskDOList,对每个任务调用 distributeCoupon(each) 方法,将任务修改状态变更为执行中,并发送到消息队列进行异步处理。

    • 检查当前获取的任务列表大小,如果小于 MAX_LIMIT,表示已经是最后一批数据,循环终止。

    • 更新 initId 为当前批次中最大的任务 ID,以确保下一次循环获取到新的任务。

    XXL-Job定时任务测试

    我们创建一条定时发送类型的优惠券分发任务。发送时间参数设置小于当前时间即可,方便触发

    通过 http://localhost:8088/xxl-job-admin/jobinfo 执行一次任务查看是否可以调用到我们的服务。我们设置的定时间为 5s/次。 检测到触发时间小于等于下一次执行时间会触发执行刚才的execute()方法。

    通过我们的日志得知,调用成功了。

    2025-01-30T17:58:58.685+08:00  INFO 28911 --- [dPool-836404352] c.xxl.job.core.executor.XxlJobExecutor   : >>>>>>>>>>> xxl-job regist JobThread success, jobId:1, handler:com.xxl.job.core.handler.impl.MethodJobHandler@bedebe9[class com.nageoffer.onecoupon.merchant.admin.job.CouponTaskJobHandler#execute]
    Creating a new SqlSession
    SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@b15c2b3] was not registered for synchronization because synchronization is not active
    JDBC Connection [HikariProxyConnection@849828015 wrapping org.apache.shardingsphere.driver.jdbc.core.connection.ShardingSphereConnection@2b15cde] will not be managed by Spring
    ==>  Preparing: SELECT id,shop_number,batch_id,task_name,file_address,fail_file_address,send_num,notify_type,coupon_template_id,send_type,send_time,status,completion_time,operator_id,create_time,update_time,del_flag FROM t_coupon_task WHERE (status = ? AND send_time <= ? AND id > ?) LIMIT 100
    ==> Parameters: 0(Integer), 2025-01-30 17:58:58.687(Timestamp), 0(Long)
    2025-01-30T17:58:58.709+08:00  INFO 28911 --- [1-1724579938685] ShardingSphere-SQL                       : Logic SQL: SELECT  id,shop_number,batch_id,task_name,file_address,fail_file_address,send_num,notify_type,coupon_template_id,send_type,send_time,status,completion_time,operator_id,create_time,update_time,del_flag  FROM t_coupon_task      WHERE  (status = ? AND send_time <= ? AND id > ?) LIMIT 100
    2025-01-30T17:58:58.709+08:00  INFO 28911 --- [1-1724579938685] ShardingSphere-SQL                       : Actual SQL: ds_0 ::: SELECT  id,shop_number,batch_id,task_name,file_address,fail_file_address,send_num,notify_type,coupon_template_id,send_type,send_time,status,completion_time,operator_id,create_time,update_time,del_flag  FROM t_coupon_task      WHERE  (status = ? AND send_time <= ? AND id > ?) LIMIT 100 ::: [0, 2025-01-30 17:58:58.687, 0]
    <==    Columns: id, shop_number, batch_id, task_name, file_address, fail_file_address, send_num, notify_type, coupon_template_id, send_type, send_time, status, completion_time, operator_id, create_time, update_time, del_flag
    <==        Row: 1827643671885918209, 1810714735922956666, 1827643671873335296, 发送百万优惠券推送任务, /Users/machen/workspace/opensource/onecoupon-rebuild/tmp/oneCoupon任务推送Excel.xlsx, null, 1000000, 0,3, 1826268813595824129, 1, 2024-07-12 12:00:00, 0, null, 1810518709471555585, 2025-01-30 17:46:22, 2025-01-30 17:46:27, 0
    <==      Total: 1
    Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@b15c2b3]
    Creating a new SqlSession
    SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@6f3a67a0] was not registered for synchronization because synchronization is not active
    JDBC Connection [HikariProxyConnection@1489831976 wrapping org.apache.shardingsphere.driver.jdbc.core.connection.ShardingSphereConnection@2b15cde] will not be managed by Spring
    ==>  Preparing: UPDATE t_coupon_task SET status=?, update_time=? WHERE id=?
    ==> Parameters: 1(Integer), 2025-01-30 17:58:58.712(Timestamp), 1827643671885918209(Long)
    2025-01-30T17:58:58.713+08:00  INFO 28911 --- [1-1724579938685] ShardingSphere-SQL                       : Logic SQL: UPDATE t_coupon_task  SET status=?,    update_time=?  WHERE id=?
    2025-01-30T17:58:58.713+08:00  INFO 28911 --- [1-1724579938685] ShardingSphere-SQL
    

    核心源码解析

    调度中心任务

    调度中心做的第一件事,就是启动一个线程不断的扫描定时任务配置表 xxl_job_info,我们可以从初始化方法 com.xxl.job.admin.core.scheduler.XxlJobScheduler#init 中找到这个线程的初始化过程,如下图:

      可以看到在初始化方法中启动两个线程,分别是:

      1. scheduleThread:任务扫描线程,用来扫描任务配置表,并判断当前任务是否应该触发
      2. ringThread:时间轮线程,除少部分直接触发的任务以外,其余任务触发都由时间轮线程调度。

      任务配置扫描流程

      scheduleThread启动之后就会循环扫描定时任务配置表,先看一下代码:

      也就是说,每次扫描,会查询xxl_job_info中,处于启动状态,且下次执行时间

      trigger_next_time <= 当前时间 + 5000ms的数据

      最多可以获取到pagesize大小的列表。这里的 trigger_next_time 指的是下次任务触发时间,是在定时任务配置保存、更新、启动时,通过Cron表达式进行计算的,并且在每次定时任务触发时,也会更新trigger_next_time的值。

      定时逻辑概述

      1. 注册执行器并在当前执行器下创建定时任务,定时规则cron设置为例如:5s/次,并启动任务,任务会被记录在xxl_job_info表中(已启动的任务表中 trigger_status字段=1;trigger_next_time 是预读时间即任务下一次触发时间
      2. 对目标方法(例如推送任务发送到MQ)添加@XxlJob注解,并启动XXL-Job。这时调度中心会启动一个线程不断监听xxl_job_info表筛选即将触发的任务,源码中的条件如下:
        • trigger_status = 1(表示任务已启动)
        • trigger_next_time <= now + 5000ms(任务即将在未来 5 秒内触发,调度中心需要处理该任务)
      3. 符合条件的任务并不是立即执行,而是会被放入触发队列,再由调度中心通过时间轮询机制判断是否有任务到达规定时间,trigger_next_time <= now时才会发送给执行器。
      4. 调度中心对执行器发起调度请求,则执行被@XxlJob标记的目标方法,从而完成定时任务。

      最终的逻辑图

      部分图片和内容引用知识星球《拿个offer》牛券项目-https://nageoffer.com/onecoupon/  

      评论
      成就一亿技术人!
      拼手气红包6.0元
      还能输入1000个字符
       
      红包 添加红包
      表情包 插入表情
       条评论被折叠 查看
      添加红包

      请填写红包祝福语或标题

      红包个数最小为10个

      红包金额最低5元

      当前余额3.43前往充值 >
      需支付:10.00
      成就一亿技术人!
      领取后你会自动成为博主和红包主的粉丝 规则
      hope_wisdom
      发出的红包
      实付
      使用余额支付
      点击重新获取
      扫码支付
      钱包余额 0

      抵扣说明:

      1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
      2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

      余额充值