飞享IM核心技术说明与方案改进说明

comsince

comsince

FshareIM Team

飞享IM(FshareIM)是一款支持私有部署的即时通讯系统,服务端采用 Java 实现,核心由 push-connector(接入层)push-group(业务层) 两个服务组成。本文从实际运行角度梳理系统的13个核心技术模块,逐一说明当前方案的设计思路、存在的隐患,以及对应的改进建议,帮助开发者快速理解系统全貌。


0. 系统容量与性能瓶颈

能同时支持多少用户在线?

飞享IM的接入层基于 t-io 框架构建,连接维持成本极低。以一台 16GB 内存的服务器为例:

  • JVM 堆内存:每条连接约消耗 2.7KB(读缓冲区 + 对象开销)
  • 操作系统内核:Linux 为每条 TCP 连接维护收发缓冲区,空闲连接约占 4–8KB

综合估算,一台 16GB 的服务器可支撑约 110 万并发连接,且真正的瓶颈不是 JVM 堆,而是 Linux 内核的 TCP 缓冲区。通过调低内核参数中的 TCP 收发缓冲最小值,可以在牺牲少量吞吐量的前提下进一步提升连接容量。

由于消息 ID 设计中预留了 6 bit 的节点编号(最多 64 个节点),理论上系统可水平扩展至 64 个接入节点,总并发连接上限接近 7000 万

消息处理吞吐量

当前消息处理链路(包含分布式锁、内存写入、数据库持久化)的平均耗时约 10–20ms 每条消息,MySQL 连接池上限 100 个,实际稳定 TPS 在 3,000–5,000 条/秒

全链路瓶颈汇总

瓶颈层根因影响
系统文件描述符上限未调优默认值 1024单节点连接数被限制在 1024
业务线程池大小按 CPU 核数固定,不适合 I/O 密集场景高并发时消息处理队列积压
分布式锁竞争同一用户的消息必须串行分配序号高频收消息时延迟叠加
MySQL 连接池上限仅 100 个,且持锁期间同步写 DB高峰期连接耗尽,等待超时报错
Hazelcast 内存Session 缓存无容量上限在线用户增多时内存无限增长,可能 OOM

1. 二进制协议设计

如何解决 TCP 粘包?

TCP 是字节流协议,连续发送多条消息时数据会粘在一起,接收端无法判断每条消息的边界。飞享IM 采用了经典的 固定包头 + 变长包体 方案:

每个数据帧以 10 字节的固定头部开始,其中包含:

  • 魔数(固定值 0xF8)用于快速甄别合法数据帧
  • 协议版本号
  • 信令类型(如 PING、认证、消息推送等)
  • 包体长度(4字节,支持最大 2GB 的消息体)
  • 子信令类型
  • 请求关联 ID(用于匹配请求和响应)

消息体使用 Protocol Buffers 序列化,相比 JSON 节省 30–60% 的流量,且字段变更向后兼容。

系统同时支持 TCP 二进制通道(Android 客户端)和 WebSocket JSON 通道(Web 端),共享同一套信令体系。

当前问题

  • 请求关联 ID 只有 2 字节(最大 65535),高并发时会回绕,导致 ACK 匹配错误
  • WebSocket 通道使用 JSON,字段名未压缩,对移动端流量有浪费
  • 子信令类型用枚举顺序编码,新增类型必须追加到末尾,否则破坏老版本客户端的解码逻辑

改进建议

将请求关联 ID 扩展到 4 字节(约 42 亿),彻底消除回绕风险。子信令类型改为显式数值编码,每个类型绑定固定的整数值,不再依赖枚举定义顺序,新增类型不会影响已有客户端。


2. 分布式消息 ID 生成

Snowflake 变体设计

飞享IM 使用类 Snowflake 算法生成全局唯一的消息 ID,一个 64 位长整数由三部分组成:

| 43位时间戳(相对2018年) | 6位节点编号(0~63)| 15位序号(每毫秒内自增)|

这种设计有两个关键特性:

  1. 时间有序:ID 可直接反推消息时间,用于消息分表路由
  2. 单节点每毫秒可生成 32768 个 ID,64 节点合计约 209 万条/毫秒,满足中等规模

当前问题

  • 时钟回拨未处理:服务器 NTP 对时时可能出现时钟短暂回拨,此时生成的 ID 会小于已有 ID,破坏时序,导致消息写入错误的分表
  • 节点编号手动配置:多节点部署时需人工保证每个实例的节点编号唯一,容器化环境下自动扩缩容极易出现重复,导致消息 ID 碰撞
  • 自旋锁在高竞争下 CPU 空转:自旋锁适合极短临界区,若 ID 生成调用非常密集,会导致 CPU 利用率异常升高

改进建议

时钟回拨处理策略:允许 5ms 以内的回拨(让线程短暂等待),超过 5ms 则直接拒绝服务并告警。节点编号改为启动时从 Redis 自动申领,进程存活则定期续期,进程退出后自动释放,彻底解决容器化环境下的冲突问题。


3. 应用层心跳机制

动态心跳设计

飞享IM 在 TCP/WebSocket 之上实现了应用层心跳,由客户端主动发送 PING 并上报期望的心跳间隔,服务端据此动态调整超时阈值(客户端上报间隔 + 60 秒宽限期)。超时后框架自动触发断线回调,将 Hazelcast 中的 Session 标记为离线并同步写入 MySQL。

当前问题

  • 心跳包序列化开销:心跳是高频包,目前使用 JSON 解析,每次都有反序列化开销
  • 幽灵连接:接入节点进程崩溃(kill -9)时断线回调不会触发,Hazelcast 中残留大量 online=true 的僵尸 Session,其他节点会持续向已死连接投递消息,造成消息静默丢失
  • 心跳未更新业务活跃时间:心跳只重置连接层超时,不更新 Session 的最后活跃时间

改进建议

心跳包使用 4 字节二进制整数替代 JSON,节省解析开销。Session 写入 Hazelcast 时设置 TTL(约为心跳间隔的 2.5 倍),每次收到心跳时续期。进程崩溃时 TTL 自动到期,幽灵 Session 得到自然清理,无需额外的定时巡检任务。


4. 通知-拉取消息模型

为什么不直接推消息?

飞享IM 采用 Notify-Pull(通知-拉取) 模式:服务端收到新消息后,先落库,再向目标客户端推一个极小的通知(不含消息正文),客户端收到通知后主动拉取消息内容。

发送方 → 服务端落库 → 推送轻量通知(MN) → 客户端发起拉取 → 返回消息体

这种设计的好处是:通知包极小(不到 20 字节),服务端推送压力低;断线重连后,客户端只需告知上次已读位置,即可完整补拉期间的所有消息,天然支持增量同步。

当前问题

  • 额外一个 RTT 的延迟:相比直接推送,多了一次客户端主动拉取的网络往返
  • 通知本身无持久化:若通知包因网络抖动丢失但连接未断,客户端无感知,只能等下次心跳或下次通知触发时才能补拉
  • 群消息通知风暴:1000 人群发一条消息,服务端需向 1000 个 Session 各发一次通知推送

改进建议

对同一 Session 的通知合并发送:在 50ms 窗口内若有多条通知,只推最后一个(携带最新序号),客户端一次拉取即可补全所有消息,减少重复拉取请求。同时在心跳响应中附带最新序号,作为通知丢失时的兜底校验机制。


5. 消息有序性:个人收件箱 Seq

双层 ID 体系

飞享IM 维护两套 ID:

ID生成方作用
messageIdSnowflake,全局唯一消息内容的唯一标识,用于存储和查找
messageSeq按用户递增,用户私有收件箱游标,客户端以此做断点续拉

每条投递给用户的消息,服务端会在 Hazelcast 分布式锁保护下为该用户分配一个递增的 messageSeq,并维护一份 seq → messageId 的映射(热缓存最近 1024 条)。客户端上报上次已读的 seq,服务端返回其后的增量消息,实现精确的断点续传。

当前问题

  • 分布式锁串行化:同一用户的所有消息必须串行分配 seq,在活跃群中该用户高频收消息时,等锁延迟会持续叠加
  • 热缓存超限后无回退:热缓存只保留最近 1024 条映射,若用户离线时间较长(积压超过 1024 条),重连后服务端找不到对应的历史映射,消息拉取会不完整
  • 持锁期间同步写数据库:seq 分配时持有锁,同时等待 MySQL 写入,加剧了锁等待时长

改进建议

用 Redis 的原子自增操作(INCR)替代 Hazelcast 分布式锁来分配 seq。Redis INCR 是原子操作,单次耗时不到 0.5ms,且无阻塞等待。Hazelcast 热缓存的更新和 MySQL 持久化均改为异步执行,锁内只做轻量计数,大幅降低等锁延迟。同时补充离线消息回退查询逻辑:当热缓存找不到客户端游标时,自动回退到 MySQL 补查历史记录。


6. 分布式会话管理

热冷双层架构

Session(在线状态)采用 Hazelcast(热)+ MySQL(冷) 两层存储:

  • Hazelcast IMap 存放在线 Session,按 clientID 索引;另一张 Map 维护 userId → Set<clientID> 的多设备映射,支持一个用户多端同时在线
  • MySQL 作为持久化层,服务重启时从 MySQL 加载 online=true 的 Session 恢复热缓存

消息投递时,服务端先查 userId → clientID 的多设备映射,再向每个活跃 Session 推送通知。

当前问题

  • 幽灵 Session(同第3节):进程崩溃不触发断线回调,残留的在线标记会导致消息投递到已死连接
  • 并发写集合存在数据竞争:Hazelcast 的 IMap.get 返回的是反序列化后的本地副本,如果两个线程同时读取 → 修改 → 写回,后写的会覆盖前写的,导致某个设备的 clientID 被丢失
  • 清理顺序不当:断线清理时先删除 MySQL 记录,再从 Hazelcast 移除,中间窗口期内状态不一致,服务重启时该 Session 不会被恢复

改进建议

Hazelcast Session 写入时设置 TTL,心跳续期(同第3节)。多设备集合的写操作改用 Hazelcast EntryProcessor 原子执行,避免并发覆盖。清理顺序调整为先清 Hazelcast、再更新 MySQL,确保崩溃恢复时不会加载无效 Session。


7. 接入层水平扩展

无中心路由设计

由于消息 ID 中嵌入了节点编号,系统无需独立的路由注册中心。每个接入节点(connector)在启动时配置唯一的 node.id(0~63),发给该节点的消息 ID 中会携带这个编号,其他节点可直接解析 ID 推算消息来源节点。

集群模式下,消息通过 Kafka 跨节点路由:push-group 业务层根据目标用户的 Session 所在节点,将消息发送到对应节点的 Kafka topic,目标节点消费后推送给用户。单节点模式下则通过 Dubbo RPC 直接调用。

当前问题

  • 集群模式默认未启用:Kafka 路由配置默认注释,实际运行是单节点 Dubbo 模式,扩容接入节点后跨节点消息会丢失
  • 节点编号手动配置:容器化自动扩缩容时容易出现 node.id 冲突(同第2节)

改进建议

节点编号改为启动时从 Redis 自动申领(参考第2节改进方案),消除人工配置的冲突风险。集群模式的 Kafka 配置提供开箱即用的默认值,降低接入门槛。


8. 消息可靠投递

服务端生成 ID,先存后 ACK

飞享IM 的可靠性保证思路是:服务端负责分配消息 ID,消息持久化成功后才向发送方返回 ACK。客户端本地有一个临时消息 ID(localMessageId),ACK 返回后客户端将本地临时消息替换为服务端的全局消息。

当前问题

  • 客户端重传时服务端未做去重:客户端收不到 ACK 时会重传,服务端的存储逻辑没有检查 localMessageId 是否已处理过,导致同一条消息被重复入库,用户看到重复消息
  • 存储与推送没有事务保障:消息落库(storeMessage)和推送通知(publish2Receivers)是两个独立操作,若落库成功但推送失败,消息已存入数据库但接收方不知道,只能等下次心跳才能感知

改进建议

在消息存储前,先以 发送方+localMessageId 为 key 查 Redis 判断是否已处理过。若已存在,直接返回已有消息,不再重复入库。这样客户端无论重传多少次,服务端只会保存一份。


9. 群消息写扩散

统一写扩散的代价

飞享IM 对所有群消息统一采用 写扩散 策略:群内每发一条消息,服务端为群内每个成员各写一份收件箱记录(分配独立的 seq、写 Hazelcast、写 MySQL、推送通知)。

以 1000 人群为例,一条消息会触发:

  • 1000 次分布式锁操作(每人分配一次 seq)
  • 1000 次 MySQL 写入
  • 最多 1000 次在线推送

当前问题

  • 大群写放大严重:千人群高频发消息(如群直播弹幕)时,锁竞争和 MySQL 批量写入迅速成为瓶颈
  • 离线用户也触发写扩散:7 天未登录的用户每条群消息依然为其写一条 seq 记录,浪费存储和计算

改进建议

按群规模采用不同扩散策略:

  • 200 人以下小群:沿用写扩散,每人独立 seq,拉取逻辑不变
  • 200 人以上大群:改为读扩散,消息只写入群消息表一份,并分配群内全局递增序号。仅向在线成员推送通知,离线成员上线时按群序号自行拉取增量消息,彻底消除离线用户的写扩散开销

10. 已读回执

两级回执:投递 + 已读

飞享IM 实现了投递回执(消息送达)和已读回执两级:

  • 投递回执:消息推送到目标 Session 时生成,通知发送方消息已送达
  • 已读回执:接收方打开对话时上报,通知发送方对方已阅读

私聊场景下,已读回执发给消息发送方;群聊场景下,读过消息的每个成员各产生一条已读记录并推送通知。

当前问题

  • 群已读回执 O(N) 写放大:1000 人群同时阅读同一条消息,产生 1000 次写操作和 1000 次推送,在大型活跃群中开销极高
  • 无批量合并:客户端每读一条消息立即上报,高速滑屏时产生大量密集的小请求

改进建议

群已读回执改为计数器模型:不记录「谁读了」,只维护「已读人数」,通过 Redis 原子自增实现,仅通知消息发送方当前已读人数的变化。若业务需要「谁读了」的明细,用 Redis BitSet 按成员位置标记,内存开销极低。客户端积累 200ms 内的已读消息,批量合并后一次上报,降低请求频率。


11. 接入层限流

漏桶算法防洪泛

接入层对每个客户端的请求频率做了限制:基于漏桶算法,5 秒内最多允许 100 次操作,超频直接返回错误码,不进入业务逻辑。

当前问题

  • 单节点内存限流,多节点失效:限流状态存在单个服务实例的内存中。用户请求被负载均衡分散到多个 connector 节点时,每个节点各自独立计数,实际放行的 QPS 是配置值的节点数倍,限流形同虚设
  • 全局自旋锁成为串行瓶颈:所有请求共用一把自旋锁,高并发下自旋等待会成为新的性能瓶颈
  • 限流粒度过粗:发消息和心跳包使用同一套限额,实际应该按操作类型分级

改进建议

用 Redis + Lua 脚本实现分布式滑动窗口限流,所有节点共享同一份计数状态,限流规则真正对用户生效。按操作类型设置不同上限(如发消息 20 次/秒,心跳 1 次/30 秒),精细化控制不同类型的流量。


12. AI 流式输出协议(SAI)

旁路推送,不走消息存储

飞享IM 新增了 SubSignal.SAI 子信令,专为 AI Agent 的流式回复设计。与普通消息不同,SAI 走旁路推送,不入消息库,每个 token 增量直接通过 WebSocket 实时推送给用户:

  1. 发送流开始信号 → 客户端创建一个占位消息气泡
  2. 每产生一个 token → 推送增量,客户端实时追加到气泡
  3. 发送流结束信号 → AI 完整回复落库,普通消息通知触发客户端替换占位气泡

这种设计让用户能看到 AI「逐字打出」的效果,无需等待完整回复生成后才显示。

当前问题

  • 每个 token 携带完整 JSON 元数据:流 ID(36 字节 UUID)、是否结束标志等在每帧都完整传输,高频 token 推送时带宽和序列化开销不可忽视
  • 断线后无法恢复流式内容:SAI 不入库,用户在 AI 回复过程中断线重连,已推送的 token 全部丢失,只能等完整回复落库后通过普通消息拉取,体验不连贯
  • AI 处理线程池固定 4 个线程:多用户同时与 AI 对话时,线程池成为并发瓶颈

改进建议

SAI 帧格式改为紧凑二进制:流 ID 改用 4 字节哈希(仅首帧携带完整 UUID),加 1 字节标志位(是否首帧、是否结束),其余全部是 UTF-8 内容,去掉所有 JSON key 名称的冗余开销。AI 处理线程池改为有界动态线程池(4~16 线程,队列上限 200),队列满时返回「服务忙」错误而非静默阻塞,避免 OOM。


13. 消息分表策略

按时间滚动分表

随着消息量增长,单表查询性能会持续下降。飞享IM 利用消息 ID 中嵌入的时间信息,实现了按月自动分表:从消息 ID 中解析出月份和年份,映射到 36 张分表(3 年 × 12 月循环复用,表名从 t_messages_0t_messages_35)。查询历史消息时自动计算涉及哪几张分表,跨表合并返回。

当前问题

  • 时钟回拨导致分表路由错误:消息 ID 中的时间戳若因时钟回拨偏差,解析出错误月份,消息写入或查询了错误的分表
  • 3 年后历史数据未清理:第 4 年的 1 月会复用第 1 年 1 月的分表,若旧数据未归档清理,新旧数据混在同一张表,造成数据污染
  • 跨表查询逻辑散乱:各处调用方自行处理跨表逻辑,新增查询场景容易遗漏

改进建议

封装统一的跨表查询工具方法,输入起止消息 ID,自动计算涉及的分表列表并合并查询,所有场景共用同一套逻辑。增加定时归档任务(每月 1 日凌晨),将 3 年前同月的历史数据导出到对象存储(如 MinIO/OSS)后清空分表,防止数据污染。


总结

模块核心难点当前方案改进优先级
消息 seq 分配分布式有序、并发安全Hazelcast 分布式锁⭐⭐⭐ 高(Redis INCR 替换)
幽灵 Session进程崩溃不触发清理无 TTL⭐⭐⭐ 高(Hazelcast TTL + 心跳续期)
大群写扩散N 人群 N 倍写放大统一写扩散⭐⭐⭐ 高(分级扩散策略)
消息幂等客户端重传去重无服务端去重⭐⭐ 中(localMessageId 幂等校验)
Snowflake 时钟回拨ID 乱序 / 分表路由错误无防护⭐⭐ 中(等待或拒绝服务)
限流单节点内存限流HashMap + 自旋锁⭐⭐ 中(Redis 分布式滑动窗口)
node_id 分配容器化自动扩缩容冲突手动配置⭐ 低(Redis 自动申领)
SAI 帧格式JSON 序列化开销JSON 逐帧推送⭐ 低(紧凑二进制帧)

三个高优先级改进(Redis INCR 替换分布式锁、Session TTL 防幽灵连接、大群读扩散)是飞享IM在中大规模场景下稳定运行的关键,其余改进可按业务发展阶段逐步落地。