qinghuan
← 回到知识探索

后端现场 / 案例拆解

明明超时了,为什么还会生成两张订单?

把“没有收到结果”和“没有执行”分开,再用同一次购买的标识识别重试。

展开本文目录
  1. 给同一次购买一个固定名字
  2. 订单和去重记录要一起落地
  3. 本地去重不能代替整个支付流程
  4. 能安全重试,还要有停止条件
  5. 记住这三点
  6. 别把例子用过头
  7. 自己试一小步
  8. 查原始资料
  9. 找一份资料继续练
开始前,会这些就够了
  • 了解请求与响应
  • 知道数据库能保存记录
遇到不熟悉的词,先看这 6 个解释
HTTP 状态码
响应里的三位数字,用来说明这次请求处理到了哪种结果,例如成功、需要重定向或发生错误;程序会结合响应内容决定下一步怎样处理。
接口
程序对外提供功能时,约定怎样调用、传哪些信息、返回什么结果;调用方按这份约定使用功能,不必先了解服务内部每一行实现代码。
幂等
在约定的范围内,把同一个请求做一次或重复做多次,对目标业务产生的预期效果相同;它用来防止重复执行把一件事变成额外多办几次。
超时
调用方为一次等待设置上限,超过时间还没拿到所需结果,就结束这次等待并报告超时;这样能限制资源占用,但并没有自动确认对方最终做到了哪一步。
重试
遇到可能只是暂时性的失败后,按预定规则再次尝试同一项操作;规则通常限制次数、等待间隔和总耗时,也要考虑重复执行会不会多做一次业务。
事务
把一组数据库操作放进同一个提交范围,让它们作为一件事被确认或撤销,并按数据库规则控制其他事务看到的变化;它帮助处理做到一半时的失败。
打开完整术语小抄 ↗

假设一个练习商城已经保存订单,返回响应时网络却断了。页面只看到超时,于是重新提交。第二次请求也创建订单,用户最终看到两张。这个假设案例的关键不是网速慢,而是客户端无法仅凭超时知道服务器做到了哪一步。

超时可能发生在请求尚未到达、执行过程中,也可能发生在提交之后。把所有超时都当作失败重新开始,会把“再问一次结果”变成“再做一次购买”。

给同一次购买一个固定名字

幂等的意思是:按约定重复同一个操作,不再增加重复的业务效果。客户端可在第一次购买时生成请求标识,后续重试继续使用它。服务端用用户身份和这个标识查找已经处理的结果;用户真的想再买一杯时,则发起新的业务操作,使用新的标识。

不要只拿商品和数量判断重复。一个人完全可能连续买两份相同商品。也不要每次重试都生成新标识,否则服务端看见的全是新操作。同一个标识却换了商品或金额,应当拒绝或按明确规则处理,不能悄悄复用另一笔订单。

订单和去重记录要一起落地

如果先创建订单,随后才保存“此请求处理过”,两步之间崩溃仍会重复。反过来先标记完成,订单却没保存,也可能永远漏单。因此要把相关数据库变更放进同一事务,利用唯一约束挡住相同标识的重复记录,并明确处理中、完成和失败如何查询。

下面用 SQLite 内存数据库演示最小范围:单进程、单连接,只创建本地订单,不扣款也不发消息。第一次调用的结果故意不显示,模拟调用者没看到响应;相同标识再次调用时,得到相同订单。唯一约束负责记录层面的去重,事务负责把本次修改一起提交或撤回。

小实验 · python
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, request_key TEXT UNIQUE NOT NULL, item TEXT NOT NULL)")

def create_order(key, item):
    with db:
        db.execute("INSERT INTO orders(request_key, item) VALUES (?, ?) ON CONFLICT(request_key) DO NOTHING", (key, item))
        row = db.execute("SELECT id, item FROM orders WHERE request_key = ?", (key,)).fetchone()
        if row[1] != item:
            raise ValueError("same key, different item")
        return row

create_order("purchase-1", "tea")  # 调用者没有显示第一次结果
print(create_order("purchase-1", "tea"))
print(db.execute("SELECT COUNT(*) FROM orders").fetchone()[0])
db.close()

怎样运行:Python 3.10+ 标准库;SQLite 需为 3.24.0+。保存为 retry_demo.py,运行 python retry_demo.py。

运行后对照结果
(1, 'tea')
1

本地去重不能代替整个支付流程

真实订单还可能调用支付、发库存消息或发送通知。本地订单表只有一行,并不能证明外部扣款只有一次。每个会产生副作用的环节都要说明如何识别重复、如何查询不确定结果,以及失败后如何继续;需要时采用持久化任务记录等方式连接这些步骤。

去重记录也不是随便保留几分钟就够了。迟到的重试可能在记录清理后到达,被认成新请求。Stripe 的文档就明确描述了键的保存期、参数核对和保存响应的规则。这是该产品的接口约定,不能直接当作所有服务共有的保证。

能安全重试,还要有停止条件

即使不会重复下单,无限制重试仍会占连接、线程和服务器容量。应设总时间预算与次数,遇到明确的参数错误就修正请求;对暂时性故障采用适当间隔。重试结束仍不知道结果时,把状态显示为待确认,并提供查询路径,比武断显示“肯定没买成”更准确。

排查时把业务请求标识与订单号一起记录,重建第一次执行和后续重试的关系。这里的重点是让不确定结果可查,而不是承诺网络永不出错。

记住这三点

  • 超时只说明调用者没有及时得到结果。
  • 同一次购买的重试应使用同一标识,新的购买使用新标识。
  • 数据库去重、外部副作用和重试预算是不同问题。

别把例子用过头

  • 内存 SQLite 演示不包含多实例并发、持久化恢复或支付事务。
  • 幂等不保证每次尝试都会成功,也不表示可以无限重试。
  • 请求标识需要限定用户等作用范围,不要写入敏感个人信息。

自己试一小步

同一个请求标识先买茶,第二次改成咖啡,系统应该如何响应?

  1. 运行例子,再修改第二次调用的商品。
  2. 为练习画出请求发出、数据库提交、响应收到三个时刻。
  3. 分别在三个时刻之间标记断网,写出调用者知道和不知道的事实。

做到这些,就算完成

  • 没有把商品相同当作同一购买的唯一依据。
  • 能区分参数冲突、执行失败和执行结果未知。
  • 说明外部扣款是否属于当前实验覆盖范围。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • Making retries safe with idempotent APIs (新标签页)↗Amazon Builders’ Library · 英文

    支持超时的不确定结果、调用者请求标识、原子记录、迟到请求和参数冲突。

    公开可读;虽然地址含 /cn/,正文为英文。示例是工程设计经验,具体接口仍以各自约定为准。

  • Idempotent requests (新标签页)↗Stripe · 英文

    支持该产品保存状态和响应、相同键参数核对、保存期与再次使用规则。

    公开英文接口文档;保存期限、参数检查和返回规则属于 Stripe 的具体约定。

  • UPSERT (新标签页)↗SQLite · 英文

    支持 ON CONFLICT 唯一性冲突与 DO NOTHING 行为;文档明确 UPSERT 从 3.24.0 加入。

    公开英文文档;本文语法需要 SQLite 3.24.0 或更高版本。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。