
简介面向具备一定开发基础、希望自建 IM 系统的技术团队这是一套已亲测可搭建的视酷酷信即时通讯源码包覆盖安卓、苹果、PC 与 Web 四端互通采用 tio 通信技术并附教程与多篇源码解析可用于学习即时通讯架构、进行二次开发或扩展商城等业务模块。压缩包共 9 个文件以 txt 解析笔记为主辅以 html 页面、doc 文档和 jpg 图示整体约 99KB便于先梳理部署思路与代码结构。目前已有 501 人学习下载。内容还涉及 Java、OC、C#、Vue 多语言客户端与服务端环境MongoDB 数据存储及 CentOS 7.6 部署并说明单机万人并发与集群扩展思路可对照教程完成服务端与四端客户端环境准备理解通信链路、数据存储与安全部署等模块适合用来评估技术选型、搭建测试环境或做二次开发原型。1. 一套四端互通的 IM 源码真正值钱的是“能改”在 iOS 上打开 H5 聊天页用 Safari 一滚动输入框被键盘顶飞、下拉一拉直接白屏——同一套即时通讯 IM 源码App 端跑得好好的网页端先散架。四端互通这四个字难的从来不是“通”而是通完还能改。这套带教程的四端 IM 通讯源码卖点不在界面多花哨而在于客户端、服务端、协议层都是可读可改的完整工程Android、iOS、PC、H5 四端共用一套账号与消息体系单聊、群聊、离线消息、好友关系这些基础功能齐全服务端代码放开可二开官方说明里也明确写了支持挂商城之类的业务模块。它适合手上已有管理后台、想补一块即时通讯能力的中小团队也适合拿它当 IM 教材从头把一条消息的链路读一遍。下面按“是什么 → 怎么跑 → 怎么改”的顺序拆。2. 技术栈与目录结构一条消息从哪进、从哪出2.1 分层架构接入层、业务层、存储层、推送层这类四端 IM 源码的主流形态是一套后端 一套协议 四个壳。后端常见做法是 PHP Workerman 或 Java Netty 起一个常驻的 WebSocket 网关前面挂 Nginx 做反向代理业务逻辑抽成独立的 HTTP 接口给登录、好友、群资料这类“非实时”操作用存储用 MySQL 存关系与消息Redis 存在线状态、token 和未读数推送单独一层负责 App 不在前台时的厂商通道和离线消息补投。看清楚分层后面排查问题才有坐标系。我一般拿到源码先画一张“入口表”把每个端口、每个进程对应到具体文件8000 是 HTTP 业务8001 是 WS 网关3306 是 MySQL6379 是 Redis。端口在代码里对不上八成是被配置文件里的环境变量覆盖了这是踩坑最多的地方。一条消息的完整链路大致是这样走的客户端把消息体连同会话标识发给 WS 网关 → 网关拿 token 去 Redis 验身份拿到 userId → 业务层校验权限是不是好友、是不是群成员、有没有被禁言→ 分配自增 seq 与全局 msgId → 先落库再回发送方 ACK → 如果接收方在线按连接路由推送不在线就写离线表并触发推送 → 接收方收到后回 ACK服务端更新已读位点。顺序里任何一步提前都会在弱网下丢消息。提示先写库再回 ACK是判断一套 IM 源码是否“能上生产”的第一道分水岭。先回 ACK 再异步落库的实现断网那一下消息就没了且客户端显示已发送用户以为发出去了。2.2 目录结构打开源码包先翻哪几个文件夹不同语言实现细节有差异但目录划分基本一致。下面是我按常见形态整理的骨架实际命名可能带项目前缀按这个思路对号入座即可im-root/ ├── server/ # 服务端主工程 │ ├── app/ │ │ ├── controller/ # HTTP 接口登录、好友、群、资料 │ │ ├── gateway/ # WS 网关握手、鉴权、心跳、路由 │ │ ├── service/ # 业务逻辑消息、会话、关系链 │ │ ├── model/ # 数据模型对应 MySQL 表 │ │ └── push/ # 离线推送适配APNs 与安卓通道 │ ├── config/ # 数据库、Redis、端口、密钥 │ ├── sql/ # 建库建表脚本 初始数据 │ └── runtime/ # 日志、缓存、上传临时目录 ├── client-app/ # 移动端Android / iOS 同源或双工程 ├── client-pc/ # PC 端多为 Electron 或 Web 打包 ├── client-h5/ # H5 端iOS 上跑 Safari、安卓跑各家内核 └── docs/ # 部署教程、接口文档、协议说明server/gateway和server/service是最该先读的两个目录。网关管连接生命周期业务层管消息语义两边通过内部调用或消息队列衔接。docs目录别跳过四端互通的协议字段、登录时序一般都在里面比读代码快。sql目录里的脚本要看清楚字符集utf8mb4缺失的话表情符号一发就报错这个坑几乎每套源码都有人踩。2.3 数据表设计消息不能只有一张表消息表的设计决定后期能不能加功能。我见过只建一张message表就敢上线的实现结果加“群成员已读人数”时只能全表扫。一套打算二开的 IM至少要有会话表、消息表、会话成员表、离线消息表四类。下面是常见的最小结构字段名按实际情况调整-- 会话表一个单聊或一个群聊对应一条 CREATE TABLE im_conversation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, conv_type TINYINT NOT NULL COMMENT 1单聊 2群聊, target_id BIGINT NOT NULL COMMENT 单聊为对方uid群聊为群id, last_msg_id BIGINT DEFAULT 0 COMMENT 最后一条消息id用于会话列表排序, last_msg_time INT DEFAULT 0, KEY idx_target (target_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消息表只追加不改写 CREATE TABLE im_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id CHAR(32) NOT NULL COMMENT 客户端生成或服务端分配的全局唯一id, conv_id BIGINT NOT NULL, from_uid BIGINT NOT NULL, msg_type TINYINT NOT NULL COMMENT 1文本 2图片 3语音 4自定义, content TEXT NOT NULL COMMENT 正文或文件地址的json, seq BIGINT NOT NULL COMMENT 会话内自增序号用于排序与增量拉取, status TINYINT DEFAULT 0 COMMENT 0正常 1撤回 2删除, created_at INT NOT NULL, UNIQUE KEY uk_msgid (msg_id), KEY idx_conv_seq (conv_id, seq) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 会话成员表已读位点、未读数、免打扰都挂这里 CREATE TABLE im_conv_member ( conv_id BIGINT NOT NULL, uid BIGINT NOT NULL, read_seq BIGINT DEFAULT 0 COMMENT 该用户已读到的最大seq, unread_cnt INT DEFAULT 0, PRIMARY KEY (conv_id, uid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三张表的职责要分清im_message只追加不更新靠seq排序im_conv_member.read_seq存每个成员的已读位置多端漫游就是靠它对齐的im_conversation.last_msg_id是会话列表的排序依据发消息时同步更新避免列表页做聚合查询。msg_id建唯一索引是为了幂等客户端重发时服务端靠它去重没有这个索引弱网重连会出现重复消息用户第一眼就发现系统不靠谱。3. 跑起来本地搭一套能互发消息的环境3.1 依赖版本与必须改的配置项环境上别追新。这类源码多数按 PHP 7.4/8.0 或 JDK 8/11 写的MySQL 用 5.7 或 8.0 都行Redis 5 以上够用Nginx 负责静态资源与 WS 反向代理。跑之前把配置文件里的这几项对齐漏一项就是启动失败或者连上了不推消息配置项作用常见错误值导致的现象DB_HOST / DB_NAME数据库连接服务能起但登录 500日志报 table not foundREDIS_HOST / REDIS_DB在线状态与 token登录成功但一发消息就提示对方离线WS_PORT / WS_HOST网关监听客户端一直在“连接中”APP_KEY / JWT_SECRETtoken 签发换端登录后旧端 token 立即失效或永不过期UPLOAD_DOMAIN图片语音外链消息发出去图片是个裂图PUSH_APNS_*iOS 离线推送App 杀进程后收不到提醒WS_HOST填0.0.0.0才能被局域网内的真机访问填127.0.0.1只有本机能连这个错误我在模拟器上排查了半小时才发现。APP_KEY四端必须完全一致PC 端和移动端用不同密钥的话跨端漫游会一直鉴权失败。3.2 建库、导脚本、起服务建库和导数据用命令行最省事图形工具导入大 SQL 容易超时中断# 1. 建库字符集必须是 utf8mb4否则表情发不出去 mysql -uroot -p -e CREATE DATABASE im_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 导入表结构与初始数据初始数据一般含测试账号 mysql -uroot -p im_db server/sql/install.sql mysql -uroot -p im_db server/sql/init_data.sql # 3. 启动 WS 网关Workerman 形态前台启动方便看日志 php server/start.php start # 4. 另开一个窗口起 HTTP 业务端口与配置一致 php server/start.php start_http # 5. 确认端口都在监听 ss -lntp | grep -E 8000|8001start.php后面跟start是前台调试模式start -d才进后台守护。调试阶段一定要前台跑消息路由失败、连接断开的原因都打在控制台里进后台就只能翻runtime/log。第 5 步确认端口是为了把“客户端连不上”和“服务没起来”这两种现象分开别一上来就怀疑代码。Nginx 反代 WS 时有两个头必须透传少了握手直接 400location /ws { proxy_pass http://127.0.0.1:8001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; # 必须协议升级 proxy_set_header Connection upgrade; # 必须缺了握手失败 proxy_set_header X-Real-IP $remote_addr; # 业务侧取真实IP做风控 proxy_read_timeout 120s; # 要大于心跳间隔否则连接被掐 }proxy_read_timeout默认 60 秒而客户端心跳一般是 30 秒一次表面看够用但只要有一次心跳因为网络抖动晚到连接就被 Nginx 单方面关掉客户端表现是“过一会儿就重连一次”。把它设到心跳间隔的两倍以上这类玄学掉线能少一大半。3.3 四端登录与互通自测服务起来后先别急着开 App用接口工具把链路验一遍更高效# 登录拿 token curl -X POST http://127.0.0.1:8000/api/login \ -H Content-Type: application/json \ -d {account:test01,password:123456} # 返回 {code:0,data:{uid:1001,token:...}} # 带 token 拉会话列表 curl http://127.0.0.1:8000/api/conversation/list \ -H Authorization: Bearer 上一步拿到的token这两步通了说明 HTTP 业务层、数据库、token 签发都正常。再开两个浏览器窗口分别登录 test01 和 test02互发一条文本然后在手机上登录 test01看历史消息是否同步过来。能同步说明seq与已读位点这套机制是通的同步不过来问题基本在会话成员表没初始化或者read_seq没被更新。H5 端单独验一遍 Safari。iOS 的软键盘弹出策略和其他浏览器不一样输入框用position: fixed定位时会被键盘顶上去或者盖住聊天页末尾滚动也会失效。这个问题不是源码 bug是移动端 Web 通病改法是监听resize收起时还原滚动位置或者把输入区改成position: sticky贴在滚动容器底部。4. 核心代码拆解鉴权、连接、消息落库4.1 登录与 token签发、续期、多端挤下线登录接口做三件事校验凭证、签发 token、把在线状态写进 Redis。续期不要靠延长过期时间而是给一个 refresh 窗口否则 token 一旦泄露就是长期有效。// 登录校验 - 签发 - 写在线态 async function login(account, password, device) { const user await db.query(SELECT * FROM im_user WHERE account ?, [account]); if (!user || !verifyPassword(password, user.password)) { throw new BizError(1001, 账号或密码错误); } // token 里带 uid、设备类型、随机串随机串用于挤下线时精确失效 const nonce randomStr(16); const token jwt.sign({ uid: user.id, device, nonce }, APP_KEY, { expiresIn: 7d }); // 一个用户一个设备一条记录便于多端管理与强制下线 await redis.hset(online:${user.id}, device, JSON.stringify({ token, nonce, ts: Date.now() })); // 反向索引网关收到 token 时快速定位用户 await redis.setex(token:${token}, 7 * 24 * 3600, user.id); return { uid: user.id, token }; }device参数是四端共存的关键同一个 uid 在 PC、Android、iOS 上各占一个 hash 字段互不覆盖。要支持“手机端登录把 PC 端踢下线”就在写入前判断策略删掉对应字段并给旧连接推一条kick指令。nonce是为了防止旧 token 在 Redis 里被误复活挤下线时按 token 前缀删比按 uid 全删更精准。expiresIn设 7 天是常见做法太短用户天天重登太长出问题收不回来。4.2 WebSocket 握手、心跳与断线重连网关的第一道关卡是握手鉴权。握手阶段拿不到合法 uid就直接关连接别让它挂在连接池里占资源。// WS 网关握手验 token注册连接启动心跳检测 wss.on(connection, async (ws, req) { const token new URL(req.url, http://x).searchParams.get(token); const uid await redis.get(token:${token}); if (!uid) return ws.close(4001, unauthorized); // 鉴权失败立即断开 ws.uid Number(uid); ws.isAlive true; // 同一用户多设备key 为 uidvalue 为连接集合 connPool.add(ws.uid, ws); ws.on(pong, () { ws.isAlive true; }); // 收到 pong 认为存活 ws.on(close, () connPool.remove(ws.uid, ws)); }); // 每 30 秒扫一遍两轮没响应就判定死连接 setInterval(() { connPool.forEach(ws { if (!ws.isAlive) return ws.terminate(); ws.isAlive false; ws.ping(); }); }, 30000);心跳用协议层的 ping/pong比业务层自定义心跳包省流量也少一层解析。connPool必须支持一个 uid 对应多条连接四端同时在线是常态用 Map 存 Set 就行。客户端侧的重连要带退避1s、2s、4s、8s上限 30 秒无脑每秒重连会在服务端重启时把自己打成 DDoS。4.3 发消息幂等、落库、路由、ACK这是全篇最该逐行读的一段顺序错了就是丢消息或者重复消息。// 处理客户端上行消息 async function onMessage(ws, payload) { const { msgId, convId, msgType, content } payload; // 1. 幂等msgId 已存在直接回 ACK不重复落库 const exist await redis.get(msg:${msgId}); if (exist) return ws.send(ack(msgId, exist)); // 2. 权限会话成员校验群聊还要查是否禁言 const member await db.query( SELECT 1 FROM im_conv_member WHERE conv_id ? AND uid ?, [convId, ws.uid]); if (!member.length) throw new BizError(4003, 不在会话中); // 3. 分配会话内自增 seq保证多端排序一致 const seq await redis.incr(seq:${convId}); // 4. 先落库再回 ACK最后推送 await db.transaction(async t { await t.query( INSERT INTO im_message (msg_id, conv_id, from_uid, msg_type, content, seq, created_at) VALUES (?,?,?,?,?,?,?), [msgId, convId, ws.uid, msgType, JSON.stringify(content), seq, now()]); await t.query( UPDATE im_conversation SET last_msg_id LAST_INSERT_ID(), last_msg_time ? WHERE id ?, [now(), convId]); }); await redis.setex(msg:${msgId}, 3600, seq); // 5. 在线则推送离线则写离线表等补投 const targets await db.query(SELECT uid FROM im_conv_member WHERE conv_id ? AND uid ?, [convId, ws.uid]); for (const { uid } of targets) { if (!connPool.push(uid, buildPush(msgId, convId, seq, content))) { await db.query(INSERT INTO im_offline (uid, msg_id, conv_id, created_at) VALUES (?,?,?,?), [uid, msgId, convId, now()]); } } ws.send(ack(msgId, seq)); // 发送方最后收到 ACK语义是“已持久化” }seq用 Redis 自增而不是数据库自增是因为它要在落库前就能拿到用于推送和 ACK。Redis 的incr是原子的但重启会丢所以生产上常见做法是双写Redis 拿号MySQL 里也存一份恢复时以库里最大值为准做一次校准。第 4 步的事务里顺手更新会话的last_msg_id会话列表就不用额外查消息表了。离线表不是必须的如果消息表本身就能按seq做增量拉取直接用read_seq差值查询更省事。判断标准是用户量小、消息量小用增量拉取消息量大、要单独清理就上离线表。这两条路源码一般只实现一条二开时别两套混用否则会出现“在线收到一遍、上线又拉一遍”的重复消息。4.4 多端漫游已读位点怎么对齐四端互通最容易翻车的地方是已读状态。手机上看过的消息PC 端还亮着红点用户就会觉得这套系统“没做好”。做法很简单每端上报自己读到的seq服务端取最大值写回会话成员表再把变化广播给该用户的其他在线端。// 上报已读取最大值避免多端并发把位点改小 async function markRead(uid, convId, seq) { await db.query( UPDATE im_conv_member SET read_seq GREATEST(read_seq, ?), unread_cnt 0 WHERE conv_id ? AND uid ?, [seq, convId, uid]); // 同步给该用户其他端让红点一起消失 connPool.pushToUser(uid, { type: read_sync, convId, seq }); }GREATEST是关键没有它PC 端上报一个旧seq会把手机端刚更新的位点覆盖回去表现就是“已读状态闪一下又变回未读”。unread_cnt直接清零是简化处理严格算应该是总数减去已读群聊消息量大时用增量更稳。这套逻辑看着简单但真跑起来并发上报的时序问题能让人调一整天。5. 踩坑与排查跑不通时先看这几处5.1 连接与鉴权类现象客户端一直显示“连接中”服务端日志一行没有。原因Nginx 没透传Upgrade和Connection两个头握手阶段就被拒了请求根本没到网关。解决按 3.2 的配置补齐两个头改完nginx -s reload再用浏览器控制台看 Network 里的 WS 请求状态码101 才算通。现象登录成功一发消息就提示“对方离线”但对方明明在线。原因token:xxx这个 key 写在了 Redis 的 db0网关读的是 db1或者两端用了不同的 Redis 实例。解决把服务端配置里的REDIS_DB和网关的对齐用redis-cli -n 0 keys token:*确认 key 真的存在。5.2 消息与时序类现象弱网下同一条消息出现两次。原因msg_id没建唯一索引或者幂等判断只做了内存缓存服务重启后缓存失效。解决数据库加UNIQUE KEY uk_msgid幂等判断落到 Redis 并设过期时间插入冲突时按已存在处理直接回 ACK。现象群聊消息顺序乱A 先发的排在 B 后面。原因seq用的是各端本地时间排序或者服务端多进程各自自增。解决统一由服务端分配会话内自增seq客户端只按seq排序本地时间只用于展示。5.3 环境与部署类现象消息里的表情符号存进库变成问号。原因建库时用了utf8而不是utf8mb4四字节字符存不下。解决库、表、连接串三处都要改成utf8mb4只改一处仍然会乱码这是血泪经验。现象iOS 上 App 杀掉进程后收不到任何提醒。原因只做了 WS 在线推送没接 APNsApp 退到后台连接被系统挂起。解决补齐推送适配层注意 APNs 的证书环境开发/生产要和打包方式匹配用错环境的表现是“能发出但收不到”日志里看不出来。6. 二次开发加商城、加机器人、改协议怎么下手二开最容易犯的错是直接在消息表上加业务字段。我一般先把改动分成三类再决定动哪里纯展示类消息气泡加个商品卡片走msg_type扩展content存 JSON服务端不用改流程类下单通知、订单状态变更走系统消息用一个固定的系统账号发复用现成的推送链路关系类商城客服会话才需要动表加一个conv_biz_type字段区分普通会话和业务会话。扩展消息类型是最划算的入口。文本、图片、语音之外加一个msg_type 10的商品卡片客户端按类型渲染不同布局服务端只做透传和长度校验改动量不超过二十行。真正需要小心的是红包、订单这类带状态的业务消息——它们不能只存一份 JSON得配一张业务表消息里只放业务 id否则订单状态一变历史消息里的卡片就成了一张死图。改造验证有个笨办法但很管用改完协议先把四端的最小闭环跑一遍登录、发文本、发图片、撤回、离线补投五个动作一个不落。我曾经只验了 App 端就上线结果 H5 端因为没处理新的消息类型收到卡片消息直接白屏用户截图就来了。改造目标改动位置注意事项加商品卡片消息消息类型枚举 客户端渲染服务端只校验长度与权限不解析业务字段加客服会话会话表增加业务类型字段会话列表接口要同步过滤别把客服会话混进私聊加群机器人群成员表增加机器人标记机器人发消息走系统账号避免被禁言逻辑拦住换存储或加缓存service 层不动 controller保持接口返回结构不变四端才不用跟着改还有一条动数据库之前先把server/sql里的脚本备份一份改完同步更新脚本文件。不然过两个月重新部署一套环境你会发现线上表结构和脚本对不上那种时候补迁移脚本比重写还累。从那以后我每次接手这类源码第一步都是把建表脚本和实际库结构 diff 一遍再动手改任何字段这个习惯救过我不止一次。希望帮到你。本文还有配套的精品资源点击获取