BitzOrcas 把定时和批处理工作放在独立 BitzOrcas.JobHost 进程中。API Host 保持裁剪安全,不引用 Quartz;JobHost 负责调度,模块拥有实际业务执行器。
模块声明与执行器 → JobHost 描述符 → Quartz Trigger ↓ 审计信封与 OpenTelemetry ↓ owner-local 业务端口关键路径图
下图展示了后台任务在 Quartz 集群中的持久化调度与执行安全模型:调度器从数据库抢占排他租约,并通过类型化执行器安全执行业务逻辑。
什么时候使用后台任务
适合:周期清理、聚合、备份验证、密钥轮换、超时扫描,以及已经接受后异步执行的导出等工作。不适合:必须与当前数据库事务原子完成的动作,或用户正在等待的短命令。
当前 JobHost 不是只有审计清理。它装配 Audit、Workflow、Identity、Backup、Export、DataLifecycle、Website、Payment 等 owner 的任务,包括导出执行、工作流定时器、孤儿文件清理、CAP 消息清理、自动续费和备份保留。
一个任务的三个部分
BackgroundJobDeclaration:稳定名称、Cron 或间隔、模块、启用状态、关键性、配置来源和说明。IJobExecutor:不依赖 Quartz 的业务执行入口,预期业务失败返回Result.Failure。- JobHost 适配器:把 Quartz 触发映射到执行器,并套上统一审计信封。
启用任务必须且只能配置 Cron 或正数间隔。禁用任务可以没有调度,但 Job 类型仍注册,因此运维入口可以受控地手动触发。
新增任务的推荐顺序
- 在 owner 模块定义稳定任务身份与声明。
- 实现
IJobExecutor<TIdentity>,只依赖模块端口和CancellationToken。 - 在该模块的 JobHost 注册扩展中绑定声明、执行器和 Quartz 适配器。
- 为成功、预期失败、异常、取消、重复执行和并发执行编写测试。
- 检查 Operations 调度报告与 JobHost 健康检查是否出现该任务。
不要直接修改 Program.cs 添加一串 AddJob<T>()。组合根通过 AddJobHostBackgroundJobs 汇总 owner-local 扩展,并在启动时校验 Catalog、Handler 和 Executor 是否完整且唯一。
幂等与并发
调度系统通常提供至少一次执行条件:进程可能在完成业务写入后、确认调度结果前退出;运维也可能手动重跑。因此执行器要能识别已经完成的批次或业务键。
常用做法包括唯一约束、状态机条件更新、处理游标和幂等记录。不要只靠“Cron 不会重叠”的假设。多实例 JobHost 使用持久化 Quartz 集群存储,但业务层仍需抵抗重复触发。
租户边界
批量处理多租户数据时,先取得明确的租户列表,再为每个租户建立可信上下文。查询必须包含租户过滤;游标、锁和幂等键也要带租户维度。后台进程没有浏览器用户,不能伪造普通用户上下文来绕过授权。
失败、审计与取消
QuartzJobExecutionAuditor 为每次执行创建 Activity,并记录任务名、Quartz FireInstanceId 或生成的关联标识、耗时和成功/失败状态。Result.Failure 在 Host 边界转为失败,使 Quartz 与审计能看到真实结果;系统异常和取消继续抛出。
审计写入失败只记录警告,不会把业务成功改成失败。相反,业务异常不能吞掉,否则调度器会误判成功。耗时循环应定期检查取消令牌,让部署关闭能够收敛。
声明、配置与运行态报告
任务身份和默认调度属于模块契约,环境是否启用及 Cron/间隔覆盖属于部署配置。稳定名称一旦进入告警、运维 API 与审计查询,就不能随类名重构而改变。
// 声明只描述稳定任务身份和调度元数据,不承载业务执行逻辑。public static readonly BackgroundJobDeclaration Cleanup = new( // 稳定名称供配置、审计和运维 API 共同引用。 Name: "files.orphan-cleanup", Module: "Files", CronExpression: "0 0/15 * * * ?", Interval: null, Enabled: true, Critical: false, ConfigurationSource: "Files:Jobs:OrphanCleanup");Operations 报告应能看到声明、实际 Trigger、下一次触发、最近结果和不一致原因。声明已启用但没有 Trigger、重复名称、缺少 Executor 或配置同时给 Cron/Interval 都属于启动或 GA 阻断。
批处理游标与事务边界
长任务按有界批次读取并提交游标,避免一个小时级数据库事务。游标要表达已提交的事实;先推进游标再提交业务写入会丢数据,先提交写入再推进游标则可能重做,因此处理动作必须幂等。
// 每批只在业务写入成功后推进游标;重复读取仍由业务键兜底。while (!cancellationToken.IsCancellationRequested){ // 小批量读取限制单次事务、内存和关闭等待时间。 var batch = await source.ReadAsync(tenantId, cursor, batchSize: 200, cancellationToken); if (batch.Count == 0) break;
await processor.ApplyIdempotentlyAsync(batch, cancellationToken); cursor = await cursors.CommitAsync(tenantId, batch[^1].Sequence, cancellationToken);}外部调用不要和数据库事务假装原子。写库后发消息使用 Outbox;调用外部 Provider 时保存可恢复状态与供应商幂等键,让下一次执行从确定状态继续。
重试、错过触发与重叠
区分调度器重试、下次 Cron 扫描和人工重跑。Misfire 策略必须按任务语义选择:结算类可能需要补跑,周期刷新类通常只需最近一次。
Quartz 锁只能约束调度实例;业务侧仍要用租约、唯一批次或条件更新防止手动入口、旧实例和消息消费者并发执行。租约需要 owner、到期和 fencing token,不能用永久布尔锁。
测试与故障注入
- 在业务写入成功、游标提交前终止进程,重启后不产生重复副作用;
- 同一 Trigger 并发两次时只有一个业务批次生效;
- 一个租户失败不阻断其他租户,且失败租户可独立重跑;
- 取消会停止新批次并让当前批次安全收敛;
- 审计/OTLP 故障不改变业务结果,但能产生运维告警;
- Cron 时区、夏令时、misfire 和未来触发时间按部署地区验证。
发布门禁
- 生产环境有数据库配置,JobHost 的运行时守卫通过。
- 关键任务已启用且存在未来触发时间,Scheduler 健康检查正常。
- 同一任务重跑不会重复收费、发信或删除数据。
- 多租户扫描没有跨租户读取或共享游标。
- 失败记录、Activity、告警和受控手动重跑路径可用。
类型与组合方式见后台任务构建块。