"每天凌晨自动同步 ERP 工单,每 30 分钟推送一次生产进度"——后台系统中,定时任务是沉默的骨架。这篇文章从若依的 Quartz 方案出发,讲清楚定时任务的创建、调度、监控,以及跨系统同步中最容易忽略的问题。


一、定时任务在业务中的真实角色

我们的 MES 系统中,定时任务承担了这些职责:

任务频率说明
ERP 工单同步每天 6:00从客户的 SQL Server 拉取新工单
生产进度推送每 30 分钟推送给车间的电子看板
超时订单标记每天 0:00标记逾期未完成的工单
数据库备份每天 3:00mysqldump + 上传 OSS
日志归档每周日 2:007 天前的日志打压缩包
临时数据清理每天 4:00清理验证码、临时 Token

这些任务的共同特点:不需要人操作、不能出错、错了要及时告警。


二、若依的 Quartz 集成

若依框架对 Quartz 做了以下封装:

组件职责
SysJob 实体数据库存任务配置(类名、cron 表达式、参数)
QuartzDisallowConcurrentExecution禁止并发执行(若依默认)
AbstractQuartzJob任务基类,统一日志和异常处理
SysJobController任务管理页面(增删改查 + 手动触发)
JobInvokeUtil反射调用目标方法

2.1 数据库设计

-- sys_job 表核心字段
CREATE TABLE sys_job (
    job_id          BIGINT PRIMARY KEY,
    job_name        VARCHAR(64),       -- 任务名称
    job_group       VARCHAR(64),       -- 任务组名
    invoke_target   VARCHAR(500),      -- 调用目标字符串
    cron_expression VARCHAR(255),      -- Cron 表达式
    misfire_policy  VARCHAR(20),       -- 错过执行策略
    concurrent      CHAR(1),           -- 是否允许并发
    status          CHAR(1)            -- 状态(0暂停 1正常)
);

2.2 invoke_target 的设计

若依用字符串来指定任务要调哪个方法:

// 调用 Spring Bean 的方法
ryTask.ryMultipleParams('ry', true, 2024)
      ↑ Bean名  ↑ 方法名    ↑ 参数列表

// 调用类的静态方法
com.ruoyi.quartz.task.RyTask.ryNoParams
                          ↑ 类全限定名    ↑ 方法名

这个设计的巧妙之处:不需要写 Java 代码就能新增定时任务——在管理页面填表单就行。


三、创建一个真实的同步任务

3.1 编写任务类

// ErpSyncTask.java
@Component("erpSyncTask")
public class ErpSyncTask {

    @Autowired
    private IErpOrderService erpOrderService;

    @Autowired
    private ITaskLogService taskLogService;

    /**
     * 每天 6:00 从 ERP 同步工单
     */
    public void syncOrders() {
        TaskLog taskLog = taskLogService.startLog("ERP工单同步");

        try {
            // 读取最后同步时间
            LocalDateTime lastSyncTime = getLastSyncTime();

            // 增量拉取
            List<ErpOrder> orders = erpOrderService.syncNewOrders(lastSyncTime);

            // 转换 + 入库
            int success = 0;
            int fail = 0;

            for (ErpOrder order : orders) {
                try {
                    erpOrderService.saveToMes(order);
                    success++;
                } catch (Exception e) {
                    log.error("工单 {} 同步失败", order.getOrderNo(), e);
                    fail++;
                }
            }

            // 记录日志
            taskLogService.finishLog(taskLog, success, fail, orders.size());

            // 如果有失败的,发钉钉通知
            if (fail > 0) {
                dingTalkService.sendMessage(String.format(
                    "ERP 同步完成,成功 %d 条,失败 %d 条,请检查",
                    success, fail
                ));
            }

        } catch (Exception e) {
            log.error("ERP 同步任务异常", e);
            taskLogService.failLog(taskLog, e.getMessage());
            dingTalkService.sendMessage("ERP 同步任务执行异常:" + e.getMessage());
        }
    }

    private LocalDateTime getLastSyncTime() {
        // 从 Redis 或数据库读取上次同步时间
        String key = "erp:last_sync_time";
        String time = redisTemplate.opsForValue().get(key);
        return time == null
            ? LocalDateTime.now().minusDays(1)
            : LocalDateTime.parse(time);
    }
}

3.2 在管理页面配置

若依的「系统监控 → 定时任务」页面,新建一个任务:

字段
任务名称ERP工单同步
任务组名SYNC
调用目标字符串erpSyncTask.syncOrders()
Cron 表达式0 0 6 * * ?
是否并发不允许

保存后点击"执行一次"先跑一把,确认没问题再开启自动调度。


四、Cron 表达式的正确写法

秒  分  时  日  月  周
0   0   6   *   *   ?       每天 6:00
0   */30 *   *   *   ?      每 30 分钟
0   0   0   *   *   ?       每天 0:00
0   0   3   *   *   ?       每天 3:00
0   0   2   ?   *   SUN     每周日凌晨 2:00

容易写错的几个例子:

意图错误写法正确写法
每天 6:000 0 6 * * *0 0 6 * * ?
每 30 分钟0 30 * * * ?0 */30 * * * ?
工作日上午 9 点0 0 9 * * MON-FRI0 0 9 ? * MON-FRI

Cron 七段中,**日和星期不能同时为 ***,否则 Quartz 不知道该判断哪一个。


五、三个生产环境必做配置

5.1 错过执行策略(MisfirePolicy)

定时任务因服务重启或宕机错过了执行时间怎么办?

策略行为
立即补偿MISFIRE_INSTRUCTION_IGNORE_MISFIRES错过了立刻补跑一次
等下一个周期MISFIRE_INSTRUCTION_DO_NOTHING错过了就算了
触发起跑MISFIRE_INSTRUCTION_FIRE_AND_PROCEED(默认)错过了马上跑,然后恢复 cron

推荐:数据同步类任务用"立即补偿"(数据不能丢),报表生成类用"等下一个周期"(没人在乎)。

5.2 禁止并发执行

// 若依默认继承自 AbstractQuartzJob,已做了禁止并发
@DisallowConcurrentExecution
public class AbstractQuartzJob implements Job {
    // ...
}

如果上一个任务没跑完、下一个调度时间又到了,Quartz 会等上一个跑完再跑下一个,不会重叠。

5.3 统一日志记录

// SysJobLog 表:每次任务执行都有一条记录
sys_job_log:
├── job_name     "ERP工单同步"
├── invoke_target "erpSyncTask.syncOrders()"
├── status       "0"(成功)/ "1"(失败)
├── start_time   执行开始时间
├── stop_time    执行结束时间
├── job_message  执行日志(成功条数、失败原因)
└── exception_info 异常堆栈(失败时)

在若依管理后台的「定时任务日志」可以查到每一次执行的结果。


六、跨系统同步的三个实战技巧

6.1 增量同步标记

// 用一张 sync_log 表做增量标记
public class SyncService {

    public void incrementalSync() {
        // 1. 读取上次同步的最大 ID
        Long lastId = syncLogMapper.getLastSyncedId("erp_order");
        
        // 2. 查询大于 lastId 的记录
        List<ErpOrder> newOrders = erpMapper.selectByGtId(lastId);
        
        // 3. 逐条处理
        for (ErpOrder order : newOrders) {
            try {
                save(order);
            } catch (Exception e) {
                // 记录失败,下轮重试
                syncLogMapper.logFail(order.getId(), e.getMessage());
            }
        }
        
        // 4. 更新标记
        if (!newOrders.isEmpty()) {
            syncLogMapper.updateLastId(
                "erp_order",
                newOrders.get(newOrders.size() - 1).getId()
            );
        }
    }
}

6.2 超时自动杀死

有些同步任务会卡住(比如对方数据库挂了),需要设置超时:

public void syncWithTimeout() {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<?> future = executor.submit(() -> {
        try {
            doSync();
        } catch (Exception e) {
            log.error("同步异常", e);
        }
    });

    try {
        future.get(10, TimeUnit.MINUTES);  // 最多等 10 分钟
    } catch (TimeoutException e) {
        future.cancel(true);
        log.error("同步任务超时,已强制终止");
    } finally {
        executor.shutdownNow();
    }
}

6.3 失败重试 + 告警

@Component
public class RetryableSyncTask {

    @Value("${sync.max-retry:3}")
    private int maxRetry;

    public void execute() {
        int retryCount = 0;
        Exception lastException = null;

        while (retryCount < maxRetry) {
            try {
                doSync();
                return;  // 成功,退出
            } catch (Exception e) {
                retryCount++;
                lastException = e;
                log.warn("同步失败,第 {} 次重试", retryCount);
                Thread.sleep(2000L * retryCount);  // 递增等待
            }
        }

        // 重试耗尽,发告警
        String msg = String.format(
            "同步任务在 %d 次重试后仍然失败,请检查。异常:%s",
            maxRetry, lastException.getMessage()
        );
        dingTalkService.sendAlert(msg);
    }
}

七、Quartz 配置调优

# application.yml
spring:
  quartz:
    job-store-type: jdbc          # 用数据库存储(支持集群)
    jdbc:
      initialize-schema: never    # 用若依自带的 quartz 表
    properties:
      org:
        quartz:
          scheduler:
            instanceName: RuoYiScheduler
            instanceId: AUTO
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10        # 线程池大小
            threadPriority: 5
          jobStore:
            class: org.springframework.scheduling.quartz.LocalDataSourceJobStore
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true       # 开启集群模式
            clusterCheckinInterval: 15000

线程池 10 个线程跑定时任务够不够?看实际情况——如果同步任务有 3 个、报表 2 个、清理 2 个,10 个绰绰有余。


八、总结

关键点做法
任务类设计独立 Bean,单一职责
配置方式管理页面填写,不需要写代码
Cron 校验日在字段和星期不能同时为 *
增量同步lastIdlastSyncTime 标记
异常处理捕获 + 记录日志 + 钉钉告警
超时保护Future.get(timeout)
错过执行同步类用补偿策略,报表类用跳过策略
并发控制@DisallowConcurrentExecution

定时任务的本质不是"定时",是"可靠"——在指定的时间,把事做了,做没做都得留证据。


如果你也在独立开发产品,或者对制造业数字化感兴趣,欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验,也包括踩过的坑。

一个人的产品之路,不孤单。👇

原创作者 MqCode(全栈开发者,印刷包装行业 MES+CRM 系统独立开发),欢迎自由转发。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐