资讯动态

分布式IM系统全链路实战:长连接、消息队列与MySQL存储设计

发布时间:2026/10/27 19:28:11 来源:尧图企业网站定制
分布式即时聊天系统这个选题在毕业设计的题目库里出现频率高得离谱但真正能把 springcloud、Netty、MQ、MySQL 这四个东西串成一条完整链路跑通的十份里未必有一份。我前后帮人看过几套这类项目绝大多数情况是这样的Netty 那边写了个 Echo 服务器Spring Cloud 起了三个空壳服务MQ 装上之后只发过一条 Hello WorldMySQL 里就两张表最后答辩演示的时候开两个浏览器窗口互相发消息——这其实连分布式的边都没摸到。这篇东西就是想把这条路彻底走一遍。我会从架构分层的取舍讲起一路拆到协议字节布局、粘包处理参数、MQ 分区键怎么选、MySQL 表怎么建索引、压测时单机能扛多少连接最后附一份我自己整理的问题排查速查表。适合正在做同类毕设的同学也适合刚进公司要接手 IM 模块的初级开发。文中不会出现任何真实机构名案例统一用某同学模拟项目 A这类代称所有参数都来自实际调过的经验值可以直接抄。1. 架构不是画着好看分层、选型与那些被忽略的取舍1.1 为什么不能拿一个 WebSocket 单体扛完全部很多人第一反应是 Spring Boot 加个ServerEndpoint就完事了本地跑起来确实能通。问题在于一旦你把分布式三个字写进标题面试官或者答辩老师问的第一个问题必然是两台服务器上各跑一个实例用户 A 连在实例一用户 B 连在实例二消息怎么从 A 到 B这一问就露馅了。单体 WebSocket 的连接对象存在单个 JVM 的ConcurrentHashMap里跨进程根本拿不到对方的Channel。要解决它只有两条路一是把消息广播到所有节点让每个节点自己判断目标用户是不是连在自己身上二是维护一张全局的用户—节点路由表精准投递。第一条路在节点数少的时候看起来能用节点一多就是广播风暴而且每条消息都要被 N 个节点反序列化一遍纯属浪费。第二条路才是正经做法也就是要引入一个中心化的在线状态存储通常是 Redis加上一个能把消息送到指定节点的中间件也就是题目里的 MQ。这两样东西一进来分布式才真正立住了。所以整个架构的第一性原理其实就一句话连接是有状态的、绑在具体机器上的而服务是无状态的、可以随便扩容的。这两者之间的矛盾必须靠一个中间层来弥合。1.2 五层结构拆解与每一层的职责边界把职责切清楚比画多漂亮的架构图都重要。我习惯按下面这五层来分层级承担角色核心组件不该干的事接入层统一入口、鉴权、限流网关不该持有长连接连接层维持 TCP/WS 长连接、收发消息Netty 节点不该做复杂业务逻辑业务层会话管理、好友关系、群组Spring Cloud 微服务不该直接操作 Channel消息层异步解耦、跨节点投递、削峰消息队列不该做持久化唯一真相存储层消息持久化、离线消息、位点MySQL Redis不该存高频变更的临时态这张表最关键的一条是连接层不该做复杂业务逻辑。我见过一个实现把好友校验、敏感词过滤、积分计算全塞在 Netty 的channelRead里结果一条消息处理耗时 200 毫秒单机吞吐直接掉到个位数。Netty 的 IO 线程是宝贝一个 EventLoop 要服务成百上千个连接你在里面做任何阻塞操作都是在谋财害命。正确的做法是IO 线程只负责解码、做轻量校验比如 token 是否过期、包体是否超长然后把消息任务丢进业务线程池。业务线程池的拒绝策略一定要设成CallerRunsPolicy或者干脆自定义丢弃并回复错误码绝不能用默认的AbortPolicy把异常吞掉——那样客户端会一直等一个永远不会来的响应。1.3 四个技术栈各自的不可替代性Netty的价值不在于能写 TCP而在于它把 NIO 的复杂度封掉了。原生 NIO 的Selector空轮询 bug、半包处理、写操作的部分写问题每一个都能让新手卡三天。Netty 的ByteBuf读写双指针设计、ChannelPipeline责任链、引用计数内存管理是这套系统能撑住几千连接的基础。Spring Cloud在这套系统里的定位要摆正。它不是用来处理消息的而是用来管理服务的。注册中心解决 Netty 节点扩缩容后地址变化的问题配置中心解决几十个参数统一下发的问题网关解决 HTTP 侧的统一鉴权和限流。它的价值是运维层面的不是功能层面的。MQ的核心作用是解耦和削峰。一条群聊消息发给 500 人群如果同步写 500 次库这条链路必然超时。正确姿势是发一条消息到 MQ由消费者异步做持久化和推送。另外跨节点投递也靠它——MQ 天然支持多消费者、多分区正好对应多个 Netty 节点。MySQL是消息的最终真相。Redis 里的在线状态、未读数都可以丢丢了重建就行但聊天记录丢了就是事故。所以所有涉及不可恢复的数据必须落 MySQL而且要保证落库成功之后才给客户端回 ACK。2. Netty 长连接层协议、编解码和连接治理2.1 私有协议设计为什么不能拿 JSON 裸传新手最容易犯的错是客户端和服务端约定发一个 JSON 字符串以换行符结尾。本地测试没问题一上并发就开始崩。原因有两层一是 JSON 本身没有长度信息服务端必须一直读到分隔符才知道包结束一旦对方恶意发一个 10MB 的没有换行的字符串服务端内存就爆了二是 JSON 的解析开销在万级 QPS 下非常可观没必要浪费。正经做法是自定义二进制协议头。我给模拟项目 A 设计的格式是这样的---------------------------------------------------------------- | 魔数 4B | 版本 1B | 序列化 1B | 指令 1B | 消息长度 4B | 消息体 N B | ----------------------------------------------------------------各字段含义字段字节数说明magic4固定 0xCAFEBABE快速识别非法连接version1协议版本为后续升级留口子serializer10JSON1Protobuf2Java 原生command1心跳、登录、单聊、群聊、ACK、踢下线length4消息体的字节长度不含协议头bodyN序列化后的业务数据魔数的作用被严重低估。有了它任何非本协议的请求比如有人拿浏览器直接访问 TCP 端口、或者健康检查探针误连在第一个字节就能被拒掉不会进到后面的解码器里浪费 CPU。版本号则是给未来留的余地——毕设答辩时老师很喜欢问如果协议要升级怎么办你能答出客户端带版本号服务端按版本分发到不同解码器就赢了。2.2 粘包拆包LengthFieldBasedFrameDecoder 的正确配法协议头定好之后粘包拆包的处理就变成一道填空题。Netty 提供了LengthFieldBasedFrameDecoder五个参数pipeline.addLast(new LengthFieldBasedFrameDecoder( 65535, // maxFrameLength单帧最大长度防内存攻击 7, // lengthFieldOffset长度字段的起始偏移 4, // lengthFieldLength长度字段本身占几字节 0, // lengthAdjustment长度修正值 0 // initialBytesToStrip解码后剥掉多少字节 ));参数怎么来的必须能自己推。魔数占 4 字节下标 0-3版本 1 字节下标 4序列化 1 字节下标 5指令 1 字节下标 6所以长度字段从第 7 个字节开始lengthFieldOffset 7。长度字段是 int 类型lengthFieldLength 4。lengthAdjustment算的是长度字段的值和它后面还剩多少字节之间的差值。我们让 length 只存消息体长度而整个帧长 11头部 length。Netty 的计算公式是frameLength lengthFieldValue lengthFieldOffset lengthFieldLength lengthAdjustment代入 11 length length 7 4 adjustment解得 adjustment 0。这个推导过程建议写进毕设文档答辩时能讲明白比背参数强得多。initialBytesToStrip我设成 0也就是保留完整帧自己解析。因为后面还要读 command 字段做路由剥掉了反而要重新拼。如果设成 11就意味着解码后直接拿到干净的 body适合只关心内容不关心指令的场景。注意maxFrameLength一定要设而且要设成你业务上真正需要的上限。设成 65535 意味着单个客户端最多能让你分配 64KB 的缓冲区一千个恶意连接就是 64MB。别设成 Integer.MAX_VALUE那是给攻击者递刀。2.3 心跳与空闲检测参数怎么定才不误杀长连接最怕的两种情况一是连接早就死了服务端还以为是好的一直往里写数据二是心跳太频繁白白消耗带宽和电量。服务端加IdleStateHandlerpipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));三个参数分别是读空闲、写空闲、读写空闲这里只关心多久没收到对端数据。设 60 秒的意思是连续 60 秒没收到这个连接的任何数据就触发IdleStateEvent我们收到事件后主动关连接并清理路由表。客户端则要设置 30 秒发一次心跳包。为什么服务端 60、客户端 30因为要留出 2:1 的冗余。网络抖动、GC 停顿、路由器排队都可能让一个包延迟几秒如果两边都设 60一次抖动就可能触发误判把好好的连接踢掉用户看到的就是消息发不出去要重连一下。2:1 的配比是实践经验误杀率能降到很低。另一个坑是运营商 NAT 的超时时间。公网环境下的 NAT 表项通常 3 到 5 分钟不活跃就会被回收如果你心跳设成 5 分钟刚好卡在边界上会出现看起来连着实际上发不出去的情况。所以心跳间隔不要超过 4 分钟对于移动端30 秒到 60 秒是常见区间还要配合系统的后台保活策略。心跳包本身也要尽量小。我给它单独设了一个 command 值body 为空整个包 11 字节。有同学非要在心跳里带上用户 ID、时间戳、设备信息包体搞到 200 字节一万连接下每秒的额外流量就上来了。断线重连的策略也有讲究不能固定间隔狂重连。我用的是指数退避加随机抖动第一次 1 秒第二次 2 秒第三次 4 秒封顶 30 秒然后每次乘以 0.8 到 1.2 之间的随机因子。抖动是为了避免服务端重启后几万个客户端在同一毫秒集体重连把服务打挂——这个场景叫惊群本地测不出来一上线就是事故。2.4 在线路由表用户在哪台机器上连接建立并鉴权成功后第一件事是写路由表。结构很简单Redis Key: im:route:{userId} Value: nodeId 过期时间: 90 秒心跳周期的 3 倍Value 只存节点标识不存具体的 IP 端口因为节点信息在注册中心里已经有了存两份容易不一致。过期时间必须设而且要靠心跳续期——这样即使节点异常宕机来不及清理路由信息也会在 90 秒后自动消失不会留下幽灵路由导致消息投递到不存在的节点。节点内部还要维护一份本地映射userId - Channel。这份映射用ConcurrentHashMap就够了但要注意两个细节一是同一个用户可能在多端登录手机 PCvalue 应该是Channel的集合而不是单个二是用户重连时旧连接可能还没断新连接进来要主动把旧的踢掉否则消息会重复推送。踢下线的实现不是简单调channel.close()而是先给旧连接发一条账号在其他设备登录的业务消息等一小段时间再关。否则用户看到的就是莫名其妙闪退没有任何提示。3. MQ 在这套系统里到底该干什么3.1 跨节点投递A 在节点一B 在节点三这是分布式的核心场景整个链路是这样的用户 A 发消息落到节点一节点一做基础校验会话是否存在、是否被拉黑、内容长度然后生成全局唯一的消息 ID 和会话内序号节点一查 Redis得到用户 B 在节点三节点一把消息投递到 MQ消息体里带上目标节点标识或者用路由键把节点标识编码进去节点三消费到这条消息从本地映射里找到 B 的 Channel写出去。这里有个关键设计点消息是按节点分区还是按用户分区按节点分区的写法是 topic 只有一个消费组里每个节点起一个消费者用广播模式。问题是广播模式下每个节点都要收到全量消息再自己过滤节点多了就是浪费。按用户分区的写法是用userId做分区键同一个用户的消息固定落到同一个分区同一个节点订阅固定的一组分区。好处是顺序性天然得到保证而且不用广播。我更推荐后者但要注意分区数必须大于等于节点数否则会有节点分不到分区。而且节点扩缩容时要处理 rebalance——这是我们踩过的一个坑节点从 3 个扩到 4 个原来 3 个节点各自持有 1 个分区扩完有一个分区要迁移迁移期间那个分区上的用户消息会积压几秒。对于 IM 场景几秒的延迟勉强可接受但要在客户端做发送中的兜底 UI别让用户以为消息丢了。3.2 削峰与最终一致性先落库还是先投递这个问题的标准答案取决于你的业务能容忍什么。方案一先落库再投递。消息先写 MySQL写成功后再发 MQ 推送给接收方。好处是绝对不会丢坏处是写库的延迟通常 2 到 10 毫秒会直接叠加到发送链路上而且 MySQL 挂了整个发送就挂了。方案二先投递异步落库。消息先进 MQ消费者异步写库并推送。吞吐高但 MQ 本身成了可靠性的瓶颈需要开启持久化和同步刷盘否则 broker 挂了内存里的消息就没了。方案三先落库异步同时投递用本地消息表保证一致。这是折中方案。节点一收到消息后先写一条待确认记录到本地表然后发 MQ消费者写正式消息表并回 ACK节点一收到 ACK 后把本地记录标记完成。如果超时没收到 ACK定时任务重试。毕设项目一般选方案一就够了因为并发量不大写库延迟完全能接受而且逻辑最简单不容易出错。选方案一的实现要点是必须在写库成功后才给发送方回 ACK。这个顺序错了用户就会看到消息显示已发送但对方收不到也查不到历史这是最严重的一类 bug。群聊场景要特殊处理。500 人群如果每个成员都单独写一条消息记录那就是 500 次写正确做法是消息主表存一份每个成员的收件箱或已读位点单独记用位点模型替代复制模型。一个群每发一条消息只写一行主记录加一行群的 seq 递增成员读的时候按seq last_read_seq拉取。3.3 幂等与顺序两个最容易翻车的地方幂等。客户端因为超时重发或者 MQ 因为消费失败重投都会导致同一条消息被处理两次。解决办法是给每条消息一个客户端生成的clientMsgId通常是 UUID服务端做唯一索引。写库时如果撞了唯一键直接忽略并返回成功不要报错。这一层必须做在数据库层面不能靠 Redis 的setnx做前置判断——因为 Redis 和 MySQL 之间没有事务你 setnx 成功了但写库失败这条消息就永远丢了。顺序。同一个会话里的消息用户期望是严格有序的。但你的系统里可能有三条链路在并行客户端重发、MQ 多分区、多线程消费。任何一条都可能打乱顺序。我的做法是会话内的顺序号由服务端统一生成用Redis INCR im:seq:{conversationId}客户端拿到的是权威序号客户端渲染时按 seq 排序而不是按到达时间如果收到 seq 不连续的客户端缓存一下等一小段时间比如 200 毫秒补齐后再渲染超时就直接展示并标记可能有历史消息缺失。至于 MQ 分区一定要用会话 ID 或者接收方用户 ID 做分区键别用随机数否则同一个会话的消息被分到不同分区多消费者并行处理顺序必然乱。4. MySQL 存储设计库里到底该放什么4.1 核心表结构与索引怎么建先给结论四张核心表就够。会话表 conversation记录单聊和群聊的元信息。CREATE TABLE conversation ( id BIGINT NOT NULL COMMENT 会话ID雪花算法, type TINYINT NOT NULL COMMENT 1单聊 2群聊, owner_id BIGINT NOT NULL COMMENT 群主单聊填0, name VARCHAR(64) DEFAULT NULL, member_count INT NOT NULL DEFAULT 0, max_seq BIGINT NOT NULL DEFAULT 0 COMMENT 当前最大序号, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner (owner_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;消息表 message核心中的核心。CREATE TABLE message ( id BIGINT NOT NULL COMMENT 消息ID雪花算法全局唯一且趋势递增, conversation_id BIGINT NOT NULL, seq BIGINT NOT NULL COMMENT 会话内序号, sender_id BIGINT NOT NULL, msg_type TINYINT NOT NULL COMMENT 1文本 2图片 3文件, content TEXT NOT NULL, client_msg_id VARCHAR(64) NOT NULL COMMENT 客户端幂等ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1已撤回, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), UNIQUE KEY uk_conv_seq (conversation_id, seq), UNIQUE KEY uk_client_msg (sender_id, client_msg_id), KEY idx_conv_time (conversation_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个唯一索引是灵魂。uk_conv_seq保证会话内序号不重复同时天然支持范围查询——拉历史消息就是WHERE conversation_id ? AND seq ? ORDER BY seq DESC LIMIT 20走这个索引是覆盖式的非常快。uk_client_msg实现幂等范围是发送者维度而不是全局因为不同用户的 clientMsgId 理论上可能撞。主键用BIGINT的雪花 ID 而不是自增 ID原因有两个一是分库分表后自增会冲突二是雪花 ID 趋势递增插入时仍然是顺序追加不会像 UUID 那样造成 B 树页分裂。这一点在毕设文档里写一句是很加分的细节。created_at用DATETIME(3)而不是TIMESTAMP因为 TIMESTAMP 有 2038 年问题和时区转换问题聊天记录用毫秒精度的时间戳更合适。4.2 分表策略与冷热分离毕设数据量不大但能讲清楚扩容方案是答辩必问。我的建议是按conversation_id取模分 16 张表用 ShardingSphere 做中间件。为什么不按时间分因为聊天记录的主要查询模式是拉某个会话的最近 N 条按会话分表能做到单表查询按时间分的话每次都要扫所有时间片反而麻烦。为什么不按用户分因为群聊里一条消息属于多个用户按用户分就得复制多份存储成本翻倍。分表之后的坑跨表分页。你要查我参与的所有会话的未读消息这是跨表的聚合查询ShardingSphere 会归并结果性能很差。解决办法是维护一张user_conversation表记录每个用户在每个会话里的未读数列表页直接读这张表一秒钟就能出来。冷热分离的策略最近 30 天的消息在主表超过 30 天的归档到message_history表或者对象存储。归档任务用定时任务每天凌晨跑一次注意要按主键范围分批删比如每次删 5000 行别一条DELETE删几百万行会导致主从延迟和锁等待。4.3 离线消息与已读位点离线消息有两种经典模型选错了会很痛苦。模型一收件箱复制。用户不在线时给每个接收者写一条offline_message记录。优点是上线后直接查自己的收件箱简单直接缺点是群聊人数一多写放大严重500 人群一条消息写 500 行。模型二位点模型。不存离线消息本身只存每个用户在每个会话里的last_read_seq。上线时查我参与的所有会话对每个会话说把 seq 大于我的 last_read_seq 的消息给我。优点是零写放大缺点是要做多会话的批量查询。我推荐位点模型表结构CREATE TABLE user_conversation ( user_id BIGINT NOT NULL, conversation_id BIGINT NOT NULL, last_read_seq BIGINT NOT NULL DEFAULT 0, last_deliver_seq BIGINT NOT NULL DEFAULT 0 COMMENT 最后投递到的位置, unread_count INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, conversation_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;last_read_seq是用户真正点开看过的位置last_deliver_seq是消息已经推到他客户端的位置。两个分开的好处是未读数可以精确计数用户在多端登录时也能正确处理——手机看过了PC 上应该是已读状态。未读数的更新要用UPDATE ... SET unread_count unread_count 1这种原子操作别在应用层先查再改并发下必然算错。用户重新上线时的拉取流程先查user_conversation拿到所有会话的last_deliver_seq然后按会话批量查message表取seq last_deliver_seq的记录。如果是首次登录last_deliver_seq 0只拉每个会话最近 20 条做展示别把几年的历史全拉下来——这是很常见的性能事故。5. Spring Cloud 那层的职责边界与踩坑点5.1 网关该不该接管长连接这个问题争议很大我的结论是毕设阶段不要让网关接管长连接。技术上是可以的网关支持 WebSocket 路由代理配置大概是spring: cloud: gateway: routes: - id: ws-route uri: lb:ws://im-connect-service predicates: - Path/ws/**但这样做的代价是网关成了所有连接的必经之路一万个长连接全部压在网关上网关的内存和句柄消耗会比 Netty 节点大得多而且网关做一次转发就多一次内存拷贝和协议转换延迟增加更要命的是网关重启会切断所有连接而 Netty 节点重启只影响它自己那部分。更合理的做法是HTTP 接口走网关长连接直连。具体流程是客户端调/api/auth/login登录走网关网关做限流和签名校验登录服务返回 token并从注册中心查出当前负载最低的 Netty 节点地址一起返回客户端拿着 token 直连那个 Netty 节点的 TCP/WS 端口Netty 节点本地验 JWT用共享密钥不调远程服务校验通过就建立连接。这样网关只承担它擅长的事HTTP 的鉴权、限流、日志、灰度。长连接的压力被分散到多个 Netty 节点上谁也不用扛全部。如果老师非要问怎么让长连接也走统一入口答可以在网关前面再加一层四层负载均衡做 TCP 转发或者用服务网格的 sidecar 模式这样既回答了问题又显示了你知道边界在哪。5.2 注册发现与长连接节点的特殊处理服务注册这块Netty 节点要额外暴露一个健康检查接口让注册中心知道它还能不能接新连接。关键的坑在这里注册中心默认的心跳机制只能判断进程活着判断不了进程活着但连接数已经满了。我见过一次事故某个节点因为内存泄漏连接还在但处理能力已经归零注册中心还认为它健康新连接源源不断进来最后整个节点 OOM。解决办法是自定义健康检查逻辑节点定期上报自己的当前连接数、CPU、堆内存超过阈值就主动把自己标记为不健康或者直接从注册中心摘除。已经在线的连接不受影响只是不再分配新连接。这个设计叫优雅摘流是生产环境的标配。节点下线时的顺序也很重要先从注册中心摘除等待 30 秒让客户端通过重连机制迁移到其他节点再关闭监听端口最后关闭已有的连接。这个顺序反了用户就会看到集中掉线。5.3 限流熔断在 IM 场景下的正确姿势IM 的限流和普通 HTTP 服务不太一样要分几个维度维度阈值示例超限处理单用户发送频率20 条/10 秒返回发送太快客户端做本地节流单用户连接数5 个踢掉最早的连接单 IP 建连频率30 次/分钟直接拒绝防爆破单条消息大小32KB连接层直接断开全局 QPS按压测结果定网关层限流排队熔断主要用在下游依赖上。比如消息落库依赖 MySQL如果 MySQL 响应时间超过 2 秒熔断器打开这期间的消息直接进待补偿队列先给客户端回 ACK 说已收到后台慢慢补。这样用户不会感知到数据库抖动体验上是连贯的。要注意的是熔断不能用在消息投递链路上。消息投递失败就重试重试还失败就进死信队列人工处理不能因为下游挂了就直接丢弃——聊天记录丢了是用户无法接受的。6. 实操从零跑通的启动顺序、参数与压测记录6.1 本地环境准备与启动顺序依赖组件JDK 17、MySQL 8、Redis 7、RocketMQ 或 RabbitMQ 4.x、Nacos 2.x、Node 环境前端可选。启动顺序必须严格否则会互相依赖超时MySQL先起建库建表导入初始化数据Redis起确认redis-cli ping返回 PONGMQ起先起 NameServer 再起 Broker确认控制台能访问Nacos起单机模式即可确认 8848 端口可访问基础服务用户服务、会话服务、消息服务它们只依赖上面四个Netty 连接节点它要注册到 Nacos同时依赖 Redis 和 MQ网关最后起因为它要拉取所有服务的路由。服务的bootstrap.yml里要配好 Nacos 地址和命名空间本地开发建议用dev命名空间隔离避免和同学的实例互相干扰。这个细节很实用——几个人一起做毕设经常出现我连到你起的实例上去了消息全发你那边的尴尬。6.2 单机压测连接数与吞吐的真实数据测试机配置4 核 8GJVM 堆 4G直接内存 1G。连接数测试。用自写的压测客户端用 Netty 写一个简单客户端开 N 个线程循环建连逐步加压连接数CPU 占用堆内存观察结果100012%380MB稳定300028%720MB稳定500045%1.1GB稳定心跳延迟正常800068%1.6GB开始出现偶发 GC 停顿1000085%2.2GB心跳超时增多不推荐结论单机 5000 到 6000 连接是舒适区8000 是极限再往上就得加机器。这个数据比支持百万连接的空话实在得多答辩时拿出来是有说服力的。消息吞吐测试。用 2000 个连接每连接每秒发 1 条 200 字节的消息总 QPS 2000指标数值发送到接收的 P50 延迟18msP99 延迟85ms落库成功率100%CPU 占用52%P99 到 85 毫秒说明有少量消息走了重试或排队。排查下来是 MySQL 的批量写入没开改成每 100 条或 200 毫秒刷一次批P99 降到了 40 毫秒以内。这个优化很值得写进文档。6.3 关键参数调优清单这些参数是踩过坑之后固化的可以直接抄操作系统层# 文件句柄数不调这个连接数上不去 ulimit -n 65535 # TCP 连接队列应对突发建连 net.core.somaxconn 32768 net.ipv4.tcp_max_syn_backlog 16384 # TIME_WAIT 快速回收注意仅在内网环境使用 net.ipv4.tcp_tw_reuse 1 net.ipv4.tcp_fin_timeout 15Netty 层ServerBootstrap b new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .option(ChannelOption.SO_REUSEADDR, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) // 禁用 Nagle降低小包延迟 .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) // 池化减少 GC .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(32 * 1024, 64 * 1024)); // 写水位防积压workerGroup的线程数默认是 CPU 核数乘 2对于 IO 密集型场景可以再调大到 4 倍但不要无限加线程切换本身也是开销。JVM 层-Xms4g -Xmx4g -Xmn2g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:MaxDirectMemorySize1g -Dio.netty.leakDetection.levelparanoid # 只在排查阶段开生产用 simple直接内存一定要显式设置。Netty 默认用直接内存做 IO 缓冲不设置上限的话它可能一直涨到物理内存耗尽被系统 kill日志里连个 OOM 都没有非常难排查。7. 问题排查实录与速查表7.1 六类高频问题复盘问题一消息发出去对方收不到但发送方显示成功。这个最典型。排查顺序是先看接收方的在线路由在不在GET im:route:{userId}没有说明连接已断但客户端没感知再看 MQ 的消费位点有没有积压最后看目标节点有没有消费到这条消息但写 Channel 失败。我遇到过的情况是 MQ 消费者线程池被慢 SQL 打满消息在队列里堆了几万条表现出来就是延迟越来越久最终超时。问题二客户端每隔几分钟掉线一次。九成是心跳参数问题。要么心跳间隔大于 NAT 超时要么服务端空闲检测时间设得比客户端心跳间隔还短。计算方式服务端读空闲时间 客户端心跳间隔 × 2。另外检查一下IdleStateHandler加的位置如果加重了或者包裹顺序不对可能不生效。问题三服务端内存持续上涨最后 OOM。先看是不是ByteBuf泄漏。开paranoid级别的泄漏检测日志里会打印出在哪一行分配的、在哪一行没释放。常见原因是自己在channelRead里retain()了但没release()或者把ByteBuf存进了列表却忘了生命周期。另一个可能是连接对象没清理用户下线后Channel还挂在 Map 里。问题四同一个用户收到重复消息。三处可能客户端超时重发但服务端幂等没生效检查唯一索引是不是建对了MQ 消费失败重投检查消费逻辑是不是有异常被吞了多端登录时旧连接没踢掉同一条消息推给了两个 Channel。逐层加日志确认别猜。问题五历史消息拉取越来越慢。看执行计划大概率是没走对索引。WHERE conversation_id ? AND seq ? ORDER BY seq DESC应该走uk_conv_seq如果优化器选错了索引可以强制FORCE INDEX。另外注意别用WHERE conversation_id ? ORDER BY created_at DESC这个组合走不了覆盖索引。问题六雪花算法生成的 ID 重复。时钟回拨。NTP 校时或者虚拟机迁移都可能让时间往后退。解决办法是在生成器里记录上次的时间戳如果发现当前时间小于上次直接抛异常或者等待一小段别硬生成。对于聊天消息我倾向等待到时间追上来因为抛异常意味着这条消息发不出去。7.2 排查速查表现象最可能的原因快速验证方式处理方式消息延迟高MQ 消费积压看控制台的消费位点差值扩容消费者或优化落库批量连接建立失败文件句柄耗尽lsof -p PID | wc -l调 ulimit 或排查连接泄漏首包解析报错协议头长度算错打印原始字节数组核对 lengthFieldOffset群消息部分人收不到路由表过期未续期查 Redis 路由 key 的 TTL心跳里加续期逻辑CPU 打满但 QPS 不高IO 线程里做了阻塞操作打线程栈看 Netty 线程状态业务逻辑移到业务线程池数据库连接池耗尽慢查询或事务未提交看连接池监控的活跃数加索引、缩短事务、调大池重连风暴无抖动的固定间隔重连看服务端连接建立曲线指数退避 随机抖动消息乱序MQ 分区键用了随机值检查生产者的分区策略改用会话 ID 做分区键最后分享一个我在带这类项目时反复强调的习惯每个环节都打一条带 traceId 的日志。客户端发消息时生成一个 traceId 带在协议头里服务端从接入、校验、落库、投递 MQ、消费、写 Channel每一跳都打印traceId 阶段 耗时。这样出问题时一条 grep 就能把整条链路串起来比在五个服务的日志里大海捞针强太多。这个习惯看着笨但真到了排查线上问题的时候你会感谢当初多写了那几行log.info。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑