多步业务常见于批量导入、审批编排、账务调整、配置发布和外部资源变更。它的难点不在"按顺序执行几条 SQL",而在于:中途失败如何恢复、操作能否重放、回滚是否安全、进程重启后如何继续。本文给出一种基于 MySQL 持久化状态机、历史快照和游标的设计。
一、先澄清:这里的 redo/undo 不是 InnoDB 日志
InnoDB 的 redo log 用于崩溃恢复和持久性,undo log 支撑事务回滚与 MVCC。这些属于数据库引擎内部机制,业务代码不能把它们当作"多步骤工作流撤销 API"。
本文的 Redo 指:从已持久化的步骤状态恢复后续执行,或以幂等方式重放某一步。Undo 指:针对已经完成的业务步骤,执行预先设计的补偿动作。两者都必须由业务定义,尤其是调用外部系统时,通常只能补偿,不能物理回到绝对的历史时刻。
二、什么问题必须引入持久化编排
假设一个"发布套餐"的操作包含:校验输入、写主数据、扣减额度、调用外部发布接口、更新搜索索引。若只在内存里顺序调用:
go
validate()
save()
charge()
publishRemote()
refreshIndex()
那么在 publishRemote 成功后、refreshIndex 前进程崩溃时,重启后系统不知道前面已经做到哪一步;直接从头执行可能重复扣费或重复发布。
持久化编排要解决四个问题:
| 问题 | 需要的设计 |
|---|---|
| 重启后从哪里继续 | workflow 状态、当前游标、每步记录 |
| 重复执行会不会破坏数据 | 稳定操作 ID、幂等键、条件更新 |
| 失败后如何撤销已完成动作 | 每步补偿定义、反向游标 |
| 如何解释历史状态 | 操作前/后快照、审计事件、版本号 |
三、核心数据模型:实例、步骤与快照分离
sql
CREATE TABLE workflow_instance (
id CHAR(36) NOT NULL,
workflow_type VARCHAR(64) NOT NULL,
business_key VARCHAR(128) NOT NULL,
status VARCHAR(24) NOT NULL,
forward_cursor INT NOT NULL DEFAULT 0,
undo_cursor INT NULL,
version BIGINT NOT NULL DEFAULT 0,
input_payload JSON NOT NULL,
last_error VARCHAR(1000) NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_workflow_business (workflow_type, business_key),
KEY idx_resume (status, updated_at)
) ENGINE=InnoDB;
CREATE TABLE workflow_step (
workflow_id CHAR(36) NOT NULL,
step_no INT NOT NULL,
step_name VARCHAR(64) NOT NULL,
status VARCHAR(24) NOT NULL,
operation_key VARCHAR(128) NOT NULL,
request_payload JSON NULL,
result_payload JSON NULL,
error_message VARCHAR(1000) NULL,
attempt INT NOT NULL DEFAULT 0,
started_at DATETIME(6) NULL,
finished_at DATETIME(6) NULL,
PRIMARY KEY (workflow_id, step_no),
UNIQUE KEY uk_operation (operation_key)
) ENGINE=InnoDB;
CREATE TABLE workflow_snapshot (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
workflow_id CHAR(36) NOT NULL,
step_no INT NOT NULL,
snapshot_kind VARCHAR(16) NOT NULL,
entity_type VARCHAR(64) NOT NULL,
entity_key VARCHAR(128) NOT NULL,
entity_version BIGINT NULL,
payload JSON NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY idx_snapshot_lookup (workflow_id, step_no, snapshot_kind)
) ENGINE=InnoDB;
workflow_instance是整体状态机,保存正向/反向游标。workflow_step是每一步的执行事实,可用于恢复、审计和定位失败。workflow_snapshot保存补偿所需的历史信息。快照应是业务上明确的不可变数据,不要把它误当作数据库 MVCC undo 记录。
四、状态机与游标如何工作
可把步骤编号为 1 到 N。forward_cursor = k 表示前 k 步已经成功提交;下一步候选是 k+1。前向失败时实例可进入 RETRY_WAIT、UNDO_PENDING 或 FAILED,取决于失败是否可重试、是否需要补偿。
text
NEW -> RUNNING -> SUCCEEDED
|
v
RETRY_WAIT -> RUNNING
|
v
UNDO_PENDING -> UNDO_RUNNING -> UNDONE
-> UNDO_FAILED
游标不能只存在内存。worker 每完成一步,应在一个短数据库事务中写入步骤成功记录并推进 forward_cursor;重启后的 worker 依据数据库状态恢复。推进时使用乐观条件防止多个 worker 同时推进:
sql
UPDATE workflow_instance
SET forward_cursor = ?, version = version + 1, updated_at = NOW(6)
WHERE id = ?
AND status = 'RUNNING'
AND forward_cursor = ?
AND version = ?;
受影响行数为 1 才表示本 worker 成功推进。为 0 时必须重新读取实例状态,而不是继续盲写。
五、正向 Redo:先记录意图,再执行副作用,再落结果
对于每一步,推荐的抽象顺序是:
- 领取实例/步骤租约,防止并发 worker 同时处理。
- 读取上一步结果和必要快照。
- 用稳定
operation_key调用本地或外部动作。 - 将成功结果、步骤状态和游标推进持久化。
- 失败时记录分类、下次重试时间或转入补偿。
外部调用无法与本地 MySQL 事务原子提交,因此必然存在"外部已成功,本地还没记录"的窗口。解决它依赖外部 API 的幂等键或可查询状态:恢复时使用同一个 operation_key 重放,外部系统返回先前结果或可被查询确认。
go
func (r *Runner) executeStep(ctx context.Context, wf Workflow, step Step) error {
// operationKey 跨重试保持不变。
result, err := r.remote.Apply(ctx, step.OperationKey, step.Request)
if err != nil { return classify(err) }
return r.store.MarkStepSucceeded(ctx, wf.ID, step.No, result)
// MarkStepSucceeded 内部应以条件更新推进游标。
}
不能把"先查外部是否执行过,再决定是否调用"当作通用原子方案:查询与调用之间仍可能竞争。应优先依赖外部幂等协议,或把外部副作用改成可去重的异步投递。
六、Undo:补偿不是倒放 SQL
如果步骤 1 创建资源、步骤 2 扣额度、步骤 3 推送外部系统,补偿通常按完成步骤的逆序执行:撤销外部发布、返还额度、标记本地资源取消。不是每一步都需要 undo:纯校验、只读查询、可安全重试的步骤可能没有补偿动作。
| 正向步骤 | 成功事实 | 补偿动作 | 关键前提 |
|---|---|---|---|
| 创建本地记录 | 生成资源 ID | 标记取消或删除草稿 | 删除不影响后续合法引用 |
| 扣减额度 | 扣减流水 ID | 以原流水 ID 生成冲正 | 金额、币种、版本可追踪 |
| 调用外部发布 | 外部操作 ID | 调用撤销接口 | 外部接口也必须幂等 |
| 写搜索索引 | 文档版本 | 写回上一版本或删除 | 快照包含目标版本 |
每执行一个补偿步骤,都要记录它已经完成,并移动 undo_cursor,否则补偿过程中崩溃后同样会重复执行。补偿动作也必须幂等。
七、快照要保存什么
快照不是把所有表完整复制一份。应以"补偿需要恢复什么"为准:
- 修改前字段值、业务版本号和实体标识;
- 外部系统返回的资源 ID、版本或撤销 token;
- 扣减前后的金额与原始流水关联;
- 规则或配置版本,保证恢复时仍按当时语义解释。
大对象、敏感信息和高频变更实体要谨慎存 JSON 快照:可能导致表膨胀、查询困难或合规问题。可存不可变对象版本、对象存储引用或结构化差异,并设置访问控制与保留期限。
八、三个必须正视的边界
1. 不能保证跨系统原子性
MySQL 本地事务只能原子提交本库数据。外部 HTTP、支付系统、文件系统、搜索引擎均无法自动参与本地事务。Saga/补偿模式追求的是可恢复的最终一致,不是传统分布式事务的强原子性。
2. Undo 可能失败,也可能无法"完全还原"
外部资源被他人修改、撤销窗口过期、资金已结算,都可能使补偿失败。设计中要存在 UNDO_FAILED 和人工处理队列,不要把补偿写成"必然成功"。
3. 游标不是分页游标
本文游标是工作流进度指针,不是数据库查询的 pagination cursor。它只描述步骤推进,不替代对大数据扫描的主键游标、时间窗口或一致性快照设计。
九、可靠恢复与观测
恢复 worker 扫描 RUNNING 租约过期、RETRY_WAIT 到期、UNDO_PENDING 的实例并重新领取。运行日志必须带 workflow ID、step number、operation key、attempt 和状态转换;监控至少包含等待实例数、重试次数、补偿失败数、步骤耗时和租约过期数。
上线前应通过故障注入验证关键断点:外部成功后进程退出、DB 提交前断电、同一实例被两个 worker 抢占、undo 过程重启、外部撤销返回超时。只测"正常全成功"无法证明恢复设计正确。
十、面试追问
Q1:为什么不用一个 status 字段就够了? 一个总状态无法准确说明已完成哪些步骤、每步请求与结果是什么,也无法安全地从中间恢复或逆序补偿。实例与步骤分离能提供可审计的执行事实。
Q2:快照和事件日志怎么选? 快照更适合快速获取某个历史状态;事件日志更适合表达状态变化和重放。很多系统混用:事件做审计,关键补偿数据做快照。两者都要设计版本演进。
Q3:redo 与重试有什么区别? 重试通常指同一次步骤因暂时失败再次尝试;redo 更强调系统恢复后基于已持久化进度重放执行。它们都要求稳定幂等键,但触发条件和审计语义不同。
总结
多步 redo/undo 的关键不是"在 catch 里倒序调用几个接口",而是把工作流建模为可持久化、可恢复的状态机:步骤事实可查、进度由游标推进、外部副作用有幂等键、补偿基于业务快照且同样可重试。接受最终一致和人工兜底的边界,才是这类设计真正可靠的前提。
建议标签: MySQL、Go、Saga、Redo、Undo、状态机、分布式事务、系统设计