即时通信这套东西,外面看着简单——不就是两个人互相发消息嘛。真自己从零撸一遍,你会发现坑一个接一个:连接保活、消息不丢、乱序、群聊扩散、多端同步、离线补偿,任何一个环节处理不好,用户侧就是"消息转圈""发出去对方收不到""刷两下顺序全乱了"。我用Go写过几套不同规模的IM服务,从几百连接的内部工具到单机十万连接的对外系统都趟过一遍,踩的坑基本能凑成一本小册子。这篇就把整套"基于Golang的即时通信系统"从架构选型到落地实现、从压测数据到故障排查,按我实际的推进顺序完整拆一遍。适合正在做IM功能的后端同学、准备把轮询换成WebSocket的团队,也适合刚接触网络编程、想找一个真实项目练手的开发者——文中的代码和参数都可以直接抄去改。我不会讲太多教科书上的分层理论,重点放在"为什么这么选""这一步不做会怎样""线上炸了怎么查"这三件事上。
1. 先把需求拆开:IM系统到底在解决什么问题
动手写代码之前,我习惯先把需求翻译成技术约束。IM系统本质上只干三件事:维护海量长连接、把消息从A准确送到B、把消息可靠地存下来并能按序取回。听着像废话,但这三件事对应的技术难度和资源消耗完全不是一个量级,很多项目死在第一步就上重型分布式方案,最后连接数才几千,纯粹是自己给自己找事。
1.1 三个核心指标决定架构走向
我在做方案设计时,会先问清楚三个数字:峰值同时在线连接数、单条消息的可接受延迟、消息的持久化要求。这三个数字基本锁死了架构的复杂度上限。
- 同时在线连接数决定网关要部署几台、单机要扛多少、需不需要做连接层的水平拆分。单机几万连接和单机几十万连接,完全是两套内存模型和调度模型。
- 消息延迟决定链路要走几跳。要求百毫秒内送达的,消息路由必须尽量在内存里完成,不能每条消息都落库再查库转发;能接受秒级的,就可以放宽,用队列削峰。
- 持久化要求决定存储层的投入。聊天记录要存多久、要不要支持全文检索、要不要支持撤回后的历史追溯,直接决定了你是用一张表还是一个独立的存储体系。
我见过一个内部协作工具,日活两千人,非要上分库分表加消息队列集群,运维成本比业务本身还高。也见过一个日活十万的产品,消息只存内存加一份异步落盘,结果一次进程重启丢了半小时的消息,用户投诉到爆炸。规模匹配比技术先进性重要得多。
1.2 为什么最终落在Go上
语言选型这块我纠结过很久。早期用Java写过一版,也考虑过用Erlang那套天然适合并发的模型,最后还是落在Go,理由很实在:
第一是goroutine的调度成本。IM系统的典型特征是"海量连接、每连接低频率活动"。一条连接大部分时间在等数据,真正干活的时间极短。用操作系统线程去承载连接,几万连接就是几万个线程,上下文切换和栈内存直接把你压垮。goroutine初始栈只有几KB,按需增长,几万甚至几十万连接在内存上是可控的。我实测过一台16核32G的机器,跑8万空闲长连接,进程常驻内存大概在4G左右,这个数字是可以接受的。
第二是GC的表现。早期Go的GC确实拉胯,STW能到几百毫秒,长连接服务最怕这个——一次停顿,心跳全部超时,连接雪崩。现在版本的GC已经把停顿压到亚毫秒级,配合对象复用(后面会讲sync.Pool的用法),基本不用担心。
第三是部署和运维。编译出来一个二进制,扔上去就能跑,不用装运行时。IM服务通常要部署很多节点,这个优势在扩容时特别明显。
第四是网络库的表达力。net包足够底层,你能精确控制每一次读写;同时也足够简洁,写一个自定义协议的解析器不用绕太多弯。对比之下,用某些语言写二进制协议解析,光是字节序和缓冲区管理就能写出几百行样板代码。
注意:不要因为"Go适合高并发"就无脑上Go。如果你的团队主力是Java,生态里现成的IM中间件更多,硬切语言的迁移成本可能比性能收益更大。技术选型永远是团队能力、生态成熟度、性能需求的三角平衡。
1.3 分层设计与模块边界
我把整套系统切成五层,每层只跟相邻层打交道,这个约束在后期排查问题时价值极高——出问题时你能快速定位是哪一层的责任。
| 层次 | 职责 | 关键约束 |
|---|---|---|
| 接入层 | TCP/WebSocket连接建立、TLS握手、限流 | 不承载任何业务逻辑,只做连接生命周期管理 |
| 协议层 | 编解码、心跳、帧校验 | 纯粹的字节处理,不依赖任何上层状态 |
| 逻辑层 | 消息校验、路由决策、ACK管理 | 无状态,可水平扩展 |
| 路由层 | 在线状态查询、连接定位、消息投递 | 与接入层节点保持映射关系 |
| 存储层 | 消息持久化、会话列表、离线队列 | 只被逻辑层调用,不直接对接连接 |
这个分层里最容易出问题的是路由层和接入层的耦合。很多实现为了图省事,把"用户在哪个节点"这张表直接放在接入层的内存里,节点之间互相广播。连接数一上来,广播风暴能把整个集群拖死。正确做法是把在线路由表抽出来做成独立的共享状态(内存KV存储就够),接入层只负责写自己的连接映射,逻辑层查表投递。
还有一个边界要划清楚:协议层绝对不能知道业务字段的含义。我见过有人把"这条消息是红包还是文本"的判断写进解包函数里,后面加消息类型的时候,解包函数改得面目全非,所有客户端版本都要跟着回归测试。协议层只认帧结构,业务类型交给逻辑层解析,这条线守住,后续扩展会轻松很多。
2. 长连接网关:整个系统最容易崩的地方
网关是IM系统的心脏,也是最先撑不住的地方。业务逻辑写得再烂,最多是功能不对;网关一崩,所有人都掉线,用户侧体感是"整个应用死了"。这一层我花的时间大概占总开发时间的四成。
2.1 连接注册表的分片设计
最直观的做法是用一个全局map存"用户ID → 连接对象",配一把读写锁。这个方案在单机几百连接时没问题,几千连接开始出现锁竞争,上万连接时锁成了瓶颈——每个心跳、每条消息都要抢这把锁。
我的做法是分片。按用户ID哈希到固定数量的分片,每个分片一把独立的锁。分片数取2的幂,用位运算取模,避免除法开销。
const shardCount = 256 type Conn struct { UserID int64 ConnID string netConn net.Conn sendCh chan []byte lastPing int64 } type ConnShard struct { mu sync.RWMutex conns map[int64]*Conn } type ConnRegistry struct { shards [shardCount]*ConnShard } func NewRegistry() *ConnRegistry { r := &ConnRegistry{} for i := 0; i < shardCount; i++ { r.shards[i] = &ConnShard{conns: make(map[int64]*Conn)} } return r } func (r *ConnRegistry) shard(userID int64) *ConnShard { return r.shards[uint64(userID)&(shardCount-1)] } func (r *ConnRegistry) Add(c *Conn) { s := r.shard(c.UserID) s.mu.Lock() s.conns[c.UserID] = c s.mu.Unlock() } func (r *ConnRegistry) Remove(userID int64) { s := r.shard(userID) s.mu.Lock() delete(s.conns, userID) s.mu.Unlock() } func (r *ConnRegistry) Get(userID int64) (*Conn, bool) { s := r.shard(userID) s.mu.RLock() c, ok := s.conns[userID] s.mu.RUnlock() return c, ok }分片数的选择有个经验值:分片数应该远大于CPU核数,但又不能让内存开销过大。256个分片在16核机器上实测锁冲突率极低,每个map的平均元素数是总连接数除以256,十万连接也就四百个元素,map操作是纳秒级的。如果你的目标是单机五十万连接,可以提到1024片,代价是多了几百个锁对象,可忽略。
注意:分片哈希一定要用用户ID,不要用连接ID。用连接ID的话,同一个用户多端登录会散落到不同分片,查"这个用户当前有哪些连接"就要遍历全部分片,多端同步功能会变得非常难受。
2.2 读写分离与goroutine的生命周期
一条连接至少需要两个goroutine:一个阻塞在Read上等数据,一个阻塞在sendCh上等发送。这个模型比"一个goroutine同时管读写"要清晰得多,也避免了一个慢客户端把读循环拖住。
但goroutine泄漏是这套模型最容易翻车的地方。我见过最典型的bug:连接断开后,写goroutine还在等sendCh,而sendCh再也没有人往里写,它就永远阻塞在那里。一天下来泄漏几万个goroutine,内存缓慢上涨,最后OOM。
正确的收尾方式是用context加关闭信号,让两个goroutine互相通知:
func (c *Conn) Serve(ctx context.Context) { ctx, cancel := context.WithCancel(ctx) defer cancel() go c.writeLoop(ctx) // 读循环跑在调用方goroutine里 c.readLoop(ctx) cancel() // 读出错退出时,通知写循环 } func (c *Conn) writeLoop(ctx context.Context) { for { select { case data := <-c.sendCh: c.netConn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := c.netConn.Write(data); err != nil { return } case <-ctx.Done(): return } } }这里有个细节值得单独说:写操作一定要设deadline。客户端网络卡住、接收窗口满了、对方进程挂起,Write都可能永久阻塞。不设deadline,这个写goroutine就废了,sendCh很快被填满,后续投递全部阻塞或者被丢弃。10秒是我试下来比较稳的值——正常的消息包几百字节,10秒都发不出去,这条连接基本已经废了,直接关掉重连更划算。
2.3 心跳策略与假死连接
TCP连接有个反直觉的特性:对端进程崩溃、网线被拔、路由器重启,你的Read调用不会立刻报错。内核在没有任何数据往来的时候,感知不到这些问题,连接会一直"挂着",直到TCP的keepalive超时(默认两小时)。两小时对一个IM系统来说是灾难,用户看到的是自己在线,别人发消息他收不到。
所以应用层心跳是必须的。我的参数配置是:客户端每30秒发一次心跳,服务端60秒没收到任何数据就判定超时。这两个数字的推导逻辑:30秒的心跳间隔能让移动端在切后台再回来时快速发现连接已断,同时不至于太耗电;服务端判定时间给两倍余量,容忍一次心跳丢失。
const ( heartbeatInterval = 30 * time.Second heartbeatTimeout = 60 * time.Second ) func (c *Conn) readLoop(ctx context.Context) { for { c.netConn.SetReadDeadline(time.Now().Add(heartbeatTimeout)) frame, err := ReadFrame(c.netConn) if err != nil { // 超时和正常断开走同一套清理逻辑 c.close() return } atomic.StoreInt64(&c.lastPing, time.Now().Unix()) if frame.Type == FrameHeartbeat { c.sendCh <- BuildHeartbeatAck() continue } // 业务消息投递给逻辑层 c.dispatch(frame) } }这里我用SetReadDeadline而不是单独的定时器扫描,理由是扫描全量连接的成本随连接数线性增长,十万连接每秒扫一遍就是十万次读操作,CPU白白浪费。用ReadDeadline让内核帮你做超时判断,代价几乎为零。
注意:心跳包不要走业务消息通道。有些实现把心跳也当成一条普通消息塞进sendCh,客户端网络一卡,sendCh堆积,心跳排在几百条业务消息后面发不出去,服务端判定超时把人踢了。心跳必须有独立的优先级,或者干脆在写循环里优先处理。
2.4 协议帧与粘包拆包
TCP是字节流,不保证消息边界。你发两次100字节,对面可能一次读到200字节,也可能分四次读到50字节。这个特性如果没处理好,表现就是"偶尔消息解析失败""偶发乱码",而且极难复现。
我的协议头设计成固定16字节:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic | 2字节 | 固定值,用于快速识别非法包 |
| Version | 1字节 | 协议版本,方便后续升级 |
| Type | 1字节 | 帧类型:心跳、上行、下行、ACK |
| BodyLen | 4字节 | 消息体长度,大端序 |
| Seq | 4字节 | 序列号,用于请求响应匹配 |
| Reserved | 4字节 | 预留,对齐用 |
有了长度字段,拆包逻辑就非常直白:先读满16字节头部,校验Magic,取出BodyLen,再读满BodyLen字节。
func ReadFrame(r io.Reader) (*Frame, error) { header := make([]byte, 16) if _, err := io.ReadFull(r, header); err != nil { return nil, err } if binary.BigEndian.Uint16(header[0:2]) != 0xCAFE { return nil, ErrBadMagic } bodyLen := binary.BigEndian.Uint32(header[4:8]) if bodyLen > maxFrameSize { return nil, ErrFrameTooLarge } body := make([]byte, bodyLen) if _, err := io.ReadFull(r, body); err != nil { return nil, err } return &Frame{ Version: header[2], Type: header[3], Seq: binary.BigEndian.Uint32(header[8:12]), Body: body, }, nil }maxFrameSize这个校验千万别省。没有长度上限校验,一个恶意的或者出bug的客户端发一个头部写着4GB的包,服务端直接make一个4GB的切片,内存瞬间打满。我一般设为1MB,业务消息超过1MB就该走文件上传接口了,不该挤占消息通道。
还有一点,每次Read都make一个新切片会产生大量GC压力。十万连接、每秒每条连接一个包,就是每秒十万次分配。我的优化是用sync.Pool缓存body缓冲区,读取完成后在业务处理结束时归还。这个改动在压测中把GC次数降低了约60%,P99延迟有明显改善。但要注意,一旦用了对象池,就不能让body被异步持有——比如把body扔进队列慢慢处理,同时池子又把它回收给别人,就会出现数据错乱。要么深拷贝,要么保证池化对象在同一个goroutine内用完即还。
3. 消息路由与可靠性投递
连接层搭好之后,真正体现系统水平的是消息投递。用户对IM的容忍度极低:晚到可以接受,丢消息不能接受,乱序也不能接受。这三条约束决定了路由层必须做不少额外工作。
3.1 消息ID生成与幂等
消息ID我踩过一次大坑。最早用数据库自增ID,结果消息在落库之前就要发给接收方,此时ID还没有,只能先生成一个临时ID,落库后再补。客户端拿到的ID和服务端的ID不一致,撤回、已读回执、引用回复全对不上。
后来改成客户端生成全局唯一ID:机器标识 + 会话内单调递增序号 + 随机后缀。接收方和发送方拿到的是同一个ID,服务端只做校验和存储,不做重新分配。这个方案的好处是天然幂等——同一条消息重复投递,接收方按ID去重就行。
更稳妥的方案是服务端统一生成,用雪花算法那套思路:时间戳 + 机器ID + 序列号,64位整数。客户端发消息时先带一个本地的临时ID,服务端受理后返回正式ID,客户端做一次映射。缺点是多了半次往返,好处是ID完全可控、可排序、可按时间范围查询。我们最后选了这个方案,因为需要支持"按时间拉取历史消息"的功能,ID有序能省掉一个索引。
3.2 上行ACK与下行ACK
消息可靠性靠ACK。但ACK不能乱加,加多了链路变成同步等待,吞吐直接腰斩。
我的做法是上行同步、下行异步:
- 上行:客户端发消息给服务端,服务端落库成功才回ACK。客户端收不到ACK就重发,最多重试三次。这一层保证消息不丢,因为落库了就不会丢。
- 下行:服务端投递给接收方,接收方收到后回一个ACK,服务端收到ACK后把这条消息从"待确认队列"里删掉。如果超时没收到ACK,重投。
下行ACK的重投队列是纯内存的,只在接收方在线时有意义。如果接收方在线但ACK超时,说明消息可能已到也可能没到,重投一次,接收方按消息ID去重。这个设计接受"可能重复但绝不丢失",符合IM的语义。
type PendingAck struct { MsgID int64 ToUserID int64 Deadline time.Time Retries int } // 重投扫描,每200ms一轮 func (s *Server) ackScanner() { ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for range ticker.C { now := time.Now() s.pending.Range(func(k, v any) bool { p := v.(*PendingAck) if now.After(p.Deadline) { if p.Retries >= 2 { s.pending.Delete(k) return true } p.Retries++ p.Deadline = now.Add(5 * time.Second) s.redeliver(p) } return true }) } }重投间隔我设的5秒,重试上限2次。为什么不设更激进的间隔?因为移动端网络抖动是常态,1秒重投会导致大量无谓重复;为什么不设更大?5秒内不确认,用户已经在界面上看到"发送中"了,体验开始变差。这个值需要根据自己的用户网络状况调,国内环境5秒是个比较稳的中位数。
注意:重投队列一定要有容量上限。如果某个用户连接假死,消息一直发不出去,队列会无限增长。给每个用户设一个上限(比如100条),超了就直接丢弃最老的,让它走离线消息流程。宁可让对方上线后从离线队列拉,也不要把网关内存拖垮。
3.3 在线路由表与多端同步
"这个用户现在连在哪个网关节点上"——这个问题必须用共享状态回答。我一开始用过网关之间互相广播连接事件,节点数到8个的时候,每次上下线要发7条广播,集群内部流量已经不可忽视了。后来改成中心化的路由表,存内存KV存储,键是用户ID,值是节点标识加连接列表,带TTL。
路由表的更新策略很关键。连接建立时写入,连接断开时删除,同时设置一个略大于心跳超时的TTL作为兜底。为什么要兜底?因为进程被强杀的时候,删除逻辑根本来不及执行,路由表里会留下僵尸记录。有了TTL,最迟一个超时周期后这条记录自动消失。
多端同步是另一个容易做歪的地方。用户可能在手机、平板、电脑三端同时在线,一条消息要投给所有端。但这里有个业务细节:如果发送方自己也在线,他自己的其他端要不要收到自己发的消息?大部分产品是要的,方便多端同步。所以投递列表是"接收方全部端 + 发送方除当前端外的其他端"。
已读状态、会话未读数这些属于"每个端独立"还是"账号共享",得跟产品对齐清楚。我们吃过亏:一开始未读数按端独立算,用户在手机上读完了,电脑上还显示99+,被投诉"同步有问题"。后来改成账号级别共享,每个端的未读状态靠"最后已读时间戳"对齐。
3.4 群聊扩散的三种写法
群聊是IM里最容易把系统打崩的场景。一个两千人的群,一条消息要投两千份。如果群里有大V直播发消息,瞬间就是几十万次投递。
我实践过三种方案,各有适用场景:
| 方案 | 做法 | 适用规模 | 缺点 |
|---|---|---|---|
| 写扩散 | 消息到达时为每个成员生成一份收件箱记录 | 小群(500人以下) | 大群放大严重,写入量剧增 |
| 读扩散 | 每个成员只维护指针,拉取时按群聚合 | 大群、频道 | 拉取时聚合成本高,未读数计算复杂 |
| 混合 | 小群写扩散,大群读扩散,按人数阈值切换 | 通用 | 实现复杂度高,两套逻辑要维护 |
我的选择是阈值设在500人。低于500的写扩散,收件箱记录按用户ID分片存储,读取时直接按用户维度扫,简单可靠。高于500的走读扩散,每个成员只存一个"最后已读的消息ID",拉取时按时间范围查群消息表。这个阈值不是拍脑袋定的,是在压测里找的拐点——500人以下写扩散的单条消息处理耗时稳定在20毫秒内,超过之后开始线性上涨。
如果做的是超大群(万人以上),还有一个技巧是合并投递。一条消息只投一次给网关节点,节点再按本地在线成员列表批量推送。这样跨节点的网络流量从N份降到节点数份。代价是网关节点要维护"本节点上有哪些群的哪些成员在线"的映射,这个映射的更新成本也不低,适合群成员在线率高的场景。
4. 存储分层与状态落盘
存储这块,我的原则是:能不放数据库的就不放数据库,必须放的想清楚怎么放。IM的消息写入量是典型的"高并发、小记录、只追加",用传统的行式关系库硬扛,很快会被写入吞吐卡死。
4.1 消息表结构与分库分表
消息表我按会话维度分片,分片键是会话ID。理由是所有查询都带会话ID——拉历史消息、查某人发过的消息、撤回某条消息,全都在会话范围内。按用户ID分片会导致"拉一个群的聊天记录"要扫全部分片,完全不可用。
表结构精简到极致:
CREATE TABLE message_001 ( msg_id BIGINT UNSIGNED NOT NULL, conv_id BIGINT UNSIGNED NOT NULL, sender_id BIGINT UNSIGNED NOT NULL, msg_type TINYINT NOT NULL, content BLOB NOT NULL, created_at BIGINT NOT NULL, PRIMARY KEY (conv_id, msg_id), KEY idx_created (conv_id, created_at) );注意主键是(conv_id, msg_id)的联合主键,而不是单独一个自增ID。这是按查询模式反推的——所有查询都是"给定会话,按ID或时间取一段",联合主键保证数据在物理存储上就是按会话聚集的,范围扫描非常快。如果主键是自增ID,同一会话的消息会散落在整个表空间,扫描性能差一个数量级。
分表数量我按预估消息量和单表容量上限倒推。单表控制在2000万行以内,按每天500万条消息估算,一张表大概撑四天,所以至少要按天分表,配合会话哈希分成64个库。这个数字算完之后,我意识到分表策略必须一开始就定好,中途改分片键基本等于重做。所以前期压测时就要把三年的增长量算进去,宁可多分一点。
4.2 会话列表的维护技巧
会话列表是IM里查询最频繁的接口——每次打开App都要拉一次。它的特点是"数据量小、更新频繁、要求强一致"。
我的做法是每个用户一个会话列表,存在内存KV存储里,按最后消息时间倒序排。每次有消息产生,更新两方的会话列表(发送方和接收方),更新内容是最后一条消息的摘要、时间、未读数。
这里有个性能陷阱:群聊消息更新会话列表是扇出的。一个两千人群来一条消息,要把两千个成员的会话列表全更新一遍。如果直接同步写,一条消息的处理耗时会被拉长到几百毫秒。我的处理是异步化——消息落库和在线投递走主链路,会话列表更新扔进队列慢慢消费,允许秒级延迟。用户侧看到的差异是"消息已经显示在聊天窗口了,但返回列表页时最后一条消息还没更新",这个延迟大部分产品是可以接受的。
未读数的计算有个坑。如果用"总数减去已读数",每次都要count一次消息表,成本很高。我的做法是未读数直接用自增计数器,来消息时incr,已读时清零。缺点是并发场景下可能不准(用户读的时候又来新消息),但这个误差在一两条范围内,用户体验上完全感知不到,为了绝对精确去加分布式锁得不偿失。
4.3 离线消息与漫游
离线消息这块,我最初的设计是"用户离线期间的消息全部堆到一个离线队列,上线后一次性拉取"。这个方案的问题很快暴露:一个用户离线一周,群里几万条消息堆着,上线时拉几万条,客户端直接卡死。
改进方案是离线消息按会话聚合,只保留每个会话最近N条。具体做法是用户离线期间,消息正常落库,同时在离线队列里只记录"该会话有新消息"和"最后一条消息ID"。用户上线后,先拉会话列表,再按会话逐个拉取最近的消息。这样无论离线多久,首次上线的数据量都是可控的。
漫游(跨设备拉取历史消息)本质上是个分页查询。这里要注意分页必须用游标而不是offset。用LIMIT offset, size在深分页时性能会急剧下降,而且消息还在不断写入,offset会漂移导致重复或遗漏。用msg_id < last_msg_id的游标方式,配合联合主键,性能稳定且结果准确。
注意:消息内容如果涉及图片、语音、视频,不要把二进制塞进消息表。我的做法是文件先上传到对象存储,消息体里只存URL和元信息(尺寸、时长、缩略图地址)。有些团队图省事直接存BLOB,结果消息表体积膨胀到几TB,备份和迁移都成了噩梦。
5. 压测、监控与故障排查实战
系统写完不等于能用,压测才是真正暴露问题的环节。我见过太多项目功能测试全绿,一上压力就原形毕露——连接数上到两万开始大量掉线,消息延迟从50毫秒飙到3秒。
5.1 压测环境与基准数据
压测的第一件事是别在线上环境做,也别用配置差太多的机器。我用一台16核32G的机器做单机基准测试,客户端用另外三台机器模拟连接,避免压测工具本身成为瓶颈。压测工具我试过几个,最后用Go自己写了个简单的模拟客户端,理由是能精确控制心跳节奏、消息大小、重连行为,比通用工具更贴近真实客户端。
基准数据供参考(单机16核32G,消息体200字节,心跳30秒):
| 指标 | 数值 | 备注 |
|---|---|---|
| 稳定长连接数 | 8万 | CPU使用率约65%,内存约4.2G |
| 消息吞吐(1对1) | 3.5万条/秒 | P99延迟38毫秒 |
| 消息吞吐(群聊500人) | 1200条群消息/秒 | 扇出后约60万次投递/秒 |
| 重连风暴承受力 | 5000连接/秒 | 超过后握手队列堆积 |
| GC暂停 | 小于1毫秒 | 开启池化后 |
这几个数字里,最值得关注的是重连风暴。服务端重启、网络抖动、运营商切换,都可能造成大量连接在同一时刻重连。我做过一个测试:强制断开8万连接,看它们多快能恢复。结果发现握手阶段成了瓶颈——TLS握手、鉴权查询、路由表写入,每个连接要花几毫秒,8万连接串行处理要几分钟。
解决方式是在接入层加一个令牌桶限流,控制接受新连接的速率,同时在客户端侧加随机退避(重连间隔加上0到5秒的随机数)。这两招下去,同样的场景恢复时间从几分钟降到40秒左右,而且服务端全程稳定。
5.2 监控指标清单
IM系统的监控重点跟普通Web服务不太一样。普通服务看QPS和响应时间,IM要看的是连接相关的指标。我必看的几个:
- 在线连接数:分节点统计,突降说明有节点异常,突增说明有重连风暴。
- 连接建立速率与断开速率:两个速率长期不平衡,说明有心跳判定问题。
- 消息投递延迟P99:从服务端接收到对方ACK的耗时,这是用户体感最直接的指标。
- 待确认队列长度:这个数持续上涨,说明投递链路有问题,或者有大量假死连接。
- 单连接sendCh水位:某条连接的发送队列堆积,说明这个客户端消费能力有问题,可能是网络差或者客户端卡死。
- goroutine数量:缓慢上涨基本就是泄漏,这条曲线我每天都会扫一眼。
第三项和第五项是最容易被忽略的。投递延迟只看平均值会被大量正常请求掩盖,必须看P99甚至P999;sendCh水位不看的话,你会看到"服务端一切正常但某些用户就是收不到消息"这种诡异现象。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 处理方式 |
|---|---|---|---|
| 部分用户频繁掉线重连 | 心跳超时判定过严,或客户端心跳被后台挂起 | 看该用户的重连日志和心跳记录 | 放宽服务端超时阈值,客户端增加前台恢复时的主动探测 |
| 消息延迟高但吞吐正常 | 单条连接sendCh积压 | 检查该连接的写超时和客户端消费速度 | 降级为丢弃非关键消息,或主动断开重连 |
| 内存持续上涨不回落 | goroutine泄漏或对象池未归还 | 抓goroutine快照对比,看数量趋势 | 定位未关闭的连接清理逻辑 |
| 群消息部分成员收不到 | 路由表有僵尸记录 | 比对路由表记录与实际上线状态 | 缩短TTL,检查进程退出时的清理钩子 |
| 消息顺序错乱 | 并发投递未保序 | 检查是否多条goroutine并发写同一连接 | 按会话维度串行化投递 |
| 重启后大量用户掉线不重连 | 客户端重连逻辑缺陷 | 客户端日志 | 补上网络状态监听和定时重连 |
消息顺序这条我要多讲两句。同一个会话的消息必须按序投递,做法是按会话ID哈希到固定的投递goroutine,同一个会话的消息永远走同一个goroutine,天然串行。如果图省事用连接池并发投递,接收方就会看到"A说完B的话才到"这种混乱。这个bug在低并发时完全测不出来,一上量就暴露,而且用户投诉的描述通常是"聊天记录乱了",很难联想到是并发投递的问题。
5.4 我踩过的坑
坑一:TLS握手把CPU吃满。一开始所有连接都走TLS,8万连接同时重连的时候,握手阶段的非对称加密运算把CPU打到100%,服务直接雪崩。后来做了两件事:一是把会话复用打开,减少完整握手次数;二是把TLS终止放到独立的接入层,跟业务处理分离,各自扩容。这个改动之后,重连风暴期间CPU峰值从100%降到50%左右。
坑二:日志把磁盘写满。调试期为了排查方便,每条消息的收发都打了一行日志。上线后日消息量上千万,日志一天几百G,磁盘两天就满了。教训是IM这种高频服务,日志必须分级且采样。我的做法是错误日志全量,正常收发日志按千分之一采样,调试日志默认关闭。同时给日志文件配上滚动和上限,防止意外情况把盘撑爆。
坑三:时区问题导致消息时间错乱。服务端用UTC存时间戳,客户端展示时区转换。有一版客户端把服务端返回的秒级时间戳当毫秒处理,消息时间显示成1970年。这个bug排查了两个小时,最后发现是单位不一致。之后我在协议里明确规定所有时间戳都用毫秒,并在解包时做范围校验,超出合理范围直接拒绝。
坑四:连接池复用导致的"幽灵消息"。这个问题最隐蔽。我们中间换过一次网络库,新库内部做了连接复用。结果一条连接断开后,新的用户复用了这条底层连接,但上层还残留着上一个用户的待发消息,导致新用户收到了别人的消息。这是个严重的安全问题。解决方式是连接对象和用户身份严格绑定,连接断开后立即销毁,不复用。复用省下的那点资源,跟数据串号的风险比根本不值。
6. 水平扩展与上线部署
单机能扛8万连接,但真实业务不可能只靠一台机器。扩展这块有几个点,做对了很顺,做错了就是一堆玄学问题。
6.1 网关"无状态"的真相
教科书会说网关要设计成无状态的,方便水平扩展。这话对一半。网关确实不该存业务状态,但它必然持有连接状态——连接在它身上,这就是最大的状态。所以"无状态"准确的说法是"状态只存在于连接生命周期内,不跨连接共享"。
基于这个认知,扩容的逻辑就清楚了:新节点上线,不承载任何存量连接,只有新建立的连接会分配过来。老节点上的连接不会自动迁移,除非主动踢掉让客户端重连。这意味扩容的收益是渐进的,不是加一台机器容量立刻翻倍。
如果确实需要快速把连接迁走(比如要下线一台机器),有几个办法:一是通知客户端重连(服务端下发一个"请重连到XX节点"的指令),二是直接断开让客户端自动重连,三是在负载均衡层把新连接引导到其他节点,等存量自然衰减。第一种体验最好但实现最复杂,第二种最简单但会造成一次短暂掉线,我一般在小流量时段用第二种。
6.2 优雅重启与灰度
优雅重启是必须做的,否则每次发版都是一次全网掉线。我的实现是:
- 收到重启信号后,网关停止接受新连接(从负载均衡摘除)。
- 给所有在线连接下发一个"服务升级,请重连"的通知。
- 等待一个宽限期(比如30秒),让客户端主动断开重连到其他节点。
- 宽限期结束后,强制关闭剩余连接,进程退出。
这套流程下来,用户侧的感知是"闪了一下",而不是"应用卡死重开"。
灰度这块,我的建议是按用户ID哈希分批。第一批放1%的用户,观察半小时,重点看掉线率、消息延迟、错误率三个指标。没问题再放10%,然后50%,最后全量。每批之间留足观察时间,因为有些问题(比如内存缓慢上涨)需要时间才能暴露。
还有一点容易被忽略:协议版本兼容。灰度期间,新老版本的网关可能同时在跑,如果协议有变更,必须保证双向兼容。我的规则是"新增字段可以,删除和改语义不行",同时协议头里的Version字段用来做能力协商,服务端根据版本决定下发什么格式。这条规则看着保守,但省下了无数次回滚。
注意:不要把网关和业务逻辑部署在同一个进程里。一旦业务逻辑出问题(比如某个查询把内存打爆),整个网关跟着挂,所有连接全断。分开部署,业务逻辑崩了最多是功能不可用,连接还在,降级提示还能推下去。
最后分享一个我一直在用的自检清单,每次上线前过一遍:连接数上限有没有配、心跳超时有没有配、sendCh容量和丢弃策略有没有配、重投队列上限有没有配、日志采样率有没有配、优雅重启的宽限期有没有配。这六项都是"没配也能跑,但出问题时后果很严重"的参数,配一次管很久,但忘了配就是定时炸弹。