1. 题目
一个订单事件流经过解析、窗口聚合和风控计算,结果写入仓库并触发下游通知。任务会因 worker 崩溃、网络超时和迟到事件重试。请解释 exactly-once 的边界,并设计不会因重试而产生重复账单的方案。
2. 约束与澄清
- 先区分三层语义:消息投递、管道内处理结果、外部系统副作用。
- 事件可能重复、乱序或迟到;不能假设日志中的“处理过”就是已提交。
- 结果需要可重放,通知等外部调用必须具备幂等键或去重能力。
- 先明确允许的延迟、迟到窗口和丢弃策略,再讨论实现。
3. 核心概念
Exactly-once processing 通常表示每条记录的管道结果在持久化输出中最多反映一次,同时系统仍要保证记录不会丢失;它不等于每个用户代码只执行一次,也不自动覆盖外部 HTTP、邮件或数据库调用。At-least-once 输入加上检查点、确定性重放和结果去重,才能形成可验证的输出保证。
4. 参考流程
onEvent(event):
key = stableEventId(event)
state = readCheckpointOrState(key)
result = deterministicTransform(event, state)
writeTransactionalResult(key, result) # unique(key)
commitCheckpointAfterResult(key)
onExternalSideEffect(result):
idempotencyKey = result.eventId + ":" + result.version
callOrOutbox(idempotencyKey, result.payload)管道内部先把结果与事件 ID 写入支持唯一约束或事务提交的存储,再推进检查点。外部通知通过幂等接口或 outbox 交给独立发送器;发送器可重复执行,但接收方只接受同一个幂等键一次。
5. 失败场景与取舍
worker 在外部调用成功后、检查点提交前崩溃,重放会再次调用外部服务;没有幂等键时无法仅靠 runner 消除副作用。窗口结果还会受迟到数据和 watermark 影响,必须定义允许更正的范围。更强的端到端保证通常增加去重状态、事务协调和存储成本;若业务允许重复,可选择 at-least-once 以换取更低延迟。
6. 验证与观测
- 注入崩溃、超时、重复消息和乱序事件,检查同一业务键的最终结果。
- 记录输入事件 ID、处理尝试次数、提交版本、去重命中和外部调用结果。
- 对账“收到、处理、提交、通知”四个计数,不能只看 worker 日志。
- 监控重复率、迟到率、检查点年龄、去重状态大小和重放积压。
7. 常见误区
- 把 exactly-once delivery、exactly-once processing 和 exactly-once side effect 当成同一个承诺。
- 认为开启框架开关后,任意自定义代码和外部 API 都自动获得一次性效果。
- 用时间戳而非稳定事件 ID 去重,导致重试或批量重放产生不同键。
- 忽略迟到事件、版本冲突和去重记录的保留期限。
8. 面试评分点
能划清保证边界
应分别说明投递、管道结果和外部副作用,并指出框架保证通常只覆盖其中一部分。
能设计可重放流程
应使用稳定事件 ID、确定性转换、事务结果提交和检查点顺序,解释崩溃后的重放路径。
能处理外部副作用
应提出幂等键、唯一约束或 outbox,并说明接收方与发送器如何共同防重复。
能用故障注入验证
应覆盖重复、乱序、迟到、超时和 worker 崩溃,使用提交数据与业务对账验证结论。