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

触发器与后台作业:哪种方式更可靠地发送通知?

了解在何时使用触发器或后台 worker 更安全地发送通知,并获得关于重试、事务边界与防止重复的实用指导。

触发器与后台作业:哪种方式更可靠地发送通知?

为什么在真实应用中通知会出问题

通知听起来很简单:用户做了某件事,然后发出一封邮件或短信。大多数真实失败都归结为时序和重复问题。消息在数据真正保存之前被发送,或者在部分失败后被发送了两次。

“通知”可以是很多东西:邮件收据、短信一次性验证码、推送提醒、应用内消息、Slack 或 Telegram 提示,或发给另一个系统的 webhook。共同的问题始终相同:你在试图把数据库变更与应用外部的某件事协调起来。

外部世界很混乱。提供方可能很慢、返回超时,或者在你的应用没收到成功响应时就接收了请求。你的应用也可能在请求中途崩溃或重启。即便是“成功”的发送也可能因为基础设施重试、worker 重启或用户再次点击按钮而被重新执行。

导致通知投递失败的常见原因包括网络超时、提供方故障或限流、应用在错误时刻重启、重试重复运行同样发送逻辑而没有唯一保护,以及把数据库写入和外部发送设计成一个组合步骤。

当人们说“可靠通知”时,通常意味着两件事之一:

  • 精确地只发送一次,或
  • 至少不要重复(重复通常比延迟更糟)。

要同时做到快速且绝对安全很难,因此你需要在速度、安全性和复杂度之间权衡。

这就是为什么在触发器和后台 worker 之间的选择不仅是架构讨论。它关乎何时允许发送、如何重试失败,以及当出现问题时如何防止重复邮件或短信。

触发器与后台 worker:含义

当人们把触发器与后台 worker 做比较时,他们实际上在比较通知逻辑运行的位置以及它与触发动作的耦合程度。

触发器就是“X 发生时立即执行”。在许多应用中,这意味着在同一个 web 请求内在用户操作后发送邮件或短信。触发器也可以存在于数据库层:数据库触发器在插入或更新行时自动运行。两者都感觉很即时,但它们继承了触发它们的时序与限制。

后台 worker 则是“尽快但不在前端执行”。它是一个独立进程,从队列中拉取作业并尝试完成。主应用记录应该发生的事并迅速返回,而 worker 处理更慢、易出错的部分,比如调用邮件或短信提供方。

“作业”是 worker 处理的工作单元。通常包含要通知的人、要使用的模板、要填充的数据、当前状态(queued、processing、sent、failed)、已经尝试的次数,有时还有计划发送时间。

典型的通知流程是:准备消息细节、入队作业、通过提供方发送、记录结果,然后决定是否重试、停止或通知某人。

事务边界:什么时候真正安全发送

事务边界是“我们尝试保存”和“它真正保存了”之间的分界线。在数据库提交之前,变更仍然可以回滚。这很重要,因为通知很难撤回。

如果在提交之前发送邮件或短信,你可能会把一条关于从未发生的事情的消息发给用户。用户可能会收到“你的密码已更改”或“你的订单已确认”的通知,然后写入由于约束错误或超时而失败。现在用户困惑,支持团队不得不去理清这些问题。

从数据库触发器内部发送听起来诱人,因为它在数据变化时自动触发。问题是触发器在相同事务内运行。如果事务回滚,你可能已经调用了邮件或短信提供方。

数据库触发器通常也更难观察、测试和安全重试。当它们执行慢的外部调用时,会比预期持有更长的锁并让数据库问题更难诊断。

更安全的方法是 outbox 思路:把通知意图作为数据记录,提交后再发送。

在同一事务中,你既做业务变更,也插入一条描述消息的 outbox 行(收件人、内容、渠道、以及唯一键)。提交后,后台 worker 读取待处理的 outbox 行,发送消息,然后标记为已发送。

对于低影响、信息类消息(例如“我们正在处理你的请求”),即时发送通常也可以接受。但对于必须与最终状态一致的任何事情,请等待提交后再发送。

重试与失败处理:各自的优势

重试通常是决定性因素。

触发器:快速,但在失败时脆弱

大多数基于触发器的设计没有良好的重试方案。

如果触发器调用邮件/短信提供方且调用失败,你通常会面临两个糟糕选择:

  • 让事务失败(阻塞原来的更新),或
  • 吞掉错误(并悄然丢失通知)。

当可靠性重要时,两者都不可接受。

在触发器内部尝试循环或延迟可能会变得更糟:事务被保持更久、锁时间增加并且使数据库变慢。如果数据库或应用在发送中途死掉,你通常无法判断提供方是否收到请求。

后台 worker:为重试而生

worker 把发送视为一个独立任务并带有自己的状态,这使得按需重试变得自然。

作为实践规则,你通常重试临时失败(超时、瞬时网络问题、服务器错误、被限流时延长等待)。你通常不重试永久性问题(无效手机号、格式错误的邮箱、已退订等硬性拒绝)。对于“未知”错误,限制尝试次数并把状态可视化。

退避是避免重试使情况更糟的关键。先短等待,再逐步加长(例如 10s、30s、2m、10m),并在固定次数后停止。

为了在部署和重启时仍能生存,需把重试状态和每个作业一起存储:尝试次数、下一次尝试时间、最后错误(简短且可读)、最后尝试时间,以及清晰的状态如 pendingsendingsentfailed

如果应用在发送中途重启,worker 可以重新检查卡住的作业(例如状态 = sending 且时间很旧)并安全地重试。这也是幂等性变得至关重要的地方,确保重试不会重复发送。

用幂等性防止重复邮件和短信

今天就使用 outbox 模式
构建 outbox + worker 流程,确保邮件与短信只在提交后发送。
试用 AppMaster

幂等性意味着你可以多次运行同样的“发送通知”操作,而用户仍只收到一次。

经典的重复场景是超时:你的应用调用邮件或短信提供方时请求超时,然后你的代码重试。第一次请求可能实际上已经成功了,因此重试会产生重复。

一个实用的修复是给每条消息一个稳定键,并把该键视为唯一真相。好的键描述消息的含义,而不是你尝试发送的时刻。

常见做法包括:

  • 在你决定“这条消息应该存在”时生成的 notification_id,或
  • 业务派生键比如 order_id + template + recipient(仅当它确实定义了唯一性时)。

然后存储一个发送账本(通常就是 outbox 表本身),并让所有重试在发送前检查它。保持状态简单且可见:created(已决定)、queued(就绪)、sent(已确认)、failed(确认失败)、canceled(不再需要)。关键规则是对于每个幂等键只允许一个活动记录。

当提供方支持时,提供方侧的幂等性也有帮助,但它不能替代你自己的账本。你仍需处理自己的重试、部署与 worker 重启。

还要把“未知”结果作为一类处理。如果请求超时,不要立即再次发送。把它标记为待确认并在可能时查询提供方的投递状态;如果无法确认,就延迟并告警,而不是重复发送。

一个安全的默认模式:outbox + 后台 worker(逐步说明)

如果你想要一个安全的默认方案,outbox 模式加 worker 很难被超越。它把发送保持在业务事务之外,同时保证了通知意图被保存。

流程

把“发送通知”视为要存储的数据,而不是立即触发的动作。

你保存业务变更(例如订单状态更新)到常规表中。在同一数据库事务中,你也插入一条 outbox 记录,包含收件人、渠道(email/SMS)、模板、payload,以及一个幂等键。提交事务。只有在那之后才允许任何发送发生。

后台 worker 定期拾取待处理的 outbox 行,发送它们并记录结果。

添加一个简单的认领步骤,避免两个 worker 同时拿到同一行。这可以是对状态改为 processing 或使用一个锁定时间戳。

阻止重复并处理失败

重复通常发生在发送成功但你的应用在记录“已发送”之前崩溃。你通过让“标记为已发送”的写入可重复安全来解决这个问题。

使用唯一性规则(例如在幂等键与渠道上加唯一约束)。以清晰规则重试:限制尝试次数、逐步延长间隔、只针对可重试错误。最后一次重试后,将作业移入死信状态(例如 failed_permanent),以便有人进行审查并手动重新处理。

监控可以保持简单:统计 pending、processing、sent、retrying 和 failed_permanent 的数量,以及最旧 pending 的时间戳。

具体例子:当订单从 “Packed” 变为 “Shipped” 时,你更新订单行并创建一条 outbox 行,幂等键为 order-4815-shipped。即使 worker 在发送中途崩溃,重试也不会重复发送,因为“已发送”的写入受该唯一键保护。

何时应选择后台 worker

部署你的发送 worker
准备好后将你的发送 worker 部署到 AppMaster Cloud 或你自己的云。
开始构建

数据库触发器在数据变化时反应迅速。但如果任务是“在复杂的真实世界条件下可靠地交付通知”,后台 worker 通常能给你更多控制力。

当你需要基于时间的发送(提醒、汇总)、高吞吐量与限流与背压处理、对提供方波动的容忍(429 限制、慢响应、短时故障)、多步工作流(发送、等待投递、然后跟进),或需要跨系统对账的事件时,worker 更合适。

简单例子:你向客户收费,然后发送短信收据,再发送邮件发票。如果 SMS 因网关问题失败,你仍希望订单保持已支付状态并在之后安全重试。把该逻辑放到触发器里会把“数据正确”与“第三方当前可用”混在一起,这是有风险的。

后台 worker 也让运维控制更容易。你可以在事故期间暂停队列、检查失败并按延迟重试。

导致丢失或重复消息的常见错误

清晰设计重试规则
使用可视化逻辑处理退避、重试次数与永久失败。
开始使用

让通知不可靠的最快方法是“觉得方便就直接发送”,然后指望重试来拯救你。无论你使用触发器还是 worker,失败与状态的细节决定了用户会收到一条、两条还是没有消息。

一个常见陷阱是从数据库触发器发送并假设它不会失败。触发器在数据库事务内运行,因此任何慢的提供方调用都可能阻塞写入、触发超时或比预期持有更多锁。更糟的是,如果发送失败并且你回滚事务,提供方实际上接受了第一次调用,之后你可能再次发送造成重复。

反复出现的错误包括:

  • 用相同方式重试所有错误,包括永久性错误(坏邮箱、被拦截的号码)。
  • 不把“queued”和“sent”分开,因此在崩溃后无法判断哪些可以安全重试。
  • 使用时间戳作为去重键,使重试天然绕过“唯一性”。
  • 在用户请求路径中调用提供方(结账与表单提交不应等待网关响应)。
  • 把提供方超时视为“未投递”,而实际上很多是“未知”。

一个简单例子:你发送短信,提供方超时,你重试。如果第一次请求实际上已经成功,用户会收到两个验证码。解决办法是记录稳定幂等键(例如 notification_id),在发送前把消息标记为 queued,并在获得明确成功响应后再标记为 sent。

上线通知前的快速检查

大多数通知错误不是工具问题,而是时序、重试与缺失记录的问题。

确认你只在数据库写入安全提交后发送。如果在写入期间发送而后发生回滚,用户可能会收到关于并未发生事情的消息。

接着,为每条通知指定唯一标识。给每条消息一个稳定的幂等键(例如 order_id + event_type + channel),并在存储层强制唯一性,以便重试不能创建第二条“新”通知。

发布前检查这些基本点:

  • 发送发生在提交之后,而不是在写入期间。
  • 每条通知都有唯一幂等键,重复会被拒绝。
  • 重试是安全的:系统可以重复运行同一作业但最多只发送一次。
  • 每次尝试都有记录(状态、last_error、时间戳)。
  • 尝试有上限,卡住的项有清晰的人工复核与重新处理入口。

有针对性地测试重启行为。故意在发送中途杀掉 worker,重启它,验证没有重复发送。在数据库负载下也做同样测试。

一个简单的验证场景:用户更改手机号,你发送短信验证。如果 SMS 提供方超时,应用重试。通过良好的幂等键与尝试日志,你要么只发送一次,要么在之后安全地重试,但不会骚扰用户。

示例场景:订单更新且不重复发送

阻止重复通知
添加幂等键以防超时和重试导致重复消息。
创建项目

一家商店发送两类消息:(1) 支付后立即发出的订单确认邮件,(2) 包裹Out for deliveryDelivered 时的短信更新。

当你过早发送(例如在数据库触发器内)时会出问题:支付步骤写入 orders 行,触发器发邮件,然后随后捕获付款失败。现在你有一封“感谢下单”的邮件对应一个并未真实生成的订单。

相反的问题是:送货状态变为 “Out for delivery”,你调用 SMS 提供方,但提供方超时了。你不知道是否发送成功。如果你立刻重试,就有两条短信的风险;如果你不重试,就可能一条也没发出。

更安全的流程使用 outbox 记录加后台 worker。应用提交订单或状态变更,并在同一事务中写入一条 outbox 行,如“向用户 Y 发送模板 X,渠道 SMS,幂等键 Z”。只有在提交后 worker 才会投递消息。

简单时间线如下:

  • 支付成功,事务提交,确认邮件的 outbox 行保存。
  • worker 发送邮件,然后把 outbox 标记为已发送并记录提供方消息 ID。
  • 送货状态变化,事务提交,SMS 更新的 outbox 行保存。
  • 提供方超时,worker 把 outbox 标记为可重试并在稍后使用相同幂等键再次尝试。

在重试时,outbox 行是单一真相。你不是创建第二个“发送”请求,而是在完成第一个请求。

对于支持团队来说这也更清晰。他们可以看到卡在 failed 的消息及最后错误(超时、坏号码、被拦截)、尝试次数,以及是否安全重试而不会重复发送。

下一步:选择一个模式并干净地实现它

选定一个默认并把它写下来。行为不一致通常来自于随意混合触发器与 worker。

从小处开始:做一个 outbox 表和一个 worker 循环。第一个目标不是速度,而是正确性:存储你打算发送的内容,在提交后发送,并且只有在提供方确认时才把它标记为已发送。

一个简单的上线计划:

  • 定义事件(order_paidticket_assigned)以及它们可使用的渠道。
  • 添加 outbox 表,字段包括 event_id、recipient、payload、status、attempts、next_retry_at、sent_at。
  • 构建一个轮询待处理行、发送并在一个地方更新状态的 worker。
  • 为每条消息增加幂等性,通过唯一键实现“如果已发送则不做任何事”。
  • 把错误分为可重试(超时、5xx)与不可重试(坏号、被拦截、硬退信)。

在你扩大吞吐量之前,先添加基本可见性。跟踪 pending 数量、失败率和最旧 pending 的年龄。如果最旧的 pending 一直增长,说明可能有 worker 卡住、提供方故障或逻辑错误。

如果你在 AppMaster (appmaster.io) 上构建,这个模式映射得很清晰:在 Data Designer 中建模 outbox,在一个事务中写业务变更与 outbox 行,然后在单独的后台进程中运行发送与重试逻辑。这种分离就是当提供方或部署出现问题时保持通知投递可靠的关键。

常见问题

我应该为通知使用触发器还是后台 worker?

后台 worker 通常是更安全的默认选择:发送是慢且易出错的环节,而 worker 天生支持重试与可观测性。触发器虽然即时,但与触发它的事务或请求耦合紧密,导致失败与重复难以干净处理。

为什么在数据库提交前发送通知有风险?

这是危险的,因为数据库写入仍可能回滚。你可能会把订单、密码更改或付款的通知发给用户,但这些变更实际上并未提交;而电子邮件或短信无法撤回。

从数据库触发器发送的最大问题是什么?

触发器在相同事务内运行。如果它调用邮件/短信提供方然后事务失败,你可能已经发送了关于并未生效的变更的真实消息;或者因为外部调用慢导致事务被阻塞。

用通俗的话说,什么是 outbox 模式?

Outbox 模式是把发送意图作为一条行保存到数据库,与业务变更在同一事务内提交。提交后,worker 读取待处理 outbox 行,发送消息并标记为已发送,从而让时序和重试更安全。

当邮件/短信提供方请求超时时我该怎么办?

通常这类情况的真实结果是“未知”,而非明确失败。合理的做法是记录这次尝试,把它标记为待确认,使用相同消息标识安全地延迟重试或查询提供方交付状态,而不是立即再发一次以免重复。

重试发生时如何防止重复的邮件或短信?

使用幂等性:为每条通知指定一个稳定键,代表消息的含义(而不是尝试的时刻)。把该键存入账本(通常是 outbox 表),并确保每个键只有一个活动记录,这样重试会完成同一条消息而不是创建新消息。

我应该重试哪些错误,哪些视为永久问题?

对临时错误(超时、5xx、限流)重试并使用延迟;对永久错误(无效地址、被拦截、硬退信)不要重试,应标记为失败并让人工或流程修正数据。

后台 worker 如何处理在发送过程中重启或崩溃的情况?

worker 可以扫描那些在 sending 状态且超过合理超时时间的作业,把它们标记回可重试状态并在退避策略下重试。前提是每个作业都记录了状态(尝试次数、时间戳、最后错误)并且幂等性保证不会重复发送。

我需要哪些作业数据才能使通知发送可观测?

能让你回答“现在重试是否安全”。存储清晰的状态如 pendingprocessingsentfailed,以及尝试次数与最后错误。这会让支持与调试变得可行,并让系统在不猜测的情况下恢复。

我如何在 AppMaster 中实现该模式?

在 Data Designer 中建模 outbox 表,在一次事务中写入业务更新与 outbox 行,然后在单独的后台进程中运行发送与重试逻辑。保持每条消息的幂等键并记录尝试,这样部署、重试与 worker 重启就不会产生重复。

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

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

开始吧