基于SQS、SES和Redis构建无重复通知流程的设计
两种队列结构的SQS设计、SES到SendGrid的备用方案以及原子级Redis锁如何确保即使在高负载情况下,业务文档也能准确发送给收件人一次。
两名员工在同一时间收到相同的事件通知。两人都查看了邮件日志,未找到相关记录,于是再次发送通知。结果某供应商收到了两份相同的采购订单,而团队是通过支持工单得知此事的,并非通过监控系统。本文将介绍一个为B2B供应链平台设计的通知引擎,该平台后来每月需处理超过五百万条事件,以及影响其发展的三起生产故障:邮件丢失、生成PDF时邮件被拦截,以及重复通知问题。
该引擎需要实现的功能
在食品供应链SaaS系统中,诸如创建或编辑订单、执行库存操作等业务事件都必须为相应人员生成相应的文档:采购订单、销售订单、发货单、收货凭证以及发票。因此,一个业务事件可能会分解为多个步骤:生成PDF文件、将其存储起来,然后发送给多个收件人。同样的处理引擎还会用于其他一些场景,发送短信和WhatsApp消息。
其重要性不言而喻。未能送达的通知并非仅仅是外观上的缺陷;缺失的采购订单可能会阻碍货物运送给客户。
两个队列:一个负责决策,一个负责交付
该系统的设计基于两个功能分工明确的Amazon SQS队列。
主队列是决策产生的地方。单个消费者会读取每一个传入的事件,并判断其含义:应生成哪份文档、使用哪个通道以及由谁来接收。
子队列仅负责传递消息。传递任务不会应用任何业务规则,它们只是将消息发送给相应的处理方。
每个接收者一条消息
这种架构中最重要的决策是让每个接收者在子队列中都拥有自己的消息。一单需要送达给十个人的订单会变成十条独立的消息。如果某个地址的传递失败,其余九条消息仍会发送出去,重试时也只会针对失败的接收者,而不会重新发送给所有人。
这种分离方式还能将变更影响限制在特定范围。更换新的服务提供商或传输渠道仅会影响数据传递环节;负责决定发送内容的代码无需知晓消息的具体传输方式。
为何不为每个渠道设置一个队列?
为电子邮件、短信和WhatsApp分别设置队列,可以让每个渠道拥有独立的处理能力和故障应对机制。在目前的业务量下并无此必要,而且根据实际需求再添加队列,比从一开始就维护四个队列更为经济。
故障一:邮件始终无法送达
电子邮件服务提供商可能会出现故障,有时会抛出明显的错误信息,有时则表现为返回的响应中缺少预期字段。此处的主要服务提供商是Amazon SES,若响应中没有消息ID,则会被视为故障,与异常情况处理方式相同。无论哪种情况,都会通过SendGrid重新发送同一封邮件:
// simplified
try {
const res = await ses.send(email);
if (!res.MessageId) throw new Error("No MessageId from SES");
} catch (err) {
await sendgrid.send(email); // fallback
}
注意,当缺少 MessageId 时会显式抛出异常:这种模糊情况会被引导至与严重错误相同的备用处理路径。理解其中的权衡十分重要。如果 SES 已接收邮件但响应丢失,备用机制可以再次发送该邮件,因此这一设计更倾向于确保“至少送达一次”而非“至多送达一次”。对于业务文档而言,这通常是正确的处理方式,但这也正是下面要介绍的去重步骤至关重要的另一个原因。
当两个服务提供商都出现故障时,邮件会被放入死信队列,并在一段时间后重新尝试发送。此外,还有一个独立的重发机制用于处理那些被标记为未送达的邮件,这样任何漏掉的邮件都有途径被重新发送。
第二种故障:高负载下的 PDF 文件
邮件发送遵循严格规则:在PDF生成完成之前不得发送任何内容。收件人需要的是实际的采购订单,而非会返回错误的链接。消费者首先调用PDF生成函数,将文件写入S3,之后才发送包含该文件链接的邮件。
这一规则本身是正确的,但却造成了隐性的依赖问题。当系统出现延迟导致PDF生成失败时,邮件并不会明显出错,因为它一直在等待一个永远无法生成的文件,所以根本不会被发送。解决办法是为PDF生成过程设置独立的重试机制,这样渲染过程中的临时故障就不会再悄无声息地取消后续的通知。
第三个问题:同一封邮件被发送两次
邮件重复的问题源自三个不同的原因,每种情况都需要不同的处理方式:
- 普通的逻辑错误,通过修复漏洞来解决;
为何仅检查日志不够
从一开始,该处理流程在发送邮件前就会查看日志:如果记录显示邮件已发出,则跳过重试。这种方式很适合处理顺序重试,但无法解决竞争问题,因为读取日志和写入日志是两个独立的操作。两个处理程序都可能在对方写入之前读到“未发送”的状态,然后继续执行。SQS标准队列至少会投递一次邮件,因此同一封邮件被两个消费者收到是正常现象,并非罕见故障。
Redis中的原子锁
解决方案是在日志检查之前在 Redis 中添加原子锁。该键由订单编号、收件人邮箱地址和文档类型组合而成,大约五分钟后过期:
// simplified
const key = `notif:${orderId}:${email}:${docType}`;
const locked = await redis.set(key, "1", "NX", "EX", 300);
if (!locked) return; // someone else already has it
使用带有 NX 选项的 SET 命令只有在键尚不存在时才会写入它,而 EX 300 则在同一命令中设置300秒的过期时间。由于在 Redis 服务器上存在性检查与写入操作是同步进行的,因此只有一个工作节点会得到真值结果;其他所有工作节点都会看到 null 并提前返回。这样就弥补了日志检查留下的漏洞。
这种方案有几点特点值得注意:
- 键的粒度很重要。包含文档类型可以确保针对同一订单和收件人的采购订单与发票不会被误认为是重复项。
同样的原理也体现在 HTTP API 的幂等键中;关于 Node.js POST 接口中幂等键的文章详细介绍了这方面内容。
系统的最终状态
经过这些改进后,该系统每月可通过订单、库存和文档触发处理超过五百万条事件,数据交付成功率为 98.9%,p95 延迟时间低于 1.2 秒。
下次应采取的不同措施
- 在客户发现之前检测故障。太多问题最初以投诉形式出现,之后才出现在日志中。关于交付失败和死信队列深度的警报应包含在首个版本中,而非后续版本。
- 防止事件丢失。有些事件因某些原因而消失,且这些原因从未得到完整解释。通过事务性出队表记录事件及其对应的业务操作并在之后传递,可以大大减少事件丢失的情况。关于出队表和入队表的文章详细解释了其原理。
- 尽可能保留单一子队列。共享的交付队列目前表现良好;只有当某个渠道开始影响其他渠道时,才需要对其进行拆分。
自行构建时的检查清单
- 将路由决策与消息投递分开处理。
- 为每个接收者发送一条独立消息,避免单点故障影响整体。
- 不要依赖单一服务提供商,并将缺失的消息ID视为故障信号。
- 为失败的消息指定处理路径:放入带重试机制的死信队列。
- 在文档生成之前切勿发送相关邮件,需重新执行生成文档的步骤。
- 确保去重操作是原子的,因为先检查后写入的方式会留下漏洞。
- 及时发出故障警报,避免让客户承担监控职责。
总结
这些保护措施并非一开始就设计好的,每一种都是在生产环境中出现故障后才添加的,这实际上也是大多数交付管道成熟的过程。其中的经验教训不在于某一个具体的修复方案,而在于思维顺序:假设消息至少会被送达一次,假设服务提供者和依赖项会悄无声息地出错,因此要让每个步骤要么是可重试的,要么有原子操作作为保障。