☰
TRex状态化API实战:从TCP握手到L4-L7业务流压测
2026/10/21 15:11:26 网站建设 项目流程

搞网络测试的朋友应该都有这种体会:拿单包打流量很容易,几行脚本就能把端口的收发速率跑满;可一旦被测对象是负载均衡、四层代理、WAF 这类产品,光有速率远远不够——连接能不能建起来、会话能不能保持、业务请求能不能被正确处理,这些才是用户真正感知到的状态。TRex 的 Python Stateful API(通常叫 ASTF,Advanced Stateful Traffic Generator)就是冲着这个场景来的。它和传统无状态模式最大的区别,是让你从“拼报文”升级成“写剧本”:只需要描述客户端和服务端各自会做什么,剩下的 TCP 握手、序列号维护、重传、挥手全部由流量发生器内置引擎完成。这篇文章我会把这套 API 的设计思路、核心对象、一个能直接落地的最小示例,以及我实测中反复踩过的坑一起整理出来,适合已经用过 TRex 无状态模式、正准备往 L4-L7 业务流测试方向深入的人。

1. Stateful API到底解决什么问题:从三次握手说起

1.1 为什么无状态模式做不了“应用层感知”

TRex 最早给人的印象是“打流快”:用无状态模式(STL)定义好报文模板,指定包长和速率,很快就能把某个端口的 PPS 冲到线速。这种模式对吞吐测试、丢包测试非常合适,但它的代价是“每个包都是孤立的”。

实际压测四层以上设备时,痛点会立刻暴露出来。比如被测对象是一个 TCP 代理,它看到只来一个 SYN 包却没有后续握手,马上就会把这条“半连接”标记为异常;你继续发数据包,它根本不转发。又比如测 HTTPS 服务器,光在链路层伪造 IP/TCP 头毫无意义,服务器会等着你完成 TLS 握手,才愿意吐一个字节的响应。一句话:L4-L7 设备的业务逻辑建立在“会话状态”之上,而 STL 模式的数据平面并不维护任何会话状态。

自己手工去构造这套状态机也不现实。你得为每个流单独跟踪序列号、确认号、窗口大小,处理重传、乱序,还要模拟 TCP 的拥塞控制行为。单个连接勉强能写,扩展到几万并发,脚本复杂度会彻底失控。这也是我接触 ASTF 之后感触最深的一点:工程上与其让测试人员去和 TCP 状态机搏斗,不如让引擎把这件事完全接管。

1.2 ASTF 的定位:把流量描述从“报文”升级成“剧本”

Stateful API 在 TRex 里的正式叫法是 ASTF。它的设计思路和“回放 PCAP”完全不一样。回放录制的报文只能忠实地重演一次抓包结果,被测设备返回的内容稍有不同,整个会话就接不上了;而且 PCAP 里的流数量固定,想动态扩展到十万并发几乎不可能。

ASTF 的模型是:你别告诉我某个连接的具体字节序列,你只告诉我“客户端会怎么做、服务端会怎么做”,然后由引擎实时生成真实协议行为。可以把两者理解成“录像带”和“剧本”的区别:录像带无论放多少遍都是同一帧画面,剧本却能让每次演出有细微差别,但情节完全受控。

在这个模型里,引擎自己承担了 TCP 连接的完整生命周期管理:三次握手、序列号与确认号计算、窗口通告、数据重传、四次挥手,全部内置。你写的脚本只需要描述应用层的交互逻辑。也正因为状态机是编译后在数据平面运行的,不是每条流量都过一遍 Python 解释器,它的性能上限比逐包构造要高得多。

1.3 Stateful API 适用的典型场景

我在实际项目中主要用这套 API 做三类事情:

  • 四层负载均衡设备、代理设备的业务容量测试:验证它能维持多少并发连接、每秒能新建多少连接,即 CPS 指标。
  • Web 网关、应用防火墙的功能验证:用真实 GET/POST 请求检测规则是否生效,而不是靠手工拼一个畸形请求包。
  • 长连接存活测试:模拟大量客户端连接建立后长时间不发数据,观察设备的 idle timeout 配置是否合理。

如果你只是想知道某个端口能不能跑满线速,用无状态模式就够了,Stateful API 反而会因为会话维护和协议开销而拉低 PPS。选型之前先想清楚一个问题:被测对象关心的是“包”还是“会话”。

2. 核心抽象拆解:Profile、Template、Program 三者如何协同

2.1 三大核心对象的关系

刚打开 ASTF 的 Python 包时,面对一堆类名确实容易懵。用了一段时间后,我发现整个模型其实可以压缩成三个层面:ASTFProfile 是一次压测任务的“总装图”,ASTFTCPClientTemplate / ASTFTCPServerTemplate 是连接双方的“角色设定”,ASTFProgram 是每一侧具体执行的“台词本”。它们之间的关系可以这样看:Profile 包含一对或多对模板,每个模板内部又挂着一个 Program。

对象职责关键参数/方法
ASTFProfile流量任务容器default_ip_gen、templates
ASTFTCPProfile绑定一对客户端/服务端模板client_template、server_template
ASTFTCPClientTemplate客户端侧连接行为port、cps、program、ip_gen
ASTFTCPServerTemplate服务端侧连接行为port、program、ip_gen
ASTFProgram单侧交互脚本connect、send_msg、recv_msg、delay
ASTFIPGen / ASTFIPGenDist地址生成策略ip_range、distribution

2.2 客户端模板与服务端模板:一对对称设计

理解这套 API 的关键,在于“对称”两个字。客户端模板描述主动发起连接的一方,服务端模板描述被动接受连接的一方。如果客户端脚本里写了“connect 之后发送 1024 字节”,服务端脚本里就应该有对应的“recv 之后回复数据”,两边像打乒乓球一样一来一回。

有个容易忽略的细节:port 参数在两端含义不同。客户端的 port 是目的端口,指向被测设备提供的业务端口;服务端的 port 是监听端口,要和客户端里的目的端口对齐。如果中间经过端口映射,实际生效的端口以报文到达服务端时的端口为准。

cps 参数通常配置在客户端模板上,表示每秒新建连接数的目标值。服务端模板一般不配 cps,因为连接到达节奏完全由客户端驱动。服务端模板甚至不需要知道客户端有多少个——它只要保证在监听端口上,对每个到达的连接都能执行对应的服务端脚本就好。

2.3 ASTFProgram:一段可执行的有限状态机脚本

ASTFProgram 是这套 API 里最像“代码”的部分,它提供了一组构建交互行为的方法:

  • connect():建立 TCP 连接,对应三次握手。
  • send_msg(msg, is_ascii):发送一段数据。
  • recv_msg(max_byte):接收数据,直到收到指定长度的内容。
  • delay(sec):等待一段时间,常用于模拟用户思考时间。
  • send_and_close():发送最后一个数据包后关闭连接。

每个方法调用都会向内部指令列表追加一条指令,最终整个 Program 会被“编译”成可在 TRex 数据平面直接执行的状态机。正是这种编译执行机制,保证了状态机脚本的运行效率,不会因为 Python 解释器的开销拖垮高并发场景。

is_ascii 参数值得单独说一下。当它为 True 时,send_msg 会把内容按 ASCII 文本处理,适合 HTTP 这类明文协议;当它为 False 时,内容按原始字节处理,适合二进制协议。我第一次写二进制 DNS 请求时,忘了关 is_ascii,结果载荷被当作文本做了转义,抓包一看数据全不对。这个坑后面还会细讲。

3. 从零跑通一个TCP/HTTP脚本:示例、参数与规模估算

3.1 环境准备:启动 ASTF 模式的 TRex 服务

先交代一下前提。ASTF 不是装个 Python 库就能跑的,它需要 TRex 服务端以 ASTF 模式启动,并且配置里至少要有一对可用端口。原因是 ASTF 需要在两端同时运行状态机:一端模拟客户端向被测设备发起真实连接,另一端模拟服务端应答被测设备转发过来的请求。被测设备就夹在这两个端口中间。

一个典型的最小配置大概是下面这个样子,接口名按你实际环境改:

port_limit: 2 version: 2 interfaces: - "eth1" - "eth2"

启动命令通常类似:

sudo ./t-rex-64 --astf --cfg /etc/trex_cfg.yaml

不同版本的启动参数可能略有差异,建议先./t-rex-64 --help确认。服务起来之后,Python 端通过ASTFApi连接,默认地址是本地 4501 端口。

3.2 最小可运行的 TCP/HTTP 脚本

下面这个例子是我实际测试中用的最简版本,逻辑就是一个完整的 HTTP 请求响应周期:

from trex.astf.api import * def build_http_profile(): # 客户端地址池,模拟 255 个不同源 IP ip_gen_c = ASTFIPGenDist(ip_range="16.0.0.1-16.0.0.255", distribution="seq") # 服务端地址池 ip_gen_s = ASTFIPGenDist(ip_range="48.0.0.1", distribution="seq") ip_gen = ASTFIPGen( global_udp_burst=False, dist_client=ip_gen_c, dist_server=ip_gen_s, ) # 客户端程序:建连 -> 发 HTTP 请求 -> 收响应 -> 关闭 prog_c = ASTFProgram() prog_c.connect() prog_c.send_msg( "GET /index.html HTTP/1.1\r\nHost: demo.test\r\n\r\n", is_ascii=True, ) prog_c.recv_msg(1024) prog_c.send_and_close() # 服务端程序:收请求 -> 回响应 -> 等客户端关闭 prog_s = ASTFProgram() prog_s.recv_msg(2048) prog_s.send_msg( "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n0123456789", is_ascii=True, ) prog_s.recv_msg(1) temp_c = ASTFTCPClientTemplate( port=80, program=prog_c, ip_gen=ip_gen, cps=1000, ) temp_s = ASTFTCPServerTemplate( port=80, program=prog_s, ip_gen=ip_gen, ) return ASTFProfile( default_ip_gen=ip_gen, templates=[ASTFTCPProfile(client_template=temp_c, server_template=temp_s)], ) def main(): api = ASTFApi() api.connect(server="127.0.0.1") api.clear_stats() profile = build_http_profile() api.load_profile(profile) api.start(duration=60) # 持续 60 秒 api.wait_on_traffic() stats = api.get_stats() print(stats) api.disconnect() if __name__ == "__main__": main()

如果你用的 TRex 版本比较新,可能会见到flows=[ASTFFlow(client_template=..., server_template=...)]这种写法替代templates。两种写法本质一样,都是把一对模板绑定成一个流量流描述,切换时留意当前版本对应哪个。

跑这段脚本的预期结果,是两端端口同时工作:客户端端口向被测设备一直发起 SYN,被测设备把连接转发到服务端端口,服务端完成握手,两边按照 Program 里的“台词”完成一次 HTTP 事务。60 秒结束后,get_stats()会返回全局统计,重点关注已建立连接数和已完成事务数这两个计数器。

3.3 CPS 与并发数:压测规模怎么估算

很多人刚上手会把 cps 当成“并发连接数”,这其实是个误区。cps 是每秒新建连接的速率,而并发连接数等于“每秒新建连接数 × 每条连接平均存活时间”。我用一个具体例子说明:

  • 目标 cps=1000,每条连接从建连到关闭平均耗时 0.5 秒。
  • 那么某一瞬间在线的连接大约是 1000 × 0.5 = 500 条。
  • 如果想让设备达到 10 万并发,而每条连接只存活 1 秒,cps 至少要设到 10 万;如果每条连接能存活 100 秒,cps 只需要 1000。

这就是长连接测试和短连接测试在参数设计上的本质区别。短事务压测看 cps,长连接压测看存活时间和池化策略。调参的时候先算清楚这条公式,能少走很多弯路。另外,cps 设得再高,也受限于 TRex 服务端分配的 CPU 核数,启动时日志会打印可用的 ASTF 线程数,超出能力的目标值只会在队列里堆积。

4. 进阶玩法:UDP、TLS、连接池与聚合统计

4.1 UDP 模板:没有握手的“事务”

UDP 没有三次握手,所以 ASTFProgram 里不需要调用 connect(),整个脚本就是“发一段数据、收一段数据”。下面是一个模拟 DNS 查询的最简结构:

prog_c = ASTFProgram() prog_c.send_msg(b"\x12\x34\x01\x00\x00\x01", is_ascii=False) prog_c.recv_msg(512) prog_s = ASTFProgram() prog_s.recv_msg(64) prog_s.send_msg(b"\x12\x34\x81\x80\x00\x01", is_ascii=False) temp_c = ASTFUDPClientTemplate( port=53, program=prog_c, ip_gen=ip_gen, cps=200, ) temp_s = ASTFUDPServerTemplate( port=53, program=prog_s, ip_gen=ip_gen, )

这里我故意把载荷写成字节串,并且把 is_ascii 设为 False。UDP 载荷经常包含不可见字符,用文本模式发送很容易在序列化时被改动。如果你发的载荷里有长度前缀字段,记得自己计算并填充正确的长度,引擎不会替你做应用层的长度修正。

4.2 TLS 上下文:HTTPS 压测的正确姿势

测 HTTPS 场景时,模板需要挂上 TLS 上下文。基本套路是先加载证书,再在 Program 里显式执行握手方法:

tls_server_ctx = ASTFTLSServerContext( cert_file="server.crt", key_file="server.key", ) tls_client_ctx = ASTFTLSClientContext( cert_file="ca.crt", key_file="client.key", ) temp_c = ASTFTCPClientTemplate( port=443, program=prog_c, ip_gen=ip_gen, cps=500, tls_ctx=tls_client_ctx, )

对应的程序脚本里,connect() 之后要先完成 TLS 握手,再发应用数据:

prog_c.connect() prog_c.tls_client() prog_c.send_msg("GET / HTTP/1.1\r\nHost: demo.test\r\n\r\n", is_ascii=True)

TLS 握手非常消耗 CPU,无论是被测设备一侧还是 TRex 这一侧都一样。把 cps 从明文场景突然调到同样数值,实际握手成功率大概率会下降。建议 TLS 场景单独调参,从低 cps 起步,观察握手失败计数再往上加。

4.3 连接池:让连接真正“活着”

默认情况下,每个 ASTF 流描述完成一次事务就会关闭连接,这在短连接压测里没问题。但真实业务里,浏览器或移动端 App 大量使用 HTTP Keep-Alive 复用 TCP 连接。要模拟这种行为,就需要连接池。

连接池的用法是在客户端模板上指定一个池标识:

temp_c = ASTFTCPClientTemplate( port=80, program=prog_c, ip_gen=ip_gen, cps=1000, connection_pool="web_pool", )

相同池标识的流之间会复用空闲连接。这样一来,连接建立速度可以降低,但线上连接数量会稳定在一个高位,更加接近真实用户行为。连接池相关的上限参数通常在全局配置里控制,建议跑长连接场景前用help(ASTFGlobalCfg)查看当前版本支持的字段。

4.4 聚合统计:从海量流里提炼一个数

ASTF 一条流对应一个连接,但一条模板可能生成几万条流,逐条看统计没有意义。ASTF 提供了聚合函数机制,把每条流的计数汇总成全局值,脚本里通过set_agg_ctx_var把自定义变量挂到流上下文上。它适合统计那些默认计数器覆盖不到的、与应用逻辑强相关的指标,比如某个特定响应码的总次数。

如果你只是想要连接数、字节数、CPS 这些基础指标,直接查get_stats()的全局结构就够了。聚合统计是为了自定义业务指标而存在的,第一次用的时候先确认当前版本对聚合函数的记账粒度,再决定变量的定义方式。

5. 我踩过的坑与排查思路

5.1 服务端根本没有进入 ASTF 模式

最经典的问题:Python 端connect()一切正常,但load_profile()一直失败,或者报出与 profile 加载相关的超时错误。排查了一圈,往往是服务端没有开启 ASTF 模式。ASTFApi 能连上服务端,只说明 TCP 控制面通了,不代表服务端加载了 ASTF 运行环境。

解决方法是检查启动命令里有没有--astf参数,并且服务端日志中是否打印了 ASTF 相关的初始化信息。另一个同样隐蔽的问题是配置里只有一个端口,而 ASTF 模式至少需要一对端口才能同时承载客户端和服务端两端的会话。

5.2 两端 Program 不对称导致会话挂起

我踩过最深的坑:客户端脚本里recv_msg(1024)等待 1KB 响应,服务端脚本却只回了 40 字节。结果客户端状态机一直等不到足够的字节数,连接就一直挂着,直到 TCP 超时才会被清理。表现在统计上,就是“已建立连接数”很高,但“已完成事务数”很低。

解决思路是让两端的 Program 严格镜像。写脚本前先在纸上画一遍数据流向:客户端发多少,服务端就必须 recv 多少;服务端回多少,客户端就必须按同样的字节数期望去收。如果你不确定对端行为,把recv_msg的期望值设小一点,或者用延迟加小包的办法降低卡死概率。

5.3 is_ascii 参数导致的二进制载荷错误

这个坑在前面提过,但值得单独拎出来。二进制载荷一旦用is_ascii=True发送,引擎会按文本规则处理,常见表现是载荷里的\x00等字节被截断或转义,抓包看到的 payload 已经不是你想发的东西。

判断标准很简单:载荷里如果出现非打印字符,就必须is_ascii=False。另外,recv_msg的max_byte和min_byte也要按字节数精确设置,不要凭感觉写一个很大的数然后指望引擎自动停止,它不会为了“凑够”长度而等待不存在的字节。

5.4 cps 虚高与实际并发上不去

cps 是目标值,不是保证值。如果被测设备处理能力不足,或者网络中有重传,实际建立连接的速率会低于配置值。我曾经遇到过配置 cps=5000,实际建连只有 2000 的情况,问题不在 TRex,而在被测设备本身。

更隐蔽的问题是 IP 地址池耗尽。当时我把客户端 IP 范围设成了16.0.0.1-16.0.0.255,以为够用,但 cps 一高,地址很快轮完,新连接只能复用之前已断开的元组,TCP 行为变得不自然。地址池范围尽量按并发规模的十倍以上预留,或者直接用分布策略让地址随机散开。

5.5 修改模板后忘了重新加载 Profile

ASTF 不会在start()时自动感知 profile 的变化。如果你改了 Program 或模板参数,直接再调用start(),跑的仍然是上一次加载的旧 profile。这个坑很坑人,因为统计看起来一切正常,但行为完全是旧的。

养成固定操作习惯:改 profile 之后,先load_profile()再start(),两个测试轮次之间显式调用clear_stats()。把这三步固化下来,能避免大部分“改了没用”的困惑。下面是我常用的排查对照表:

现象可能原因处理方式
load_profile 超时服务端未开启 ASTF 模式确认启动命令带 --astf
连接建立但事务不完成两端 Program 不对称对照脚本检查收发字节数
二进制载荷乱码is_ascii 误设为 True改用 is_ascii=False 并手动控制长度
实际 CPS 远低于目标被测设备处理瓶颈或地址池耗尽扩大 IP 范围,逐步加压
修改脚本后行为不变未重新 load_profile重新加载并 clear_stats

6. 最后分享几个偷懒技巧

先说一个对我帮助最大的习惯:在 Python 交互环境里直接调用help(ASTFTCPClientTemplate),把当前版本支持的所有参数一次性看完。各版本之间参数名有差异,文档有时候跟不上代码,直接查类定义是最准确的。我每次升级 TRex 版本后都会做一遍这件事,比翻文档省时间得多。

另一个建议是前期把 cps 调低到 10,用最小脚本跑通全链路,再逐步加压。很多时候问题出在协议脚本本身,而不是性能不够,低速率下排查会轻松很多。等确认事务完成率和统计指标都正常,再往高并发调。

最后,我建议把常用的 profile 封装成一个小工具类,用字典或者 YAML 配置驱动脚本生成。这样每个压测场景都是一份可版本管理的配置,而不是散落在代码里的硬编码。这个习惯帮我省了大量重复劳动,也让我在复盘测试结果时能准确说出“当时跑的是哪份配置”。Stateful API 的上手门槛比无状态模式高不少,但只要理解了“两端对称、脚本化、引擎维护连接”这三件事,后面基本就是一马平川了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询