AbTest在灰度发布和流量回放中的应用

1,414 阅读6分钟

一、背景

 在系统开发的过程中,有时会遇到调用第三方接口的情况。由于第三方接口的不确定性,可能出现,在上线一段时间后,第三方公司对调用的接口进行了升级,原先老的接口不再维护的情况。这时内部需要重新修改代码调用第三方新的接口。
 在调用新接口的过程中存在如下问题:

1、新接口是否稳定,如何检验
2、新接口返回的数据结构跟老接口不一致如何处理
3、原始接口流量较大,直接切换到新接口出问题了怎么办
4、如何验证新老接口的差异性,即在请求参数相同的情况下,新老接口公共字段返回的值是否一致

 使用abTest来灰度发布可以解决以上的四个问题。

二、abtest基本概念

 A/B 测试是一种对比分析方法,通过对流量进行细分和随机实验,并监控和跟踪实验效果,来判断实验所代表的策略的可行性和有效性。 A/B 测试解决的是策略优化的问题,即从多个可选策略里找出最优策略。常见的应用场景包括:

  • 灰度发布:技术&算法迭代
  • 功能优化:界面模块、样式风格、交互方式等
  • 内容优化:推广海报、落地页、内容模块、文案等
  • 运营优化:运营策略、沟通话术等

1618881460602.png

三、最佳实践

3.1 业务场景

 回到背景中的4个问题,假设第三方有api1接口,用于返回人员信息。之后第三方公司重新开发了api2接口用来实现api1的功能,在api1的基础上又返回了其他更多的信息,并且表示api1以后不再维护。

graph TD
内部系统 --> |原始调用|api1
内部系统 --> |新调用|api2

 这时内部系统需要修改原始代码,将api1的部分全部换成api2。最简单的做法就是直接修改代码,如下所示。

   Person person = api1();// 老接口
   ---------------------------------
   PersonNew personNew = api2();
   Person person = convert(personNew);

然后直接上线。但是这么做存在这么几个问题。

1、第三方提供的新接口可能不稳定,由于是第三方接口,内部没有办法保证接口一定没有问题。特别是如果调用这个第三方接口的流量
如果很大,直接全部切换成api2的话就可能直接造成线上事故。这种情况在互联网企业是无法接受的。
2、调用api2,内部没有办法知道新接口返回的结果是不是跟老接口一模一样,虽然第三方企业承诺完全兼容api1,但是从稳定性的角度
考虑,内部还是要对api2进行一下线上验证。

所以考虑到这两个问题,我们可以利用AbTest来进行灰度,已经流量回放。

3.2 解决思路

 利用abtest,我们可以完美解决以上两个问题。abtest核心思想就是从大流量中随机抽出一部分流量进行试验。所以按照这个思路,我们可以从总的流量中抽取少量流量同时调用api1和api2,在调用的过程中监控api2的成功率,相应时间,95线,99线等若干指标,来验证api2的稳定性。同时由于参数相同,我们可以对比api1和api2的返回情况,记录两个接口的返回结果差异性。从而验证新接口是否完全兼容老接口。

111.png

 在验证过程中如果发现api2接口不稳定或者结果与api1不相符,可以通知第三方公司对接口进行修复,在api2稳定性和返回结果都符合预期的情况下,可以逐渐加大api2的流量。最终对api2放量到100%,平滑完成对api1的切换工作。

3.3 代码模拟

 在实验过程中,创建一个OuterApi类用来表示第三方的接口,里面有api1和api2两个方法。每个方法传入一个idCard,表示根据身份证号返回具体人员信息。在api1和api2中通过一个随机数来设置每次返回人员信息的年龄,从而模拟每次接口的不同返回。
 在api2中除了返回api1中国所有的属性外,额外增加了人员的性别,用来表示新接口的增加的属性部分。

   public class OuterApi {
    private Random random = new Random();

    public Person api1(String idCard) {
        Person person = new Person();
        person.setName("小张");
        if (random.nextDouble() < 0.5) {
            person.setAge("13");
        } else {
            person.setAge("15");
        }
        person.setIdCard(idCard);
        return person;
    }

    public PersonNew api2(String idCard) {
        PersonNew personNew = new PersonNew();
        personNew.setName("小张");
        if (random.nextDouble() < 0.5) {
            personNew.setAge("13");
        } else {
            personNew.setAge("15");
        }
        personNew.setSex("1");
        personNew.setIdCard(idCard);
        return personNew;
    }
}

 创建了一个AbTestHelper类,用来实现灰度和流量回放功能。实现灰度的过程中,需要有个配置项,来决定灰度流量的百分比以及是否开启流量回放工能。配置项的配置如下所示,含义如注释所示。真实生产环境下,该配置一般放置在配置中心中,或redis等缓存中,在模拟实验时,直接放入一个名为abtest.properties文件中表示。

   #流量回放开关
   trafficPlaybackSwitch = true
   #灰度控制比例 分号前表示中总份数,分号后面表示命中灰度的份数
   grayConfig = 100;0-49

 AbTestHelper类代码如下所示。

   package abTest;


import java.io.InputStream;
import java.util.Comparator;
import java.util.Properties;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Supplier;

public class AbTestHelper<T, R> {

    // 新老接口的比较器
    private Comparator<T> comparable;
    // 新老接口有差异时的反馈函数
    private BiConsumer<T, T> diffCallBack;
    // 新老接口返回值转换函数
    private Function<R, T> convert;

    public AbTestHelper(Comparator<T> comparable, BiConsumer<T, T> diffCallBack, Function<R, T> convert) {
        this.comparable = comparable;
        this.diffCallBack = diffCallBack;
        this.convert = convert;
    }

    /**
     * abest比较
     *
     * @param aSupplier     原接口
     * @param bSupplier     新接口
     * @param grayKey       灰度的key
     * @return
     */
    public T doABTest(Supplier<T> aSupplier, Supplier<R> bSupplier, int grayKey) {
        GrayConfig grayConfig = new GrayConfig(getConfig("grayConfig"));
        T result = null;
        R tmpResult = null;
        boolean hitGrayKey = false;
        if (grayConfig.hitGray(grayKey)) {
            hitGrayKey = true;
            tmpResult = bSupplier.get();
            System.out.println("命中灰度");
        } else {
            result = aSupplier.get();
            System.out.println("未命中灰度");
        }

        // 命中灰度key,将新接口结果转换回老接口
        if (hitGrayKey) {
            result = convert.apply(tmpResult);
        }

        // 是否开启流量回放
        if (Boolean.parseBoolean(getConfig("trafficPlaybackSwitch"))) {
            flowPlayBack(hitGrayKey, aSupplier, bSupplier, result);
        }

        return result;
    }

    private static String getConfig(String key) {
        String value;
        try {
            Properties properties = new Properties();
            InputStream in = AbTestHelper.class.getClassLoader().getResourceAsStream("abTest.properties");
            properties.load(in);
            value = properties.getProperty(key);
        } catch (Exception e) {
            value = null;
            // ignore
        }
        return value;
    }

    /**
     * 一般流量回放会放到一个线程池中异步执行,避免影响主流程响应
     *
     * @param hitGrayKey
     * @param aSupplier
     * @param bSupplier
     * @param result
     */
    private void flowPlayBack(boolean hitGrayKey, Supplier<T> aSupplier, Supplier<R> bSupplier, T result) {
        if (hitGrayKey) {
            T aResult = aSupplier.get();
            if (comparable.compare(aResult, result) != 0) {
                diffCallBack.accept(aResult, result);
            }
        } else {
            R bResult = bSupplier.get();
            T tmpResult = convert.apply(bResult);
            if (comparable.compare(result, tmpResult) != 0) {
                diffCallBack.accept(result, tmpResult);
            }
        }
    }
}

 每次调用第三方api时就会创建一个helper类。创建该类时,需要传入比较器、反馈函数、转换器等。在最终调用过程中需要传入新老接口以及灰度的key。在代码中灰度策略使用了grayKey % totalPercent来判断哪些id调用新接口。

 最后是一个总的模拟入口,

package abTest;

import java.util.Random;

import org.apache.commons.lang3.StringUtils;

public class MainTest {
    public static void main(String[] args) throws InterruptedException {
        OuterApi api = new OuterApi();

        AbTestHelper<Person, PersonNew> helper = new AbTestHelper<>((person, person1) -> {
            if (person == null || person1 == null) {
                return -1;
            }
            if (StringUtils.equals(person.getAge(), person1.getAge()) && StringUtils.equals(person.getIdCard(), person1.getIdCard()) &&
            StringUtils.equals(person.getName(), person1.getName())) {
                return 0;
            }
            return -1;
        }, (aResult, bResult) -> {
            // 当发现两者结果不相同时,一般是通过埋点的方式上报。这里简单的输出一下各自接口的值
            System.out.println("新旧接口返回值不相同,原始接口获取的年龄:" + aResult.getAge() + ",新接口获取的年龄:" + bResult.getAge());
        }, personNew -> {
            Person person = new Person();
            person.setIdCard(personNew.getIdCard());
            person.setAge(personNew.getAge());
            person.setName(personNew.getName());
            return person;
        });
        Random random = new Random();

        while (true) {
            String idCard = random.nextInt(100) + "";
            Person result = helper.doABTest(() -> api.api1(idCard), () -> api.api2(idCard), Integer.parseInt(idCard));
            System.out.println(result.toString());
            System.out.println("==========");
            Thread.sleep(1000);
        }

    }
}

 随机传入一个身份证号,并且根据身份证号返回指定对象。模拟结果如下所示。

1111111.png

 完整代码参考:github.com/Tyoukai/tec…