☰
开源IM聊天APP拆解:长连接网关与消息必达闭环
2026/10/26 3:18:47 网站建设 项目流程

简介:微聊APP聊天功能开源项目是一份面向移动端开发者与社交应用学习者的原生聊天应用源码包,覆盖Android与iOS两端,适合研究即时通讯、消息协议、界面搭建与本地数据存储等核心环节。压缩包共594个文件,约23.05MB,以png界面素材、class编译文件、xml布局与配置、java源码及jar依赖为主,另有少量图片、字体和工程配置文件,能够较完整地还原聊天类APP的工程结构。目前已有1845人学习或下载。读者可对照消息推送、数据库存储、网络通信、权限管理、多线程处理与安全加密等实现思路,并参考图片、语音等消息类型的资源组织方式;结合测试、版本控制与CI/CD线索,也有助于理解社交应用从功能开发到发布维护的链路,适合作为课程设计、毕设原型或二次开发的学习样本。

1. 微聊APP 拆箱:一套能跑通单聊闭环的开源聊天骨架

想做聊天社交 APP 的人,最先遇到的问题往往来自消息发送后的结果,界面通常不是第一道障碍:发出去对方收不到、杀掉进程重进会话列表一片空白、手机和网页同时在线未读数对不上。微聊APP 这类开源聊天 APP 项目正好覆盖了这些工程环节,把长连接网关、消息落库、会话列表、离线补偿这几块平时最难自己处理的部分,做成了一套能读、能改、能在本地跑通的代码。它适合三类人:拿现成骨架做垂直社交产品的独立开发者、需要给业务系统补 IM 模块的后端,以及想弄清「消息必达」到底靠什么保证的工程师。下面按启动顺序拆。

2. 四层架构的分工边界:网关、业务、存储、客户端各管哪一段

拿到一个 IM 项目,我一般不急着翻 UI 代码,先看服务端被切成了几块。微聊APP 的分层是典型的四段式:客户端 SDK 负责连接维持、本地库、重发队列;接入网关负责 WebSocket 握手、鉴权、连接保活和消息上下行;业务服务负责好友关系、群成员、会话元数据这些 HTTP 侧的逻辑;存储层用 MySQL 存消息正文和关系数据,用 Redis 存在线状态、会话内序号、未读数。

这四段里最容易出问题的是网关和业务的边界。很多人图省事,把 WebSocket 的连接对象直接塞进业务服务的单例 Map 里,单机跑得无比顺滑,一上两台实例就开始「消息发了对方没反应」。原因不复杂:用户 A 的 TCP 连接落在实例一,用户 B 的消息被负载均衡打到实例二,实例二的 Map 里根本没有 A 的 channel。所以网关必须是独立的一层,且要维护一张「userId 到网关实例」的路由表,或者干脆用 Redis 发布订阅做跨实例广播。

2.1 长连接网关为什么必须单独一层

网关这一层干的事非常单一:维持连接、解析协议、把上行消息丢进队列、把下行消息写回 channel。它不做业务判断,不查好友关系,也不关心消息内容是什么格式。这样切的好处是扩容逻辑清晰——连接数涨了加网关,业务逻辑复杂了加业务服务,两边互不牵扯。

我一般会把网关的无状态部分和有状态部分分开看:连接本身是有状态的,必须绑在某一台实例上;但「谁在线」这个信息可以外置到 Redis。常见做法是连接建立时写一条online:{userId}:{deviceId} = instanceId带 TTL 的记录,心跳每 30 秒续期一次,实例挂掉后 TTL 到期自动失效,不需要额外的清理任务。心跳间隔这个参数不要拍脑袋定,移动端一般 25 到 30 秒比较稳,太短会增加耗电和流量,太长则运营商 NAT 超时后连接被静默断开,客户端还以为自己在线。

2.2 消息 ID、会话 ID 与本地库表设计

IM 里有两种 ID 容易混:消息 ID和会话内序号 seq。消息 ID 要全局唯一,可以雪花算法也可以号段;seq 是会话维度的自增,作用只有一个——客户端补拉时用它当水位。这两个东西不能互相替代:消息 ID 全局有序不代表会话内连续,客户端断线重连后没法用消息 ID 判断「我漏了哪几条」。

会话 ID 的设计更讲究。单聊如果用自增主键,双方各存一份,很容易出现「A 建的会话 B 找不到」。我一般用两个 userId 排序后拼接,比如s_10086_10087,谁先发起都得到同一个字符串,天然幂等。群聊直接用g_{groupId}。

本地库是客户端能不能流畅起来的关键。至少两张表:会话表和消息表。消息表必须建(conversation_id, seq)的唯一索引,一是防重复插入,二是让「按会话查最近 20 条」走索引而不是全表扫。

-- 服务端消息表:两个唯一索引是幂等和补拉的地基 CREATE TABLE im_message ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, conversation_id VARCHAR(64) NOT NULL COMMENT '单聊 s_a_b,群聊 g_groupId', seq BIGINT NOT NULL COMMENT '会话内递增,客户端补拉水位', client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端生成,用于去重', sender_id BIGINT NOT NULL, type TINYINT NOT NULL COMMENT '1文本 2图片 3语音 4自定义', content JSON NOT NULL, created_at BIGINT NOT NULL, UNIQUE KEY uk_conv_seq (conversation_id, seq), UNIQUE KEY uk_client_msg (sender_id, client_msg_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_conv_seq保证同一会话内 seq 不重复,插入冲突时直接判定为重复消息;uk_client_msg是第二道防线,客户端重发时即使 seq 变了,同一个client_msg_id也插不进去。content用 JSON 而不是拆字段,是因为图片消息要存 URL、宽高、缩略图,语音要存时长和格式,拆字段会越加越多。代价是不能对内容做索引,但聊天场景基本不需要。

2.3 本地跑通的最小启动顺序

依赖没起全就点「运行」,是新手最常见的翻车点。微聊APP 这类项目一般要三个基础件:MySQL、Redis、对象存储(本地开发可以用目录模拟)。启动顺序建议按下表走,前一步没通不要往下跳。

步骤动作验证方式
1起 MySQL 并导入建表脚本表结构存在,uk_conv_seq索引生效
2起 RedisPING返回 PONG
3起接入网关日志出现监听端口,/health可访问
4起业务服务注册接口返回 token
5客户端连网关抓包能看到握手 101
# 先起依赖,再起网关,最后起业务服务 docker compose up -d mysql redis mysql -h127.0.0.1 -uroot -p im_db < schema.sql docker compose up -d gateway docker compose up -d api # 验证网关是不是真的在监听,别只看容器状态是 Up curl -s http://127.0.0.1:8081/health

docker compose up -d只代表容器起来了,不代表应用初始化完成。MySQL 首次启动要几十秒,脚本跑早了会报连接拒绝。/health这个接口看着不起眼,但它能区分「进程活着」和「端口真的在收包」——我见过容器状态 Up、端口却没绑上的情况,靠这一行 curl 省下半小时。

3. 消息链路实操:从 WebSocket 握手到 ACK 回执的五个字段

链路这章是整套项目里最值得细看的地方。一条消息从按下发送到对方屏幕亮起,中间至少经过五个环节:客户端本地落库、上行发送、服务端鉴权与幂等、分配 seq 并持久化、下行推送与 ACK。任何一环缺失,用户看到的就是「转圈」「红色感叹号」或者更糟的「显示发送成功但对方没收到」。

先讲握手。WebSocket 建连时把 token 放在 query 或者子协议头里都行,我倾向于 query,因为部分客户端库对自定义 header 支持不完整。服务端在onOpen阶段解析 token,拿到 userId 和 deviceId,然后把 channel 绑到路由表。鉴权失败要立刻 close,不要留着连接等客户端自己超时——这会让「为什么一直连不上」变得极难排查。

3.1 连接建立与三个必传参数

握手阶段有三个参数不能省:token、deviceId、clientVersion。前两个决定身份和多端区分,第三个在协议升级时救过命——老版本客户端发上来的字段和新人不一样,服务端靠它做兼容分支。

// 客户端:连接 -> 上线补拉 -> 收推送 -> 先落库再 ACK const ws = new WebSocket(`ws://127.0.0.1:8081/ws?token=${token}&deviceId=${deviceId}&v=1`); ws.onopen = () => { // 重连后先补拉,lastSeq 取自本地库该会话的最大 seq ws.send(JSON.stringify({ cmd: 'sync', lastSeq: localMaxSeq, limit: 200 })); }; ws.onmessage = (evt) => { const msg = JSON.parse(evt.data); if (msg.cmd === 'push') { saveMessage(msg.data); // 必须先落库 ws.send(JSON.stringify({ cmd: 'ack', seq: msg.data.seq, conversationId: msg.data.conversationId })); } };

lastSeq是补拉水位,服务端返回 seq 大于它的所有消息,limit是单次上限,防止刚装好的客户端一次拉几万条把内存撑爆。真正容易错的是onmessage里的顺序:先落库再回 ACK。反过来写,ACK 发出去了、进程恰好被杀,这条消息在服务端标记为已送达,在本地却不存在,用户重进就永远看不到——这就是所谓的「幽灵消息」,排查起来极其痛苦。

3.2 上行消息的幂等:SETNX 加唯一索引双保险

客户端弱网重发是必然发生的,所以服务端必须假设「同一条消息会来两次以上」。处理方式分两层:先用 Redis 的SET NX拦一道,再用数据库唯一索引兜底。只靠 Redis 不行,进程重启缓存丢了就穿;只靠数据库也不行,高频重复插入会打满连接池。

// 网关:幂等 -> 分配 seq -> 落库 -> 推送 async function onChatMessage(ctx, payload) { const { clientMsgId, conversationId, type, content } = payload; // 第一道:24 小时内同一 clientMsgId 只处理一次 const first = await redis.set(`idem:${clientMsgId}`, '1', 'NX', 'EX', 86400); if (!first) return ctx.reply({ cmd: 'ack', clientMsgId, duplicated: true }); // 第二道:会话内 seq 自增,保证严格有序 const seq = await redis.incr(`conv:seq:${conversationId}`); // 先持久化再推送,顺序反了会出现"收到了但查不到" await db.insert('im_message', { conversation_id: conversationId, seq, client_msg_id: clientMsgId, sender_id: ctx.userId, type, content, created_at: Date.now() }); // 在线直推,离线的等下次 sync 补拉 const members = await db.query( 'SELECT user_id FROM im_conversation_member WHERE conversation_id = ?', [conversationId]); members.forEach(m => pushToUser(m.user_id, { cmd: 'push', data: { conversationId, seq, type, content } })); ctx.reply({ cmd: 'ack', clientMsgId, seq }); }

EX 86400这个过期时间按业务容忍度调,太短会让慢重发穿透到数据库,太长占内存。incr出来的 seq 有个隐患:如果落库失败,这个序号就永久空洞了,客户端补拉时看到 seq 不连续会以为丢消息。稳妥做法是把conv:seq的分配放在事务里,或者接受空洞——补拉逻辑按seq > lastSeq查询而不是seq = lastSeq + 1,空洞就无害了。

3.3 多端同步与已读回执的合并

同一账号手机和网页同时在线时,一条消息要推给所有设备,但「已读」只需要一次。常见做法是给每个设备单独维护已读位置readSeq,会话的已读状态取所有设备里的最大值,再把最大值广播给其他端清未读。这样手机上看过的消息,网页端下一秒也会变灰,不需要用户手动刷新。

4. 会话列表与未读数:Redis 计数和本地表的对齐办法

会话列表是 IM 里被低估的模块。它不难,但极容易做得「看起来对、用起来错」:消息来了列表不刷新、未读数加到了别的会话、置顶的会话被新消息挤下去。根因通常是三个数据源没对齐——Redis 里的未读数、MySQL 里的会话最后消息时间、客户端本地表的顺序。

4.1 未读数为什么不能只压在一张表上

最直觉的做法是在会话表加一列unread,来一条消息+1,已读时清零。单机小流量能跑,用户一多就出问题:每次消息都要更新这一行,同一会话的高频消息会把行锁打成串行,数据库 QPS 直接顶到天花板。

我一般换成 Redis Hash,key 是im:unread:{userId},field 是 conversationId,value 是计数。HINCRBY是原子操作,天然抗并发,而且一次推送只需要动一个 key。

# 未读数:hash 结构,field 是会话 ID HINCRBY im:unread:10086 s_10086_10087 1 # 会话列表只需要前 50 个会话的未读,别用 HGETALL 全量拉 HMGET im:unread:10086 s_10086_10087 s_10086_10089 g_2001

HGETALL是这一层的经典坑。用户加了 500 个群,每次进首页把 500 个字段全拉出来,流量和序列化开销都不小。会话列表本来就只展示前 50 个,用HMGET精确取就够了,拉不到的按 0 处理。

Redis 里的数只是「热数据」,一定要有回落。进程重启、key 被淘汰、用户换设备,都可能让计数丢失。我的做法是客户端本地也维护一份未读数,进首页时用本地值先渲染,同时异步拉服务端值覆盖,两边不一致以服务端为准。这样冷启动不会白屏,热数据错了也能自愈。

4.2 会话排序:lastMsgAt、置顶与免打扰

排序规则看着简单,写起来容易互相打架。基本顺序是:置顶优先,其次按最后消息时间倒序。坑在于「最后消息时间」用哪个——用消息的created_at会在服务端时钟漂移时乱序,用seq更稳,因为 seq 是会话内单点分配的。

-- 会话列表:置顶优先,其次按最后消息时间倒序 SELECT c.conversation_id, c.type, c.last_seq, c.last_msg_at, m.pinned, m.muted FROM im_conversation_member m JOIN im_conversation c ON c.conversation_id = m.conversation_id WHERE m.user_id = ? AND c.last_msg_at > ? ORDER BY m.pinned DESC, c.last_msg_at DESC LIMIT 50;

last_msg_at > ?是增量参数,客户端本地已有列表就只拉变化的,避免每次全量。pinned和muted放在成员表而不是会话表,因为置顶和免打扰是「每个人自己的选择」,放会话表会导致一个人置顶、所有人都置顶。

免打扰会话的处理要单独说一句:收到消息照样要更新last_msg_at让它排到前面,但未读数不增加,只亮一个小红点。这个逻辑如果写在推送侧而不是会话侧,多端同步时就会出现「手机不响、网页响」的割裂感。

4.3 已读回执的 readSeq 合并

已读回执最忌讳「按消息 ID 逐条上报」。一次进会话看了 200 条,发 200 个请求,网络和数据库都受不了。正确姿势是上报一个readSeq,表示「这个会话我读到第几号了」,服务端把成员的read_seq更新为MAX(旧值, 新值),再算出未读数。

-- 已读上报:只增不减,避免旧设备的延迟上报把进度打回去 UPDATE im_conversation_member SET read_seq = GREATEST(read_seq, ?) WHERE conversation_id = ? AND user_id = ?;

GREATEST这个细节很关键。用户在两台设备上操作,手机上报 seq=120,网页因为网络慢后到一步上报 seq=80,没有GREATEST就会把已读进度退回到 80,用户会看到「刚看完的消息又变未读了」。这类问题在测试环境几乎复现不出来,只有真机双端同时用才会撞上。

5. 避坑排查:五类真机与弱网环境下的高频翻车

下面这五条基本是我在 IM 项目里踩过或者帮人捞过的,按出现频率从高到低排。每条都按「现象 → 原因 → 解决」写,对着现象查就行。

5.1 在线却收不到消息

现象:两端都显示在线,A 发消息 B 那边毫无反应,日志里也查不到推送记录。

原因:网关起了多个实例,A 的连接在实例一,B 的消息由实例二处理,实例二的路由表里找不到 B 的 channel,推送直接被丢弃。单机开发环境永远复现不了,一上测试环境两台机器立刻出现。

解决:路由表外置到 Redis,或者用发布订阅做全实例广播。我一般选前者,online:{userId}:{deviceId}存实例 ID,推送时先查实例再转发;查不到就按离线处理,落库等补拉。别用「所有实例都推一遍」的偷懒写法,设备一多就是广播风暴。

5.2 消息重复与幽灵消息

现象:弱网下同一条消息出现两次,或者服务端显示已送达、客户端本地查不到。

原因:重复是客户端重发没做幂等,服务端直接 insert;幽灵消息是客户端先回 ACK 再落库,中间被杀进程。

解决:服务端client_msg_id加唯一索引 + Redis SETNX 双保险;客户端严格「先落库、后 ACK」,落库失败就不回 ACK,让服务端重推。这条顺序我写进了代码 review 清单,改一次能省掉后面几十次扯皮。

5.3 未读数不清零、多端不一致

现象:进会话看了消息,返回列表红点还在;手机清完了网页还挂着 99+。

原因:只在本地清了未读,没上报readSeq;或者上报了但没广播给其他端。

解决:进会话时上报readSeq,服务端更新后用GREATEST防回退,再把新值推给同账号其他设备。客户端收到后本地清零并重算列表。

5.4 切后台重连后消息空洞

现象:App 切后台十分钟再回来,中间的消息一条都没有,后面的消息正常。

原因:重连后只做了「拉最新 N 条」,没有按lastSeq补拉差值,中间那段就永久丢了。

解决:重连第一件事是发sync带本地最大 seq,服务端返回seq > lastSeq的全部消息,分页拉完再开放新消息接收。补拉期间收到的实时推送先缓存,拉完再按 seq 归并,否则会出现「新的先到、旧的后到」的倒序。

5.5 图片消息 OOM 与本地路径失效

现象:聊天记录里图片一片空白,或者滑动列表时 App 直接闪退。

原因:消息体里直接存了本地文件路径,换设备、清缓存后路径失效;列表里加载原图,十几张大图同时解码就把内存打满。

解决:图片先上传对象存储,消息体只存 URL、宽高和缩略图 URL,本地路径只做临时展示用。列表统一加载缩略图,点开才拉原图,并且按屏幕尺寸做降采样。宽高一定要存,不然图片加载前占位高度算不出来,列表会疯狂跳动。

6. 上线前的可靠性进阶:三个压测口径和一条消息轨迹

功能跑通只是及格线,能不能上线看的是几个数:消息端到端延迟、推送成功率、重连后补拉完整率。我压测时固定三个口径,不然数据没法横向比:单聊 500 并发、每人每秒 1 条,持续 10 分钟;群聊 200 人群、每秒 5 条;以及断网 60 秒后恢复,检查补拉是否有空洞。

指标采集点及格线参考
端到端延迟 P99客户端发送时间戳到对端落库同城 500ms 内
推送成功率网关门禁推送返回 vs ACK 回收99.5% 以上
补拉完整率补拉条数 vs 服务端区间条数100%

定位问题时最有效的手段是给每条消息打一个traceId,从上行、落库、投递到 ACK 全链路带上,日志按它串起来一次就能看出卡在哪一跳。

// 一条消息的轨迹:traceId 贯穿 上行 -> 落库 -> 投递 -> ACK const trace = { traceId, clientMsgId, userId, conversationId, t1: Date.now() }; logger.info('msg.up', trace); // 网关收到 // ... 落库、推送各打一次 logger.info('msg.ack', { ...trace, t4: Date.now(), cost: Date.now() - trace.t1 });

t1到t4的差值就是服务端处理耗时,和客户端测的端到端延迟一对,就能判断慢在网络还是慢在库里。早期我没做这一步,靠翻散落的日志猜,一条「偶尔延迟两秒」的问题查了三天,最后发现是某个群成员特别多导致推送循环串行。从那以后我每接一个 IM 项目,都先把seq水位补拉和clientMsgId幂等这两件事写进验收清单,跑不通不开工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询