我花两年业余时间做了个IM系统,然后呢😂??

做了一个仿微信的即时通讯系统,从协议设计到消息投递,从 Java 微服务到 Flutter 跨端 SDK。系统做完了,但我发现,故事似乎刚刚开始。

一、为什么做这个项目

1.1 与 IM 结缘

与 IM 结缘是在 2022 年。当时在研究各类分布式系统时,我发现即时通讯是后端领域挑战性最大的方向之一,长连接管理、消息可靠投递、多端同步、高并发处理,每一个单独拿出来都是硬核问题,而 IM 系统需要把这些全部整合到一起,还能稳定运行。

从那时起,我就对 IM 如痴如醉。

但我很快发现,看文章和动手做完全是两码事。不落地的设计不是好设计------这是我在这个项目中反复验证的道理。很多方案在纸面上看起来很完美,真正写代码部署上去才发现各种边界条件和异常场景,这些都是不亲手做永远体会不到的。

IM 系统有一个很大的"欺骗性"------看起来很简单,好像人人都能写一个。发消息、收消息、聊天列表,界面就那几个,功能说出来谁都懂。但真正动手做才会发现,想做好、做稳定,要考虑的东西多到让人头皮发麻。

我把这个项目过程中踩过的核心难题列出来,也给出我的解法,后文会展开每个点的具体实现:

消息可靠性

难题 到底难在哪 我的解法
消息不丢 网络抖动、服务重启、客户端崩溃,任何环节都可能丢消息 Redis ZSet 延迟重试队列 + RocketMQ 持久化兜底,三次渐进式重试(5s→30s→300s),超过重试次数转离线存储
消息去重 重试、网络重传、断线重连都会产生重复消息 双 ID 设计:clientMsgId(UUID)客户端去重 + msgId(雪花算法)服务端排序;SDK 内存缓存 2 分钟 TTL
消息有序性 群聊多人同时发言,消息到达顺序可能和发送顺序不一致 雪花 ID 天然递增,客户端按 msgId 排序展示,不依赖到达顺序
重试消息 重试过程中服务重启怎么办?多个重试任务并发怎么防冲突? Lua 脚本原子认领(ZRANGEBYSCORE + ZREM 原子操作),LZ4 压缩存储节省 Redis 内存

连接与状态

难题 到底难在哪 我的解法
断线重连 移动端网络频繁切换(WiFi↔4G),重连不能丢消息 SDK 指数退避自动重连(1s→30s),_pendingSends 队列保存发送失败的消息,重连后自动 flush
弱网优化 地铁、电梯里网络时断时续,消息发不出去又不能丢 客户端消息级重试(2s→4s→8s,最多 3 次)+ 离线消息队列双重保障,网络恢复后自动补发
在线状态一致性 用户连在节点 A,但节点 B 还以为他在线;用户断网了服务端没感知到 Netty IdleStateHandler 心跳检测 + 本地 ConcurrentHashMap 管理连接 + 定时清理 stale channel
百万连接 单机内存、线程模型、GC 压力都是瓶颈 Netty Epoll + Boss/Worker 线程分离 + PooledByteBufAllocator 池化内存 + TCP 参数调优(目前还在优化中)

消息投递

难题 到底难在哪 我的解法
离线消息 用户离线期间积压了几百条消息,上线后怎么高效拉取? 推拉结合:MobPush 推送通知唤醒 → 客户端主动拉取会话列表摘要 → 打开聊天时按需拉历史,避免大量消息瞬间涌入
群消息广播 一个 500 人的群,一条消息要推到所有在线成员,延迟怎么控? RocketMQ 广播消费 + 每个 im-connect 只推本地在线成员,读扩散模式只存一份消息
多端同步 同一账号手机 + 平板同时登录,消息状态怎么保持一致? 支持最多 5 设备同时在线,每设备独立 Token(Redis Key 含 deviceType),已读回执实时同步
已读未读 用户打开聊天就标记已读,但快速切换多个聊天会话时容易乱 SDK 上下文感知 ACK:ChatSessionManager 追踪当前打开的会话,自动发送已读回执,用户无感知
已读回执 一条一条发已读 ACK 太频繁,浪费带宽和性能 水位线机制:客户端只发一个 lastReadMsgId,服务端按 msgId ≤ lastReadMsgId 批量标记已读,一条指令搞定,无需逐条确认

协议与存储

难题 到底难在哪 我的解法
消息 ID 设计 UUID 无序不好排序,自增 ID 分布式环境下会冲突 双轨设计:clientMsgId(UUID 去重)+ msgId(雪花算法,全局唯一且天然有序)
协议设计 JSON 体积大解析慢,高频消息场景下是性能瓶颈 Protobuf 二进制协议 + 信封模式 + 字段级优化(fixed64 存 ID、bytes 存 UUID、chatId 动态计算)
客户端消息存储 聊天记录全放服务端?每次打开聊天都网络请求? 本地 SQLite 缓存 + 服务端 MongoDB 持久化,拉取历史时按时间范围增量同步
高并发低延迟 消息链路越长延迟越高,怎么把端到端延迟压到毫秒级? 三层路由(本地直推 → gRPC 跨节点 → 离线存储),Protobuf 减少序列化开销,TCP_NODELAY 禁用 Nagle

上面这张表基本覆盖了 IM 系统的核心难点。每一行背后都是反复调试、推翻重来的过程。所以很多人写了聊天 Demo 就觉得 IM 不过如此,但 Demo 和生产级系统之间的差距,可能比 Java 和 JavaScript 的差距还大,哈哈,都是泪啊。

后面的章节会逐一展开每个点的具体实现细节和踩坑经历。

1.2 那些想放弃的瞬间

说实话,这个项目过程中有太多次我想放弃:

  • 早期用 Dubbo 做服务间通信,序列化问题和版本兼容搞了好久,最后决定整个迁移到 gRPC,几乎推倒重来
  • 消息存储经历了三次演进:MySQL 分库分表扛不住高频写入 → 迁移到 HBase 发现查询模式不匹配 → 最终迁移到 MongoDB,重新设计消息分片策略
  • 从 Java 11 升级到 Java 21,从 Spring Boot 2.7 升级到 3.3.7,中间遇到了各种 API 变更和兼容性问题
  • 最难的是客户端------找不到合适的 app 端同学,我决定自己写。一个后端开发去学 Flutter、学 Dart、学移动端的 UI 范式,从零开始写 IM SDK,这个过程真的是咬着牙挺过来的

每次遇到这些问题,我都会想:要不就算了吧,谁也不缺这么一个项目。但每次想到"不落地的设计不是好设计"这句话,又觉得如果连一个能真正跑起来的 IM 系统都没做过,那之前所有的学习和研究都是纸上谈兵。

于是就这么坚持下来了。这个项目从 2024 年 5 月正式启动,那时候还没有 AI 辅助编程的概念,Vibe Coding 更是闻所未闻------每一行代码都是纯手搓出来的。一直到 2025 年才慢慢开始接入 AI 辅助,但核心的架构设计和关键模块都是自己一行一行敲的。两年下来,记不清有多少个深夜对着屏幕 debug 到天亮,只记得每次调通一个复杂链路时的那种兴奋感------值了。

以下是git提交和代码情况:

GitLab 上一共维护了 4 个项目,各司其职:

项目 说明
xzll-im-server Java 微服务后端,包含 gateway、auth、connect(Netty 长连接)、business(核心业务)、console(管理后台 API)、data-sync(ES 数据同步)、social(社交功能)7 个模块
xzll-im-flutter-client Flutter 客户端 UI 层,基于 GetX 状态管理,22 个页面,负责聊天、好友、群组、音视频通话等用户界面
xzll-im-flutter-sdk 自研 IM SDK(独立 Flutter Package),封装 WebSocket 连接管理、Protobuf 序列化、消息收发、自动重连、离线队列、去重、ACK 等底层能力,与 UI 层完全解耦
xzll-im-app-uniapp 鉴于uniapp 的性能问题,基本废弃。后期会出个uniapp的sdk。

1.3 当前进展

目前这个 IM 系统已经实现了以下功能:

  • 单聊消息收发、已读回执、正在输入、消息撤回
  • 群聊消息、群消息 ACK、@提及、群消息撤回
  • 好友系统(搜索、申请、同意/拒绝、删除、拉黑)
  • 多媒体文件发送(图片、语音、视频,地理位置,文件)
  • 音视频通话(基于 LiveKit,开发中)
  • 社交相关,逐步完善中
  • 管理后台(Vue 3 + Element Plus)

整个系统目前有 104+ 个 API 接口、约 25 张数据库表、22 个 Flutter 页面

技术栈也经历了几轮演进,最终定型为:

bash 复制代码
后端:Spring Boot 3.3.7 + Spring Cloud 2023.0.4 + JDK 21
通信:Netty WebSocket + Protobuf + gRPC
存储:MySQL + MongoDB(分片集群)+ Redis + Elasticsearch + MinIO
消息队列:RocketMQ(DLedger 集群模式)
客户端:Flutter 3.35 + GetX + 自研 IM SDK
配置中心:Nacos

为了支撑这套系统,我自建了 ESXi 服务器,跑了 6 个虚拟机来模拟生产环境。基础设施全自建,从数据库到消息队列到配置中心,全部自己搭建和维护。

这个项目全部是在业余时间完成的。白天上班,晚上和周末写代码,经常写到凌晨两三点,有时候一个 bug 调通抬头天已经亮了。两年下来,长时间夜间编码对眼睛和颈椎的考验很大,后来换了明基 RD270Q 编程显示器,夜间编码的体验提升很大------后文会结合具体的开发场景聊感受。

深夜编码场景实拍:

屏幕光线很柔和,长时间盯着代码眼睛也不酸,凌晨两三点写代码和白天一样舒服。

另外说明一下,文中所有显示器实拍照片由于像素压缩和灯光环境的影响,和实际显示效果有较大差别,拍不出真实屏幕的细腻度和画质,其实实际观感要好得多。

二、整体架构设计

2.1 微服务拆分

IM 系统的业务特点决定了它天然适合微服务架构------长连接服务(有状态)和业务处理服务(无状态)的扩缩容节奏完全不同,必须拆开。

我把系统拆成了以下 7 个核心模块:

模块 职责 端口
im-gateway Spring Cloud Gateway API 网关,JWT 鉴权过滤,限流 8081
im-auth OAuth2 授权服务器,JWT Token 签发与验证 8082
im-connect Netty WebSocket 长连接层,实时消息收发 8085
im-business 核心业务逻辑(消息存储、好友、群组、搜索) 8083
im-data-sync MongoDB → Elasticsearch 数据同步(CQRS) 8086
im-console 管理后台 API 8084
im-social 社交功能(圈子、帖子、活动) 8087

2.2 为什么这样拆

最关键的设计决策是把 im-connect(有状态)和 im-business(无状态)拆开

  • im-connect 维护着所有客户端的 WebSocket 长连接,是典型的有状态服务。它只负责消息的路由和推送,不做持久化。
  • im-business 消费 RocketMQ 消息做持久化,完全无状态,可以随意水平扩展。

这种拆分意味着:如果业务逻辑的 QPS 上来了,直接加 im-business 实例就行,不需要动长连接层。反过来,如果连接数不够了,加 im-connect 节点,通过 Nacos 服务发现自动注册。

数据同步也独立成了 im-data-sync 模块,采用 CQRS 模式------消息先写到 MongoDB,然后异步同步到 Elasticsearch 做全文搜索,不影响主消息链路的写入性能。

2.3 为什么选这套技术栈

在技术选型上,我走过一些弯路,最终的选择都有明确的理由:

gRPC 替代 Dubbo:项目早期用的是 Dubbo 做服务间通信,遇到了不少序列化兼容问题和版本升级痛点。后来迁移到 gRPC,配合 Protobuf 的强类型定义,接口契约清晰,跨语言支持好(服务端 Java,客户端 Flutter 都能用同一套 proto 定义),维护成本大幅降低。这个迁移过程很痛苦,但值得。

RocketMQ 替代 Kafka :IM 场景需要支持广播消费(群消息推送到所有 connect 节点)、事务消息、延迟消息。RocketMQ 原生支持这些特性,不需要像 Kafka 那样额外搭建基础设施。而且 RocketMQ 对消息轨迹和死信队列的支持更完善,方便排查消息丢失问题。部署上采用 DLedger 集群模式,基于 Raft 协议实现 Broker 的主从自动切换,避免了单点故障导致消息丢失的风险------IM 系统对消息可靠性要求极高,任何一条消息都不能丢。

消息存储的三次演进(MySQL 分库分表 → HBase → MongoDB):消息存储是整个项目调整最多的部分。第一版用 MySQL,数据量上来后做了分库分表,但 IM 消息的高频写入很快遇到瓶颈。第二版迁移到 HBase,写入吞吐上去了,但发现 IM 的查询模式(按会话拉取历史、按时间范围分页、按用户 ID 查最近 N 条)和 HBase 的 scan 模型并不匹配------HBase 擅长基于 rowkey 的精确查找,而 IM 消息更多是范围查询。第三版迁移到 MongoDB,灵活的 Schema 和丰富的索引能力让消息存储和查询都顺畅了。这个选型过程走了不少弯路,但每一步都加深了对不同存储引擎特性的理解。

Spring Cloud Gateway:WebFlux 异步非阻塞模型,适合网关这种高并发低延迟的场景。配合 JWT Filter 做鉴权,整体链路简洁高效。

2.4 部署架构

系统整体架构图如下:

部署拓扑如下:

上边是设计层面的架构和部署拓扑,实际跑起来是这样的------各服务在虚拟机上的运行日志:

三、消息协议设计:Protobuf 优化实战

3.1 为什么选 Protobuf

IM 场景下,协议效率直接影响系统性能。每秒可能有数千条消息在传输,如果用 JSON 这种文本协议,体积大、解析慢,在高频消息场景下是明显的瓶颈。

Protobuf 作为二进制协议,相比 JSON 有几个关键优势:

  • 体积小 3-10 倍:二进制编码,没有冗余的括号、引号、空格
  • 解析快 20-100 倍:直接内存映射,不需要文本解析
  • 强类型:编译期就能发现协议错误,不用等到运行时

还有一个我选择 Protobuf 的重要原因:三端协议一致性 。IM 系统有 Java 服务端、Flutter SDK、Flutter 客户端三个端,Protobuf 的 .proto 文件就是三端共享的接口契约。改了 proto 文件,三端编译时立刻就能发现不兼容的地方,比 JSON 靠文档约束字段名可靠得多。

当然 Protobuf 也有代价------proto 文件的维护和跨端同步需要额外的工作量,但这个代价和它带来的收益相比完全值得。

3.2 信封模式协议设计

我采用了经典的「信封模式」来设计协议------外层是统一的信封,内层按消息类型分发不同的载荷:

protobuf 复制代码
// 客户端 → 服务端
message ImProtoRequest {
  MsgType type = 1;    // 消息类型
  bytes payload = 2;   // 具体消息内容(按 type 反序列化)
}

// 服务端 → 客户端
message ImProtoResponse {
  MsgType type = 1;
  bytes payload = 2;
  int32 code = 3;      // 响应码
  string msg = 4;      // 响应描述
}

MsgType 枚举定义了 24 种消息类型,覆盖了 IM 系统的所有场景:

protobuf 复制代码
enum MsgType {
  C2C_SEND = 1;           // 单聊发送
  C2C_ACK = 2;            // 单聊 ACK
  C2C_WITHDRAW = 3;       // 单聊撤回
  C2C_MSG_PUSH = 5;       // 单聊消息推送
  GROUP_SEND = 7;          // 群聊发送
  GROUP_MSG_PUSH = 8;      // 群消息推送
  GROUP_ACK = 9;           // 群消息 ACK
  GROUP_WITHDRAW = 10;     // 群消息撤回
  FRIEND_REQUEST = 11;     // 好友申请
  FRIEND_RESPONSE = 12;    // 好友响应
  READ_RECEIPT = 14;       // 已读回执
  TYPING_STATUS = 16;      // 正在输入
  CALL_OFFER = 20;         // 通话邀请
  CALL_ANSWER = 22;        // 通话接听
  CALL_REJECT = 23;        // 通话拒绝
  CALL_END = 24;           // 通话结束
  // ...更多类型
}

代码位置:im-common/src/main/proto/message_service.proto

3.3 字段级优化:每条消息省下 100+ 字节

这是我觉得最有意思的部分。在 IM 系统中,每条消息虽然可能只省几十个字节,但在百万级消息量下,累计节省的带宽非常可观。

我做了三个关键的字段优化:

1. fixed64 存雪花 ID

protobuf 复制代码
fixed64 msgId = 2;    // 8 字节
fixed64 from = 3;     // 8 字节(用户 ID)
fixed64 to = 4;       // 8 字节(用户 ID)

雪花算法生成的 ID 是纯数字,用 fixed64(固定 8 字节)存储,而不是用 string(19 字节的十进制字符串)。每个 ID 字段省 60% 的空间。一条消息里有 msgIdfromtotime 四个 ID 字段,加起来就省了很多。

2. bytes 存 UUID

protobuf 复制代码
bytes clientMsgId = 1;  // 16 字节(UUID 原始字节)

客户端生成的消息唯一 ID 是 UUID,用 bytes 类型存储 16 字节的原始值,而不是 36 字节的字符串表示。省 56%

3. chatId 动态计算,不在协议中传输

会话 ID 的格式是 bizType-chatType-smallUserId-bigUserId(例如 100-1-123-456,小 ID 在前),这个 ID 可以由发送者和接收者的 ID 动态计算出来,根本不需要在消息中传输。每条消息省 33 字节

protobuf 复制代码
message C2CSendReq {
  bytes clientMsgId = 1;
  fixed64 msgId = 2;
  fixed64 from = 3;
  fixed64 to = 4;
  int32 format = 5;
  string content = 6;
  fixed64 time = 7;
  // chatId 已移除!由服务端/客户端根据 from + to 动态计算
  optional ReplyInfo reply_info = 10;
}

3.4 gRPC 服务间通信

不同 im-connect 节点之间通过 gRPC 转发消息,定义了 6 个 RPC 方法:

protobuf 复制代码
service MessageService {
  rpc ResponseServerAck2Client(ServerAckPush) returns (WebBaseResponse);
  rpc ResponseClientAck2Client(ClientAckPush) returns (WebBaseResponse);
  rpc SendWithdrawMsg2Client(WithdrawPush) returns (WebBaseResponse);
  rpc PushFriendRequest2Client(FriendRequestPush) returns (WebBaseResponse);
  rpc PushFriendResponse2Client(FriendResponsePush) returns (WebBaseResponse);
  rpc TransferC2CMsg(ImProtoRequest) returns (WebBaseResponse);  // 跨节点转发
}

其中 TransferC2CMsg 是最核心的------当发送者和接收者连在不同的 im-connect 节点上时,通过这个 RPC 方法做跨节点转发。

proto 文件代码截图:

写 proto 文件的时候有个感受很明显------像 l/1O/0q/g 这种在普通显示器上容易看混的相似字符,在编码模式下清晰锐利,一眼就能区分。分区对比度技术让深色代码主题下关键字、变量、注释的颜色层次更鲜明,长时间看协议定义不会串行。

编码模式可以通过热键一键切换,设置入口很方便:

四、长连接层设计:Netty WebSocket 服务端

4.1 Netty 启动配置

im-connect 模块是整个系统最核心的部分------它维护着所有客户端的 WebSocket 长连接,负责消息的实时路由和推送。

启动时自动检测运行平台,Linux 用 Epoll(性能最优),其他平台回退到 NIO:

java 复制代码
private void initEventLoopGroups() {
    int cpuCores = Runtime.getRuntime().availableProcessors();
    int bossThreads = Math.max(1, cpuCores / 4);   // 连接接收线程
    int workerThreads = cpuCores * 2;                // IO 处理线程

    if (Epoll.isAvailable()) {
        bossGroup = new EpollEventLoopGroup(bossThreads,
            new DefaultThreadFactory("netty-boss", Thread.MAX_PRIORITY));
        workerGroup = new EpollEventLoopGroup(workerThreads,
            new DefaultThreadFactory("netty-worker", Thread.NORM_PRIORITY));
    } else {
        bossGroup = new NioEventLoopGroup(bossThreads, ...);
        workerGroup = new NioEventLoopGroup(workerThreads, ...);
    }
}

TCP 参数也做了针对性调优:

java 复制代码
bootstrap
    .childOption(ChannelOption.SO_KEEPALIVE, true)
    .childOption(ChannelOption.TCP_NODELAY, true)     // 禁用 Nagle 算法,降低延迟
    .option(ChannelOption.SO_BACKLOG, 65535)          // 连接队列
    .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)  // 池化内存
    .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
        new WriteBufferWaterMark(writeBufferLow, writeBufferHigh));  // 写缓冲水位

代码位置:im-connect/im-connect-service/.../netty/NettyServer.java

4.2 Channel Pipeline 设计

每个客户端连接进来后,消息会经过一系列 Handler 的链式处理:

java 复制代码
protected void initChannel(SocketChannel ch) {
    ChannelPipeline pipeline = ch.pipeline();

    pipeline.addLast(new HttpServerCodec());               // HTTP 编解码
    pipeline.addLast(new HttpObjectAggregator(65536));      // HTTP 聚合
    pipeline.addLast(new ChunkedWriteHandler());            // 大数据块写入

    // WebSocket 压缩(可选)
    if (config.isEnableCompression()) {
        pipeline.addLast(new WebSocketServerCompressionHandler());
    }

    pipeline.addLast("heart-notice",
        new IdleStateHandler(idleCheckInterval, 0, 0, TimeUnit.SECONDS));
    pipeline.addLast("connection-limit", connectionLimitHandler);   // 连接数限制
    pipeline.addLast("flow-control", flowControlHandler);           // 流量控制
    pipeline.addLast("metrics", new MetricsHandler());              // 指标采集
    pipeline.addLast("auth", authHandler);                          // JWT 鉴权
    pipeline.addLast("websocket", webSocketServerHandler);          // 消息分发
}

代码位置:im-connect/.../netty/channel/WebSocketChannelInitializer.java

几个关键 Handler 的职责:

  • AuthHandler:WebSocket 握手阶段做 JWT 鉴权。验证通过后,把自己从 Pipeline 中移除------后续消息不再走鉴权逻辑,减少不必要的开销。
  • IdleStateHandler:读空闲检测,超过配置时间没有收到消息就触发心跳超时。
  • WebSocketServerHandler:核心消息分发器,只接受 BinaryWebSocketFrame(Protobuf 二进制帧),拒绝文本帧。

4.3 JWT 鉴权流程

鉴权是在 WebSocket 握手的 HTTP 升级请求中完成的:

java 复制代码
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    if (!(msg instanceof FullHttpRequest)) {
        ctx.fireChannelRead(msg);  // 非 HTTP 请求,放行给下一个 Handler
        return;
    }

    FullHttpRequest request = (FullHttpRequest) msg;
    String clientIp = getClientIp(ctx);

    // IP 黑名单检查
    if (isIpLocked(clientIp)) {
        ctx.channel().close();
        return;
    }

    // IP 白名单放行
    if (isIpWhitelisted(clientIp)) {
        handleWhitelistAccess(ctx, request, clientIp);
        return;
    }

    // JWT Token 校验
    if (performAuthentication(ctx, request, clientIp)) {
        resetAuthFailures(clientIp);
        ctx.pipeline().remove(this);  // 鉴权通过,移除自身
        ctx.fireChannelRead(msg);     // 放行
    }
}

Token 校验通过 Redis 完成,Key 格式为 USER_TOKEN_KEY:{userId}:{deviceType}:{tokenMd5},确保多设备登录时 Token 不会混淆。

还有防暴力破解机制------同一个 IP 连续认证失败超过阈值,直接锁定 IP:

java 复制代码
private void handleAuthFailure(ChannelHandlerContext ctx, String clientIp, String reason) {
    RAtomicLong failureCounter = redissonUtils.getAtomicLong(AUTH_FAILURE_KEY_PREFIX + clientIp);
    long currentFailures = failureCounter.incrementAndGet();
    failureCounter.expire(lockoutDurationMinutes * 2, TimeUnit.MINUTES);

    if (currentFailures >= maxAuthFailures) {
        lockIp(clientIp);  // 锁定 IP
    }
    ctx.channel().close();
}

代码位置:im-connect/.../netty/handler/AuthHandler.java

4.4 连接管理

本地连接通过 LocalChannelManager 管理,底层是 ConcurrentHashMap<String, Channel>

  • 支持多设备同时在线(最多 5 个)
  • 断线重连时自动替换旧 Channel
  • 定时清理任务(60 秒一次)扫描并清理已断开的 stale channel
  • 用户上线时异步推送离线期间积压的好友请求和好友响应

WebSocketServerHandler(682 行)这种核心分发器,消息接收、鉴权判断、异常处理、连接管理的逻辑全在一个类里,横屏看需要来回滚动才能理清完整的调用链。竖屏旋转之后一屏多看几十行,从消息入口到分发出口的完整链路一眼看全,高度和角度也能自由调节,找到最舒服的姿势,长时间编码脖子不酸。

顺带说一句,这台显示器的支架设计确实省心------快拆式安装不需要螺丝刀,单手就能完成横竖屏切换、高度升降和角度倾斜,几分钟就搞定,对经常切换横竖屏的开发场景很友好。

核心 Handler 竖屏编码实拍:

五、单聊消息投递:三层路由机制

这是整个 IM 系统最核心的部分------一条消息从发送者发出,到接收者收到,中间经历了什么?

5.1 策略模式消息分发

所有消息进来后,先由 HandlerDispatcher 做分发。它利用 Spring 的 ApplicationContext 自动发现所有 ProtoMsgHandlerStrategy 实现,按 MsgType 注册到 Map 中:

java 复制代码
@Component
public class HandlerDispatcher implements ApplicationContextAware {
    private final Map<MsgType, ProtoMsgHandlerStrategy> protoHandlers = new HashMap<>();

    public void dispatcher(ChannelHandlerContext ctx, ImProtoRequest protoRequest) {
        MsgType msgType = protoRequest.getType();
        ProtoMsgHandlerStrategy handler = protoHandlers.get(msgType);
        handler.exchange(ctx, protoRequest);
    }

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        Collection<ProtoMsgHandlerStrategy> strategies =
            ctx.getBeansOfType(ProtoMsgHandlerStrategy.class).values();
        for (ProtoMsgHandlerStrategy strategy : strategies) {
            protoHandlers.put(strategy.supportMsgType(), strategy);
        }
    }
}

策略接口定义很简单:

java 复制代码
public interface ProtoMsgHandlerStrategy {
    MsgType supportMsgType();
    void exchange(ChannelHandlerContext ctx, ImProtoRequest protoRequest);
    default WebBaseResponse receiveAndSendMsg(ImProtoRequest protoRequest) {
        return WebBaseResponse.returnResultError("不支持跨服务器转发");
    }
}

新增消息类型时,只需要实现这个接口并加上 @Component 注解,Spring 自动注册,完全不需要改分发器的代码。

而且分发时使用 CompletableFuture.runAsync() 将业务逻辑放到独立线程池执行,实现了 Netty IO 线程与业务线程的隔离------即使某个消息处理耗时很长,也不会阻塞 Netty 的 EventLoop。

代码位置:im-connect/.../dispatcher/HandlerDispatcher.javaim-connect/.../strategy/ProtoMsgHandlerStrategy.java

5.2 C2C 消息处理全流程

这是整个系统最复杂的部分。当用户 A 给用户 B 发一条消息,完整的流转路径如下:

bash 复制代码
客户端 A(Flutter)通过 WebSocket 发送 Protobuf 二进制帧
    ↓
WebSocketServerHandler 解析 ImProtoRequest
    ↓
HandlerDispatcher 按 MsgType=C2C_SEND 路由到 C2CMsgSendProtoStrategyImpl
    ↓
exchange() 方法执行三步:
    ① RocketMQ 发消息 → im-business 消费并持久化到 MySQL + MongoDB
    ② 查找接收者 B 的在线状态
    ③ 根据状态走不同的路由策略

三层路由的核心逻辑

java 复制代码
public void exchange(ChannelHandlerContext ctx, ImProtoRequest protoRequest) {
    C2CSendReq req = C2CSendReq.parseFrom(protoRequest.getPayload());
    C2CSendMsgAO packet = convertToAO(req);

    // 第一步:消息发到 RocketMQ,由 im-business 异步持久化
    boolean mqSent = c2CMsgProvider.sendC2CMsg(packet);

    // 第二步:查找接收者在线状态
    ReceiveUserDataDTO receiveUserData =
        super.getReceiveUserDataTemplate(packet.getToUserId(), this.redissonUtils);

    // 第三步:三层路由
    if (targetChannel != null && userStatus == ONLINE) {
        // 🟢 第一层:接收者在本地(同一台 im-connect 服务器)
        sendProtoMsg(targetChannel, buildPushMsgResp(packet), packet);
        c2CMsgRetryService.addToRetryQueue(packet);  // 加入重试队列保底

    } else if (userStatus == null && targetChannel == null) {
        // 🔴 第三层:接收者离线
        c2CMsgProvider.offLineMsg(buildOffLineMsgDTO(packet));

    } else if (targetChannel == null && userStatus == ONLINE && ipPortStr != null) {
        // 🟡 第二层:接收者在线,但在另一台 im-connect 服务器上
        SmartGrpcClientManager.GrpcStubWrapper stubWrapper =
            grpcClientManager.getStubByIP(targetIp, targetPort);
        MessageServiceBlockingStub stub =
            MessageServiceGrpc.newBlockingStub(stubWrapper.getChannelInfo().getChannel());

        // 通过 gRPC 转发到目标 im-connect 节点
        ImProtoRequest forwardRequest = ImProtoRequest.newBuilder()
            .setType(MsgType.C2C_SEND)
            .setPayload(ByteString.copyFrom(c2cReq.toByteArray()))
            .build();
        stub.transferC2CMsg(forwardRequest);
    }
}

代码位置:im-connect/.../strategy/impl/c2c/C2CMsgSendProtoStrategyImpl.java

为什么是三层?

  1. 本地推送 (最快):接收者正好连在同一台 im-connect 上,直接通过 Channel 写入推送,毫秒级延迟。
  2. gRPC 转发 (较快):接收者在线,但连在另一台 im-connect 上。通过 gRPC TransferC2CMsg 方法转发到目标节点,目标节点的 receiveAndSendMsg() 方法接收并推送到本地 Channel。
  3. 离线存储 + 推拉结合(兜底):接收者不在线,消息持久化到 MongoDB,同时通过 MobPush 发送 APNs/FCM 通知唤醒用户。用户上线后,客户端主动调用会话列表接口拉取所有会话的最新消息和未读数,打开具体聊天时再拉取完整历史。

为什么要推拉结合?

纯推送模式有个问题:用户离线期间积压了几百条消息,上线时一次性全推过来,WebSocket 连接瞬间被打满,客户端解析不过来容易卡顿甚至崩溃。纯拉取模式也有问题:用户不知道有新消息,不会主动来拉。

所以采用推拉结合:

  • :只推一条通知("你有新消息"),轻量级,不占 WebSocket 带宽。群聊场景下还会做 5 秒聚合------同一个群 5 秒内的多条消息合并为一条通知,避免通知轰炸
  • :客户端收到通知或用户主动打开 App 后,调用会话列表接口拉取摘要(每个会话只返回最新一条消息 + 未读数),打开具体聊天时再按需拉取历史消息

这样既保证了消息及时性,又避免了大量离线消息瞬间涌入导致客户端压力过大。

5.3 消息可靠性:重试机制

消息投递不能丢消息。即使第一层推送成功了,也存在网络抖动导致客户端没收到的情况。所以我设计了一套基于 Redis ZSet 的延迟重试队列。

核心设计

  • Redis ZSet 做延迟队列:score 存执行时间戳,到期后扫描处理
  • Redis Hash 存消息数据:key 是消息 ID,value 是压缩后的消息体
  • Lua 脚本保证原子性:添加、移除、认领操作都是原子执行,避免并发问题
  • LZ4 压缩:消息数据压缩后存入 Redis,压缩率 50-70%,节省大量 Redis 内存
java 复制代码
public void addToRetryQueue(C2CSendMsgAO packet) {
    C2CMsgRetryEvent retryEvent = buildRetryEvent(packet);
    String jsonValue = JSONUtil.toJsonStr(retryEvent);
    String compressedValue = CompressionUtil.compressToBase64(jsonValue);  // LZ4 压缩

    long executeTime = System.currentTimeMillis() + retryDelays[0] * 1000;

    // Lua 原子操作:同时写入 ZSet(时间排序)和 Hash(数据存储)
    redissonUtils.executeLuaScriptAsLongUseStringCodec(
        addToRetryQueueScript,
        Arrays.asList(C2C_MSG_RETRY_QUEUE, C2C_MSG_RETRY_INDEX),
        compressedValue, String.valueOf(executeTime), packet.getMsgId()
    );
}

重试策略:渐进式延迟,5s → 30s → 300s,共 3 次重试:

java 复制代码
@Value("${im-server.c2c.retry.max-retries:3}")
private int maxRetries;

@Value("${im-server.c2c.retry.delays:5,30,300}")
private String delaysConfig;  // 5秒、30秒、5分钟

定时扫描@Scheduled 每秒执行一次,扫描 ZSet 中到期的消息:

java 复制代码
@Scheduled(fixedRateString = "${im-server.c2c.retry.scan-interval:1000}")
public void scanRetryQueue() {
    // 第一步:Lua 原子认领(ZRANGEBYSCORE + ZREM)
    List<String> expiredMsgIds = redissonUtils.executeLuaScriptAsStringListUseStringCodec(
        claimRetryMessagesScript, ...);

    // 第二步:批量获取 Hash 中的压缩数据
    Map<String, String> compressedDataMap =
        redissonUtils.batchGetHashWithStringCodec(...);

    // 第三步:解压、按用户分组、异步处理
    for (Map.Entry<String, List<C2CMsgRetryEvent>> entry : groupedByUserId.entrySet()) {
        CompletableFuture.runAsync(
            () -> processRetryBatch(toUserId, userRetryEvents), retryExecutor);
    }
}

重试时检查接收者是否在线:在线就重新推送并再次加入重试队列;离线或超过最大重试次数,则标记为离线消息通过 RocketMQ 存入持久化队列。

代码位置:im-connect/.../service/impl/C2CMsgRetryServiceImpl.java

5.4 消息 ID 双轨设计

每条消息有两个 ID:

  • clientMsgId:客户端生成的 UUID,用于消息去重。网络重传或重试时可能发送重复消息,通过 clientMsgId 去重。
  • msgId:服务端生成的雪花算法 ID,用于消息排序。雪花 ID 天然有序,可以按时间排序消息。

双 ID 设计兼顾了客户端去重和服务端排序两个不同需求。

5.5 已读回执、正在输入、消息撤回

除了基本的消息收发,系统还支持几个增强功能:

  • 已读回执ReadReceiptProtoStrategyImpl):客户端打开聊天后发送一个 lastReadMsgId 水位线,服务端按 msgId ≤ lastReadMsgId 批量标记已读,MySQL 和 MongoDB 同时更新
  • 正在输入TypingStatusProtoStrategyImpl):A 正在输入时,B 能实时看到"对方正在输入..."
  • 消息撤回WithdrawMsgSendProtoStrategyImpl):支持撤回已发送的消息,带时间校验

消息流转流程图:

多窗口调试场景实拍(IDE + 终端 + 日志):

调试这些功能比实现它们更花时间------比如已读回执,需要同时观察客户端的消息状态变化、服务端的 ACK 处理逻辑、Redis 中的已读标记是否正确更新,一个问题可能涉及三四个环节。多窗口同时打开不显得拥挤,144Hz 切换窗口没有拖影。这种一盯就是几个小时的 debug 场景,护眼认证是真的能感受到差别------同样的时长,眼睛疲劳感明显减轻。

低蓝光模式效果:

六、群聊消息实现

6.1 读扩散 vs 写扩散

群聊消息的存储模型有一个经典的设计选择:读扩散 还是写扩散

  • 写扩散(推模型):发送者发一条消息,服务端为群内每个成员写一条独立的消息记录。群越大,写放大越严重------一个 500 人的群,一条消息要写 500 次。但读取简单,每个成员只需要读自己的收件箱。
  • 读扩散(拉模型):一条消息只存一份,每个成员读取时根据群 ID + 时间范围实时拉取。写入高效,但读取时需要聚合,性能压力转移到读侧。

我的方案采用的是读扩散 模式。消息只存一份到 MongoDB(通过 RocketMQ 异步写入),客户端拉取群聊历史时根据 chatId(格式:bizType-chatType-groupId,如 100-2-789)+ 时间范围分页查询。这样写入效率最高,而且 MongoDB 分片集群的范围查询能力足以支撑读侧的压力。

6.2 群消息广播

群聊消息和单聊的最大区别在于:一条消息需要推送给群内所有在线成员。

我的实现方案是利用 RocketMQ 的广播消费模式

  1. 发送者将群消息发到 RocketMQ
  2. 所有 im-connect 节点都以广播模式消费这条消息
  3. 每个 im-connect 节点检查本地有哪些群成员在线,逐一推送

这种方案的好处是:im-connect 节点不需要知道全局的群成员在线分布,只需要关注本地连接的用户,逻辑简单清晰。

代码位置:im-connect/.../strategy/impl/group/GroupMsgSendProtoStrategyImpl.java

6.3 群消息撤回与 @提及

  • 群消息撤回GroupMsgWithdrawProtoStrategyImpl):和单聊撤回类似,带时间校验,撤回后通知所有在线群成员
  • @提及GROUP_MENTION):支持 @特定成员,被 @ 的成员会收到特殊通知

群消息广播的核心逻辑和运行时的终端日志:

七、数据存储分层设计

IM 系统的数据特点决定了需要多种存储引擎配合:

7.1 为什么用这么多数据库

存储 用途 选择原因
MySQL 用户、好友、群组、会话等关系数据 事务一致性要求高,关系查询成熟
MongoDB 消息记录(分片集群) 高写入吞吐,Schema 灵活,适合非结构化消息体
Elasticsearch 消息全文搜索 倒排索引,支持分词搜索和高亮
Redis 缓存、未读计数、离线消息、分布式锁 内存级读写性能,支持丰富的数据结构
MinIO 图片、语音、视频等文件存储 兼容 S3 协议,自部署,成本低

这里特别说一下消息存储的选型。这条路上我走了三次弯路:第一版用 MySQL 存消息,数据量上来后做了分库分表,但 IM 消息的高频写入很快把 MySQL 打到瓶颈;第二版迁移到 HBase,写入性能上去了,但 IM 的查询模式(按会话拉取历史消息、按时间范围查询、按用户 ID 查最近 N 条)和 HBase 的 scan 模型并不匹配------HBase 擅长基于 rowkey 的精确查找,而 IM 消息更多是范围查询和分页查询。

迁移到 MongoDB 后,利用其灵活的 Schema 和丰富的查询能力,消息存储和查询都顺畅了很多。目前 MongoDB 部署为分片集群架构,配合智能查询路由(Smart Query Router),可以根据查询条件自动选择最优的分片和索引,避免全分片扫描。

消息写入路径:

bash 复制代码
客户端发消息 → im-connect → RocketMQ → im-business → MySQL(关系数据)+ MongoDB(消息体)
                                                                  ↓(异步)
                                                        im-data-sync → Elasticsearch

MySQL 存储消息的关系数据(发送者、接收者、时间、状态),MongoDB 存储消息的完整内容(文本、附件、扩展字段)。两者配合实现高效的消息存储和查询。

7.2 CQRS 数据同步

搜索功能通过 CQRS(Command Query Responsibility Segregation,命令查询职责分离)模式实现------简单说就是写入和读取用不同的存储引擎,各管各的:

  • 写路径:消息写入 MySQL + MongoDB
  • 读路径(搜索)im-data-sync 消费 RocketMQ 消息,异步同步到 Elasticsearch
  • 搜索请求直接查询 Elasticsearch,不影响主链路的写入性能

代码位置:im-data-sync 模块的 DataSyncMessageConsumer

分屏场景实拍(左半屏代码,右半屏数据库客户端):

日常开发中,同时管理 MySQL、MongoDB、Redis、ES 四种数据源的连接和调试是家常便饭。PBP 分屏模式下左半屏写代码,右半屏开数据库客户端查看数据,不用 Alt+Tab 切来切去,效率高很多。需要对比两端数据时,PIP 画中画也很方便------主屏写代码,小窗监控 Redis 状态。

八、Flutter 客户端与 SDK

8.1 为什么选 Flutter

客户端技术选型纠结过一阵子。IM 客户端需要同时支持 Android 和 iOS,原生开发意味着要写两套代码,维护成本翻倍。React Native 和 Flutter 是两个主要的跨平台选项。

最终选择 Flutter 有几个原因:

  • 性能:Flutter 不用 JavaScript Bridge,直接编译为原生 ARM 代码,UI 渲染性能接近原生。IM 客户端的聊天列表、消息滚动、动画效果对流畅度要求很高。
  • Dart 的单线程事件循环模型:和 Node.js 类似,天然适合处理 WebSocket 消息的异步收发,不需要处理多线程同步问题。
  • 自包含渲染引擎:不依赖平台原生控件,Android 和 iOS 上的表现完全一致,不用处理平台差异。

当然,最现实的原因是------找不到合适的 app 端同学来帮忙,我决定自己写。Flutter 的学习曲线对后端开发来说比较友好,Dart 的语法和 Java/Kotlin 有很多相似之处。从一个纯后端开发到能独立写出完整的 Flutter 客户端和 IM SDK,大概花了两个月时间。

日常就是左边开 IDEA 写后端 Java 代码,右边写 Flutter 客户端和 SDK,双端联调:

8.2 客户端架构

Flutter 客户端采用 GetX 做状态管理、路由和依赖注入,遵循 Binding + Controller + View 的分层模式。

启动时注册一系列全局服务:

dart 复制代码
Future<void> initServices() async {
  Get.put(ThemeController());
  Get.put(AppData());
  final imSdkManager = Get.put(IMSDKManager());
  await imSdkManager.init();              // IM SDK 初始化
  final mobPushService = Get.put(MobPushService());
  await mobPushService.init();            // 推送服务初始化
  Get.put(BadgeService());                // 角标服务
  Get.put(ChatBackgroundService());       // 聊天背景
  Get.put(LiveKitService());             // 音视频服务
  Get.put(CallAudioService());           // 通话音效
  Get.put(CallManagerService());          // 通话管理
}

App 生命周期管理也很重要------前后台切换时需要正确处理 WebSocket 连接:

dart 复制代码
void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        // 回到前台:检查 WebSocket 状态,断连则重连
        Get.find<IMSDKManager>().setAppInBackground(false);
        if (wsStatus != WebSocketStatus.connected) {
          IMSDKManager.to.connect();
        }
      case AppLifecycleState.paused:
        // 进入后台:标记后台状态,连接保持不断(用于接收推送)
        Get.find<IMSDKManager>().setAppInBackground(true);
      case AppLifecycleState.detached:
        // 进程终止:清理资源
    }
}

代码位置:xzll-im-flutter-client/lib/main.dart

8.3 SDK 核心设计

自研的 IM SDK(xzll_im_sdk)是独立 Flutter Package,封装了所有 IM 能力,客户端通过它来收发消息。

单例模式

SDK 采用工厂单例模式,确保全局只有一个实例:

dart 复制代码
static final XZLLIMClient _instance = XZLLIMClient._internal();
factory XZLLIMClient() => _instance;
XZLLIMClient._internal();

自动重连

断线后自动重连,使用指数退避策略(1s → 2s → 4s → 8s → ... → 最大 30s),后台状态下直接等 30s:

dart 复制代码
void _scheduleReconnect({bool immediate = false}) {
    if (_isManualDisconnect) return;  // 手动断开不重连
    _reconnectAttempts++;

    Duration delay;
    if (immediate) {
      delay = Duration.zero;
    } else if (_isAppInBackground) {
      delay = Duration(seconds: 30);  // 后台 30 秒
    } else {
      final exponent = _reconnectAttempts - 1;
      final delaySeconds = (1 << exponent).clamp(1, 30);  // 2^n, 上限 30s
      delay = Duration(seconds: delaySeconds);
    }
    _reconnectTimer = Timer(delay, () => _performReconnect());
}

消息去重

网络重传和重试机制可能导致重复消息。SDK 使用内存缓存做去重,2 分钟 TTL,最多缓存 1000 条:

dart 复制代码
final Map<String, int> _messageCache = {};
static const int _maxCacheSize = 1000;
static const int _messageCacheTtlMs = 2 * 60 * 1000;  // 2 分钟

bool _isDuplicateMessage({String? msgId, String? clientMsgId}) {
    _evictExpiredMessageCacheEntries();
    if (msgId != null && _messageCache.containsKey('s:$msgId')) return true;
    if (clientMsgId != null && _messageCache.containsKey('c:$clientMsgId')) return true;
    return false;
}

上下文感知 ACK

这是 SDK 中一个比较巧妙的设计。ChatSessionManager 追踪当前打开的聊天会话,SDK 自动对当前聊天发送已读 ACK------用户完全无感知:

dart 复制代码
class ChatSessionManager {
  final XZLLIMClient _imClient;
  final String _chatId;
  bool _isClosed = false;

  ChatSessionManager._({required XZLLIMClient imClient, required String chatId})
      : _imClient = imClient, _chatId = chatId {
    _imClient.setCurrentOpenChatId(_chatId);  // 打开聊天时自动设置
  }

  void close() {
    if (_isClosed) return;
    _isClosed = true;
    _imClient.setCurrentOpenChatId('');  // 关闭时清空
  }
}

客户端使用方式很简单:

dart 复制代码
// 进入聊天页面
final session = XZLLIMClient().openChatSession(chatId);
// ... 收发消息 ...
// 离开聊天页面
session.close();

打开聊天会话后,SDK 收到的该会话消息会自动发送已读 ACK,不需要手动调用。

代码位置:xzll-im-flutter-sdk/lib/src/core/chat_session_manager.dart

离线消息队列

网络断开时,发送失败的消息不会丢失。SDK 用 _pendingSends Map 保存待重发的消息,重连后自动 flush:

dart 复制代码
final Map<String, _PendingSendTask> _pendingSends = {};

Future<void> _flushPendingMessages() async {
    if (_pendingSends.isEmpty) return;
    final tasks = _pendingSends.values.toList();
    for (final task in tasks) {
      await _executeRetrySend(task);  // 串行 flush,避免 WebSocket 拥堵
    }
}

消息级别也有重试机制,每条消息最多重试 3 次,指数退避(2s → 4s → 8s):

dart 复制代码
void _scheduleMessageRetry(_PendingSendTask task) {
    if (task.retryCount >= _maxMessageRetry) {
      _markMessageFailed(task.clientMsgId, task.chatId);
      return;
    }
    final delaySeconds = 2 << task.retryCount;  // 2s, 4s, 8s
    _messageRetryTimers[task.clientMsgId] = Timer(Duration(seconds: delaySeconds), () {
      _executeRetrySend(task);
    });
}

代码位置:xzll-im-flutter-sdk/lib/src/core/xzll_im_client.dart

8.4 多媒体文件发送

文件存储使用 MinIO(自部署,兼容 S3 协议),支持图片、语音、视频上传。SDK 中的 MediaDownloadManager 提供异步下载能力,通过 Stream 通知 UI 层下载进度和完成状态。

代码位置:xzll-im-flutter-sdk/lib/src/media/

8.5 音视频通话(LiveKit 集成)

音视频通话是正在开发的功能,方案设计如下:

  • 信令:通过已有的 WebSocket 长连接传输 CALL_OFFER / CALL_ANSWER / CALL_REJECT / CALL_END 信令
  • 音视频:使用 LiveKit SDK 做音视频传输
  • 通话管理CallManagerService 管理通话的完整生命周期

信令复用 WebSocket 长连接,不需要额外建立连接,减少了延迟和复杂度。

Flutter 开发需要同时跑 Android 和 iOS 两端调试,加上后端服务,设备多了管理起来麻烦。USB-C 65W 反向供电一线连 MacBook,笔记本充电和视频输出一条线搞定。KVM 切换功能在多设备调试时也很实用------同一套键鼠控制 MacBook 和另一台测试机,不用来回切换,客户端界面在 2K 屏上各种细节看得清清楚楚。

USB-C 接口还能直接给手机反向充电,调试时手机不用找充电器:

九、总结与开发体验

项目成果

从 2022 年与 IM 结缘到现在,经历了多次技术选型推翻和重写,每一次推倒重来都很痛苦,但"不落地的设计不是好设计"这句话一直驱动着我------方案好不好,只有真正跑起来才知道。

目前这个 IM 系统已经具备了微信的基础通信能力:单聊、群聊、好友系统、多媒体文件发送,音视频通话正在基于 LiveKit 开发中。整个系统有 104+ 个 API 接口、约 25 张数据库表、22 个 Flutter 页面。

未来规划

这个项目还有很多事情要做:

目前正在准备用 Gatling 做系统级的压力测试,后续会分享详细的压测数据和优化过程。除此之外:

1. LiveKit 音视频通话完善

目前信令层(CALL_OFFER / CALL_ANSWER / CALL_REJECT / CALL_END)已经通过 WebSocket 实现,CallManagerService 也搭建好了生命周期管理框架。接下来要完成的是:

  • 音视频流的稳定传输和弱网适配
  • 多人音视频通话的支持
  • 通话质量监控和异常恢复

2. 百万连接压测

1 万用户只是热身。下一步目标是:

  • 单机 10 万 WebSocket 长连接压测,验证 Netty 的内存和线程模型
  • 集群百万连接压测,验证 gRPC 跨节点转发和 RocketMQ 广播的性能瓶颈
  • 消息延迟、吞吐量、资源占用的全面基准测试

3. 整体性能优化

  • Netty 的 ByteBuf 池化策略优化,减少 GC 压力
  • Redis 的 Pipeline 批量操作优化,减少 RTT
  • MongoDB 的分片键和索引策略调优
  • Protobuf 序列化/反序列化的零拷贝优化

4. UI 优化

  • Flutter 客户端的聊天列表长列表性能优化(ListView.builder + 懒加载)
  • 多媒体消息的缓存和预加载策略
  • 消息气泡动画和交互体验优化

5. 社交功能

  • im-social 模块已搭建好基础框架(圈子、帖子、活动、话题、打招呼)
  • 后续会逐步完善社交功能,让 IM 和社交形成闭环

6. SDK 与 UI 分离:一套内核,无限可能

这个项目从设计之初就把 SDK 和 UI 彻底分离 了。xzll-im-flutter-sdk 是一个独立的 Flutter Package,封装了全部 IM 能力------WebSocket 连接管理、Protobuf 序列化、消息收发、自动重连、离线队列、去重、ACK,全部在 SDK 层完成。而 xzll-im-flutter-client 只是一个基于 SDK 的 UI 实现。

这意味着什么?拿这套 SDK 换一层 UI 外壳,就是一个全新的产品。

几个可行的方向:

  • 企业内部通讯:SDK + 企业通讯录 UI + 组织架构 + 审批流程 = 企业 IM
  • 在线客服系统:SDK + 客服工作台 UI + 工单系统 + 排队分配 = 客服 IM
  • 社交 App:SDK + 陌生人匹配 UI + 朋友圈/动态 = 社交产品
  • AI 客服助手:SDK + AI 对话 UI + RAG 知识库 = 智能客服

IM 的底层能力(连接、消息、可靠性)是通用的,差异只在业务层和 UI 层。SDK 和 UI 分离后,上层业务可以自由定制,不用重复造轮子。

7. AI + IM:智能化方向

AI 是 IM 系统最天然的结合点之一,也是我后续最想探索的方向:

  • AI 智能助手:在 IM 中内置一个 AI 联系人,用户可以直接和 AI 对话。基于 SDK 的消息收发能力,AI 助手就是一个"特殊用户",技术实现上只需要在消息处理层加一层 AI 路由。
  • 群聊消息摘要:群消息积压了几百条没看?AI 自动生成摘要,"今天群里主要讨论了 XX,@你的消息有 3 条"。这个功能基于已有的消息存储(MongoDB + ES),只需要加一层 AI 总结。
  • AI 客服:基于 SDK 做一个智能客服产品,企业知识库 + RAG + IM 对话,比传统客服系统更灵活。
  • 智能回复建议:收到消息后,AI 根据上下文生成 2-3 条回复建议,用户一键选择发送,降低打字成本。

这些方向之所以可行,正是因为 SDK 层已经把 IM 的底层能力全部封装好了,AI 只需要对接消息的输入输出,不需要关心连接管理、消息可靠性这些复杂逻辑。

开发体验

前面聊了不少技术实现,最后结合整个项目的开发体验,补充两个 RD270Q 最让我印象深刻的点。

编码模式确实是这台显示器最核心的功能。专门为编程优化的显示模式 + 高对比度,整个项目开发下来,深色主题下的代码可读性提升非常明显。

Eye Care 夜间保护是深夜编码的加分项------这个项目大部分代码都是深夜写的,夜间防护模式可以调到极低亮度,屏幕光线柔和不刺眼,搭配 MoonHalo 背面光环营造的氛围,凌晨两三点写代码的体验和白天一样好。

Eye Care 夜间保护设置:

RD270Q 整体外观------27 寸 2K 编程显示器,正面和侧面:

竖屏模式------横竖切换单手搞定,不需要任何工具:

十、写在最后:

说实话,项目做出来是一回事,怎么变现是另一回事。这也是我目前最头疼的问题------技术做完了,但怎么让技术产生商业价值?

目前想到的几个方向,但都不太确定:

  • 私有化部署 / 二次开发:把整套 IM 系统打包,给需要内部通讯的企业做私有化部署和定制开发。企业对数据安全敏感,很多公司不愿意用第三方 IM,这可能是最直接的变现路径。
  • IM SDK / 云服务:像融云、环信那样,把 IM 能力做成云服务对外提供。但这个方向需要持续运维,对业余时间做项目来说成本很高。
  • 技术课程 / 知识付费:把搭建 IM 系统的过程做成系统化的技术课程。毕竟 IM 涉及的技术栈足够全面,从 Netty 长连接到 Protobuf 协议到微服务架构,每个点都能深入讲。
  • 接外包 / 技术咨询:以项目作为技术能力的背书,承接即时通讯相关的技术咨询和开发外包。

但说实话,这些方向都还在摸索中,没有哪个跑通了。白天上班晚上写代码,最难的不是技术------技术再难熬几个通宵总能啃下来------而是找到一个值得投入时间的方向。

所以想问问各位读者------如果你是我,你会怎么变现这样的项目?欢迎在评论区聊聊你的想法,也许你的一句话就能点醒我。

另外,如果有同学对这个项目感兴趣,想一起开发,或者有好的变现思路想聊聊,欢迎私信我或者在文章底下评论,一个人的力量终究有限,期待和你交流。


这个项目之前是完全开源的,但由于一些商业化考虑,目前已经闭源,代码托管在我个人的 GitLab 上。不过早期的开源版本仍然可以访问:开源地址:github.com/598572/xzll...


我是蝎子莱莱爱打怪,全网同名,欢迎关注我的公众号。

文中使用的明基 RD270Q 为 27 英寸 2K 编码显示器,144Hz 高刷新率,支持编码模式、分区对比度、六大护眼认证、USB-C 65W 供电、KVM 多设备切换,适合长时间编码的开发者使用。

相关推荐
叫我少年7 小时前
.NET 11 来了:Kestrel 提速 40%,还有这些你可能不知道的变化
后端
用户2279584482877 小时前
医生问“现在还在吃吗”:EHR 用药 RAG 先看 effectivePeriod,别先信 note
后端
努力成为AK大王7 小时前
Java并发线程核心知识(一)
java·开发语言·面试
geovindu7 小时前
go: Read-Write Lock Pattern
开发语言·后端·设计模式·golang·读写锁模式
один but you7 小时前
Hash表
缓存·面试·职场和发展
百珏8 小时前
AI 应用技术演进串讲大纲
人工智能·后端·架构
Bacon8 小时前
装上就回不去了:CodeGraph 让 AI 编程效率飙升 92%,它到底做了什么?
前端·人工智能·后端
Xiacqi18 小时前
Spring全局异常处理
java·后端
狗头大军之江苏分军8 小时前
Python 协程进化史:从 yield 到 async/await 的底层实现
前端·后端