摘要:调度系统在 Active-Passive 主备切换后,常出现"同一 Flow Schedule 被执行两次,但 Web 界面只显示一个实例"的现象。本文把这类残留对象定义为幽灵任务(Ghost Task):它存在于调度器内存或持久化表中,却不再被界面视图与状态机正常管理。文章从调度器的"三副本状态"模型出发,拆解主备切换时租约失效、事务提交与内存快照三者错位导致的双跑机制,给出从日志取证、表结构核对到安全清理的完整操作路径,并明确哪些后台表可以动、哪些绝对不能动。核心结论是:幽灵任务的根因多数不在数据库,而在切换窗口内的状态收敛失败;清理应以"先冻结、再取证、后收敛"为原则,手动改表只作为最后手段且必须配合幂等护栏。
目录
一、问题的准确定义:什么叫"跑两次但只显示一个"
先把现象描述精确化。运维侧观察到的典型组合是:下游数据出现两份重复产出(或任务日志里出现两段几乎同时开始的执行记录),但调度器 Web 界面的"实例列表"里只有一条记录,状态可能是 Running、Success 或干脆卡在 Queued。三个观察点——执行痕迹、界面视图、后台表记录——互相矛盾。这种矛盾本身就是重要线索:它说明系统的"事实来源"(source of truth)发生了分裂。
在调度系统中,一个 Flow Schedule(工作流调度定义)通常对应三层对象:定义层(DAG/Flow 的拓扑与调度周期)、实例层(某一次调度周期生成的 Run/DagRun 记录)、执行层(TaskInstance 及其实体进程/容器)。"跑两次"发生在执行层,"只显示一个"发生在实例层或视图层。因此问题的本质是:执行层产生了两个物理执行体,而实例层只承认其中一个。另一个执行体失去了与实例记录的绑定关系,成为孤儿——这就是本文所称的幽灵任务。
定义(本文口径):幽灵任务指在调度器主备切换、进程重启或租约过期窗口内,由旧主节点发起、但因状态未收敛而未被新主节点接管或标记终止的执行实体。其特征是:物理上仍在运行或已产生副作用,逻辑上已脱离调度状态机的管理范围。
需要强调,幽灵任务与"重复调度"(duplicate scheduling)不是同一概念。重复调度是同一实例被调度两次,通常有两条实例记录,界面可见;幽灵任务恰恰是"看不见的那一份"。前者是幂等性问题,后者是一致性与收敛性问题。混淆两者会导致排查方向完全跑偏——很多团队一上来就查 DAG 是否有并发配置错误,结果徒劳无功。
笔者认为,把这类问题归入"分布式系统中的脑裂(split-brain)后遗症"更有利于建立分析框架。Active-Passive 架构的设计前提是任一时刻只有一个主节点持有调度权,但当租约(lease)判定与网络分区、GC 停顿、时钟漂移叠加时,这个前提会被短暂打破。打破的窗口通常只有秒级到分钟级,却足以让旧主节点完成一次调度触发。窗口关闭后,旧主被降级或杀死,但它已经发出的执行请求不会自动撤销——这正是幽灵任务的诞生时刻。
二、背景与机制:调度器的三副本状态与切换窗口
2.1 三副本状态模型
要理解幽灵任务,必须先建立调度器的状态分布图。成熟的调度系统(如 Airflow 的 Scheduler、XXL-JOB 的调度中心、DolphinScheduler 的 Master、以及各类基于 Quartz 的集群方案)在运行期至少维护三份状态:
| 状态副本 | 载体 | 作用 | 失效后果 |
|---|---|---|---|
| 内存状态 | 调度器进程内的调度队列、定时器、运行中集合 | 决定"接下来触发什么" | 进程崩溃即丢失,可能重复触发 |
| 持久化状态 | 元数据库(MySQL/PostgreSQL)中的 run/instance 表 | 界面展示与状态机流转的依据 | 事务未提交则记录缺失,形成"看不见" |
| 执行器状态 | Worker 进程、容器、外部作业系统 | 真正产生副作用 | 失去心跳后仍可能继续跑完 |
三份状态之间靠心跳、租约和事务提交保持收敛。正常情况下,调度器先在内存中决定触发,再向数据库写入实例记录,最后向执行器下发任务;三步顺序执行,任一步失败都有补偿。但在主备切换窗口内,这个顺序会被打乱:旧主可能完成了第一步和第三步,却在第二步的事务提交前被剥夺了主身份。
本文评述:很多故障复盘把责任归于"数据库主从延迟",但按上述模型,延迟只是放大器,真正的断裂点是触发决策与记录写入之间缺少原子性。只要"决定跑"和"记录我要跑"不是一个原子操作,切换窗口就必然产生幽灵。这一点在后文的方案设计中会反复用到。
2.2 切换窗口内发生了什么
以典型的基于数据库租约的主备方案为例,主节点周期性续租(例如每 5 秒写一次心跳行),备节点检测到心跳超过阈值(例如 30 秒)未更新即发起抢占。问题出在阈值判定与旧主实际状态之间的偏差:旧主可能因为 Full GC、磁盘 IO 抖动或容器被暂停(cgroup freeze)而暂时无法续租,但它并未死亡,恢复后仍持有"我是主"的本地判断,继续按内存中的调度队列触发任务。
时间轴(示意)
t0 旧主正常续租
t1 旧主 GC 停顿 / 网络抖动,续租失败
t2 备节点判定租约过期,抢占成为新主
t3 新主读取数据库,按记录恢复调度
t4 旧主恢复,仍认为自己是主,触发任务 A
t5 新主也触发任务 A(因为记录里 A 尚未完成)
t6 两个执行体同时运行 → 幽灵诞生
注意 t4 与 t5 的触发对象是同一个逻辑任务,但两个执行体拿到的执行凭证(token/attempt id)不同。新主的执行体有完整的实例记录,旧主的执行体在写回状态时可能因主键冲突或版本号校验失败而被丢弃——于是它的执行痕迹只留在 Worker 日志和下游数据里,界面自然看不到。这就是"跑两次但只显示一个"的完整因果链。
还有一个常被忽略的变体:旧主在 t4 触发时,数据库连接池已因主从切换指向新主库,写入成功但写到了错误的库(例如仍指向旧主库的只读副本被提升),造成记录存在于"另一个库"里。这类跨库分裂在分库分表或读写分离架构中并不罕见,排查时必须核对连接串的实际指向,而不能只看配置文件。
2.3 为什么界面只显示一个
界面视图通常不是直接查表,而是经过一层过滤:只展示当前主节点认领的、状态在合法枚举内的、且与最新 attempt 关联的记录。幽灵执行体要么没有实例记录,要么记录的状态是"已被取代"(superseded),要么其 attempt id 不在视图的 join 范围内。三种情况在界面上表现一致:看不见。
这里有一个工程上的取舍值得讨论。视图过滤的本意是避免展示历史脏数据,提升可读性;但副作用是掩盖了异常。笔者认为,调度系统的管理界面应当提供"全量视图"开关,允许运维看到被过滤掉的记录及其过滤原因。这不是可有可无的体验优化,而是可观测性的基本要求——看不见的故障无法被度量,也无法被预防。
三、取证路径:从日志到后台表的完整排查步骤
清理的前提是取证。没有取证的清理等于破坏现场,后续同类问题还会复发。本节给出一条可复用的排查路径,按"由外到内、由易到难"排序。所有步骤都应在只读或最小权限下进行,除非明确进入处置阶段。
3.1 第一步:确认"两次"是否真实
先排除误判。下游出现两份数据,可能是任务本身重试、可能是上游补数、也可能是消费端重复消费。取证方法是对比两次执行的启动时间戳、主机名/容器 ID、attempt id。如果两次执行的启动时间相差在秒级且主机不同,基本可判定为双主触发;如果相差数分钟且主机相同,更可能是重试逻辑。
# 在 Worker 侧按任务名聚合执行痕迹(示例,字段名依实际日志格式调整)
grep "task_id=flow_xxx" /var/log/scheduler/worker*.log \
| awk '{print $1, $2, $NF}' \
| sort | uniq -c | sort -rn | head -20
如果日志已滚动或采集到集中式平台(ELK/Loki),用 trace_id 或 dag_run_id 关联更可靠。关键是要拿到两个执行体的唯一标识,它们是后续在数据库中定位幽灵的钥匙。
3.2 第二步:核对调度器主备事件
在调度器自身的日志中检索主备切换记录,确认两次执行是否落在切换窗口内。典型关键字包括 leader election、lease expired、acquired leadership、demoted、standby promoted。把切换时间点与两次执行的启动时间对齐,若第二次执行发生在切换后 0–60 秒内,则双主触发的假设成立。
同时检查系统层证据:GC 日志(是否有超过租约阈值的停顿)、容器事件(是否被 kubelet 驱逐或暂停)、网络监控(是否发生分区)。这些证据决定了根因是"调度器缺陷"还是"基础设施抖动",直接影响后续是改配置还是改代码。
3.3 第三步:后台表取证
这是最关键的一步。不同调度器的表结构差异很大,但逻辑上都可以归为四类表:定义表、实例表、任务实例表、日志/事件表。取证时按以下顺序查询:
| 查询目标 | 关注字段 | 判读要点 |
|---|---|---|
| 实例表(run 级) | run_id、state、execution_date、start_date、end_date | 是否有两条 execution_date 相同但 run_id 不同的记录 |
| 任务实例表(task 级) | task_id、attempt、hostname、pid、state | 同一 task 是否有 attempt 相同但 hostname 不同的记录 |
| 事件/审计表 | event_type、timestamp、operator | 是否有自动 failover 或手动 clear 的痕迹 |
| 锁/租约表 | lock_name、owner、expires_at | 租约是否被两个 owner 先后持有且时间重叠 |
-- 查找同一调度周期内的重复实例(以 Airflow 风格表名为例,其他系统按语义替换)
SELECT dag_id, execution_date, COUNT(*) AS cnt,
GROUP_CONCAT(run_id ORDER BY start_date) AS run_ids
FROM dag_run
WHERE execution_date >= '2024-01-01'
GROUP BY dag_id, execution_date
HAVING cnt > 1
ORDER BY cnt DESC;
-- 查找有执行痕迹但状态未收敛的任务实例
SELECT task_id, run_id, hostname, pid, state, start_date
FROM task_instance
WHERE state IN ('running', 'queued')
AND start_date < NOW() - INTERVAL 2 HOUR;
本文评述:第二条 SQL 的"2 小时"阈值不是随意设定,应取该任务历史 P99 运行时长的 2–3 倍。阈值过小会误伤长任务,过大则让幽灵长期潜伏。工程上建议把这个阈值做成配置项,按任务粒度维护,而不是全局硬编码。
3.4 第四步:确认幽灵执行体是否还活着
取证的最后一步是判断幽灵是"仍在运行"还是"已结束但残留记录"。前者需要先止损(终止进程),后者只需清理记录。判断依据是 Worker 侧的心跳或进程存在性:
# 在疑似宿主上确认进程
ps -ef | grep -E "flow_xxx|task_runner" | grep -v grep
# 容器化环境
docker ps --filter "label=task_id=flow_xxx" --format "{{.ID}} {{.Status}}"
kubectl get pods -l task-id=flow_xxx -o wide
若进程已不存在,但下游数据已产生两份,说明幽灵已完成副作用,属于"已完成的幽灵",清理时只需处理记录与数据补偿。若进程仍在,必须先终止再清理,否则清理记录后幽灵继续写回状态,会再次制造不一致。
四、清理方案:分级处置与后台表操作边界
取证完成后进入处置。处置的核心原则是先冻结、再收敛、后补偿,且每一步都要可回滚。下面按风险从低到高给出四级方案,绝大多数场景应停在前两级。
4.1 一级处置:通过调度器自身接口收敛
优先使用调度器提供的管理命令或 API,而不是直接改表。这类接口内部会处理状态机流转、事件记录和下游通知,副作用可控。常见操作包括:将卡住的实例标记为 failed、clear 后重跑、mark success、终止运行中的 task instance。
# Airflow 风格(2.x CLI)
airflow tasks state failed
airflow tasks clear -t -s -e --only-failed
airflow dags list-runs -d --state running
# XXL-JOB / DolphinScheduler 等通过管理台或 REST API 终止实例
curl -X POST "$API/executor/kill" -d '{"jobId":123,"instanceId":456}'
一级处置能解决"记录存在但状态错误"的幽灵,对"记录根本不存在"的幽灵无效——后者需要二级处置。执行一级处置后,务必观察一个完整调度周期,确认没有再次触发。
4.2 二级处置:隔离与冻结
当幽灵执行体仍在运行且无法通过接口终止时,采取隔离措施:暂停该 Flow 的调度(pause),阻止新实例产生;在 Worker 侧终止进程或驱逐容器;必要时在数据库层将相关记录置为"冻结"状态(例如 state='frozen' 或加标记位),防止调度器误认领。冻结不是删除,保留现场供后续分析。
笔者认为,冻结状态应当成为调度系统的标准状态之一,而不是靠运维临时打标。标准化的冻结语义可以让视图、告警、清理工具统一识别,避免每个团队各写一套脚本。这是产品设计层面的改进点,成本不高但收益明显。
4.3 三级处置:受控的手工表操作
这是回答"后台表要不要手动动"的核心。结论是:可以动,但只能动实例层与任务实例层的残留记录,且必须满足四个前提——已备份、已确认无运行中进程、已确认无下游依赖等待该记录、已准备好回滚 SQL。定义层(DAG/Flow 定义、调度周期配置)和锁/租约表不应手工修改,前者会导致版本漂移,后者会破坏主备选举。
| 表类型 | 可否手工操作 | 说明 |
|---|---|---|
| 实例表 / 任务实例表 | 谨慎可动 | 仅删除/标记已确认的幽灵记录,先备份 |
| 事件/日志表 | 只读 | 审计证据,不应删除 |
| 定义表 | 禁止 | 通过版本化接口修改,避免漂移 |
| 锁/租约表 | 禁止 | 破坏选举一致性,可能引发更大范围脑裂 |
-- 清理前备份(务必先执行)
CREATE TABLE task_instance_bak_20240115 AS
SELECT * FROM task_instance WHERE run_id = 'ghost_run_xxx';
-- 标记而非删除(推荐)
UPDATE task_instance
SET state = 'failed', end_date = NOW()
WHERE run_id = 'ghost_run_xxx' AND state = 'running';
-- 确认后再删除(可选)
DELETE FROM task_instance WHERE run_id = 'ghost_run_xxx' AND state = 'failed';
注意 UPDATE 与 DELETE 的取舍。标记为 failed 保留了记录,视图可见,审计可查,且不会触发外键级联;直接 DELETE 更"干净"但丢失证据,且在某些系统中会触发级联删除日志表记录。本文建议默认用标记,只有在记录量影响查询性能时才考虑删除。
4.4 四级处置:数据补偿与幂等修复
幽灵已完成副作用时,清理记录只是第一步,还要处理重复产出。补偿方式取决于下游是否幂等:幂等写入可直接重跑覆盖;非幂等写入(如追加式日志、计数累加)需要先回滚再重跑。这一步必须与业务方确认口径,不能由运维单方面决定。
更根本的修复是给任务加幂等护栏。常见手段包括:以 (flow_id, execution_date) 作为唯一键写入;在任务入口做分布式锁;下游采用 merge/upsert 而非 insert。本文评述:幂等护栏不能替代调度器的一致性修复,但它是最后一道防线——当切换窗口无法完全消除时,幂等能把"重复执行"降级为"重复计算",避免演变成数据事故。
五、对比与局限:主流调度器的容错设计与共性缺陷
不同调度器对主备切换的处理差异很大,但共性缺陷清晰可见。下表按"选举机制、租约粒度、切换后收敛策略"三个维度对比几类典型实现(基于公开文档与社区讨论整理,具体版本行为以官方文档为准)。
| 系统类型 | 选举机制 | 租约粒度 | 切换后收敛 |
|---|---|---|---|
| 数据库租约型(多数自研/XXL-JOB 类) | 抢占心跳行 | 全局单租约 | 依赖新主扫描超时任务,窗口内不收敛 |
| 外部协调型(ZooKeeper/etcd) | 临时节点 + session | 会话级 | session 过期即失效,收敛较快 |
| 无状态分片型(部分云原生调度) | 一致性哈希 + 分片租约 | 分片级 | 仅影响单分片,爆炸半径小 |
| Quartz 集群型 | 数据库行锁 | 触发器级 | 靠 misfire 策略补偿,配置不当易重复 |
从表中可以看出,租约粒度越细,切换的影响面越小,但实现复杂度越高。全局单租约的方案最容易出现本文描述的幽灵任务,因为一次切换影响所有 Flow。本文评述:对于 Flow 数量大、周期密集的团队,向分片租约演进是值得投入的方向;而对于中小规模,更务实的做法是缩短租约阈值、增加切换后的全量对账,用工程手段弥补架构缺陷。
局限也要说清楚。本文的排查路径假设调度器有较完整的日志与可查询的元数据库,对于日志缺失或元数据加密的系统,取证会困难得多。此外,跨库分裂场景需要 DBA 配合核对连接指向,单靠应用侧日志难以定论。这些边界条件决定了本文方案不是万能的,落地时需按实际架构裁剪。
六、结论与展望:从"事后清理"到"切换即收敛"
回到最初的问题:Flow Schedule 跑两次但界面只有一个,本质是主备切换窗口内三副本状态未收敛,旧主发出的执行请求成为脱离管理的幽灵。清理的正确顺序是取证、冻结、收敛、补偿,后台表可以动但只限实例层,且必须备份与回滚预案。手动改表是止血手段,不是根治方案。
根治的方向有三个层次。第一层是触发原子化:把"决定触发"与"写入实例记录"放进同一事务或同一幂等操作,消除窗口。第二层是租约分片化:缩小单次切换的影响面,让幽灵只影响个别 Flow。第三层是切换后对账:新主接管时主动扫描执行器侧的在途任务,与数据库记录比对,发现孤儿立即终止或收养。第三层成本最低、见效最快,建议优先落地。
展望来看,调度系统正在从"定时触发器"演化为"状态协调器",对一致性的要求会越来越高。学术界在分布式协调与 exactly-once 语义上的积累(如基于逻辑时钟的去重、基于 fencing token 的隔离)完全可以引入调度领域。笔者认为,fencing token 机制尤其值得借鉴:每次主备切换生成单调递增的 token,执行器只接受 token 不小于本地记录的任务,旧主的请求会被自动拒绝。这比事后清理优雅得多,也更能经得起规模增长的考验。
拓展阅读(外部链接,新窗口打开):
主要参考文献
- Apache Airflow 官方文档:Scheduler HA 与 Task Instance 状态机,2024。
- XXL-JOB 官方文档:调度中心注册与故障转移机制,2023。
- Apache DolphinScheduler 官方文档:Master 容错与任务恢复,2024。
- Kleppmann M. How to do distributed locking. 2016.
- Kreps J. 关于日志与状态一致性的工程讨论(分布式系统实践资料汇编),2022。
- Quartz Scheduler 官方文档:集群模式与 misfire 策略,2023。
- etcd 官方文档:Lease 与 Leader Election 机制,2024。
- ZooKeeper 官方文档:Session 与临时节点语义,2023。
- 社区故障复盘资料汇编:调度系统主备切换重复执行案例(公开技术博客整理,模拟整合数据),2023–2024。
文章声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。
内容仅供学习参考。如需引用,请以原始文献为准。 全文约 6800 字 | 参考文献 60 篇(主要 9 篇)。

