作业层级
父子作业可无限嵌套,支持顺序或并行执行。子作业天然幂等:相同方法 + 相同参数 → 只执行一次,结果会被缓存。
Apache 2.0 · Java 与 Kotlin · Spring Boot 2/3/4 · PostgreSQL 15+
JES 可靠地运行您的作业与工作流:它们能在崩溃、重启以及跨机器迁移中存活——状态完好无损。无需部署独立服务器。只要已有 Spring Boot 和 PostgreSQL,您就万事俱备。
一条依赖。三张表。零基础设施。
@Service
public class OrderWorkflow {
// 启动订单处理工作流
public void start(String orderId) {
JobOptions options =
JobOptions.jobWithExternalId(orderId);
jobSystem.startJob(options,
this::processOrder, String.class, orderId);
}
// 崩溃与重试自动恢复
public String processOrder(String orderId) {
String receipt = jobSystem.execute(
paymentService::chargePayment,
String.class, orderId);
try {
jobSystem.execute(
deliveryService::deliverGoods, orderId);
} catch (JobFailure e) {
jobSystem.execute(
paymentService::rollbackPayment,
orderId, receipt);
throw e;
}
return receipt;
}
}
@Service
class OrderWorkflow(
private val jobSystem: JobSystem,
private val paymentService: PaymentService,
private val deliveryService: DeliveryService,
) {
// 启动订单处理工作流
fun start(orderId: String) {
val options =
JobOptions.jobWithExternalId(orderId)
jobSystem.startJob(options,
this::processOrder, String::class.java, orderId)
}
// 崩溃与重试自动恢复
fun processOrder(orderId: String): String {
val receipt = jobSystem.execute(
paymentService::chargePayment,
String::class.java, orderId)
try {
jobSystem.execute(
deliveryService::deliverGoods, orderId)
} catch (e: JobFailure) {
jobSystem.execute(
paymentService::rollbackPayment,
orderId, receipt)
throw e
}
return receipt
}
}
父子作业可无限嵌套,支持顺序或并行执行。子作业天然幂等:相同方法 + 相同参数 → 只执行一次,结果会被缓存。
至少一次执行保证、无限重试、故障后自动恢复——即使所有应用实例全部宕机也能恢复。
具名、类型化的状态槽在作业重启后依然存在,并可从外部读取。类型化上下文可在作业间传递——支持多态。
向运行中的作业发送任意消息、从外部取消、必要时中断。信号沿层级向下传递。
可限制并发作业总数、按作业类型或按资源限制。应用实例之间的均匀分配开箱即用。
Micrometer 指标、trace_id/span_id 沿层级传播、Actuator HealthIndicator、内置 HTTP API 与作业历史搜索。
轻量级 TestJobSystem 在单元测试中运行真实作业——无需 PostgreSQL,自动跳过延迟,限制重跑次数。
在应用持续运行的同时演进作业的成熟策略:方法版本化、按首次运行时间分支、先排空再更新。
JES 已发布到 Maven Central。需要 Spring Boot 2.7+、Java 17+、PostgreSQL 15+。
<dependency>
<groupId>dev.jesproject</groupId>
<artifactId>job-execution-system</artifactId>
<version>2.0.0</version>
</dependency>
implementation("dev.jesproject:job-execution-system:2.0.0")
通过 Liquibase 建立三张表:将仓库中的 db/job_execution_system 复制到你的迁移目录,并引入 changelog。
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog">
<!-- JES 的表结构随库一同提供:
将 db/job_execution_system 复制到你的迁移目录 -->
<include file="job_execution_system/init/v2.0.xml"
relativeToChangelogFile="true"/>
</databaseChangeLog>
databaseChangeLog:
# JES 的表结构随库一同提供:
# 将 db/job_execution_system 复制到你的迁移目录
include:
file: job_execution_system/init/v2.0.xml
relativeToChangelogFile: true
在应用主类上添加一个注解。
@SpringBootApplication
@EnableJobExecutionSystem
public class ExampleApp {
public static void main(String[] args) {
SpringApplication.run(ExampleApp.class, args);
}
}
@SpringBootApplication
@EnableJobExecutionSystem
class ExampleApp
fun main(args: Array<String>) {
SpringApplication.run(ExampleApp::class.java, *args)
}
Spring Bean 的任何公共方法都会成为持久化作业——失败后自动重试直至成功。
@Service
public class HelloService {
private final JobSystem jobSystem;
public void runHelloJobs() {
jobSystem.startJob(this::hello, "World");
}
public void hello(String name) {
// 至少执行一次,故障后自动恢复
log.info("Hello, {}!", name);
}
}
@Service
class HelloService(
private val jobSystem: JobSystem,
) {
fun runHelloJobs() {
jobSystem.startJob(this::hello, "World")
}
fun hello(name: String) {
// 至少执行一次,故障后自动恢复
log.info("Hello, {}!", name)
}
}
作业只是 PostgreSQL 表中的一行:方法、参数、父作业、状态、调度计划。没有隐藏状态——对 jobs、job_signals 和 job_data 三张表执行 SELECT,一切尽收眼底。
应用实例轮询数据表,用带 FOR UPDATE SKIP LOCKED 的短事务领取下一个作业——因此数十个实例可以水平扩展而互不干扰,工作自动流向空闲实例。
每个执行器通过心跳确认存活。实例宕机后,其他实例会接管它的作业并重新执行——至少一次,状态完好无损。整个集群宕机也同样如此。
工作流作业会派生子作业——业务流程的步骤,可顺序或并行执行。子作业天然确定性:相同方法 + 相同参数 → 结果已在数据库中。因此父作业在任何地方重启时,已完成的步骤不会重复执行——工作流从停止处继续。
几个小片段——每一段都是无需编写任何基础设施代码即可获得的保证。
每个订单一个子作业——并行执行、独立重试,全部完成后汇总结果。
// 每个订单一个子作业:并行执行、独立重试
public List<Report> processAll(List<Order> orders) {
List<Job> jobs = jobSystem.startJobs(
JobDefinition.ofList(this::processOrder, orders));
// 等待全部子作业并收集结果
return Job.getAll(jobs, Report.class).toList();
}
public Report processOrder(Order order) {
// 相同参数 → 只执行一次,重跑时结果来自缓存
return warehouse.reserve(order);
}
// 每个订单一个子作业:并行执行、独立重试
fun processAll(orders: List<Order>): List<Report> {
val jobs = jobSystem.startJobs(
JobDefinition.ofList(this::processOrder, orders))
// 等待全部子作业并收集结果
return Job.getAll(jobs, Report::class.java).toList()
}
fun processOrder(order: Order): Report {
// 相同参数 → 只执行一次,重跑时结果来自缓存
return warehouse.reserve(order)
}
Kafka 重复投递不会启动第二个作业:事件 id 即幂等键,已存在的作业会被直接复用。
// Kafka 监听器:重复投递不得启动第二个作业——
// 事件 id 是幂等键
@KafkaListener(topics = "orders")
public void onOrderCreated(
String eventId, String orderId, String userId) {
try {
jobSystem.startJob(
JobOptions.jobWithExternalId(eventId),
this::processOrder, orderId, userId);
} catch (JobAlreadyExistsException e) {
// 重复投递:作业已存在
log.info("Event {} already has a job", eventId);
}
}
public void processOrder(String orderId, String userId) {
// 重试直至成功——事件不会丢失
}
// Kafka 监听器:重复投递不得启动第二个作业——
// 事件 id 是幂等键
@KafkaListener(topics = "orders")
fun onOrderCreated(
eventId: String, orderId: String, userId: String
) {
try {
jobSystem.startJob(
JobOptions.jobWithExternalId(eventId),
this::processOrder, orderId, userId)
} catch (e: JobAlreadyExistsException) {
// 重复投递:作业已存在
log.info("Event {} already has a job", eventId)
}
}
fun processOrder(orderId: String, userId: String) {
// 重试直至成功——事件不会丢失
}
作业运行期间可随时向其发送任意消息——由作业自身决定何时、如何处理。
// 作业运行期间接收指令
public void exportReport(ExportRequest request) {
Signal<ExportCommand> command = Signal.of();
for (String page : request.pages()) {
ExportCommand cmd = command.waitAndPop();
render(page, cmd);
}
}
// 从任何位置——例如 REST 控制器:
jobSystem.signal(job, new ExportCommand("change-scope"));
// 作业运行期间接收指令
fun exportReport(request: ExportRequest) {
val command: Signal<ExportCommand> = Signal.of()
for (page in request.pages()) {
val cmd = command.waitAndPop()
render(page, cmd)
}
}
// 从任何位置——例如 REST 控制器:
jobSystem.signal(job, ExportCommand("change-scope"))
调度本身就是一个持久化作业:应用崩溃后,错过的执行会被恢复并继续。
@Service
public class ReportScheduler {
private final RecurringJobScheduler recurring;
public void register() {
// 每晚 02:00 执行——本身就是一个持久化作业
recurring.scheduleAtCron(
"nightly-report", "0 0 2 * * *",
this::generateReport);
}
public void generateReport() {
// 崩溃后自动恢复
}
}
@Service
class ReportScheduler(
private val recurring: RecurringJobScheduler,
) {
fun register() {
// 每晚 02:00 执行——本身就是一个持久化作业
recurring.scheduleAtCron(
"nightly-report", "0 0 2 * * *",
this::generateReport)
}
fun generateReport() {
// 崩溃后自动恢复
}
}
从保存的索引开始从数据库获取分块;重启后从停止处继续处理——已完成的分块不会被重新获取。
// 数据库中的分块:借助保存的索引,重启后
// 可以从停止处继续,无需重复处理已完成的分块
public void processBacklog() {
// 返回已保存的索引,首次运行返回 0
Integer index = Job.computeState("index",
Integer.class, current ->
current == null ? 0 : current);
// 从保存的索引开始获取分块
List<Chunk> chunks =
chunkRepository.findFrom(index);
for (int i = 0; i < chunks.size(); i++) {
Job.throwIfInterruptedOrCancelled();
chunkService.handle(chunks.get(i));
Job.setState("index", index + i + 1);
}
}
// 数据库中的分块:借助保存的索引,重启后
// 可以从停止处继续,无需重复处理已完成的分块
fun processBacklog() {
// 返回已保存的索引,首次运行返回 0
val index = Job.computeState("index",
Integer::class.java) { it ?: 0 }
// 从保存的索引开始获取分块
val chunks =
chunkRepository.findFrom(index)
chunks.forEachIndexed { i, chunk ->
Job.throwIfInterruptedOrCancelled()
chunkService.handle(chunk)
Job.setState("index", index + i + 1)
}
}
每个步骤都注册自己的补偿逻辑。若后续步骤失败,已完成的步骤按逆序回滚——且每个作业都会重试直至成功。
// Saga:每个步骤注册自己的补偿。
// 失败时,已完成的步骤按逆序回滚。
public String placeOrder(String orderId) {
Deque<Runnable> compensate = new ArrayDeque<>();
try {
String paymentId = jobSystem.execute(
paymentService::charge, String.class, orderId);
compensate.push(() -> jobSystem.execute(
paymentService::refund, orderId, paymentId));
String shipmentId = jobSystem.execute(
deliveryService::ship, String.class, orderId);
compensate.push(() -> jobSystem.execute(
deliveryService::cancelShipment, shipmentId));
jobSystem.execute(loyaltyService::addPoints, orderId);
return paymentId;
} catch (JobFailure e) {
compensate.forEach(Runnable::run); // 逆序回滚
throw e;
}
}
// Saga:每个步骤注册自己的补偿。
// 失败时,已完成的步骤按逆序回滚。
fun placeOrder(orderId: String): String {
val compensate = ArrayDeque<() -> Unit>()
try {
val paymentId = jobSystem.execute(
paymentService::charge, String::class.java, orderId)
compensate.addFirst {
jobSystem.execute(
paymentService::refund, orderId, paymentId)
}
val shipmentId = jobSystem.execute(
deliveryService::ship, String::class.java, orderId)
compensate.addFirst {
jobSystem.execute(
deliveryService::cancelShipment, shipmentId)
}
jobSystem.execute(loyaltyService::addPoints, orderId)
return paymentId
} catch (e: JobFailure) {
compensate.forEach { it() } // 逆序回滚
throw e
}
}
可随时从外部取消作业:取消信号级联传播到子作业,作业在步骤之间自行停止。
// 作业在步骤之间检查是否被取消
public void exportReport(ExportRequest request) {
for (String page : request.pages()) {
Job.throwIfInterruptedOrCancelled();
render(page);
}
}
// 从任何位置:取消会级联到子作业
jobSystem.cancel(job);
// 作业在步骤之间检查是否被取消
fun exportReport(request: ExportRequest) {
for (page in request.pages()) {
Job.throwIfInterruptedOrCancelled()
render(page)
}
}
// 从任何位置:取消会级联到子作业
jobSystem.cancel(job)
JES 开箱即用,默认值即是合理配置——无需任何配置。但每一项参数都开放可调:线程池、重试延迟、心跳、自动清理以及内置管理 API。
job-execution-system:
max-runner-count: 100 # 每实例的作业线程数
jobs:
new-check-interval: 200ms # 新作业启动速度
heartbeat-rate: 850ms # 故障实例检测
heartbeat-timeout: 5000ms # 故障转移阈值
delay-on-error: 0ms # 重试前的暂停
signals:
new-check-interval: 1000ms # 信号投递延迟
cleanup:
enabled: false # 旧作业自动清理
expiration-period: P1Y8M5D
cron: "0 0 */20 * * *" # 清理计划
api:
enabled: false # HTTP 管理 API
以上所有值都是可选的——省略整个配置块,JES 也会以相同的默认值运行。
线程数、预取与心跳按应用实例设置;实例之间自动均衡。
轮询频率与 Hikari 连接池大小均可调整——连接数受到严格限制。
所有配置项均为可选——示例中显示的即默认值。