文章 / 2026-10

自研 IM 网关,最难的不是连上,是扩容

连接管理、消息路由、投递语义与多端同步——四个环节里,真正会让人半夜爬起来的是最后那个。

单机十万连接,用 Netty 写一个能跑的网关,说实话不难。难的是一年以后要加机器,而那时候会话状态已经散在内存里了。

这篇记一下我在做网关时反复权衡的几个点,不讲具体实现,只讲为什么这么选。

一、连接层:谁持有连接

有两种做法:

网关无状态,连接只做转发,所有会话数据放 Redis。扩容最轻松——加一台机器,连上来就行。代价是每条消息都要多跳一次网络,延迟和 Redis 的负载都上去了。

网关有状态,会话上下文就在本机内存里。延迟低、吞吐高,但扩容时要回答一个问题:这台新机器上的连接,怎么找到原来跟他聊天的那个人的会话?

我最后选的是中间路线:连接状态本地、业务状态外置。谁连着哪台网关记录的是一张路由表,用户的会话、未读数这些放共享存储。这样单机延迟不受影响,扩容时要做的是迁移路由,而不是搬整个会话。

二、消息投递:至少一次,还是恰好一次

“恰好一次”是个很诱人的说法,代价是没有上限的复杂度和延迟。IM 场景里其实不需要它——消息重复可以靠客户端去重,消息丢失才是致命的。

所以选择很明确:用带序列号的”至少一次”投递,客户端按会话内的单调递增序号去重和补洞。

envelope = {
  convId,        // 会话
  seq,           // 会话内单调递增
  senderId,
  payload,
  serverTime
}

这个 seq 是整个系统的核心。有了它:

  • 乱序到达,客户端能自己排序
  • 中间丢了一条,客户端能发现并拉历史补齐
  • 多端同步时,可以直接用”我最后收到的 seq”作为增量拉取游标

没有它,就得靠时间戳——而时间戳在分布式里几乎一定不准。

三、多端同步:游标比推送更靠得住

同一账号在手机、平板、PC 同时在线。已读状态、消息补推、未读数,这三样最容易做乱。

我踩过的坑是:只做在线推送,不做离线拉取。用户在一台设备上读了消息,另一台没收到已读回执,未读数就永远对不上。

后来统一成一条规则:推送只负责”提醒有新东西”,真相永远从游标拉取。

每条连接上都记一个 lastSeq。上线时先按游标拉一次增量,之后收到的推送只当作触发器,真正写进本地库的内容仍然走拉取。这样不管推送丢没丢、顺序对不对,最终状态都是收敛的。

四、扩容时真正会出问题的地方

前面说的都是设计,实际迁移时折磨人的是这些:

路由表的瞬间不一致。 新机器加入、老连接迁移的这段时间,路由表里同一用户可能同时指向两个节点。消息会丢,或者重复投到两个地方。做法是迁移期间对目标用户加一个短租约,写入方必须先确认归属再发。

心跳和判活的误判。 网络抖动导致的短暂掉线,如果立刻判定用户离线并触发离线推送,用户会收到一堆重复通知。给判活加一个缓冲窗口,比追求”实时准确”更实用。

连接数不等于负载。 一万个连接如果都在安静地挂着,负载很低;一千个连接如果都在高频发消息,机器先倒。扩容指标得看消息速率,不能只看连接数。

灰度顺序。 先扩一台,观察路由表收敛情况,再批量。这一步如果跳过,很容易在半夜对着一堆”用户收不到消息”的工单。

小结

回头看,这个项目教我的不是怎么把连接数做上去,而是:任何要扩容的系统,第一天就得想清楚状态放在哪。放在内存里的东西,扩容那天都要还回来。

写得再快也没用,能不能顺着加机器才是分水岭。