首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >IM分布式架构系列(07) 消息上行那N毫秒 | 客户端到接入层有几道关

IM分布式架构系列(07) 消息上行那N毫秒 | 客户端到接入层有几道关

原创
作者头像
拉丁解牛说技术
发布2026-05-29 18:33:29
发布2026-05-29 18:33:29
290
举报

最喜欢苏东坡的这两句话:竹杖芒鞋轻胜马,谁怕?一蓑烟雨任平生,回首向来萧瑟处,归去,也无风雨也无晴

人是可以有"定力"的。不是麻木,不是逃避,是在看清生活所有的残酷之后,依然选择好好活、有滋有味地活

这种豁达,不是天生的乐观。是被生活反复暴击之后,长出来的本事。


一、消息上行最开始的那N毫秒发生了什么

二、上行那一秒走过的几道关

三、大厂如何做


一、消息上行最开始那N毫秒发生了什么?

今天讲讲从用户点"发送"到消息抵达接入层第一个微服务这段路,平均耗时大概10-200ms,这期间:客户端 SDK 怎么拼包、走哪个通道、怎么解析域名、TLS 怎么握、鉴权怎么过、Channel 怎么"落位"。每道关里都有一两件事值得我们去盘算。

1.1 上行链路在 IM 系统里的位置

图 1. 上行那一秒走过的六道关

上行的最开始这块,有个问题要留意:就是弱网、切网、后台、被劫持、被限流的状况下,要么上行成功,要么把失败告知用户。决不允许发生"消息丢了但用户不知道",这是 IM 最大的负面体验,比"没发出去"还糟糕。

1.2 这最开始的1秒决定了一半的用户体验

这一秒的关键指标有两个:上行成功率上行延迟

我们做这一块第一反应几乎都是"加重试、加超时",最容易忽略的是 DNS 解析失败、TLS 握手失败、token 过期、端口被防火墙拦掉——这几类在弱网用户里频率比"网络丢包"高得多,靠重试是修不好的,只是把同一个失败重复一遍。

二、上行那一秒走过的几道关

2.1 设计这一关的硬约束

  • 网络不可控——弱网 / 切网 / NAT 超时 / DNS 劫持,客户端能假设的只有"网络随时会变"
  • 失败要可恢复——任何一关失败 SDK 都得有兜底,不能让"DNS 解析失败 = 消息丢"
  • 延迟有目标——端到端 P99 几百毫秒内是常见口径,这一秒的握手 / 解析 / 鉴权加起来不能超过这个目标阈值

2.2 客户端 SDK 拼包与本地落盘

用户点"发送"那一刻,SDK 做的第一件事不是发包,而是先把消息按本地序号、消息 ID、时间戳记录到本地数据库。没有这一步,失败就没法重发、断网就没法续传、UI 也立不起"发送中"气泡。

接着是拼包。一条上行消息大致是:协议头(magic + version + length + checksum) + 鉴权字段(token / session) + 业务体。业务体序列化格式有取舍:

格式

优势

代价

JSON

可读、调试方便、跨语言无痛

体积大、字段名重复占用、解析慢

Protobuf

体积小(可省 30%~50%)、解析快、强类型

调试需要 .proto、跨版本兼容要小心字段编号

自定义二进制

体积最小、贴合业务

维护成本高、跨平台对齐麻烦、扩展难

我们的经验是:初创期直接用Protobuf。自定义二进制只在某信那种规模、要榨干每字节的项目里才值得做——中等规模 toB IM 用 Protobuf 已经够,加压缩(gzip / zstd)能再拿一截。

接下来是上行队列。SDK 内部 FIFO 队列,本地落盘消息按序进队,网络可用时按序出队。两个细节容易被忽略:断网时不阻塞用户(状态走"发送中",不卡输入框)、重启不丢消息(队列持久化到本地数据库)。

代码语言:javascript
复制
on_user_send(content):
    msg = build_message(content)
    msg.local_id = generate_local_id()// 客户端本地 ID,服务端拿到后映射成 msgSeq
    msg.client_ts = now()
    db.save(msg, status=PENDING)// 关键:先落盘,后发送
    show_ui(msg, status=SENDING)
    upload_queue.enqueue(msg)

最容易踩的坑:消息 ID 用服务端生成,客户端只用 local_id 做去重映射。这条错过了,网络抖动重传时服务端拿不到稳定客户端 ID,就只能靠业务字段去重,代价巨大。

2.3 接入通道的三级降级

消息拼好、本地落盘了,SDK 要决定走哪条通道。接入通道设计为三级降级结构:

图 2. 接入通道三级降级:长连优先,HTTP 备用,本地兜底。

  • 第一级 长连(TCP/WebSocket):延迟最低,前提是长连活跃
  • 第二级 HTTP 短连:长连建不起来或被网络限制(企业内网 / 部分 Wi-Fi 只放 80/443)时的备用通道;只要能上网就能通。某信 mars 专门提到"同时提供长连、短连两种网络通道",原因就在这里
  • 第三级 本地兜底:两条通道都不通时消息留本地队列等网络恢复

降级判定:

  1. 长连断了不立刻降级(可能是临时抖动,先等几百毫秒);
  2. 长连一通也不只走长连(发出去没收到 ACK,中间节点可能丢包,降级 HTTP 重试);
  3. 后台限制是重要变量(iOS/Android 进后台长连会被杀,切三方推送拉醒 App 是另一条路)。

2.4 域名解析与 HTTPDNS

通道选定,SDK 要知道接入层在哪里——也就是域名解析。这一关是失败率最高的一关

运营商 LocalDNS 在移动场景下几个老问题:劫持(返回错误 IP 把流量导走)、调度不准(华南用户路由到华北节点)、缓存过期不及时跨网解析慢。这些影响叠加,弱网场景下上行失败率会显著高于稳态网络(具体数字依网络环境差异较大)。

业界主流是 HTTPDNS——用 HTTP 协议直接向自家 DNS 服务请求 IP,绕开 LocalDNS。机制简单,工程化有几个层次:

层次

做法

说明

基础版

客户端 HTTP → 自家 DNS 拿 IP → 本地缓存 → 发起业务连接

解决劫持,不解决调度精度

进阶版

缓存按"城市 + 运营商"粒度,服务端按地理位置返回最近节点

某字火山 HTTPDNS 2.0 之前的主流做法

精细版

缓存粒度细化到"网段"——同城同运营商内不同网段走不同节点

某字火山 HTTPDNS 2.0,解决"城市级调度污染"

HTTPDNS 的代价是首次解析多 1 个 RTT(向自家 DNS 服务也得发 HTTP),所以几乎所有实现都做"启动时预解析"——App 一启动就把 IP 拉回来缓存,用户真正发消息时直接走缓存。

2.5 TLS 握手与 0-RTT

IP 拿到了,SDK 要和接入层建连。任何一条 IM 上行通道都要加密——明文传输在移动场景下等于把消息内容曝光给运营商、企业代理、公共 Wi-Fi。问题在于,标准 TLS 握手本身就要 1~2 个 RTT,在弱网下这一段可能占到整个上行延迟的一半。

图 3. TLS 握手的三种姿势

  • TLS 1.3 是默认选择,1-RTT 是基线
  • 0-RTT 在 IM 上行非常有价值——用户每天 N 次发消息每次省 1 RTT 是看得见的收益
  • 0-RTT 不是免费的——弱化了前向安全(同 PSK 多次使用)和重放保护(Early Data 可被重放);
  • 会话恢复优化:握手成功后保存 session ticket,下次同服务端直接复用,省证书校验和密钥协商;移动端切网后复用能省半秒以上

2.6 接入层鉴权与连接归一

TLS 通了,消息加密发出去了,轮到接入层做事——接入层要回答两个问题:这是谁发的(鉴权)、这条 socket 挂到哪个用户上(连接归一)

鉴权三种时机:

时机

做法

优势 / 代价

TCP 握手前

TLS 客户端证书 / mTLS

最严;客户端证书管理复杂,移动端实施成本高

TCP 握手后第一个业务包

首包带 token,校验通过才允许后续业务

主流做法,兼顾安全和实施成本

每包都鉴权

每条消息都带 token

性能损耗大,只在高敏场景下用

主流是第二种:长连建立后,第一个业务包必须是"鉴权包"(常叫 Login / PushReg / Hello),包含 token / 设备指纹 / 协议版本。接入层校验 token(常委托独立鉴权服务),通过后把 Channel 标记"已激活"。

接着做连接归一——把这条 socket 和"用户 + 设备端类型"(uid#mobile / uid#desktop / uid#web)绑定。绑定信息写两个地方:本地内存 Map<userKey, Channel>(下行查 socket 用)、集中存储 im:online:<userKey> → 接入层 IP:port(其他节点知道用户挂在哪台机器)。

这是 Session 管理的入口——Session 一致性(死连接 / 多端互踢 / Pod 漂移)是独立大话题,这一秒里只需做对"绑定完成、可投递"这件事。

代码语言:javascript
复制
on_first_business_packet(channel, packet):
    if not is_auth_packet(packet):
        channel.close() // 关键:不是鉴权包,直接断
        return
    token = packet.token
    user = auth_service.verify(token)
    if user is None:
        channel.close()
        return
    channel.user = user
    channel.activated_at = now()
    local_session_map.put(user.key, channel)
    redis.set("im:online:" + user.key, this_node_ip, ttl=300)
    publish_online_event(user.key)

2.7 上行那一秒的时间轴

用户感知上是瞬间,但每一关都吃几十到几百毫秒——时间轴长这样:

图 4. 上行那一秒的时间轴。长连复用 + DNS 命中缓存稳态下点击到 ACK 通常 50~200ms;长连重建 + DNS 重解析的冷启动可能 500ms~2s(我们toB IM 项目里观察到的范围,具体值随网络质量和服务部署差异显著)。

冷启动和稳态差一个数量级——这是 IM SDK 优化的核心命题:让冷启动尽可能快、稳态尽可能稳。客户端预解析、长连预热、0-RTT 都是这条命题下的具体手段。

三、大厂如何做

3.1 某信 mars:TCP socket 之上长出一套通信组件

mars 是某信内部后来开源的移动端 IM 跨平台网络组件,把 IM 上行链路的工程经验沉淀到 socket 层封装。公开资料里的关键点:

  • 同时提供长连、短连两种通道,业务按场景选
  • DNS 防劫持 + 动态 IP 下发 + 就近接入 + 容灾恢复,把"域名解析"做厚
  • socket 层自控连接策略 / 多级读写超时 / 收发策略,不依赖标准 HTTP 栈
  • 复合连接 + IP 排序应对弱网——同时试多个 IP、按历史连通率排序
  • 智能心跳——动态调整间隔平衡保活和耗电
  • MMTLS——基于 TLS 1.3 草案的自研安全协议,默认 0-RTT PSK

维度

详情

优势

每一关都做到极致;开源后业界可参考的工程实现样本;长连 + 短连 + 弱网都覆盖

代价

工程量极大,需长期投入的底层团队;中小团队复用 mars 比自研更现实

3.2 接入层无状态化的通用做法

接入层无状态化是他们公开资料里完整披露的话题——核心三个点:连接层无状态化(Channel 状态外移到集中存储)、多 IDC 调度(用户连接可在不同机房漂移)、连接层和业务层解耦(接入层只管连接,业务消息透传)。

无状态化直接对应这一秒"接入层鉴权与连接归一"的工程难点:接入层 Pod 可随时重启升级(状态在 Redis,新 Pod 拉起用户重连即可)、单 IDC 挂了客户端可重连到另一个 IDC、横向扩容线性加机器。

维度

详情

优势

接入层水平扩展简单;故障域小;升级 / 灰度成本低

代价

集中存储成为新瓶颈和故障点;消息下行要先查"用户挂哪台机器"多一跳;

3.3 某字火山 HTTPDNS:把"城市-运营商"降到"网段"

某字火山 HTTPDNS 服务他们自家多个核心产品 App。把缓存粒度从"城市-运营商"细化到"网段"。采用"城市 + 运营商"做缓存键——同城同运营商所有用户拿同一份 IP。但实际网络里,同城同运营商不同网段可能属不同 CDN 调度域,部分用户会被调度到"次优节点"。他们通过自研网段库 + 动态划分算法压住这个调度污染问题,工程上还配多级缓存、预取、拨测日志聚合作为网段划分数据源。

维度

详情

优势

根据某字公开数据,缓存命中率提升 15%,CPU/流量降低约 70%

代价

网段库维护成本高;客户端缓存策略复杂度上升;

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

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

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

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

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、消息上行最开始那N毫秒发生了什么?
    • 1.1 上行链路在 IM 系统里的位置
    • 1.2 这最开始的1秒决定了一半的用户体验
  • 二、上行那一秒走过的几道关
    • 2.1 设计这一关的硬约束
    • 2.2 客户端 SDK 拼包与本地落盘
    • 2.3 接入通道的三级降级
    • 2.4 域名解析与 HTTPDNS
    • 2.5 TLS 握手与 0-RTT
    • 2.6 接入层鉴权与连接归一
    • 2.7 上行那一秒的时间轴
  • 三、大厂如何做
    • 3.1 某信 mars:TCP socket 之上长出一套通信组件
    • 3.2 接入层无状态化的通用做法
    • 3.3 某字火山 HTTPDNS:把"城市-运营商"降到"网段"
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档