在疾速迭代的互联网背景下,零碎为了实现疾速上线,经常会抉择最快的开发模式,例如咱们常见的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@Slf4jpublic 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 <T> List<T> mergeWithSupplier(        Supplier<List<T>> oldSupplier, Supplier<List<T>> newSupplier, Function<T, Long> 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 <T> Boolean update(T t) {        if (t == null) {            return false;        }        if (总开关没关上) {            // 数据没有迁徙结束            // 更新要双写,如有,保持数据统一            targetRepository.update(t);        }        // 更新新数据        proxyRepository.update(t);        return true;    }

实际

本文只是提出一种迁徙的计划思路,可能并不能实用于所有场景,然而在系统升级的过程中,工程师面对的最终的指标应该是统一的,即为了让零碎稳固的上线,并且在呈现问题时可能平安回滚。本文的实现逻辑是通过注解和切面实现对指标接口的办法进行转发,转发到代理类接口,从而切换到新逻辑和新数据源,并由ProxyManager来适配数据源的代理散发逻辑,实现数据的查问、更新、新增逻辑。