事情是这样的我写了一个基于Java的网页聊天室功能上是正经的多用户实时通信但一直只在本地单机聊天测试过。等到要部署到公司内网给团队试用的前一天我才意识到聊天室这种东西单机跑得再好也代表不了多人在线时的真实表现。于是我把测试环境拉起来花了三天时间做了这一轮相对完整的测试包括功能验证、并发压测、异常输入、浏览器兼容性和稳定性观察。这篇就是这份测试的完整记录写给正在做或准备做同类项目的人参考。先说清楚被测对象这是一个基于Spring Boot WebSocket实现的Java多用户网页聊天室前端用的是原生JavaScript的WebSocket客户端没有引入重量级框架。功能上支持用户注册登录、公共大厅、创建/加入房间、实时文本消息收发、在线用户列表、历史消息拉取和断线重连。整个项目大概六千行代码不算复杂但涉及网络通信、并发访问、会话管理这些容易出问题的地方。聊天室这类应用有个特点功能链路不长但并发场景复杂。一个人跟自己聊天永远测不出问题两个人聊天勉强能跑通真到了几十上百人同时在线、高频发言的时候各种连接异常、消息延迟、资源泄漏才会冒出来。这也是我坚持要在测试环境完整跑一轮的原因。1. 功能测试清单多用户聊天室最容易藏在“交互”里的问题1.1 注册登录与会话保持的实测结果第一轮测的是基本功能。注册登录看起来简单但在聊天室场景里有两个点容易出问题一个是同一个账号多处登录怎么处理另一个是页面刷新后登录态能不能保持。我的实现方案是登录成功后在服务端生成tokenWebSocket握手时带上token服务端通过拦截器校验同时维护一个userId到WebSocketSession的映射。同一账号重复登录时强制踢掉旧连接。这个逻辑在单机自测时一切正常但测试中我发现了一个边界情况当用户从A房间切换到B房间时前端会先断开旧连接再建立新连接如果断开和建立之间没有做好串行化服务端可能收到一个“已失效”的token导致握手失败。最终在前端加了一个连接状态锁切换房间时确保旧连接完全关闭后再发起新连接。测试用例和结果见下用例编号用例描述预期结果实际结果LC-01新用户注册并登录登录成功跳转大厅通过LC-02同一账号在另一浏览器登录旧连接被踢下线新连接保留通过LC-03登录后刷新页面会话保持自动重连成功通过LC-04token过期后访问聊天接口返回401前端提示重新登录通过LC-05登出后立即重新登录旧会话清理完毕新会话正常通过但发现会话清理有延迟见4.2这里想提醒一句token校验逻辑最好做成无状态的不要把整个Session对象放在内存Map里不清理。测试中我遇到过登出用户仍然出现在在线列表里的情况就是因为会话Map的remove操作和WebSocket关闭事件之间没有完全同步。后面加了会话注册表SessionRegistry每次连接关闭时主动清理才算解决干净。1.2 消息收发与多房间隔离的验证消息收发是聊天室的核心。我重点验证了三件事消息能否实时到达、消息顺序是否一致、多房间之间是否隔离。实时性相对好验证WebSocket天然支持服务端推送。麻烦的是顺序问题。聊天室没有采用消息队列而是直接在内存里按房间维度维护一个消息列表收到新消息就追加并广播。单机测试时顺序没问题但在压测环境里发现了一个有意思的现象两个用户几乎同时发言时不同的客户端看到的消息顺序可能不一致。原因在于广播时服务端是逐个session推送的先推给A还是先推给B取决于session遍历顺序。这个问题的根子在“全局顺序”和“可感知顺序”的区别。聊天场景下用户只能感知到自己和他人消息的相对顺序只要每个客户端接收到的消息按服务端接收顺序排列就不会有体验问题。因此我在内存消息列表里加了全局序号sequence每条消息落地时从AtomicLong取一个递增ID客户端按ID排序展示。这样每个客户端看到的顺序都是一致的。多房间隔离也测出了真问题。起初房间消息列表是全局一个List后来改成ConcurrentHashMaproomId, List 按房间隔离。测试时我用两个浏览器窗口分别进入不同房间互发消息确认不会串房。这个环节整体通过但暴露了一个隐患如果房间不限制数量内存中的消息列表会无限增长。后面我在消息列表外层包了一层固定容量的环形缓冲每个房间最多保留最近200条消息旧的自动丢弃。1.3 在线状态和上下线通知的准确率在线状态在聊天室里的重要性不亚于消息本身。我需要知道谁在线、谁离线了并且要实时展示在客户端。最初的实现依赖WebSocket的close事件来判断离线。但测试发现一个典型问题用户直接拔网线、电脑休眠、或者手机切换Wi-Fi时TCP连接不是立刻断开的close事件可能要几分钟后才触发。这就导致用户明明已经不在线系统里还显示在线发消息也显示“已送达”但实际上对方收不到。为了解决这个问题我引入了心跳机制客户端每30秒发送一个ping帧服务端收到后回复pong如果服务端连续90秒没有收到某个客户端的ping就判定该用户离线并广播下线通知。测下来发现心跳机制能显著缩短异常离线的感知时间但要注意两个参数不能设得太激进间隔太短会白白增加服务端压力超时太短容易误杀网络抖动用户。我测试时的参数是30秒心跳、90秒超时团队内网环境下误杀率在可接受范围内。2. 并发压测模拟200人同时聊天时系统先崩的还是我先崩2.1 压测方法和压力模型功能测试通过后我开始做并发压测。这一步是聊天室测试的重点也是很多Java初学者最容易忽略的部分代码在单机时跑得好好的一上并发就开始卡顿、丢消息甚至直接OOM。我采用了两种压测方式并行一种是JMeter的WebSocket Sampler插件用来做标准化的并发连接和消息发送统计另一种是我自己写的一个Java客户端压测程序基于Java-WebSocket库方便模拟更真实的聊天行为。两个工具配合使用JMeter负责输出可复现的指标数据自写客户端负责模拟“真人用户”的发言节奏。压力模型我定义了三个档位档位在线人数平均发言间隔场景说明低强度50人3秒/人日常群聊大部分时间在看中强度100人2秒/人活跃讨论消息比较密集高强度200人1秒/人大型活动刷屏场景极限测试300人1秒/人验证系统极限在哪里压测时长每组持续10分钟同时监控服务端CPU、内存、GC时间、活跃连接数、线程池活跃度等指标。这里有个实操建议压测时不要只盯着响应时间还要看服务端的线程数和连接数变化曲线。聊天室是长连接应用连接数上去之后不会像HTTP请求那样快速释放连接泄漏只有在持续压测一段时间后才会暴露。2.2 并发数据与瓶颈分析压测数据汇总如下。这些数据基于我的测试环境单机部署4核8GJDK 17Spring Boot内嵌Tomcat默认线程池配置。并发连接数消息延迟P50消息延迟P95消息延迟P99丢消息率连接失败率5018ms42ms80ms0%0%10035ms110ms210ms0%0%200120ms480ms1.2s0.2%0%300800ms3.5s8s2.1%3.2%从数据可以明显看到两个拐点200并发时P99延迟升高到1.2s300并发时系统整体进入不稳定状态。这个结果在意料之中Tomcat默认的线程池上限大约就是200WebSocket长连接会长期占用线程300并发时连接开始被拒绝。不过延迟飙升还有另一个原因而且是很多人容易忽略的广播机制。我当时调用WebSocketSession.sendMessage是同步的服务端要把一条消息发送给100个在线用户就需要依次调用100次sendMessage。如果其中某个客户端网络慢这个同步调用就会阻塞当前线程拖慢其他所有用户的消息推送。这个过程在压测中会形成“木桶效应”一个慢客户端拉低所有人的体验。这个问题的修复方案我放在第4部分详细讲这里先按下不表。压测中还发现200并发时服务端堆内存从512MB逐渐涨到了800MB持续压测过程中没有回落。初步怀疑是离线用户会话没有被及时回收这个在4.2节一并排查了。2.3 长时间稳定性观察压测数据只能反映短时间内的表现聊天室类应用还有一个关键指标长时间运行下内存和连接数是否稳定。我用100个虚拟用户在线、每分钟每人发5条消息的低强度模式跑了8个小时。监控了JVM堆内存、非堆内存、活跃连接数和GC频率。结果如下时间点堆内存使用活跃连接数GC频率每分钟延迟P95第1小时480MB1001250ms第4小时650MB10018120ms第8小时820MB10025230ms堆内存从480MB一路涨到820MB不回落这基本可以确定存在内存泄漏。排查看下来问题出在历史消息的环形缓冲区每个房间的最近200条消息虽然是固定容量但我没有限制房间数量压测过程中我创建了50多个临时房间这些房间和对应的消息列表都没有被回收。修复方式是在房间空闲超过30分钟时自动销毁同时把房间总数上限设为100。修复后重新跑了8小时堆内存稳定在500-550MB区间。3. 从测试里揪出来的三个高价值故障与排查过程3.1 中文消息乱码浏览器里好好的压测脚本里就乱这个坑我一开始完全没想到。手动在浏览器聊天页面里发中文消息一切正常但用Java客户端压测程序发中文服务端收到的全是乱码而且数据库里存的也是乱码。排查链路是这样的一开始我怀疑是服务端的解码问题检查了Spring WebSocket的配置确认TextMessage默认按UTF-8解码没问题然后怀疑是数据库连接串没配编码检查后也没问题最后回到压测脚本身发现是Java客户端发送消息前没有设置消息体编码格式。Java-WebSocket库在处理文本消息时默认使用String.getBytes()获取字节数组而我的压测程序所在环境的默认字符集不是UTF-8发出去的消息直接按系统默认字符集编码了。修复就一行代码在发送前显式指定字符集session.sendText(new String(message.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8));这个问题的教训是只要涉及网络传输编码格式一定要显式声明不能依赖环境默认值。尤其是做压测脚本、写消息队列的生产者、对接第三方接口时任何“默认”都可能在特定环境下翻车。我在服务端入口处也加了强制校验错误编码的消息直接返回错误码而不是默默存下来。3.2 断线重连后看不到历史消息第二个问题也很有意思。正常在线收发消息完全正常但只要网络断开再恢复重连成功后客户端收不到断开期间的消息甚至连进入房间时的历史消息列表都是空的。我当时的第一反应是房间ID传错了但检查前端日志后发现房间ID正确。继续排查发现服务端在接收重连请求时没有根据用户身份恢复他之前的房间会话。我的原始设计是用户进入房间时服务端记录“用户A在房间room1”并加入对应的WebSocket广播列表。断开重连后客户端的JS逻辑会重新发起“进入房间”的请求但服务端发现用户A的WebSocketSession对象已经变成新的了而旧的Session信息还残留在广播列表里导致新Session没有收到后续消息也没有被加入历史消息拉取的范围。修复方法是重写会话恢复逻辑WebSocket握手成功后服务端根据token解析userId然后从会话注册表里查这个用户断线前的房间信息自动重新加入对应的广播列表并把重连时间点作为增量消息拉取起点。关键代码示意如下OnOpen public void onOpen(Session session, HeaderParam(token) String token) { User user authService.parseToken(token); String lastRoomId sessionRegistry.getLastRoomId(user.getId()); if (lastRoomId ! null) { roomManager.joinRoom(user.getId(), lastRoomId, session); // 拉取最后一次看到的消息ID之后的新消息 ListMessage missed roomManager.getMessagesAfter(lastRoomId, sessionRegistry.getLastMsgId(user.getId())); sendMissedMessages(session, missed); } }修复之后断线重连的测试用例全量通过。这里有一个经验值得分享聊天室应用不要只把WebSocketSession当成“一个连接”要把它理解成“一个用户的实时通道”通道是会换的但用户身份不能丢。凡是涉及会话的地方都要以用户维度做关联而不是以Session实例做关联。3.3 200并发以上消息积压导致延迟飙升第2部分压测数据里200并发时P99延迟飙升到1.2秒就是这个原因。所有消息广播都是同步发送服务端处理消息的线程在依次推送过程中一旦遇到慢客户端后续所有消息都被阻塞形成消息积压。我在压测过程中观察到的现象是消息产生速率基本平稳但服务端线程池活跃度达到了95%以上大量线程卡在Socket写入上。定位到根因后我决定把同步发送改成异步发送消息进入 BlockingQueue由单独的线程池负责消费并推送为了保证同一个房间内的消息有序我按照房间ID做分片哈希让同一个房间的消息始终由同一个消费线程处理避免并发发送导致顺序错乱。改造后的核心逻辑大致如下public void broadcast(String roomId, Message message) { ExecutorService roomExecutor executorRouter.route(roomId); roomExecutor.submit(() - { for (WebSocketSession session : roomManager.getSessions(roomId)) { try { synchronized (session) { session.sendMessage(new TextMessage(message.toJson())); } } catch (IOException e) { roomManager.removeBrokenSession(session); } } }); }注意这里仍然用了synchronized (session)是为了避免同一个Session被多个线程并发写导致报错。实测改造后200并发场景的P99延迟从1.2s降到了230ms300并发时也不再出现大面积积压只是P95仍在700ms左右属于可接受范围。4. 异常输入与兼容性测试总有人会发1MB的消息也总有人用旧浏览器4.1 消息长度边界、空消息和注入字符聊天室面向的是普通用户用户不会按你的设计边界去操作。我专门设计了一批“找茬”测试用例测试内容具体输入测试结果处理方式空消息只发空格、只发换行前端拦截服务端也返回参数错误双端校验超短消息单字符正常收发通过超长消息64KB、1MB64KB正常1MB导致连接断开调整Tomcat maxTextMessageSize配置特殊字符script、、emoji、SQL片段消息展示为纯文本未触发XSS前端转义高频消息同一用户1秒内连发20条消息全部到达但延迟变大前端按房间维度限流后端做了背压这里重点说下超长消息的坑。Tomcat对WebSocket文本消息默认大小限制是8KB左右超过限制会直接断开连接。我一开始没改配置测试时粘贴一大段文本消息连接就断了这个体验非常糟糕。我把maxTextMessageSize调到了2MB但调到2MB后又带来新问题如果单条消息真的达到2MB服务端广播时内存压力会很大。所以最终方案是服务端接收上限2MB超过则拒绝广播前如果消息大于50KB走独立的慢速通道避免拖慢正常小消息的推送。4.2 不同浏览器和网络切换场景浏览器兼容性测试我用了Chrome、Firefox、Safari、Edge四个浏览器覆盖了主流的WebSocket实现。结果如下浏览器版本测试结果备注Chrome120全部通过WebSocket实现最规范Firefox121全部通过无异常Safari17消息收发正常旧版Safari对2MB消息支持不友好Edge120全部通过基于Chromium与Chrome一致Safari的问题需要单独说明。我借了一台老版本Safari14之前测试时发现使用new WebSocket(url)建立的连接一直报错。查了资料后才明白旧版Safari对WebSocket握手协议中的扩展头支持不完全遇到大帧数据时会静默丢弃。对于老版本浏览器的兼容方案业内通常使用SockJS作为降级方案我这边因为主要面向内网团队浏览器版本可控所以没有引入额外依赖。如果读者你做的聊天室要面向公网用户建议在前端加一个兼容性检测旧浏览器自动提示升级或者使用Socket.IO这样的库。网络切换场景我也跑了测试手机连接Wi-Fi然后切到4GWebSocket连接立刻断开客户端30秒内自动重连成功断线期间的消息在重连后通过增量拉取补齐。这里建议所有聊天室客户端都要实现“重连后补拉增量消息”的逻辑否则网络抖动一下用户就丢消息了。5. 测试结论与上线建议5.1 测试数据汇总与最终结论三轮测试做完整体结论是系统可以小规模上线但有一个前提——先把本文提到的P0问题全部修复。测试维度主要结果风险评估上线建议功能测试核心功能全部通过断线重连发现1个Bug中修复后上线并发性能200并发可稳定运行P95在500ms内中建议先内部50人试用长时间稳定性修复内存泄漏后8小时平稳运行中监控内存和GC指标异常输入数据校验完善未发现XSS和崩溃低通过浏览器兼容现代浏览器全部通过旧版Safari有兼容风险低内网可控5.2 上线前的改进清单按照优先级排序我在上线前完成了这些整改优先级改进项说明P0消息广播改为异步发送解决慢客户端拖垮全房间的问题P0断线重连自动恢复房间会话解决重连后丢消息的问题P0会话注册表增加定期清理解决僵尸用户和内存泄漏P1房间空闲自动销毁限制内存增长P1统一消息编码校验避免乱码和数据错乱P1前端加消息限流防止单用户刷屏影响服务器P2接入基础监控指标在线人数、消息速率、GC曲线P2消息持久化到数据库为重启后恢复做准备5.3 这轮测试里我最后悔没早点做的两件事测试做完了踩了坑也填了坑如果让我重新安排这三天的时间有两件事我会提前做。第一件事是早一点引入监控指标。我一开始只关注功能和压测结果等到排查内存泄漏的时候才发现连最基础的在线人数曲线、消息吞吐曲线都没有全靠临时加日志和反复压测复现浪费了大半天时间。用Actuator Prometheus把关键指标先接上排查问题的速度会快一个量级。第二件事是应该先做异常网络场景的测试而不是先做高并发压测。并发压测只能暴露性能上限但断线重连、弱网切换才是聊天室日常运维中最常见的故障来源。这两类问题的优先级在真实使用场景里应当排在“200人同时发消息”之前。测试并不是一个“跑一遍看结果”的动作它更像是给系统做一次全面体检。体检报告出来了才能安心把系统交给用户用。我这个聊天室现在已经顺利进入团队内测阶段希望这篇记录能给你一些参考价值。