这是
Health4All在mhts v5设计过程中沉淀的一条工程原则。
上一篇文章 档位选择不是一次性决策 讲了从档位 2 到档位 3 的决策;这篇文章讲一旦选定档位 3 后,多套状态机并存时谁说了算。
关联 ADR:0008-multi-host-task-system-temporal-kernel.md。
TL;DR
当一个业务实体(比如"任务")同时被多个系统描述时,只能有一个系统是真相源。
其他系统都是投影(单向派生)、意图输入(人工通过它表达),或审计视图(只读)。
任何"两个系统都能改同一个字段"的设计,迟早会出"两边都对但不一致"的 case。
问题的样子
mhts v5 里,一个任务的"状态"会出现在四个地方:
- Temporal workflow 的 internal state(生命周期: created → validated → gate_pending → … → succeeded)
tasks表的status列(PostgreSQL,业务侧查询用)- Redmine 的
Hermes Status自定义字段(用户和 PM 看的地方) - Redmine 的原生
status字段(开箱即用,但传统人工操作也写它)
任何一个系统能写两个以上 → 终局混乱:
- workflow 显示 succeeded,Redmine 显示 new → 谁对?
- PM 在 Redmine 改 status 为 closed,但 workflow 还在跑 → 谁覆盖谁?
- tasks 表显示
succeeded,但 workflow 显示failed因为 OOM 触发补偿?
错误的解法
❌ “双向同步”
听起来好:两个系统之间相互同步,最终一致。
实际坏:任何“短暂不一致”都会进入“哪个先到”的竞态。你不知道 worker A 看到的是哪个版本。你写补偿逻辑时,补偿写谁的字段?写另一个字段时,哪个先写?
最终: 同步链 = 死锁链。
❌ “中间层统一读写”
听起来也好:加一个中台,所有读写都走中台,中台负责同步。
实际坏:中台成了第五个真相源。“中台显示 X,但 Redmine 显示 workflow,y 是 Z”——你只是把混乱挪了个地方。
中台只在真有业务量时才有用(因为减少 N 次系统协调的逻辑可以收敛到一处)。在 2-3 worker 规模下,这是 over-engineering。
❌ “分布式事务 / Saga / TCC”
听上去最对: 用工业标准的分布式事务模式。
实际坏:成本不匹配。分布式事务/Saga 的复杂度在"事务协调器"上——一个状态机 + 补偿逻辑 + 重试策略。这个复杂度本身需要被管理,又引入新的真相源问题(谁负责"已经补偿"这个状态?)
mhts v5 不是金融交易。Saga 是给"必须原子化" 的业务用的。一个任务跑了 30 分钟、失败、重试 5 次、最终放弃 —— 它的生命周期里没有人会追究"是不是原子地失败"。所以分布式事务的复杂度不值得。
正确的解法:单向投影 + 人工走 Adapter
我们在 mhts v5 P0-1 决议里定的真相优先级:
workflow > tasks 表 > Redmine
逐字解释:
- workflow 是真相源。任何时候,任务是"在 Temporal 里的当前状态"。
tasks表是 workflow 的业务查询投影。它是给"管理台/审计" 用的只读视图,由 activities 在状态迁移时写入(同一次 activity 内连 Redmine 回写,所以"DB+投影" 至少最终一致)。- Redmine 是用户展示投影。PM 看的页面。Redmine 原生
status字段不回写(避免和人工操作打架)。Hermes Status 字段承担自动化状态展示。 - 人工操作 Redmine 改 status → Adapter 监听到 → 转 signal 表达意图 → workflow 收到 signal 后处理。人工不直接改 tasks/workflow。
四个角色的"能写什么":
| 系统 | 能不能改 workflow | 能不能改 tasks | 能不能改 Redmine |
|---|---|---|---|
| Temporal workflow (activities) | ✅ 唯一 | ✅ (迁移时) | ✅ (Hermes Status 投影) |
| Redmine 用户(人工) | ❌ 经 Adapter 转 signal | ❌ | ✅ 自己的字段(原生 status) |
| Adapter | ❌ (只发 signal) | ❌ | ✅ (响应人工 action) |
| 管理台/查询 | 只读 | 只读 | 只读 |
关键不变量:
- workflow 是唯一能改变 “任务生命周期状态” 的组件。任何其他组件想表达状态变化意图,只能通过 signal。
- Redmine 原生 status 是用户的领域,自动化不碰它。 自动化用独立的
Hermes Status字段。 tasks表的status是 workflow 状态在关系数据库里的副本,workflow 拥有"最终解释权"。- 不一致发生时,reconcile 任务以 workflow 为基准重写下游(
describe出来的当前状态是 ground truth)。
为什么"workflow 是真相"是对的?
不是所有系统都能当真相。挑真相源有几个原则:
1. 真相源必须是确定性的
Temporal workflow 是基于事件溯源 + 状态机重放,给定同样的 input sequence,它只能到达同样的状态。这是"确定性"。
tasks 表不是——任何人都能 UPDATE 它(即使有约束,数据库本身不阻止)。如果让它当真相,你需要为每一条 UPDATE 写审计、对账、回滚。
Redmine 更不是——它是给 PM 用的 UI,人工点击、批量导入、API 调用都能改状态。
只有确定性系统才能当真相源。非确定性的,只能当"投影" 或"输入"。
2. 真相源必须自己有恢复能力
如果真相源崩溃,系统能否恢复?
- Temporal workflow: 可以从事件历史重放 ✅
tasks表: 必须从 Redmine 或 workflow 重建 ⚠️- Redmine: 不能,人工写的状态无法重建 ❌
只有 workflow 满足"自恢复"——所以它必须是真相。
3. 真相源必须有全局唯一 ID
给一条消息钉"现实":必须有全局唯一的标识符。
- workflow 有
workflow_id = redmine-issue-{issue_id}✅ (全局唯一 + 可追溯) - tasks 表的 row_id 是 PG 自增 ⚠️ (数据库内唯一,但跨系统不唯一)
- Redmine issue id 唯一 ✅ (但人工改字段不影响 issue id)
workflow_id 同时满足“业务含义”和“系统标识”——比 DAG 多也好。
违反原则的反例
我(指 Health4All)见过的"多个系统都能改状态" 案例:
| 系统 | 失败模式 | 后果 |
|---|---|---|
| 两套 CRM 同步客户状态 | A 显示 active,B 显示 churned | 客服按 A 跟进,客户已流失 |
| 订单系统 + 库存系统 + 财务系统都改 status | 同一订单 3 个 status | 财务记账找不到状态,客服查到过期 |
| CI 系统 + 发布系统 + 监控系统 都改 “deploy_status” | 监控显示 success,CI 显示 fail | 没人敢回滚,也不敢继续 |
每个案例的根因相同:没有一个组件被指定为真相源,或者"指定了" 但没有 enforcement(代码不阻止其他系统写)。
落地的 5 个具体动作
如果你的系统也面临"多系统状态同步",从这 5 件事开始:
1. 写 ADR 明确真相优先级
格式:系统 A > 系统 B > 系统 C,每一级说明"为什么这一级是上一级的真相"。
2. 在代码里 enforce
不是"我们约定"——是 schema、约束、API 都阻止越级写。
我们的实现:
tasks.status列加 trigger,只允许特定 service account 写- Redmine API key 给 adapter 单独的,普通用户不能调"写 Hermes Status"
- workflow_id 是 issue id 的派生,不允许自定义(防止 ID 冲突)
3. reconcile 任务必须有
哪怕"按设计永远不会不一致",也要有 reconcile。原因:总有 bug,总有人手抖,总有数据迁移。
我们的 reconcile 每 5 分钟跑一次,以 workflow describe 输出为基准重写 tasks.status 和 Redmine.Hermes Status。
4. 监控"不一致"事件
每次 reconcile 检测到不一致,应当触发告警 + 落档 incident。不一致不是"噪音",是"设计有漏洞" 的信号。
我们的告警:`reconcile_drift_total > 0 for 5m > SRE 通知。
5. 人工操作只能通过专用通道
PM 想改任务状态?不能直接点 Redmine → 必须走 Adapter → Adapter 发 signal → workflow 处理。
听起来麻烦。但这是唯一能保证真相不被污染的方式。如果嫌麻烦,说明这个系统的状态并不重要——那就可以接受"多真相源"的混乱。
与 /agents/ 的连接
mhts v5 的 P0-4 决议(心跳超时归属)和 P0-1(状态所有权)是同一族问题:
- P0-1: 状态的"权威性"归谁?
- P0-4: 状态变化(超时)的"裁决权"归谁?
答案都是同一个组件。在我们的实现里,是 Temporal workflow + 自建 CP。P0-1 保护的是静止状态,P0-4 保护的是状态转移的合法性。
链接
- ADR 0008: 多机任务系统 — Temporal 工作流内核(v5 路线)(仓库历史资料)
- mhts v5 设计全文(v3.1 定稿)(仓库历史资料)
- 上一篇文章:档位选择不是一次性决策
/agents/— 5 条工程经验提炼,本条是其中第 2 条
如果你在做多系统集成:别急着画同步箭头图。先花 30 分钟回答:谁说的算? 答案有了,设计就有了。
如果你评审别人的设计:问"如果系统 A 写完状态、系统 B 还没更新,系统 A 崩了,谁来收尾?" 如果答不上来,真相源没找对。
— Health4All · 2026-09-20