若依定时任务 + Quartz:跨系统数据同步的设计与实现
"每天凌晨自动同步 ERP 工单,每 30 分钟推送一次生产进度"——后台系统中,定时任务是沉默的骨架。这篇文章从若依的 Quartz 方案出发,讲清楚定时任务的创建、调度、监控,以及跨系统同步中最容易忽略的问题。
一、定时任务在业务中的真实角色
我们的 MES 系统中,定时任务承担了这些职责:
| 任务 | 频率 | 说明 |
|---|---|---|
| ERP 工单同步 | 每天 6:00 | 从客户的 SQL Server 拉取新工单 |
| 生产进度推送 | 每 30 分钟 | 推送给车间的电子看板 |
| 超时订单标记 | 每天 0:00 | 标记逾期未完成的工单 |
| 数据库备份 | 每天 3:00 | mysqldump + 上传 OSS |
| 日志归档 | 每周日 2:00 | 7 天前的日志打压缩包 |
| 临时数据清理 | 每天 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:00 | 0 0 6 * * * | 0 0 6 * * ? |
| 每 30 分钟 | 0 30 * * * ? | 0 */30 * * * ? |
| 工作日上午 9 点 | 0 0 9 * * MON-FRI | 0 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 校验 | 日在字段和星期不能同时为 * |
| 增量同步 | lastId 或 lastSyncTime 标记 |
| 异常处理 | 捕获 + 记录日志 + 钉钉告警 |
| 超时保护 | Future.get(timeout) |
| 错过执行 | 同步类用补偿策略,报表类用跳过策略 |
| 并发控制 | @DisallowConcurrentExecution |
定时任务的本质不是"定时",是"可靠"——在指定的时间,把事做了,做没做都得留证据。
如果你也在独立开发产品,或者对制造业数字化感兴趣,欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验,也包括踩过的坑。
一个人的产品之路,不孤单。👇
原创作者 MqCode(全栈开发者,印刷包装行业 MES+CRM 系统独立开发),欢迎自由转发。
更多推荐



所有评论(0)