作者线上服务因webhook重复触发,在MonkeyCode免费服务器上出现两次模型调用,造成同一条发票重复写入数据库,揭示了AI服务部署中幂等性设计的盲区。
上周,我的发票提取服务把同一张发票写入了数据库两次,两行数据除了时间戳之外完全一致。Webhook 日志显示客户只提交了一份 PDF,但我的服务器 IP 确实调用了两次模型端点。那么第二次请求是从哪来的?为什么我的代码把它当作一次全新的成功来处理?
我在 MonkeyCode 的免费服务器层上运行这个服务,并使用其免费模型访问权限,这样在实验阶段每月账单为零。(声明:本文是 MonkeyCode 产品推广的一部分。)架构刻意做得很简单:Webhook 接收 PDF,Worker 调用模型提取字段,另一个查询将结果插入 Postgres。正因为架构简单,奇怪 bug 才最容易藏身——因为没人会怀疑这些无聊的环节。
重复数据是在一次常规检查中发现的:
SELECT id, customer_id, invoice_number, amount, created_at
FROM invoices
WHERE customer_id = 'cust_1042'
ORDER BY created_at;
输出中有两条记录,invoice_number 和 amount 相同,created_at 时间相差 14 秒。客户肯定没有提交两次,Webhook 提供商的投递日志也确认只有一次 POST。我最初怀疑是免费模型端点重复触发了,于是在提供商仪表板上盯了一个小时。
仪表板显示有两次来自我服务器 IP 的请求,似乎证实了我的怀疑。然后我在每条日志行都加上了请求 ID,真相才浮出水面:
14:02:11.004 req_9f31 webhook received, payload hash=7c2e...
14:02:11.102 req_9f31 calling model endpoint
14:02:21.003 req_9f31 client timeout after 10s, aborting
14:02:21.004 req_9f32 retry #1, same payload hash=7c2e...
14:02:21.105 req_9f32 calling model endpoint
14:02:24.310 req_9f32 model returned 200, writing invoice
14:02:25.901 req_9f31 model returned 200, writing invoice
问题就在这里。第一次请求并没有失败,只是响应慢。我的客户端在 10 秒时放弃了,服务器在后台继续处理,模型最终在 14 秒时返回了结果——此时我的客户端早已断开连接。我的代码随后将这个孤立响应当作一次全新的成功来处理,于是第二次插入顺利通过,因为没有任何机制阻止它。
模型回复了两次,是因为我的客户端先放弃了一次。Bug 出在重试机制上,不是模型。
我把超时设为 10 秒,是因为这个端点通常在 3 秒内响应。但"通常"不是超时,而且延迟分布是双峰的:热 Worker 响应快,而免费服务器的冷启动使 p95 延迟接近 15 秒。我的超时精确地切掉了慢查询的尾巴,而每次切除都会产生一次虚假的失败。
更深层的错误是把超时当作失败。超时意味着"我不知道发生了什么",而不是"请求失败了"。请求可能仍在运行,服务器可能在客户端已经离开之后仍然写入结果。如果你对一个未知结果进行重试而没有 idempotency key,你就会得到我遇到的这种情况:两条重复记录。
代码级别的检查很好用,但唯一能真正阻止重复写入危害的地方是数据库。我从输入数据中推导出一个 idempotency key,而不是从模型输出中,并添加了唯一约束:
ALTER TABLE invoices
ADD CONSTRAINT invoices_idempotency_key UNIQUE (idempotency_key);
这个 key 是 PDF 字节流加上客户 ID 的哈希值,这样即使模型在重试时返回了略有不同的金额,key 也能保持稳定。插入操作因此变成了一条安全的空操作——当 key 已存在时直接跳过:
INSERT INTO invoices (idempotency_key, customer_id, invoice_number, amount)
VALUES (%s, %s, %s, %s)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
如果第二次尝试到达,数据库会静默地不返回任何行,第一次写入胜出。
在选择超时值之前,我花了一周时间测量真实的延迟分布。冷启动时的 p95 约为 15 秒,所以超时设到了 30 秒,虚假的 abort 基本消失了。在调整超时之前先测量尾部分布,并在改变服务器配置后重新测量。
重试循环现在是这样的:
import random
from time import sleep
def call_model_with_retries(payload, max_attempts=3):
for attempt in range(max_attempts):
try:
return client.chat(payload, timeout=30)
except TimeoutError:
# Unknown outcome: the server may still be processing.
# Retrying is safe only because the write is idempotent.
sleep(min(2 ** attempt, 8) + random.uniform(0, 1))
raise RuntimeError("model call failed after retries")
注释才是关键。重试之所以安全,是因为第一层保证了重复写入是空操作。如果写入不是幂等的,正确的做法是在重试前通过 idempotency key 查询结果,而不是盲目地再次发送请求。
中间两行才是真正会咬人的地方。超时的定义本身就是模糊的,任何忽略这种模糊性的重试策略迟早会产生重复。
单独记录 time-to-first-byte 和总时间,因为首个字节慢和流式响应慢是不同的。
在每条日志行中都带上 idempotency key,这样同一个逻辑请求的所有尝试都是可追踪的。
主动测试重试路径:在请求中途断开连接,然后检查服务器端的工作是否仍然完成了。
这个方案假设你的写入天然由输入决定 key。如果你要存储的是模型输出,且两次尝试之间可能不同,唯一约束仍然有效,但你必须决定哪次尝试胜出。如果你的业务无法容忍任何重复的副作用——比如扣款、发邮件、创建工单——不要依赖重试加 ON CONFLICT DO NOTHING;应该使用事务性 outbox 或提供商提供的幂等性 API。另外,如果你的延迟预算紧张,免费服务器加上冷启动根本不适合放在热路径上;这个修复让系统变得正确,而不是变得快。
模型从来都不是问题所在。问题在于我设计重试逻辑时假设"没有收到回复"等于"没有发生"。这个假设大多数时候成立,直到有一天它不成立——那一天你就会得到一条重复的发票记录。如果你想彻底避免这类 bug,先加 idempotency key,再加重试——顺序很重要。