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

来源:阿里大淘宝技术2022技术年货

📌作者:腿儿(曾臻)

📌出品:大淘宝技术

在快速迭代的互联网背景下,系统为了实现快速上线,常常会选择最快的开发模式,例如我们常见的mvp版本迭代。大部分的业务系统对于未来业务的发展是不确定的,因此随着时间的推移,往往会遇到各种各样的瓶颈,例如系统性能、无法适配业务逻辑等问题,这时可能就涉及到系统架构的升级。系统升级往往包含最基础的两个部分:接口迁移重构和数据迁移重构,在系统架构升级的过程中,最重要的是需要保证系统稳定性,即用户不感知。因此文本的目的是提供一种可灰度、回滚的设计思路,实现稳定的架构升级。

场景

在我们系统迭代过程中,往往涉及到重构、数据源切换、接口迁移等场景,为了保障**系统平稳上线,因此在接口迁移过程中应该保证可回滚、可灰度。接口迁移可能也涉及到数据迁移,两者的先后顺序应该不影响到系统的稳定性。总结一下,接口迁移的目标:

  1. 可灰度,即使用新老接口是能够控制的。
  2. 可回滚,如使用新接口异常,能够快速回滚到老接口。
  3. 不入侵业务逻辑,不改动原来的业务逻辑代码,等迁移完毕后再整体下线,防止直接侵入修改造成不可逆的影响。
  4. 老接口在系统平稳运行后收口,即对老的数据源访问、老的接口能够平稳下线

迁移方案

下图表示了接口迁移的思路,参考了cglib的jdk的代理方式。假设你有一个待迁移接口类(目标类),那么你需要重新写一个代理类作为迁移后的接口。目标类和代理类的选择通过开关去控制,开关涉及到两个层面:

  1. 总开关:用于控制是否全量切换新接口,当接口迁移稳定上线 且 数据迁移完毕(如有)
  2. 灰度开关:可以设置一个灰度开关列表,用于控制你的那些接口/数据需要走代理接口

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

单条数据查询

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

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

多条数据查询

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

数据更新

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

数据插入

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

实践

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

代理分发逻辑

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

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

伪代码如下:

/**
 * 是否开启代理
 *
 * @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. 老表、新表都存在数据(迁移完成),此时判断总控是否打开,以及数据是否在灰度名单,满足其一使用新表数据;否则使用老表数据

更新数据

更新数据需要双写,如果总开关打开(即迁移完毕),则可以停止老数据的写入,因为不会再读了。

@Transactional(rollbackFor = Throwable.class)
 public <T> Boolean update(T t) {
   if (t == null) {
     return false;
   }
   if (总开关没打开) {
     // 数据没有迁移完毕
     // 更新要双写,如有,保持数据一致
     targetRepository.update(t);
   }
     // 更新新数据
     proxyRepository.update(t);
   return true;
 }

总结

本文只是提出一种迁移的方案思路,可能并不能适用于所有场景,但是在系统升级的过程中,工程师面对的最终的目标应该是一致的,即为了让系统稳定的上线,并且在出现问题时能够安全回滚。本文的实现逻辑是通过注解和切面实现对目标接口的方法进行转发,转发到代理类接口,从而切换到新逻辑和新数据源,并由ProxyManager来适配数据源的代理分发逻辑,完成数据的查询、更新、新增逻辑。

Last Updated: