当IM系统撑不住了:一场从单体到微服务的游戏聊天架构演进实录
前言:那场让整个服务器"爆掉"的周末活动
事情发生在去年夏天的一个周末。我们运营策划了一场全服公会战,预期同时在线也就两三万人。结果活动开启的瞬间,IM服务的内存直接拉满,消息延迟从毫秒级飙升到十几秒,玩家的聊天消息发不出去,技能指挥全靠吼——最后活动被迫中断,运营同学在群里连发了十几个"抱歉"。
事后复盘,问题的根源很清楚:我们那个一路"打补丁"打过来的单体IM架构,终究还是扛不住了。游戏聊天和普通IM不一样,它是高并发、低延迟、消息爆发式增长的场景,对系统的要求极其苛刻。 也就是从那之后,我们开始了一场从单体到微服务的艰难演进。这篇文章记录了我们踩过的坑和最终走通的路,纯个人视角,希望能给遇到类似问题的朋友一些参考。
一、单体架构当初有多香,后来就有多痛
坦白说,项目刚开始的时候,单体架构是我们最正确的选择。一个Go写的IM服务,把网关、消息路由、会话管理、消息存储全部揉在一起,部署简单、调试方便、开发效率极高。最开始几千人同时在线的时候,单机轻松扛住,延迟控制在几十毫秒以内。
但随着用户量从几千涨到几万、再到十几万,问题开始一个一个冒出来:
首先是资源隔离的问题。 广播消息的时候CPU飙高,结果连带着用户登录认证也跟着超时。一个模块的资源争抢,影响的是整个系统的稳定性。
其次是发布风险极高。 每次发版都要停服,哪怕只改了一行日志代码,也得把整个IM进程重启一遍。而且随着代码膨胀,每次发版都像拆弹,怕改坏某个完全不相干的模块。
最致命的是无法水平扩展。 连接状态全在内存里,加机器解决不了问题——新机器不认识旧机器上的连接,玩家换个节点就收不到消息了。我们尝试做了一层简单的哈希路由,但节点扩缩容时连接大规模迁移,用户体验直线下降。
单体架构的痛,不在于它"不能扛",而在于当它扛不住的时候,你没有渐进式的应对手段,只能硬着头皮重构。 而我们当时,已经到了不得不重构的节点。
二、拆分的思路:不是"推倒重来",而是"切蛋糕"
很多团队做微服务容易走极端——要么觉得"单体还能扛,先不拆",要么一上来就照着大厂的架构图拆成几十个服务,结果还没上线先把自己搞晕了。
我们走了一条折中的路:只拆最痛的部分,保留暂时稳定的部分。
第一个拆出来的是网关层——它负责维护WebSocket长连接、接收消息、推送消息。这个模块是IO密集型的,和业务逻辑耦合度本来就不高,拆出来之后可以独立扩容,哪条线路压力大就加网关节点。
第二个拆出来的是路由层——它负责维护"用户ID→网关节点"的映射关系。这个映射需要全局一致,所以我们把它做成了一个独立服务,后端用Redis存储映射关系。网关收到消息后先问路由层"这个用户在哪台机器上",再精准投递。
消息存储、离线消息、历史记录这些模块我们没有急着拆,因为它们的访问模式相对固定,对性能的要求也不算变态,留在单体里继续跑,通过API和新的微服务交互。
这种"分阶段拆分"的策略让我们的重构压力小了很多——每次只拆一块,拆完验证稳定了再拆下一块,整个过程中系统一直在线,没有停服。
三、连接迁移:从"重启即断连"到"用户无感"
微服务拆分后,连接迁移是绕不开的坎。以前单体时代,重启就断连,玩家重连就行。但微服务架构里,节点扩缩容是常态,不能让玩家每次换个节点就收不到消息。
我们的做法是把"连接"和"会话"解耦:网关节点只负责维持TCP/WebSocket连接,连接背后的"用户身份""所在群组""未读消息"这些状态,全部保存在Redis里。当网关节点需要下线或扩容时,路由层把该节点上的用户映射重新分配,新的网关节点从Redis里恢复会话状态,用户甚至感知不到自己"被换了一台机器"。
这个过程我们踩了不少坑,最大的教训是Redis的访问延迟比想象中要高——每个消息都要查一次Redis,QPS上去之后Redis成了瓶颈。后来我们加了一层本地缓存(把高频查询的映射关系缓存在网关内存里),并配合订阅发布机制在路由关系变更时同步更新缓存,才把延迟压了回去。
四、Go语言在IM系统中的独特优势
选Go做IM系统,有几个点在实际生产中被反复验证是"真香"的。
goroutine处理海量连接,每个连接一个goroutine,几十万连接也就是几十万个轻量级协程,内存开销远小于Java的线程模型,这使得单机能支撑的连接数比传统方案高一个数量级。
channel是天然的"消息管道"。IM系统里到处都是"生产者-消费者"模式:网关接收消息→投递到处理队列→业务逻辑消费→推送给接收方。用channel做队列,用select做超时控制和多路复用,写出来的代码既简洁又高效。这些在Go里都是"原生体验",不需要额外引入消息队列框架。
GC压力可控。 IM系统中大量临时对象(消息体、解析结果等)频繁创建和销毁,如果GC频繁触发STW,延迟直接炸裂。Go的内存管理和低停顿GC在实际压测中表现可接受,配合对象池(sync.Pool)复用高频对象,GC压力降到了一个可控的范围。
五、演进后的真实收益
重构完成后,我们做了一轮压测对比。同样是5万并发用户、每秒峰值消息2万条,单体架构下CPU飙到85%、消息延迟均值为320ms且有大量超时;微服务架构下网关CPU约45%、路由服务约30%,消息延迟均值压到了45ms以内,99.9%的请求在80ms内完成。
更重要的是运维体验的质变。以前发版必须停服,现在网关和路由可以轮流灰度发布,用户完全无感。某个模块出问题,熔断降级只影响部分功能,不会把整个IM拖垮。
最大的变化是团队协作效率。 以前所有人改同一个代码库,代码冲突天天有,发版互相等。现在网关、路由、存储各有一个独立的代码仓库和发布流水线,三个小组并行开发互不干扰,周迭代频率从两次提升到了五次。
六、给同样处境的朋友几点建议
如果你们也正在考虑从单体IM拆成微服务,这几条是我们的血泪教训:
不要一上来就追求完美的服务拆分。 先拆最痛的部分(通常是网关),其他模块保持原样。每一步拆分都要有明确的"解决了什么问题"作为目标,而不是为了"架构好看"。
连接状态必须从内存中移出去。 不管是Redis还是etcd,全局状态存储是水平扩展的前提。这一步越早做,后面重构的成本越低。
压测和监控要提前到位。 微服务拆完,链路变长了,出问题更难定位。没有全链路追踪和实时监控,你连"消息丢在哪了"都查不出来。
接受不完美。 我们现在也还在演进的路上——消息存储还没拆彻底,离线消息和已读回执还在单体里挂着。但这条路走通了,剩下的只是时间问题。
写在最后
从单体到微服务,与其说是一次技术升级,不如说是一次认知升级。我真正学到的是:架构没有银弹,只有权衡。 单体有单体的好,微服务有微服务的复杂。判断什么时候该拆、拆到什么程度、用什么方式拆,比知道怎么拆更重要。
希望我们的故事,能给正在IM路上挣扎的同行一些参考。毕竟,游戏聊天这个场景,从来就不是一件容易的事。
暂无评论