架构设计
数据库幂等不是防重复请求,而是别太相信当前状态
很多数据库幂等问题不在请求是不是又来了一次,而在写操作是不是还建立在一个不稳定的当前状态上。沿着创建、更新、覆盖冲突和删除四类动作,我把自己最常用的几条工程默认值重新收了一遍。
写完高危告警流程里,我最后为什么补了状态正确性以后,我又往前倒了一步。
那篇讲的是高危流程里,旧执行流恢复以后为什么还能把状态写坏。再往前看,其实很多问题在走到分布式锁、状态机和 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_CREATEDORDER_PAIDORDER_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>的状态转移"]
结语
回头看,数据库里的幂等设计,本质上从来不只是“防止重复提交”。
它要解决的是:
- 一次写操作是不是还依赖不稳定的当前状态
- 一次更新是不是还能建立在过期前提上成功
- 一个结果是不是会被重复应用或者错误覆盖
从唯一键、绝对值更新,到版本号和状态约束,这些规则背后其实都在做同一件事:
把不确定的状态修改,改造成更可验证、更可拒绝、更接近一次性成立的状态转移。
很多系统到了后面,出问题的地方在于数据库里的那次写,本来就写得太相信当前状态了。
如果再往前走一步,问题就不会只停在数据库写法,而会变成:这次业务真正成立的边界到底落在哪。这个问题我放到下一篇再展开。