使用NGINX和NGINX Plus进行A/B测试

NGINX | 2016年7月25日

测试应用更改时,有些因素只能在生产环境中衡量,而无法在开发测试环境中衡量。例如,UI更改对用户行为的影响,以及对整体性能的影响。一种常见的测试方法是A/B 测试,也称为拆分测试。这种方法会将一部分(通常占比较小)用户引导至应用的新版本,而大多数用户仍继续使用当前版本。

本文将探讨在部署新版Web应用时进行A/B测试的重要性,以及如何使用NGINX和NGINX Plus控制向用户显示哪个应用版本。配置示例将说明如何使用NGINX和NGINX Plus的指令、参数和变量,实现准确且可衡量的A/B测试。

为什么要进行A/B测试?

如前所述,A/B 测试可用于衡量两个版本在应用性能或效果方面的差异。例如,开发团队可能希望调整 UI 中按钮的视觉布局,或全面改造购物车流程,但需要比较交易成交率,以确认更改能否产生预期的业务影响。通过 A/B 测试,可以将指定比例的流量发送到新版本,其余流量发送到旧版本,并衡量应用两个版本的效果。

或者,您可能不太关注对用户行为的影响,而更关注性能影响。假设您计划对Web应用部署大量更改,但认为质量保证环境中的测试无法准确反映这些更改在生产环境中可能造成的性能影响。在这种情况下,A/B部署可先向一小部分指定比例的访问者提供新版本,以衡量更改对性能的影响,然后逐步提高该比例,最终向所有用户推出更改后的应用。

使用NGINX和NGINX Plus进行A/B测试

NGINX和NGINX Plus提供两种方法,用于控制Web应用流量的发送目标。第一种方法适用于这两款产品,第二种方法仅适用于NGINX Plus。

这两种方法都根据一个或多个NGINX变量的值来选择请求的目标位置。这些变量用于捕获客户端特征(例如其IP地址)或请求URI的特征(例如命名参数)。不过,两种方法之间的差异使其分别适用于不同的A/B测试用例:

  • The split_clients方法根据从请求中提取的变量值的哈希来选择请求的目标位置。所有可能的哈希值会划分给不同的应用版本,并且可以为每个应用分配不同比例的哈希值。最终将以随机方式选择目标位置。
  • The sticky route方法可让您更精细地控制每个请求的目标位置。应用选择基于变量值本身(而非哈希值),因此可以明确设置由哪个应用接收具有特定变量值的请求。还可以使用正则表达式,仅根据变量值的一部分进行决策,并可优先选择某个变量而非其他变量作为决策依据。

使用split_clients方法

在此方法中,split_clients配置块会为每个请求设置一个变量,用于确定proxy_pass指令将请求发送到哪个上游组。在以下配置示例中,$appversion变量的值决定proxy_pass指令将请求发送到何处。split_clients块使用哈希函数,将该变量的值动态设置为以下两个上游组名称之一:version_1a或version_1b。

http {    # ...
    # 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;
        }
    }
}

The first parameter to the split_clients指令的第一个参数是一个字符串(本例中为"${arg_token}"),NGINX会在处理每个请求时使用MurmurHash2函数对该字符串进行哈希计算。URI参数在NGINX中以名为$arg_name的变量形式提供。例如,$arg_token变量会捕获名为token的URI参数。可以使用任意NGINX变量或由多个变量组成的字符串作为待哈希字符串。例如,可以对客户端IP地址($remote_addr变量)、端口($remote_port)或两者的组合进行哈希计算。应使用在NGINX处理请求之前生成的变量。包含客户端初始请求信息的变量较为合适,例如前面提到的客户端IP地址和端口、请求URI,甚至HTTP请求标头。

The second parameter to the split_clients指令的第二个参数(在本例中为$appversion)是一个变量,其值根据第一个参数的哈希值动态设置。花括号内的语句将哈希表划分为多个“桶”,每个桶包含一定比例的可能哈希值。可以创建任意数量的桶,各桶的大小不必相同。请注意,最后一个桶的百分比始终以星号(*)表示,而不是具体数字,因为哈希值的数量可能无法按指定百分比均匀划分。

在本例中,我们将95%的哈希值放入与version_1a上游组关联的存储桶,其余哈希值放入与version_1b关联的另一个存储桶。哈希值的取值范围为0至4,294,967,295,因此第一个存储桶包含从0到约4,080,218,930的值(占总数的95%)。变量$appversion会被设置为与相应存储桶关联的上游,该存储桶包含变量$arg_token的哈希值。具体而言,哈希值100,000,000位于第一个存储桶中,因此$appversion会动态设置为version_1a。

测试split_clients配置

为了验证split_clients配置块是否按预期工作,我们创建了一个测试配置,按照与上述相同的比例(95%和剩余比例)将请求分配给两个上游组。我们将组中的虚拟服务器配置为返回一个字符串,指明由哪个组(version_1a或version_1b)处理请求(可在此处查看测试配置)。然后,我们使用curl生成20个请求,并通过对urandom文件运行cat命令,为URI参数token随机设置值。此操作仅用于演示和随机化。结果符合预期:每20个请求中有1个(95%)由version_1b提供服务(为简洁起见,此处仅显示其中10个请求)。

# for x in {1..20}; do curl 127.0.0.1?token=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1); doneToken: p3Fsa86HfJDFwZ9ZnYz4QbaZLzcb70Ka		Served from site version_1a. 
Token: a7z7afUz7zQijXwsLGfj6mWyPU8sCRIZ		Served from site version_1a. 
Token: CjIr6W57NtPzChf4qbYMzD1Estif7jOH		Served from site version_1a. 
                 ... output for 10 requests omitted ...
Token: gXq8cbG3jhl1LuYmICPTfDQT855gaO5y		Served from site version_1a. 
Token: VVqGuNN3cRU2slsl5AWOR93aQX8nXOpk		Served from site version_1a. 
Token: z7KnewxTX5Sp6wscT0fgmTDohTsQuCmy		Served from site version_1b!!
Token: fWOgH9WRrR0kLJZcIaYchpLhceaQgPD1		Served from site version_1a. 
Token: mTADMXrVnwnr1cd5JE6QCSkgTwfWUnDk		Served from site version_1a.
Token: w7AzSNmNJtxWZaH6cXe2PWIFqst2o3oP		Served from site version_1a. 
Token: QR7ay0dA39MmVlXtzgOVsj6SBTPi8ECC		Served from site version_1a.

使用sticky route方法

在某些情况下,您可能希望根据某个NGINX变量的全部或部分值做出客户端路由决策,从而定义静态路由。为此,可以使用sticky route指令,该指令仅在NGINX Plus中提供。此指令接受包含一个或多个参数的列表,并将路由设置为列表中第一个非空参数的值。利用此功能,可以按优先级确定请求中的哪个变量控制目标选择,从而在单个配置中支持多种流量拆分方法。

此方法有两种不同的使用方式。

  • 使用客户端方法时,可以根据包含客户端最初直接发送值的NGINX变量选择路由,例如客户端的IP地址,或特定于浏览器的HTTP请求标头(如客户端的User-Agent)。
  • 采用服务器端或应用端方法时,应用会决定将首次访问的用户分配到哪个测试组,并向其发送Cookie或重定向URI,其中包含代表所选组的路由标识符。客户端下次发送请求时,会提供该Cookie或使用重定向URI;sticky route指令会提取路由标识符,并将请求转发到相应的服务器。

本例采用应用端方法:sticky route 指令优先将 upstream 组中的路由设置为服务器所提供 Cookie 中指定的值(捕获到 $route_from_cookie 中)。如果客户端没有 Cookie,则将路由设置为请求 URI 参数中的值($route_from_uri)。随后,路由值决定将请求发送到上游组中的哪个 server:如果路由为 a,则发送到第一台服务器;如果路由为 b,则发送到第二台服务器(这两台服务器分别对应应用的两个版本)。

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;
}

但在实际的cookie或URI中,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兼容正则表达式(PCRE)语法,将该值的一部分(在本例中,即句点后的字符串(w+))提取到命名捕获组route中,并将其赋给同名的内部变量。该值还会赋给第一行中的$route_from_cookie变量,以便将其传递给sticky route指令。

例如,第一个map块从此 Cookie 中提取值“a”,并将其赋给$route_from_cookie:

ROUTE=iDmDe26BdBDS28FuVJlWc1FH4b13x4fn.a

在第二个map块中,$arg_route变量表示请求URI中名为route的参数。与Cookie一样,第二行的正则表达式会提取URI的一部分;在本例中,即route参数中句点后的字符串(w+)。该值会读入命名捕获组,并赋给一个内部变量以及$route_from_uri变量。

例如,第二个map块从此URI中提取值b,并将其赋给$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;
        }
    }
}

测试sticky route配置

对于split_clients方法,我们创建了一个测试配置,可在此处访问。我们使用curl发送名为ROUTE的Cookie,或在URI中包含route参数。Cookie或参数的值是通过对urandom文件运行cat命令生成的随机字符串,并在末尾附加.a或.b。

首先,我们使用以​.a结尾的Cookie进行测试:

# curl --cookie "ROUTE=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1).a" 127.0.0.1Cookie Value: R0TdyJOJvxBkLC3f75Coa29I1pPySOeQ.a
Request URI: /
Results: Site A - Running on port 8089

然后,我们使用一个以 .b结尾的 Cookie 进行测试。

# curl --cookie "ROUTE=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1).b" 127.0.0.1Cookie Value: JhdJZrScTnPBLhqmzK3podNRcJAIc8ST.b
Request URI: /
Results: Site B - Running on port 8099

最后,我们测试不使用Cookie,而是在请求URI中使用一个以route参数,其值以.a结尾。输出结果确认,当没有Cookie时(Cookie Value字段为空),NGINX Plus会使用从URI派生的路由值。

# curl 127.0.0.1?route=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1).aCookie Value:
Request URI: /?route=yNp8pHskvukXK6XqbWefhVUcOBjbJv4v.a
Results: Site A - Running on port 8089

记录并分析结果

本文所述的测试足以验证配置是否按预期分发请求,但要解读实际A/B测试的结果,则需要更详细地记录和分析请求的处理方式。采用何种日志记录和分析方法取决于多种因素,不在本文的讨论范围内。不过,NGINX和NGINX Plus提供了完善的内置请求处理日志记录和监控功能。

可以使用log_format指令定义包含任意NGINX变量的自定义日志格式。之后可以分析NGINX日志中记录的变量值。有关自定义日志记录和运行时监控的详细信息,请参阅《NGINX Plus Admin Guide》。

最后还需考虑的几点

设计实验或A/B测试方案时,请确保在不同应用版本之间分配请求的方式不会预先决定结果。如果希望实验完全随机,可使用split_clients方法,并对多个变量的组合进行哈希处理,以获得更理想的结果。例如,根据请求中的Cookie和用户ID组合生成唯一的实验令牌,比仅对客户端的浏览器类型和版本进行哈希处理更能实现随机化测试。这是因为许多用户很可能使用相同类型和版本的浏览器,因而都会被定向到同一应用版本。

还需要考虑到,许多用户属于所谓的“混合组”。他们会通过多台设备访问Web应用,例如办公电脑、家用电脑,以及平板电脑或智能手机等移动设备。这类用户有多个客户端IP地址,因此,如果以客户端IP地址作为选择应用版本的依据,他们可能会看到应用的两个版本,从而影响实验结果的准确性。

最简单的解决方案可能是要求用户登录,以便像我们的sticky route方法示例一样跟踪用户的会话Cookie。这样就能跟踪用户,并始终将其定向到首次访问测试时看到的版本。如果无法采用这种方式,有时可以将用户划分到测试期间不太可能发生变化的组中。例如,利用地理位置向洛杉矶的用户显示一个版本,向旧金山的用户显示另一个版本。

结论

A/B 测试可将不同比例的流量分配到备选服务器,是分析和跟踪应用变更以及监控应用性能的有效方法。NGINX 和 NGINX Plus 均提供可用于构建可靠 A/B 测试框架的指令、参数和变量,还支持记录每个请求的实用详细信息。祝测试顺利!

亲自体验NGINX Plus及sticky route方法,立即开始30天免费试用,或联系我们,讨论您的用例。


分享
标签: F5 NGINX, Tech

关于作者

相关博客文章

NGINX Plus R26 正式发布
NGINX | 2022/02/15

NGINX Plus R26 正式发布

NGINX Plus R26通过缓存JSON Web密钥集加快JWT验证,并强化TLS握手,从而提升安全性。NGINX JavaScript的新功能包括增强对异步函数的支持,以及实现WebCrypto API。

使用NGINX App Protect DoS保护gRPC应用,抵御严重的DoS攻击
NGINX | 2022/02/10

使用NGINX App Protect DoS保护gRPC应用,抵御严重的DoS攻击

gRPC具备开发人员设计和部署现代应用所需的速度与灵活性,但其开放性也使其极易受到第7层DoS攻击。NGINX App Protect DoS可保护gRPC应用免受此类破坏性攻击。

Ingress Controller 选型指南(第 2 部分):风险与面向未来的规划
NGINX | 2021/09/13

Ingress Controller 选型指南(第 2 部分):风险与面向未来的规划

了解选择错误的Ingress控制器可能带来的风险,以及如何从关键方面着手,让您的选择更好地适应未来需求。

NGINX Plus R19 正式发布
NGINX | 2019/09/03

NGINX Plus R19 正式发布

NGINX Plus R19扩展了监控功能,不仅提供更多指标,还新增了指标分析方式;增加试运行模式,用于测试限速效果;增强键值存储,以支持IP地址范围;并使限速更加动态。

NGINX现已正式加入F5
NGINX | 2019/05/09

NGINX现已正式加入F5

NGINX现已正式成为F5的一部分。Gus Robertson现任F5 NGINX业务部门负责人。他阐述了两家公司在人才、文化和愿景方面融合的价值,以及双方将如何携手增强NGINX的开源产品和商业产品。

使用NGINX和NGINX Plus实现高性能缓存
NGINX | 2016/08/24

使用NGINX和NGINX Plus实现高性能缓存

我将介绍NGINX在内容缓存和性能方面的工作原理,并分享一些控制NGINX内容缓存方式的技巧。