做了一个仿微信的即时通讯系统,从协议设计到消息投递,从 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% 的空间。一条消息里有 msgId、from、to、time 四个 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/1、O/0、q/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.java、im-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
为什么是三层?
- 本地推送 (最快):接收者正好连在同一台
im-connect上,直接通过 Channel 写入推送,毫秒级延迟。 - gRPC 转发 (较快):接收者在线,但连在另一台
im-connect上。通过 gRPCTransferC2CMsg方法转发到目标节点,目标节点的receiveAndSendMsg()方法接收并推送到本地 Channel。 - 离线存储 + 推拉结合(兜底):接收者不在线,消息持久化到 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 的广播消费模式:
- 发送者将群消息发到 RocketMQ
- 所有
im-connect节点都以广播模式消费这条消息 - 每个
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 多设备切换,适合长时间编码的开发者使用。