业务背景
继续深入探究业务,上节我们是设计了直接进行优惠券分发的任务接口,但实际业务中还有定时分发任务(例如在双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 中找到这个线程的初始化过程,如下图:

可以看到在初始化方法中启动两个线程,分别是:
- scheduleThread:任务扫描线程,用来扫描任务配置表,并判断当前任务是否应该触发
- ringThread:时间轮线程,除少部分直接触发的任务以外,其余任务触发都由时间轮线程调度。
任务配置扫描流程
scheduleThread启动之后就会循环扫描定时任务配置表,先看一下代码:


也就是说,每次扫描,会查询xxl_job_info中,处于启动状态,且下次执行时间
trigger_next_time <= 当前时间 + 5000ms的数据
最多可以获取到pagesize大小的列表。这里的 trigger_next_time 指的是下次任务触发时间,是在定时任务配置保存、更新、启动时,通过Cron表达式进行计算的,并且在每次定时任务触发时,也会更新trigger_next_time的值。
定时逻辑概述
- 注册执行器并在当前执行器下创建定时任务,定时规则cron设置为例如:5s/次,并启动任务,任务会被记录在xxl_job_info表中(已启动的任务表中 trigger_status字段=1;trigger_next_time 是预读时间即任务下一次触发时间。
- 对目标方法(例如推送任务发送到MQ)添加@XxlJob注解,并启动XXL-Job。这时调度中心会启动一个线程不断监听xxl_job_info表筛选即将触发的任务,源码中的条件如下:
- trigger_status = 1(表示任务已启动)
- trigger_next_time <= now + 5000ms(任务即将在未来 5 秒内触发,调度中心需要处理该任务)
- 符合条件的任务并不是立即执行,而是会被放入触发队列,再由调度中心通过时间轮询机制判断是否有任务到达规定时间,trigger_next_time <= now时才会发送给执行器。
- 调度中心对执行器发起调度请求,则执行被@XxlJob标记的目标方法,从而完成定时任务。
最终的逻辑图

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

被折叠的 条评论
为什么被折叠?



