先交代一个背景:我做这个消息转发子服务的时候,项目已经跑了大半年,最初的版本里转发逻辑是直接写在网关里的。当时觉得天经地义——用户连上 WebSocket 网关,A 给 B 发消息,网关查一下 B 在哪个连接上,直接推过去,完事。直到网关从 1 台扩到 8 台,线上开始出现"为什么 B 明明在线却收不到消息"的工单,我才意识到一个问题:当系统从单机长连接变成微服务架构下的多网关集群时,"找 B 在哪儿"这件事本身,已经复杂到必须有一个独立的子服务来专门负责。
这篇就当是复盘。我会把消息转发子服务的设计动机、完整链路、核心代码、以及我在实际联调和压测中踩过的坑全部展开讲。适合正在做即时通讯相关功能的团队参考,也适合刚接触 Spring Cloud 微服务、想知道消息模块该怎么拆的人。内容不绕弯子,直接说方案和理由。
1. 消息转发子服务到底在 IM 架构里扛什么活
1.1 从网关内转发拆出来的真正原因
很多人觉得"转发"不就是把消息从 A 的网关推到 B 的网关吗,为什么非要多一个服务?我先说说在网关里直接转发会遇到什么。
网关这个节点的本质是有状态的长连接管理。一个 Netty 进程挂了,它上面挂的几万个连接就全断,所以网关必须是无状态可水平扩展的——连接被路由到哪个网关,是由负载均衡决定的,用户自己不知道也不关心。但如果把转发逻辑写在网关里,问题就来了:A 和 B 可能不在同一个网关实例上,网关 A 根本不知道 B 连在哪个实例。
早期有人用广播解决:A 的网关广播"这条消息给 B",所有网关各自查本地连接,谁有 B 谁推。听起来简单,但一旦群里几十条消息、几百个在线成员,广播风暴能把整个集群打垮。而且广播只能解决"在线转发",解决不了离线消息存储、多端同步、消息回执、重复消息去重这些一连串问题。
所以拆出单独的消息转发子服务,本质原因是:网关只做"连接管理和字节流收发",把消息的"路由决策 + 投递调度 + 可靠性兜底"集中到一个独立服务里。这样网关可以继续做薄,转发服务独立扩缩容,聊天的业务逻辑也不和长连接生命周期耦合。
1.2 转发服务要负责的事情清单
拆出来之后,转发服务不是简单的"查路由然后推消息",它实际承担了这些职责:
- 会话路由:维护"用户 ID -> 当前连接的网关实例 + 连接 ID"的映射关系,并在连接建立/断开时更新。
- 在线投递:把消息推到接收方当前所在的网关,由网关写回连接。
- 离线补偿:接收方不在线时,消息进入离线存储,登录后再拉取。
- 多端同步:同一个账号在手机、PC、Web 多端在线时,消息要按设备维度 fan-out。
- 消息回执:接收端 ack 后更新投递状态,超时未 ack 的需要重试。
- 幂等去重:网络抖动导致生产者重发时,消费者侧不能重复落库、不能重复推送。
- 顺序保证:同一个会话内的消息,到达顺序不能乱。
这一串职责如果堆在网关里,网关代码会膨胀到没法维护。拆成独立服务后,每个职责都能用独立的模块去实现、测试、压测,出问题也容易定位。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 链路剖析:一条消息从发送端到接收端的完整旅程
2.1 会话路由表:转发服务的心脏
转发服务要工作,第一件事是知道"谁在哪个网关"。我这边用 Redis 存路由表,结构很简单:
code复制route:user:{userId} -> { gatewayId: "gw-3", connId: "conn-8f3a...", device: "android" }
key 是用户维度,value 是当前有效的连接信息。这里有个关键细节:连接信息不是登录时写一次就完事的。用户的网络会切换、网关会重启、连接会被服务端主动断开,路由表必须跟随连接生命周期实时变更。
我的做法是两段式更新:
- 客户端连接上网关后,网关向转发服务上报
connect事件;转发服务写 Redis。 - 客户端断开、或者网关心跳发现连接死了,网关上报
disconnect事件;转发服务删 Redis 记录。
网关上报用的是内部 MQ,不直接调转发服务的接口。因为网关可能在同一瞬间有大量连接变化(比如网关重启,几万连接同时断开),直接 HTTP 调用会把转发服务打挂,走 MQ 可以削峰。同时每一条 Redis 记录都带 TTL,比如 90 秒,网关侧每 30 秒续期一次。这样即使 disconnect 事件丢失,记录也会自动过期,不会出现"用户早断了,路由表还指向旧连接"的脏数据。
2.2 在线、离线、多端三种形态的处理差异
消息进入转发服务后,第一件事是查路由表,但结果不一定只有"有连接"和"没连接"两种,实际会分三档:
在线且单端。路由表只有一条记录,直接把消息投递给对应网关。网关根据 connId 找到连接,写回客户端。这是最简单的情况。
在线但多端。路由表里有手机、PC 两条记录。转发服务要把消息复制成两份,分别投递到两个网关。这里需要注意:不同端的推送通道可能不同,手机端可能走 WebSocket,也可能走 APNs/厂商推送,而 PC 端只走 WebSocket。转发服务不能假设所有端都是同一通道,需要根据 device 字段选择投递方式。
离线。路由表查不到有效记录。消息不能丢,要进入离线消息存储。我这里用的是 MySQL 一张 offline_msg 表,按接收方用户 ID 分表,字段包括 msg_id、from_user、to_user、content、create_time。客户端登录成功后,转发服务检测到连接建立事件,异步把离线消息捞出来,按会话分组成批推给客户端。
这里有个经验之谈:离线消息不要发一条推一条。用户离线一天可能积压几百条消息,登录瞬间几百个推送同时打到网关上,网关要逐个写连接,容易把连接写阻塞。我是攒批 + 按会话合并的,比如同一会话有 50 条没读,那就推一条"会话消息批量同步"的通知,客户端自己拉取,拉取接口再走分页。
2.3 存储选型分工:Redis、Kafka、MySQL 各管一段
很多初学者会把"消息存哪里"当成一个问题来问,实际上 IM 链路里不同角色对存储的要求完全不同,要拆开看。我在这个子服务里的选型是:
| 存储 | 负责内容 | 选型理由 |
|---|---|---|
| Redis | 会话路由表、消息去重、未读数 | 读多写少、要求微秒级延迟,Redis 的 SETNX 和 TTL 正好覆盖幂等和过期场景 |
| Kafka | 网关与转发服务之间的消息管道 | 需要削峰、需要按会话分区保证顺序、需要消费位移管理和重放能力 |
| MySQL | 离线消息、消息历史、回执状态 | 需要事务、需要按用户分表查询,关系模型最适合做条件检索 |
这三个是配合关系,不是替代关系。有个常见误区是"把消息全放 Redis,反正内存快"。但消息历史是持续增长的,Redis 的内存成本扛不住;而且 Redis 主从切换时可能丢数据,消息这种不能丢的可靠性要求,还是要落到 Kafka 的持久化和 MySQL 的事务上。
3. 核心实现拆解:Spring Cloud + Netty + Kafka 的协作方式
3.1 接入层:网关为什么必须和转发服务分开部署
在 Spring Cloud 体系里,网关用了 Netty 做 WebSocket 服务端,注册到 Nacos,转发服务是独立的 Spring Boot 应用。网关和转发服务之间不直接 RPC,而是通过 Kafka 通信,整个链路是:
code复制客户端A <--WebSocket--> 网关A --> Kafka(topic: im_msg_input) --> 转发服务 --> 查Redis路由表 --> Kafka(topic: im_msg_deliver) --> 网关B --WebSocket--> 客户端B
网关侧的关键代码是连接注册。Netty 的 ChannelHandler 里,连接建立后生成 connId,并把连接信息上报:
java复制@ChannelHandler.Sharable
public class WebSocketServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Override
public void channelActive(ChannelHandlerContext ctx) {
String connId = UUID.randomUUID().toString().replace("-", "");
ctx.channel().attr(AttributeKey.valueOf("connId")).set(connId);
// 上报连接建立事件,走 Kafka,避免高并发连接风暴打垮转发服务
ConnectionEvent event = ConnectionEvent.connect(connId, userId, deviceType);
kafkaTemplate.send(TOPIC_CONNECTION_EVENT, userId.toString(), event);
}
}
这里一定要强调:连接事件和业务消息必须分开 topic。我见过有团队把连接事件和消息混在一个 topic 里,结果消费端逻辑一堆 if 分支,而且连接事件的量级和消息完全不在一个维度,混在一起会导致分区分配不均匀。
3.2 路由层:Redis 路由表的读写与容错
转发服务消费连接事件,维护路由表。写入逻辑很简单:
java复制public void onConnect(ConnectionEvent event) {
String routeKey = "route:user:" + event.getUserId();
RouteInfo routeInfo = new RouteInfo(event.getGatewayId(), event.getConnId(), event.getDeviceType());
redisTemplate.opsForValue().set(routeKey, JSON.toJSONString(routeInfo), 90, TimeUnit.SECONDS);
}
但只写不行,还得防两个问题。第一个问题是错乱覆盖:用户在新设备登录时,旧设备的断开事件可能晚于新设备的连接事件到达,如果旧事件直接把新记录覆盖掉,消息就全推给旧设备了。解决方式是在连接事件里带 loginTime,Redis 写入时用 Lua 脚本比较,只有新事件的 loginTime 更大才允许覆盖:
lua复制local current = redis.call('get', KEYS[1])
if current then
local curLoginTime = cjson.decode(current).loginTime
if tonumber(ARGV[1]) < tonumber(curLoginTime) then
return 0
end
end
redis.call('set', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
第二个问题是网关重启后的路由残留。网关重启时来不及发 disconnect 事件(进程直接没了),Redis 里的路由记录会残留到 TTL 过期。这个靠 TTL 兜底就够了,90 秒的窗口内消息可能投递到死连接,网关 B 收到投递请求后发现自己没有这个 connId,会返回"连接不存在",转发服务就可以把这条路由标记失效并重投到其他在线端。后面第 4 节我会详细讲这个排查过程。
3.3 转发层:按会话分区的消费与投递
转发服务消费 im_msg_input 时,最核心的一个决定是 Kafka 分区键的选取。我用的是会话 ID(conversationId),而不是用户 ID。原因是同一个会话内的消息必须有序,而 Kafka 只能保证单个分区内有序。用 conversationId 做 partition key,同一个会话的所有消息都会进同一个分区,由同一个消费者线程处理,天然保证了这个会话内的顺序。
消费端的伪代码是这样的:
java复制@KafkaListener(topics = "im_msg_input", concurrency = "6")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
ImMessage message = JSON.parseObject(record.value(), ImMessage.class);
// 幂等去重:消息 ID 在 Redis 里已存在则跳过
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("dedup:msg:" + message.getMsgId(), "1", 24, TimeUnit.HOURS);
if (first == null || !first) {
ack.acknowledge();
return;
}
// 查接收方路由
List<RouteInfo> routes = queryRoutes(message.getToUserId());
if (routes.isEmpty()) {
// 离线,存离线表
offlineMessageService.save(message);
} else {
for (RouteInfo route : routes) {
// 按连接维度投递到目标网关
kafkaTemplate.send("im_msg_deliver", route.getConnId(), buildDeliverPayload(message, route));
}
}
// 处理成功后再提交位移
ack.acknowledge();
}
这里有两个细节值得提。第一,投递到 im_msg_deliver 的 partition key 用的是 connId 而不是 userId,这样同一个连接的消息会进同一分区,网关侧消费时天然按连接聚合,写连接的顺序也保持一致。第二,位移提交必须在业务处理成功之后,也就是 ack.acknowledge() 放在最后。如果先提交位移再查路由,查询失败时消息就丢了;如果不提交位移,处理失败会重复消费——所以处理逻辑本身要幂等,Redis 去重那一层就是干这个的。
4. 实测踩坑记录:丢消息、乱序、重复投递的完整排查链路
4.1 Kafka rebalance 引发的重复消费:如何用幂等兜底
第一次压测时就撞上了这个问题。当时 3 个转发服务实例消费 im_msg_input,压测脚本持续灌消息,突然有个实例 Full GC 超过 60 秒,触发了 Kafka 的 session.timeout.ms,消费者被踢出消费组,触发 rebalance。重平衡期间,原本这个实例消费的分区被分配给其他实例,但位移还停在原地,等原实例恢复后,有一批已经消费但没来得及提交位移的消息又被重新投递出来。
线上表现就是:接收端收到了重复消息,而且因为是压测消息没有业务去重,直接暴露了。这个问题的根因不是"Kafka 丢消息",而是 at-least-once 语义下重放是正常现象,处理方必须有幂等能力。
我的处理方案分两层:
- 消费侧 Redis 去重:用
SETNX msgId判重,命中直接跳过并提交位移,这个前面代码里已经有。 - 存储侧唯一索引:离线消息表和历史消息表都建
msg_id唯一索引,即使 Redis 去重失效,数据库也能挡住重复落库。
压测环境里我还特意模拟过"处理到一半抛异常"的场景:先查路由、写离线表,然后在投递 Kafka 前主动抛异常,验证重放后不会产生重复的离线记录。只有两层都过了,才敢说这个链路是真的抗重复。
4.2 WebSocket 连接漂移导致的路由错乱
这个坑是在联调时发现的,现象特别诡异:用户 B 明明在线,消息却偶尔收不到,过几秒又好了。排查链路从路由表查起。
第一步,查 Redis,route:user:B 显示的 gatewayId 是 gw-2。第二步,查 gw-2 的日志,确实收到了投递请求,但日志里报 connId not found。第三步,查 B 实际连的是哪个网关,发现是 gw-5。也就是说:B 的连接已经从 gw-2 漂移到 gw-5,但 Redis 里的路由记录还停留在 gw-2。
根因是负载均衡层的空闲超时。客户端通过 Nginx 连接网关,Nginx 的 proxy_read_timeout 设置的是 120 秒,B 的客户端因为是测试工具,没有发心跳,连接被 Nginx 静默断开。但断开是 Nginx 干的,网关侧感知不到 TCP 断开(没收到 FIN),所以网关没有上报 disconnect 事件,Redis 路由就成了脏数据。等 B 重新连上,实际连到了 gw-5,但 gw-5 是新建连接,它是会上报 connect 事件的啊——问题就出在顺序上:gw-5 的 connect 事件可能先到,gw-2 的补充心跳超时检测后到,后者反而覆盖了前者的新路由。
修复方案是把心跳和路由续期做成一套机制:
- 客户端每 30 秒发一次业务心跳,网关每 60 秒扫描一次连接,超过 90 秒没收到心跳的连接主动关闭并发 disconnect 事件。
- 路由续期由转发服务的定时任务来做,不给网关侧续期,避免不同的网关实例对同一用户各自续期导致覆盖。
- connect/disconnect 事件都带连接建立时间戳,Redis 里用 Lua 脚本比较时间戳,旧连接的事件不允许覆盖新连接。
这套修完之后,连接漂移导致的"收不到消息"工单基本清零。
4.3 多端在线时的消息乱序问题
多端同步时我遇到一个更隐蔽的问题:PC 和手机同时在线,PC 收消息正常,手机偶尔出现两条消息顺序颠倒。
第一反应是 Kafka 分区的问题。查下去发现,我在投递到 im_msg_deliver 时 partition key 用的是 connId,每台设备的 connId 不同,所以手机和 PC 的消息进的是不同分区,这没问题。但问题是在转发服务内部,我先查路由拿到两个 connId,然后在一个 for 循环里并发发送了两条 Kafka 消息。同一个会话的连续两条消息,在 dev 环境单分区时没事,上到多分区后,两条消息可能被分发到不同分区,网关消费时就不保证顺序了。
那是不是投递 Kafka 也要用 conversationId 做 key?不行。前面我说过投递 topic 按 connId 分区是为了网关写连接有序,同一个用户的两个 connId 天然是不同分区,没法保证跨连接顺序。真正的解法是:转发服务在给一个用户的所有连接投递消息时,要用串行方式,并且消息里带上服务器分配的会话序号。
具体做法:
- 每条会话消息进入转发服务时,从 Redis 的
conversation_seq:{conversationId}递增取号,写进消息体。 - 投递到网关时加一个用户级锁(用 Redis 分布式锁按
{userId}加锁),保证同一个用户的消息在转发服务内是串行产生的。 - 客户端侧按会话序号排序渲染,如果发现序号不连续,主动向服务端请求补拉。
这里有个取舍:用户级锁会限制单用户的吞吐,但一个用户一条消息的产生频率本来就不高,用锁换来的是全局不乱序,值得。
5. 性能调优与压测结果
5.1 转发服务的关键指标:吞吐、延迟、消息积压
压测之前,先把目标定清楚。我们当时的业务场景是万级 DAU、日消息量百万级,峰值 QPS 撑到 2000 就够了。但由于转发服务在整条链路中间,有两个指标比普通服务更敏感:
- 端到端延迟:从 A 发消息到 B 收到消息的耗时,要求 P99 小于 500ms。
- 消费积压:
im_msg_input和im_msg_deliver的 consumer lag 必须接近 0,一旦出现积压,等于消息在路上堵车了。
压测工具用的 JMeter + WebSocket 插件,模拟 2000 个客户端并发收发。第一次压测结果很不理想,P99 延迟到了 1.2 秒,积压最严重时 Kafka lag 到了几万条。定位后发现瓶颈不在转发服务本身,而是网关写连接时的串行化——每个 Netty worker 线程处理写请求时用了同步锁,高并发下锁竞争严重。改成分连接粒度加锁(每个 connId 一把独立的锁),P99 直接降到 180ms。
5.2 Netty 线程模型与 Kafka 分区数的匹配
网关侧的调优经验是:Netty 的 worker 线程数要大于 Kafka 消费线程数。如果消费线程把消息取出来,但 worker 线程不够,消息会在队列里排队,间接表现为延迟升高。我的配置是 8 核机器,Netty boss 线程 1、worker 线程 12,Kafka 消费并发 6,这样消费和写连接之间有足够的并行度。
Kafka 的分区数也要提前规划。im_msg_input 我用 12 个分区,im_msg_deliver 用 24 个分区。为什么投递 topic 的分区更多?因为投递的消息量是输入消息的 fan-out 倍数(多端、群聊都会放大),分区数不够会导致单分区数据量过大,拉取和落盘都有压力。分区数尽量是消费实例数的整数倍,我 3 个消费实例,12 个分区就是每实例 4 个分区,分配均匀。
5.3 背压与限流:转发服务不能无脑冲
转发服务是个中间节点,如果下游网关阻塞了,上游还拼命灌,消息只会越积越多。我在两个地方做了兜底:
- 投递侧熔断:转发服务记录每个网关实例最近的投递失败率,如果某个网关连续失败超过阈值(比如 30 秒内失败率超过 50%),就把它标记为不健康,后续投递直接走离线补偿,不再往这个网关发。网关恢复后再自动解除。
- 写 Redis 的限流:路由更新走 Kafka 消费,消费速度由分区数决定,不会出现瞬时打爆 Redis 的情况,但离线消息落库是直接写 MySQL 的,所以离线消息批量插入用了分片分批策略,每批 200 条,避免高峰期大事务锁表。
压测最终数据:2000 并发下,转发服务 CPU 稳定在 45% 左右,端到端 P99 延迟 210ms,无消息积压,单实例能扛住 1500 QPS 的消息转发。
6. 上线后必须盯住的几个运维指标
6.1 转发服务的监控大盘
转发服务不像网关那么直观,网关挂没挂看连接数就行,转发服务要看的是一组指标的组合。我上线后 Grafana 大盘上保留了这几个面板:
- Kafka 消费 lag:
im_msg_input和im_msg_deliver各一张图,lag 持续上涨就是第一报警信号。 - 路由表命中率:转发消息时"查路由找到在线连接"的占比。如果命中率突然下降,优先怀疑心跳机制或连接断连上报有问题。
- 离线消息积压量:
offline_msg表的当日新增量。这个指标异常通常意味着大量用户连接异常,或者网关大面积故障。 - P99 端到端延迟:从转发服务收到消息到投递成功的耗时,超过 1 秒就要查。
告警阈值我调过两轮。最初 lag 超过 100 就告警,结果 Kafka 抖动一下也会触发,告警疲劳。后来改成连续 3 个采集周期 lag 都超过 1000 才告警,既不会漏报也不会频繁打扰。
6.2 消息积压的应急处理流程
最后分享一个实战预案。一旦监控发现 im_msg_input 积压严重,不要第一时间去重启消费实例——重启只会触发 rebalance,反而加剧抖动。我的处理顺序是:
- 确认是消费变慢还是生产暴涨:先看消费实例的 CPU 和 GC。CPU 高但 lag 在涨,是消费逻辑有问题;CPU 正常但 lag 在涨,往往是生产端突发流量,比如某个大群在刷屏。
- 如果是生产暴涨,紧急扩容消费实例:新实例启动会自动触发 rebalance,把分区均摊。我经历过 3 实例扩到 6 实例,lag 在 10 分钟内从 5 万降到 0。
- 如果是消费变慢,先抓线程栈:
jstack看消费者线程卡在哪个调用上,最常见的卡点是查 Redis 慢查询或者离线落库锁等待。 - 应急扩容后一定要复盘:积压消息消费完,不代表问题结束,要查是什么动作导致消费变慢,把根因修掉。
这套流程在线上立功过两次,一次是促销活动群里消息量暴增,一次是 Redis 主从切换导致路由查询毛刺。靠的都是预案而不是临场救火。
消息转发子服务这个模块,技术栈并不复杂,复杂的是链路里那些"没想到"的边界情况。如果你也在搭类似的 IM 微服务架构,我建议从路由表的设计和消息幂等这两件事入手,先把这两个问题想透,再写代码,会少走很多弯路。
