分类:架构与高并发 · 作者:管理员 · 发布于 2026-09-24 · 阅读 0 · 标签:Spring Boot / 定时任务 / XXL-JOB / 分布式调度 / 高可用
一、痛点:@Scheduled 够用吗?
大多数 Spring Boot 项目起步时都用 @Scheduled 跑定时任务——写个注解、配个 cron 表达式,搞定。但当你做电商中台、一个服务部署多个节点时,噩梦来了:
- 重复执行:两台实例同时执行同一任务,库存扣两次、消息发两条
- 无容错:节点挂了,任务直接丢失,没有自动转移
- 无执行记录:任务跑了多久、成功还是失败、下次触发时间——全是黑盒
- 无分片:处理 100 万条数据时,一台机器跑死,其他机器闲着
本文从单机 @Scheduled 开始,逐步演进到基于 XXL-JOB 的弹性分布式调度方案,给出每一步的代码实现与生产踩坑记录。
二、方案一:@Scheduled + 数据库分布式锁(过渡方案)
在不引入新中间件的前提下,最简单的分布式互斥方案是数据库行锁 + 任务表:
@Component
public class ScheduleLockAspect {
@Autowired
private JdbcTemplate jdbcTemplate;
@Around("@annotation(scheduled)")
public Object around(ProceedingJoinPoint pjp, Scheduled scheduled) throws Throwable {
String taskName = pjp.getSignature().getDeclaringTypeName() + "." + pjp.getSignature().getName();
// 尝试获取锁(插入或更新一条记录,唯一键做互斥)
int updated = jdbcTemplate.update(
"UPDATE task_lock SET locked_at = NOW(), node_id = ? " +
"WHERE task_name = ? AND (locked_at IS NULL OR locked_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE))",
getNodeId(), taskName
);
if (updated == 0) {
log.info("任务 {} 已被其他节点持有,跳过本次执行", taskName);
return null;
}
try {
return pjp.proceed();
} finally {
jdbcTemplate.update("UPDATE task_lock SET locked_at = NULL WHERE task_name = ?", taskName);
}
}
}
优缺点:零依赖、实现简单,但数据库是单点,高并发下锁竞争大,且释放锁的非原子性可能导致死锁。
三、方案二:Redis 分布式锁 + Redisson(改进方案)
用 Redisson 的 RLock 替代数据库锁,利用 Redis 的 TTL 自动释放,规避死锁:
@Component
public class RedisScheduledExecutor {
@Autowired
private RedissonClient redissonClient;
private static final String TASK_LOCK_PREFIX = "schedule:lock:";
public void executeWithLock(String taskName, long leaseTime, TimeUnit unit, Runnable task) {
RLock lock = redissonClient.getLock(TASK_LOCK_PREFIX + taskName);
boolean acquired = false;
try {
// waitTime=0 表示拿不到就放弃,不阻塞
acquired = lock.tryLock(0, leaseTime, unit);
if (!acquired) {
log.info("任务 {} 被其他节点持有,跳过", taskName);
return;
}
task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
痛点依然存在:没有执行日志、失败告警、分片能力、任务编排。当微服务规模超过 5 个节点,这些缺失会变成运维灾难。
四、方案三:XXL-JOB 弹性分布式调度(生产方案)
4.1 架构概述
XXL-JOB 由调度中心(Admin)和执行器(Executor)组成:
- 调度中心:负责任务的 cron 管理、触发、日志、告警,无状态可集群部署
- 执行器:业务服务嵌入 XXL-JOB 客户端,注册到调度中心,接收任务回调
- 路由策略:支持轮询、随机、故障转移、分片广播等 10+ 种策略
4.2 Spring Boot 集成执行器
<!-- pom.xml -->
<dependency>
<groupId>com.xuxueli</groupId>
<artifactId>xxl-job-core</artifactId>
<version>2.4.2</version>
</dependency>
# application.yml
xxl:
job:
admin:
addresses: http://admin-scheduler.example.com:8080/xxl-job-admin
executor:
appname: order-job-executor
port: 9999
logpath: /srv/app/logs/xxl-job/jobhandler
logretentiondays: 30
@Configuration
public class XxlJobConfig {
@Value("${xxl.job.admin.addresses}")
private String adminAddresses;
@Value("${xxl.job.executor.appname}")
private String appName;
@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() {
XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
executor.setAdminAddresses(adminAddresses);
executor.setAppName(appName);
executor.setPort(port);
executor.setLogPath(logPath);
executor.setLogRetentionDays(logRetentionDays);
return executor;
}
}
4.3 编写任务 Handler
@Component
public class OrderTimeoutHandler extends IJobHandler {
@Autowired
private OrderService orderService;
@Override
public ReturnT<String> execute(String param) throws Exception {
XxlJobHelper.log("开始处理超时订单取消任务,参数: {}", param);
// 分片参数:当前分片索引和总分片数
int shardIndex = XxlJobHelper.getShardIndex();
int shardTotal = XxlJobHelper.getShardTotal();
// 按订单 ID 取模分片,每个节点只处理自己的那部分
List<Order> timeoutOrders = orderService.queryTimeoutOrders(shardIndex, shardTotal);
int successCount = 0;
for (Order order : timeoutOrders) {
try {
orderService.cancelOrder(order.getId());
successCount++;
} catch (Exception e) {
XxlJobHelper.log("取消失败 orderId={}: {}", order.getId(), e.getMessage());
}
}
XxlJobHelper.log("本次执行完成,成功取消 {} 笔订单", successCount);
return ReturnT.SUCCESS;
}
}
五、生产级踩坑记录
- 执行器端口冲突:同一台机器部署多个服务用了相同执行器端口,导致注册失败。解决:为每个服务分配独立端口或用随机端口
port: 0,但随机端口在容器重启后 IP 不变端口变,调度中心会缓存旧端口——解决方案是用固定端口 + 服务名做唯一标识。 - 分片不均导致数据倾斜:用
id % shardTotal分片时,如果业务 ID 分布不均(比如某些范围 ID 缺失),部分节点几乎空跑。优化:改用 ID 范围分段 + 动态负载,每个节点先抢一段 ID 范围,处理完后继续抢下一段。 - 调度中心单点:XXL-JOB Admin 默认内嵌了 H2 数据库,部署单节点时调度中心挂 = 所有任务停摆。生产必须:Admin 集群 + MySQL 共享存储 + Nginx 反向代理。
- 日志清理:XXL-JOB 默认不会自动清理执行日志,运行半年后日志表可达数 G,影响调度性能。配置
xxl.job.logretentiondays并结合定时清理任务。 - 任务阻塞策略:一个长时间任务(如批量数据同步)执行超过 cron 间隔时,默认策略是「丢弃后续调度」,如果不留意会导致业务漏跑。按场景配置为「单机串行」或「覆盖上一次」。
六、最佳实践总结
- 小规模(≤2 节点):@Scheduled + Redis 分布式锁够用,简单维护成本低
- 中规模(3-10 节点):XXL-JOB 执行器嵌入业务服务,调度中心集群部署,推荐路由策略「故障转移」
- 大规模(10+ 节点):XXL-JOB 分片广播 + 动态分片算法,结合业务分库分表取模
- 任务治理:所有定时任务必须有超时阈值、失败重试次数上限、告警通知(钉钉/邮件)
- 灰度发布:上线的定时任务先切 10% 流量跑新版 handler,观察日志无误再全量
定时任务看似简单,但一旦规模上来就是典型的分布式一致性问题。从 @Scheduled 到 XXL-JOB 的演进,本质是从「本地时钟触发」到「集中调度 + 弹性执行」的架构思维转变。下一篇可以聊聊任务依赖 DAG 编排,欢迎持续关注。