2025年12月20日·阅读约1分钟

在 PostgreSQL 中用 outbox 模式实现可靠的 API 集成

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

在 PostgreSQL 中用 outbox 模式实现可靠的 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 在跨系统唯一性方面很棒,但它们不会按创建顺序排序,这会让轮询不太确定且索引开销更大。

一个常见的折衷是:保留 idbigint 以保证顺序,并在需要跨服务共享稳定标识时额外添加 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 循环

发布可调试的集成
连接支付、消息和外部 API,同时保持集成故障可检查。
开始使用

事件进入 outbox 之后,你需要一个 worker 来读取它们并调用第三方 API。这是把这个模式变成可靠集成的关键部分。

轮询通常是最简单的选项。LISTEN/NOTIFY 可以降低延迟,但它增加了移动部件,并且当通知丢失或 worker 重启时仍然需要回退机制。对大多数团队来说,使用小批量的稳定轮询更容易运行和调试。

安全认领行

worker 应该认领行,确保两个 worker 不会同时处理相同事件。在 PostgreSQL 中,常见做法是使用行锁和 SKIP LOCKED 来选择一批记录,然后把它们标记为进行中。

一个实用的状态流包括:

  • pending:准备发送
  • processing:被 worker 锁定(使用 locked_bylocked_at
  • sent:已成功交付
  • failed:达到最大尝试后停止(或移到人工审查区)

把批次保持小以减轻数据库压力。常见起始配置是每次 10 到 100 行,每 1 到 5 秒运行一次。

当调用成功时,将行标记为 sent。失败时,增加 attempts,把 available_at 设到未来(退避),清除锁,然后返回到 pending

有帮助的日志(但不要泄露密钥)

良好的日志让故障可操作。记录 outbox id、事件类型、目标名称、尝试次数、耗时,以及 HTTP 状态或错误类别。避免记录请求体、认证头和完整响应。如果需要关联信息,存储安全的请求 ID 或哈希,而不是原始 payload 数据。

在真实系统中可行的排序规则

在不重复的情况下添加重试
设置类似 worker 的发送器,安全重试并记录交付状态。
立即试用

很多团队一开始都想“按创建顺序发送事件”。问题是“相同顺序”很少是全局的。如果你强制全局队列,单个慢客户或不稳定 API 会拖慢所有人。

一个实用规则是:保持按组的顺序,而不是整个系统的顺序。选一个与外部世界对你数据理解一致的分组键,例如 customer_idaccount_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.createdorder.paid)并对 payload 模式做版本控制(如 v1v2)。版本化让你在不破坏老消费者的前提下添加字段。

创建 PostgreSQL outbox 表并为 worker 常用查询添加索引,尤其是 (status, available_at, id)

更新写入流程,使业务变更和 outbox 插入发生在同一个数据库事务中。这是核心保证。

添加交付与控制

一个简单的实现计划:

  • 定义可长期支持的事件类型和 payload 版本。
  • 创建 outbox 表和索引。
  • 在主数据变更旁插入 outbox 行。
  • 构建一个认领行、发送到第三方 API、然后更新状态的 worker。
  • 添加带退避的重试调度,达到上限后转到 failed 状态。

添加基础指标以便你能尽早发现问题:滞后(最旧未发送事件的年龄)、发送速率和失败率。

一个简单示例:向外部服务发送订单事件

构建更安全的集成流程
使用 PostgreSQL outbox 构建可靠的集成并保持用户请求快速响应。
试用 AppMaster

客户在你的应用下单。两件事必须在系统外完成:计费服务收款,以及运输提供方创建运输单。

使用 outbox 模式,你不会在结账请求中调用这些 API。相反,你在同一个 PostgreSQL 事务中保存订单并写入一条 outbox 事件,这样就不会出现“订单保存了,但没通知出去”(或相反)的情况。

典型的 outbox 行可能包含 aggregate_id(订单 ID)、event_type(如 order.created)和包含总价、商品和收货信息的 JSONB payload。

worker 会抓取 pending 行并调用外部服务(按定义顺序调用,或发出不同事件如 payment.requestedshipment.requested)。如果某个提供方宕机,worker 会记录尝试次数,把 available_at 推到未来并继续。订单依然存在,事件会在以后重试而不会阻塞新的结账。

排序通常按“每个订单”或“每个客户”来做。确保相同 aggregate_id 的事件一次只处理一个,以免 order.paidorder.created 之前到达。

去重可以防止重复收费或创建重复运输单。当第三方支持幂等键时发送它,并保留目标交付记录,这样超时后的重试不会触发第二次动作。

在发布前的快速检查

部署到你团队运行的环境
部署到 AppMaster Cloud 或你自己的 AWS、Azure 或 Google Cloud 环境。
试用 AppMaster

在你信任某个集成可以转移资金、通知客户或同步数据之前,测试边缘情况:崩溃、重试、重复、以及多 worker 场景。

能捕捉常见失败的检查项:

  • 确认 outbox 行是在与业务变更相同的事务中创建的。
  • 验证发送器能在多个实例中安全运行。两个 worker 不应同时发送同一事件。
  • 如果排序重要,用一句话定义规则并用稳定键强制执行。
  • 针对每个目标,决定如何防止重复以及如何证明“我们发送过”。
  • 定义退出策略:在 N 次尝试后,把事件移为 failed,保留最后一次错误摘要,并提供简单的重新处理操作。

现实检验:Stripe 可能接受请求但你的 worker 在保存成功前崩溃。没有幂等性,重试会导致二次操作。有了幂等性加上保存的交付记录,重试就安全了。

下一步:在不干扰应用的情况下逐步推出

推出阶段通常决定 outbox 项目是成功还是搁浅。先把范围做小,这样你能在不让整个集成层冒险的情况下看到真实表现。

从一个集成和一种事件类型开始。例如,仅向单个供应商 API 发送 order.created,其他仍按现有方式运行。这会给你一个清晰的吞吐、延迟和失败率基线。

让问题早暴露。为 outbox 滞后(有多少事件在等,以及最旧的事件多久)、失败率(有多少在重试)添加仪表盘和告警。如果你能在 10 秒内回答“我们现在落后了吗?”,你就能在用户察觉之前捕捉到问题。

在首次事故发生前准备好安全的重新处理方案。决定“重新处理”是什么意思:重试相同 payload、从当前数据重建 payload,还是送人工审查。记录哪些情况可以安全重发,哪些需要人工检查。

如果你用像 AppMaster (appmaster.io) 这样的无代码平台构建,原则相同:在 PostgreSQL 中把业务数据和 outbox 行一起写入,然后运行独立的后端进程来交付、重试并把事件标记为已发送或失败。

常见问题

When should I use the outbox pattern instead of calling the API directly?

当某个用户操作既修改你的数据库又必须触发另一个系统的工作时,就使用 outbox 模式。当超时、不稳定网络或第三方故障可能导致“我们保存了,但他们没有收到”的情况时,它特别有用。

Why does the outbox insert need to be in the same transaction as the business write?

在同一个数据库事务中写入业务行和 outbox 行会给你一个明确的保证:要么两个都存在,要么都不存在。这可以防止像“API 调用成功但订单未保存”或“订单已保存但 API 调用未发生”这样的部分失败情况。

What fields should an outbox table include to be practical?

一个实用的默认字段集包括 idaggregate_idevent_typepayloadstatuscreated_atavailable_atattempts,以及像 locked_atlocked_by 这样的锁字段。这样可以让发送、重试调度和并发控制保持简单而不让表过于复杂。

What indexes matter most for an outbox table in PostgreSQL?

常见基线是 (status, available_at, id) 的索引,这样工作进程可以快速按顺序获取下一个可发送事件。只有在确实按那些字段查询时再添加额外索引,因为额外索引会降低插入速度。

Should my worker poll the outbox table or use LISTEN/NOTIFY?

轮询对大多数团队来说是最简单且最可预测的方法。先用小批量和短间隔开始,然后根据负载与滞后进行调优;当问题出现时,一个简单的循环更容易调试。虽然 LISTEN/NOTIFY 可以降低延迟,但它增加了复杂性并需要备用方案以防通知丢失或 worker 重启时错过通知。

How do I prevent two workers from sending the same outbox event?

使用行级锁去认领行,确保两个 worker 不会同时处理同一事件,通常用 SKIP LOCKED。然后把行标记为 processing,写上锁时间和 worker ID,发送后把它标记为 sent 或把锁清除并设置未来的 available_at 返回到 pending

What’s the safest retry strategy for outbox deliveries?

使用指数退避并设定最大尝试次数,只对可能暂时性的失败重试。超时、网络错误和 HTTP 429/5xx 是适合重试的候选;大多数 4xx(例如 400、401/403、404)和数据校验错误应视为最终错误,直到修正数据或配置为止。

Does the outbox pattern guarantee exactly-once delivery?

不能把 exactly-once 当作理所当然:重复发送仍有可能发生,比如 worker 在 HTTP 调用后崩溃但在记录成功之前。使用对每个目标和每个事件都稳定的幂等键,并保留交付记录(加上唯一约束),这样即使 worker 竞态也不能造成真正的双重动作。

How do I handle ordering without slowing down the whole system?

默认在组内保留顺序,而不是全局保序。使用像 aggregate_id(订单 ID)或 customer_id 这样的分组键,每组一次只处理一个事件,并允许不同组并行处理,这样一个慢或有问题的客户不会阻塞所有人。

What should I do with a “poison” event that keeps failing?

在达到最大重试次数后将其标记为 failed,保留简短且安全的错误摘要,并暂停同一组的后续事件,直到有人修复根本原因。这可以控制影响范围,防止无休止的重试噪音,同时保持其他组继续运作。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧