第 3 章:把恢复需要的事实放进持久状态
进程里的变量能帮助执行当前尝试,却不能承担另一位 worker 接手时的全部判断。恢复所需的信息必须在原进程消失后仍然可查询,而且含义应当足够明确,让新进程不必猜测上一次函数执行到了哪一行。
3.1 用业务状态表达责任
示例把任务放在有限的状态集合里:等待审批、就绪、运行、结果未知、成功、取消、预算失败和版本不兼容。状态的作用是表达下一步允许谁做什么,不是把页面上的进度条切成更多颜色。
waiting_approval 表示意图已保存,但执行许可尚未成立;ready 表示可以参加领取;running 表示某个拥有者持有有期限的本地执行权;unknown 表示需要核查工具效果;succeeded 表示已经保存匹配回执。它们不能互相替代。
特别要保留 unknown。进程崩溃后,系统只知道上一项领取失去了继续确认的能力,并不知道工具是否执行。即使工具支持幂等,也先记录未知,再查询已有结果。查询为空、能力合同允许、取消与预算检查通过后,才允许使用原操作键再次排队。
3.2 工作流行是一份当前决策材料
一行工作流至少关联业务身份、不可变计划与指纹、计划版本、审批主体和期限、当前状态、拥有者、围栏编号、尝试次数与预算、租约期限、下次就绪时间、取消请求和结果回执。这些字段各自回答不同问题。
拥有者可以采用 worker 实例标识,但不能单独提供安全性。进程名可能被重复使用,一台机器也可能重新启动。围栏编号随着领取或权限失效而单调推进,旧拥有者携带旧编号时,即使名称相同也不能再写入。
审批期限与租约期限同样不同。前者约束业务许可,后者约束本地执行者的占有时间。延长租约不应自动延长审批;某个 worker 保持健康,也不能替原审批人同意额外等待几小时。
任务预算属于持久记录,而不只属于进程配置。否则新版本 worker 使用不同的默认重试次数,接手后就可能扩大原先承诺的执行范围。示例把最大尝试次数保存下来,使恢复进程和当前进程依据相同预算。
3.3 当前状态与事件共同提交
只保存当前状态,能够回答现在怎样,却难以解释为什么变成这样。事件表记录提出、批准、领取、租约失效、核查、重排和完成等决策。每条事件关联工作流身份和当时的围栏编号。
状态更新和它对应的事件应处于同一个本地事务。若状态已经成功而事件写入失败,观察者会缺少必要解释;若事件宣称成功但状态没有改变,又会出现相互矛盾的证据。
开始本地事务
条件更新当前工作流行
检查确实更新了一行
写入对应事件
提交本地事务
这段事务只覆盖工作流数据库。工具的积分账本在另一个事务里提交,不属于这里的原子边界。随书故障实验会故意让完成事件违反数据库约束,检查状态更新也被撤销,同时工具已经发生的效果仍然存在。
3.4 条件更新就是一部分状态机
状态机不一定需要一个大型框架。关键在于每次变更都验证前置状态、拥有者、围栏与期限,并检查受影响行数。若更新零行,不应直接当作成功,也不应立刻覆盖最新值。
例如旧 worker 想写入成功时,条件需要同时确认任务仍在 running、拥有者相符、围栏相符且租约没有到期。任何一项失效,都说明这次写入没有资格裁决当前状态。返回 stale 是一项正确拒绝,不是要求应用无限重试更新。
另一类条件属于核查者。它处理 unknown 时,应绑定核查开始时读到的围栏与状态。若并发取消改变了重排资格,或者另一位核查者已经保存结果,当前核查必须重新判断。不能因为手里有一份旧查询结果,就无条件把任务改回 ready。
3.5 持久化也有部署前提
PostgreSQL 会依据其事务、日志和持久性配置执行提交,但应用仍需说明它依赖的实例与读取位置。读到延迟副本中的旧状态,可能让已经完成的工作看起来尚未发生;跨数据库写入,也不会因为表名相同就共享事务。
随书实验在一个隔离 PostgreSQL 实例中运行,使用独立 schema 避免测试互相污染。它验证进程终止之后的数据库可见状态和恢复行为,没有把关闭 worker 等同于数据库断电,更没有把单实例结果说成高可用验证。
保存事件还需要保留与脱敏策略。事件应记录可解释决策所需的身份、原因和关联编号,通常不需要复制全部模型上下文、凭据或工具返回原文。删掉不必要的内容可以减少长期保存的敏感数据,但不能删除恢复仍然依赖的业务指纹与结果身份。
练习。 系统先更新工作流为成功,再通过另一个数据库连接写事件。第二次写入失败。请画出观察者可能看到的两种事实,并说明怎样用本地事务和受影响行数检查消除这个缺口。外部工具已经执行的部分,能否被这次本地回滚撤销?