自研 IM 网关,最难的不是连上,是扩容
连接管理、消息路由、投递语义与多端同步——四个环节里,真正会让人半夜爬起来的是最后那个。
单机十万连接,用 Netty 写一个能跑的网关,说实话不难。难的是一年以后要加机器,而那时候会话状态已经散在内存里了。
这篇记一下我在做网关时反复权衡的几个点,不讲具体实现,只讲为什么这么选。
一、连接层:谁持有连接
有两种做法:
网关无状态,连接只做转发,所有会话数据放 Redis。扩容最轻松——加一台机器,连上来就行。代价是每条消息都要多跳一次网络,延迟和 Redis 的负载都上去了。
网关有状态,会话上下文就在本机内存里。延迟低、吞吐高,但扩容时要回答一个问题:这台新机器上的连接,怎么找到原来跟他聊天的那个人的会话?
我最后选的是中间路线:连接状态本地、业务状态外置。谁连着哪台网关记录的是一张路由表,用户的会话、未读数这些放共享存储。这样单机延迟不受影响,扩容时要做的是迁移路由,而不是搬整个会话。
二、消息投递:至少一次,还是恰好一次
“恰好一次”是个很诱人的说法,代价是没有上限的复杂度和延迟。IM 场景里其实不需要它——消息重复可以靠客户端去重,消息丢失才是致命的。
所以选择很明确:用带序列号的”至少一次”投递,客户端按会话内的单调递增序号去重和补洞。
envelope = {
convId, // 会话
seq, // 会话内单调递增
senderId,
payload,
serverTime
}
这个 seq 是整个系统的核心。有了它:
- 乱序到达,客户端能自己排序
- 中间丢了一条,客户端能发现并拉历史补齐
- 多端同步时,可以直接用”我最后收到的 seq”作为增量拉取游标
没有它,就得靠时间戳——而时间戳在分布式里几乎一定不准。
三、多端同步:游标比推送更靠得住
同一账号在手机、平板、PC 同时在线。已读状态、消息补推、未读数,这三样最容易做乱。
我踩过的坑是:只做在线推送,不做离线拉取。用户在一台设备上读了消息,另一台没收到已读回执,未读数就永远对不上。
后来统一成一条规则:推送只负责”提醒有新东西”,真相永远从游标拉取。
每条连接上都记一个 lastSeq。上线时先按游标拉一次增量,之后收到的推送只当作触发器,真正写进本地库的内容仍然走拉取。这样不管推送丢没丢、顺序对不对,最终状态都是收敛的。
四、扩容时真正会出问题的地方
前面说的都是设计,实际迁移时折磨人的是这些:
路由表的瞬间不一致。 新机器加入、老连接迁移的这段时间,路由表里同一用户可能同时指向两个节点。消息会丢,或者重复投到两个地方。做法是迁移期间对目标用户加一个短租约,写入方必须先确认归属再发。
心跳和判活的误判。 网络抖动导致的短暂掉线,如果立刻判定用户离线并触发离线推送,用户会收到一堆重复通知。给判活加一个缓冲窗口,比追求”实时准确”更实用。
连接数不等于负载。 一万个连接如果都在安静地挂着,负载很低;一千个连接如果都在高频发消息,机器先倒。扩容指标得看消息速率,不能只看连接数。
灰度顺序。 先扩一台,观察路由表收敛情况,再批量。这一步如果跳过,很容易在半夜对着一堆”用户收不到消息”的工单。
小结
回头看,这个项目教我的不是怎么把连接数做上去,而是:任何要扩容的系统,第一天就得想清楚状态放在哪。放在内存里的东西,扩容那天都要还回来。
写得再快也没用,能不能顺着加机器才是分水岭。