关于微服务:微服务拆分治理最佳实践

29次阅读

共计 12296 个字符,预计需要花费 31 分钟才能阅读完成。

作者:京东批发 徐强 黄威 张均杰

背景

部门中保护了一个老零碎,性能都耦合在一个单体利用中 (300+ 接口),表也放在同一个库中 (200+ 表),导致系统存在很多危险和缺点。经常出现问题:如数据库的单点、性能问题,利用的扩大受限,复杂性低等问题。

从下图可见。各业务互相耦合无明确边界,调用关系盘根错节。

随着业务疾速倒退,各种问题越来越显著,急需对系统进行微服务革新优化。通过思考,整体革新将分为三个阶段进行:

  • 数据库拆分:数据库依照业务垂直拆分。
  • 利用拆分:利用依照业务垂直拆分。
  • 数据拜访权限收口:数据权限依照各自业务畛域,归属到各自的利用,利用与数据库一对一,禁止穿插拜访。

数据库拆分

单体数据库的痛点:未进行业务隔离,一个慢 SQL 易导致系统整体呈现问题;吞吐量高,读写压力大,性能降落;

数据库革新

依据业务划分,咱们打算将数据库拆分为 9 个业务库。数据同步形式采纳主从复制的形式,并且通过 binlog 过滤将对应的表和数据同步到对应的新数据库中。

代码革新计划

如果一个接口中操作了多张表,之前这些表属于同一个库,数据库拆分后可能会分属于不同的库。所以须要针对代码进行相应的革新。

目前存在问题的地位:

  • 数据源抉择:零碎之前是反对多数据源切换的,在 service 上增加注解来抉择数据源。数据库拆分后呈现的状况是同一个 service 中操作的多个 mapper 从属于不同的库。
  • 事务:事务注解目前是存在于 service 上的,并且事务会缓存数据库链接,一个事务内不反对同时操作多个数据库。

革新点梳理:

  • 同时写入多个库,且是同一事务的接口 6 个:需革新数据源,需革新事务,须要关注分布式事务;
  • 同时写入多个库,且不是同一事务的接口 50+:需革新数据源,需革新事务,无需关注分布式事务;
  • 同时读取多个库 或 读取一个库写入另一个库的接口 200+:需革新数据源,但无需关注事务;
  • 波及多个库的表的联结查问 8 个:需进行代码逻辑革新

梳理形式:

采纳部门中的切面工具,抓取入口和表的调用关系(可辨认表的读 / 写操作),找到一个接口中操作了多个表,并且多个表分属于不同业务库的状况;

分布式事务:

进行利用拆分和数据收口之后,是不存在分布式事务的问题的,因为操作第二个库会调用对应零碎的 RPC 接口进行操作。所以本次不会正式反对分布式事务,而是采纳代码逻辑保障一致性的形式来解决;

计划一

将 service 中别离操作多个库的 mapper,抽取成多个 Service。别离增加切换数据源注解和事务注解。

问题:改变地位多,波及改变的每个办法都须要梳理历史业务;service 存在很多嵌套调用的状况,有时难以理清逻辑;批改 200+ 地位改变工作量大,危险高;

计划二

如图所示,计划二将数据源注解挪动到 Mapper 上,并应用自定义的事务实现来处理事务。

将多数据源注解放到 Mapper 上的益处是,不须要梳理代码逻辑,只须要在 Mapper 上增加对应数据源名称即可。然而这样又有新的问题呈现,

  • 问题 1:如上图,事务的是配置在 Service 层,当事务开启时,数据源的连贯并没有获取到,因为真正的数据源配置在 Mapper 上。所以会报错,这个谬误能够通过多数据源组件的默认数据源性能解决。
  • 问题 2:mybatis 的事务实现会缓存数据库链接。当第一次缓存了数据库链接后,后续配置在 mapper 上的数据源注解并不会从新获取数据库链接,而是间接应用缓存起来的数据库链接。如果后续的 mapper 要操作其余数据库,会呈现找不到表的状况。鉴于以上问题,咱们开发了一个自定义的事务实现类,用来解决这个问题。

上面将对计划中呈现的两个组件进行简要阐明原理。

多数据源组件

多数据源组件是单个利用连贯多个数据源时应用的工具,其外围原理是通过配置文件将数据库链接在程序启动时初始化好,在执行到存在注解的办法时,通过切面获取以后的数据源名称来切换数据源,当一次调用波及多个数据源时,会利用栈的个性解决数据源嵌套的问题。

/**
 * 切面办法
 */
public Object switchDataSourceAroundAdvice(ProceedingJoinPoint pjp) throws Throwable {
        // 获取数据源的名字
        String dsName = getDataSourceName(pjp);
        boolean dataSourceSwitched = false;
        if (StringUtils.isNotEmpty(dsName)
                && !StringUtils.equals(dsName, StackRoutingDataSource.getCurrentTargetKey())) {
            // 见下一段代码
            StackRoutingDataSource.setTargetDs(dsName);
            dataSourceSwitched = true;
        }
        try {
            // 执行切面办法
            return pjp.proceed();} catch (Throwable e) {throw e;} finally {if (dataSourceSwitched) {StackRoutingDataSource.clear();
            }
        }
    }
public static void setTargetDs(String dbName) {if (dbName == null) {throw new NullPointerException();
    }
    if (contextHolder.get() == null) {contextHolder.set(new Stack<String>());
    }
    contextHolder.get().push(dbName);
    log.debug("set current datasource is" + dbName);
}

StackRoutingDataSource 继承 AbstractRoutingDataSource 类,AbstractRoutingDataSource 是 spring-jdbc 包提供的一个了 AbstractDataSource 的抽象类,它实现了 DataSource 接口的用于获取数据库链接的办法。

自定义事务实现

从计划二的图中能够看到默认的事务实现应用的是 mybatis 的 SpringManagedTransaction。

如上图,Transaction 和 SpringManagedTransaction 都是 mybatis 提供的类,他提供了接口供 SqlSession 应用,处理事务操作。
通过下边的一段代码能够看到,事务对象中存在 connection 变量,首次取得数据库链接后,后续以后事务内的所有数据库操作都不会从新获取数据库链接,而是会应用现有的数据库链接,从而无奈反对跨库操作。


public class SpringManagedTransaction implements Transaction {private static final Log LOGGER = LogFactory.getLog(SpringManagedTransaction.class);

  private final DataSource dataSource;

  private Connection connection;

  private boolean isConnectionTransactional;

  private boolean autoCommit;

  public SpringManagedTransaction(DataSource dataSource) {notNull(dataSource, "No DataSource specified");
    this.dataSource = dataSource;
  }
  // 下略
}

MultiDataSourceManagedTransaction 是咱们自定义的事务实现,继承自 SpringManagedTransaction 类,并在外部反对保护多个数据库链接。每次执行数据库操作时,会依据数据源名称判断,如果以后数据源没有缓存的链接则从新获取链接。这样,service 上的事务注解其实管制了多个单库事务,且作用域范畴雷同,一起进行提交或回滚。

代码如下:

public class MultiDataSourceManagedTransaction extends SpringManagedTransaction {
    private DataSource dataSource;

    public ConcurrentHashMap<String, Connection> CON_MAP = new ConcurrentHashMap<>();


    public MultiDataSourceManagedTransaction(DataSource dataSource) {super(dataSource);
        this.dataSource = dataSource;
    }

    @Override
    public Connection getConnection() throws SQLException {
        Method getCurrentTargetKey;
        String dataSourceKey;
        try {getCurrentTargetKey = dataSource.getClass().getDeclaredMethod("getCurrentTargetKey");
            getCurrentTargetKey.setAccessible(true);
            dataSourceKey = (String) getCurrentTargetKey.invoke(dataSource);
        } catch (Exception e) {log.error("MultiDataSourceManagedTransaction invoke getCurrentTargetKey 异样", e);
            return null;
        }

        if (CON_MAP.get(dataSourceKey) == null) {Connection connection = dataSource.getConnection();
            if (!TransactionSynchronizationManager.isActualTransactionActive()) {connection.setAutoCommit(true);
            } else {connection.setAutoCommit(false);
            }
            CON_MAP.put(dataSourceKey, connection);
            return connection;
        }

        return CON_MAP.get(dataSourceKey);
    }

    @Override
    public void commit() throws SQLException {if (CON_MAP == null || CON_MAP.size() == 0) {return;}
        Set<Map.Entry<String, Connection>> entries = CON_MAP.entrySet();
        for (Map.Entry<String, Connection> entry : entries) {Connection value = entry.getValue();
            if (!value.isClosed() && !value.getAutoCommit()) {value.commit();
            }
        }
    }

    @Override
    public void rollback() throws SQLException {if (CON_MAP == null || CON_MAP.size() == 0) {return;}
        Set<Map.Entry<String, Connection>> entries = CON_MAP.entrySet();
        for (Map.Entry<String, Connection> entry : entries) {Connection value = entry.getValue();
            if (value == null) {continue;}
            if (!value.isClosed() && !value.getAutoCommit()) {entry.getValue().rollback();}
        }
    }

    @Override
    public void close() throws SQLException {if (CON_MAP == null || CON_MAP.size() == 0) {return;}
        Set<Map.Entry<String, Connection>> entries = CON_MAP.entrySet();
        for (Map.Entry<String, Connection> entry : entries) {DataSourceUtils.releaseConnection(entry.getValue(), this.dataSource);
        }
        CON_MAP.clear();}
}

注:下面并不是分布式事务。在数据收口之前,它只存在于同一个 JVM 中。如果我的项目容许,能够思考应用 Atomikos 和 Mybatis 整合的计划。

数据安全性

本次进行了很多代码革新,如何保障数据安全,保证数据不失落,咱们的机制如下,分为三种状况进行探讨:

  • 跨库事务:6 处,采纳了代码保障一致性的革新形式;上线前通过重点测试,保障逻辑无问题;
  • 单库事务:依赖于自定义事务实现,针对自定义事务实现这一个类进行充沛测试即可,测试范畴小,安全性有保障;
  • 其余单表操作:相干批改是在 mapper 上增加了数据源切换注解,改变地位几百处,简直是无脑改变,但也存在脱漏或错改的可能;测试同学能够笼罩到外围业务流程,但边缘业务可能会脱漏;咱们增加了线上监测机制,当呈现找不到表的谬误时(阐明数据源切换注解增加谬误),记录以后执行 sql 并报警,咱们进行逻辑修复与数据处理。

综上,通过对三种状况的解决来保证数据的安全性。

利用拆分

零碎靠近单体架构,存在以下危险:

  1. 系统性危险:一个组件缺点会导致整个过程解体,如内存透露、死锁。
  2. 复杂性高:零碎代码繁多,每次批改代码都大惊失色,任何一个 bug 都可能导致整个零碎解体,不敢优化代码导致代码可读性也越来越差。
  3. 测试环境抵触,测试效率低:业务都耦合在一个零碎,只有有需要就会呈现环境抢占,须要额定拉分支合并代码。

拆分计划

与数据库拆分雷同,零碎拆分也是依据业务划分拆成 9 个新零碎。

计划一:搭建空的新零碎,而后将老零碎的相干代码挪到新零碎。

  • 长处:一步到位。
  • 毛病:须要主观筛选代码,而后挪到新零碎,可视为做了全量业务逻辑的变动,须要全量测试,危险高,周期长。

计划二:从老零碎原样复制出 9 个新零碎,而后间接上线,通过流量路由将老零碎流量转发到新零碎,后续再对新零碎的冗余代码做删减。

  • 长处:拆分速度快, 首次上线前无业务逻辑改变,危险低;后续删减代码时根据接口调用量状况来断定,也可视为无业务逻辑的改变,危险较低,并且各零碎可各自进行,无需整体排期, 较为灵便。
  • 毛病:分为了两步,拆分上线和删减代码

咱们在思考拆分危险和拆分效率后,最终抉择了计划二。

拆分实际

1、搭建新零碎

间接复制老零碎代码,批改零碎名称,部署即可

2、流量路由

路由器是拆分的外围,负责散发流量到新零碎,同时须要反对辨认测试流量,让测试同学能够提前在线上测试新零碎。咱们这边用 filter 来作为路由器的,源码见下方。

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {HttpServletRequest servletRequest = (HttpServletRequest) request;
    HttpServletResponse servletResponse = (HttpServletResponse) response;

    // 路由开关 (0- 不路由, 1- 依据指定申请头路由, 2- 全量路由)
    final int systemRouteSwitch = configUtils.getInteger("system_route_switch", 1);
    if (systemRouteSwitch == 0) {filterChain.doFilter(request, response);
        return;
    }
    // 只路由测试流量
    if (systemRouteSwitch == 1) {
        // 查看申请头是否蕴含测试流量标识 蕴含才进行路由
        String systemRoute = ((HttpServletRequest) request).getHeader("systemRoute");
        if (systemRoute == null || !systemRoute.equals("1")) {filterChain.doFilter(request, response);
            return;
        }
    }

    String systemRouteMapJsonStr = configUtils.getString("route.map", "");
    Map<String, String> map = JSONObject.parseObject(systemRouteMapJsonStr, Map.class);
    String rootUrl = map.get(servletRequest.getRequestURI());

    if (StringUtils.isEmpty(rootUrl)) {log.error("路由失败,本地服务外部解决。起因:申请地址映射不到对应零碎, uri : {}", servletRequest.getRequestURI());
        filterChain.doFilter(request, response);
        return;
    }

    String targetURL = rootUrl + servletRequest.getRequestURI();
    if (servletRequest.getQueryString() != null) {targetURL = targetURL + "?" + servletRequest.getQueryString();
    }
    RequestEntity<byte[]> requestEntity = null;
    try {log.info("路由开始 targetURL = {}", targetURL);
        requestEntity = createRequestEntity(servletRequest, targetURL);
        ResponseEntity responseEntity = restTemplate.exchange(requestEntity, byte[].class);
        if (requestEntity != null && requestEntity.getBody() != null && requestEntity.getBody().length > 0) {log.info("路由实现 - 申请信息: requestEntity = {}, body = {}", requestEntity.toString(), new String(requestEntity.getBody()));
        } else {log.info("路由实现 - 申请信息: requestEntity = {}", requestEntity != null ? requestEntity.toString() : targetURL);
        }

        HttpHeaders headers = responseEntity.getHeaders();
        String resp = null;
        if (responseEntity.getBody() != null && headers != null && headers.get("Content-Encoding") != null && headers.get("Content-Encoding").contains("gzip")) {byte[] bytes = new byte[30 * 1024];
            int len = new GZIPInputStream(new ByteArrayInputStream((byte[]) responseEntity.getBody())).read(bytes, 0, bytes.length);
            resp = new String(bytes, 0, len);
        }

        log.info("路由实现 - 响应信息: targetURL = {}, headers = {}, resp = {}", targetURL, JSON.toJSONString(headers), resp);
        if (headers != null && headers.containsKey("Location") && CollectionUtils.isNotEmpty(headers.get("Location"))) {log.info("路由实现 - 须要重定向到 {}", headers.get("Location").get(0));
            ((HttpServletResponse) response).sendRedirect(headers.get("Location").get(0));
        }
        addResponseHeaders(servletRequest, servletResponse, responseEntity);
        writeResponse(servletResponse, responseEntity);
    } catch (Exception e) {if (requestEntity != null && requestEntity.getBody() != null && requestEntity.getBody().length > 0) {log.error("路由异样 - 申请信息: requestEntity = {}, body = {}", requestEntity.toString(), new String(requestEntity.getBody()), e);
        } else {log.error("路由异样 - 申请信息: requestEntity = {}", requestEntity != null ? requestEntity.toString() : targetURL, e);
        }
        response.setCharacterEncoding("UTF-8");
        ((HttpServletResponse) response).addHeader("Content-Type", "application/json");
        response.getWriter().write(JSON.toJSONString(ApiResponse.failed("9999", "网络忙碌哦~,请您稍后重试")));
    }
}

3、接口抓取 & 归类

路由 filter 是依据接口门路将申请散发到各个新零碎的,所以须要抓取一份接口和新零碎的映射关系。
咱们这边自定义了一个注解 @TargetSystem,用注解标识接口应该路由到的指标零碎域名,

@TargetSystem(value = "http://order.demo.com")
@GetMapping("/order/info")
public ApiResponse orderInfo(String orderId) {return ApiResponse.success();
}

而后遍历获取所有 controller 依据接口地址和注解生成路由映射关系 map

    /**
     * 生成路由映射关系 MAP
     * key:接口地址,value:路由到指标新零碎的域名
     */
    public Map<String, String> generateRouteMap() {Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
        Set<Map.Entry<RequestMappingInfo, HandlerMethod>> entries = handlerMethods.entrySet();
        Map<String, String> map = new HashMap<>();
        for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : entries) {RequestMappingInfo key = entry.getKey();
            HandlerMethod value = entry.getValue();
            Class declaringClass = value.getMethod().getDeclaringClass();
            TargetSystem targetSystem = (TargetSystem) declaringClass.getAnnotation(TargetSystem.class);
            String targetUrl = targetSystem.value();
            String s1 = key.getPatternsCondition().toString();
            String url = s1.substring(1, s1.length() - 1);
            map.put(url, targetUrl);
        }
        return map;
    }

4、测试流量辨认

测试能够用利用抓包工具 charles,为每个申请都增加固定的申请头,也就是测试流量标识,路由器拦挡申请后判断申请头内是否蕴含测试流量标,蕴含就路由到新零碎,不蕴含就是线上流量留在老零碎执行。

5、需要代码合并

执行零碎拆分的过程中,还是有需要正在并行开发,并且需要代码是写在老零碎的,零碎拆分实现上线后,须要将这部分需要的代码合并到新零碎,同时要保障 git 版本记录不能失落,那应该怎么做呢?

咱们利用了 git 能够增加多个多个近程仓库来解决需要合并的痛点,命令:git remote add origin 仓库地址,把新零碎的 git 仓库地址增加为老零碎 git 的近程仓库,老零碎的 git 变动就能够同时 push 到所有新零碎的仓库内,新零碎 pull 下代码后进行合并。

6、上线危险

危险一:JOB 在新老零碎并行执行。新零碎是复制的老零碎,JOB 也会复制过去,导致新老零碎有雷同的 JOB,如果这时候上线新零碎,新零碎的 JOB 就会执行,老零碎的 JOB 也始终在 run,这样一个 JOB 就会执行 2 次。新零碎刚上线还没通过测试验证,这时候执行 JOB 是有可能失败的。以上 2 种状况都会引起线上 Bug,影响零碎稳定性。

危险二:新零碎提前生产 MQ。和危险一一样,新零碎监听和老零碎一样的 topic,如果新零碎间接上线,音讯是有可能被新零碎生产的,新零碎刚上线还没通过测试验证,生产音讯有可能会出异样,造成音讯失落或其余问题,影响零碎稳定性。

如何解决以上 2 个上线危险呢?

咱们用“动静开关”解决了上述危险,为新老零碎的 JOB 和 MQ 都加了开关,用开关管制 JOB 和 MQ 在新 / 老零碎执行。上线后新零碎的 JOB 和 MQ 都是关掉的,待 QA 测试通过后,把老零碎的 JOB 和 MQ 关掉,把新零碎的 JOB 和 MQ 关上就能够了。

零碎瘦身

拆分的时候曾经梳理出了一份“入口映射关系 map”,每个新零碎只须要保留本人零碎负责的接口、JOB、MQ 代码就能够了,除此之外都能够进行删除。

拆分带来的益处

  1. 零碎架构更正当,可用性更高:即便某个服务挂了也不会导致整个零碎解体
  2. 复杂性可控:每个零碎都是繁多职责,零碎逻辑清晰
  3. 零碎性能晋升下限大:能够针对每个零碎做优化,如加缓存
  4. 测试环境抵触的问题解决,不会因为多个零碎需要并行而抢占环境

数据拜访权限收口

问题介绍

数据拜访权限未收口:一个业务的数据库被其余业务利用间接拜访,未通过 rpc 接口将数据拜访权限收口到数据领有方本人的利用。数据拜访逻辑扩散,存在业务耦合,妨碍后续迭代和优化。

问题产生的背景:之前是单体利用和单体数据库,未进行业务隔离。在进行数据库拆分和零碎拆分时,为解决零碎稳定性的问题需疾速上线,所以未优化拆分后跨业务拜访数据库的状况。本阶段是对数据库拆分和利用拆分的延长和补充。

革新过程

  1. RPC 接口统计(如图一)

进行比对,如程序入口归类和调用的业务 DB 归类不统一,则认为 Dao 办法需提供 RPC 接口

经统计,利用拜访非本业务数据库的地位有 260+。因为波及地位多,人工革新老本高、效率较低,且有错改和漏掉的危险,咱们采纳了开发工具,用工具进行代码生成和批量批改的形式进行革新。

  1. RPC 接口生成(如图二)
  • 读取须要生成 RPC 接口的 Dao 文件,进行解析
  • 获取文件名称,Dao 办法列表,import 导包列表等,放入 ClassContext 上下文
  • 匹配 api、rpc 文件模板,从 classContext 内取值替换模板变量,通过 package 门路生成 java 文件到指定服务内
  • 批量将服务内 Dao 名称后缀替换为 Rpc 服务名,缩小人工改变危险,例:SettleRuleDao -> SettleRuleRpc

名词解释:

  • ftl:Freemarker 模板的文件后缀名,FreeMarker 是一个模版引擎,一个基于文本的模板输入工具。
  • interfaceName:用寄存 api 文件名称
  • className:用于寄存 serviceImpl 文件名称
  • methodList:用于寄存办法列表,蕴含入参、出参、返回值等信息
  • importList:用于寄存 api 和 impl 文件内其余援用实体的导包门路
  • apiPackage:用于寄存生成的 Api 接口类包名
  • implPackage:用于寄存生成的 Api 实现类包名
  • rpcPackage:用于寄存生成的 rpc 调用类包名

  1. 灰度计划(如图三)
  • 数据操作对立走 RPC 层解决,初期阶段 RPC 层兼顾 RPC 调用,也有之前的 DAO 调用,应用开关切换。
  • RPC 层进行双读,进行 Api 层和 Dao 层返回后果的比对,后期优先返回 Dao 层后果,验证无问题后,在全量返回 RPC 的后果,革除其余业务数据库连贯。
  • 反对开关一键切换,按流量进行灰度,升高数据收口危险

收益

  1. 业务数据解耦,数据操作对立由各自垂直零碎进行,入口对立
  2. 不便后续在接口粒度上减少缓存和降级解决

总结

以上,是咱们对单体零碎的革新过程,通过了三步优化、上线,将单体零碎平滑过渡到了微服务构造。解决了数据库的单点问题、性能问题,利用业务失去了简化,更利于分工,迭代。并且能够针对各业务独自进行优化降级,扩容、缩容,晋升了资源的利用率。

正文完
 0