前面我已经分别拆过两层问题:

写到这里以后,我又碰到一个更前面的分叉:

为什么有些业务用 request_id + 唯一键 + 事务 就够了,有些业务却非得上状态机幂等表,甚至最后还会继续长到 Redis 票据、分布式锁和 fencing token 那一层?

我不想把这个问题理解成“哪个方案更高级”。分水岭不在技术栈,在另一件事:

这次业务成立的边界,到底落在哪。

我现在区分幂等方案,先看业务原子性边界

如果把不同方案放在一起看,我后来更愿意用一种很朴素的方式去区分它们。

边界还在本地事务里

最优方案通常就是:

  • request_id
  • 唯一键
  • 本地事务捆绑

边界已经跨出本地事务

就必须开始显式记录过程状态,比如:

  • PROCESSING
  • COMPLETED
  • FAILED_RETRYABLE
  • FAILED_PERMANENT

也就是所谓状态机幂等表。

如果流程再复杂

比如:

  • 步骤长
  • 外部调用多
  • 需要接管恢复
  • 并发重试频繁
  • 还要防旧执行流覆盖新结果

那系统就会继续进入分布式过程控制的世界,用 Redis、锁、状态机、fencing 这类机制去接管 correctness。

所以这一整套东西并不是谁替代谁,而更像是沿着业务边界不断演进的一条梯度。

这条演进路径,画出来大概长这样:

flowchart TD
    Start["新业务需要幂等"] --> D1{"业务效果能被\n本地事务包住?"}
    D1 -->|"能"| L1["唯一键 + request_id\n+ 本地事务"]
    D1 -->|"不能:涉及 RPC / MQ / 跨库"| L2["状态机幂等表\n显式记录过程状态"]
    L2 --> D2{"PROCESSING 卡死\n需要恢复机制?"}
    D2 -->|"引入清道夫 / 补偿任务"| L3["后台扫描超时记录\n重试或修正状态"]
    L3 --> D3{"清道夫与正常请求\n并发冲突?"}
    D3 -->|"需要互斥与正确性保护"| L4["分布式过程控制\nRedis 票据 + 锁 + fencing"]

如果业务效果能被一个本地事务包住,纯幂等表依然是最优解

谈架构时容易把简单方案说得像“只适合初级场景”。不过纯幂等表方案其实没那么简陋——它有一个非常明确的成立条件:

业务效果和幂等记录,能不能在同一个事务里一起生效。

典型写法很简单:

  1. 调用方生成唯一 request_id
  2. 在事务里先插入幂等表
  3. 再执行核心业务 SQL
  4. 一起提交

如果 request_id 已存在,插入失败,整个事务回滚。应用层捕获唯一键冲突,就知道这是一笔重复请求。

像这种场景,幂等表大概会长成这样:

CREATE TABLE idempotent_request (
  request_id VARCHAR(64) PRIMARY KEY,
  biz_type VARCHAR(32) NOT NULL,
  created_at DATETIME NOT NULL
);

然后在同一个事务里做这两件事:

INSERT INTO idempotent_request (request_id, biz_type, created_at)
VALUES (?, 'CREATE_ORDER', NOW());

INSERT INTO orders (order_no, status, amount)
VALUES (?, 'INIT', ?);

它之所以漂亮,是因为它在一个原子事务里,同时保证了两件事:

  • 这次请求只会成功“记账”一次
  • 这次业务也只会生效一次

只要这个前提成立,纯幂等表方案会有几个非常难得的优点:

  • 逻辑简单
  • 容易审计
  • 不需要额外恢复机制
  • 没有 PROCESSING 这种中间悬挂状态
  • 新请求路径也足够快

我现在更愿意把它看成:当原子性边界足够小的时候,数据库事务就是最强的过程控制器。

这套东西什么时候开始不够用

分水岭跟并发高不高关系不大,关键在于你的业务是否已经跑出了本地事务的边界。

比如下面这类流程:

  • 先写数据库
  • 再调一个外部 RPC
  • 再发一条 MQ
  • 最后再更新另一张表

这时候,纯幂等表方案最依赖的那个前提已经没了:你再也没法把“业务确实完成”这件事,包进一个本地事务瞬间里。

然后你会发现,两个很经典的坏选择都会出现。

先插幂等记录,再调外部 RPC

这样做的风险是:

  • 幂等记录成功了
  • 但 RPC 失败了

结果是,这次请求在幂等层面已经被认为“做过了”,可实际业务并没有完成。后面再来重试时,系统可能直接把它挡掉,造成一种假成功。

这种问题排查起来还特别慢。

先调外部 RPC,再插幂等记录

这样做的问题正好反过来:

  • RPC 真的执行成功了
  • 但服务在写幂等记录前挂了

下一次请求再进来,因为还看不到幂等记录,它会以为这次业务没做过,于是把 RPC 又调一遍。

这时候你就会意识到:唯一键仍然有用,但它已经不够证明“这次请求真的完成了”。

当业务的生效边界跑到事务之外时,纯幂等表方案的问题不在于“写得不够细”——它依赖的基础条件本身已经消失了。

状态机幂等表,本质上是在记录“现在做到哪了”

当“完成”不再是一个事务瞬间,而变成一个跨步骤过程时,系统就必须开始回答另一个问题:

如果这次业务中途停住了,我们现在到底做到哪一步了?

这就是状态机幂等表出现的原因。

和纯幂等表相比,它最关键的区别在于思路变了,多几个字段只是表面:

  • 纯幂等表关心的是:这次请求有没有成功成立过
  • 状态机幂等表关心的是:这次请求现在处于什么生命周期阶段

典型状态可能会包括:

  • PROCESSING
  • COMPLETED
  • FAILED_RETRYABLE
  • FAILED_PERMANENT

这套设计的价值在于它终于承认现实:

  • 请求可能处理中挂掉
  • 外部调用可能半成功半失败
  • 一个流程的完成,本来就是逐步推进的
  • 幂等不再只是防重,而开始承担恢复和可观测职责

当请求到来时,系统不再只是“查一下有没有这个 request_id”,而会变成:

  • 如果已经 COMPLETED,直接返回结果
  • 如果还在 PROCESSING,判断是否超时
  • 如果 FAILED_RETRYABLE,决定能不能继续推进
  • 如果不存在,插入一条新的 PROCESSING

也就是说,幂等表不再只是一个“防重名单”,而更像是一张小型过程票据。

状态机方案真正麻烦的,不是多了一个状态字段

第一次看到状态机幂等表,容易觉得无非就是:

  • 表里加个 status
  • 成功改成 COMPLETED
  • 失败改成 FAILED

但难的地方不在状态字段本身,而是下面这些问题。

PROCESSING 卡死怎么办

这几乎是状态机方案绕不开的第一个坑。

如果服务在调用完下游以后、更新最终状态之前崩了,记录就会一直停在 PROCESSING。这时候你既不能轻易说它成功了,也不能轻易说它失败了。

就悬在那儿。

系统得再引入一个恢复机制,去处理那些“处理中超时”的任务。

为什么通常需要清道夫或补偿任务

因为总会有一些请求,无法在当次调用链里自然收尾。

你需要一个后台任务去扫描超时的 PROCESSING 记录,然后决定:

  • 是重试
  • 是查下游状态后修正
  • 还是标记成失败并等待人工介入

到这里,幂等设计已经明显从“防重技巧”演进成“过程管控”。

清道夫和正常请求可能会打架

这时候新的问题又会冒出来:

  • 正常请求正在处理这笔任务
  • 清道夫也来扫描并修它
  • 两边可能同时操作同一条状态记录

所以很多系统走到这一步时,会开始给 request_id 级别的处理再加一层分布式互斥。也就是说,状态机方案最后往往会自然长出锁。

下游能力开始反过来决定你能恢复到什么程度

这是很多文章讲得不够的一点。

你的状态机表能不能顺利恢复,不只取决于你自己写得多好,还取决于下游到底给不给你可核实的事实。

如果下游能提供:

  • 幂等查询接口
  • 外部请求号查询能力
  • 明确的最终状态语义

那你的补偿逻辑就能比较稳地把本地状态修回来。

如果下游完全是黑盒,不支持查询,也不支持幂等键,那很多卡在中间的请求,最后就很难靠系统自动恢复,只能靠人工补偿或者对冲操作。

走到这里,你已经在处理一个跨系统过程了。

再往前一步:什么时候要从数据库状态机继续演进到 Redis 过程控制

并不是所有事务外业务,都要立刻上 Redis 过程控制。

但有些场景,数据库状态机会逐渐显出吃力:

  • 同一个流程步骤很多
  • 外部调用耗时长且不稳定
  • 重试和接管频繁
  • 高并发下有大量重复请求
  • 系统很在意恢复速度
  • 最终状态正确性不能被过期执行流污染

这时候,光靠数据库表里的 status 往往已经不够优雅了。系统会进一步演进出这些东西:

  • Redis 票据,记录过程态
  • 分布式锁,控制接管互斥
  • 看门狗,处理长任务持锁
  • fencing token,阻止旧执行流回来覆盖新结果

也就是说,状态机方案并非终点,它只是你开始承认“过程本身需要被管起来”的起点。如果问题继续往 correctness 那一侧长,最后就会走到我在前一篇里拆的那层。

我现在自己的选择顺序

所以如果一定要把这件事压缩成一句决策建议,我现在会这样判断。

能放进一个本地事务里,就尽量别把问题升级

如果核心业务能完全被数据库事务包起来,那唯一键 + 本地事务,通常就是最简单也最强的方案。

只要边界跨出事务,就别再假装唯一键足够了

一旦流程里出现 RPC、MQ 或其他事务外动作,幂等问题就已经变了。这时候继续只靠“插一条防重记录”是不够的,系统必须开始显式记录过程状态。

如果过程已经足够复杂,就进入真正的流程控制

当你开始在乎:

  • 断点续作
  • 自动接管
  • 并发恢复
  • 正确性保护
  • 高危状态不被覆盖

那系统最终大概率会演进到 Redis 状态票据、分布式锁和 fencing 那一层。

结语

回头看,幂等设计其实很少是“哪个好,哪个差”的问题。

它更像是在不断回答一个更根本的问题:

这次业务成立的边界,到底落在哪。

如果边界在本地事务里,那数据库唯一键和事务就是最干净的解法。
如果边界已经跨出事务,那系统就必须开始面对现实:它已经超出了“防重复插入”的范畴,变成一段需要追踪、恢复和接管的过程。

所以我不把幂等理解成一个单点技巧。它更像是一种建模能力:

你能不能准确地看见一段业务的原子性边界,并为这个边界之外的不确定性,准备对应层级的过程控制。