一百万条通知,不该压垮你的 Go Server

周五下午四点,运营发来一条消息:

晚上八点活动开始,要给一百万名用户发推送,最好几分钟内到。

会议室里安静了几秒。

有人说,Go 很适合高并发,多开一些 goroutine 就行。另一个人提议直接上 Kafka,再加几百个 Worker。还有人担心 Firebase 会不会限流。

这些答案都不算错,但顺序错了。

在讨论队列和 Worker 之前,应该先问一句:“几分钟”到底是多少分钟?

先算吞吐

一百万条通知,三十分钟发完,平均每秒需要发送 556 条;十分钟发完是每秒 1,667 条;一分钟发完,则要达到每秒 16,667 条。

目标时间 平均吞吐
30 分钟 556 条/秒
10 分钟 1,667 条/秒
1 分钟 16,667 条/秒
10 秒 100,000 条/秒

这张表会改变整个方案。

如果目标是十分钟,普通的 PostgreSQL、几个 Go Worker,再加供应商的批量接口,往往已经够用。如果目标是十秒,问题很可能不在 Go,也不在数据库,而在 Firebase、APNs、邮件或短信服务商是否允许你以这个速度发送。

队列不会凭空提高下游吞吐。它只是让应用不必和下游一起窒息。

“那 API 收到请求后做什么?”有人问。

“创建一场发送活动,然后马上返回。”

API 不应该在一个 HTTP 请求里查询一百万名用户,更不应该等通知发完。它只需要验证参数,在数据库事务中创建一条发送活动和一条 Outbox 事件,然后返回 campaign_id。调用方拿到的是 202 Accepted,而不是一场持续十分钟的 HTTP 长跑。

此后,任务生成器使用用户主键做游标分页,每次读取几千名接收者,生成通知任务,再继续下一页。整个过程中,内存里始终只有一小批数据。

这比把一百万个用户塞进一个 slice,再为每个人启动一个 goroutine 朴素得多,也安全得多。

队列、限流与 Worker

假设任务已经进入队列,Worker 正常发送。突然,通知供应商开始返回 429 Too Many Requests

最危险的反应是继续扩容 Worker。

如果供应商每秒只允许一万条请求,十台机器一起冲过去,并不会得到十倍吞吐,只会更快触发封禁。此时正确的做法是降低发送速度,尊重供应商返回的 Retry-After,让队列暂时积压。

积压是队列的本职工作。只要最老任务的等待时间仍在业务允许范围内,它就不是事故。

Go Worker 要做的也不是“尽可能多地并发”,而是维持一个有上限的并发池:

领取一批任务
按供应商、租户和模板分组
等待限流令牌
调用供应商批量接口
逐条记录结果

单实例时,进程内的 Token Bucket 足够。多实例部署后,限流额度必须共享,否则十个实例各自认为每秒可以发送一千次,实际请求量就变成了一万次。这个共享限流器可以放在 Redis,也可以由一个统一的 Dispatcher 分配发送额度。

这里至少有三道闸门:供应商级限流保护外部依赖,租户级限流避免某个客户吃光全部容量,Worker 自身的并发上限则保护数据库连接池、HTTP 连接和本机资源。

幂等与重复投递

通知系统最麻烦的故障通常发生在两个动作之间。

Worker 调用供应商,供应商已经接受了请求。就在 Worker 把结果写回数据库之前,进程崩溃了。租约到期后,另一台 Worker 会重新领取这条任务。

它应该重发吗?

不重发,可能丢通知。重发,用户可能收到两次。

有一种看似干净的处理方式:发送前先往 processed_notifications 插入通知 ID,插入成功才发送,插入失败就跳过。它确实挡住了重复,却制造了更隐蔽的问题——如果记录已经插入、通知还没发出去时进程崩溃,这条通知会被永久跳过。

分布式系统里,本地数据库和外部供应商之间没有共同事务。仅凭一条本地记录,无法同时保证“不丢”和“不重”。

更诚实的目标是 At-least-once delivery:允许任务重试,通过确定性的通知 ID 和供应商幂等能力,把重复概率降到最低。

任务状态可以保持简单:

pending
sending ──────► sent
   ├──────────► retry_wait ──► pending
   └──────────► dead

Worker 领取任务时写入 lease_until。任务处于 sending,但租约过期,就说明上一个 Worker 可能已经死亡,需要重新检查或重试。

如果供应商支持幂等键,直接使用稳定的 notification_id。如果供应商提供消息查询接口,可以在重发前查询结果。如果两者都没有,就只能在“偶发重复”和“永久丢失”之间做选择。对大多数营销推送而言,偶发重复通常比永久丢失更容易修复;支付和安全通知则需要更严格的业务确认机制,不能照搬同一套容错取舍。

数据库还应设置业务唯一约束,例如同一个活动、接收者和渠道只能生成一条任务。它解决的是任务生成阶段的重复,不是假装解决跨系统的 Exactly Once。

Outbox 与任务入队

还有一种故障发生得更早。

API 已经在数据库中创建了发送活动,但还没来得及向队列发布消息,进程就退出了。数据库里看得到活动,队列里却什么也没有。

Outbox 模式处理的正是这个缝隙:发送活动和 Outbox 事件在同一个数据库事务中提交。独立的 Dispatcher 持续读取未发布事件,成功放入队列后再标记完成。即使 Dispatcher 中途退出,下次仍会继续处理。

Outbox 不能解决供应商端的重复发送,它解决的是“业务数据已经落库,但异步任务没有入队”。这两个问题常被混在一起,最后得到一张看起来很完整、实际仍会丢消息的架构图。

重试与死信

并不是所有失败都值得重试。

网络超时、供应商 5xx429 通常是暂时故障,可以使用指数退避和随机抖动重试。无效设备 Token、错误邮箱、用户退订、模板参数错误则是永久故障。反复重试这些任务,只会浪费额度并掩盖真实问题。

一个实用的重试节奏可以从几十秒开始,逐渐拉长到几分钟、几十分钟。超过最大次数后,任务进入死信状态,等待自动清理、人工检查或单独补偿。

批量接口也不能只记录“这一批成功或失败”。供应商可能接受其中大部分通知,只拒绝少量无效 Token。Worker 需要保存每一条通知的结果,否则一次局部失败会把已经成功的通知全部重发。

PostgreSQL 还是 Kafka

如果系统本来没有 Kafka,不要仅仅因为任务数量达到一百万就引入它。

对于偶发的百万级活动、分钟级完成目标,PostgreSQL 任务表配合 FOR UPDATE SKIP LOCKED,已经能提供并发领取、状态持久化和崩溃恢复。系统少一个组件,也少一套监控、扩容和故障处理逻辑。

Kafka 更适合持续高吞吐、多个下游消费者、需要分区顺序,或者一分钟以内完成百万级投递的场景。即使使用 Kafka,PostgreSQL 仍然应该保存活动和最终投递状态;队列不是业务事实来源。

一套克制的第一版,可以只有三个运行角色,而且来自同一个 Go 项目:

server api          接收请求、查询进度
server dispatcher   生成任务、发布 Outbox
server worker       限流、批量发送、重试

API 部署两个实例;Dispatcher 通过数据库锁保证同一任务不会被重复生成;Worker 根据最老待处理任务的年龄扩容。只有当多实例限流成为现实问题时,再加入 Redis。只有吞吐和消费模型确实超过 PostgreSQL 队列能力时,再换 Kafka。

上线后看什么

通知系统不能只监控 CPU 和队列长度。更有用的指标是:

  • 最老待处理任务等待了多久;
  • 每秒成功发送多少条;
  • 供应商返回 429 的比例;
  • 重试和死信比例;
  • 每个渠道的投递延迟;
  • Worker 并发和数据库连接池等待时间。

队列突然出现一百万条任务,可能只是活动刚刚开始。最老任务不断变老、发送吞吐持续下降,才说明系统真的处理不过来。

回到周五下午的会议室。

“所以我们到底要多少台机器?”

“先问运营能接受十分钟还是一分钟,再去确认供应商配额。机器数量是最后算的。”

一百万条通知听起来像一个并发问题,真正落地后却是容量、边界和失败语义的问题。Go 能轻松启动很多 goroutine,但成熟的系统知道什么时候不该启动下一个。