您现在的位置是:亿华云 > 系统运维

一种可灰度的接口迁移方案

亿华云2025-10-04 04:04:14【系统运维】3人已围观

简介www.ydisp.cn/oss/202207/13/825218f522d76123b21815523af85b73a7a5c0.jpg" alt="图片" title="图片" style="vi

www.ydisp.cn/oss/202207/13/825218f522d76123b21815523af85b73a7a5c0.jpg" alt="图片" title="图片" style="visibility: visible; width: 676.997px; height: 673px;" data-type="inline">

针对不同的灰度接口逻辑,代理接口实现逻辑会有差异,口迁具体场景如下文所述。移方

2.单条数据查询

针对单条数据,灰度可以通过数据源来判断来源。口迁基于可灰度和回滚的移方原则,目标类和代理类的灰度路由规则如下:

优先判断总开关,如果总控制开关已打开,口迁则说明迁移已完成并且验证校验完毕,移方此时走代理接口,灰度这样可以实现接口、口迁数据的移方收口,达到我们的灰度迁移目标。如果数据不存在于老数据表中,口迁那么无论这条数据有没有存在于新表中,移方我们都可以直接走代理接口,收拢新数据的接口逻辑。如果数据存在于老数据表中,但是不在灰度名单内,此时使用目标类(回滚时可这么操作),走原来的接口方法,即老逻辑,这是不会影响到系统功能。如果数据存在于老数据表中,但是云服务器在灰度名单内,说明这条数据已经迁移完成待验证,此时可以使用代理类(灰度时可这么操作)走新的接口逻辑。3.多条数据查询

不同于单条数据的查询,我们需要查询中新表、老表中所有符合条件的数据,多条数据查询涉及到数据重复的问题(即数据会同时存在于老表和新表中),因此需要对数据进行去重,然后再合并返回结果。

4.数据更新

因为在数据迁移后到系统灰度的过程中存在中间时间,所以在数据更新时我们应该通过双写来保持新、老表数据的一致性。同时为了对接口和数据进行收口,我们也要先判断总控开关是否开启,如果总开关已经打开,则数据更新只需要更新新表即可。

5.数据插入

对数据和接口收口,我们需要对增量数据进行切换,因此直接使用代理类并将数据插入到新表中,控制老表的数据增量,在数据迁移的时候只需要考虑存量数据即可。

实践

例如在零售场景中,每个门店都有唯一的身份标识门店id,亿华云那么我们的灰度列表就可以存放门店id列表,按门店维度进行灰度,来粒度化影响范围。

1.代理分发逻辑

分发逻辑是核心逻辑,数据的去重规则、接口/仓储层代理转发都是基于这套逻辑来控制:

先判断总开关,总开关开启说明迁移完成,此时全部通过代理类走新的接口逻辑和数据源。判断灰度开关,如果在灰度过程中包含了灰度的门店,那么就通过代理类走新的接口;否则走原接口的老逻辑,实现接口的切换。新数据转发到代理类,对新的逻辑和数据进行收口,防止增量数据的产生。批量查询接口需要转发到代理类,因为涉及到对新、老数据进行去重、合并的过程。 /

**

* 是否开启代理

*

* @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;

}

}2.接口代理

接口代理主要通过切面来拦截,通过注解方法的方式来实现。代理注解如下

@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;

}

}

}3.仓储层代理

如果走了代理类,那么逻辑都会被转发到ProxyManager,由代理类管理器来负责数据的分发、去重、合并、更新、插入等操作。

单条数据查询

代理查询流程图如下图所示,目标接口的目标方法会通过代理被切面拦截掉,切面判断是否需要走代理接口

如果不需要走代理接口(即数据源是老的并且未被灰度),则继续走目标接口如果需要走代理接口(即数据源是新的或者老数据迁移后在灰度列表内),则调用代理接口方法,在代理接口方法中会对仓储层逻辑进行进一步的转发,由ProxyManage统一进行收口。在单条数据的查询逻辑里,只需要调用代理仓储层服务查询新数据源就可以了,逻辑比较简单。

例如单个门店的信息查询,那么我们核心控制器ProxyManager方法逻辑就可以这么实现:

public T getById(Long id, Boolean enableProxy) {

if (enableProxy) {

// 开启代理,就走代理仓储层的查询服务

return proxyRepository.getById(id);

} else {

// 没开启代理,走原来仓储层的服务

return targetRepository.getById(id);

}

}多条数据查询+去重

多条数据的去重逻辑是一样,去重规则如下:

新表、老表都不存在,数据剔除,不反回结果。新表没有,使用老表数据的信息。老表没有,使用新表数据的信息。老表、新表都存在数据(迁移完成),此时判断总控是否打开,以及数据是否在灰度名单,满足其一使用新表数据;否则使用老表数据

基于以上去重逻辑,所有的查询接口都可以抽象成统一的方法

查询老数据,业务定义,用supply函数封装查询逻辑查询新数据,业务定义,用supply函数封装查询逻辑合并去重,抽象出统一的合并工具

核心的流程如下图所示,目标接口的目标方法都会被切面拦截,转发到代理接口。代理接口在调用数据源的地方可以进一步转发给ProxyManager进行查询&合并。如果总开关未开启,说明全量数据还没有迁移验证完毕,那么还是需要查老的数据源(防止数据遗漏)。如果开关开启了,则说明迁移完成,此时不会再调用原来的仓储层服务,达到了对老的数据源收口的目的。

例如批量查询门店列表,可以这么合并,核心实现如下:

public ListqueryList(Listids, FunctionidMapping) {

if (CollectionUtils.isEmpty(ids)) {

return Collections.emptyList();

}

// 1. 查询老数据

Supplier> oldSupplier = () -> targetRepository.queryList(ids);

// 2. 查询新数据

Supplier> 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 ListmergeWithSupplier(

Supplier> oldSupplier, Supplier> newSupplier, FunctionidMapping) {

Listold = Collections.emptyList();

if (总开关未打开) {

// 未完成切换,需要查询老的数据源

old = oldSupplier.get();

}

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

}

/

**

* 去重并合并新老数据

*

* @param idMapping 门店id映射函数

* @param oldData 老数据

* @param newData 新数据

* @return 合并结果

*/

public static Listmerge(FunctionidMapping, ListoldData, ListnewData) {

if (CollectionUtils.isEmpty(oldData) && CollectionUtils.isEmpty(newData)) {

return Collections.emptyList();

}

if (CollectionUtils.isEmpty(oldData)) {

return newData;

}

if (CollectionUtils.isEmpty(newData)) {

return oldData;

}

MapoldMap = oldData.stream().collect(

Collectors.toMap(idMapping, Function.identity(), (a, b) -> a));

MapnewMap = 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来适配数据源的代理分发逻辑,完成数据的查询、更新、新增逻辑。

团队介绍

天猫校园技术团队,一个致力于服务校园人群,提升校园人群生活品质,提供校园saas全套解决方案的技术团队。

很赞哦!(2)