分类:架构与高并发 · 作者:管理员 · 发布于 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;
    }
}

五、生产级踩坑记录

  1. 执行器端口冲突:同一台机器部署多个服务用了相同执行器端口,导致注册失败。解决:为每个服务分配独立端口或用随机端口 port: 0,但随机端口在容器重启后 IP 不变端口变,调度中心会缓存旧端口——解决方案是用固定端口 + 服务名做唯一标识。
  2. 分片不均导致数据倾斜:用 id % shardTotal 分片时,如果业务 ID 分布不均(比如某些范围 ID 缺失),部分节点几乎空跑。优化:改用 ID 范围分段 + 动态负载,每个节点先抢一段 ID 范围,处理完后继续抢下一段。
  3. 调度中心单点:XXL-JOB Admin 默认内嵌了 H2 数据库,部署单节点时调度中心挂 = 所有任务停摆。生产必须:Admin 集群 + MySQL 共享存储 + Nginx 反向代理。
  4. 日志清理:XXL-JOB 默认不会自动清理执行日志,运行半年后日志表可达数 G,影响调度性能。配置 xxl.job.logretentiondays 并结合定时清理任务。
  5. 任务阻塞策略:一个长时间任务(如批量数据同步)执行超过 cron 间隔时,默认策略是「丢弃后续调度」,如果不留意会导致业务漏跑。按场景配置为「单机串行」或「覆盖上一次」。

六、最佳实践总结

  • 小规模(≤2 节点):@Scheduled + Redis 分布式锁够用,简单维护成本低
  • 中规模(3-10 节点):XXL-JOB 执行器嵌入业务服务,调度中心集群部署,推荐路由策略「故障转移」
  • 大规模(10+ 节点):XXL-JOB 分片广播 + 动态分片算法,结合业务分库分表取模
  • 任务治理:所有定时任务必须有超时阈值、失败重试次数上限、告警通知(钉钉/邮件)
  • 灰度发布:上线的定时任务先切 10% 流量跑新版 handler,观察日志无误再全量

定时任务看似简单,但一旦规模上来就是典型的分布式一致性问题。从 @Scheduled 到 XXL-JOB 的演进,本质是从「本地时钟触发」到「集中调度 + 弹性执行」的架构思维转变。下一篇可以聊聊任务依赖 DAG 编排,欢迎持续关注。