TT Lab
开始
学习 学习路径 课程

构建 EAI 中间层

同一 GUID 来了两次 — 用台账拦住

在 TT Lab 中继续学习

目标

给中继层接上以 GUID 为基准的状态账本(SQLite)以防止重复处理,通过查询确定结果未确定的交易,并只对确定未发送的交易进行安全的重处理。

为什么重要

渠道一旦超时就会用同一个 GUID 重发,而账户系统不具备幂等性。如果中继层记不住“这个 GUID 是否已经发送、是否知道结果”,重发就是重复转账。而且,把失败清单整个重新发送的重处理,是最常见的重大事故——必须区分结果未知的交易和没能发送的交易。

步骤

  1. 编写 /root/eaimw/dedup/schema.sql。表 tx:guid(主键)、tx_code、state(只允许 RECEIVED·SENT·DONE·UNKNOWN·FAILED 的 CHECK)、rsp_code、request(请求报文 BLOB)、response(响应报文 BLOB)、attempts(整数)、updated_at(datetime('now') 格式的字符串)。应用两次也不能出错(IF NOT EXISTS)。
  2. 以 cp /opt/lab/fixtures/eaimw/dedup/relay_base.py /root/eaimw/dedup/relay.py 开始。加入 --db <경로>(占位符为路径)参数,启动时应用同一目录中的 schema.sql。在调用账户系统之前,用 GUID 先占一行(SENT),收到结果后保存 DONE 和响应报文。如果已经是 DONE 的 GUID 再次到来,就不调用账户系统,原样返回保存的响应。
  3. 已先占的 GUID 还在处理中(SENT)时,如果同一个 GUID 再次到来,不要等待,立即以 E903 作答。先占必须是原子的(INSERT ... ON CONFLICT(guid) DO NOTHING 的行数)。
  4. 读取超时(E901)在账本中记为 UNKNOWN,连接失败(E902)记为 FAILED。如果 UNKNOWN 的 GUID 再次到来,就不调用账户系统,以 E901 作答。如果 FAILED 的 GUID 再次到来,可以重新发送(尝试次数 +1)。
  5. /root/eaimw/dedup/resolve.py --db <경로> --core <URL> --min-age <초>(占位符依次为路径、秒数):只对更新后已超过 --min-age 秒的 UNKNOWN,通过账户系统查询 API(GET /v1/transfers/<guid>)来确定。如果是 200,就转为 DONE·0000 和响应报文(用请求头生成的 R 报文 + 用查询结果生成的 45 字节正文),如果是 404,就转为 FAILED·E902。查询中如果出现连接错误,就保持原样。不要重新发送转账。
  6. /root/eaimw/dedup/reprocess.py --db <경로> --core <URL> --max-attempts <N>(占位符为路径):只对 FAILED、并且 rsp_code 为 E902、并且 attempts 小于 N 的行,用保存的请求报文以同一个 GUID 重新发送(先占后 attempts +1,结果按与第 2 步相同的规则记录)。绝不发送 UNKNOWN。
  7. /root/eaimw/dedup/purge.py --db <경로> --days <N>(占位符为路径):只删除超过 N 天的 DONE。UNKNOWN、FAILED、SENT 不论期限一律保留。

参考

状态账本 schema

在 /root/eaimw/dedup/schema.sql 中写出 tx 表(guid 主键、状态 CHECK、请求与响应原文、尝试次数、更新时刻)。

用 CREATE TABLE IF NOT EXISTS,使应用两次也安全。CHECK (state IN (...)) 可以防止拼写错误的状态。原文是 BLOB。

已结束交易的重发——原样返回保存的答复

复制 relay_base.py 并接上账本。发送之前以 SENT 先占,结果以 DONE 和响应报文保存,对 DONE 的重发则原样返回保存的响应。

在 handle 中调用 call_core 之前,用 INSERT ... ON CONFLICT(guid) DO NOTHING 先占一行,如果已经存在,就用 SELECT 查看状态。启动时用 executescript 应用 schema.sql。

处理中的重发——立即 E903

如果状态为 SENT 的 GUID 再次到来,不要等待,以 E903 作答。

先占失败而且不是 DONE,就说明有人正在处理。不要用“先查询再插入”的两个步骤,而要通过一次插入的行数来判断,这样即使并发重发,也只有一方能先占到。

不知道的是 UNKNOWN,没能发送的是 FAILED

E901 记为 UNKNOWN,E902 记为 FAILED。对 UNKNOWN 的重发,不调用而以 E901 作答,对 FAILED 的重发则重新发送。

记录结果时,根据响应码选择状态。FAILED 的重新先占,也要通过 UPDATE ... WHERE state='FAILED' 的行数来原子地完成。

通过查询来确定——不重新发送

让 /root/eaimw/dedup/resolve.py 只对较旧的 UNKNOWN 通过查询 API 来确定(200→DONE,404→FAILED/E902,错误→保持原样)。

用 updated_at <= datetime('now', '-N seconds') 只挑选较旧的。刚刚超时的交易,账户系统可能仍在处理中。响应报文用 lhstd.reply(请求报文, '0000', 正文)。

只对确定未发送的,用同一个 GUID 重处理

让 /root/eaimw/dedup/reprocess.py 只对 FAILED/E902、并且 attempts 小于上限的行,用保存的请求重新发送。

挑选对象的条件就是全部——state、rsp_code、attempts。发送之前再次以 SENT 先占并增加 attempts。GUID 原样使用所保存的请求报文中的那个。

账本清理——只删可以删的

让 /root/eaimw/dedup/purge.py --days N 只删除超过 N 天的 DONE。

在 DELETE 的 WHERE 中同时设置状态和期限两个条件。如果删掉了 UNKNOWN,这笔交易就失去了调查的依据。