Webhook 重投别再执行两次:Python + SQLite 收件箱实测

同一个 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 验签与本地收件箱,不适用于直接替换其他平台的签名协议。

相关推荐
Eider19981 小时前
Python基础知识之并发相关-2
开发语言·前端·python
第九序言T1 小时前
Python 数据处理进阶(二十二)用 Pandas 做一次完整的用户行为分析
开发语言·python·数据分析·pandas
云和数据.ChenGuang1 小时前
再论深拷贝和浅拷贝
开发语言·人工智能·python·1024程序员节
第九序言T2 小时前
Python 数据处理进阶(二十一)相关性与分组对比:数据里藏着什么关系
大数据·人工智能·python·数据分析·pandas
Gent_倪2 小时前
Python 语句梳理
python
m0_738120722 小时前
JWT 安全从入门到实战:六种典型攻击场景解析
前端·网络·python·安全·网络安全
用户298698530142 小时前
Python 实现 HTML 转纯文本:两种场景下的文档模型方案
python·html·api
小小张说故事3 小时前
Python 的赋值为什么不会复制对象?拆开引用、浅拷贝与深拷贝
后端·python
ljt27249606613 小时前
Python笔记(十三)--异常
笔记·python