在 PostgreSQL 中用 outbox 模式实现可靠的 API 集成
学习 outbox 模式:在 PostgreSQL 中存储事件,再可靠地投递到第三方 API,支持重试、排序和去重。

为什么集成会失败,即便你的应用工作正常
经常会出现这样的情况:在你的应用中某个操作显示“成功”,但背后的集成默默失败。你的数据库写入是快速且可靠的,但对第三方 API 的调用却不一定可靠。这就造成了两个不同的世界:你的系统认为变更已发生,但外部系统根本没收到通知。
典型例子:客户下单,你的应用把订单保存到 PostgreSQL,然后尝试通知物流提供方。如果对方超时 20 秒且你的请求放弃了,订单依然存在,但运输单从未创建。
用户会感到困惑,表现为不一致:缺失的事件看起来像“什么都没发生”,重复的事件看起来像“为什么我被多收费了?”支持团队也会很头疼,因为很难判断问题出在你的应用、网络,还是合作方。
重试能有所帮助,但光靠重试不能保证正确性。如果在超时后重试,你可能会发送相同的事件两次,因为你不知道对方是否收到了第一次请求。若顺序混乱,你可能会先发送“订单已发货”,再发送“订单已付款”。
这些问题通常来自正常的并发:多个 worker 并行处理、多个应用服务器同时写入,以及在负载变化时行为会变化的“尽力而为”队列。故障模式可预测:API 宕机或变慢、网络丢包、进程在关键时刻崩溃,重试在没有幂等保障时会造成重复。
outbox 模式正是为了解决这些常见故障。
用通俗的话说什么是 outbox 模式
outbox 模式很直白:当你的应用完成一个重要变更(比如创建订单)时,同时在数据库表里写入一条“待发送事件”的记录,并把这两个写入放在同一个事务中。如果事务提交成功,你就知道业务数据和事件记录一起存在。
之后,一个独立的 worker 会读取 outbox 表并把这些事件投递给第三方 API。如果某个 API 很慢、宕机或超时,你的主请求依然会成功,因为它不必等待外部调用完成。
这样可以避免在请求处理器内直接调用 API 时出现的尴尬状态:
- 订单已保存,但 API 调用失败。
- API 调用成功,但你的应用在保存订单前崩溃。
- 用户重试,你发送了同样的东西两次。
outbox 模式主要帮助解决事件丢失、部分失败(数据库 OK,外部 API 不 OK)、意外双发以及更安全的重试(你可以在以后再试,而不必猜测)。
它并不能解决所有问题。如果你的负载错误、业务规则有误,或第三方 API 拒绝该数据,你仍然需要验证、良好的错误处理以及检视和修复失败事件的方式。
在 PostgreSQL 中设计 outbox 表
一个好的 outbox 表故意应该很平凡。它应该容易写、容易读,并且难以误用。
下面是一个可实践的基线模式,你可以据此调整:
create table outbox_events (
id bigserial primary key,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
status text not null default 'pending',
created_at timestamptz not null default now(),
available_at timestamptz not null default now(),
attempts int not null default 0,
locked_at timestamptz,
locked_by text,
meta jsonb not null default '{}'::jsonb
);
ID 的选择
使用 bigserial(或 bigint)能让排序简单且索引高效。UUID 在跨系统唯一性方面很棒,但它们不会按创建顺序排序,这会让轮询不太确定且索引开销更大。
一个常见的折衷是:保留 id 为 bigint 以保证顺序,并在需要跨服务共享稳定标识时额外添加 event_uuid。
重要的索引
你的 worker 会整天重复相同的查询模式。大多数系统需要:
- 一个像
(status, available_at, id)的索引,用于按顺序获取下一个待发送事件。 - 如果你计划过期过时锁,则在
(locked_at)上建索引。 - 如果你有时需要按聚合顺序投递,则在
(aggregate_id, id)上建索引。
保持 payload 稳定
保持 payload 小且可预测。存储接收方实际需要的字段,而不是整行数据。在 meta 中添加显式版本信息,这样你可以安全演进字段。
把 meta 用于路由和调试上下文,比如租户 ID、关联 ID、trace ID,以及去重键。这些额外上下文在支持团队回答“这个订单发生了什么?”时会大有裨益。
如何在业务写入中安全地存储事件
最重要的规则很简单:在同一个数据库事务中写入业务数据和 outbox 事件。如果事务提交,则两者都存在;如果回滚,则两者都不存在。
示例:客户下单。在一个事务中插入订单行、订单项,以及一条 outbox 行,例如 order.created。如果任何一步失败,你就不会让“已创建”的事件泄露到外部世界。
一次写入一个事件还是多个?
能做到时从每个业务动作发布一个事件开始。这样更容易理解且处理成本更低。只有在不同消费者确实需要不同的时序或 payload 时才拆分为多个事件(例如,为履约发 order.created,为计费发 payment.requested)。为一次点击生成很多事件会增加重试、顺序问题和重复处理的复杂度。
应该存什么 payload?
通常有两个选择:
- 快照:存储动作发生时的关键字段(订单总额、币种、客户 ID)。这避免以后额外读取并让消息稳定。
- 参考 ID:只存订单 ID,让 worker 之后加载详情。这样可以保持 outbox 小,但会增加读取并且订单被编辑时可能变化。
实用的折中是:标识符加上一小部分关键值的快照。它帮助接收方快速执行,也便于调试。
保持事务边界紧凑。不要在同一事务中调用第三方 API。
向第三方 API 交付事件:worker 循环
事件进入 outbox 之后,你需要一个 worker 来读取它们并调用第三方 API。这是把这个模式变成可靠集成的关键部分。
轮询通常是最简单的选项。LISTEN/NOTIFY 可以降低延迟,但它增加了移动部件,并且当通知丢失或 worker 重启时仍然需要回退机制。对大多数团队来说,使用小批量的稳定轮询更容易运行和调试。
安全认领行
worker 应该认领行,确保两个 worker 不会同时处理相同事件。在 PostgreSQL 中,常见做法是使用行锁和 SKIP LOCKED 来选择一批记录,然后把它们标记为进行中。
一个实用的状态流包括:
pending:准备发送processing:被 worker 锁定(使用locked_by和locked_at)sent:已成功交付failed:达到最大尝试后停止(或移到人工审查区)
把批次保持小以减轻数据库压力。常见起始配置是每次 10 到 100 行,每 1 到 5 秒运行一次。
当调用成功时,将行标记为 sent。失败时,增加 attempts,把 available_at 设到未来(退避),清除锁,然后返回到 pending。
有帮助的日志(但不要泄露密钥)
良好的日志让故障可操作。记录 outbox id、事件类型、目标名称、尝试次数、耗时,以及 HTTP 状态或错误类别。避免记录请求体、认证头和完整响应。如果需要关联信息,存储安全的请求 ID 或哈希,而不是原始 payload 数据。
在真实系统中可行的排序规则
很多团队一开始都想“按创建顺序发送事件”。问题是“相同顺序”很少是全局的。如果你强制全局队列,单个慢客户或不稳定 API 会拖慢所有人。
一个实用规则是:保持按组的顺序,而不是整个系统的顺序。选一个与外部世界对你数据理解一致的分组键,例如 customer_id、account_id 或像 order_id 这样的 aggregate_id。然后保证每个组内的顺序,同时对不同组并行投递。
并行 worker 同时不破坏顺序
运行多个 worker,但要确保不会有两个 worker 同时处理同一组。常见做法是始终交付给定 aggregate_id 的最早未发送事件,并允许不同聚合间的并行处理。
把认领规则保持简单:
- 仅交付每个组中最早的 pending 事件。
- 允许不同组之间并行,但同组内不并行。
- 认领一个事件,发送它,更新状态,然后继续下一条。
当某个事件阻塞其后的事件时
总有一天,一个“毒性”事件会失败数小时(错误的 payload、令牌被撤销、提供方故障)。如果你严格按组顺序处理,该组后续事件应等待,但其他组应继续。
一个可行的折衷是对单个事件的重试次数设置上限。之后,把它标记为 failed 并只暂停该组,直到有人修复根本原因。这样可以防止一个坏客户拖慢所有人。
重试而不把事情搞得更糟
重试是衡量 outbox 是否可靠或噪声满天的关键。目标是:在可能成功时重试,在不可能时尽快停止。
使用指数退避并设定硬性上限。例如:1 分钟、2 分钟、4 分钟、8 分钟,然后停止(或继续但最大延迟不超过例如 15 分钟)。始终设置最大尝试次数,以免一个坏事件把系统堵死。
并非所有失败都应重试。保持规则清晰:
- 重试:网络超时、连接重置、DNS 问题,以及 HTTP 429 或 5xx 响应。
- 不重试:HTTP 400(错误请求)、401/403(认证问题)、404(错误端点),或可在发送前检测到的校验错误。
在 outbox 行上存储重试状态。增加 attempts,设置下一次尝试的 available_at,并记录短小且安全的错误摘要(状态码、错误类别、截断后的消息)。不要在错误字段中存储完整 payload 或敏感数据。
速率限制需要特殊处理。如果收到 HTTP 429,尊重 Retry-After(如果存在)。否则就更激进地退避,避免重试风暴。
去重与幂等基础
如果你要构建可靠的 API 集成,就要假设同一事件可能会被发送多次。worker 可能在 HTTP 调用后崩溃但在记录成功之前退出;超时可能掩盖一次成功;重试可能与第一次慢速尝试重叠。outbox 模式减少了事件丢失,但本身并不能防止重复。
最安全的方法是幂等:重复投递应产生与一次投递相同的结果。当调用第三方 API 时,包含一个对该事件和该目标稳定的幂等键。很多 API 支持在 header 上传幂等键;如果不支持,就把键放在请求体中。
一个简单的键是目标加事件 ID。对于 ID 为 evt_123 的事件,始终使用类似 destA:evt_123 的键。
在你这侧,通过维护出站交付日志并强制唯一规则(如 (destination, event_id))来防止重复发送。即使两个 worker 并发,只有一个能创建表示“我们正在发送这个”的记录。
Webhook 也会重复
如果你接收 webhook 回调(例如“交付确认”或“状态更新”),也要同样对待。提供方会重试,你可能会收到多次相同的 payload。存储已处理的 webhook ID,或从提供方的消息 ID 计算稳定哈希并拒绝重复。
保留数据多长时间
保留 outbox 行直到你记录成功(或接受的最终失败)。交付日志要保留更长时间,因为当有人问“我们有没有发送过?”时,它们是你的审计记录。
常见做法:
- Outbox 行:在成功后加上短期安全窗口(几天)后删除或归档。
- 交付日志:根据合规与支持需求保留数周或数月。
- 幂等键:至少保留到重试窗口结束(并对 webhook 去重保留更久)。
分步:实现 outbox 模式
决定你要发布什么。保持事件小、聚焦且易于重放。一个好规则是每个事件包含一条业务事实,并有足够数据让接收方行动。
打好基础
选用清晰的事件命名(例如 order.created、order.paid)并对 payload 模式做版本控制(如 v1、v2)。版本化让你在不破坏老消费者的前提下添加字段。
创建 PostgreSQL outbox 表并为 worker 常用查询添加索引,尤其是 (status, available_at, id)。
更新写入流程,使业务变更和 outbox 插入发生在同一个数据库事务中。这是核心保证。
添加交付与控制
一个简单的实现计划:
- 定义可长期支持的事件类型和 payload 版本。
- 创建 outbox 表和索引。
- 在主数据变更旁插入 outbox 行。
- 构建一个认领行、发送到第三方 API、然后更新状态的 worker。
- 添加带退避的重试调度,达到上限后转到
failed状态。
添加基础指标以便你能尽早发现问题:滞后(最旧未发送事件的年龄)、发送速率和失败率。
一个简单示例:向外部服务发送订单事件
客户在你的应用下单。两件事必须在系统外完成:计费服务收款,以及运输提供方创建运输单。
使用 outbox 模式,你不会在结账请求中调用这些 API。相反,你在同一个 PostgreSQL 事务中保存订单并写入一条 outbox 事件,这样就不会出现“订单保存了,但没通知出去”(或相反)的情况。
典型的 outbox 行可能包含 aggregate_id(订单 ID)、event_type(如 order.created)和包含总价、商品和收货信息的 JSONB payload。
worker 会抓取 pending 行并调用外部服务(按定义顺序调用,或发出不同事件如 payment.requested 和 shipment.requested)。如果某个提供方宕机,worker 会记录尝试次数,把 available_at 推到未来并继续。订单依然存在,事件会在以后重试而不会阻塞新的结账。
排序通常按“每个订单”或“每个客户”来做。确保相同 aggregate_id 的事件一次只处理一个,以免 order.paid 在 order.created 之前到达。
去重可以防止重复收费或创建重复运输单。当第三方支持幂等键时发送它,并保留目标交付记录,这样超时后的重试不会触发第二次动作。
在发布前的快速检查
在你信任某个集成可以转移资金、通知客户或同步数据之前,测试边缘情况:崩溃、重试、重复、以及多 worker 场景。
能捕捉常见失败的检查项:
- 确认 outbox 行是在与业务变更相同的事务中创建的。
- 验证发送器能在多个实例中安全运行。两个 worker 不应同时发送同一事件。
- 如果排序重要,用一句话定义规则并用稳定键强制执行。
- 针对每个目标,决定如何防止重复以及如何证明“我们发送过”。
- 定义退出策略:在 N 次尝试后,把事件移为
failed,保留最后一次错误摘要,并提供简单的重新处理操作。
现实检验:Stripe 可能接受请求但你的 worker 在保存成功前崩溃。没有幂等性,重试会导致二次操作。有了幂等性加上保存的交付记录,重试就安全了。
下一步:在不干扰应用的情况下逐步推出
推出阶段通常决定 outbox 项目是成功还是搁浅。先把范围做小,这样你能在不让整个集成层冒险的情况下看到真实表现。
从一个集成和一种事件类型开始。例如,仅向单个供应商 API 发送 order.created,其他仍按现有方式运行。这会给你一个清晰的吞吐、延迟和失败率基线。
让问题早暴露。为 outbox 滞后(有多少事件在等,以及最旧的事件多久)、失败率(有多少在重试)添加仪表盘和告警。如果你能在 10 秒内回答“我们现在落后了吗?”,你就能在用户察觉之前捕捉到问题。
在首次事故发生前准备好安全的重新处理方案。决定“重新处理”是什么意思:重试相同 payload、从当前数据重建 payload,还是送人工审查。记录哪些情况可以安全重发,哪些需要人工检查。
如果你用像 AppMaster (appmaster.io) 这样的无代码平台构建,原则相同:在 PostgreSQL 中把业务数据和 outbox 行一起写入,然后运行独立的后端进程来交付、重试并把事件标记为已发送或失败。
常见问题
当某个用户操作既修改你的数据库又必须触发另一个系统的工作时,就使用 outbox 模式。当超时、不稳定网络或第三方故障可能导致“我们保存了,但他们没有收到”的情况时,它特别有用。
在同一个数据库事务中写入业务行和 outbox 行会给你一个明确的保证:要么两个都存在,要么都不存在。这可以防止像“API 调用成功但订单未保存”或“订单已保存但 API 调用未发生”这样的部分失败情况。
一个实用的默认字段集包括 id、aggregate_id、event_type、payload、status、created_at、available_at、attempts,以及像 locked_at 和 locked_by 这样的锁字段。这样可以让发送、重试调度和并发控制保持简单而不让表过于复杂。
常见基线是 (status, available_at, id) 的索引,这样工作进程可以快速按顺序获取下一个可发送事件。只有在确实按那些字段查询时再添加额外索引,因为额外索引会降低插入速度。
轮询对大多数团队来说是最简单且最可预测的方法。先用小批量和短间隔开始,然后根据负载与滞后进行调优;当问题出现时,一个简单的循环更容易调试。虽然 LISTEN/NOTIFY 可以降低延迟,但它增加了复杂性并需要备用方案以防通知丢失或 worker 重启时错过通知。
使用行级锁去认领行,确保两个 worker 不会同时处理同一事件,通常用 SKIP LOCKED。然后把行标记为 processing,写上锁时间和 worker ID,发送后把它标记为 sent 或把锁清除并设置未来的 available_at 返回到 pending。
使用指数退避并设定最大尝试次数,只对可能暂时性的失败重试。超时、网络错误和 HTTP 429/5xx 是适合重试的候选;大多数 4xx(例如 400、401/403、404)和数据校验错误应视为最终错误,直到修正数据或配置为止。
不能把 exactly-once 当作理所当然:重复发送仍有可能发生,比如 worker 在 HTTP 调用后崩溃但在记录成功之前。使用对每个目标和每个事件都稳定的幂等键,并保留交付记录(加上唯一约束),这样即使 worker 竞态也不能造成真正的双重动作。
默认在组内保留顺序,而不是全局保序。使用像 aggregate_id(订单 ID)或 customer_id 这样的分组键,每组一次只处理一个事件,并允许不同组并行处理,这样一个慢或有问题的客户不会阻塞所有人。
在达到最大重试次数后将其标记为 failed,保留简短且安全的错误摘要,并暂停同一组的后续事件,直到有人修复根本原因。这可以控制影响范围,防止无休止的重试噪音,同时保持其他组继续运作。


