两个定时任务,你真要给 Quartz 建十一张表?

用 Spring 自带的 @Scheduled 做定时任务,本地一般都正常。一上多实例就不一样了。同一个任务,两台机器各跑一遍,消息发重,数据也对不上。翻日志翻半天,才想起来:@Scheduled 没有集群这回事。每个实例自己触发自己的,旁边那台它看不见。

很多人下一上去就是 Quartz。打开集群模式文档,先建表:QRTZ_JOB_DETAILSQRTZ_TRIGGERSQRTZ_CRON_TRIGGERSQRTZ_FIRED_TRIGGERS,数一下十一张,配置也不少。项目里本来就两个定时任务,铺十一张表,我觉得过了。

db-scheduler 只要一张表,能在集群里跑,嵌进现有 Spring Boot 就行。

db-scheduler 是什么

Gustav Karlsson 写的 Java 调度库,持久化的。他自己的说法:受 ScheduledExecutorService 启发,比 Quartz 简单,集群里也能用。

多个实例同时跑,某次执行只会被其中一个抢到。互斥走数据库乐观锁,或者 select for update。ZooKeeper、Redis 都不用接。调度信息和执行状态在库里,进程重启不会丢,机器挂了也不会丢。

元数据全在一张 scheduled_tasks 里。它是个库,跑在你应用进程里。官方压测大约每秒 2000 到 10000 次执行(Postgres,4 个实例)。核心库只依赖 slf4j

两种任务。周期的:每小时一次,每天 3 点一次。一次性的:5 分钟后跑,或者业务事件之后延迟执行。Spring Task 只管周期。一次性延迟,以前要么上消息队列,要么自己拿 ScheduledExecutorService 写。

跟 Spring Task、Quartz 比,差在哪

维度 Spring Task(@Scheduled Quartz db-scheduler
持久化 无,重启即丢
集群互斥 无,需要额外方案 支持,但配置较重 原生支持
所需数据表 0 11 张(QRTZ_* 1 张
一次性延迟任务 不支持 支持 支持
部署形态 嵌入应用 嵌入应用 嵌入应用
上手成本 最低 较高

@Scheduled 单实例时够用。上了集群,锁得自己想办法。

ShedLock 是给 @Scheduled 配的分布式锁。任务还是 Spring Task 跑,锁另外管。只是不想让定时任务在集群里跑重,它够轻。任务持久化和一次性延迟,它不管。

Quartz 触发器、日历排除、任务分组、失败重试,该有的都有。Java 生态里当了很多年标准答案。代价是十一张表,以及 JobDetail / Trigger / Scheduler 三层概念。JobStore 对事务隔离级别还有要求。db-scheduler 的 FAQ 写过,它一开始就是给 schema 不大的应用用的。中小项目为两个 cron 塞十一张表,不划算。

要报警、多团队共用一套任务平台,去看 XXL-JOB。那是调度平台,得单独部署调度中心。项目还在扁平快阶段,这笔账算不过来。只是想打开页面看任务跑没跑,不必为此再养一套。

为什么适合「扁平快」的项目

扁平快,我指的是栈不想铺、部署链路短、人不多、发版勤。定时任务这种需求,不想再养一个中间件。

db-scheduler 跟着 Spring Boot 一起启停,没有独立进程要盯,也不用单独申请容器。一张表,字段不到十五个。任务写成 Spring Bean,注册和抢锁交给 starter。

已经在用 Spring Boot 的中小型后端,接入成本不高。集群不会跑重,也能按订单、按用户去排一次性延迟任务。

快速上手

下面按 Spring Boot 4.x 写。3.x 把 starter 的 artifactId 换成 db-scheduler-spring-boot-starter,其余一样。db-scheduler 从 16.x 起要求 Java 17+,先确认项目的 JDK。

引入依赖

1
2
3
4
5
<dependency>
<groupId>com.github.kagkarlsson</groupId>
<artifactId>db-scheduler-spring-boot-4-starter</artifactId>
<version>16.12.0</version>
</dependency>

建表

官方仓库给主流数据库都备了 DDL,这里用 MySQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
create table scheduled_tasks (
task_name varchar(100) not null,
task_instance varchar(100) not null,
task_data blob,
execution_time datetime(6) not null,
picked BOOLEAN not null,
picked_by varchar(50),
last_success datetime(6) null,
last_failure datetime(6) null,
consecutive_failures INT,
last_heartbeat datetime(6) null,
version BIGINT not null,
priority SMALLINT,
PRIMARY KEY (task_name, task_instance),
INDEX execution_time_idx (execution_time),
INDEX last_heartbeat_idx (last_heartbeat),
INDEX priority_execution_time_idx (priority desc, execution_time asc)
);

MySQL 和 MariaDB 用的是不带时区的 TIMESTAMP / DATETIME。官方要求这两种库必须开 .alwaysPersistTimestampInUTC(),跨时区部署时执行时间会偏。Spring Boot starter 的默认属性没暴露这个开关,自己注册一个 DbSchedulerCustomizer Bean。几行代码,官方仓库有示例。

基础配置

1
2
3
4
5
6
7
db-scheduler:
enabled: true
table-name: scheduled_tasks
polling-interval: 10s
threads: 10
heartbeat-interval: 5m
immediate-execution-enabled: false

polling-interval 是去数据库扫到期任务的间隔,默认 10 秒。调度精度的下限就是这个数。它不是内存里的定时器,先求可靠,不求毫秒级准。真要任务一到点就跑,打开 immediate-execution-enabledschedule() 之后调度器会提前看一眼,不用干等到下一个轮询周期。

写一个周期任务

声明成 RecurringTask 类型的 Bean,starter 启动时自己注册:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class ScheduledTasksConfig {

@Bean
public RecurringTask<Void> syncOrderStatusTask() {
return Tasks.recurring("sync-order-status", FixedDelay.ofMinutes(5))
.execute((instance, ctx) -> {
// 这里写具体的业务逻辑,比如同步订单状态
log.info("同步订单状态任务执行");
});
}
}

FixedDelay.ofMinutes(5) 是上次跑完再隔 5 分钟。要每天固定点,换成 Schedules.cron("0 0 3 * * ?")Schedules.daily(LocalTime.of(3, 0))

写一个一次性任务

先定义执行逻辑,再用的时候排进去。

1
2
3
4
5
6
7
8
9
10
11
public static final TaskDescriptor<OrderReminderData> ORDER_REMINDER_TASK =
TaskDescriptor.of("order-reminder", OrderReminderData.class);

@Bean
public OneTimeTask<OrderReminderData> orderReminderTask() {
return Tasks.oneTime(ORDER_REMINDER_TASK)
.execute((instance, ctx) -> {
OrderReminderData data = instance.getData();
log.info("发送订单提醒,orderId={}", data.orderId);
});
}

业务代码里触发调度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
@RequiredArgsConstructor
public class OrderService {

private final SchedulerClient schedulerClient;

public void createOrder(Long orderId) {
// ... 创建订单的业务逻辑

// 30 分钟后检查一次,未支付则提醒
schedulerClient.schedule(
ScheduledTasksConfig.ORDER_REMINDER_TASK
.instance(String.valueOf(orderId))
.data(new OrderReminderData(orderId))
.scheduledTo(Instant.now().plusSeconds(1800))
);
}
}

SchedulerClient 是 starter 装配好的 Bean,注入就能用,不用自己构建 Scheduler

下单后 30 分钟检查支付,没付就提醒。@Scheduled 很难写成「每个订单各自延迟 30 分钟」。上延迟消息又要多一个中间件。一次性任务正好干这个。

想看界面:db-scheduler-ui

db-scheduler 自己没有控制台。官方 README 把 db-scheduler-ui 列在第三方扩展里,Bekk 做的,嵌在你的 Spring Boot 应用里。

页面上能看到 Scheduled、Running、Failed。失败了可以点重跑,也能删任务。

Spring Boot 4 加这个 starter。

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>no.bekk.db-scheduler-ui</groupId>
<artifactId>db-scheduler-ui-spring-boot-4-starter</artifactId>
<version>5.0.0</version>
</dependency>


```yaml
db-scheduler-ui:
history: true
log:
enabled: true

5.x 起日志写入已经打进 UI starter,不用再单独引 db-scheduler-log

生产环境把 /db-scheduler/db-scheduler-api 用 Spring Security 挡住。只给看、不给点,开 db-scheduler-ui.read-only=true

多团队和独立报警后台,那是调度平台的事。这个 UI 只管看自己应用里的任务。