Files
chongming-utils-task/docs/distributed-design.md
chakcy f4de43c743 初始提交:chongming-task 任务控制库
单任务受控执行(15 态状态机 + 审计轨迹)、DAG 管道、Controller 门面。
执行器三个:线程(默认,含 async def 任务)、内联(同步 / 测试)、进程
(spawn 子进程:硬性停止、绕开 GIL、崩溃隔离)。
调度:并发额度 + 每桶限流 + 具名资源信号量(按标签)+ 优先级排队。
持久层:TaskStore 契约 + 内存 / SQLite / Postgres 实现,契约测试随包发布。
跨进程与多节点:写穿 / 读穿、指令通道与租约、owned_buckets 划界、reconcile。
CLI、12 个可直接运行的示例、docs/ 下 5 份文档。

门禁:ruff / ruff format / mypy(strict) 干净;
      pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
      带一台可连的 Postgres 时 607 passed。
2026-10-08 23:47:50 +08:00

30 KiB
Raw Permalink Blame History

分布式设计说明

本文说明多节点部署的架构、边界与风险。

阅读顺序:§2 的边界表一眼说清今天能做什么、不能做什么;§5 是选定架构;§11 是这个 架构新增的风险;§17 记录了被否决的另一个方案及原因。

一句话:不是分布式集群,而是多个自治的定时任务节点 + 一套统一的存储与控制平面, 靠桶区分"哪一份是我的"。


1. 结论速览

层次 状态
稳定分片(桶) ✅ 已实现:同一个键在任何进程、任何机器上落到同一个桶
节点归属(我负责哪些桶) ✅ 已实现:Controller(owned_buckets=...)
统一存储(多节点共享任务状态) ✅ 已实现(默认后端是 SQLite 文件,跨机需要换后端)
统一控制(跨节点停止/暂停/查询) ✅ 已实现:指令通道 + 统一 CLI
判活与重启恢复 ✅ 已实现:监督者租约 + reconcile()
跨节点执行对方的任务 ❌ 刻意不做(见 §17)
弹性伸缩 / 负载均衡 ❌ 不做(定时任务有归属,不是无差别算力)

2. 边界表:今天能做什么

✅ 能做

能力 位置
同一个键稳定映射到同一个桶(跨进程、跨机器一致) policies.BucketPolicy(blake2b,不是内置 hash())
桶号与原始业务键记在每条任务上 TaskSnapshot.bucket / .bucket_key
声明本节点负责的桶 Controller(owned_buckets=...)
提交时校验归属(防止任务写进别人的桶) Controller._check_bucket_ownership()
每桶独立限流 Controller(max_parallel_per_bucket=...)
按桶查询,下推到存储层且有索引 TaskQuery.buckets / idx_tasks_bucket
多进程共享任务状态(同一台机器) SqliteTaskStore(WAL + 乐观并发)
一个进程控制另一个进程的任务 指令通道:submit_command / claim_commands / complete_command / release_command
崩溃后的清理,限定在自己的桶内 reconcile() + 监督者租约
换掉持久层而不改业务代码 TaskStore 契约 + 随包发布的 StoreContract
写穿失败后自动追上(不永久冻结) 写穿缓冲(见 §14)

❌ 不能做

做不到的事 原因
两台机器共享 SQLite 文件 网络文件系统的锁不可靠。跨机必须换网络后端
节点执行另一个节点定义的任务 架构决定:任务定义各节点自有,见 §5 / §17
任务在任意节点间漂移/迁移 归属是配置,不是调度结果
自动发现"某个桶没人负责" 需要一份中心归属声明,见 §11.3
检测"两个节点配了同一个桶" 同上

这里的关键区分是"共享"与"分工":本文解决的是共享(谁都能看到、谁都不会踩到别人), 分工由配置表达——每个节点声明自己的桶,不靠协商。


3. 今天的三条基础机制

这三条已经跑通,也是后面一切的地基。

3.1 乐观并发:一个任务只有一个写入者

save(snapshot, expect_version=...) 是单键 compare-and-set。冲突就抛 ConcurrencyConflict,绝不自动重试——因为每个任务的写入者唯一(它的持有进程), 冲突意味着存在第二个写入者,那是设计缺陷。

reconcile() 直接依赖这条:发现版本冲突时它放手,因为"原来的监督者又活过来了"。

3.2 指令通道:控制意图怎么跨进程

一个进程无法给另一个进程的执行载体发信号,所以 stop / pause / resume 变成指令:

控制方                          存储                          持有者
  stop(T1)  ──submit_command──▶ [c1: stop T1]  ◀──claim──  worker-1
                                  (租约 30s)                 │
                                  [c1: done]   ◀──complete── 执行 run.stop()

取走指令时打租约:取走者崩溃后租约到期,别人可以重取。 取到不属于自己的任务时交还(release_command)而不是标记完成——后者会静默丢 指令,前者让真正的持有者马上能取到。

这条通道本来就是跨节点的,只是单机部署时收发双方碰巧同机。所以"统一控制平面"不需要 任何新机制。

3.3 监督者租约:谁还在管这个任务

每个监督进程定期给自己负责的任务续租(lease_owner / lease_until,经 touch 高频 旁路写入)。reconcile() 据此判定僵尸任务。

它与任务心跳是两件事,不能互相推导:

字段 谁写 回答
last_heartbeat_at 任务自己的 checkpoint() 任务在推进吗(停滞 = 卡死)
lease_until 监督进程续租 还有人在管它吗(过期 = 监督者没了)

用任务心跳判断进程存活,会把一个长时间不调 checkpoint() 的健康任务误判为失联并 判 LOST——把一个正常任务杀掉。这条在持久层契约里也钉死了:save 不得写租约列。


4. 已经就位的地基里,哪些是为分布式而做的

这些今天就有价值,但它们的存在理由包含"将来要多节点":

  1. blake2b 而不是内置 hash()。 CPython 对 str 的 hash() 带进程级随机盐, 同一个键在不同进程会落到不同的桶。后果是"同桶任务在同一台机器上"静默失效。 有跨进程用例守着(真实子进程 + 不同 PYTHONHASHSEED)。
  2. 桶号记在任务上,而不是每次重算。 万一分桶策略或桶数变化,重算会把一个正在跑的 任务挪到别的桶,局部性与隔离假设随之悄悄失效。
  3. bucket_key 留存。 改桶数或改归属时,只有拿得到原始键才能重新分桶。
  4. 按桶查询下推 + 索引。 "取我拥有的那组桶"是这套架构里最高频的查询。
  5. 持久层契约可换。 这是跨机部署的第 0 步——SQLite 文件不能跨机共享。
  6. owner 标识(默认 主机名:pid)写进指令里,便于事后追溯是谁按的停止。

5. 选定架构:自治节点 + 统一平面

每个节点有自己的任务定义与触发方式;节点之间不互相执行对方的任务;但共用同一套存储与 控制层,靠桶区分"哪一份是我的"。

        ┌──────────────────────────────────────────────┐
        │  PostgreSQL(统一存储)                       │
        │  tasks / transitions / commands              │
        └───┬────────────────┬────────────────┬────────┘
            │                │                │
   ┌────────▼──────┐  ┌──────▼────────┐  ┌────▼──────────┐
   │ 节点 A         │  │ 节点 B         │  │ 运维 / CLI     │
   │ 自己的任务定义  │  │ 自己的任务定义  │  │ 统一观测与控制  │
   │ 自己的定时触发  │  │ 自己的定时触发  │  │ --owned-buckets│
   │ 桶 {0-15}      │  │ 桶 {16-31}     │  │ --num-buckets  │
   └───────────────┘  └───────────────┘  └───────────────┘
维度 自治(每节点自有) 统一(全局共享)
任务定义(函数、默认重试/超时、标签) ✅ —
触发方式(定时、事件、手动) ✅ —
负责的桶(按业务划分,可多个) ✅ 声明 —
存储(任务状态、审计轨迹、指令) — ✅ 一套
控制层(API、CLI、停止/暂停、reconcile) — ✅ 一套

没有调度器进程,也没有跨节点的任务派发。 每个节点只做自己的事;统一平面提供的是 看得见与管得着。


6. 桶在这个架构里的三个作用

  1. 划界(核心)——节点只接受、只清理自己负责的桶:

    • 提交时校验:任务落进别人的桶会在提交那一刻报错;
    • reconcile() 只看自己的桶,不去动别的节点的任务。
  2. 隔离与公平——桶内并发限制(max_parallel_per_bucket),一个租户的积压不饿死 其他租户。这是分桶在本地就有的价值,不必等到多节点。

  3. 将来迁移的最小单位——bucket_key 已留存,改桶数或换归属都有依据。


7. 统一平面给了你什么

既然节点不跑对方的任务,"统一"的全部回报就是可观测与可控制:

回报 靠什么
一个看板看全部,不必逐台登录 所有任务在一张表里,ps / stats 直接查
跨节点控制 已有的指令通道:stop / pause / resume 别的节点正在跑的任务
统一的审计轨迹 所有状态迁移在一张表里:谁的、什么时候、为什么
重启恢复各自负责,但对运维可见 每个节点只 reconcile 自己的桶,而"哪个桶有僵尸"全局可见
能发现"没人管"的任务 见 §11.2——这是统一平面唯一能做而分散存储做不到的事

最后一条最重要:分散存储时,"某个桶的节点已经下线,它的任务在静默堆积"是不可发现的。


8. 节点归属:owned_buckets(已实现)

# 节点 A:负责 0..15 号桶(按业务划分,可以多于一个桶)
controller = Controller(
    store=SqliteTaskStore("tasks.db"),
    owner="node-a",
    owned_buckets=range(0, 16),
)

# 单机,或不构成划分的节点:不限定(默认)
controller = Controller(store=store)  # owned_buckets=None

行为:

成员 说明
owned_buckets 本节点负责的桶;None 表示不限定
owns_all_buckets 是否不构成划分:不限定,或显式覆盖了全部桶
owns_bucket(b) 某个桶是否归本节点(只回答归属,不回答"无桶任务能否提交")
submit() 限定时,任务必须落在自己的桶里,否则 ConfigurationError
reconcile() 限定时,只清理自己桶里的僵尸任务

两条判据上的细节:

  • 覆盖全部桶 = 不构成划分,此时无桶任务同样会且只会由本节点执行,因此不再强制 bucket_key(否则生成出来的"负责 0-63"配置会带来纯摩擦)。
  • 提交时是硬错误而不是警告:落在别人桶里的任务不会有任何节点去跑它,它会永远停在 queued,而且没有任何报错。与其让它静默堆积,不如在提交那一刻就说清楚。

启动时校验越界桶号(0 <= bucket < num_buckets):放过去的症状同样是"任务永远排队且 没有报错",那是最难查的一类问题。


9. 命令轮询:声明了归属就从启动开始(已实现)

Controller 在声明了 owned_buckets 时,构造那一刻就启动指令轮询线程。

否则有一个延迟生效的坑:别人 stop 一个属于本节点、当前还排在队列里的任务时,指令已经 写进存储了,但本节点要等到第一次提交才会启动轮询——对一个定时任务节点来说,那可能是 几分钟甚至几小时之后。

没有声明归属的实例(纯查询客户端)仍然不轮询,避免抢走别人的指令——这是早先就定下的 规则,两条一起成立的判据是"你是不是一个真 worker"。


10. 运维视图(CLI)

# 声明本节点负责的桶与集群桶数(两者都必须与部署一致)
chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15,20 \
    ps --all --mine

# 每个桶有多少任务;星号标出本节点负责的桶
chongming-task --db tasks.db --num-buckets 64 --owned-buckets 0-15 buckets

# 重启恢复:只清理自己桶里的僵尸
chongming-task --db tasks.db --owned-buckets 0-15 reconcile --dry-run

--num-buckets 必须显式给出(或确认默认值 64 与部署一致):桶数是部署范围的约定, 各节点不一致会让同一个键落到不同的桶,而且不会有任何报错。

桶归属的校验在做任何事之前完成。 有些命令(ps / stats / buckets)直接查存储、 不构造 Controller;如果把校验只放在 Controller 里,越界的桶号在这些命令上会被静默 接受——筛选条件匹配不到任何东西,看起来就像"这个桶没有任务"。


11. 这个架构新增的风险(诚实)

"各司其职"用配置换掉了协调,代价是没有东西替你检查配置。

11.1 配置漂移

情况 后果 今天能挡住吗
两个节点配了同一个桶 两个都可能执行同一个任务 ❌ 挡不住。提交校验只能挡"写进别人的桶"
某个桶没人配 它的任务永远 queued,静默堆积 ❌ 挡不住

缓解手段:

  1. 提交时校验(已做)能挡住"写进别人桶"这一类;
  2. 监控"长时间未变化的 queued 任务"——这是最有效的兜底,因为它直接盯住症状;
  3. 把桶归属做成一处生成、多处使用的配置(配置即代码),从源头避免手写漂移。

11.2 孤儿任务

拥有某桶的节点下线后,它的任务没人跑,也没人报警——这是"各司其职"的固有代价。

统一存储恰好能发现它,而且只有统一存储能:

非终态任务 + 超过 N 分钟没有状态变化 + 该桶没有活着的节点  →  告警

最后那个条件需要知道"谁负责哪个桶"(见 §11.3)。

11.3 桶归属需要一个可审计的声明(建议,未实现)

今天 owned_buckets 只存在于各节点自己的配置里,中心看不到,因此无法检测重复与空洞。 建议加一张静态声明表:

CREATE TABLE node_buckets (
    bucket     INTEGER PRIMARY KEY,
    node       TEXT        NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

节点启动时把自己的桶写进这张表(ON CONFLICT (bucket) DO UPDATE),并在冲突时告警 ("桶 7 同时被 node-a 与 node-b 声明")。

⚠️ 注意它与被否决方案里的"桶租约表"是两件完全不同的事(见 §17): 那是动态抢占的运行时状态(在本架构里多余);这是静态归属的声明(用于审计与 告警,不参与抢占,也不决定谁能执行)。同名不同物,别混淆。

11.4 跨节点停止的送达

stop 一个属于别的节点、当前没在跑的任务会写一条指令,等那个节点取走。所以节点必须 持续轮询——这一条已经在 §9 落地。

指令的语义仍然是"请求已受理",不是"已停止":要确认结果得查状态。

11.5 不能跨节点重跑

存储里没有参数(见 §13),所以谁也不能重跑别人的任务——本来就是这样,现在更明确: 连"将来做跨节点重跑"都不必考虑了。重跑只能由拥有该任务定义的节点自己做。


12. 做的时候不能破的不变量

这些是已经用测试和文档钉死的性质。破了任何一条,故障都是静默的。

不变量 破了会怎样
每个任务只有一个写入者 乐观并发冲突变成常态;reconcile 会不断"放手"
save 不写租约列 一次状态迁移把租约覆盖回旧值 → 活着的任务被误判失联并杀掉
心跳必须先同步进内存快照(refresh_activity) save 会写心跳列,而快照不知道 touch 写了什么 → 看板上的心跳被覆盖回空
release_command 不标记完成 静默丢指令,真正的持有者永远收不到
touch 不递增版本号 高频写入与状态写入持续冲突,版本号变噪声
save 的版本号单调但不连续 写穿缓冲恢复后要能一次跳进;要求"只接受 +1"等于重新引入永久冻结
CANCELLED 不是 fail_fast 的触发条件 用户主动取消被记成失败;连坐再触发连坐
判活靠租约,不靠任务心跳 长时间不 checkpoint 的健康任务被误杀
blake2b,不用内置 hash() 跨进程分桶静默错位
桶数全局一致 同一个键落到不同的桶,无任何报错
返回值为 JSON 可序列化(见 §14) 存储里停在 running,重启后被判 LOST

关于时钟

租约判定用的是本地时钟(snapshot.lease_until > self._clock()),而 lease_until 由另一个进程用自己的时钟算出。同机或 NTP 良好时不是问题(TTL 默认 30 秒,NTP 偏差通常 毫秒级)。迁移到 Postgres 后建议改用存储侧 now() 做租约裁定(见 §15),那样这个 假设就彻底消失了。


13. 语义上的诚实:它是 at-least-once

必须说清楚的事实:

  • 被 stop / cancel 的任务可能已经产生了副作用。系统把它记为 stopped / cancelled,而不是假装什么都没发生。
  • 进程崩溃时,任务可能仍在后台运行。此时状态是 LOST——"结果未知",不是 "没跑过"。把"未知"说成"已停止"会让人放心地重跑,而副作用已经发生两次。
  • reconcile() 不会重新执行任何任务(存储里只有状态,没有参数)。重跑要靠调用方 自己 submit,而那需要调用方知道参数。
  • 因为节点不跑对方的任务,跨节点的重复执行只可能来自"两个节点配了同一个桶" (§11.1),而不是来自租约竞争。

一句话:它报告真相,不提供幻觉。


14. 已修复的缺陷(记录)

这两处曾是真实的数据损坏,现在都有回归用例。记录在这里,因为它们解释了若干不变量的来历。

14.1 一次写穿失败会让任务在存储里永久冻结

_persist 曾用 expect_version = transition.version - 1,这个式子假设此前每一次迁移 都成功落库。一旦某次 save 失败(网络抖动、连接被断、返回值不可序列化……),存储停在 k-1 而内存已到 k;此后每一次写入都因版本对不上而冲突——一次瞬时故障让该任务的存储 状态永久冻结,最后被 reconcile() 判成 LOST。

修法:记录上次确实写进存储的版本(不是上次算出来的版本),未落库的迁移进有界缓冲, 下次迁移时一并补上;close() 时再尽力补一次。用"上次确实写进去的版本"仍然冲突时, 判定为外部写入者并停止写穿(Controller.desynced_tasks),而不是反复重试或覆盖 对方。

修复前                                 修复后
进程内 succeeded                        进程内 failed(result_invalid)
存储里   running  version=3             存储里   failed  version=4
历史     [queued, starting, running]    历史     [queued, starting, running, failed]

(一次网络抖动)
存储里   starting version=2(永久)      存储里   succeeded version=4
历史     [queued, starting]             历史     [queued, starting, running, succeeded]

14.2 非法返回值会静默损坏存储状态

返回值会写入存储(outcome 列),因此必须 JSON 可序列化。不校验的话,失败发生在写穿 路径上——而那条路径的失败只被记录、不会让任务失败,结果是进程内显示 succeeded、 存储停在 running,随后 reconcile() 把一个真正成功过的任务判成 LOST。

修法:TaskRun._settle() 在 mark_succeeded 之前校验,不可序列化就让任务失败 (失败类型 result_invalid),并且不重试(同一个值重试多少次都一样)。

顺带修掉的另一个独立缺陷:CancellationToken.checkpoint() 只在进入暂停分支时设置 _ack,于是 resume() 之后紧接着的 request_pause() 会因为任务线程仍在等待循环里 而永远得不到确认——表现是"暂停 → 恢复 → 再暂停"里第二次暂停永久失效。


15. PostgreSQL 迁移建议

替换默认持久层(SQLite 文件)是跨机部署的第 0 步:SQLite 的网络文件系统锁不可靠, 不要跨机共享。

已实现:chongming_task.stores.postgres.PostgresTaskStore (pip install "chongming-task[postgres]";核心包仍是零依赖——没装驱动时它惰性报出安装 命令)。CLI 同样认连接串:--db 'postgresql://…'。§15.2 的硬要求全部满足(StoreContract 在真库上全绿),§15.4 的三条并发用例已固化在 tests/integration/test_postgres_store.py 里(不给 DSN 就跳过,不会默默变绿)。

本节其余部分保留为设计依据:它解释了为什么是这些要求,以及实现里刻意偏离了 哪些建议——偏离处都记在 §15.1 与 §15.3 的注里,不留"文档说 A、代码做 B"的暗角。

15.1 Postgres 解锁了什么

能力 对现有设计的直接影响
服务端 now() 租约裁定不再依赖"各节点时钟有界漂移"(§12 的隐患直接消失)
SELECT … FOR UPDATE SKIP LOCKED 认领不再需要"先查后写",多个 worker 互不阻塞
JSONB + GIN 标签精确匹配不必再用 LIKE '%"io"%' 这种权宜之计
事务 + 行锁 乐观并发从"唯一防线"降级为"第二道保险"

时间处理建议:把"业务时间"与"租约裁定时间"分开——created_at / started_at / ended_at / last_heartbeat_at 仍用应用时钟(Clock 可注入 → 可测试);租约的 "过期了吗"一律用存储侧 now()。

实现时的偏离(重要,也是上表里唯一没兑现的一行):租约裁定用的仍是调用方传入的 now,没有换成存储侧 now()。原因在契约:claim_commands(*, now, lease_seconds, …) 的时间由调用方给出,StoreContract 正是靠注入一个固定时钟才能可重复地测"租约到期 / 未到期";后端若自作主张改用服务端时钟,契约测试与"时钟可注入"这条设计会同时失效。 所以 §12"关于时钟"的假设依旧成立(各节点时钟需有界漂移,租约 TTL 30 秒足够宽容), 要真正兑现这一行得先改协议——让契约接受"时间由存储决定",那是独立的一次变更。

其余三项都已用上:SKIP LOCKED 认领、JSONB + GIN 精确标签、TIMESTAMPTZ 行锁事务。

15.2 契约上的硬要求

要求 为什么
save 的"校验 + 写入"必须原子 否则跨进程下版本检查形同虚设
版本号单调但不连续:只要求 expect_version == 行里的版本 写穿缓冲恢复后会一次跳进;若后端要求"只接受 +1"就等于重新引入 §14.1 的冻结
不要自动重试冲突 冲突意味着存在第二个写入者,是设计缺陷
claim_commands 的"挑选 + 打租约"必须原子 否则同一次停止被下发两次
append 幂等(task_id + version) 崩溃恢复与写穿补写都会重放
save 不写租约列 否则活着的任务被误判失联并杀掉

15.3 Schema 与索引建议

  • 时间列一律 TIMESTAMPTZ,不要 timestamp(后者无时区,跨机必然出错)。
  • tags / progress / failure / outcome 改 JSONB:标签查询变成 WHERE tags @> '["io"]'::jsonb + GIN 索引。
  • 未完成行用部分索引(认领类查询只关心未完成的,而表在持续变大):
CREATE INDEX ON commands (created_at) WHERE finished_at IS NULL;
CREATE INDEX ON tasks (bucket, created_at) WHERE status = 'queued';
CREATE INDEX ON tasks (lease_until) WHERE status IN ('starting','running','pausing','stopping');

两处没照做的索引(有意为之):(lease_until) WHERE status IN (…) 与 (bucket, created_at) WHERE status = 'queued' 都没有建。不是省事,而是当前代码路径 用不上它们,建了就是纯写入开销:

  • 租约过期判定发生在 Python 里——Controller.reconcile() 用 store.query() 取回 非终态任务后逐条对照 lease_until,SQL 里没有 lease_until 谓词,索引无从命中;
  • query() 是通用查询,拼出来的是 status = ANY($1)——即使只传一个值,规划器也无法 证明它等价于 status = 'queued',部分索引不会被选中。

什么时候该加:把租约判定下推成 WHERE lease_until < …(那是一次查询接口变更),或给 query() 加"已知单一状态"的快路径。在那之前,建成的 idx_tasks_status / idx_tasks_bucket / idx_tasks_created_at 覆盖的是现有查询形状。

  • transitions 只增不减:按月分区或定期归档。prune() 在规模上不够用。
  • 连接池:TaskStore 会被多线程共享(SQLite 实现用 check_same_thread=False + 自己的 锁)。Postgres 实现应当用真的连接池,并把 close() 的语义写清楚。

15.4 契约测试的盲区:并发测试必须自建

StoreContract 只测单连接语义——设计使然(它不依赖任何外部环境,所以能随包发布)。 Postgres 后端必须额外自证:

要验证的 期望 已固化的用例
N 个连接并发 save 同一任务 恰好一个成功,其余 ConcurrencyConflict ✅ 8 线程 + 屏障,断言"1 个 ok + 7 个 conflict"
N 个连接并发 claim_commands 同一条指令不会被两个连接取到 ✅ 8 个消费者抢 20 条指令,断言无重复且全部认领
pg_terminate_backend 杀掉持有者 租约仍能正常过期并被接管 ✅ 杀连接后:期限内取不到、过期后可接管、池自愈

用例在 tests/integration/test_postgres_store.py(CHONGMING_TEST_POSTGRES 给 DSN 才跑)。 不给 DSN 是 skip 而不是 pass——"跨进程没测"这件事必须显式可见。

另一个已经踩过的坑:InMemoryTaskStore 直接持有对象、从不序列化,所以"内存后端 能跑"不等于"真后端能跑"。类型/序列化类的契约必须在会序列化的后端上测。


16. 落地清单

阶段 内容 状态
0 修掉写穿永久冻结与非法返回值(§14) ✅ 已完成
1 节点桶归属:owned_buckets + 提交校验 + reconcile 范围 ✅ 已完成
2 CLI:--owned-buckets / --num-buckets / ps --mine / buckets 标记 ✅ 已完成
3 声明归属的节点从启动就轮询指令(§9) ✅ 已完成
4 统一存储换成 Postgres(§15) ✅ 已完成:PostgresTaskStore(可选 extra,契约 + 并发用例全绿)
5 桶归属的中心声明 node_buckets + 重复/空洞告警(§11.3) 待做:运维需求
6 孤儿任务告警(§11.2) 待做:运维需求

阶段 4 已完成,多节点可以跨机了(共用一套 Postgres):各节点仍是自治的,靠 owned_buckets 划界,跨节点控制走指令通道。阶段 1–3 在单机上也有价值:提交校验能挡住配错桶, reconcile 的范围更准,CLI 能看"我这一份"。

跨机之后仍未解决的是运维可见性(阶段 5 / 6):两个节点配了同一个桶、或某个桶没人管, 现在依然不会自动报错——那两条是纯运维需求,与存储后端无关。


17. 附:被否决的方案——"任意节点可跑任意任务"

早先设计过一个真正的集群方案:提交者把任务写进共享存储,任意节点都能认领并执行它, 节点通过成员表 + rendezvous 哈希分配桶,桶带租约,支持弹性伸缩与抢占接管。

它被否决,因为它解决的问题不是这里的问题。

能力 集群方案 自治方案
需要持久化"配方"(函数名 + 参数) ✅ 必须——别的节点要能重建这次执行 ❌ 不需要——任务只在定义它的节点上跑
需要跨节点原子认领(FOR UPDATE SKIP LOCKED + 在事务里递增 attempt) ✅ 必须(防重复执行) ❌ 不需要(没人认领别人的任务)
需要成员表、心跳、租约、epoch fencing ✅ 必须 ❌ 不需要
需要 rendezvous/一致性哈希分配 + 再均衡 ✅ 必须 ❌ 不需要(归属是配置)
需要处理"接管时的在途任务归属" ✅ 必须 ❌ 不需要
弹性伸缩 / 算力池化 ✅ 有 ❌ 没有
跨机部署的复杂度 高 低(只需换存储后端)

根因:定时/业务任务是有归属的——谁的数据谁处理,谁的定时谁触发。它不是一个 无差别的算力池,不需要"随便找台机器跑"。为它建集群,等于用一整套分布式协调去换一个 用不到的弹性。

从那个方案里仍然成立、值得记住的结论(它们与架构无关,是分布式本身的性质):

  1. 正确性应来自"认领任务的原子 CAS",而不是"桶的归属"——归属只是优化。自治方案里 这条依然成立:跨节点重复执行来自配置冲突,而不来自租约竞争。
  2. attempt 的递增必须在认领事务里,否则"认领后就崩溃"的任务会被无限重试。
  3. 执行任务期间绝不能持有事务或行锁——任务可能跑几十分钟。
  4. 不要用 LISTEN/NOTIFY 当投递机制(不持久、payload 有 8KB 上限、同事务内相同通知 会去重);它只能当延迟优化,轮询必须保留为真相来源。
  5. 不要用 advisory lock 表达持久所有权——它是会话级的,连接池会把它泄漏给后来的请求。
  6. at-least-once 是这类系统的常态(§13),任务作者要保证幂等。

18. 明确不做的事

  • 不做跨节点的任务执行(节点只跑自己的任务)。
  • 不做配方落库 / 跨节点认领 / 成员表 / rendezvous / 再均衡 / fencing——见 §17。
  • 不做推送式调度器(没有中心调度进程)。
  • 不做分布式信号量:桶亲和性已经给了近似全局的每桶并发上限。
  • 不做资源感知调度:不感知 CPU/内存/GPU,不做装箱。
  • 不做 exactly-once:见 §13。
  • 不做跨节点 DAG 协调:管道结构不落库,分布式 DAG 是另一个数量级的问题。
  • 不做多租户安全隔离:分桶给的是公平性与亲和性,不是安全边界。