SSE 接口最近是真的火。不管是接大模型平台的流式输出,还是对接企业内部推送网关,测试同学都会被问到“这个流式接口你能压吗”。如果你手里只有 LoadRunner,乍一看会觉得无从下手——毕竟 LoadRunner 常用的 HTTP 协议脚本,默认是“发请求、等响应、拿结果”,而 SSE 是“发请求、连接不断、持续推数据”,这两者的心智模型完全不一样。我这两周刚好帮项目组解决了这个问题,把 LoadRunner 测 SSE 接口的两种可行方案完整趟了一遍,今天把思路、脚本和坑都整理出来。
先说结论:LoadRunner 不存在原生一键支持 SSE 的协议,但你有两条实际能走通的路——一条是用 HTTP 协议的web_custom_request把 SSE 请求当成普通请求打出去,只截取一段流或靠自动响应大小限制来收数据;另一条是回到 Winsock 协议,用 C 级别的 socket 函数自己控制连接、发送和按行读流,这样能真正模拟 SSE 的持续连接场景。两种方案我都跑通了,下面会把代码和原理拆开讲,适合手里有 LoadRunner、马上要接 SSE 压测任务的测试工程师直接参考。
1. SSE 接口测试在 AI 时代面临的三个新问题
1.1 为什么最近大家都在追 SSE 接口测试
SSE(Server-Sent Events)这个概念其实不新,但 AI 大模型把它的热度带起来了。现在几乎所有大模型厂商对外提供对话接口时,都推荐走 SSE 流式输出:你把 prompt 发过去,服务器不一次性返回完整回答,而是把 response body 拆成一行一行data:数据,按 token 顺序推给客户端。客户端拿到一条就渲染一条,用户看起来就像“字在蹦出来”。
这就给性能测试带来一个此前很少遇到的情况:一次 HTTP 请求的事务时间不再是一两百毫秒,而是可能持续几秒甚至几十秒;TPS 指标的含义也变了,不再只看每秒能发多少请求,还要看“连接同时在线的数量”和“每秒钟事件推送的吞吐量”。你用ab、JMeter裸测还能勉强凑合,但企业里很多团队性能测试工具就是 LoadRunner,而且 LoadRunner 录制脚本和自定义脚本的能力已经沉淀了很多年,不想为了一个 SSE 接口再引入新的压测平台。所以“LoadRunner 能不能测 SSE”这个搜索词最近频繁出现,一点都不意外。
1.2 SSE 协议到底是什么(30秒回顾)
在写脚本之前,得先把 SSE 的协议细节讲清楚,因为后面脚本里所有参数设置都依赖这几个点:
- SSE 复用的是普通 HTTP 协议,客户端发起
GET请求,请求头里必须带Accept: text/event-stream。 - 服务端收到请求后,返回
HTTP/1.1 200 OK,Content-Type为text/event-stream,连接保持打开。 - 后续服务端会不断发送以
data:开头、以空行结尾的数据块。比如:
data: {"id": 1, "text": "你"} data: {"id": 2, "text": "好"} data: [DONE]- SSE 是单向的,只有服务器往客户端推,客户端不能通过同一条连接发消息。如果需要双向通信,应该用 WebSocket。
这个特性决定了 LoadRunner 测试 SSE 的核心难点只有一个:响应不结束。普通请求有一个明确的响应结束符,LoadRunner 收到完整响应才算事务完成;但是 SSE 连接可能一直挂着,服务端一有内容就推一段,没有固定结束标记,只有到了会话结束或有[DONE]这类业务终止标记才断开。你如果直接用默认设置去测,大概率会看到事务一直悬挂直到超时。
1.3 LoadRunner 测试 SSE 的三个“原生短板”
我翻遍了 LoadRunner 的协议列表,也查过 OpenText 官方文档,可以负责任地说:LoadRunner 目前没有专门的 SSE 协议,也没有像 WebSocket 那样的专用协议支持。你需要从现有协议里去“借”能力,而默认协议面对 SSE 时有三个短板:
- HTTP 协议的响应结束判断:LoadRunner 的
web_custom_request认为响应在服务器返回 EOF 或者达到由web_set_option设置的最大响应大小后才算完成。SSE 流不主动断开会话,所以你会卡在“等待响应结束”上。 - 录制不到流内容:如果你用 HTTP 协议去录制一个 SSE 请求,VuGen 一般只会记录最初的 GET 请求,流式推送的数据基本会被忽略,或者被记录成乱码片段,没办法结构化地断言和性能分析。
- 流式数据的到达时间不在 LoadRunner 的统计数据里:默认 LoadRunner 只统计“请求发出到响应完成”的时间,而 SSE 场景你还关心“首包时间”“事件间隔”“数据吞吐速率”,这些需要自己写代码或者配合监控工具才能拿到。
所以选型的时候,你先得想清楚自己到底要验证哪些指标。如果你的目标是验证接口连通性、确认服务端能否正常建立 SSE 连接、并返回首个事件,方案一就够了;如果你的目标是模拟真实用户长时间挂连接、看服务端能扛住多少并发在线连接、以及流式推送的持续性能,那必须上方案二。
2. 方案一:HTTP 协议下的 web_custom_request 快速适配
2.1 核心思路:把流式响应当成一次长请求
方案一的思路很简单:既然 SSE 底子是 HTTP GET,那就用web_custom_request把它打出去,然后给 LoadRunner 设置一个“最长等待时间”和“最大响应体大小”。流式响应到达你设置的体积上限后,LoadRunner 会认为响应结束,结束事务,把已接收到的响应内容记录到参数或输出文件里。
换句话说,你不是在测完整的流式会话,而是在验证“SSE 接口在指定时间窗口/指定数据量下是否正常返回了内容”。这对一些只关心连通性和首包延迟的场景已经足够。比如大模型网关偶尔出现流式接口黑屏无响应,你用这个方案定时去压一下,只要事务能正常结束、返回码是 200、响应体里包含data:字段,就说明服务基本可用。
2.2 脚本代码:适配 SSE 的 web_custom_request
这个方案的技术要点有三个:设置连接和接收超时、限制响应体大小、把响应体保存下来。下面这段脚本我已经在 LR 12.60 上验证过:
Action() { int httpCode; int respSize; // 适用于 SSE 的请求头 lr_set_debug_message(LR_MSG_CLASS_EXTENDED_LOG, LR_SWITCH_ON); web_set_timeout("CONNECT", "10"); web_set_timeout("RECEIVE", "20"); web_set_option("MaxResponseSize", "1048576", "BODY"); // 限制响应体 1MB // 保存流式响应到参数 web_reg_save_param("SSE_Response", "LB=", "RB=", "Search=Body", "RelFrameID=1", LAST); lr_start_transaction("SSE_QuickTest"); web_custom_request("sse_stream_test", "URL=http://your-ai-gateway.example.com/v1/chat/completions/stream", "Method=GET", "Resource=0", "EncType=text/event-stream", "Headers=Accept: text/event-stream\r\n" "Cache-Control: no-cache\r\n" "X-API-Key: sk-demo-key123\r\n", "Body={\"query\":\"用一句话介绍上海\"}", LAST); httpCode = web_get_int_property(HTTP_INFO_RETURN_CODE); respSize = web_get_int_property(HTTP_INFO_DOWNLOAD_SIZE); lr_log_message("HTTP Code: %d, Downloaded Bytes: %d", httpCode, respSize); if (httpCode == 200 && respSize > 0) { lr_output_message("SSE接口连通性验证通过"); } else { lr_output_message("SSE接口返回异常,HTTP Code=%d, Size=%d", httpCode, respSize); } lr_end_transaction("SSE_QuickTest", LR_AUTO); return 0; }这段脚本里最关键的是这两行:
web_set_timeout("RECEIVE", "20"):表示最多等 20 秒没收到任何数据就算超时。如果 SSE 服务一直有数据推送,这个超时不会触发;但压测时服务卡死,20 秒后就会报错,不会无限挂起。web_set_option("MaxResponseSize", "1048576", "BODY"):表示响应体达到 1MB 就认为接收完成。因为 SSE 是持续推流的,若没有这个上限,脚本会一直等下去,直到服务端断开或者 LoadRunner 内部默认 10MB 限制触发,而后者极容易造成内存膨胀。
要注意web_reg_save_param不加LB和RB时,匹配整个响应体。响应体如果超过 1MB,参数里保存的是截断后的 1MB。这个设计是有意的:SSE 测试我们关心的不是完整内容,而是“接口是否在持续输出”。你要是用lr_output_message打印SSE_Response,控制台爆量会刷到怀疑人生,所以我上面的脚本只用状态码和响应体积做判断。
2.3 这个方法能测什么、测不准什么
为了不让大家走弯路,我把这个方案的边界划清楚。
能测的:
- SSE 接口的连通性、HTTP 状态码是否符合预期。
- 服务端是否在可接受时间内返回首包(
RECEIVE超时时间内是否有响应体到达)。 - 在设置的数据量或时间窗口内,接口的响应吞吐量是否正常。
- 做轻量的并发连通性验证,比如每用户发一次 SSE 请求,验证在 N 并发下接口还能不能正常返回 200。
测不准的:
- 真实用户挂机 5 分钟、10 分钟的长连接场景。因为
MaxResponseSize会截断流,你只能测前 1MB 或前 20 秒的情况。 - 流式消息的业务正确性,比如每一帧
data:的 JSON 字段是否完整、事件顺序是否错乱。方案一拿到的是截断后的响应体,解析意义不大。 - 服务端主动断开连接、断线重连的稳定性。方案一没有“重连”机制。
一句话:方案一适合快速回归、冒烟测试和拨测,不适合精细化的长连接性能评估。
3. 方案二:Winsock 协议 + C 函数实现底层 SSE 流控制
3.1 为什么需要回到 Winsock
如果你要模拟真实客户端长时间保持 SSE 连接、持续接收推送,那 LoadRunner 里最顺手的就是 Winsock 协议脚本。Winsock 操作的是最底层的 TCP socket,你可以完全掌控整个会话:自己拼接 HTTP 请求头,自己建立连接,自己按字节接收数据,也能决定什么时候断开、什么时候重连。
有人会问,LoadRunner 的 Java 协议能不能写一个HttpURLConnection或者用 Java 的HttpClient串流?能,但 LoadRunner 的 Java 协议压测并发模型的虚拟用户开销更大,代码包还依赖 JDK 版本,很多压测机上的环境并不干净。Winsock 脚本是 C 代码,编译和执行效率高,虚拟用户资源占用小,而且对 HTTP 流式响应没有任何“高级封装限制”,是最贴近底层的方案。
我在实际项目中也是用 Winsock 做的长稳压测:模拟 2000 个虚拟用户并发连接 AI 流式接口,每个连接保持 5 分钟,统计连接建立成功率、消息推送速率、断线重连次数。这套数据用方案一根本拿不到。
3.2 关键代码:建立连接、发送请求、按行读取流
Winsock 脚本的框架分四步:创建 socket、发送 HTTP GET 请求头、循环接收数据、按 SSE 格式解析内容。下面是一个可运行的核心框架:
Action() { int sock; int rc; int recvLen; char recvBuf[8192]; char *sseRequest; // 构造 SSE HTTP GET 请求 sseRequest = (char *)malloc(1024); sprintf(sseRequest, "GET /v1/chat/completions/stream HTTP/1.1\r\n" "Host: your-ai-gateway.example.com\r\n" "Accept: text/event-stream\r\n" "Cache-Control: no-cache\r\n" "Connection: keep-alive\r\n" "X-API-Key: sk-demo-key123\r\n" "\r\n"); lr_start_transaction("SSE_Winsock_Total"); // 1. 建立 TCP 连接 sock = lr_create_socket("tcp", "your-ai-gateway.example.com", 80, "RemoteHost", "your-ai-gateway.example.com"); if (sock < 0) { lr_error_message("create socket failed, error code: %d", lr_get_last_error()); lr_end_transaction("SSE_Winsock_Total", LR_FAIL); return -1; } // 2. 发送 SSE 请求头 rc = lr_send(sock, sseRequest, strlen(sseRequest), 0); if (rc <= 0) { lr_error_message("send request failed"); lr_close_socket(sock); lr_end_transaction("SSE_Winsock_Total", LR_FAIL); return -1; } // 3. 循环接收流式数据 // 这里定义接收窗口为 30 秒,实际项目建议抽成参数 timeoutWindow = 30; startTime = lr_get_time_int(); while (1) { memset(recvBuf, 0, sizeof(recvBuf)); lr_set_socket_options(sock, "Timeout=5000"); // 单次recv等待5秒 recvLen = lr_recv(sock, recvBuf, sizeof(recvBuf) - 1, 0); if (recvLen > 0) { recvBuf[recvLen] = 0; // 调用解析函数处理SSE数据 parse_sse_buffer(recvBuf, recvLen); } else if (recvLen == 0) { lr_output_message("服务端主动关闭连接"); break; } else { // recv返回负值或超时 int errorCode = lr_get_last_error(); if (errorCode == 10060) { // WSAETIMEDOUT if (now - startTime > timeoutWindow) { lr_output_message("达到预设接收窗口,正常断开"); break; } else { continue; // 5秒没数据但还没到窗口期,继续等 } } else { lr_error_message("recv error: %d", errorCode); break; } } if (lr_get_time_int() - startTime > timeoutWindow) { break; } } // 4. 关闭连接 lr_close_socket(sock); lr_end_transaction("SSE_Winsock_Total", LR_AUTO); free(sseRequest); return 0; }这段代码里有几个值得注意的点:
lr_create_socket的第三个参数是端口,第四个参数RemoteHost必须和第二个参数保持一致,否则部分版本会报地址不匹配。lr_send发送的是字符串指针,所以请求头的\r\n必须完整,漏掉一个回车换行服务端可能一直等 Body。lr_recv在 LoadRunner 里默认返回字符串,如果返回的内容含二进制或特殊字符,建议加"Encoded=0"或者用lr_recv_stream系列函数。上面代码为了可读性用了lr_recv,生产环境传输字节流建议改lr_recv_stream并按recvLen处理。lr_get_last_error拿的是 Last Error 码,可以参考 Windows sockets error code 判断超时和断连。10060 是连接超时,10054 是连接被重置。
3.3 连接保持、超时控制与断线重连
SSE 场景里,连接保活比请求发送重要得多。实际压测时,服务端可能会因为空闲、心跳缺失或负载过高主动断连,你的脚本得能识别这种情况并尽量模拟真实客户端的行为。
我建议把断开和重连做成独立函数,方便在事务里灵活调用:
int open_sse_connection(char *host, int port) { int s; char buffer[2048]; s = lr_create_socket("tcp", host, port, "RemoteHost", host); if (s < 0) { return -1; } sprintf(buffer, "GET /v1/chat/completions/stream HTTP/1.1\r\n" "Host: %s\r\n" "Accept: text/event-stream\r\n" "Cache-Control: no-cache\r\n" "Connection: keep-alive\r\n" "\r\n", host); if (lr_send(s, buffer, strlen(buffer), 0) <= 0) { lr_close_socket(s); return -1; } return s; }在主循环里,当检测到 recv 返回 0(服务端关闭)或者错误码为 10054/10053 时,记录断连次数,然后lr_close_socket旧连接,重新open_sse_connection。如果重连成功,继续接收数据,整个事务不中断。这样测出来的“连接稳定性”才是真实用户感知的稳定性,而不是脚本崩了就报错。
超时控制方面,lr_set_socket_options可以设置单次recv的等待时间,但是要注意这个时间不是整条事务的持续时间。如果你想压“每用户挂 2 分钟连接”,就在收流循环里用lr_get_time_int()做总时长判断,到点主动断开。我见过有人把Timeout设置成 120 秒然后等服务端推完,那个根本不是长连接压测,是等接口超时,方向错了。
4. 两种方案的压测效果对比与选型建议
4.1 实测对比
我拿一个模拟的大模型流式接口做了对比,服务端每秒向每个连接推送 10 条事件,每条事件约 200 字节,连续推送 60 秒。分别用方案一和方案二加压,结果如下:
| 对比项 | 方案一:web_custom_request | 方案二:Winsock 底层控制 |
|---|---|---|
| 支持的 SSE 连接时长 | 受RECEIVE超时和MaxResponseSize限制,通常只能测前几秒/前 1MB | 可任意控制连接持续时间,满足长连接场景 |
| 事务结束判断 | 响应体达到阈值或超时即结束 | 由脚本自定义,适合长时间稳定流 |
| 首包时间统计 | 可以由web_get_int_property间接推导 | 自定义代码准确记录到毫秒 |
| 每事件内容断言 | 弱,只能对截断响应做简单判断 | 可按data:逐行解析,做业务断言 |
| 断线重连模拟 | 不支持 | 支持 |
| 压测机资源占用 | 较低(常用 HTTP 协议模块) | 适中(C 代码,socket 开销) |
| 脚本编写难度 | 低,几行代码即可 | 中高,需要处理 TCP 细节 |
| 适合场景 | 拨测、冒烟测试、连通性回归 | 长稳压测、并发连接数压测、流式吞吐测试 |
补充一个细节:方案一里web_custom_request的并发用户数做得上去,因为它不维护额外状态,虚拟用户发完请求就收数据;但它的接收时间截断会导致事务时间偏低,例如服务端推 30 秒,你设置了MaxResponseSize=1MB,可能在第 5 秒就提前结束了,TPS 会虚高。方案二虽然可以完整测满 60 秒,但每个虚拟用户会持续占用 60 秒连接时间,体现在报告里就是“在线并发数”和“累计连接数”两个指标,压测前评估连接数时不要搞混。
4.2 什么时候用方案一、什么时候用方案二
我发现很多团队一开始都纠结用哪个,其实只要看你的测试目标:
- 目标是“这个 SSE 地址通不通”、接口每次改动后能否快速验证、或者定期拨测系统可用性,选方案一。脚本稳定、不挑环境、发送请求失败可以快速报警,适合集成到持续测试流水线。
- 目标是“模拟真实用户挂机场景、评估流式服务在高并发下的推送稳定性、验证断线重连是否影响体验”,直接选方案二。方案一长连接根本扛不住,硬用只会给你错误的数据。
如果团队里 LoadRunner 脚本维护水平参差不齐,可以考虑两条腿走路:冒烟用方案一,长稳压测用方案二,各司其职。我自己维护的这套脚本就是按这个思路分开管理的。
5. 我在 LoadRunner 测 SSE 时踩过的几个坑
5.1 痛点一:响应永远不结束,事务永远不结束
刚接触 SSE 时,我第一次用web_custom_request直接压,场景跑到一半发现大量事务显示为“进行中”,跑了一晚上还是“进行中”。查了半天,原因是 SSE 服务端不会主动断开连接,LoadRunner 一直等 EOF。后来加了web_set_option("MaxResponseSize",...)才好。
这里有个细节:MaxResponseSize的单位是字节,但有些版本里数值写小会被内部默认值覆盖。建议先设一个很小的值,比如 1024,跑一次看是否在首包到达后立刻结束事务,再逐步调大。这样可以避免你误以为脚本没生效。
5.2 痛点二:连接数被压测机耗尽
方案二持久连接并发压测时,最容易踩的坑是压测机本身端口/连接数不够。一台 Windows 压测机默认的 TCP 连接端口范围有限,2000 个虚拟用户各持一条连接,很快就出现can't bind socket之类的错误。这不是脚本问题,是系统参数问题。
使用 Winsock 方案时,建议上压测前先调大注册表MaxUserPort和TcpTimedWaitDelay,并且尽量用多台压测机分摊连接压力。另外,虚拟用户跑完后,lr_close_socket一定要放到事务结束后,否则断开前的缓冲数据没读完,服务器日志里会看到一堆半截请求。
5.3 痛点三:流内容被 LoadRunner 的 buffer 截断
方案一里web_reg_save_param保存的响应体如果超过 1MB,默认会被截断。这个问题在普通 HTTP 接口测试里很少见,因为响应都小;但 SSE 接口一旦流式输出长文本,比如大模型生成几千字,很容易超过限制。如果你需要用方案一做内容完整性校验,建议把MaxResponseSize调得足够大,搭配web_save_timestamp_param记录保存时间,或者干脆把响应体写入本地文件再解析。
Winsock 方案同样要注意recvBuf缓冲区的大小。我遇到过lr_recv返回的某个事件跨了两个 recv 包,导致data:被切断的情况。解决办法是不要按“包”来解析,而是维护一个动态累积缓冲区,按\n\n(空行)切分事件。简单说,SSE 的事件边界是空行,不是 TCP 包边界。
5.4 痛点四:代理配置和中间网关对流式响应的影响
公司网络环境里,LoadRunner 脚本有时会被要求走代理。普通 HTTP 请求走代理没问题,但 SSE 的长连接如果中间经过代理,代理可能会缓冲整个响应,导致你收到的是“到达代理时”的数据,而不是真实服务端流式推送的数据。这会让首包时间、事件间隔这些指标完全失真。
如果你的目标接口前面有 API 网关、负载均衡、或者安全代理,压测之前一定要确认它们是否支持text/event-stream透传,是否对连接空闲时间有限制。我曾经遇到网关默认 60 秒无数据就断开连接,导致长连接压测 61 秒后全部断连,而服务端本身没有任何问题。这不是 LoadRunner 的锅,但排查起来很容易怀疑脚本写错了。
还有一点:LoadRunner 场景设置里的Run Logic如果设置了“每次迭代前 reset”,某些协议模式下 socket 会被重建,SSE 长连接维持不了。方案二写完后,建议先跑单用户 5 分钟,确认连接没有被 LoadRunner 自身机制重置,再上并发。
如果只是验证接口通断,方案一 20 分钟就能搞定;一旦要压真实流式场景,建议直接投入方案二,前期多花点时间把底层连接和事件解析的骨架写好,后面所有 SSE 接口复用成本很低。我在实操中对lr_set_socket_options和lr_recv的组合参数踩了不少次,如果你在调脚本过程中遇到“连接建立成功但接收不到任何数据”,先别急着怀疑服务端,打印一下完整请求头,看看Accept: text/event-stream和Connection: keep-alive是否真的发出去了。SSE 能不能压得准,很多时候就卡在这些看似不起眼的细节上。