工程2026年8月6日9 分钟阅读

在 Cloudflare Workers 上接收 Webhook:我们线上 Worker 的经验

没有 Node crypto,CPU 预算以毫秒计,waitUntil 最多 30 秒且不持久。接收端换成 Worker 之后有哪些不同,来自 AnyHook 自己的接入层。

同步处理 · 共用一个时限Stripe验证 + AI 处理程序45秒它的超时到此为止重试 → 重复运行边缘应答 + 异步投递 · 两个时限Stripe边缘节点持久化 + 200约 1 秒队列重试处理程序45 秒,没问题平台的计时在边缘节点就停止了
把接收端放在 Workers 上的理由是:应答和持久化都很便宜,所有昂贵的工作都可以放到别处去做。

AnyHook 的接入层是一个 Cloudflare Worker。服务收到的每个 Webhook 都先落到这里:验证签名、持久化,然后约 1 秒向发送方返回 200,其中大部分时间花在去队列所在区域的一次往返上。它在生产环境里已经跑了一段时间,而真正别扭的地方,和我预想的不一样。

本文是在 Vercel 上接收 Webhook,不让事件丢失的姊妹篇,同样讲接收端。两者的故障模式确实不同,而且大多源于 Workers 不是 Node。

TL;DR

  • 默认没有 node:crypto。签名验证要走 WebCrypto,而它是异步的
  • 和在其他平台上一样,在任何代码解析请求体之前,先把它按字节读取一次
  • 在流式读取时就执行你自己的请求体大小上限,因为读完一个大请求体才发现它太大,顺序就错了
  • ctx.waitUntil 能让请求在响应之后继续运行,但它不持久,和 Vercel 的完全一样
  • Workers Free 套餐的 CPU 预算很小,而且和挂钟时间分开计算。等待一个慢 API 不消耗它;对大请求体跑密集循环才会
  • Worker 很适合做应答和持久化,不适合跑你的业务逻辑

签名验证是异步的,这会改变你的控制流

在 Node 上,你会写一个同步的验证函数,并且尽早调用它。WebCrypto 的方法返回 Promise,同样的逻辑就变成了异步,于是人们很容易在已经为了路由解析过请求体之后,才去 await 它。别这样。验证必须放在任何信任请求体的代码之前。

下面是我们的接入层在验证签名之前,解密已存储的平台密钥的写法。全部用的是 crypto.subtle:

const keyMaterial = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
  "AES-GCM",
  false,
  ["decrypt"],
);

const decrypted = await crypto.subtle.decrypt(
  { name: "AES-GCM", iv, tagLength: 128 },
  keyMaterial,
  dataToDecrypt,
);

有两个细节,如果靠踩坑才发现,会很费时间。

WebCrypto 的 AES-GCM 要求认证标签(auth tag)附在密文后面,而 Node 的 createDecipheriv 通过 setAuthTag 单独接收它。如果你在 Node 上加密、在 Worker 上解密,就得自己把两者拼起来。没有任何提示告诉你这一点;你只会得到一个 OperationError。

WebCrypto 没有标准的 timingSafeEqual。做 HMAC 验证时,优先用 crypto.subtle.verify,它会替你做常量时间比较,而不是自己算出摘要再比较字符串。

你可以打开 nodejs_compat,得到一个 node:crypto 兼容层(shim),对简单的 HMAC 来说,这是个合理的捷径。但这个 shim 只是建立在同一批原语之上的兼容层,而直接基于 WebCrypto 编写,意味着这段验证代码不用改就能在 Deno、Bun 和浏览器里运行。

以字节读取请求体,太大就尽早停止

原始请求体的规则到哪里都一样:HMAC 覆盖的是到达的每一个字节,所以验证之前什么都不要解析。先 await request.text() 或 await request.arrayBuffer()。

边缘接收端特有的问题是:任何拿到你 URL 的人都能敲你的大门,如果先把一个任意大小的请求体缓冲下来,再决定不要它,你就是在为别人的流量买单。我们的接入层先检查 content-length,在流式读取时再执行一次上限,因为 content-length 只是客户端的声明,而不是事实:

const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;

const reader = request.body?.getReader();
if (!reader) return new ArrayBuffer(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  totalBytes += value.byteLength;
  if (totalBytes > limitBytes) {
    reader.cancel();          // stop pulling bytes we have decided to reject
    return null;
  }
  chunks.push(value);
}

reader.cancel() 是值得照抄的部分。没有它,你会把一个已经决定拒收的请求体下载完。

最便宜的检查放在最前面

边缘接收端是一个公开端点,检查的先后顺序既是安全决策,也是成本决策。

我们的顺序是:先拒收缺少所声称平台必带请求头的请求,然后在配置了密钥的情况下验证签名,最后才把事件计入配额。一个自称是 Stripe Webhook、却没有 Stripe-Signature 请求头的请求,在任何加密运算之前、在任何计数之前就会被拒收。没有正确请求头的攻击者,几乎消耗不了我们任何资源。

这个顺序有效的前提,是便宜的检查真的便宜。检查请求头是否存在只是一次 map 查找。签名验证是一次密钥导入加一次 HMAC。配额是一次 KV 读取。按这个顺序排,昂贵的操作只会在已经通过前面检查的流量上运行。

在这一切之下还有按 IP 和按 App 的限流,以及对不存在的 slug 的短时负缓存,这样有人扫描有效端点时就不会变成一张账单。

再说一次:waitUntil 不是队列

ctx.waitUntil 让 Worker 在响应之后继续存活,好让后台任务跑完。它确实有用,但它不等于持久化。

如果 isolate 被驱逐,或者请求在中途被取消,这些任务就没了。没有重试,没有死信,也没有记录。而你已经返回了 200,发送方认为事件已投递,永远不会再发一次。这个事件从此哪里都不存在。

waitUntil · 这个 200 是你兑现不了的承诺验证返回 200waitUntil(work)只在内存中被回收 → 丢失,不会再重发先持久化 · 这个 200 是真的验证持久化写入返回 200处理可重试
唯一值得发出的 200,是你能拿已经写下来的东西作证的那一个。

这和 Vercel 的 waitUntil 是同一个陷阱,适用同一条规则:先持久化,再应答。把事件写到持久化的地方,返回 200,然后再处理。

我们只把 waitUntil 用在丢了也无妨的工作上,比如给计数器加一。如果拒收计数器在一次驱逐中少记了三次,谁也不会受损。如果一个事件的请求体丢了,就会有人受损。

它的时间也很短。Cloudflare 会在响应发出 30 秒后取消 waitUntil 的工作,由你交给它的所有 Promise 共享,而一次模型调用自己就可能超过这个时间。你的 Webhook 处理程序调用大模型讲了每个平台上该改用什么。

对于必须保住的东西,选项是队列、持久化存储,或者两者都用。我们的接入层把事件发布到托管队列;如果发布失败,就把事件转存到 R2;如果这一步也失败,就把事件存进 Postgres。如果三者都失败,我们返回 503 而不是 200,让发送方自己的重试成为最后一道防线。这个决定值得明确说出来:为一个哪里都没能存下的事件返回 200,是一个会让你失去这个事件的谎言。

CPU 时间不是挂钟时间

Workers 按 CPU 时间计费和限制,而不是按请求等待的时间。从 Lambda 过来的人会被这一点搞糊涂,因为在 Lambda 上两者是一回事。

等待一次数据库写入或一个上游 API 不消耗 CPU。解析一个两兆字节的 JSON 请求体会。对一个大请求体做哈希会。遍历一万个数组元素的循环也会。

对 Webhook 接收端来说,这大体是好消息,因为你本来想要的形态几乎全是 I/O:读字节、验证、写入、响应。真正会坑你的,是人们随手加上去的操作,比如对一个只需要存下来的请求体调用 JSON.parse,或者对整个请求体跑正则。

如果你只需要根据请求头路由、再把字节持久化,那就根本不要解析请求体。把它按字节存下来,让有真正 CPU 预算的下游消费者去解析。

什么该放在边缘,什么不该

在生产环境里跑过之后,这条界线比一开始清楚多了。

适合放在 Worker 上的:在离发送方近的地方终结 TLS、拒收垃圾请求、验证签名、持久化请求体、发布到队列、快速返回 200。全都是 I/O 密集型,全都便宜,而且都对时延敏感,离调用方近确实有好处。

不适合放在 Worker 上的:你的业务逻辑。任何需要长连接池、庞大依赖树、大量 CPU 或 Node 生态的东西。不是因为做不到,而是因为你会毫无收益地和平台较劲,而把接收端放在边缘的全部意义,就在于昂贵的部分在别处按它自己的节奏进行。

这种拆分,也就是在边缘节点应答、异步投递,是 AnyHook 本身的架构,而不是我们事后编出来的说法。

一个值得知道的部署陷阱

如果你在一个 wrangler.toml 里配置了多个环境,不带 --env production 的 wrangler deploy 会部署默认环境。你的生产 Worker 继续跑着旧代码,而且没有任何报错,因为部署确实成功了,只是没部署到你想要的地方。

我们为此浪费了一个下午。密钥也是同样的情况:要用 wrangler secret put NAME --env production,否则你会把它设置到一个不接流量的地方。

总结

只要你接受 Worker 本来的样子,它就是一个非常好的 Webhook 接收端:一扇 I/O 密集型的前门,没有 Node,waitUntil 不持久,CPU 预算还会惩罚你多余的解析。

在任何解析之前先读字节,请求体超过上限就取消流,把便宜的拒收放在昂贵的检查之前,永远不要为还没持久写下来的东西返回 200。其他一切都属于下游,那里有的是时间。

全部文章2026年8月6日 · 9 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。