
TCP 单聊与群聊这个题目乍一看像是教科书里的老生常谈但真正动手把两条链路跑通、把消息可靠地送到对端中间踩的坑远比想象中多。我见过太多人卡在服务端能收到消息但客户端收不到群聊里一个人掉线整个房间卡死粘包导致消息错乱这些具体问题上。这篇内容就是围绕一个可运行的单聊加群聊 TCP 案例展开把协议设计、连接管理、消息分发、粘包处理、异常断线这些环节拆开讲透。适合已经懂一点 Socket 基础、想从能连上进阶到能稳定聊天的开发者也适合想理解长连接服务端设计思路的朋友。下面所有代码和参数都是我在实际调试中验证过的你可以直接照着复现。1. 为什么单聊和群聊要放在同一个 TCP 案例里讲1.1 单聊和群聊的本质差异不在连接数而在消息路由很多人第一反应是单聊就是两个客户端连服务端群聊就是多个客户端连服务端区别只是人数。这个理解只对了一半。从 TCP 连接层面看单聊和群聊确实都是客户端到服务端的星型拓扑客户端之间从不直接建连。真正的差异出现在服务端的消息路由逻辑上。单聊的路由是点对点定向转发A 发消息给 B服务端查表找到 B 的连接句柄把消息写进 B 的 socket。群聊的路由是广播式分发A 发一条消息服务端要遍历房间内除 A 以外的所有成员逐个写入。这两条路径在代码结构上可以复用同一套连接管理但分发策略必须分开设计。如果偷懒用同一套广播逻辑处理单聊消息会泄露给无关的人反过来用点对点逻辑处理群聊就得在客户端维护成员列表并逐条发送把复杂度推给了客户端得不偿失。我在设计这个案例时刻意让单聊和群聊共用同一个连接池和同一个消息帧格式只在服务端的 dispatch 层做分支。这样既保证了代码复用又能清晰对比两种路由策略的实现差异。你复现的时候也会发现连接管理部分写一遍就够了真正需要动脑的是分发那一层。1.2 星型拓扑下服务端是唯一可信的消息中转站TCP 本身只保证两个端点之间的可靠传输它不关心消息的语义。在聊天场景里A 想跟 B 说话但 A 和 B 之间没有直接连接所有消息都得经过服务端中转。这意味着服务端必须承担三个职责维护用户标识到连接的映射表、校验消息的合法性、按路由规则投递。这里有个容易被忽略的点服务端不能信任客户端传来的任何路由信息。比如客户端发来发给用户 B服务端必须自己查表确认 B 是否在线而不是盲目转发。我早期写的一个版本就犯过这个错客户端传什么目标 ID 就转发给谁结果有人伪造目标 ID 把消息发给了不该收的人。修正后的做法是消息帧里只带目标标识服务端用自己维护的映射表做二次校验查不到就直接丢弃并回一个错误帧。星型拓扑还有个好处是 NAT 穿透问题天然不存在。客户端只需要主动连服务端不需要互相打洞。这也是为什么绝大多数即时通讯系统在接入层都采用这种结构把复杂的点对点问题收敛到服务端统一处理。1.3 这个案例真正训练的是长连接状态管理能力单聊群聊的表面是消息收发底层练的是长连接的状态管理。一个 TCP 连接从建立到断开中间会经历登录、心跳、消息收发、异常掉线、重连等一系列状态迁移。单聊场景下状态相对简单群聊场景下还要额外维护房间成员关系这个维度。我建议你在复现时重点关注三张表全局连接表用户标识到 socket 的映射、房间成员表房间标识到成员集合的映射、以及每个连接自身的状态标记是否已登录、最后心跳时间。这三张表的一致性维护才是这个案例最有价值的训练点。消息收发本身反而是最简单的部分真正让服务端变复杂的是某个用户突然断线时如何干净地从所有相关表里摘除他并通知受影响的房间。2. 消息帧格式设计粘包问题的根源与解法2.1 为什么裸 TCP 一定会粘包TCP 是字节流协议它只保证字节按序到达不保证一次 send 对应一次 recv。你发两条各 100 字节的消息接收端可能一次 recv 到 200 字节也可能分三次各收到 30、70、100 字节。这不是 bug是 TCP 的设计本意——它把消息边界这件事交给了应用层。我见过最典型的错误写法是客户端send(hello)服务端recv后直接当成一条完整消息处理。本地测试时因为数据量小、网络快往往碰巧能跑通一上真实网络就原形毕露。粘包的本质是接收端无法从字节流里判断一条消息到哪里结束所以必须在应用层定义边界。解决思路无非三种固定长度、特殊分隔符、长度前缀。固定长度太浪费且不灵活分隔符方案遇到消息内容本身含分隔符就要转义处理起来恶心长度前缀是最稳妥的通用方案也是我在这个案例里采用的。2.2 长度前缀帧的字段定义与字节序约定我设计的帧格式是4 字节长度 1 字节类型 4 字节目标标识 消息体。长度字段表示类型 目标标识 消息体的总字节数不包含长度字段自身。这样接收端先读 4 字节拿到长度 N再读 N 字节就是完整的一帧。字节序必须统一我选的是大端序网络字节序这也是跨平台通信的惯例。Java 的ByteBuffer默认就是大端C 语言里用htonl转换Python 用struct.pack(I, n)。如果你混用不同语言写客户端字节序不统一会导致长度字段解析出天文数字然后接收端一直等一个永远收不满的帧表现为消息发出去但对方毫无反应。这个坑我踩过排查了半天才发现是字节序问题。字段长度说明长度4 字节后续内容总长度大端序类型1 字节1单聊 2群聊 3登录 4心跳 5系统通知目标标识4 字节单聊为目标用户群聊为房间号登录为自身标识消息体变长UTF-8 编码的文本内容2.3 接收端的拆包循环一次 recv 可能包含多帧接收端不能假设一次 recv 等于一帧正确做法是维护一个接收缓冲区每次 recv 到的数据追加到缓冲区末尾然后循环判断缓冲区长度是否大于等于 4是则读出长度 N再判断缓冲区剩余是否大于等于 N是则切出一帧处理否则跳出循环等待更多数据。这个循环是粘包处理的核心我把它封装成一个FrameDecoder类内部维护byte[] buffer和一个readIndex。每次feed(byte[] data)后调用decode()返回帧列表。实测下来这个结构能正确处理半帧多帧粘连跨多次 recv 的帧所有情况。要注意缓冲区要能动态扩容否则大消息会溢出我设的初始容量是 8KB超过就翻倍扩容。public ListFrame feed(byte[] data) { appendToBuffer(data); ListFrame frames new ArrayList(); while (bufferRemaining() 4) { int len readIntBigEndian(); if (bufferRemaining() len) { rewindReadIndex(4); // 长度字段回退等更多数据 break; } byte type readByte(); int target readIntBigEndian(); byte[] body readBytes(len - 5); frames.add(new Frame(type, target, body)); } return frames; }注意回退 readIndex 那一步很关键。如果发现剩余数据不够一帧必须把读指针退回到读长度之前的位置否则下次进来长度字段就错位了。这个细节我在第一版里漏了导致偶发的消息错乱加了日志才定位到。3. 服务端连接池与用户映射表的落地实现3.1 用 ConcurrentHashMap 维护用户到连接的映射服务端要为每个已登录用户保存其 socket 连接我用ConcurrentHashMapString, ClientSession来存。key 是用户标识登录时客户端上报value 是封装了 socket、输出流、接收缓冲区的会话对象。选 ConcurrentHashMap 而不是普通 HashMap是因为多个连接线程会并发读写这张表——一个线程在处理 A 的消息时可能要查 B 的连接同时 B 可能正在登录写入这张表。这里有个并发陷阱登录和登出必须保证映射表的原子性更新。比如用户重复登录旧连接还没清理新连接就写进来了会导致消息发到旧连接上。我的处理是登录时用put的返回值判断是否已存在旧会话存在则先关闭旧会话再放入新的。登出时用remove(key, session)的带值版本只有当前会话确实是表里那个才删除避免误删新会话。会话对象里我额外存了lastActiveTime配合一个后台定时线程扫描超时会话。心跳间隔设的 30 秒超时阈值设 90 秒三次心跳没到就判定掉线。这个比例不是拍脑袋定的太短会误杀网络抖动的正常用户太长则掉线用户占着资源不释放。90 秒是我在弱网环境实测比较平衡的值。3.2 每个连接一个读线程还是用 NIO 多路复用这个案例我选择的是阻塞 IO 加每连接一个读线程的模型而不是 NIO。原因很实际这个案例的目标是讲清楚单聊群聊的逻辑连接数在几十到几百的量级阻塞模型代码直观、调试方便。NIO 的 Selector 虽然能撑更多连接但引入的复杂度会掩盖消息路由这个核心主题。每个连接建立后服务端起一个线程循环recv解出帧后交给 dispatch 处理。线程数等于连接数几百个连接就是几百个线程现代 JVM 完全扛得住。如果你要扩展到上万连接那就得换 NIO 或者 Netty但那是另一个话题了。我在文末会提一句扩展方向这里先把逻辑讲透。写线程这块要注意多个线程可能同时往同一个 socket 写比如 A 和 C 同时给 B 发消息而 socket 的输出流不是线程安全的并发写会导致帧数据交错。我的做法是给每个会话配一个发送队列加一个专属写线程所有要发给该用户的消息先入队写线程串行取出写入。这样既保证了帧的完整性又避免了在业务线程里做阻塞写。3.3 登录握手第一帧必须是登录帧连接建立后服务端不信任任何未登录的连接。客户端必须先发一个类型为登录的帧携带自己的用户标识服务端校验通过后把会话放进映射表回一个登录成功帧。在登录之前发任何其他类型的帧服务端直接关闭连接。这个约束看起来严格但能挡掉大量脏数据。我早期版本允许匿名发消息结果测试时各种半截连接往里灌垃圾日志全是解析错误。加了登录前置校验后服务端的健壮性明显提升。登录帧的校验还包括用户标识的格式检查长度、字符集防止有人用超长标识撑爆内存。4. 单聊的点对点转发链路4.1 目标在线性校验与错误回执单聊消息到达服务端后第一件事是查映射表确认目标用户是否在线。在线则取出目标会话把原始消息体封装成来自某用户的帧投递过去不在线则给发送方回一个类型为系统通知的帧内容是对方不在线。这里有个设计取舍离线消息要不要存。这个案例我选择不存直接回执告知不在线。原因是离线存储涉及持久化、消息去重、上线拉取等一整套逻辑会把案例撑得过于庞大。但我在代码里留了一个OfflineStore接口的空实现你可以按需接入数据库或内存队列。实际生产系统里离线消息几乎是必备的但作为教学案例先把在线链路跑通更重要。错误回执的帧类型我复用了系统通知通过消息体内容区分具体错误。这样客户端只需要处理一种额外的帧类型逻辑简单。回执里我带了原始消息的一个摘要方便客户端做 UI 提示比如消息发送失败对方不在线。4.2 发送方与接收方的标识透传单聊帧里客户端只填目标标识但接收方需要知道这条消息是谁发的。服务端的做法是在转发时重新组帧把发送方的标识写进消息体的前缀或者扩展帧格式加一个来源标识字段。我选的是后者在帧头里加了一个可选的来源字段只在转发时填充。为什么不直接让客户端在消息体里带发送者标识因为那样服务端就无法校验真实性客户端可以伪造发送者。服务端在转发时用自己的会话信息填充来源字段接收方拿到的来源一定是可信的。这个原则在群聊里同样适用群消息的来源也由服务端填充。透传的另一个细节是消息顺序。同一个发送方连续发两条消息给同一个接收方接收方必须按发送顺序收到。由于我们每个会话有独立的发送队列且串行写入这个顺序天然得到保证。但如果发送方是多线程并发发消息入队顺序就不确定了需要在客户端侧保证同一会话的消息串行发送。4.3 单聊链路的完整时序复盘把单聊走一遍A 登录后映射表里有 A 的会话B 登录后表里有 B。A 构造单聊帧目标填 B 的标识消息体是在吗。服务端读到帧查表找到 B 的会话重新组帧来源填 A入 B 的发送队列。B 的写线程取出写入 socketB 的客户端解码后显示A在吗。如果此时 B 恰好断线但服务端还没检测到心跳未超时消息会写进一个已经半关闭的 socket写操作可能抛异常。我的处理是在写线程里捕获 IO 异常触发该会话的清理流程同时给发送方回一个对方已离线的回执。这种写失败才发现掉线的情况在真实网络里很常见不能只依赖心跳检测。5. 群聊的房间模型与广播分发5.1 房间成员表的增删与并发安全群聊的核心数据结构是房间到成员集合的映射我用ConcurrentHashMapString, SetClientSession。成员集合用ConcurrentHashMap.newKeySet()创建保证并发增删安全。用户加入房间时把会话加进集合离开时移除房间空了可以选择保留空房间或删除我选择保留因为频繁创建销毁房间的开销不值得。这里有个经典问题用户断线时如何从所有房间摘除。一个用户可能同时在多个房间里断线清理时必须遍历所有房间检查该用户是否在成员集合中。如果房间数量多这个遍历成本不低。优化方案是反向维护一张用户到房间集合的映射断线时直接按用户查房间列表精准摘除。我在案例里加了这个反向索引清理效率从 O(房间数) 降到 O(用户所在房间数)。并发安全方面要注意广播时遍历成员集合同时可能有成员加入或退出。用 ConcurrentHashMap 的 keySet 遍历是弱一致的遍历过程中新增的成员可能看不到这条消息退出的成员可能还会收到。这个语义对聊天场景是可接受的——新加入的人看不到加入前的消息很正常刚退出的人多收一条也无伤大雅。如果你要求强一致就得在广播时对成员集合加锁但那会牺牲并发性能。5.2 广播时排除发送者自身的实现细节群聊广播时发送者自己不应该再收到一遍自己发的消息客户端本地已经显示过了。实现上就是在遍历成员集合时跳过发送者会话。这里要注意会话对象的相等性判断我用的是会话的唯一 ID 比较而不是对象引用比较因为同一个用户可能重连产生新会话对象。广播的性能是群聊的瓶颈。假设房间有 500 人一条消息要入 499 个队列。如果每个入队操作都做复杂校验单条消息的处理时间会明显上升。我的优化是把校验前置到消息入口处做一次广播循环里只做纯粹的入队操作。实测 500 人房间单条消息分发耗时在几毫秒量级够用。还有个细节是广播失败的隔离。某个成员的队列满了消费太慢入队可能阻塞或失败不能让它拖累整个广播。我给每个队列设了容量上限比如 1000 条满了就丢弃该成员这条消息并记录日志而不是阻塞等待。聊天场景里丢一条消息比整个房间卡住要好得多。5.3 群消息的来源标识与房间号校验群消息帧里客户端填的目标是房间号服务端转发时同样要填充来源标识。此外还要校验发送者是否真的在该房间里——不在房间的人不能往房间发消息。这个校验查一下成员集合即可成本很低但能防止越权发言。转发出去的群消息帧目标字段我保留房间号来源字段填发送者。接收方客户端根据房间号决定显示在哪个聊天窗口根据来源显示发言人。这样客户端不需要额外维护这条消息属于哪个房间的推断逻辑直接读帧头就行。房间号的分配我用了简单的自增整数实际系统里可能用 UUID 或业务自定义的字符串。案例里用整数是为了帧格式里 4 字节目标字段能直接容纳。如果你要用字符串房间号把目标字段改成变长或者用哈希映射到整数即可。6. 心跳、断线检测与重连的工程化处理6.1 心跳帧的设计与超时判定长连接最怕假在线——TCP 连接看起来还在实际上对端已经挂了比如拔网线、进程被杀操作系统要很久才会感知到。应用层心跳就是用来快速发现这种情况的。客户端每隔固定间隔发一个心跳帧服务端收到后更新该会话的lastActiveTime。服务端用一个后台线程每隔 30 秒扫描一遍所有会话把lastActiveTime超过 90 秒的会话判定为掉线执行清理。为什么是 90 秒因为要容忍连续两次心跳丢失网络抖动第三次还没到才判定死亡。心跳间隔 30 秒、容忍 3 次这是业界比较通用的经验值你可以根据实际网络质量调整。心跳帧本身不携带业务数据类型字段单独设一个值。服务端收到心跳只更新时间戳不回任何东西减少无谓流量。客户端也不需要等服务端的心跳响应单向发送即可。这种单向心跳 服务端超时扫描的模式比请求-响应式心跳简单且同样有效。6.2 掉线清理的完整链路一个会话被判定掉线后清理动作要覆盖所有相关数据结构从全局连接表移除、从所有房间成员集合移除、关闭 socket、停止该会话的写线程。这几步的顺序有讲究——先摘表再关 socket否则关 socket 触发的异常处理可能又去操作已经摘掉的表产生空指针。清理还要考虑通知。如果掉线用户在某房间里是否要广播某某已离开我的案例里做了这个通知但做了节流同一用户短时间内反复上下线通知合并处理避免刷屏。这个节流逻辑用了一个简单的最后通知时间判断5 秒内的重复通知丢弃。清理的幂等性也很重要。掉线可能被多个路径触发心跳超时、写异常、客户端主动断开。多个路径同时触发清理时要保证只清理一次。我用会话对象里的一个AtomicBoolean closed做标记清理前先 CAS 置位置位失败的直接返回。6.3 客户端重连后的状态恢复客户端检测到连接断开后应该自动重连并重新登录。重连逻辑要注意退避第一次立即重连失败后等 1 秒、2 秒、4 秒递增避免服务端刚重启就被大量重连请求打垮。我设的最大退避是 30 秒超过后保持 30 秒间隔持续尝试。重连成功后客户端需要重新加入之前的房间。这个之前加入过哪些房间的信息客户端自己维护一份列表重连登录后逐个重新加入。服务端不需要记住用户的历史房间因为房间成员关系在掉线清理时已经删除了重连就是全新的加入。这里有个体验细节重连期间用户发的消息怎么办我的案例里是直接提示连接已断开请稍后重试不做本地缓存重发。因为重发涉及消息去重万一服务端其实收到了只是响应丢了复杂度高。实际产品里可能会做本地队列加服务端幂等但案例里点到为止。7. 实测中暴露的几个典型问题与修法7.1 半包导致的登录帧解析失败测试时遇到一个偶发问题客户端刚连上就发登录帧服务端偶尔报帧长度非法。排查发现是登录帧很小几十字节客户端一次 send 出去但服务端 recv 可能只收到前几个字节长度字段还没读全就去解析读出了错误的值。修法就是前面说的拆包循环——长度字段读不全时回退读指针等待更多数据。这个问题的隐蔽性在于它依赖网络时序本地回环测试几乎必现不了得用工具模拟慢速网络或者人为在 send 之间加延迟才能复现。我后来在测试里加了一个分片发送的开关把每帧拆成随机大小的块发送专门用来验证拆包逻辑的健壮性。7.2 群聊广播时的 ConcurrentModificationException早期版本广播时直接遍历成员集合同时有用户加入房间修改了集合抛了并发修改异常。虽然用的是 ConcurrentHashMap 的 keySet理论上弱一致不会抛这个异常但我当时用的是普通 HashSet 加 synchronized遍历时另一线程增删就炸了。换成ConcurrentHashMap.newKeySet()后问题消失。这个教训是并发容器要用对别自己用普通集合加锁凑合。自己加锁容易漏掉某条路径而且锁粒度控制不好会拖慢广播。用现成的并发容器语义清晰性能也更好。7.3 写线程阻塞导致的整体卡顿有个版本里我在业务线程直接调 socket 的 write某个客户端网络极差write 阻塞了十几秒导致处理该消息的线程被卡住连带影响了其他消息的处理。这就是前面说的每会话独立写线程加队列要解决的问题。改成队列加写线程后业务线程只做入队非阻塞写阻塞只影响该会话自己的写线程不波及全局。这个改动对吞吐量的提升在弱网环境下非常明显。入队用LinkedBlockingQueue的offer带超时队列满时短暂等待后丢弃避免无限堆积。7.4 客户端 UI 线程被 recv 阻塞客户端侧也有类似问题如果在 UI 线程里直接 recv网络一卡界面就假死。正确做法是 recv 放在独立线程收到消息后通过线程安全的方式比如消息队列或 UI 框架的 post 机制通知 UI 更新。这个虽然是客户端常识但我在案例的示例客户端里特意做了正确示范因为见过太多新手在这里翻车。8. 从案例到生产几个可以继续深挖的方向这个案例把单聊群聊的核心链路跑通了但离生产系统还有距离。如果你想让这套代码更接近实用我建议按优先级考虑这几个扩展消息持久化离线消息、历史记录、鉴权体系登录不只是报个标识要有令牌校验、水平扩展单机连接数有上限多机部署时消息路由要跨节点、以及协议升级比如支持二进制消息体、消息已读回执。我个人在实际操作中的体会是这类长连接服务最难的不是功能实现而是异常路径的覆盖。正常收发消息的代码可能只占三成剩下七成都在处理各种断线、超时、并发、脏数据。你在复现这个案例时不妨故意制造一些异常——拔网线、杀进程、发超长消息、并发狂发——看看你的代码能不能扛住。能扛住这些才算真正把这个案例吃透了。