在滴滴的代码仓库里面有超过 1500 多个模块是含有 Golang 的代码片断的,有1800多位 Gopher 在滴滴提交过 Golang 编写的代码,仅仅是我们的中台服务,就有2000多台机器在跑 Go 的服务。
1.1 我们用Go做了什么
DUSE 是滴滴的分单引擎,负责滴滴司机和乘客的撮合,每秒钟负责万级的撮合需求。
DOS 是滴滴订单系统,负责实时订单状态的流转,同时也负责滴滴历史订单的检索,是一个百亿级别数据的检索服务。
DISE 是我们自主开发的 schemaless 数据存储服务,使用了类似 Bigtable 的实现。
DESE 服务是一个serverless 分布式框架,只需完成业务函数即可完成分布式业务的搭建,类似亚马逊的 Lambda 业务。
1.2 中台业务
大家使用滴滴的时候,会碰到一些业务线。快车、专车、顺风车,在滴滴这些业务线叫做前台服务,他们有一些共同的特性,都有司机信息,订单的状态,收银,账号等等这些业务逻辑,我们会把专门的业务逻辑集合起来,形成专职的服务,这些就是中台服务。中台服务作为前台服务的支撑,重要性是不言而喻的。
1.3 挑战
开发中台服务的时候遇到一些挑战,主要来自三个方面:
1.4 Why Golang
第一是执行效率非常高
第二是优秀的开发效率,Golang的语法比较简洁清楚,可以屏蔽很多技术细节,让业务开发更加顺畅。
第三是Golang活跃的社区和丰富的库,帮我们解决很多问题。
第四是学习成本低,我们刚开始使用Go的时候,发现工程师非常难招,其他语言的工程师通过很快的学习就可以了解Go,熟悉Go,开发Go的程序。
2.1 庞大的业务系统
滴滴业务比较特殊,每一个请求都涉及到司机、乘客、订单的三者的状态信息,我们有很多微服务保证服务的状态。我做过简单的统计,如果把一个快车订单拿出来看,涉及的子服务有50多个,Rpc请求达到了300个,日志行数是1000多条,这样人工进行分析非常困难。
2.2 服务治理的难题
微服务过多带来很多的问题,比如异常定位比较困难;系统链路不清楚哪一块好,哪一块差;做服务优化和服务迁移的也会比较困难。针对这三点,我介绍一下滴滴是怎么做的。
异常定位
随着初期滴滴业务的野蛮增长,很多服务没有遵循开发规范,导致我们滴滴日志混乱,异常定位也非常困难,缺少上下游基本定位的信息。 大量工程师的人力都浪费在异常定位、异常分析上面了,随着我们的业务发展,后期人力投入会越来越大。我们就想能不能做异常的分布式追踪,还有日志规范化的工作。
日志规范化
日志串流
这是我们把脉生成的线上服务追踪的链路,大家可以比较清楚地看到每个请求的耗时,协议。
把脉解决了滴滴异常追踪的问题,但是我们还是不能回答系统的吞吐的瓶颈是什么,系统总的容量是多少,新建机房是否用,以及灾备预案是不是可靠的。
一般来说在业界我们解决这些问题,最好的方式就是跑压测,可惜的是由于滴滴业务比较特殊——我自己发明了一个词叫非函数式的业务——就是说我们相同的输入得到的输出是不同的,输入和输出之间有很多状态信息,司机状态、订单状态,包括今天是不是下雨,是不是高峰期等等这些东西,都会影响业务结果,把这些都做到完全一致非常困难。
这种情况下我们很难通过流量回放的方式来进行压测,同时也由于涉及到状态信息,很难通过线下压测等比放大估计整个系统的容量。
既然线下压不到,就线上压测好了,内部称之为全链路压测。基础的逻辑就是通过对压测流量添加一个额外的标识,比如thrift协议加一个额外的参数,HTTP协议添加额外的Header,就可以把这些流量进行区分开。
我们将流量进行标识以后,流量经过的每个业务模块都需要进行额外的开发工作。当业务模块识别到压测流量的时候,业务模块需要对这个流量进行标记的透传,保证所有的业务模块都可以感知到压测的标识。我们的Cache模块会对这个流量设计一个较短的超时,以保证在压测结束后,缓存资源能够被尽快的释放。
最后就是数据库这些地方,我们会建立一套和线上完全一样的数据结构,一套table,我们叫影子表,影子表只负责处理压测流量。我们管刚刚那套流量标记的逻辑和各个模块的修改叫压测通道。滴滴的压测频率非常频繁,除了对新机房进行压测以外,也会周期性都会对业务压测,以保证在业务快速变更的同时,能够满足系统容量设计的预期。压测范围包括了我们所有主流程模块,以保证我们的服务比较稳定。
2.3 希望什么
滴滴如何迁移业务
说到迁移,我们希望能够做到三点。
第一、业务是无感知的。我们希望中台服务迁移过程当中,前台业务无感知的,他完全不知道我们迁移了,或者说他只是帮我们观察服务,微感知就可以了。
第二、服务迁移稳定,不要在迁移中挂掉了。
第三、迁移后的新老模块的功能没有什么差异。
迁移经验
这块以PHP的典型MVC框架作为例子,这是典型后端服务,理想状态下是拿Go对着PHP代码直接翻译,API和功能,最后完全一致,皆大欢喜。实际上我们做的时候发现并没有那么简单。我们开发的时候,大家都会使用一些动态语言的特性,比如说可能对String和数值类型没有做区分,或者PHP的关联数组和普通数组混合起来操作。这个时候硬性翻译,就需要Golang代码做大量的adapter适配语言差异,这个对Golang业务代码的污染很严重。
滴滴因为有了很多的经验,总结了三步,保证切流过程中服务比较稳定,第一是旁路引流,第二是流量切换,第三是线上观察。
第一步先部署Go Server,通过Proxy引百分之百的旁路流量到Go Server,实际上是对Go Server的压测。而客户端的返回值是以的PHP的返回值为准,等于说Proxy异步调了一下Go Server,但是不会把数据吐给前端。这个时候我们会在Proxy去做Diff看他们的数据是不是一致的,同时会在Go Server和Proxy的底层,去DIff他们底层的存储,看看业务逻辑上是不是一致的,如果出现问题就去进行修复。当整个流程持续一段时间以后,diff的量到一定可控的地步后,进行下一步——小流量切流。
我们将Proxy将Go Server的返回值逐渐的透给Client,这是一个比较慢的过程,1%、2%、10%、20%,切流持续时间可能比较长。这个时候我们要求Client业务端去观察,看看有没有什么异常,这个时候Proxy层还在继续Diff返回值有没有问题,底层也在看是不是存储是一致的。假如说这个过程非常顺利,没有出现问题,逻辑是一致的,就会进入下一步。
跟第一张图比较相似,PHP Server变成了一个旁路流量,而Go Server变成了一个主流量,Client已经完全是Go Server的逻辑了。我们会持续的在线上观察一段时间,可能是以月来计的,通过这个方式验证Go Server是不是可行的,如果观察没有问题,会在合适的时机把PHP Server下掉,否则的话,遇到风吹草动我们就切回去了。
讲完了切流,刚刚又说到了把脉,说到压测,说到流量迁移,每一个中台服务可能都要去接入这些服务治理的组件,接入压测、把脉和服务发现,还有负载均衡等等模块,如果中台服务都按部就班的接,额外的开发工作量非常大。
同时每个服务治理组件接入都不是很容易,导致开发周期长,浪费了很多人力,推广起来很困难。这个时候服务治理的同学就提出了DIRPC的设想,这实际上是一套标准化的SDK组件。上下游交互通过标准SDK形式划分,提供统一的、一站式的服务发现、容错调度、监控采集等,进而降低服务开发、运维成本。
3.1 第一个问题,是Golang的net.Conn接口,double close的问题
下图是我们之前尝试在web服务上建设优雅重启的逻辑,我们希望服务退出的时候能够保证已经建立链接的请求可以处理完,不会因为重启导致错误增高。怎么实现?
我们实现了服务全局的计数器,在链接建立的时候+1,在链接关闭的时候-1。服务退出的时候就检查这个是不是归零了,如果归零就退出,否则就等待归零,看得出来,底层实际上就是WaitGroup。链接操作是托管给底层net.http这个包来做的,我们对本身的net.Conn没有任何直接的操作。
结果当我们把这个逻辑放到线上时,服务Panic掉了,原因是计数器变成了负数。我们的想法就是一个链接只有一次打开,只有一次关闭,所以肯定不会有负数出现。除非,net.http包对链接有多次的关闭操作?结果,发现的确是这样,我们发现Golang底层net.http在处理链接的时候,可能会对链接进行多次的关闭操作,我这里列了两处。这块是不是bug?
要不要提出一个issue?实际上并不是bug,如果你注意到net.Conn接口的注释就会发现,“多个协程可能会同时并发的调用net.Conn接口中的函数”,意味着实现链接操作的时候,一定要保证第一要防并发,第二是防重入,而不能认为Golang底层只会有一次打开和关闭,大家要注意这一点。
我们之前准备上线一个模型服务,这个模型服务维度比较大,各种各样的参数比较多。服务上线以后,线下测试没有什么问题,都是很稳定的。服务丢到线上去发现随着流量增大,超时请求越来越多,但平均耗时并不是很高。如果看99分位的耗时,毛刺非常严重,已经到秒级的请求了。
首先排查了机器的CPU内存,变化并不大,没有太大的浮动,另外排查了网络等。
排除了机器的客观因素后,我们就怀疑是不是代码写出问题了。
我们用Golang的工具分析我们的代码,很快发现,大量的CPU资源被golang的GC三色标记算法的扫描函数占据;同时服务中的in_used的对象数量,也达到了1000W之多。虽然目前GC的STW时间比较短,但是三色算法是并发标记的,可能就会用大量的CPU资源去遍历这些对象,导致了我们这些CPU资源消耗率比较多,间接影响了服务的吞吐和质量。
知道了原因,优化的思路就很清晰了,我们想办法减少一些不必要的对象类型的分配,那么在Golang中什么是对象类型呢?除了比较熟悉的指针,String,map,slice都是对象类型。
通过把string变成定长的数组来避免三色算法遍历,还有一些不必要的slice,全部变成数组等,可以减少对象类型的分配。虽然浪费了一些内存资源,但能够帮助我们减少GC的消耗,优化以后的效果很明显,很快99分位的耗时就降下来了。这个解决方案比较通用,如果大家有发现99平均耗时比较高,毛刺比较严重的话,大家可以看看是不是有这个因素在。
4.1 第一个轮子是滴滴开源的数据库操作辅助工具——gendry
它提供三个工具,分别帮助管理数据库链接,构建SQL语句,以及 完成数据关系映射。
第一个组件是连接池管理类,帮助你管理连接池信息,处理一些基本的操作。
第二个是SQL构建工具,可以帮助你完成SQL的拼接操作。
最后一个scanner是结构映射工具,将你查出来原始数据映射到对象中去。
4.2 第二个轮子是Jsoniter
它是一套Json编解码工具。在兼容原生golang的json编解码库的同时,效率上有6倍左右的提升。我非常推荐这个库,相比easyJson的好处在于它不需要额外生成json处理代码,只需要替换一个引用,就可以完美的帮你达到一个六倍的收益。