如何用 MySQL 持久化、历史快照与游标实现多步 Redo/Undo

多步业务常见于批量导入、审批编排、账务调整、配置发布和外部资源变更。它的难点不在"按顺序执行几条 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_WAITUNDO_PENDINGFAILED,取决于失败是否可重试、是否需要补偿。

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:先记录意图,再执行副作用,再落结果

对于每一步,推荐的抽象顺序是:

  1. 领取实例/步骤租约,防止并发 worker 同时处理。
  2. 读取上一步结果和必要快照。
  3. 用稳定 operation_key 调用本地或外部动作。
  4. 将成功结果、步骤状态和游标推进持久化。
  5. 失败时记录分类、下次重试时间或转入补偿。

外部调用无法与本地 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、状态机、分布式事务、系统设计

相关推荐
SL-staff1 小时前
APS排产规则底座实践:如何用生产全局配置统一自动拆分、成批基数与齐套策略
大数据·数据库·人工智能·智能制造·aps·mes·排产规则
Wang's Blog1 小时前
PostgreSQL笔记64:分区插件生态与扩展查询协议深度解析
数据库·笔记·postgresql
小蒜学长1 小时前
基于Django的社区团购购物平台的设计与实现(代码+数据库+LW)
数据库·后端·python·django
灯澜忆梦1 小时前
【基于GO的Web开发9】gin框架返回json
前端·后端·golang·gin
IMPYLH1 小时前
HTML 的 <ins> 元素
前端·javascript·html
刘婉晴1 小时前
【AI提效】利用 qwenwork 智能体平台(免费)实现 webgoat 靶场 SQL 注入速通
数据库·sql
kymjs张涛1 小时前
DeepSeek Harness源码分析:非 AI 开发者的 Agent 学习总结
前端·后端·面试
Dovis(誓平步青云)1 小时前
番茄钟真正难的不是倒计时:启动、暂停、重置与页面销毁
android·服务器·前端
一千柯橘1 小时前
了解 dev-tools 不同的 debug 模式
前端