针对 OpenAI webhook 题目的完整解答:API 与 schema、容量计算、扇出、按端点分道、并发上限与熔断器、带退避和抖动的重试、死信与重放、签名、SSRF 防护以及可观测性。

由 The Forward Deployed 编辑团队撰写并审校

本文属于 OpenAI 系统设计题库。该题目对面试轮次具有代表性。分析与解答为本站原创。

题目描述

客户注册端点,例如

https://example.com/hooks

,并选择事件类型,例如

invoice.paid

。当平台产生一个事件时,该服务会将其投递到每个订阅的端点,对失败进行长达数天的重试,并允许客户查看和重放投递记录。这些端点属于客户,因此它们经常很慢、宕机或配置错误。

澄清问题

  • 量级? 每天 10 亿个事件,峰值约为平均值的三倍。
  • 扇出? 大多数事件只发往一个端点;有些发往多个端点。
  • 投递保证? 至少一次。可以接受重复;不能接受丢失。
  • 顺序? 要问。假设没有全局顺序,但有按对象的顺序提示。
  • 重试窗口? 大约三天,之后放弃并通知客户。
  • 载荷大小? 最大 256 KB,平均 2 KB。
  • 安全性? 客户必须能够验证请求确实来自我们。

Webhook 投递为什么难

你要投递到的端点并不受你控制。任一时刻,都有成千上万个端点在超时、返回错误,或指向已不存在的服务器。在端点健康时运行良好的设计,一旦少数大客户的端点开始超时就会崩溃。

核心故障模式是队头阻塞。如果所有投递共享一个队列和一组发送者,一个慢端点会在每次尝试时占用发送者整整一个超时时长。少数高流量的慢端点就能占满所有发送者,其他所有客户的 webhook 都会在它们后面停滞。

因此,核心矛盾是单个端点的可靠性与所有端点之间的公平性。系统必须对故障端点持续重试数天,同时不能让它们拖慢健康的端点。

flowchart LR P[Producers]:::user --> EL[[Event log]]:::svc --> FO[Fan-out]:::svc FO --> Q1[[Lane: endpoint A]]:::store FO --> Q2[[Lane: endpoint B]]:::store FO --> Q3[[Lane: endpoint C]]:::store Q1 --> S[Senders]:::svc Q2 --> S Q3 --> S S -->|POST| EP([Customer endpoints]):::user classDef user fill:#e6efec,stroke:#315e55,color:#171717; classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef store fill:#fdf3dc,stroke:#c4492d,color:#171717;
关键思想。 将端点彼此隔离。无论一个故障端点有多少流量,它给系统造成的容量损耗都应是有界且很小的。

关键概念

至少一次投递

发送方会不断重试,直到收到成功响应或放弃。端点可能已处理请求但未能及时应答,因此可能会收到同一事件两次。接收方必须根据事件 ID 去重。

指数退避加抖动

每次失败后,等待更长时间再重试:1 分钟、5 分钟、30 分钟、2 小时,依此类推。抖动为每次等待增加随机性,这样大量事件的重试就不会恰好在端点恢复的那一刻同时到达。

熔断器

反复失败后,暂停一段时间不向该端点发送请求。这既保护端点使其得以恢复,也保护你的发送方不在注定失败的请求上浪费时间。

HMAC 签名

发送方使用与客户共享的密钥,对载荷和时间戳计算带密钥的哈希,并将其放入请求头中发送。接收方重新计算该哈希,以验证请求真实且是近期的。

服务端请求伪造(SSRF)

如果客户可以输入任意 URL,攻击者就可以注册

http://169.254.169.254/

或某个内部主机名,让你的服务器调用你自己的内部系统。Webhook 发送方是典型的 SSRF 攻击目标。

核心要点。 使用退避和抖动进行重试,用通道和熔断器实现隔离,用签名证明真实性,并防御来自客户提供的 URL 对自身网络的攻击。

需求

在继续阅读之前。 列出需求。然后说出你会优先针对设计的那一种故障模式。

1.1 功能需求

  • 注册、更新、禁用和删除带事件类型过滤器的端点。
  • 通过 HTTP POST 将每个事件投递到每个匹配的端点。
  • 使用退避机制重试失败,最长持续三天。
  • 记录每次尝试;让客户查询投递历史。
  • 让客户重试单次投递或重放某个时间范围。
  • 对每个请求签名;支持密钥轮换。

1.2 非功能需求

  • 持久性。 已接受的事件绝不丢失。
  • 隔离性。 一个缓慢或故障的端点不会延迟其他客户。
  • 延迟。 健康的端点在数秒内收到事件。
  • 规模。 每天 10 亿事件,峰值每秒 35,000 个。
  • 安全性。 请求签名;防止 SSRF 进入内部网络。

1.3 约束与特性

持久、至少一次的投递是特性。 不可靠的客户端点是约束。 它们决定了通道设计、并发上限、熔断器和重试调度。

粗略估算

  • 平均事件数:10 亿 / 86,400 ≈ 每秒 11,600 个;峰值约为每秒 35,000 个。
  • 按平均扇出 1.2 计算,峰值投递量约为每秒 42,000 次。
  • 发送方:如果一次健康投递耗时 200 毫秒,一个发送方槽位每秒处理 5 次,因此每秒 42,000 次需要约 8,400 个并发槽位。异步 HTTP 客户端每台机器可持有数千个槽位,因此只需几十台机器。
  • 用数字看慢端点问题:一个 10 秒超时的端点,在每秒 1,000 个事件的流量下,如果不设上限,会占用 10,000 个槽位,比整个健康集群的槽位还多。
  • 载荷存储:10 亿 × 2 KB = 每天 2 TB。保留 3 天重试窗口加上 30 天重放窗口的载荷:约 60 TB。
  • 尝试日志:平均每次投递约 1.3 次尝试,每条尝试记录 200 字节,约每天 300 GB。
核心思想。 每秒 42,000 次投递是普通量级。危险的数字是单个慢端点对发送方槽位的需求,它可能超过整个集群。

API 设计

继续阅读之前。 事件载荷应该包含完整对象,还是只包含一个由客户自行获取的 ID?

两者都有用途。完整载荷为客户省去一次往返请求,并且在你的 API 宕机时仍然可用。只含 ID 的精简载荷避免发送过期或敏感数据,并保持载荷较小。一个常见的答案是:发送带版本号的完整对象,并在文档中说明 API 是最新状态的唯一可信来源。

3.1 客户 API

POST   /v1/webhook_endpoints
  {url, event_types: ["invoice.paid", "invoice.failed"], description}
  -> 201 {id, secret}            // shown once
GET    /v1/webhook_endpoints/:id
PATCH  /v1/webhook_endpoints/:id  {url?, event_types?, disabled?}
DELETE /v1/webhook_endpoints/:id
POST   /v1/webhook_endpoints/:id/rotate_secret  -> {secret, old_secret_expires_at}

GET  /v1/webhook_endpoints/:id/deliveries?status=failed&limit=50&cursor=...
GET  /v1/deliveries/:id          -> {event, attempts: [{at, status_code, latency_ms, error}]}
POST /v1/deliveries/:id/retry
POST /v1/webhook_endpoints/:id/replay  {from, to, event_types?}

3.2 客户收到的请求

POST https://example.com/hooks
Content-Type: application/json
Webhook-Id: evt_01J9...                     // stable across retries
Webhook-Timestamp: 1790000000
Webhook-Signature: v1,<base64 HMAC-SHA256(secret, id.timestamp.body)>

{"id": "evt_01J9...", "type": "invoice.paid", "created": 1790000000,
 "data": {"object": {...}, "object_version": 12}}

在 10 秒内返回 2xx 响应视为成功。其他任何情况,包括重定向,都视为失败。

数据模型

endpoints ( id, customer_id, url, event_types text[], status, -- active | disabled secret_ref, old_secret_ref, old_secret_expires_at, max_concurrency int DEFAULT 10, created_at ) events ( id, customer_id, type, payload_ref, created_at -- 载荷存于对象存储 ) deliveries ( id, event_id, endpoint_id, status, -- pending | delivered | retrying | dead attempt_count, next_attempt_at, last_status_code, last_error, created_at, delivered_at ) index (endpoint_id, created_at desc) -- 历史分页索引 index (status, next_attempt_at) -- 重试调度索引 attempts (delivery_id, attempt_no, started_at, status_code, latency_ms, error)

在这个量级下,deliveries 和 attempts 应存入分区存储:一个分布式键值或宽列数据库,按端点和时间分区,并设置生存时间用于保留策略。

高层设计

5.1 由生产者内联发送

创建发票的服务直接调用客户的 URL。慢端点会拖慢发票 API,失败会丢失,且没有任何重试。

5.2 修复 1:持久事件日志加扇出步骤

生产者将事件追加到持久日志。一个扇出工作进程读取每个事件,找到订阅的端点,并为每个端点创建一条投递。生产者的工作在事件写入日志后即告结束。

5.3 修复 2:按端点划分通道

将每条投递放入其端点对应的通道,例如以端点 ID 为键的分区。发送方跨通道取任务,但从任一通道取的任务数不超过该端点的并发上限。

flowchart LR FO[Fan-out]:::svc --> LA[[Lane A: healthy]]:::new FO --> LB[[Lane B: timing out]]:::new FO --> LC[[Lane C: healthy]]:::new LA -->|up to 10 in flight| SND[Sender pool]:::svc LB -->|up to 10 in flight, breaker open| SND LC -->|up to 10 in flight| SND classDef svc fill:#f4f1e8,stroke:#315e55,color:#171717; classDef new fill:#ffffff,stroke:#c4492d,stroke-width:2px,stroke-dasharray:5 3,color:#171717;

5.4 修复 3:重试调度器

投递失败后,会离开热通道,并带着下一次尝试的时间进入调度器。时间一到,该投递就回到其所属通道。

5.5 修复 4:死信、重放和仪表盘

在最后一次尝试之后,投递变为死信。客户可以在仪表盘中看到它,并可以重试或重放某个时间范围。

5.6 组合后的设计

sequenceDiagram
  autonumber
  participant P as Producer
  participant L as Event log
  participant F as Fan-out
  participant Q as Endpoint lane
  participant S as Sender
  participant C as Customer endpoint
  participant R as Retry scheduler
  P->>L: append invoice.paid (event id)
  L->>F: consume
  F->>F: lookup subscribed endpoints (cached)
  F->>Q: delivery per endpoint
  S->>Q: take (respecting concurrency cap, breaker)
  S->>S: resolve URL, check IP not private, sign
  S->>C: POST (10 s timeout)
  alt 2xx
    C-->>S: 200
    S->>S: mark delivered, record attempt
  else error or timeout
    S->>R: schedule retry at now + backoff + jitter
    R-->>Q: re-enqueue when due
  end
核心思想。 先写日志,扇出到各端点的通道,限制每个端点的并发,把重试调度移出热路径,最后以客户可重放的死信收尾。

深入探讨

6.1 隔离慢速端点

继续阅读之前。 客户 X 的端点开始出现 10 秒超时。X 每秒接收 1,000 个事件。请推演一下你的系统会如何表现。
  • 并发上限。 通道 X 最多只能有 10 个在途请求。X 的超时现在只占用 10 个槽位,而不是 10,000 个。其他所有人都不受影响。
  • 超时。 10 秒的超时限制了每个槽位的浪费。有些系统使用更短的连接超时,比如 3 秒,以及更长的读取超时。
  • 熔断器。 比如说,在连续失败 20 次后,熔断器打开。在接下来的一分钟内,通道 X 不发送任何内容;投递直接进入重试调度器。然后由一个单独的探测请求来测试端点。成功后,熔断器关闭,投递逐步恢复。
  • 积压。 熔断器打开期间,X 的通道以每秒 1,000 个事件的速度增长。通道存放在持久化存储中,因此可以容纳数小时的积压。
  • 恢复。 当 X 恢复后,以受控的速率排空积压,以免恢复中的端点被压垮。
  • 通知。 在设定的失败时长后,给 X 的负责人发邮件。数天后,禁用该端点。

区分答案水平的关键:隔离

弱共享队列

所有投递共享一个队列和一个发送方池,因此一个慢客户会拖慢所有人。

良好按端点设置并发上限

限制每个端点的并发请求数,并使用较短的超时。

强通道、熔断器和受控恢复

增加带探测的熔断器、持久化的按端点积压、恢复后限速排空,以及针对长期失败的自动通知和禁用。

6.2 重试调度

采用类似这样的调度:1 分钟、5 分钟、30 分钟、2 小时,之后每 5 小时一次,直到大约 3 天。为每次等待增加约 20% 的抖动,这样一批同时失败的事件就不会一起重试。

重试绝不能放在热通道里,否则会阻塞新事件。应将它们放入调度器:一张按下次尝试时间索引的表,每秒轮询一次到期项;或使用延迟队列,在指定时间释放消息。到期后,投递重新进入其端点所在的通道。

对响应要区别对待。超时、连接错误、5xx 和 429(遵循 Retry-After)可以重试。400、401、404 或 410 不要重试超过几次;这些问题很少会自行恢复,而 410 Gone 应禁用该端点。

6.3 顺序与重复

在继续阅读之前。 一位客户投诉说他们先收到了 invoice.updated,后收到 invoice.created。这是 bug 吗?

不是;这在有重试的情况下是预期行为。如果

created

失败并在之后重试,而

updated

成功了,客户看到它们的顺序就会错乱。保证顺序意味着一旦较早的事件失败,就要阻塞该对象的所有后续事件,这是用一次失败的投递换来多次失败的投递。

相反,应明确文档说明顺序不做保证,并为客户提供处理它的工具:事件创建时间,以及

object_version

载荷中的字段,让他们可以忽略比自己已有的更旧的更新。有些客户干脆在任何事件到来时直接获取对象的最新状态。

重复也是预期行为。事件 ID 在重试期间保持不变,接收方据此去重。请清晰地公布这一契约。

6.4 签名与密钥轮换

使用端点的密钥,对事件 ID、时间戳和原始请求体做 HMAC-SHA256 签名,签名每个请求。接收方重新计算签名,并拒绝时间戳超过五分钟的请求,这可以防止被截获的请求被重放。

轮换:当客户轮换密钥时,签发一个新密钥,并让旧密钥在 24 小时内保持有效。在此窗口期内,在请求头中同时发送两个签名,每个密钥一个。客户可以在窗口期内的任意时间更新其服务器,而不会丢失事件。

6.5 SSRF 防护

在继续阅读之前。 一位客户将 http://metadata.internal/latest/credentials 注册为他们的 URL。是什么阻止你的发送方去抓取它?

有多项检查,因为任何单独一项都可能被绕过:

  • 注册时要求 HTTPS 和公共 DNS 名称。
  • 在发送时解析并检查,而不仅在注册时。DNS 是可能变化的:攻击者可以在注册时把一个域名指向公共 IP,之后再指向私有 IP。
  • 对每个解析出的地址(IPv4 和 IPv6),屏蔽私有、回环、链路本地和元数据地址段。
  • 连接到你检查过的那个 IP,而不是重新解析域名,以防止检查与连接之间发生 DNS 重绑定。
  • 不要跟随重定向,或者对每一跳都做同样的检查。
  • 让发送方运行在隔离网络中,该网络本来就没有通往内部服务的路由,作为纵深防御。

6.6 可观测性

向每个客户展示其端点的健康状况:成功率、延迟、近期错误和积压。这样可以在客户提问之前就回答大部分支持问题。在内部,跟踪每个端点的通道滞后、发送方槽位使用情况、熔断器状态计数、每小时死信数量,以及最旧未送达事件的存留时间。对滞后进行告警,因为滞后才是客户真正感受到的东西。

6.7 选择队列技术

在继续阅读之前。 你会用 Kafka 分区、数据库表,还是支持每队列语义的消息代理来构建通道?每种方案各会在哪里出问题?

按端点分区的 Kafka。 持久且快速。但一个分区是有序的:卡在分区头部的消息会阻塞该分区中排在后面的所有消息。由于许多端点共享每个分区,一个慢端点会拖累同分区的其他端点。Kafka 适合作为供扇出使用的事件日志,而不太适合直接充当通道本身。

一张投递数据库表。 发送方通过 skip-locked 查询和每端点并发计数器,按端点选取到期的行。灵活且易于检查;但在每秒 42,000 次投递的规模下,写入负载需要分区数据库和精心的索引设计。

在支持大量队列和逐条消息确认的代理中为每个端点建一个队列。 它与通道直接对应,但数百万个端点意味着数百万个队列,而许多代理对此处理不佳。

一种常见的生产形态:用 Kafka 作为事件日志;用分区的键值存储或关系型存储保存投递和重试状态;再由一个调度器在并发上限内按端点分发到期的投递。明确说出这种权衡;面试官想看到你明白为什么单一有序分区是个问题。

6.8 一个故障端点的完整一天

练习题:端点 E 每秒接收 50 个事件。09:00 时它的服务器开始返回 500。

  • 09:00 到 09:01:连续 20 次失败触发 E 的熔断器。发送方停止调用 E。E 的新投递进入重试调度器,延迟 1 分钟。
  • 09:01 起:每分钟用一个探测请求测试 E。每次探测都失败。
  • 09:30:E 的积压达到 90,000 个事件,已持久化存储。其他端点不受影响。
  • 10:00:一封邮件提醒 E 的负责人;仪表盘显示故障和积压情况。
  • 11:15:E 恢复;一次探测成功;熔断器闭合。
  • 11:15 起:约 400,000 个事件的积压以受限速率排空,例如每秒 200 个,高于 E 正常的每秒 50 个,约 45 分钟完成。
  • 结果:没有事件丢失;延迟等于故障时间加上排空时间;其他人都没察觉。

区分答案水平的关键:机制

弱对队列没有认识

只是提到某种队列,却没有考虑队头阻塞或积压。

良好了解有序分区

能解释为什么共享的有序分区会让健康端点被慢端点阻塞。

优秀端到端地走完一次故障

为交付选择存储并给出重试原因,并将端点故障的完整过程走一遍:熔断器、探测、积压、告警以及限速的排空。

变体

7.1 按对象严格排序

如果某些客户确实需要顺序,可以提供一种可选模式,按对象 ID 对交付排序:某个对象的通道会被其首次失败阻塞。要把代价讲清楚:一个失败的事件会阻塞该对象之后的所有事件。

7.2 批处理

高流量客户可能更倾向于批量:每个请求最多 100 个事件,每秒刷新一次。请求数更少,但每次失败会影响更多事件。

7.3 拉取代替推送

提供一个客户可以用游标轮询的事件 API 作为备选方案。对于偏好这种方式的客户,它消除了端点可靠性问题。

7.4 事件量达到十倍时

在每天 100 亿事件的规模下,峰值交付量约为每秒 42 万次。该设计依然成立,只需更多发送者和更大的交付存储。成本转移到出站带宽和尝试日志上:几天后对成功的尝试记录进行采样,失败记录则完整保留。向最大的客户提供批处理,这能将他们的请求数最多降低一百倍。

可迁移的模式

Webhook 交付本质上是向不可靠的消费者进行可靠交付。同样的设计适用于推送通知、邮件发送以及任何调用合作方系统的集成:持久化日志、按目的地隔离的通道、上限和熔断器、在热路径之外进行带退避和抖动的重试、带重放的死信,以及严格的出站策略。

复习:30 秒回答

  • 先写日志,再分发到按端点划分的通道。 生产者永远不必等待客户。
  • 限制每个端点的并发;加入熔断器。 一个慢端点只占用 10 个槽位,而不是整个集群。
  • 在调度器中进行带退避和抖动的重试。 大约三天后进入死信并可重放。
  • 至少一次、不保证顺序,并使用稳定的事件 ID。 接收方去重并使用对象版本。
  • 使用 HMAC 和时间戳签名;阻止 SSRF。 在发送时检查解析后的 IP,并且不跟随重定向。

测验

+一个客户的慢端点如何拖慢所有其他客户?

如果所有交付共享同一个发送者池,每个慢请求都会在整个超时期间占用一个发送者。一个高流量的慢端点可以占满所有发送者,导致所有其他交付都在等待。

+为什么要在重试延迟中加入抖动?

否则一起失败的事件会一起重试,在同一时刻以突发流量冲击正在恢复的端点。抖动可以把重试分散开。

+为什么要在发送时检查 URL 解析出的 IP,而不是只在注册时检查?

DNS 在注册后可能发生变化。攻击者可以先注册一个指向公网地址的域名,然后将其重新指向内部地址。

+为什么不默认保证按序交付?

排序会迫使一个对象的每个后续事件都排在前面失败的事件之后等待。对大多数客户来说,更好的方案是无序投递,配合时间戳和对象版本。

+密钥轮换如何避免丢失事件?

旧密钥在一个时间窗口内保持有效,请求同时携带两个密钥的签名。客户可以在窗口内的任意时间完成切换。

+为什么 Kafka 分区不适合作为每个端点的独立通道?

分区是有序的,而多个端点共享同一个分区。队头卡住的消息会延迟其后的所有消息,包括其他端点的投递。

+端点恢复后,为什么要以限速方式清空其积压?

端点刚恢复时很脆弱。全速发送整个积压可能再次把它压垮;以高于其正常流量的限速清空积压,既不会压垮它,又能完成清理。

资料来源与延伸阅读

NextDesign Slack