如何避免单点风险:基于实践经验分享服务拆分原则的一些思考

Posted 沧海一滴

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何避免单点风险:基于实践经验分享服务拆分原则的一些思考相关的知识,希望对你有一定的参考价值。

  • 缘起:系统崩了

  • 具体情况:1%的请求影响了剩余90%的请求

  • 架构演进:拆分热点服务【进程级隔离】

  • 复盘

  • 总结

  • 拆服务的经典实践

 


不能变形的变形金刚也叫变形金刚?

缘起
系统崩溃了?别惊慌!这里有快速恢复的方法!
分析发现,网站崩时服务X被流量打垮,继而依赖服务X的其它服务开始互相“踩踏”,最终崩溃。
网站崩时,服务X的QPS过高,实际达到了业务gateway服务的2倍~~

 

出故障时的系统结构概览:

 


一个请求的生命周期

具体情况:1%的请求影响了剩余90%的请求

 

相同时段之前崩掉服务的情况:

 

业务gateway的流量为什么会远小于服务x的流量是不是合理?

 

业务服务与基础数据服务耦合在一块是不是合理的??

 


接口响应时长, 也从毫秒级, 快速退化到秒级

 

一个查Redis缓存的请求耗时7708ms

org.springframework.web.servlet.FrameworkServlet.doGet(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletResponse response)

解决方案:拆分热点服务【进程级隔离】+水平扩容+k8s HPA

https://www.processon.com/view/6447cd7e8e188d4a087d3d1e

架构升级后服务的效果:
1、架构更合理了
2、接口响应时间更短

 

 

复盘
1、这是谁的问题?
这是服务的稳定性的问题。

2、有什么问题?
一个服务崩了,导致整个业务系统崩了。

3、解决的思路是什么?
把整个业务系统依赖的基础拆出来。

总结
一个基础服务不可用,导致整个业务系统不可用。
然后,通过把这个基础服务拆成一个单独的服务,来解决这个问题。

 

思考
服务拆分的原则是什么?

在分享关于服务拆分的思考之前,让我们先来谈一些相关的概念吧。

什么是架构?

软件架构是指软件系统的顶层结构,而架构设计的目的是为了解决软件复杂度,是为了使软件项目更加可靠、高效和可扩展,以便于持续迭代。
架构的思考来源于对生命周期的识别,以及对生命周期的拆分。如果没有生命周期拆分的思考,就不能算架构。什么是微服务架构?
微服务架构是一种架构模式,它提倡将单一应用程序划分成一组小的服务,服务之间互相协调、互相配合,为用户提供最终价值。微服务架构的本质是分布式系统。
来看看Martin Fowler大佬的理解:

 


上述英文的三个关键词分别是:small、lightweight、automated,这基本上浓缩了微服务的精华,也是微服务和SOA的本质区别所在。

微服务拆分的目的是什么?服务拆分的目的是将庞大复杂的系统拆分成小而简单的服务,以便更好地应对持续迭代的需求。
这样做的好处在于,各个服务可以独立运行,降低了彼此的耦合度,使得后续的开发、部署、测试和维护等工作更加容易进行,从而提高了系统的可靠性、可扩展性和可维护性。因此,服务拆分不仅是为了简化系统,也有助于让系统更好地适应未来的变化

微服务拆分的原则:化大而复杂为小而简单。

进行服务化拆分,把一个大而复杂的问题化解为多个小而简单的问题,服务之间通过契约来约定依赖,做到服务独立发布和演进。
微服务到底有多微,是个仁者见仁,智者见智的问题,最重要的是团队觉得是合适的。但注意,要达成“团队觉得合适”的结论,至少还应该遵循以下两个基本前提:

  • 业务独立性

首先,应该保证微服务是具有业务独立性的单元,并不能只是为了微而微。关于如何判断业务的独立性,也有不同的考量。譬如可以将某一个领域的模型作为独立的业务单元,譬如 订单、商品、财务、售后等;也可以将某业务行为作为独立的业务单元,譬如发送邮件、不同数据库之间的业务数据同步等。

  • 团队自主性

其次,考虑到团队的沟通及协作成本,一般不建议超过10个人。当团队超过10个人,在沟通、协作上的所耗费的成本会显著增加。当团队成员超过10个人的时候,可以考虑继续再划分子团队,让不同的子团队承担独立的工作。

微服务拆分过程中的坑

  • 微服务拆过细,过分强调“small”
  • 微服务基础设施不健全,忽略了“automated”
  • 微服务并不轻量级,规模大了后,“lightweight”不再适应


如何拆分?

对拆分依据的思考,实际上是在问:“服务的粒度如何界定”。
针对微服务拆分过细导致的问题,建议基于团队规模进行拆分,类似贝索斯在定义团队规模时提出的“两个披萨”理论(每个团队的人数不能多到两个Pizza都不够吃的地步)。
那么,以“三个火枪手”为原则的微服务拆分粒度,即一个微服务三个人负责开发。当我们在实施微服务架构时,根据团队的规模来划分微服务数量,如果业务规模继续发展,团队规模扩大,我们再将已有的微服务按这个原则拆分。
“三个火枪手”的原则主要应用于微服务设计和开发阶段,如果微服务经过一段时间发展后已经比较稳定,处于稳定期了,无需太多的开发,那么1个人维护1个微服务甚至几个微服务都可以。
拆分的思路

基于“三个火枪手”的理论,我们可以计算出拆分后合适的服务数量,但具体怎么拆分也是有技巧的,并不是快刀斩乱麻随便拆分成指定的数量就可以了。常见的拆分方式有如下几种,接下来我们一一介绍。
(1)基于业务逻辑拆分
将系统中的业务模块按照职责范围识别出来,每个单独的业务模块拆分为一个独立的服务。
(2)基于可扩展拆分
将系统中的业务模块按照稳定性进行排序,将已经成熟和改动不大的服务拆分为稳定服务,将经常变化和迭代的服务拆分为变动服务。刚开始,稳定的服务粒度可以粗一些,即使逻辑上没有强关联的服务,也可以放在同一个子系统中。例如,将“消息推送服务”和“升级服务”放在同一个子系统中。
这样拆分主要是为了提升项目快速迭代的效率,避免在开发的时候,不小心影响已有的成熟功能,引发了线上问题。
(3)基于可靠性拆分
将系统中的业务模块按照优先级排序,将可靠性要求高的核心服务和可靠性要求低的非核心服务拆分开来,然后重点保证核心服务的高可用。具体拆分的时候,核心服务可以是一个,也可以是多个,只要最终的服务数据满足“三个火枪手”的原则就可以。
这样拆分可以带来如下几个好处:

  • 避免非核心服务故障影响核心服务

  • 核心服务高可用方案可以更简单

核心服务的功能逻辑更加简单,存储的数据可能更少,用到的组件也会更少,设计高可用方案在大部分情况下要比不拆分要简单得多。

  • 能够降低高可用成本

    将核心服务拆分出来后,核心服务占用的机器、带宽等资源比不拆分要少得多。因此只针对核心服务做高可用方案,机器、带宽等成本比不拆分要节省较多。

    (4)基于性能拆分

基于性能拆分和基于性能拆分类似,将性能要求高或性能压力大的模块拆分出来,避免性能压力大的服务影响其它服务。
常见的拆分方式和具体的性能瓶颈有关,可以拆分Web服务、数据库、Cache等。
例如,电商的抢购,性能压力最大的入口的排队功能,可以将排队功能独立为一个服务。

以上几种拆分方式不是多选一,而是可以根据实际情况自由排列组合。

小结
服务拆分的目的是为了提高软件项目的可靠性、效率和可扩展性,以便更好地应对持续迭代的需求、支撑软件的增长。通过将大型应用程序拆分成小的、独立的部分,可以降低系统复杂度并提高整体质量。这种架构设计也有助于快速开发、测试和部署代码,从而提高开发团队的工作效率。
如果无法实现上述的目标,就需要重新考虑是否有必要进行服务拆分。


引用
https://martinfowler.com/articles/microservices.html
https://time.geekbang.org/column/intro/100006601?code=SWmCO2yChZKMsxvoZphPgPyr8SX0c6wLhHZBcxyOZFs%3D&source=app_share
《从零开始学架构 照着做,你也能成为架构师》
《微服务 架构与实践》
《聊聊“架构”》
https://www.bilibili.com/video/av71345202

https://mp.weixin.qq.com/s/f1sEprXHw1ciYljH5PP3yw

 

微服务拆分治理最佳实践

作者:京东零售 徐强 黄威 张均杰

背景

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

从下图可见。各业务相互耦合无明确边界,调用阿关系错综复杂。

微服务拆分治理最佳实践_微服务

随着业务快速发展,各种问题越来越明显,急需对系统进行微服务改造优化。经过思考,整体改造将分为三个阶段进行:

  • 数据库拆分:数据库按照业务垂直拆分。
  • 应用拆分:应用按照业务垂直拆分。
  • 数据访问权限收口:数据权限按照各自业务领域,归属到各自的应用,应用与数据库一对一,禁止交叉访问。

微服务拆分治理最佳实践_数据源_02

数据库拆分

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

数据库改造

微服务拆分治理最佳实践_京东云_03

根据业务划分,我们计划将数据库拆分为9个业务库。数据同步方式采用主从复制的方式,并且通过binlog过滤将对应的表和数据同步到对应的新数据库中。

代码改造方案

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

目前存在问题的位置:

  • 数据源选择:系统之前是支持多数据源切换的,在service上添加注解来选择数据源。数据库拆分后出现的情况是同一个service中操作的多个mapper从属于不同的库。
  • 事务:事务注解目前是存在于service上的,并且事务会缓存数据库链接,一个事务内不支持同时操作多个数据库。

改造点梳理:

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

梳理方式:

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

分布式事务:

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

方案一

微服务拆分治理最佳实践_微服务_04

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

问题:改动位置多,涉及改动的每个方法都需要梳理历史业务;service存在很多嵌套调用的情况,有时难以理清逻辑;修改200+位置改动工作量大,风险高;

方案二

微服务拆分治理最佳实践_微服务_05


如图所示,方案二将数据源注解移动到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。

微服务拆分治理最佳实践_京东云_06


如上图,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个新系统,然后直接上线,通过流量路由将老系统流量转发到新系统,后续再对新系统的冗余代码做删减。

  • 优点:拆分速度快, 首次上线前无业务逻辑改动,风险低;后续删减代码时依据接口调用量情况来判定,也可视为无业务逻辑的改动,风险较低,并且各系统可各自进行,无需整体排期, 较为灵活。
  • 缺点:分为了两步,拆分上线和删减代码

微服务拆分治理最佳实践_数据源_07

我们在考虑拆分风险和拆分效率后,最终选择了方案二。

微服务拆分治理最佳实践_京东技术_08

拆分实践

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;

微服务拆分治理最佳实践_微服务_09

4、测试流量识别

测试可以用利用抓包工具charles,为每个请求都添加固定的请求头,也就是测试流量标识,路由器拦截请求后判断请求头内是否包含测试流量标,包含就路由到新系统,不包含就是线上流量留在老系统执行。

微服务拆分治理最佳实践_业务改造_10

5、需求代码合并

执行系统拆分的过程中,还是有需求正在并行开发,并且需求代码是写在老系统的,系统拆分完成上线后,需要将这部分需求的代码合并到新系统,同时要保证git版本记录不能丢失,那应该怎么做呢?

我们利用了git可以添加多个多个远程仓库来解决需求合并的痛点,命令:git remote add origin 仓库地址,把新系统的git仓库地址添加为老系统git的远程仓库,老系统的git变动就可以同时push到所有新系统的仓库内,新系统pull下代码后进行合并。

微服务拆分治理最佳实践_数据源_11

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打开就可以了。

微服务拆分治理最佳实践_微服务_12

系统瘦身

拆分的时候已经梳理出了一份“入口映射关系map”,每个新系统只需要保留自己系统负责的接口、JOB、MQ代码就可以了,除此之外都可以进行删除。

拆分带来的好处

  1. 系统架构更合理,可用性更高:即使某个服务挂了也不会导致整个系统崩溃
  2. 复杂性可控:每个系统都是单一职责,系统逻辑清晰
  3. 系统性能提升上限大:可以针对每个系统做优化,如加缓存
  4. 测试环境冲突的问题解决,不会因为多个系统需求并行而抢占环境

数据访问权限收口

问题介绍

数据访问权限未收口:一个业务的数据库被其余业务应用直接访问,未通过rpc接口将数据访问权限收口到数据拥有方自己的应用。数据访问逻辑分散,存在业务耦合,阻碍后续迭代和优化。

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

微服务拆分治理最佳实践_京东技术_13

改造过程

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

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

微服务拆分治理最佳实践_数据源_14

经统计,应用访问非本业务数据库的位置有260+。由于涉及位置多,人工改造成本高、效率较低,且有错改和漏掉的风险,我们采用了开发工具,用工具进行代码生成和批量修改的方式进行改造。

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

微服务拆分治理最佳实践_京东技术_15

名词解释:

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

微服务拆分治理最佳实践_京东云_16

微服务拆分治理最佳实践_数据源_17

  1. 灰度方案(如图三)
  • 数据操作统一走RPC层处理,初期阶段RPC层兼顾RPC调用,也有之前的DAO调用,使用开关切换。
  • RPC层进行双读,进行Api层和Dao层返回结果的比对,前期优先返回Dao层结果,验证无问题后,在全量返回RPC的结果,清除其他业务数据库连接。
  • 支持开关一键切换,按流量进行灰度,降低数据收口风险

微服务拆分治理最佳实践_京东云_18

收益

  1. 业务数据解耦,数据操作统一由各自垂直系统进行,入口统一
  2. 方便后续在接口粒度上增加缓存和降级处理

总结

以上,是我们对单体系统的改造过程,经过了三步优化、上线,将单体系统平滑过渡到了微服务结构。解决了数据库的单点问题、性能问题,应用业务得到了简化,更利于分工,迭代。并且可以针对各业务单独进行优化升级,扩容、缩容,提升了资源的利用率。

以上是关于如何避免单点风险:基于实践经验分享服务拆分原则的一些思考的主要内容,如果未能解决你的问题,请参考以下文章

java写入文件怎么换行,经验分享

微服务拆分治理最佳实践

贝壳金控赵文乐:基于 Spring Cloud 的服务治理实践

基于ABP落地领域驱动设计-04.领域服务和应用服务的最佳实践和原则

微服务拆分原则以及实践

经验分享自然语言技术的内部审计风险预警框架构建与应用