Webhook、消息队列与业务事件:处理重复、乱序和错误关联

财务系统在上午九点发出到账回执,合同系统上午九点零八分才发出签署确认。AcmeFlow 可能先收到付款,再收到签署;也可能因为网络重试先收到签署的第二次投递,再收到付款的第一次投递。更麻烦的是,一条来自别的租户的消息可能带着看似相同的业务编号,一个针对旧材料版本的签署结果可能在新材料提交后才迟到。若 Webhook handler 只按 business_key 查一条申请,然后直接写 status=READY,重复、乱序与错配就会变成错误开通。
第 06 篇讨论 Outbox、Inbox 和幂等,重点是消息能否可靠送达、重复送达时是否只产生一次业务效果。本篇在这个基础上处理“收到的到底是什么事实、属于哪份申请、能否参与当前条件”。我们使用一个真正运行的 Redis Streams 容器做队列,HTTP Webhook 入口验签并校验关联字段,消费者用 SQLite Inbox 唯一约束去重。实验按“付款先到—消费成功但故意不 ACK—重投递—重复付款—后到签署—三种无效请求被拒绝”的顺序运行,最后检查两种事实恰好各一份、pending 归零、就绪条件成立。本地 SQLite 是内存库,仅用于演示关联与去重,不构成跨进程持久 Inbox。
一、从 HTTP 回调到业务状态,中间至少有三层
Webhook 是外部系统向我们推送消息的入口,不是允许远端直接改变业务状态的后门。入口先验证请求来源及完整性,再检查事件契约版本、租户与对象关联;通过后把原始事件或经过规范化的信封放进 Stream,向发送方返回 202。消费者随后读取、去重、把业务事实写进 Inbox,并计算申请是否满足签署与收款的联合条件。若事实尚不齐全,它可以安静地等待;若事实指向另一租户或旧材料版本,不能为了让当前流程前进而硬套。
这个拆分有两个目的。第一,外部发送方可能只接受几秒内的 HTTP 响应,业务处理可能涉及数据库事务和人工规则,入口应避免长时间占用对方连接。第二,消息即使入队成功,也不代表下游业务已完成;返回 202 只表明接入层接受了事件。入口和消费者之间仍可能发生 Redis 故障、队列积压或重复投递,必须有监控与恢复策略。本篇的 202 没有生产级持久投递承诺,因为 Redis 容器是单节点教学环境,SQLite Inbox 也是内存的;后续部署应结合第 06 篇的 Outbox/Inbox 事务边界重做。

图 1:教学架构图。Webhook 入口负责接入,Inbox 负责接受事实,流程规则负责决定是否就绪。
本篇仍用系列的申请身份:tenant_id=xinghe-demo,application_id 是完整 UUID,business_key 为 APP-EVENT-014,material_version 为 1。business_key 方便销售查询,但不能单独当消息关联主键,因为不同租户或不同环境可能重复编号;完整 UUID 也不能脱离 tenant_id,否则错配数据可能跨越隔离边界。付款事实以 finance/PAY-014 标识,签署事实以 contracts/SIG-014 标识,分别保存,不让一条事件覆盖另一条。最终 READY 是两种已核验事实的组合结论,不是 Stream 中最后一条消息的内容。
二、事件信封不是越长越好,而是要能核实
示例事件包含 schema_version、source、event_id、event_type、sent_at、tenant_id、application_id、business_key 与 material_version。source 与 event_id 合在一起做去重身份:不同系统即使都生成 001,也不应互相冲突。event_type 表示业务事实的类型;source 与类型的组合必须在约定白名单中,例如 finance 只能提供付款确认,contracts 才能提供签署确认。sent_at 用于五分钟时效检查;schema_version 决定用哪版解析规则;tenant_id、application_id、business_key 和 material_version 一起用于关联。字段不是为了日志好看,而是避免把一条有效但不属于当前申请的消息用在错误对象上。
入口对原始 HTTP body 计算 HMAC-SHA256,与 X-Acme-Signature 用 hmac.compare_digest 比较。Python hmac 官方文档建议在验证场景使用 compare_digest,降低基于比较耗时的泄漏风险。验签之后再解析 JSON,是为了让签名覆盖发送方实际传来的字节序列;若先解析再重新序列化,不同字段顺序或空格可能改变签名材料。本机教学密钥写在脚本常量里,只能在隔离实验环境使用。生产应为不同来源分配受控密钥或更合适的身份机制,限制重放窗口、轮换密钥并审计失败请求。
入口先比较原始 body 的摘要,再完成关联字段校验,最后才写 Stream。下面是关键操作摘录;实际代码还有时间窗口、来源类型和对象版本检查:
wanted = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
supplied = self.headers.get("X-Acme-Signature", "")
if not hmac.compare_digest(wanted, supplied):
self.send_response(400)
self.end_headers()
return
# 省略 schema、tenant、application、material_version 等后续检查
client.xadd(STREAM, {"body": body.decode()})

图 2:信封字段示意。事件 ID 解决重复,关联键与版本解决“属于谁、针对哪版”。
HMAC 验证通过也不等于业务事实可信到可以直接开通。它只说明持有共享密钥的一方构造了这段请求,仍要确认发送方有权报告这种事件,申请是否存在,资料版本是否匹配,付款金额、合同编号或签署主体是否符合业务规则。本篇代码只验证来源类型映射、租户、申请、业务键和资料版本,没有接真实支付账本、电子签约服务或授权系统。生产入口应在安全地保留拒绝原因与保护敏感内容之间取平衡:不能把包含签名密钥或完整合同信息的请求体随意写进普通日志。
三、乱序到达不该改变联合条件的答案
付款先到时,Inbox 写入 PAYMENT_CONFIRMED,签署还没有来,因此 ready=false。八分钟后签署到达,Inbox 再写 CONTRACT_SIGNED,集合中已有两种事实,ready=true。这个判断与消息在 Stream 中的先后顺序无关。Redis Stream 的条目 ID 表示进入这条 Stream 的顺序,不等同于企业外部事实发生的真实顺序;两个系统的时钟也可能不同。因此不能按“最后一条事件是签署”去判断业务完成,也不能把“付款来得太早”当作无效事实丢弃。如果业务规则确实要求“先签署后付款”,应明确在领域层验证合同与款项的关系,而不是把传输顺序误当业务顺序。
图 3 的重点是事实先保存、条件后汇合。第 04 篇讨论过 BPMN 并行汇合,但一个图里的并行网关本身不会替入口缓存订阅还没创建时的早到消息。本篇在接入层显式保留付款事实,直到签署事实到来再重算条件。对于长期运行的申请,这种事实集合比单一“最近事件”字段更稳定;也让人工对账能看到哪一项已经满足、哪一项尚缺。若后续材料升级到 v2,旧 v1 签署事实可以留作历史,但不能自动满足 v2 的签署条件。

图 3:教学乱序图。先到账不必丢失,后续签署也不应覆盖到账证据。
现实中的乱序还包括“更早发生的撤销消息后到”。例如合同系统先推签署完成,随后撤销签署,网络却先送撤销再送完成;只用两种布尔事实就不够了。此时需要来源系统定义版本号、序列号或可查询的最终状态,并规定收到过期事件的处理规则。不同来源之间通常没有一个可信的全局顺序,不要凭服务器收到时间构造出不存在的因果关系。若无法判断哪个签署状态有效,应暂停开通并查询合同系统,而不是让最后到达的消息无条件获胜。本篇只演示两个单调事实的联合条件,未实现撤销与状态覆盖。
四、重复投递与业务去重是两回事
Redis Streams 消费者组通过 XREADGROUP 把条目交给消费者;交付后尚未 XACK 的消息留在 Pending Entries List。官方 Redis Streams 文档及 XPENDING 命令说明解释了这一机制。实验先读取 PAY-014,写入 SQLite Inbox,然后故意跳过 XACK。此时 XPENDING 显示一条待确认消息。脚本再用同一消费者从 pending 位置读取该条目,Inbox 的 (source,event_id) 复合主键阻止第二次插入,随后才 XACK。因而消息被重新交付一次,业务事实仍只有一份。
这里的“故意不 ACK”是模拟宕机窗口,不是实际杀掉消费进程。SQLite 用 :memory: 建库,在同一脚本中保留连接;若真杀掉这个进程,Inbox 也会消失,无法证明跨进程去重。生产版必须用持久业务数据库,并把“接受消息、记录事实、更新业务状态”设计为合适的事务边界;如果需要跨消费者恢复 pending 消息,应使用 XPENDING、XAUTOCLAIM 等机制与超时策略,而不是让同一消费者永远在线。Redis XAUTOCLAIM 官方文档说明它可以转移长时间 pending 的条目。本篇只演示最小的同消费者重读。

图 4:教学重投递图。重复交付被允许,重复业务效果被唯一约束挡住。
随后入口又接受了一次相同的付款事件,它作为新的 Stream 条目到达,但仍带 finance/PAY-014 身份。消费者读到它,Inbox 再次识别为 duplicate,确认后不增加事实数。直到 contracts/SIG-014 到达,Inbox 才增加第二份事实。这说明有两种重复:同一 Stream 条目未 ACK 后再次被消费,以及同一来源把相同业务事件重新投递到入口。两者发生位置不同,Inbox 唯一键却都能保护“接受一次”的业务效果。若来源重发时换了 event_id,就需要额外的业务唯一规则,例如同一合同版本只接受一次有效签署,不能全靠传输层事件 ID。
五、错误关联必须在推进前挡住
脚本发送三条无效请求:签名不匹配、租户变成 other-tenant、材料版本变成 v2。三条都返回 400,不进入 Stream。请注意,错租户和错版本请求在测试中仍使用教学密钥正确签名;实验要证明的是“验签通过也不能跳过业务关联检查”。如果系统只验证 HMAC,然后按 business_key 找当前申请,持有合法密钥但配置错误的来源仍可能污染另一租户的数据。若系统只检查 UUID,不检查材料版本,旧合同签署回执可能让新材料申请错误达到 READY。图 5 把三道拒绝分开展示,便于验收时逐项注入。

图 5:教学拒绝图。身份验证和业务关联是连续检查,不能任选其一。
返回 400 之后也要想清楚运营处置。恶意签名错误适合安全告警和限流;合作系统把租户配置错了,需要通知集成负责人修正;材料版本过期可能是正常迟到事件,应保留必要元数据、决定是否记录为历史事实或进入对账。示例统一返回 400 以保持最小代码,但生产接口可以使用不同内部错误码和隔离队列,外部响应避免泄漏其他租户申请是否存在。把所有拒绝消息直接丢弃,虽然当前状态安全,却可能失去调查来源系统质量的机会;把它们直接写入当前申请,则更危险。
六、本地结果能证明什么
实跑 Redis 容器报告版本 7.4.11,Python 客户端使用 redis 6.4.0。脚本启动本机 HTTP 服务,实际调用 Webhook,实际执行 XADD、XREADGROUP、XPENDING 与 XACK。第一段输出显示付款先到、pending_before_recovery=1、Inbox facts=1、ready=false。第二段显示两种事实共两份、duplicate_deliveries=2、三条无效 Webhook 被拒绝、ready=true、pending=0。运行后只停止并清理本实验创建的 acmeflow-wf14-0926 容器。一次运行的申请 UUID 在 actual-output.txt 里,复跑会不同。
这不等于已经实现生产级 Webhook。示例没有真实支付或合同账号,没有多租户鉴权、密钥轮换、持久 SQLite、Redis 主从故障切换、恶意流量限制或消息积压告警。Redis 即使开启 AOF,单节点容器和本地临时环境也不能代表企业持久队列的可用性保证。HTTP 202 在本实验只说明事件写入本地 Stream,既不保证消费者已经更新业务,也不保证硬件故障后一定可恢复。生产上线应按第 06 篇的事务边界设计持久 Inbox/Outbox,并进行跨进程和跨节点的故障演练。

图 6:验收边界图。实测了单机队列、关联与去重,生产级持久性和来源治理需另验。
七、至少要区分三种“时间”
一条业务消息里可能有三个时间:源系统认为付款发生的 occurred_at,源系统发送回调的 sent_at,以及 AcmeFlow 接收入队的 received_at。本篇代码只带 sent_at,因为最小实验只验证五分钟内的请求,不判断财务账本的时间先后。真实项目不能把这三个时间混成一个字段。银行入账可能发生在昨晚,财务系统今天批量核对后才发送;Webhook 因故障明天才送达。若根据 received_at 认为付款比合同签署“晚一天”,就可能错误触发逾期规则;若完全信任源系统时间,又可能遇到对方机器时钟错误或恶意回填。
处理办法不是指定某一个时间永远正确,而是为每项业务规则明确使用哪种时间及其可信来源。合同约定“到账时间不晚于截止日”时,应以财务账本中经过核验的业务时间为依据,而不是队列入站时间;运营 SLA “收到回调后两小时内处理”则应以我方接收时间为依据。源系统序列号可以帮助识别同一对象的较新状态,但并不自动解决不同来源之间的全局排序。合同系统的序列 17 与财务系统的序列 203 没有可比较意义。缺少共同的因果标识时,流程应依赖可交换的事实集合和明确的查询对账策略。
对于撤销类事件,集合也必须更精细。PAYMENT_CONFIRMED 之后若出现 PAYMENT_REVERSED,不能简单把后一条当作“重复付款”,也不能因为两条都保存在 Inbox 就继续计算 paid=true。领域模型应区分原付款凭据、撤销所针对的凭据、撤销是否有效、金额是否全额退回。签署撤销也要核对合同版本和撤销权限。消息系统只负责保存与交付这些声明,最终事实仍要由相应权威系统或人工核实。为了不让章节代码膨胀,本篇限制为两个只增不减的教学事实;读者把它用于退款、撤销或部分付款之前,必须重新设计业务投影。
八、处理找不到申请的消息
外部回执有时会先于本地申请记录可见。例如客户在合同平台签署后,销售系统的申请创建事件仍在另一条队列里;或者回调误填了 business_key,application_id 尚未同步。如果入口简单返回 404,发送方可能反复重试,导致通知风暴;如果随手创建一份空申请来“接住”回调,又可能绕过材料与审批。更稳妥的做法是把已验签但暂时无法关联的事件放在有界隔离区,记录来源事件 ID、租户、关联键与接收时间,短时间内重试关联,超时后交人工核对。不能让未知事件无限期占用队列,也不能悄悄丢掉财务事实。
本篇代码为简化实验,要求 application_id 等于已知教学 UUID,任何未知对象都在入口返回 400;它没有实现隔离区。这个选择适合证明错误关联不会污染当前申请,却不代表真实接入层都应该如此处理。生产策略要与发送方的重试协议配合:对格式错误和签名错误明确拒绝;对暂时查不到但凭证有效的事件,可能先 202 接收后隔离;对明确属于其他租户的事件则不能暴露目标申请信息。关键是让每种情况有保存位置、重试期限与人工责任,而不是一个泛化的“失败后再试”。
业务键的变化还会影响关联。销售可能给申请改显示编号,但合同与财务回执仍带旧编号;不同环境也可能生成相同 APP-001。入口应优先使用稳定的 application_id 与 tenant_id,business_key 做交叉校验和人工搜索。若外部系统暂时只能传可读编号,就要有受控映射表,并把映射版本或生效时间纳入核验。不能为了接入便利,在全库按模糊编号搜索第一条申请,然后把付款贴上去。错误关联比消息丢失更难发现,因为流程表面上可能顺利完成。
九、Redis 消费者组还需要运维规则
消息进入 Stream 后,消费者组并不会替业务团队处理所有异常。XREADGROUP 用特殊位置读取新消息,已投递未确认的消息进入 PEL;消费者异常退出后,要检查 pending 空闲时间并决定何时认领。太早认领,原消费者可能还在处理,两个消费者并发做同一动作;太晚认领,客户申请会长期卡住。XAUTOCLAIM 能帮助转移超时条目,但认领后仍要依靠持久 Inbox 与业务唯一约束抵御重复效果。不能把“换个消费者再执行”当成 exactly-once。
毒消息也必须有出口。某条 JSON 虽然通过入口的基本字段检查,却在新消费者解析具体合同字段时总是抛异常,反复认领会让 PEL 增长,掩盖其他积压。应记录失败次数、最后错误类别与来源事件 ID,超过预算后转入隔离或死信流,由人工决定修复数据、升级解析器或拒绝该事件。与此同时,其他有效消息仍要能继续处理。仅仅把异常打印出来然后 XACK,会丢失待处理事实;永远不 XACK 又会让它反复占资源。二者之间需要明确的业务处置状态。
还要计划 Stream 的保留与删除。Redis 的 Stream ID 是队列条目身份,不是业务事件 ID;消费者组确认一条消息,不等于可以立即删掉所有记录,因为其他消费者组可能还没处理。长期保留所有原始回调会带来存储和隐私成本。可制定“业务事实已进入持久 Inbox、所有必要消费组已确认、审计保留期满足”之后的清理策略,并定期验证恢复所需的事件仍可获得。单机教学容器运行完即删除,因此本篇没有提供保留期方案;生产环境不能照做。
十、验签通过后仍有多个信任边界
共享密钥如果被合同系统与财务系统共用,那么其中任一系统都可能生成看起来来自另一方的有效 HMAC;所以生产应按来源隔离密钥,并把密钥与来源身份绑定。示例在验签后额外验证 source 与 event_type 的允许组合,但由于使用同一个教学密钥,它只是逻辑约束,不是密码学上的来源隔离。还要考虑密钥轮换期间新旧密钥并存、签名头格式、代理是否修改 body、重放攻击窗口、请求体大小上限和速率限制。这些都应与外部系统在联调前写成协议,而不是出现签名失败时临时关闭验证。
时间窗口检查也需要时钟治理。本篇用本机 time() 与发送方 sent_at 的差值做五分钟判断,能挡住一类过旧重放,却假定双方时钟相近。真实系统应监控时间同步,允许合理偏差,并在暂时离线重放的业务场景里提供受控补偿路径。不能因为某次正常消息晚到就简单放宽到“永不过期”,否则截获的合法请求有更长的重放机会;也不能把所有迟到事件都丢弃,否则可能漏掉真实付款。把真实性验证、时效判断和业务迟到策略分开,才容易做出正确处置。
Webhook 入口还要防止资源消耗攻击。读取 Content-Length 前设请求体上限,限制 JSON 深度和字段长度,拒绝不支持的 schema_version,按来源限流,确保失败日志不会把签名、客户信息和完整合同材料写入公共可见系统。示例代码为了聚焦事件关联,只做了最小验证,没有实现这些生产安全控制;它绑定 loopback 地址,没有对公网开放。若读者把示例复制到公网端点,即使 HMAC 逻辑正确,也不满足可上线的入口安全要求。
十一、把验收写成一张事实表
测试负责人可以准备六组消息:正确付款、正确签署、同一付款重复、同一 Stream 条目未 ACK 后重读、错租户、错材料版本,再加签名错误。每条都记录 HTTP 结果、是否入 Stream、是否进入 Inbox、业务事实数和申请是否 READY。正确付款先到时 READY 必须为假;签署后来后才为真;两个重复场景不增加业务事实;三种错配不能污染申请。最后核对 PEL 已清空,且每个被确认的消息都有相应的 Inbox 接受、重复或隔离证据。只看消息总数与“没有异常日志”不足以证明业务正确。
生产验收还应引入实际进程故障:消费者在 Inbox 事务提交后崩溃,另一个消费者使用 XAUTOCLAIM 认领;Redis 断开时 Webhook 不应无凭据地返回成功;业务数据库提交失败时不能提前 XACK;申请已终止后迟到付款应进入规定的对账路径;密钥轮换期间旧新签名按明确窗口处理。每个故障都明确唯一业务结果、最大恢复时间与人工处理入口。本篇本地脚本只覆盖其中一小部分,图 6 将未测范围单独列出,避免把教学成功当成完整企业级可靠性保证。
真正可交付的事件接入,最终是一条从来源凭据到业务动作的可追踪链。一个客户问“我的款已经到了,为什么还没开通”,运营应该能查到财务事件编号、Webhook 接收记录、Stream 条目、Inbox 接受结论、签署事实是否齐备、ERP 创建是否确定,以及谁负责当前异常。如果其中某一环只有“应该有”而没有可核对记录,故障时就只能靠猜。把这条链做实,比在架构图上画更多队列和箭头更能改变企业系统的可靠性。
这条证据链还要说明“没有发生”的依据。例如财务系统坚持已发送付款回执,我方入口查不到记录,首先应按来源事件 ID 核对发送日志、请求时间、HTTP 响应和签名验证结果;如果入口返回 202 而 Stream 查不到条目,就排查接入层确认与 Redis 写入之间的故障窗口;如果 Stream 有条目但 Inbox 没有,就查消费者组的 pending、失败次数和隔离队列。不同断点对应不同团队与修复动作。把所有问题统一归类为“MQ 丢消息”,既可能冤枉队列,也可能错过真正的关联规则错误。
同样,客户看到 READY 也要能向后追:是哪些付款与签署凭据组成了这个结论,凭据针对哪个资料版本,是否已被撤销,后续 ERP 创建是否用了同一申请身份。若做不到反向追踪,乱序消息很可能在某次资料改版后制造一个看似合理的状态。工作流工程的重点不是把事件尽快消费到零,而是保证每次状态推进都有来源、对象、版本和规则依据;队列积压可以监控和修复,错误开通却可能已经产生财务与合同后果。
因此,对消息系统的容量指标也要结合业务对象解读。每秒消费数下降并不必然代表客户受影响,关键是哪些申请的必需事实迟迟未确认;相反,吞吐量很高也不代表安全,若关联错误率上升,系统可能高速地推进错误申请。监控应从 Stream 条目回溯到 application_id,再从申请追到合同、款项与 ERP 记录,让技术告警与业务影响能够对应。
十二、Workflow Thinking:消息先回答“属于谁”
一条事件被成功投递,只表示传输链工作;它还没有获得推进流程的资格。系统应先回答五个问题:是谁发出的,有没有有效凭证;它声称影响哪一个租户、申请和资料版本;它是否是已经接受过的同一事实;与当前已有事实是否矛盾;这组事实是否满足业务规则。只有这些问题都有可靠答案,才允许把申请推进到 READY 或启动后续 ERP 创建。工程上多做这些检查,看起来让回调处理变慢,却能避免“消息到了就改状态”的隐蔽错误。
本篇与第 06 篇的关系也因此更清楚。Outbox 关心发送事件不要在业务提交后悄悄丢失,Inbox 关心重复消费不要重复产生效果;本篇把来源验证、对象关联、版本绑定与乱序事实汇合补齐。真实企业应用还要把这三层连成连续证据链:源系统事件编号、队列条目 ID、Inbox 接受结果、申请状态变更和后续 ERP 记录相互可查。下一篇将比较编排与事件协作的边界:同一个业务过程由谁掌握全局进度,谁负责在缺少事件时发现并处理卡点。
