干掉Quartz!一个数据库表就能搞定的Java集群调度器!
别让定时任务拖垮你的集群,这套方案只用一张表!
DB-Scheduler是一个基于数据库的Java任务调度库,支持集群部署、任务持久化,只需一张数据库表即可运行,吞吐量可达每秒2000到10000次执行。
调度器这么重要的东西,为什么大家还在用十几年前的方案?
定时任务调度这件事,Java开发者绕不开。
每天凌晨三点跑对账,每小时拉一次第三方数据,用户注册五分钟后发欢迎邮件——这些事你不写代码也能想到,但真写起来全是坑。
单机跑没问题,一上集群就乱套。三个节点同时盯着同一张表,谁先抢到谁执行,抢不到的就干等着。更麻烦的是任务执行到一半节点挂了,那个任务到底是跑完了还是卡死了,没人知道。
Java生态里有个老牌选手Quartz,功能确实强,但复杂得让人头疼。光数据库表就要建11张,配置堆成山,集群部署更是噩梦。有开发者吐槽说,Quartz在高负载下大量作业会阻塞在触发器表里,执行线程根本拿不到任务。还有人反映Quartz用“抢占式”拿数据库锁,抢到锁的节点累死,抢不到的闲死,节点负载悬殊大得离谱。
这就怪了——调度器本该是基础设施,怎么越搞越复杂?
有个叫Gustav Karlsson的挪威程序员也受不了了。他在Bekk公司工作,天天跟Java“迷你服务”打交道,需要一个能在多个节点上跑、带冗余的调度器。需求其实很简单——定时发发未发出的邮件,跑跑周期性的批处理任务。但扫了一圈开源方案,没有一个在功能和简洁之间找到平衡点。
于是他干脆自己写了一个。
一张表解决所有问题,这事靠谱吗?
db-scheduler的核心思想就一句话:用数据库表来管任务。
传统的调度器把任务定义、触发器、调度状态分门别类存到不同表里,Quartz那11张表就是这么来的。db-scheduler反过来——所有任务都塞到一张表里,靠几个字段区分状态。
这张表长这样:
sql
CREATE TABLE scheduled_tasks (
task_name TEXT NOT NULL,
task_instance TEXT NOT NULL,
task_data BYTEA,
execution_time TIMESTAMP WITH TIME ZONE NOT NULL,
picked BOOLEAN NOT NULL,
picked_by TEXT,
last_success TIMESTAMP WITH TIME ZONE,
last_failure TIMESTAMP WITH TIME ZONE,
consecutive_failures INT,
last_heartbeat TIMESTAMP WITH TIME ZONE,
version BIGINT NOT NULL,
priority SMALLINT,
PRIMARY KEY (task_name, task_instance)
);
task_name是任务类型,比如“发送邮件”;task_instance是具体某次执行的唯一ID;execution_time告诉你啥时候该跑;picked标记有没有被某个节点领走;picked_by记录谁领的;last_heartbeat用来检测节点是否还活着。
就这么简单。
但事情没这么简单——一张表怎么保证集群里只有一个节点执行同一个任务?
db-scheduler用了乐观锁。多个节点同时扫描这张表,发现到期的任务就尝试更新picked字段。更新成功的那个节点拿到执行权,其他节点看到picked已经被占了就放弃。配合version字段做乐观锁控制,基本不会出现两个节点抢到同一个任务的情况。
这个设计够聪明吧?
但反过来想——如果拿到任务的节点在执行过程中挂了呢?
心跳机制听着靠谱,实际跑起来什么样?
db-scheduler有心跳机制。
节点领走任务后,每隔一段时间更新last_heartbeat字段。其他节点扫描时发现某个任务被picked了,但last_heartbeat很久没更新,就认为那个节点已经死了,自动把任务释放出来重新执行。
默认配置下,调度器每10秒扫一次数据库,心跳间隔5分钟,连续错过6次心跳就算任务死亡。
但这里有个致命的逻辑漏洞。
有开发者在实际使用中发现,任务被picked之后,如果正在执行中,心跳就不会更新。如果一个任务执行时间特别长——比如跑了10分钟——那这10分钟里其他节点看到的就是“这个任务被picked了,但心跳好像很久没更新了”。
然后呢?
然后其他节点可能判定这个任务死亡,抢过来再执行一遍。
同一个任务被执行了两次。
这事在db-scheduler的GitHub讨论区里被反复提及。官方承认,不同的轮询策略(polling strategy)对这个问题的处理方式不一样。有的策略下,任务会一直处于picked状态,其他节点只能干等着,直到心跳超时才能接手。
所以心跳机制看着靠谱,但长任务面前照样露馅。
每秒两万次执行,这个数据哪来的?
db-scheduler官方宣称吞吐量能达到每秒2000到10000次执行。
这个数字怎么测出来的?
GitHub上有个独立的性能测试项目,专门跑db-scheduler的基准测试。DoorDash的实习生还做过一轮对比实验,发现优化后的版本在四个调度器实例、每个20线程的配置下,吞吐量提升了将近27倍。
但注意前提条件——你要用lock-and-fetch轮询策略。
db-scheduler提供了两种轮询策略。一种是乐观锁方式,用version字段做冲突检测;另一种是lock-and-fetch,直接用数据库的select for update行锁。
lock-and-fetch性能更高,但不是所有数据库都支持。有团队用SQL Server跑db-scheduler,10个Pod每个8线程,总共80个线程在跑,一小时只能处理340到360个任务。每个任务耗时40到50秒。
340个任务一小时,距离每秒两万次差了好几个数量级。
数据库类型、网络延迟、任务本身的执行时长——这些变量一加进来,官方那个漂亮的数字就变得遥不可及。
动态任务调度听着很美,实际用起来呢?
db-scheduler支持三种任务类型。
简单循环任务——写死一个调度周期,比如每小时跑一次。一次性任务——在某个未来时间点跑一次,可以带自定义数据。动态循环任务——运行时动态注册,注册完了按周期一直跑。
一次性任务带数据这个功能特别实用。
比如用户下单后,你想在30分钟后发一条提醒短信。用db-scheduler的话,创建一个一次性任务,把用户ID和订单号塞进task_data字段里,设置execution_time为30分钟后,完事。到了时间调度器自动执行,handler从task_data里取出数据干活。
默认序列化用的是Java原生序列化。想用Jackson或Gson也行,需要自己配。
但动态任务有个坑——一旦注册了就停不下来。
有开发者在博客里吐槽,说他想用db-scheduler跑周期任务,但任务执行失败后的处理逻辑得自己写。失败日志要自己建表自己记。全局stop之后没法再start,报错。
官方提供的功能就到“调度和执行”为止。失败重试、死信队列、可视化监控——这些统统没有。
社区有个叫db-scheduler-ui的扩展项目,提供简单的管理界面。但功能有限,连分页查询都不支持。
生产环境真有人用吗?
有!
GitHub上列了几个名字。Digipost——挪威的电子邮箱服务商。Vy Group——北欧最大的交通集团之一;还有Wise和TOMRA。
但用量有多大、跑什么任务、有没有踩过坑——这些细节官方没公布。
DoorDash的实习生项目里提到了db-scheduler。他们用关系型数据库当任务队列,让多个调度器实例协同工作。结论是db-scheduler的数据模型更简单,扩展性比Quartz好。
但这些评价都来自特定场景。DoorDash的实习生项目毕竟是实习生项目,生产环境的真实表现还得打问号。
依赖少、上手快,但代价是什么?
db-scheduler的核心依赖只有SLF4J。加上Spring Boot Starter之后,自动配置、自动发现任务Bean,用起来跟Spring原生@Scheduled差不多。
配置也简单:
properties
db-scheduler.threads=3
db-scheduler.polling-interval=5s
但简洁是有代价的。
第一,不支持毫秒级精度。默认10秒扫一次数据库,调成1秒已经是极限了。要精确到毫秒的任务,db-scheduler做不到。
第二,只支持关系型数据库。用MongoDB的团队用不了。
第三,没有内置的监控和告警。任务跑失败了你看不到 Dashboard,只能去翻数据库表。
第四,某些数据库的功能支持不完整。MySQL 8.0以下不支持降序索引,优先级功能就用不了。
这些问题在官方文档里写得明明白白。选择db-scheduler就意味着接受这些取舍。
一张表能撑起多大的调度系统?
db-scheduler的设计哲学是“够用就好”。
作者Gustav Karlsson在博客里说得很直白——他需要的就是一个能在多节点上跑、带冗余的调度器。需求不复杂,就是定时发发未发出的邮件。
从这个出发点来看,db-scheduler确实做到了。一张表、一个依赖、几行配置,集群调度就能跑起来。
但如果你需要的是复杂的工作流编排、可视化的任务依赖图、精细的权限管理——db-scheduler不是你的菜。
有意思的是,恰恰是这种“够用就好”的定位,让db-scheduler在特定场景下比Quartz更高效。DoorDash那篇分析里提到,db-scheduler的数据模型更简单、扩展性更好。复杂的调度逻辑被砍掉之后,剩下的核心功能反而跑得更快。
但这又引出一个问题——当你的系统从“发发邮件”进化到“处理百万级订单”时,db-scheduler那张单薄的表还能撑住吗?
GitHub上有个讨论串,有人问怎么让db-scheduler把任务更均匀地分配到集群各节点。官方给的答案是——目前做不到。任务被哪个节点抢到就是哪个节点执行,没法做负载均衡。
这个问题至今没有解决。
所以db-scheduler到底适合谁?
适合那些“不想被Quartz折磨、但又不需要企业级调度功能”的团队。适合那些跑在云上、节点会频繁扩缩容的微服务。适合那些对“简洁”的追求超过对“功能齐全”的追求的项目。
不适合那些任务执行时间很长、对负载均衡有严格要求、需要完善监控告警的系统。
挪威的Digipost在用,北欧的Vy Group在用。但用得好不好、遇到了什么问题、怎么解决的——这些信息官方没有细说。
db-scheduler的GitHub仓库里有个issue,标题是“Database Deadlocks with MSSQL 2019”。有人用SQL Server跑db-scheduler,遇到了死锁问题。死锁发生在last_heartbeat_idx索引和主键之间。当一个自定义任务试图重新调度自己时,死锁就触发了。
这个issue是2022年12月提的。到现在还open着。
一张表能撑起多大的调度系统?答案可能取决于你用的是什么数据库、任务有多长、节点有多少。但有一点是肯定的——那张表迟早会成为瓶颈。问题只是什么时候来、来的时候你准备好了没有。