
最近折腾了短信通知系统的可靠性问题,踩了几个坑,这篇把问题说清楚。
简短回答:在小规模生产级别的试用中验证了拦截机制、模板版本控制、区域投递报表和有界批量重试之后,再选 SMS API(短信应用程序接口)。
对于房产交易平台来说,需求看似简单:告诉卖家有新订单需要处理。正常运行时这条告警路径有用,主应用故障时它就成了生命线,所以 SLA(服务水平协议)不能只靠提供商的功能清单来保证。它需要一个端到端的、属于初创公司自己的目标,以及从订单创建到消息送达、投递报告或明确终态的完整证据链。
别只看纸面吞吐量。一个发得快的服务商,重试后重复发送 2000 条告警、给已拦截号码发短信、或者静默使用过期模板,都是运营倒退——尽管它的每秒请求数图表很好看。
第一个边界是订单事件。给每个订单通知分配一个稳定的幂等键,持久化目标收件人和模板版本,然后在 worker 调用任何消息 API 之前把它放入持久化队列。Worker 可能在远程接受和本地确认之间停止——这是每个演示都忽略的小间隙——所以重试必须找到同一条逻辑通知,而不是制造第二条。
当去重是显式的时候,至少一次处理是可以管理的。数据库应该在通知键上强制唯一性约束,而发送方把尝试记录与通知的最终状态分开。HTTP 202 只表示请求被接受待处理,并不能证明手机已收到。客户端超时则让远程结果变得不确定。把这两种情况当作"重新发送"会把歧义变成重复。
拦截检查应该在模板渲染之前,也应该在批量组装之前。检查应该使用标准化后的目标号码、沟通目的和当前策略版本,而不是进程启动时加载的昨天那份 CSV。这里存在一个竞态:卖家选择退出和排队的告警离开系统之间的时间差。可防御的设计是在接近发送时重新检查,并记录是哪条策略决策授权了这次尝试。US 和 EU 的具体策略会不同,我不认为在没有法律审核的情况下能制定一个单一的全球保留规则。工程团队至少可以让决策点可观测且可审计。
消息长度是另一个可靠性输入。Twilio 的字符限制文档说明了 SMS 编码和分段会改变分段中的字符数量。这意味着一个看似无害的模板编辑可能会改变分段数、延迟暴露和容量需求。锁定模板版本,用最坏情况下的卖家和房产数据渲染,然后在与应用代码相同的发布流程中检查编码后的结果。
小细节会累积。
测试工作流,不是宣传页。使用覆盖 US 和 EU 格式的预发目标集,但永远不要用真实的卖家号码做压测。先从团队自己负责的故障边界开始:重复订单事件、提供商接受后 worker 终止、延迟投递回调、新被拦截的目标、无效的模板变量,以及大于 worker 配置并发数的批量。
评估应该产生针对书面 SLA 的证据。一个有用的起始形式是"99.9% 的符合条件的卖家通知在五分钟内到达终态",其中"符合条件"、"终态"和测量窗口都有精确定义。这是一个示例目标,不是通用目标。容量规划则从平台峰值订单率、重试预算、预期分段数和最大可容忍队列年龄反推。你的情况可能不同——拍卖集中结束时脉冲式的市场和均匀分布的租金支付订单需要的容量边界完全不同。
保持供应商试用可比:
| 决策领域 | 需要收集的证据 | 拒绝或需要调查的情况 |
|---|---|---|
| 投递生命周期 | 接受、送达、失败和未知状态都绑定到同一个通知键 | 某种状态无法协调,必须靠猜测 |
| 拦截 | 最后一刻的退订阻止发送并留下审计记录 | 拦截只是手动导入的列表 |
| 模板 | 不可变版本 ID、验证过的变量和编码检查 | 模板可以在队列中的工作下发生变化 |
| 批量行为 | 每个收件人的结果、有界并发和部分重试行为 | 一个坏目标导致整批盲目重放 |
| 区域运营 | US/EU 测试目标、时间戳状态证据和文档化的数据处理 | 团队无法说明运营数据在哪里处理 |
| 运行时契合度 | 维护中的 Node.js 支持或稳定的 HTTP 契约、超时控制和测试替身 | 客户端对调用方隐藏重试和超时行为 |
三个候选可以选 Twilio、阿里云短信和腾讯云短信,但名字不应该决定结果。用同一个测试框架运行每个候选,记录观察到的行为,而不是根据营销页打分。如果现有提供商已经满足了可衡量的目标,而迁移风险超过未解决的可靠性差距,就继续用它;只有当试用暴露了重大的控制或可观测性边界时才切换。
核心接口应该把提供商语义隔离在订单服务之外。虽然 Node.js 支持属于 JavaScript 初创公司的选型矩阵,但下面的 Go worker 让可靠性契约可见:预约一个通知、重新检查拦截、渲染不可变的模板版本、发送有界的收件人批量,并为每个结果持久化。这里没有凭空发明的提供商路由;适配器实现的是试用选择的商业或自托管传输层。
package alerts import ( "context" "errors" "time"
) type Notification struct { Key string SellerID string PhoneE164 string OrderID string TemplateVersion string
} type SendResult struct { ProviderID string State string
} type Store interface { Reserve(ctx context.Context, key string, lease time.Duration) (bool, error) MarkSuppressed(ctx context.Context, key string) error MarkResult(ctx context.Context, key string, result SendResult) error Release(ctx context.Context, key string) error
} type Suppression interface { Blocked(ctx context.Context, phoneE164, purpose string) (bool, error)
} type Templates interface { Render(version string, data map[string]string) (string, error)
} type Sender interface { Send(ctx context.Context, idempotencyKey, phoneE164, body string) (SendResult, error)
} type Worker struct { Store Store Suppression Suppression Templates Templates Sender Sender
} func (w Worker) NotifySeller(ctx context.Context, n Notification) error { reserved, err := w.Store.Reserve(ctx, n.Key, 30*time.Second) if err != nil || !reserved { return err } blocked, err := w.Suppression.Blocked(ctx, n.PhoneE164, "new_order") if err != nil { _ = w.Store.Release(ctx, n.Key) return err } if blocked { return w.Store.MarkSuppressed(ctx, n.Key) } body, err := w.Templates.Render(n.TemplateVersion, map[string]string{ "order_id": n.OrderID, }) if err != nil { _ = w.Store.Release(ctx, n.Key) return err } sendCtx, cancel := context.WithTimeout(ctx, 4*time.Second) defer cancel() result, err := w.Sender.Send(sendCtx, n.Key, n.PhoneE164, body) if err != nil { _ = w.Store.Release(ctx, n.Key) return errors.New("send outcome requires reconciliation") } return w.Store.MarkResult(ctx, n.Key, result)
}
刻意让人不舒服的那行是协调错误。超时不是拒绝的证明。把那个通知置于协调状态,通过适配器的状态机制查询(如果支持的话),如果不确定性超过了通知的错误预算就告警运营。不要立即创建一个新的幂等键。
对于批量发送,并发应该是从测量容量推导出的配置上限,而不是 len(recipients) 个 goroutine。在发送前把工作拆分成可独立识别的通知,这样部分结果仍然可恢复。退避需要抖动和重试预算;永久的目标或策略失败应该成为终态,不消耗那个预算。发送适配器也是 Node.js 实现必须暴露取消、超时和原始关联标识符的地方,而不是把它们吞在自动重试后面。
预生产测试是必要的,但发布门槛需要生产信号。测量队列年龄、每个通知的尝试次数、拦截决策、模板渲染失败、提供商接受延迟、终态延迟、未知结果和重复终态投递。按区域和模板版本分割视图。避免在指标标签中使用收件人号码或渲染后的消息体;高基数个人数据是不好的可观测性设计。
使用针对通知 SLA 的燃烧率告警,而不是每次单独失败都 pagerduty。告警应该包含队列中最老的通知、受影响的区域和模板版本、最近的部署标识符,以及未知结果集合的大小。不应该在凌晨三点让值班工程师从提供商仪表板的截图重建批量。
验证流程很短:先用一个模板版本金丝雀发布,并发保持在测量限制以下,比较终态延迟和未知结果与上一版本的差异,然后分步骤扩展。注入一个重复事件并在接受边界终止一个测试 worker。确认同一个通知键存活了下来,并且任何后续尝试前都重新评估了拦截。
不玩虚的。保留证据。
回滚意味着停止通过变更后的适配器的新发送,同时保留队列中的通知身份。在把未发送的工作指向之前的传输之前,排空或协调进行中的尝试。如果两个适配器在过渡期间同时运行,它们必须共享同一个幂等账本;两个孤立的"恰好一次"声明仍然可能发送两次。
买与自建的决策本质上是一个所有权决策:
| 选项 | 合理的情况 | 问题所在 |
|---|---|---|
| 托管 SMS API | 小团队需要运营商连接,可以通过适配器验证所需控制 | 外部状态语义、数据处理和速率行为仍然是依赖 |
| 基于传输层自托管编排 | 策略、队列、模板和协调需要一致的内部所有权 | 团队拥有更多代码、存储、升级和值班负担 |
| 完全自管理消息路径 | 监管或特殊路由需要深度运营控制 | 当初创公司无法配备持续的电信运营人员时不适用 |
因此建议是有条件的。当试用满足 SLA 且适配器保留了你的控制平面时,购买传输能力;构建保护卖家信任的策略、幂等性和可观测性层。只有当文档化的需求超过额外运营负担和降低锁定的好处时才增加自托管。如果当前系统已经能在预算内产生可协调的结果,就别动它。