一種可灰度的介面遷移方案

語言: CN / TW / HK

在快速迭代的網際網路背景下,系統為了實現快速上線,常常會選擇最快的開發模式,例如我們常見的mvp版本迭代。大部分的業務系統對於未來業務的發展是不確定的,因此隨著時間的推移,往往會遇到各種各樣的瓶頸,例如系統性能、無法適配業務邏輯等問題,這時可能就涉及到系統架構的升級。系統升級往往包含最基礎的兩個部分:介面遷移重構和資料遷移重構,在系統架構升級的過程中,最重要的是需要保證系統穩定性,即使用者不感知。因此文字的目的是提供一種可灰度、回滾的設計思路,實現穩定的架構升級。

場景

在我們系統迭代過程中,往往涉及到重構、資料來源切換、介面遷移等場景,為了保障系統平穩上線,因此在介面遷移過程中應該保證可回滾、可灰度。介面遷移可能也涉及到資料遷移,兩者的先後順序應該不影響到系統的穩定性。總結一下,介面遷移的目標:

  1. 可灰度,即使用新老介面是能夠控制的。
  2. 可回滾,如使用新介面異常,能夠快速回滾到老介面。
  3. 不入侵業務邏輯,不改動原來的業務邏輯程式碼,等遷移完畢後再整體下線,防止直接侵入修改造成不可逆的影響。
  4. 老介面在系統平穩執行後收口,即對老的資料來源訪問、老的介面能夠平穩下線

遷移方案

本文主要為介面遷移和資料遷移提供了一種思路,在第3節裡會有實踐的核心程式碼實現。(程式碼只是提供思路,並不是能夠直接執行的程式碼)

總體遷移方案

下圖表示了介面遷移的思路,參考了cglib的jdk的代理方式。假設你有一個待遷移介面類(目標類),那麼你需要重新寫一個代理類作為遷移後的介面。目標類和代理類的選擇通過開關去控制,開關涉及到兩個層面:

  1. 總開關:用於控制是否全量切換新介面,當介面遷移穩定上線 且 資料遷移完畢(如有)
  2. 灰度開關:可以設定一個灰度開關列表,用於控制你的那些介面/資料需要走代理介面

圖片

針對不同的介面邏輯,代理介面實現邏輯會有差異,具體場景如下文所述。

單條資料查詢

針對單條資料,可以通過資料來源來判斷來源。基於可灰度和回滾的原則,目標類和代理類的路由規則如下:

  1. 優先判斷總開關,如果總控制開關已開啟,則說明遷移已完成並且驗證校驗完畢,此時走代理介面,這樣可以實現介面、資料的收口,達到我們的遷移目標。
  2. 如果資料不存在於老資料表中,那麼無論這條資料有沒有存在於新表中,我們都可以直接走代理介面,收攏新資料的介面邏輯。
  3. 如果資料存在於老資料表中,但是不在灰度名單內,此時使用目標類(回滾時可這麼操作),走原來的介面方法,即老邏輯,這是不會影響到系統功能。
  4. 如果資料存在於老資料表中,但是在灰度名單內,說明這條資料已經遷移完成待驗證,此時可以使用代理類(灰度時可這麼操作)走新的介面邏輯。

多條資料查詢

不同於單條資料的查詢,我們需要查詢中新表、老表中所有符合條件的資料,多條資料查詢涉及到資料重複的問題(即資料會同時存在於老表和新表中),因此需要對資料進行去重,然後再合併返回結果。

資料更新

因為在資料遷移後到系統灰度的過程中存在中間時間,所以在資料更新時我們應該通過雙寫來保持新、老表資料的一致性。同時為了對介面和資料進行收口,我們也要先判斷總控開關是否開啟,如果總開關已經開啟,則資料更新只需要更新新表即可。

資料插入

對資料和介面收口,我們需要對增量資料進行切換,因此直接使用代理類並將資料插入到新表中,控制老表的資料增量,在資料遷移的時候只需要考慮存量資料即可。

實踐

例如在零售場景中,每個門店都有唯一的身份標識門店id,那麼我們的灰度列表就可以存放門店id列表,按門店維度進行灰度,來粒度化影響範圍。

代理分發邏輯

分發邏輯是核心邏輯,資料的去重規則、介面/倉儲層代理轉發都是基於這套邏輯來控制:

  1. 先判斷總開關,總開關開啟說明遷移完成,此時全部通過代理類走新的介面邏輯和資料來源。
  2. 判斷灰度開關,如果在灰度過程中包含了灰度的門店,那麼就通過代理類走新的介面;否則走原介面的老邏輯,實現介面的切換。
  3. 新資料轉發到代理類,對新的邏輯和資料進行收口,防止增量資料的產生。
  4. 批量查詢介面需要轉發到代理類,因為涉及到對新、老資料進行去重、合併的過程。

``` /* * 是否開啟代理 * * @param ctx 上下文 * @return 是:開啟代理,否:不開啟代理 / public Boolean enableProxy(ProxyEnableContext ctx) { if (ctx == null) { return false; }

    // 判斷總開關
    if (總開關開啟) {
        // 說明資料遷移完成,介面全部切換
        return true;
    }

    if (單個門店操作) {
        if (存在老資料來源) {
            // 判斷是否在灰度名單,是則返回true;否則返回false;

            } else {
                // 新資料
                return true;
            }
    } else {
        // 批量查詢,需要走代理合並新、老資料來源
        return true;
    }

} ````

介面代理

介面代理主要通過切面來攔截,通過註解方法的方式來實現。代理註解如下

``` @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface EnableProxy {

// 用於標識代理類
Class<?> proxyClass();

// 用於標識轉發的代理類的方法,預設取目標類的方法名
String methodName() default "";

// 對於單條資料的查詢,可以指定key的引數索引位置,會解析後轉發
int keyIndex() default -1;}

```` 切面的實現核心邏輯就是攔截註解,根據代理分發的邏輯去判斷是否走代理類,如果走代理類需要解析代理型別、方法名、引數,然後進行轉發。

``` @Component @Aspect @Slf4j public class ProxyAspect {

// 核心代理類
@Resource
private ProxyManager proxyManager;

// 註解攔截
@Pointcut("@annotation(***)")
private void proxy() {}

@Around("proxy()")
@SuppressWarnings("rawtypes")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    try {
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();       
        Class<?> clazz = joinPoint.getTarget().getClass();
        String methodName = methodSignature.getMethod().getName();
        Class[] parameterTypes = methodSignature.getParameterTypes();
        Object[] args = joinPoint.getArgs();
        // 拿到方法的註解
        EnableProxy enableProxyAnnotation = ReflectUtils
            .getMethodAnnotation(clazz, EnableProxy.class, methodName, parameterTypes);

        if (enableProxyAnnotation == null) {
            // 沒有找到註解,直接放過
            return joinPoint.proceed();
        }

        //判斷是否需要走代理
        Boolean enableProxy = enableProxy(clazz, methodName, args, enableProxyAnnotation);            if (!enableProxy) {
            // 不開啟代理,直接放過
            return joinPoint.proceed();
        }

        // 預設取目標類的方法名稱
        methodName = StringUtils.isNotBlank(enableProxyAnnotation.methodName())
            ? enableProxyAnnotation.methodName() : methodName;

        // 通過反射拿到代理類的代理方法
        Object bean = ApplicationContextUtil.getBean(enableProxyAnnotation.proxyClass());
        Method proxyMethod = ReflectUtils.getMethod(enableProxyAnnotation.proxyClass(), methodName, parameterTypes);
        if (bean == null || proxyMethod == null) {
            // 沒有代理類或代理方法,直接走原邏輯
            return joinPoint.proceed();
        }

        // 通過反射,轉發代理類方法
        return ReflectUtils.invoke(bean, proxyMethod, joinPoint.getArgs());


    } catch (BizException bizException) {
        // 業務方法異常,直接丟擲
        throw bizException;
    } catch (Throwable throwable) {
        // 其他異常,打個日誌感知一下
        throw throwable;
    }
}

} ````

倉儲層代理

如果走了代理類,那麼邏輯都會被轉發到ProxyManager,由代理類管理器來負責資料的分發、去重、合併、更新、插入等操作。

單條資料查詢

代理查詢流程圖如下圖所示,目標介面的目標方法會通過代理被切面攔截掉,切面判斷是否需要走代理介面

  1. 如果不需要走代理介面(即資料來源是老的並且未被灰度),則繼續走目標介面
  2. 如果需要走代理介面(即資料來源是新的或者老資料遷移後在灰度列表內),則呼叫代理介面方法,在代理介面方法中會對倉儲層邏輯進行進一步的轉發,由ProxyManager統一進行收口。在單條資料的查詢邏輯裡,只需要呼叫代理倉儲層服務查詢新資料來源就可以了,邏輯比較簡單。

圖片

例如單個門店的資訊查詢,那麼我們核心控制器ProxyManager方法邏輯就可以這麼實現:

````

public <T> T getById(Long id, Boolean enableProxy) {

    if (enableProxy) {
        // 開啟代理,就走代理倉儲層的查詢服務
        return proxyRepository.getById(id);
    } else {
        // 沒開啟代理,走原來倉儲層的服務
        return targetRepository.getById(id);
    }
}

````

多條資料查詢+去重

多條資料的去重邏輯是一樣,去重規則如下:

  1. 新表、老表都不存在,資料剔除,不反回結果。
  2. 新表沒有,使用老表資料的資訊。
  3. 老表沒有,使用新表資料的資訊。
  4. 老表、新表都存在資料(遷移完成),此時判斷總控是否開啟,以及資料是否在灰度名單,滿足其一使用新表資料;否則使用老表資料

基於以上去重邏輯,所有的查詢介面都可以抽象成統一的方法

  1. 查詢老資料,業務定義,用supply函式封裝查詢邏輯
  2. 查詢新資料,業務定義,用supply函式封裝查詢邏輯
  3. 合併去重,抽象出統一的合併工具

核心的流程如下圖所示,目標介面的目標方法都會被切面攔截,轉發到代理介面。代理介面在呼叫資料來源的地方可以進一步轉發給ProxyManager進行查詢&合併。如果總開關未開啟,說明全量資料還沒有遷移驗證完畢,那麼還是需要查老的資料來源(防止資料遺漏)。如果開關開啟了,則說明遷移完成,此時不會再呼叫原來的倉儲層服務,達到了對老的資料來源收口的目的。

圖片\

例如批量查詢門店列表,可以這麼合併,核心實現如下:

````

public <T> List<T> queryList(List<Long> ids, Function<T, Long> idMapping) {
    if (CollectionUtils.isEmpty(ids)) {
        return Collections.emptyList();
    }

    // 1. 查詢老資料
    Supplier<List<T>> oldSupplier = () -> targetRepository.queryList(ids);
    // 2. 查詢新資料
    Supplier<List<T>> newSupplier = () -> proxyRepository.queryList(ids);
    // 3. 根據合併規則合併,依賴合併工具(對合並邏輯進行抽象後的工具類)
    return ProxyHelper.mergeWithSupplier(oldSupplier, newSupplier, idMapping);
}

合併工具類實現如下: public class ProxyHelper {

/**
 * 核心去重邏輯,判斷是否採用新表資料
 *
 * @param existOldData 是否存在老資料
 * @param existNewData 是否存在新資料
 * @param id      門店id
 * @return 是否採用新表資料
 */
public static boolean useNewData(Boolean existOldData, Boolean existNewData, Long id) {             if (!existOldData && !existNewData) {
        //兩張表都沒有
        return true;
    } else if (!existNewData) {
        //新表沒有
        return false;
    } else if (!existOldData) {
        //老表沒有
        return true;
    } else {
        //新表老表都有,判斷開關和灰度開關
        return 總開關開啟 or 在灰度列表內
    }

}

/**
* 合併新/老表資料
*
* @param oldSupplier 老表資料
* @param newSupplier 新表資料
* @return 合併去重後的資料
*/

public static List mergeWithSupplier( Supplier> oldSupplier, Supplier> newSupplier, Function idMapping) {

    List<T> old = Collections.emptyList();

    if (總開關未開啟) {
        // 未完成切換,需要查詢老的資料來源
        old = oldSupplier.get();
    }

    return merge(idMapping, old, newSupplier.get());
}


/**
 * 去重併合並新老資料
 *
 * @param idMapping      門店id對映函式
 * @param oldData        老資料
 * @param newData        新資料
 * @return 合併結果
 */
public static <T> List<T> merge(Function<T, Long> idMapping, List<T> oldData, List<T> newData) {
    if (CollectionUtils.isEmpty(oldData) && CollectionUtils.isEmpty(newData)) {            
        return Collections.emptyList();
    }

    if (CollectionUtils.isEmpty(oldData)) {
        return newData;
    }

    if (CollectionUtils.isEmpty(newData)) {
        return oldData;
    }

    Map<Long/*門店id*/, T> oldMap = oldData.stream().collect(            
        Collectors.toMap(idMapping, Function.identity(), (a, b) -> a));

    Map<Long/*門店id*/, T> newMap = newData.stream().collect(
        Collectors.toMap(idMapping, Function.identity(), (a, b) -> a));

    return ListUtils.union(oldData, newData)
        .stream()
        .map(idMapping)
        .distinct()
        .map(id -> {
            boolean existOldData = oldMap.containsKey(id);
            boolean existNewData = newMap.containsKey(id);
            boolean useNewData = useNewData(existOldData, existNewData, id);
            return useNewData ? newMap.get(id) : oldMap.get(id);
        })
        .filter(Objects::nonNull)
        .collect(Collectors.toList());
}

} ````

增量資料

程式碼省略,直接執行代理倉儲層的插入方法即可

更新資料

更新資料需要雙寫,如果總開關開啟(即遷移完畢),則可以停止老資料的寫入,因為不會再讀了。

``` @Transactional(rollbackFor = Throwable.class) public Boolean update(T t) { if (t == null) { return false; }

    if (總開關沒開啟) {
        // 資料沒有遷移完畢
        // 更新要雙寫,如有,保持資料一致
        targetRepository.update(t);
    }

    // 更新新資料
    proxyRepository.update(t);
    return true;
}

````

實踐

本文只是提出一種遷移的方案思路,可能並不能適用於所有場景,但是在系統升級的過程中,工程師面對的最終的目標應該是一致的,即為了讓系統穩定的上線,並且在出現問題時能夠安全回滾。本文的實現邏輯是通過註解和切面實現對目標介面的方法進行轉發,轉發到代理類介面,從而切換到新邏輯和新資料來源,並由ProxyManager來適配資料來源的代理分發邏輯,完成資料的查詢、更新、新增邏輯。