本文是 Agent 工程里的经典架构 系列的一篇。文中的业务场景和代码为讲解设计,产品能力以所链接的官方资料为准。

用户点了一次退款,页面转了很久,最后提示超时。Agent 接手以后又调了一次退款接口。如果第一次请求已经成功,只是响应丢失,第二次调用就可能真的退两次钱。网络给出的信息只是“没有收到回复”,业务上却存在成功与失败两种可能。

Idempotent Receiver 把识别重复请求的责任放在接收方:客户端提供稳定标识,服务端保存已经处理的结果,重复请求获得原结果。这里最重要的是业务操作的身份,而不是重试次数。

比如订单可以分两次退款,直接把订单号当作幂等键就不够。一笔退款申请应该有自己的操作编号,同一申请的所有重试使用这个编号;另一笔合法退款使用新的编号。反过来,如果每重试一次都生成随机编号,接收方就无法知道它们属于同一件事。

机制图:用稳定操作标识与原子记录识别重复请求。

下面把退款流水和幂等结果放在同一个 SQLite 事务里。第一次创建流水并保存结果,第二次直接返回原来的流水号。相同操作编号却携带不同金额,会被明确拒绝。金额采用整数分,避免浮点数参与金额比较。

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE refunds (id INTEGER PRIMARY KEY, amount INT)")
conn.execute("CREATE TABLE requests (key TEXT PRIMARY KEY, amount INT, result INT)")


def refund(key, amount):
    if type(amount) is not int or amount <= 0:
        raise ValueError("金额必须是正整数分")
    conn.execute("BEGIN IMMEDIATE")
    try:
        row = conn.execute(
            "SELECT amount, result FROM requests WHERE key=?", (key,)
        ).fetchone()
        if row:
            if row[0] != amount:
                raise ValueError("相同操作编号的参数发生变化")
            result = row[1]
        else:
            result = conn.execute(
                "INSERT INTO refunds(amount) VALUES (?)", (amount,)
            ).lastrowid
            conn.execute("INSERT INTO requests VALUES (?,?,?)",
                         (key, amount, result))
        conn.commit()
        return result
    except Exception:
        conn.rollback()
        raise


assert refund("refund-17", 500) == refund("refund-17", 500)
try:
    refund("refund-17", 600)
except ValueError:
    pass
else:
    raise AssertionError("参数变化必须被拒绝")
assert conn.execute("SELECT COUNT(*) FROM refunds").fetchone()[0] == 1
conn.close()
print("重复请求复用结果,没有产生第二笔退款")

完整示例可下载,使用 Python 3.9 或以上版本运行:python3 example.py。只依赖标准库,不调用真实业务服务。

事务边界是例子成立的关键。假设先退款、再写幂等记录,程序在中间退出,下次重试仍然可能重复执行。如果先写“成功”、再退款,中间退出又会产生没有执行的成功记录。把本地效果和结果放进同一事务,才消除了这个本地窗口。唯一约束防止同一键产生多条记录,事务则处理整体成功与失败。

但这个技巧不能直接扩展到外部支付接口。自己的数据库事务无法覆盖另一家服务,长时间持有事务等待网络也不合适。真实退款需要支付方支持相同操作编号去重,或者允许按商户流水号查询状态。无法确认结果时,应保留处理中或待对账状态,而不是让模型换个编号再试一次。

并发调用同样要考虑。两个实例可能同时收到同一个操作,不能只用“先查询有没有,再执行”的普通代码。查询与占用之间存在竞争窗口,需要数据库唯一约束、事务锁或条件写入来裁决。等待中的重复请求可以查询状态或返回处理中,但这些行为必须形成稳定的接口约定。

幂等记录的有效期也影响承诺。如果只保留一天,客户端两天后重试,可能再次产生效果。有效期至少要覆盖业务允许的重试和消息延迟窗口;金额类操作往往应关联长期业务流水。清理策略需要和调用方一起设计,不能只是缓存满了就丢掉最旧记录。

从 Agent 的角度看,幂等键应由业务程序保存并传入工具,不能依赖模型每次“记得”生成同一个值。模型可以决定是否继续处理,工具层负责执行同一操作的身份约束。权限检查也不能因为命中旧结果而省略,否则一个调用者可能借别人的操作编号读取结果。

示例使用内存数据库,方便每次运行从干净状态开始,它不会在进程退出后保留幂等记录。真实服务需要可靠存储,并让所有处理同类请求的实例共享去重依据。把记录放在某台机器的缓存里,换一台机器重试就可能失效。

结果缓存也应该保持语义稳定。第一次退款返回流水号,后续重试可以查询这笔流水的最新状态,但不能把新的业务操作混进同一次重试。是否返回原始响应或最新状态,应在接口契约中说清楚。

我会在联调时专门模拟三种情况:响应丢失后重试、两个实例同时提交、同一编号携带不同参数。只测试第一次调用成功,根本没有测试到幂等的价值。真正可重试的接口,是调用方不需要猜测上一次发生了什么,也不会因为合理的恢复动作造成第二次业务影响。

上一篇 · 系列目录 · 下一篇