【架构实战】任务调度XXL-JOB:定时任务的正确姿势
XXL-JOB架构、分片任务、失败重试、实战案例
一、从一个真实的故事说起
2024年某支付公司对账系统出现严重问题------连续3天的交易记录没有对账。
排查后发现,对账任务每天凌晨2点执行,但3天前的凌晨2点,数据库正在做慢查询优化,导致对账任务执行超时。更诡异的是,任务显示"执行成功",但实际没有任何对账记录生成。
"我们不是配置了超时告警吗?为什么没有收到告警?"
"告警是配置了,但任务执行器在超时后直接kill掉了线程,异常没有被正确捕获,告警逻辑根本没有执行。而且由于任务状态被标记为'成功',第二天、第三天的任务也不会重新执行。"
"那我们不是配置了失败重试吗?"
"失败重试只在任务抛出异常时触发,超时kill线程不会触发重试。"
这个故事告诉我们:定时任务不是简单的"到点执行",还需要考虑超时处理、失败重试、幂等性、监控告警等多个维度。
二、XXL-JOB架构解析
2.1 整体架构
XXL-JOB采用"调度中心+执行器"的架构:
┌─────────────────────────────────────────────────────────────┐
│ 调度中心(Admin) │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ 任务管理 │ │ 调度管理 │ │ 日志管理 │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ 调度触发器 │ │
│ │ (Quartz集群) │ │
│ └─────────┬─────────┘ │
└────────────────────────┼───────────────────────────────────┘
│ HTTP触发
┌───────────────┼───────────────┐
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ 执行器1 │ │ 执行器2 │ │ 执行器3 │
│ │ │ │ │ │
│ JobHandler1 │ JobHandler2 │ JobHandler3
│ JobHandler2 │ JobHandler3 │ JobHandler1
└─────────┘ └─────────┘ └─────────┘
调度中心职责:
- 任务管理:任务的CRUD操作
- 调度管理:基于Quartz的调度触发
- 日志管理:任务执行日志的查询和管理
- 执行器管理:执行器的注册和心跳检测
执行器职责:
- 任务执行:接收调度请求,执行具体的任务逻辑
- 结果回调:将执行结果回调给调度中心
- 日志记录:记录任务执行的详细日志
2.2 核心流程
1. 调度中心根据cron表达式触发任务
2. 调度中心选择一个执行器(路由策略)
3. 调度中心通过HTTP请求触发执行器
4. 执行器将任务放入任务队列
5. 执行器线程池执行任务
6. 执行器回调结果给调度中心
7. 调度中心记录执行日志
2.3 核心配置
调度中心配置:
properties
# application.properties
server.port=8080
# 数据库配置(存储任务信息和执行日志)
spring.datasource.url=jdbc:mysql://localhost:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root
# 调度中心集群配置
xxl.job.accessToken=your_token
执行器配置:
properties
# application.properties
server.port=8081
# 执行器配置
xxl.job.admin.addresses=http://localhost:8080/xxl-job-admin
xxl.job.executor.appname=xxl-job-executor-sample
xxl.job.executor.ip=
xxl.job.executor.port=9999
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
xxl.job.executor.logretentiondays=30
xxl.job.accessToken=your_token
三、任务类型与使用
3.1 BEAN模式(推荐)
BEAN模式是最常用的任务类型,任务逻辑写在Spring Bean中:
java
@Component
public class SampleJob {
@XxlJob("demoJob")
public void demoJob() {
XxlJobHelper.log("开始执行任务...");
// 业务逻辑
doBusiness();
XxlJobHelper.log("任务执行完成");
}
private void doBusiness() {
// 具体业务逻辑
}
}
获取任务参数:
java
@XxlJob("paramJob")
public void paramJob() {
// 获取任务参数
String param = XxlJobHelper.getJobParam();
// 解析参数
JSONObject config = JSON.parseObject(param);
String startDate = config.getString("startDate");
String endDate = config.getString("endDate");
XxlJobHelper.log("参数: startDate={}, endDate={}", startDate, endDate);
}
3.2 GLUE模式
GLUE模式允许在调度中心直接编辑任务代码:
java
// GLUE(Java)模式
import com.xxl.job.core.handler.IJobHandler;
import com.xxl.job.core.context.XxlJobHelper;
public class DemoGlueJobHandler extends IJobHandler {
@Override
public void execute() throws Exception {
XxlJobHelper.log("GLUE模式任务执行...");
// 业务逻辑
// ...
}
}
优点 :无需重启执行器,修改即时生效
缺点:代码在数据库中,版本管理困难,不推荐生产环境使用
3.3 分片任务
分片任务可以将一个大任务拆分为多个小任务,并行执行:
java
@Component
public class ShardingJob {
@XxlJob("shardingJob")
public void shardingJob() {
// 获取分片参数
int shardIndex = XxlJobHelper.getShardIndex(); // 当前分片序号
int shardTotal = XxlJobHelper.getShardTotal(); // 总分片数
XxlJobHelper.log("分片参数: {}/{}", shardIndex, shardTotal);
// 根据分片参数处理数据
// 例如:处理订单表,按订单ID取模分配
List<Order> orders = getOrdersBySharding(shardIndex, shardTotal);
for (Order order : orders) {
processOrder(order);
}
XxlJobHelper.log("处理完成,共{}条", orders.size());
}
private List<Order> getOrdersBySharding(int shardIndex, int shardTotal) {
// SQL: SELECT * FROM orders WHERE id % #{shardTotal} = #{shardIndex}
return orderMapper.selectBySharding(shardIndex, shardTotal);
}
}
分片任务配置:
在调度中心配置任务时,设置"路由策略"为"分片广播",并配置"分片数量"。
四、高级特性
4.1 失败重试
XXL-JOB支持任务失败后自动重试:
java
@XxlJob("retryJob")
public void retryJob() {
XxlJobHelper.log("执行任务,尝试次数: {}", XxlJobHelper.getRetryCount());
try {
// 业务逻辑
doBusiness();
} catch (Exception e) {
XxlJobHelper.log("任务执行失败: {}", e.getMessage());
// 抛出异常触发重试
throw e;
}
}
配置方式:
在调度中心配置任务时,设置"失败重试次数"(如3次)。任务失败后,会自动重试,最多重试3次。
重试间隔:
默认立即重试,可以通过自定义实现延迟重试:
java
@XxlJob("delayRetryJob")
public void delayRetryJob() {
int retryCount = XxlJobHelper.getRetryCount();
if (retryCount > 0) {
// 延迟重试
try {
Thread.sleep(retryCount * 1000L); // 第1次重试延迟1秒,第2次延迟2秒...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 业务逻辑
doBusiness();
}
4.2 任务超时
XXL-JOB支持任务超时设置:
java
@XxlJob("timeoutJob")
public void timeoutJob() {
long startTime = System.currentTimeMillis();
int timeoutSeconds = 60; // 超时时间60秒
XxlJobHelper.log("任务开始执行,超时时间: {}秒", timeoutSeconds);
try {
// 分批处理,每批检查超时
int page = 1;
int pageSize = 1000;
while (true) {
// 检查是否超时
if (System.currentTimeMillis() - startTime > timeoutSeconds * 1000L) {
XxlJobHelper.log("任务超时,停止执行");
XxlJobHelper.handleFail("任务超时");
return;
}
// 处理一批数据
List<Data> dataList = getDataByPage(page, pageSize);
if (dataList.isEmpty()) {
break;
}
processDataList(dataList);
page++;
}
XxlJobHelper.handleSuccess("任务执行成功");
} catch (Exception e) {
XxlJobHelper.log("任务执行异常: {}", e.getMessage());
XxlJobHelper.handleFail(e.getMessage());
}
}
配置方式:
在调度中心配置任务时,设置"任务超时时间"(如60秒)。
4.3 任务依赖(工作流)
XXL-JOB支持任务依赖,实现工作流调度:
任务A(数据抽取)
↓
任务B(数据清洗)
↓
任务C(数据统计)
配置方式:
在调度中心配置任务C时,设置"上游任务"为任务B。任务B执行成功后,才会触发任务C执行。
子任务触发:
java
@XxlJob("parentJob")
public void parentJob() {
XxlJobHelper.log("父任务执行...");
// 执行业务逻辑
doBusiness();
// 触发子任务
XxlJobHelper.handleSuccess("执行成功,触发子任务");
}
4.4 动态调度
XXL-JOB提供API接口,支持动态创建和触发任务:
java
@RestController
public class JobController {
@Autowired
private XxlJobService xxlJobService;
@PostMapping("/addJob")
public Result addJob(@RequestBody JobInfo jobInfo) {
// 动态添加任务
return xxlJobService.add(jobInfo);
}
@PostMapping("/triggerJob")
public Result triggerJob(@RequestParam int jobId) {
// 手动触发任务
return xxlJobService.trigger(jobId);
}
@PostMapping("/stopJob")
public Result stopJob(@RequestParam int jobId) {
// 停止任务
return xxlJobService.stop(jobId);
}
}
五、实战案例:电商订单对账系统
5.1 业务需求
某电商平台需要每天凌晨对账,检查订单系统和支付系统的数据一致性:
- 从订单系统获取前一天的所有订单
- 从支付系统获取前一天的所有支付记录
- 对比订单金额和支付金额,发现差异
- 生成对账报告,差异记录发送告警
5.2 架构设计
┌─────────────────────────────────────────────────────────────┐
│ XXL-JOB调度中心 │
│ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ 对账任务 │ │ 告警任务 │ │ 报表任务 │ │
│ │ (分片广播) │ │ (单机执行) │ │ (单机执行) │ │
│ └───────┬───────┘ └───────────────┘ └───────────────┘ │
└──────────┼──────────────────────────────────────────────────┘
│
┌──────▼──────┐
│ 对账执行器 │
│ │
│ 分片1: 处理订单1-10000
│ 分片2: 处理订单10001-20000
│ 分片3: 处理订单20001-30000
└─────────────┘
5.3 代码实现
对账任务:
java
@Component
public class ReconciliationJob {
@Autowired
private OrderService orderService;
@Autowired
private PaymentService paymentService;
@Autowired
private ReconciliationService reconciliationService;
@Autowired
private AlertService alertService;
@XxlJob("reconciliationJob")
public void reconciliationJob() {
// 获取分片参数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
// 获取对账日期(默认昨天)
String param = XxlJobHelper.getJobParam();
String date = StringUtils.isNotEmpty(param) ? param : getYesterday();
XxlJobHelper.log("开始对账: 日期={}, 分片={}/{}", date, shardIndex, shardTotal);
try {
// 1. 获取订单数据(按分片)
List<Order> orders = orderService.getOrdersByDateAndSharding(date, shardIndex, shardTotal);
XxlJobHelper.log("获取订单{}条", orders.size());
// 2. 获取支付数据(按分片)
List<Payment> payments = paymentService.getPaymentsByDateAndSharding(date, shardIndex, shardTotal);
XxlJobHelper.log("获取支付记录{}条", payments.size());
// 3. 对账
List<ReconciliationResult> results = reconciliationService.reconcile(orders, payments);
XxlJobHelper.log("对账完成,发现差异{}条", results.size());
// 4. 保存对账结果
reconciliationService.saveResults(results);
// 5. 发送告警(如果有差异)
if (!results.isEmpty()) {
alertService.sendReconciliationAlert(date, results);
}
XxlJobHelper.handleSuccess("对账完成");
} catch (Exception e) {
XxlJobHelper.log("对账异常: {}", e.getMessage(), e);
XxlJobHelper.handleFail("对账失败: " + e.getMessage());
}
}
private String getYesterday() {
LocalDate yesterday = LocalDate.now().minusDays(1);
return yesterday.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
}
}
对账服务:
java
@Service
public class ReconciliationService {
public List<ReconciliationResult> reconcile(List<Order> orders, List<Payment> payments) {
List<ReconciliationResult> results = new ArrayList<>();
// 构建支付记录Map
Map<String, Payment> paymentMap = payments.stream()
.collect(Collectors.toMap(Payment::getOrderId, Function.identity()));
// 对账
for (Order order : orders) {
Payment payment = paymentMap.get(order.getId());
if (payment == null) {
// 支付记录缺失
results.add(new ReconciliationResult(
order.getId(),
"PAYMENT_MISSING",
order.getAmount(),
BigDecimal.ZERO,
"支付记录缺失"
));
continue;
}
if (order.getAmount().compareTo(payment.getAmount()) != 0) {
// 金额不一致
results.add(new ReconciliationResult(
order.getId(),
"AMOUNT_MISMATCH",
order.getAmount(),
payment.getAmount(),
"订单金额与支付金额不一致"
));
}
}
// 检查是否有订单缺失的支付记录
Set<String> orderIds = orders.stream()
.map(Order::getId)
.collect(Collectors.toSet());
for (Payment payment : payments) {
if (!orderIds.contains(payment.getOrderId())) {
results.add(new ReconciliationResult(
payment.getOrderId(),
"ORDER_MISSING",
BigDecimal.ZERO,
payment.getAmount(),
"订单记录缺失"
));
}
}
return results;
}
}
5.4 幂等性保证
对账任务需要保证幂等性,避免重复执行导致数据错误:
java
@Service
public class ReconciliationService {
public void saveResults(List<ReconciliationResult> results) {
// 使用INSERT ON DUPLICATE KEY UPDATE保证幂等性
for (ReconciliationResult result : results) {
reconciliationMapper.insertOrUpdate(result);
}
}
}
// Mapper
@Mapper
public interface ReconciliationMapper {
@Insert("""
INSERT INTO reconciliation_result
(order_id, date, type, order_amount, payment_amount, message, create_time, update_time)
VALUES
(#{orderId}, #{date}, #{type}, #{orderAmount}, #{paymentAmount}, #{message}, NOW(), NOW())
ON DUPLICATE KEY UPDATE
type = #{type},
order_amount = #{orderAmount},
payment_amount = #{paymentAmount},
message = #{message},
update_time = NOW()
""")
void insertOrUpdate(ReconciliationResult result);
}
六、踩坑实录
踩坑一:任务执行超时未正确处理
问题:任务执行超时后,线程被kill,异常未捕获,任务状态错误。
java
@XxlJob("timeoutJob")
public void timeoutJob() {
// 错误:没有检查超时
while (true) {
processData();
// 如果执行时间超过超时时间,线程被kill,异常未捕获
}
}
解决方案:主动检查超时
java
@XxlJob("timeoutJob")
public void timeoutJob() {
long startTime = System.currentTimeMillis();
int timeoutSeconds = 60;
while (true) {
// 检查超时
if (System.currentTimeMillis() - startTime > timeoutSeconds * 1000L) {
XxlJobHelper.log("任务超时,停止执行");
XxlJobHelper.handleFail("任务超时");
return;
}
processData();
}
}
踩坑二:分片任务数据倾斜
问题:分片任务数据分配不均匀,某些分片执行时间过长。
java
// 错误:按ID取模分片,但ID分布不均匀
SELECT * FROM orders WHERE id % #{shardTotal} = #{shardIndex}
解决方案:按时间或其他均匀分布的字段分片
java
// 方案一:按创建时间分片
SELECT * FROM orders
WHERE DATE(create_time) = #{date}
AND HOUR(create_time) % #{shardTotal} = #{shardIndex}
// 方案二:按用户ID分片(假设用户ID分布均匀)
SELECT * FROM orders
WHERE user_id % #{shardTotal} = #{shardIndex}
踩坑三:任务失败重试导致重复执行
问题:任务失败重试时,已执行的部分逻辑重复执行。
java
@XxlJob("retryJob")
public void retryJob() {
// 错误:没有考虑重试的情况
List<Order> orders = getOrders();
for (Order order : orders) {
processOrder(order); // 重试时会重复处理已成功的订单
}
}
解决方案:记录执行进度,重试时跳过已执行的部分
java
@XxlJob("retryJob")
public void retryJob() {
String jobId = XxlJobHelper.getJobId();
// 获取已执行的订单ID
Set<String> processedOrderIds = getProcessedOrderIds(jobId);
List<Order> orders = getOrders();
for (Order order : orders) {
// 跳过已执行的订单
if (processedOrderIds.contains(order.getId())) {
continue;
}
processOrder(order);
// 记录执行进度
saveProgress(jobId, order.getId());
}
}
踩坑四:调度中心单点故障
问题:调度中心单机部署,故障后所有任务无法执行。
解决方案:调度中心集群部署
properties
# 调度中心集群配置(多个实例连接同一个数据库)
spring.datasource.url=jdbc:mysql://localhost:3306/xxl_job
# Quartz集群配置
spring.quartz.job-store-type=jdbc
spring.quartz.properties.org.quartz.scheduler.instanceId=AUTO
spring.quartz.properties.org.quartz.jobStore.isClustered=true
踩坑五:执行器内存溢出
问题:任务处理大量数据,导致执行器内存溢出。
java
@XxlJob("batchJob")
public void batchJob() {
// 错误:一次性加载所有数据
List<Order> orders = orderService.getAllOrders(); // 可能OOM
processOrders(orders);
}
解决方案:分批处理
java
@XxlJob("batchJob")
public void batchJob() {
int page = 1;
int pageSize = 1000;
while (true) {
// 分批加载数据
List<Order> orders = orderService.getOrdersByPage(page, pageSize);
if (orders.isEmpty()) {
break;
}
processOrders(orders);
page++;
}
}
七、监控与运维
7.1 任务监控
XXL-JOB提供Web界面查看任务执行情况:
调度中心 → 任务管理 → 执行日志
可以查看:
- 任务执行时间
- 执行结果(成功/失败)
- 执行日志
- 执行耗时
7.2 告警配置
XXL-JOB支持邮件告警,任务失败时自动发送邮件:
properties
# 调度中心配置
xxl.job.mail.host=smtp.qq.com
xxl.job.mail.port=25
xxl.job.mail.username=your_email@qq.com
xxl.job.mail.password=your_password
xxl.job.mail.sendNick=XXL-JOB告警
也可以自定义告警逻辑:
java
@Component
public class CustomAlertService {
@Autowired
private DingTalkService dingTalkService;
public void sendAlert(String jobName, String message) {
// 发送钉钉告警
dingTalkService.send("任务告警: " + jobName + "\n" + message);
}
}
7.3 性能优化
优化一:调整执行器线程池
properties
# 执行器配置
xxl.job.executor.logretentiondays=30
# 线程池配置(在代码中配置)
@Bean
public XxlJobExecutor xxlJobExecutor() {
XxlJobExecutor executor = new XxlJobExecutor();
executor.setAdminAddresses(adminAddresses);
executor.setAppname(appname);
executor.setIp(ip);
executor.setPort(port);
executor.setLogPath(logPath);
executor.setLogRetentionDays(logRetentionDays);
// 设置线程池大小(默认10)
executor.setExecutorService(
Executors.newFixedThreadPool(50)
);
return executor;
}
优化二:批量处理
java
@XxlJob("batchJob")
public void batchJob() {
int batchSize = 1000;
List<Order> orders = getOrders();
// 分批处理
Lists.partition(orders, batchSize).forEach(batch -> {
processBatch(batch);
});
}
优化三:异步处理
java
@XxlJob("asyncJob")
public void asyncJob() {
List<Order> orders = getOrders();
// 异步并行处理
CompletableFuture<?>[] futures = orders.stream()
.map(order -> CompletableFuture.runAsync(() -> processOrder(order)))
.toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futures).join();
}
八、总结
XXL-JOB作为分布式任务调度平台,其核心优势在于:
- 架构简单:调度中心+执行器,职责清晰
- 功能丰富:支持分片、重试、超时、依赖等高级特性
- 运维友好:Web界面管理,日志查询方便
- 扩展性强:支持动态调度、自定义路由策略
但同时,使用时需要注意:
- 幂等性:任务需要保证幂等性,避免重复执行导致数据错误
- 超时处理:主动检查超时,避免线程被kill后状态错误
- 分片均衡:合理设计分片策略,避免数据倾斜
- 监控告警:配置完善的监控告警,及时发现和处理问题
九、思考题
-
如果你的任务需要处理上亿条数据,如何设计分片策略?如何保证任务的可恢复性(执行到一半失败后,能从断点继续)?
-
XXL-JOB的调度中心使用Quartz实现,Quartz是基于数据库锁实现的集群调度。如果数据库性能成为瓶颈,你会如何优化?是否可以考虑使用Redis实现分布式锁?
-
对于金融场景的对账任务,除了金额一致性,还需要检查哪些维度?如何设计对账规则和差异处理流程?
十、个人观点
在我参与过的多个项目中,定时任务最常见的误区是:忽视幂等性设计。
很多开发同学觉得"定时任务每天执行一次,不会重复"。但实际上,任务失败重试、手动触发、调度异常等情况都可能导致重复执行。如果任务没有幂等性设计,重复执行可能导致数据错误、重复发送通知等严重问题。
我的建议是:所有定时任务都应该设计为幂等的。可以通过以下方式实现:
- 唯一标识:为每次执行生成唯一标识,已执行的直接跳过
- 状态检查:检查数据状态,已处理的直接跳过
- 去重表:记录已处理的数据ID,重复执行时过滤
另一个误区是:忽视任务监控。很多团队配置了任务,就以为万事大吉,直到业务出问题才发现任务早就失败了。建议从项目初期就建立完善的监控体系:
- 执行日志:记录每次执行的详细信息
- 告警通知:任务失败时及时告警
- 定期检查:定期检查任务执行情况,发现异常
最后,XXL-JOB虽然功能强大,但也有其局限性。对于复杂的任务依赖(如DAG依赖),XXL-JOB的工作流功能相对简单。如果业务场景复杂,可以考虑使用Airflow、DolphinScheduler等专业的工作流调度平台。
作者:架构实战系列 | 字数:约5200字