文章指出,执行操作后再记录幂等键,即使有唯一约束,也可能在并发请求下产生重复写入。提出先抢占执行资格,再由抢占结果决定是否执行操作。
Assistant 调用了一个执行写入的工具。同一次调用可能到达两次:模型重试了调用,同一个确认被处理了两次,或者进程在执行途中重启了。
第二次到达时,绝不能再写入一次。常见做法是使用幂等键,并在这个键上添加唯一约束,但单靠它还不够。约束是在记录写入时生效的,而记录是在操作完成后才写入的。最终,执行记录表里只有一条记录,目标系统却已经被写入了两次。
唯一约束保护的是执行记录,不是操作本身。
本文提出的做法是:在操作运行之前先取得执行权,再由执行权决定谁来执行。其余设计都由此展开。
先看最容易写出来的版本。
执行之前,检查键是否存在。如果不存在,就执行操作,之后再记录这个键。
只有一个请求正在处理时,这样做没有问题。一旦出现并发,它就失效了。两个请求都读到“还不存在”,于是都执行了操作。等它们随后记录各自的键时,唯一约束会拒绝第二条记录。但操作早已发生。
所以,约束阻止的是第二次记录,不是第二次写入。这就是前面那句话的具体含义:执行记录表上的约束,管不到已经发生在表外的操作。
这种模式有个名字:先检查,再执行。决策和操作之间存在一个时间窗口,而决策无法感知这个窗口内发生的事情。
改动很小:把检查换成执行权申领。
执行之前,先向执行权表插入一条记录。表上的唯一约束就是裁决点:
关键不在于这条记录本身,而在于插入发生在操作之前。插入是原子的,因此在任何操作运行之前,执行权归属就已经确定,中间没有可乘之机。
这也解释了为什么不能根据完成状态推断执行权归属。如果唯一能观察到的状态是“写入已完成”,那么两个并发请求在各自行动之前,看到的都会是“尚未完成”,于是又回到了先检查、再执行的老路。
这一部分值得单独讨论,因为它决定了什么才算同一次写入。
我们的做法是把四项内容放在一起计算 hash:
hash( who : which conversation : which tool : arguments with keys sorted )
谁发起的。 不同用户使用相同的工具、传入相同的参数,属于不同的写入。
哪段对话。 同一段对话内的重复调用属于重试。跨两段对话发生的两次相同调用,则是两个事件。
哪个工具。 这一项无需解释。
参数,而且要先对参数的键排序。 这一项至关重要。排序能让同一次调用的两种写法算出相同的键。如果不排序,{a:1,b:2} 和 {b:2,a:1} 就会生成两个键,同一次调用会被当成两次写入,幂等机制也就有了漏洞。
这四项回答的是:这是不是同一次调用。它们并不回答:这次写入会影响什么。这是两个不同的问题,把它们塞进同一个字段会引出另一组问题,那是另一个话题。
这是最难的部分。我们的答案既不是“要”,也不是“不要”,而是分成两种情况,依据我们实际掌握的信息来选择。
本地写入:我们知道结果。 失败就是失败,已经落库的记录就是已经落库了。这种情况下,可以释放执行权,让重试真正重新执行;也可以保留执行权,如实记录失败。无论选择哪一种,重点都是:我们清楚自己知道什么。
外部写入:我们不知道结果。 请求已经发出,却没有收到响应,或者连接断开了。在前面这三种情况下,对方完全可能已经收到了请求。如果此时释放执行权,放行重试,那么重试就真的是第二次写入。
因此,外部写入这一分支会有意保留执行权,并在结果中明确说明:
这次请求可能已经到达目标系统,结果应由目标系统报告,这里不会进行任何重试。
这句话不是措辞上的选择,而是实际行为。结果未知时,我们不会放行重试,因为重试会把“未知”变成“可能执行两次”。
第二个请求不应该再次写入,也不应该被报告为失败。它并没有做错什么。
我们的做法是重放结果。命中已有记录时,将记录下来的结果返回给本次调用,并标记为复用:
{ id: <the id recorded the first time>, idempotent: true }
调用方拿到的结果与第一次调用相同。从它的角度看,“我调用了一次,并得到了答复”这件事成立。
复合写入还需要多做一步。一次调用写入多条记录时,基础键由根成员持有,因此命中时必须重放整组结果,而不能只返回根成员。如果只返回根成员,调用方就会丢失其他成员的信息,而这些成员之后也再不会被返回。
以上所有机制,都只能阻止我们发送两次。
它不能保证端到端的实际效果只发生一次。要做到这一点,目标系统必须认可这个键:我们通过 header 将键一并发送过去,但对方是否据此去重,由对方决定。
准确地说:我们能保证自己不重复发送,却不能保证对方只接收一次。
我们把这一点写进结果消息,而不是留给读者自行发现。
四个问题。任何一个没有可靠答案,都意味着存在一个时间窗口。
决策和操作之间是否存在间隙? 如果代码先检查、再执行,这个间隙就存在。
什么定义了“同一次写入”? 如果键中缺少用户或对话信息,那么不同用户或不同会话只要参数相同,就会被合并成同一次写入。这是另一种错误。
计算 hash 之前,是否对参数的键进行了排序? 如果没有,同一次调用的两种写法就会算出两个键。
失败之后,重试会做什么? 如果答案不是“取决于我们是否知道对方已经收到请求”,那多半只是在猜。
回到那句话:唯一约束保护的是执行记录,不是操作本身。记住这一点,第一个检查就很自然了,因为问题变成了:决策究竟发生在操作之前,还是操作之后。
我们的做法是:操作前先取得执行权,由执行权裁决谁来执行;根据用户、对话、工具以及排序后的参数生成键;根据已知信息选择失败处理策略;同一次调用再次到达时,重放原始结果。
有两点需要明确说清。第一是前面提到的边界:我们不保证对方只接收一次。第二是机制本身的限制:只有参数完整表达了写入行为时,这套机制才成立。如果工具的行为还依赖参数之外的因素,比如当前时间、一次随机抽样或外部状态,那么相同参数出现两次,并不代表同一次写入,基于这些参数生成的键也就无法作出任何有效判断。这样的工具必须先改造,把这些因素也作为参数传入。在那之前,幂等键只是摆设。
本文由 AI assistant 协助起草。系统、观点和错误均由我本人负责。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。