一种简单可行的abtest流量切换实现方案

为什么需要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。

  数据流示意图如下:
一种简单可行的abtest流量切换实现方案_第1张图片

  用户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中的值

一种简单可行的abtest流量切换实现方案_第2张图片
点击切换开关,改写流量切换比例

其他

  如果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;

微信关注公众号获取更多精彩内容

一种简单可行的abtest流量切换实现方案_第3张图片

你可能感兴趣的:(lua)