问题与范围
一个 SaaS 平台会产生 invoice.paid、order.shipped、user.disabled 等事件。客户注册 HTTPS 端点,并为每个端点订阅指定事件类型。请设计从业务事件提交成功到客户返回 HTTP 响应的出站投递平台。端点管理、投递历史、密钥轮换和手动重放都在范围内;客户确认请求之后如何在内部处理,不受平台控制。
采用以下面试假设:
- 产品每天产生 5000 万个业务事件,每个事件平均匹配 4 个端点,因此每天形成 2 亿个逻辑投递。
- 平均负载约为每秒 579 个事件、2315 个新投递;10 倍峰值约为每秒 5787 个事件、23148 个新投递。
- 若重试令尝试次数增加 10%,峰值分发链路应能承载约每秒 25463 次尝试。
- 正常峰值下,99% 符合条件的新投递应在 10 秒内开始第一次尝试。重试截止时间为 24 小时,投递历史可查询 30 天。
- 假设不可变事件载荷为 1.5 KB、每个投递元数据为 300 字节、每次尝试为 250 字节,且每个投递平均尝试 1.1 次,则逻辑存储约为每天 190 GB、30 天 5.7 TB;未计索引、副本、压缩和对象存储开销。
这些数值用于容量推导,不是行业基准。计费、任意载荷转换、入站 Webhook 和客户应用内部设计不在范围内。核心契约是至少一次投递:允许重复和乱序,但承诺的保留与重试边界内,任何静默丢失都必须能被发现和修复。
面试官在考察什么
第一个信号是候选人能否准确区分标识和交付保证。一个 eventid 代表已经提交的业务事实;一个 deliveryid 代表该事件发往某个端点,并在自动重试和手动重放时保持不变。对 (eventid, endpointid) 建唯一约束,可防止扇出任务重放时生成第二个逻辑投递。但若工作进程没有收到响应,它仍无法阻止同一 HTTP 请求两次到达客户。
第二个信号是事务边界。业务数据库提交后再直接发布消息会形成双写缺口:事务可能已经提交,进程却在发布前崩溃。事务型 outbox、变更数据捕获流或等价的持久事件源可以封闭这个缺口。扇出阶段应先物化投递状态再发送,让系统能回答哪些端点被选中、哪些尝试已经发生、还有哪些任务到期未执行。
第三个信号是故障隔离。一个缓慢端点不能占满所有连接;一个拥有数千个故障目标的租户不能耗尽健康租户的重试预算。仅靠队列分区不能保证公平,还需要端点级并发上限、租户配额、延迟重试调度,以及断路或暂停状态。
第四个信号是双向安全。接收方需要对精确载荷字节和投递元数据做 HMAC 签名验证;发送方也必须把客户可控 URL 视为 SSRF 攻击面,限制协议和目标地址、重新校验 DNS 结果、约束重定向并隔离出口网络。优秀答案还会把这些控制与密钥轮换、防重放、审计和事故处置连起来。
回答前要澄清的问题
- 什么算成功? 本设计把任何
2xx响应视为端点确认。3xx、超时、连接错误、408、429或5xx都不算成功。确认只代表端点接受了请求,不能证明客户后续业务处理成功。 - 承诺哪些事件和载荷版本? 每种事件类型都需要有文档化的 schema 和版本策略。重试必须发送相同的不可变载荷字节,不能用数据库当前状态重建旧事件。
- 需要什么顺序? 默认不保证顺序。若客户需要同一业务对象的顺序,可携带
objectid和单调递增的objectversion,或提供按 key 有序的可选能力并接受队头阻塞。全局有序既没有必要,也无法经济地实现。 - 允许重试和重放多久? 本题中自动重试在 24 小时后停止,日志保留 30 天。自动重试窗口结束后的手动重放仍使用原事件和投递标识,但会创建新的尝试记录。
- 端点可以指向任意地址吗? 产品只接受公网 HTTPS 端点。IPv4 和 IPv6 的私网、环回、链路本地、多播、保留地址和云元数据地址都必须拒绝。
- 哪些数据可以离开平台? 扇出时要检查订阅授权并最小化载荷。若集成方能通过鉴权 API 获取详情,敏感字段应省略或改为资源引用。
30 秒回答框架
“我会通过 outbox 提交每个业务事件,将其发布到持久事件日志,再由扇出服务解析当前有效订阅。服务先为每个 (eventid, endpointid) 创建一条投递记录,再把任务送入队列。工作进程用租约领取尝试,执行端点和租户级限流,把不可变载荷字节与稳定的投递 ID、当前尝试时间戳一起签名,然后通过 HTTPS 发送。2xx 结束投递;可重试故障采用带完全抖动的指数退避,直到 24 小时截止;永久故障立即停止。由于请求发出后的超时结果不确定,契约只能是至少一次,客户必须按投递 ID 去重。我还会补上密钥轮换、SSRF 安全 URL 校验、投递日志与重放、端点断路器,以及检查 outbox、扇出和队列缺口的对账任务。”
分步深入设计
从事件产生开始。业务状态变更所在的本地事务内,同时写入一条 outbox 记录,包含 eventid、租户、事件类型、schema 版本、业务对象标识和版本、发生时间,以及规范化序列化载荷字节的引用。outbox relay 将其发布到按分区组织的持久日志。relay 可能重复发布,因此下游按 eventid 去重。若业务数据跨越多个系统,权威生产者负责事件;Webhook 服务不能通过轮询可变表来重新拼装业务事实。
控制面 API 可以保持精简明确:
POST /v1/webhook-endpoints
PATCH /v1/webhook-endpoints/{endpoint_id}
POST /v1/webhook-endpoints/{endpoint_id}/rotate-secret
GET /v1/webhook-deliveries?endpoint_id=&status=&cursor=
POST /v1/webhook-deliveries/{delivery_id}/replay创建端点时验证租户身份,接收 HTTPS URL 和事件类型过滤条件,校验目标地址,并只返回一次签名密钥。挑战投递可以证明端点所有权,但挑战成功并不代表未来的 DNS 解析结果都安全。密钥轮换在有限重叠期内同时保留当前和上一版本,并标记本次签名使用的版本。URL 更新应产生可审计的配置版本,不能静默改写历史尝试。
使用四种持久记录:
Endpoint(endpoint_id, tenant_id, url, status, event_types,
current_secret_version, previous_secret_version, config_version)
Event(event_id, tenant_id, type, schema_version, object_id,
object_version, occurred_at, payload_ref, payload_hash)
Delivery(delivery_id, event_id, endpoint_id, endpoint_config_version,
status, attempt_count, next_attempt_at, expires_at, lease_version)
Attempt(attempt_id, delivery_id, attempt_number, started_at, finished_at,
http_status, latency_ms, error_class, response_digest)扇出服务读取事件,查询该租户和事件类型下获得授权且处于启用状态的订阅,再分小批插入投递记录。数据库对 (eventid, endpointid) 强制唯一。只有投递行落盘后,系统才把 delivery_id 入队。如果入队失败,扫描任务会找出到期但没有活动队列工作的 PENDING 行。扇出进程若在中途崩溃,重放会再次查订阅,唯一约束只允许补齐缺失行。保存订阅快照或端点配置版本,才能解释后续审计结果。
新投递和重试不能共用一个无限 FIFO。调度器先把到期行放入时间桶,再执行租户加权公平调度、端点令牌桶和端点并发上限。缓慢端点达到自己的 in-flight 上限后,健康端点仍能继续。连续失败会打开断路器并推迟后续尝试,但投递仍然可见,也仍可执行受控探测。租户总配额可以阻止一个租户用大量坏端点占满工作进程。重试可借用空闲容量,但不能让最老的新投递违反 SLO。
工作进程以原子方式领取投递租约,并递增 leaseversion。它读取已经保存的载荷字节,生成本次尝试时间戳,用 HMAC-SHA256 对 deliveryid.timestamp.payload_bytes 之类的规范字符串签名。发送的必须是被签名的原始字节,同时在请求头携带事件类型、投递 ID、事件发生时间、尝试时间、schema 版本和签名版本。接收方对原始字节做常量时间比较,检查时间戳容差,并按稳定的投递 ID 去重。每次重试使用新的尝试时间和签名,但投递 ID 与事件字节保持不变。
响应策略必须确定。任何 2xx 都把投递标记为 SUCCEEDED;不能自动跟随 3xx。408、429、5xx、连接重置和超时可重试,并在 429 或 503 时遵守设有上限的 Retry-After;其他大多数 4xx 对当前配置属于永久故障。TLS 与 DNS 故障可以先重试,但应快速打开端点断路器。采用带完全抖动的指数退避、最大延迟、最大尝试次数和 24 小时绝对截止时间。请求离开工作进程后的超时是未知结果:重试可能让客户重复处理,因此平台绝不能承诺恰好一次。
手动重放会为同一逻辑 Delivery 新增一条 Attempt,不会创建新的业务事件。操作人员可以看到原始不可变载荷、当时使用的端点配置、所有响应分类,以及自动重试是否仍在进行。重放前重新鉴权,新尝试可以使用端点当前有效密钥签名。若产品承诺历史认证也必须逐字节保持一致,就需要按明确的密钥保留策略保存对应历史密钥。
客户提供的 URL 需要专门的出口安全边界。用一种经过验证的 URL 解析实现,只允许 HTTPS 和批准端口,拒绝 URL 中的凭据,解析全部 A 与 AAAA 记录,并阻止私网、环回、链路本地、保留、多播和元数据地址。连接时再次验证,或固定已验证的地址,以减小 DNS rebinding 和检查时与使用时不一致的缺口。关闭重定向;若必须支持,每一跳都重新执行完整策略,且不能转发密钥。工作进程所在出口网络不能访问控制面或内部服务,并限制连接时间、总请求时间、响应字节和解压大小。
容量取决于扇出,而不只是事件数。5000 万事件乘以 4 个订阅,得到每天 2 亿个投递;每个投递平均 1.1 次尝试,则每天有 2.2 亿条尝试历史。按题设原始大小计算,50M × 1.5 KB = 75 GB,200M × 300 B = 60 GB,220M × 250 B = 55 GB,合计约每天 190 GB。只为当前到期状态保留小型在线索引,按时间和租户哈希对历史分区,把不可变载荷和旧尝试归档到更便宜的存储。监控事件到扇出的延迟、新投递和重试的最老年龄、第一次尝试延迟、按端点和状态码分类的成功率、重试放大倍数、断路状态、租户限流和重放结果。
最后,对每个持久边界做对账:比较已提交 outbox 与已发布事件 ID、事件与订阅快照预期投递数、到期投递与调度租约、终态数量与尝试历史。故障注入应覆盖 relay 发布后崩溃、扇出完成一半后崩溃、队列消息重复、远端已处理 POST 但本地写成功前工作进程被杀、批量同步返回 429,以及一个租户的端点全部卡住。验收依据是持久计数、受控队列年龄、公平恢复和可解释重复,不能只看一次演示请求成功。
高质量示范回答
“我会为每个已提交业务事实分配不可变的 eventid,并为每个事件与端点组合分配稳定的 deliveryid。生产者在业务事务中写 outbox,relay 发布到持久日志;扇出服务在入队前按 (eventid, endpointid) 唯一约束物化投递。这样重放能够修复缺失任务,不会创建第二个逻辑投递。
题设峰值下,扇出每秒产生约 23148 个新投递,包含重试的分发链路按约每秒 25463 次尝试规划。调度器分离新投递和重试,执行租户加权公平、端点并发上限和令牌桶,再由工作进程用带版本的租约领取尝试。一个缓慢客户因此只能消耗自己的额度。连续失败会打开端点断路器,同时保留受控探测和 24 小时重试截止状态。
每次尝试都用 HMAC-SHA256 对 delivery_id、尝试时间戳和精确不可变载荷字节签名。客户用常量时间比较校验签名,拒绝过期时间戳,并按稳定投递 ID 去重。2xx 表示成功;408、429、5xx、网络错误和超时采用带完全抖动的指数退避,其他大多数 4xx 停止。工作进程可能在客户处理完成后、记录成功前崩溃,所以我只承诺至少一次,并明确要求接收方做幂等处理。
控制面提供端点与订阅管理、有限重叠的密钥轮换、投递日志和重放。端点 URL 在隔离出口网络内通过 HTTPS、IP 范围、DNS rebinding、重定向、超时和响应大小控制。我会通过持久边界对账,以及重复发布、部分扇出、确认丢失、批量 429 和大量挂起端点的故障注入来验证。通过标准是第一次尝试 SLO、没有无法解释的投递缺口、受控的重试放大,以及健康租户能够公平恢复。”
常见错误
- 业务数据提交后再发布 → 两个操作之间崩溃会丢失 Webhook 事件 → 使用事务型 outbox 或消费等价的持久变更流。
- 每次重试都生成新的投递 ID → 接收方无法对同一逻辑投递去重 → 保持
delivery_id稳定,另建尝试记录。 - 承诺 HTTP 恰好一次投递 → 远端处理完成后响应丢失,发送方无法知道结果 → 提供至少一次投递,并要求消费者幂等。
- 重试时重建载荷 → 数据库当前状态会篡改历史事件,也会让旧签名失效 → 保存规范化的不可变事件字节和 schema 版本。
- 所有端点共用一个 FIFO → 缓慢目标占据连接并拖延健康客户 → 限制端点资源,并按租户公平调度。
- 所有非 2xx 都立即重试 → 永久故障浪费容量,同步重试还会放大事故 → 分类错误,并用带完全抖动和截止时间的指数退避。
- 跟随客户 URL 的重定向 → 公网 URL 可以把工作进程导向内部服务 → 关闭重定向,或在隔离出口内完整重验每一跳。
- 对解析后的 JSON 签名 → 再序列化会改变字节并导致合法签名失败 → 对精确原始 body 以及经过认证的 ID、时间戳元数据签名和验签。
- 把控制台重放当成新事件 → 下游可能用新身份再次应用同一业务事实 → 重放现有投递,只新增尝试记录。
追问与回答
追问一:平台为什么不能保证恰好一次投递?
假设端点已经提交内部工作并返回 200,但连接在工作进程读到响应之前关闭。重试可能重复客户副作用,不重试又可能丢掉一个实际从未到达的事件。发送方无法与任意客户服务器建立原子事务。稳定投递 ID、接收方幂等和对账能让至少一次可管理,但无法把两个数据库和一段网络变成一次恰好提交。
追问二:如何避免一个故障端点拖慢所有人?
为每个端点设置较小的 in-flight 上限和令牌桶,再按租户加权公平调度。超时后释放租约并调度延迟重试,不能让工作进程原地等待。连续失败打开断路器,只运行受控探测,健康端点继续使用工作集群。同时监控端点和租户两级重试放大;攻击者即使创建多个端点,也仍受租户总预算限制。
追问三:你会提供什么顺序保证?
默认不保证投递顺序。在载荷中包含 occurredat、objectid 和单调递增的 object_version,消费者可以丢弃旧更新或拉取最新状态。若付费能力要求同一对象有序,就按 ordering key 对该订阅分区,并让每个 key 同时只有一个活动序列。较早任务失败会阻塞该 key 的后续任务,所以产品必须公开延迟与可用性代价,不能声称全局有序。
追问四:签名密钥轮换期间怎么处理?
创建新版本,在有限重叠期内保留上一版本,并通过请求头或文档标明可接受版本。重叠期间可以同时携带两个密钥生成的签名,也可以让消费者安全地依次验证两者。新尝试使用当前密钥,不改变不可变载荷和投递 ID。审计操作者、时间、端点配置版本和最终退役动作。若密钥已经泄露,可能需要立即退役并向客户提供可见的重放指引,而不能继续重叠。
追问五:扇出只完成一部分时如何恢复?
让持久事件重新经过扇出。(eventid, endpointid) 唯一约束会让已完成插入成为空操作,只创建缺失投递。对账任务比较事件保存的订阅快照或配置版本与物化行。如果订阅语义要求采用事件发生时的配置,就必须保存该快照;修复时使用今天的订阅,可能把旧事件发给当时无权接收的目标。
追问六:手动重放应重置 24 小时截止时间吗?
自动重试与人工重放是两份契约。自动任务在原截止时间停止。30 天历史窗口内的重放要重新鉴权和检查端点状态,只创建一条新尝试,并在界面明确标记为人工操作;不能静默重新启动自动调度。安全或删除类事件即使日志仍可见,也可能根据业务策略在更短截止时间后禁止重放。