Plato项目学习笔记
Plato项目学习笔记
项目背景
技术问题
在一个长连接IM服务中,如果用户在连接到网关后换了一个基站(链接断开了要重连),怎么解决?
- 用户连接到网关,建立长连接,获取Session ID。
- 用户移动,基站切换,TCP连接断开。
- 客户端检测到心跳超时,触发重连逻辑。
- 客户端携带Session ID向网关发起重连请求。
- 网关验证Session ID,恢复会话状态,并推送断连期间的未读消息。
- 客户端收到消息,更新UI,用户无感知地继续使用。
TCP本身不是有心跳吗?为什么要自定义心跳
TCP协议本身确实提供了keep-alive机制,可以用来检测连接是否存活。
在IM服务等需要高实时性、可靠性的场景中,TCP的keep-alive存在以下局限性,导致它无法完全满足需求:
- 检测时间太长
默认配置下,TCP keep-alive的检测周期非常长(可能需要几分钟甚至几小时才能确认断连)。例如,默认2小时空闲时间加上9次探测(75秒/次),总共可能超过2小时才能发现连接失效。
- 参数调整受限
虽然可以通过系统调用(如setsockopt)调整keep-alive的参数,但这些调整是全局的,影响服务器或客户端上的所有TCP连接。IM服务可能与其他服务共用同一主机,无法为IM单独优化。
- 仅检测网络层状态
只能检测网络层的连接是否存活。IM服务需要更细粒度的状态检测,例如确认对端是否还能正常处理消息,而不仅仅是网络连接是否存在。
- 缺乏业务逻辑
- TCP keep-alive只是简单地发送空包,无法携带业务数据。如果IM服务需要通过心跳顺便同步状态(如用户在线状态、未读消息数),keep-alive无法做到。
- 自定义心跳可以设计为包含业务信息,比如客户端发送心跳时附带当前时间戳,服务器返回未读消息数量或会话状态。
- 中间设备干扰
对业务的规模有了解吗
整个业务大概是200w左右的qps,实现架构升级后,单机目标要维持50w左右的长链接(单机64G内存)
应用层协议与WebSocket的比较:
- Plato-go使用基于TCP的自定义二进制协议,而WebSocket是基于HTTP的标准协议。
- Plato-go协议结构:消息头(8字节)+消息体,包含消息类型(1字节)、消息ID(4字节)、消息长度(2字节)和校验和(1字节),消息体使用Protobuf序列化。相比之下,WebSocket协议有更复杂的帧结构,包含FIN、RSV、opcode、mask等字段。
- 性能方面:Plato-go协议更轻量,头部仅8字节,而WebSocket帧头至少2字节,最多可达14字节;Plato-go使用Protobuf序列化,比WebSocket常用的JSON更高效。
- 功能方面:Plato-go实现了自定义的心跳机制、消息确认和断线重连;WebSocket有内置的ping/pong机制但需要额外实现消息确认和会话恢复。
- 兼容性:WebSocket被广泛支持,跨平台性更好;Plato-go协议需要客户端和服务端都实现相同的协议解析。
IM系统相关的概念
P2P
由A直接连接B的模式叫P2P
上行消息和下行消息
A客户端发给服务器的消息叫上行消息
服务器发给B客户端的消息叫下行消息
消息风暴
"消息风暴"通常指的是由于某种原因导致的消息流量突然激增的现象。这可能是由群聊、即时通讯软件或其他类型的分布式系统中发生的大量消息快速传播所引起的。例如,在群消息场景下,如果一条消息被迅速转发给大量用户,可能会造成服务器负载骤增,形成所谓的"消息风暴扩散系数",从而对系统的实时性、可达性和离线消息处理带来挑战。
P99
在所有请求中,有 99% 的请求响应时间不超过这个值。换句话说,只有 1% 的请求会比 P99 值更慢。
QPS
QPS 表示每秒钟系统接收到并处理的请求数量。
L1——L7
| 层级 | 名称 | 功能 |
|---|---|---|
| L1 | 物理层 | 负责在各种媒介上传输原始的比特流。包括物理设备如网卡、网线、集线器等。确定信号的电压、电流、无线电频率等。 |
| L2 | 数据链路层 | 提供点到点的数据传输。负责帧的创建、传输和接收。检测并纠正物理层传输中的错误。使用MAC地址进行硬件寻址。 |
| L3 | 网络层 | 负责数据包的路径选择和转发。提供逻辑地址(如IP地址)用于多跳传输。进行路由选择,决定数据包如何在网络中转发。 |
| L4 | 传输层 | 提供端到端的可靠传输和错误恢复。使用传输协议(如TCP和UDP)确保数据完整性和顺序。实现流量控制和端口寻址。 |
| L5 | 会话层 | 管理应用程序之间的会话。负责建立、维护和终止会话。提供对话控制和同步服务。 |
| L6 | 表示层 | 处理数据的格式化和翻译。负责数据加密、解密、压缩和解压缩。确保不同系统之间的数据表示一致。 |
| L7 | 应用层 | 为用户提供各种网络应用服务。包含常见的网络应用协议(如HTTP、FTP、SMTP等)。负责应用程序之间的通信。 |
系统中的各种id
| 名称 | 定义 |
|---|---|
| connID | 连接ID,用于唯一标识一个网络连接。 |
| clientID | 客户端ID,用于唯一标识一个客户端设备。 |
| seqID | 序列号,用于标识消息在一条连接中的顺序。 |
| sessionID | 会话ID,用于标识一个会话。 |
| msgID | 消息ID,用于唯一标识一条消息。 |
| Device ID |
IM系统要解决的问题
消息问题
消息的一致性
一致性:任意时刻消息保证与发送端顺序一致。
消息的端到端一致性 = 上行消息一致 + 服务端业务一致 + 下行消息一致
IM消息的一致性体现在:
- 1)单聊时:要保证发送方发出聊天消息的顺序与接收方看到的顺序一致;
- 2)群聊时:要保证所有群员看到的聊天消息,与发送者发出消息时的绝对时间序是一致的。
没有全局时钟
一个真正堪用的生产系统,显示不可能所有服务都跑在一台服务器上,分布式环境是肯定的。
那么:在分布式环境下,客户端+服务端后台的各种后台服务,都各自分布在不同的机器上,机器之间都是使用的本地时钟,没有一个所谓的"全局时钟"(也没办法做到真正的全局时钟),那么所谓的消息时序也就没有真正意义上的时序基准点。所以消息时序问题显然不是"本地时间"可以完全决定的。
多发送方问题
服务端分布式的情况下,不能用"本地时间"来保证时序性,那么能否用接收方本地时间表示时序呢?
遗憾的是,由于多个客户端的存在(比如群聊时),即使是一台服务器的本地时间,也无法表示"绝对时序"。
如上图所示:绝对时序上,APP1先发出msg1,APP2后发出msg2,都发往服务器web1,网络传输是不能保证msg1一定先于msg2到达的,所以即使以一台服务器web1的时间为准,也不能精准描述msg1与msg2的绝对时序。
多接收方问题
多发送方不能保证时序,假设只有一个发送方,能否用发送方的本地时间表示时序呢?遗憾的是,由于多个接收方的存在,无法用发送方的本地时间,表示"绝对时序"。
如上图,绝对时序上,web1先发出msg1,后发出msg2,由于网络传输及多接收方的存在,无法保证msg1先被接收到先被处理,故也无法保证msg1与msg2的处理时序。
网络传输与多线程问题
既然多发送方与多接收方都难以保证绝对时序,那么假设只有单一的发送方与单一的接收方,能否保证消息的绝对时序一致性呢?
结论是悲观的,由于网络传输与多线程的存在,这仍然不行。web1先发出msg1、后发出msg2,即使msg1先到达(网络传输其实还不能保证msg1先到达),由于多线程的存在,也不能保证msg1先被处理完。
消息的可靠性
可靠性: 消息一旦显示发送成功就必定送达到对端(即A发出消息后,必须确保B可以收到)
在传递给业务层时服务端进程崩溃,但客户端A认为已经送达,服务端业务层无感知,因此消息丢失。
多客户端发送消息/多服务端接收消息/多线程多协程处理消息,顺序难以确定。
设计IM必须具有端到端的设计思维,底层对可靠性的保证仅能保证底层的可靠,而不能保证上层的可靠, 底层的可靠仅是减小了发生故障的概率: 底层可靠不等于上层可靠,同理: 底层一致不等于上层一致
消息的幂等性
定义:消息幂等性指在即时通讯(IM)系统中,无论同一条消息被重复发送、传输或处理多少次,最终的结果(如消息状态、内容展示等)与仅处理一次的效果完全一致。
核心目标:避免因网络重传、客户端重发、服务端重复消费等场景导致消息重复生效(如重复扣款、重复通知等)。
消息幂等性的必要性
- 网络不确定性
- 消息可能因超时、丢包被客户端或服务端多次重传。
- 客户端容错
- 用户网络恢复后,客户端可能主动重发未收到ACK的消息。
- 服务端可靠性
- 消息队列(如Kafka)可能重复投递消息,或服务端宕机后恢复时重复处理未确认的消息。
技术挑战与解决方案
挑战1:消息的唯一标识
问题:如何唯一标识一条消息,确保不同消息不会因重复传输导致混淆。
解决方案
- 全局唯一ID(UUID/GUID):
服务端为每条消息生成全局唯一ID(如Snowflake算法),客户端和服务端均基于此ID去重。 - 业务关联ID:
对于特定业务场景(如支付订单),使用业务方提供的唯一订单号作为消息ID。
- 全局唯一ID(UUID/GUID):
挑战2:消息状态的持久化与同步
- 问题:如何在分布式系统中记录消息的处理状态(已读、已送达、已消费),避免状态不一致。
挑战3:消息顺序性与并发控制
问题:消息可能乱序到达,导致幂等处理失效(如先处理后一条消息的撤销操作,再处理前一条)。
解决方案
- 分桶有序处理:
按业务分组(如用户ID分片)或消息ID哈希值分桶,保证单桶内消息有序。 - 版本号机制:
为消息附加递增版本号,服务端仅处理版本号等于当前状态的最新消息。
- 分桶有序处理:
挑战4:存储与性能的权衡
- 问题:去重表可能成为性能瓶颈,尤其是高并发场景下频繁的插入和查询。
挑战5:业务逻辑的幂等性设计
- 问题:某些业务操作天然不具备幂等性(如转账、库存扣减)。
长链接接入层技术挑战
长链接接入层主要解决的问题就是 实现服务端主动及时地将消息发送给客户端的功能。而在这个过程中,会有非常多的技术挑战:
- 客户端如何选择网关IP地址? 才能降低延迟,保证连接可靠,负载均衡?
- 网关服务如何接收客户端的消息,获得最大的并发度获得消息的高吞吐,低延迟?
- 为了能使用长连接收发消息,需要维护哪些状态,如何使其占用更少的内存,单机承载更多的连接?
- 业务层是怎么感知到连接在哪一个网关机器,并把消息分发下去的呢? 如何降低网络请求的扇出?
- 客户端进入地铁/切换基站/连接wifi 等情况导致连接断开,如何能快速重连,而不影响用户体验?
- 如何尽可能的减少长连接服务的崩溃/重启次数,做到永不宕机?
- 长连接服务如何做限流/熔断/降级策略? 实现对网关的过载保护,提高可靠性?
- 长连接服务如何做到通用性,灵活对接各种业务场景?
- 如何多数据中心部署长连接网关?
消息协议设计
协议目标
【性能】协议传输效率,尽可能的降低端到端延迟。
【兼容】既要向前兼容也要向后兼容。
【存储】减少消息包的大小,降低空间占用率。
【计算】减少编解码时造成的CPU使用率的权衡。
【网络】尽可能的减少网络带宽消耗。
【安全】协议安全性,防止协议被破解。
【迭代】尽可能的灵活扩展,支持IM复杂业务的演进。
【通用】可跨平台接入,H5,客户端,IoT设备。
【可读】易于理解,方便调试。
消息协议各层技术选型介绍
| 应用层 | 文本协议: 可读性好,性能低。二进制协议: 可读性差,难以调试,性能高,业界无可争议的使用 protobuf![]() | |
| 安全层 | 基于密钥的生命周期可以划分为: 1.TLS/SSL: 加密效果好,但证书管理相对复杂。 2.固定加密: 通信前客户端和服务端约定好密钥和加密算法。 3.一人一密: 在通信前客户端先向服务端请求密钥,服务端会用用户特有属性生成密钥下发下去,然后进行加密通信。 4.一次一密: 创建连接建立一次会话时,双方进行加密三次握手,使用非对称加密握手,对称加密传输, 参考TLS握手过程。 加密消耗cpu计算资源,安全性也要考虑消息在服务端存储的安全性和合规性要求,要做出取舍。网关对数据包进行TLS3.0协议的密钥协商握手,加解密操作,这会消耗大量CPU,所以对于加解密操作可以使用GPU。 参考: 通俗易懂:一篇掌握即时通讯的消息传输安全原理 | ```mermaid |
| graph TD |
subgraph "TLS + 网关终止"
Client[客户端] -->|TLS加密数据| Gateway(网关/LB)
Gateway -->|解密后数据| Server(应用服务器)
subgraph Gateway
direction LR
Term(TLS终止)
end
end
classDef client fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef gateway fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef server fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class Client client
class Gateway gateway
class Server server| 传输层 | TCP: 面向连接的可靠传输协议,仅能保证数据到达传输层,维护状态消耗资源,网络不稳定时频繁重连性能差。UDP: 无状态的传输协议,弱网环境更优。<br />参考: [网络是怎样连接的](https://book.douban.com/subject/26941639/),[深入解析QUIC协议](https://segmentfault.com/a/1190000041234654) | TCP保证数据可靠传输到服务器,减少复杂度,使用epoll技术以及应用层设计,可以克服有状态链接的弊端。参考: [基维百科](https://zh.wikipedia.org/wiki/传输控制协议) |
### 市面上开源协议对比
好的,以下是表格内重新编号的内容:
| 名称 | 特性 | 取舍 |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| [IMPP](https://rfc2cn.com/rfc2778.html) | 1. [RFC2798](https://rfc2cn.com/rfc2778.html) | [RFC2799](https://rfc2cn.com/rfc2779.html)<br>2. 这是一个协议标准,没有具体实现<br>3. 其中涉及的角色具有参考意义,是非常通用的设计 | 1. 太过抽象/通用, 可读性差<br>2. 仅是一个模型,跟自研没什么区别 |
| [XMPP](https://zh.wikipedia.org/zh-hans/可扩展消息与存在协议) | 1. 一种基于XML的应用层协议<br>2. XML可以跨平台,跨IM服务传输<br>3. 适用于一些邮箱应用如Spark<br>4. [开源地址](https://github.com/mellium/xmpp) | 1. 文本协议性能差,信息冗余压缩率低<br>2. 解析dom极耗时,性能极差<br>3. 难以保证消息可靠性Qos |
| [SIMPLE](https://rfc2cn.com/rfc3428.html) | 1. [SIP协议RFC](https://datatracker.ietf.org/doc/html/rfc3428),应用于流媒体,音视频场景,这是对其的扩展<br>2. 简单了解看[这里](https://www.cnblogs.com/xiaxveliang/p/12434170.html)<br>3. 针对IM聊天场景的扩展,是应用层文本协议,类似HTTP<br>4. [开源协议地址](https://github.com/RestComm/sip-servlets)<br>5. [详细介绍看这里](https://github.com/TongxinV/oneBook/tree/master/0.0.Document update catalog/class) | 1. 文本协议,压缩率低,**占用网络带宽**<br>2. 没有找到直接相关的SIMPLE,SIP/SDP都需要较大改造成本<br>3. 不满足性能与可迭代性<br>4. 难以保证消息可靠性Qos |
| [MQTT](https://bbs.huaweicloud.com/blogs/196152) | 1. 异步通信,消息报文简单,适合推送场景<br>2. 轻量级发布订阅模式, 一对多的分发模式, 资源消耗也很少<br>3. 代码少,可以在多种单片机上轻松实现<br>4. 支持QoS(0-2)<br>5. 适用于设备的存储和网络带宽有限的物联网场景<br>6. [【必读】详细资料](http://www.52im.net/thread-525-1-1.html) | 1. 需要增加可变头并做一些改进,才能支持**时序性**<br>2. 基于IM需求定制化开发的场景很多,**扩展性差** |
| [websocket](https://datatracker.ietf.org/doc/html/rfc6455) | 1. 客户端和服务端仅需要一次握手就可以创建链接<br>2. 使用简单,支持全双工,各大浏览器均支持,多用于H5<br>3. 复用HTTP通道,在HTTP基础上进行协议升级<br>4. 基于[数据帧](https://datatracker.ietf.org/doc/html/rfc6455#section-5.2)格式进行传输<br>5. [【必读】详细看这里](http://www.52im.net/forum.php?mod=viewthread&tid=1341&highlight=websocket)<br>6. [源码地址](https://github.com/gobwas/ws)<br>7. [性能压测对比](https://colobu.com/2015/07/14/performance-comparison-of-7-websocket-frameworks/)<br>8. [协议格式](https://www.cnblogs.com/zhangmingda/p/12678630.html) | 1. 需要业务自己保证消息**时序性**<br>2. 需要业务**处理断线重连**等场景,**扩展性弱**<br>3. 建立长链接时,需要通过HTTP协议升级,建立和重连都很慢<br>4. 数据帧格式定制化能力较差,信息有冗余<br>5. 原生客户端难以扩展,协议库需要二次开发<br>6. websocket的协议还是字符流协议,**信息压缩率差,浪费带宽** |
| 自研二进制协议 | 1. 几乎主流IM APP公司都这么做<br>2. 灵活/高效/难以破解 | 1. **通用性差**,不易扩展<br>2. 需要手写字节流处理逻辑,容易出错,**迭代效率低** |
| 私有协议+开源序列化 | 1. 克服了自研二进制协议的**通用性和维护性**的问题<br>2. [Protobuf](http://www.52im.net/thread-277-1-1.html)<br>3. [【必读】详细原理](http://www.52im.net/thread-323-1-1.html) | 1. 完美的选择 |
### 市面上网络框架性能对比
使用三台C3.4xlarge AWS服务器做测试。 一台作为服务器,两台作为客户端机器, 每台客户端机器启动10个client,一共20个client
- 20 clients
- setup rate设为500 * 20 requests/second = 10000 request /second
- 每个client负责建立50000个websocket 连接
- 等1,000,000个websocket建好好,发送一个消息(时间戳)给所有的客户端,客户端根据时间戳计算latency
- 如果服务器setup rate建立很慢,主动停止测试
- 监控三个阶段的性能指标: setup时, setup完成后应用发呆(idle)时,发送消息时
| 框架名称 | 框架初始化时 CPU 占用 (%) | 框架初始化完成时 CPU 占用 (%) | 框架初始化完成时 内存使用 (GB) | 发送消息时 CPU 占用 (%) | 消息延迟 数量 (count) | 消息延迟 最小值 (ms) | 消息延迟 最大值 (ms) | 消息延迟 平均值 (ms) | 消息延迟 标准差 (ms) | 消息延迟 中位数 (ms) | 消息延迟 99% (ms) | 消息延迟 99.9% (ms) |
| ------------ | ------------------------- | ----------------------------- | ------------------------------ | ----------------------------------- | --------------------- | -------------------- | -------------------- | -------------------- | -------------------- | -------------------- | ----------------- | ------------------- |
| **Netty** | 10 | 0 | 1.68 | 25 | 50000 | 0 | 18301 | 2446.09 | 3082.11 | 1214.00 | <= 13274.00 | <= 18301.00 |
| **Undertow** | 10 | 0 | 4.02 | 35 | 50000 | 1 | 11948 | 1366.86 | 2007.77 | 412.00 | <= 8051.00 | <= 11948.00 |
| **Node.js** | 6 | 0 | 5.0 | 6 | 50000 | 0 | 18 | 1.27 | 3.08 | 1.00 | <= 1.00 | <= 15.00 |
| **Vert.x** | 5 | 0 | 6.37 | 24 ~ 53 | 50000 | 49 | 18949 | 10427.00 | 5182.72 | 10856.00 | <= 18658.00 | <= 18949.00 |
| **Go** | 6 | 0 | 15 | 6 | 50000 | 0 | 35 | 1.89 | 1.83 | 1.00 | <= 4.00 | <= 34.00 |
| **Grizzly** | 80 | 80 | 11.5 | 无法正常建立websocket,主动终止测试 | - | - | - | - | - | - | - | |
| **Spray** | 20 | 20 | - | 无法正常建立websocket,主动终止测试 | - | - | - | - | - | - | | |
| **Jetty** | 98 | 98 | 5 | 无法正常建立websocket,主动终止测试 | - | - | - | - | - | - | - | |
### Plato自研基本消息协议技术选型
> 对于传输层,我们选择**TCP协议**,安全层选择**TLS协议**,应用层选择 **自研二进制协议+开源序列化协议**
##### 选TCP的原因
TCP 协议保障了消息可靠的传送到网关服务上,相对于udp来说少处理很多bad case,简化开发成本,同时可以通过在业务层实现**断线重连等弱网优化手段,来应对弱网环境tcp频繁断链的情况。**
##### 选TLS3.0的原因
TLS3.0协议,优化了握手的速度提升了性能,同时可以较好的兼顾性能和安全性是一个高性价比选择,但是如果在gateway server上实现,由于TLS的握手/加解密都是cpu密集型操作,极端情况下会拉高gateway server的cpu使用率使其造成性能抖动。
为此我们选择 在L7层负载均衡器上实现**TLS终止**,使用L7层负载均衡器会增加一跳的数据包的转发这会造成性能损耗,不过可以使用TLS加速卡(因特尔 QAT)等[硬件加速技术](https://developer.aliyun.com/article/597750)解决。
对于IM场景,如果仅考虑性能的话,可以在L4负载均衡器上实现TLS终止,减少对L7负载均衡器的依赖,因为gateway server本身也工作在L7层。
对于应用层一个简单灵活的二进制协议实现可以分为固定消息头,变长消息头,消息体三部分。
##### plato数据格式
对于应用层一个简单灵活的二进制协议实现可以分为固定消息头,变长消息头,消息体三部分。
```mermaid
graph TD
subgraph Plato协议格式
direction LR
A[固定消息头<br>FixedHeader] --> B[可变消息头<br>VarHeader] --> C[消息体<br>MsgBody]
end
subgraph "FixedHeader (14 bytes)"
direction LR
v[Version<br>1 byte] --> mt[MsgType<br>1 byte] --> ml[MsgLen<br>4 bytes] --> vhl[VarHeadLen<br>4 bytes] --> crc[CRC32<br>4 bytes]
end
subgraph "VarHeader & MsgBody"
direction LR
D[Protobuf序列化数据]
end
A --> D
B --> D
C --> D
classDef header fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
classDef body fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef proto fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
class A,v,mt,ml,vhl,crc header
class B,C body
class D proto编解码器伪代码
/**
* 消息实体类,包含协议头和协议体
*/
public class Message {
private FixedHeader fixedHeader; // 固定头部
private PBData varHeader; // 可变头部(Protobuf二进制数据)
private PBData msgBody; // 消息体(Protobuf二进制数据)
// Getter/Setter方法
}
/**
* 固定头部结构,对应TCP协议中的协议头
*/
public class FixedHeader {
private byte version; // 协议版本(1字节)
private byte msgType; // 消息类型(1字节,如0x01表示文本,0x02表示心跳)
private int msgLen; // 总消息长度(FixedHeader + VarHeader + MsgBody,4字节大端序)
private int varHeadLen; // 可变头部长度(4字节大端序)
private int crc32Sum; // CRC32校验和(4字节,用于数据完整性校验)
// 构造方法
public FixedHeader(byte version, byte msgType, int msgLen, int varHeadLen, int crc32Sum) {
this.version = version;
this.msgType = msgType;
this.msgLen = msgLen;
this.varHeadLen = varHeadLen;
this.crc32Sum = crc32Sum;
}
// Getter/Setter方法
}
编码器
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
/**
* 自研协议编码器:将Message对象编码为字节流
*/
public class MessageEncoder extends MessageToByteEncoder<Message> {
@Override
protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
FixedHeader fixedHeader = msg.getFixedHeader();
PBData varHeader = msg.getVarHeader();
PBData msgBody = msg.getMsgBody();
// 1. 写入FixedHeader字段(共14字节)
out.writeByte(fixedHeader.getVersion()); // 版本号(1字节)
out.writeByte(fixedHeader.getMsgType()); // 消息类型(1字节)
out.writeInt(fixedHeader.getMsgLen()); // 总消息长度(4字节,大端序)
out.writeInt(fixedHeader.getVarHeadLen()); // 可变头部长度(4字节)
out.writeInt(fixedHeader.getCrc32Sum()); // CRC32校验和(4字节)
// 2. 写入VarHeader(Protobuf序列化后的二进制数据)
byte[] varHeaderBytes = varHeader.toByteArray();
out.writeBytes(varHeaderBytes); // 直接写入字节数组
// 3. 写入MsgBody(Protobuf序列化后的二进制数据)
byte[] msgBodyBytes = msgBody.toByteArray();
out.writeBytes(msgBodyBytes); // 直接写入字节数组
}
}解码器
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
/**
* 自研协议解码器:从字节流中解析Message对象
* 解决TCP粘包/拆包问题,按FixedHeader -> VarHeader -> MsgBody顺序解析
*/
public class MessageDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
// 1. 确保至少有FixedHeader的14字节(1+1+4+4+4)
if (in.readableBytes() < 14) {
return; // 数据不足,等待下一次读取
}
// 2. 标记当前读指针位置(用于异常回滚)
in.markReaderIndex();
try {
// 3. 读取FixedHeader字段
byte version = in.readByte(); // 版本号(1字节)
byte msgType = in.readByte(); // 消息类型(1字节)
int msgLen = in.readInt(); // 总消息长度(4字节,大端序)
int varHeadLen = in.readInt(); // 可变头部长度(4字节)
int crc32Sum = in.readInt(); // CRC32校验和(4字节)
// 4. 校验数据完整性:确保后续数据足够
if (in.readableBytes() < varHeadLen + msgLen) {
in.resetReaderIndex(); // 数据不足,回滚到标记位置
return;
}
// 5. 读取VarHeader(Protobuf二进制数据)
byte[] varHeadBytes = new byte[varHeadLen];
in.readBytes(varHeadBytes); // 读取可变头部
PBData varHeader = PBData.parseFrom(varHeadBytes); // 反序列化为Protobuf对象
// 6. 读取MsgBody(业务数据)
byte[] msgBodyBytes = new byte[msgLen]; // 注意:msgLen包含FixedHeader自身长度?
in.readBytes(msgBodyBytes); // 读取消息体
PBData msgBody = PBData.parseFrom(msgBodyBytes); // 反序列化为Protobuf对象
// 7. 校验CRC32(需根据业务逻辑实现)
if (!validateCRC32(varHeadBytes, msgBodyBytes, crc32Sum)) {
throw new RuntimeException("CRC32校验失败");
}
// 8. 构建Message对象并输出到Pipeline
FixedHeader fixedHeader = new FixedHeader(version, msgType, msgLen, varHeadLen, crc32Sum);
Message message = new Message(fixedHeader, varHeader, msgBody);
out.add(message);
} catch (Exception e) {
in.resetReaderIndex(); // 异常时回滚读指针
throw e;
}
}
/**
* CRC32校验逻辑(需根据实际业务实现)
*/
private boolean validateCRC32(byte[] varHeadBytes, byte[] msgBodyBytes, int crc32Sum) {
CRC32 crc32 = new CRC32();
crc32.update(varHeadBytes); // 更新校验和计算
crc32.update(msgBodyBytes);
return crc32.getValue() == crc32Sum; // 比较计算值与接收值
}
}消息方案设计
备选方案讨论
上行消息
| 方案 | 收益 | 代价 |
|---|---|---|
| clientID 严格递增 | 1. 任意时刻仅存储一个消息ID 2. 保证严格的有序性 3. 实现简单,可用 4. 长连接通信延迟低 5. 以发送方顺序为标准(权衡) | 1. 弱网情况下,消息丢包严重时将造成大规模消息重发,导致网络瘫痪影响消息及时性。 2. 无法保证群聊中的消息因果顺序 |
| 弱网问题,可以通过优化传输协议层(比如协议升级为Quic)来优化,长连接不适合在弱网环境工作,丢包和断线属于传输层问题。 | ||
| *****clientID 链式引用***** | 1. 客户端A使用本地时间戳作为clientID,并在每次发送消息的时候携带上个消息的clientID。 2. 服务端存储上一个消息的clientID记作为preClientID,只有preClientID 和当前消息的preClientID对比,匹配上则说明消息未丢失,否则拒绝。 | 1. 协议的消息带宽 |
| clientID 滑动窗口 | 1. 减少弱网重传时的消息风暴问题 2. 使用此 client List 作为滑动窗口,来保证消息幂等等 | 1. 实现更加复杂 2. 网关层需要更多内存维护连接状态 3. 由于传输层使用tcp,已经对弱网有一定的优化,应用层也维护滑动窗口收益不大 |
消息转发
为什么要分配seqID?
IM场景中聊天会话至少有两个客户端参与(单聊/群聊),因此任何一个客户端分配的clientID都不能作为整个会话内的消息ID,否则会产生顺序冲突,因此clientID仅是保证消息按客户端A发送的顺序到达服务端,服务端需要在整个会话范围内分配一个全局递增的ID。
事实上仅需要保证同一个客户发送消息的先后顺序即可。
| 挑战 | 方案 | 代价 | 收益 |
|---|---|---|---|
| 如果服务端在分配seqID前此请求失败或进程崩溃怎么办? | 服务端在分配SeqID之后再回复ACK消息。 | - ack回复变慢,收发消息变慢 - 如果消息存储失败消息将丢失 - seqID 分配成为性能瓶颈 | - 保证了分配seqID消息的可用性 |
| 如果服务端在存储消息、业务处理、接入层路由时失败怎么办? | 1. 消息存储后再回复ACK,如果ACK失败则客户端重试时再次等待回复ACK。 2. 如果服务端崩溃导致长连接断开,客户端重新建立连接时发送pull信令拉取历史消息进行补洞。 3. 如果消息存储后仅是业务层失败,接入层无感知,业务层需要做异常捕获,并追加pull信令请求给到客户端B,主动触发其拉取历史消息。 | - 上行消息的p95延迟将增加 - 整体通信复杂度增高 - 应对弱网环境需要协议升降级机制 | - 保证了业务处理全流程的可用性 - 在出现异常情况时,可毫秒级触发接收端,保证消息及时性 |
| 如何保证消息不丢失,同时减少延迟? | 可以将消息交给MQ异步存储,MQ来保证消息不丢失。 | - 无 | - 异步写入,优化了p95延迟 |
| 如何解决seqID分配的单点瓶颈问题? | seqID无需全局有序,仅保证在会话内有序即可。 | - 无 | - 解决了seqID分配的单点瓶颈 |
下行消息
服务端将消息发送给客户端B,其协议设计依赖于seqID的生成方式。
| 方案 | 实现细节 | 收益 | 代价 |
|---|---|---|---|
| 客户端轮询拉取 | 定期发起pull请求获取新消息 | - 实现简单,保证可用性 | 1. 客户端耗电高(用户体验差) 2. 消息时延高,不满足及时性 |
| seqID严格递增机制 | 1. Redis incrby生成会话级seqID 2. 按服务端接收顺序分配全局序号 3. 客户端B按preSeqID+1校验幂等性 4. 超时重传机制 | - 实现简单,快速上线 - 最大程度保证严格递增 | 1. 弱网重传问题 2. Redis单点风险 3. 需维护超时队列 4. 无法处理离线场景传递 |
| Redis高可用seqID方案 | 1. Lua脚本存储maxSeqID和runID 2. 校验节点身份防止脑裂 3. 主从切换时跳变maxSeqID 4. 客户端B异常时主动补洞 5. 离线用户仅存储消息 | - 保证连续性 - 单调递增 - 支持Redis集群扩展 - 减少带宽消耗 | 1. 协议复杂度上升 2. 需评估用户规模 3. 群聊易引发风暴 |
| 推拉结合整流方案 | 服务端打包消息并智能推送 | - 解决消息风暴问题 | - 实现复杂度高 |
| SeqID链式验证机制 | 1. 客户端本地维护maxSeqID 2. 服务端携带preSeqID和当前seqID 3. 客户端通过maxSeqID校验 4. 服务端存储逻辑链表 5. preSeqID不一致时退化为pull | - 屏蔽趋势递增依赖 | - 存储成本增加(需记录preSeqID) |
plato的消息v1.0方案
- 客户端A创建连接后,分配一个clientID,从0开始即可,发送一个消息时获得clientID并自增。
- 启动一个消息计时器,等待ack消息的回复,或者超时后触发重传。
- 基于tcp连接将msg1发送给服务端。
- 服务端请求redis使用sessionID进行分片,incryBy获得seqID。
- 异步写入MQ,保证消息可靠存储。
- 立即回复客户端A ack消息,告诉他消息已经可靠送达。
- 启动一个下行消息定时器,等等客户端B的ack消息,或者超时后触发重传。
- 客户端A收到ack消息后,取消定时器。
- 服务端发起下行消息请求,将msg1发送给客户端B。
- 客户端B根据当前session的maxSeqID+1 是否等于当前消息的seqID来决定是否接收。
- 客户端B回复服务端消息已经确认或者拒绝。
- 服务端根据客户端B回复决定是进行消息补洞还是关闭定时器。
长连接网关设计
模块背景
Plato 为保证消息的及时性需要使用tcp长链接与客户端进行通信(节省DNS,握手等开销,并可主主动push消息给客户端),但长链接服务端需要一直维护连接状态。 连接状态通常分为系统部分和应用部分,系统部分指的是socket的管理,应用部分指的得是连接过程中的uid/did/fd 之前的映射关系,以及clientID等信息的存储。
这些信息的生命周期是跟随一个长连接的创建而产生,长链接的断开而消亡极易变化,持久化存储除了用于数据分析,同时这些信息也是收发消息维度的访问频率,QPS极高,因此需要存储在内存中被使用。
这就导致整个长链接服务是一个有状态服务,难以运维和管理,业务需求的频繁上线会造成系统的重启更新,长链接势必会断开,客户端将有所感知,影响用户体验。因此,必须将长连接收发消息的功能和状态维护一个统一的服务,尽可能减少其重启的频率,保证其稳定性和收发消息的延迟。
这就是接入层的由来,而接入层的核心组件就是长连接网关
消息链路流程
当客户端初始化建立长链接时
- 向某个IP的长连接服务发送创建连接信令。
- 网关server解析信令得知其为创建连接信令。
- 网关server,获得底层socket的FD,以及用户的uid/did,建立注册表。
- 回复客户端连接建立成功。
当客户端发送消息时
- 客户端发送上行消息信令。
- 网关服务接收到消息,并解析信令为上行消息信令。
- 根据clientID和sessionID进行路由,分配seqID等状态更新逻辑。
- 然后转发给业务层服务处理,确认业务层收到消息后立即回复客户端ACK。
当业务处理后,将消息转发给接收客户端时
- 业务根据sessionID定位到该会话的接收者的连接在哪一个网关服务上。
- 然后将消息通过RPC交给网关服务,网关拿到数据后通过uid对应connID,确定fd。
- 然后根据fd找到对应的socket,将消息拼接固定消息头发送给接收方客户端。
当连接断开的时
- 心跳超时,连接断开/异常断开
- 状态回收释放
长链接方案选型
| 方案 | 收益 | 代价 |
|---|---|---|
| 写死 ip 列表 | 实现简单 | 毫无扩展性,更新扩展需要发版一旦IP被监控,没有兜底手段 |
| 使用httpDNS服务 | 可以水平扩展长连接网关精准调度防止劫持实时解析 | 不能针对长连接来做精准调度httpDNS本身也会带来可用性问题 |
| 自建一个http server作为ip config server 通过一个域名+https协议访问 ipconfig 服务从中获得一批IP列表(减少请求&负载均衡&快速重连)客户端通过ip列表直接tcp连接长连接网关 | 自建http server 提供更高的可靠性基于业务场景做智能调度策略 | 不能避免 loaclDNS 劫持等问题 |
| httpDNS + ip config httpDNS 解析获得正确的http server 的公网ip地址然后通过此ip地址访问ip config server获得ip 列表 | 解决loacDNS问题实现长连接精准调度 | |
并发通信模型方案选型
| 方案 | 收益 | 代价 |
|---|---|---|
两个协程监听两个channel实现全双工+一个定时器协程 goim 一个线程监听accept从accept socket返回创建连接消息服务端对这个fd 创建两个协程分别负责收发消息每发送一个消息创建一个定时器对象并阻塞一个协程```mermaid | ||
| graph TD |
subgraph "GoIM并发模型"
direction LR
Conn[连接FD] --> G1(协程-读)
Conn --> G2(协程-写)
Conn --> G3(协程-定时器)
end
subgraph "消息处理"
G1 -- "读取消息" --> Biz(业务逻辑)
Biz -- "发送消息" --> G2
G3 -- "超时触发" --> Conn
end
classDef conn fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef g fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef biz fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class Conn conn
class G1,G2,G3 g
class Biz biz| 一个协程使用select实现轮询阻塞 一个conn对象创建后,分配一个协程阻塞在conn的read 和 send 两个channel上在一个select语句上轮询两个channel,谁有消息到来就去处理谁的逻辑业务回调时,开辟一个协程通过注册表找到send channel,交给连接处理协程发送消息```mermaid
graph TD
subgraph "单协程Select模型"
G(协程)
subgraph G
direction LR
S(Select)
end
Read[Read Channel] --> S
Send[Send Channel] --> S
S -->|可读| HandleRead(处理读逻辑)
S -->|可写| HandleSend(处理写逻辑)
Biz(业务逻辑) -- "消息" --> Send
end
classDef g fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef chan fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef logic fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class G g
class Read,Send chan
class HandleRead,HandleSend,Biz logic
``` | 节省了一个协程的开销,内存占用减少三分之一协程数量减少三分之一,runtime调度开销减少,延迟有所提降低 | 同一时间只能接收或发送消息,群聊场景延迟升高协程的阻塞和唤醒在消息收发场景下依旧是瓶颈依旧没有解决下行消息时定时器和协程内存问题 |
| **goroutine pool** 一个协程阻塞监听socket的read函数有信令到达后解析并处理业务层回调时,也是取**goroutine pool**取一个用来处理向socket send 消息。```mermaid
graph TD
subgraph "Goroutine Pool模型"
ListenG(监听协程) -- "读取Socket" --> GP(Goroutine Pool)
subgraph GP
direction LR
g1(协程1)
g2(协程2)
g3(协程3)
end
GP -- "处理上行消息" --> Biz(业务逻辑)
Biz -- "回调" --> GP
GP -- "处理下行消息"
end
classDef g fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef pool fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
classDef logic fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
class ListenG g
class GP,g1,g2,g3 pool
class Biz logic
``` | 业务层回调函数,使用协程池化技术,减少了协程的调度开销限制了协程资源的上界,避免协程分配过多导致OOM | 还是会有一个协程被阻塞需要维护conn的索引,才能让从协程池中获得的协程找到socket还是单工 |
| **reactor+goroutine pool** 端到端设计原则 通过epoll 系统调用,将收发消息完全事件化当epoll读来临时,从**goroutine pool中拿到消息并解析后转发**当业务层回调的时候,直接从**goroutine pool中拿到一个goroutine来处理逻辑**```mermaid
graph TD
subgraph "Reactor + Goroutine Pool"
Epoll(Epoll) -- "读/写事件" --> Reactor(Reactor)
Reactor -- "分发任务" --> GP(Goroutine Pool)
subgraph GP
direction LR
g1(协程1)
g2(协程2)
g3(协程3)
end
GP -- "处理读/写/业务"
end
classDef reactor fill:#e2d1f9,stroke:#9d4edd,stroke-width:3px,color:#5a189a,font-weight:bold
classDef g fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef pool fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class Epoll,Reactor reactor
class GP,g1,g2,g3 pool
``` | 收发消息无协程阻塞,减少了调度开销与内存占用 | |
### 长连接状态存储方案选型
| **方案分类** | **优化点** | **核心思想** | **具体措施** | **收益** | **代价** |
| :------------------------------------------------: | :----------------------------------------------------------: | :-----------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: |
| **单点服务:状态映射表** | ```mermaid
graph TD
subgraph "网关状态"
direction LR
subgraph "连接状态"
Conn(Connect对象)
Conn -- "fd" --> FD(文件描述符)
Conn -- "clientID" --> CID(客户端ID)
Conn -- "flightQueue" --> FQ(飞行队列)
Conn -- "timer" --> T(定时器)
end
subgraph "路由状态"
UID(uid/did) --> EP(Endpoint)
SID(sessionID) --> ConnID
end
end
classDef state fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef route fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class Conn,FD,CID,FQ,T state
class UID,EP,SID,ConnID route
``` | 中心化存储连接状态,通过UID/DID反向路由 | 1. 维护`fd/clientID/飞行队列/定时器`<br />2.维护`uid/did 到 connect对象所在机器 endpoint` <br />3. 倒排维护`sessionID→connID→connect对象` <br />4. 业务层通过`sessionID→uid→endpoint`路由 | 点查性能高 实现简单 | 1. 飞行队列/定时器内存占用大<br /> 2. 网络扇出问题(群聊场景)<br /> 3. 定时器需协程维护 |
| | **飞行队列优化**<br />```mermaid
graph TD
subgraph "飞行队列优化"
GW(网关) -- "飞行消息" --> R(Redis List)
TW(时间轮算法) -- "管理超时" --> R
end
subgraph "之前"
direction LR
LocalFQ(本地飞行队列)
end
subgraph "之后"
direction LR
RedisFQ(Redis List)
end
LocalFQ --> RedisFQ
classDef gw fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef redis fill:#ffccd5,stroke:#ff5d8f,stroke-width:2px,color:#c9184a,font-weight:bold
classDef algo fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class GW gw
class R,RedisFQ redis
class TW,LocalFQ algo
``` | 用Redis替代本地飞行队列 | 使用Redis List存储飞行队列,时间轮算法替代原生堆实现 | 减少网关内存消耗 协程规模从线性变为常数 | 内存**仍然**要维护时间轮数据结构 |
| **微服务拆分:将服务拆分为:State Server和getway** | | 解耦网关与状态管理 | 1. 网关仅维护`connID→fd`映射<br /> 2. connect对象中定时器,飞行队列等状态交给完全独立的state server维护,与网关server之间通过RPC进行通信<br /> 3. 业务层通过State Server控制收发逻辑 | 网关内存节约 ,可靠性提升, 重启次数减少 | 1. 增加RPC网络调用群聊场景下,会造成消息风暴。<br />2.业务层通过sessionID查到uid list,再跳到conn所在的机器上发送消息,**依旧有高扇出问题**<br /> 3. State Server设计复杂度上升 |
| | **容器绑定,同机部署** | 降低State Server与网关的网络开销 | 1,`State Server`与`Gateway`两个docker部署在同一个宿主机或物理机,改变宿主机两个docker的网络模式和协议栈,优化二者通信<br />2.State server 可以设计成无状态的,使用中心化存储,做到存储计算分离,计算层无状态可水平扩展 | 1.减少了state 与 gateway通信的网络开销<br />2.使用两个docker隔离资源,可独立部署,进而保证可靠性同机部署,网络延迟消耗可忽略 | 1.造成服务之间相互依赖。扩展性降低,两个进程之间会共享os进而资源共享,导致可靠性降低<br />2.无法应对机房级故障 |
| | **资源回收** | 拆分`Ip config`,进行客户端无感知的调度重连 | 当单机持有一定数量的连接后,为避免资源被耗尽,会主动关闭最早接入的连接客户端收到连接后,会静默(客户端无感知)的进行重连Ip config 负责调度到新的gateway server上后台启动一个运维任务,周期的扫描情况长时间空闲的连接(需要根据业务场景决策) | 避免了资源耗尽OOM等问题 | 需要耗费额外的存储空间存储连接建立的时间戳 |
| **分布式定时任务系统** | **独立定时任务系统** | | 通过MQ异步通信,用来做飞行消息/心跳/连接重连等计时任务提供注册定时任务接口,告知定时时间,以及回调token提供异步的定时任务消息触发机制,gateway可感知到定时任务到期,并根据回调的消息进行逻辑处理,例如: 消息发送出去后,将飞行消息和定时时间打包成一个task注册到分布式定时任务系统中,然后到期后,通过回调接口将飞行消息发送给网关,网关自行重发,并再次计时。并且任务系统还要提供定时任务的取消功能,来应对心跳的重置逻辑。 | State Server无状态化。可以水平扩展,没有负担路由 | 1. 系统复杂度增加 2. 依赖MQ稳定性 3. 定时精度挑战 |
### 长连接服务感知方案选型
### **消息分发技术方案对比表**
| **方案分类** | **具体方案** | **核心思想** | **收益** | **代价** |
| -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **广播路由策略** | **全扇出(Pub/Sub)**<br />```mermaid
graph TD
subgraph "Pub/Sub 广播模型"
Biz[业务服务] -- "发布消息(sid, did)" --> MB(消息总线 - Redis/MQ)
MB --> GW1(网关1)
MB --> GW2(网关2)
MB --> GWN(网关N)
GW1 -->|检查本地连接| C1(客户端1)
GW2 -->|忽略| C2
GWN -->|检查本地连接| CN(客户端N)
end
classDef biz fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
classDef bus fill:#e2d1f9,stroke:#9d4edd,stroke-width:3px,color:#5a189a,font-weight:bold
classDef gw fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef client fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
class Biz biz
class MB bus
class GW1,GW2,GWN gw
class C1,C2,CN client
``` | 利用Redis的Pub/Sub功能或MQ订阅所有长连Server(MQ也可),业务服务将消息推送至消息总线;总线将消息分发给所有长连服务,附带Session和UID/DID信息;各长连服务检查本地是否存在对应Session的Socket(排除发送者),存在则发送,否则忽略。 | 1. 业务侧分发逻辑简单</br>2. 无需维护全局状态</br>3. 群聊/Push场景无效请求少,整体吞吐量高 | 1. C2C或小群聊场景存在大量无效分发</br>2. 高无效网络调用占用带宽与协程资源,可能导致延迟增加或OOM</br>3. 跨DC广域Push不可行 |
| | **一致性Hash**<br />```mermaid
graph TD
subgraph "一致性Hash路由"
Biz[业务服务] -- "消息(uid)" --> CH{一致性Hash环}
CH -- "计算节点" --> GW1(网关1)
CH -- "计算节点" --> GW2(网关2)
CH -- "计算节点" --> GWN(网关N)
note for Biz "根据uid计算目标网关"
Biz --> GW2
end
classDef biz fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
classDef hash fill:#e2d1f9,stroke:#9d4edd,stroke-width:3px,color:#5a189a,font-weight:bold
classDef gw fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef noteClass fill:#fff1e6,stroke:#ddbea9,stroke-width:1px,color:#9c6644,font-weight:bold
class Biz biz
class CH hash
class GW1,GW2,GWN gw
``` | 基于服务发现机制,业务服务感知长连网关节点数量,通过一致性Hash算法将用户绑定到固定网关节点;创建连接时采用相同Hash算法确保用户长连固定在某一机器。 | 1. 性能优异</br>2. 支持弹性扩展,避免分片不均 | 1. IP配置服务器调度灵活性受限</br>2. 群聊场景优化效果有限 |
| **精确路由策略** | **映射路由表(独立服务)**<br />```mermaid
graph TD
subgraph "独立路由服务模型"
Biz[业务服务] -- "1. 查询路由(sid)" --> RS(路由服务)
RS -- "2. 返回Endpoint" --> Biz
Biz -- "3. 发送消息" --> GWN(目标网关)
GWN -- "4. 推送消息" --> Client(客户端)
RS -- "维护映射表" --> DB[(uid/did -> connID -> endpoint)]
end
classDef biz fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
classDef rs fill:#e2d1f9,stroke:#9d4edd,stroke-width:3px,color:#5a189a,font-weight:bold
classDef gw fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef client fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef db fill:#ffccd5,stroke:#ff5d8f,stroke-width:2px,color:#c9184a,font-weight:bold
class Biz biz
class RS rs
class GWN gw
class Client client
class DB db
``` | 独立路由服务集群维护多级映射关系:<br>- SessionID → UID列表<br>- UID → DID列表<br>- DID → ConnID列表<br>- ConnID → Endpoint映射<br>创建连接时路由服务建立SessionID到Endpoint的倒排索引,业务服务通过RPC查询Endpoint并分发消息。 | 1. 精准传输零冗余</br>2. 内存操作高性能</br>3. 支持多业务隔离 | 1. 群聊/Push场景大规模扇出时查询冗余</br>2. 额外网络跳数增加通信复杂度</br>3. 跨DC广域Push不可行 |
| | **State Server集中路由**<br />```mermaid
graph TD
subgraph "State Server 集中路由模型"
Biz[业务服务] -- "1. 发送消息(sid, data)" --> SS(State Server)
SS -- "2. 查询路由并转发" --> GWN(目标网关)
GWN -- "3. 推送消息" --> Client(客户端)
end
classDef biz fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
classDef ss fill:#e2d1f9,stroke:#9d4edd,stroke-width:3px,color:#5a189a,font-weight:bold
classDef gw fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef client fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
class Biz biz
class SS ss
class GWN gw
class Client client
``` | State Server统一管理路由信息,业务服务仅需传递SessionID,由State Server自主定位具体长连网关并分发消息。 | 1. 减少网络调用次数</br>2. 端到端延迟降低 | 1. State Server复杂度显著提升</br>2. 可靠性风险增加</br>3. 跨DC广域Push不可行 |
| **混合路由策略** | **组合策略** | 混合使用两种模式:<br>- C2C/小规模群聊采用精确路由(路由服务查询)</br>- 大规模活跃群采用全扇出(Pub/Sub广播)。 | 1. 动态平衡资源消耗</br>2. 场景适配灵活 | 实现复杂度高 |
| **地理路由策略** | **基于地理位置调度** | 根据用户IP等地理位置信息,将相邻用户分配至同一网关Server。 | 1. 同城社交/O2O场景延迟优化</br>2. 区域性服务体验提升 | 1. 路由策略单一化</br>2. 实现复杂度高 |
| **社交图谱路由策略** | **社交关系图计算** | 离线或近线计算用户社交关系网络,综合会话活跃度权重分配最佳节点:<br>- 将高频活跃聊天的用户聚集到同一长连网关</br>- 维护Session到Endpoint的倒排索引加速查询。 | 1. 广域Push路径最优</br>2. 性能调优空间大 | 1. 实现复杂度极高</br>2. 需持续维护动态关系模型 |
| **基础设施优化** | **弱网感知调度** | 监控网络质量(如延迟、丢包率),动态选择最优数据中心节点建立连接。 | 1. 弱网环境下用户体验保障 | 数据中心建设及运维成本高昂 |

### 断线重连方案选型
| **方案分类** | **具体方案** | **核心思想** | **收益** | **代价** |
| ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------------- |
| **连接稳定性优化** | **租约机制** | 客户端与服务端建立长连接心跳(端到端设计):<br>- 客户端发起心跳降低服务端性能开销<br>- 服务端维护连接超时计时器,超时后主动断开连接回收资源<br>- 心跳成功则重置计时器<br>- 收发消息可替代心跳减少频次<br>- 附心跳机制示意图:<br />```mermaid
sequenceDiagram
participant C as 客户端
participant S as 服务端
loop 心跳检测
C->>S: Heartbeat Request
S->>S: 重置连接计时器
S-->>C: Heartbeat ACK
end
Note right of S: 若超时未收到心跳<br>则主动断开连接
``` | 1. 保证端到端连接可靠性</br>2. 实现简单逻辑清晰</br>3. 避免运营商截断空连接风险 | 1. 弱网下极易断连导致资源浪费</br>2. 运营商路由器空闲连接限制</br>3. 心跳引发流量潮汐可能压垮服务 |
| **容灾恢复机制** | **断线重连** | 断线重连机制设计:<br>- 连接断开或心跳超时后启动重连计时器<br>- 计时到期前重连成功则复用状态(更新路由表)<br>- 重连优先使用原IP网关,失败后切换IP配置列表<br>- 随机等待时间分散流量<br>- 附重连流程示意图:<br />```mermaid
graph TD
subgraph "断线重连流程"
A(连接断开/心跳超时) --> B{启动重连计时器}
B --> C{尝试重连原IP}
C -->|成功| D[复用状态<br>更新路由]
C -->|失败| E{切换IP配置列表重连}
E -->|成功| D
E -->|失败| B
B -->|超时| F[彻底断开<br>清理资源]
end
classDef start fill:#ffccd5,stroke:#ff5d8f,stroke-width:2px,color:#c9184a,font-weight:bold
classDef process fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef success fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef failure fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class A start
class B,C,E process
class D success
class F failure
``` | 1. 缓解弱网环境频繁断连问题</br>2. 减少资源释放与创建开销 | 1. 弱网判定阈值固定无法动态适配</br>2. 流量潮汐问题未彻底解决</br>3. 跨网关状态复用能力有限 |
| **冗余连接策略** | **主备连接** | 维护双长连接通道:<br>- 主连接用于常规消息收发<br>- 备用连接用于PUSH等场景<br>- 主连接断开时自动降级为备用连接<br>- 上行消息失败时切换为HTTP POST请求<br>```mermaid
graph TD
subgraph "主备连接 + HTTP降级"
Client[客户端] -->|主连接(TCP)| GW(网关)
Client -->|备用连接(TCP)| GW
subgraph "降级策略"
Client -.->|主连接失败| Client
Client -.->|切换备用连接| GW
Client -.->|上行失败| HttpServer(HTTP服务器)
end
end
classDef client fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef gw fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef http fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class Client client
class GW gw
class HttpServer http
``` | 1. 降低用户连接中断感知</br>2. 保障下行消息可达性 | 1. 实现复杂需维护双连接逻辑</br>2. 跨网关状态同步成本高</br>3. 无备用连接时需独立维护长连成本过高 |
| **协议弹性机制** | **协议升降级** | 动态协议切换策略:<br>- 弱网环境下TCP降级为QUIC/SPDY<br>- 进一步降级为HTTP协议(去除TLS握手)<br>- Gateway仅解析固定消息头,剩余字节交State Server解析<br> | 1. 提升弱网环境连接成功率</br>2. 规避TCP协议缺陷(队头阻塞等)</br>3. 优化加密传输性能 | 1. QUIC依赖UDP协议成熟度不足</br>2. 缺乏非对称加密安全性</br>3. 协议切换实现复杂度高 |

### 减少长连接服务的崩溃/重启次数,实现永不宕机方案选型
| **方案分类** | **具体方案** | **核心思想** | **收益** | **代价** |
| ------------------ | ------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **进程隔离策略** | **运行时隔离** | 将Gateway与State Server拆分为独立进程运行,通过进程级隔离实现:<br>- 网关进程与服务进程解耦<br>- 故障隔离避免相互影响<br>- 支持分层部署(同机/跨机) | 1. 降低服务重启频率</br>2. 灵活部署提升资源利用率</br>3. 增强系统容错能力 | 1. 跨机器部署时网络调用增加</br>2. 部署复杂度上升</br>3. 同机部署才能最大化网络性能优势 |
| **状态持久化策略** | **共享内存** | State Server将状态数据写入共享内存(mmap机制):<br>- 进程崩溃后可快速从共享内存恢复状态<br>- 避免磁盘IO带来的恢复延迟 | 1. 减少崩溃导致的业务中断时间</br>2. 提升故障恢复速度 | 1. 共享内存管理复杂度增加</br>2. 数据一致性保障难度上升</br>3. 内存占用量可能翻倍 |
| **服务热升级策略** | **长连接平滑重启** | 通过双进程接力实现无感重启:<br>- 新进程注册监听后与老进程建立Unix Domain Socket通信<br>- 同步历史连接状态(FD/映射关系)<br>- 逐连接迁移后释放老进程资源<br>- 支持跨进程文件描述符传递<br> | 1. 客户端完全无感知重启</br>2. 避免流量潮汐冲击</br>3. 支持在线升级 | 1. 单机万级连接迁移耗时过长</br>2. 跨机房迁移不可行</br>3. 实现复杂度高(需处理锁/序列化/状态同步) |
| **服务终止策略** | **优雅关闭** | 通过信号触发有序退出:<br>- 停止接受新连接请求<br>- 主动通知客户端下线并等待确认<br>- 完成连接释放后回收资源<br>- 支持与Ops Server联动控制<br> | 1. 用户无感知的服务下线</br>2. 支持集群水平扩展</br>3. 规范运维操作流程 | 1. 客户端交互流程复杂</br>2. 异常case处理困难</br>3. 下线时间不可控(可能无限延长) |
| **跨机迁移策略** | **扩缩容迁移** | 基于Redis启发的连接迁移方案:<br>- State Server规划连接迁移计划<br>- 新旧网关建立双写通道<br>- 客户端重建连接并同步状态<br>- 原网关释放资源<br>- 支持跨数据中心迁移<br> | 1. 支持在线扩容/缩容</br>2. 最小化连接中断时间</br>3. 解决长连网关扩展瓶颈 | 1. 实现复杂度极高(需处理网络分区/状态同步)</br>2. 依赖客户端配合</br>3. 状态迁移过程易出错需人工干预 |
| **运维自动化策略** | **独立Ops Server** | 构建专用运维决策系统:<br>- 实时采集业务指标(CPU/内存/连接数/QPS)</br> - 基于规则引擎触发运维操作</br> 支持自动扩缩容/故障转移</br>- 提供人工干预接口</br> | 1. 提升规模化运维效率</br>2. 实现故障自愈能力</br>3. 规范运维操作标准 | 1. 小规模场景收益不明显</br>2. 系统复杂度倍增</br>3. 初期研发成本较高 |

### 限流/熔断/降级 方案选型
| **方案** | **核心思想** | **收益** | **代价** |
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **基于服务发现** | 通过服务发现机制分发分布式限流配置:<br>- 将全局限流规则同步至各单机节点<br>- 单机采用令牌桶/漏桶算法执行本地限流<br>- 支持动态更新配置(附配置同步流程图)[链接](https://hardcore.feishu.cn/docs/doccnvQAX7MWAZA4PRlxXoBPPyf) | 1. 防止突发流量压垮系统<br>2. 实现全局状态一致性管理<br>3. 降低单节点配置管理复杂度 | 1. 静态阈值难以适应复杂场景<br>2. 无法动态调整限流策略<br>3. 存在漏限/误限风险 |
| **负反馈调节** | 基于近线采样的日志分析实现闭环控制:<br>- 采集连接断开率/失败率/RTT等指标<br>- 通过算法模型(如PID控制器)计算最优阈值<br>- 动态推送参数至网关节点<br>- 支持实时运维干预(附算法决策流程图) | 1. 动态优化限流阈值<br>2. 自动修复漏限/误限问题<br>3. 减少人工干预成本 | 1. 实现复杂度高(需算法工程化)<br>2. 模型训练数据质量依赖性强<br>3. 实时性受反馈周期限制 |
| **多目标融合** | 综合多维度指标进行限流决策:<br>- 监控连接成功率/心跳RTT/内存/CPU/协程池状态等20+指标<br>- 建立加权评分模型计算节点权重<br>- 通过IP Config Server下发最优节点列表<br>- 支持客户端重连重试策略(附多指标融合公式) | 1. 系统稳定性最大化<br>2. 避免单一指标决策偏差<br>3. 支持复杂网络环境自适应 | 1. 指标关联性分析复杂<br>2. 融合公式调优困难<br>3. 实时计算资源消耗大 |

### 长连接服务中台化,产品化,实现通用性方案选型
在商业公司中,有限的资源(时间&人力)下要求我们高效率的进行开发迭代,提高效率本质上就是仅做必要的事情,为了使研发资源最大程度的复用,大型公司建设起了中台项目,拆解出基础能力横向支持所有业务线的产品,缩短一个产品的上线周期,提高人效。 而对于长连接网关,在具有众多产品矩阵的中大型公司,都会演进为消息中台。在这里面,所面对的技术挑战,是**动态变化的需求与通用架构的矛盾**
消息系统,必须抽象出一套良好的领域模型,以便于应对不确定的需求迭代,并保证自身的技术目标 。
| **方案分类** | **具体方案** | **核心思想** | **收益** | **代价** |
| ---------------- | ------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **业务隔离策略** | **APP ID逻辑区分** | 通过APP ID实现业务方隔离:<br>- 不同APP ID对应独立业务逻辑代码<br>- 部署独立集群实现资源隔离<br>- 支持多业务线并行运行<br> | 1. 实现简单易于理解<br>2. 基础资源隔离能力<br>3. 快速支持新业务接入 | 1. 业务扩展后系统复杂度指数级增长<br>2. 缺乏领域建模导致代码复用率低<br>3. 跨业务协同能力弱 |
| **消息路由策略** | **发布/订阅模式** | 基于领域建模构建消息通道:<br>- 抽象网关为消息中间件(知乎长连网关设计)<br>- 支持多对多订阅/广播模式<br>- QoS分级策略控制<br>- Token鉴权(ACL)<br>- 动态Token下发与运行时资源隔离<br> | 1. 高灵活组合能力(生产者/消费者模式)<br>2. 支持复杂业务场景(通讯/上报/广播)<br>3. 实现中台化架构 | 1. 领域建模复杂度高<br>2. 代码复用性与业务匹配度需权衡<br>3. 高并发场景鉴权性能瓶颈 |
| **部署架构策略** | **单元化部署方案** | Pipeline模式实现模块化部署:<br>- 独立集群(IP Config + Gateway + State Server)<br>- Ops Server统一管理<br>- 可插拔Pipeline组件(业务自定义逻辑)<br>- 混合/独占部署模式<br> | 1. 支持现有代码平滑升级<br>2. 模块化提升代码复用率<br>3. 资源隔离保障可靠性<br>4. 统一调度提升资源利用率 | 1. 需要完整接入文档指导<br>2. 存储一致性问题(读放大)<br>3. 写放大导致延迟增加 |
| **会话管理策略** | **Session绑定策略** | 策略模式实现灵活分发:<br>- 提供RPC回调让业务方实现绑定逻辑<br>- 吞吐优先(群聊)与延迟优先(C2C)双模式<br>- 本地内存维护Session-DID映射<br>- 业务层感知连接状态变更<br> | 1. 解决通用分发方案缺失问题<br>2. 兼顾群聊与C2C场景差异<br>3. 业务自主控制分发策略 | 1. 业务需深度感知网关状态<br>2. 维护Session映射增加复杂度<br>3. 跨业务状态同步挑战大 |

### 多IDC方案选型
| **方案分类** | **具体方案** | **核心思想** | **收益** | **代价** |
| ------------------ | -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **智能调度策略** | **基于连接调度策略** | 通过地理信息/社交关系/活跃会话关系分析:<br>- IP Config Server动态下发最优IP列表<br>- 高频通信用户跨数据中心聚合<br>- 减少广域请求次数(附调度策略示意图) | 1. 广域请求数量降至最低<br>2. 下行消息P99延迟显著优化<br>3. 跨区域流量成本降低 | 1. 数据中心异常导致全局不可用<br>2. 用户关系实时计算延迟<br>3. 算法复杂度高(需处理动态权重计算) |
| **高可用架构策略** | **旁路化部署** | 构建IP Config Server多活架构:<br>- 独立数据中心部署<br>- 多集群热备机制<br>- 故障时自动切换(附旁路架构图) | 1. 接入层可用性提升至P0级<br>2. 消除单点故障风险<br>3. 支持跨区域容灾 | 1. 多数据中心部署成本翻倍<br>2. 热备数据同步延迟增加<br>3. 配置一致性保障难度上升 |
| **传输优化策略** | **下行消息专线+MQ** | 混合传输架构设计:<br>- 中心化存储维护跨机房路由表<br>- DID-Endpoint映射多机房同步(含机房标识)<br>- 跨机房通信走专属MQ通道<br>- 专线传输降低延迟(附专线拓扑图) | 1. 跨机房通信对业务透明<br>2. 专线保障传输质量<br>3. 架构改造平滑(兼容现有MQ体系) | 1. 专线建设成本高昂<br>2. 公网MQ传输存在抖动风险<br>3. 机房标识维护增加复杂度 |

### Plato长连接网关方案总结
| **模块** | **核心方案** | **实现方式** | **核心收益** | **主要代价** |
| ------------ | --------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------- |
| **整体架构** | 三服务架构 + 横向运维服务 | 1. **IP Config Server**:连接调度与IP分发<br>2. **Gateway**:长连接状态管理<br>3. **State Server**:协议解析与路由<br>4. **Ops Server**:统一运维调度 | 支持大规模集群水平扩展<br>实现高可用与低延迟<br>解耦业务逻辑与基础服务 | 部署复杂度增加<br>跨机房一致性维护成本高 |
| **连接发现** | 动态服务发现 + 实时负载均衡 | 客户端通过域名查询IP Config Server,获取Top 8网关IP列表<br>IP Config Server基于实时指标(CPU/内存/连接数/QPS)动态评分排序 | 避免单点故障<br>跨机房负载均衡<br>减少广域请求次数(P99延迟优化) | 需维护实时指标监控体系<br>初始连接存在跨机房抖动 |
| **连接注册** | 双阶段注册 + 状态同步 | 1. 客户端通过IP列表选择网关建立TCP连接<br>2. Gateway转发注册信令至State Server<br>3. State Server持久化`did→endpoint`映射 | 状态集中管理<br>支持跨网关迁移<br>业务层无感知连接状态变更 | 增加一次RPC通信开销<br>需维护分布式定时器 |
| **消息分发** | 分场景策略(MQ广播/RPC直连) | 1. **群聊**:MQ广播 + 飞行队列重传(Group内仅消费一次)<br>2. **点对点/C2C**:RPC直连(Session ID扇出查询DID列表) | 群聊减少网络扇出<br>点对点降低延迟<br>支持跨机房无损迁移 | MQ存储压力<br>重传复杂性<br>需维护Session ID到DID的倒排索引 |
| **会话绑定** | 业务可控的分发策略 | 业务层回调State Server建立`Session ID→DID`倒排索引,同步至网关本地内存 | 群聊直达目标网关<br>规避全扇出发放<br>支持吞吐优先(群聊)与延迟优先(C2C)双模式 | 业务侵入性增强<br>需维护跨进程Session映射 |
| **断线重连** | 客户端心跳超时重连 + 网关回收状态 | 1. 客户端心跳超时后尝试重连原IP,失败切换IP列表<br>2. 网关启动重连计时器,超时回收状态 | 客户端无感知重连<br>减少资源浪费 | 心跳超时阈值固定<br>跨网关状态复用能力有限 |
| **平滑重启** | 跨进程复制Socket | 新网关通过Unix Domain Socket接收旧网关状态,完成Conn对象迁移 | 客户端无感知重启<br>避免流量潮汐 | 实现复杂度高(需处理锁/序列化/状态同步) |
| **连接迁移** | Ops Server触发迁移 | Ops Server查询中心存储,发起RPC请求迁移did到目标网关,客户端被动重定向 | 支持在线扩容/缩容<br>最小化连接中断时间 | 实现复杂<br>依赖客户端配合<br>状态迁移易出错 |
| **心跳确认** | 客户端随机心跳 + State Server重置计时器 | 客户端5分钟+随机30s心跳<br>State Server重置计时器并回复ACK | 减少心跳包数量<br>降低网络负载 | 需维护心跳计时器<br>客户端需重置心跳 |
| **超时重传** | 飞行队列 + MQ重传 | 消息加入飞行队列,超时后通过MQ重传<br>网关监听MQ并分发 | 群聊场景可靠重传 | MQ存储压力<br>重传顺序需保证 |
| **消息回执** | 业务层ACK触发删除 | 业务层确认消息接收后,State Server删除飞行队列消息并取消重传计时器 | 避免重复消息<br>释放资源 | 需维护消息状态<br>依赖业务层ACK |
| **连接回收** | 状态清理 + 资源释放 | 断开连接后清理Conn对象<br>通知State Server释放定时器、重传队列等资源 | 降低内存泄漏风险<br>快速释放资源 | 需维护多级定时器与状态同步 |
## Plato微服务拆分
plato是一个完整的IM解决方案,我们已经确定了整体的**长连接 接入层**的技术方案但对业务却了解甚少,为了能得到在**业务,技术,管理**三个维度都做到优雅的技术方案,我们需要进行IM 业务层的**微服务**的划分,规划出**plato的业务架构图**。
plato的最终意义是,实现一个人能够从0到1落地的复杂度更高的IM系统。
### 服务拆分目标
我们期望,实现IM的[基本功能](https://cloud.tencent.com/document/product/269/1499)如下:
1. 单聊/群聊/聊天室/多设备登陆/在线状态
2. 文本消息/多媒体消息/离线同步/历史消息/消息漫游
3. 多端同步/消息撤回/已读未读/离线推送
4. 添加好友/会话列表/好友列表
1. 技术角度看
1. 整体通信复杂度降低,RPC调用次数可以收敛
2. 关键接口满足**延迟,吞吐,可用**等要求指标约束
3. 便于快速发现/定位/修复问题的可维护&可观测性
2. 业务角度看,实现上述产品功能,面向业务需求可扩展
1. 反复同类需求可配置化且自动化,可沉淀到运营&产品平台进行自助解决
2. 便于数据分析,提供在线&近线&离线数据的快速且灵活查询
3. 兼顾长短期目标,短期敏捷迭代,长期整洁架构
3. 管理角度看
1. 保证交付需求的质量,上线后不会引入新问题,达成预期
2. 保证需求如期交付,技术人员的需求负载均衡,且高吞吐低延迟的交付需求
3. 应对人员流动,每个模块都要有buckup,并且保证新人快速接手
4. 合理划分技术团队职责,保证协作沟通效率,分离关注点与责任范围
### DDD介绍
#### 什么是DDD
`DDD`是:领域(模型)驱动设计的缩写;一种指导复杂软件如何构建的方法论;以治理复杂度为目标的软件构建方法;是一种指导面向对象程序设计的具体方法
| 术语 | 含义 |
| ------------ | ------------------------------------------------------------ |
| **领域** | 即业务知识,包含问题域(业务现状与目标)和解空间(解决问题的技术实现)。 |
| **模型** | 对客观事物的选择性描述,通过简化不必要信息,抽象出对解决问题有价值的知识。 |
| **领域模型** | 对业务概念和规则的抽象,最终表现为类型、对象、属性和行为,用于描述业务领域的核心逻辑。 |
| **驱动** | 最先思考的对象、最先考虑的问题或最先满足的目标,通常表述为"以xxx为核心",作为设计或开发的出发点。 |
#### DDD的使用场景
**指导微服务的落地**,提供了一种拆分微服务的指导方法。
**治理软件的复杂性**,得到技术/业务/管理 三者综合权衡下的更好设计。
使用DDD可以综合考虑:
技术复杂度: 延迟,吞吐,可靠,一致性,成本(机器资源,人力成本)。
业务复杂度: 业务目标(产品&商业&增长),交付周期。
管理复杂度: 团队规模,协作成本,人员流动,人均产出。
在三者之间获得最佳平衡的架构设计。
所以,DDD为我们提供了一种在软件构建过程中 **拆解问题,权衡利弊**的一种实践
#### DDD中涉及的名词解释
| 术语 | 含义 |
| ------------------ | ------------------------------------------------------------ |
| **领域专家** | 产品&运营&顾问等角色,对业务领域有深刻认识,掌握足够的领域知识的人 |
| **统一语言** | 领域专家与开发团队之间建立的标准化术语,消除沟通歧义 |
| **界限上下文** | 划分自治单元的边界,具有最小完备、自我履行、独立进化、稳定空间四个特点;对应微服务和技术团队 |
| **UP(上游)** | 被调用者,提供能力的依赖方 |
| **Down(下游)** | 调用者,使用上游能力解决问题的角色 |
| **上下文映射** | 描述上下文间的依赖关系,反映团队协作关系 |
| **实体** | 具有唯一标识符的对象 |
| **值对象** | 无唯一标识符,依赖实体存在的对象 |
| **聚合** | 包含强关联实体的集合,通过标识符引用内部实体 |
| **聚合根** | 聚合的唯一入口句柄,外部访问必须通过聚合根,跨聚合引用也需通过聚合根 |
| **工厂** | 负责创建和销毁复杂聚合的工厂模式工具 |
| **资源库** | 统一管理聚合的增删改查及状态变更,与工厂共同管理聚合生命周期 |
| **领域服务** | 封装跨聚合协作的业务逻辑,解决单一聚合无法完成的复杂业务功能 |
| **应用服务** | 协调领域服务完成业务流程,关注横向业务编排和功能交付 |
| **基础设施** | 封装技术实现细节(如数据库、消息队列等),为上层提供技术能力支持 |
| **领域事件** | 领域内发生的客观事实,命名格式:`产生事件的对象名称 + 动作的过去式`(如`OrderPlaced`) |
| **角色命令** | 描述业务行为触发逻辑:`角色 + 触发的命令 + 引发的领域事件`(如`用户提交订单触发OrderSubmitted事件`) |
| **问题空间(域)** | 业务领域的客观事实(现状)与期望目标(需求)的集合 |
| **解系统(空间)** | 通过技术手段解决问题的实现方案 |
| **分层架构** | 四层结构:<br>1. 用户界面层(展示交互)<br>2. 应用层(协调业务用例)<br>3. 领域层(核心业务逻辑)<br>4. 基础设施层(技术实现支撑) |
### 如何应用DDD(重点)
| **分类** | **步骤/元素** | **详细描述** | **关联元素/关键点** |
| ------------ | -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| **问题域** | 1. 事件风暴 | 识别业务中发生的所有事件(如订单创建、支付完成),梳理因果关系 | **输出**:事件列表、因果链(如库存不足→订单取消) |
| | 2. 命令风暴 | 分析用户操作指令(如"提交订单""取消订阅"),驱动功能分解 | **输入**:用户角色需求(如"用户下单链路") |
| | 3. 寻找聚合 | 定义实体、值对象及其关系(如订单聚合包含订单项值对象) | **关键产出**:聚合根(如订单实体) |
| | 4. 子域划分 | 将业务拆分为核心域(高价值)、支撑域(依赖性强)、通用域(通用能力) | **输入**:子问题归纳(如支付、库存) |
| **解空间** | 5. 上下文映射 | 定义限界上下文间关系(如防腐层ACL隔离外部依赖) | **输出**:模块边界(如订单服务与库存服务的交互规则) |
| | 6. 微服务划分 | 基于子域和上下文映射拆分微服务(如订单服务、库存服务) | **输入**:子域类型(核心域优先拆分) |
| | 7. 用例分析 | 以角色为中心串联事件(如用户下单→支付→发货),形成完整业务链路 | **输出**:流程闭环(如订单生命周期) |
| | 8. 领域模型 | 描述实体、值对象、聚合根及属性(如订单实体包含订单号、金额属性) | **关键元素**:实体/值对象/属性、聚合根 |
| | 9. 状态机 | 定义业务对象状态转换(如待支付→已支付→已发货) | **输入**:领域事件(如支付完成触发状态变更) |
| **全局目标** | 10. 业务模型生命周期 | 通过上述步骤实现业务模型到技术实现的完整映射(如从事件风暴到微服务部署) | **输入**:所有问题域和解空间产出<br>**输出**:软件边界清晰、生命周期完整的可落地模型 |
### 应用DDD划分Plato微服务
#### Plato-IM系统问题域
> 事件风暴
```mermaid
graph TD
subgraph "Plato 事件风暴"
direction LR
U(用户) --> C1{发起<br>注册/登录}
C1 --> E1(用户已认证)
E1 --> C2{发起<br>创建会话}
C2 --> E2(会话已创建)
E2 --> C3{发起<br>发送消息}
C3 --> E3(消息已发送)
E3 --> C4{系统<br>推送消息}
C4 --> E4(消息已送达)
E4 --> C5{用户<br>读取消息}
C5 --> E5(消息已读)
E3 --> C6{用户<br>撤回消息}
C6 --> E6(消息已撤回)
end
classDef actor fill:#90e0ef,stroke:#0077b6,stroke-width:2px,color:#03045e,font-weight:bold
classDef command fill:#a7e8bd,stroke:#21ba45,stroke-width:2px,color:#2b9348,font-weight:bold
classDef event fill:#f9c74f,stroke:#f8961e,stroke-width:2px,color:#e85d04,font-weight:bold
class U actor
class C1,C2,C3,C4,C5,C6 command
class E1,E2,E3,E4,E5,E6 event命令风暴
聚合及聚合根
Plato-IM系统解空间
划分界限上下文
业务层微服务划分
拆分用户服务 user server, 用于管理用户信息与权限。
拆分关系服务 relation server, 用于管理用户关系链及链上的权限,信息,数据。
拆分消息服务 message server, 用于管理消息维度的权限,信息,存储等工作**。**
上述三者为领域服务,对外暴露一个应用服务API Server,业务适配与需求交付。
抽象一个common 包封装所有基础设施的依赖接口,实现**依赖倒置**
领域层仅依赖接口而非基础设施的具体实现
所有服务均使用相同的common包来操作基础设施层
只有抽象出领域服务,确定核心域才能形成真正的业务资产
收益
每个服务都是最小完备的,所谓最小完备就是未来可以发展为一个独立中台的能力
每个子领域内的研发仅关注自身业务即可,关注点分离,职责明确
便于技术团队的人才建设,每个子领域配备一个资深两个高级,以及若干应届
每个子域的技术团队自行调节需求的负载,充分发挥每个研发的技术能力
每个子域的需求皆可独立完成,尽可能降低跨子域需求的数量
即使存在跨子域需求也可以通过对外提供的API Server最终聚合而实现
业务功能的实现方案
优化前
| 业务模块 | 具体方案 | 收益 | 代价 | 优化方案 | 具体优化措施 | 优化收益 | 优化代价 |
|---|---|---|---|---|---|---|---|
| 单聊消息 | 1. API Server直接调用Msg Server异步存储消息2. 通过MQ交互下行消息3. 根据to user id调用User Server获取用户信息后下发 | 实现简单,满足百万DAU以下架构需求 | 无显著缺点 | 无额外优化需求 | - | - | - |
| 群聊消息 | 1. 调用Relation Server查询sessionID下除自身外的did List2. 逐条打包下行消息,交由接入层分发 | 支持群组通信 | 群聊过大时,查询did导致消息风暴 | 群聊风暴优化 | 1. 大群聊使用session绑定减少读放大2. 按需查询did而非全量拉取 | 降低群聊消息风暴影响 | 需维护session绑定逻辑 |
| 多设备登录 | 1. 接入层通知API Server用户DID登录2. 注册userID-DID关系到关系链服务3. 发送下行消息时返回多设备消息 | 支持多设备消息同步 | 好友数量大时在线状态推送压力大;弱网频繁掉线导致状态频繁更新 | 在线状态推送优化 | 1. 从推模式改为拉模式:客户端异步查询在线状态 | 减轻服务端推送压力 | 客户端需额外逻辑处理,可能增加延迟 |
| 在线状态 | 1. 用户登录时,API Server查询Relation Server推送上线状态给所有好友 | 实时更新用户在线状态 | 好友数量庞大时推送压力大;弱网环境状态频繁推送 | 在线状态推送优化 | 1. 客户端异步查询在线状态 | 减轻服务端推送压力 | 客户端需额外逻辑处理,可能增加延迟 |
| 多媒体消息 | 1. 客户端上传云存储获取URL2. 将URL上传至IM系统3. 定义多媒体类型扩展 | 支持图片、视频等多类型消息 | 存在黑产风险;重复存储成本高 | 多媒体存储优化 | 1. 上传时计算文件hash值消重2. 底层仅存储一份数据 | 显著降低存储成本 | 增加hash计算和校验逻辑 |
| 消息漫游 | 1. 通过HTTP接口按session和seqID分批拉取历史消息2. 显示离线未读数量 | 支持消息漫游和离线推送 | 长期存储成本极高 | 历史消息分级存储 | 1. 冷热数据分离2. 用户活跃度分级管理 | 降低存储成本 | 存储层设计复杂,存在读写放大问题 |
| 消息撤回 | 1. API Server标记消息删除2. 推送给会话所有人并修改离线缓存 | 支持消息撤回功能 | 群聊撤回存在消息风暴;离线消息修改可能引发一致性问题 | 消息撤回优化 | 1. 结合分级存储和seqID偏移量处理撤回逻辑 | 解决撤回的读写放大问题 | 需存储层支持并发冲突解决 |
| 已读状态 | 1. 客户端上报已读事件(通过HTTP接口)2. 接入层维护已读列表并推送 | 减轻长连接带宽压力 | 高频传播导致延迟和一致性问题挑战 | 已读事件优化 | 1. 大群聊场景下,已读事件退化为拉取模式2. 用户停留在消息窗口时主动拉取 | 降低推送压力 | 客户端需主动查询,可能增加延迟 |
| 好友管理 | 1. API Server操作Relation Server增删好友2. 首次聊天创建会话3. 客户端拉取会话列表和好友列表存储至Relation Server | 建立基础社交关系功能 | Relation Server存储压力大,单体服务技术成本高 | Relation Server拆分 | 1. 将relation拆解为 会话/关系 两个服务 2.提供对会话的增删改查操作,并提供查询一个用户拥有的会话或者会话下拥有的用户,用户下的设备ID等。 3.关系服务提供对用户关系的增删操作,维护用户与用户设备之间的存储关系,提供查询用户好友列表,设备列表 | 降低通信复杂度,职责更清晰 | Relation Server定位模糊,拆分后需重新定义服务边界 |
| 服务可靠性 | - | - | Relation Server承载过多业务逻辑,单体服务技术成本高 | User Server合并 | 1. 合并用户信息、关系、设备查询到User Server2. 用户、消息、会话作为独立中台发展 | 用户,消息,会话 三个上下文均是各自完备的。用户可以发展为用户中台。消息可以发展为消息中台,直播,弹幕,im,推送。会话属于消息中台的通用子域存在。 | User Server可能成为性能瓶颈,需扩展性设计 |

业务演进需求的考量

独立创建一个console server,用来对外承载 web ui对外接口调用
一个config server 作为整体的业务配置化管理中心
创建一个报表引擎服务,用来做IM的数据分析
提供自动化接入能力,提供IM SDK自动分配appID/bizID,自动创建服务集群,对外提供saas api能力
对外多租户鉴权,功能分级管理,对外输出控制台能力,可运营配置化,形成IM SaaS 产品。
技术管理需求的考量

拆解 消息,用户,会话 三个界限上下文,垂直需求可独立完成,横向需求可闭环
持续优化运行时 监控&报警&日志&巡检 确保有效性: 误报/漏报/及时/区分度
打造高效稳定的测试环境: 研发&预览&生产 泳道模式提高 测试效率确保交付质量
交付需求交由 API Server 团队负责,直接与产品对接,支持产品需求落地
基础设施团队提供技术支持,领域服务通过依赖倒置原则,屏蔽实现细节
核心逻辑收敛于算子函数通过配置可插拔,基于DAG做业务编排,基于事件消息驱动
plato整体架构设计总结
设计目标
一款全世界都在使用的企业IM系统。
指标假设
DAU 5亿+,收发消息p99 200ms以下,QPS 日平均500w,峰值750w。
假设每人每天100条消息,每条消息10KB,则每日存储增长约 4.5PB。
实现的功能
添加好友/加入群聊/退出群聊
单聊/群聊/万人群聊的【在线/离线/历史】消息多端同步
消息回执/已读,未读数/撤销
服务划分
领域驱动设计
对于接入层,可以将其要解决的问题分为四个部分:
获取最佳ip地址,使得客户端接入最佳的网关机,因为为了使得连接的质量更高,选择最近负载最低的网关机将获得收益。
对连接的管理,保证长连接被可靠的持有,连接断开后能够快速重连,维护连接的基本路由信息,实现基本的上行/下行消息的收发功能,以最小的内存占用为优化目标,使得单机存储更多的长连接,尽可能减少有状态服务重启对用户体验的影响。
隔离变更频繁的控制状态,心跳,重试,回执,路由等状态,尽可能的使用分布式内存,计算与存储分离,保证处理逻辑的频繁迭代不会影响线上功能。
下行消息下发时,减少网络调用次数,降低单聊下行消息的延迟,提高群聊下行消息的吞吐。
对于业务层来说,其为了实现业务功能之外,为应对快速迭代的需求,其在设计上需要更多的考虑可维护性。
基于经典的洋葱架构,我们可以简单的拆解为应用层,领悟层,基础设施层,对于复杂业务也可以设置BBF服务。
应用层,对于应用层来说,用来处理简单的面向业务需求的处理逻辑,比如权限管理,业务规则,数据聚合等需求。
领域层,领域层用来沉淀业务无关可跨领域复用的功能需求,提高核心的业务价值,主要可以分为用户,会话,消息三个部分,用户管理主要用户获取用户信息,用户的配置,生成用户ID等功能。会话管理用来分配会话ID管理群聊与单聊的元信息,组内成员列表,加入群,退出群,禁言等对会话进行管理的功能,消息管理也以消息作为实体,控制消息的同步,存储,状态变更等控制行为。
基础设施层,基于依赖倒置原则,保证领域层不依赖任何基础设施,而是通过定义标准的适配接口屏蔽对基础设施库的依赖,在基础设施层用来封装,MQ,redis,mysql,timeline ,rpc,服务发现等基础组件的时候,由于定义了适配接口实现了依赖倒置,基础设施层可以随意替换而不必修改代码,从业务角度来看,基础设施的代码虽然重要,但不是业务核心,可随时替换最佳组件。
对于存储服务来说,大型互联网架构上,为方便数据得复用,都会抽象出独立的数据服务,也更加方便数据团队维护和迭代,通常可以叫做逻辑数据库,面向业务抽象定制化的存储模型,用来更加便捷的描述数据特征,对业务提供更灵活高效的数据存取服务。
对于存储服务来说,可以划分为,普通结构化数据的存储,例如用户信息,会话信息。对于计数数据,由于其巨量的写吞吐我们将其存储在特殊的专用存储引擎上并封装为counter server,更好的提供极大规模的计数服务,对于消息服务来说其挑战在于短时效消息的及时可靠的多端同步,海量消息的存储与检索,因此抽象出专用的timeline 模型,对业务层屏蔽存取细节,存储模型底层基于数据时效实现冷热数据分离存储,权衡高吞吐,低延迟,高可靠,低成本等技术目标的综合效果最大化。
消息状态机
状态机分析方法
Im 本质是三端通信,从消息的角度去看,一次收发过程,可以得到一个状态机。
当发送方将消息发送给服务端时,如果服务端通过clitenID判断了消息幂等,则服务端接收到消息,消息变更为分配ID的状态,在此状态接入层会调用message server,message server会请求seq server获得一个唯一的seqid,然后异步的写入timeline server中,并尝试立即进行在线消息的同步。
message server 调用state server的push rpc 将消息分发下去,此时消息进入下行中状态,如果当前接收方不在线,则下行消息将被拒绝。如果接收方在线,则客户端通过seqID对下行消息进行幂等处理,保证仅送达一次,消息状态将变为接收成功,否则由于网络丢包,seqID重复等问题导致客户端多次接收相同消息,则此消息将被拒绝,网络超时等原因是可重试的,则会变为下行失败,交由接入层state server中的消息飞行计时器判断,超时后则会进行重试,消息的状态进入下行重试阶段,重试成功则进入接收成功的状态。
当接收方打开app后,会自动进入消息拉取中,通过api gateway访问message server,其从timeline server中拉取离线消息,timeline server 根据策略自动进行冷热数据的分离,无需区别离线还是历史数据的存储。
如果拉取的消息过多,客户端会进行分页消息的拉取,拉取失败则会重新刷新,直到将消息补洞为止,保证消息的可靠送达。
如果客户端拉取历史消息,则处理逻辑也同上。
存储设计方案(业务)
用户-消息数据库表拆分
建设专用的三张关系表,user_to_user, user_to_device, user_to_session
建设一张消息表,msg(userID,sessionID, msgID, seqID, content, type, is_delete)
建设一张消息状态表,msg_state(userID, msgID, type)
上行消息到来时,异步写入消息表一条记录。
下行消息下发时,通过user_to_session表,以sessionID反查询到userID列表,然后再通过多个userID查询 user_to_device表,查询到did list。然后进行消息打包与分发

分布式缓存系统设计
业务缓存的设计模式
| 策略名称 | 写操作流程 | 读操作流程 | 适用场景 | 优点 | 缺点 | 关键说明 |
|---|---|---|---|---|---|---|
| 旁路缓存 | 写时:更新DB → 删除缓存 | 读时:缓存命中返回;未命中→查DB→回写缓存 | 高一致性场景(如订单系统) | 灵活性强,避免脏数据 | 缓存击穿风险 | 适合强一致性场景,但需处理缓存击穿问题(可通过互斥锁优化)。 |
| 穿透缓存 | 写时:命中缓存→更新DB+缓存;未命中→仅更新DB | 读时:缓存命中返回;未命中→查DB→回写缓存 | 冷热数据分区(如用户资料) | 写操作简单,天然一致 | 未命中时额外DB查询 | 天然保证缓存与数据库一致,但冷数据未命中时会有额外开销。 |
| 异步缓存 | 写时:仅更新缓存→异步批量更新DB | 读时:缓存命中返回;未命中→查DB→回写缓存 | 高频写场景(如日志系统) | 写性能极高 | 数据可能丢失 | 牺牲一致性换取性能,适合对脏数据容忍度高的场景(如点赞数统计)。 |
| 兜底缓存 | 写时:直接写DB | 读时:优先读DB→成功则回写缓存;失败→降级读缓存 | 高可用场景(如大促期间) | 极致高可用 | 缓存数据可能过时 | 核心目标是保障系统可用性,需配合降级策略(如限流)。 |
| 只读缓存 | 写时:更新DB → 异步更新缓存 | 读时:缓存命中返回;未命中→查DB→回写缓存 | 静态数据(如配置表) | 读性能极致 | 写操作无法实时同步 | 适用于数据变化极少且最终一致即可的场景(如城市列表)。 |
| 回源缓存 | 写时:直接写DB | 读时:缓存命中返回;未命中→直接读DB(不回写缓存) | 缓存降级时期(如Redis宕机) | 最小化缓存依赖 | 数据库压力剧增 | 本质是缓存降级,需确保数据库能承受直接访问压力。 |
IM不同业务缓存模式选择

| 业务场景 | 缓存策略 | 写操作流程 | 读操作流程 | 适用场景 | 优点 | 缺点 | 关键说明 |
|---|---|---|---|---|---|---|---|
| 离线同步消息 | 穿透模式 | 1. 写DB(msg表); 2. 写Redis ZSET(sessionID → msgID列表,最多100条)。 | 1. 从Redis ZSET拉取msgID列表; 2. 补全DB数据并更新缓存至最多1000条。 | 消息离线存储与增量同步 | Redis快速读取高频离线消息 | ZSET容量限制需合理设置 | 冷热分层(Redis存最近,DB存全量),避免缓存击穿。 |
| 映射关系维护 | 只读模式 | 1. 用户会话创建时写入正排(userID→sessionID)、倒排(sessionID→userID); 2. 设备登出时删除映射。 | 直接查询Redis中的正排/倒排索引。 | 高频访问的会话关系查询 | Redis高性能支持实时查询 | 数据变更需同步更新Redis | 内存维护映射关系,减少数据库查询压力。 |
| 消息状态缓存 | 旁路模式 | 状态变更时删除Redis缓存(del命令)。 | 1. 缓存命中直接返回; 2. 未命中则查DB→回写缓存。 | 消息状态实时更新(如已读/未读) | 保证最终一致性,避免脏数据 | 删除操作可能引发缓存击穿 | 通过删除而非更新缓存,确保下次读取时获取最新状态。 |
| 实时状态流式更新 | 异步模式 | Flink流式处理: 1. 实时更新DB状态; 2. Checkpoint机制保障恢复。 | 1. 优先读缓存; 2. 未命中则回源DB。 | 实时性要求高的状态同步(如在线状态) | 分布式流处理+容灾能力 | 数据延迟可能较高 | 结合异步更新和Checkpoint,平衡实时性与可靠性。 |
IM业务中使用缓存的收益与挑战
| 收益维度 | 具体收益描述 | 挑战维度 | 具体挑战描述 |
|---|---|---|---|
| 性能提升 | 1. 通过内存缓存替代磁盘DB,实现毫秒级响应速度 2. 避免频繁的磁盘I/O操作,降低消息读写延迟 | 数据一致性保障 | 1. 缓存与DB双向同步需保证最终一致性 2. 消息状态变更时需处理缓存删除/更新的原子性操作 |
| 架构优化 | 1. 只读缓存模式确保消息映射关系100%命中率 2. 冷热数据分离策略平衡存储成本与查询效率 | 大Key与热Key问题 | 1. 聊天室场景sessionID-userID映射呈指数增长引发大Key风险 2. 热点群聊session倒排查询导致Redis资源耗尽 |
| 系统稳定性 | 1. 流处理机制保障正倒排数据更新稳定性 2. 只读缓存规避写入冲突风险 | 缓存穿透与击穿 | 1. 消息撤回导致多用户查询失效msgID引发缓存穿透 2. 高频消息状态更新触发缓存击穿 |
| 成本控制 | 1. 分层存储策略降低冷数据存储成本 2. 冷热分离减少高频访问数据的磁盘I/O压力 | 资源消耗挑战 | 1. Redis海量KV存储带来高内存成本 2. 分布式集群多Key操作引发后台任务堆积和慢查询 |
| 扩展能力 | 1. 支持横向扩展应对消息量增长 2. 异构存储架构适配不同业务场景需求 | 跨中心一致性 | 1. Redis跨数据中心同步延迟导致数据不一致 2. 分布式锁机制在跨地域场景下的性能损耗 |
| 容灾能力 | 1. 冷热分离架构支持故障时快速切换数据源 2. 只读缓存模式隔离写入故障影响范围 | 运维复杂度 | 1. 多级存储介质需要差异化运维策略 2. 缓存拓扑结构变化需动态调整路由规则 |
| 业务连续性 | 1. 流批一体架构保障消息处理不中断 2. 缓存预热机制确保服务冷启动时性能稳定 | 数据恢复挑战 | 1. Redis故障恢复期间需要重建海量缓存数据 2. 数据回溯时需处理缓存与DB的时序同步问题 |
IM业务挑战的解决方案
| 挑战类型 | 具体挑战描述 | 解决方案 | 具体实施策略 | 优势 | 遗留问题 |
|---|---|---|---|---|---|
| 大Key问题 | 1. String类型value超10KB 2.集合类型元素超5000个或单元素超10MB | 架构优化:基于业务特性的Session绑定机制 | 1. 用户加入聊天室时,业务Server通知接入层 2. 网关机本地内存维护 sessionID-did映射关系 | 1. 规避Redis大Key风险 2. 降低集群间数据同步压力 | 1. 网关机内存消耗增加 2. 系统组件间依赖增强,交互复杂度上升 |
| 热Key问题 | 1. 热点群聊session倒排查询引发Redis资源耗尽 2. 消息状态高频更新导致缓存穿透 | 分层治理: - 读写分离架构 - 多级缓存策略 - 动态限流降级 | 1. 本地缓存:对于普通群聊,设置LRU/LFU淘汰策略,设置秒级过期 2. 分布式缓存:消息可见性与已读状态拆分为独立Key 3. 旁路缓存:存储多副本并同步(如消息状态拆解为 read_status:{msgID}和visibility:{msgID})4. 流量控制:自动限流+手动禁用+降级兜底 5.对于只读缓存,无需考虑缓存同步问题,直接读取多个只读副本来缓解热key问题。 6.对于旁路缓存,要求一致性,存储多副本需要考虑同步问题,此时可以考虑对消息状态进行拆解,比如: 消息已读和消息可见性拆成两个独立的key,以便于下游分布式缓存的分片路由,打散热key。 | 1. 全链路覆盖热Key场景 2. 缓存负载均衡 3. 支持强一致性要求场景 | 1. 本地缓存一致性问题窗口期存在 2. 多级缓存同步逻辑复杂度指数级上升 3. 极端场景仍需多次网络扇出请求(N次查询session队列+消息状态) |
| 读写放大问题 | 1. 为了减少读放大问题,就需要把用户对消息的状态特化到离线消息列表中,这势必造成每个用户一份消息列表(消息的状态是对应到用户维度的),当有一条消息发送或者状态变更时,后台都需要有一个实时流计算的任务更新当前群聊中所有用户的消息列表,写入次数等价于群聊用户数,这种情况称之为 写放大 2. 那么对于整个会话如果存储唯一一个离线消息列表,则只能存储基本信息,保证其共享性,这势必造成对于用户特化的信息要再去其他数据源读取一次,造成读延迟的增加,读时网络请求次数更多,这种情况称之为 读放大 | 场景化策略: - 小群采用写放大模式 - 大群采用读放大模式 | 1. 写放大模式:共享消息基础列表,异步流计算更新用户特化状态 2. 读放大模式:维护单份全局消息列表,用户状态独立存储 3. PLATO架构动态切换策略: - 单聊/小群:优先降低读延迟 - 大群:优先保障写入稳定性 | 1. 根据场景动态平衡读写性能 2. 极端场景延迟可控 | 1. 写放大模式下网络调用次数仍较高 2. IM高并发场景下整体吞吐存在瓶颈 |
批处理优化![]() | 消息下发需多次网络调用导致延迟增加 | 流式聚合: - 业务Server窗口缓冲 - Flink状态管理 | 如果对于消息下发场景,业务server聚合一个窗口,用来缓冲一段时间内对同一用户需要下发的消息,那么就可以有效减少需要进行的网络调用次数,通常窗口需要考虑 时间与条数两个维度的限制,例如 1s内,必须发送出去否则影响消息及时性,100条时必须发送,否则影响消息包总体大小。 这一技术可以在业务server内存中自行实现,但这会导致业务server产生状态,违反无状态的设计模式, 对于旁路缓存,要求一致性,存储多副本需要考虑同步问题,此时可以考虑对消息状态进行拆解,比如: 消息已读和消息可见性拆成两个独立的key,以便于下游分布式缓存的分片路由,打散热key。1. 业务层缓冲:按时间(1s)和数量(100条)阈值聚合消息 2. Flink集成: - 维护有状态窗口计算 - 云原生架构兼容 3. 最终一致性保障: - 窗口超时强制刷盘 - 丢失消息补偿机制 | 1. 显著减少网络调用次数 2. 符合无状态服务设计原则 | 1. 窗口参数调优影响消息实时性 2. Flink集群引入额外运维成本 |
session 绑定方法的优化
session 升降级机制
业务流程
定义专门的session Type: super,当接入层识别此sessionType后 直接存储sessionID到conn对象的映射到本地内存中
当此session发生push操作时,直接通过sessionID来查询此映射,直接push即可
当业务方根据规则判断这是一个活跃大群时,在下一次push时,将sessionType改为 super_upgrade
接入层 im gateway感知后,则在这一次遍历did分发时,将所有的conn对象都存储到<sessionID,conn>对象的映射中
并异步向 业务 server确认升级成功,回复ack,今后业务server分发此群聊消息时,session type == super即可
对于降级情况 session type 定义为 super_demote 即可,****im gateway执行反操作即可。
如果有部分机器失败,则下次还需要发送super_upgrade/super_demote 的session type,进行重试,直到全部迁移完毕
此时用户拉取到会话时sessionType已经变更,此时加入会话时接入层直接将其绑定到seesionID到conn对象的映射中即可
session 升降级机制 的收益
对于活跃群或者超大聊天室,此策略可以避免session 的大key扇出查询,从而解决此性能瓶颈,整体吞吐量上涨,整个大群聊下发的瓶颈就被压到了im gateway单机的push上去,此时要么优化单机性能,要么就去水平扩展加机器得以解决。
所以说,一个好的系统设计是能够让性能问题通过扩容来得到解决的,而坏的设计会造成系统性问题,扩容只会放大问题。
很好的解决了小群聊广播机制的带宽浪费问题
session 升降级机制 的风险
实现复杂度高,接入层与业务层需要进行频繁交互,这导致不同的业务方接入消息中台的成本增加,复杂度没有被屏蔽。
整个升级过程中是单机操作的,存在局部失败的问题,如果出现某台机器失败的情况,要么重试,要么忽略,重试会导致性能下降,忽略会导致有群成员漏发消息,需要额外的事务机制来控制升级的一致性问题。
相比于对小群聊广播造成的带宽浪费,此种方法引入的复杂度代价过高。
简单的广播/组播
对于单聊全部时组播,对于群聊和聊天室等场景全部时广播即可

分布式缓存设计-高可用优化策略
缓存穿透
缓存穿透强调的是查询不存在的key,对于不存在的key 旁路缓存等设计模式,必然会打到DB查询,对于空key的查询将导致缓存失效
空保护,在缓存中增加对空key的缓存,通常TTL设置的较短,以保证一致性。
布隆过滤器, bf假阳性对不存在的key判断是一定正确的,这说明bf可以有效防止空key查询,同时对空间的占用最小。
由于消息状态变化的很快,要求实时性,而构建bf需要离线构建,难以在线化bf算法,使其并不适用此场景
因此plato选择在缓存中,缓存5s的空key来防止缓存穿透现象。
缓存击穿
大量的key同时失效,热key失效,缓存节点崩溃重启,等情况都会导致在某个瞬间缓存失效,DB被大量访问。
针对大量的key同时过期问题,在ttl上加入随机时间戳
针对缓存节点崩溃问题,可以增加缓存预热环节(cdn),当命中率达到一定程度后才能对外提供服务
数据库也可以做限流管理,介入MQ,或者通过数据库代理来做限流,过载保护机制。
可以使用分布式锁来解决旁路缓存更新时由于删除key造成的击穿情况(租约机制)
a.
删除key的同时,由于set key并非原子操作,此时就会存在被大量QPS击穿DB的情况
b.
解决办法就是使用分布式锁,最先读取的线程发现其key为空,则同时set一个时间戳作为锁(租约机制),过期后自动释放
c.
其他线程查询时发现此为处key是一个时间戳则认为有锁进行自旋
d.
最先读取的线程将DB的数据回写到此key处,对覆盖之前的时间戳完成锁的释放
e.
读空key后写入一个时间戳的操作需要使用lua脚本实现(或者使用其他支持原子事务的kv)
f.
除时间戳外,还需要一个标记当前读线程的id,用来作为锁标记,防止多个线程同时释放锁,造成踩踏。
Plato 对消息状态的更新可以使用此种办法防止缓存击穿。
慢查询
限制单词请求批处理key的数量
避免持久化等后台任务在高峰时期进行
禁止可能导致性能瓶颈的命令,保证集群专用性
分布式redis的多key的扇出请求
检查请求次数,pipline等操作,pipeline是客户端行为改造成本低。
对于单机redis来说,网络请求消耗是可以忽略的,但是分布式redis来说,其网络请求次数的累积效应过高,可以通过改造客户端支持私有化协议,替换协议栈使用os bypass技术,RDMA等RPC来加速网络调用。
业务上优化,减少分片的数量,来权衡网络请求也是可行的。
业务隔离
为避免缓存资源因为资源共享导致的资源竞争,可以对缓存进行独立物理机部署。
同时在业务上,也要保重对于向存储映射关系的只读缓存模式禁止使用ttl则不应该与其他业务共用一套缓存集群。
只读模式,旁路模式,穿透模式 都应该独立部署集群,以便于基于不同的缓存模式,使用合理的缓存淘汰策略。
业务层面的外部一致性优化策略
缓存一致性问题
当缓存数据发生变更时,需要更新缓存集群中多个副本的数据,但是网络请求存在延迟,这就会造成不一致
被动失效
缓存数据通过ttl机制,自动过期,过期后查询DB进行回写,这种方式实现简单,适用于运行一定时间内不一致情况的业务,同时要避免失效后穿透服务器,命中率下跌等问题。
对于命中率下跌造成的稳定性问题,根据业务特点可以使用双TTL策略,5sttl 加一个24小时ttl,第一个ttl过期后优先从DB回写,回写成功后更新第二个ttl数据,回写失败则读取第二个ttl的老数据做兜底,这损失了一致性但提高了可用性。
主动更新
当数据库发生变更时主动触发更新缓存,可以使用DB本身的触发器,或者通过agent消费binlog日志然后update缓存,通过kafka同步binlog日志,保证仅执行一次语义。
主动失效
业务逻辑自己判断是否缓存失效,并处理缓存删除等操作,需要保证操作的事务性且要要做一些自旋操作减缓命中率下跌。
比如通过分布式锁,在第一个线程回写后其他请求进行一段时间的自旋再回写成功后再来查询,降低了命中率。
对于plato,可以选择根据不同场景灵活组合上述三种策略。
对于一些配置参数等变更不频繁的信息,可以使用主动失效策略。
对于只读模式的缓存,可以使用主动更新,例如关系映射的存储,同时主动更新更多适用于跨数据中心复制的场景。
对于消息状态这种旁路缓存的模式,更加适用于主动失效的策略,但最好是找一个支持事务操作的kv db,这样实现更加优雅
redis跨数据中心缓存数据同步
Master/Slave IDC 模式
a.
redis的master必须在某个单一的IDC内,其他的slave存在于其他IDC中。
b.
所有的写操作都必须路由到master机房内,DB 通过agent+MQ等机制同步binlog
c.
到达slave IDC后通过agent消费binlog并删除redis内的数据,下一次该IDC的查询将访问DB更新数据保证一致性。
d.
Slave IDC 也可以使用分布式锁来避免缓存击穿。
Master 回源策略,解决IDC缓存一致性问题,假设: 用户写key后需要立即读到最新的内容,而其他用户可以稍微延迟一些
a.
如果slave idc有写请求发生时,先对本地的key做一个标记,标记其可能过期,然后再去msater idc update key。
b.
此时,slave idc 如果在 master idc 没有binlog 同步过来之前,有读请求到来,发现缓存中存在远程标记,则直接回查master idc
c.
从master idc 中拿到最新的数据
内存不足
📌
压缩key
对value进行高效的序列化,编解码,然后应用压缩算法。
压缩效率: snappy < LZ4 < ZSTD < GZIP 增加延迟: GZIP > ZSTD > LZ4 > snappy
替换存储引擎
使用高性能的磁盘kv避免大规模内存kv的运维问题
对于 plato, 存储映射关系等倒排索引信息可以使用专用的持久化kv,例如 rocksdb等,增加1-3ms延迟可以换来巨大的运维收益,且更加稳定的性能曲线。
缓存针对运维优化方案
易于水平扩展,支持多数据中心部署,且便于运维(重启迁移,可观测性,自动化能力,手动干预)
| 方案 | 收益 | 风险 |
|---|---|---|
| 对于 旁路缓存和穿透模式 在重启后缓存会丢失,需要预热处理1.热备: 对于重启时,先启动新版本集群,然后通过流量拷贝对缓存做无损预热,等命中率达恢复后,切换流量,并下线旧集群。2.Checkpint: redis可用通过RDB,AOF等方式恢复缓存,但是对于高并发场景这些操作是性能杀手,对此可以通过MQ作为分布式的预写日志,WAL文件。对所有的变更命令写入到MQ中,并定期的做checkpint,将其写入HDFS中,当集群重启时,选中最近一次的checkpoint文件重新load到本地,恢复启动,由于变更命令使用异步的MQ方式,MQ保证消息不丢失,使得简化了操作提示了系统可靠性。 | 1.热备方案实现简单,可靠性强2.checkpoint方案 灵活可扩展 | 1.热备要消耗非常多的成本,但命中效果无损2.checkpoint会有一致性问题切换后命中率效果有损 |
| 对于可观察性1.checkpint机制可以很好的用来做调试,回溯日志等功能,比如在本地启动集群,使用某个版本的checkpoint数据做为测试调试使用2.基于checkpoint数据去做日志分析等操作。 | ||
| 自动化能力当分布式缓存的规模上升到一定程度,最大的问题不再是性能问题(通过水平扩展解决), 而是容错与运维,这就需要一系列的一套专用的自动化运维系统,可以在缓存命中率下降,或者内存不足时,进行主动发现与扩容(通常是报警)等操作。这就要与我们之前讲稿的ops server集合使用,解决运维成本。所有的架构演进都是如此。 | ||
| 图片资源缺失:blob:https://hardcore.feishu.cn/42ca6ab2-db71-4054-9b1e-e67dde6f6eaa |
分布式存储-IM的存储模型设计
事实上,对于IM来说,最重要的只有两件事 同步和存储。
消息的同步又有两种,一种是在线同步,一种是离线同步,在本章中我们主要关注离线同步与历史存储。
存储消息的目的无外乎两个,同步和检索。
离线消息方案选型
| 方案 | 具体方案 | 优势 | 风险 |
|---|---|---|---|
| 写放大+推模型 | 1. 离线的消息要被读取,为优化读取的延迟,采用写放大模式,将消息按收件箱维度存储,用户A发送消息到会话中后会产生N次写操作(N=会话人数-1)。2. 用户登录时变更为在线状态,通知业务server拉取未同步的离线消息并push给用户,用户通过userID和maxSeqID分页同步。3. 消息seqID不连续时从历史消息补洞。4. 以inbox:{userID}为key,value为打包msg结构,用zset存储(score为seqID)。5. 单个zset最多存1000条以防大key。6. 同步成功后ack并删除消息实现FIFO模型。7. 新消息插入队列头部并删除旧消息保持新鲜度。8. 超过离线存储的消息由客户端主动拉取。 | 1. 读取延迟低2. 实现简单3. 及时性高4. 通过拉取补洞保证可靠性 | 1. 万人群聊单消息需上万次写入不可接受2. 离线消息存储为缓存,存在一致性问题(如消息撤回需更新多收件箱,可通过仅存msgID+查询状态缓存缓解)3. 客户端需实现消息补洞逻辑 |
| 客户端拉取离线消息 | 1. 客户端登录后主动分页拉取离线消息。2. 写入收件箱时检查seqID连续性(存储msgID和preMsgID结构体,链式校验连续性,漏洞时触发补偿逻辑)。 | 实现简单,客户端可控,无消息补洞问题 | 未解决万人群聊写放大问题 |
| 读放大+拉模型 | 1. 万人群聊场景下降级为读放大,按会话维度存储消息避免写入风暴。2. 所有用户读取同一份消息列表,按用户维度维护消息可见性。3. 优先读吞吐,采用批处理思想。 | 解决万人群聊写入风暴问题 | 推拉结合实现复杂度高 |
历史消息方案选型
消息历史的查询,通常存在于消息漫游的场景,当同一个用户登陆了自己的一个新设备后,需要同步最近的历史消息过来,并且对于某个会话不断的向上拉取,可以获得历史消息,对于企业IM来说,通常要支持保留全部历史消息。
| 业务 | 具体方案 | 优势 | 风险 |
|---|---|---|---|
冷热分离![]() | 1. 一周之内的消息存储在MySQL中,一周之前的消息存储在HBASE上。2. 假设IM消息存在明显冷热分离特征,近期消息高频读取,历史消息因时效性衰退且检索需求低,可通过客户端存储降低服务端压力。3. 换设备时的消息漫游视为低频操作。4. 每周切换新DB集群,老集群离线dump数据到HDFS并同步至HBASE。 | 充分发挥不同存储介质优势,冷数据用低成本高容量存储(HBASE),热数据用高性能数据库(MySQL) | 1. 实现复杂度高2. 冷热库切换时存在查询一致性问题 |
流处理机制![]() | 通过Flink等流计算平台异步将消息高效写入HBASE。 | 解决冷热库切换时因高延迟导致的一致性问题 | 无额外描述 |
| 基于写多读少的分布式KV系统 | 1. 历史消息存储为写多读少场景,需构建适配索引(MySQL B+Tree索引存在性能浪费,因其OLTP事务隔离级别不适用)。2. 使用分布式KV数据库,key为sessionID:seqID,value为PB序列化消息。3. LSM结构按key排序,相同sessionID消息存储在同一机器SST文件(或compact后的连续SST)。4. 历史消息检索通过sessionID分片路由,单机点查效率高。5. 历史消息写吞吐优先,读延迟优先。6. sessionID哈希分片至MQ分区,根据分区数%shard数确定消费逻辑。7. 热群消息可扩展至分区倍数,shard混合存储支持LSM L7层扩展至HDFS实现无限容量。8. 通过sessionID哈希定位机器,优先保障高热群消息查询性能。 | 1. 专用负载设计实现降本增效2. 流批一体架构保证冷热数据一致性3. 极端热点场景下Kafka吞吐足够支撑4. 高热群历史消息价值低,可选择性关闭历史功能 | 1. 研发成本巨大2. 多级异构存储复杂度高 |
分级存储和异构存储设计

| 业务 | 具体方案 | 优势 | 风险 |
|---|---|---|---|
| 多级别异构 Timeline 架构 | 1. 将所有会话抽象为时间线,通过seqID形成全局唯一时间线,消息生产者写入MQ后由触发器写入存储库。2. 消息按时效分层存储:1天内存Redis,1周内分布式KV,1周以上HBASE。3. 通过MQ消息总线串联全局顺序一致性,实现批流一体(Flink保障仅执行一次语义)。 4.在此模型中,消息的生产者仅需要将数据写入MQ然后由专用的触发器负责写入存储库中。 | 1. MQ保障消息一致性2. 多存储介质组合实现效能最大化 | 1. 多数据库异构存储导致运维复杂度高2. MQ流式处理存在误差风险,需离线dump补偿机制(Lambda架构初衷) |
| 专用Timeline数据库 | 1. 设计时序数据库存储消息,key为sessionID:日期(如{sessionID}:20220703),value为二进制压缩消息。2. 基于LSM结构分层存储:当日数据存内存(L0-L1),7天内本地磁盘(L2),7天以上HDFS(L3+)。3. 查询时直接RPC调用HDFS恢复SST文件,需实现分布式compact组件合并跨机器SST。4. 用户维度离线消息仅存L0层避免compact。 | 1. 单集群支持离线/历史消息存储2. LSM compact高效实现冷热分离3. 统一存储团队维护提升人效 | 研发成本巨大,需自研数据库级产品 |



