资讯动态

Java 聊天室从能跑到能用:通信模型选型、消息编解码与断线检测实战

发布时间:2026/10/26 22:31:13 来源:尧图企业网站定制
用 Java 写一个网络聊天室几乎是每个往后端方向走的人都会碰到的练手题。我第一次接这个活儿的时候也觉得它简单得不像话——服务端开个 Socket 监听客户端连上来收到谁的消息就转发给其他人几十行代码就能跑起来。真正把它挂在一台机器上让十几个人同时用问题才一个个冒出来有人在输入框里打了一长串中文对面收到半截乱码有人直接把客户端窗口叉掉服务端那边的线程就那么挂着不释放压测到两百连接的时候 CPU 看着不高吞吐却卡在一个数字上不往上走。这些问题在教程里基本看不到但恰恰是网络聊天室这个题目真正的价值所在。这篇内容不打算复述 Socket 的基本 API讲的是从能跑通到能用住之间那段路Java 里三种通信模型到底该怎么选消息在 TCP 这条字节流上怎么划出边界群聊广播为什么会把整个服务拖慢断线检测的时序应该怎么设计以及我在联调阶段实实在在踩过的几个坑。适合已经写过 Hello World 级别 Socket 程序、想做点真东西的人也适合带新人做项目时想提前知道雷区在哪的人。基础部分我会点到但重心放在那些只有跑起来才会暴露的细节上。1. 动手之前先想清楚聊天室这道题到底难在哪1.1 一个能演示的版本和能同时用的版本差了什么我见过太多这样的实现主线程accept()拿到 Socket然后就地开一个while(true)读输入流读完打印。单机单人测试毫无问题两个人连上去就发现第二个人连不进来——因为主线程卡在第一个连接的读取循环里accept()根本没机会执行。这个阶段的 bug 是结构性的不是加个try-catch能解决的。从能演示到能同时用核心要跨过三道坎。第一道是并发模型谁负责接收连接谁负责读写数据这两件事必须解耦否则连接数一多就全堵死。第二道是消息边界TCP 给的是连续字节流它不保证你一次write()的内容对应对面一次read()的内容一次粘三条消息或者一条消息分两次到达都是合法的。第三道是状态一致性在线用户表会被多个线程同时读写广播的时候有人正在退出这些竞态在低并发下几乎测不出来一上量就开始随机崩。这三道坎之外还有一层隐性成本——异常路径的处理。网络程序的正常流程往往只占代码量的三成剩下七成全在处理对面突然没了网线被拔了客户端发来半截数据就再不说话这些情况。教程代码通常只演示正常路径所以看着简洁真实可用的版本里异常分支才是主体。我个人的判断标准很朴素能不能在没有任何提示的情况下让一个用户直接关掉客户端进程服务端的在线列表在十秒内正确更新且不泄漏线程和内存。做到这一点这个聊天室才算及格。1.2 BIO、NIO、Netty 三条路的取舍逻辑选通信模型这件事很多人的第一反应是哪个先进选哪个这个思路不太对。选型的依据应该是连接规模和团队维护成本而不是技术新旧。模型线程模型适合的连接量代码复杂度典型问题BIO阻塞 IO一连接一线程几十到几百低线程数随连接线性增长慢客户端阻塞线程NIOJDK 原生单线程或少量线程 Selector几千中高状态机难写半包处理容易出错Netty主从 Reactor上万中框架封装后需要理解 ByteBuf 引用计数、EventLoop 线程模型BIO 的问题不在性能在线程成本。64 位 Linux 上 JVM 默认线程栈是 1MB一千个连接就是一千个线程光是栈空间就要预留 1GB 虚拟内存。更要命的是这些线程大部分时间都在read()上阻塞着什么都不干纯占资源。但在连接数一百以内、消息频率不高的场景下BIO 的代码清晰度优势非常明显——你只需要关心读到了什么不用操心SelectionKey的注册和取消。JDK 原生 NIO 我一般不推荐给新手直接用。Selector的循环里有个经典陷阱selectedKeys()取出来的集合必须手动remove()忘了这一步会导致同一个事件被反复处理早期 JDK 版本里还存在epoll空轮询问题select()返回 0 却让循环空转CPU 直接打到 100%。Netty 内部对这些都做了规避这也是它在生产环境占主流的原因之一。我的建议是分场景如果这是学习项目、目的是搞懂网络编程的底层机制从 BIO 起步写通了再改造成 NIO这个改造过程本身价值极高如果是奔着做一个能长期跑的服务去的直接用 Netty把精力省下来放在协议设计和业务逻辑上。中间那条手写 NIO 上生产的路除非你对状态机调试有充分经验否则不建议走。顺带说一句java.net包里还有个AsynchronousSocketChannelAIO回调式的写法看着优雅但实际项目里用得很少生态和调试工具都不如 Netty 成熟不用花时间纠结。1.3 先定协议再写代码这是我最想强调的一条经验。很多人写聊天室是先想服务端怎么处理然后随手定个消息格式等做到一半发现要加私聊、要加图片、要加撤回原来的格式撑不住了只能推倒重来。正确的顺序是反过来先想清楚有哪些类型的消息每种消息带哪些字段把这些定死了再动键盘。一个最小可用的聊天室至少需要这几类消息登录带昵称、群聊文本、私聊文本带目标昵称、用户列表变更通知、心跳、服务端错误回执。如果一开始没把类型这个字段预留出来后面每加一种消息都要改解析逻辑。我在第一个版本里犯的错就是只设计了纯文本转发消息体就是字符串本身。等要加私聊的时候只能靠约定的前缀符号比如某人 内容来区分结果用户昵称里带就解析错了。这个教训很直接任何走网络的协议第一个字节就应该是类型标识。2. 服务端骨架线程究竟该在哪里阻塞2.1 一连接一线程的 BIO 实现与它的真实上限先给一个我认为结构正确的 BIO 骨架重点看线程的分工而不是 API 的调用public class ChatServer { private static final int PORT 8888; private static final MapString, ClientSession ONLINE new ConcurrentHashMap(); public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(PORT)); ExecutorService workers Executors.newCachedThreadPool(); while (!Thread.currentThread().isInterrupted()) { Socket socket serverSocket.accept(); socket.setTcpNoDelay(true); // 小包多关掉 Nagle socket.setSoTimeout(0); // 读超时交给应用层心跳判断 workers.submit(new ClientHandler(socket)); } } }这里有两个点值得展开。setTcpNoDelay(true)关掉的是 Nagle 算法那个算法会把小数据包攒一攒再发对文件传输友好对聊天室是灾难——用户敲一句在吗可能被延迟几十毫秒才发出去体感上就是这软件有点卡。聊天场景下包本来就小攒包的收益微乎其微。setSoTimeout我设成 0永不超时把断线判断完全交给应用层心跳。原因是SO_TIMEOUT超时后read()抛的是SocketTimeoutException而 Socket 本身还是可用的你得自己区分这次没数据和连接坏了逻辑反而更绕。交给心跳机制统一处理更清爽。关于线程池的选择newCachedThreadPool()在连接建立时按需创建线程空闲 60 秒回收比固定大小池更适合聊天室这种连接数波动大的场景。但它没有上限理论上可以被大量连接打爆。生产环境我会换成带最大值的ThreadPoolExecutor核心线程数 8、最大 200、队列用SynchronousQueue超出直接拒绝并回一条服务器繁忙的提示——这比默默堆积任务然后全部超时体验好得多。BIO 的真实上限经验值是在普通云主机上撑 300 到 500 个长连接就开始吃力瓶颈不在 CPU 而在上下文切换和内存。想再多就得换模型没有别的办法。2.2 会话对象该存什么以及为什么不能只存 Socket我见过不少实现把Socket直接塞进一个ListSocket里当在线列表用广播时遍历这个 List 挨个getOutputStream().write()。这在功能上能跑但会带来两个后续麻烦一是没法快速按昵称找人私聊要遍历全表二是每次发消息都要重新拿输出流而这个方法本身有同步开销。正确的做法是包一层会话对象把连接和身份绑在一起public class ClientSession { private final String sessionId; // 连接级唯一 IDUUID private volatile String nickname; // 登录后赋值 private final Socket socket; private final DataOutputStream out; private final BlockingQueueFrame sendQueue new LinkedBlockingQueue(256); private volatile long lastActiveAt System.currentTimeMillis(); public void send(Frame frame) { if (!sendQueue.offer(frame)) { // 队列满说明对端消费不过来直接断开比无限堆积更安全 close(); } } }注意sendQueue这个设计。它的存在是为了把业务线程想发消息和网络线程真正写出去这两件事分开。如果业务线程直接调out.write()一旦对端网络慢write会阻塞而这个业务线程可能正在遍历整个在线列表做广播——一个慢用户就能把所有人的消息都卡住。这个坑我在第 4 章会详细讲。nickname用volatile修饰是因为它会在登录线程里被写入、在广播线程里被读取没有volatile的话广播线程可能长时间读到null。这种可见性问题在单机测试里几乎不可能复现但它的存在是确定的。sessionId和nickname分离也很重要。登录前用户没有昵称但连接已经建立了这时候如果用nickname做主键会出问题多个未登录连接都是null在 Map 里会互相覆盖。用sessionId做连接标识、nickname做业务标识两个索引各管各的逻辑就清楚了。2.3 登录流程的时序为什么必须先注册再回执登录这一步看似简单但顺序错了会引出一堆边界情况。我的做法是客户端连接成功后立刻发一条 LOGIN 帧带昵称服务端做三件事——校验昵称合法性、检查是否重名、把会话注册进在线表。这三步全部成功之后才回一条 LOGIN_ACK然后广播某某上线了。顺序不能反。如果先广播再注册理论上存在一个极短的时间窗口其他用户收到上线通知去在线列表里找这个人找不到。虽然窗口只有几微秒但在高并发下是能撞上的表现就是偶尔有人看到某某上线但列表里没有他。重名检查也有讲究。ConcurrentHashMap的putIfAbsent是原子的用它来做检查和占位一步完成比先containsKey再put安全——后者在两个请求同时到达时会双双通过检查然后后一个覆盖前一个前一个用户就变成了幽灵会话能收消息但列表里显示的是别人的名字。if (ONLINE.putIfAbsent(nickname, session) ! null) { session.send(Frame.error(昵称已被占用)); session.close(); return; }这段代码短但它规避的是一类在压测时才会暴露的竞态。写并发代码时凡是检查后操作的组合都要条件反射地问一句这两步之间会不会被别的线程插进来。3. 消息在网络上怎么走从字节流里切出完整消息3.1 为什么 println 是最容易埋雷的写法新手最爱用的写法是客户端BufferedReader.readLine()配服务端PrintWriter.println()。它确实好用——换行符天然成了分隔符读一行就是一条消息。但它有三个隐患。第一个是内容里不能有换行。用户在输入框里按 ShiftEnter 换行这条消息就被切成两条发出去了。要支持多行消息就得在发送前做转义接收后再还原多了一层容易出错的编解码。第二个是编码不确定。new PrintWriter(socket.getOutputStream())用的是平台默认编码Windows 上通常是 GBKLinux 上通常是 UTF-8。开发机是 Windows、部署机是 Linux中文就变问号了。这个坑我在第 5 章会详细展开它是最常见的联调事故之一。第三个是没有类型位。纯文本流里没法自然地区分这是聊天内容还是这是心跳只能靠特殊字符约定一旦用户输入了特殊字符就乱套。DataOutputStream.writeUTF()看起来是个好选择它自带长度前缀2 字节和 UTF-8 编码。但它的长度只有 2 字节意味着单条字符串不能超过 65535 字节而且它用的是修改版 UTF-8对某些特殊字符的编码和标准 UTF-8 不一致跨语言对接时会出问题。做纯 Java 内部通信可以用但我不推荐因为将来想加个 Web 端或移动端就得重写协议。3.2 长度前缀协议的完整编解码实现我最终采用的是最经典也最稳的方案固定头部 长度前缀 消息体。帧结构设计如下全部用大端序网络字节序偏移长度字段说明04 字节magic固定值 0xCAFE用于快速识别非法数据41 字节version协议版本当前为 151 字节type消息类型1 登录、2 群聊、3 私聊、4 心跳、5 系统通知64 字节bodyLen消息体字节数上限 819210bodyLen 字节bodyUTF-8 编码的消息体总共 10 字节头部。头部固定长度的好处是解码时可以先读 10 字节从中拿到bodyLen再决定后续读多少逻辑非常线性。public final class FrameCodec { private static final int MAGIC 0xCAFE; private static final byte VERSION 1; private static final int HEADER_LEN 10; private static final int MAX_BODY 8 * 1024; public static void write(OutputStream out, Frame frame) throws IOException { byte[] body frame.getBody().getBytes(StandardCharsets.UTF_8); if (body.length MAX_BODY) { throw new IllegalArgumentException(body too large: body.length); } ByteBuffer buf ByteBuffer.allocate(HEADER_LEN body.length); buf.putInt(MAGIC).put(VERSION).put(frame.getType()) .putInt(body.length).put(body); out.write(buf.array()); out.flush(); } }服务端的读取侧是一个累积状态机这是整个协议层最容易写错的地方public class FrameDecoder { private final ByteBuffer buf ByteBuffer.allocate(16 * 1024); /** 返回 null 表示数据还不够需要继续读 */ public Frame decode(InputStream in) throws IOException { // 先尝试从已有数据里解出一帧 Frame frame tryParse(); if (frame ! null) return frame; byte[] chunk new byte[4096]; int n in.read(chunk); if (n -1) throw new EOFException(peer closed); buf.put(chunk, 0, n); // 累积 buf.flip(); frame tryParse(); buf.compact(); // 保留未消费的残余字节 return frame; } private Frame tryParse() { if (buf.remaining() HEADER_LEN) return null; buf.mark(); int magic buf.getInt(); byte version buf.get(); byte type buf.get(); int bodyLen buf.getInt(); if (magic ! MAGIC || version ! VERSION || bodyLen 0 || bodyLen MAX_BODY) { throw new IllegalStateException(bad frame header); } if (buf.remaining() bodyLen) { buf.reset(); // 头还不完整回退 return null; } byte[] body new byte[bodyLen]; buf.get(body); return new Frame(type, new String(body, StandardCharsets.UTF_8)); } }buf.compact()这一步是精髓。flip()把缓冲区切成读模式compact()则把没读完的残余字节挪到缓冲区开头、切回写模式这样下一次读到的数据自然就接在后面。忘了调compact或者用clear残余的半包数据就被丢了表现是偶尔丢消息或者偶尔解析出乱码——极难排查因为复现概率取决于网络分片时机。提示ByteBuffer的mark/reset和position语义是这套代码的正确性基础。建议先写一个单元测试手工构造一次给半帧、再一次给另外半帧的输入确认能正确拼出一整帧再往上叠业务逻辑。这个测试花十分钟能省掉后面几小时的困惑。3.3 心跳时序怎样在十秒内发现对面已经死了TCP 的连接断开是一个优雅的过程——正常关闭会发 FIN对端read()返回 -1这个能立刻感知。但现实中的断线大多不优雅手机切飞行模式、笔记本合盖、网线被拔、进程被 kill -9。这些情况下对面不会发任何东西你这边read()会一直阻塞着连接在系统层面看还是 ESTABLISHED。TCP 自带的 keepalive 指望不上。Linux 默认要 7200 秒两小时才开始探测改这个参数要动内核配置而且它探测的是链路是否通不是应用是否活着——客户端进程已经卡死但 TCP 栈还在keepalive 是探不出来的。所以必须做应用层心跳。我用的方案是客户端每 20 秒发一个 PING 帧服务端不主动回包省一次往返只更新lastActiveAt。另起一个单独线程每 10 秒扫一遍在线表private void heartbeatScan() { long now System.currentTimeMillis(); for (ClientSession s : ONLINE.values()) { if (now - s.getLastActiveAt() 60_000L) { s.close(); ONLINE.remove(s.getNickname(), s); broadcast(Frame.system(s.getNickname() 已离线)); } } }三个参数这样定的理由20 秒发送间隔保证正常网络下三次心跳内必定有一次成功60 秒判定阈值是发送间隔的三倍容忍两次连续丢包10 秒扫描间隔保证判定延迟最多 10 秒。这组数字的组合下用户断线后最长 70 秒被发现实际体感通常在半分钟左右。这个扫描不能放在主线程里做否则扫描期间没人接收新连接。也不能每个会话开一个定时器线程——几百个连接就是几百个ScheduledFuture调度器压力大。一个线程扫全表是最经济的做法因为ConcurrentHashMap的遍历本身不阻塞写入弱一致性迭代器扫的时候别人照样能进出。还有一点容易忽略心跳不应该刷新业务层的最后发言时间。如果两者混用一个沉默但活着的用户永远不会被判离线看起来没问题但反过来如果心跳不刷新任何时间戳那你就失去了判断连接活性的唯一依据。我的做法是分两个字段lastActiveAt记录任何帧的到达时间含心跳lastSpeakAt只记录聊天消息前者用于断线判定后者用于最近活跃用户这类展示。4. 群聊、私聊与在线列表并发容器选对了只是开始4.1 在线会话表的索引设计与遍历时的坑在线表要支持两种查询按昵称找单个会话私聊、遍历全部会话广播。前者用ConcurrentHashMapString, ClientSession天然合适后者也只是values()遍历一下。但这里有个陷阱广播时不能在遍历过程中修改 Map。虽然ConcurrentHashMap的迭代器是弱一致的、不会抛ConcurrentModificationException但语义上仍然可能漏掉新加入的用户或者看到已经退出的用户。更危险的是如果你在循环体里调用了ONLINE.remove()虽然不抛异常但遍历顺序和预期会不一致某些元素可能被跳过。我的处理办法是把收集和发送分开先snapshot new ArrayList(ONLINE.values())拿到一份快照再在快照上遍历。快照是 O(n) 的复制在几百人的规模下开销可以忽略换来的是完全确定的遍历行为。不要在循环里边走边改这个规则在并发容器上同样适用。另一个坑是size()。ConcurrentHashMap.size()在高并发下返回的是估算值它内部用CounterCell分散计数求和时可能有偏差不是精确值。如果只是显示当前在线 128 人估算完全够用但如果要拿它做容量控制比如满 100 人拒绝新连接就必须自己维护一个AtomicInteger计数器在注册和注销时精确加减。4.2 广播的真实瓶颈慢客户端如何拖垮整个服务前面提到sendQueue的设计这里说清楚它解决的具体问题。假设你用最朴素的写法做广播for (ClientSession s : snapshot) { s.getOut().write(bytes); // 直接写 s.getOut().flush(); }这个循环在局域网里跑得好好的。一旦有个用户的网络很差地铁里、电梯里、Wi-Fi 信号弱他的 TCP 接收窗口很快被填满发送方的内核缓冲区也满了write()就从立即返回变成阻塞等待。这时整个广播线程就停在这个人身上其他所有用户都收不到消息——表现是聊天室突然卡住几十秒然后恢复。这个现象有个特点从服务端看 CPU 不高、内存不高、日志正常因为线程就是单纯地阻塞在 IO 上没有任何指标报警。线上排查时很容易误判成网络抖动。解法就是前面那种入队 独立写线程// 每个 ClientSession 内部有一个专属写线程 private void writeLoop() { try { while (!closed) { Frame frame sendQueue.poll(30, TimeUnit.SECONDS); if (frame null) continue; // 超时回去检查 closed 标志 FrameCodec.write(out, frame); } } catch (Exception e) { close(); } }这样广播线程只做offer非阻塞瞬间完成真正的阻塞发生在各自的写线程里一个慢用户只会卡住他自己卡到队列满我设的 256就被判为消费不过来直接断开。这个队列满即断开的策略是我踩坑之后加上的——之前是无限队列结果一个卡死的客户端在内存里堆了几万条消息最后 OOM。队列长度 256 这个数字按平均每条消息 100 字节算就是 25KB 的积压缓冲一个正常消费的客户端绝不会积压到这个量。设得太大只是给异常情况留更多内存泄漏空间。4.3 私聊与离线消息的边界处理私聊的逻辑本身很简单按目标昵称查表查到就发给对方查不到就回一条错误回执。真正麻烦的是目标用户此刻正在退出。你get()拿到了会话对象正要send()对方的close()已经执行完了。这时send会把帧塞进一个已经没人消费的队列消息石沉大海但发送方以为成功了。处理办法是在send里检查状态并且让移除和关闭的顺序保持确定先从 Map 里remove再标记closed最后关 Socket。这样任何时刻get到会话的线程要么拿到的是一个还没开始关闭的对象要么根本拿不到。这个顺序不能反——先关 Socket 再 remove中间窗口里get会返回一个已经废掉的会话。离线消息要不要做取决于你对这个项目的定位。我一开始觉得聊天室不需要离线消息后来发现测试同事经常抱怨我发了没看到回复不知道对方收没收到。所以加了一个很轻量的方案每个昵称对应一个LinkedListFrame上限 50 条登录时取出并下发然后清空。存在内存里、不落库、重启就丢。这在练手项目里够用真要持久化得上数据库或消息队列那是另一个量级的工作。这里有个细节离线消息队列也得用并发安全的容器而且要配合登录时先取出、再加入的原子性。我用的做法是登录成功后立刻把昵称注册进在线表然后才去读离线队列读完清空。如果在注册之前读队列可能和正在写入的线程撞上导致一条消息既进了队列又没被读到。5. 联调阶段的真实故障几个我花了半晚上才定位的问题5.1 客户端强杀后服务端线程不释放一次完整的排查链路现象是反复启动关闭客户端二十来次之后服务端的线程数从 30 涨到 60 多一直不回落。用jstack一看大量线程停在SocketInputStream.read上对应的都是已经关掉的客户端。排查的第一步是确认read()的返回值处理。翻代码发现ClientHandler的读循环是这样写的while (true) { Frame f decoder.decode(in); handle(f); }问题就在这里当对端正常关闭发 FIN时in.read()返回 -1而decode里我对 -1 的处理是throw new EOFException。可handle的外层catch (IOException e) { log.warn(...) }把异常吞掉了循环继续下一次decode又立刻抛异常再被吞掉——于是这个线程陷入了一个高速空转的死循环每毫秒抛几十次异常。线程确实没卡住但它也永远不会退出而且疯狂占用 CPU。这个 bug 的隐蔽性在于日志里会刷满EOFException的警告但如果你只看服务是否还能响应会发现一切正常——因为 CPU 还扛得住。我是通过top -H -p pid看到几个线程 CPU 占用接近 100% 才把注意力转过去的。修复就是让循环有明确的退出条件try { while (!closed) { Frame f decoder.decode(in); handle(f); } } catch (EOFException e) { log.info(client closed: {}, sessionId); } catch (IOException e) { log.warn(io error: {}, sessionId, e); } finally { cleanup(); // 从在线表移除、关闭 socket、通知广播 }关键是把连接结束当成正常路径而不是异常路径。EOFException在语义上就是对面说完了它是一个信号不是错误不应该被吞掉后继续循环。第二步才是查线程泄漏。上面的修复解决了空转但还有另一类泄漏cleanup()里只关了 Socket忘了从ONLINE移除或者忘了取消那个 30 秒poll的写线程。写线程的poll超时后回去检查closed标志所以只要closed被正确置位它最多 30 秒后自己退出这算是可接受的延迟。但如果closed是普通 boolean 而不是volatile写线程可能永远读不到更新后的值——这又是一个可见性问题。我把排查思路整理成一张表遇到类似问题可以按顺序过一遍检查项判断方法常见根因线程数是否回落jstack统计线程名读循环没退出条件线程状态jstack看栈顶RUNNABLE且栈顶在同一点 空转在线表是否清空打日志输出ONLINE.size()finally里漏了 remove内存是否增长jmap -histo看对象数发送队列无限增长CPU 是否异常top -H找热点线程异常被吞导致的忙循环这里的心得是网络服务里任何while(true)都必须在循环体内有一个能真正终结它的条件而且这个条件要对所有退出路径正常关闭、异常、超时、外部中断都成立。单靠异常来跳出循环是最脆弱的写法。5.2 中文乱码的三层原因与定位方法乱码这个问题看着简单但它的成因有三个层次症状各不相同很容易误判。第一层是流包装时没指定字符集。new InputStreamReader(socket.getInputStream())用的是Charset.defaultCharset()这个值取决于 JVM 的file.encoding而它又取决于操作系统区域设置。同一份 jar 在开发机和服务器上跑出不同结果八成是这里。修复方法很直接所有涉及字符串和字节转换的地方一律显式写StandardCharsets.UTF_8一个都不要省略。第二层是长度计算和编码不一致。这是最容易踩的坑协议头里写的bodyLen如果按String.length()字符数算而实际写入时按 UTF-8 编码一个中文 3 字节那服务端就会多读或少读字节直接导致后面的所有帧全乱。我见过一个实现单条纯英文消息完全正常只要出现中文就必然崩原因就在这里。修复很简单但必须养成习惯协议里的长度永远是字节数不是字符数。byte[] body text.getBytes(StandardCharsets.UTF_8); // 先转字节 int len body.length; // 用字节长度第三层是终端编码。如果服务端日志输出到 Windows 控制台GBK而日志内容里的中文是 UTF-8 解码后的字符串控制台上看到的就是问号。这个不影响功能只影响你排查——但很多人会把它误判成数据传错了然后去改协议越改越乱。我的定位顺序是先在服务端打印每条收到的消息的bodyLen和实际字节数组的length两者一致说明传输层没问题问题在编码再把字节数组Arrays.toString()出来看是不是标准的 UTF-8 三字节序列中文通常是0xE4到0xE9开头。这一步能在一分钟内区分传坏了和显示坏了。5.3 压测时吞吐上不去三个被忽略的细节我第一次做压测用 200 个模拟连接每秒发一条消息结果服务端的吞吐卡在每秒 800 条左右CPU 只有 30%。按理说 BIO 模型在 200 连接下应该轻松应对这个数字不对劲。第一个细节是Nagle 算法。前面提过setTcpNoDelay(true)我当时只在服务端设了客户端的模拟程序忘了设。每个小包延迟 40ms 才发出200 个连接叠加起来消息到达的节奏就被打散了。补上之后吞吐涨了约一倍。第二个细节是日志同步刷盘。每条消息我都打了一行log.info(recv: {} - {}, from, body)用的是默认的同步 appender。高并发下日志写入成了串行瓶颈而且这种开销在 CPU 上体现不明显因为它主要是 IO 等待。后来改成异步 appender并且把 per-message 的日志降到 debug 级别吞吐又涨了一截。第三个细节最隐蔽每条消息都在创建新对象。new String(body, UTF_8)、new Frame(...)、String.format拼日志、snapshot new ArrayList(ONLINE.values())每次都复制一遍。单个对象创建只要几十纳秒但乘上每秒上万次消息、再乘上广播时要遍历的几百个会话就是每秒几十万次分配。表现是 GC 频繁Young GC 每几秒一次每次 GC 有几十毫秒的停顿吞吐曲线呈锯齿状。优化方向很明确广播时不要在每条消息上做全表快照改成一次广播一个快照复用于所有接收者日志用占位符而不是String.format能复用的ByteBuffer就复用。做完这三件事同样的压测条件下吞吐上到了每秒 3000 条左右。不过我得说句实话对多数场景聊天室根本不需要扛这个量。一个中等规模的群聊峰值也就每秒几百条。上面这些优化的真正价值不在于能扛多少而在于让你理解性能瓶颈往往不在你以为的地方——你以为卡在网络 IO实际上卡在日志和 GC 上。6. 让它像个正经服务几个容易被跳过的工程细节6.1 配置外置与优雅停机把端口、最大连接数、心跳间隔这些硬编码在代码里的项目换一台机器就要改代码重新打包这个体验很糟糕。我习惯在启动时读一个application.properties用Properties就行不用上 SpringProperties props new Properties(); try (InputStream in ChatServer.class.getClassLoader() .getResourceAsStream(chat.properties)) { props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); }注意这里也指定了UTF_8——配置文件里如果有中文的欢迎语不指定编码又是一轮乱码排查。优雅停机这件事练手项目基本不做但它其实是能不能用在真实环境的分水岭。直接kill掉进程的话在线用户收到的是连接被重置客户端上显示连接异常断开体感很差。加一个 shutdown hook先给所有会话发一条服务端维护中请稍后重连再关闭监听套接字不再接受新连接最后逐个关闭会话Runtime.getRuntime().addShutdownHook(new Thread(() - { log.info(shutting down, notifying {} clients, ONLINE.size()); broadcast(Frame.system(服务端将在 3 秒后重启)); try { Thread.sleep(3000); } catch (InterruptedException ignored) {} ONLINE.values().forEach(ClientSession::close); }));那个sleep(3000)是给消息留出真正被发出去的时间不然广播刚入队进程就退了用户什么也看不到。6.2 日志该记什么不该记什么网络服务的日志是排查的唯一抓手但记多了反而是负担。我的原则是每个连接的生命周期必须有一条完整的记录每条消息的内容只记摘要。具体来说连接建立、登录成功、登录失败重名、正常断开、异常断开这五类事件各记一条 INFO带上sessionId和昵称。消息内容本身我在调试阶段记全量上线后只记类型和长度正文只在 DEBUG 级别输出。原因很实际聊天内容涉及用户隐私日志文件里留着全量聊天记录不是个好习惯。sessionId这个字段我坚持每条日志都带上。因为昵称会变用户改名、可能重复不同人在不同时间段用同一个昵称而sessionId是连接级的唯一标识用它可以把一个连接的所有日志串成一条线。排查时先grep昵称找到sessionId再grep sessionId看全链路这个流程比什么都管用。还有一个建议把每秒的消息数、在线连接数、发送队列的平均深度这几个指标定时打到日志里比如每 30 秒一行。这几个数字放在一起看能提前发现很多问题——队列深度持续上涨就是有慢客户端的信号消息数突然掉零可能是心跳线程挂了。我在这套东西上折腾了几轮最深的体会是聊天室的代码量其实不大核心逻辑两千行以内能写完剩下的全是各种异常路径和边界处理。写的时候别急着让它能用多花点时间想想如果对面是个恶意或异常的用户会怎样——发个 10MB 的假长度头、连上就不说话、疯狂刷消息、在昵称里塞特殊字符。把这些想一遍代码的健壮性会上去一大截。真正在线上跑起来之后你才会发现用户的行为永远比你想的更有想象力。

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

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

免费获取报价 →
↑