首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >IM分布式架构系列(14)随手发1句 Hi 要经过几个微服务 | 单聊链路拆解

IM分布式架构系列(14)随手发1句 Hi 要经过几个微服务 | 单聊链路拆解

原创
作者头像
拉丁解牛说技术
发布2026-06-13 19:28:22
发布2026-06-13 19:28:22
1120
举报

在AI时代之前0和9只差9。

但是AI时代下,AI是个放大器,9*100是900;而0*100 依然是个0,0和9的差距将是900!

短视频、爽文、工具流让我们一时看,一时爽!

而真正值得你花10分钟、1小时去阅读的信息,是可以让你增值的内容信息。

扯个题外话:有人问,博主为啥不分享AI实践、这多热门?

先明确回答,这个确实很吸引人,未来我也会分享,to be honest有太多可以分享。但是市场并不缺这样的信息,而且这个时代,也不应该只有AI相关才能被看见以及被热捧。

实用之上是老牛的原则,有用我就发,没流量没人看,我还是会写。

本人目前已经参与并设计研发了多个实用企业级AI系统应用,以及可商业化的产品,但由于写作时间关系,只能一步步陆续开源分享出来,分享太多,大家看的也疲惫、甚至会感觉很AI很水,大家不要被信息过载,请大家持续关注,一起学习讨论,积极拥抱这个时代的进步。而程序员的工作,只剩一个merge动作已经来临,并且我们已经在深度实践,现在每天只剩三件事:喝茶+看报+点merge发布。

一、为什么一条单聊消息要跨这么多服务

二、单聊完整链路拆解

三、某钉 DTIM 的单聊链路设计

四、如何优化提升


一、为什么一条单聊消息要跨这么多服务

发一句「在吗,方便聊下」,从你按下发送到对方手机弹窗消息[老六:发来了一条消息],中间发生了什么?

单机demo时代答案很简单:写一行数据库,对方轮询拉取,完事。但在一个完整的To B产品,需要拆成几十个、上百微服务才能支持复杂业务的分布式 IM 。客户发一条IM消息,一句:hello,会被打包、改写、分配序号、查路由、落库、再投递——一路接力穿过至少 6 个独立部署的服务,跨过 3 到 6 次消息队列。每一跳都是一次网络往返,每一站都可能成为延迟瓶颈或丢消息的断点。

在IM里,消息链路追踪是一件非常重要,但是客户可能看不见的核心功能,今天我们:把散落在各服务里的环节缝合成一条可追踪的端到端链路,把单聊场景下,1条消息的路由路径「这一站做了什么、交接给下一站什么数据」,但是各子系统的内部实现不深挖--文章太长大家看着也累,我尽量长话短说,明白就好,对大家有一点帮助就好,重点是讲清楚它们怎么衔接成一个整体。

1.1 单聊链路在 IM 系统中的位置

单聊链路,就是一条点对点消息从发送方客户端到接收方设备落地的完整服务调用路径。它不是某一个服务,而是贯穿接入层、消息核心、存储、推送四大泳道的一条主干道。IM 里几乎所有功能——群聊、已读、撤回、漫游——都是在这条主干道上做加法。看懂单聊链路,就有了理解整个 IM 后端的骨架。

单聊链路的主干道:四大泳道串成一条路径。

1.2 链路缺一环会怎样

把这条链路拆开看,每一环都不是可有可无的摆设。

没有序号站,消息会乱序。 「吃了吗」和「刚吃完」到达顺序颠倒,对话就读不通。分布式环境下多台机器并发处理同一会话,必须有一个地方给消息盖上单调递增的序号章,让客户端据此排序、去重、补洞。

没有在线/离线分叉,消息会丢或会重。 接收方是在线挂着长连、还是已离线退到后台?两种处理路径完全不同——前者直推长连,后者写离线盒子加三方推送。链路里没有一个明确的分叉决策点,要么离线的人收不到,要么在线的人被重复推送。

没有落库与投递的解耦,可靠性无从谈起。 投递可能失败(弱网、连接刚断),但消息不能因此丢失。链路必须保证「先落库、再投递」——哪怕投递失败,消息也躺在历史库里等下次拉取,把「存得下」和「送得到」拆成两件独立的事。

这三件事,决定了单聊链路为什么必须拆成多个环节、而不能塞进一个服务里一把梭哈。

二、单聊链路拆解

2.1 单聊链路的设计目标

在分析了解一个新功能、系统、架构之前,其实我们都需要知道其背后的设计目标,要解决什么问题。单聊是最为基础的业务场景,该链路要实现的IM目标:

  • 顺序:同一会话的消息,接收方看到的顺序必须和发送方一致。
  • 不丢:消息一旦被服务端接收(回了 ACK),就不能丢,哪怕接收方此刻离线、哪怕投递失败。
  • 低延迟:在线场景的端到端延迟通常压在百毫秒内,超出后用户能明显感到卡。链路越长、MQ 跳数越多,延迟越难控制。
  • 多端一致:同一个人可能同时挂着手机、桌面、Web 三个端,一条消息要让所有在线端都收到,且状态一致。
  • 可降级:链路上任何一个非核心环节(如三方推送、回执聚合)挂掉,不能阻塞主干的消息送达。

这几条里,消息的有序性-保证「顺序」和可靠性-保证「不丢」是IM的底线,而「低延迟」和服务端路由链路长度天然矛盾对立的——这个是单聊链路设计里最核心的张力,解决它需要平衡和取舍。

2.2 接入层:上行落地与 localId 占位

链路的第一站是接入层长连网关(这里叫它 ChatGateway)。消息到达网关时已是一个解过密、解过包的结构化对象,带着发送方 f、接收方 t、消息体 m 等短字段(为压缩传输用了单字母字段名)。

这一站要解决一个容易被忽略的问题:消息 ID 在客户端和服务端是两套体系。 客户端发消息时本地先生成一个 localId,用来在 UI 上立刻把消息显示成「发送中」;而服务端的全局消息标识 msgId 要等到序号站才分配。所以接入层的关键交接动作是: localId 透传下去、并在最终回执里带回来,让客户端能把「服务端确认」对应回「本地那条消息」。

代码语言:javascript
复制
on_upstream_message(channel, raw):
    payload = decode(raw)              // 已解密解包
    payload.from = channel.user_id     // 用连接身份回填发送方,不信客户端自报
    payload.localId = payload.m.localId // 客户端本地 ID,原样透传
    publish(CONVERSATION_TOPIC, payload)  // 关键:投到序号站,不在网关分配 msgId

注意这里有个安全细节:发送方身份 f 用连接已鉴权的身份回填,不信客户端自报的发送方——否则伪造别人发消息就成了漏洞。这一站做完,消息进入第一个 MQ(CONVERSATION_TOPIC),交接给序号站。

2.3 序号站:msgSeq 分配与顺序锚点

序号站(ConversationService)-也叫做发号服务,是链路里第一个「有状态」的环节,它干一件事:给消息在所属会话里盖上一个单调递增的序号 msgSeq,作为接收方排序、去重、补洞的锚点。

为什么序号要单独成站、而不在接入层顺手分配?因为接入层多实例无状态,同一会话的两条消息可能落到不同网关实例上,没法保证单调。序号分配必须收敛到一个能对「同一会话」串行化的独立服务里。具体用全局发号器、会话级锁加号段、还是数据库自增——发号方案本身的取舍是另一个课题,这里只关心交接:分配完 msgSeq,序号站把消息投到第二个 MQ(MSG_ROUTE_TOPIC)交接给路由站。从这里开始,消息带上了它在会话里的「座位号」-占座成功。

2.4 路由站:在线/离线的分叉决策

路由站(RouteService)是整条链路的中枢,它要回答最主要的一个问题:接收方现在能不能直接推到长连? 这个判断决定消息走「在线直推」还是「离线兜底」两条完全不同的路。

判断依据是接收方的路由在线状态——接收方登录时,接入层会把它的「在线路由」(用户 ID → 它挂在哪台网关实例的 host)写进 Redis,形如 online:route:<uid>。路由站查这个 key:

代码语言:javascript
复制
on_route(payload):
    persist_async(payload)             // 先发落库,不阻塞投递决策
    route = redis.get("online:route:" + payload.to)
    if route and route.host:           // 在线:有路由记录
        rpc_push(route.host, payload)  // 直推到接收方所在的网关实例
    else:                              // 离线:无路由记录
        publish(OFFLINE_TOPIC, payload)
        if payload.needPush:
            publish(PUSH_TOPIC, payload)  // 触发三方推送

这里有个常被忽略的双重在线判定问题:「路由在线」和「推送在线」是两套信号。 路由站看 Redis 路由决定能不能直推长连;要不要发三方推送(APNs/FCM)则要查用户中心确认设备是否真离线。一个用户可能 Redis 里还残留路由记录(连接刚断、key 没来得及清),但实际已离线——这时直推会失败,得有兜底。这也是图里离线分支和推送分支分开的原因。

2.5 落库与离线写入的并行处理

很多人以为消息是「投递成功后才存」,其实恰恰相反——落库发生在投递决策之前,且和投递并行。 路由站拿到消息后第一个动作就是异步把消息发往历史存储(HistoryService)。这步和「查在线状态、决定怎么投」并行:不管接收方在不在线、投递成不成功,消息都得先存下来。这就是 2.1 里「先落库、再投递」次序的落地。

接着是离线分支。接收方不在线时,消息要写进收件箱-离线盒子OfflineService)。这里有个数据模型上的关键交接:离线不是「给每个离线用户复制一条消息」,而是按 用户#端 维度维护离线游标和未读计数——记录这个用户在这个会话里读到哪了。用户重新上线时按游标增量补拉。

图 2. 路由站的三路并行:落库(无条件)+ 在线/离线分叉

这一步的设计要点是职责分离:历史库存「会话的完整消息流」,离线盒子存「每个用户的未读状态」。两者解耦后,历史漫游和离线补拉成两套独立逻辑,互不干扰。

2.6 下行投递与链路 ACK 回收

消息走到在线分支,路由站通过 RPC 把它推给接收方所在的那台网关实例(不是任意一台,是 Redis 路由记录指明的那一台),网关在本地连接表里找到接收方的 Channel 把消息写下去。

这里又是一个交接细节:网关实例是有状态的——它只持有连本机的那批连接。 所以路由站必须精确知道接收方连在哪台机器上,不能广播给所有网关。如果接收方刚好做了 Pod 漂移、重连到另一台机器,旧路由记录还没更新,这次直推就会落空,得靠离线兜底补上-这里会滋生新的问题-可以细扣

投递到接收方后链路并没结束。接收方收到消息必须原路回一个 ACK ,让服务端确认「确实送达了」;服务端也向发送方回确认,让那条「发送中」变成「已送达」,这里是不是有点像TCP的三次握手?可靠投递的多层确认,其实是单聊可靠性的核心。

2.7 端到端链路全景

把前面六个步骤-6个站点串起来,一条单聊消息的完整旅程如下:

单聊在线链路全景(离线分支,可以看图 2)

数一下这条链路经过的不同微服务类型:接入网关、序号站、路由站、历史库,在线分支到此 4 类;走离线分支再加离线服务、推送服务,凑齐 6 类。注意接入网关在图里出现两次(发送方网关、接收方网关),但它们是同一种网关服务的收发两端实例,机器不同、服务类型相同,不算两个服务。算上 MQ:在线核心路径 3 次跳,走离线分支最多 6 次。每一跳都是为换取「无状态可扩展」和「职责解耦」付出的延迟成本——这就是分布式 IM 单聊链路的真实账单。

三、某钉 DTIM 的单聊链路设计

我们讲完一般单聊业务的链路,看看某钉 DTIM 在互联网公开分享的「收发链路」。

3.1 Receiver / Processor / 同步服务三段式

按某钉团队公开分享的 DTIM 技术设计,一条消息的收发链路被切成清晰的三段:

第一段 Receiver(接收):统一接入层把发送请求转发到 Receiver。Receiver 做两件事——消息合法性校验(安全审核、群禁言、会话限流)和成员关系校验(单聊校验双方是否能聊、群聊校验发送者是否在群内)。校验通过后 Receiver 为消息生成全局唯一的 MessageId,连同消息体和接收者列表打包投递给异步队列,然后立刻给客户端返回发送成功的回执。这和前面 2.6 讲的回执时机不同——DTIM 把回执提前到了「投递进队列即返回」,不等下游处理完。

第二段 Processor(处理):从队列消费发送事件,先按接收者的地域分布做分流(DTIM 支持跨地域单元化部署),把本地域用户的消息本地存储入库(消息体、接收者维度、已读状态、会话红点一次性写完),再把消息和本域接收者列表打包成「同步事件」转发给同步服务。

第三段 同步服务(投递):按接收者维度写入各自的同步队列,同时查接收方设备在线状态——在线就从队列捞未同步的消息经长连推下去;离线就打包成通知事件转给 PNS 模块,走三方厂商通道推送。

这个三段式的关键在「同步队列」这个抽象:每个接收者有自己的同步队列,无论在线离线,消息都先进队列,在线就实时捞、离线就上线后捞。这把「在线直推」和「离线补拉」统一成了「捞同步队列」一件事——而很多中小项目把这两条路分开实现(在线走 RPC 直推、离线走离线盒子),逻辑上是两套代码。

3.2 DTIM 链路的优势与代价

维度

详情

优势

三段职责边界非常清晰,回执前置(投递进队列即返回)降低发送方感知延迟;同步队列直接把在线/离线两条路统一了,实现了逻辑收敛;地域分流支持跨域单元化,这种架构,天然适配超大规模,适应全球化、区域化扩展。

代价

同步服务按接收者维度维护队列,存储和写入成本随用户规模线性增长,对存储选型要求高(DTIM 依赖高性能 NoSQL 存储,如表格存储这类服务);回执前置意味着「服务端收下了」不等于「已落库」,对队列可靠性要求极高;整套模型实现复杂度远超中小项目所需;

DTIM 是奔着「企业级、海量、跨地域」去的,「同步队列」模型在大规模下优势明显,但对中小项目来说,为每个用户维护同步队列的存储成本和实现复杂度往往超出实际需要。中小项目的「在线 RPC 直推 + 离线盒子」两条路虽是两套代码,但实现直接、问题好定位。

四、如何优化提升

4.1 链路分段的硬决策

在做这个架构设计的时候,考虑链路切成几段、每段放什么,本质是「无状态可扩展」和「延迟」的取舍。每多切一段、多过一次 MQ,就多一次网络往返、多一个可能丢消息的断点;但合并环节又会牺牲扩展性和职责清晰度。判断标准有两条:一是看这个环节是否需要有状态串行化(如序号分配必须独立,要对同一会话串行);二是看它是否需要独立扩缩容(如三方推送吞吐和核心链路差异大,独立部署才好单独扩)。

4.2 把回执前置到接入层

我们前面说「投递成功后回执」,但 DTIM 给了另一个思路:回执前置到消息进入异步队列就返回。 发送方关心的是「服务端收下了、不会丢」,而非「对方收到了」。把发送回执提前到「落了 MQ 就返回」,能显著降低发送方感知的延迟(省掉等路由、等投递的时间)。代价是要保证那个 MQ 足够可靠——一旦回了「成功」却在队列里丢了,就是真丢消息。这是个值得权衡的折中:规模不大、对延迟不敏感的项目维持「投递后回执」更简单可靠;体验要求高的可以考虑前置。

4.4 全链路可观测

单聊链路最难排查的问题,是「消息延迟了但没丢」和「偶发丢一条」——它们都发生在跨服务的缝隙里。关键是 traceId 必须穿透 MQ:链路里有 3 到 6 次 MQ 投递,traceId 若在投递时没塞进消息属性、消费时没取出来续上,链路在 MQ 这一跳就断了,后面的日志全成孤儿。除了 traceId,还要在每一站埋「入站时间戳」,才能算出每段耗时,一眼看出是序号站的锁卡了、还是路由站的 Redis 慢了。可观测这个事情,越早做越好。

4.5 链路的未来瓶颈

这套「网关 + 序号 + 路由 + 存储」的链路,在单地域、百万级量级以内通常够用,具体临界点受服务器规格、MQ 吞吐、锁竞争影响。撑不住的临界点主要有两个:一是跨地域——用户分布在多个地域时,单一路由站要跨域查在线状态、跨域投递,延迟和复杂度都失控,这时要往单元化(按用户归属地域分单元、消息在单元内闭环)演进,参考 DTIM 的地域分流。二是单会话超高并发——序号站的会话锁在极热会话(如百万人的直播间弹幕)下会成为瓶颈,这时单调序号这个约束本身就得放松,改用更弱的排序保证(只保证大致有序、靠客户端二次排序)。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、为什么一条单聊消息要跨这么多服务
    • 1.1 单聊链路在 IM 系统中的位置
    • 1.2 链路缺一环会怎样
  • 二、单聊链路拆解
    • 2.1 单聊链路的设计目标
    • 2.2 接入层:上行落地与 localId 占位
    • 2.3 序号站:msgSeq 分配与顺序锚点
    • 2.4 路由站:在线/离线的分叉决策
    • 2.5 落库与离线写入的并行处理
    • 2.6 下行投递与链路 ACK 回收
    • 2.7 端到端链路全景
  • 三、某钉 DTIM 的单聊链路设计
    • 3.1 Receiver / Processor / 同步服务三段式
    • 3.2 DTIM 链路的优势与代价
  • 四、如何优化提升
    • 4.1 链路分段的硬决策
    • 4.2 把回执前置到接入层
    • 4.4 全链路可观测
    • 4.5 链路的未来瓶颈
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档