做后端开发的人迟早要碰一次IM,不管是自己练手、写个聊天室,还是公司要做一个客服系统。我见过太多人一听到“高并发IM”就发怵,其实IM最基本的形态非常简单:一个服务端、一个客户端,加上一条消息流转的通道。这篇东西就是写这个的。我不讲架构,不聊分布式,只把最基础的IM服务端和客户端从0到1拆开,让你明白消息是怎么从A的屏幕跑到B的屏幕上的。适合刚接触网络编程、想自己写个聊天工具的开发者,也适合想搞懂IM底层原理、免得以后面试被问到就懵的人。
1. 一个最基本的IM,到底在做什么
1.1 别被“IM”两个字吓住
即时通讯(IM)听起来高大上,本质就是“两个人通过网络互相传文字”。它和你在浏览器里访问网页、在App里刷列表没有本质区别,只不过IM对“实时性”的要求更高:A发一条消息,B最好能在几百毫秒内看到。
要做到这一点,最朴素的模型只有三个角色:发送方、接收方、以及中间负责转发的服务端。发送方和接收方都是客户端,服务端就像一个邮局。A把信交给邮局,邮局根据收件人地址把信送到B手里。如果B当时不在家,邮局还得先把信存起来,等B回来再给。这就是离线消息。
很多初学者一上来就想着怎么做群聊、怎么做加密、怎么做已读回执,结果代码写了一大堆,连最基本的“单聊消息从A到B”都没跑通。我自己的经验是,先做一个能跑通的最简闭环,再慢慢加功能。这篇就是用最朴素的方式,把服务端和客户端的骨架搭出来。
1.2 服务端和客户端的职责边界
很多人搞不清哪些逻辑该放服务端,哪些该放客户端。其实有一条很简单的分界线:凡是需要“全局状态”的,必须放服务端;凡是只和“当前用户界面”相关的,放客户端就行。
举个例子,在线状态列表就是全局状态。A上线了,B应该能看到A在线,这个判断必须由服务端来维护,因为A和B各自都不知道对方的情况。而聊天记录在界面上怎么展示、气泡靠左还是靠右,这是客户端自己的事,服务端不需要管。
我列了一张表,可以帮你快速对齐两者分工:
| 职责 | 服务端 | 客户端 |
|---|---|---|
| 连接管理 | 维护所有客户端的网络连接 | 负责发起连接、断开连接 |
| 身份认证 | 验证用户身份、绑定用户ID和连接 | 发起登录请求,携带账号信息 |
| 在线状态 | 记录哪些用户在线 | 展示当前联系人状态 |
| 消息路由 | 根据消息目标找到对应连接并转发 | 发送消息时带上目标用户ID |
| 消息存储 | 保存历史消息、离线消息 | 按需拉取历史记录 |
| 界面交互 | 无 | 输入框、消息气泡、状态提示 |
这条边界一旦想清楚,写代码时就不会什么都往服务端堆,也不会什么都往客户端塞。最简单的IM服务端,其实只需要做三件事:管连接、认身份、转消息。
2. 技术选型:我为什么选这套组合
2.1 通信层:WebSocket优先
客户端和服务端之间需要一条“实时双向通道”。传统做法是HTTP轮询,客户端每隔几秒问一次“有新消息吗”,简单但浪费流量,而且做不到真正实时。TCP长连接能做,但浏览器里的网页应用没法直接裸用TCP,需要自己实现协议,对新手不友好。
所以我推荐用WebSocket。它在浏览器里原生支持,服务端这边用Node.js、Java、Go、Python都有很成熟的库。WebSocket是真正的全双工,服务端可以主动往客户端推数据,客户端也可以随时发数据,非常适合IM这种场景。
我见过有人问“要不要自己用Socket写一个IM”,如果是为了学习底层网络原理,那当然可以;但如果只想快速把IM跑起来,WebSocket是性价比最高的选择。就算你以后要写原生客户端,WebSocket协议同样是跨平台的。
2.2 状态存储:Redis存储在线状态,MySQL存用户和消息
在线状态这种数据,特点是“变化快、量不大、丢了也无所谓”。用户上线了就写一个在线标记,掉线了就删掉。这种场景很适合用Redis,读写快,天然带过期时间,配合Redis可视化客户端可以一眼看到当前哪些用户在线。
用户资料和聊天记录则是“不能丢”的数据,得放到数据库里。初学者用MySQL就足够了,建一张用户表、一张消息表,逻辑清晰。这里要注意,不要把用户在线状态这种东西放到MySQL里去,否则每次上下线都要更新数据库,数据库压力很大,而且没有必要。
在我这个最基本版本里,存储可以再简化:用户数据先写死几个测试账号,消息记录暂时不落库,只做实时转发。先把链路跑通,后面再补持久化。别嫌这个版本“太简陋”,能把一条消息从A传到B,就是IM的核心能力。
2.3 客户端选型:先用Web页面跑通,再考虑其他端
客户端我建议先用Web页面做。原因很简单:浏览器自带WebSocket API,打开一个HTML文件就能跑,不用装Android Studio、不用配Xcode,也不需要处理各种系统级网络权限。
等Web端跑通之后,你再去做桌面客户端或者移动客户端,核心逻辑是一样的,都是“连接服务端、发协议包、收协议包、渲染界面”。很多热词里提到的“豆包linux客户端”“苹果电脑svn客户端”之类的,本质上也都是“客户端”这个概念在不同场景下的具体形态,底层网络通信大同小异。
所以这篇文章的示例代码会以Web端为主,服务端用Node.js写。你只要了解JavaScript,就能完整复现。
3. 服务端从零开始:连接、登录、转发
3.1 基础骨架:一个能收消息的WebSocket服务
先用Node.js搭一个最简服务端,依赖用ws这个库。安装命令是npm install ws,然后写下面的代码:
const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); wss.on('connection', (ws) => { console.log('新客户端连接'); ws.on('message', (data) => { console.log('收到消息:', data.toString()); }); ws.on('close', () => { console.log('客户端断开'); }); });这段代码只是打地基。wss.on('connection')会在每个客户端连上来时触发,里面的ws代表当前这个客户端连接。ws.on('message')能收到客户端发来的原始数据。
到这里,服务端已经能“收消息”了,但它不知道这条消息是谁发的、要发给谁。所以下一步是处理登录包。
3.2 登录包:怎么知道谁在说话
WebSocket连接建立后,服务端只知道“有个连接进来了”,并不知道这背后是哪个用户。所以IM协议的第一步,通常是客户端主动发送一个登录包。
我习惯用JSON作为消息格式,简单、可读、好调试。一个登录包长这样:
{ "type": "login", "userId": "alice", "token": "test123" }服务端收到后,做两件事:把userId和当前连接ws绑定在一起,保存到一个内存Map里;然后告诉客户端“登录成功”。代码可以这样写:
const clients = new Map(); wss.on('connection', (ws) => { ws.on('message', (data) => { const msg = JSON.parse(data.toString()); if (msg.type === 'login') { clients.set(msg.userId, ws); ws.userId = msg.userId; ws.send(JSON.stringify({ type: 'loginSuccess', userId: msg.userId })); } }); });这里有一个细节:我在ws对象上额外挂了一个userId属性,方便后面断线时知道这个连接属于谁。clients这个Map就是整个IM的核心,它记录了“每个用户当前对应的连接”。如果这个Map查不到某个用户,说明该用户不在线。
3.3 转发逻辑:一条消息从A到B的完整路径
登录完成之后,客户端就可以发消息了。消息协议也可以很简单:
{ "type": "chat", "from": "alice", "to": "bob", "content": "你好" }服务端收到后,先从clients里查一下to这个用户有没有在线连接。如果在线,就把这条消息原样转发过去;如果不在线,就先存到“离线消息”列表里(后面再讲)。核心代码:
if (msg.type === 'chat') { const targetWs = clients.get(msg.to); if (targetWs) { targetWs.send(JSON.stringify(msg)); } else { // 目标不在线,可以记一条离线日志 console.log('用户不在线,暂存离线消息:', msg.to); } }整个路径就是:alice的客户端 -> 服务端 -> bob的客户端。这里没有经过数据库,也没有经过消息队列,因为最基本的IM根本用不着那些东西。
我在这个环节踩过最大的坑是:服务端把消息转发出去之后,忘记告诉发送方“发送成功”或“对方离线”。导致客户端以为消息发出去了,实际上对方根本没收到。所以你至少要在协议里加一个ack字段,或者直接回复发送方一条sendResult消息。
3.4 心跳与断线清理:防止僵尸连接
网络连接是不可靠的,客户端可能直接断网、关电脑,服务端不一定能立刻感知到。如果不做处理,clients这个Map里就会堆积大量“僵尸连接”,消息往这些连接上发,既发不出去,还白白占着内存。
解决办法是心跳机制。客户端每隔一段时间发一个心跳包,服务端如果超过一定时间没收到某个连接的数据,就认为它已经死了,主动关闭连接并清理Map。代码可以这样加:
const HEARTBEAT_TIMEOUT = 60000; // 60秒没有消息就断开 function heartbeatCheck(userId, ws) { ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); const timer = setInterval(() => { if (!ws.isAlive) { clearInterval(timer); clients.delete(userId); ws.terminate(); } else { ws.isAlive = false; ws.ping(); } }, HEARTBEAT_TIMEOUT); }这段代码用的是WebSocket自带的ping/pong机制,比自定义心跳包更省流量。你在客户端不需要手动回pong,浏览器和WebSocket库会自动处理。服务端只要定期检测哪个连接没回pong,就把哪个连接清掉。
这是整个服务端最容易漏掉的部分。很多人写IM,连心跳都不知道要加,结果服务端跑个半天,连接数越来越多,内存也不断往上涨。
4. 客户端接入:连线、发消息、收消息
4.1 连接服务器并完成登录握手
服务端写好了,客户端这边就简单多了。Web端的WebSocket连接只需要一行代码:
const ws = new WebSocket('ws://localhost:8080');连接是异步的,你要在onopen事件里发送登录包。注意一个常见错误:很多人把send直接写在new WebSocket后面,这时候连接还没建立,消息会丢失。
const userId = 'alice'; ws.onopen = () => { ws.send(JSON.stringify({ type: 'login', userId: userId, token: 'test123' })); };服务端收到登录包后,会回一条loginSuccess。你可以在onmessage里处理:
ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'loginSuccess') { console.log('登录成功:', msg.userId); } };登录成功之后,这个客户端就算正式接入系统了。接下来要做的事情只有两件:发消息、收消息。
4.2 发送消息:把用户输入变成协议包
客户端发消息通常由用户操作触发,比如点击“发送”按钮,或者按回车。发之前要拼接一个协议包,然后send出去:
function sendMessage(toUserId, content) { const msg = { type: 'chat', from: userId, to: toUserId, content: content }; ws.send(JSON.stringify(msg)); }这里有个小坑:如果用户输入的内容里有换行、引号、特殊字符,直接拼JSON可能会出问题。最稳妥的方式是用JSON.stringify,而不是手动拼字符串。
发送消息不能只发出去不管。好的客户端会在界面里先显示一条“发送中”的消息,等服务端确认后再把状态改成“已发送”。如果服务端返回“对方离线”,客户端应该给出提示。这就要求服务端在转发失败时给发送方回一条错误消息,你才能在这里做对应逻辑。
4.3 接收消息:解析响应并渲染到界面
收消息是IM客户端最核心的体验。收到消息后,先判断消息类型,再根据类型做不同处理:
ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'chat') { // 渲染一条消息到聊天窗口 renderMessage(msg.from, msg.content); } else if (msg.type === 'system') { // 系统通知,比如用户上线/下线 updateUserStatus(msg.userId, msg.online); } };渲染聊天界面时,一般会区分“自己发的消息”和“对方发的消息”。最简单的方式是判断msg.from是否等于当前登录用户的ID。等于就是右边气泡,不等于就是左边气泡。
实际做的时候还会遇到一个体验问题:消息列表要通过滚动来加载。新消息进来时,要么自动滚到底部,要么在底部出现“有新消息”的提示。这个逻辑不复杂,但很影响使用感受,建议你在一开始就把滚动位置处理好。
4.4 断线重连:客户端必做的保命手段
WebSocket连接一定会断:网络切换、服务端重启、代理超时、手机锁屏,原因太多了。没有一个自动重连机制的IM客户端是完全没法用的。
最简单的重连策略是:检测到onclose事件后,隔一段时间重新new WebSocket。为了避免服务端刚重启完、立刻重连导致的新一轮崩溃,我习惯用“指数退避”:
function connect() { ws = new WebSocket('ws://localhost:8080'); ws.onopen = () => { // 登录逻辑 login(ws); }; ws.onclose = () => { const delay = Math.min(5000, retryCount * 1000); retryCount++; setTimeout(connect, delay); }; ws.onmessage = (event) => { retryCount = 0; // 正常消息处理 }; }这里retryCount是重连次数,第一次等待1秒,第二次2秒,最多5秒封顶。每次成功收到消息后就把重试次数清零,这样网络恢复时能立刻正常使用,不会一直处于“等待重连”的状态。
断线重连时还有一个坑:重连成功后,服务端会认为进来了一个新连接,老连接可能还在Map里。所以你的登录包设计一定要能支持“同一用户二次登录时,把旧连接踢掉”。服务端要做的就是从Map里取出旧连接并关闭,再把新连接放进去。
5. 我踩过的坑:常见问题与排查技巧
5.1 连接总是被莫名断开
这是我在实际项目里遇到最多的问题,尤其是浏览器环境。原因主要有这么几类:浏览器标签页休眠、公司网络代理超时、Nginx或网关的idle超时、以及防火墙对长连接的限制。
排查方法很简单:先看服务端日志,确认断开时有没有收到close事件。如果没有任何日志就断了,多半是中间网络设备掐掉的。此时优先做两件事:第一,把心跳间隔调到30秒;第二,在Nginx或网关层把代理空闲超时调大到300秒以上。
如果是本地开发环境,还要注意是不是有多个服务抢占同一个端口。我遇到过好几次,服务端明明在跑,客户端却连不上,一看是另一个进程把8080占了。
5.2 消息发出去对方却收不到
这个问题要从两端同时排查。我习惯按下面的顺序查:
| 排查点 | 检查方法 |
|---|---|
| 服务端是否收到消息 | 在message回调里打印完整日志 |
| 目标用户是否在线 | 查clientsMap里有没有目标用户 |
| 目标用户连接是否绑定正确 | 看登录包里的userId是否和Map的key一致 |
| 客户端是否收到消息 | 在onmessage里打印所有数据 |
| 消息协议是否匹配 | 检查type、from、to字段名是否前后端一致 |
其中“字段名不一致”是最隐蔽的坑。我曾经把服务端字段命名为toUser,客户端用的是to,结果服务端永远找不到目标用户,所有消息都进了离线列表。后来我用接口测试工具直接模拟客户端发消息,才定位到是字段名的问题。
这里要单独说一句:调试IM时,不要只靠客户端界面。推荐用命令行工具或者接口测试工具直接连WebSocket,服务端打上详细日志。否则你很难分清“是客户端没发出去”还是“服务端没转发”。
5.3 重复消息和乱序问题
重复消息最常见的原因是客户端重连后重新发送了上次没发成功的消息。比如用户点了一次发送,网络超时,客户端自动重试了一次,结果服务端实际收到了两次,对方就看到两条一模一样的消息。
解决办法是在消息协议里加一个messageId,每次发送时生成唯一ID。服务端转发时保留这个ID,客户端收到消息时根据ID做去重。最简单的去重方法是在客户端维护一个已经接收到的ID列表,重复的ID直接忽略。
乱序问题在TCP和WebSocket层面一般不会发生,但如果你后面做了多服务端部署,或者用了消息队列,就可能会乱序。到那时候可以加一个timestamp字段,客户端按时间排序后再渲染。不过在最基本的版本里,这个可以先不用管。
5.4 连接数一多服务端就卡
我最初用Node.js写的时候,连接数到几百就开始卡,查了半天发现是日志打印太多,文件描述符也不够用。两个关键点:第一,连上来的每个WebSocket都会占用一个文件描述符,Linux系统默认可能只允许1024个,需要调大ulimit;第二,服务端不要在每个连接上无脑打印日志,压测时要分级打印。
如果后续要做到“高并发IM”那种级别,靠单机内存Map是撑不住的。你需要把在线状态放进Redis,用Redis的Pub/Sub做消息广播,再用消息队列做削峰。这些是扩展方向,但前提是你先把单机版跑扎实。
6. 从“最基本”到“能上线”的几条扩展路径
6.1 消息落库和离线消息
最基本的IM只做实时转发,对方不在线消息就丢了。真要能用,必须做离线消息。做法很简单:服务端在转发失败时,把消息存到MySQL的消息表里,等目标用户下次登录成功后,再查一次离线消息,一条条补发过去。
这里有个经验:离线消息表别只存内容和时间,还要存一个is_read字段,否则用户客户端重启后会反复拉取同一批消息。补发完成之后,记得标记已读。
6.2 多服务端节点与会话共享
当你有多台IM服务端时,clientsMap这个方案就失效了,因为用户的连接在A机器,消息却发到了B机器。这时候必须把在线状态放到Redis里,并且让所有服务端节点监听同一个Redis频道。
流程变成:A机器在Redis里记录“bob在A机器在线”,Alice发消息到B机器时,B机器去Redis查bob的节点,发现是A机器,就把消息发布到Redis频道,A机器收到后再转发给bob。这就是最基本的分布式IM路由。理解了单机版,再去理解这套方案会轻松很多。
6.3 群聊、已读回执和输入状态
这些功能都是在基本协议上做加法。群聊就是把消息同时转发给群成员列表;已读回执需要消息带ID,接收方收到后上报“已读”;输入状态则是客户端在用户打字时发一个typing事件,服务端转发给对方。
每加一个功能,协议就要扩展一个type。所以一开始设计协议时,一定要保留type字段,不要把所有消息都塞进一个类型里,否则后面对接的客户端会越写越乱。
6.4 给客户端做一层SDK封装
Web端直接写在页面里的WebSocket代码,用久了会非常难维护。比较好的做法是封装一个简单的SDK,把连接、心跳、重连、消息收发都封装成几个方法:
const im = new IMClient({ url: 'ws://localhost:8080', userId: 'alice', onMessage: (msg) => { /* 渲染 */ } }); im.connect(); im.sendMessage('bob', '你好');这样页面代码只关注界面渲染,网络逻辑都收敛到一个模块里。以后要做多端,也可以复用同一套协议和SDK设计。
我做这个小项目时最深的体会是:IM看起来复杂,但如果能静下心把最基本的一条消息从A传到B跑通,后面所有的高级功能都只是在这个链路上做补充。很多人卡住不是因为IM难,而是因为一开始就想着“高并发”“分布式”,最后连最基础的发送接收都没调通。先自己动手把服务端和客户端连起来,跑通一遍登录、心跳、转发、重连,再去谈架构和优化,路会顺很多。