首页 / 资讯中心 / 文章详情

微聊APP开源IM项目全链路解析:长连接、消息幂等与多端同步

微聊APP开源IM项目全链路解析:长连接、消息幂等与多端同步 ★ FEATURED ARTICLE
简介微聊APP是一套面向Android与iOS双平台的开源聊天应用项目适合希望理解即时通讯产品完整实现路径的移动开发者、计算机专业学生及社交应用创业者研读借鉴。项目以原生开发为主线覆盖消息收发与推送、本地数据库存储、WebSocket网络通信、加密与权限管理、多线程调度以及图片、语音、视频等富媒体消息扩展可帮助读者对照真实工程理解社交软件的核心架构与性能优化思路。压缩包共594个文件约23.05MB以png界面切图与jpg/jpeg素材、class编译产物、xml布局与配置、java源码及jar依赖为主兼有properties配置、ttf字体和dex等构建文件基本还原了一个可运行的聊天应用工程结构。目前已有1845人学习关注适合按模块拆解UI、通信与数据层实现并以此为脚手架搭建自己的社交应用原型。1. 从一个消息发不出去的下午说起微聊APP 到底是什么去年帮朋友看一个聊天 社交 APP 的开源项目他在真机上给同事发消息对方屏幕一直不亮日志里长连接却显示已连接。查了两个小时才发现是心跳包被系统后台掐了客户端还以为自己活着。微聊APP 就是这类项目里比较完整的一个把单聊、群聊、会话列表、未读红点、历史消息翻页、离线消息补拉这条主链路做成了能跑的工程客户端加服务端一起给不是只丢一个聊天界面 Demo 出来。适合两类人想给自己产品加 IM 模块但不想从零啃 WebSocket 和消息表的开发者以及想拿一个真实项目练手客户端架构、长连接、消息幂等的同学。它不解决千万级并发这种问题但把聊天功能最容易翻车的那几个点都摆在了明面上。2. 技术栈与目录结构一个聊天 APP 被拆成客户端、接入层、消息服务三块先别急着npm install。这类开源聊天项目跑不起来八成不是代码错是没搞清楚它到底由几块拼起来、哪块必须先起。我拿到任何一个 IM 项目第一件事都是把目录结构和进程边界画清楚不然调试的时候连日志该去哪个终端看都不知道。2.1 三层结构客户端、接入层、业务与存储微聊APP 的骨架是典型的三段式。客户端管界面和本地缓存接入层管长连接和消息转发业务服务管会话、好友、历史消息这些走 HTTP 的东西最底下是 MySQL 存消息、Redis 存在线状态和未读计数。层次常见选型在这类项目里负责什么客户端Kotlin MVVM Room会话列表、聊天页、本地消息缓存、断线重连客户端网络OkHttp WebSocket Retrofit长连接收发消息帧HTTP 拉历史与资料接入层Node.js Socket.IO握手鉴权、连接保活、按用户房间投递业务服务Node.js Express登录、会话创建、历史消息分页、已读上报存储MySQL Redis消息持久化在线表、未读计数、幂等键为什么长连接和 HTTP 要分开因为两者的失败模式完全不同。HTTP 挂了重试一次就行长连接挂了要考虑重连期间的消息补偿还要考虑重连后是不是要重新拉一次离线消息。混在一起写出问题的时候你分不清是接口 500 还是连接断了。客户端这边我一般会坚持 MVVM 加一层 domainUI 只观察 StateFlow发消息、拉历史、标记已读这些动作走用例层用例层再决定是写本地库还是发网络。这样后面加撤回引用回复的时候改动集中在用例层不用动界面。2.2 目录结构改代码之前先知道该进哪个文件夹工程大致长这样我把关键目录标了注释照着这个找基本不会迷路weiliao/ ├── app/ # Android 客户端 │ ├── src/main/java/.../ui/chat # 聊天页、会话列表页、输入框组件 │ ├── src/main/java/.../ui/conversation # 会话列表与未读红点 │ ├── src/main/java/.../data/local # Room 实体、DAO、数据库升级 │ ├── src/main/java/.../data/remote # Retrofit 接口 WebSocket 客户端 │ └── src/main/java/.../domain # 用例SendMessage、LoadHistory、MarkRead ├── server/ │ ├── src/socket/ # 握手鉴权、房间管理、帧分发 │ ├── src/modules/chat/ # 消息落库、投递、已读回执 │ ├── src/modules/user/ # 登录、资料、token 签发 │ └── src/db/migrations/ # 建表 SQL按序号执行 └── docs/ # 协议文档、部署说明有个细节值得注意server/src/socket和server/src/modules/chat是分开的。socket 层只做收到一帧、校验、交给业务不碰 SQLchat 模块只做存消息、算未读、投递不关心连接是怎么建立的。这个边界如果一开始不划后面加群聊、加消息撤回socket 文件会膨胀成两千行的怪物。2.3 启动顺序先起依赖再起服务最后改客户端地址新手最容易翻的车是直接开 Android Studio 点运行然后对着连接超时发呆。正确顺序是先起 MySQL 和 Redis再跑建表脚本再起服务端最后才动客户端。# 1. 起依赖compose 文件一般在仓库根目录 docker compose up -d mysql redis # 2. 按顺序执行建表脚本别用客户端工具手动点容易漏表 for f in server/src/db/migrations/*.sql; do mysql -h127.0.0.1 -uroot -p weiliao $f done # 3. 起服务端HTTP 和 WebSocket 通常同端口 cd server cp .env.example .env # 改 DB_HOST、REDIS_HOST、JWT_SECRET npm install npm run dev # 4. 客户端把 API_HOST 改成局域网 IP模拟器用 10.0.2.2 反而不通真机 # app/build.gradle 里搜 API_HOST改成 192.168.x.x:3000 ./gradlew installDebug参数上要盯三个地方.env里的JWT_SECRET必须和客户端登录拿到的 token 对得上否则握手阶段就被拒API_HOST必须是真机能访问到的地址localhost在手机上指向手机自己建表脚本的执行顺序不能乱conversation_member依赖conversation和user倒着跑会直接外键报错。提示服务端起在 3000 端口时如果手机浏览器打不开http://192.168.x.x:3000/health先查防火墙和是否同网段别急着怀疑代码。启动这块顺了后面才有资格谈消息链路。3. 消息链路拆解一条文字从点发送到对方屏幕亮起的完整路径聊天功能真正的复杂度全在链路上。点一下发送中间要经过本地落库、连接可用性判断、服务端幂等校验、全局序号分配、在线投递或离线计数、对端 ack 回执、发送方状态更新。任何一环省掉都会在某个场景下变成玄学问题。3.1 客户端长连接握手带票据、心跳防假活先看连接怎么建。这里的关键不是能连上而是知道自己什么时候掉线了。class ChatSocketClient( private val url: String, private val tokenProvider: () - String ) { private var webSocket: WebSocket? null private val client OkHttpClient.Builder() // 协议层 ping防止中间设备把空闲连接提前回收 .pingInterval(30, TimeUnit.SECONDS) .build() fun connect() { val request Request.Builder() // 握手阶段就带票据服务端在 upgrade 时校验省掉一条 auth 帧 .url($url?token${tokenProvider()}) .build() webSocket client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(ws: WebSocket, response: Response) { // 连上后立刻同步一次本地最大 seq用于补拉断线期间的消息 ws.send({type:sync,maxSeq:${LocalStore.maxSeq()}}) } override fun onMessage(ws: WebSocket, text: String) { // 所有下行帧统一解析按 type 分发给对应处理器 FrameParser.parse(text)?.let { FrameDispatcher.dispatch(it) } } override fun onFailure(ws: WebSocket, t: Throwable, resp: Response?) { // 指数退避避免弱网下疯狂重连把服务端打满 Reconnector.schedule(attempt 1, baseDelayMs 1000, maxDelayMs 30000) } }) } }pingInterval(30s)这一行是心跳的核心OkHttp 会在连接空闲时自动发协议层 ping收到 pong 才算活。很多人以为业务层自己发 JSON 心跳就够了但业务心跳在 TCP 已经半死的情况下照样能写成功这就是典型的假活。token放在 query 里是因为 WebSocket 握手不方便带自定义 Header服务端在 upgrade 那一步就能拒掉非法连接。onOpen里那条sync帧很重要客户端把自己本地存的最大seq报上去服务端返回这个序号之后的所有消息断线期间漏掉的消息一次性补齐。没有这一步用户锁屏十分钟再打开聊天记录中间就是断的。3.2 消息帧协议一个 JSON 里必须有的六个字段帧格式看着简单但字段少了任何一个后面都要返工。// 上行客户端发一条文本消息 { type: chat, // 帧类型chat / ack / read / sync / ping localId: c-8f3a-uuid, // 客户端生成的唯一 id用于幂等和本地占位 conversationId: 10241, // 会话 id单聊也有会话不要用对方 uid 代替 msgType: 1, // 1 文本 2 图片 3 语音 4 系统通知 content: 晚上七点碰头, clientTime: 1730000000000 // 客户端时间仅用于展示排序不认它 } // 下行服务端确认 { type: ack, localId: c-8f3a-uuid, // 原样回传客户端靠它把发送中改成已发送 serverId: 998231, // 服务端主键 seq: 457, // 会话内递增序号真正的排序依据 serverTime: 1730000001200 }localId是整套机制里最便宜也最值钱的字段。客户端点发送时立刻在本地插一条状态为发送中的消息用localId做临时主键界面马上有反馈服务端收到后用localId userId做幂等键重复投递直接返回上次的serverId。弱网下 WebSocket 断在服务端已落库、ack 没回来这个时间点客户端重连重发靠的就是这个键去重。seq而不是时间戳排序是血泪经验。两台手机系统时间差三分钟是常态按时间排出来的会话顺序两边不一致用户会以为消息丢了。seq由服务端在会话维度自增谁后落库谁号码大天然一致。clientTime只用来在气泡上显示昨天 21:03不参与任何逻辑。3.3 服务端处理幂等、鉴权、落库、投递四步走服务端收到chat帧之后顺序不能乱。先查幂等再验成员再落库最后投递。// server/src/modules/chat/handler.js const MAX_CONTENT 2000; async function onChat(io, socket, frame) { const { localId, conversationId, msgType, content } frame; // 1. 幂等同一个 localId 只落库一次10 分钟窗口足够覆盖重连 const idemKey idem:${socket.userId}:${localId}; const cached await redis.get(idemKey); if (cached) { return socket.emit(ack, JSON.parse(cached)); } // 2. 鉴权确认发送者确实在这个会话里防止伪造 conversationId 发消息 const [member] await db.query( SELECT 1 FROM conversation_member WHERE conversation_id ? AND user_id ?, [conversationId, socket.userId] ); if (!member) return socket.emit(error, { localId, code: 403 }); // 3. 落库seq 在事务里自增保证会话内严格递增 const saved await db.transaction(async (trx) { const [{ nextSeq }] await trx.query( SELECT COALESCE(MAX(seq), 0) 1 AS nextSeq FROM message WHERE conversation_id ? FOR UPDATE, [conversationId] ); const [ret] await trx.query( INSERT INTO message (conversation_id, sender_id, type, content, seq, created_at) VALUES (?,?,?,?,?,NOW(3)), [conversationId, socket.userId, msgType, content.slice(0, MAX_CONTENT), nextSeq] ); return { serverId: ret.insertId, seq: nextSeq }; }); const ack { type: ack, localId, ...saved, serverTime: Date.now() }; await redis.setex(idemKey, 600, JSON.stringify(ack)); // 4. 投递在线直推离线只加未读等对方上线靠 sync 补 const members await getMembers(conversationId); for (const uid of members) { if (uid socket.userId) continue; if (online.has(uid)) { io.to(u:${uid}).emit(chat, { ...frame, ...saved }); } else { await redis.incr(unread:${uid}:${conversationId}); } } socket.emit(ack, ack); }FOR UPDATE那一行是群聊并发的关键。两个人同时发消息如果不加行锁两条 INSERT 可能读到同一个MAX(seq)直接撞唯一索引。代价是这个会话在事务期间串行单会话吞吐有上限但聊天场景几乎不会踩到这个上限。MAX_CONTENT截断是防恶意长文本撑爆存储真实项目里还要在网关层限制单帧大小。离线投递只加未读不落推送是因为消息本身已经进库了对方上线时sync一拉就有再走一遍推送通道纯属浪费。3.4 客户端会话列表未读数不能靠遍历消息算会话列表页最容易被写烂的地方是每次刷新都去SELECT COUNT(*)统计未读。消息一多这个页面就卡成幻灯片。// 会话列表只需要一次聚合查询未读存在会话成员表里 Query( SELECT c.id, c.title, c.last_msg_preview, c.last_msg_time, m.unread_count FROM conversation c JOIN conversation_member m ON m.conversation_id c.id AND m.user_id :uid ORDER BY c.last_msg_time DESC ) fun observeConversations(uid: Long): FlowListConversationItemunread_count维护在conversation_member上收到新消息时1用户进入聊天页时清零。Flow的好处是 Room 表一变就自动重推不需要手动刷新。last_msg_preview冗余在会话表上避免列表页 join 消息表取最后一条。这个设计有个坑清零和1是并发操作时可能出现刚清零又被加回来。常见做法是把清零做成记录已读的最大 seq未读数用当前最大 seq - 已读 seq计算这样天然幂等不会反弹。4. 会话与存储设计历史消息、未读数、多端同步怎么落到表上聊天的所有体验问题追到根上都是表设计问题。翻页卡、红点不消、换手机丢记录基本都能在 schema 里找到答案。4.1 三张核心表message、conversation、conversation_memberCREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conversation_id BIGINT NOT NULL, sender_id BIGINT NOT NULL, type TINYINT NOT NULL DEFAULT 1, -- 1文本 2图片 3语音 4系统 content TEXT NOT NULL, seq BIGINT NOT NULL, -- 会话内递增客户端排序唯一依据 revoked TINYINT NOT NULL DEFAULT 0, -- 撤回用软标记不物理删 created_at DATETIME(3) NOT NULL, UNIQUE KEY uk_conv_seq (conversation_id, seq), KEY idx_conv_id (conversation_id, id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE conversation_member ( conversation_id BIGINT NOT NULL, user_id BIGINT NOT NULL, read_seq BIGINT NOT NULL DEFAULT 0, -- 已读到的最大 seq未读总seq-它 joined_at DATETIME NOT NULL, PRIMARY KEY (conversation_id, user_id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_conv_seq这个唯一索引是顺序的兜底就算代码写错并发插入数据库也会拦住。read_seq用序号而不是计数字段解决上面说的红点反弹问题。revoked用软标记是因为撤回后 UI 要显示对方撤回了一条消息物理删了就没法渲染占位。content用 TEXT 存 JSON 字符串还是纯文本我一般建议纯文本存文本消息图片和语音存 JSONURL、宽高、时长解析交给客户端按type分支。全部塞 JSON 会让服务端的搜索和审计变得很难受。4.2 历史消息翻页游标比 OFFSET 稳-- 进聊天页首屏取最新 20 条 SELECT id, sender_id, type, content, seq, created_at FROM message WHERE conversation_id ? ORDER BY seq DESC LIMIT 20; -- 往上翻以当前列表最小 seq 为游标 SELECT id, sender_id, type, content, seq, created_at FROM message WHERE conversation_id ? AND seq ? ORDER BY seq DESC LIMIT 20;WHERE seq ?走的是uk_conv_seq索引翻到第一万条和翻到第一条耗时一样。换成LIMIT 20 OFFSET 9980MySQL 要先扫过前 9980 行再丢掉越翻越慢用户手指还没抬起来页面就白了。前端拿到的结果要倒序插到列表头部别直接addAll到尾部否则消息顺序看起来是反的。这个 bug 我自己写过一次被测试同学追着问了两天。4.3 多端同步靠 seq 而不是靠推送多端一致最省事的做法是每端各自记自己的最大 seq。用户从手机切到平板平板登录时带自己的lastSeq服务端返回之后的消息手机上的已读操作通过read帧上报read_seq服务端更新后推给同一用户的其他在线设备其他端把本地对应会话的未读清零。这里要注意read_seq只能增不能减服务端收到比当前值小的直接忽略。否则多端同时上报晚到的小值会把红点重新点亮。5. 避坑与排查跑聊天项目最容易翻车的五个地方下面这几条都是真实踩出来的每条按现象、原因、解决写。5.1 连接假活后台挂十分钟就收不到消息现象App 切后台再回来界面显示连接正常但新消息一条都收不到杀掉重进才恢复。原因系统在后台冻结了进程或者中间网络设备回收了空闲 TCP 连接客户端本地对象还在实际链路早断了。业务层心跳包写成功只是写进了内核缓冲区不代表对端收到。解决把pingInterval设到 30 秒以内让协议层参与探活同时监听onFailure和onClosing任何异常都走重连重连成功后必须发一次sync帧补拉断线期间的消息。只重连不补拉等于连接回来了但消息还是空的。5.2 消息重复同一条在列表里出现两次现象弱网下发消息气泡先变成发送中过几秒又冒出来一条一模一样的内容。原因服务端已经落库ack 在返回路上丢了客户端超时重发服务端又插了一条。缺的是幂等键。解决客户端生成localId并随帧上报服务端用idem:userId:localId做 Redis 键命中就返回缓存的 ack不再落库。缓存过期时间给 10 分钟基本覆盖重连窗口。另外客户端渲染层要按localId去重本地临时消息收到 ack 后原地替换成正式消息而不是追加。5.3 顺序错乱两台手机看到的排列不一样现象两个人同时发消息A 手机上自己那条在上面B 手机上相反。原因用clientTime或created_at排序。两台设备时间不同步服务端NOW()在并发插入时也可能相同。解决排序只用会话内seq。seq在事务里用MAX(seq)1加行锁分配严格递增且全局唯一。客户端本地消息在拿到 ack 之前先按localId排在末尾收到 ack 后按seq重排。5.4 未读红点归零又反弹现象点进聊天页红点消失退出来又变成 1。原因清零用的是unread_count 0覆盖写同时对方发来新消息做了1两个操作并发顺序不可控。解决改成记录read_seq未读数实时计算为maxSeq - read_seq。清零就是把read_seq更新成当前最大seq更新语句加WHERE read_seq ?天然幂等重复执行也不会回退。5.5 数据库连接被打满一进群聊就卡现象单聊正常进几十人的群发一条消息整个服务卡住十几秒日志里全是连接超时。原因投递逻辑在循环里对每个成员查一次库几十个成员就是几十次查询还没走连接池复用。解决成员列表一次查出来放内存在线状态走 Redis 批量mget投递用房间广播而不是逐个emit。另外把getMembers的结果在会话维度缓存几十秒群成员变动不频繁没必要每条消息都回源。6. 二次开发加一种消息类型并把项目改成自己的产品跑通之后最有价值的动作是动一次协议。我建议从加撤回开始因为它同时碰到 UI、协议、存储、投递四个面做完一遍整个工程的数据流就通了。第一步服务端加帧类型。在chat之外加一个revoke参数只有serverId校验发送者身份和 2 分钟时限// server/src/modules/chat/revoke.js async function onRevoke(io, socket, { serverId }) { const [msg] await db.query( SELECT conversation_id, sender_id, created_at FROM message WHERE id ?, [serverId] ); if (!msg || msg.sender_id ! socket.userId) return; // 只能撤自己的 if (Date.now() - new Date(msg.created_at).getTime() 120000) return; // 超时不允许 await db.query(UPDATE message SET revoked 1 WHERE id ?, [serverId]); // 通知会话内所有人刷新这一条而不是让客户端自己猜 io.to(c:${msg.conversation_id}).emit(revoke, { serverId }); }第二步客户端在FrameParser里注册revoke分支收到后按serverId找到本地 Room 里那条记录把revoked置 1UI 上把气泡内容替换成灰色提示文字不要直接删行——删了之后列表高度会跳。第三步验证。别只看界面用两个客户端交替操作A 发消息 B 收到A 撤回看 B 的气泡是否变灰B 断网后再上线走sync拉到的历史里那条也必须是已撤回状态。这一步能验出你sync接口有没有把revoked字段带出去我见过不少项目就是漏了这个字段导致新登录的设备看到的是原文。改完这一轮你大概会形成一个习惯动任何协议字段之前先问自己离线补拉的时候这个字段带没带。那次线上出问题就是补拉接口少返回一个状态位用户换设备登录看到一堆发送中的消息回滚加数据修复折腾到半夜。从那以后我每次改消息结构都强制把sync的返回完整跑一遍双端对比再懒也不省这一步。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站