用幂等键防住重复接收
目标
设计幂等键,使用数据库约束阻止重复,在并发执行时也保持安全,并实现先保存后响应,从而构建可安全接收重发请求的接收端。
为什么重要
在存在异步集成和重试的环境中,重复并非例外,而是默认情况。然而,如果在应用程序中采用조회 후 없으면 삽입的方式防止重复,**两个请求同时到达的瞬间就会失守。**这是因为查询与插入之间存在空隙。数据库约束可以消除这一空隙,即使应用程序有缺陷也不会被绕过。再进一步,把首次响应保存下来,并在收到重复请求时原样返回,发送方就可以在超时后放心重发。这正是支付 API 使用 Idempotency-Key 的原因。
步骤
- 在
/root/i/idem.db(sqlite)中创建inbox_log表。列为msg_id,biz_key,status,response,created_at,且msg_id必须具有PRIMARY KEY 或 UNIQUE 约束。 - 编写
/root/i/key.md。正文必须包含以下三项。- 该接口采用什么作为幂等键,以及原因
- 为什么不能只使用
order_no - 幂等历史的保留期限及其依据
必须同时出现
멱등키,보관,수정三个词。
- 创建
/root/i/apply.sh。接收两个参数(DB파일 JSON파일):- 如果是新消息,则加载并在第一行输出
applied,退出码为 0 - 如果已经存在,则不执行任何操作,在第一行输出
duplicate,退出码为 0 必须采用利用约束的方式,而不是查询后再插入。
- 如果是新消息,则加载并在第一行输出
- 加载
/opt/lab/fixtures/eai/idem/messages/中的所有 JSON,加载时使用apply.sh。inbox_log的行数必须等于不同 msg_id 的数量- 将因重复而忽略的
msg_id按升序保存到/root/i/dup.txt
- 创建
/root/i/race.sh。接收两个参数(DB파일 JSON파일),对同一条消息同时尝试加载 10 次,并在最后一行输出rows=<해당 msg_id 의 행 수>。该值必须为 1。将运行结果保存到/root/i/race.txt。(为应对 sqlite 锁竞争,请设置PRAGMA busy_timeout。) - 创建
/root/i/purge.sh。接收两个参数(DB파일 보관일수),只删除created_at早于保留天数的行,并在最后一行输出deleted=<건수> remain=<건수>。 - 扩展
apply.sh,实现先保存后响应。- 处理新消息时,把响应 JSON 保存到
response列,并在applied后一行(第二行)输出该响应 - 遇到重复消息时,在
duplicate后一行(第二行)原样输出已保存的首次响应 对同一消息应用两次时,第二次输出的第 2 行必须与第一次响应相同。将确认结果保存到/root/i/replay.txt。
- 处理新消息时,把响应 JSON 保存到
- 编写
/root/i/report.md。把发生重复的四种路径分别写成一项,并为每条路径同时写明防御位置。必须同时出现재시도,큐,수동,배치四个词。
参考
- 利用约束插入:执行
INSERT OR IGNORE INTO ...后,用changes()判断是否已应用 - 并发执行:
for i in $(seq 10); do ... & done; wait - 等待锁:
PRAGMA busy_timeout=5000; - 日期比较:
created_at < datetime('now', '-30 days') - 常见错误 1:先用
SELECT确认再执行INSERT。并发执行时会失守。 - 常见错误 2:把清理批处理设计为“从最旧数据起删除 N 条”。流入量激增时会删除最近的数据。
- 常见错误 3:未设置
busy_timeout就并行执行,因database is locked而失败。
幂等历史表
在 /root/i/idem.db(sqlite)中创建 inbox_log 表。列为 msg_id, biz_key, status, response, created_at,且 msg_id 必须具有PRIMARY KEY 或 UNIQUE 约束。
防止重复的最后一道防线不是应用程序,而是数据库约束。请先确定应在哪个列上设置约束。
幂等键设计文档
编写 /root/i/key.md。正文必须包含以下三项。
- 该接口采用什么作为幂等键,以及原因
- 为什么不能只使用
order_no - 幂等历史的保留期限及其依据
必须同时出现
멱등키,보관,수정三个词。
仅使用业务键往往不够。同一订单可能存在修改报文,报文编号也可能按天循环。
加载脚本
创建 /root/i/apply.sh。接收两个参数(DB파일 JSON파일):
- 如果是新消息,则加载并在第一行输出
applied,退出码为 0 - 如果已经存在,则不执行任何操作,在第一行输出
duplicate,退出码为 0 必须采用利用约束的方式,而不是查询后再插入。
键已处理时,应当不执行任何操作并明确告知。查询后插入会在并发执行时失守,因此请使用约束。
批量加载与重复统计
加载 /opt/lab/fixtures/eai/idem/messages/ 中的所有 JSON,加载时使用 apply.sh。
inbox_log的行数必须等于不同 msg_id 的数量- 将因重复而忽略的
msg_id按升序保存到/root/i/dup.txt
必须从加载结果中分别统计新消息和重复消息。保留重复键列表,可用于日后的原因分析。
防御并发执行
创建 /root/i/race.sh。接收两个参数(DB파일 JSON파일),对同一条消息同时尝试加载 10 次,并在最后一行输出 rows=<해당 msg_id 의 행 수>。该值必须为 1。将运行结果保存到 /root/i/race.txt。(为应对 sqlite 锁竞争,请设置 PRAGMA busy_timeout。)
即使并行多次插入同一条消息,也必须只保留一条。sqlite 经常发生锁竞争,因此需要设置等待时间。
按保留期限清理
创建 /root/i/purge.sh。接收两个参数(DB파일 보관일수),只删除 created_at 早于保留天数的行,并在最后一行输出 deleted=<건수> remain=<건수>。
清理的瞬间,相应区间的重复防御也会消失。必须只按期限条件删除;按数量删除可能会删掉最近的数据。
先保存后响应
扩展 apply.sh,实现先保存后响应。
- 处理新消息时,把响应 JSON 保存到
response列,并在applied后一行(第二行)输出该响应 - 遇到重复消息时,在
duplicate后一行(第二行)原样输出已保存的首次响应 对同一消息应用两次时,第二次输出的第 2 行必须与第一次响应相同。将确认结果保存到/root/i/replay.txt。
向重复请求原样返回首次响应后,从发送方角度看,重发就会完全安全。这是支付 API 采用的方式。
整理重复发生路径
编写 /root/i/report.md。把发生重复的四种路径分别写成一项,并为每条路径同时写明防御位置。必须同时出现 재시도, 큐, 수동, 배치 四个词。
重复会通过四种路径到来。请同时写明每条路径中哪一个位置是防线。