1. 先把最朴素的模型讲清楚IM到底在哪里跑很多新手接触到IM、即时通讯这组词的时候第一反应往往是“这不就是聊天软件嘛”。真要动手写一个脑子里却全是“消息怎么发出去”“对方怎么收到”“我在睡觉的时候消息还能不能到”这类问题。这些问题的答案都落在最朴素的“服务端、客户端”两个角色上。我做了几年即时通讯相关的东西也带过不少新人发现只要把这两个角色的分工彻底搞明白后面无论你是做IM服务端开发、客户端开发还是只是用第三方云IM做业务集成底层逻辑都会顺很多。先抛一个最关键的结论IM和普通HTTP请求不一样它不是“问一次、答一次”就结束的模型。HTTP像是去柜台办业务你递单据过去柜员把回执递给你这次交互就结束了。IM则是两个人待在同一个房间里面持续对话房间本身要一直存在谁来开门、谁在里面等着、谁负责把话传给正确的人这些活儿全都压在了服务端和客户端头上。所以IM的“最基本的服务端、客户端”本质上是在描述一套“长期在线的消息通道”的搭建方式。从这一篇开始我会把IM从零到一拆开来讲。这一篇只讲最基本的骨架服务端定时监听连接客户端主动连上来双方维持一个长连接然后按约定好的格式互相扔消息。不管你是想做局域网内的临时聊天工具、游戏里的队伍语音/文字通讯还是想摸清楚微信、钉钉这类东西背后最底层的那层逻辑这篇文章都够用。适合什么人看写给两类人。第一类是刚开始接触网络编程、想做个毕业设计或者练手项目的同学第二类是工作以后被业务推着必须碰IM、但从来没系统梳理过通讯模型的后端/客户端开发。前者看完能自己搭出一个能跑通的demo后者看完能把服务端和客户端的职责边界、消息格式设计这些基础盘得很清楚。我尽量不用特别玄乎的架构词所有概念都拿来配合真实可运行的代码讲。2. 服务端设计从一条裸连接开始理解IM的引擎2.1 为什么IM必须用长连接而不是频繁请求先讨论一个很多人纠结的问题服务端到底要不要“一直跟客户端连着”。HTTP也能传消息客户端轮询一下服务端“有没有新消息”不也能实现IM吗能但不好用。轮询有个天然缺陷客户端不知道服务端什么时候有新消息只能隔几秒问一次要么多等要么多浪费请求。更麻烦的是即时消息讲究“秒达”轮询的间隔一旦拉长体验立刻崩掉。所以IM基本都走“长连接”这条路线。客户端连上服务端之后这个连接不关闭双方随时可以往这个连接里写数据。服务端一旦收到A发来的消息能立刻把这条消息顺着B已经建立好的连接推给B。这就是即时性的来源。简单说长连接让“服务端主动找客户端”变成了可能而HTTP那套模型天然不擅长这个动作。长连接需要一个基础服务端必须能把“连接”当作一种资源长期保存下来。这在代码上的直接体现就是服务端不再是一来一往的处理完就完事而是要维护一张“当前在线客户端”的名单。这条名单就是IM服务端早期最核心的东西。别的暂且都不用管先把这张表维护好IM的基本盘就稳了。2.2 写一个最小可用的IM服务端我用Python写一个最原始的服务端纯粹用标准库里的socket不引任何框架让所有逻辑都暴露在眼皮底下。这样不管你是用Java、C#、Go还是Node.js都能把这段代码映射成自己熟悉的东西。import socket import threading # 在线客户端连接表: {client_id: (conn, addr)} clients {} lock threading.Lock() def broadcast(sender_id, message): 把一条消息转发给所有在线客户端 with lock: for cid, (conn, addr) in list(clients.items()): if cid sender_id: continue try: conn.sendall(message.encode(utf-8)) except Exception: # 发送失败说明对方连接可能已经断开暂时不管 pass def handle_client(conn, addr): client_id f{addr[0]}:{addr[1]} with lock: clients[client_id] (conn, addr) print(f[新连接] {client_id} 上线当前在线: {len(clients)}) try: while True: data conn.recv(1024) if not data: break msg data.decode(utf-8) print(f[收到] {client_id}: {msg}) broadcast(client_id, msg) except Exception as e: print(f[异常] {client_id}: {e}) finally: with lock: clients.pop(client_id, None) conn.close() print(f[断开] {client_id} 下线当前在线: {len(clients)}) def main(): server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 8888)) server.listen(128) print(IM服务端监听在 0.0.0.0:8888) while True: conn, addr server.accept() t threading.Thread(targethandle_client, args(conn, addr)) t.daemon True t.start() if __name__ __main__: main()这个服务端非常简单每来一个客户端连接就开一个线程盯着它收数据收到一条消息就把这条消息群发给其他所有在线客户端。它已经从功能上完成了IM最基础的一环——消息中转。注意几个细节recv(1024)是阻塞调用单线程模式下它一次最多读1024字节。真实IM肯定要用更大的缓冲区、半包粘包处理、异步IO但这不是这一篇的核心后面我会专门聊协议设计的时候再展开。我用了线程锁来保护clients字典因为多线程同时修改它容易出问题。这个细节看起来低级但它是并发编程的必修课后面所有IM服务端都逃不开“并发安全”这四个字。conn.sendall如果遇到连接断开Python会抛异常。这里我只是简单跳过生产环境里需要精准地做离线处理。2.3 为什么说这张在线表是整个服务端的灵魂留意上面的代码clients这个字典看起来不起眼但它是IM服务端区别于普通Web服务端的分水岭。普通的HTTP服务端是无状态的请求来了处理完就拉倒不需要记住客户端。IM服务端却必须“记人”——谁在线、他的连接对象是哪一个、他什么时候发的消息、发给谁。后面的路由、多人群聊、离线消息、单点登录全都是在“在线客户端表”周围加逻辑。你可以这样理解这张表是房子的地基群聊是在地基上加了房间概念单聊加了目标地址概念离线消息是给表里不存在的用户加了个暂存区。地基不牢上面全是空中楼阁。所以当有人问“IM服务端到底难在哪”的时候我的回答永远是难在要把“连接”当作一种需要精细管理的长期资源。TCP本身只管“通没通”不管“你是谁”“你发给谁”。这两个问题从TCP层面到了IM应用层之后全得由服务端自己定义和解决。3. 客户端实现连接、心跳、重连一个都不能少3.1 客户端的职责从来不只是“发消息”服务端搞定了“连接管理”和“消息转发”不代表客户端就很轻松。相反客户端在IM体系里的职责一点都不比服务端少。它至少要干这么几件事主动建立连接、维护连接状态、按协议编码消息、解码收到的消息、处理异常断开、必要的时候自动重连。如果你做的是移动端IM还得处理网络切换、前后台切换这是另一套复杂的东西。最朴素版本的客户端任务就是“连接服务端”和“收发消息”但即便是这样也藏着不少坑。先说连接。服务端监听在某个端口上客户端要用正确的IP和端口去连它。这一步对应到代码里就是socket连接几行就能搞定。但真正的麻烦从连接建立之后才开始这条连接不会一直稳定路由器重启、WiFi切换、服务器空闲超时任何一个环节都能让连接悄悄断掉。而断掉的时候客户端这边如果不知道代码还傻乎乎地往这个断裂的管道里写数据就会出现数据丢失。3.2 心跳机制给连接上一条保险绳怎么判断连接还活着最笨的办法是“发消息的时候发现发不出去再处理”但那时候消息已经丢了。正规做法是加心跳——客户端每隔一段时间一般是30秒到60秒给服务端发一个很小的包服务端收到之后回一个确认或者服务端主动给客户端发心跳包。只要心跳还在走就说明链路没问题一旦连续几次心跳都没回应客户端就能断定连接断了然后走重连流程。加一个极简心跳逻辑的客户端长这样import socket import time import threading SERVER_ADDR (127.0.0.1, 8888) def send_heartbeat(sock): 每隔10秒发一个心跳包带tag区分消息类型 while True: time.sleep(10) try: sock.sendall(b[HEARTBEAT] ping) except Exception: print(心跳发送失败连接可能已断开) break def main(): sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(SERVER_ADDR) print(已连接到IM服务端) threading.Thread(targetsend_heartbeat, args(sock,), daemonTrue).start() try: while True: data sock.recv(1024) if not data: print(服务端关闭了连接) break print(f[服务端消息] {data.decode(utf-8)}) except Exception as e: print(f接收异常: {e}) finally: sock.close() if __name__ __main__: main()这个心跳包还非常粗糙——它和服务端业务消息混在一起没有考虑心跳超时次数、没有在断线后主动重连。但它的意义在于告诉你一个原则IM的客户端不能“被动等消息”它必须主动监测自己所处的网络状态。心跳周期怎么定太频繁了浪费流量和电量太稀疏了发现问题太慢。通常做法是心跳周期设成服务端空闲超时时间的一半左右这样能在服务端把连接踢掉之前把心跳送到。3.3 别让客户端成为一个只会聊天的小白接着上面说重连。重连这个动作看起来简单断开之后重新调用connect就行但实际上也有讲究。如果你的客户端断线后立刻无脑重连服务端万一正在重启客户端就会陷入“连不上-报错-重连-连不上”的循环里CPU和网络全都打满。稍微好一点的做法是“指数退避重连”第一次失败等1秒第二次等2秒第三次等4秒最多等到30秒或60秒封顶。这样既不会放过恢复机会也不会把自己搞死。再往深一层说真正的生产级客户端还会处理“历史消息拉取”“消息去重”“本地缓存”。但这些都属于锦上添花不是最基本的骨架。这一篇强调的就是“最基本”这三个字先让连接稳稳地建起来、保持住、坏了能自愈再谈别的。3.4 客户端开发工具的现实选择实际做客户端的时候很多人不会真用裸socket写。移动端有WebSocket、有MQTT协议库PC端有封装好的网络库Web端可能直接用WebSocket API。但不管用哪个网络库底层模型跟我上面写的基本逻辑是一模一样的建立连接、保活、收发、断开重连。所以不要觉得“我用现成框架就不用懂socket了”恰恰相反——框架只是帮你省了写网络字节约的时间连接状态管理、协议设计这些问题一样也省不掉。顺带一提排查客户端问题时大家常说的“我用xx可视化工具连一下服务端看看”本质上也还是充当一个特殊形态的客户端。比如你用Redis的可视化客户端去连Redis服务端用FTP客户端去连FTP服务端用数据库客户端工具去连数据库服务端背后全是同一套“服务端—客户端”模型。理解了IM的基本骨架再去看任何C/S架构软件会发现都是同一个套路自定义或固定协议、端口、鉴权、数据交换。4. 实操实录从收发到识别用户手把手跑通一个双端IM4.1 运行环境和启动顺序我把自己平时练手用的最小IM环境跑一遍全程没有任何第三方依赖装好Python就能跑。服务端和客户端都各自是一个文件我用两个终端窗口来跑。先说环境Python 3.8及以上操作系统Windows、Linux、macOS都无所谓socket是标准库跨平台表现一致。操作顺序上有个小坑要注意必须先启动服务端再启动客户端。否则客户端connect会直接报“目标计算机拒绝连接”。听上去像废话但真有人反过来跑然后一脸茫然。# 终端1启动服务端 python im_server.py # 终端2启动客户端 python im_client.py启动服务端后控制台会输出“IM服务端监听在 0.0.0.0:8888”。这个地址里0.0.0.0的意思是“本机所有网卡地址都接受连接”即局域网里的其他机器也能通过你的局网IP连进来。如果只想本机调试改成127.0.0.1更安全。端口8888是我随便选的只要没被占用就行生产环境会选一个特定范围内的端口并做好防火墙放行。客户端连接成功之后你再启动第二个客户端实例三个终端窗口摆一起一个服务端两个客户端。此时在任意一个客户端里直接输入文字回车你会发现另一个客户端几乎同时收到了消息服务端终端里则记录了完整的转发日志。这个效果虽然简陋但IM最核心的体验——消息中转和实时到达——已经完全实现了。4.2 给客户端编个号从广播走向点对点上面的demo离真正可用的IM还差一步它只会“广播”所有人都能收到同一个人的消息。真正的IM必须有用户身份和路由逻辑我要把消息只发给指定的那个人而不是发给所有人。这一步的改动逻辑上是质的飞跃实现上却没那么复杂。最简单的方式是让客户端连接后先发一个“握手包”告诉服务端“我是谁”。服务端把client_id和连接对象绑定起来。等到发消息的时候消息里带上接收方的ID服务端不广播只从在线表里找到对应连接然后只发给那一个连接。握手包的数据格式我用最简单的文本协议来定LOGIN|zhangsan消息格式对应定成MSG|zhangsan|lisi|你好晚上一起吃饭吗这里我把每条消息都拆成了用竖线分隔的字段第一个字段是消息类型后面依次是发送方、接收方、内容。这是最原始的IM协议雏形。别看它简陋很多现成的IM私有协议本质上也就是这套东西只不过字段更丰富、编码更紧凑、加了加密和二进制序列化。协议设计的核心就一句话让通信双方对“一段字节流到底表达什么含义”达成完全一致的约定。4.3 验证收发的调试现场我自己在跑通这个流程的时候非常喜欢用一个调试技巧在服务端的broadcast或转发逻辑里把原始消息一字不差地打出来。这样能直观看到客户端发来的是不是完整的协议数据也方便揪出编码问题。比如有次我故意用Windows记事本存了UTF-8带BOM的Python文件结果客户端发出去的中文消息在服务端解码后前面多了\ufeff字符整个人被带偏了好久。这种“看不见的字符”问题在没有抓包工具辅助的阶段只能靠打印原始字节来定位。如果这时候你手头有Wireshark或者Fiddler也可以直接抓TCP包看。但我更推荐先靠日志把应用层协议调通再去碰底层抓包因为一次抓包会看到大量TCP确认包、窗口更新包新手很容易迷失在乱七八糟的底层信息里。4.4 遇到问题怎么排查一张速查表我把自己在跑这类练手项目时最常遇到的问题整理成表格这些经验不止适用于这个demo几乎适用于所有C/S架构联调现象大概率原因处理手段客户端connect报“目标机器拒绝连接”服务端没启动或者端口不一致确认服务端监听端口telnet测试连通性一端发消息另一端收不到服务端转发逻辑写错或消息格式不对看服务端日志确认是否收到了这条消息中文显示乱码编码不一致常见UTF-8和GBK混用两端统一用UTF-8编码客户端关掉后服务端还显示在线没有走fin流程服务端没检测到断开在recv返回空数据时清理在线表发送频率稍高就会丢消息处理速度跟不上或recv缓冲区太小加大缓冲区或用独立线程收发电信级别的高级问题先不在这篇提后面我会单独讲高并发IM那篇再系统地谈抗抖动、限流、消息顺序保证这些。5. 从最基本的服务端和客户端到高并发IM差的到底是什么5.1 单机模型的瓶颈藏在哪上面这套代码在几个连接的情况下跑得飞快那是不是加个几千个连接也能一样跑很遗憾不是。问题不在逻辑而在资源管理方式。先看现在的多线程模型每个客户端连接来了我们开一个线程。线程本身消耗内存默认栈大小通常1MB起步8MB也常见3000个连接就意味着几千个线程操作系统光是切换上下文就能把CPU拖垮。这是大的方向问题线程不是不能用的方案但绝对撑不起高并发。再看收发阻塞问题。recv是阻塞的一个线程只能处理一个连接这是当前模型的天花板。生产环境的IM服务端几乎清一色走事件驱动模型靠IO多路复用select、poll、epoll、kqueue让一个线程管几千上万个连接。Python里你可以用asyncioC语言里直接铺epollJava里Netty封装了一整套Go则靠着goroutine把并发的编程门槛降了一大截。方向一旦转过来高并发的路才算真正起步。5.2 数据落地离线消息和聊天记录来了最基本的IM可以纯内存跑消息转完就丢弃。但真实产品不允许这样。用户下线之后别人发的消息不能直接扔掉得先存起来等用户上线再拉给他。这就引出了离线消息存储。更进一步的聊天记录同步也需要把消息持久化到数据库里。这里我想给你一个很好的实操视角服务端里从内存里的一句clients[target].sendall(msg)变成“先查库、再判断在不在线、不在线就写离线表、在线就直接push”整个复杂度直接翻倍。遇到消息内容和状态更新的原子性、数据库读写性能、分库分表这些问题你已经完全脱离“最基本的IM”这个阶段了。这一篇先把内存版跑通后续我会单独梳理离线消息的实现方案。5.3 别人口中的“高并发IM”到底是什么在并发很多招聘帖上写着“高并发IM开发经验”别被这几个字吓住。拆开来看所谓高并发IM其实就是几个具体问题的组合连接数是否能支撑几十万级、消息吞吐是否够快、消息延迟是否足够低、极端情况全员在线抢红包下服务端会不会雪崩。这四个问题层层递进而解决它们的前提就是你先把最基本的服务端客户端模型揉碎了弄明白。地基是同一块地基楼怎么盖是后面的事。我在实际带人的时候发现能把这一篇里所有代码和概念都吃透的人后面看Netty、看IM云服务商文档、看各种框架源码速度会快很多。因为它们的底层词汇是相通的长连接、心跳、协议、路由、在线状态。6. 最后聊点实在经验做了这么久IM相关开发我个人的器重体会是IM看着神秘真正动手拆掉那层概念外壳之后剩下来的东西就是“服务端维护连接、客户端维护状态”这两件事。所谓亿级用户的大厂IM复杂在分布式协同、协议兼容、消息可靠性上但本质上仍然是从最基本的服务端、客户端骨架生长出来的。你把socket、长连接、心跳、消息格式这四个关键词吃透了后面看任何IM系统心里都会有一张地图。还有个小建议练手的时候不要一开始就套用WebSocket、Netty这些重量级的东西。先像我上面这样用裸socket把流程跑通你会被迫理解每一个字节的来龙去脉。等跑通了再换框架你会发现自己能秒懂框架里每一个抽象都是为了解决你刚刚亲手踩过的那些坑。这一步几乎没有人会替你省掉。至于下一步我建议你可以试试给这个demo加上emoji表情的发送、文件传输、多人群组或者把服务端改成异步模型。每一步改造都会逼你去解决一个新的真实问题而这些问题全都是“服务端、客户端”这两个角色下面最实在的功课。