返回博客
ShopifyWebhookNestJSBullMQMedusa

Shopify-to-Medusa:构建可靠的 Webhook 接收与异步处理体系

深入讲解 Shopify Webhook 接收器的完整设计:从 HMAC 签名验证、事件持久化、BullMQ 队列解耦,到幂等性处理、重试与死信队列兜底、状态机管理与消息重放,构建一条从接收到处理的全链路可靠管道。

RaytonX
2 min read

在 Shopify-to-Medusa 的渐进式迁移中,数据同步层是第一阶段的核心目标。在不中断 Shopify 现有业务的前提下,将商品、订单、用户数据实时镜像到自有数据库,是后续所有模块替换的前提。

而这条同步链路的入口,是 Webhook。

对于任何 Shopify 集成来说,Webhook 都是整个数据流的起点。无论是商品更新、订单创建,还是库存变化,Shopify 都会主动向你的服务推送一个 HTTP 请求。看起来只是一个简单的接口,但如果希望同步系统稳定可靠,仅仅提供一个 /webhook 接口远远不够。

本文介绍这条管道的完整设计:从安全接收、可靠持久化、队列解耦,一直到幂等处理、失败兜底、状态追踪与消息重放。


面临的问题

一个生产级别的 Webhook 管道需要回答以下问题:

  • 请求是否合法?
  • 如何在 5 秒内返回响应?
  • 如何避免重复处理同一个事件?
  • 如何异步处理业务逻辑?
  • Worker 失败如何自动重试?
  • 超过重试次数怎么办?
  • 如何追踪整个生命周期?
  • 如何精确重放失败事件?

整体架构

核心原则只有一个:接收与处理必须分离。Webhook 接收层负责可靠接收,Worker 负责可靠处理,两者通过持久化事件和消息队列连接。

Shopify 要求 Webhook 接收方在 5 秒内返回 200 响应,超时则视为失败,触发重试,最多重试 19 次,时间窗口长达 48 小时。如果在接收层直接处理业务逻辑——查库、字段映射、写入目标数据库——5 秒根本不够用,而且任何下游故障都会直接暴露给 Shopify 的推送机制。

因此,接收层只做一件事:把数据安全地接进来,交给队列,立刻返回。

Shopify
    │
    ▼
Webhook API
    │
HMAC 校验 + 幂等校验
    │
持久化原始事件(状态:pending)
    │
投入 BullMQ 队列
    │
立即返回 200
    │
    ▼
Worker 异步消费
    │
  ┌─┴─────────────────┐
成功                 失败
  │                   │
状态 → done      指数退避重试
                      │
                超出最大次数
                      │
                 进入 DLQ
                      │
                 状态 → failed
                 触发告警

第一步:验证请求是否合法

Shopify 发送的每个 Webhook 都会携带一个 HMAC 签名(X-Shopify-Hmac-SHA256 header)。

服务端收到请求后,使用配置好的 Shared Secret 对原始请求体重新计算签名,与 header 中的签名比对。签名不一致,直接拒绝请求,这一步有效防止伪造请求进入系统。

这里有一个容易踩坑的细节:HMAC 校验必须基于原始 Request Body 计算。如果框架提前解析了 JSON,再去计算签名,结果就会不一致,导致合法请求被误拒。在 NestJS 中需要单独保留原始请求体,绕过默认的 JSON 解析中间件。


第二步:幂等校验,过滤重复推送

HMAC 校验通过之后,下一步是幂等校验,而不是直接持久化。

Shopify 的 Webhook 机制是"至少一次"(at-least-once)语义,而不是"恰好一次"(exactly-once)。网络抖动、我们的服务短暂不可用、Shopify 自身的重试策略,都可能导致同一条事件被推送多次。如果不做幂等处理,同一笔订单可能被处理两次,同一个商品可能被更新两次,数据库出现重复记录或错误状态。

Shopify 的每次推送都携带一个唯一标识(X-Shopify-Webhook-Id header)。我们将这个 ID 作为唯一键,在持久化事件之前先查询数据库:如果已存在,说明是重复推送,直接返回 200,不做任何处理;如果不存在,继续后续流程。

幂等校验放在接收层,而不是 Worker 层,原因很直接:如果在 Worker 层做,重复消息已经进入了队列,占用了资源,Worker 需要消费它才能知道"这是重复的"。在入口提前过滤,更高效,链路也更干净。


第三步:持久化原始事件

幂等校验通过后,将整个 Webhook 事件持久化到数据库,通常保存以下信息:

  • 事件 ID(Shopify Webhook ID)
  • 事件主题(products/updateorders/create 等)
  • 店铺域名
  • 原始 Payload(完整保存,不做任何裁剪)
  • 处理状态(初始为 pending
  • 接收时间
  • 重试次数
  • 错误信息

原始 Payload 必须完整保存,这是整条管道可恢复性的基础。队列负责调度,数据库负责持久化。即使队列中的任务丢失,也可以根据数据库中的事件记录重新投递。。


第四步:投入队列,立即返回 200

持久化完成后,向 BullMQ 投递一个任务,携带事件 ID,然后立即返回 200。

整个接收层的目标是在 100 毫秒以内完成,远低于 Shopify 的 5 秒限制,留出足够的安全边际。

我们按事件类型做了队列隔离:商品变更、订单变更、用户变更各有独立的队列和对应的 Worker。这样一来,即使某类事件的处理逻辑出现问题,只影响对应队列,不会传染到其他数据类型。

队列解耦带来的好处是系统性的:Webhook 接口响应不受下游影响,Worker 可以独立扩容,流量高峰时队列自然起到削峰作用,下游服务延迟时也不影响 Webhook 的持续接收。


第五步:Worker 异步处理

Worker 从队列消费消息,执行实际的业务逻辑:读取数据库中保存的原始 Payload,做字段映射和格式转换,写入目标数据库,更新事件状态为 processing,处理完成后更新为 done

Worker 层与接收层完全解耦,可以独立部署和扩展。当订单量激增时,单独为订单 Worker 增加并发数,不需要动商品或用户的处理逻辑。当某个 Worker 出现 bug 需要修复时,修复和重新部署不影响 Webhook 的持续接收。


第六步:重试 + 死信队列

Worker 处理失败是生产环境的常态,原因五花八门:下游数据库临时不可用、第三方 API 超时、数据格式边界情况、代码 bug。如何对待失败,决定了这套系统在生产中的可信度。

重试策略:指数退避。 第一次失败后等待几秒重试,此后每次等待时间倍增。这样设计是为了避免在下游服务压力大时持续轰炸,给系统自愈的时间。不是所有错误都应该重试——数据格式错误重试多少次都不会成功,这类错误应该直接进入死信队列,而不是浪费重试配额。

死信队列(DLQ):兜底,不是垃圾桶。 当重试次数达到上限后,消息路由到 DLQ,同时触发告警通知。进入 DLQ 的消息不会丢失,工程师可以查看原始 Payload 和每次失败的错误详情,修复问题后将消息重新投递到正常队列处理。

DLQ 有消息,意味着有问题需要人工介入。告警机制确保这不是一个无声的黑洞——每一条进入 DLQ 的消息都应该被感知到,而不是静静地堆积在那里。


第七步:状态机管理与消息重放

每条消息的完整生命周期由数据库中的状态记录管理:

pending
    │
    ▼
processing
    ├──────────────► done
    │
    ▼
retrying
    │
    ├────► processing
    │
    ▼
failed(DLQ)

状态由 Worker 在处理过程中实时更新。这个设计让系统的每一步都有迹可循:任何时刻都可以查询"哪些消息正在处理"、"哪些失败了"、"失败原因是什么"、"影响了哪些数据"。

消息重放建立在这个状态基础上。当生产出现问题时——例如修复了一个处理逻辑的 bug,需要对历史数据重跑——可以按条件筛选出 failed 状态的消息记录,重新投入队列处理,而不影响已经 done 的消息。这让故障恢复变得精确可控,而不是靠全量重跑碰运气。

可观测性是生产系统的基础能力,不是锦上添花。能在几分钟内定位到问题,和需要几个小时翻日志盲目排查,是两种完全不同的工程体验。


小结

这条管道的七个步骤,解决的是同一个根本问题:让每一条 Shopify 事件都能被安全接收、可靠处理、故障可恢复。

接收层快速确认,不做多余的事;幂等校验在入口过滤重复;原始事件完整持久化,作为可恢复的事件日志;队列解耦让接收与处理互不影响;重试和 DLQ 确保失败不丢失;状态机和重放机制让整条链路可观测、可恢复。