探探如何三个月完成微服务改造,以及踩过的“坑”

在探探创建之初,单体架构很好满足了业务的发展与迭代,但随着业务和流量的快速增长,传统的单体架构受到了巨大的挑战,从而需要进行微服务重构以满足开发及公司发展需求。本文主要分享探探微服务架构演进的过程、问题、解决方法,以及微服务架构体系建设、思考及落地。

本文字数:5610字

精读时间:10分钟

也可在5分钟内完成速读

00

前言

大家好!我叫彭亮,主要跟大家分享一下探探微服务架构的演进过程,主要分四个部分:

    第一,我们碰到了哪些问题,为什么需要微服务;

    第二,对微服务的认知;

    第三,微服务实施的过程;

    第四,微服务实施过程中遇到的关于 Go 的坑和解决办法。

01

探探为什么需要微服务

主要是技术带宽的限制,我们产品的需求非常多,但是开发的进度跟不上,并且代码的耦合度太高,职能划分不是很清晰,引用新的框架跟技术也不是很方便;因为都是单体应用,所以部署的时间非常长。我们几十上百台机器上部署一个服务,基本是很久的过程,每个人都在排队部署,出现一个小的Bug对整个的QPS影响非常大。

              

 

后端服务架构

这是前期的架构,大家可以看到绿色的部分是gateway,蓝色的部分是服务。

             

这些是之前一个大的单体应用,它做了很多事情,类似于滑动、聊天、朋友圈,都被包括在一个服务里面,而且这些服务都是共用DB的。我们有很多DB,这些DB每个服务都可以访问,包括BI和数据仓库的同步,这样其实有很多的问题,比如其他的服务写了一个很慢的SQL, 会影响整个的db性能,也可能导致db直接崩溃。

02

什么是微服务

微服务定义

微服务架构很多人都谈过,但到底怎么样拆分?到底一个微服务有多微?这里引用敏捷开发专家Martin Fowler的对微服务的定义,我觉得可以分为三个部分:

一、职责单一,一个微服务只需要做一件事情。

二、服务是自治的,可以独立开发、独立部署,可以有自己的技术栈。

三、最终目的是实现敏捷开发。 

              

 

微服务不是银弹

当然微服务也有很多的问题,比如开发过程中会变得更复杂,以前的单体应用是一个函数一个调用,现在一个请求都是变成一个rpc,链路很长,开发成本也很高,有很多的组件和应用接口,每个组之间都要进行协调过程,沟通成本非常高。测试也更加复杂,环境更加脆弱,依赖不同的基础设施,每个基础设施有不同的特性,如果基础设施出问题的话,对我们来讲是不可用的状态。工作量也大了很多,复杂度也会受影响。特别是服务高可用,如果一个请求经过十个服务,每个服务原本都是4个9的可用性,这个请求可能变成3个9了。

       

03

如何“微”服务

我们是怎么实施微服务?

 

依据组织架构和团队职能

我们实现微服务过程是非常精彩的。去年年初的时候,探探后端大概80%到90%都是新人,来自不同的技术栈,我们仅仅用了3个月的时间,把整个的后台的代码全部推倒重写。

首先,对团队的职能进行划分,每个业务的垂直领域由一个团队负责,虽然里面有多个微服务,其实大家都不需要关心,只要把相关接口暴露出来就可以了。

             

 

业务梳理,边界划分

其次,对业务进行了梳理。上面是API层,主要是外部服务的调用和第三方回调;中间是业务层,主要功能是业务逻辑的开发;下面是基础服务层,主要是基础化的数据;最下面是业务扩展层,类似于推送服务,他们都是通过grpc调用基础服务层对数据访问和修改。一个领域的数据修改会写到kafka集群里去,我们称之为DCL,这类似于一个领域事件。

             

 

服务通讯

服务的通讯选的是gRPC,主要考虑gRPC的Streaming功能。

       

还有DCL,这是我们自己定义的名字,全称是Domain Commit log,基于数据表的变更,将这些变更写入到kafka集群中。它的主要功能是解耦,上游跟下游的服务进行异步解耦,达到最终一致性。一个服务请求,在写多个表的情况下,如果要保证强一致性,那么就要做分布式事务,这样开发就会变得复杂,也可能会导致整个链路延迟变高。我们会使用DCL进行解耦。比如现在有两个DB,一个请求修改两个DB,两个worker消费同一个topic再写入到这两个DB里去,保证幂等,这样就能做到最终一致性。然后是事件溯源,为什么需要这个东西?在DB里存储的的数据和状态都是最终态的,没有办法进行溯源,无法知道数据变更的历史版本信息,我们通过DCL就可以实现溯源的动作。

             

DCL的产生,有两种方式:双写和CDC。双写主要是一致性的问题,如果写DB成功了,写kafka不成功,数据就不一致,如果要做到一致就需要分布式事务,这样分布式事务的延时会增加,复杂度也会增高。但是我们为什么没有选择CDC呢?我们为了降低db复制延迟,基本用的都是物理复制,而不是用逻辑复制的方式,所以就没有办法进行数据捕获。(我们的业务)表变更比较频繁,如果表总是变更,导致捕获程序不断变更的话,处理过程相对比较麻烦。我们毕竟是做互联网不是做金融,对一致性要求没有那么高,权衡之后,还是采用双写的方案。

             

 

链路追踪   

大家可以看到在一个请求过来,会形成一个TraceID和SpanID,调用chat服务的话,将TraceID和SpanID传过去,chat服务会自己生成一个新的SpanID,再一层一层传递下去,包括DCL和Push。如果一个请求过来,就会通过TraceID把整个链路串联起来,TraceID和服务的Span等信息都会传递给日志中心。这样在日志中心就可以通过一个TraceID将请求经过的所有服务的日志都串联起来了。

             

 

进程内上下文

进程内上下文的传递,一般有两种方案。一是接口变更,Context传递,这种方式对代码侵入比较高;需要Context的每个接口都得变。二是goroutine的局部存储,这个方法比较hack,如果你用了这个方法,你的Go 版本升级,可能会遇到一定的麻烦。Go官方也没有提供一个方案做goroutine局部存储,其也建议在函数参数中传递context的方式来达到这种目的。最终我们认为长痛不如短痛,还是采用更改接口的方式。

             

 

进程间上下文

进程间上下文传递,我们在middleware中处理了RPC和DCL的相关逻辑,这个对业务是透明的,业务开发人员感知不到传递了这些信息。

             

 

链路分析

我参考了几个开源的APM组件,最终还是使用采用了jaeger方案,它是Uber开源的链路追踪工具。它的主要原理是把Trace信息打到Agent上去,做一个聚合,收集到数据收集器,然后写入DB。然后会跑一个SparkJobs脚本,分析服务的依赖和流量的来源。

             

通过服务链路,我们可以知道一个请求经过多少服务,请求时间的长短,同时也可以知道它做了哪些具体的操作,这样就可以对请求进行性能优化。

             

服务的依赖,这里可以看到,左边就是服务依赖分析,可以看到这个服务调用了哪些服务以及哪些服务调用了它。

jaeger原生的UI只提供了服务级别的流量情况,我们自己修改了spark以及UI,增加了API层面的依赖和流量分析。

可以看到右边的grpc服务的90%的数据流量是来自于这个Http的服务,其中有47%的流量调用了gRPC的这个接口,这样子你会对一些请求进行优化。同时,我会很清楚的知道这个服务有多少的流量,是来自于哪个服务的哪个接口以及调用了这个服务的哪个接口。

             

 

高可用

APP流量会经过API Gateway,会做一些鉴权等安全性验证,也充当着LB的角色,包括健康检查、限流、熔断。限流是通过Redis实现的一个简单的分布式限流,它会对每一个用户进行限流的动作,再去根据每一个API进行限流动作。健康检查,如果API gateway发现这个服务已经故障的,会把它踢出去。一个请求调到用户服务集群,集群会对每一个服务进行限流,然后会调用User服务,失败的话会重试。请求失败次数会被监控、日志系统捕获到,最后对数据进行聚合动作,聚合结果会产生报警,推送给TSP,打电话或者其他方式通知给服务负责人,如果服务负责人不进行ACK的话,会继续往上一级通知。最后可以通过降级接口对服务降级,这个降级只是提供一个flag,具体的逻辑得由业务实现。

             

对于高可用,大家实现可能都大同小异,我就不讲具体的实现了,就讲我们一个非常核心的业务踩过的一个坑。刚开始我们做的时候比较好,因为很多东西对业务自己是有保障。业务开发的时候没有对参数进行校验,就往DB上传,导致db driver就开始报错,报这个参数不正确,业务开发也没有对错误的信息进行校验,直接把它传给熔断器,所以把整个DB给熔断了。

             

 

AB 测试

微服务前我们做一个测试很方便的,直接拉一个分支做ab修改,然后部署到ab集群,在将流量路由到ab集群。做了微服务之后,包括同步、异步,都得把信息传递下去,我们通过网关层对流量做了染色,将上下文信息一层一层的传下去,这样下游的服务就知道这个请求来自于某个ab了。这样有一个小问题,刚才说了,请求产生的事件会写DCL,因为AB有可能会改DCL结构,会导致我们的服务都跟着改,如果不跟着改,AB改动点就不知道了。这个我们后续会优化。

             

 

CI&CD

CI&CD我们做的还不错,git push会触发gitlab CI,然后会启动pipeline,pipeline对代码进行静态检查,以及单元测试和集成测试,最终会部署到相应的环境中。静态代码检查和测试的结果,会推送到sonar平台,然后我们会知道代码存在着多少的BUG,代码质量如何,测试的覆盖率等等。

             

 

重复性工作

我们在做微服务的时候很多工作都是重复性的,包括一些初始化工作,Client跟server的构造过程,还有测试的编写,包括部署的脚本,它们其实都是一样的。于是我们在做微服务的过程中,做了一个代码生成器,它定义了服务具有哪些特性,http还是rpc,哪些接口,需要哪些东西,我们可以通过工具生成相关的代码。刚开始做微服务的时候这个工具还是挺有用的,服务数量比较多,每个人都做同样的工作,避免这些重复工作。但是到了后面,我们的微服务数量没有那么多,维护这个东西比较麻烦。而且如果改动某一个微服务框架,每改一个地方,代码生成器也得跟着改。

             

       

04

Go 和微服务

到这里整个微服务的重构过程就结束了,重构过程用了不到3个月,而且,参与重构的同学80到90%都是新人且来自于不同技术栈。我们在短短三个月时间能把这个东西重构,这个跟技术人员的技术功底及项目管理较好之外最大的原因还是Go本身的特性——上手比较简单。如果你之前做C++的话,我估计最多两天就可以写代码了。如果今天用的是Java,我觉得重构的过程不可能在3个月内做完,且上线时候也没有出现任何的故障。

 

Context

我主要讲下 Context 和 pprof 的使用经验和遇到的坑。

第一个Context。一个流量请求从A服务到B服务,B 服务到 C 服务。B服务开启一个 goroutine 请求D服务,如果这个时候C服务响应了B服务,B又响应了A,意味着请求已经结束了,B就会把goroutine传递 Context Cancel掉,然后B 服务产生的 goroutine 会传递 RST 帧给服务D,服务 D 会把请求Cancel 掉。这个时候会有三种情况,第一种服务请求已经成功完成,这个时候不希望把它Cancel掉。第二种情况请求超时(C 超时),你其实想把D Cancel掉。还有一种,C 报错,需要把 D Cancel 掉。对于这三种情况,我们之前也进行了简单培训,但是没有引起很多其他技术栈同学的重视。所以做Go的同学有一部分没有踩这个坑,但是其他的同学基本踩了这个坑。

       

为了解决这些问题,我们会做类似于把Context进行派生或繁衍的工作,会把cancel和 deadline 移除掉,这种时候C服务不管成不成功,都会让B服务调成功。还有把deadline传递下去,这种情况类似于C服务如果成功了,不需要特殊处理。

             

 

pprof

这有一个微服务的典型案例,案例中我们是如何通过pprof发现活锁的过程的。当时有一个Push的服务,作用很简单——消费 DCL,调用第三方的Http2.0服务进行消息推送。在这个过程中我们对Push服务并发做了限制,最高的限制是100,(看图可以发现)流量在这个时候并发已经达到最高,但可用的QPS却是0。不知道什么原因,这个问题持续了好几次,第一次没有太多重视,就给第三方服务沟通了下,他们说刚才改了一个东西,然后马上回滚。那我们就认为是第三方厂商的问题了,直到再次出现的时候,我们觉得这个事情不正常了,但是事情已经发生了,没有什么现场数据可参考的。我们每一个服务都有提供一个debug的接口,通过这个接口可以获取 goroutine 的调用栈信息,于是我们就写了一个脚本,每隔两三分钟会拉取堆栈信息,拉完之后我们才发现问题不是我们想象的那样子的。

       

大家可以看一下堆栈信息,(第一个红色箭头)这个地方是一个goroutine获得锁,但是 IO wait 持续了五分钟。另一些 goroutine 也在等待锁,也等了五分钟。从堆栈信息可以看出,这个goroutine已经发出请求了,并且已经超时了,需要要把它reset掉。stream的 reset必须进行加锁的过程,系统会调用write,write 会返回EAGAIN。Go 的 IO 层运用epoll边缘触发的方式,返回EAGAIN就表明不可以写入了,需要等待epoll的通知。这个连接等了很久,持续了五分钟,肯定是出问题了。

       

当时我大概总结了可能是这样的原因:首先,可能是网络不稳定导致丢包。 然后Http2.0 Client发现超时,就会cancel掉超时的请求,cancel需要给 connection 加锁,发送Reset帧。之后会调用系统调用write,write返回EAGAIN,Client开始等待epoll“可写”通知。而且,Go的http客户端的Transport维护了一个连接池,发送请求时候会遍历连接池中连接是否可用,判断是否可用要加锁,而刚好步骤1中的连接一直在等待epoll通知,无法释放锁,导致其他http client一直拿不到锁。之后,http 超时时间并没有应用到 io 层,导致步骤 3 中的连接开始不断重传,直到连接断开。

                     

之后,我们当时修改 RTO 参数来验证上面的分析是否正确。

             

后来,我在网上查了一下,发现Go本身就有这个问题,别人也提过相关的 issue。刚刚分享了两个案例,通过Context踩了哪些坑,epoll解决哪些问题,希望对大家有所帮助。我的分享大概就到这里,大家看到我们微服务的架构跟微服务治理,相对一些大厂处于比较初级的阶段,这也意味着我们还有很多的事情可以做。

最后,我们探探在招Go的工程师,大家如果感兴趣的话可以聊一聊。谢谢大家!

重磅活动预告

欢迎联系 GoCN

国内最具规模和生命力的 

Go 开发者社区

 

讲师

GopherMeetup/GopherChina 演讲

 

投稿

展示个人/团队原创文章

聪明又努力的 Gopher 们,你“在看”我吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值