尘世随缘
作者尘世随缘·2020-04-21 15:06
技术总监·上海某互联网金融公司

企业微服务架构设计及实施难点剖析“硬核”实战分享

字数 10910阅读 4166评论 0赞 1

现如今不管是传统企业还是互联网公司都在谈论微服务,微服务架构已经成为了互联网的热门话题,同时,微服务的开发框架比如Dubbo、SpringCloud等也是在高频迭代中,以满足层出不穷的技术需求。当企业遇到系统性能瓶颈、项目进度推进乏力、系统运维瓶颈的时候,都会试图把微服务当着一根救命稻草,认为只要实施微服务架构了,所有的问题都迎刃而解。然而,在实施微服务过程中出现的各种各样问题如何优雅的去解决呢?本文接下来将介绍如何以“硬核”的方式去解决微服务改造过程中遇到的难点问题。

一、服务拆分粒度问题

- 服务到底怎么拆分合适

在微服务架构中“服务”的定义是指分布式架构下的基础单元,包含了一组特定的功能。服务拆分是单体应用转化成微服务架构的第一步,服务拆分是否合理直接影响到微服务架构的复杂性、稳定性以及可扩展性。服务拆分过小,会导致不必要的分布式事务产生,而且整个调用链过程也会变长,反之,如果服务拆分过大,会逐步演变为单体应用,不能发挥微服务的优势。判断一个服务拆分的好坏,就看微服务拆分完成后是否具备服务的自治原则,如果把复杂单体应用改造成一个一个松耦合式微服务,那么按照业务功能分解模式进行分解是最简单的,只需把业务功能相似的模块聚集在一起。比如:

  1. 用户管理:管理用户相关的信息,例如注册、修改、注销或查询、统计等。
  2. 商品管理:管理商品的相关信息。

业务功能分解模式另外的优势在于在初级阶段服务拆分不会太小,等到业务发展起来后可以再根据子域方式来拆分,把独立的服务再拆分成更小的服务,最后到接口级别服务。

以用户管理举例,在初始阶段的做服务拆分的时候,把用户管理拆分为用户服务,且具备了用户的增删改查功能,在互联网中流量获客是最贵的,运营团队通过互联网投放广告获客,用户在广告页上填写手机号码执行注册过程,如果此时注册失败或者注册过程响应时间过长,那么这个客户就可能流失了,但是广告的点击费用产生了,无形中形成了资源的浪费。当用户规模上升之后需要对增删改查功能做优先级划分,所以此时需要按方法维度来拆分服务,把用户服务拆分为用户注册服务(只有注册功能),用户基础服务(修改、查询用户信息)。

- 哪些功能需要被拆分成服务

无论是单体应用重构为微服务架构,还是在微服务架构体系下有新增需求,都会面临这些功能或者新增需求是否需要被拆分为服务。虽然没有相关规定,但是可以遵循服务拆分的方法论:当一块业务不依赖或极少依赖其它服务,有独立的业务语义,为超过 2 个或以上的其他服务或客户端提供数据,应该被拆分成一个独立的服务模,而且拆分的服务要具备高内聚低耦合。所谓的高内聚是指一个组件中各个元素互相依赖的程度,是衡量某个模块或者类中各个代码片段之间关联强度的标准,比如用户服务,只会提供用户相关的增删改查信息,假如还关联了用户订单相关的信息,那就说明这个功能不是高内聚的功能,拆分的不好。

低耦合是指系统中每个组件很少知道或者不知道其他独立组件的定义,其中的组件可以被其他提供相同功能的组件替代。

二、缓存到底怎么用才更有效

- 缓存需要在哪层增加

微服务架构下,原本单体应用被划分为聚合层和原子服务层,每一层所负责的功能各不相同。

  1. 聚合层:收到终端请求后,聚合多个原子服务数据,按接口要求把聚合后的数据返回给终端,需要注意点是聚合层不会和数据库交互;
  2. 原子服层:数据库交互层,实现数据的增删改查,结合缓存和工具保障服务的高响应;要遵循单表原则,禁止2张以上的表做join查询,如有分库分表,那么对外要屏蔽具体规则,提供服务接口供外部调用。

如果使用到缓存,那么到底在聚合层加还是原子层加还是其他呢?应该遵循“谁构建,谁运维”这一理念,是否使用缓存应该由对应的开发人员自行维护,也就是说聚合层和原子层都需要增加缓存。一般来说聚合层和原子层由不同的团队开发,聚合层和业务端比较贴近,需要了解业务流程更好的服务业务,和App端交互非常多,重点是合理设计的前后端接口,减少App和后端交互次数。原子服务则是关注性能,屏蔽数据库操作,屏蔽分库分表等操作。在聚合层推荐使用多级缓存,即本地缓存+分布式缓存,本地缓存不做缓存数据的变更,使用TTL自动过期时间来自动更新缓存内的数据。

- 缓存使用过程中不可避免的问题

在使用缓存的时候不可避免的会遇到缓存穿透、缓存击穿、缓存雪崩等场景,针对每种场景的时候需要使用不同的应对策略,从而保障系统的高可用性。

  • 缓存穿透:是指查询一个一定不存在缓存key,由于缓存是未命中的时候需要从数据库查询,正常情况下查不到数据则不写入缓存,就会导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透,有2个方案可以解决缓存穿透:

1) 可以使用布隆过滤器方案,系统启动的时候将所已存在的数据哈希到一个足够大的bitmap中,当一个一定不存在的数据请求的时候,会被这个bitmap拦截掉,从而避免了对底层数据库的查询压力

@Component
public class BloomFilterCache {
    public static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000);
    @PostConstruct
    public  void init(){
List<Integer> list=Lists.newArrayList(); //初始化加载所有的需要被缓存的数据ID
        list.forEach(id ->bloomFilter.put(id));
    }
public  boolean addKey(Integer key){
     return bloomFilter.put(key);
}
    public  boolean isCached(Integer key){
        return bloomFilter.mightContain(key);
    }
}

这里的BloomFilter选用guava提供的第三方包,服务启动的时候,init方法会加载所有可以被缓存的数据,把id都放入boolmFilter中,当有新增数据的时候,执行addKey把新增的数据放入BoolmFilter过滤器中。

当在需要使用缓存的地方先调用isCached方法,如果返回true表示正常请求,否则拒绝。

2) 返回空值:如果一个查询请求查询数据库后返回的数据为空(不管是数据不存在,还是系统故障),仍然把这个空结果进行缓存,但它的过期时间会很短,比如1分钟,但是这种方法解决不够彻底。

  • 缓存击穿:缓存key在某个时间点过期的时候,刚好在这个时间点对这个Key有大量的并发请求过来,请求命中缓存失败后会通过DB加载数据并回写到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮,解决方案也很简单通过加锁的方式读取数据,同时写入缓存。
  Object [] objects={0,1,2,3,4,5,6,7,8,9};
    public List<String> getData(Integer id) throws InterruptedException {
        List<String> result = new ArrayList<String>();
        result = getDataFromCache(id);
        if (result.isEmpty()) {
                int objLength= objects.length;
                 synchronized (objects[id% objects.length]) {
                    result = getDataFromDB(id);
                    setDataToCache(result);
               }
              } 
        
        return result;
}

这里加锁的方法使用的是Object数组,是希望因不同的id不会因为从数据库加载数据被阻塞,例如id=1、id=2、id=3的key同时在缓存中消失,微服务路由策略刚好都把这些请求都路由到同一台机器上,假设查询DB需要50毫秒,如果仅使用synchronized(object){……}则id=3的请求会被阻塞,需要等等150毫秒才能返回结果,但是使用上述方法则只需要50毫秒出结果。其中objects数据的大小可以根据DB能承载的并发量以及原子服务数量综合考虑。

  • 缓存雪崩:是指在设置缓存时使用了相同的过期时间,导致缓存在某一时刻同时失效,所有的查询都请求到数据库上,导致应用系统产生各种故障,这样情况称之为缓存雪崩,可以通过限流的方式来限制请求数据库的次数。

三、串行化并行解决效率问题

一个应用功能被拆分成多个服务之后,原本调用一个接口就能完成的功能如今变成需要调用多个服务,如果按顺序逐个调用的话,使用微服务改造后的接口会比原始接口响应时间更长,因此要把原本串行调用的服务修改为并行调用,同时原本通过SQL的join多表联合查询操作变成单表操作,然后在聚合层的内存中做拼接。

例如接口A,需要调用S1(耗时200毫秒),S2(耗时180毫秒),S3(耗时320毫秒)这3个接口,使用串行调用方式,那么接口A累计耗时=SUM(S1+S2+S3)=700毫秒。为了让响应时间更短,就需要把这些串行调用的方式更改为并行调用的方式,并行调用方式调用接口A累计耗时为MAX(S1,S2,S3)=320毫秒。可以使用jdk8提供的CompletableFuture方法,伪代码如下:

CompletableFuture<DTOS1> futureS1 = CompletableFuture.supplyAsync(() -> {
        S1接口 },executor);
CompletableFuture<DTOS2> futureS2 = CompletableFuture.supplyAsync(() -> {
        S2接口  },executor);
CompletableFuture<DTOS3> futureS3 = CompletableFuture.supplyAsync(() -> {
        S3接口 },executor);
CompletableFuture.allOf(futureS1, futureS2, futureS3).get(500, TimeUnit.MILLISECONDS);

此时就把原本串联调用的服务变成并行调用,节约了接口请求时间,但却引发一个新的问题,内部接口调用换成网络RPC调用,会导致服务调用的不确定性,引起接口不稳定。

四、服务的熔断降级处理

把内部接口调用替换为RPC调用,在调用过程中可能会出现网络抖动、网络异常,当服务提供方(Provide)变得不可用或者响应慢时,也会影响到服务调用方的服务性能,甚至可能会使得服务调用方占满整个线程池,导致这个应用上其它的服务也受影响,从而引发更严重的雪崩效应。因此需要梳理所有服务提供者并把服务分级,同时引入了Hystrix或则Sentinel做服务熔断和降级处理,目的如下:

  • 降级目的:业务高峰期的生活,去掉非核心链路,保障主流程正常运行;
  • 熔断目的:防止应用程序不断地尝试可能超时或者失败的服务,能达到应用程序正常执行而不需要等待下游修正服务。

熔断器需要做以下设置

  • 设置错误率:可以设置每个服务错误率到达制定范围后开始熔断或降级;
  • 具备人工干预:可以人工手动干预,主动触发降级服务;
  • 设置时间窗口:可配置化来设置熔断或者降级触发的统计时间窗口;
  • 具备主动告警:当接口熔断之后,需要主动触发短信告知当前熔断的接口信息;

以Sentinel为例,它提供了很多微服务框架的适配器,如果是Dubbo应用,提供了SentinelDubboConsumerFilter和SentinelDubboProviderFilter等Filter,企业零开发即可快速接入Sentinel完成对服务的保护,只需要在工程的pom.xml里面引入

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-apache-dubbo-adapter</artifactId>
    <version>1.7.2</version>
</dependency>
如果是Spring Cloud,只需要在pom.xml里面引入以下内容即可快速接入
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

这里需要注意2点:

1) 要先梳理服务做好服务分级,降级、熔断是针对非核心流程,如核心流程处理能力不满足业务需要,则需要扩充或者优化核心流程;

2) 降级是动态配置后立即生效,而非手动去修改源代码后再发布服务服务;

五、接口幂等处理

在分布式环境中,网络环境比较复杂,如前端操作抖动、APP自动重试、网络故障、消息重复、响应速度慢等原因,对接口的重复调用概率会比单体应用环境下更大,所以说重复消息在分布式环境中很难避免,所以在分布式架构中,要求所有的调用过程必须具备幂等性,即用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。幂等的处理方案有多种,比如幂等表、乐观锁、token令牌,但是在实际过程中并不是每个场景都需要做幂等处理。例如有些场景自身具备幂等性

select * from user_order where order_num=?

无论查询多次其结果不会因为查询次数导致结果有影响,所以select的操作天然具备幂等性,无需处理。

update sys_user set user_state=1 where user_id=?

直接赋值型的update语句操作多次不会影响结果,所以此类update操作也天然具备幂等性。

但是当以下语句多次调用的时候会引起数据不一致,因此需要对幂等处理
insert into user_order(id,order_num,user_id) values(?,?,?)

update user_point set point = score +20 where user_id=?
唯一主键机制:这个机制是利用了数据库的主键唯一约束的特性,解决了在insert场景时幂等问题。但主键的要求不是自增的主键,而是需要业务生成全局唯一的主键,如果有分库分表了那么唯一主键机制就没有效果了。

幂等表:利用数据库唯一索引做防重处理,当第一次插入是没有问题的,第二次在进行插入会因为唯一索引报错,从而达到拦截的目的。

乐观锁:通过version来判断当前请求的数据是否有变动,例如
update user_point set point = point + 20, version = version + 1 where user_id=100 and version=20

Token令牌:为防止重复提交, 为每次请求生成请求唯一键,服务端对每个唯一键进行生命周期管控,规定时间内只允许一次请求,非第一次请求都属于重复提交,后端要给出单独生成token令牌接口,前端要在每次调用时候先获取token令牌。

无论是唯一主键机制还是幂等表都存在唯一键的要求,以电商下单场景为例,看看如何来做幂等处理。例如用户在App下单后,下单请求首先通过Nginx反向代理,转发到聚合层,聚合层再调用原子服务,其中订单号为全局唯一。这种场景订单号谁来生成,如何来保障用户下单的幂等性呢?

  • 假设原子服务生成订单号:如果聚合层第一次调用原子服务超时了,此时原子服务已经生成了订单号为A并写入订单表。因第一次超时,聚合层会再次发送请求调用原子服务,此时原子服务再生成订单号B并写入订单表,导致一次下单生成2份订单数据。
  • 假设聚合层生成订单号:如果订单号是聚合层生成,理论上多次调用原子层都是同一个订单号,具备幂等性,但是如何Nginx重复调用聚合层的话,仍然会导致一次申请多个订单的情况。
  • 假设Nginx生成订单号:如果Nginx生成订单号,理论上多次调用原聚合层都是同一个订单号,具备幂等性,但是如何App端重复调用Nginx的话,任然会导致一次申请多个订单的情况。
  • 假设App生成订单号:最后只能是App针对每一次下单生成一个订单号,并和请求报文一起发送给后端。因为每个App根据规则生成订单号可能会导致订单号重复。

    比较优雅的解决方案是App在下单的时候生成以一串针对该用户唯一的序列(sequenceId)和下单请求一起发送到后端,聚合层首先判断sequenceId是否存在,如存在则直接返回成功,否则生成订单号并把sequenceId写入缓存,然后调用原子服务插入订单数据,如果原子服务写入订单成功则删除缓存中的sequenceId。通过这里例子可以看到,在微服务中解决任何问题不能仅看一小块,需要从全局角度来看待问题。

六、如何保障数据一致性

因事物所具备的四大特性ACID(原子性、一致性、隔离性、持久性),使用事物是保障数据一致性的有效手段。例如用户在平台上下单订购某种业务的时候,需要涉及到订单服务,积分服务,在单体模式下这种业务非常容易实现,通过事务即可完成,伪代码如下:

@Transaction 
public Boolean createOrder(OrderDTO order){
创建订单
增加积分
} 

然而在微服务的情况下,原本通过简单事务处理的却变得非常复杂,订单、积分被拆分为不同的服务部署在独立的服务器上,并且数据存在在不同的数据库中,传统的事物处理模式已经失效,这里又引出了分布式框架下数据的一致性要求。在谈数据一致性要求的时候有2个非常重要的理论即CAP定理和Base理论:

  1. CAP定理:C表示一致性,也就是所有用户看到的数据是一样的,A表示可用性,是指总能找到一个可用的数据副本,P表示分区容错性,能够容忍网络中断等故障。
  2. BASE理论:BA指的是基本业务可用性,支持分区失败,当分布式系统出现故障的时候,允许损失一部分可用性,例如在电商大促的时候,对一些非核心链路的功能进行降级处理来提高系统的可用性,S表示柔性状态,允许系统存在中间状态,这个中间状态不会影响系统整体可用性。比如,数据库读写分离,写库同步到读库(主库同步到从库)会有一个延时,E表示最终一致性,数据最终是一致的,例如主从同步虽然有短暂的数据不一致情况,但是最终数据还是一致的。

分布式系统中最重要的是让系统稳定并满足业务需求,而不是追求高度抽象,绝对的系统特性。针对分布式事物目前开源方案有阿里巴巴开源的无侵入分布式解决方案Seata,它为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案,例如最简单的AT模式,特点就是对业务无入侵式,分二阶段提交,通过简单配置并在接口上增加@GlobalTransactional即可完成分布式事物,但是在性能上有衰减。在实际中可以通过本地事务和发送MQ消息这种柔性事物方式来解决分布式事物所面临的问题,既能保障服务的稳定性又能保障调用效率的高效性,在MQ可以使用Apache的RocketMQ所提供的事物消息和本地事物表结合。其中以下概念需要理解下:

  1. 半事务消息:暂不能投递的消息,发送方已经成功地将消息发送到了消息队列服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,处于该种状态下的消息即半事务消息。
  2. 消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,消息队列服务端通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit 或是 Rollback),该询问过程即消息回查。

整个流程如下:聚合服务收到创建订单请求的时候,会发送一个事务性的MQ消息,注意这里的消息只是发送到消息队列,并没有收到生产者的确认,因此消息处于半事物状态,消息队列收到消息后会回调生产者,这个时候就可以完成本地事物(写订单表,写日志表),如果事物提交成功,则把发送确认消息给MQ。针对下单这种情况,必须要考虑以下几种异常:

1、 App调用下单接口,此时发送MQ消息异常则直接返回下单失败,App需要重新点击下单

2、 MQ回调生产者的时候,生产者开始写入订单数据,此时事物发生异常,则返回UNKNOW状态,不要返回ROLLBACK_MESSAGE,因为App已经收到下单成功的通知了,不允许再出现下单失败的情况;

3、 MQ长时间(默认1分钟,时间可调整)没有收到生产者确认提交消息,会进行消息的回查

相关代码具体如下:

public class TransactionOrderProducer {
    public void init(){
        producer = new TransactionMQProducer(group);
        producer.setTransactionListener(orderTransactionListener);
        this.start();
    }
    //事务消息发送
    public TransactionSendResult send(String data, String topic) throws MQClientException {
        Message message = new Message(topic,data.getBytes());
        return this.producer.sendMessageInTransaction(message, null);
    }
}

当消息队列收到消息后,会回调orderTransactionListener的executeLocalTransaction方法,在这个方法里面createOrder会执行订单入库的操作,同时会在日志表总记录一条数据。

public class OrderTransactionListener implements TransactionListener {
    @Override
public LocalTransactionState executeLocalTransaction(Message message, Object o) {  
        LocalTransactionState state;
        try{
            String order = new String(message.getBody());         
            orderService.createOrder(order,message.getTransactionId());
            state = LocalTransactionState.COMMIT_MESSAGE;
        }catch (Exception e){
            state = LocalTransactionState.UNKNOW;
        }
        return state;
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        LocalTransactionState state;
        String transactionId = messageExt.getTransactionId();
        if (transactionService.check(transactionId)){
            state = LocalTransactionState.COMMIT_MESSAGE;
        }else {
            String body = new String(messageExt.getBody());
            OrderDTO order = JSONObject.parseObject(body, OrderDTO.class);
            try {
                orderService.createOrder(order, messageExt.getTransactionId());
            }catch (Exception e){
                return LocalTransactionState.UNKNOW;
            }
            state = LocalTransactionState.COMMIT_MESSAGE;
        }
        return state;
    }
}

积分服务只需要消费普通MQ的消息即可完成分布式事物,在这里把原先要求一致性的事物写入订单和增加积分转换为先写入订单,积分服务消费MQ来增加积分,达到柔性事物的机制。

以上六种常见问题是在实施微服务中最容易遇到的问题,当然解决办法也是因人而异,但是遇到问题的时候不能仅仅去看一个点,比如幂等问题,如果仅看一个技术点的话,很难优雅的处理幂等问题。总的来说实施微服务不难,因为已经有很多成功案例可以借鉴,遇到问题的时候多去想,从多个角度去考虑,从全局去考虑。

如果觉得我的文章对您有用,请点赞。您的支持将鼓励我继续创作!

1

添加新评论0 条评论

Ctrl+Enter 发表

作者其他文章

相关文章

相关问题

相关资料

X社区推广