Nginx的日志系统是整个A/B测试数据链路中非常关键的一环。当流量被Nginx分流到不同实验组后,后端服务记录的业务日志往往缺少“这个请求属于哪个实验组”的信息,除非在每个后端服务里都硬编码一段实验判断逻辑。更简单的做法是让Nginx在网关层完成分流,并把分流结果直接写进access log,这样日志采集系统只需要从Nginx日志里解析一个字段,就能把实验分组和业务行为关联起来。这个分流标识可以是cookie值、自定义请求头,也可以是Nginx内部变量,最终通过log_format指令输出到每一行日志中。

定义带分流标识的日志格式
Nginx的日志格式通过log_format指令定义,语法本身不复杂,但要把分流标识放进去,需要先搞清楚标识从哪来。假设你的A/B测试框架会把实验组编号写进一个名为abtest_group的cookie,那么在Nginx里可以用$cookie_abtest_group这个变量直接读取它。如果标识是通过HTTP头传递的,比如X-ABTest-Group,对应的变量就是$http_x_abtest_group。Nginx会自动把请求头名称转换为小写并加上http_前缀生成变量。
下面是一个完整的配置示例。在http块中定义log_format,使用split_clients做分流,并在server块或location块中指定access_log:
http {
# 定义分流规则:根据用户IP hash分为A/B两组
split_clients "${remote_addr}${http_user_agent}" $abtest_variant {
50% "A";
* "B";
}
# 自定义日志格式,包含分流标识
log_format abtest_log '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'abtest_group=$abtest_variant '
'cookie_group=$cookie_abtest_group '
'header_group=$http_x_abtest_group';
server {
listen 80;
server_name api.ipipp.com;
access_log /var/log/nginx/abtest_access.log abtest_log;
location / {
# 将分流结果传给后端
proxy_set_header X-ABTest-Group $abtest_variant;
proxy_pass http://backend_upstream;
}
}
}
上面的配置里,split_clients指令根据客户端IP和User-Agent的组合做哈希,把流量稳定地分成两组。后续同一个用户再来访问时,基本会落到同一组,这对A/B测试来说是必要的。变量$abtest_variant的值可以是A或B,直接在log_format中以abtest_group=$abtest_variant的形式输出。日志中的每一行末尾就会带上类似abtest_group=A这样的字段,方便下游解析。把cookie里的组号和请求头里的组号也一并记录,是为了在排查分流不一致问题时能有更多线索。
需要注意的是,log_format里如果引用了不存在的变量,Nginx不会报错,只会输出一个空字符串,这在调试时容易造成迷惑。比如cookie在用户首次访问时还不存在,$cookie_abtest_group就会输出-或者空值,取决于具体的变量类型。因此建议不要只依赖cookie作为分流依据,配合split_clients或者map指令在网关层生成一个兜底的组别标识是更可靠的方案。
分流标识的几种传递方式对比
把分流标识写进日志只是最后一步,前面的关键是标识从哪里产生、如何传递。常见的有三种做法。第一种是使用cookie传递,测试平台在用户进入实验时下发一个cookie,后续所有请求都携带这个cookie,Nginx通过$cookie_名称读取。好处是前端可以直接感知分组,做一些客户端渲染的逻辑;缺点是cookie可能被用户清除、被浏览器安全策略拦截,而且跨域场景需要额外配置。
第二种是使用自定义请求头,由网关或上游服务在响应中设置,客户端在后续请求中带上,或者纯内部服务之间用请求头传递。这种方式对后端服务透明,不占用cookie空间,但纯前端调用场景下需要客户端配合,而且如果经过CDN或中间代理,自定义头可能被剥离。
第三种是完全在Nginx内部生成,比如用split_clients或map指令基于IP、UA等维度计算出组别,把结果存到Nginx变量里。这种方式不需要客户端做任何配合,完全由网关掌控,最稳定也最容易统一。缺点是无法把分组信息直接暴露给前端,如果前端需要根据实验组渲染不同界面,还得通过响应头把组别传回去。
下表总结了三种方式的优劣:
| 传递方式 | 前端可感知 | 用户可清除 | 中间代理影响 | 实现复杂度 |
|---|---|---|---|---|
| Cookie | 是 | 是 | 低 | 低 |
| 自定义请求头 | 取决于客户端 | 否 | 可能被剥离 | 中 |
| Nginx内部生成 | 需要额外下发 | 否 | 无 | 中 |
实际项目中往往会组合使用。比如Nginx内部用split_clients算出组别,然后通过add_header或proxy_set_header把组别传给客户端和后端,日志里再记录一份。这样前端能拿到组别做渲染,后端能拿到组别做业务逻辑,日志里也有完整的分组字段供数据分析团队使用,三端一致。
split_clients与map配合的进阶用法
单独使用split_clients已经能覆盖很多场景,但当实验不再简单是A/B两组,而是多组、多层、甚至多个实验同时运行时,就需要更灵活的分流策略。split_clients支持多个百分比区间,可以把流量切分成A、B、C、D四组,只需要在指令块中依次列出百分比和对应的变量值。百分比是相对于前一个区间剩余流量的比例,最后用*兜底。
split_clients "${remote_addr}" $experiment_group {
25% "control";
50% "variant_a";
25% "variant_b";
* "control";
}
这段配置把流量分成三组:25%进control组,剩余流量的50%(也就是总流量的37.5%)进variant_a,剩余25%(总流量的约18.75%)进variant_b,最后的*兜底再归到control组。如果团队里有多个人同时配置实验,这种百分比分配很容易算错,建议在注释里写清楚每个区间实际上占总流量的多少,避免误读。
map指令在这里的作用是把分流结果转换成具体的业务参数或者后端地址。举个例子,某个实验需要把variant_b组的请求代理到新版后端服务,control组和variant_a组仍然走旧版:
map $experiment_group $backend_upstream {
default old_backend_pool;
variant_b new_backend_pool;
}
server {
listen 80;
access_log /var/log/nginx/exp_access.log abtest_log;
location / {
proxy_set_header X-Experiment-Group $experiment_group;
proxy_pass http://$backend_upstream;
}
}
这样做的好处是分流逻辑和路由逻辑分离。split_clients负责“分到哪个组”,map负责“这个组应该做什么”。后面要调整实验比例,只需要改split_clients的百分比;要把某个组切到新版本服务,只需要改map里的映射关系。日志里的abtest_group字段记录的是split_clients的原始结果,不会因为路由变化而丢失实验分组信息。
日志落盘后的解析与数据管道衔接
日志写入磁盘后,真正的价值要在数据分析阶段才能体现。现代日志采集管道通常使用Filebeat、Fluentd或类似工具监控Nginx日志文件,把每一行日志结构化之后发送到Elasticsearch、ClickHouse或Kafka。日志格式里追加的abtest_group=A字段会以键值对的形式出现在原始文本中,采集端需要做对应的解析规则。
如果团队使用的是ELK体系,可以在Filebeat的配置中定义include_lines和exclude_lines来过滤日志,再通过Logstash的grok或者dissect插件提取实验组信息。下面是一个简单的Logstash filter示例,用于从日志行中解析出abtest_group字段:
filter {
grok {
match => { "message" => "%{IPORHOST:client_ip} - %{DATA:remote_user} \[%{HTTPDATE:timestamp}\] \"%{WORD:method} %{DATA:request} HTTP/%{NUMBER:http_version}\" %{NUMBER:status} %{NUMBER:body_bytes_sent} \"%{DATA:referer}\" \"%{DATA:user_agent}\" abtest_group=%{DATA:abtest_group}" }
}
}
grok模式写了一长串,是因为Nginx默认日志格式的字段比较多,加上自定义的abtest_group字段后整个表达式就显得复杂。如果团队有自主选择空间,可以把日志格式调整为JSON输出,Nginx的log_format支持直接输出JSON结构:
log_format abtest_json escape=json
'{"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request_method":"$request_method",'
'"request_uri":"$request_uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"abtest_group":"$abtest_variant",'
'"experiment_group":"$experiment_group",'
'"user_agent":"$http_user_agent"}';
JSON格式的日志在采集端解析成本更低,几乎不需要写grok表达式,直接按JSON对象处理即可。注意escape=json参数可以避免日志内容中的特殊字符破坏JSON结构。不过JSON格式的日志体积会比默认的文本格式稍大一些,对日志量特别大的系统来说,需要评估磁盘和网络带宽成本。
分流标识进入日志后,数据分析人员就可以按abtest_group字段对数据进行分组统计,计算每个实验组的转化率、留存率或业务指标差异。日志中同时保留后端业务日志的关联字段(如请求ID、用户ID)会更有帮助,这需要在Nginx层生成或透传一个全局唯一的request_id,把它和分流标识一起写进日志,后续就能跨系统串联起整条请求链路。具体做法是使用$request_id变量(Nginx 1.11.0及以上版本支持),也可以在location块中通过set指令生成,再通过proxy_set_header传给后端。这样一来,A/B测试从流量分配、标识透传、日志记录到数据分析的整条链路才算真正打通。