site logo

Marico's space

摘要保证:如何在公共 HTTPS 中选择 Webhook 推送、订阅还是轮询

前端技术 2026-08-31 11:29:55 4

最近折腾了一个在线教育 SaaS 的每周摘要推送功能,踩了几个坑,这篇把问题说清楚:公共 HTTPS 环境下,Webhook 推送、订阅模式、轮询三种方案怎么选。

先给结论:对于一个在欧美市场运营的小型教育 SaaS,每周发一次摘要,最稳妥的做法是——为每个客户每周维护一个幂等的投递任务,然后从轮询 worker 起步。只有当实测队列延迟、区域隔离需求或运维复杂度确实需要改进时,才考虑切换到队列推送或订阅模式。

传输层本身不是保证。Webhook 可以重试,订阅可以重新投递,轮询循环可能在发送后、记录成功前崩溃。这三种设计里,硬边界是一样的:持久化的任务标识、原子化的认领、带过期的租约、以及能容忍重复执行的投递操作。先把这些做对,再选最容易维护的方案——不是那个快速上手文档最短的,是出问题时需要手动干预的部分最少的。

这对每周摘要很重要,因为重复投递会损害用户信任,而漏发一条消息往往很难察觉。在截止时刻活跃的客户必须映射到一个稳定的键,比如 customer_id + digest_week;从轮询切换到推送时,这个标识不能改变。

每周摘要实际上需要什么样的投递保证?

"恰好一次"是应用层的结果,不是从队列标签能推断出来的有用承诺。有四个关键时刻需要区分:计算 eligibility、提交任务、worker 认领任务、下游投递系统接受。每个时刻之间进程都可能中断。如果在接收后、任务标记完成前中断,重试是保守做法,但重试可能导致摘要重复——除非下游操作接受同一个幂等键。

选传输层之前先写清楚契约:

  • 每周截止时刻每个活跃客户对应一个持久化任务
  • 一个任务可能被尝试多次
  • 每次尝试使用相同的 digest_key,且在账本中唯一
  • 认领有过期时间,停止的 worker 不能永久占有工作
  • 运维人员能区分 pending、leased、delivered、exhausted 状态的任务
  • 区域恢复不会为同一批客户创建第二个逻辑调度器

最后一点容易被低估。欧洲和美国不只是两个部署标签——两个调度器扫描复制后的客户数据,可能同时决定客户 4187 需要 2026-W33 的摘要。在一个权威任务账本中加入唯一性约束,把这个竞态变成一条记录。没有明确定义的写权威,最终复制可能产生两条本地都有效的记录。任何 worker 拓扑都修复不了这种歧义。

持久化还需要具体的恢复目标。如果摘要晚几个小时到达,适度的轮询间隔和数据库备份策略完全可行。如果必须在固定截止后 30 秒内开始投递,队列延迟和区域 failover 就成了实质性需求。"快"不是保证;需要说明最大可接受的延迟,以及被放弃的租约最多阻塞重试多久。

选推送、订阅还是轮询之前,先把账本搭起来

下面这个 Python 程序创建了一个刻意简化的本地账本,排程示例活跃客户,用 60 秒租约认领到期工作,然后记录成功。它只需要 Python 标准库就能跑。用 SQLite 在笔记本上练习状态机很方便,但这不是建议的多区域存储方案;生产环境需要一个事务型数据库,其文档化的 consistency、durability、备份和区域恢复行为要匹配上面的契约。

import sqlite3
import time
from pathlib import Path DATABASE = Path("digest_jobs.db") def connect(): connection = sqlite3.connect(DATABASE) connection.row_factory = sqlite3.Row return connection def initialize(connection): connection.execute("PRAGMA journal_mode=WAL") connection.execute( """ CREATE TABLE IF NOT EXISTS digest_jobs ( digest_key TEXT PRIMARY KEY, customer_id INTEGER NOT NULL, digest_week TEXT NOT NULL, due_at INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'leased', 'delivered')), attempts INTEGER NOT NULL DEFAULT 0, lease_until INTEGER, delivered_at INTEGER ) """ ) connection.commit() def schedule(connection, customer_ids, digest_week, due_at): rows = [ (f"{customer_id}:{digest_week}", customer_id, digest_week, due_at, "pending") for customer_id in customer_ids ] connection.executemany( """ INSERT OR IGNORE INTO digest_jobs (digest_key, customer_id, digest_week, due_at, state) VALUES (?, ?, ?, ?, ?) """, rows, ) connection.commit() def claim_one(connection, now, lease_seconds=60): connection.execute("BEGIN IMMEDIATE") job = connection.execute( """ SELECT * FROM digest_jobs WHERE due_at <= ? AND (state = 'pending' OR (state = 'leased' AND lease_until < ?)) ORDER BY due_at, digest_key LIMIT 1 """, (now, now), ).fetchone() if job is None: connection.commit() return None connection.execute( """ UPDATE digest_jobs SET state = 'leased', lease_until = ?, attempts = attempts + 1 WHERE digest_key = ? """, (now + lease_seconds, job["digest_key"]), ) connection.commit() return dict(job) def mark_delivered(connection, digest_key, now): connection.execute( """ UPDATE digest_jobs SET state = 'delivered', delivered_at = ?, lease_until = NULL WHERE digest_key = ? AND state = 'leased' """, (now, digest_key), ) connection.commit() if __name__ == "__main__": with connect() as database: initialize(database) now = int(time.time()) schedule(database, [4187, 4188, 4191], "2026-W33", now) job = claim_one(database, now) if job: print(f"deliver {job['digest_key']}") mark_delivered(database, job["digest_key"], int(time.time()))

跑两遍。第一遍投递一条记录,第二遍认领另一条;再次排程同样的三个客户不会产生重复。这个小测试展示了账本的幂等性,但不能证明端到端去重。把 print 替换成投递服务调用,把 digest_key 作为幂等键传过去——如果接口支持的话。如果不支持,不确定性是真实存在的:请求发出后超时,worker 无法知道接收是否成功,产品决策必须在可能的重复和可能的漏发之间做出选择。

别把这个选择藏在重试后面。

租约值需要做负载测试而不是靠猜。60 秒只是示例数据。测量摘要渲染和下游接收的延迟尾部,然后设置一个比正常处理时间更长的租约,同时保留对真正耗时工作的续期或恢复机制。至少要追踪队列 age、每个任务的尝试次数、过期租约、已投递任务、以及超过投递目标后仍在 pending 的任务。单看数量可能会漏掉一条卡在新任务后面的老任务。

小型 SaaS 怎么选:公共 HTTPS Webhook 推送、队列订阅还是轮询 worker?

从团队能观察到的故障边界开始。轮询 worker 按固定间隔读取和认领权威账本。它没有公开的任务接收器,凭证可以保留在私有数据路径上,重放就是普通查询。代价是轮询增加了人为延迟和重复读取;除非认领有索引并且批处理做得好,否则 worker 容量和数据库会耦合。对于每周工作负载、恢复窗口以小时计的场景,这通常是合理的初始实现,因为状态机在一个地方保持可见。

队列订阅把排程和消费分离。账本事务应该创建一条 outbox 记录,relay 再发布这条记录;在数据库提交后直接发布会在进程中断时产生间隙。消费者仍然按 digest_key 去重,因为重放是正常恢复流程的一部分。这个模型适合 worker 需要独立扩展,或者多种任务类型共享成熟的消息控制平面,但会增加队列保留、死信处理、访问策略和重放流程到运维面上。

公共 HTTPS 推送去掉持续轮询的消费者,让队列主动向接收器投递。这也把认证、请求验证、速率控制、证书续期、超时行为和部署兼容性推到了请求路径上。接收器应该在持久化接收后才响应,尽快返回,并把重复投递当作正常情况处理。当策略禁止公开端点,或者应用部署无法保证接收器可用性时,不适合用这个方案——在这些情况下坚持用私有订阅者或轮询 worker。

机制 投递边界 运维优势 重要限制 何时选用
轮询 worker 任务账本中的事务性认领 组件少,重放查询直接 轮询间隔增加延迟;扫描和认领给数据库带来负载 每周量不大,数据库已经运维良好
队列订阅 Broker 投递 + 消费者去重 worker 与排程解耦扩展 Outbox relay、保留、死信、重放都需要有人负责 消息运维已存在,或需要背压隔离
公共 HTTPS 推送 接收器对认证请求的持久化接收 边缘没有长时间运行的轮询循环 公开入口、验证、超时、背压成为应用层职责 需要托管推送,且团队能运维接收器契约

两个公开服务说明了为什么产品标签必须狭义理解。阿里云文档用 FIFO 队列描述消息顺序和去重,而腾讯云/百度云文档描述了拉取和推送订阅模式。这些能力回答的是 broker 层的问题。它们不能决定摘要的客户 eligibility 事务、跨区域写权威、或下游幂等边界,所以在没有映射这三个决策的情况下比较特性名称会产生虚假信心。

我不太相信存在什么放之四海而皆准的"最简单"答案。一个已经跑着 broker、死信策略和订阅者仪表盘的团队会觉得订阅比加数据库轮询简单;一个只有事务型数据库、没有任何消息队列运维经验的三人 SaaS 可能得出相反结论。能解决这个问题的证据是本地的:每次截止预期的任务数、可接受的启动延迟、认领查询负载、恢复演练耗时,以及谁来接收告警。

测试崩溃窗口,不是正常路径

最有价值的测试是在命名的边界处停止执行。插入两次任务,确认每个 digest_key 只有一条记录。在认领后停止 worker,验证另一个 worker 只有在租约过期后才能重新认领。模拟下游接收后丢失响应,验证重复请求携带相同的键。延迟整个区域,确认 failover 不会对带独立唯一性域的可写副本运行第二个调度器。

保持一条异常任务可见。

一个有用的 staging 演练:创建 10 个任务,认领 3 个,标记 2 个已投递,让 1 个租约过期,然后重启 worker。预期的最终状态是 10 条已投递记录,过期的任务显示两次尝试。这比发送 10 个干净请求更有信息量,因为它锻炼了最可能产生重复的状态转换。这也让监控有一个精确的断言:最老的 pending age 必须在恢复后消失,而尝试计数器保留恢复发生过的证据。

部署同样需要克制。先加上账本和幂等键,再换传输层。用内部 cohort 跑新的 worker,对比 eligible 客户数和创建任务数,只有在过期演练和恢复演练成功后才能扩大范围。对于欧美服务,为每个客户 cohort 分配一个调度权威,并记录这个权威在 failover 时如何转移;active-active worker 没问题,只要它们从一个一致的账本认领,但 active-active 调度器写到独立账本是另一种更危险的设计。

然后紧凑地迁移:先建立账本,再用有限批次跑轮询,只有当实测显示数据库压力或不可接受的拾取延迟时才引入 outbox 和队列。全程保留 digest_key 和状态转换。传输层迁移应该改变工作对消费者的可见方式,而不是重新定义"一份每周摘要"的含义。