第 57 章 / 共 57 章 · 约 7 分钟
API 设计 5:Webhook 与长任务
有些操作没法在一次请求里做完:上传一个 2GB 的视频,转码要几分钟,生成一份年度报表要半小时。这一章讲三件事:大文件怎么传,长任务的接口怎么设计,任务完成后怎么通知调用方。案例是一个视频平台的上传和转码 API。
大文件上传:别经过你的服务器
让 2GB 的文件流经 API 服务器,会长时间占着连接和带宽,服务器重启时上传就断了。更好的做法是让客户端直接传到对象存储:
- 客户端调用
POST /uploads,说明文件大小和类型; - API 校验权限和大小限制,向对象存储发起分片上传,为每个分片生成一个预签名 URL(带签名和过期时间,只允许往这个位置写入);
- 客户端用这些 URL 并行上传各个分片,失败的分片单独重传;
- 全部传完后调用
POST /uploads/{id}/complete,API 通知对象存储合并分片。
分片让上传可以断点续传,也能并行提速。对象存储的细节见对象存储和大文件处理两章。
长任务:先返回 202
转码要几分钟,不能让 HTTP 请求一直挂着等:连接会超时,网关和负载均衡器也不允许。正确的做法是立即返回 202 Accepted,并给出一个操作资源的地址:
查看图表源码
sequenceDiagram
accTitle: 上传视频并转码:直传对象存储,异步处理,完成后用 Webhook 通知
participant C as 客户端
participant API as 视频 API
participant S3 as 对象存储
participant W as 转码任务
participant H as 客户的 Webhook 地址
C->>API: POST /uploads(文件大小、类型)
API-->>C: upload_id 和每个分片的预签名 URL
C->>S3: 并行 PUT 各个分片
C->>API: POST /uploads/{id}/complete
API-->>C: 202 Accepted,Location /operations/op_7
API->>W: 提交转码任务
C->>API: GET /operations/op_7(轮询,可选)
API-->>C: running,进度 40%
W->>API: 转码完成
API->>H: POST video.ready 事件(带签名)
H-->>API: 200
操作资源记录任务状态,调用方可以随时查询:
{
"id": "op_7",
"status": "running",
"progress": 40,
"created_at": "2026-10-15T09:12:00Z",
"result": null,
"error": null
}
状态一般是 pending、running、succeeded、failed 几种,成功时 result 指向产物(比如转码后的视频),失败时 error 用上一章的错误格式说明原因。如果任务支持取消,提供 POST /operations/{id}:cancel。长任务在服务端怎么调度、重试,见长任务一章。
查询方式有三种,各有适用场景:
| 方式 | 怎么工作 | 适合 | 代价 |
|---|---|---|---|
| 轮询 | 调用方定期 GET 操作资源 | 任务较短,调用方没有公网地址 | 大量无效请求,要带退避 |
| Webhook | 完成时服务端 POST 到调用方提供的 URL | 服务器之间的集成 | 调用方要有公网地址,要处理签名、重复和乱序 |
| SSE 或 WebSocket | 保持连接,服务端推送进度 | 用户在页面上等着看进度条 | 要维持长连接 |
实践中常常组合使用:Webhook 负责通知,轮询作为兜底,因为 Webhook 可能因为对方故障而迟到。
Webhook 的设计要点
一次事件投递长这样:
POST /hooks/video HTTP/1.1
Content-Type: application/json
Webhook-Id: evt_3Kd9
Webhook-Signature: t=1792055521,v1=6c1f0e4b...
{"id": "evt_3Kd9", "type": "video.ready", "created": 1792055521,
"data": {"video_id": "vid_88", "operation_id": "op_7"}}
签名。接收方要能确认请求确实来自你,而且内容没被改过。做法是为每个 Webhook 地址生成一个密钥,对“时间戳 + 请求体”做 HMAC-SHA256,把时间戳和签名放进请求头。接收方用同样的密钥重新计算,用常量时间比较;时间戳太旧就拒绝,防止有人截获一个合法请求反复重放。
至少一次投递。对方返回非 2xx 或超时,就按指数退避重试(加随机抖动),持续一两天。这意味着同一个事件可能送达多次,接收方必须按事件 ID 去重。
快速响应。接收方应该验签、去重、放进自己的队列,然后马上返回 200,再慢慢处理。如果处理完才返回,处理时间一长就会触发发送方的超时重试。
顺序不保证。重试会打乱顺序,video.ready 可能比 video.processing 先到。事件里带上资源 ID 和版本号,接收方需要时再去 API 查一次最新状态,不要依赖到达顺序。
发送方的安全。Webhook 地址是客户填写的,你的服务器会向任意地址发请求。要校验地址:拒绝内网 IP 和本机地址,防止被利用来探测你的内网(SSRF)。
接收方和它的队列消费端的参考实现:
import hashlib
import hmac
import json
import time
def verify(secret: bytes, header: str, body: bytes, tolerance: int = 300) -> bool:
fields = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
ts, sig = fields.get("t", ""), fields.get("v1", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False # too old: possibly a replay
expected = hmac.new(secret, ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
def handle(db, queue, secret: bytes, header: str, body: bytes) -> int:
if not verify(secret, header, body):
return 400
if db.exists("seen_events", json.loads(body)["id"]): # already processed
return 200
queue.put(body) # raises if the queue is down: a 5xx, so the sender retries
return 200 # do the slow work later; answer fast
def consume(db, body: bytes) -> None:
event = json.loads(body)
with db.transaction():
if not db.insert_if_absent("seen_events", event["id"]):
return # queued twice: the first copy already did the work
process(event) # its writes commit together with the seen id
事件 ID 要在处理时记为已处理,和处理结果写在同一个事务里,而不是一收到就记。如果 handle 先记下 ID、入队却失败了,发送方重试时会被当成重复事件直接拿到 200,这个事件就永远丢了。handle 里的检查只是省掉一次排队,真正让重复事件无害的是消费端。
扩展讨论
- 失败处理:连续失败太多次就暂停这个地址并通知客户;控制台提供“重新发送”和最近投递记录。
- 事件内容:只放 ID 的“瘦事件”更安全(敏感数据不经过 Webhook),代价是接收方要多调用一次 API。
- 密钥轮换:轮换期间同时用新旧两个密钥签名,请求头里带两个签名,接收方任意一个验证通过即可。
- 上传安全:预签名 URL 要设置较短的过期时间,并限制文件大小和类型;上传完成后再做病毒扫描和格式校验。