
做了这么多年性能测试HTTP协议压测大家都会但轮到TCP协议很多人的第一反应是“不就是往socket里塞报文吗有什么好测的”真上手操作才发现连个客户端都调不通要么响应读不到全包要么压到一半连接全堆在TIME_WAIT里出不来。这篇文章我用一个平时最常见的场景把JMeter做TCP协议性能测试从原理到底层配置、从脚本编写到问题定位完整捋一遍希望能帮你把这块短板补上。1. 三次握手和压测之间到底差了几步先别急着打开JMeter写脚本。TCP协议压测和HTTP协议最大的区别在于HTTP对使用者屏蔽了连接管理的细节而TCP是裸连接从建立到关闭的每一个环节都会直接影响你的压测结果。1.1 一次完整TCP会话比想象中更耗时一个最基本的TCP连接要经历三次握手和四次挥手客户端 服务端 | SYN | |------------------------| | SYN ACK | |------------------------| | ACK | |------------------------| | ...数据传输... | | FIN | |------------------------| | ACK | |------------------------| | FIN | |------------------------| | ACK | |------------------------|三次握手解决的是一个核心问题让通信双方确认彼此的收发能力正常并同步初始序列号。这个过程的网络耗时是“一个RTT加一个单向传输时间”也就是常说的连接建立成本。如果压测脚本每个请求都新建连接那么连接建立和关闭的开销会被计入每个请求的响应时间中而且这个开销与服务端处理能力毫无关系纯粹是网络延迟和系统协议栈的代价。实测经验在内网环境延迟0.5ms左右下一个空TCP连接的建立关闭大约消耗1~2毫秒。当目标系统单请求处理只需要0.5ms时连接管理开销占了整体耗时的大头压出来的TPS和响应时间完全不能真实反映系统的业务处理能力。1.2 TIME_WAIT和端口耗尽压测机的隐形杀手四次挥手里最容易出问题的是主动关闭方会进入TIME_WAIT状态并且在默认配置下要等待2MSL通常为60秒才会彻底释放连接。这个机制存在的初衷是为了防止最后一次ACK丢失后被动关闭方重发FIN主动关闭方还能有状态可以响应。但在高并发压测场景下TIME_WAIT状态的积压会导致一个非常现实的问题客户端端口耗尽。一个TCP连接由四元组源IP、源端口、目标IP、目标端口唯一标识。压测机用同一个目标IP和端口发起大量短连接时源端口数量成了瓶颈默认的端口范围大约只有28,000多个不同系统配置不同。一旦所有端口都处于TIME_WAIT中新连接就无法建立了表现就是压测机测着测着突然大量连接超时但服务端其实一点问题都没有。压测TCP协议时是否复用连接不是想不想的问题是必须仔细设计的关键参数这个我们后面讲采样器配置时再展开。2. 先说透JMeter怎么和TCP打交道JMeter里TCP协议压测的核心元件是TCP Sampler它跟HTTP Sampler的思路完全不同。HTTP采样器处理了URL解析、请求构建、响应解析等一系列工作而TCP采样器只做一件事把字节流发出去再把字节流读回来。封装程度低自由度极高这意味着你几乎可以压测任何基于TCP的协议——前提是你能自己搞定报文格式和编解码规则。2.1 TCPClient classname这五个实现类的差别TCP采样器的第一个关键配置是“TCPClient classname”这直接决定你以什么方式读写数据。下拉框里默认有几个选项很多人根本不看区别就随便选一个结果后面全是在跟粘包、乱码、读不到响应做斗争。各实现类的定位与适用场景如下实现类读写逻辑适用场景TCPClientImpl文本读写按文本内容直接发送和接收不处理长度前缀纯文本协议、控制台调试BinaryTCPClientImpl二进制读写发送十六进制字符串按服务端关闭或超时判定结束自定义二进制协议、短连接LengthPrefixedBinaryTCPClientImpl二进制读写数据前附加长度前缀有长度字段的二进制协议HTTPTCPClientImpl模拟HTTP报文格式含Header特殊场景下直接构造HTTP原始报文自定义类用户继承AbstractTCPClient实现有特殊编解码要求的复杂生产协议最常用的选择是TCPClientImpl文本和BinaryTCPClientImpl二进制。如果你压测的协议是HTTP之外的专有协议那么大概率要选BinaryTCPClientImpl因为它把数据完全交给开发者控制不猜测数据边界。2.2 端口、CPS、连接重用这些参数背后的逻辑在TCP采样器配置界面里有几个参数值得仔细推敲Port目标服务端口这个没什么好说的。Re-use connection是否复用连接。这是TCP性能压测中最重要的开关之一直接决定你的压测模型是短连接还是长连接场景。Close connection每个样本结束后是否关闭连接。如果与Re-use connection搭配使用可以实现“每线程固定次数请求后关闭连接”的效果。SO_LINGER延迟关闭连接的设置单位是秒。默认值是0表示正常四次挥手关闭设置非零值可以强制RST关闭跳过TIME_WAIT但这样做有代价——可能导致数据丢失生产环境一般不建议。End of message定义一条消息的结束标志这个参数影响的是响应读取时何时停止。这些参数之间是联动的。比如压测一个长连接服务期望每个连接上持续发送多笔请求就需要勾选Re-use connection并关闭Close connection压测一个短信网关之类的短连接服务则要关闭Re-use connection让每个请求独立走完建立→发送→接收→关闭的完整链路。2.3 为什么我建议你先学会“Receive Response”很多人在配置TCP采样器时会看到一个叫“Response”的区域里面有一项“Receive Response”复选框默认是勾选状态。这个选项的含义是发送请求后等待接收服务端的响应并将响应内容作为采样结果记录下来。如果你压测的是纯推送式的服务服务端主动下发数据客户端不发送请求可以把“Receive Response”保留勾选但要将“End of message”设置为合适的值否则JMeter会一直等待直到超时。读响应这里还有一个细节JMeter的响应读取是“读到一个EOF或超时或满足End of message条件就认为响应结束”不同实现类对响应结束的判断逻辑不同。如果不了解协议的报文边界定义常见结果是你这边读到半个包就结束或者一直挂在那里不肯返回。这块内容后面在脚本调试部分我会用真实案例仔细讲。3. 从零开始搭一个能跑的TCP压测脚本纸上谈兵没用直接实操。下面用一个模拟的“加密报文查询服务”作为目标系统协议格式是4字节报文长度 2字节命令字 N字节业务数据服务端返回类似格式的响应报文。这是很多内部系统实际使用的报文格式很有代表性。3.1 准备一个本地模拟服务端压测调试阶段强烈建议先用脚本起一个简单的模拟服务端这样你能精确知道服务端收到了什么也方便通过打印日志查看字节内容。下面这个Python脚本用socket实现一个极简的TCP服务端import socket import struct def handle_conn(conn): while True: # 先读4字节长度字段网络字节序大端 head recv_exact(conn, 4) if not head: break msg_len struct.unpack(I, head)[0] data recv_exact(conn, msg_len) print(recv payload:, data.hex()) # 原样返回相同长度的响应回包带4字节长度前缀 resp_payload bOK: data resp_with_len struct.pack(I, len(resp_payload)) resp_payload conn.sendall(resp_with_len) conn.close() def recv_exact(conn, n): buf b while len(buf) n: chunk conn.recv(n - len(buf)) if not chunk: return None buf chunk return buf srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((127.0.0.1, 9001)) srv.listen(50) print(sim server on 9001) while True: conn, _ srv.accept() # 真实场景中这里应该用线程池处理并发 import threading threading.Thread(targethandle_conn, args(conn,), daemonTrue).start()这个脚本做的事情很简单读4字节长度再读对应长度的业务数据打印出十六进制内容然后返回一个OK:前缀加原始数据的响应报文。注意recv_exact这个函数——TCP是流式协议recv返回的数据长度不保证和请求的长度一致必须循环读够长度才算完整这个逻辑和业务系统处理粘包、拆包是同一个道理。3.2 新建测试计划完成最基础的连通性验证打开JMeter创建测试计划添加线程组和TCP采样器。关键配置如下字段TCPClient classname: BinaryTCPClientImpl Server Name or IP: 127.0.0.1 Port Number: 9001 Re-use connection: 勾选 Close connection: 不勾选 SO_LINGER: 留空 End of message: 留空用BinaryTCPClientImpl的默认判定逻辑BinaryTCPClientImpl的“EOM”处理逻辑是基于连接关闭或读超时来判定响应结束的这个实现类本身不解析长度前缀。所以要准确读出“一条完整响应”更稳妥的方式是自己实现一个解析长度字段的客户端类。这个踩坑点很关键后面第4章会专门讲。先用最简单的方式验证连通性把上面的报文以十六进制格式填到TCP采样器的“Data”里。假设业务数据是123456789010字节完整报文就是长度10十六进制是 0000000A 数据31323334353637383930即ASCII码的1234567890采样器Data一栏填写0000000A31323334353637383930端口设好线程数设1循环次数设1直接运行。如果一切正常查看结果树时能看到响应报文是000164F4F4B3A31323334353637383930这里OK:三个字符的十六进制是4F4B3A报文长度是13十六进制就是0000000D实际响应十六进制应该是0000000D4F4B3A31323334353637383930。看到这个结果说明从发送到接收已经通了。3.3 设置线程组参数把场景压到目标模型连通性验证通过后开始设计压测场景。针对这个模拟服务如果要测长连接场景下的最大吞吐线程组可以这样配置线程数50Ramp-up10秒循环次数100调度器勾选持续时间120秒这个模型模拟的是50个客户端连接同时建立持续发送请求连接不复用。注意这里Ramp-up要设置得合理让连接建立过程呈现逐渐攀升的状态避免瞬间50个连接同时发起SYN导致服务端accept队列打满。压测开始前打开聚合树监听器和汇总报告。聚合树用于确认单条请求的报文收发正确汇总报告用于统计TPS、平均响应时间、错误率等指标。如果前期没有确认逐条请求内容后面看TPS异常时根本没法定位是报文问题还是系统问题。4. 脚本调试TCP协议压测的大坑基本都在这里了TCP压测脚本调试比HTTP脚本费劲得多因为你看不到请求的完整链路。这个章节把实战中踩过最多、出现频率最高的几个问题整理出来同时讲清楚排查思路。4.1 连接不断新建TIME_WAIT堆积导致端口耗尽这是最典型的问题。用短连接模式压测高并发场景压着压着发现错误率飙升查看服务端日志却看不到异常查看压测机状态发现大量端口处于TIME_WAIT状态。原因前面讲过每次请求新建连接关闭后进入TIME_WAIT默认60秒才释放。压测机的可用端口数量是有限的。排查方式压测机上执行netstat -ant | grep 目标端口 | wc -l统计连接数。执行netstat -ant | grep TIME_WAIT | wc -l确认TIME_WAIT堆积情况。对比请求失败时间点和TIME_WAIT数量增长的时间点。解决方式是区分场景来看。如果目标系统本身就是短连接服务连接管理开销属于业务成本的一部分压测必须如实包含这个开销这时可以调整压测机内核参数拓宽可用端口范围net.ipv4.ip_local_port_range扩大可用端口范围例如设置为1024 65000。net.ipv4.tcp_tw_reuse开启TIME_WAIT复用。但如果被测系统是长连接服务而脚本错误地配置成了短连接模型那压测结果就没有参考意义了需要在采样器里勾上Re-use connection。个人经验先确认被测系统的连接模型再去调压测机内核参数。我看到过有人为了压测结果“好看”把短连接服务改成端口复用SO_LINGER强制RST关闭压测TPS上去了但完全脱离生产实际情况——生产环境里的客户端不会这么干。性能测试的第一原则是模型准确而不是数字漂亮。4.2 二进制报文总是读到半个包或者粘连包JMeter内置的BinaryTCPClientImpl读取响应的逻辑比较简陋。它会把连接上收到的所有数据都算作一次响应然后根据EOM条件决定什么时候停止。如果一次读取操作中服务端返回了两个业务响应默认的读取方式可能会把它们合并成一条响应响应时间统计和报文内容全都失真反过来如果服务端分两次发送一个响应可能出现的现象是首次读取只读到半个包但采样器已经认为响应结束了。这个问题在HTTP里不存在因为HTTP有Content-Length和Chunked机制明确了响应边界。但TCP裸报文没有这些必须自己定义“一条完整报文”的边界。最可靠的做法是自定义一个TCPClient实现继承AbstractTCPClient在读取响应时先读4字节长度再根据长度循环读够完整报文。下面这段代码可以直接用package com.example.jmeter; import org.apache.jmeter.protocol.tcp.sampler.AbstractTCPClient; import org.apache.jmeter.protocol.tcp.sampler.TCPSampler; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; public class LengthPrefixedSocketClient extends AbstractTCPClient { public LengthPrefixedSocketClient() { super(); setEolByte(Byte.MAX_VALUE); } Override public void write(OutputStream os, String s) throws IOException { byte[] payload hexToBytes(s.trim()); byte[] lenBytes new byte[4]; lenBytes[0] (byte) ((payload.length 24) 0xFF); lenBytes[1] (byte) ((payload.length 16) 0xFF); lenBytes[2] (byte) ((payload.length 8) 0xFF); lenBytes[3] (byte) (payload.length 0xFF); os.write(lenBytes); os.write(payload); os.flush(); } Override public String read(InputStream is) throws IOException { byte[] lenBytes readExact(is, 4); int len ((lenBytes[0] 0xFF) 24) | ((lenBytes[1] 0xFF) 16) | ((lenBytes[2] 0xFF) 8) | (lenBytes[3] 0xFF); if (len 0) { return ; } byte[] body readExact(is, len); return bytesToHex(body); } private byte[] readExact(InputStream is, int n) throws IOException { byte[] buf new byte[n]; int offset 0; while (offset n) { int r is.read(buf, offset, n - offset); if (r -1) { throw new IOException(stream closed, got offset bytes, expect n); } offset r; } return buf; } private byte[] hexToBytes(String hex) { int len hex.length() / 2; byte[] out new byte[len]; for (int i 0; i len; i) { out[i] (byte) Integer.parseInt(hex.substring(i * 2, i * 2 2), 16); } return out; } private String bytesToHex(byte[] bytes) { StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02X, b)); } return sb.toString(); } }把这段类打成Jar包放到JMeter的lib/ext目录下然后在TCP采样器的“TCPClient classname”里填com.example.jmeter.LengthPrefixedSocketClient重启JMeter再跑响应报文就会按照长度字段精确切分不再出现半包或粘包问题。这段逻辑是很多内部二进制协议通用的前4字节是长度后面跟数据。如果你的协议长度字段是2字节或者偏移位置更复杂修改readExact和解析部分即可核心思路保持不变。4.3 报文编码与字符集文本协议里容易忽视的一环如果压测的是文本协议比如简单指令行协议编码问题会以比较隐蔽的方式出现。典型场景是压测包含中文业务报文的协议采样器里写的是中文字符串服务端收到的却是乱码导致业务处理失败或响应命令字异常。排查思路先确认目标协议定义的字符集。很多老旧系统用的是GBK而不是UTF-8。在TCP采样器下方的“Charset”配置项中填入目标协议要求的字符集例如GBK。如果协议是二进制方式承载文本不要依赖采样器自动编码。将报文内容在本地用Python或命令行工具转换为十六进制再填到Data区域最可控。python3 -c print(你好abc.encode(utf-8).hex()) # 输出: e4bda0e5a5bd616263把这段十六进制填入Data一栏无论JMeter内部如何设置字符集都不会破坏报文内容这也是二进制方式做文本协议压测的经典技巧。4.4 每次循环的结果都异常变量引用和线程间共享问题压测脚本如果使用了JMeter变量或者属性来实现参数化很容易出现多线程环境下的取值串号问题。具体现象是单线程调试一切正常多线程压测时服务端收到的请求内容张冠李戴或者某些请求报了业务校验错误。原因是JMeter变量的作用域和取值时机问题。如果变量在循环体内被多个线程同时修改或者引用了未正确使用线程局部方式存储的变量就可能出现并发读取到同一个值的现象。解决方式使用${__threadNum}变量区分线程标识拼入业务数据体中。给变量名加上线程前缀避免不同线程覆盖同一个变量。在需要每个请求都不同时可以使用计数器配置元件或CSV Data Set Config逐行读取参数。最典型的操作是在线程组中添加一个计数器从1开始引用格式${counter}然后在报文数据中用它拼出一个唯一业务编号。这样每个请求的业务号都不重复服务端可以正常按唯一键处理也方便出问题时根据业务号回溯链路。5. 从脚本能跑到结果可信性能分析还差哪些关键步骤脚本能跑通只是第一步。TCP协议性能测试的结果可信度更多取决于对采样结果、连接状态和抓包数据的综合分析。5.1 聚合报告里的数字不代表系统的真实能力在JMeter的聚合报告里你会看到TPS、平均响应时间、错误率等指标。但对于TCP协议压测来说有几个数字需要特别小心地解读。平均响应时间严重受长尾请求影响建议同时关注90%响应时间、95%响应时间和最大响应时间。TPS和响应时间互相矛盾时先检查采样器是否发生超时等待。超时重试的请求会显著拉长TP99数值但TPS却不会明显下降。错误率有一条隐藏的坑连接建立失败和业务处理失败在JMeter里都算Error但在排障时完全是两条路线。结合一次项目经验来说某跨平台系统的压测结果中错误率高达8%但服务端日志没有任何异常堆栈。逐个检查失败样本后发现全是java.net.BindException: Cannot assign requested address典型的本地端口耗尽跟业务系统半毛钱关系都没有。调整压测机端口范围后错误率清零同样一份TPS数据才真正有了参考意义。5.2 两手都要硬Linux抓包与服务端视角对照看JMeter的数据是客户端视角想完整还原一次TCP交互的细节抓包是绕不开的手段。在压测机或服务端使用tcpdump抓包可以直观看到TCP报文的时间线、重传情况和握手细节。tcpdump -i eth0 host 192.168.1.100 and port 9001 -w tcp_test.pcap抓包结果用Wireshark打开后重点看三件事SYN重传如果出现大量SYN重传说明服务端accept队列溢出应用层背压已经生效。零窗口窗口大小反复降到0说明接收方处理不过来缓冲区被打满。乱序和重传大量乱序说明网络路径或服务端多线程处理模型存在瓶颈。这三类特征光看JMeter的采样报告是发现不了的只能在报文层面识别。建议做TCP协议压测时抓包工具和控制台命令窗口同时开启一旦采样报告出现异常第一时间对照抓包文件定位。5.3 压测过程中的实时监控清单跑压测时推荐在压测机和服务端各开一个终端实时监控以下指标压测机ss -ant | grep 9001 | awk {print $1} | sort | uniq -c查看连接状态分布。服务端pidstat -p PID 1查看CPU占用和上下文切换。两端sar -n DEV 1查看网卡收发包量和吞吐。系统全局vmstat 1关注si、soswap换入换出和wa数值。这些命令组合起来能快速定位瓶颈层是客户端连接建立能力不够、服务端CPU处理饱和、还是网络带宽达到上限。分清瓶颈在哪一层压测报告才具备说服力。6. 换个思路TCP压测的式作不止JMeter一条路JMeter是万能螺丝刀但有些场景用别的工具会更顺手。基于个人经验整理几个替代方案的适用边界。6.1 自研压测脚本的适用范围如果被测协议是私有二进制格式且有复杂的加解密、签名、会话状态管理逻辑JMeter的采样器会显得非常笨重。这时候性价比最高的方式是写一个贴合协议的压测脚本。用Python的asyncio或Go的goroutine能更自然地控制连接生命周期和协议编解码。import asyncio async def tcp_stress_one(host, port, payload, duration): stats {count: 0, errors: 0} start loop.time() while loop.time() - start duration: try: r, w await asyncio.open_connection(host, port) w.write(payload) await w.drain() data await r.readexactly(4) # 长度 n int.from_bytes(data, big) body await r.readexactly(n) if body expect: stats[count] 1 else: stats[errors] 1 w.close() await w.wait_closed() except Exception: stats[errors] 1 return stats async def main(): host, port, duration 127.0.0.1, 9001, 30 task [tcp_stress_one(host, port, payload, duration) for _ in range(50)] results await asyncio.gather(*task) total sum(x[count] for x in results) errors sum(x[errors] for x in results) print(fcount{total}, errors{errors}) payload bytes.fromhex(0000000A31323334353637383930) asyncio.run(main())这种方式的优势是协议处理逻辑完全透明可控编解码错误可以精确捕获。劣势是没有现成的监听器、报告插件和图形化界面统计指标需要自己实现。对熟悉编程的测试人员来说成本不高。6.2 选型建议什么时候该用JMeter什么时候不该我的个人判断标准很简单场景特征建议工具协议复杂需要大量参数化和断言JMeter 自定义TCPClient协议简单纯粹验证吞吐上限Python/Go脚本直连需要长时间稳定性测试并记录丰富指标JMeter有完整的报告生态压测集群分布式执行JMeter分布式或Go脚本自建调度需要嵌入CI流水线作为接口自动化回归自研脚本更合适启动快依赖少JMeter的最大优势是生态完整、学习成本低、有大量现成的监听器和插件最大劣势是TCP采样器功能原始协议编解码必须靠扩展代码弥补。而自研脚本反过来灵活度高但什么都要自己造轮子。根据团队的技术储备和实际场景来选择就好不必迷信某一个工具。7. 压测过后的数据整理与复盘要点压测不是跑完就完事数据整理和复盘同样重要。这里分享几个实际工作中沉淀下来的整理习惯。首先每次压测前固定好一轮压测的变量信息并发数、压测时长、连接模型长连接/短连接、报文大小、目标服务版本。没有变量信息的数据没法复盘更没法对比两轮压测之间的差异来源。其次对压测结果建立一个固定格式的汇总表至少包含以下列轮次并发数连接模型TPS(均值)TPS(峰值)Avg RTTP99Error%目标服务版本备注150长连接4200450018ms35ms0.2%v1.2.3端口耗尽排除后250短连接1800200032ms78ms0.0%v1.2.3TIME_WAIT影响明显最后花时间定位每一轮压测的系统瓶颈点在哪个环节。CPU、内存、网络、文件描述符、连接表项、内核参数任何一个都可能成为短板。把结论写进压测报告同时记录下如何验证这个瓶颈的排查过程比如通过哪个监控命令看到异常数据、抓包文件里哪个报文特征佐证了判断。这些内容对后续调优和容量规划比TPS数字本身更有价值。我个人测试过程中的一个体会是TCP协议性能压测最耗时的工作往往不是脚本编写而是数据可信度的确认。同样一个TPS数值到底是系统能力上限还是压测机自身端口耗尽导致的假象这两者之间隔着一次完整的抓包分析和端口状态检查。养成“结果异常先查压测机自身状态”的条件反射能省下不少排查弯路。最后补一句——做TCP压测永远给抓包工具留一个后台窗口停在那里。等你在数据堆里一头雾水时那片pcap文件可能是你唯一的救命稻草。