12

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

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzIwMDY0Nzk2Mw%3D%3D&%3Bmid=2650320899&%3Bidx=1&%3Bsn=4ad5d46e6bb2edc478f538be817a7509
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

转自: Go中国

fU3In2u.jpg!web

本文字数: 5610字

精读时间: 10分钟

也可在5分钟内完成速读

00

前言

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

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

第二,对微服务的认知;

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

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

01

探探为什么需要微服务

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

       IFRj6rA.png!web       

后端服务架构

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

2yAZRfr.jpg!web

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

02

什么是微服务

微服务定义

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

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

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

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

VZZBnia.jpg!web       

微服务不是银弹

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

Nv6RNjY.png!web

03

如何“微”服务

我们是怎么实施微服务?

依据组织架构和团队职能

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

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

ui26FzF.png!web     

业务梳理,边界划分

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

NFJ3uyV.jpg!web     

服务通讯

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

qyYvEfM.png!web

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

iYrUjmB.jpg!web     

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

myEjMbf.png!web     

链路追踪    

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

IvyaYfn.jpg!web     

进程内上下文

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

Ejmqy2v.jpg!web     

进程间上下文

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

7RJb22r.jpg!web     

链路分析

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

  FvQjieV.jpg!web     

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

6nABbmV.jpg!web     

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

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

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

jQVjaqq.jpg!web     

高可用

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

    BnyQjmA.jpg!web     

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

YbumeuR.jpg!web     

AB 测试

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

m6FbArE.jpg!web     

CI&CD

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

QvEBJj7.jpg!web     

重复性工作

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

EbQvUfE.png!web     

ruaayiB.jpg!web

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的同学有一部分没有踩这个坑,但是其他的同学基本踩了这个坑。

Mnyyqey.jpg!web

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

yeEJ7vB.jpg!web

pprof

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

vqyiiaY.jpg!web

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

  uYVB7f7.jpg!web

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

YJvIFj6.jpg!web                 riMFNr6.jpg!web

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

ZjEzqiM.jpg!web

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

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

UjmyInZ.jpg!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK