一種可灰度的介面遷移方案
在快速迭代的網際網路背景下,系統為了實現快速上線,常常會選擇最快的開發模式,例如我們常見的mvp版本迭代。大部分的業務系統對於未來業務的發展是不確定的,因此隨著時間的推移,往往會遇到各種各樣的瓶頸,例如系統性能、無法適配業務邏輯等問題,這時可能就涉及到系統架構的升級。系統升級往往包含最基礎的兩個部分:介面遷移重構和資料遷移重構,在系統架構升級的過程中,最重要的是需要保證系統穩定性,即使用者不感知。因此文字的目的是提供一種可灰度、回滾的設計思路,實現穩定的架構升級。
場景
在我們系統迭代過程中,往往涉及到重構、資料來源切換、介面遷移等場景,為了保障系統平穩上線,因此在介面遷移過程中應該保證可回滾、可灰度。介面遷移可能也涉及到資料遷移,兩者的先後順序應該不影響到系統的穩定性。總結一下,介面遷移的目標:
- 可灰度,即使用新老介面是能夠控制的。
- 可回滾,如使用新介面異常,能夠快速回滾到老介面。
- 不入侵業務邏輯,不改動原來的業務邏輯程式碼,等遷移完畢後再整體下線,防止直接侵入修改造成不可逆的影響。
- 老介面在系統平穩執行後收口,即對老的資料來源訪問、老的介面能夠平穩下線
遷移方案
本文主要為介面遷移和資料遷移提供了一種思路,在第3節裡會有實踐的核心程式碼實現。(程式碼只是提供思路,並不是能夠直接執行的程式碼)
總體遷移方案
下圖表示了介面遷移的思路,參考了cglib的jdk的代理方式。假設你有一個待遷移介面類(目標類),那麼你需要重新寫一個代理類作為遷移後的介面。目標類和代理類的選擇通過開關去控制,開關涉及到兩個層面:
- 總開關:用於控制是否全量切換新介面,當介面遷移穩定上線 且 資料遷移完畢(如有)
- 灰度開關:可以設定一個灰度開關列表,用於控制你的那些介面/資料需要走代理介面
針對不同的介面邏輯,代理介面實現邏輯會有差異,具體場景如下文所述。
單條資料查詢
針對單條資料,可以通過資料來源來判斷來源。基於可灰度和回滾的原則,目標類和代理類的路由規則如下:
- 優先判斷總開關,如果總控制開關已開啟,則說明遷移已完成並且驗證校驗完畢,此時走代理介面,這樣可以實現介面、資料的收口,達到我們的遷移目標。
- 如果資料不存在於老資料表中,那麼無論這條資料有沒有存在於新表中,我們都可以直接走代理介面,收攏新資料的介面邏輯。
- 如果資料存在於老資料表中,但是不在灰度名單內,此時使用目標類(回滾時可這麼操作),走原來的介面方法,即老邏輯,這是不會影響到系統功能。
- 如果資料存在於老資料表中,但是在灰度名單內,說明這條資料已經遷移完成待驗證,此時可以使用代理類(灰度時可這麼操作)走新的介面邏輯。
多條資料查詢
不同於單條資料的查詢,我們需要查詢中新表、老表中所有符合條件的資料,多條資料查詢涉及到資料重複的問題(即資料會同時存在於老表和新表中),因此需要對資料進行去重,然後再合併返回結果。
資料更新
因為在資料遷移後到系統灰度的過程中存在中間時間,所以在資料更新時我們應該通過雙寫來保持新、老表資料的一致性。同時為了對介面和資料進行收口,我們也要先判斷總控開關是否開啟,如果總開關已經開啟,則資料更新只需要更新新表即可。
資料插入
對資料和介面收口,我們需要對增量資料進行切換,因此直接使用代理類並將資料插入到新表中,控制老表的資料增量,在資料遷移的時候只需要考慮存量資料即可。
實踐
例如在零售場景中,每個門店都有唯一的身份標識門店id,那麼我們的灰度列表就可以存放門店id列表,按門店維度進行灰度,來粒度化影響範圍。
代理分發邏輯
分發邏輯是核心邏輯,資料的去重規則、介面/倉儲層代理轉發都是基於這套邏輯來控制:
- 先判斷總開關,總開關開啟說明遷移完成,此時全部通過代理類走新的介面邏輯和資料來源。
- 判斷灰度開關,如果在灰度過程中包含了灰度的門店,那麼就通過代理類走新的介面;否則走原介面的老邏輯,實現介面的切換。
- 新資料轉發到代理類,對新的邏輯和資料進行收口,防止增量資料的產生。
- 批量查詢介面需要轉發到代理類,因為涉及到對新、老資料進行去重、合併的過程。
``` /* * 是否開啟代理 * * @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,由代理類管理器來負責資料的分發、去重、合併、更新、插入等操作。
單條資料查詢
代理查詢流程圖如下圖所示,目標介面的目標方法會通過代理被切面攔截掉,切面判斷是否需要走代理介面
- 如果不需要走代理介面(即資料來源是老的並且未被灰度),則繼續走目標介面
- 如果需要走代理介面(即資料來源是新的或者老資料遷移後在灰度列表內),則呼叫代理介面方法,在代理介面方法中會對倉儲層邏輯進行進一步的轉發,由ProxyManager統一進行收口。在單條資料的查詢邏輯裡,只需要呼叫代理倉儲層服務查詢新資料來源就可以了,邏輯比較簡單。
例如單個門店的資訊查詢,那麼我們核心控制器ProxyManager方法邏輯就可以這麼實現:
````
public <T> T getById(Long id, Boolean enableProxy) {
if (enableProxy) {
// 開啟代理,就走代理倉儲層的查詢服務
return proxyRepository.getById(id);
} else {
// 沒開啟代理,走原來倉儲層的服務
return targetRepository.getById(id);
}
}
````
多條資料查詢+去重
多條資料的去重邏輯是一樣,去重規則如下:
- 新表、老表都不存在,資料剔除,不反回結果。
- 新表沒有,使用老表資料的資訊。
- 老表沒有,使用新表資料的資訊。
- 老表、新表都存在資料(遷移完成),此時判斷總控是否開啟,以及資料是否在灰度名單,滿足其一使用新表資料;否則使用老表資料
基於以上去重邏輯,所有的查詢介面都可以抽象成統一的方法
- 查詢老資料,業務定義,用supply函式封裝查詢邏輯
- 查詢新資料,業務定義,用supply函式封裝查詢邏輯
- 合併去重,抽象出統一的合併工具
核心的流程如下圖所示,目標介面的目標方法都會被切面攔截,轉發到代理介面。代理介面在呼叫資料來源的地方可以進一步轉發給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 > oldSupplier, Supplier
> newSupplier, Function
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
if (總開關沒開啟) {
// 資料沒有遷移完畢
// 更新要雙寫,如有,保持資料一致
targetRepository.update(t);
}
// 更新新資料
proxyRepository.update(t);
return true;
}
````
實踐
本文只是提出一種遷移的方案思路,可能並不能適用於所有場景,但是在系統升級的過程中,工程師面對的最終的目標應該是一致的,即為了讓系統穩定的上線,並且在出現問題時能夠安全回滾。本文的實現邏輯是通過註解和切面實現對目標介面的方法進行轉發,轉發到代理類介面,從而切換到新邏輯和新資料來源,並由ProxyManager來適配資料來源的代理分發邏輯,完成資料的查詢、更新、新增邏輯。
- 第14個天貓雙11,技術創新帶來消費新體
- 如何避免寫重複程式碼:善用抽象和組合
- 淘寶PC改版!我們跟一位背後付出6年的男人聊了聊……
- 在阿里做前端程式設計師,我是這樣規劃的
- 一種可灰度的介面遷移方案
- 如何快速理解複雜業務,系統思考問題?
- 淘寶iOS掃一掃架構升級 - 設計模式的應用
- HTTP3 RFC標準正式釋出,QUIC會成為傳輸技術的新一代顛覆者嗎?
- 2022大淘寶技術工程師推薦書單
- 國際頂會OSDI首度收錄淘寶系統論文,端雲協同智慧獲大會主旨演講推薦
- 如何持續突破效能表現? | DX研發模式
- 列表容器&事件鏈如何幫業務提升發版迭代效率? | DX研發模式
- 2022淘寶天貓618背後——與你息息相關的技術祕密
- 淘寶Native研發模式的演進與思考 | DX研發模式
- CVPR2022 | 開源:基於間距自適應查詢表的實時影象增強方法
- 無線運維的起源與專案建設思考
- 淘寶購物車5年技術升級與沉澱
- 從標準到開源,阿里大淘寶技術的“創新擔當”
- 程式設計師如何在業餘時間提升自己?