这阵子给某项目组的IM消息系统做了一次完整的 WebSocket 压测,从最底层的 ChatMessage.proto 字段梳理开始,一直到输出压测报告并推动服务端完成两个瓶颈优化,前前后后跑了将近两周。这中间踩了不少坑,也有很多值得记录的判断和取舍。今天就把整个流程完整复盘一遍,从协议定义、工具选型、脚本设计,到真正的压测执行和报告解读,全部串起来讲清楚。如果你正在准备对自己家的IM或任何长连接消息系统做压测,这篇文章应该能帮你少走不少弯路。
多说一句,这篇文章的侧重点不在“怎么把JMeter跑起来”这种入门教学,而是“在真实IM这种复杂消息场景里,怎么用JMeter把WebSocket压测做对”。里面涉及的一些参数、写法、排查思路,都是我实际跑过验证过的,可以直接拿去参考。
1. 项目背景与压测目标拆解
1.1 压测对象是条什么链路
先交代一下被测系统的背景。这是一个面向移动端的 IM 消息系统,核心链路是:
客户端通过 WebSocket 接入消息网关,网关做连接管理、鉴权、心跳,然后把消息投递到后端消息服务;后端服务负责消息路由、离线存储、回执生成,最后把消息推送回目标客户端。这里说的“客户端”,实际是很多个业务端,包括App、小程序、H5,它们的共性都是走同一个 WebSocket 网关接入。
这次压测的重点落在两个地方。第一个是网关接入层,关心的是同时能撑住多少条 WebSocket 长连接,连接建立的速度怎么样,连接断开重连是否平滑。第二个是单条消息从“客户端发送上行”到“目标客户端收到下行”这条主链路的吞吐能力,包括上行消息接收、消息路由转发、下行消息推送,整个过程在服务端怎么流转、耗时多少、瓶颈在哪。
聊压测之前一定得先把被测对象搞清楚,不然压了半天也不知道压的是什么东西。我这次是先拉着服务端同学对齐了核心链路,把网关、路由、存储、推送四个环节的数据流图画出来,再决定压测脚本要模拟哪些行为。后面做报告的时候你会发现,没有这个环节对齐,你拿到的TPS再高也没有人认。
1.2 为什么用 JMeter 来做这个事
很多团队压 WebSocket 会先想到自己写压测工具,甚至用代码起几百个协程去怼服务端。这种做法不是不行,但有几个现实问题:脚本没有统一编排,结果没有统一统计,线程和连接的管理要自己处理,出问题以后不好排查。而且一旦需要模拟带登录态的用户、带参数的消息内容,自己写工具的复杂度就上来了。
JMeter 的好处在于它是一个成熟的压测框架,线程模型、采样器、断言、监听器、报告输出都是现成的。它原生支持 HTTP,WebSocket 则需要通过插件扩展。我们这次用的就是 JMeter 5.x 配合 WebSocket Sampler 插件,再加上 JSR223 Sampler 来跑自定义的 protobuf 序列化逻辑。整个压测脚本全部跑在 JMeter 里,最后的聚合报告直接出图表,服务端同学看得懂,也不用来回扯皮。
选择 JMeter 还有一个很实际的原因:JMeter 的 JSR223 Sampler 允许直接写 Java 代码,这意味着我可以把一个“模拟真实客户端”的完整逻辑塞进去——创建 protobuf 消息、签发 token、计算序列号、维护心跳,全部在 Sampler 里完成。WebSocket 插件负责的是连接和收发帧,自定义逻辑负责的是业务模拟。这个组合在灵活性上已经足够覆盖大多数 IM 压测场景。
1.3 压测指标是怎么定义的
压测报告里如果没有明确的指标定义,那做出来的报告就是一堆数字堆在一起,没有说服力。所以在一开始,我们就定好了这次要关注的几个核心指标。
- 连接能力:网关能够维持的最大 WebSocket 长连接数,以及连接建立的速率(每秒能完成多少次握手)。
- 消息下行 TPS:单位时间内目标客户端实际收到的消息条数,这是用户感知最直接的指标。
- 消息处理耗时:从客户端发出消息到服务端下发回执之间的耗时,通常会看 RT 的 P50、P90、P99 分位数。
- 错误率:包括连接失败率、消息发送失败率、回执超时率。任何压测报告里错误率必须单独呈现,不能只看TPS。
- 服务端资源:连接数、CPU、内存、GC、线程池活跃度,这些指标用来定位瓶颈发生在哪一层。
指标定完以后,我们把它和业务指标做了一次映射。比如当时业务方的要求是“高峰期在线用户 5 万,每人每 10 秒产生一条消息”,那倒推下来消息下行 TPS 至少在 5000 以上,再考虑到峰值波动,我们目标定在 8000 TPS 才算安全。这个数字后来成了压测是否通过的基准线。
2. 压测环境与工具链准备
2.1 JMeter 版本与 WebSocket 插件选型
JMeter 版本的选择上,我建议直接用 5.4 以上的稳定版本,JDK 用 11 或 17。不要小看这两个基础选型,后面跑长时间压测的时候,JDK 8 在默认 GC 参数下很容易出现 Full GC 导致压测机抖动,直接影响结果。我们一开始就是在某台 JDK 8 的压测机上跑,跑 20 分钟以后 JMeter 的 GC 越来越频繁,后面换了 JDK 17 才稳定下来。
WebSocket 插件的选型是另一个关键点。JMeter 本身不带 WebSocket Sampler,需要从插件管理器安装 websocket-sampler 插件。这个插件有两个核心组件:WebSocket Open Connection(负责建立连接)和 WebSocket Request Response Sample(负责收发消息帧)。另外还有 WebSocket 心跳定时器之类的小工具。安装完成后,JMeter 的采样器列表里会出现 WebSocket 相关组件,就可以正常使用了。
有一个值得注意的细节:WebSocket Sampler 插件在默认情况下发的是文本帧,如果要发二进制帧(protobuf 场景),必须显式指定请求数据为二进制,并勾选对应的选项。这个细节放到后面讲报文构造的时候再展开。
2.2 压测环境规划和参数设计
压测环境我分两个维度来说。
第一个是服务器端。被测服务部署在测试环境,配置是 8C16G,Java 服务,使用 Netty 作为 WebSocket 的底层通信框架。测试环境的网络是内网千兆,压测机和被测服务之间没有经过外网,这样可以排除公网带宽和延迟的干扰,专注于验证服务自身的处理能力。
第二个是压测端。JMeter 本身是 Java 应用,默认启动参数经常不够用,尤其是跑长连接场景时,每个 WebSocket 连接在 JMeter 侧都要占用一个线程,线程一多,默认堆大小极易出问题。我这边是直接修改了 JMeter 的启动参数,把堆内存调到 4G,同时明确禁用 GC 日志之外的额外输出,避免压测机自己成为瓶颈。
这里强调一下线程数和连接数的关系。JMeter 的 WebSocket Sampler 是每个线程维护一个 WebSocket 连接,所以“1000 个线程”就等于“1000 条连接”,不是“1000 并发请求”。这条对应关系在做连接数压测的时候非常重要。你要测 2 万连接,JMeter 就要起 2 万个线程,这对压测机是很大的压力。我们在设计时考虑了两台压测机做分布式压测,每台跑 1 万线程,才把连接数推到 2 万这个量级。
2.3 服务端监控与埋点准备
压测不只是看 JMeter 这边的数字,服务端必须有一套监控手段。我们这次用到的服务端观测手段包括:
- JDK 自带工具:jstack 看线程状态、jmap 看堆内存、jstat 看 GC 情况。
- 操作系统层面:top 和 vmstat 看 CPU 和内存。
- 连接数观察:因为网关用的是 Netty,可以通过自定义的 Metrics 接口实时获取当前连接数、消息处理数、线程池活跃度。
为什么要把监控做在前面?因为压测遇到性能瓶颈时,你只能靠服务端指标去定位是 CPU 计算密集、内存不足、线程池打满,还是连接队列溢出。没有这些数据,你会陷入“TPS 上不去,但不知道问题在哪”的尴尬局面。我们实际压测中遇到的三个关键瓶颈全部是靠服务端监控定位出来的,这个后面详细讲。
2.4 关于测试数据的准备
压测之前还有一个很容易被忽略的环节:准备足够多的测试用户和会话数据。IM 场景里,压测需要使用真实的用户 ID、token、房间号,如果全部用同一个用户,服务端的单用户串行处理逻辑会直接把 TPS 卡死,结果完全失真。
我这边准备了 2 万个测试用户,提前导入到服务的用户表里。每个用户有一个唯一的 user_id,对应的 token 也是预先签发好的。另外准备了 1000 个模拟群聊房间,每个房间有 50 个成员,用于模拟群消息广播场景。这些数据放在 CSV 文件里,压测脚本启动时按线程读取,保证每个模拟用户都有独立的身份。
3. ChatMessage.proto 报文解析与 JMeter 消息构造
3.1 ChatMessage.proto 的关键字段拆解
聊到 IM 消息系统,协议定义是所有事情的起点。我们的 ChatMessage.proto 定义在很多核心模块间共用,是一个标准的 protobuf 消息体。它的简化结构大致是这样的:
syntax = "proto3"; package im; message ChatMessage { string message_id = 1; string from_user = 2; string to_user = 3; string room_id = 4; uint32 msg_type = 5; int32 content_type = 6; bytes content = 7; map<string, string> extra = 8; int64 timestamp = 9; uint32 seq = 10; int32 ack_status = 11; }看起来字段不多,但每个字段怎么填、能不能复用、会不会冲突,都直接影响压测脚本的编写。
message_id 是全局唯一消息ID,压测时必须保证不重复,否则服务端会有去重逻辑,直接丢弃重复消息。我们采用时间戳加线程编号加自增序列号的方案,确保同一线程和不同线程之间都不会冲突。from_user 和 to_user 是从 CSV 参数化出来的,前者是当前线程的用户,后者根据场景不同可能是另一个用户(单聊)或一个房间 ID(群聊)。room_id 只在群聊场景下有意义,单聊时留空。
msg_type 用于区分消息类型,例如文本消息、图片消息、系统消息。压测时一般固定使用文本消息,也就是 msg_type=1。content_type 表示内容格式,固定在 body 里放一段有代表性的文本内容。extra 是一个 map,用来携带扩展属性,压测时可以塞一个固定的设备信息,不需要太复杂。
timestamp 和 seq 这两个字段要单独说明。timestamp 是客户端生成消息的时间戳,服务端会拿它和服务器时间做差来计算消息延迟。压测构造消息时,timestamp 不能是固定的,必须取当前时间,否则延迟统计完全不准。seq 是客户端发送序列号,同一个连接上的 seq 必须严格递增,这是我们脚本里最容易写错的地方之一。ack_status 用于回执状态,压测发消息时置为 0,表示待确认,收到回执后由服务端改写。
3.2 在 JMeter 中序列化 protobuf 消息的两种思路
确定了协议结构,就要解决一个实际问题:JMeter 怎么把 protobuf 消息发出去。
第一条思路是使用二进制帧手工拼字节。第二条思路是加载 protobuf 的 jar 包,在 JSR223 Sampler 里用 Java 代码构造消息体然后序列化。我的建议是直接用第二种。手工拼二进制字节极易出错,而且消息字段一增加就要重新改字节偏移,可维护性极差,压测脚本本身就会变成一个事故隐患。
用 JSR223 Sampler 之前,需要先把 protobuf-java 的 jar 包放到 JMeter 的 lib/ext 目录下,同时把你自己的 ChatMessageProto 编译出来的 Java 类也打包成 jar 放进去。然后 JSR223 Sampler 的语言选择 Java,脚本里直接引用生成的 Builder 类来构造消息。
下面是用 JSR223 Sampler 构造 ChatMessage 的参考写法:
import im.ChatMessageProto.ChatMessage; import java.time.Instant; import java.util.UUID; String messageId = UUID.randomUUID().toString().replace("-", "") + "_" + vars.get("threadNum") + "_" + (counter.incrementAndGet()); String fromUser = vars.get("from_user"); String toUser = vars.get("to_user"); String roomId = vars.get("room_id"); long now = Instant.now().toEpochMilli(); ChatMessage msg = ChatMessage.newBuilder() .setMessageId(messageId) .setFromUser(fromUser) .setToUser(toUser) .setRoomId(roomId) .setMsgType(1) .setContentType(1) .setContent(ByteString.copyFromUtf8("im-load-test-message")) .setTimestamp(now) .setSeq(seq.incrementAndGet()) .setAckStatus(0) .build(); byte[] bytes = msg.toByteArray(); vars.putObject("request_payload", bytes);注意几个容易出错的地方。第一,vars.putObject 存的是 Object,后面 WebSocket Sampler 要读取 byte[] 类型的数据,不能存成 String,否则插件会按文本帧发送,服务端反序列化直接失败。第二,message_id 的生成必须保证在跨线程场景下依然唯一,用 UUID 加线程编号加自增计数是最稳妥的方案。第三,seq 的递增不能依赖单线程私有变量,要在 JSR223 里使用全局原子变量,否则多个采样器并发写同一个字段会乱序。
3.3 二进制帧怎么交给 WebSocket Sampler
消息构造完成以后,下一步是把它交给 WebSocket Sampler 发送。在 JMeter 里,WebSocket Sampler 的请求数据框支持从变量读取。如果上面 JSR223 Sampler 已经把 byte[] 存到了 request_payload,那么 WebSocket Sampler 的 Request Data 可以填写 ${request_payload},并将数据类型设置为 Binary。
WebSocket Open Connection 组件也需要相应的配置。连接地址是 ws://测试环境网关地址/ws,路径里带上 token 参数,网关通过 token 识别用户身份。连接超时一般设 10 秒,防止连接建立阶段卡住导致线程堆积。
这里要特别提醒一点:如果 WebSocket Sampler 的 Response Data 需要读取服务端返回的内容,需要把 Read Response 设置为 true,并设置一个合理的 Read Timeout。IM 场景中,服务端返回消息不是即时的,客户端发一条消息后可能要等几毫秒甚至几十毫秒才能收到回执。Read Timeout 如果太短,会出现大量“等待回执超时”的误报;如果太长,又会让采样器长时间占用线程。我们最终按业务指标把 Read Timeout 设为 5 秒,这样既不会误报,也不会卡线程。
4. JMeter WebSocket 压测脚本设计与实现
4.1 压测场景的脚本结构怎么搭
一个完整的 IM 压测脚本,不能只有一个 WebSocket Sampler 来回发消息,它至少得包含:获取身份数据、建立连接、发送登录消息、等待登录回执、发送业务消息、接收消息、维持心跳、统计结果。这么一长串逻辑,在 JMeter 里更像一个小型工作流。
我设计的 Thread Group 结构大概是这样的:
- 配置元件:CSV Data Set Config,负责读取 user_id、token、room_id 等参数。
- JSR223 预处理:根据当前线程的用户信息构造登录报文。
- WebSocket Open Connection:建立连接。
- JSR223 Sampler:发送登录消息,并设置读超时等待登录回执。
- WebSocket Request Response Sampler:发送业务消息(二进制帧)。
- WebSocket 心跳定时器:定时发送心跳消息,保持连接活跃。
- 断言:校验回执中的 message_id 是否与发送的消息匹配。
第 4 步和第 5 步的区别要理解清楚。登录消息是一次性的,业务消息是循环发起的,所以它们在脚本里的位置不同,采样器的循环次数控制也不同。登录消息放在 Thread Group 的 Setup 线程组里执行一次,业务消息放在主循环线程组里反复执行。
4.2 参数化与会话数据的管理
长连接压测里最怕的就是把所有用户压成一个用户。CSV Data Set Config 的配置很简单,但要注意 Sharing Mode 要设置为 Current Thread,这样每个线程会从 CSV 文件里取不同行,而不是所有线程共用一行。我们准备的 CSV 是三列:user_id、token、room_id,1000 个用户的数据量非常小,JMeter 加载完全没压力。
这里我要分享一个实践细节:登录态的 token 不一定需要真的通过 HTTP 接口获取。压测场景下,可以先离线批量生成一批 token 写入 CSV,或者编写一个 JSR223 预处理脚本,在本地按规则生产 token。真实 IM 系统的登录可能依赖短信验证码、外部认证服务,压测时如果每条线程都走完整登录流程,压测机的资源会被大量无关逻辑消耗。我们当时是直接把 token 生成逻辑做成一个小的 Java 工具类放到 lib 里,压测脚本里通过调用工具类快速生成 token,既模拟了真实登录态,又不拖慢压测脚本。
4.3 连接与登录阶段的关键时序
WebSocket 和 HTTP 不一样,连接建立以后不是马上就可以发业务消息的。我们的网关要求客户端首先要发一条 login 消息,服务端返回 login_ack 后,才能收发普通消息。这个时序在压测脚本里必须严格遵守,否则服务端直接断开连接。
所以我在每个线程的逻辑里做了两件事。第一,发完 login 消息后,强制采样器等待服务端回执,确认拿到 login_ack 才继续;第二,判断回执内容,如果回执里的 result_code 不为 200,就记录一次连接失败,并且终止当前线程,防止带病连接继续压测污染数据。
你可能会问,为什么要这么严格?因为如果有一批连接没完成握手就进入业务逻辑,服务端会把这些连接标记为异常连接,它们并不会真正参与消息收发。最后压测报告里的连接数会很高,但实际有效请求很低,TPS 指标完全失真。我在第一次压测时就因为这个吃了亏,后来直接把这一步写成了一个断言才解决。
4.4 心跳机制的模拟
WebSocket 长连接场景下,心跳必不可少。大多数 IM 服务端会设置一个空闲超时时间,比如 60 秒内没有收到客户端消息就断开连接。压测脚本里如果不发心跳,跑几分钟后所有连接都会被服务端主动断开。
JMeter 的 WebSocket 插件里带了一个 WebSocket Ping Pong Sample,可以直接发 WebSocket 协议层的 ping 帧。但实际 IM 系统的心跳往往不是协议层 ping,而是应用层的心跳消息,也就是向服务端发送一条 msg_type=0 的业务消息。因此我们是在 JSR223 Sampler 里循环构造心跳消息,用固定 Rate Timer 控制每 30 秒发送一次。
使用定时器时有另外一个陷阱:JMeter 的定时器作用于它所在的 Sampler 之前,如果把它放在业务消息 Sampler 后面,会拖慢业务消息的发送节奏,导致 TPS 被压测脚本自身的定时器卡住。正确做法是让心跳消息单独走一个逻辑,不要和业务消息混在同一个采样器流程里。我这边是把心跳放在 WebSocket Sampler 成功之后的子采样器里,通过循环控制器每分钟执行一次,保证它不影响主流程的吞吐。
4.5 单聊、群聊、广播三种压测模式
IM 系统的压测不能只测“大家都往同一个房间里发消息”这一种情况。我们根据业务场景把消息发送模式分成了三种:
- 单聊模式:每个线程用户给一个固定的对方用户发私聊消息,适合验证点对点消息链路的数据隔离和实时性。
- 群聊模式:每个线程用户向自己所在的群聊房间发消息,房间内其他成员都会收到,适合验证消息广播、扇出逻辑。
- 公屏模式:所有用户都在同一个大房间里发消息,相当于集中广播,对网关的下行推送压力最大。
三种模式的压测数据不能混在一起,否则报告没法解释。我是在 JMeter 里建了三个独立的测试计划,通过命令行参数选择执行哪个场景。单聊模式跑出的 TPS 通常是三种里最高的,因为消息只推给一个人;公屏模式的 TPS 虽然看着高,但每一条消息要被广播给大量连接,服务端实际转发压力要大得多。这个差异在最终报告里一定要写清楚,不能只看上行 TPS 就下结论。
5. 压测执行过程与真实瓶颈案例
5.1 执行策略:先温压再阶梯加压
压测执行不是一上来就开 2 万线程。我的标准流程是先做一轮小规模温压,确认脚本没有问题。比如先用 100 个线程跑 5 分钟,观察 JMeter 日志有没有连接失败、断言失败、超时告警。如果这轮就有问题,直接停下来修脚本,不要浪费后面的时间。
确认脚本稳定后,再进入正式的阶梯加压。我按 500、1000、2000、5000、10000 用户五档递增,每档持续 10 分钟,记录稳定阶段的指标。阶梯加压的好处是能够看到系统从“容量充足”到“接近瓶颈”再到“突破瓶颈”的完整变化过程。报告里如果能画出这样的曲线,读者一眼就能看出拐点在哪。
每档压测结束以后,我会留两分钟观察服务端指标是否回落,再启动下一档。如果服务端资源一直没有回落,说明可能存在资源泄漏,这时候继续加压会把系统压挂,数据也没有意义。
5.2 第一次瓶颈:连接数上来以后握手失败率飙升
第一个真实瓶颈出现在连接数压到 10000 以上的时候。现象是 JMeter 里出现了大量 WebSocket 连接失败,错误信息是连接超时。但看服务端 CPU 和内存都在正常范围,说明问题不在服务端处理能力上,而是连接接入层的某些配置到达了上限。
用 jstack 抓取服务端线程状态后,发现大量 Netty 工作线程阻塞在同步等待新的连接 channel 上。进一步查下来,问题出在服务端 TCP 连接的 accept 队列过短。Linux 系统里每个监听 socket 有一个 accept 队列,用来暂存尚未被应用层 accept 的连接,队列满了以后,内核会拒绝新的连接请求。默认队列长度往往只有 128,对普通服务足够,但对 10000 并发连接来说完全不够。
解决办法很直接:修改 TCP backlog 参数,同时调整 Netty 的 boss 线程组处理连接的配置。我们把 backlog 调到 4096 后重新压测,连接失败率直接降到 0。这个案例很有典型意义,IM 长连接场景下,服务端代码本身的线程池可能没打满,但底层连接队列是第一个瓶颈。
5.3 第二次瓶颈:CPU 没满但 TPS 上不去
第二个瓶颈比较隐蔽。压测到 2000 用户单聊消息场景时,服务端 CPU 使用率只有 55% 左右,但消息处理 TPS 始终上不去,稳定在 3000 左右。这时候 CPU 没满,内存正常,线程池也没打满,但吞吐就是卡住不动。
我们怀疑是某个流程里的串行锁或单线程队列拖住了整体吞吐。用 jstack 连续抓了几次线程栈,发现一个现象:有一个业务线程池里的线程几乎全部处于 BLOCKED 状态,等待的锁对象指向同一个地方。查看代码后发现,消息发送模块里一个用来做全局计数的 ConcurrentHashMap 操作,被包在了一个 synchronized 块里,而且这个锁的作用范围覆盖了消息序列化之后,实际上锁内代码是很短的,但因为并发量大,锁竞争导致线程大部分时间都在等待。
去掉了不必要锁保护,把计数操作改成原子类之后,同样压力下 TPS 从 3000 提升到了 7500 左右,CPU 使用率也上到了 80% 以上。这个案例的启发很大:压测发现了自己在生产环境很难发现的并发瓶颈,这是压测最大的意义之一。
5.4 第三次瓶颈:长时间压测发生内存泄漏
第三个问题是长时间压测跑出来的。我们在 10000 连接的场景下连续压了 30 分钟,服务端内存开始缓慢增长,最终触发了 OOM。查 JVM 堆 dump 后定位到,消息网关在处理一条消息时,把消息体放入了本地缓存,但不是按 message_id 做过期淘汰,而是简单地只增不删,导致慢速积压。
定位到具体的业务原因后,修复也比较快。但我想强调的是:压测不能只跑十几分钟就收工。很多内存类问题、连接泄漏类问题都需要长稳测试才能暴露。我们后面的每次版本发布前,都会保留一个“10000 连接 8 小时长稳”的压测用例,专门用来排查稳定性问题。
5.5 压测报告怎么看:指标对照与拐点判断
这部分是很多同学问得最多的。JMeter 的聚合报告给了一堆字段,哪些值得看?
- Average、Median、90% Line、99% Line:这些是响应时间关键分位数。重点看 99% Line,它反映的是长尾延迟。如果 P99 明显比 P50 高出一个数量级,说明存在少数消息被卡在某个慢路径上,比如等待锁、等待队列。
- Throughput:单位时间完成的请求数。IM 场景里一般看 messages/sec。
- Error %:错误率。连接失败、断言失败、超时都算错误,错误率超过 1% 基本可以判定当前档位不支持。
- 连接数曲线:如果服务端能维持的长期稳定连接数和 JMeter 线程数一致,说明连接能力没有问题;如果压测中途连接数掉线,大概率是心跳或者空闲超时。
报告里需要包含的一个关键内容是指标拐点。比如随着用户数从 1000 涨到 5000,TPS 几乎线性增长,说明系统还处于有余力的状态;继续增加到 8000,TPS 增长变缓,P99 开始明显抬升,这时候就说明接近瓶颈了;再往上加用户,TPS 开始下降,P99 突跳,这意味着已经过载。报告里把这些拐点写清楚,服务端同学才会真正重视压测结果。
6. 常见问题与排查技巧实录
6.1 WebSocket 连接频繁被服务端断开
现象:压测运行一段时间后,JMeter 里的连接出现大量断开重连。原因大多是心跳超时。服务端如果规定 60 秒内没有任何消息就断开连接,客户端必须在 60 秒之内至少发一次心跳。需要注意,这里的“任何消息”包含 WebSocket 协议层的 ping 帧吗?很多系统是不包含的,必须发应用层业务心跳。排查时用 Wireshark 抓包看连接断开前是否有心跳帧,一目了然。
6.2 JMeter 压测机负载过高导致结果失真
跑 10000 线程以上时,压测机可能先扛不住。CPU 打满、JVM 频繁 GC,都会导致采样器本身变慢,进而拖低压测结果。遇到这种情况,优先考虑分布式压测,把线程分配到多台压测机上。如果只有一台机器,可以减少不必要的断言和监听器,尤其是图形化监听器,非常消耗资源。
6.3 protobuf 字段构造错误导致消息丢死
服务端解析 protobuf 消息失败时不会直接报错,而是会记录一条 WARN 日志然后丢弃消息。JMeter 这边看到的现象是发了消息但收不到回执。排查方法是打开服务端的 protobuf 解析日志,或者先用一个简单的 Java 客户端验证构造的字节能否被正常解析。最容易出错的是 message_id 重复和 seq 乱序,这两个字段必须保持严格唯一和严格递增。
6.4 JMeter 自身出现 OutOfMemory
JMeter 默认堆内存只有 1G,跑长连接压测很容易 OOM。修改 JMeter 安装目录下的 jmeter 启动脚本,把 JVM 堆参数改成:
export HEAP="-Xms4g -Xmx4g -XX:MaxMetaspaceSize=512m"还有一点容易被忽略:JMeter 的日志和临时文件会持续写入磁盘,长期压测时建议把结果存储改为 CSV 而不是 XML,因为 XML 写入的体积是 CSV 的十倍以上。
6.5 采样器超时时间和实际网络时延不匹配
如果你把 Read Timeout 设成了 1 秒,而服务端在高峰期处理消息需要 1.5 秒,那么大量正常消息会被误判为超时。唯一的办法是参考服务端的历史延迟分布来设置超时,宁可设置 5 秒或者更宽松,也不要为了追求低超时导致误报。
6.6 一点额外的经验:压测完别急着删脚本
每次压测完成以后,我会把测试计划、参数文件、服务端监控数据、报告原文归档到一个单独目录。下次做性能优化或者版本升级以后,用同一套脚本重新跑,才能做到“同样条件下对比优化前后”的公平比较。这一点看起来不复杂,但很多团队没做到,导致每次压测之间没有可比性,之前的指标都白跑了。
经过这几轮折腾,我个人最大的体会是:IM 消息系统的压测,真正难的不是把 JMeter 配起来,而是如何让模拟流量尽可能地贴近真实业务。协议结构、连接行为、心跳时序、数据规模,任何一个环节失真,最后报告里的数字就都成了自嗨。把 ChatMessage.proto 里的每个字段吃透,把 JMeter 脚本打造成一个能真正模拟真实客户端行为的工具,再去谈 TPS、RT 和瓶颈优化,整个压测这件事才算真正有了价值。