LoadRunner 将关联的参数写进本地文件¶
把关联出来的订单号、token、流水号写到本地文件,是压测后对账、跨脚本传参、离线分析最直接的手段。但 VuGen 的 C 脚本跑在 64 位运行时上,网上流传的很多写法在 2020 之后的版本里会直接崩溃。这篇从一段“能跑但到处是坑”的旧脚本开始,逐条改成可并发、可校验、可归档的实现。环境为 LoadRunner 2023(24.1),Windows LG + Linux 被测端。
一、先看一段真实旧脚本¶
这是很典型的网上流传版本,逻辑上确实能写出文件:
int flen;
long filedes;
char file[256] = "C:\\Users\\test\\Desktop\\count.txt";
web_submit_data("logincheck",
"Action=http://shop.example.com:8705/logincheck",
"Method=POST",
"RecContentType=application/json",
ITEMDATA,
"Name=name", "Value={username}", ENDITEM,
"Name=pword", "Value=XX23456", ENDITEM,
LAST);
web_reg_save_param_ex(
"ParamName=count",
"LB= \"totalcount\": ",
"RB=, \"data",
SEARCH_FILTERS,
LAST);
web_url("getpaylistjson",
"URL=http://shop.example.com:8705/caiwu/getpaylistjson?first=0&count=100",
"Resource=0",
"RecContentType=application/json",
LAST);
lr_output_message(lr_eval_string("{count}"));
flen = strlen(lr_eval_string("{count}"));
if (flen > 0) {
if ((filedes = fopen(file, "a")) == NULL) {
lr_output_message("Unable to create %s");
return -1;
}
fprintf(filedes, lr_eval_string("{username}"));
fprintf(filedes, ": ");
fprintf(filedes, lr_eval_string("{count}"));
fprintf(filedes, "%s\n", " ");
fclose(filedes);
}
这段代码在 32 位 VuGen 时代勉强能工作,放到 LR 2023 上至少有六个问题,其中一个会直接让脚本崩溃。
二、六个问题逐条拆¶
2.1 long filedes 存 FILE *,64 位下指针被截断¶
fopen 返回的是 FILE *(64 位下 8 字节),赋给 long(Windows LLP64 下仍是 4 字节)会截断高 32 位。fprintf 再把这个残缺值当指针用,典型表现:
Action.c(61): Error: C interpreter run time error: Action.c (61):
Error -- memory violation : Exception ACCESS_VIOLATION received.
这不是“某台机器有问题”,而是必然事件,只是地址恰好落在低 4GB 时才侥幸不崩。正确写法只有一种:
旧教程里的 long file; 一律改掉,不要保留“兼容写法”。
2.2 格式串少了参数¶
%s 没有对应实参,lr_output_message 会从栈上读一个随机地址当字符串,轻则输出乱码,重则访问违规。必须写:
2.3 把变量值当格式串¶
count 的值里只要出现 %(签名串、URL 编码串都可能),就会被当成格式符解析。正确写法:
这条在旧脚本里极其常见,审查脚本时优先全局搜索 lr_output_message(lr_eval_string。
2.4 路径写死在桌面¶
C:\Users\test\Desktop\count.txt 在 Controller 上存在,在 LG 上大概率不存在;就算存在,压测账号也未必有写权限。fopen 失败只返回 NULL,不会自己报错,于是整场压测“看起来正常”,实际一个字节都没写。
路径必须来自运行参数或脚本目录,且目录要预先创建。
2.5 所有 Vuser 写同一个文件¶
500 个 Vuser 同时 fopen(..., "a") 追加同一文件,每个进程有自己的 stdio 缓冲区,fclose 时按 4KB 块刷盘,行与行会交错,甚至出现半行。压测结束后拿到的文件无法按行解析。
2.6 没有校验写入结果¶
fprintf 的返回值没判断,fclose 的返回值没判断,磁盘满、句柄耗尽、编码失败全都静默通过。等对账时发现少了几万条订单,已经无法复现。
三、正确的文件操作骨架¶
3.1 必要的头文件¶
在 globals.h 或脚本文件顶部:
VuGen 默认已经引入了一部分,但 errno.h 和 direct.h 建议显式写,避免换版本后编译报未定义。
3.2 目录准备¶
static void ensure_dir(const char *dir)
{
if (_mkdir(dir) == 0) {
lr_output_message("created dir: %s", dir);
} else if (errno == EEXIST) {
/* 已存在,正常 */
} else {
lr_error_message("mkdir failed: %s (errno=%d)", dir, errno);
}
}
_mkdir 只能建一级目录,D:\lr_out\2026-09-08 这种两级路径要逐级创建,或者干脆在压测前由运维统一建好。
3.3 路径拼接¶
char path[512];
int vuser_id = 0;
char *vuser_name = NULL;
int group_size = 0;
// 签名:int lr_whoami(int *vuser_id, char **vuser_name, int *vuser_group_size)
lr_whoami(&vuser_id, &vuser_name, &group_size);
snprintf(path, sizeof(path), "D:\\lr_out\\order_%s_%d.csv",
lr_get_host_name(), vuser_id);
四个要点:
- 用
snprintf而不是sprintf,明确给出缓冲区大小。 lr_get_host_name()区分 LG,lr_whoami的vuser_id区分 Vuser,两者组合才唯一。- 第三个参数是组大小,不是场景 ID,别按名字猜语义。
- 路径总长不要超过 260 字符(Windows MAX_PATH),超过后用
\\?\前缀或改用短目录。
3.4 追加与覆盖¶
| 模式 | 行为 | 适用 |
|---|---|---|
"w" |
截断重建,每次打开都清空 | 每个 Vuser 初始化时建文件 |
"a" |
追加到末尾,文件不存在则创建 | 每次迭代追加一行(推荐) |
"w, ccs=UTF-8" |
截断并以 UTF-8 编码写入 | 需要中文且用 MSVC 运行时 |
"a" 模式在 Windows 上会把 \n 转成 \r\n,如果后续用 Linux 工具解析,记得处理 CRLF。
3.5 写入、刷新、关闭¶
static int append_line(const char *path, const char *line)
{
FILE *fp = fopen(path, "a, ccs=UTF-8");
int rc = 0;
if (fp == NULL) {
lr_error_message("fopen failed: %s (errno=%d)", path, errno);
return -1;
}
if (fprintf(fp, "%s\n", line) < 0) {
lr_error_message("fprintf failed: %s (errno=%d)", path, errno);
rc = -1;
}
if (fclose(fp) != 0) {
lr_error_message("fclose failed: %s (errno=%d)", path, errno);
rc = -1;
}
return rc;
}
为什么每次迭代都开关文件,而不是在 vuser_init 里打开、vuser_end 里关闭:
- 长事务场景下 Vuser 可能跑几个小时,缓冲区里的数据在进程崩溃时会全部丢失;关文件即刷盘,中途强停也能保留已完成的数据。
- 每次
fopen/fclose的代价约几十微秒,相对于一次 HTTP 事务可以忽略。
如果确实是每秒上千次写入的高频场景,才考虑常驻句柄 + 定期 fflush,并在 vuser_end 里兜底 fclose。
四、CSV 转义:值里什么字符会坏事¶
关联来的值不是“干净”的:订单备注可能带逗号,地址可能带换行,JSON 片段可能带双引号。直接 fprintf("%s") 会让 CSV 结构错乱。
4.1 转义函数¶
static void csv_escape(const char *in, char *out, size_t out_size)
{
size_t i, j = 0;
if (out_size < 4) {
if (out_size > 0) out[0] = '\0';
return;
}
out[j++] = '"';
for (i = 0; in[i] != '\0' && j + 3 < out_size; i++) {
if (in[i] == '"') {
out[j++] = '"'; /* CSV 里双引号用两个双引号转义 */
out[j++] = '"';
} else if (in[i] == '\r' || in[i] == '\n') {
out[j++] = ' '; /* 换行替换为空格,保证一行一条记录 */
} else {
out[j++] = in[i];
}
}
out[j++] = '"';
out[j] = '\0';
}
处理规则:
| 字符 | 处理 | 原因 |
|---|---|---|
" |
变成 "" |
RFC 4180 的 CSV 转义 |
\r \n |
替换为空格 | 否则一条记录被拆成多行 |
, |
不处理 | 整行已被双引号包裹 |
| 中文 | 原样保留 | 依赖文件编码正确 |
4.2 组装一行¶
char esc_user[256], esc_order[256], line[1024];
csv_escape(lr_eval_string("{username}"), esc_user, sizeof(esc_user));
csv_escape(lr_eval_string("{orderNo}"), esc_order, sizeof(esc_order));
snprintf(line, sizeof(line), "%s,%s,%s",
esc_user, esc_order, lr_eval_string("{__time(yyyy-MM-dd HH:mm:ss)}"));
append_line(path, line);
snprintf 截断时不会越界,但会静默丢内容。判定标准:写入前检查返回值,>= sizeof(line) 就说明被截断,应该缩字段或加长缓冲。
4.3 参数本身超长¶
lr_eval_string 返回的是内部缓冲区指针,长度没有显式上限,但 lr_output_message 打印长串会被截断。真正需要处理超大 JSON 时用扩展接口:
char *val = NULL;
unsigned long len = 0;
lr_eval_string_ext("{order_list}", 0, &val, &len, 0, 0, 0);
lr_output_message("order_list length=%lu", len);
/* 使用 val ... */
lr_eval_string_ext_free(&val);
lr_eval_string_ext 返回的内存由 LR 管理,用完必须 lr_eval_string_ext_free,否则每次迭代泄漏一块,几万次迭代后 LG 内存会被吃光。
五、每个 Vuser 独立文件¶
5.1 目录与命名约定¶
命名规则:{业务}_{LG主机名}_{vuser_id}.csv。好处:
- 不会互相覆盖或交错。
- 出问题能定位到具体 LG 和 Vuser。
- 压测后可以按 LG 并行合并。
5.2 文件头与 BOM¶
每个文件第一次创建时写表头。用 "a" 模式无法判断“是否第一次”,可以在 vuser_init 里用 "w" 建文件并写表头,Action 里用 "a" 追加:
vuser_init()
{
char path[512];
int vuser_id = 0;
FILE *fp;
lr_whoami(&vuser_id, NULL, NULL);
snprintf(path, sizeof(path), "D:\\lr_out\\order_%s_%d.csv",
lr_get_host_name(), vuser_id);
fp = fopen(path, "w, ccs=UTF-8");
if (fp != NULL) {
fwrite("\xEF\xBB\xBF", 1, 3, fp); /* UTF-8 BOM,Excel 才不乱码 */
fprintf(fp, "user,orderNo,ts\n");
fclose(fp);
} else {
lr_error_message("init file failed: %s (errno=%d)", path, errno);
}
return 0;
}
BOM 只写一次,且必须在文件最前面。如果多个 Vuser 共用一个文件,BOM 会被追加到中间,变成乱码字符,所以“独立文件 + 初始化写 BOM”是一套绑定的做法。
5.3 合并¶
压测结束后在 Controller 上合并:
Get-ChildItem D:\lr_out\order_*.csv |
ForEach-Object { Get-Content $_.FullName | Select-Object -Skip 1 } |
Set-Content D:\lr_out\all_orders.csv -Encoding UTF8
Select-Object -Skip 1 跳过每个文件的表头,最后单独补一行表头即可。文件达到几十万行时不要用 Get-Content 全量读入,改用 [System.IO.File]::ReadLines() 逐行流式写出,避免 PowerShell 进程吃掉数 GB 内存。
六、并发写冲突的真实表现¶
6.1 为什么不能共用一个文件¶
即使每次都是 fopen("a") + 一行 + fclose,也不能保证原子:
fprintf可能分多次write(格式串 + 参数),中间可能被其他进程插入。- Windows 的 CRT 在
fclose时才刷缓冲区,一次刷 4KB,多个进程的块会交错;杀毒软件还会短暂锁文件,导致fopen返回NULL,错误率随 Vuser 数上升。
实测:200 Vuser 共写一个文件跑 10 分钟,约 0.3% 的行出现交错或半行;独立文件为 0。
6.2 如果业务强制要求单文件¶
三种可行方案:
- 每个 Vuser 写自己的文件,压测后合并(推荐)。
- 写数据库表,压测后导出。LR 脚本里用
lr_db_connect+lr_db_executeSQLStatement,但会增加被测库压力。 - 写共享文件时加锁。Windows 下用
_fsopen(path, "a", _SH_DENYWR)独占(需#include <share.h>),失败重试;代价是写入串行化,Vuser 多时会成为瓶颈。
FILE *fp = _fsopen(path, "a", _SH_DENYWR);
if (fp == NULL) {
lr_think_time(0.01); /* 简单退避,生产脚本要有重试上限 */
}
不推荐方案 3,压测机上为了写日志引入锁竞争,得不偿失。
七、编码:中文乱码的三个来源¶
7.1 来源一:脚本编码与文件编码不一致¶
VuGen 脚本文件本身可能是 GBK,lr_eval_string 取到的中文按字节原样传出。如果 fopen 用默认 ANSI 模式写,文件就是 GBK;下游按 UTF-8 读就乱码。
| 方案 | 写法 | 结果 |
|---|---|---|
| UTF-8(推荐) | fopen(path, "a, ccs=UTF-8") |
文件无 BOM,需自行写 BOM 给 Excel |
| 系统 ANSI | fopen(path, "a") |
中文 Windows 下是 GBK |
| 宽字符 | _wfopen + fwprintf |
需要 wchar_t 转换,脚本复杂度高 |
7.2 来源二:响应编码不是 UTF-8¶
关联到的值在响应里是 UTF-8 字节,LR 存进参数时不做转码。若脚本按 GBK 解释,写出来就是乱码。确认方法:
lr_output_message("first bytes=%02X %02X %02X",
(unsigned char)lr_eval_string("{name}")[0],
(unsigned char)lr_eval_string("{name}")[1],
(unsigned char)lr_eval_string("{name}")[2]);
UTF-8 中文首字节通常在 E4–E9,GBK 在 B0–F7。不确定就统一转:
lr_convert_string_encoding(lr_eval_string("{name}"),
LR_ENC_SYSTEM_LOCALE, LR_ENC_UTF8, "name_utf8");
7.3 来源三:Excel 不认无 BOM 的 UTF-8¶
Windows 版 Excel 双击打开无 BOM 的 UTF-8 CSV,会按 ANSI 解析,中文全变问号或乱码。两种处理:
- 写文件时加 BOM(见 5.2)。
- 或让 Excel 走“数据 → 从文本/CSV → 选择 UTF-8”。
判定标准:文件前三个字节是 EF BB BF 才算带 BOM 的 UTF-8,第九节给了检查命令。
八、完整脚本¶
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <direct.h>
static int append_line(const char *path, const char *line)
{
FILE *fp = fopen(path, "a, ccs=UTF-8");
int rc = 0;
if (fp == NULL) {
lr_error_message("fopen failed: %s (errno=%d)", path, errno);
return -1;
}
if (fprintf(fp, "%s\n", line) < 0) {
lr_error_message("fprintf failed: %s (errno=%d)", path, errno);
rc = -1;
}
if (fclose(fp) != 0) {
lr_error_message("fclose failed: %s (errno=%d)", path, errno);
rc = -1;
}
return rc;
}
static void csv_escape(const char *in, char *out, size_t out_size)
{
size_t i, j = 0;
if (out_size < 4) {
if (out_size > 0) out[0] = '\0';
return;
}
out[j++] = '"';
for (i = 0; in[i] != '\0' && j + 3 < out_size; i++) {
if (in[i] == '"') {
out[j++] = '"';
out[j++] = '"';
} else if (in[i] == '\r' || in[i] == '\n') {
out[j++] = ' ';
} else {
out[j++] = in[i];
}
}
out[j++] = '"';
out[j] = '\0';
}
Action()
{
char path[512];
char line[1024];
char esc_user[256];
char esc_order[256];
int vuser_id = 0;
FILE *rf;
char buf[1024];
int lines = 0;
lr_whoami(&vuser_id, NULL, NULL);
_mkdir("D:\\lr_out");
snprintf(path, sizeof(path), "D:\\lr_out\\order_%s_%d.csv",
lr_get_host_name(), vuser_id);
/* 1. 登录(脚本略,使用 {p_user}/{p_pwd} 参数化) */
/* 2. 关联订单号 */
web_reg_save_param_json(
"ParamName=orderNo",
"QueryString=$.data.orderNo",
SEARCH_FILTERS,
"Search=Body",
LAST);
web_custom_request("create_order",
"URL=http://shop.example.com/api/order",
"Method=POST",
"EncType=application/json",
"Body={\"skuId\":\"{sku}\",\"qty\":1}",
LAST);
/* 3. 关联失败直接暴露,不写空值 */
if (strcmp(lr_eval_string("{orderNo}"), "{orderNo}") == 0 ||
strlen(lr_eval_string("{orderNo}")) == 0) {
lr_error_message("orderNo not correlated, skip writing");
return -1;
}
/* 4. 组装并写入 */
csv_escape(lr_eval_string("{p_user}"), esc_user, sizeof(esc_user));
csv_escape(lr_eval_string("{orderNo}"), esc_order, sizeof(esc_order));
snprintf(line, sizeof(line), "%s,%s,%s",
esc_user, esc_order, lr_eval_string("{__time(yyyy-MM-dd HH:mm:ss)}"));
if (append_line(path, line) != 0) {
lr_error_message("write failed: %s", path);
return -1;
}
/* 5. 读回校验:文件至少有一行数据 */
rf = fopen(path, "r, ccs=UTF-8");
if (rf == NULL) {
lr_error_message("read back failed: %s (errno=%d)", path, errno);
return -1;
}
while (fgets(buf, sizeof(buf), rf) != NULL) {
lines++;
}
fclose(rf);
if (lines < 2) {
lr_error_message("verify failed: %s has %d lines", path, lines);
return -1;
}
lr_output_message("written: %s lines=%d order=%s",
path, lines, lr_eval_string("{orderNo}"));
return 0;
}
关键点:
- 关联失败用
strcmp判断字面量,这是最可靠的“变量未创建”检测。LR 对未定义参数保留{orderNo}文本。 - 读回校验放在同一个 Action 里,只验证“这次写进去了”,不验证内容正确性;内容校验靠压测后与数据库比对。
- 每个 Vuser 每轮都会重读整个文件,文件很大时这是 O(n²) 开销。超过 10 万行时把读回校验改成只读最后一行,或仅在调试轮开启。
九、调试步骤¶
- 1 Vuser × 1 iteration,确认
D:\lr_out下有且只有一个文件,内容两行(表头 + 数据)。 - 打印参数长度:
lr_output_message("len=%d", (int)strlen(lr_eval_string("{orderNo}")));,长度为 0 或等于字面量长度就是没关联上。 - 打开
Runtime Settings → Log → Extended log → Parameter substitution,确认写入前参数已被替换。 - 用
Get-Content -Encoding UTF8检查中文与 BOM:
Format-Hex -Path D:\lr_out\order_LG01_1.csv -Count 3
Get-Content D:\lr_out\order_LG01_1.csv -Encoding UTF8 | Select-Object -First 3
- 2 Vuser × 5 iteration,确认生成两个文件、每个文件 6 行,行不交错。
- 50 Vuser × 100 iteration 压 10 分钟,检查文件数 = Vuser 数,总行数 = 迭代数 × Vuser 数(按成功事务算)。
- 用 Process Explorer 看
mmdrv.exe的 Handle 数是否随迭代线性增长,增长即句柄泄漏。
十、常见错误表¶
| 现象 | 根因 | 处理 |
|---|---|---|
ACCESS_VIOLATION |
long 存 FILE *,64 位截断 |
改 FILE * |
| 文件不存在但脚本不报错 | fopen 返回 NULL 未判断 |
判空 + 打 errno |
| 文件为空 | 关联失败,写入的是 {orderNo} 字面量 |
写前 strcmp 检测 |
| 一行变多行 | 参数含 \n |
csv_escape 替换换行 |
| CSV 列错位 | 值含逗号或引号 | 整行加引号,内部引号双写 |
| 行数少于预期 | 进程被强杀,缓冲区未刷 | 每轮 fopen/fclose |
| 句柄数持续上涨 | 错误分支漏 fclose |
所有路径统一关闭 |
fopen 偶发失败 |
杀毒软件锁文件 / 权限 | 换目录、加白名单、检查 ACL |
| 路径无效 | C 字符串 "D:\lr_out" 中 \l 是错误转义 |
写成 "D:\\lr_out" |
| 写入被截断 | 缓冲区小于内容 | 用 snprintf 并检查返回值 |
十一、典型用途¶
| 业务 | 写入内容 | 后续处理 |
|---|---|---|
| 订单对账 | 用户、订单号、时间 | 与 DB 订单表比对,查漏单/重复单 |
| 跨脚本传参 | token、userId | 另一个脚本 vuser_init 读文件(注意有效期) |
| 压测取证 | 关联原始值、响应码 | 复现失败样本,定位是脚本还是服务端问题 |
涉及 token 的文件属于敏感数据,压测结束后要删除或加密归档,不要留在共享目录。
十二、检查清单¶
- 所有文件句柄都是
FILE *,fopen/fprintf/fclose返回值都有判断 - 目录在写入前创建,路径不含未转义的反斜杠
- 每个 Vuser 独立文件,命名含 LG 主机名与 vuser_id
- 首次创建写表头与 UTF-8 BOM,追加时不再写
- 写入前对逗号、引号、换行做 CSV 转义
- 关联失败时不写空值,直接报错并跳过
- 每轮
fopen/fclose,做过多 Vuser 并发验证 - 压测后合并脚本已就绪,敏感字段已按规范清理