异步任务创建接口如何幂等?

第58题:异步任务创建接口如何幂等?

1. 核心回答

异步任务创建接口通常使用 POST

http 复制代码
POST /tasks

它会产生"创建任务"这一副作用。客户端遇到网络超时后直接重试,可能创建两个完全相同的任务。

因此我会要求客户端为一次业务意图生成唯一的:

text 复制代码
Idempotency-Key

例如:

text 复制代码
7e9b6f84-2a22-4d36-9db0-4c8c8d733101

服务端把:

text 复制代码
tenant_id
+
idempotency_key

作为幂等作用域,并在数据库中建立唯一约束。

第一次请求成功创建:

text 复制代码
task_id = T100

以后无论客户端因为超时重试多少次,只要:

text 复制代码
Idempotency-Key相同
+
请求参数相同

都返回同一个:

text 复制代码
task_id = T100

并且不会再次创建任务。

核心语义可以写成:

$$

Create(k,p)

Create(k,p)

T

其中: * kkk:幂等键; * ppp:请求参数; * TTT:唯一任务。 *** ** * ** *** ### 2. 为什么异步创建接口需要额外做幂等 HTTP 中 `POST` 本身没有天然的幂等保证。 例如客户端发送: ```http POST /tasks Idempotency-Key: abc123 ``` 服务端实际已经完成: ```text 创建task_001 ``` 随后响应包在网络中丢失。 客户端看到的是: ```text timeout ``` 它无法知道服务端究竟处于哪一种状态: ```text 请求根本没有到服务端 ``` 或者: ```text 任务已经创建,只是响应丢了 ``` 如果客户端重新发送普通 `POST`: ```text 第一次 → task_001 第二次 → task_002 ``` 就产生了重复任务。 所以需要把"这是同一次业务意图的重试"显式编码进请求。 这就是 `Idempotency-Key` 的作用。 *** ** * ** *** ### 3. 接口应该怎样设计 客户端创建任务时: ```http POST /tasks Idempotency-Key: 7e9b6f84-2a22-4d36-9db0-4c8c8d733101 Content-Type: application/json ``` 请求: ```json { "repo": "project-a", "action": "security_scan" } ``` 服务端接受以后可以返回: ```http 202 Accepted ``` 响应: ```json { "task_id": "T100", "status": "PENDING", "status_url": "/tasks/T100" } ``` 这里的语义是: ```text 请求已经被接受 任务仍在异步执行 ``` RFC 9110 对 `202 Accepted` 的定义就是请求已被接受处理,但处理尚未完成,并建议响应提供当前状态以及后续状态查询入口。 *** ** * ** *** ### 4. 服务端需要保存什么幂等记录 我会建立类似: ```text idempotency_record ``` 的数据结构: ```text tenant_id idempotency_key request_hash task_id status response_code response_body created_at expire_at ``` 例如: ```text tenant_id = user_100 idempotency_key = abc123 request_hash = SHA256(canonical_request) task_id = T100 status = ACCEPTED ``` 数据库建立唯一约束: ```sql UNIQUE (tenant_id, idempotency_key) ``` 这样两个服务器实例同时收到相同请求时,也只有一个请求能够成功创建对应记录。 *** ** * ** *** ### 5. 为什么不能只写"先查数据库,再创建" 下面这种代码存在并发问题: ```text 查询key是否存在 ↓ 不存在 ↓ 创建任务 ``` 假设两个请求同时到达: ```text Request A Request B ``` 可能发生: ```text A查询:不存在 B查询:不存在 A创建task_1 B创建task_2 ``` 最终仍然创建两个任务。 因此: ```text Check → Insert ``` 不能依赖应用层先查后写实现互斥。 真正的唯一性应该由数据库原子约束保证。 例如: ```sql INSERT INTO idempotency_record (...) VALUES (...) ON CONFLICT (tenant_id, idempotency_key) DO NOTHING; ``` 只有成功插入记录的请求获得: ```text 创建权 ``` 其他请求读取已有记录并返回对应的 `task_id`。 *** ** * ** *** ### 6. 最好把幂等记录和Task放在一个事务中 假设先保存: ```text idempotency_key = abc ``` 然后服务崩溃,任务还没有创建。 之后客户端重试。 服务端发现: ```text abc已经存在 ``` 但找不到对应任务。 因此本地数据库中最好执行一个事务: ```text BEGIN ``` 完成: ```text 1. 创建idempotency_record 2. 创建task 3. 绑定idempotency_record.task_id ``` 然后: ```text COMMIT ``` 保证: IdempotencyRecord↔Task IdempotencyRecord \\leftrightarrow Task IdempotencyRecord↔Task 具有一致的提交结果。 *** ** * ** *** ### 7. 同一个Key再次请求时怎么处理 收到: ```text Idempotency-Key = abc ``` 首先查询: ```text (tenant_id, abc) ``` #### 7.1 第一次出现 不存在记录。 执行: ```text 创建幂等记录 + 创建Task ``` 然后返回: ```text task_id = T100 ``` #### 7.2 相同Key、相同参数 已经存在: ```text key = abc request_hash = H1 task_id = T100 ``` 新请求计算: Hnew=H1 H_{new}=H_1 Hnew=H1 说明这是同一次请求的重试。 直接返回: ```text task_id = T100 ``` 不会创建新任务。 #### 7.3 相同Key、不同参数 例如第一次: ```json { "repo": "project-a" } ``` 第二次: ```json { "repo": "project-b" } ``` 虽然: ```text Idempotency-Key = abc ``` 相同,但: Hnew≠Hold H_{new}\\neq H_{old} Hnew=Hold 这说明同一个幂等键表达了两个不同业务意图。 服务端应该拒绝。 例如返回: ```http 409 Conflict ``` 并说明: ```text IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD ``` AWS EC2 的 ClientToken 和 Stripe 的 Idempotency-Key 都采用了类似的参数一致性检查。 *** ** * ** *** ### 8. Request Hash应该怎么计算 不能简单直接 hash 原始 JSON 字符串。 下面两个请求: ```json { "a": 1, "b": 2 } ``` 和: ```json { "b": 2, "a": 1 } ``` 业务含义相同,但原始字节不同。 因此应该先进行: ```text Canonicalization ``` 再计算: ##

request_hash

SHA256(canonical_payload)

$$

Canonical Payload 可以包含真正决定任务语义的字段,例如:

text 复制代码
action
resource_id
model
parameters
scope

不要把下面这些随机字段加入语义 hash:

text 复制代码
trace_id
request_time
随机nonce

否则同一次重试也会得到不同 hash。


9. 客户端超时后应该怎么办

假设客户端请求:

text 复制代码
POST /tasks
key = abc

然后发生:

text 复制代码
Timeout

客户端首先可以使用同一个:

text 复制代码
Idempotency-Key = abc

重新提交。

服务端如果已经创建:

text 复制代码
task_id = T100

再次返回:

text 复制代码
T100

如果第一次请求根本没有创建成功,则当前请求完成首次创建。

因此网络层可以采用:

text 复制代码
Retry
+
Same Idempotency-Key

而不需要猜测第一次请求是否成功。


10. 为什么还需要状态查询接口

异步任务创建成功只代表:

text 复制代码
任务进入系统

并不代表:

text 复制代码
任务执行完成

因此还需要:

http 复制代码
GET /tasks/T100

返回:

json 复制代码
{
  "task_id": "T100",
  "status": "RUNNING",
  "created_at": "...",
  "updated_at": "..."
}

状态可以设计为:

text 复制代码
PENDING
↓
RUNNING
↓
SUCCEEDED

失败时:

text 复制代码
RUNNING
↓
FAILED

客户端发生网络异常以后,可以:

text 复制代码
使用相同Idempotency-Key重试创建

获得稳定 task_id,随后:

text 复制代码
GET /tasks/{task_id}

查询真实状态。


11. 为什么202和Task ID要一起使用

202 Accepted 只表示:

text 复制代码
服务端接受了这个异步处理请求

它不表示任务最终一定成功。

因此响应最好同时返回:

text 复制代码
task_id
status
status_url

例如:

json 复制代码
{
  "task_id": "T100",
  "status": "PENDING",
  "status_url": "/tasks/T100"
}

这样:

text 复制代码
POST

负责创建异步任务。

text 复制代码
GET

负责查询异步任务最终状态。

职责边界比较清晰。


12. 任务真正入队时还存在一个双写问题

很多系统创建任务以后还需要:

text 复制代码
写数据库
+
发送Kafka消息

例如:

text 复制代码
INSERT task
↓
Kafka.send(task_created)

存在一个故障窗口:

text 复制代码
数据库成功
↓
进程崩溃
↓
Kafka消息没有发送

此时:

text 复制代码
数据库显示任务存在

但:

text 复制代码
Worker永远收不到任务

反过来如果:

text 复制代码
先发Kafka
↓
数据库事务失败

又可能出现不存在的幽灵任务。


13. 可以用Transactional Outbox解决双写问题

在同一个数据库事务中写入:

text 复制代码
Task
+
Outbox Event
+
Idempotency Record

即:

text 复制代码
BEGIN

INSERT idempotency_record ...
INSERT task ...
INSERT outbox_event ...

COMMIT

形成:

Task+IdempotencyRecord+Outbox Task + IdempotencyRecord + Outbox Task+IdempotencyRecord+Outbox

同事务提交。

随后独立的 Relay:

text 复制代码
读取Outbox
↓
发送Kafka
↓
记录已发布

即使 Relay 崩溃,也可以重新发送。

由于消息本身仍然可能重复发布,Consumer 也需要使用:

text 复制代码
task_id

或:

text 复制代码
event_id

做幂等消费。


14. 任务状态应该记录到什么粒度

可以设计:

text 复制代码
PENDING
QUEUED
RUNNING
SUCCEEDED
FAILED
CANCELLED

对于会产生外部副作用的复杂任务,还可以进一步记录步骤状态:

text 复制代码
PLANNED
STARTED
COMMITTED

例如:

text 复制代码
创建云资源

任务已经:

text 复制代码
STARTED

随后 Worker 超时。

恢复时不能立即再次创建一次云资源。

应该先查询外部系统:

text 复制代码
这个resource_id是否已经创建?

然后再决定:

text 复制代码
继续
完成状态对账
补偿
人工接管

这一点与原表强调的:

text 复制代码
响应丢失先查询结果再决定

一致。


15. 幂等Key需要永久保存吗

通常不需要永久保存,但必须定义明确的:

text 复制代码
Idempotency Window

例如:

text 复制代码
24小时
7天
任务生命周期 + 24小时

具体时间取决于:

  • 客户端最大重试窗口;
  • 任务最长运行时间;
  • 网络故障恢复周期;
  • 存储成本;
  • 业务重复操作风险。

记录可以包含:

text 复制代码
expire_at

达到安全期限后再清理。

客户端必须知道:

幂等保证只在规定的有效期内成立。

如果记录已经过期并被删除,再使用同一个 key,服务器可能把它当成新的任务创建请求。


16. 为什么幂等Key要带作用域

假设两个不同用户都生成:

text 复制代码
key = abc

如果数据库只建立:

text 复制代码
UNIQUE(idempotency_key)

会发生跨用户冲突。

因此一般采用:

text 复制代码
tenant_id
+
idempotency_key

或者:

text 复制代码
user_id
+
endpoint
+
idempotency_key

作为作用域。

例如:

Unique(tenant,endpoint,key) Unique( tenant, endpoint, key ) Unique(tenant,endpoint,key)

这样相同 key 可以安全出现在不同租户或不同业务接口中。


17. 哪些错误允许重试

幂等机制解决的是:

text 复制代码
重复执行副作用

它不意味着所有失败都应该重试。

可以分类:

17.1 可以重试

例如:

text 复制代码
网络超时
429
部分5xx
临时依赖故障
连接重置

采用:

text 复制代码
有限重试
+
指数退避
+
Jitter

并保持相同 Idempotency-Key

17.2 通常不应自动重试

例如:

text 复制代码
400参数错误
401认证失败
403权限拒绝
业务规则拒绝
Idempotency-Key参数冲突

这些问题重复发送相同请求通常仍然失败。


18. 创建失败以后同一个Key怎么办

这一点必须在接口契约中提前规定。

一种设计是:

text 复制代码
只要任务已经正式创建
→
key永久绑定该task直到幂等窗口结束

即使最终:

text 复制代码
task.status = FAILED

客户端再次使用相同 key,仍然得到:

text 复制代码
原来的FAILED任务

如果用户真正希望"重新执行一次",需要生成新的:

text 复制代码
Idempotency-Key

这种语义最清晰。

另外一种设计允许特定失败阶段重新提交,但状态机会复杂很多。

一般业务中,我会优先采用:

text 复制代码
一个Idempotency-Key
=
一次业务意图
=
一个Task

任务执行失败也不会改变这个映射。


19. 最终应该保证哪些不变量

19.1 唯一任务

在一个幂等作用域内:

key→task key \rightarrow task key→task

必须是单值映射。

即:

∣{task:key=k}∣≤1 |\{task:key=k\}|\leq1 ∣{task:key=k}∣≤1

19.2 参数一致性

同一 key 的所有合法重试:

request_hashi=request_hash1 request\_hash_i=request\_hash_1 request_hashi=request_hash1

19.3 返回结果稳定

同一请求重试:

task_idi=task_id1 task\_id_i=task\_id_1 task_idi=task_id1

19.4 本地状态一致

任务存在时,对应的幂等绑定也必须存在:

Task(k)⇒IdempotencyRecord(k) Task(k) \Rightarrow IdempotencyRecord(k) Task(k)⇒IdempotencyRecord(k)

19.5 异步事件最终可发布

如果任务创建事务已经提交:

TaskCreated⇒EventEventuallyPublished TaskCreated \Rightarrow EventEventuallyPublished TaskCreated⇒EventEventuallyPublished

使用 Outbox 时可以通过重试实现这一性质。


20. 应该怎样测试

我会专门设计下面这些测试。

20.1 连续重复请求

同一个请求发送100次:

text 复制代码
key = abc
payload = P1

最终要求:

text 复制代码
task数量 = 1

并且100个响应中的:

text 复制代码
task_id

全部相同。

20.2 高并发重复请求

100个线程同时发送:

text 复制代码
same key
+
same payload

仍然要求:

text 复制代码
task数量 = 1

这主要验证数据库唯一约束是否真正生效。

20.3 相同Key不同Payload

发送:

text 复制代码
key = abc
payload = P1

随后:

text 复制代码
key = abc
payload = P2

要求:

text 复制代码
第二次请求被拒绝

不能静默返回旧任务。

20.4 响应丢失

服务端完成:

text 复制代码
Task创建

随后模拟响应丢失。

客户端重试后仍然得到:

text 复制代码
同一个task_id

20.5 数据库事务中途失败

分别在:

text 复制代码
创建Idempotency Record后
创建Task后
创建Outbox后

注入故障。

要求事务回滚后不存在半完成状态。

20.6 Kafka不可用

数据库事务成功,但 Kafka 暂时不可用。

要求:

text 复制代码
Task仍然存在
Outbox仍然存在

Kafka恢复后事件最终发送。

20.7 Key过期

超过 expire_at 后验证服务端行为与接口协议一致。


21. 面试时可以压缩成下面这段

异步任务创建通常是 POST,所以我会要求客户端为一次业务意图生成唯一的 Idempotency-Key。服务端以 (tenant_id, idempotency_key) 建数据库唯一约束,同时保存请求参数的 canonical hash 和对应的 task_id

第一次请求通过数据库事务创建幂等记录和 Task。相同 key、相同参数再次请求时直接返回原来的 task_id;相同 key 携带不同参数时返回冲突。这样客户端即使遇到网络超时,也可以安全地携带同一个 key 重试。

异步接口可以返回 202 Accepted、稳定的 task_id 和状态查询地址。客户端随后通过 GET /tasks/{task_id} 查询执行状态。

如果创建 Task 后还需要发送 Kafka 消息,我会把 Task、Idempotency Record 和 Outbox Event 放在同一个本地事务中,再由 Relay 异步发送 Kafka,解决数据库和消息系统之间的双写问题。

最终要保证:

一个业务意图 → 一个Idempotency-Key → 一个Task

并通过唯一约束、request hash、事务、状态查询和 Outbox 覆盖并发重复、响应丢失和部分失败。


22. 来源

  1. RFC 9110, HTTP Semantics :定义 HTTP 幂等方法,并规定 202 Accepted 表示请求已经接受处理但尚未完成,响应应提供当前状态及状态监控入口。
  2. AWS Builders' Library, Making retries safe with idempotent APIs:讨论使用 caller-provided request identifier 实现安全重试,以及相同 request ID 携带不同参数时的冲突处理。
  3. Amazon EC2 Documentation, Ensuring idempotency in Amazon EC2 API requests :说明 ClientToken 的幂等机制,以及相同 Token、不同参数触发 IdempotentParameterMismatch
  4. Stripe API Documentation, Idempotent requests :说明 Idempotency-Key、首次请求结果复用、参数一致性检查以及幂等 key 生命周期。
  5. PostgreSQL Documentation, INSERT / ON CONFLICT:说明使用唯一约束冲突作为原子写入仲裁机制。
  6. AWS Prescriptive Guidance, Transactional outbox pattern:说明数据库状态更新和消息发布之间的 Dual Write 问题,以及 Transactional Outbox 的处理方式。
相关推荐
倔强的石头1061 小时前
连接数失控排查:从数据库会话、应用线程池到连接池泄漏
数据库·oracle
用户0934077735141 小时前
HarmonyOS WPS Open SDK 实践:把水印与修订收成打开策略层
android·typescript·harmonyos
sumatch1 小时前
ESP32
android
恒拓高科WorkPlus1 小时前
企业如何搭建安全内部通讯平台|企业内部通讯平台怎么选才更安全?
jvm·数据库·安全
夜雪一千2 小时前
MySQL如何限制账号权限,最小权限原则实践
数据库·mysql
pengyu2 小时前
【Kotlin 协程修仙录 · 大乘境 · 后阶】 | 死锁天劫:协程同步原语与 ThreadLocal 迁移之道
android·kotlin
事圆则缓2 小时前
Android 常用设计模式速查
android·设计模式
sickworm陈浩2 小时前
日常修改,3秒生效:腾讯音乐 Android 秒编方案 Jugg 开源
android·编译原理·编译器
2601_962056232 小时前
EasyMarkets:“网络安全需求持续升温”
数据库·人工智能