单任务受控执行(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。
30 KiB
分布式设计说明
本文说明多节点部署的架构、边界与风险。
阅读顺序:§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. 已经就位的地基里,哪些是为分布式而做的
这些今天就有价值,但它们的存在理由包含"将来要多节点":
blake2b而不是内置hash()。 CPython 对str的hash()带进程级随机盐, 同一个键在不同进程会落到不同的桶。后果是"同桶任务在同一台机器上"静默失效。 有跨进程用例守着(真实子进程 + 不同PYTHONHASHSEED)。- 桶号记在任务上,而不是每次重算。 万一分桶策略或桶数变化,重算会把一个正在跑的 任务挪到别的桶,局部性与隔离假设随之悄悄失效。
bucket_key留存。 改桶数或改归属时,只有拿得到原始键才能重新分桶。- 按桶查询下推 + 索引。 "取我拥有的那组桶"是这套架构里最高频的查询。
- 持久层契约可换。 这是跨机部署的第 0 步——SQLite 文件不能跨机共享。
owner标识(默认主机名:pid)写进指令里,便于事后追溯是谁按的停止。
5. 选定架构:自治节点 + 统一平面
每个节点有自己的任务定义与触发方式;节点之间不互相执行对方的任务;但共用同一套存储与 控制层,靠桶区分"哪一份是我的"。
┌──────────────────────────────────────────────┐
│ PostgreSQL(统一存储) │
│ tasks / transitions / commands │
└───┬────────────────┬────────────────┬────────┘
│ │ │
┌────────▼──────┐ ┌──────▼────────┐ ┌────▼──────────┐
│ 节点 A │ │ 节点 B │ │ 运维 / CLI │
│ 自己的任务定义 │ │ 自己的任务定义 │ │ 统一观测与控制 │
│ 自己的定时触发 │ │ 自己的定时触发 │ │ --owned-buckets│
│ 桶 {0-15} │ │ 桶 {16-31} │ │ --num-buckets │
└───────────────┘ └───────────────┘ └───────────────┘
| 维度 | 自治(每节点自有) | 统一(全局共享) |
|---|---|---|
| 任务定义(函数、默认重试/超时、标签) | ✅ | — |
| 触发方式(定时、事件、手动) | ✅ | — |
| 负责的桶(按业务划分,可多个) | ✅ 声明 | — |
| 存储(任务状态、审计轨迹、指令) | — | ✅ 一套 |
| 控制层(API、CLI、停止/暂停、reconcile) | — | ✅ 一套 |
没有调度器进程,也没有跨节点的任务派发。 每个节点只做自己的事;统一平面提供的是 看得见与管得着。
6. 桶在这个架构里的三个作用
-
划界(核心)——节点只接受、只清理自己负责的桶:
- 提交时校验:任务落进别人的桶会在提交那一刻报错;
reconcile()只看自己的桶,不去动别的节点的任务。
-
隔离与公平——桶内并发限制(
max_parallel_per_bucket),一个租户的积压不饿死 其他租户。这是分桶在本地就有的价值,不必等到多节点。 -
将来迁移的最小单位——
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,静默堆积 |
❌ 挡不住 |
缓解手段:
- 提交时校验(已做)能挡住"写进别人桶"这一类;
- 监控"长时间未变化的
queued任务"——这是最有效的兜底,因为它直接盯住症状; - 把桶归属做成一处生成、多处使用的配置(配置即代码),从源头避免手写漂移。
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/一致性哈希分配 + 再均衡 | ✅ 必须 | ❌ 不需要(归属是配置) |
| 需要处理"接管时的在途任务归属" | ✅ 必须 | ❌ 不需要 |
| 弹性伸缩 / 算力池化 | ✅ 有 | ❌ 没有 |
| 跨机部署的复杂度 | 高 | 低(只需换存储后端) |
根因:定时/业务任务是有归属的——谁的数据谁处理,谁的定时谁触发。它不是一个 无差别的算力池,不需要"随便找台机器跑"。为它建集群,等于用一整套分布式协调去换一个 用不到的弹性。
从那个方案里仍然成立、值得记住的结论(它们与架构无关,是分布式本身的性质):
- 正确性应来自"认领任务的原子 CAS",而不是"桶的归属"——归属只是优化。自治方案里 这条依然成立:跨节点重复执行来自配置冲突,而不来自租约竞争。
attempt的递增必须在认领事务里,否则"认领后就崩溃"的任务会被无限重试。- 执行任务期间绝不能持有事务或行锁——任务可能跑几十分钟。
- 不要用
LISTEN/NOTIFY当投递机制(不持久、payload 有 8KB 上限、同事务内相同通知 会去重);它只能当延迟优化,轮询必须保留为真相来源。 - 不要用 advisory lock 表达持久所有权——它是会话级的,连接池会把它泄漏给后来的请求。
- at-least-once 是这类系统的常态(§13),任务作者要保证幂等。
18. 明确不做的事
- 不做跨节点的任务执行(节点只跑自己的任务)。
- 不做配方落库 / 跨节点认领 / 成员表 / rendezvous / 再均衡 / fencing——见 §17。
- 不做推送式调度器(没有中心调度进程)。
- 不做分布式信号量:桶亲和性已经给了近似全局的每桶并发上限。
- 不做资源感知调度:不感知 CPU/内存/GPU,不做装箱。
- 不做 exactly-once:见 §13。
- 不做跨节点 DAG 协调:管道结构不落库,分布式 DAG 是另一个数量级的问题。
- 不做多租户安全隔离:分桶给的是公平性与亲和性,不是安全边界。