Announcement

👇Official Account👇

Welcome to join the group & private message

Article first/tail QR code

Skip to content

commit 之后响应丢了:重试为什么造出第二个订单,以及"先查再插"为什么不够 ​

设想一个客户端提交订单:

  1. 服务端把这一行插进数据库;
  2. 事务 commit 成功;
  3. 就在响应即将到达客户端的那一刻,连接断了。

客户端看到的是一个错误。而数据库里,躺着一个完好无损的订单。

现在客户端该做什么?

从它的角度看,重试是合理的——它根本看不到那次 commit。但如果服务端把这个重试当成一个新操作,那现在就有两个订单了。

这个故障跟慢数据库无关,也跟事务出错无关。事务完全按它该做的方式执行了。歧义就夹在 commit 和响应之间。

这篇文章用一个最小程序把这道缝照出来——它或许能解释,为什么你的订单系统在线上偶发地多出一单。


一、那道缝在哪里 ​

先把"缝"画出来:

text
   客户端                                服务端
     │                                     │
     │──── POST /orders ──────────────────▶│
     │                                     │  INSERT + COMMIT  ✅ 成功
     │                                     │
     │        ✗  连接在此断开  ✗           │  准备发送 201 + order_id
     │◀───────────────────────────────────✗
     │
     │  客户端收到 NetworkError
     │
     │──── POST /orders (重试) ───────────▶│
     │                                     │  ← 若当作新操作 = 第 2 个订单 ❌
     │                                     │  ← 若识别为重复   = 返回原订单 ✅

关键在于:报错返回给调用方,并不证明"没有写入发生"。 反过来,数据库 commit 成功,也不证明"调用方收到确认了"。 这是同一枚硬币的两面,只记 HTTP 结果,就丢掉了其中一面。


二、用最小程序把这道缝照出来 ​

下面这段程序用 SQLite 当作订单库的替身,并在 commit 之后立刻抛出一个连接错误。程序里没有 HTTP 服务器——返回的状态码代表 API 会发出的响应。这样能把实验聚焦在数据库状态和客户端的未知上。

第一个版本存了请求 key,但不强制它唯一;第二个版本加上唯一约束,并让重试复用同一个 key。只需要 Python 标准库:

python
import sqlite3
import tempfile
from pathlib import Path


def create_order(path, key, payload, safe, lose_reply=False):
    db = sqlite3.connect(path)
    try:
        if safe:
            cursor = db.execute(
                "INSERT INTO orders(request_key, payload) VALUES (?, ?) "
                "ON CONFLICT(request_key) DO NOTHING",
                (key, payload),
            )
            inserted = cursor.rowcount == 1
            order_id, stored_payload = db.execute(
                "SELECT id, payload FROM orders WHERE request_key = ?",
                (key,),
            ).fetchone()
        else:
            cursor = db.execute(
                "INSERT INTO orders(request_key, payload) VALUES (?, ?)",
                (key, payload),
            )
            inserted = True
            order_id, stored_payload = cursor.lastrowid, payload

        db.commit()
        if lose_reply:
            raise ConnectionError("response lost after commit")
        if stored_payload != payload:
            return 409, None
        return (201 if inserted else 200), order_id
    finally:
        db.close()


def run(safe):
    with tempfile.TemporaryDirectory() as directory:
        path = Path(directory) / "orders.db"
        db = sqlite3.connect(path)
        try:
            unique = " UNIQUE" if safe else ""
            db.execute(
                "CREATE TABLE orders (id INTEGER PRIMARY KEY, "
                f"request_key TEXT{unique}, payload TEXT NOT NULL)"
            )
            db.commit()
        finally:
            db.close()

        try:
            create_order(path, "checkout-42", "one book", safe, lose_reply=True)
        except ConnectionError as error:
            print(f"safe={safe}: first={error}")

        print(f"safe={safe}: retry={create_order(path, 'checkout-42', 'one book', safe)}")
        db = sqlite3.connect(path)
        try:
            ids = [row[0] for row in db.execute("SELECT id FROM orders ORDER BY id")]
        finally:
            db.close()
        print(f"safe={safe}: stored_ids={ids}")
        if safe:
            print(
                "safe=True: changed_payload="
                f"{create_order(path, 'checkout-42', 'two books', safe)}"
            )


run(False)
run(True)

跑出来的结果:

text
safe=False: first=response lost after commit
safe=False: retry=(201, 2)
safe=False: stored_ids=[1, 2]
safe=True: first=response lost after commit
safe=True: retry=(200, 1)
safe=True: stored_ids=[1]
safe=True: changed_payload=(409, None)

两种情况下,第一次调用都 commit 了订单 1,然后报出"响应丢失"。 差别在重试:

  • 无约束的版本:重试插入了订单 2。客户端收到了一个成功响应,但数据库里一次意图对应两条记录——这就是线上那个"多出一单"。
  • 有请求 key 的版本:重试找到了订单 1,返回原始标识符,而不是新建一行。

注意客户端的行为其实没变:它从第一次错误里,依然无法知道原请求到底提交没有。它不需要在重试前知道——只要服务端能识别出这是一个"重复的意图"。

再看最后一行:同一个 key 带着不同 payload 到达时,示例返回 409。 如果对一个被改过的订单默默返回订单 1,就会掩盖客户端的 bug。一个 request key 标识的是一次意图操作,包含它有意义的那部分输入。


三、"先 SELECT 再 INSERT"为什么不够 ​

最诱人的补丁是:在 insert 之前先跑一个 SELECT。key 存在就返回它的订单,不存在就插一行。这在顺序执行的演示里能用,但它不是一条完整的并发规则。

问题在于:两个 handler 可以在任何一方 insert 之前,先后都查到"不存在"。 两边都看到没有行,两边都继续往下走,于是两边都插。

text
  Handler A                     Handler B
     │                             │
  SELECT → 无行                  SELECT → 无行
     │                             │
   INSERT ──────────┐            INSERT ────────┐
     │              │              │            │
     └──────────────┴──────────────┴─────▶ 两行都写进去了 ❌

决定性的保护是数据库里的唯一约束——竞争性的插入会在写入层面被仲裁,而不是在进程内的锁上。ON CONFLICT(request_key) DO NOTHING 让"输掉的那次尝试"去读取那个已经拥有该 key 的行。这样代码不依赖一个进程本地、另一个服务器实例根本看不见的锁。

一句话:

幂等不能建立在"读到的状态"上,只能建立在"写入时的约束"上。


四、真实 API 里,request key 还要补齐什么 ​

那个玩具 schema 只留了一个字符串 payload,为的是让冲突可见。真实 API 里至少要补上这些:

补什么为什么
把 key 限定到调用方/租户不同调用方的 checkout-42 不该互相冲突;key 的作用域必须明确
定义 key 的有效期否则记过的 key 会无限堆积,一张表越写越大
存足够信息去比对请求的关键部分才能在"同 key 不同 payload"时返回 409,而不是静默复用
若一次操作写多行,key 记录必须与那些行在同一个事务里否则崩溃会留下"记住了 key 却没有完成操作",或者"操作完成却没有可用的重试记录"

响应也值得同样的谨慎。 当回复里只有一个 order ID 时,复用已存在的 ID 很容易。但真实的端点可能包含价格快照、确认号,或第一次尝试中生成的其它值。这时应该持久化或重建一份一致的结果,而不是每次重试都重新生成一个新的确认号——否则同一个幂等请求会返回两份互相矛盾的"事实"。


五、commit 的两侧:前失败与后失败 ​

注入错误的位置很关键。 同样是"客户端收到网络错误",背后可能是两种完全不同的状态:

失败位置数据库状态重试应该做什么
commit 之前事务回滚重试新建订单
commit 之后订单已持久化重试必须找到已存在的订单

客户端在两种情况下可能收到一模一样的网络错误。 这正是不该把"HTTP 报错"当作"没有写入"的原因。所以实践中:

  • 把 request key 和操作结果一起记下来,并让它在日志里可搜索;
  • 事故复盘时要能回答:某次重试复用了已有操作、新建了操作、还是因为 payload 变了被拒;
  • 一个笼统的 "500 计数" 回答不了这个问题。

六、唯一键证明不了什么:外部副作用 ​

必须承认唯一约束的边界:这个程序只守护了一行 SQLite 记录。

如果"创建订单"还要向支付服务扣款、或向外发布一条消息,那么单靠一条数据库约束,无法让那些外部效应恰好发生一次。此时设计要回答的是:

  • 每个副作用各自归谁所有,它的重试行为是什么?
  • 数据库变更可以用 outbox 模式与后续的消息投递协调——但消费者依然要能处理重复投递(至少一次投递 + 幂等消费);
  • 支付调用需要支付方自己的幂等能力,或者一条精心设计的恢复路径。

这个小脚本解决不了上面这些。它隔离出的是第一道边界——数据库提交了,然后程序模拟了一次丢失的响应。

但它足以说清一件事:

最初,重试把一次意图订单变成了两行; 一旦 request key 变成数据库约束,同一个重试就恢复了已存在的结果。 两次运行里注入的故障一模一样——区别只在于,服务端能不能识别出一个被重复表达的意图。


七、小结 ​

  • "响应丢失"是一类独立故障:它既不慢、事务也没坏,只是 commit 和确认之间出现了歧义;客户端重试是合理行为,服务端必须准备好识别重复意图。
  • 幂等靠写入约束,不靠读-改-写:SELECT 再 INSERT 在并发下会漏;UNIQUE + ON CONFLICT 才是决定性保护,且它天然跨进程、跨实例。
  • key 要带作用域、有效期和关键输入:同 key 不同 payload 要显式拒绝(409),而不是静默复用。
  • 别把"恰好一次"当成数据库一个问题:它是一组副作用的所有权与重试行为的设计问题。

相关阅读:站内的 Go database/sql 实战(连接、事务与重试的工程细节)、PostgreSQL 17 + pgvector 做 RAG。


参考资料 ​

上次更新于: