为什么需要abtest
线上交易系统快发展,业务功能不断迭代,每周按固定频次上线新功能,难免会有一些BUG,全量上线,出现错误后回滚,导致业务单量损失,我们要将这种损失减少或者尽量降低,这就是需要abtest的原因。关于蓝绿部署,灰度发布,金丝雀等应用部署方案不作讨论,最终原理都是一样的,即通过较少量用户体验来发布某些应用新功能。
实现思路
abtest对于业务开发来说,最好是独立的,也就是我们需要在业务开发之外实现,无感知切入abtest,同时注意保持业务一致性,例如在某一时期,a用户始终看到A版,B用户始终看到B版。最终我们选用nginx+lua方案,通过在nginx中执行嵌入的lua脚本,动态计算upstream,将不同的用户导向不同的程序版本,达到abtest的目的。
具体实现
我们通过提取某一个特征cookie标识用户,该cookie在一定周期内针对同一个用户不是随意改变的。假如存在这个cookie,名称为__abc=testuser.123123,如果cookie值为数值化,可以直接进行模运算取余,如果是字符型,先进行一个hash运算得到数值,再进行模运算取余。
如果业务系统不存在特征cookie,条件允许可以在网站域下种一个新的cookie。
数据流示意图如下:
用户b的cookie特征提取为001,跟配置的分流比例300比较,符合条件,将upstream改为b.domain.com, 用户b一直访问新版本程序。
nginx安装lua模块
lua-nginx-module官方文档 ,请参考https://github.com/openresty/lua-nginx-module#installation,也可以直接安装openresty。
nginx conf配置
lua_package_path "/XXXX/servers/lualib/?.lua;;";
lua_package_cpath "/XXXX/servers/lualib/?.so;;";
#dns解析服务器,如果redis使用域名连接,可能需要配置dns
resolver 192.168.2.2 192.168.2.3;
#初始化全局变量,包括是否启用分流,流量切换比例, 默认为false不启用,流量切换比例0,不分流
init_by_lua_file /XXXX/conf/abtesting/init.lua;
#定时从redis中刷新 是否启用分流 和 流量切换比例值
init_worker_by_lua_file /XXXX/conf/abtesting/worker.lua;
#默认A版
upstream tomcat_a.domian.com {
server 127.0.0.1:1601 weight=100 max_fails=2 fail_timeout=30s ;
server 192.168.0.1:80 weight=1 max_fails=2 fail_timeout=30s ;
}
#新功能B版
upstream tomcat_b.domain.com {
server 192.168.0.2:80 weight=100 max_fails=2 fail_timeout=30s ;
server 192.168.0.3:80 weight=1 max_fails=2 fail_timeout=30s ;
}
server {
listen 80;
set $default_backend 'tomcat_a.domain.com';
location / {
proxy_next_upstream http_500 http_502 http_503 http_504 error timeout;
proxy_set_header Host 'y.domain.com';
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
expires 0;
set $backend $default_backend;
#此处计算可能会修改backend这个nginx变量,也就是变量修改了upstream
#具体执行逻辑是提取用户特征,也就是__abc这个cookie值,是否满足具体规则
rewrite_by_lua_file '/XXXX/conf/abtesting/diversion.lua';
proxy_pass http://$backend;
}
}
初始化脚本
global_configs = {
["divEnable"] = false, -- 分流开关,true表示开启
["newTrafficRate"] = 0, -- 分流比例,0-1000, 1000表示全部流量,100%
["redis"] = {
ap_host='192.168.1.10', -- redis主机ip或者是host
ap_port=6379, -- redis主机端口
ap_key='testToken' -- redis连接密码
}
}
定时任务脚本
-- 每隔10秒定时执行,可以自行调整定时任务间隔
local start_delay = 10
local new_timer = ngx.timer.at
local log = ngx.log
local ERR = ngx.ERR
local refresh
local get_redis
local close_redis
-- redis中分流开关key
local switch_key = "abtest:switch:global"
-- redis中 分流比例key
local traffic_key = "abtest:limit:traffic"
-- 连接redis
get_redis = function()
local redis = require "resty.redis"
local red = redis:new()
local ok, err = red:connect(global_configs['redis']['ap_host'],global_configs['redis']['ap_port'])
if ok and global_configs['redis']['ap_key'] then
ok, err = red:auth(global_configs['redis']['ap_key'])
end
return red, ok, err
end
-- 关闭redis连接
close_redis = function(red)
if not red then
return
end
local ok, err = red:close()
if not ok then
ngx.log(ngx.ERR,"fail to close redis connection : ", err)
end
end
-- 真实执行的任务
local function do_refresh()
local red, ok, err = get_redis()
if not ok then
log(ERR, "redis is not ready!")
return
end
local traficLimitStr, err = red:get(traffic_key)
-- 从redis中刷新 开关 值
local enable, err = red:get(switch_key)
if err then
log(ERR, err)
else
if ngx.null ~= enable then
global_configs["divEnable"] = ("true" == enable) and true or false
end
end
-- 从redis中刷新 流量比例 值
local trafficLimitStr, err = red:get(traffic_key)
if err then
log(ERR, err)
else
if ngx.null ~= trafficLimitStr and tonumber(trafficLimitStr) > 0 then
global_configs["newTrafficRate"] = tonumber(trafficLimitStr)
log(ERR, "update newTrafficRate: ", global_configs["newTrafficRate"])
end
end
return close_redis(red)
end
-- 任务执行与下次延时处理
refresh = function(premature)
if not premature then
do_refresh()
local ok, e = new_timer(start_delay, refresh)
if not ok then
log(ERR, "failed to create timer: ", e)
return
end
end
end
-- 程序入口,第一次nginx timer at定时执行
local ok, e = new_timer(start_delay, refresh)
if not ok then
log(ERR, "failed to create timer: ", e)
return
end
分流计算脚本
if not global_configs["divEnable"] then
return
end
local abc = ngx.var.cookie___abc
if abc then
-- abc这个cookie可能是 123123.0xab23eff1,或者是 1231123.123123123这种,我们提取第二段值的最后3个字符,可能是10进制或者16进制数字,最终值可能会大于1000,所以取余
local v = ngx.re.match(abc, [[^\d+\.([0-9a-fA-FxX]+)([0-9a-fA-F]{3})\.]])
if v and v[2] then
local ckVal = (tonumber(v[2]) or tonumber(v[2], 16) ) % 1000
if ckVal and (ckVal < global_configs["newTrafficRate"]) then
ngx.var.backend = "tomcat_b.domain.com"
end
end
end
最后我们简单做个操作界面,用于动态改变redis中的值
点击切换开关,改写流量切换比例
其他
如果cookie是字符串,可以先进行hash运算,下面是一个基于ffi的hash实现可以参考下,文件名是murmurhash2.lua
local ffi = require "ffi"
local ffi_cast = ffi.cast
local C = ffi.C
local tonumber = tonumber
ffi.cdef[[
typedef unsigned char u_char;
uint32_t ngx_murmur_hash2(u_char *data, size_t len);
]]
return function(value)
return tonumber(C.ngx_murmur_hash2(ffi_cast('uint8_t *', value), #value))
end
调用示例代码方式如下
local mmh2 = require("abtesting.murmurhash2")
-- 对string类型的特征cookie进行hash计算,hash函数是nginx默认实现ngx_murmur_hash2
local hash = mmh2(uid)
local suffix = hash % 1000;