Go面试官:如何设计百万级别的消息推送系统?
两年前我去一家做海外电商的公司面试。面试官是位看起来比我大不了几岁的技术负责人话不多但问问题的方式很特别。他递给我一张纸上面写着一行字“午夜 12 点给 100 万用户发一条推送。你怎么设计”我心想“这题我准备过Kafka 嘛。” 于是开始流畅地讲我的方案用消息队列解耦、异步消费、多个 Worker 并发处理……他听完没有点头也没有摇头只是接着问“如果 Firebase 突然对你们限流了怎么办”“如果一个 Worker 发完消息但还没来得及确认就挂了用户收到两条推送怎么办”“如果市场部说这次要发 1 亿用户你的方案还成立吗”我愣在那里。我准备了一个“答案”但我的“答案”在真正的“问题”面前显得那么单薄。那次面试我大概没通过。但那次对话让我彻底改变了对“大规模系统”的理解。今天我想用 Go 语言把那次面试里学到的设计思想重新讲一遍。第一轮别把请求和推送绑在一起面试官的第一个问题其实是考察我是否理解“同步 vs 异步”的边界。我当时的代码其实写了类似这样的东西funcSendCampaignHandler(w http.ResponseWriter,r*http.Request){users:getAllUsers()for_,user:rangeusers{pushService.Send(user.DeviceToken,Black Friday Sale!)}}这段代码的问题非常明显它把一次 HTTP 请求的生命周期和 100 万次外部调用的生命周期强行绑在了一起。结果就是请求超时、内存爆炸、服务不可用。正确的第一步是解耦。typeCampaignJobstruct{IDstringMessagestringUserIDs[]stringCreatedAt time.Time}funcSendCampaignHandler(w http.ResponseWriter,r*http.Request){varreq CampaignRequest// 解析请求...job:CampaignJob{ID:uuid.New().String(),Message:req.Message,UserIDs:req.UserIDs,}// 放入队列立即返回producer.Produce(job)w.WriteHeader(http.StatusAccepted)}这个改动看似简单但它改变了整个系统的性质推送的“命令”和“执行”被彻底分离了。API 层只需要保证“任务被可靠地记录下来”而不用关心它什么时候完成。第二轮Worker 池——控制节奏而不是被节奏控制面试官接着问“如果你的 Worker 消费太快把下游的推送服务打死了怎么办”这个问题让我意识到有队列还不够消费速率必须可控。在 Go 里我们可以用rate.Limiter来精确控制每个 Worker 的消费速度typePushWorkerstruct{Limiter*rate.Limiter// 控制 QPSClient*push.Client}func(w*PushWorker)Run(){formsg:rangeconsumerChannel{// 等待令牌如果没有则阻塞err:w.Limiter.Wait(context.Background())iferr!nil{continue}w.send(msg)}}更重要的是Worker 的数量应该是可动态调整的。当队列深度增加时我们可以自动扩缩 Worker 实例——这在 Kubernetes 里可以通过 HPAHorizontal Pod Autoscaler基于队列长度来实现。第三轮让重试变得无害面试官问了一个很刁钻的问题“如果一个 Worker 在发送成功之后、确认消息之前崩溃了会发生什么”答案是消息会被重新投递用户会收到两条通知。这不是理论上的风险这是分布式系统里每天都在发生的事。解决这个问题的方法是引入幂等性。在发送之前记录这条通知的唯一 IDtypeNotificationstruct{IDstring// 全局唯一UserIDstringDeviceTokenstringMessagestring}func(w*PushWorker)sendWithIdempotency(notif Notification)error{// 尝试在 Redis 中记录这条通知的 IDTTL 7 天ok,err:w.redis.SetNX(notif.ID,processed,7*24*time.Hour).Result()iferr!nil{returnerr}if!ok{// 已经处理过了直接跳过returnnil}// 真正发送returnw.Client.Send(notif.DeviceToken,notif.Message)}SetNXSet if Not eXists是 Redis 实现分布式锁和幂等性最常用的原子操作。有了它无论重试多少次用户都只会收到一次通知。第四轮批量发送——把 100 万变成 2000面试官又抛出一个问题“如果你的推送服务每次调用只能发一条100 万次调用光网络开销就很大了。怎么办”答案很简单批量发送。大多数推送服务都支持批量 APIfunc(w*PushWorker)sendBatch(notifications[]Notification)error{batch:make([]push.BatchItem,len(notifications))fori,n:rangenotifications{batch[i]push.BatchItem{DeviceToken:n.DeviceToken,Payload:n.Message,}}_,err:w.Client.SendBatch(batch)returnerr}假设一次批量最多发 500 条100 万条通知就只需要 2000 次网络调用。这不仅仅是“快”的问题它极大地降低了 API 限流的概率。第五轮处理失败——死信队列DLQ面试官接着问“如果一个设备 Token 是无效的你重试多少次也没用。这种消息怎么处理”这就是死信队列Dead Letter Queue, DLQ存在的意义。constMaxRetries5func(w*PushWorker)processWithDLQ(notif Notification){err:w.sendWithIdempotency(notif)iferrnil{return}retries:w.retryStore.Increment(notif.ID)ifretriesMaxRetries{// 放入死信队列等待人工处理w.dlqProducer.Send(notif)return}// 指数退避重试time.Sleep(time.Duration(math.Pow(2,float64(retries)))*time.Second)w.processWithDLQ(notif)}DLQ 是一个非常重要的“安全网”。它保证了系统不会因为一小部分“坏消息”而被卡住也让运维团队有一个地方去排查那些永远发不出去的推送。第六轮如果目标是 1 亿用户呢面试官最后抛出的问题是最有杀伤力的“如果这次不是 100 万而是 1 亿用户呢你的 Kafka 里要放 1 亿条消息吗”这是一个关于架构可扩展性的问题。答案是不要一次性生成所有任务而是动态分页生成。funcGenerateCampaignJob(campaignIDstring){pageSize:10000lastID:for{users:getUserBatch(lastID,pageSize)iflen(users)0{break}job:CampaignJob{ID:uuid.New().String(),UserIDs:users,}producer.Produce(job)lastIDusers[len(users)-1].ID// 控制生成速率避免瞬间洪峰time.Sleep(100*time.Millisecond)}}这样即使是 1 亿用户系统也不会一次性扛下巨大的内存和网络压力而是像“水龙头”一样平稳地把任务释放到队列里。结语那次面试教会我的事那次面试我大概没能拿到 offer。但那个下午那个面试官追问的每一个问题都在我后来的职业生涯里反复回响。他让我明白了一件事设计大规模系统不是设计一个“能处理 100 万请求”的系统而是设计一个“在面对 100 万个不确定性时依然能保持稳定”的系统。以下是那次面试之后我每次设计推送系统时都会带上的清单异步解耦别让 HTTP 请求等 Worker 处理完。限流消费用rate.Limiter保护下游服务。幂等性用SetNX或数据库唯一键让重试无害。批量发送减少网络调用提升吞吐量。死信队列DLQ给“坏消息”一个妥善的归宿。动态分页别一次性生成所有任务面对海量用户时尤是如此。这些思想不只在“推送”这个场景有用。在支付系统、订单系统、数据同步系统里它们同样适用。如果你正在准备一场系统设计的面试我希望这个故事能帮你少走一些弯路。毕竟面试官真正想看的不是你背了多少“最佳实践”而是你面对一个“简单问题”时能不能看到它背后的“复杂现实”。

相关新闻