揭秘xxl-job:从高可用到调度一致性
在快节奏的互联网世界里,每一项业务都像一支高速运转的股票,需要精准的交易策略和风控系统来支撑。而我们的任务调度系统,就如同股市里的"基金经理",负责在正确的时点(收盘、清算、结算),执行正确的操作(发送账单、生成报表、数据同步),确保每一笔"交易"都精准无误。
想象一下,如果这位"基金经理"只有一个,一旦他离岗或身体不适,整个盘面就会陷入停滞,业务将面临巨大的风险。在技术世界里,这就是我们常说的单点故障(Single Point of Failure) 。为了防止"崩盘",我们需要构建一个由多位"基金经理"组成的团队,让他们相互备份、协同工作,确保无论何时都能稳健地管理好每一笔交易。
这,正是分布式调度系统的核心价值所在。我们将深入探讨其中的几个关键概念:分布式与高可用(HA) 、Admin 多节点部署 、调度一致性保障 以及执行器高可用机制。理解这些,是构建一个健壮、可靠的分布式调度系统的基石,确保你的业务"股票"持续健康地增长。
1. 分布式与高可用(HA):为什么不能单打独斗?
单机系统最大的风险在于单点故障(Single Point of Failure, SPOF) 。一旦这唯一的节点宕机,整个系统就会瘫痪。这就好比一个公司只靠一个总调度员来管理所有生产线,一旦他缺席,所有生产都会停滞。
为了解决这个问题,我们引入了分布式和**高可用(HA)**的设计理念。
- 分布式:将系统的功能分散部署在多个节点上,让不同的机器承担不同的角色或相同的角色,从而避免所有鸡蛋放在一个篮子里的风险。
- 高可用(HA) :通过冗余(Redundancy)来消除单点故障。当某个节点发生故障时,其他健康的节点能迅速接管其工作,确保服务不中断。
Admin 多节点部署和执行器高可用,正是这两种思想在调度系统中的具体实践。
2. Admin 多节点部署:打造"不倒翁"调度中心
Admin 节点是调度系统的"大脑",负责任务的调度、管理和监控。如果只有一个 Admin 节点,那它就是整个系统的最大风险点。
Admin 多节点部署 的核心原理在于,将多个 Admin 服务部署成一个集群。这些节点不依赖本地存储,而是共享一个中心化的数据库(通常是 MySQL)。
- 如何实现高可用? 当一个 Admin 节点宕机后,其他节点可以继续从共享数据库中读取任务信息,并承担起调度工作,保证调度服务不会中断。
- 如何实现数据同步? 所有 Admin 节点都读写同一个数据库。任务信息、执行日志、注册的执行器列表等所有关键数据都集中存储在此。这就像一个团队成员都共享同一个项目管理文档,确保信息一致。
在 XXL-JOB 的配置文件 application.properties
中,所有 Admin 节点都配置相同的数据库连接信息,指向同一个 MySQL 实例。
yml
### database
spring.datasource.url=jdbc:mysql://db-host:3306/xxl-job?serverTimezone=Asia/Shanghai
spring.datasource.username=root
spring.datasource.password=password
3. 调度一致性保障:如何避免"重复派单"?
当 Admin 部署了多个节点后,一个新的问题出现了:如何确保多个节点不会同时调度同一个任务?
如果一个每分钟执行一次的任务,在整点到来时,两个 Admin 节点都发现它需要被执行,并同时尝试调度,这会导致任务被重复触发,造成业务逻辑混乱。
为了解决这个问题,分布式调度系统引入了分布式锁 机制,确保在同一时间只有一个 Admin 节点能够对某个任务进行调度。在 XXL-JOB 中,这个锁是利用数据库的行级锁来实现的。
源码:XXL-JOB 的调度线程会定期扫描数据库中需要执行的任务。在每次扫描并处理之前,它会尝试获取一个数据库锁。只有成功获取锁的线程才能继续,否则会跳过本次调度,从而避免重复执行。
Java
// JobScheduleHelper.java
public void start() {
// ...
// 调度线程循环
while (!scheduleThreadToStop) {
Connection conn = null;
try {
conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
conn.setAutoCommit(false); // 开启事务
// 核心锁逻辑:尝试对名为 'schedule_lock' 的记录加行级锁
preparedStatement = conn.prepareStatement("select * from xxl_job_lock where lock_name = 'schedule_lock' for update");
preparedStatement.execute();
// 如果成功获取锁,继续执行调度任务的逻辑
// ...
conn.commit(); // 提交事务,释放锁
} catch (Throwable e) {
// ... 异常处理,回滚事务 ...
} finally {
// ... 释放资源 ...
}
// ...
}
}
- 加锁竞争 :当任务需要被调度时,所有 Admin 节点都会尝试执行一个
SELECT ... FOR UPDATE
语句来锁定调度资源。 - 原子性保障:由于数据库锁的特性,只有一个 Admin 节点能成功获取锁。这个节点会继续执行后续的调度逻辑,其他未获取到锁的节点会放弃本次调度,从根本上解决了重复调度的问题。
4. 执行器高可用机制:任务执行的"B计划"
解决了 Admin 节点的单点故障和调度一致性后,我们还需要关注任务执行端。如果一个任务已经下发给了执行器,但执行器在执行过程中突然宕机了,该怎么办?
为了保障执行器的高可用性,系统通常会结合以下两种机制:
- 心跳机制 :Admin 节点会周期性地向所有已注册的执行器发送心跳请求。如果连续多次没有收到某个执行器的响应,Admin 就会认为该执行器已宕机。
- 故障转移(Failover) :这是在发现执行器故障后的补救措施。一旦 Admin 发现某个正在执行任务的执行器宕机,它会立即将该任务标记为失败,并根据预设的策略(如重试次数),将任务重新调度到集群中另一个健康的执行器上。
执行器的注册与发现机制是实现这一点的基础。每个执行器启动时,都会向 Admin 报告自己的地址、负载等信息。Admin 维护着一个健康的执行器列表,当需要进行故障转移时,可以从这个列表中选择一个合适的"替补"来接管任务。
4.1 故障检测:谁来判断执行器是否存活?
故障检测是在 Admin 端完成的。Admin 端会有一个后台监控线程,通过定时扫描数据库来判断执行器的存活状态。
源码 :故障检测的逻辑主要在 com.xxl.job.admin.core.thread.JobRegistryHelper
类中。
Java
// JobRegistryHelper.java
public void start() {
registryMonitorThread = new Thread(() -> {
while (!toStop) {
try {
// 1. 查询数据库中,心跳超时的"死亡"执行器
List<Integer> ids = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao()
.findDead(RegistryConfig.DEAD_TIMEOUT, new Date());
// 2. 将超时的执行器从注册表中移除
if (ids != null && ids.size() > 0) {
XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().removeDead(ids);
}
} catch (Throwable e) {
// ...
}
// 线程休眠,等待下一次检测
try {
TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
} catch (Throwable e) {
// ...
}
}
});
// ...
}
4.2 任务重新调度:失败或宕机后如何处理?
任务的重新调度同样是在 Admin 端 完成的,它会监控两种情况:任务执行失败 和 执行器宕机导致的任务卡死。
源码 :重新调度的核心逻辑位于 com.xxl.job.admin.core.thread.JobFailMonitorHelper
类中。
Java
// JobFailMonitorHelper.java (简化版)
public void start() {
monitorThread = new Thread(() -> {
while (!toStop) {
try {
// 1. 查询数据库,找出所有执行失败的任务日志(包括主动上报失败和因宕机而卡死的任务)
List<Long> failLogIds = xxlJobLogDao.findFailJobLogIds(1000);
if (failLogIds != null && !failLogIds.isEmpty()) {
for (long failLogId : failLogIds) {
XxlJobLog log = xxlJobLogDao.load(failLogId);
// 2. 如果任务配置了失败重试次数
if (log.getExecutorFailRetryCount() > 0) {
// 3. 核心:调用触发器,将任务重新调度
JobTriggerPoolHelper.trigger(log.getJobId(), TriggerTypeEnum.RETRY, (log.getExecutorFailRetryCount() - 1), log.getExecutorShardingParam(), log.getExecutorParam(), null);
// 4. 更新日志,记录重试信息
log.setTriggerMsg(log.getTriggerMsg() + "<br><br><span style="color:#F39C12;" > >>>>>>>>>>> Retry Trigger... <<<<<<<<<<< </span><br>");
xxlJobLogDao.updateTriggerInfo(log);
}
}
}
} catch (Throwable e) {
// ...
}
// 线程休眠,等待下一次扫描
try {
TimeUnit.SECONDS.sleep(10);
} catch (Throwable e) {
// ...
}
}
});
// ...
}
5. 执行器心跳:如何向 Admin 报告存活?
执行器端的心跳逻辑封装在一个独立的线程中,它会定期向 Admin 发送注册请求,从而实现"心跳续约"。
源码 :执行器心跳逻辑位于 com.xxl.job.core.thread.ExecutorRegistryThread
。
Java
// ExecutorRegistryThread.java (简化版)
public void start(final String appname, final String address) {
registryThread = new Thread(() -> {
// 循环执行,直到停止
while (!toStop) {
try {
// 构建注册/心跳参数
RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
// 遍历所有 Admin 地址,发送注册/心跳请求
for (AdminBiz adminBiz : XxlJobExecutor.getAdminBizList()) {
ReturnT<String> registryResult = adminBiz.registry(registryParam);
if (registryResult != null && ReturnT.SUCCESS_CODE == registryResult.getCode()) {
// 只要有一个 Admin 接收成功,就跳出循环
break;
}
}
} catch (Throwable e) {
// ...
}
// 睡眠,等待下一次心跳,默认30秒
try {
TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
} catch (Throwable e) {
// ...
}
}
// ... 线程停止时,发送注销请求 ...
});
registryThread.setDaemon(true);
registryThread.setName("xxl-job, executor ExecutorRegistryThread");
registryThread.start();
}
总结
构建一个健壮的分布式调度系统,不仅仅是将单机应用简单地扩展到多机,更需要深入思考和解决分布式环境下的种种挑战。通过 Admin 多节点部署来消除单点故障 ,利用数据库锁来保障调度一致性 ,以及通过心跳、故障检测和自动重试机制来保证执行器高可用,这些技术共同构筑了一个可靠、高效的任务调度体系,为业务的稳定运行保驾护航。