一百万条通知,不该压垮你的 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 不能解决供应商端的重复发送,它解决的是“业务数据已经落库,但异步任务没有入队”。这两个问题常被混在一起,最后得到一张看起来很完整、实际仍会丢消息的架构图。
重试与死信
并不是所有失败都值得重试。
网络超时、供应商 5xx 和 429 通常是暂时故障,可以使用指数退避和随机抖动重试。无效设备 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,但成熟的系统知道什么时候不该启动下一个。