
这篇文章接着之前那篇《设计C/S架构的IM通信软件》往下写重点把拖垮很多新项目的长连接、消息协议、可靠性这三块讲透。C/S架构的IM通信软件服务端不是把数据塞给客户端就完事而是要面对几十万条在线连接的同时还要保证消息不丢不重、可追溯、可运营。到了第二篇我默认你已经把总体架构想清楚了所以内容会偏向“怎么落地”而不是停留在画架构图阶段。如果到现在你还在纠结“用 HTTP 轮询能不能实现 IM”我的建议是赶紧转头去看需求消息延迟可不可以到几秒客户端能不能长时间挂着并发用户是不是过千只要有一个答案是“要求高”老老实实上长连接。这篇就是围绕长连接这条主线把网关、协议、消息可靠性、离线消息、后台运营逐层拆开讲。1. 长连接选型与双端接入取舍别让协议拖垮整个项目1.1 轮询方案为什么走不通IM 的核心体验是“别人一发消息你马上能收到”。如果靠客户端每 3 秒轮询一次技术上不是完全不行小规模熟人聊天工具勉强能跑。可一旦人数上来每次 HTTP 请求都要带鉴权、建连、断连服务端 QPS 会瞬间涨到吓人数据库压力也会跟着上去。更要命的是轮询做不到真正的“即时”。就算你缩短到 1 秒一次服务器那一条新消息最多也只能延迟 1 秒网络抖动一次就会到 3 秒、5 秒。用户体验上就是“转圈”“消息来得忽快忽慢”这在 IM 场景里是致命的。换成长连接之后客户端和服务器之间维持一条双向通道服务器有消息可以直接推过来不需要客户端反复询问。这个模型才是 C/S 架构 IM 通信软件的地基后面所有消息可靠性、离线补拉、在线状态都是围绕这条通道展开的。1.2 WebSocket 与私有 TCP 协议的真实取舍很多人一听“长连接”就想到自己封装一个 TCP 私有协议并且喜欢自定义包头、包体、加密、压缩。这当然能做但我建议先冷静看一下自己的客户端形态。如果客户端是纯网页、小程序、或者需要快速迭代的跨平台桌面端WebSocket 是最稳的选择。浏览器原生支持 WebSocket小程序也支持类似能力不需要你去处理 Nginx 对 TCP 的代理不需要自己想办法过防火墙绝大多数网络环境都允许 443 端口的 WebSocket 通行。但如果你做的是对流量极其敏感的原生客户端而且团队擅长 C/C、Rust、Go能接受服务端直接暴露自定义 TCP 端口那私有 TCP 协议的优势也很明显协议头可以做到非常小封包结构完全可控方便做二进制加密能省掉不少 JSON 解析开销。我把几个维度放在一张表里方便按自己的项目情况选对比维度WebSocket JSONWebSocket Protobuf私有 TCP 协议开发效率高浏览器原生支持中需要生成客户端代码低所有端都要自己维护协议调试难度低浏览器开发者工具直接看帧内容中需要手动解析二进制高必须写抓包解析工具兼容性好能过大多数 Nginx、防火墙好差容易被运营商或防火墙干扰流量控制一般JSON 冗余较大可控最可控团队要求前后端都能写后端熟悉 IDL全链路都要有协议经验我自己的经验是除非业务明确有 IoT 设备、长连接海量推送、或者对每分钟流量成本极度敏感否则第一版不要上私有 TCP 协议。先把业务跑通再把协议层抽象好以后要换无非是替换一个 Transport 实现而“协议信封”如果不一开始统一后面想换都换不动。1.3 技术栈落地Go 网关 Vue 客户端回到这套 C/S 架构 IM 通信软件上我采用的服务端主语言是 Go客户端先用 Vue 做 Web 版。选 Go 不是因为“热词都在提 Go”而是长连接服务对并发模型的要求和 Go 的 goroutine 模型天然匹配。每一个 WebSocket 连接可以只用一个 goroutine 去读另一个 goroutine 去写配合 channel 做消息分发代码写起来比 Java 的 NIO 框架直观很多。Vue 这边则负责渲染聊天界面、维护本地消息缓存、启动 WebSocket、发起重连。前端并不是一个纯展示层它要管消息队列、幂等重试、已读上报必须认真设计不能粗暴地“后端给什么前端渲染什么”。2. 消息协议信封架构里最不值钱但最容易忽略的设计2.1 一套能兼容所有消息类型的信令格式IM 里除了聊天消息还有登录、心跳、已读回执、正在输入、消息撤回、好友状态变更。如果每种消息都单独定义一套字段服务端的解析代码会越写越乱。所以第一步不是给某条聊天消息设计协议而是把所有信令封装到一个“信封”里。比如 WebSocket 上传输的 JSON 消息我倾向于固定这种格式{ v: 1, seq: a1b2c3d4-1234-5678-9abc-ef0123456789, cmd: chat.send, ts: 1710000000000, token: eyJhbGciOi..., body: { convId: user:1002, msgType: text, content: 你好 } }说明一下字段意义v协议版本号。将来协议字段变化时服务端可以根据版本号决定兼容策略。seq客户端生成的全局唯一消息序号主要用于去重和超时重试。cmd指令名告诉服务端这条消息想干什么。ts毫秒时间戳方便追问题和分析延迟。token鉴权信息只在登录或者需要鉴权的指令中携带。body业务数据服务端只根据cmd解析对应的 body 结构。这个信封的好处是新增一种消息类型时不用改收发框架只需要新增一个cmd和对应的 body 结构。信封的收发逻辑在所有客户端里都是通用的调试的时候也能直接抓包看到完整协议内容。2.2 指令与业务事件的分工还需要区分“指令”和“事件”。指令是客户端主动发起的请求比如auth、chat.send、msg.sync事件是服务端主动推送给客户端的消息比如msg.push、status.notify。明确这个分工是为了防止双向通信变成一团乱麻。以发送消息为例客户端发送的 cmd 是chat.send服务端返回的 ack 是chat.send_ack但服务器给接收方推送新消息时发送方不在这个链路里接收方收到的应是一个服务端事件msg.push。如果接收方在多个端在线还需要考虑多端同步而不是简单给某个连接回一条响应。我建议所有指令表统一维护到一个协议文档或枚举文件里哪怕是小型项目也要有。协议如果没有“官方文档”业务做一半基本就乱套。2.3 为什么 seq 和版本必须进信封绝大多数第一次做 IM 的人都会问seq 不是数据库里生成的吗为什么客户端要传原因是网络会重试。客户端发出一消息后如果没收到服务端 ack超时后会自动重发但服务端可能其实已经收到了只是 ack 在网络中丢失。如果没有客户端 seq服务端没办法判断这两条一模一样的内容到底是不是同一条消息于是就会出现重复消息。客户端生成的 seq 就是“幂等键”服务端收到相同 seq 时可以直接返回上次的 ack不再重复写入。v协议版本也很容易被忽略。客户端版本迭代很快老客户端可能好几年都还在线上运行如果服务端某天新增字段后老客户端不兼容那就要靠协议版本号做灰度或降级。不要相信“我们团队不存在老版本”这种话线上总会给你惊喜。3. 网关连接生命周期心跳保活与断线重连的硬骨头3.1 服务端判定“死连接”的机制长连接服务最怕的不是连接太多而是连接处于“半开状态”客户端已经断网但 TCP 连接没有被双方感知服务端以为对方还在线实际上消息已经推不过去了。常见的半开状态是 WiFi 切换、手机锁屏、笔记本休眠后恢复。这时候 TCP 层面没有 FIN 包服务端不会立刻知道连接断了如果不做任何处理这条死连接会一直占着文件描述符和内存。处理方案是在应用层做心跳。服务端给每个连接维护一个 lastReadTime如果超过一定时间没收到客户端任何数据包就主动断开。具体阈值没有绝对标准我用过的组合是客户端每 30 秒发一次心跳。服务端如果 75 秒内没有收到任何数据就判定这个连接不可用。服务端主动发送 ping 帧探测客户端必须自动回 pong但没有业务心跳可不行因为浏览器 WebSocket 的 pong 是协议层自动回的不回证明不了业务层还活着。时间间隔不能设得太短否则移动端电量消耗会很难看也不能设得太长否则用户已经断网了服务端还要等很久才把连接释放掉。3.2 客户端重连策略WebSocket 断开后并不是无脑重连。根据断开原因决定重连策略是非常基础但经常被忽略的细节如果是主动登出或服务端主动要求客户端关闭代码收到的关闭码是 1000这时候绝对不能自动重连否则用户刚点退出登录又被拉回来。如果是网络异常导致的断开关闭码一般是 1006这时需要自动重连。如果是服务端鉴权过期重连多少次都没用应当先刷新 token 或重新登录。重连间隔要采用指数退避不能写成固定 5 秒。我的通常设置是第一次 1 秒第二次 2 秒第三次 4 秒最大不超过 60 秒同时加少量随机抖动避免大量客户端同时重连把服务端打挂。客户端重连成功之后还必须补偿重连期间的离线消息。WebSocket 本身只负责传输它不保证重连之后能把“断开那几分钟的消息”补回来。所以客户端的逻辑应该是先建立连接、再鉴权、然后做一次增量消息同步。如果忽略这一步用户断网几分钟后重连看到的消息会少一段。3.3 上线前容易踩的网关配置坑第一个坑是 Nginx 代理超时时间太短。默认的 proxy_read_timeout 是 60 秒如果 WebSocket 连接 60 秒内没有数据通信Nginx 就会主动断开。很多项目是开发环境直连后端没问题一上生产环境就频繁断线查了半天发现是 Nginx 在中间“截胡”。location /ws { proxy_pass http://im_gateway; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 75s; proxy_send_timeout 75s; }默认心跳间隔 30 秒Nginx 超时设 75 秒是比较稳的组合。第二个坑是服务端发心跳时没有带上业务数据导致 Nginx 认为连接一直在“收但不回”这种情况需要在 Nginx 超时参数和业务心跳之间保持一致性。第三个坑是上线早期出现过服务器内存溢出原因是维护了一个巨大的在线用户 Map却只加不删。每个连接关闭时要记得删掉连接映射、频道订阅、待发送队列这些资源不清理最多跑几天服务就撑不住了。排查这类问题不能只盯着内存指标要配合连接总数、goroutine 总数和堆内存快照一起看。4. 消息可靠性从发送到送达要跨过的四道确认关卡4.1 发送链路和容易误解的“已送达”IM 的服务端并不只是转发消息它更像“邮局”。消息发出去之后接收方可能在线、可能离线、可能在一台设备上登录后切到另一台服务端必须对每种情况都有明确处理逻辑。完整的高层链路如下发送方客户端生成消息并把消息存到本地“发送中”列表。WebSocket 将消息上行到网关网关做基础校验。网关把消息投递给业务逻辑层业务逻辑层决定是持久化还是推送。消息先写入历史存储确保哪怕接收方不在线消息也不丢。服务端回给发送方一个 ack告诉它“我已经收下了”。接收方如果在线服务端直接通过长连接推送消息。接收方客户端收到消息后本地落盘并回一个客户端接收 ack。接收方真正读消息时再发已读回执。很多人把“服务端收到”和“接收方收到”混在一起导致 UI 上乱显示状态。比如发送方消息旁边有个小圆圈转圈服务端其实已经入库了只是因为客户端没收到 ack 就一直转。更合理的设计是把状态拆成发送中、服务端已接收、接收方已送达、接收方已读。后端 ack 只表示“服务端确认收到”绝不能当作对方已读。4.2 服务端 ACK、客户端 ACK、已读回执怎么区分这套系统里有三种容易混淆的回执服务端 ACK服务端成功入库后返回客户端防止客户端无脑重发。客户端 ACK接收方客户端成功把消息写入本地存储后返回服务端用于告诉服务端“这条消息已经安全到达这个设备了”。已读回执用户在界面上真正看到并打开这条会话后上报用于在发送方界面显示“已读”。通信协议里至少要分别定义对应的 cmd。千万不要图省事用一个 ack 包干所有事否则后面做多端同步时你会发现已读状态和送达状态全是脏数据。接收方的客户端 ack 可以带消息 id 批量上报。比如一次收到 50 条消息客户端全部落库后再一次性发一个msg.ack里面是一个消息 id 数组。这样能省非常多的小包请求也减轻服务端的写压力。4.3 重复消息重试机制下的幂等设计网络重试必然导致重复消息所以服务端必须做幂等。单聊场景相对简单可以用发送方客户端 seq 做唯一键。数据库专门加一个唯一索引alter table im_message add unique index uk_sender_seq(sender_uid, client_msg_id);服务端处理消息的伪逻辑是func handleChatSend(msg *Message) error { // 尝试插入如果出现唯一键冲突说明之前已经收过 // 直接返回旧 ack避免重复入库 err : insertMessage(msg) if IsDuplicateError(err) { return ReplyOldAck(msg.Seq) } return ReplyAck(msg.Seq) }不管客户端因为什么原因重复发送服务端只会保留一份后面的消息推送也不会执行两次。群聊场景的幂等会更复杂因为一条群消息可能要写多个成员的收件箱这时需要在“消息主表”和“群成员收件箱表”之间保证幂等。实现上一般用消息表先入库生成全局 msgId再向成员收件箱表投递投递表上做唯一索引如(group_id, msg_id, member_uid)。如果成员重复收到投递任务也能安全跳过。5. 离线消息与历史记录服务端宕机也不能丢消息5.1 数据库表设计的拆分思路很多 IM 项目只顾着写“在线时消息能通”一让设计离线消息就只会加一张离线表存着存着就变成一张巨大的、永远不会清理的表。我推荐把离线与历史存储拆成两个语义不同的部分消息内容表保存完整消息比如会话 ID、发送者、消息类型、消息内容、发送时间。这张表是历史记录的最终来源查询时按会话维度分页。消息游标表保存每个用户在每个会话里已经读到的位置。用户上线后不需要把全量历史拉一遍只需要按游标增量拉取。如果是单聊场景最简单的消息内容表可以长这样字段说明msg_id全局递增消息 ID也是排序依据conv_id会话 IDsender_uid发送者target_uid接收者content消息内容status消息状态create_time消息发生时间接入层查询时尽量用msg_id做游标不要用create_time。因为时间戳在分布式环境下可能有误差而消息 ID 只要设计成单调递增就能保证拉取顺序稳定。5.2 Redis 离线队列只做“提醒层”有些教程会告诉你“离线消息直接放 Redis 里用户上线再从 Redis 拿”这个设计能跑但千万不要把 Redis 当成唯一存储。Redis 是内存型存储如果节点重启或数据过期用户离线期间的消息就会永久丢失。我会把 Redis 离线队列当作“未读数 提醒层”来用。用户在离线状态下收到消息时MySQL 里已经落库Redis 里只记录“你有一个新消息”这种摘要或未读数量。用户上线时先读 Redis 拿到未读提醒再根据游标去 MySQL 拉真正的消息内容。这样即使 Redis 数据抖动或丢失也不会丢消息最多是未读角标要重新计算一次。5.3 按序拉取为什么不能让客户端直接按时间查很多客户端开发习惯用“最后一条消息的时间”作为翻页条件。这有个隐患如果两条消息出现在同一毫秒时间排序就会不稳定。更好的做法是让客户端记住自己已经拉到的最大全局消息 ID然后每次增量同步时带上这个 ID服务端返回大于这个 ID 的消息。同步协议可以设计成这样{ cmd: msg.sync, seq: 客户端生成的同步请求序号, body: { cursor: 1024, size: 100 } }服务端返回nextCursor客户端再把游标存到本地下次同步时更新游标。这样即使中间断网重连也不会因为时间计算问题漏消息或重复拉取。为什么不能让客户端直接按时间查因为客户端本地时间和服务器时间可能不一致跨天、跨时区、手机时间被用户自己改了都会导致定位错乱。消息 ID 作为游标不依赖本地时钟抗干扰能力强得多。界面上“按时间显示”那是展示层的处理不代表底层拉取也一定要用时间字段。6. IM 后台系统从懵懂到成熟中间差一个“运营视角”6.1 后台系统至少要有三个看板“IM 后台系统”听起来像辅助功能其实它比聊天界面本身还重要。没有后台系统线上出了问题只能靠登数据库盲查想看在线人数变化也没地方看。我自己在新项目启动的时候会把后台系统和核心链路一起规划而不是等上线再说。第一个看板是实时监控。既要看网关层指标也要看业务层指标。例当前在线连接数、连接地域分布。WebSocket 每秒的新建连接数、关闭连接数。消息上行成功率、推送成功率、平均推送耗时。最近 1 小时发送量 TOP10 会话。第二个看板是用户与状态查询。运营同学需要能查某个账号是否在线、最近在哪个 IP 登录、最近一次心跳是什么时候、当前连接在哪个网关节点上。第三个看板是消息内容检索。要给客服或者用户支持提供一个按用户、按会话、按关键词搜索消息的入口方便定位用户反馈的上下文。6.2 管理端接口设计与数据权限后台系统不能直接让前端操作 MySQL而是通过独立的 HTTP Admin API 访问底层服务。这样能统一做权限校验、操作审计和限流。建议后台接口单独走一套internal路径不暴露在客户端网关的公共域名下。内部接口至少要区分查看类权限比如查看用户信息、查看消息记录。操作类权限比如强制下线、撤回消息、封禁用户、解除封禁。每次操作都要写审计日志。Post 到日志里的字段至少包括操作人、操作时间、目标用户、操作原因、请求参数。一旦发生误操作能快速定位是谁做的、为什么做。对于类似“消息同步、消息回传”这种能力如果是自己给自己做后台不要做成一键全量导出的“数据仓库”。它主要解决日常问题不是处理离线分析任务。6.3 值班场景下真正有用的小功能后台系统做了之后真正要看的是值班体验。一个 IM 系统上线后线上总会有用户反馈“我发消息对方收不到”。如果后台只能看到用户在线不在线排障效率会很低。我给你一个判断链路是否正常的小功能设计在用户详情页显示“当前连接状态”和“最近心跳时间”。点击“连接详情”可以看到这个用户当前连接的网关 IP、连接建立时间、最近 15 分钟的消息收发数量。如果用户在线但收不到消息可以在这个页面上输入一条测试消息推给该连接。如果推送失败后台会展示具体错误位置比如用户不在线、连接在半开状态、消息命令不识别。这个功能不复杂但排障时价值非常大。它比让你在生产环境一台台翻日志快得多。我个人带项目时会要求后台系统在 Beta 版本之前跑起来。这个系统不需要一开始就很完整但“在线用户监控”“按用户查消息”“强制下线”这三个功能必须尽早做。因为灰度测试阶段肯定会出现奇奇怪怪的连接异常如果连着个能看的后台都没有光靠客户端日志和服务端日志两边对比会非常痛苦。说到底C/S 架构的 IM 通信软件做到第二篇真正进入攻坚区了前面需求分析做得再漂亮协议设计不统一、长连接保活不过关、消息可靠性没兜底最后都会被线上问题打回原形。这篇里的协议信封、心跳阈值、重连策略、幂等设计、离线游标、后台看板我基本是按照“能直接落到自己项目里”的标准去说的。如果大家做到某一步发现和实际场景有冲突建议先记下差异点再看看是哪一层抽象不一致共同绕开的问题多了架构就会越来越清晰。