多提供商通知服务内部:队列、备用方案与信任机制
一种可靠通知服务的设计详解:凭证存储、服务提供商备用方案、BullMQ重试与错误队列机制、租户公平性保障以及Webhook签名验证。
来源: Building NotifyKit: The Engineering Behind Reliable Notifications
发送电子邮件或短信看似只是简单的一步操作:调用服务提供商的API即可。但在实际应用中,这单一的send()调用会悄然演变成一个分布式系统问题,因为服务提供商可能会超时、限制发送频率、断线、乱序投递,而重试操作还可能导致消息重复。本文将剖析一个基于Node.js、BullMQ和Redis构建的开源多租户通知服务的架构,涵盖七个方面:密钥管理、服务提供商故障处理、队列设计、消息排序、投递可靠性、租户公平性以及webhook验证。针对每一方面,文章都会说明相关决策、背后的理由以及明确接受的局限性,以便您在自己的系统中复用这些思路。
两种类型的密钥,两种存储策略
一个通知平台会持有多组凭证,对于每组凭证而言,关键问题很简单:系统将来是否需要恢复其原始值?
服务提供商密钥:需进行加密,因为必须能够找回它们
客户会自带其电子邮件服务提供商的API密钥,平台在每次发送消息时都必须将这些密钥提交给相应的服务提供商。这就排除了使用哈希算法的可能,因为密钥必须能够被恢复。设计流程首先会验证每组密钥是否属于对应的提供商,然后在存储之前用AES-256-GCM对其加密。GCM是一种带认证功能的加密模式,因此除了保障保密性外,还能检测到数据是否被篡改:如果密文被改动,就无法解密,而不会生成看似合理但实际上错误的凭证。
明文永远不会接触到数据库、日志或缓存。它仅在两个地方被解密:一是在控制面板中显示带掩码的预览内容,二是在发送任务处理程序中,在向外部服务发起请求之前。将解密操作限制在几条明确的代码路径上,使得意外泄露的情况更易于审计。
平台发放的 API 密钥:使用哈希值,因为只需进行验证
平台向其客户发放的 API 密钥则遵循相反的逻辑。系统无需恢复这些密钥,只需确认所呈现的密钥是真实的即可。因此,密钥在创建时仅显示一次,只有其 SHA-256 哈希值会被保存下来。每次收到请求时,都会对传入的密钥进行哈希处理,并与存储的哈希值进行比对。丢失的密钥无法找回,必须重新生成。
此处适合使用如SHA-256这样的快速哈希算法,因为密钥是长度较长且熵值较高的随机字符串。而对于人类输入的密码而言,这种选择则是错误的,因为后者需要bcrypt、scrypt或Argon2这类刻意设计得较慢的算法来防止被猜测。
Webhook签名密钥:介于两者之间
用于签署外出Webhook的密钥由平台生成,类似于API密钥,但与提供商密钥类似,每次签署Webhook时都需要其原始值。因此,该密钥在静态状态下会用AES-256-GCM进行加密,在每次签名操作时再临时解密,并且仅在使用期间保留在内存中。
通过有序回退机制应对不可靠的提供商
任何依赖单一服务提供商的系统都会受到该提供商的故障、流量限制和超时问题的影响。该服务通过为每位客户设置优先级列表来降低此类风险。客户可以注册多个提供商,例如 SendGrid、Resend 和 Postmark,并为它们排序。当发送请求未指定提供商时,系统会按照优先级顺序从列表中选择下一个提供商进行尝试。
系统会按顺序逐一测试这些提供商。如果某个提供商因停机、流量限制或响应过慢而失败,系统会记录该故障并尝试下一个提供商。一旦有提供商成功处理请求,流程即终止,除非所有提供商都失败,否则客户不会察觉到备用机制的启动。
客户还可以将消息固定发送给某个特定的提供商,同时可选地指定一个备用提供商;在这种情况下,系统只会尝试这对指定的提供商。并非所有错误都会被同等对待:
- 永久性故障,如凭证被拒绝或请求格式错误,会立即导致失败,因为重试无法解决这些问题。
- 临时性故障,如超时、速率限制以及服务提供商的5xx响应,会通过延迟重试的方式进行处理。
当整个列表都出现故障时,错误会上传至队列,由队列自身的重试策略来处理。回退机制并不能消除服务提供商的故障,它只是为每条消息提供更多成功的机会,从而避免故障在客户端显现。
有一个需要考虑的细节:超时并不代表服务提供方拒绝了消息。服务提供方可能接受了请求但未能及时回复,此时采取备用方案可能会导致同一通知被发送两次。通常的处理方法是在服务提供方支持时传入幂等键,或对无关紧要的消息容忍偶尔出现的重复。我们在关于Node.js POST端点幂等键的文章中对此进行了深入探讨。
队列架构
在触发请求的 API 请求中直接发送通知,会导致调用方的延迟与最慢的服务提供商绑定。如果某个服务提供商响应迟缓或不可用,客户的 HTTP 请求就会一直挂起,直到最终成功处理或超时。该服务通过将请求接收与通知发送分离来避免这一问题。
请求与通知发送的解耦
API 仅负责验证请求、在数据库中创建 Job 行并将任务加入队列。Redis 上的 BullMQ 负责调度工作,每种类型的工作都有对应的队列:
notifications-email用于发送给客户的邮件notifications-webhook用于发送给客户的网络钩子通知platform-email用于服务自身的内部警报notifications-failed作为那些已用尽重试次数的任务的死信队列
独立的队列意味着某一种类型的工作出现延迟或需要调整时,不会影响其他类型的工作。如需更深入了解 BullMQ 的任务设计,可参阅使用 BullMQ 和 Redis 构建可靠的后台任务系统。
重试次数针对的是整个处理流程,而非单个提供者
每项任务有三次尝试机会,采用指数退避策略,而关键在于“一次尝试”的具体含义。它并非指每个服务提供商各试一次,而是一次按优先级顺序遍历所有客户的服务提供商,一旦首次成功即停止。如果某次遍历中所有提供商都失败,则该任务使用掉一次尝试机会,接下来的重试会从优先级最高的提供商开始。只有经过三次失败后,该任务才会连同错误信息一起被放入死信队列,而原始任务状态会被标记为FAILED。
即便队列本身出现故障,数据库依然是事实的来源。无论是客户端的Job,还是用于内部邮件的PlatformEmailLog,在进入队列之前都会被标记为PENDING状态。如果无法连接到Redis或添加任务时发生错误,系统会记录该错误并反馈失败信息,同时将对应行更新为FAILED状态并注明原因。没有任何请求会在API与队列之间悄无声息地消失。
死信队列的用途
当某个任务尝试了三次仍失败时,其完整内容、错误信息以及时间戳会被保存到notifications-failed中,同时数据库中的记录也会被设置为FAILED状态并注明相同原因。之所以要重复记录一次失败信息,是因为这两类记录的服务对象不同:
- 数据库中的记录面向客户,用于支持
GET /jobs/:id接口。 - DLQ中的记录则面向操作员,是可供监控并采取相应措施的一次性副本。
只有有人持续关注,DLQ才有价值。每有一项任务到达,就会触发Slack警报,该警报机制实际上复用了之前为平台邮件发送失败而建立的报警路径。这样一来,队列就变成了一个早期预警信号:在短时间内有大量任务被标记为死信,通常意味着存在系统性问题,比如服务提供商中断或部署失败。
没有任务会自动从DLQ中移除。只有当客户调用POST /jobs/:id/retry接口时,该死信任务才会被重新尝试处理,此时其状态会重置为PENDING,流程也会重新启动。
优先级与并发性
任务具有三种优先级之一:CRITICAL、NORMAL 或 LOW,这些优先级与 BullMQ 的原生优先级支持相对应。免费套餐的流量始终被设置为 LOW,以避免与付费流量竞争。平台发送的邮件则完全不受此限制:它们始终以 CRITICAL 状态入队,并采用更短的延迟策略。
工作进程池会持续拉取任务,每个进程最多同时处理五个任务,因此每个池可同时处理多达五个任务。
排序机制:刻意设置的间隔
该服务无法保证按客户顺序处理请求。为提升吞吐量,系统会调整并发性和优先级,因此在有五个并行处理任务且支持重试的情况下,按顺序发送的两条消息很可能会以相反的顺序被送达。对于大多数通知而言,由于它们彼此独立,这种情况并无危害。只有那些存在逻辑关联、顺序具有重要意义的消息对才会出现问题,比如按理说应该先发送的一条消息后却收到了另一条。
通常有两种解决方案,但每种都有其代价:
- 按客户进行序列化处理。通过单一通道处理某客户的任务,从而保证顺序,但代价是该客户无法利用并行处理能力。
- 让消息具备顺序容错性。为消息添加时间戳,由接收方自行判断顺序,这样责任就转移到了客户如何解读消息送达顺序上。
这一限制早已为人所知,且人们为提升传输效率而接受了它。目前的使用情况尚不足以证明其成本合理性,因此决策被推迟,待真实的客户数据表明哪种权衡更合适后再做决定。明确记录此类问题往往比仅解决一半问题更好。
交付能力:在发送前后防止故障
根据发件人的信誉以及随时间积累的各种信号,邮件可能会被退回、归类为垃圾邮件或消失不见。该服务从两个层面解决这一问题。
发送前:域名验证
当客户希望从自己的域名发送邮件时,该服务会将该域名在客户选定的服务商处注册,并获取能证明所有权的DNS记录。不同服务商的这些记录格式各异,因此会展示给客户以便其在自己的DNS服务器上添加。在验证通过之前,从该域名发送邮件的功能仍会被限制。
邮件发送后:处理服务商反馈的事件
服务商通过webhook上报邮件的送达情况、打开率、点击率、退信情况以及垃圾邮件投诉。该服务会存储这些事件,并根据不同类型采取相应处理措施:
- 硬退信(地址不存在或被永久拒绝接收)会自动屏蔽该收件人,之后发往该地址的邮件在联系任何服务商之前就会被阻止。
- 垃圾邮件投诉也会触发同样的屏蔽措施。
规则是只有表明存在永久性问题的信号才会导致地址被封禁。这虽然不能彻底解决邮件送达问题——因为信誉的增减取决于诸多超出单条邮件范畴的因素——但可以防止系统反复向已知无法送达的地址发送邮件,而这本身也是损害信誉的因素之一。
防止租户之间互相影响
所有客户共享相同的队列、工作进程池和 Redis 实例;没有任何物理上的隔离。公平性是通过在不同阶段实施的三种控制措施来保障的。
第一道关卡:API 级别的每客户速率限制
在请求被转为任务之前,系统会先根据套餐类型检查其在60秒时间窗口内每个客户的Redis限流阈值:
- 免费套餐:每分钟5次请求
- 独立开发者套餐:每分钟50次请求
- 初创企业套餐:每分钟200次请求
若某个账户的请求量突然激增,会在边缘层被拒绝,从而不会涌入队列。
第二道关卡:任务接受后的优先级处理
已被接受的请求会按优先级进行处理,免费套餐的请求始终属于低优先级。工作节点会优先处理高优先级的任务,因此即使免费账户有任务积压,付费客户的通知也会先得到处理。
第三道关卡:月度配额限制
从更长期的角度来看,还有配额限制。免费套餐每月允许发送100次邮件和webhook请求。付费套餐仅对webhook请求有限制,邮件发送则没有限制。
还有哪些缺失的功能
不存在严格的资源隔离机制:没有为每个客户分配专用工作进程。处于同一优先级级别的两个客户会共享相同的处理能力,而在同一级别内,任务大致按照先到先得的原则处理。在目前的业务量下,这并未造成问题,因为流量仍在共享基础设施的承载范围内,且没有某个客户明显拖慢了其他客户的处理速度。但随着规模扩大,就需要实现按客户隔离或采用公平的调度机制(例如在相同优先级级别内按轮询方式分配任务)。
双向验证 webhook
每个集成服务提供商都会通过 webhook 发送各种事件:投递确认、邮件退回通知、垃圾邮件报告以及支付状态变更。每个接收端点都是一个公共 URL。由于无需验证发送方身份,任何人都可以发布虚假的“已投递”事件,伪造邮件退回信息以屏蔽他人的地址,或者最恶劣的是伪造支付事件,将订阅状态标记为已付款。
入站消息:每个提供商采用一种方案
由于没有统一标准,每项集成都需要单独进行安全处理:
- SendGrid 使用 ECDSA 对数据包进行签名,在信任该数据包之前会先用 SendGrid 的公钥对其签名进行验证。
- Resend 通过 Svix 发送 webhook,Svix 采用带时间戳的 HMAC 方案,可防止数据被篡改或重复使用。
standardwebhooks 库来进行验证。Postmark 的实现方式存在一个容易被忽视的隐患:必须以恒定时间复杂度来比较密钥,例如使用 Node 的 crypto.timingSafeEqual 方法,而不能采用普通的字符串相等性检查。普通比较一旦发现字符差异就会立即返回结果,而这些时间差会让攻击者能够逐个字符恢复密钥,而无需对整个密钥值进行暴力破解。
这些方案彼此之间无法互换,每次集成都需要从零开始学习和实现。所有方案共有的原则是:在验证消息来源之前,不会对任何到达 webhook 端点的消息采取任何操作。
出站:让客户信任平台
当服务将 webhook 发送到客户端点时,也会出现同样的问题。如果客户配置了签名密钥,那么每次发送都会包含 X-Webhook-Timestamp 标头和 X-Webhook-Signature 标头。该签名是通过字符串 timestamp.payload 计算得出的 HMAC-SHA256 值,用于将消息内容与特定时间点关联起来。接收方会重新计算该签名,并拒绝过时的时间戳,从而防止被截获的请求在之后被重复使用。
客户可以完全掌控自己的密钥:他们可以生成密钥、查看其状态、更换密钥或删除密钥。即使没有配置密钥,webhook 仍然会发送,只是没有签名。这有利于向后兼容,但也意味着接收方必须主动选择验证才能获得保护。如需了解接收端的更多信息,请参阅理解 Node.js POST 接口中的幂等性键。
最小回退,然后校验签名
deliver 按顺序尝试供应商,返回第一个接受消息的供应商。整次任务的重试属于队列,不属于这个循环。
type Provider = {
name: string;
send: (to: string, body: string) => Promise<void>;
};
const deliver = async (providers: Provider[], to: string, body: string) => {
const errors: string[] = [];
for (const provider of providers) {
try {
await provider.send(to, body);
return provider.name;
} catch (err) {
errors.push(`${provider.name}: ${(err as Error).message}`);
}
}
throw new Error(`all providers failed\n${errors.join('\n')}`);
};
签名覆盖 timestamp.payload。接收方拒绝错误的 MAC,也拒绝超过五分钟的时间戳。
import { createHmac, timingSafeEqual } from 'node:crypto';
const sign = (secret: string, timestamp: string, payload: string) =>
createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
const verify = (
secret: string,
timestamp: string,
payload: string,
signature: string,
) => {
const ageMs = Math.abs(Date.now() - Number(timestamp));
if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return false;
const expected = Buffer.from(sign(secret, timestamp, payload));
const received = Buffer.from(signature);
if (expected.length !== received.length) return false;
return timingSafeEqual(expected, received);
};
关键要点
- 根据是否需要恢复原始值来决定采用加密还是哈希处理。
- 将服务提供商的备用机制与队列重试视为独立的层次,并明确界定“一次尝试”的具体含义。
- 在将请求加入队列之前先写入持久化的数据库记录,以避免队列故障导致请求丢失。