第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. 来源
- RFC 9110, HTTP Semantics :定义 HTTP 幂等方法,并规定
202 Accepted表示请求已经接受处理但尚未完成,响应应提供当前状态及状态监控入口。 - AWS Builders' Library, Making retries safe with idempotent APIs:讨论使用 caller-provided request identifier 实现安全重试,以及相同 request ID 携带不同参数时的冲突处理。
- Amazon EC2 Documentation, Ensuring idempotency in Amazon EC2 API requests :说明
ClientToken的幂等机制,以及相同 Token、不同参数触发IdempotentParameterMismatch。 - Stripe API Documentation, Idempotent requests :说明
Idempotency-Key、首次请求结果复用、参数一致性检查以及幂等 key 生命周期。 - PostgreSQL Documentation, INSERT / ON CONFLICT:说明使用唯一约束冲突作为原子写入仲裁机制。
- AWS Prescriptive Guidance, Transactional outbox pattern:说明数据库状态更新和消息发布之间的 Dual Write 问题,以及 Transactional Outbox 的处理方式。