AtomicBoolean 能防止一个进程重复提交任务,却不能协调三个同时运行的 Spring Boot 实例。每个实例都有自己的内存,每次重启还会丢失正在执行的任务标记。
本章继续使用 Java 21、Spring Boot 4.1.1 和已有 PostgreSQL。把待执行检查保存成数据库任务,用短事务认领、带令牌的租约和结果唯一约束,让实例退出后其他实例可以接手,并让失去所有权的旧实例无法提交结果。
1. 先确定可以承诺什么
为每个服务按数据库时间划分 30 秒调度窗口,同一 (service_id, scheduled_slot) 只创建一项业务任务。任务可以有多次执行尝试,但成功结果最多写入一次。
1READY -> RUNNING(owner=A, lease=30s) -> DONE2|3+-- A 退出,租约到期 -> RUNNING(owner=B) -> DONE4+-- 可重试失败 -> READY(稍后重试)5+-- 达到 3 次尝试 -> FAILED
租约不可能撤回已经发出的 HTTP 请求。A 请求超时后,B 可能再次探测目标;A 甚至可能在 B 开始后才恢复。这里采用可重复执行的 at-least-once 处理思路,加上有限重试和单次持久化效果,不是 exactly-once 网络调用,也不保证失败任务最终一定成功。
探测使用只读 GET,重复调用通常可以接受。若远端操作有扣款等副作用,远端也必须接受稳定幂等键;仅给本地表加唯一索引不能让远端副作用自动幂等。
2. 创建任务与结果表
V6 已用于资源成员关系,V7 用于告警 Outbox,本章使用 V8。任务保存目标地址的配置快照,结果表使用 job_id 主键约束同一任务最多产生一份结果。
01create table monitoring_job (02id bigint generated always as identity primary key,03service_id bigint not null references monitored_service(id) on delete restrict,04service_name varchar(50) not null,05scheduled_slot bigint not null,06target_url text not null,07state varchar(16) not null default 'READY'08check (state in ('READY', 'RUNNING', 'DONE', 'FAILED')),09attempts integer not null default 0 check (attempts between 0 and 3),10available_at timestamptz not null default clock_timestamp(),11lease_until timestamptz,12owner_token uuid,13created_at timestamptz not null default clock_timestamp(),14unique (service_id, scheduled_slot),15check (16(state = 'RUNNING' and owner_token is not null and lease_until is not null)17or (state <> 'RUNNING' and owner_token is null and lease_until is null)18)19);2021create index idx_monitoring_job_ready on monitoring_job (available_at, id)22where state = 'READY';23create index idx_monitoring_job_expired on monitoring_job (lease_until, id)24where state = 'RUNNING';2526create table monitoring_job_result (27job_id bigint primary key references monitoring_job(id) on delete restrict,28status varchar(8) not null check (status in ('UP', 'DOWN')),29observed_at timestamptz not null default clock_timestamp()30);
scheduled_slot 是业务标识的一部分,owner_token 是每一次认领的新 UUID,两者不能混用。重试保持相同 job ID,但必须换 token。这里只由受控配置生成 target_url,不能开放一个接收任意 URL 的匿名任务提交接口。
3. 在短事务中认领任务
下面是完整的数据库任务存储服务。每个公开方法都由另一个 Bean 调用,事务代理负责提交;HTTP 探测不放进这些事务中。
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.health.HealthState;04import java.net.URI;05import java.util.Optional;06import java.util.UUID;07import org.springframework.jdbc.core.simple.JdbcClient;08import org.springframework.stereotype.Service;09import org.springframework.transaction.annotation.Transactional;1011@Service12public class MonitoringJobStore {13private final JdbcClient jdbc;1415public MonitoringJobStore(JdbcClient jdbc) {16this.jdbc = jdbc;17}1819public record Job(long id, String serviceName, URI targetUrl, UUID ownerToken) {}2021@Transactional(timeout = 5)22public void enqueue(String serviceName, URI targetUrl) {23jdbc.sql("""24insert into monitoring_job25(service_id, service_name, scheduled_slot, target_url)26select id, name,27floor(extract(epoch from clock_timestamp()) / 30)::bigint, :url28from monitored_service where name = :service and enabled = true29on conflict (service_id, scheduled_slot) do nothing30""").param("service", serviceName).param("url", targetUrl.toString()).update();31}3233@Transactional(timeout = 5)34public Optional<Job> claim() {35jdbc.sql("""36update monitoring_job37set state = 'FAILED', owner_token = null, lease_until = null38where state = 'RUNNING' and attempts >= 339and lease_until <= clock_timestamp()40""").update();4142return jdbc.sql("""43with candidate as (44select id from monitoring_job45where attempts < 3 and (46(state = 'READY' and available_at <= clock_timestamp())47or (state = 'RUNNING' and lease_until <= clock_timestamp())48)49order by available_at, id50limit 151for update skip locked52)53update monitoring_job j54set state = 'RUNNING', owner_token = :token,55lease_until = clock_timestamp() + interval '30 seconds',56attempts = j.attempts + 157from candidate c where j.id = c.id58returning j.id, j.service_name, j.target_url, j.owner_token59""").param("token", UUID.randomUUID())60.query((rs, row) -> new Job(rs.getLong("id"),61rs.getString("service_name"), URI.create(rs.getString("target_url")),62rs.getObject("owner_token", UUID.class))).optional();63}6465@Transactional(timeout = 5)66public boolean complete(Job job, HealthState state) {67int accepted = jdbc.sql("""68update monitoring_job69set state = 'DONE', owner_token = null, lease_until = null70where id = :id and state = 'RUNNING' and owner_token = :token71and lease_until > clock_timestamp()72""").param("id", job.id()).param("token", job.ownerToken()).update();73if (accepted == 0) {74return false;75}7677jdbc.sql("""78insert into monitoring_job_result(job_id, status)79values (:id, :status)80""").param("id", job.id()).param("status", state.name()).update();81return true;82}8384@Transactional(timeout = 5)85public void retry(Job job) {86jdbc.sql("""87update monitoring_job88set state = case when attempts < 3 then 'READY' else 'FAILED' end,89available_at = clock_timestamp()90+ least(60, 5 * power(2, attempts - 1)) * interval '1 second',91owner_token = null, lease_until = null92where id = :id and state = 'RUNNING' and owner_token = :token93and lease_until > clock_timestamp()94""").param("id", job.id()).param("token", job.ownerToken()).update();95}96}
生产者重复执行 INSERT 时,由数据库唯一约束完成竞争仲裁,不需要先查询“任务存在吗”。ON CONFLICT DO NOTHING 保留首次提交的地址快照,因此同一部署的副本必须使用一致目标配置。30 秒窗口由数据库计算,避免各实例机器时间不同导致业务键漂移。应用全部离线期间不会补造过去的窗口;需要补偿扫描时应另设有限回放范围。
认领过程将行锁和所有权更新放在同一条语句中。其他工作实例跳过被锁住的行,可继续处理别的任务;SKIP LOCKED 适合这样的队列表,不适合需要完整一致结果的普通业务查询。参见 PostgreSQL SELECT 锁定规则。
完成时先判断 job ID、状态、token 和租约,再写结果;两个操作处于同一事务,插入失败会撤销 DONE。判断成功的瞬间获得该行写锁,接管者会等待并在提交后看到 DONE。即使调用方没有收到提交成功响应,重新调用 complete 也不会增加第二份结果。
检查租约使用数据库 clock_timestamp()。current_timestamp 固定为事务开始时间,不适合表达等待之后的实际过期判断,区别见 PostgreSQL 时间函数。这个 token 是数据库内的所有权凭证,不是可以让任意外部系统自动拒绝旧请求的通用 fencing token。
4. 替换原来的调度器
移除旧 HealthCheckScheduler 或 AsyncHealthCheckScheduler 的 @Component,只保留一种调度入口;原来的单元测试仍可直接创建那些普通 Java 对象。否则旧调度器会继续绕过任务表访问目标并写入旧结果。
保留 @EnableScheduling,将新任务功能做成显式开关,方便测试禁用后台轮询:
01app:02monitoring:03target-name: inventory04target-url: ${TARGET_HEALTH_URL:http://localhost:8081/api/v1/health}05jobs:06enabled: true07spring:08task:09scheduling:10pool:11size: 212http:13clients:14connect-timeout: 2s15read-timeout: 3s16redirects: dont-follow
MonitoringProperties 和 RemoteHealthClient 使用前文版本。目标名称需要在 monitored_service 中存在且 enabled 为 true;可复用资源授权篇的 inventory 教学数据。禁用服务会停止创建新任务,已经提交的任务仍按快照完成;若需要即时取消,必须另外增加取消状态和完成条件。
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.config.MonitoringProperties;04import org.slf4j.Logger;05import org.slf4j.LoggerFactory;06import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;07import org.springframework.scheduling.annotation.Scheduled;08import org.springframework.stereotype.Component;0910@Component11@ConditionalOnProperty(prefix = "app.monitoring.jobs", name = "enabled",12havingValue = "true")13public class DatabaseHealthScheduler {14private static final Logger log = LoggerFactory.getLogger(DatabaseHealthScheduler.class);15private final MonitoringJobStore jobs;16private final RemoteHealthClient client;17private final MonitoringProperties properties;1819public DatabaseHealthScheduler(MonitoringJobStore jobs, RemoteHealthClient client,20MonitoringProperties properties) {21this.jobs = jobs;22this.client = client;23this.properties = properties;24}2526@Scheduled(fixedDelay = 5000, initialDelay = 5000)27public void produce() {28jobs.enqueue(properties.targetName(), properties.targetUrl());29}3031@Scheduled(fixedDelay = 1000, initialDelay = 1000)32public void consume() {33jobs.claim().ifPresent(job -> {34try {35ProbeResult result = client.probe(job.targetUrl());36if (!jobs.complete(job, result.state())) {37log.warn("Discarded result from expired monitoring job owner: {}", job.id());38}39} catch (RuntimeException error) {40log.error("Monitoring job attempt failed: {}", job.id(), error);41jobs.retry(job);42}43});44}45}
consume() 没有事务,claim 返回时认领事务已提交,网络等待期间没有持有数据库行锁或事务连接。一次只认领一个任务,每个实例最多一个同步消费调用;增加副本会增加并行消费者,不需要在本章再叠加 @Async 与无界任务队列。
RemoteHealthClient 已将超时、非成功响应等转换成 DOWN,因此 DOWN 是一次有效监控结果,会正常完成任务,不会立刻重试到变成 UP。retry 处理的是未能完成本次任务的应用异常。若数据库完全不可用、retry 本身也失败,记录日志后让租约过期,其他实例之后再接管;达到三次尝试的任务进入 FAILED,交给运维检查。
30 秒租约只是本案例预算,不等于保证所有 HTTP 调用都在 30 秒内结束。必须结合真实最坏耗时设置,过期结果会被丢弃。长任务若需要续租,续租同样必须附带 token 和未过期条件,不能让旧 owner 无条件延长新 owner 的租约。
5. 验证重复提交与旧 owner
下面的测试复用 PostgreSQL Testcontainers。特意不加测试类级 @Transactional,让每一次认领和完成都真正提交,避免一个测试事务掩盖真实租约边界。
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.TestcontainersConfiguration;04import cn.usehook.spring.health.HealthState;05import java.util.UUID;06import org.junit.jupiter.api.Test;07import org.springframework.beans.factory.annotation.Autowired;08import org.springframework.boot.test.context.SpringBootTest;09import org.springframework.context.annotation.Import;10import org.springframework.jdbc.core.simple.JdbcClient;11import org.springframework.security.oauth2.jwt.JwtDecoder;12import org.springframework.test.context.bean.override.mockito.MockitoBean;13import static org.assertj.core.api.Assertions.assertThat;1415@SpringBootTest(properties = {16"app.monitoring.jobs.enabled=false",17"app.monitoring.initial-delay=1h",18"app.alerts.enabled=false",19"app.history.retention.enabled=false"20})21@Import(TestcontainersConfiguration.class)22class MonitoringJobTests {23@Autowired MonitoringJobStore jobs;24@Autowired JdbcClient jdbc;25@MockitoBean JwtDecoder decoder;2627@Test28void rejectsExpiredOwnerAndPersistsOneResult() {29String name = "job-" + UUID.randomUUID();30long group = jdbc.sql("insert into monitoring_group(name) values (:name) returning id")31.param("name", name).query(Long.class).single();32long service = jdbc.sql("""33insert into monitored_service(name, group_id) values (:name, :group) returning id34""").param("name", name).param("group", group).query(Long.class).single();35try {36// 固定窗口标识,避免测试刚好跨越真实的 30 秒边界。37for (int i = 0; i < 2; i++) {38jdbc.sql("""39insert into monitoring_job(service_id, service_name, scheduled_slot, target_url)40values (:service, :name, 123, 'http://inventory/api/v1/health')41on conflict (service_id, scheduled_slot) do nothing42""").param("service", service).param("name", name).update();43}44assertThat(jdbc.sql("select count(*) from monitoring_job where service_id=:id")45.param("id", service).query(Long.class).single()).isEqualTo(1L);46MonitoringJobStore.Job first = jobs.claim().orElseThrow();47assertThat(jobs.claim()).isEmpty();48jdbc.sql("update monitoring_job set lease_until=clock_timestamp()-interval '1 second' where id=:id")49.param("id", first.id()).update();50assertThat(jobs.complete(first, HealthState.UP)).isFalse();51MonitoringJobStore.Job second = jobs.claim().orElseThrow();52assertThat(second.id()).isEqualTo(first.id());53assertThat(second.ownerToken()).isNotEqualTo(first.ownerToken());54assertThat(jobs.complete(first, HealthState.UP)).isFalse();55assertThat(jobs.complete(second, HealthState.DOWN)).isTrue();56assertThat(jobs.complete(second, HealthState.DOWN)).isFalse();57assertThat(jdbc.sql("select status from monitoring_job_result where job_id=:id")58.param("id", second.id()).query(String.class).single()).isEqualTo("DOWN");59} finally {60jdbc.sql("delete from monitoring_job_result where job_id in (select id from monitoring_job where service_id=:id)")61.param("id", service).update();62jdbc.sql("delete from monitoring_job where service_id=:id").param("id", service).update();63jdbc.sql("delete from monitored_service where id=:id").param("id", service).update();64jdbc.sql("delete from monitoring_group where id=:id").param("id", group).update();65}66}67}
执行 ./mvnw -Dtest=MonitoringJobTests test。测试使用专用空容器任务表,不允许并行用例或其他后台消费者写同一张队列表;清理条件只针对本次生成的服务 ID。通过主动调整测试租约制造过期,不需要等待 30 秒或依靠不稳定的 sleep。
再补三组故障验证:两个独立连接同时 claim 不能得到同一有效任务;第三次尝试仍失败时进入 FAILED;complete 的结果插入失败时,DONE 必须随事务回滚。它们分别验证并发认领、重试上限和原子提交,不能只断言 Java 方法调用次数。
6. 观察两实例接管
在共享教学 PostgreSQL 上启动两个实例,端口分别设为 8080、8082,目标配置相同。不要启动旧调度器。观察任务状态与结果数量:
01select j.id, j.service_id, j.scheduled_slot, j.state, j.attempts,02j.owner_token, j.lease_until, r.status03from monitoring_job j04left join monitoring_job_result r on r.job_id = j.id05order by j.id desc06limit 20;0708select service_id, scheduled_slot, count(*)09from monitoring_job10group by service_id, scheduled_slot11having count(*) > 1;
正常情况下第二条查询为空,第一条中的大多数任务一次尝试后变为 DONE。若需要演示接管,在专用调试环境让 A 停在“claim 已返回、complete 尚未调用”的断点;租约过期后 B 会使用新 token 认领。恢复 A 时 complete 必须返回 false。不要在生产环境通过暂停所有线程模拟这个过程。
检查延迟超过一个调度窗口时,不同窗口仍可能同时执行,这是任务之间的正常并行;本方案只防止同一有效租约被重复认领,不承诺一个服务永远只有一条远程请求在途。所有实例离线、数据库长时间故障或三次尝试耗尽时,需要运维介入,不能把 FAILED 自动无上限重放。
7. 与历史和告警衔接
本章结果保存在 monitoring_job_result,原来的手动检查入口仍写已有历史表。消费者故意只调用 RemoteHealthClient,不调用会提前落库的 HealthCheckService;否则旧 owner 可能先写历史、发告警,最后才发现 complete 失败。
若要接入前面的状态历史和 Outbox,应把它们的数据库写入移动到 complete 的 accepted 检查之后、同一个事务之内,并用 job ID 作为事件幂等标识。还要处理不同 scheduled_slot 乱序完成:旧窗口结果可以追加历史,但不能覆盖较新窗口的“当前状态”。最直接的办法是在最新状态表保存最后处理的 slot,并在 UPDATE 中要求新 slot 更大;不能直接复用无条件覆盖的旧 Repository。
任务和结果表也需要保留策略。清理 DONE/FAILED 任务前先清理关联结果,并明确去重窗口:唯一记录一旦删除,就不再阻止相同业务键重新创建。当前生产者只创建当前窗口,不回放过去,因此历史清理与日常调度不会冲突;增加人工重放功能时必须重新设计这一约束。
PostgreSQL 已经提供本案例所需的唯一约束、条件更新和行锁,暂时无需引入额外分布式锁服务。数据库队列成为瓶颈、需要大量独立消费者或跨系统投递时,再评估消息系统。ON CONFLICT 的原子行为见 INSERT 官方文档,Spring 方法事务的边界见 事务传播官方文档。