背景
我正在研发推荐系统,在设计中,一个推荐接口会有一个或多个RC版本,我需要观测不同版本的有效性,以此选出推荐效果最好的版本上线。
为此,我使用了Nginx控制流量分发,实现A/B测试,然后对接口的不同版本进行分析,以此评估有效性。
这里对使用Nginx实现A/B测试的探索做一个记录。
A/B测试简介
当测试应用程序的更改时,有些因素只能在生产环境中测量,而不能在开发测试平台中测量。示例包括UI更改对用户行为的影响以及对整体性能的影响。一种常见的测试方法是A/B测试-也称为拆分测试-在该测试中,通常是一小部分用户被定向到应用程序的新版本,而大多数用户仍在使用当前版本。
通过A/B测试,我们可以将用户群体按照指定比例分成几部分,每个部分对应一个应用程序版本,然后观测不同版本的性能和有效性,以此来辅助应用程序的更新决策。
使用Nginx实现A/B测试
Nginx和Nginx Plus提供了两种方式控制流量分发:
split_clients
- 基于hash的流量分发。在Nginx和Nginx Plus中都有效。sticky route
- 可以显式控制流量分发。只在Nginx Plus中有效。
两种方式适用于不同的场景,可以根据需要选择。
我使用的是split_clients
方式,所以这里主要对此进行介绍,如果需要了解两种方式更详细的用法介绍,可以参考文章底部的参考链接。
使用split_clients方式
split_clients
配置块的主要功能是为不同的请求返回不同的值,类似if...else...
。
语法:
split_clients string $variable { ... }
string
位置可以使用任何 Nginx参数 或者字符串。
$variable
位置可以自定义参数名,以提供其他配置块引用,需以$
开头。
配置示例:
http {
split_clients "${remote_addr}AAA" $variant {
0.5% .one;
2.0% .two;
* "";
}
server {
location / {
index index${variant}.html;
原始字符串的值使用MurmurHash2进行哈希处理。在给定的示例中,哈希值从0到21474835(0.5%)对应于$variant
变量的值" .one",哈希值从21474836到107374180(2%)对应于值".two",并且哈希从107374181到4294967295的值对应于值""(空字符串)。
在详细介绍之前,这里将应用程序的更新分为两种:
- 覆盖式更新 - 同一个应用进程只会存在新旧版本的其中之一。在web应用中比较常见。
- 新增式更新 - 同一个应用进程同时存在新旧版本。在数据接口应用中比较常见。
下面分别对这两种更新方式进行描述并设计A/B测试。
覆盖式更新
一些开发场景中,应用更新会覆盖原有逻辑,在发布时,按照服务进行分组,在不同的服务组中启动不同的应用版本。对于这种更新,应用的新旧版本uri是保持一致的,在处理分流时,需要分发到不同的服务组。
主要配置实现:
# application version 1a
upstream version_1a {
server 10.0.0.100:3001;
server 10.0.0.101:3001;
}
# application version 1b
upstream version_1b {
server 10.0.0.104:6002;
server 10.0.0.105:6002;
}
split_clients "${arg_token}" $appversion {
95% version_1a;
* version_1b;
}
server {
# ...
listen 80;
location / {
proxy_set_header Host $host;
proxy_pass http://$appversion;
}
}
在这个配置中,split_clients
配置块中,两个返回值都是upstream的名称,这个返回之后,server下的location配置块中的proxy_pass http://$appversion;
就会转化为具体的upstream名称,例如:proxy_pass http://version_1a;
,然后接下来的流程就和直接指定upstream名称一样了。
新增式更新
一些开发场景中,应用更新不会覆盖原有逻辑,而是增加新的版本,在发布时,在每个服务组中同时存在应用的多个版本。对于这种更新,应用的新旧版本uri是有版本号差异的,在处理分流时,需要分发到不同的uri版本。
不同版本的uri示例:
/recommender/ui
/rc0/recommender/ui
/rc1/recommender/ui
主要配置实现:
upstream recommender {
server 10.1.22.17:3031;
server 10.1.22.18:3031;
}
split_clients "${arg_userid}" $version {
5.0% /rc0;
5.0% /rc1;
* '';
}
server {
# ...
listen 80;
location / {
proxy_set_header Host $host;
proxy_pass http://recommender$version$request_uri;
}
}
在这个配置中,split_clients
配置块中,两个返回值对应的时接口版本前缀,这个返回之后,server下的location配置块中的proxy_pass http://recommender$version$request_uri;
进行了拼接,将接口版本前缀增加到uri中,例如:proxy_pass http://recommender/rc0$request_uri;
,然后接下来的流程就和直接指定upstream名称一样了。
这里注意proxy_pass http://recommender$version$request_uri;
的写法,这是对uri进行了重组,$request_uri
一定要加上,否则uri不正确。
使用sticky route方式
没有深入研究sticky route
方式,这里只对其进行简单介绍。
sticky route(粘性路由)
可以实现显示控制流量分发,通过在cookie或uri中添加route参数,根据route参数的特征进行显示控制客户端和应用版本的映射关系,以保证客户端使用的总是同一个应用版本。
有两种不同的方式使用sticky route
。
- 客户端方式。基于NGINX变量选择路由,这些变量包含最初直接从客户端发送的值,例如客户端IP地址或者浏览器专用的HTTP请求头。
- 服务端或应用端方式。 由应用程序确定首次用户分配给哪个测试组,然后向其发送cookie或重定向URI,其中包括代表所选组的路由指示符。客户端下一次发送请求时,它将显示cookie或使用重定向URI。sticky route指令提取路由指示器并将请求转发到相应的服务器。
使用应用端方式更加繁琐但是更加可控,可以适用更复杂的场景。
示例
示例使用应用端方式。
upstream backend {
zone backend 64k;
server 10.0.0.200:8098 route=a;
server 10.0.0.201:8099 route=b;
sticky route $route_from_cookie $route_from_uri;
}
在upstream组的sticky route
优先设置route的值为一个服务提供的cookie中指定的值(捕获于$route_from_cookie
)。如果客户端没有cookie,则route被设置为请求URI中一个参数的值($route_from_uri
)。然后route值决定upstream组中哪个server获取请求 —— 如果route是a,第一个server获取请求;如果route是b,第二个server获取请求。两个server对应应用的两个版本。
在实际情况中,a 和 b 是内嵌在更长的字符串中,为了只提取字母,我们可以为cookie和URI各配置一个map配置块。
map $cookie_route $route_from_cookie {
~.(?P<route>w+)$ $route;
}
map $arg_route $route_from_uri {
~.(?P<route>w+)$ $route;
}
在第一个map中,$cookie_route
代表名为ROUTE的cookie的值,第二行的正则表达式使用的是 Perl Compatible Regular Expression (PCRE) 语法,提取值的一部分,并将提取值分配给一个名为route的内部参数,同时也分配给第一行的 $route_from_cookie
参数,使其可用于传递到sticky route
指令。
示例:
ROUTE=iDmDe26BdBDS28FuVJlWc1FH4b13x4fn.a
在第二个map中,$arg_route
代表URI中一个名为route的值,与cookie方式类似,第二行的正则表达式提取值除了分配给一个名为route的内部参数,同时也分配给第一行的 $route_from_uri
参数。
示例:
www.example.com/shopping/my-cart?route=iLbLr35AeAET39GvWK2Xd2GI5c24y5go.b
完整配置示例:
http {
# ...
map $cookie_route $route_from_cookie {
~.(?P<route>w+)$ $route;
}
map $arg_route $route_from_uri {
~.(?P<route>w+)$ $route;
}
upstream backend {
zone backend 64k;
server 10.0.0.200:8098 route=a;
server 10.0.0.201:8099 route=b;
sticky route $route_from_cookie $route_from_uri;
}
server {
listen 80;
location / {
# ...
proxy_pass http://backend;
}
}
}
结束语
我对推荐接口更新和评估的设计是,对外服务的地址是不变的,后端接口可以根据需要使用不同的版本。这样对于调用端是无感知的,对数据服务的更新和新推荐算法的评估也会比较方便,皆大欢喜。
经过探索和测试,我完成了 新增式更新 中的这种设计,达到了我的预期效果,同时这种设计,在需要多个版本共存的情境中也是适用的。
A/B测试是一个有效的方式,用来分析和追踪应用并监控应用性能。
split_client适用于更加随机的场景,通过选择合适的参数组合,可以得到更好的效果。
sticky route适用于静态路由的场景,可以保证客户端和应用版本的固定映射,以此来保证用户看到的总是同一个版本,提高用户体验和保证测试效果有效性。
不同的场景选择不同的方案。
参考:
https://www.nginx.com/blog/performing-a-b-testing-nginx-plus/
https://www.nginx.com/blog/dynamic-a-b-testing-with-nginx-plus/
https://nginx.org/en/docs/http/ngx_http_split_clients_module.html?_ga=2.251322371.442393638.1597455335-461953950.1597455335
https://nginx.org/en/docs/varindex.html?_ga=2.109055299.778078225.1597384417-2056163875.1597384417