做后端这一行,基于Golang的即时通信系统这个题目我前后被问过不下十次,问的人里有刚把语法啃完想找个项目练手的,也有手上业务真到了要自己搭一套消息通道的。这两拨人的诉求完全不一样:前者想看到一个能跑起来的骨架,后者更关心这套东西在几千、几万连接下会不会塌。
我自己的经历是,第一次写这类系统的时候,代码两天就跑通了,单机连上几百个客户端收发正常,当时觉得不过如此。真正上线之后才发现,麻烦根本不在"能发消息",而在连接管理、心跳判定、消息幂等、离线补拉这些边角料上。一个客户端网络抖动重连三次,消息就收了四份;一个用户三端同时在线,某个端还没收完消息就被踢掉了;服务跑了一周内存涨了 3G,最后发现是某个 goroutine 卡在写阻塞上永远不退出。
所以我打算在这篇里把这些年踩过的坑,连同我最终稳定下来的那套设计,一次性摊开讲。读者对象我分成两类:如果你是刚接触这个方向,前面几节能让你知道一套 IM 到底由哪几块拼起来、每块负责什么;如果你已经在写了,中间那些协议布局、序列号生成、幂等去重、扩容路由的细节应该能直接拿去改。文中给出的代码是能编译能跑的最小骨架,不是伪代码,但生产环境还需要你自己补监控和日志。
1. 先把"即时通信"这件事拆开看
1.1 长连接不是目的,稳定的消息通道才是
很多人一上手就盯着 WebSocket 或者 TCP 长连接,觉得把连接维持住就算成功。我一开始也这么想,后来才明白连接只是载体,真正要交付的是三件事:消息一定能到、到了不会重复、顺序不乱。这三条听起来简单,实际每一条都要单独设计机制。
举个最常见的场景:A 给 B 发一条消息。表面上是 A 的连接把字节写进去,B 的连接把字节读出来。但中间要考虑:A 写的时候 B 不在线怎么办?在线的但是网络断了没收到怎么办?B 收到了也回 ACK 了但 ACK 在路上丢了怎么办?B 换了个设备登录,旧设备上的消息要不要同步?这些问题不解决,连接维持得再稳也只是个聊天室级别的玩具。
我一般的做法是把消息通道拆成"传输层"和"语义层"。传输层只负责字节的收发、心跳、断线重连,语义层负责消息 ID、去重、顺序、离线。传输层可以换,今天用 TCP 明天用 WebSocket,语义层不用动。这个分层是我在第二个项目里才想明白的,第一个项目里这两层搅在一起,后来换协议相当于重写。
1.2 为什么我最终选了 Go:并发模型的现实账
语言选型这事我不想过度吹,但 Go 在这个场景里确实省事。最直接的原因是连接是天然并发的,一万个连接就是一万个独立的 IO 上下文,用线程模型的话光是上下文切换和栈内存就够呛。Go 的 goroutine 初始栈只有 2KB 左右,一个连接配两个 goroutine(读一个、写一个),十万连接也就 20 万 goroutine、几百 MB 的栈开销,这个账算下来是可以接受的。
第二个原因是标准库够用。net、bufio、encoding/binary、sync、context这套东西写长连接服务足够了,不需要引一堆第三方。我不是说生态不重要,而是长连接这种长期运行的进程,依赖越少、出问题的面越小。我曾经在一个项目里引了个挺流行的网络框架,结果它的某个内部定时器在高连接数下会有精度漂移,排查了两天才定位到,从此对这种"帮你封装好一切"的库保持警惕。
第三个原因是部署简单。编译出来一个静态二进制,扔到机器上就能跑,不用装运行时,不用管版本冲突。灰度的时候直接换文件重启,回滚也是换文件。这个在运维上省的心,做过传统 Java 服务的人应该都有体会。
当然代价也有:GC 虽然一直在优化,但在大内存堆上还是会有抖动;没有泛型之前写通用容器很痛苦(现在有了但生态还没完全跟上);错误处理啰嗦。这些我都认,只是权衡下来在我这个场景里不是决定性的。
1.3 一套系统里到底有几层在跑
我把一套 IM 分成四层,这个划分方式在不同项目里微调过,但主体没变:
接入层(gateway):只干三件事——握手鉴权、维持连接、字节收发。它不知道消息是聊天还是通知,也不查数据库。这一层是唯一持有大量连接的地方,所以必须轻,任何耗时操作都不能放这里。
逻辑层(logic):消息的路由、校验、去重、入库。它不直接面对客户端连接,只跟接入层通过内部 RPC 通信。
状态层(state):在线状态、路由表、未读数、序列号。这个东西必须是共享的,因为用户可能从任何一台接入层连进来。我一般用 Redis 承担,量大了再考虑自研或者分片。
存储层(storage):消息历史、会话列表、离线消息。这部分是持久化的,关系型数据库加一层缓存是常规做法。
这么分的好处是扩容可以按需扩。连接数涨了扩接入层,消息量大涨了扩逻辑层,这两种压力不是同步增长的。我见过把三层揉在一个进程里的写法,早期确实省事,但用户量一上来就只能整体扩容,资源浪费很严重。
1.4 几个必须先定的技术岔路口
动手之前有几个决定一旦做了就很难改,我列出来,顺便说说我自己的选择理由:
| 决策点 | 常见选项 | 我的选择与原因 |
|---|---|---|
| 传输协议 | 原生 TCP / WebSocket / HTTP 长轮询 | WebSocket 优先,浏览器端零成本;需要极致省流量再上原生 TCP |
| 消息编码 | JSON / Protobuf / 自定义二进制 | 自定义二进制头 + Protobuf 体,头固定解析快,体可演进 |
| 消息投递 | 推 / 拉 / 推拉结合 | 在线推、离线拉,纯推会丢、纯拉有延迟 |
| 会话顺序 | 服务端统一分配 / 客户端生成 | 服务端按会话维度分配递增序列号,客户端只负责去重 ID |
| 多端在线 | 单端互踢 / 多端同步 | 同类型设备互踢,不同类型设备同步 |
提示:这几项里最容易被低估的是"会话顺序"。很多人图省事让客户端生成时间戳排序,结果两端时钟不同步,消息顺序就乱了。序列号这东西必须由单一权威方分配。
2. 架构落地:从单机到分层的取舍
2.1 接入层与逻辑层的边界在哪里
刚写的时候我特别容易把业务逻辑往接入层塞,因为数据就在手边,直接查库返回多方便。这个习惯要不得。接入层的 goroutine 数量等于连接数,里面任何一个阻塞操作乘以十万就是灾难。我给自己定的规矩是:接入层的每个处理函数里不允许出现数据库查询、不允许出现同步 RPC 调用、不允许出现无超时的等待。
那接入层收到消息之后怎么办?扔给逻辑层。这里有个细节:扔的方式决定了系统的可靠性上限。最省事的是开个 channel 塞进去异步处理,进程一挂消息就没了。稳妥一点是同步调用逻辑层拿到结果再回 ACK,但这样接入层的处理能力就受逻辑层制约。我折中的做法是:消息先落本地一个带持久化的队列(或者直接同步写一次 Redis 的 list),再异步转给逻辑层,逻辑层处理完回 ACK 给客户端。这样即使逻辑层短暂不可用,消息也不会丢,客户端那边看到的是"发送中"而不是"发送失败"。
逻辑层这边要保证幂等。因为接入层重试、客户端重发、网络层重复投递这几种情况都会导致同一条消息被处理多次。做法是客户端生成一个client_msg_id(UUID 或者"设备ID+本地自增"),逻辑层拿这个 ID 做唯一索引,插入冲突就直接返回成功,不重复入库也不重复推送。
2.2 连接和用户的状态该放在哪
这块我绕了不少弯路。第一版我把userID -> conn的映射放在接入层进程的内存里,单机跑得好好的,一扩容就发现用户在 A 机器上,消息从 B 机器来,B 根本找不到这个连接。
正确做法是连接的物理信息留在接入层本地,逻辑映射放到共享状态层。具体来说:
- 接入层本地维护
connID -> *Conn的 map,这是热数据,必须本地。 - 共享状态层维护
userID + deviceType -> gatewayAddr + connID,这是个路由表。 - 逻辑层要推消息给某个用户,先查路由表找到网关地址,再通过内部 RPC 把消息发给那个网关,网关再根据 connID 找到具体连接写出去。
路由表的写入时机是连接建立成功之后、断开之前。这里有个坑:进程被 kill -9 的时候来不及清理路由表,导致路由表里残留死连接。解决办法是给路由表加 TTL,接入层定期刷新(心跳刷新),过期自动失效;同时逻辑层投递失败时要能感知并清理。
还有一种更简单的方案是广播:逻辑层不查路由,直接把消息发到消息队列,所有接入层订阅,各自检查本地有没有这个用户的连接,有就推。这个方案在接入层节点少的时候非常好用,代码简单不会出错。节点多了之后广播的浪费就明显了,十万连接分布在 20 台机器上,一条消息要空跑 19 次。我的经验是 10 台以内用广播,超过就上路由表。
2.3 消息ID与序列号,两套编号别搞混
这里我要专门强调一下,因为我自己混淆过。系统里需要两种编号,作用完全不同:
全局消息 ID:一条消息在整个系统里的唯一标识,用来做去重、做追踪、做问题排查。要求全局唯一,不要求有序。我一般用雪花算法变体:41 位时间戳 + 10 位机器号 + 12 位序列,一毫秒能出 4096 个,够用。
会话序列号 seq:同一个会话(单聊就是两个用户,群聊就是那个群)内严格递增。它决定了消息在会话里的顺序,也是离线补拉的游标。这个必须由单一权威方分配,我用 Redis 的INCR conv:{convID}:seq拿,拿到之后连同消息一起落库。
为什么不能只用一个?因为全局 ID 虽然时间上大致递增,但同一毫秒内的多个消息、不同机器上的消息,顺序是不保证的。而用户看到的消息顺序必须跟会话序列号走。我见过用全局 ID 排序导致同毫秒消息乱序的案例,用户截图发过来问"为什么我先发的在后头",很尴尬。
序列号还有个用途是未读数计算。用户 A 的某个会话读到 seq=100,当前会话最大 seq=115,那未读就是 15 条。这个比每次 count 一遍消息表高效太多。
2.4 存储选型,别一上来就上大件
消息存储这块,我看到很多方案上来就是 HBase、Cassandra、TiDB。除非你日增消息量已经到亿级,否则这些带来的运维成本远大于收益。我自己的分层是这样的:
| 数据 | 我用的方案 | 理由 |
|---|---|---|
| 最近 7 天消息 | MySQL 分表 或 Redis List | 查询最频繁,需要低延迟,量可控 |
| 历史消息(7天以上) | 对象存储 / 冷库 | 查询少,成本敏感,可以慢 |
| 会话列表 | MySQL 单表 + 缓存 | 每个用户几十到几百条,量小 |
| 未读数 | Redis Hash | 高频读写,允许丢(丢了大不了重算) |
| 离线消息 | Redis List + MySQL 兜底 | 上线就要读,必须快 |
| 路由表 | Redis | 高频读写,需要 TTL |
分表的话按会话 ID 哈希或者按用户 ID 哈希都行,我倾向按会话 ID,因为查历史都是整段整段查的,同一个会话的数据聚在一起命中率高。
注意:消息表千万别加太多索引。我见过一张消息表上挂了六个索引,写入性能直接腰斩。真正需要索引的查询就那么几种:按会话+seq 范围查、按消息ID查、按发送者+时间查。其他都是低频的,走离线任务就行。
3. 协议与编解码:把字节流变成可维护的东西
3.1 自定义二进制协议的字段布局
为什么不用纯 JSON?因为长连接上每条消息都带一遍字段名,流量和解析开销都浪费。我用的方案是固定长度的头 + 可变长度的体,头负责路由和框架,体用 Protobuf 或者干脆也是自定义。
我的头是 16 字节,布局如下:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | magic | 固定 0xAB01,用来快速识别非法连接 |
| 2 | 1 | version | 协议版本,方便后面演进 |
| 3 | 1 | cmd | 命令字,登录/心跳/单聊/群聊/ACK |
| 4 | 4 | seq | 请求序号,客户端自增,用于请求响应配对 |
| 8 | 4 | bodyLen | 体长度,上限我设 64KB |
| 12 | 4 | flags | 标志位,压缩/加密/分片等 |
这个布局最关键的字段是bodyLen,它是解决粘包的核心。magic字段是为了在收到脏数据(比如 HTTP 请求打到了 TCP 端口上)时能立刻识别并关闭连接,不然解析器会一直等着,白白占资源。cmd只有 1 字节,最多 256 种命令,看起来少,但实际够用,真不够了可以用 flags 扩展。
体部分我用 Protobuf,因为它的兼容性好,加字段不影响老客户端。这里有个小技巧:体里再套一层自己的信封,包含client_msg_id、timestamp、from、to这些通用字段,业务字段再放一层。这样不同 cmd 之间能复用解析代码。
3.2 粘包与半包,用 io.ReadFull 一次解决
TCP 是流式的,你发两个包,对面可能一次收到,也可能分三次收到。这是新人最常踩的坑,看到第一次收到的数据不完整就懵了。处理方法就一句话:先读固定长度的头,从头里拿到体长度,再读恰好那么多字节的体。
const maxBodyLen = 64 * 1024 func DecodePacket(r *bufio.Reader) (*Packet, error) { var head [16]byte if _, err := io.ReadFull(r, head[:]); err != nil { return nil, err } if binary.BigEndian.Uint16(head[0:2]) != 0xAB01 { return nil, ErrBadMagic } bodyLen := binary.BigEndian.Uint32(head[8:12]) if bodyLen > maxBodyLen { return nil, ErrBodyTooLarge } pkt := &Packet{ Version: head[2], Cmd: head[3], Seq: binary.BigEndian.Uint32(head[4:8]), Flags: binary.BigEndian.Uint32(head[12:16]), } if bodyLen > 0 { pkt.Body = make([]byte, bodyLen) if _, err := io.ReadFull(r, pkt.Body); err != nil { return nil, err } } return pkt, nil }io.ReadFull这个函数专门干这事,它会一直读到缓冲区填满或者出错为止,正好对应"读够 N 字节"。自己写 for 循环也能实现,但容易漏掉n > 0 && err != nil这种情况——一次读到了一部分数据同时也返回了错误,这时候那部分数据是有效的,不能直接丢。这个细节我在早期代码里错过,导致偶发的消息丢失,很难复现。
maxBodyLen这个上限一定要设。不设的话,一个恶意客户端发个头部声明 bodyLen 是 4GB,服务端就真的去make([]byte, 4GB),内存瞬间爆掉。上限设多少取决于你的最大业务消息,我一般给 64KB,图片视频走单独的对象存储上传,不通过消息通道。
3.3 心跳、超时与断线判定
心跳这块的设计目标是尽快发现死连接,同时不浪费带宽。死连接占着 goroutine 和内存池,不清掉就是纯浪费。
我的做法是三层配合:
第一层,服务端读超时。每次从连接读数据之前,设置SetReadDeadline(now + 90s)。如果 90 秒内没收到任何字节(包括心跳),读操作返回超时错误,直接关连接。这个是最可靠的,因为它是内核层面保证的。
第二层,客户端主动心跳。客户端每 25 秒发一个心跳包,服务端收到后什么都不做,只是让上面那个读超时被重置。25 秒和 90 秒的比值大概是 3.6,能容忍丢两次心跳。
第三层,服务端主动探测。对于某些移动端场景,客户端可能进入后台挂起了,发不出心跳。这时候服务端在 60 秒没收到数据时,主动发一个 ping 帧(WebSocket 有原生支持),看能不能收到 pong。收不到就关。
func (c *Conn) readLoop() { defer c.Close() for { c.conn.SetReadDeadline(time.Now().Add(90 * time.Second)) pkt, err := DecodePacket(c.reader) if err != nil { return } c.handlePacket(pkt) } }这里有个反直觉的点:不要用time.After或者独立的定时器做超时检测。因为每个连接一个定时器,十万连接就是十万个定时器,Go 的定时器堆会变得很大,GC 压力也上来了。用SetReadDeadline让内核帮你管,几乎零成本。
提示:心跳间隔不是越短越好。我见过设 5 秒的,结果客户端在弱网下频繁重连,体验反而更差。20 到 30 秒是个比较舒服的区间,具体看你的业务对掉线感知的敏感度。
3.4 编解码器的那些性能细节
bufio.Reader和bufio.Writer必须用,而且要指定大小。默认 4096 对于消息体比较小的场景够用,但如果你的消息经常是几 KB 的,可以调到 8192 减少系统调用次数。我实测过,从 4096 调到 8192,吞吐有大概 8% 的提升,再往上加收益就不明显了。
make([]byte, bodyLen)这块每次都分配新内存,GC 压力不小。优化方案是用sync.Pool缓存缓冲区,用完放回去。但要注意放回去之前必须确保这块内存不再被引用,否则会出现数据被意外改写的问题。我一般的做法是只在需要大缓冲的地方用 Pool,小消息直接分配,因为小对象分配在 Go 里已经很快了。
写侧要特别注意写超时。如果对端不读,写操作会阻塞,goroutine 就卡在那了。必须在每次写之前SetWriteDeadline(now + 10s),超时就关连接。这个我踩过:某个客户端进程假死但 TCP 连接还在,服务端往它写,写缓冲区满了就阻塞,那个 goroutine 永远不退出,内存慢慢涨。加上写超时之后这个问题就没了。
4. 代码实操:一个能跑起来的最小骨架
4.1 目录结构与依赖划分
我习惯的目录结构是这样的,不追求什么 DDD 分层,按职责分就够了:
im/ ├── cmd/ │ └── gateway/main.go // 接入层入口 ├── internal/ │ ├── protocol/ // 协议编解码 │ │ ├── packet.go │ │ └── codec.go │ ├── gateway/ // 连接管理、读写循环 │ │ ├── conn.go │ │ ├── manager.go │ │ └── handler.go │ ├── logic/ // 消息处理、去重、路由 │ │ ├── dispatch.go │ │ └── seq.go │ └── store/ // 存储访问 │ ├── message.go │ └── route.go ├── go.mod └── config.yaml依赖我控制在很少的量:google.golang.org/protobuf做序列化,github.com/redis/go-redis/v9连 Redis,gopkg.in/yaml.v3读配置。其他都尽量用标准库。这个依赖量级意味着编译出来的二进制大概 20MB 左右,部署很轻松。
4.2 连接对象与连接管理器的实现
连接对象是整个系统的核心数据结构,字段设计要克制,能不放的就不放,因为每个连接都有一份:
type Conn struct { id uint64 userID string platform string conn net.Conn reader *bufio.Reader writer *bufio.Writer sendCh chan []byte closeOnce sync.Once done chan struct{} lastActive int64 // 原子访问,纳秒时间戳 } func (c *Conn) Close() { c.closeOnce.Do(func() { close(c.done) c.conn.Close() }) } func (c *Conn) touch() { atomic.StoreInt64(&c.lastActive, time.Now().UnixNano()) }sendCh的缓冲大小很重要。设太小了,稍微有点突发就阻塞;设太大了,慢客户端会占着大块内存不放。我一般设 1024,缓冲区满了就说明这个客户端处理不过来,直接断开是最优解——继续攒着只会拖垮整个服务。这个策略叫"快失败",在长连接服务里非常关键。
管理器的实现:
type Manager struct { mu sync.RWMutex conns map[uint64]*Conn byUser map[string]map[string]uint64 // userID -> platform -> connID } func (m *Manager) Add(c *Conn) { m.mu.Lock() defer m.mu.Unlock() m.conns[c.id] = c if m.byUser[c.userID] == nil { m.byUser[c.userID] = make(map[string]uint64) } // 同平台旧连接踢掉 if oldID, ok := m.byUser[c.userID][c.platform]; ok { if old, exists := m.conns[oldID]; exists { go old.Close() } } m.byUser[c.userID][c.platform] = c.id }这里用读写锁而不是分片锁,是因为连接注册的写操作远少于查找的读操作。不过如果连接数真的到了几十万,单把锁会成为瓶颈,那时候要改成按 connID 哈希分片,每片一把锁。这个优化我没有在十万连接以下做过,因为收益不明显。
注意踢线那段用了go old.Close()。为什么不在锁里直接关?因为 Close 可能触发一些回调,回调里如果再拿锁就死锁了。用 goroutine 异步关是安全的做法,虽然会有一小段时间两个连接同时存在,但业务上可以接受。
4.3 读写分离与消息分发的完整链路
读循环和写循环各自独立,通过 channel 交互,这是 Go 里长连接服务的标准写法:
func (c *Conn) writeLoop() { defer c.Close() ticker := time.NewTicker(25 * time.Second) defer ticker.Stop() for { select { case data, ok := <-c.sendCh: if !ok { return } c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := c.writer.Write(data); err != nil { return } if err := c.writer.Flush(); err != nil { return } case <-ticker.C: c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := c.writer.Write(heartbeatFrame); err != nil { return } c.writer.Flush() case <-c.done: return } } }写循环里的Flush一定要有。bufio.Writer是带缓冲的,不 Flush 数据就留在缓冲区里发不出去。我见过有人写完之后忘了 Flush,debug 半天以为是对端没收到,其实是根本就没发出去。
分发这块,读循环收到消息后不直接处理,而是交给一个工作池:
func (c *Conn) readLoop(dispatch func(*Conn, *protocol.Packet)) { defer c.Close() for { c.conn.SetReadDeadline(time.Now().Add(90 * time.Second)) pkt, err := protocol.DecodePacket(c.reader) if err != nil { return } c.touch() switch pkt.Cmd { case protocol.CmdHeartbeat: continue // 心跳不往下传,直接吃掉 case protocol.CmdAck: // ACK 处理是轻量的,直接在读循环里做 dispatch(c, pkt) default: select { case taskCh <- &task{conn: c, pkt: pkt}: default: // 工作队列满了,反压 c.sendErr(protocol.ErrServerBusy) } } } }心跳单独处理的细节很重要。心跳包在整体流量里占比可能到 5% 到 10%,如果也走工作池,等于白白占用队列。CmdAck也是同理,处理逻辑就是删个缓存标记,没必要占用工作协程。
工作池的大小我一般设成CPU 核数 * 4。设太大会导致上下文切换开销,设太小处理不过来。这个数值我压测过,8 核机器给 32 个 worker,在消息体平均 200 字节的情况下能跑到单机 8 万 QPS 左右。
4.4 离线消息与上线补拉的实现
离线消息的流程是:用户不在线时,消息照常入会话库,同时往 Redis 的离线队列里塞一份。用户上线后,先拉离线队列,再按 seq 游标补拉会话库。
func (s *Service) OnUserOnline(userID string, lastSeq map[string]int64) error { // 1. 补拉离线队列 key := fmt.Sprintf("offline:%s", userID) items, err := s.redis.LRange(ctx, key, 0, 499).Result() if err != nil { return err } for _, item := range items { s.pushToConn(userID, item) } s.redis.LTrim(ctx, key, int64(len(items)), -1) // 2. 按会话补拉游标之后的消息 for convID, seq := range lastSeq { msgs, err := s.store.ListAfter(convID, seq, 200) if err != nil { continue } for _, m := range msgs { s.pushToConn(userID, s.encode(m)) } } return nil }这里有个坑:补拉和实时推送可能并发。用户正在补历史消息,这时候来了一条新消息推过去,顺序就乱了。解决办法是在补拉期间给这个用户加个临时标记,新消息先缓存,补拉完了再按 seq 排序统一推。或者更简单:客户端拿到消息后按 seq 自己排序去重,服务端只管投递。我倾向后者,因为服务端加状态会引入更多并发问题。
离线队列用 Redis List 有一个风险:用户长期不上线,队列会无限增长。要给个上限,超过就只保留最近的 N 条,剩下的靠上线时按 seq 从库里补。这个 N 我设的是 500,因为从库补拉本身是支持的,Redis 队列只是个快速通道。
5. 可靠性与扩展性:真正上线才会遇到的
5.1 消息不丢、不重、不乱序的完整闭环
这三条要一起考虑,单独解决任何一个都会破坏另外两个。
不丢的链条是:客户端发消息 → 本地持久化待发列表 → 服务端接收 → 落库 → 返回 ACK → 客户端删除本地待发。客户端的超时重发是保证不丢的关键,服务端这边靠client_msg_id唯一索引保证重复消息不会重复入库。
不重主要在服务端做两层:第一层是入库时的唯一索引,冲突就认为已处理过;第二层是推送时的去重窗口,每个连接维护一个最近 1000 条的client_msg_id的环形缓冲,重复的直接跳过。第二层其实是可选的,因为第一层已经能挡住大部分,但对于"入库成功但 ACK 在路上丢了"这种情况,客户端会重发,这时候第一层照样生效。
不乱序靠 seq。前面说过 seq 由 Redis INCR 生成,同一会话内严格递增。这里有个副作用要注意:如果消息处理是并发的,两条消息可能先拿到 seq=5 的还没入库,后拿到 seq=6 的就入库了,客户端按 seq 排序的时候就找不到 5。解决方式是入库时如果发现前面有空洞,要么等待,要么标记成"待补"。我用的是简单方案:同一会话的消息串行处理,用会话 ID 哈希到固定的 worker,保证单会话内顺序。这会牺牲一点并发,但业务上完全可以接受。
注意:群聊的 seq 要按群维度生成,而不是按接收者维度。一个群 500 人,消息只生成一个 seq,然后扇出推给 500 个人。如果按接收者生成 seq,那就成了 500 条独立消息,存储和计算都翻 500 倍。
5.2 多端在线与踢线策略的选择
这块完全看产品需求,没有标准答案。我列几种常见策略和实现复杂度:
| 策略 | 实现方式 | 适用场景 | 复杂度 |
|---|---|---|---|
| 单端在线互踢 | 新登录时关闭旧连接 | 简单即时通讯 | 低 |
| 同平台互踢,跨平台共存 | 按 platform 维度维护连接 | 手机+电脑同时在线 | 中 |
| 全端共存,消息全同步 | 每个连接独立推送 | 办公协作类 | 中高 |
| 全端共存,已读状态同步 | 额外同步已读游标,其他端标记已读 | 现代聊天应用 | 高 |
我一般在第二种和第四种之间选。第二种实现起来不复杂,路由表按userID + platform做 key 就行。第四种多了一个"已读同步"的要求,需要在用户读消息时更新游标,然后推给该用户所有在线端,让它们把对应消息标记成已读。这个功能的坑在于游标的冲突合并:手机端读到 seq=100,电脑端读到 seq=80,最终游标应该是 100。所以游标更新要用max语义,不能直接覆盖。
5.3 水平扩展时的路由与消息转发
单机撑不住之后就要扩容,扩容的核心问题是消息怎么找到人在哪台机器上。前面提过路由表的方案,这里说下具体实现。
路由表存 Redis,key 是route:{userID}:{platform},value 是{gatewayID}:{connID},TTL 设 90 秒,客户端每次心跳时刷新。逻辑层要投递消息时:
- 查路由表拿到 gatewayID。
- 如果 gatewayID 就是自己(网关和逻辑在同一个进程),直接本地投递。
- 否则通过内部 gRPC 调目标网关的投递接口。
- 投递失败(网关已完成或连接已断),清理路由表,转离线流程。
gRPC 调用这里要设超时,我设 2 秒。因为投递本身只是往 channel 里塞数据,正常情况是微秒级的,超过 2 秒说明对方出问题了,继续等没意义。
还有一个扩容时的坑:消息跨节点转发会带来额外的网络开销。A 在网关 1,B 在网关 6,A 给 B 发消息,链路是网关1 → 逻辑层 → 网关6。如果逻辑层和网关是分离部署的,这就是两次网络跳转。我做过一个优化是让网关之间直连,逻辑层只做鉴权,消息直接在网关间转发。但这样网关就要知道全部的鉴权结果和路由信息,复杂度上来了。小规模下没必要,等到跨机房部署的时候再说。
5.4 容量估算与压测方法
容量估算这事必须提前做,不然上线第一天就被打脸。我的估算方式:
| 指标 | 假设值 | 计算 | 结果 |
|---|---|---|---|
| 目标在线连接 | 10 万 | - | 10 万 |
| 每连接 goroutine | 2 个 | 10万 × 2 | 20 万 |
| 每 goroutine 栈 | 4KB(平均) | 20万 × 4KB | 800MB |
| 每连接缓冲 | 读4K+写4K+chan 8K | 10万 × 16KB | 1.6GB |
| 消息速率 | 人均 0.2 条/秒 | 10万 × 0.2 | 2 万 QPS |
| 单条消息扇出 | 群聊平均 3 人 | 2万 × 3 | 6 万次投递 |
加起来内存大概 3GB 左右,这是纯连接开销,加上业务缓存和 Go 运行时的堆,一台 16GB 的机器跑 10 万连接是有余量的。这个估算我在实际项目里验证过,偏差在 20% 以内。
压测我不用现成的工具,因为 IM 的连接要保持,还要模拟心跳和消息收发,通用压测工具很难配。我一般写个小程序,用-conns和-rate两个参数控制连接数和消息速率:
go run ./cmd/bench -addr 127.0.0.1:8080 -conns 50000 -rate 0.2 -duration 10m压测的时候重点看三个指标:连接建立速率(能不能在 1 分钟内扛住 5 万连接涌入)、消息 P99 延迟(超过 200ms 就要查)、GC pause(超过 10ms 就要调参数)。第一个指标最容易被忽视,因为很多服务设计成能维持 10 万连接但建立连接特别慢,一波重连就雪崩了。解决办法是加连接建立的限流,比如用令牌桶限制每秒最多接受 2000 个新连接。
6. 常见问题与排查实录
6.1 goroutine 泄漏是最难查的问题
我遇到过的线上事故里,goroutine 泄漏占了将近一半,因为它不会立刻报错,只是内存慢慢涨,等到 OOM 的时候已经过了好几天,现场都没了。
典型的泄漏场景有三个:写阻塞不退出(前面说的没设写超时)、channel 满了之后永久阻塞、context 没有正确传递导致 select 永远等不到。排查方法很简单,在服务里开一个 pprof 端点,出问题的时候抓一下:
go tool pprof -http=:9999 http://127.0.0.1:6060/debug/pprof/goroutine看哪个函数的 goroutine 数量在持续增长。我一般的判断标准是:稳态下 goroutine 数量的波动不超过 5%,超过就说明有问题。
还有个小技巧是给每个连接的 goroutine 加个标记,比如在启动的时候把 connID 写到一个全局的 map 里,出问题的时候对比 map 大小和 goroutine 数量,能快速定位是哪类连接泄漏。
6.2 内存暴涨的几种典型原因
除了 goroutine 泄漏,内存问题还有几个常见来源:
缓冲区配置过大。有人把bufio.Reader设成 64KB,十万连接就是 6.4GB,光缓冲区就吃满了。缓冲区大小要跟实际消息大小匹配,大多数 IM 消息都在 1KB 以内,4KB 缓冲足够。
消息体没有大小限制。前面说过要设maxBodyLen,这个不只是一次请求的防御,长期看也是防止恶意客户端持续发大包把内存池撑爆。
sync.Pool 用错。Pool 里的对象会被 GC 回收,如果每次取出来都做大量初始化,那用 Pool 就没意义了。反过来如果 Pool 里的对象持有大块内存又不释放,反而加重 GC 负担。我的用法是只在固定大小的缓冲区上用 Pool,每次用完清空内容再放回。
消息缓存没有淘汰。有的实现为了加速会给每个会话缓存最近 N 条消息,但从来不清。活跃会话多了之后缓存就成灾了。一定要设总容量上限,用 LRU 淘汰。
6.3 长连接排查速查表
最后整理一张表,这是我在实际排障中积累的对照关系,遇到问题先扫一眼能省不少时间:
| 现象 | 可能原因 | 排查命令或方法 | 处理方式 |
|---|---|---|---|
| 连接数上不去 | 文件描述符限制 | ulimit -n | 调到 65535 以上 |
| 连接数上不去 | 端口耗尽 | ss -s看 TIME_WAIT | 开启 tcp_tw_reuse |
| 消息偶发丢失 | 粘包处理错误 | 抓包看字节流 | 用 io.ReadFull 重写解码 |
| 消息重复 | 未做幂等 | 查日志找重复 client_msg_id | 加唯一索引 + 去重窗口 |
| 消息乱序 | 按时间戳排序 | 检查客户端排序逻辑 | 改用服务端 seq 排序 |
| 内存缓慢增长 | goroutine 泄漏 | pprof goroutine | 加写超时和 channel 非阻塞 |
| 内存缓慢增长 | 缓存无上限 | pprof heap | 加 LRU 淘汰 |
| 断线重连风暴 | 重连无退避 | 看连接建立速率曲线 | 客户端加指数退避 |
| 延迟突然升高 | 工作池积压 | 看 taskCh 长度 | 扩 worker 或加限流 |
| 部分用户收不到 | 路由表脏数据 | 查 Redis route key | 加 TTL + 投递失败清理 |
| CPU 打满 | 心跳过于频繁 | 看 QPS 分布 | 调大心跳间隔 |
| 频繁 Full GC | 短生命周期大对象 | pprof allocs | 用 Pool 或改结构 |
这张表里我想单独强调"断线重连风暴"这一条。服务端重启或者网络抖动的时候,十万客户端同时重连,如果客户端不做退避,重连请求会把服务端打满,然后服务端更慢,更多客户端超时重连,形成雪崩。客户端的重连间隔必须是指数退避的,比如 1s、2s、4s、8s,上限 30s,再加个 ±20% 的随机抖动,避免所有客户端在同一时刻重连。
6.4 我对这套东西的一点个人体会
写了这么多年,我越来越觉得 IM 系统的难点不在于某个技术点有多深,而在于各个环节的边界要划清楚。接入层别管业务,逻辑层别管连接,存储层别管推送。边界一乱,问题就变成一团,出故障的时候根本不知道从哪下手。
还有一个体会是关于"够用"的判断。我早期特别喜欢把方案往复杂里做,什么分布式事务、多机房容灾,结果一个小项目跑了几十台机器,运维成本比业务价值还高。后来慢慢学会了先按最小可用做,等真到了瓶颈再优化。这套骨架在单机十万连接的规模下是够用的,如果你现在才几千用户,完全不必纠结分片和路由表选型。
最后分享一个我们内部一直在用的自检清单,每次上线前过一遍:连接有没有读超时和写超时、channel 满的时候有没有反压、消息有没有服务端分配的 seq、客户端有没有消息去重 ID、路由表有没有 TTL、离线队列有没有长度上限。这六条只要有一条没做,早晚会以事故的形式提醒你。