基于Go构建滴滴核心业务平台的实践

4,433 阅读24分钟
原文链接: mp.weixin.qq.com

作者简介:

石松然,滴滴资深开发工程师,负责中台业务的维护和开发工作。本文主要内容是基于Go构建滴滴核心业务平台的实践经验。

内容大纲:

1、Golang 在滴滴业务的应用发展及规模

2、滴滴使用Go治理模块的经验

3、分享两个具体的Go在应用上的问题

4、推荐两个开源工具

正文

1.Go 在滴滴内部的应用和发展的情况

   在滴滴的代码仓库里面有超过 1500 多个模块是含有 Golang 的代码片断的,有1800多位 Gopher 在滴滴提交过 Golang 编写的代码,仅仅是我们的中台服务,就有2000多台机器在跑 Go 的服务。

1.1 我们用Go做了什么

DUSE 是滴滴的分单引擎,负责滴滴司机和乘客的撮合,每秒钟负责万级的撮合需求。

DOS 是滴滴订单系统,负责实时订单状态的流转,同时也负责滴滴历史订单的检索,是一个百亿级别数据的检索服务。

DISE 是我们自主开发的 schemaless 数据存储服务,使用了类似 Bigtable 的实现。

DESE 服务是一个serverless 分布式框架,只需完成业务函数即可完成分布式业务的搭建,类似亚马逊的 Lambda 业务。

1.2 中台业务



大家使用滴滴的时候,会碰到一些业务线。快车、专车、顺风车,在滴滴这些业务线叫做前台服务,他们有一些共同的特性,都有司机信息,订单的状态,收银,账号等等这些业务逻辑,我们会把专门的业务逻辑集合起来,形成专职的服务,这些就是中台服务。中台服务作为前台服务的支撑,重要性是不言而喻的。

1.3 挑战

  开发中台服务的时候遇到一些挑战,主要来自三个方面:

  1、高可用:中台服务支撑了所有的前台服务,出现问题就会导致前台服务集体挂掉,高可用是非常重要的。

  2、高并发:中台服务是所有流量的承载体,需要非常高的承载能力和很快的响应速度。

  3、业务复杂:中台服务也是一个业务服务,所以业务的复杂程度直接决定了中台系统有多复杂。

1.4 Why Golang

  第一是执行效率非常高

  第二是优秀的开发效率,Golang的语法比较简洁清楚,可以屏蔽很多技术细节,让业务开发更加顺畅。

  第三是Golang活跃的社区和丰富的库,帮我们解决很多问题。

  第四是学习成本低,我们刚开始使用Go的时候,发现工程师非常难招,其他语言的工程师通过很快的学习就可以了解Go,熟悉Go,开发Go的程序。

2.滴滴在治理Go模块的经验

2.1 庞大的业务系统

   滴滴业务比较特殊,每一个请求都涉及到司机、乘客、订单的三者的状态信息,我们有很多微服务保证服务的状态。我做过简单的统计,如果把一个快车订单拿出来看,涉及的子服务有50多个,Rpc请求达到了300个,日志行数是1000多条,这样人工进行分析非常困难。

2.2 服务治理的难题

   微服务过多带来很多的问题,比如异常定位比较困难;系统链路不清楚哪一块好,哪一块差;做服务优化和服务迁移的也会比较困难。针对这三点,我介绍一下滴滴是怎么做的。

异常定位

  随着初期滴滴业务的野蛮增长,很多服务没有遵循开发规范,导致我们滴滴日志混乱,异常定位也非常困难,缺少上下游基本定位的信息。 大量工程师的人力都浪费在异常定位、异常分析上面了,随着我们的业务发展,后期人力投入会越来越大。我们就想能不能做异常的分布式追踪,还有日志规范化的工作。

日志规范化



日志串流



我们参考了OpenTracing的一套逻辑,通过SpanID和TraceID。在日志中全部存留TraceID和SpanID,通过TraceID对所有请求的进行串联,通过SpanID记录每个节点的耗时情况。同时把日志结构规范成对人友好,对机器可解析的一套日志规范,通过DLTAG表达此条日志记录了什么的。仅仅是规范日志还不够,只是比较容易将日志串联起来了,如果依赖人工分析的话,还是非常困难的。这个时候我们做了统一的处理系统,统一对日志的数据进行采集、计算、存储、索引,以及最后的可视化。这套系统怎么做的呢?我们的日志一般来自于服务端以及APP端,我们通过日志采集服务SWAN将这些日志收集过来,发送到消息队列中;SRIUS服务从消息队列中读取日志信息,把日志变成我们结构化的数据,再将结构化的数据存储于ARIUS系统当中,ARIUS系统底层实际上是ES的检索,通过对日志索引的建设,能够帮助我们对日志快速的检索。最后有一个把脉,实际上是建立在ARIUS系统上的一个应用,通过ARIUS查询完成滴滴对业务的链路还原,服务分析和性能的追查。



这是我们把脉生成的线上服务追踪的链路,大家可以比较清楚地看到每个请求的耗时,协议。



把脉解决了滴滴异常追踪的问题,但是我们还是不能回答系统的吞吐的瓶颈是什么,系统总的容量是多少,新建机房是否用,以及灾备预案是不是可靠的。一般来说在业界我们解决这些问题,最好的方式就是跑压测,可惜的是由于滴滴业务比较特殊——我自己发明了一个词叫非函数式的业务——就是说我们相同的输入得到的输出是不同的,输入和输出之间有很多状态信息,司机状态、订单状态,包括今天是不是下雨,是不是高峰期等等这些东西,都会影响业务结果,把这些都做到完全一致非常困难。这种情况下我们很难通过流量回放的方式来进行压测,同时也由于涉及到状态信息,很难通过线下压测等比放大估计整个系统的容量。

   既然线下压不到,就线上压测好了,内部称之为全链路压测。基础的逻辑就是通过对压测流量添加一个额外的标识,比如thrift协议加一个额外的参数,HTTP协议添加额外的Header,就可以把这些流量进行区分开。



   我们将流量进行标识以后,流量经过的每个业务模块都需要进行额外的开发工作。当业务模块识别到压测流量的时候,业务模块需要对这个流量进行标记的透传,保证所有的业务模块都可以感知到压测的标识。我们的Cache模块会对这个流量设计一个较短的超时,以保证在压测结束后,缓存资源能够被尽快的释放。最后就是数据库这些地方,我们会建立一套和线上完全一样的数据结构,一套table,我们叫影子表,影子表只负责处理压测流量。我们管刚刚那套流量标记的逻辑和各个模块的修改叫压测通道。滴滴的压测频率非常频繁,除了对新机房进行压测以外,也会周期性都会对业务压测,以保证在业务快速变更的同时,能够满足系统容量设计的预期。压测范围包括了我们所有主流程模块,以保证我们的服务比较稳定。

  解决了线上压测的问题,那怎么发测呢?刚刚讲到,业务很难通过流量回放来做压力测试。

  滴滴采取的方案是,通过Mock滴滴司机端和乘客端的打车行为,利用事件引擎模拟司乘客打车的操作,以完成司乘订单完整流程的测试。压力控制则通过同时在线的司机数量和乘客数量来模拟。

  滴滴的做法是写了两个事件引擎,一个是Driver Agent模拟司机上线,等待接单,接到订单,完成订单,模拟司机对系统的反馈。我们通过另一个Agent模拟乘客的行为,从上车到完成支付的过程,这样就避免了传统流量回放的压测,因为状态错误,传达不到系统底层的问题。压测流量的压力怎么控制?就是通过同时在线的司机数和乘客数来控制整个系统的压力,这块和我们业务场景是非常相似的,非常直观。

  通过我们这么一个大的线上军事演习,就能知道这个系统层级详细的性能数据,机房流量上限,系统瓶颈分析。故障处理预案也可以进行评估,看看是不是有效的。但同时也有其他的问题,第一就是成本过高,我们所有的模块和平台都要维护压测通道,另外就是风险过高,线上压测如果压挂就很麻烦。实际生产中,可以通过一些基础组件和操作规范,保证风险在可控范围,压测成本则可以通过基础组建的建设来降低。

   通过把脉和压测,我们发现部分服务成为了系统瓶颈,我们尝试优化,或者重构这些服务到Go服务。

  第一是性能问题。我们希望它迁移到性能比较好的平台上去。

  第二是接口准确性。我们是希望接口有一个准确的特性,而一些动态语言,导致一些接口是不定的,简单来说是可靠性不足。

  最后就是异步业务逻辑。一般来说,可能在做一些在线服务的时候,要做异步的逻辑,而进程模型的动态语言很难完成异步逻辑。

  这些模块可能是性能上的问题;可能是逻辑问题,错误率较高;又或者说,旧模块由于补丁太多,的确需要重新梳理了。

2.3 希望什么

  滴滴如何迁移业务 

说到迁移,我们希望能够做到三点。

  第一、业务是无感知的。我们希望中台服务迁移过程当中,前台业务无感知的,他完全不知道我们迁移了,或者说他只是帮我们观察服务,微感知就可以了。

  第二、服务迁移稳定,不要在迁移中挂掉了。

  第三、迁移后的新老模块的功能没有什么差异。

迁移经验



  这块以PHP的典型MVC框架作为例子,这是典型后端服务,理想状态下是拿Go对着PHP代码直接翻译,API和功能,最后完全一致,皆大欢喜。实际上我们做的时候发现并没有那么简单。我们开发的时候,大家都会使用一些动态语言的特性,比如说可能对String和数值类型没有做区分,或者PHP的关联数组和普通数组混合起来操作。这个时候硬性翻译,就需要Golang代码做大量的adapter适配语言差异,这个对Golang业务代码的污染很严重。



 所以,除了翻译Go的代码,还需要额外做一层Proxy,或SDK。我们希望Go server专注于它的业务,关注业务逻辑就好了,不要关注接口细节,如果接口不一致,或者因为一些特别的原因接口类型不一致的话,通过Proxy层,我们对Golang的接口和Proxy的接口的差异进行屏蔽,保证我们的Go server比较纯净,只关注自己的业务就可以。同时Proxy层也能帮助我们导流,比如说切流的时候用Proxy切流,通过Proxy的间隔,就可以保证Client对我们Go server迁移是无感知的,同时这个Proxy也可以把线下流量进行记录,帮助线下Go server进行测试。假如说我们终于把Go的代码开发完了,测试也通过了,大家觉得好像没有什么问题了,准备上线了,这时也是整个过程最危险的时候——要切流了。

滴滴因为有了很多的经验,总结了三步,保证切流过程中服务比较稳定,第一是旁路引流,第二是流量切换,第三是线上观察。



   第一步先部署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.在讨论什么?

   我们讨论RPC、SDK的时候到底在讨论什么,服务治理的同学希望能提供统一的一站式服务治理接入方案,通过一站式服务平台,能够完成对SDK,完成对服务治理的一站式接入,降低服务开发的成本和运维成本,保证服务的稳定性。除了C端容错、服务发现、请求埋点、以及服务规范外,DIRPC中也实现了刚刚介绍的压测通道,把脉等逻辑。

   怎么做的呢?我们在底层基础组件封装了一个基础库,这里面有服务状态、负载均衡等基础组件,然后在上层开发我们自己所属的client。中台服务利用这些Client进行业务SDK的开发,同时需要满足一定的规范,这样SDK就能够开发出来了。这是我们的理想,但是开发当中有很多困难的

第一他们完全符合一定的规范是不可能的,因为组件多,时间花的非常长。

第二如果有些模块已经有SDK了,再迁移到新的SDK,迁移成本过高,会有稳定性风险。这个时候,在刚刚的DiRPC上进一步的做了一套东西,叫DiRPCGen。这是一套CodeGen的工具,通过对thrift的Idl扩充,业务只要写一套IDL,便可以通过Dirpc的这套工具,直接生成SDK组件,非常方便,同时直接将所有的规范全部做到DiRPCGen中。这是比较大的工程,目前来说,这个IDL是可以兼容thrift语法的,在thrift的IDL上面做了一些扩充,以支持我们http协议和额外的滴滴的东西。这样我们每一个中台服务迁移的时候,成本都非常低,而且获得的收益非常大,各个模块就愿意迁移,推广也比较顺利。

4.现在说两个Golang的两个小问题

 4.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底层只会有一次打开和关闭,大家要注意这一点。

   4.2 第二是GC的问题

  我们之前准备上线一个模型服务,这个模型服务维度比较大,各种各样的参数比较多。服务上线以后,线下测试没有什么问题,都是很稳定的。服务丢到线上去发现随着流量增大,超时请求越来越多,但平均耗时并不是很高。如果看99分位的耗时,毛刺非常严重,已经到秒级的请求了。 首先排查了机器的CPU内存,变化并不大,没有太大的浮动,另外排查了网络等。 排除了机器的客观因素后,我们就怀疑是不是代码写出问题了。

我们用Golang的工具分析我们的代码,很快发现,大量的CPU资源被golang的GC三色标记算法的扫描函数占据;同时服务中的in_used的对象数量,也达到了1000W之多。虽然目前GC的STW时间比较短,但是三色算法是并发标记的,可能就会用大量的CPU资源去遍历这些对象,导致了我们这些CPU资源消耗率比较多,间接影响了服务的吞吐和质量。    知道了原因,优化的思路就很清晰了,我们想办法减少一些不必要的对象类型的分配,那么在Golang中什么是对象类型呢?除了比较熟悉的指针,String,map,slice都是对象类型。通过把string变成定长的数组来避免三色算法遍历,还有一些不必要的slice,全部变成数组等,可以减少对象类型的分配。虽然浪费了一些内存资源,但能够帮助我们减少GC的消耗,优化以后的效果很明显,很快99分位的耗时就降下来了。这个解决方案比较通用,如果大家有发现99平均耗时比较高,毛刺比较严重的话,大家可以看看是不是有这个因素在。

 5.  最后推两个开源的轮子给大家

5.1 第一个轮子是滴滴开源的数据库操作辅助工具——gendry

  它提供三个工具,分别帮助管理数据库链接,构建SQL语句,以及 完成数据关系映射。

第一个组件是连接池管理类,帮助你管理连接池信息,处理一些基本的操作。

第二个是SQL构建工具,可以帮助你完成SQL的拼接操作。

最后一个scanner是结构映射工具,将你查出来原始数据映射到对象中去。

 5.2 第二个轮子是Jsoniter

  它是一套Json编解码工具。在兼容原生golang的json编解码库的同时,效率上有6倍左右的提升。我非常推荐这个库,相比easyJson的好处在于它不需要额外生成json处理代码,只需要替换一个引用,就可以完美的帮你达到一个六倍的收益。

   以上就是所有的内容,谢谢大家!

【提问环节】   

提问者1

   提问:说上线的PHP时候是通过旁路,另外一个服务状态加上数据库加上存储是怎么做到不相冲突的?   

  石松然:两个系统的底层存储是隔离的。举个例子,我们今天跑一天旁路,会使用脚本Diff两个系统底层存储的数据,如果发现存储上的差异,就说明有一些逻辑差异导致了存储不一致。两个存储数据是通过脚本定期,或者周期性的方式同步的,一开始是以PHP为准,后期是以Go为准。

   提问:你们开始说全量的压测,一开始说数据源很难构造,后续的怎么做的?

   石松然:因为我们滴滴的每个业务订单信息,包含了司机乘客的状态信息,不是在接口输入范围之内,是系统额外的状态信息。传统压测,一般的方式是根据时间维度聚合线上流量,然后再去回放,以保证压测的成功。如果滴滴采用这种方式的话,流量很可能因为司乘状态不符导致请求直接失败,进不到底层去。我们的做法是通过事件引擎模拟司机和乘客的行为来完成压测,这样子保证不会因为司机乘客订单状态的信息导致压测的失败。

 提问者2

   提问:从PHP Server迁移到Go Server,做的时候,你说要把线上的流量迁过来,要把PHP Server和 Go Server进行diff,你业务里面用户的请求和返回是根据实际的情况不一样的,那这个PHC Server返回和Go Server返回一样吗?

   石松然:确定性的业务是可以完全diff的,不确定的业务是看接口的结果是不是符合预期的,并不是要求结果完全一样的。所以小流量切流的时候还要继续观察,如果能够做到完全一样的话,也就不需要后面的小流量,也不需要上线观察了。

   提问:我看到两个Agent,这两个Agent你们怎么样做到把它模拟地比较像真实的环境?

   石松然:我找同学们聊了一下,他们底层就是两个事件引擎,根据一段时间模拟司机上线,通过系统返回值不同触发不同的函数。收到一个订单的时候就根据事件引擎去选择相应的函数去处理,就是模拟司机、乘客的选择不同的函数操作来实现的。

提问者3

   提问:你刚才提到API的一致性,就是切流的这一部分,中间有一个部分是Proxy,这是怎么实现的?是拿一个请求向上游服务器去发呢,还是翻成代理实现呢?下游请求和上游请求的延迟怎么解决的?这个延迟的有多少,压力会不会是系统里面的瓶颈?

   石松然:Proxy是无状态的服务,所以是可以无限横向扩容的,这个性能问题不是什么大的障碍。您刚刚说的因为双次调动导致的延迟,这个是异步的过程,不会有耗时问题。

   提问:会有超时的问题吗?这种差异导致的异常不一致怎么做的?

   石松然:会有,超时问题也可能是网络问题造成的。这种情况肯定会出现的,我们很难做到接口层百分之百的结果一致,所以后面小流量切流过程当中,还需要人工判断,是因为网络抖动的超时,还是逻辑有问题。需要通过小流量持续观察,来判断是不是网络异常导致的问题。

提问者4

   提问:后台服务大规模更新的时候,有没有出现过短暂的停止服务,还是都是平滑的过渡过来?

   石松然:重启的过程当中,我们运维服务有一些额外的逻辑。中台服务进行重启,不是完全挂掉再启动起来的过程,而是一台机器,一台机器重启。在重试的场景下,可能业务失败后,再进行一次重启就成功了。上线更新过程当中,我们会让我们的Proxy,前端的业务进行一个额外的重试以保证业务成功率。

   提问:所以用户端的表现可能就是卡了一下是吗?

   石松然:大部分是无感知的,因为重试的话,假如说你一百台机器,你一台机器一台机器的上线的话,你很难遇到两次重试都打到一台情况,这样的概率是很小的。

   提问:你完成一单需要300次Rpc的操作,这个是比较耗费性能的,这个过程全部是异步的吗?

   石松然:不是,300次Rpc其实是指整个订单的流程,这不是一个Rpc请求导致的300个Rpc。比如说这个是司机出车是一次RPC,这是乘客的发单是一次RPC,它们都可能涉及到十几个,二十几个关联的请求,这个不是一次性的Rpc请求。


2018年的 Gopher Meetup 将在深圳开启巡回第一站,这一次邀请了很多新的讲师给大家一起交流分享Go的使用经验〜

点击 阅读原文报名参加