news 2026/7/15 5:28:25

Go面试官:如何设计百万级别的消息推送系统?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 里可以通过 HPA(Horizontal Pod Autoscaler)基于队列长度来实现。

第三轮:让重试变得无害

面试官问了一个很刁钻的问题:“如果一个 Worker 在发送成功之后、确认消息之前崩溃了,会发生什么?”

答案是:消息会被重新投递,用户会收到两条通知。

这不是理论上的风险,这是分布式系统里每天都在发生的事。

解决这个问题的方法,是引入幂等性。在发送之前,记录这条通知的唯一 ID:

typeNotificationstruct{IDstring// 全局唯一UserIDstringDeviceTokenstringMessagestring}func(w*PushWorker)sendWithIdempotency(notif Notification)error{// 尝试在 Redis 中记录这条通知的 ID,TTL 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)}

SetNX(Set if Not eXists)是 Redis 实现分布式锁和幂等性最常用的原子操作。有了它,无论重试多少次,用户都只会收到一次通知。

第四轮:批量发送——把 100 万变成 2000

面试官又抛出一个问题:“如果你的推送服务,每次调用只能发一条,100 万次调用光网络开销就很大了。怎么办?”

答案很简单:批量发送。

大多数推送服务都支持批量 API:

func(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)存在的意义。

constMaxRetries=5func(w*PushWorker)processWithDLQ(notif Notification){err:=w.sendWithIdempotency(notif)iferr==nil{return}retries:=w.retryStore.Increment(notif.ID)ifretries>MaxRetries{// 放入死信队列,等待人工处理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)lastID=users[len(users)-1].ID// 控制生成速率,避免瞬间洪峰time.Sleep(100*time.Millisecond)}}

这样,即使是 1 亿用户,系统也不会一次性扛下巨大的内存和网络压力,而是像“水龙头”一样,平稳地把任务释放到队列里。

结语:那次面试教会我的事

那次面试,我大概没能拿到 offer。但那个下午,那个面试官追问的每一个问题,都在我后来的职业生涯里反复回响。

他让我明白了一件事:设计大规模系统,不是设计一个“能处理 100 万请求”的系统,而是设计一个“在面对 100 万个不确定性时,依然能保持稳定”的系统。

以下是那次面试之后,我每次设计推送系统时都会带上的清单:

  1. 异步解耦:别让 HTTP 请求等 Worker 处理完。
  2. 限流消费:用rate.Limiter保护下游服务。
  3. 幂等性:用SetNX或数据库唯一键让重试无害。
  4. 批量发送:减少网络调用,提升吞吐量。
  5. 死信队列(DLQ):给“坏消息”一个妥善的归宿。
  6. 动态分页:别一次性生成所有任务,面对海量用户时尤是如此。

这些思想,不只在“推送”这个场景有用。在支付系统、订单系统、数据同步系统里,它们同样适用。

如果你正在准备一场系统设计的面试,我希望这个故事能帮你少走一些弯路。毕竟,面试官真正想看的,不是你背了多少“最佳实践”,而是你面对一个“简单问题”时,能不能看到它背后的“复杂现实”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/15 5:28:18

研0day2----联邦学习论文一篇

开启看第二篇论文,感觉对联邦学习有一点感兴趣 先看了一个50分钟的课,讲联邦学习 联邦学习分布式加密技术 同态加密: Ftop-k 梯度欠账本”。这一轮没上传的小梯度先欠着,下一轮继续累积,直到它的重要性足够大&…

作者头像 李华
网站建设 2026/7/15 5:28:03

从零构建生信分析基石:参考基因组与注释文件权威指南与实战下载

1. 参考基因组与注释文件:生物信息学的基石刚接触RNA-seq或重测序项目时,我完全被各种数据库和版本搞晕了。参考基因组就像一本用ATCG四个字母写成的天书,而注释文件就是解读这本天书的字典。没有它们,后续的分析工作根本无法开展…

作者头像 李华
网站建设 2026/7/15 5:27:34

从根源到修复:深入剖析HTTP 40X与50X状态码的实战诊断指南

1. HTTP状态码基础:40X与50X的定位与区别HTTP状态码是服务器对客户端请求的响应标识,用三位数字表示请求的处理结果。40X和50X这两类状态码分别代表了客户端错误和服务器错误,是开发运维人员日常最常遇到的故障信号。先说40X系列,…

作者头像 李华
网站建设 2026/7/15 5:27:11

免费AI图像视频增强神器:Waifu2x-Extension-GUI完整使用指南

免费AI图像视频增强神器:Waifu2x-Extension-GUI完整使用指南 【免费下载链接】Waifu2x-Extension-GUI Video, Image and GIF upscale/enlarge(Super-Resolution) and Video frame interpolation. Achieved with Waifu2x, Real-ESRGAN, Real-CUGAN, RTX Video Super …

作者头像 李华
网站建设 2026/7/15 5:26:26

FreeRTOS操作系统

一、任务创建创建任务可以分为动态创建、静态创建。核心区别就在于任务控制块(TCB)和堆栈的内存由谁分配。动态创建 xTaskCreate() 内存由 FreeRTOS 在运行时自动从堆(heap)里分配。静态创建 xTaskCreateStatic() 内存由你自己预先…

作者头像 李华
网站建设 2026/7/15 5:25:54

Matlab 之数据分布拟合:从交互式 APP 到编程实战

1. 数据分布拟合入门:为什么需要它?第一次拿到一组数据时,我常常会盯着屏幕发呆:这些数字到底遵循什么规律?是正态分布还是指数分布?这时候数据分布拟合就成了我的救命稻草。简单来说,分布拟合就…

作者头像 李华