cswork / 系统设计

第 57 章 / 共 57 章 · 约 7 分钟

API 设计 5:Webhook 与长任务

本页目录
  1. 大文件上传:别经过你的服务器
  2. 长任务:先返回 202
  3. Webhook 的设计要点
  4. 扩展讨论
  5. 章节测验

有些操作没法在一次请求里做完:上传一个 2GB 的视频,转码要几分钟,生成一份年度报表要半小时。这一章讲三件事:大文件怎么传,长任务的接口怎么设计,任务完成后怎么通知调用方。案例是一个视频平台的上传和转码 API。

大文件上传:别经过你的服务器

让 2GB 的文件流经 API 服务器,会长时间占着连接和带宽,服务器重启时上传就断了。更好的做法是让客户端直接传到对象存储:

  1. 客户端调用 POST /uploads,说明文件大小和类型;
  2. API 校验权限和大小限制,向对象存储发起分片上传,为每个分片生成一个预签名 URL(带签名和过期时间,只允许往这个位置写入);
  3. 客户端用这些 URL 并行上传各个分片,失败的分片单独重传;
  4. 全部传完后调用 POST /uploads/{id}/complete,API 通知对象存储合并分片。

分片让上传可以断点续传,也能并行提速。对象存储的细节见对象存储和大文件处理两章。

长任务:先返回 202

转码要几分钟,不能让 HTTP 请求一直挂着等:连接会超时,网关和负载均衡器也不允许。正确的做法是立即返回 202 Accepted,并给出一个操作资源的地址:

上传视频并转码:直传对象存储,异步处理,完成后用 Webhook 通知
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
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 要设置较短的过期时间,并限制文件大小和类型;上传完成后再做病毒扫描和格式校验。

章节测验

3 道选择题
  1. 为什么 Webhook 签名里要带时间戳,并且拒绝太旧的请求?
  2. Webhook 采用“至少一次”投递,接收方应该怎么处理?
  3. 创建转码任务的接口,最合适的设计是?

章节讨论

正在加载讨论…