同一个 Webhook,为什么会把业务执行两遍?问题通常不是服务器同时收到两条不同消息,而是一条消息可能被重新投递:接收方处理完了,响应却没有及时送回;上游认为失败,于是再次发送。一次网络超时,足以让"扣一次库存""发一次通知""创建一次任务"变成两次。真正要解决的不是如何让上游永远只发一遍,而是我们如何让重复到来的同一事件,不重复进入自己的业务流程。
很多项目的第一版实现是收到请求就立即调用业务函数,再返回成功。流量少时看不出问题;上线后遇到超时、重放、服务重启与多进程竞争,才发现内存里记一个已处理集合完全不够。本篇用 Python 标准库和 SQLite 做一个能运行、能并发测试、能在重启后保留结果的最小"收件箱"。它不宣称分布式系统里可以轻易做到全链路恰好执行一次,而是把能保证的边界说清楚。
一、先分清三件事:投递、入库和业务生效
Webhook 请求来到服务器只是"投递";把可信事件保存在数据库是"接收";真正给用户发货、调用第三方 API 或修改订单才是"业务生效"。这三件事经常被混在一个函数里,于是 HTTP 的超时就变成业务上的不确定性。如果在执行完付款后的附加操作时服务器崩溃,调用方看不到成功响应,重投是合理行为,但重投不应该意味着再次收费。
想象一个典型时间线:第零秒,事件到达;第一秒,服务器写入本地数据库;第二秒,业务已经调用外部服务;第三秒,连接断开;几秒后,上游重发相同的事件。若你的程序只根据"这是第二个 HTTP 请求"判断,它会再次执行。把业务代码包进 try 和 except 并不能消除这个缺口,因为异常可能发生在外部服务已执行之后。
可靠设计第一步是让接收接口变短。收到请求后先验证它确实来自可信上游,然后使用上游提供的稳定事件标识执行一次数据库插入,成功就尽快响应;耗时操作交给独立处理流程。GitHub 官方 Webhook 建议明确提到了验证 secret、尽快返回成功、利用 X-GitHub-Delivery 识别投递。它同时指出,手动重新投递时这个标识会保持不变。这里的"相同"指同一个投递标识,不是简单比较两次请求到达的时间。
二、不要拿请求时间、正文哈希当唯一事件号
事件身份来自业务协议,而不是我们的猜测。假设同一个仓库连续发生两次内容完全相同但合法独立的更新,正文可能很接近甚至相同;如果仅根据正文哈希去重,就会误删真实事件。反过来,重投可能携带新的传输层元数据,把完整 HTTP 报文哈希后反而得出两个不同的键。所谓幂等键必须在上游文档里能找到稳定含义。
对于 GitHub 仓库 Webhook,可以按配置好的来源与 X-GitHub-Delivery 组成唯一键。若一套服务同时接收多个仓库、租户或不同厂商的事件,唯一键还需要加入业务隔离维度。不能把厂商 A 的事件 ID 当成厂商 B 的相同事件,也不能仅以客户端提交的任意字符串作为"可信身份",否则攻击者可以抢先占用某个 ID,导致后续合法事件被误判。
别先 SELECT 再 INSERT。两个并发请求都可能在查询时看到"不存在",然后一起向下执行。最终裁判应该是数据库里的 UNIQUE 约束。我们可以在业务代码中预先读取状态用于展示,但是否抢到首次接收资格,必须由数据库原子操作返回的结果决定。对 SQLite 来说,复合主键以及 ON CONFLICT DO NOTHING 正好适合表达这个意思。
三、先验签,再持久化:一个最小收件箱
下面用 GitHub 的 HMAC SHA-256 请求头作例子。请求处理框架应该交给验签函数原始请求体字节,不要先把 JSON 解析成对象再重新序列化;字节变化会使签名结果不同。secret 从服务端配置读取,不接受请求参数里的密钥。示例只展示"已经取得原始 body、签名头和 delivery ID 以后"的核心逻辑,不包含公网 HTTP 服务,不代表直接复制就能暴露到互联网。
为了不让多个线程共享同一个 sqlite3 连接,每次接收都创建独立连接。表的主键由来源和投递标识两列构成,重复插入直接忽略。INSERT 执行完并正常退出 with 后,事务会提交;若 SQLite 抛出异常,就让上层返回失败,不要悄悄假装已经接收成功。这个细节尤其重要:一旦你返回成功,上游通常就没有继续重试的理由了。
python
# 文件:inbox.py,Python 3.11+
from contextlib import closing
import hashlib
import hmac
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS inbox (
provider TEXT NOT NULL,
delivery_id TEXT NOT NULL,
body BLOB NOT NULL,
state TEXT NOT NULL DEFAULT 'pending',
received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (provider, delivery_id)
)
"""
def init_db(db_path):
with closing(sqlite3.connect(db_path, timeout=10)) as conn, conn:
conn.execute(SCHEMA)
def accept_delivery(db_path, provider, delivery_id,
raw_body, signature_header, secret):
expected = "sha256=" + hmac.new(
secret, raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature_header):
return "invalid_signature"
if not provider or not delivery_id:
return "invalid_identity"
with closing(sqlite3.connect(db_path, timeout=10)) as conn, conn:
cursor = conn.execute(
"""INSERT INTO inbox(provider, delivery_id, body)
VALUES (?, ?, ?)
ON CONFLICT(provider, delivery_id) DO NOTHING""",
(provider, delivery_id, raw_body),
)
return "accepted" if cursor.rowcount == 1 else "duplicate"
有两条边界不能省略。第一,验签必须发生在数据库防重之前;不能为了节省计算量而让未经验证的请求先占坑。第二,代码里的 accepted 只表示"安全落地一条待处理事件",不表示订单已经完成,也不能据此发放不可撤销的权益。HTTP 层对 accepted 和 duplicate 通常都可返回成功,但 invalid_signature 应该明确拒绝,数据库异常也不该被转换为成功。
这里故意没用 INSERT OR REPLACE。SQLite 的 REPLACE 并非"无害刷新";当唯一键冲突时,它可能删除旧行再写新行,等于把先前的处理状态覆盖。重复投递需要保持第一次写入的事件与状态不动,ON CONFLICT DO NOTHING 才更贴近我们的业务含义。数据库约束负责判定身份,应用负责判定下一步,这个分工比把所有判断堆进一个请求函数清楚得多。
四、并发冲进来二十次,到底会留下几条
防重逻辑不能只用"手动点两次按钮"证明有效。真正容易出问题的是几条请求几乎同时进入,全部通过前置校验。下面的测试把相同请求交给多个线程,观察谁拿到了首次插入资格;再重新打开数据库,检查收件箱中的持久记录。它是本地并发模拟,不代表已经完成上游真实网络重试、跨机器压测或故障恢复演练。
python
# 文件:test_inbox.py,与 inbox.py 放在同一目录
from concurrent.futures import ThreadPoolExecutor
from contextlib import closing
from pathlib import Path
from tempfile import TemporaryDirectory
import hashlib
import hmac
import sqlite3
from inbox import accept_delivery, init_db
body = b'{"action":"updated","item":42}'
secret = b"local-test-only"
signature = "sha256=" + hmac.new(
secret, body, hashlib.sha256
).hexdigest()
with TemporaryDirectory() as folder:
db = str(Path(folder) / "events.sqlite3")
init_db(db)
def send(_):
return accept_delivery(
db, "github-demo", "delivery-42",
body, signature, secret
)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(send, range(20)))
assert results.count("accepted") == 1, results
assert results.count("duplicate") == 19, results
assert accept_delivery(
db, "github-demo", "delivery-42",
body, "sha256=bad", secret
) == "invalid_signature"
with closing(sqlite3.connect(db)) as conn:
count = conn.execute("SELECT COUNT(*) FROM inbox").fetchone()[0]
state = conn.execute("SELECT state FROM inbox").fetchone()[0]
assert count == 1 and state == "pending"
print("accepted=1 duplicate=19 rows=1 pending=1")
这个测试的目标不是性能数字,而是业务不变量:同一个来源的同一个投递标识,只能新增一条待处理记录。重复送达仍可以获得成功响应,但不会再生成新任务。换一台机器重新运行时,如果使用同一个持久数据库文件,历史防重记录才继续有效;把数据库换成内存或者把文件删除,结论当然不能成立。因此部署时数据库文件的位置、备份与恢复策略,是"防重是否可靠"的一部分,而不只是运维细节。
并发插入依赖 SQLite 自己的写事务和唯一约束。它不要求提前给每个事件套一把 Python 进程内锁,也不要求所有 Web 请求都落在同一个进程。需要注意的是,锁等待和磁盘故障仍可能引起异常;代码选择了等待有限时间并向上报告错误,而不是把插入失败当成已去重。若你的系统使用多台服务器并且不能共同安全访问同一个数据库,应该使用支持共享事务约束的数据库服务,不要在每台机器上各放一份互不相通的 SQLite 文件。
五、收件箱防重,不等于外部副作用只发生一次
到这里我们只证明"同一个投递仅落库一次",仍没有证明"每个业务操作只执行一次"。下一步通常是由工作进程把 pending 事件取出,标成 processing,执行操作,然后标成 done。这条链路存在一个经典断点:外部接口已经成功,工作进程尚未记录 done 就崩溃。恢复时你只能看到 processing,无法仅凭本地状态知道外部接口是否已经执行。
如果失败后完全不重试,可能漏处理;如果一律重试,可能造成重复副作用。把两个数据库操作和外部 HTTP 调用放在同一个本地事务里也无济于事,因为远端服务不参与 SQLite 事务。正确的办法是按具体业务选择可核对的幂等业务键。例如发起一笔支付相关的远端操作时,优先使用对方明确支持的幂等请求键;调用成功后还要能查询最终业务状态。远端若没有幂等能力,就必须设计补偿、人工对账或"未知状态待核"的流程,不应对外承诺绝对一次。
对于本地数据库内的订单状态更新,可以把"检查订单当前状态"和"写入下一状态"放在同一事务,用唯一约束或者带条件的 UPDATE 保护状态流转。对于发消息、发邮件、提交工单等跨系统副作用,则可以考虑事务性 outbox:业务变更与"待发送消息"同时提交到本地数据库,再由独立发送者异步投递。不过 outbox 本身通常提供的是至少一次发送,接收方仍需要幂等键,不能把它宣传成神奇的一次性保证。
六、最容易误判的六种结果
在真实故障里,我们需要把"没收到请求""已接收但没执行""执行了但没确认"区分开来。接口日志如果只记录 HTTP 状态码,运营人员很难知道该不该补单。至少要有稳定投递号、记录首次到达时间、当前业务状态、最后一次尝试时间与失败原因,且日志不能输出完整 secret 或敏感正文。
| 场景 | 接收层应该做什么 | 是否再次创建待处理事件 |
|---|---|---|
| 第一次合法投递 | 验签、入库并确认 | 是 |
| 同一投递号重新发送 | 确认已有记录 | 否 |
| 正文合法但签名错误 | 拒绝请求,不写收件箱 | 否 |
| 数据库被锁且写入未确认 | 报告失败、由上游重试 | 不可假定成功 |
| 入库成功但 HTTP 响应丢失 | 重投时识别已有记录 | 否 |
| 外部副作用执行后崩溃 | 查询业务状态,按幂等规则恢复 | 不另建事件,但可能要处理旧事件 |
这张表还揭示了一个常见错误:把重复请求直接丢弃,同时返回错误码。某些上游会因此反复重试,制造不必要的流量与告警。既然同一可信事件已经持久接收,返回可接受的成功响应通常更合适。相反,把数据库超时也返回成功,则等于告诉上游"我已妥善保存",实际磁盘上可能什么都没有。两者只差一个分支,后果却完全不同。
还有一个经常漏掉的细节是签名密钥轮换。更换密钥期间,旧事件重放是否合法,要由上游协议和你的密钥轮换窗口决定;不要为了避免误拒绝而长期同时信任全部旧密钥。重放时间窗口与事件去重保留期也不是同一个概念。签名校验解决"请求是否可信",身份去重解决"是否已经接收",长期保留与清理策略解决"过多久仍能识别旧事件"。这几条规则应当分别配置和审计。
七、正式上线之前,还缺哪些工程动作
演示代码为了便于复现实验,省略了 HTTP 服务、身份权限、生产级数据库备份、队列消费与监控。实际接入时,我会先把入口分成四层:框架读取原始请求体;验签并验证来源与事件类型;将唯一身份与原始必要数据写入收件箱;在事务提交后返回成功。真正修改业务的 worker 从收件箱单独领取工作。这样即使处理第三方接口很慢,也不需要让 Webhook 请求一直等着。
worker 的领取动作要在数据库事务中完成,不能先查出一条 pending 再无条件让多个进程同时执行。状态机至少要区分待执行、执行中、完成、失败待重试和待人工核对;执行中的任务若超过租约期限,需要安全恢复。租约过期只说明原工作者可能失联,不保证原工作者没有继续执行,所以仍需要目标业务的幂等约束。重试时应有上限、退避和死信处理;不能因为某条消息永远失败,就占满整个队列。
随后做四类真实演练。第一类是对同一投递号并发重放,检查数据库只插入一次;第二类是接收完立即断开 HTTP 连接,检查重投仍识别旧记录;第三类是在业务操作的不同位置杀死 worker,观察恢复和外部核对;第四类是重启服务、切换节点或恢复数据库备份,确认防重键不会因环境更换消失。每次演练不仅记录请求量,还要记录"业务最终生效次数",因为只统计 HTTP 成功率会掩盖重复执行。
监控指标也不需要堆砌。至少看首次接收数、重复投递数、签名拒绝数、待处理积压、最老任务等待时间、工作者失败与人工待核数。重复投递突然增加,可以是上游网络抖动,也可以是你的响应变慢;单看重复率不能直接归因。需要联动错误日志和队列积压判断。更不能看到 accepted=1 就对用户说"支付完成",那只代表一条消息已经等待处理。
八、结论:数据库能守住接收边界,业务仍要自己兜底
Webhook 不承诺每一条请求只出现一次,我们也不应该依赖这种幻想。先验证可信身份,再由数据库唯一约束决定是否首次持久接收,然后尽快响应上游,是成本很低却能消除大量重复入队问题的一步。Python 的内存集合、请求到达时间和先查询再插入,都不能替代持久化唯一键。
但这一步与最终业务恰好执行一次之间,还有任务领取、失败恢复、外部幂等接口和人工核对等现实问题。把"不重复落库"误写成"不会重复扣款",比没有防重更危险。对于个人项目和小型服务,用一个可备份的 SQLite 收件箱先把边界画清楚;负载增长时再迁移共享数据库与队列,保留同样的事件身份和事务原则,比一上来堆满中间件更容易验证。
参考资料:GitHub Webhook 最佳实践 docs.github.com/en/webhooks... UPSERT 规范 www.sqlite.org/lang_upsert... sqlite3 文档 docs.python.org/3/library/s... GitHub 风格的 HMAC 验签与本地收件箱,不适用于直接替换其他平台的签名协议。