问题与使用场景
接收器要把一个不可信的 HTTP 请求转换成持久的内部事件,难点就在这两个状态的边界。如果返回 200 OK 后进程仍可能在保存事件前崩溃,就会静默丢失;如果等支付业务全部执行完再响应,慢依赖又会触发服务商重试并放大流量。
采用以下面试假设:
- 峰值为每秒 2,000 个请求,原始请求体平均 10 KiB、最大 1 MiB,因此按平均请求体计算的峰值入口流量约为 19.5 MiB/s,尚未计入请求头、副本和存储开销。
- 服务商要求 2 秒内响应,内部目标是确认延迟 p99 不超过 500 ms,以保留余量。
- 投递语义是至少一次且不保证顺序。服务商可能并发发送同一逻辑事件、稍后重试,或先送达对象的较新状态。
- 已接收事件必须在接收器崩溃后仍可恢复,并最终进入
PROCESSED或FAILED终态;同一逻辑事件不能重复执行同一业务变更。 - 签名密钥需要无停机轮换。原始载荷加密保留 30 天用于恢复和审计;幂等记录至少覆盖服务商文档规定的重新投递周期。
主要场景包括支付状态、订阅生命周期、退款、争议和账户通知。系统要兼容多个服务商,同时不能假设它们的请求头、签名算法、重试身份和时间戳规则完全相同。
面试官考察什么
第一,面试官要看候选人能否准确界定确认语义。2xx 表示“接收器已持久接收该事件”,不表示邮件、账本或服务商 API 调用已经完成。在持久写入前返回成功会产生静默丢失;对已经接收的重复请求返回成功则是正确做法,因为服务商可以停止重试。
第二,安全检查必须按正确顺序执行。接收器先限制方法、内容类型、请求头和请求体大小,保留完全一致的原始字节,再用可信的密钥版本验证服务商签名,使用常量时间比较 MAC;若服务商提供已签名的新鲜度元数据,还要验证它。验签前解析并重新序列化 JSON 可能改变空格或键顺序,让合法签名失效。
第三,需要区分重放防护和重试去重。已签名时间戳可以拒绝很早以前截获的请求;稳定的服务商事件 ID 或投递 ID 可以防止合法重试被执行两次。一些服务商会在每次重试时生成新的尝试时间戳和签名,但保留同一个逻辑事件 ID。两种机制不能互相替代。
第四,需要给出没有数据库与队列双写漏洞的持久处理模型。可以在同一事务中提交收件箱记录和发件箱记录,再由中继发布任务;也可以让工作进程直接租约领取收件箱记录。唯一键必须由存储层强制保证,不能采用容易被并发重复请求穿透的“先查再写”。
最后,高质量回答还要覆盖乱序状态、外部副作用、密钥轮换、毒性事件、背压、可观测性、对账,以及每个事务边界上的崩溃。
回答前的澄清问题
2xx究竟承诺什么? 本题中,它表示验签成功,而且事件或先前接收的重复事件已经持久化;不表示邮件、账本或服务商 API 调用已经结束。- 哪个服务商身份在重试期间保持稳定? 每个适配器都要说明逻辑事件 ID、尝试时间戳、签名格式,以及手动重投是否沿用同一 ID。不能只用载荷哈希推导身份。
- 服务商是否对原始请求体和元数据签名? 适配器负责定义规范签名字节,HTTP 框架必须在 JSON 中间件运行前提供未经修改的请求体。
- 存在哪些顺序信息? 优先使用权威的对象版本或序列号。事件创建时间可以作为证据,但不天然是严格顺序;如果只有状态型事件,可以向服务商查询当前状态。
- 重新投递可能持续多久? 幂等记录保留期和旧密钥重叠期必须覆盖服务商文档行为及产品的手动重放政策。本题的 30 天原始事件保留期只是产品假设,并非通用服务商规则。
- 哪些故障应触发重试? 如果无法确认真实性或持久存储不可用,就不能确认;事件持久接收后,工作进程故障不应改变 HTTP 响应。
- 哪些数据属于敏感数据? 原始请求体要加密、限制访问并在日志中脱敏,还要定义删除例外。签名验证真实性和完整性,不负责加密内容。
30 秒回答框架
“我会在带请求体大小和限流保护的服务商专用 HTTPS 入口保留原始字节,使用当前或上一版密钥,对已签名的 ID、时间戳和载荷做常量时间验签。在一个数据库事务中,以服务商事件唯一键写入收件箱和发件箱,提交后才返回 2xx;已接收的重复请求也返回 2xx。中继和工作进程通过租约异步处理并重试。业务变更和处理标记一起提交,外部副作用则使用发件箱和稳定幂等键。乱序投递使用对象版本,或查询服务商权威当前状态,绝不使用到达顺序。我会监控确认延迟、拒绝原因、收件箱积压、重复和失败,并测试并发重复、密钥重叠、过期签名、乱序事件及每个提交边界的崩溃。”
分步深入分析
每个服务商使用一个适配器,但共用同一条接收流水线。适配器提供允许的事件类型、最大请求体、请求头解析、规范签名字节、算法、可信密钥版本、新鲜度策略,以及逻辑事件 ID 的提取方式。密钥来自托管密钥存储,只能在有限时间内缓存。请求不能通过不可信请求头自行指定一个“可信”验签密钥。
入口顺序需要刻意设计:
1. 只接受 HTTPS POST,并按端点和服务商限流。
2. 校验有界请求头;存在 Content-Length 时先检查它。
3. 流式读取最多 1 MiB 原始字节,超过上限立即拒绝。
4. 只解析签名元数据,不先解析 JSON 请求体。
5. 使用当前和上一版可信密钥,以常量时间方式验签。
6. 按服务商专属容差检查已签名的尝试时间戳。
7. 解析已验签请求体,校验事件信封和允许的类型。
8. 以逻辑事件唯一键持久接收,随后再确认。时间戳新鲜度和去重解决的是不同问题。假设攻击者截获了一条合法签名请求,严格的已签名时间窗口能阻止窗口之外的重放,但同一请求仍可能在窗口内到达两次。反过来,事件 ID 记录可以阻止重复逻辑事件,却不能证明一个未签名时间戳是新鲜的。不同服务商还可能在重试时更新已签名尝试时间戳,但沿用事件 ID。因此两项检查都要保留,具体语义归适配器所有。
使用收件箱作为事实源:
WebhookInbox(
inbox_id, provider, endpoint_id, provider_event_id,
event_type, object_id, object_version, provider_created_at,
received_at, raw_payload_ref, payload_hash, matched_secret_version,
status, attempt_count, next_attempt_at, lease_until, last_error
)
WebhookOutbox(outbox_id, inbox_id, topic, created_at, published_at)
UNIQUE(provider, endpoint_id, provider_event_id)在一个数据库事务中插入收件箱记录及其发件箱通知。如果唯一键已存在,就读取已有接收状态并返回 2xx,不创建更多任务。这里必须是原子插入,不能“先查询再插入”。事务提交后才能确认。如果数据库不可用或提交结果未知,就返回可重试的非 2xx;若第一次提交其实成功,后续重复请求会收敛到同一条唯一记录。
数据库加发件箱的设计消除了保存与入队之间的缺口。中继反复发布未发布记录,并在之后标记已发布。发布可能发生两次,因此队列消费者仍按 inbox_id 去重。更简单的实现可以省略消息代理,让工作进程通过租约领取到期收件箱记录,例如使用 FOR UPDATE SKIP LOCKED。可以根据吞吐和运维需要选择,但收件箱始终是持久接收和审计边界。
工作进程领取短租约,解析带版本的事件,并只路由支持的类型。若业务变更位于同一个数据库,就在一个事务中更新业务行、记录已处理事件,并把收件箱标为 PROCESSED。已处理事件表使用同一稳定服务商键,因此工作进程重试会成为空操作。若要调用其他服务,则写入本地发件箱,并以 inbox_id 作为幂等键。任意网络之间无法保证严格恰好一次;稳定身份和幂等接收端能让至少一次执行保持安全。
业务顺序不能由到达顺序决定。如果事件携带权威对象版本,就以 incomingversion > storedversion 之类的条件更新,旧事件作为已处理空操作。如果只有“状态已改变”型通知,就查询服务商当前资源并让本地状态收敛。若事件代表不可重复的增量,就要求序列号,有限缓存缺口,并对账补齐缺失版本。单独时间戳可能相同、存在时钟偏差,或表示创建时间而非提交顺序。
故障要按持久边界划分。接收前,签名无效、已签名时间戳过期、请求体过大、密钥不可用和存储不可用,都应按策略拒绝或返回可重试响应。不能仅为调试而持久保存未经验证的敏感请求体。接收后,即使队列或工作进程故障,也可以返回 2xx;收件箱会积压,恢复后继续处理。瞬时处理故障使用带抖动的退避;模式错误或重试耗尽进入 FAILED,保留脱敏诊断并提供运维可见的恢复路径。
密钥轮换在一个有界周期内同时信任当前和上一版本,周期依据服务商重新投递行为确定。记录匹配的版本,但绝不记录密钥或完整签名。先在两端配置新密钥,观察生产流量开始匹配,再明确退役旧版本。紧急泄露可能要求立即停用并从服务商重新投递,因此轮换手册要区分计划重叠和事故响应。
在每秒 2,000 个请求、平均 10 KiB 时,入口原始请求体流量约为 19.5 MiB/s。容量规划还要计算 TLS 与 HMAC CPU、数据库事务率、副本、队列放大和突发持续时间。无状态入口水平扩展;必要时按服务商和时间划分收件箱索引,同时在其所有权边界内保持唯一键可全局强制;如果原始加密请求体会让数据库行过大,则放入对象存储。
要分别统计接收、重复、签名无效、过期、超限和不支持事件。监控确认延迟 p50/p95/p99、数据库提交延迟、最旧未处理收件箱年龄、工作成功率与重试率、FAILED 数量、发件箱中继延迟及匹配的密钥版本。告警应以延迟和持久状态为依据,不能只看队列深度。对账任务比较已接收收件箱、处理记录和发件箱发布状态,并安全地重新投递缺失工作。
测试要从原始 HTTP 字节开始。先使用服务商公开的签名测试向量,再改动一个字节、空格、ID 或时间戳。覆盖缺失和重复请求头、请求体大小边界、时钟偏差、当前/上一密钥和旧密钥退役。并发发送数百份同一事件,证明只有一条收件箱记录和一次业务变更。在收件箱提交后但响应前、队列发布后但发件箱标记前、业务提交后但工作确认前分别制造崩溃。按 3、1、2 顺序投递版本,压满工作进程再恢复,证明系统最终收敛且确认延迟有界。
高质量示例回答
“我把 2xx 定义为持久接收。入口是无状态的,只有适配器边界按服务商区分。它接收 HTTPS POST,把请求体限制为 1 MiB,保留完全一致的原始字节,再用可信的当前或上一版密钥验证服务商规范中的 ID、尝试时间戳和载荷。HMAC 使用常量时间比较,之后才解析事件信封并只允许支持的事件类型。
我在一个数据库事务中,以 UNIQUE(provider, endpointid, providerevent_id) 插入 WebhookInbox 和发件箱记录,提交后才确认。并发重复请求会在唯一键上冲突,不产生新任务,同样返回 2xx。每秒 2,000 个请求、平均 10 KiB 对应约 19.5 MiB/s 原始入口流量,因此入口水平扩展,并按签名 CPU、数据库提交、副本和突发存储来定容,而不只计算请求数。
发件箱中继发布 inboxid,重复发布是安全的。工作进程租约领取收件箱记录。能在同一库完成时,业务变更、已处理事件标记和收件箱完成状态在一个事务中提交。远程副作用使用另一层发件箱,并把 inboxid 作为幂等键。这样不会声称跨网络恰好一次,同时能保证重试不会重复逻辑效果。
我不使用到达顺序。权威对象版本控制更新,否则状态型 Webhook 会触发读取服务商当前状态;缺失的序列缺口进入对账。持久接收前,无法验真的请求或存储故障不能确认;接收后,工作进程故障由收件箱吸收,HTTP 仍可返回 2xx。
密钥轮换采用有界的当前/上一版本重叠,并记录匹配版本。我监控确认延迟、各类拒绝、重复、收件箱年龄、发件箱延迟、失败和密钥版本使用情况。最后使用官方签名向量、字节变更、过期时间戳、密钥重叠、并发重复、乱序版本,以及每个提交边界的崩溃来测试。通过标准是每个逻辑事件只有一条持久记录和一次业务变更,不丢失任何已确认事件,并在恢复后最终收敛。”
常见错误
- 验签前解析 JSON → 重新序列化改变被签名字节 → 先捕获并验证完全一致的原始请求体。
- 持久化前返回
200→ 崩溃会静默丢失已确认事件 → 提交收件箱后再响应。 - 先写数据库再单次发布队列 → 两次写入之间崩溃会搁置工作 → 与收件箱一起提交发件箱,或直接租约领取收件箱。
- 先读再判断重复 → 并发请求可以同时通过 → 以原子方式强制服务商事件唯一键。
- 只检查时间戳新鲜度 → 同时到达的重复仍会执行两次 → 结合新鲜度和稳定 ID 去重。
- 只使用事件 ID → 当记录不存在或过期时,截获的合法请求仍可重放 → 还要按服务商约定验证已签名的新鲜度。
- 把到达顺序当事件顺序 → 迟到事件会让状态倒退 → 使用版本、单调状态迁移或权威状态对账。
- 在 HTTP 处理器执行远程副作用 → 延迟会导致重试和未知结果 → 先持久接收,再异步幂等执行。
- 记录完整载荷和签名 → 可观测性变成数据与密钥泄露 → 加密保存受限证据,只记录脱敏身份。
追问与回答
追问 1:为什么尚未处理完成的重复事件也返回 2xx?
原事件已经持久接收,服务商继续重试不会增加恢复能力,只会制造更多重复流量。已有收件箱记录仍可由工作进程和对账任务处理。这个结论的前提是记录已经持久化,而且状态不表示接收事务已回滚。
追问 2:收件箱提交后、发送 2xx 前进程崩溃怎么办?
服务商会重试。唯一键找到已提交记录,不创建第二个任务,接收器返回 2xx。这就是预期的至少一次路径。如果客户端收到 2xx,但服务商看到的连接结果不确定,同样可以收敛。
追问 3:可以把幂等键放在 Redis 吗?
Redis 可以做加速层,但仅靠短期缓存比接收承诺更弱。淘汰、故障转移或过期都可能让同一支付事件再次执行。应在要求的业务与重新投递周期内持久保存已处理身份;只有丢失该键不会破坏承诺时,才可单独使用 Redis。
追问 4:密钥存储故障时怎么办?
可以用有明确过期时间的加密内存缓存保存已信任版本,并监控其状态。如果没有有效缓存密钥,就应关闭式失败并返回可重试响应,让服务商重新投递。不能接收未签名工作,也不能自动信任请求提供的密钥标识。
追问 5:如何恢复乱序的支付事件流?
优先使用服务商对象版本或单调的领域状态迁移,拒绝状态回退。如果事件只说明对象发生变化,就读取其当前权威状态。对于带序号的增量事件,有限缓存缺口,主动补齐缺失版本,并在超出恢复窗口时告警。不能只按本地接收时间排序。
追问 6:为什么这仍然不是恰好一次?
接收器可以让本地变更和处理标记原子提交,却无法和任意远程邮件、银行或服务商系统原子提交。网络故障可能掩盖远端是否已经执行。稳定幂等键、事务发件箱、重试和对账,可以在远端 API 支持幂等时实现业务效果上的一次,而传输语义仍是至少一次。