
简介这是一套面向具备一定开发基础的即时通讯系统搭建与二次开发参考资料围绕视酷酷信IM源码展开覆盖安卓、苹果、PC与Web四端互通场景采用国产tio通信技术适合需要自建通讯服务、研究多端消息同步或进行商城等功能扩展的技术人员也适合评估源码安全性与部署可行性。压缩包共9个文件以6个txt解析文本、1个doc说明、1个html页面和1张jpg预览图为主整体约99KB内容侧重源码结构、部署环境与关键实现思路的整理。已有500人学习读者可从中了解Java、OC、C#、Vue多语言客户端构成以及MongoDB、CentOS 7.6下的部署线索并获取四端互通、单机万人并发与集群扩展等系统优势说明便于后续按自身服务器环境评估搭建方案、核对二次开发边界、私有化部署与安全要点。1. 消息“消失”的那两分钟问题往往不在服务端某团队把 H5 端上线后Android 和原生 iOS 一切正常唯独 Safari 上用户切到后台两分钟再回来聊天列表不刷新客户以为消息丢了——这类问题在四端互通 IM 里太常见。这套四端互通的 IM 源码就是把这些情况提前踩过的版本Android、iOS、PC、H5 共用一条消息链路带完整搭建教程服务端能起来、四端能同时在线收发。它适合有一定后端基础、想拿一套能改的 IM 骨架做产品底座的开发者纯新手照教程也能把服务跑起来但要动业务、加模块Java 和前端至少得看懂。2. 架构拆解一条消息从 Android 发到 Safari 要过几道手2.1 四端的分工与技术选型这套源码把“端”拆得比较干净四端不是四套独立逻辑而是共用服务端的一套协议。Android 和 iOS 走原生长连接PC 端通常用桌面壳套 Web 页面或原生客户端H5 直接跑在浏览器里——Safari、Chrome、微信内置浏览器都算 H5 这一端。之所以强调 Safari是因为它在移动端浏览器里对后台连接的处理最保守把它跑通其余基本不出事。端技术形态主要职责长连接方式Android原生客户端收发消息、离线补偿、本地库WebSocket 心跳iOS原生客户端收发消息、推送兜底WebSocket APNsPC桌面壳 / 原生多窗口、文件传输WebSocketH5Vue 类 SPA免安装访问、分享页WebSocket 前台唤醒服务端常见做法是 Spring Boot 负责 REST 接口登录、好友、群管理、后台管理Netty 单独起一个长连接网关负责 WebSocket两者通过 Redis 共享在线状态。存储侧 MySQL 存用户、会话、消息索引Redis 存在线表、token、未读数、会话自增序号。这套组合不算新鲜但胜在职责清晰业务接口再怎么改网关层不用动。选型上有个细节值得说消息序号没有用数据库自增主键而是用 Redis 的INCR按会话维度生成。原因是 MySQL 自增在分表之后不连续四端对“消息顺序”的理解容易打架用会话内单调递增的seq更稳客户端拉增量也简单——本地存一个最大 seq重连时带上去就行。2.2 长连接接入层与消息投递链路接入层是整个 IM 里最容易被低估的部分。用户量小的时候一台机器一个 Netty 实例就能扛但代码必须按“多节点”写否则横向扩容时整条链路都要重写。这套源码的网关做了一件很关键的事连接建立后把用户ID → 节点标识:通道ID写进 Redis 的在线表投递时先查这张表知道对方挂在哪台机器上。投递链路大致是这样客户端发消息 → 网关校验帧合法性 → 转给业务层的消息服务 → 消息服务先落库、再更新会话摘要 → 查接收方的在线设备 → 在本节点就直接写 Channel在别的节点就通过 Redis 发布订阅把消息转过去 → 对端回 ACK把消息状态从“已入库”改成“已送达”。顺序上坚持“先入库后投递”宁可重复推一次让客户端按msgId去重也不能出现“推过去了但库里没有”的黑匣子情况。在线表的结构值得单独看一眼。用 Hash 存field 是设备类型1 安卓、2 iOS、3 PC、4 H5value 是节点和通道。这样同一个账号在四个端同时在线时投递逻辑可以按需选择普通消息全端推保证多端同步正在输入、已读回执这类状态只推给当前活跃端减少无谓流量。多端同步的坑基本都出在这一层后面第 5 章会细讲。2.3 存储模型消息表、会话表与在线状态消息表的设计直接决定后面查询顺不顺手。常见的踩坑是把所有消息塞一张表几个月后翻页查询慢得离谱。这套源码的写法是按会话维度组织查询消息表保留session_id seq的联合索引翻历史消息走where session_id ? and seq ? order by seq desc limit 20稳定走索引不会随着数据量增长而雪崩。-- 消息主表按会话 seq 查询避免全表扫描 CREATE TABLE im_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(40) NOT NULL COMMENT 全局唯一消息ID客户端幂等去重用, session_id VARCHAR(64) NOT NULL COMMENT 单聊为 小ID_大ID群聊为 g_群ID, from_id BIGINT NOT NULL COMMENT 发送者, to_id BIGINT NOT NULL COMMENT 接收者或群ID, msg_type TINYINT NOT NULL DEFAULT 1 COMMENT 1文本 2图片 3语音 4视频 5自定义卡片, content TEXT NOT NULL COMMENT 消息体媒体类存 JSON, seq BIGINT NOT NULL COMMENT 会话内自增序号顺序以它为准, status TINYINT NOT NULL DEFAULT 0 COMMENT 0已入库 1已送达 2已读 3已撤回, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_msg_id (msg_id), KEY idx_session_seq (session_id, seq) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;上面这段建表里msg_id加了唯一索引是为了兜住客户端重发——网络抖动时同一条消息可能发两次服务端插入冲突直接返回已有记录不会产生重复气泡。seq单独建联合索引而不是和created_at混用是因为客户端排序只认 seq时间戳只用来展示。status字段承担了送达和已读两个状态改动时注意别把“已读”写回“已送达”。会话表则只存摘要最后一条消息 ID、最后一条内容预览、未读数、更新时间。列表页查的是会话表点进去才查消息表这个分层几乎所有成熟 IM 都是这么做的。Redis 里的未读数用 Hash 存im:unread:{userId}field 是 sessionId读完清零四端同步时以服务端为准客户端本地计数只做乐观展示。3. 跑通顺序从改配置文件到四端同时在线3.1 目录结构各端代码放在哪拿到源码包先别急着敲启动命令花十分钟认目录能省掉后面一半的排查时间。常见结构是服务端、四端客户端、数据库脚本、部署文档分开存放网关和业务接口在服务端目录下再分模块。下面这是一种典型组织方式实际目录名可能略有差异按README和启动脚本对齐即可。im-source/ ├── server/ # 服务端 │ ├── im-api/ # REST 接口登录、好友、群、后台 │ ├── im-gateway/ # Netty 长连接网关 │ ├── im-common/ # 公共实体、工具、常量 │ └── sql/ # 建库建表脚本 初始化数据 ├── client-android/ # 安卓端工程 ├── client-ios/ # iOS 端工程 ├── client-pc/ # PC 端工程 ├── client-h5/ # H5 端工程Vue 类 └── docs/ # 部署教程、接口说明认目录时重点看三处sql/里的初始化脚本有没有分区表或存储过程im-gateway的配置文件里端口和 Redis 地址是不是写死的client-h5里config或.env系列的接口地址配置。这三处不改后面启动必翻车。另外注意docs/里的教程版本和代码是否对得上老项目常见教程写的是旧端口代码已经改过。3.2 服务端依赖安装与配置项逐条改服务端跑起来的前提是 MySQL 和 Redis 先就位。MySQL 建议 5.7 以上或 8.0字符集统一utf8mb4否则昵称里的表情符号会变成问号——这个坑很经典导入脚本时忘了改字符集注册个带 emoji 的昵称直接报错。Redis 用 5.0 以上即可主要用到 String、Hash 和发布订阅不涉及复杂数据结构。先把 SQL 脚本按顺序导进去再改配置文件。配置项通常集中在一个application.yml或config.properties里逐条对一遍最稳server: port: 8080 # REST 接口端口H5 端跨域要对着这个改 im: gateway: port: 9000 # WebSocket 端口四端都连这个 path: /ws # 握手路径客户端拼 ws://ip:9000/ws heartbeat-timeout: 90 # 秒超过这个时间没收到心跳判定离线 datasource: url: jdbc:mysql://127.0.0.1:3306/im_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 改成你自己的 redis: host: 127.0.0.1 port: 6379 database: 0 upload: path: /data/im/upload # 图片语音的落盘目录必须提前建好且有写权限配置里最容易出事的是heartbeat-timeout和上传目录。心跳超时设得太短Safari 切后台回来就被判离线用户看到的是“连接已断开”设得太长异常断线的连接会占着在线表不放消息推到一个已经死掉的通道上。90 秒是个折中值配合客户端 30 秒一次心跳比较合适。上传目录不提前创建发第一张图时服务端直接抛异常报错信息还未必指向目录容易查半天。启动顺序建议是先im-api再im-gateway。有些实现里网关启动时会去拉一次接口层的配置比如敏感词库、群配置缓存接口没起来网关会报连接拒绝。启动日志里重点看三行数据源初始化成功、Redis 连接成功、网关端口监听成功。三行齐了再往下走。3.3 四端启动顺序与联调验证四端不建议同时开一台一台来出问题好定位。先开 H5 端最省事浏览器里改接口地址即可不用重新打包。H5 的接口地址要同时改两处REST 基地址和 WebSocket 地址前者走http://你的IP:8080后者走ws://你的IP:9000/ws。用localhost在本机测没问题但手机连同一局域网时localhost指向手机自己一定要换成电脑的局域网 IP。# H5 端本地起服务端口按项目 package.json 里的配置来 cd client-h5 npm install npm run dev -- --host 0.0.0.0 # 加 host 参数手机才能通过局域网 IP 访问 # 安卓端如果走模拟器注意 10.0.2.2 才是宿主机 # 真机调试直接把接口地址改成电脑局域网 IP adb reverse tcp:9000 tcp:9000 # 可选把真机 9000 端口转发到本机联调验证按四步走每步都有明确的通过标准不要跳步。第一步H5 登录浏览器 Network 里能看到 WebSocket 状态 101说明握手成功第二步安卓登录同一账号服务端日志出现两条在线记录设备类型 1 和 4说明多端在线表写对了第三步H5 发一条文本安卓立刻收到返回的 ACK 让消息状态变 1第四步安卓发图H5 能显示说明上传目录和静态资源映射都通了。四步都过基本链路就通了。这之后再测 SafariiOS 上用 Safari 打开 H5切后台三十秒再回来看消息能不能补上。补不上不是网络问题是前台唤醒和离线增量拉取的逻辑没生效第 4 章会给出这段代码。另外提醒一句iOS Safari 上ws://在非 localhost 的 https 页面里会被直接拦掉本地联调要么用 http 页面要么配好wss这一步卡住的人不少。4. 核心代码鉴权、落库投递、前台唤醒三段实现4.1 握手鉴权把 token 绑到 Channel 上WebSocket 握手阶段拿不到自定义请求头浏览器端尤其如此所以常见做法是连接建立后第一帧发鉴权包服务端校验通过才允许后续收发。这套源码的网关里有一个独立的鉴权 Handler逻辑不复杂但顺序很关键先判帧类型再查 token最后写在线表。任何一步失败立刻关连接不给未鉴权的通道留活口。ChannelHandler.Sharable Component public class AuthHandler extends SimpleChannelInboundHandlerTextWebSocketFrame { Resource private StringRedisTemplate redis; Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { JSONObject json JSON.parseObject(frame.text()); // 第一帧必须是鉴权帧其它类型一律断开防止绕过登录直接发消息 if (!auth.equals(json.getString(type))) { ctx.close(); return; } String token json.getString(token); Integer device json.getInteger(device); // 1安卓 2iOS 3PC 4H5 String deviceId json.getString(deviceId); // 设备唯一标识用于挤下线判断 String uid redis.opsForValue().get(im:token: token); if (uid null) { ctx.writeAndFlush(new TextWebSocketFrame({\type\:\auth\,\code\:401})); ctx.close(); return; } // 绑定属性后续 Handler 直接取不用再解析 ctx.channel().attr(ChannelAttr.UID).set(Long.valueOf(uid)); ctx.channel().attr(ChannelAttr.DEVICE).set(device); // 在线表field 为设备类型value 为 节点ID:通道短ID投递时靠它路由 redis.opsForHash().put(im:online: uid, String.valueOf(device), NodeHolder.nodeId() : ctx.channel().id().asShortText()); ctx.pipeline().remove(this); // 鉴权只走一次移除自身减少后续开销 ctx.writeAndFlush(new TextWebSocketFrame({\type\:\auth\,\code\:200})); } }这段代码里device参数决定在线表的 field四端互通靠的就是它。同一个账号在 H5 和安卓同时登录在线表里是两行投递时遍历所有设备逐个推多端同步就成立了。deviceId是为了处理同端重复登录——同一个账号在两台手机上登录是要互踢还是共存看业务决定但设备标识必须带上否则判断不了。最后pipeline().remove(this)是个小优化鉴权完成后这个 Handler 不再需要留着只会增加每次消息的解析开销。有个细节容易写错token 存 Redis 时用 token 做 key 还是用用户 ID 做 key。用 token 做 key 的好处是校验时一次get搞定坏处是没法主动让某个 token 失效用户改密码要踢线。常见做法是两套都存im:token:{token} → uid用来校验im:uid_token:{uid} → token用来踢线改密码时拿后者删前者。4.2 消息落库与可靠投递消息服务的核心是顺序和状态。先拿序号、再落库、再投递三步的顺序不能乱。如果先投递再落库投递成功但入库失败客户端显示的这条消息在服务端不存在刷新就消失用户会以为“消息被吞了”。先落库虽然可能造成重复推送但客户端按msgId去重就能兜住代价小得多。Service public class MessageService { Resource private MessageMapper messageMapper; Resource private StringRedisTemplate redis; Resource private RouteService routeService; Transactional(rollbackFor Exception.class) public Message send(Long fromId, String sessionId, int msgType, String content) { // 会话内自增序号四端看到的顺序以它为准避免时间戳误差导致的乱序 Long seq redis.opsForValue().increment(im:seq: sessionId); Message m new Message(); m.setMsgId(IdGen.next()); // 全局唯一客户端幂等去重 m.setSessionId(sessionId); m.setFromId(fromId); m.setMsgType(msgType); // 1文本 2图片 3语音 4视频 5自定义卡片 m.setContent(content); m.setSeq(seq); m.setStatus(0); // 0已入库 1已送达 2已读 m.setCreatedAt(new Date()); messageMapper.insert(m); // 会话摘要同步更新列表页只查这张表 messageMapper.updateSession(sessionId, m.getMsgId(), abbreviate(content), seq); // 投递本节点直接写 Channel跨节点走 Redis 发布订阅 routeService.deliver(sessionId, m); return m; } }routeService.deliver里做的事值得单独说清楚。它先查会话成员单聊两个人群聊查群成员缓存再逐个查im:online:{uid}在线表拿到节点标识和通道 ID。节点标识等于本机就直接从ChannelGroup里取通道写数据不等就发一条 Redis 消息给对应节点由那边的订阅者负责写。这样单机和集群跑的是同一套代码扩容时不用改业务逻辑。送达状态怎么更新客户端收到消息后回一个 ACK 帧网关里另有一个 Handler 接住按msgId把status从 0 改成 1。已读回执同理用户在会话里点开客户端发已读帧带上最大 seq服务端把小于该 seq 的消息批量改 2同时清掉 Redis 里对应的未读数。这里可以用延迟双删或者简单的定时任务兜底避免 ACK 丢包导致状态一直停在“已入库”。4.3 前端 WebSocket 封装与 Safari 前台唤醒前端这一层的核心不是“连上”而是“断了之后怎么像没断过”。Safari 的表现最典型切到后台后页面的定时器被系统挂起心跳发不出去服务端 90 秒后判定离线并清掉在线表用户切回来页面恢复但连接对象还停在 OPEN 状态的旧值上不发消息看着正常一发就失败。这段逻辑必须显式处理可见性变化。class IMClient { constructor({ url, token, device 4, onMessage }) { this.url url; this.token token; this.device device; // H5 固定为 4 this.onMessage onMessage; this.retry 0; // 重连次数用于指数退避 this.maxSeq 0; // 本地已收到的最大 seq重连后据此拉增量 } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.retry 0; // 第一帧鉴权服务端校验通过才允许收发 this.ws.send(JSON.stringify({ type: auth, token: this.token, device: this.device, deviceId: this.deviceId })); }; this.ws.onmessage (e) { const msg JSON.parse(e.data); if (msg.type auth msg.code 200) this.pullOffline(); if (msg.seq msg.seq this.maxSeq) this.maxSeq msg.seq; this.onMessage(msg); }; this.ws.onclose () this.reconnect(); } // Safari 切后台会挂起定时器心跳停发被判离线回到前台立刻重连并补消息 bindVisibility() { document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { if (!this.ws || this.ws.readyState ! WebSocket.OPEN) this.reconnect(0); else this.pullOffline(); // 连接还在也要补一次防止后台期间漏推 } }); } reconnect(delay) { const wait delay ! undefined ? delay : Math.min(1000 * 2 ** this.retry, 30000); setTimeout(() this.connect(), wait); // 指数退避最多 30 秒一次避免打爆服务端 } // 用本地最大 seq 增量拉取只拉缺的那一段 pullOffline() { fetch(/api/message/offline?maxSeq${this.maxSeq}) .then(r r.json()) .then(list list.forEach(m this.onMessage(m))); } }bindVisibility是这段代码的重点。只监听onclose不够因为 Safari 挂起页面时连接对象不一定触发关闭事件回来时状态是“假的 OPEN”。所以回到前台要判断两件事连接不是 OPEN 就重连是 OPEN 也要主动拉一次离线增量。pullOffline带上本地maxSeq服务端只返回比它大的消息避免每次唤醒都全量拉一遍。reconnect用了指数退避2 ** retry最多等 30 秒。这个参数别设太小服务端重启时几十个客户端同时按 1 秒间隔重连瞬间就把网关打满。另外 Safari 上还有一个隐藏坑页面被挂起期间WebSocket对象可能已经进入 CLOSING 状态此时调send直接抛异常所有发送逻辑都要包一层状态判断别裸调。5. 避坑记录Safari 挂起、节点路由与多端串消息的排查5.1 Safari 与 H5 端的连接异常现象一iOS Safari 切后台两分钟回来消息列表停在原处手动下拉才刷新。原因有两层后台定时器被挂起导致心跳中断服务端按heartbeat-timeout清掉在线表同时页面恢复后没有触发重连和增量拉取。解决办法是把visibilitychange监听挂上回到前台先判连接状态不是 OPEN 就重连是 OPEN 也调一次pullOffline用本地最大seq拉增量。改完还要顺手把服务端心跳超时调到 90 秒以上别让短暂切后台就判死。现象二https 页面里 H5 完全连不上控制台报混合内容拦截。原因是浏览器安全策略不允许 https 页面建立非加密的ws://连接Safari 拦得比 Chrome 更早更彻底。解决方式是正式环境统一上wss本地联调用 http 页面或者localhost调试。别想着在客户端关掉校验绕过那是把问题推给用户。现象三语音消息在 Safari 上点了没声音安卓和 PC 正常。原因是 Safari 的自动播放策略要求音频播放必须由用户手势触发页面加载时预创建的AudioContext处于挂起状态。解决是在点击事件里先audioContext.resume()再播放首次交互时把上下文激活后面就能正常播。5.2 服务端启动与节点路由的常见问题现象网关启动了但两个客户端互相收不到消息日志里投递记录显示“查无在线”。原因多半是 Redis 在线表写进去了但 key 前缀不一致或者网关和业务接口连的不是同一个 Redis 库一个database: 0一个database: 1。排查顺序是先redis-cli看im:online:{uid}存不存在再看节点标识是否为空。多节点部署时还要确认每个节点都有唯一 ID两个节点用同一个 ID消息就会投到错误的机器上。现象群聊发消息群成员收到重复的两条。原因是投递逻辑里既遍历了在线表又走了一遍发布订阅的广播两条路径都写了一次 Channel。解决办法是把投递收口到一个入口本节点命中就从本地写未命中才发 Redis 消息两个分支互斥。客户端也要保留msgId去重双保险。现象服务端起来了发图报 500日志里是路径不存在。配置里的upload.path目录没建或者容器里挂载的卷没写权限。这个错误日志往往只报 IO 异常不直接指向配置项容易查偏。建议启动时加一段自检目录不存在就创建创建失败直接启动失败把问题暴露在启动阶段。5.3 多端同步与二次开发时的数据问题现象手机上读了消息PC 上的未读红点还在。原因是已读回执只清了当前端的本地计数没同步到服务端。正确做法是已读帧带上会话 ID 和最大seq服务端改状态、清 Redis 未读数再给其它在线端推一条已读通知。客户端收到通知后按会话清红点本地计数只做乐观展示以服务端推送为准。现象二开加了一个新的消息类型安卓显示正常H5 直接白屏。原因是前端解析消息体时按固定类型走switch遇到未知类型没走默认分支直接抛异常把整个列表渲染打断。改法是解析层加默认分支未知类型统一渲染成“暂不支持的消息类型”同时把消息类型常量抽成配置各端从同一份定义里取。现象挤下线逻辑一开四端互踢用户手机和电脑反复掉线。原因是挤下线按用户维度判断没区分设备类型。多端互通的账号本来就该允许多设备同时在线正确做法是按uid device维度判断同设备类型重复登录才踢不同设备类型共存。这条在二开时特别容易被改坏改登录逻辑前先想清楚业务要不要支持多端。6. 二开落地给 IM 加一个商城入口要动的四处代码加商城不是加几张表那么简单得先想清楚它和 IM 的关系。常见的两种做法一种是独立模块用户在 IM 里点一个入口跳到商城页面消息只做通知另一种是深度整合商品卡片作为一种消息类型直接发在会话里点击卡片进详情。后者体验好但改动面大建议先跑通前者再演进。无论哪种落点都在四处。第一处是消息类型常量在服务端和四端各有一份定义新增一个6-商品卡片必须五处同步改漏一处就会出现某端不认。第二处是消息体解析卡片消息的content存 JSON字段至少包含商品 ID、标题、封面、价格客户端按类型走卡片渲染。第三处是权限和菜单商城的入口挂在会话页的扩展面板里需要后台配置项控制开关。第四处是订单表和 IM 的用户表通过user_id关联别另外建一套账号体系。-- 商品卡片消息体示例存进 im_message.content { itemId: 10086, title: 某示例商品, cover: /upload/goods/10086.jpg, price: 199.00, link: /mall/detail?id10086 }这段 JSON 的字段设计有两个原则一是别把展示需要的全部字段都塞进去商品改价后历史消息会不一致只存快照里必须的点击时再查实时详情二是link用相对路径换域名时不用改历史数据。客户端渲染卡片时对cover做懒加载H5 端在 Safari 上尤其要注意一次性加载几十张图会把内存顶上去。验证方式很简单按四端各走一遍发送卡片消息检查四端是否都能渲染点击卡片跳转是否正常商品下架后历史消息里的卡片是否还能打开应该降级成“商品已下架”而不是白屏。这三步走完再考虑把订单状态回写成 IM 通知消息比如发货提醒走系统会话这块复用现有的系统消息通道就行不用新增类型。二次开发最怕的是改一处漏一处尤其是四端并存的项目。我一般会先把所有消息类型的定义集中到一个枚举或者常量文件里各端从同一份定义生成新增类型时用脚本检查五处是否齐全比人肉对靠谱得多。还有一条血泪经验动登录和在线表之前先把现有的多端登录场景全测一遍别改完才发现挤下线逻辑把 PC 端也踢了。希望帮到你。本文还有配套的精品资源点击获取