256条消息的缓冲,在10万用户时是保险栓,在100万用户时变成绞索。某体育直播平台的工程师团队用一场OOM屠杀学到了这个教训——他们的节点在决赛夜像僵尸一样被Linux逐个爆头。
这不是内存泄漏,是设计层面的"慢性溺水"。
系统架构白纸黑字写着标准答案:消息中心(Hub)把比分推给每个WebSocket客户端,一个带缓冲的channel防止主循环阻塞。Go语言的并发模型让这段代码看起来无懈可击。
100万用户同时在线时,"无懈可击"变成了每小时数GB的内存黑洞。
第一刀:缓冲区的复利陷阱
团队给每个客户端分配了256条消息的send channel。数学很简单:256 × 单条消息大小 × 用户量。但他们漏算了一个变量——N不是常数,是毒。
用户不会均匀消费。有人用5G看直播,有人在地铁里用3G。后者的Write()操作可能阻塞数秒,而上游的Hub还在拼命往channel里塞消息。
缓冲区的初衷是"吸收瞬时波动",实际是"为慢消费者提供无限赊账"。当10%的用户变成慢节点,系统要额外承担25.6万条消息的内存驻留——这还没算Go运行时的channel开销。
更隐蔽的是垃圾回收(GC)的报复。这些积压的消息跨越了多个GC周期,从年轻代晋升到老年代,最终把堆内存撑到触发OOM killer的阈值。节点不是"崩溃",是被操作系统合法处决。
第二刀:Write()的同步幻觉
代码里藏着一个危险假设:c.conn.NextWriter()和w.Write()是"快速操作"。网络编程的初级课本会告诉你这是错的,但面试白板上的架构图不会提醒你。
WebSocket的Write在TCP层是异步的,在应用层是同步阻塞的。当对端接收窗口归零,Write会挂起直到超时或恢复。这个"直到"在代码里看不见,在监控里表现为CPU.idle和内存.usage的诡异背离。
团队最初的排查方向是JSON序列化。他们加池子、换协议、压测CPU,全错。真正的瓶颈在几百行代码之外,在一个他们"假设很快"的函数调用里。
CPU利用率不到30%,内存却像漏水的船——这是典型的IO阻塞型饥饿。
第三刀:背压机制的集体失忆
流处理系统的第一课叫backpressure(背压):当消费者跟不上,要向上游反馈,而不是无限缓冲。这个平台的Hub没有这门课。
消息生产端(比分数据源)和消费端(用户浏览器)之间隔着一层"假装一切正常"的channel。Hub的select语句非阻塞发送,满了就丢?不,他们的实现是阻塞发送——于是整个广播循环被单个慢用户拖垮。
没有背压的广播系统,本质上是在用内存买时间。买的时间不是给用户追赶进度,是给工程师逃避"该踢掉谁"这个决策。
决赛夜的800k用户峰值把这张信用卡刷爆了。
止血方案:从"缓冲一切"到"感知一切"
团队的重构没有炫技。他们把256条固定缓冲改成动态水位监测,Write()超时从"无限"改成500ms,超时就关闭连接——慢用户被踢,但系统活下来。
更关键的改动在Hub层:引入有界广播。每个消息携带序列号,客户端ACK确认。丢包就丢包,用户端会请求重传特定区间。这相当于把TCP的可靠性机制在应用层重做一遍,但控制权握在自己手里。
Go的channel被保留,但用途变了:从"缓冲池"变成"信号量"。满channel不再堆积消息,而是触发流控,让上游降速或采样。
新架构上线后的压力测试显示,同等硬件下内存占用稳定在峰值15%,GC暂停从秒级降到毫秒级。代价是3G用户偶尔会看到"连接断开,正在重连"——这比看到白屏强。
「我们以为在优化代码,其实是在偿还设计债。」团队在后记里写道。那个256的魔数来自早期原型测试,当时100个并发用户跑得很顺,就再也没动过。
热门跟贴