写完高危告警流程里,我最后为什么补了状态正确性以后,我又往前倒了一步。

那篇讲的是高危流程里,旧执行流恢复以后为什么还能把状态写坏。再往前看,其实很多问题在走到分布式锁、状态机和 fencing 之前,就已经埋在数据库写法里了。

很多系统一提幂等,第一反应都是“防重复请求”。

这当然没错,但我觉得这个说法有点太粗了。真到了数据库层,幂等最后考验的是一件更具体的事:

你的写操作,到底是不是还在依赖一个不稳定的当前状态。

如果一个动作重复执行时,结果会继续变化,那它天然就不幂等。
如果一个动作成立的前提,建立在“我以为数据库现在还是这个样子”之上,那它也很脆弱。
如果一个动作会把别人已经确认过的结果覆盖掉,那它甚至已经在伤 correctness 了。

这篇我只想收在数据库层面,不往外讲 RPC、MQ 和过程接管。只讲一件事:如果问题还没跑出数据库,这一层最值得长期坚持的默认值到底是什么。

我现在最在意的,不是防重本身,而是别太直接改当前状态

回头看,数据库里的幂等设计经常会翻车,原因往往在写法本身。它在表达:

  • 再来一次,就再变一次。
  • 只要我查到这行数据,我就默认它现在还是我刚才看到的状态。
  • 我可以直接把结果覆盖上去,不需要确认前提还成不成立。

这几件事,恰好都是数据库层最容易出事的地方。

所以我后来总结下来,数据库里的幂等设计,最常用也最值得坚持的,其实就四条规范。它们不是什么银弹,但很适合作为工程默认值。

创建类操作,优先把唯一性下沉到数据库约束里

这个是最基础的一条,也是最容易被应用层 if-else 替代错的一条。

如果一个业务动作本质上是在“创建一件只能存在一次的东西”,那最稳的做法是先想清楚这个创建动作的业务唯一性到底是什么,然后直接把它变成数据库约束。

比如:

  • 用户注册,唯一性可能是 email
  • 第三方订单落库,唯一性可能是 out_trade_no
  • 幂等请求日志,唯一性可能是 request_id

一旦唯一性定义清楚,后面的事就简单很多了。应用层把唯一性判断交给数据库,让它做最擅长的事:拒绝重复创建。

像这种创建类动作,我现在更愿意先从 schema 开始想:

CREATE TABLE payment_order (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  out_trade_no VARCHAR(64) NOT NULL,
  amount DECIMAL(18,2) NOT NULL,
  status VARCHAR(32) NOT NULL,
  UNIQUE KEY uk_out_trade_no (out_trade_no)
);

然后应用层就不要再写一段很脆的“先查再插”:

INSERT INTO payment_order (out_trade_no, amount, status)
VALUES (?, ?, 'INIT');

如果这次创建本来就只能成立一次,那唯一键冲突本身就是业务边界的一部分。

这条规范值钱的地方在于,它把“防重”从一段不太可靠的业务代码,沉到了 schema 约束里。也就是说,哪怕调用方忘了先查再写,数据库也不会让你写出第二份。

更新类操作,能写绝对值,就别写相对值

这一条我后来特别在意。

很多更新之所以不幂等,问题出在写法本身就在表达“再来一次就再变一次”。

最典型的就是这种:

UPDATE users
SET level = level + 1
WHERE user_id = ?;

或者:

UPDATE accounts
SET balance = balance + 100
WHERE account_id = ?;

这种写法的问题不复杂:它把结果建立在“当前值是多少”这个前提上,而当前值在并发和重试场景里,恰恰是最不值得轻信的东西。

就这一点。

相对地,更接近幂等的写法,通常是绝对值更新:

UPDATE users
SET level = 'GOLD'
WHERE user_id = ?;

它不关心你之前是什么状态,也不关心这个请求来了一次还是三次。只要这个动作的业务语义就是“把会员等级设置成黄金会员”,那数据库层的表达也应该尽量长得像“设置成某个确定结果”,而不是“在当前基础上再往前推一步”。

当然,不是所有业务都能直接写成绝对值。

像充值、扣款、积分增加,这些天然就是相对变化。这时候更稳的做法,是把幂等性前移到“这笔交易本身只能成立一次”这件事上。

也就是说:

  • 不是让账户余额那条记录去承担幂等
  • 而是让“交易凭证”承担幂等

比如先插一条 transaction_id 唯一的流水,再由流水去驱动余额变化。任何重复请求,都会先被重复的 transaction_id 挡住。

这条规范说到底,重点是别直接去改那个会不断波动的当前状态——先找到那个更稳定、更可验证的动作凭证。

会发生覆盖冲突的更新,必须把前置条件写进 SQL

数据库里的很多 bug,不在于你多执行了一次,在于你在一个过期前提上执行成功了。

这类问题最常见的场景是:

  • 两个人同时编辑同一份内容
  • 两个线程同时推进同一个状态机
  • 一个旧执行流恢复以后,继续拿旧上下文覆盖新结果

这种时候,单纯的 UPDATE ... WHERE id = ? 往往不够。因为它表达的只是“找到这行数据并改掉它”,它没有表达另一个同样重要的约束:

我只接受在某个前提仍然成立时,才执行这次更新。

这个前提,常见有三种写法。

版本号

UPDATE articles
SET content = ?, version = version + 1
WHERE article_id = ?
  AND version = 10;

时间戳

UPDATE tasks
SET result = ?, updated_at = NOW()
WHERE task_id = ?
  AND updated_at = ?;

状态约束

UPDATE approvals
SET status = 'APPROVED_B'
WHERE id = ?
  AND status = 'PENDING_B';

这三种写法都在做同一件事:把“我以为现在还是这个状态”显式写进 SQL 条件里。

这样数据库会在更新时,原子地帮你完成两件事:

  • 检查前提是否仍然成立
  • 只有成立时才执行更新

这是一种很重要的设计态度:不要默认自己看到的数据一定还是最新的,要把“成立前提”一起交给数据库做原子判断。

这条规则,在复杂状态流里尤其重要。因为它不只是让更新更幂等,也能防止错误覆盖。

删除操作,只要业务开始在乎可追溯,就优先逻辑删除

从结果上看,删除好像天然就是幂等的。

比如:

DELETE FROM orders
WHERE order_id = 123;

第一次执行,订单没了。
第二次执行,订单还是没了。
从“最终状态”这个角度说,它确实是幂等的。

但很多线上逻辑真正出问题的地方,不在最终状态,而在调用方开始依赖“这次到底删没删到”这个返回值。

比如:

  • 第一次删,影响行数 = 1
  • 第二次删,影响行数 = 0

如果业务层开始把这个差异解释成“1 代表删除成功,0 代表删除失败”,那这个删除动作虽然结果幂等,语义却已经不稳定了。

坑就在这儿。

所以在很多需要审计、恢复或状态追踪的场景里,我更倾向于逻辑删除:

UPDATE orders
SET status = 'DELETED'
WHERE order_id = 123;

这样你做的就变成了“把这条记录稳定地推进到一个删除状态”。

这会带来几个实际好处:

  • 删除动作更像一次受控状态转移
  • 是否删除过,可以被长期审计
  • 后续恢复、补偿、对账都更容易处理
  • 多次重复执行时,系统语义也更稳定

当这四条还不够时,系统通常会继续往外长

写到这里,这四条已经能覆盖很多数据库层的幂等问题了。

但复杂业务里,总会有一些场景继续往外长。比如:

  • 你已经很难再直接表达绝对值更新
  • 你关心的是“某个动作是否发生过”,而不是某个字段此刻是多少
  • 你需要保证状态只能沿着某条业务路径推进,而不是随便改成任何值

这时候系统会继续演进到三个方向。

用事件追加,替代状态覆盖

也就是把“改当前状态”变成“追加一条不可变事件”。

比如先别急着改 orders.status,改成追加事件:

  • ORDER_CREATED
  • ORDER_PAID
  • ORDER_SHIPPED

状态变成这些事件聚合出来的结果。幂等性也从“状态怎么改”变成“这个事件能不能重复插入”。

用交易凭证,替代直接修改余额或计数

这在金融、积分、配额一类系统里很常见。

关键在于先保证这笔交易凭证只能成立一次。实际的余额变化,变成凭证生效后的派生结果。

用带前置条件的状态转移,替代自由更新

也就是把业务规则直接编码到状态更新里。

不是简单地写:

UPDATE approvals SET status = 'APPROVED';

而是写成:

UPDATE approvals
SET status = 'APPROVED_B'
WHERE status = 'PENDING_B';

这样一个 SQL 本身,就同时承载了流程顺序约束、幂等性和并发安全的一部分。

把上面这些方案的选择路径画出来,大概是这样一棵决策树:

flowchart TD
    Start(["写操作要做幂等"]) --> Type{"操作类型?"}

    Type -->|"创建"| UK["唯一键约束<br>下沉到 schema"]
    Type -->|"更新"| Abs{"能写成绝对值?"}
    Type -->|"删除"| Trace{"业务在乎可追溯?"}

    Abs -->|"能"| AV["绝对值更新"]
    Abs -->|"不能:余额/计数等相对变化"| TX["交易凭证承担幂等"]

    AV --> Conflict{"存在覆盖冲突?"}
    TX --> Conflict

    Conflict -->|"不存在"| OK(["基础幂等已覆盖"])
    Conflict -->|"存在:并发或旧流恢复"| Pre["前置条件写进 WHERE<br>版本号 / 时间戳 / 状态约束"]

    Trace -->|"是"| LD["逻辑删除"]
    Trace -->|"否"| PD["物理删除"]

    Pre --> Enough{"四条规范够用?"}
    Enough -->|"够"| Done(["工程默认值已覆盖"])
    Enough -->|"难以表达绝对值"| Evt["事件追加<br>替代状态覆盖"]
    Enough -->|"关心动作是否发生过"| Voucher["凭证先落库<br>再驱动变化"]
    Enough -->|"状态须沿路径推进"| SM["带前置条件<br>的状态转移"]

结语

回头看,数据库里的幂等设计,本质上从来不只是“防止重复提交”。

它要解决的是:

  • 一次写操作是不是还依赖不稳定的当前状态
  • 一次更新是不是还能建立在过期前提上成功
  • 一个结果是不是会被重复应用或者错误覆盖

从唯一键、绝对值更新,到版本号和状态约束,这些规则背后其实都在做同一件事:

把不确定的状态修改,改造成更可验证、更可拒绝、更接近一次性成立的状态转移。

很多系统到了后面,出问题的地方在于数据库里的那次写,本来就写得太相信当前状态了。

如果再往前走一步,问题就不会只停在数据库写法,而会变成:这次业务真正成立的边界到底落在哪。这个问题我放到下一篇再展开。