一个助手调工具去写数据。这次调用可能**到达两次**:模型自己重试一次、同一次确认被裁决两次、或者进程在中途重启。
第二次到达,不能真的再写一遍。
常见的答案是给这次写一个**幂等键**,再在账上给这个键加**唯一约束**。这个做法**单独用是不够的**。
因为约束是在**写账**的时候生效的,而写账发生在**动作之后**。所以,账上确实只有一行,而目标系统已经被写了两次。
**唯一约束护的是账,不是动作。**
后面讲我们怎么补上这一块,以及补完之后还剩一条我们**挡不住**的边界。
## 一、为什么「查一下再做」不够
先看最容易写的那个版本。
执行前先查:这个键**有没有**?没有就执行,执行完把键记下来。
单线程下它没问题。并发下它是坏的:**两份请求都读到了「还没有」**,于是两边都执行。事后再记键的时候,唯一约束把第二份挡掉了——**可动作已经发生过了**。
所以约束挡下的是那次**登记**,不是那次**写**。这就是上面那句话的意思:**账上的约束,管不到账外已经发生的事。**
一句话概括这个形状:**先查后做(check-then-act)** —— 判断和执行之间有一个窗口,窗口里发生的事,判断看不到。
## 二、补法:执行前先占位,让占位本身当仲裁
补法很小:**把「查一下」换成「占一下」。**
执行**之前**,先往占位表里插一行。那一行的唯一约束,就是这次的仲裁点:
- **插进去了** ⇒ 这次执行归我,继续。
- **插不进去**(键已存在)⇒ **说明已经有人持着这次执行**,**绝不重复执行**。
关键不在「插一行」,而在**插入发生在动作之前**。插入是原子的,所以「谁拥有这次执行」在动作开始前就已定下,**中间没有窗口**。
这也解释了为什么占位表不能靠「写完成」来推:如果我们只能事后知道「写完成了」,那并发的那两份请求,在动作开始前都是「还没有」——**又是一个 check-then-act**。
## 三、那键该由什么构成
这点值得单独说,因为它决定了**什么算「同一笔写」**。
我们的键是四样拼在一起,取哈希:
```
哈希( 谁 : 哪一场对话 : 哪个工具 : 参数(键名排序后) )
```
逐样说:
- **谁** —— 同一个工具、同一组参数,换个用户就是另一笔写。
- **哪一场对话** —— 同一场对话里重复调用才叫重试;跨对话的两次相同调用,是两件事。
- **哪个工具** —— 不必解释。
- **参数,且键名先排序** —— 这一步是为了让**同一次调用的两种写法算出同一个键**。不排序的话,`{a:1,b:2}` 与 `{b:2,a:1}` 会算出两个键,于是同一次调用被当成两笔写——**幂等就漏了**。
这四样合起来,回答的是「**这是不是同一次调用**」;而**不是**「这次写会碰到什么」。这两个问题不一样,把它们压成一个字段会出别的问题(那是另一个题目,这里只说键)。
## 四、占住之后失败了,键要不要放掉
这是最难的一处,而我们的答案不是「放」或「不放」,是**分两支**——依据是「**我们究竟知道什么**」。
**本地写**:我们知道结果。失败就是失败,没落就是没落。这一支可以**放掉**占位,让重试能真的重来;也可以留着并如实标成失败。关键是——**我们知道自己知道**。
**外部写**:我们**不知道**。请求发出去了、对方没回话、连接断了——这三种情况里,对方可能**收到了**。这时候如果放掉占位、让重试再来一次,那第二次就是**一次真的双写**。
所以外部那一支**故意把键占住**,并在结果里写明:
> 该请求**可能已到达**目标系统,结果以目标系统为准 —— **本次不重试**。
**这一句不是措辞,是这一次的行为。** 「不知道」的时候不放行重试,是因为**重试会把「不知道」变成「可能两次」**。
## 五、重复到达时,应该返回什么
第二份请求不该再写一次,但它也不该被当成失败——**它没做错什么**。
我们的做法是:**回放**。命中已有记录时,把**当初那次的结果**返回给这次调用,并标明这是复用:
```
{ id: <当初那条记录的 id>, idempotent: true }
```
调用方拿到的,和第一次拿到的**是同一个结果**。对它来说,「我调了一次、拿到了结果」这句话是真的。
复合写还有一处必须处理:一次调用写多行时,**基键由主体那条占用**,所以命中之后要**回放整组**,而不是只回主体那一行——只回一行,调用方会丢掉其余成员,而那些成员**再也不会被声明**。
## 六、一条我们挡不住的边界
上面这套,挡的是**我们自己**重发。
它**不保证**端到端只发生一次。要做到那个,**目标系统得认这个键**:我们把键放进请求头一起发出去,对方要不要据此去重,是对方的事。
所以准确的表述是:**我们能保证自己不重复发;不能保证对方只收一次。**
这一条我们会写在结果消息里,而不是留给读者去猜。
## 七、一个你可以自己做的检验
问四个问题,任何一个答不好,就是有窗口:
1. **判断和执行之间有没有窗口?** 如果代码是「先查、再做」,那窗口就在那儿。
2. **「同一笔写」是由什么定义的?** 如果键里没有「谁」或没有「哪一场对话」,那么换个人、换个会话,同一组参数会被当成同一笔——**那是另一种坏**。
3. **键的参数部分排序了吗?** 没排序,同一次调用的两种写法会算出两个键。
4. **失败之后,重试会发生什么?** 如果答案在「我们知道不知道对方收到了」之外,那它多半是猜的。
## 结语
回到开头那句:**唯一约束护的是账,不是动作。** 这句话只要记住,第 1 条检验就自己会做——**去问那个判断发生在动作之前还是之后**。
我们做的是:**执行前占位、占位即仲裁**;键由「谁 / 哪一场对话 / 哪个工具 / 排序后的参数」构成;**失败政策分两支**,本地放掉、外部占住;重复到达时**回放当初的结果**。
最后照实交代两处。一是上面那条边界:**我们不保证对方只收一次**。二是——**这套东西只在「参数能完整表达这次写」的时候成立**:如果某个工具的行为还取决于参数之外的东西(时间、随机、外部的当前状态),那同一组参数两次调用本来就**不是同一笔写**,键也就说不清。那种工具**得先让它把那些东西变成参数**,否则键是假的。