转发:滴滴杜欢:大型微服务框架设计实践
桔妹导读:在不久前的 Gopher China 大会中,滴滴高级专家工程师杜欢以《大型微服务框架设计实践》为主题的深度分享。小编在此整理成文,与大家分享。
————
大家好,我是杜欢,很荣幸能代表滴滴来做分享。我来滴滴的第一件事情就是帮助公司统一技术栈,在服务端我们要把以前拿 PHP 和 Java 做的服务统一起来,经过很多思考和选择之后我们决定用 Go 来重构大部分业务服务。现在,滴滴内部已经有非常多的用 Go 实现的服务和大量 Go 开发者。
《⼤型微服务框架设计实践》是一个很大的话题,这个题目其实分为三个方面,“微服务框架”、“大型”和“设计实践”。我们日常看到的各种开源微服务框架,在我看来都不算“大型”,解决的问题比较单纯。大型微服务框架究竟是什么,又应该怎么去一步步落地实践,我会从问题出发,分别从以下几个方面来探讨这个话题。
• 发现问题:服务开发过程中的痛点
• 以史鉴今:从服务框架的演进历程中找到规律
• ⼤道⾄简:⼤型微服务框架的设计要点
• 精雕细琢:框架关键实现细节
▍发现问题:服务开发过程中的痛点
▍复杂业务开发过程中的痛点
我们在进行复杂业务开发的过程中,有以下几个常见的痛点:
• 时间紧、任务多、团队⼤、业务增⻓快,如何还能保证架构稳定可靠?
• 研发⽔平参差不⻬、项⽬压⼒⾃顾不暇,如何保证质量基线不被突破?
• 公司有各种⼯具平台、SDK、最佳实践,如何尽可能的在业务中使⽤?
互联网业务研发的特点是“快”、“糙”、“猛”:开发节奏快、质量较粗糙、增长迅猛。我们能否做到“快”、“猛”而“不糙”呢?这就需要有一些技术架构来守住质量基线,在业务快速堆砌代码的时候也能保持技术架构的健康。
在大型项目中,我们也经常会短时间聚集一批人参与开发,很显然我们没有办法保证这些人的能力和风格是完全拉齐的,我们需要尽可能减少“人”在项目质量中的影响。
公司内有大量优秀的技术平台和工具,业务中肯定是希望尽可能都用上的,但又不想付出太多的使用成本,必定需要有一些技术手段让业务与公司基础设施无缝集成起来。
很自然我们会想到,有没有一种“框架”可以解决这个问题,带着这个问题我们探索了所有的可能性并找到一些答案。
▍以史鉴今:从服务框架的演进历程中找到规律
▍服务框架进化史
服务框架的历史可以追溯到 1995 年,PHP 在那一年诞生。PHP 是一个服务框架,这个语言首先是一个模板,其次才是一种语言,默认情况下所有的 PHP 文件内容都被直接发送到客户端,只有使用了 标签的部分才是代码。在这段时间里,我们也称作 Web 1.0 时代里,浏览器功能还不算强,很多的设计理念来源于 C/S 架构的想法。这时候的服务框架的巅峰是 2002 年推出的 ASP.net,当年真的是非常惊艳,我们可以在 Visual Studio 里面通过拖动界面、双击按钮写代码来完成一个网页的开发,非常具有颠覆性。当然,由于当时技术所限,这样做出来的网页体验并不行,最终没有成为主流。
接着,Web 2.0 时代来临了,大家越来越觉得传统软件中经常使用的 MVC 模式特别适合于服务端开发。Django 发布于 2003 年,这是一款非常经典的 MVC 框架,包含了所有 MVC 框架必有的设计要素。MVC 框架的巅峰当属 Ruby on Rails,它给我们带来了非常多先进的设计理念,例如“约定大于配置”、Active Record、非常好用的工具链等。
2005 年后,各种 MVC 架构的服务框架开始井喷式出现,这里我就不做一一介绍。
▍标志性服务框架
随着互联网业务越来越复杂,前端逻辑越来越重,我们发现业务服务开始慢慢分化:页面渲染的工作回到了前端;Model 层逐步下沉成独立服务,并且催生了 RPC 协议的流行;业务接入层只需要提供 API。于是,MVC 中的 V 和 M 逐步消失,演变成了路由框架和 RPC 框架两种形态,分别满足不同的需求。2007 年,Sinatra 发布了,它是一个非常极致的纯路由框架,大量使用 middleware 设计来扩展框架能力,业务代码可以实现的非常简洁优雅。这个框架相对小众(Github Stars 10k,实际也算很有名了),其设计思想影响了很多后续框架,包括 Express.js、Go martini 等。同年,Thrift 开源,这是 Facebook 内部使用 RPC 框架,现在被广泛用于各种微服务之中。Google 其实更早就在内部使用 Protobuf,不过直到 2008 年才首次开源。
再往后,我们的基础设施开始发生重大变革,微服务概念兴起,虚拟化、docker 开始越来越流行,服务框架与业务越发解耦,甚至可以做到业务几乎无感知。2018 年刚开源的 Istio 就是其中的典型,它专注于解决网络触达问题,包括服务治理、负载均衡、动态扩缩容等。
▍服务框架的演进趋势
通过回顾服务框架的发展史,我们发现服务框架变得越来越像一种新的“操作系统”,越来越多的框架让我们忘记了 Web 开发有多么复杂,让我们能专注于业务本身。就像操作系统一样,我们在业务代码中以为直接操作了内存,但其实并不然,操作系统为我们屏蔽了总线寻址、虚地址空间、缺页中断等一系列细节,这样我们才能将注意力放在怎么使用内存上,而不是这些跟业务无关的细节。
随着框架对底层的抽象越来越高,框架的入门门槛在变低,以前我们需要逐步学习框架的各种概念之后才能开始写业务代码,到现在,很多框架都提供了非常简洁好用的工具链,使用者很快就能专注输出业务代码。不过这也使得使用者更难以懂得框架背后发生的事情,想要做一些更深层次定制和优化时变得相对困难很多,这使得框架的学习曲线越发趋近于“阶跃式”。
随着技术进步,框架也从代码框架变成一种运行环境,框架代码与业务代码也不断解耦。这时候就体现出 Go 的一些优越性了,在容器生态里面,Go 占据着先发优势,同时 Go 的 interface 也非常适合于实现 duck-typing 模式,避免业务代码显式的与框架耦合,同时 Go 的语法相对简单,也比较容易用一些编译器技巧来透明的增强业务代码。
▍⼤道⾄简:⼤型微服务框架的设计要点
▍站在全局视角观察微服务架构
服务框架的演进过程是有历史必然性的。
传统 Web 网站最开始只是在简单的呈现内容和完成一些单纯的业务流程,传统的“三层结构”(网站、中间件、存储)就可以非常好的满足需求。
Web 2.0 时代,随着网络带宽和浏览器技术升级,更多的网站开始使用前端渲染,服务端则更多的退化成 API Gateway,前后端有了明显的分层。同时,由于互联网业务越来越复杂,存储变得越来越多,不同业务模块之间的存储隔离势在必行,这种场景催生了微服务架构,并且让微服务框架、服务发现、全链路跟踪、容器化等技术日渐兴盛,成为现在讨论的热点话题,并且也出现了大量成熟可用的技术方案。
再往后呢?我们在滴滴的实践中发现,当一个公司的组织结构成长为多事业群架构,每个事业群里面又有很多事业部,下面还有各种独立的部门,在这种场景下,微服务之间也需要进行隔离和分层,一个部门往往会需要提供一个 API 或 broker 服务来屏蔽公司内其他服务对这个部门服务的调用,在逻辑上就形成了由多个独立微服务构成的“大型微服务”。
在大型微服务架构中,技术挑战会发生什么变化?
据我所知,国内某一线互联网公司的一个事业群里部署了超过 10,000 个微服务。大家可以思考一下,假如一个项目里面有 10,000 个 class 并且互相会有各种调用关系,要设计好这样的项目并且让它容易扩展和维护是不是很困难?这是一定的。如果我们把一个微服务类比成一个 class,为了能够让这么复杂的体系可以正常运转,我们必须给 class 进行更进一步的分类,形成各种 class 之上的设计模式,比如 MVC。以我们开发软件的经验来看,当开发单个 class 不再成为一件难事的时候,如何架构这些 class 会变成我们设计的焦点。
我们看到前面是框架,更多解决是日常基础的东西,但是对于人与人之间如何高效合作、非常复杂的软件架构如何设计与维护,这些方面并没有解决太好。
大型微服务的挑战恰好就在于此。当我们解决了最基本的微服务框架所面临的挑战之后,如何进一步方便架构师像操作 class 一样来重构微服务架构,这成了大型微服务框架应该解决的问题。这对于互联网公司来说是一个问题,比如我所负责的业务整个代码量几百万行,看起来听多了,但跟传统软件比就没那么吓人。以前 Windows 7 操作系统,整体代码量一亿行,其中最大的单体应用是 IE 有几百万行代码,里面的 class 也有上万个了。对于这样规模的软件要注意什么呢?是各种重构工具,要能一键生成或合并或拆分 class,要让软件的组织形式足够灵活。这里面的解决方法可以借鉴传统软件的开发思路。
▍大型微服务框架的设计目标
结合上面这些分析,我们意识到大型微服务框架实际上是开发人员的“效率产品”,我们不但要让一线研发专注于业务开发,也要让大家几乎无感知的使用公司各种基础设计,还要让架构师能够非常轻易的调整微服务整体架构,方便像重构代码一样重构微服务整体架构,从而提升架构的可维护性。
公司现有架构就是业务软件的操作系统,不管公司现有架构是什么,所有业务架构必须基于公司现有基础进行构建,没有哪个部门会在做业务的时候分精力去做运维系统。现在所有的开源微服务框架都不知道大家底层实际在用什么,只解决一些通用性问题,要想真的落地使用还需要做很多改造以适应公司现有架构,典型的例子就是 dubbo 和阿里内部的 HSF。为什么内部不直接使用 dubbo?因为 HSF 做了很多跟内部系统绑定的事情,这样可以让开发人员用的更爽,但也就跟开源的系统渐行渐远了。
大型微服务框架是微服务框架之上的东西,它是在一个或多个微服务框架之上,进一步解决效率问题的框架。提升效率的核心是让所有业务方真正专注于业务本身,而不是想很多很重复的问题。如果 10,000 个服务花 5,000 人维护,每个人都思考怎么接公司系统和怎么做好稳定性,就算每次开发过程中花 10% 的时间思考这些,也浪费了 5,000 人的 10% 时间,想想都很多,省下来可以做很多业务。
▍Rule of least power
要想设计好大型微服务框,我们必须遵循“Rule of least power”(够用就好)的原则。
这个原则是由 WWW 发明者 Tim Berners-Lee 提出的,它被广泛用于指导各种 W3C 标准制定。Tim BL 说,最好的设计不是解决所有问题,而是恰好解决当下问题。就是因为我们面对的需求实际上是多变的,我们也不确定别人会怎么用,所以我们要尽可能只设计最本质的东西,减少复杂性,这样做反而让框架具有更多可能性。
Rule of least power 其实跟我们通常的设计思想相左,一般在设计框架的时候,架构师会比较倾向于“大而全”,由于我们一般都很难预测框架的使用者会如何使用,于是自然而然的会提供想象中“可能会被用到”的各种功能,导致设计越来越可扩展的同时也越来越复杂。各种软件框架的演进历史告诉我们,“大而全”的框架最终都会被使用者抛弃,而且抛弃它的理由往往都是“太重了”,非常具有讽刺意味。
框架要想设计的“好”,就需要抓住需求的本质,只有真正不变的东西才能进入框架,还没想清楚的部分不要轻易纳入框架,这种思想就是 Rule of least power 的一种应用方式。
▍大型微服务框架的设计要点
结合 Rule of least power 设计思想,我们在这里列举了大型微服务框架的设计要点。
最基本的,我们需要实现各种微服务框架必有的功能,例如服务治理、水平扩容等。需要注意的是,在这里我们并不会再次重复造轮子,而是大量使用公司内外已有的技术积累,框架所做的事情是统一并抽象相关接口,让业务代码与具体实现解耦。
从工具链层面来说,我们让业务无需操心开发调试之外的事情,这也要求与公司各种进行无缝集成,降低使用难度。
从设计风格上来说,我们提供非常有限度的扩展度,仅在必要的地方提供 interceptor 模式的扩展接口,所有框架组件都是以“组合”(composite)而不是“继承”(inherit)方式提供给开发者。框架会提供依赖注入的能力,但这种依赖注入与传统意义上 IoC 有一点区别,我们并不追求框架所有东西都可以 IoC,只在我们觉得必要的地方有限度的开放这种能力,用来方便框架兼容一些开源的框架或者库,而不是让业务代码轻易的改变框架行为。
大型微服务框架最有特色的部分是提供了非常多的“可靠性”设计。我们刻意让 RPC 调用的使用体验跟普通的函数调用保持一致,使用者只用关系返回值,永远不需要思考崩溃处理、重试、服务异常处理等细节。访问基础服务时,开发者可以像访问本地文件一样的访问分布式存储,也是不需要关心任何可用性问题,正常的处理各种返回值即可。在服务拆分和合并过程中,我们的框架可以让拆分变得非常简单,真的就跟类重构类似,只需要将一个普通的 struct methods 进行拆分即可,剩下的所有事情自然而然会由框架做好。
▍精雕细琢:框架关键实现细节
▍业务实践
接下来,我们聊聊这个框架在具体项目中的表现,以及我们在打磨细节的过程中积累的一些经验。
我们落地的场景是一个非常大型的业务系统,2017 年底开始设计并开发。这个业务已经出现了五年,各个巨头已经投入上千名研发持续开发,非常复杂,我们不可能在上线之初就完善所有功能,要这么做起码得几百人做一年,我们等不起。实际落地过程中,我们投入上百人从一个最小系统慢慢迭代出来,最初版本只开发了四个多月。
最开始做技术选型时,我们也在思考应该用什么技术,甚至什么语言。由于滴滴从 2015 年以来已经积累了 1,500+ Go 代码模块、上线了 2,000+ 服务、储备了 1000+ Go 开发者,这使得我们非常自然的就选择 Go 作为最核心的开发语言。
在这个业务中我们实现了非常多的核心能力,基本实现了前面所说大型微服务框架的各种核心功能,并达成预期目标。
同时,也因为滴滴拥有相对完善的基础设施,我们在开发框架的时候也并没有花费太多时间重复造一些业务无关的轮子,这让我们在开发框架的时候也能专注于实现最具有特色的部分,客观上帮助我们快速落地了整体架构思想。
上图只是简单列了一些我们业务中常用的基础设施,其实还有大量基础设施也在公司中被广泛使用,没有提及。
▍整体架构
上图是我们框架的整体架构。绿色部分是业务代码,黄色部分是我们的框架,其他部分是各种基础设施和第三方框架。
可以看到,绿色的业务代码被框架整个包起来,屏蔽了业务代码与底层的所有联系。其实我们的框架只做了一点微小的工作:将业务与所有的 I/O 隔离。未来底层发生任何变化,即使换了下面的服务,我们能够通过黄色的兼容层解决掉,业务一行代码不用,底层 driver 做了任何升级业务也完全不受影响。
结合微服务开发的经验,我们发现微服务开发与传统软件开发唯一的区别就是在于 I/O 的可靠程度不同,以前我们花费了大量的时间在各种不同的业务中处理“稳定性”问题,其实归根结底都是类似的问题,本质上就是 I/O 不够可靠。我们并不是要真的让 I/O 变得跟读取本地文件一样可靠,而是由框架统一所有的 I/O 操作并针对各种不可靠场景进行各种兜底,包括重试、节点摘除、链路超时控制等,让业务得到一个确定的返回值——要么成功,要么就彻底失败,无需再挣扎。
实际业务中,我们使用 I/O 的种类其实很少,也就不过十几种,我们这个框架封装了所有可能用到的 I/O 接口,把它们全部变成 Go interface 提供给业务。
▍实现要点
前面说了很多思路和概念,接下来我来聊聊具体的细节。
我们的框架跟很多框架都不一样,为了实现框架与业务正交,这个框架干脆连最基本的框架特征都没有,MVC、middleware、AOP 等各种耳熟能详的框架要素在这里都不存在,我们只是设计了一个执行环境,业务只需要提供一个入口 type,它实现了所有业务需要对外暴露的公开方法,框架就会自动让业务运转起来。
我们同时使用两种技术来实现这一点。一方面,我们提供了工具链,对于 IDL-based 的服务框架,我们可以直接分析 IDL 和生成的 Go interface 代码的 AST,根据这些信息透明的生成框架代码,在每个接口调用前后插入必要的 stub 方便框架扩展各种能力。另一方面,我们在程序启动的时候,通过反射拿到业务 type 的信息,动态生成业务路由。
做到了这些事情之后业务开发就完全无需关注框架细节了,甚至我们可以做到业务像调试本地程序一样调试微服务。同时,我们用这种方式避免业务思考“版本”这个问题,我们看到,很多服务框架都因为版本分裂造成了很大的维护成本,当我们这个框架成为一个开发环境之后,框架升级就变得完全透明,实际中我们会要求业务始终使用最新的框架代码,从来不会使用 semver 标记版本号或者兼容性,这样让框架的维护成本也大大降低。“更大的权力意味着更大的责任”,我们也为框架写了大量的单元测试用例保证框架质量,并且规定框架无限向前兼容,这种责任让我们非常谨慎的开发上线功能,非常收敛的提供接口,从而保持业务对框架的信任。
大家也许听说过,Go 官方的 database/sql 的 Stmt 很好用但是有可能会出现连接泄漏的问题,当这个问题刚被发现的时候,公司很多业务线都不得不修改了代码,在业务中避免使用 Stmt,而我们的业务代码完全不需要做任何修改,框架用很巧妙的方法直接修复了这个问题。
下图是框架的启动逻辑,可以看到,这个逻辑非常简单:首先创建一个 Server 实例 s,传入必要的配置参数;然后新建一个业务类型实例 handler,这个业务类型只是个简单的 type,并没有任何约束;最后将接口 IDL interface 和 handler 传入 s,启动服务即可。
我们在 handler 和 IDL interface 之间加一个夹层并做了很多事情,这相当于在业务代码的执行开始和结束前后插入了代码,做了参数预处理、日志、崩溃恢复和清理工作。
我们还需要设计一个接口层来隔绝业务和底层之间的联系。接口层本身没什么特别技术含量,只是需要认真思考如何保证底层接口非常非常稳定,并且如何避免穿透接口直接调用底层能力,要做好这一点需要非常多的心力。
这个接口层的收益是比较容易理解的,可以很好的帮助业务减少无谓的代码修改。开源框架就不能保证这一点,说不定什么时候作者心情好了改了一个框架细节,无法向前兼容,那么业务就必须跟着做修改。公司内部框架则一般不太敢改接口,生怕造成不兼容被业务投诉,但有些接口一开始设计的并不好,只好不断打补丁,让框架越来越乱。
要是真能做到接口层设计出来就不再变更,那就太好了。
那我们真的能做到么?是的,我们做到了,其中的诀窍就是始终思考最本质最不变的东西是什么,只抽象这些不变的部分。
上图就是一个经典案例,展示一下我们是怎么设计 Redis 接口的。
左边是 github.com/go-redis/redis 代码(简称 go-redis),这是一个非常著名的 Redis driver;右边是我们的 Redis 接口设计。
Go-redis 非常优秀,设计了一些很不错的机制,比如 Cmder,巧妙的解决了 Pipeline 读取结果的问题,每个接口的返回值都是一个 Cmder 实例。但这种设计并不本质,包括函数的参数与返回值类型都出现多次修改,包括我自己都曾经提过 Pull Request 修正它的一个参数错误问题,这种修改对于业务来说是非常头疼的。
而我们的接口设计相比 go-redis 则更加贴近本质,我阅读了 Redis 官方所有命令的协议设计和相关设计思路文档,Redis 里面最本质不变的东西是什么呢?当然是 Redis 协议本身。Redis 在设计各种命令时非常严谨,做到了极为严格的向前兼容,无论 Redis 从 1.0 到 3.x 如何变化,各个命令字的协议从未发生过不兼容的变化。因此,我严格参照 Redis 命令字协议设计了我们的 Redis 接口,连接口的参数名都尽量与 Redis 官方保持一致,并严格规定各种参数的类型。
我们小心的进行接口封装之后,还有一些其他收获。
还是以 Redis 为例,最开始我们底层的 Redis driver 使用的是公司广泛采用的 github.com/gomodule/redigo,但后来发现不能很好的适配公司自研的 Redis 集群一些功能,所以考虑切换成 go-redis。由于我们有这样一层 Redis 接口封装,这使得切换完全透明。
我们为了能够让业务研发不要关心很多的传输方面细节,我们实现了协议劫持。HTTP 很好劫持,这里不再赘述,我主要说一下如何劫持 thrift。
劫持协议的目的是控制业务参数收到或发送的协议细节,可以方便我们根据传输内容输出必要的日志或打点,还可以自动处理各种输入或输出参数,把必要参数带上,免得业务忘记。
劫持思路非常简单,我们做了一个有限状态机(FSM),在旁路监听协议的 read/write 过程并还原整个数据结构全貌。比如 Thrift Protocol,我们利用 Thrift 内置的责任链设计,自己实现了一个 protocol factory 来包装底层的 protocol,在实际 protocol 之上做了一个 proxy 层拦截所有的 ReadXXX/WriteXXX 方法,就像是在外部的观察者,记录现在 read/write 到哪一个层级、读写了什么结构。当我们发现现在正在 read/write 我们感兴趣的内容,则开始劫持过程:对于 read,如果要“欺骗”应用层提供一些额外的框架数据或者屏蔽框架才关心的数据,我们就会篡改各种 ReadXXX 返回值来让应用层误以为读到了真实数据;对于 write,如果要偷偷注入框架才关心的内容,我们会在调用 WriteXXX 时主动调用底层 protocol 的相关 write 函数来提前写入内容。
协议可以劫持之后,很多东西的处理就很简单了。比如 context,我们只要求业务在各个接口里带上 context,RPC 过程中则无需关心这个细节,框架会自动将 context 通过协议传递到下游。
我们实现了协议劫持之后,要想实现跨服务边界的 context 就变得很简单了。
我们根据 context interface 和设计规范实现了自己的 context 类型,用来做一些序列化与反序列化的事情,当上下游调用发生时,我们会从 context 里提取框架关心的内容并注入到协议里面,在下游再透明解析出来重新放入 context。
使用 context 时候还有个小坑:context.WithDeadline 或者 context.WithTimeout 很容易被不小心忽略返回的 cancel 函数,导致 timer 资源泄露。我们为了避免出现这种情况设计了一个低精度 timer 来尽可能避免创建真正的 time.Time 实例。
我们发现,业务中根本不需要那么高精度的 timer,我们说的各种超时一般精度都只到 ms,于是一个精度达 0.5ms 的 timer 就能满足所有业务需求。同时,在业务中也不是特别需要使用 Context interface 的 Done() 方法,更多的只是判断一下是否已经超时即可。为了避免大量创建 timer 和 channel,也为了避免让业务使用 cancel 函数,我们实现了一个低精度 timer pool。这是一个 timer 的循环数组,将 1s 分割成若干个时间间隔,设置 timer 的时候其实就是在这个数组上找到对应的时刻。默认情况下,done channel 都不需要初始化,直到真正有业务方需要 done channel 的时候才会 make 出来。在框架里我们非常注意的避免使用任何 done channel,从而避免消耗资源且极大的提高了性能。
业务压力大的时候,我们比较容易在代码层面上犯错,不小心就放大单点故障造成雪崩,我们借用前面所有的技术,让调用超时约束从上游传递到下游,如果单点崩溃了,框架会自动摘除故障节点并自动 fail-fast 避免压力进一步上升,从而实现防雪崩。
防雪崩的具体实现原理很简单:上游调用时会设置一个超时时间,这个时间通过跨边界 context 传递到下游,每个下游节点在收到请求时开始记录自己消耗的时间,如果自己耗时已经超出上游规定的超时时间就会主动停止一切 I/O 调用,快速返回错误。
比如上游 A 调用下游 B 前设置 500ms 超时,B 收到请求后就知道只有 500ms 可用,从收到请求那一刻开始计时,每次在调用其他下游服务前,比如访问 B 的下游 C 本身需要 200ms,但当前 B 已经消耗了 400ms,只剩 100ms 了,那么框架会自动将 C 的超时收敛到 100ms,这样 C 就知道给自己的时间不多了,一旦 C 没能在 100ms 内返回就会主动 fail-fast,避免无谓的消耗系统资源,帮助 C 和 B 快速向上游报告错误。
▍业务收益
我们实现的这个框架切实的给业务带来了显著的收益。
我们总共用超过 100 名 Go 语言开发者,在非常大的压力下开发了好几个月便完成一个完整可运营的系统,实现了大量功能,开发效率相当的高。我们后来代码量和服务数量也不断增加,并且由于业务发展我们还支持了国际化,实现了多机房部署,这个过程是比较顺畅的。
我觉得非常自豪的是,我们刚上线一个月就做了全链路压测,框架层稍作修改就搞定了,显著提升了整体系统稳定性和抗压能力,而这个过程对业务是完全透明的,对业务未来的迭代也是完全透明的。我们在线上也没有出现过任何单点故障造成的雪崩,各种监控和关键日志也是自动的透明的做好,服务注册发现、底层 driver 升级、一些框架 bug 修复等对业务都十分透明,业务只用每次升级到最新版就好了,十分省心。
▍版本管理
最后提一个细节:管理框架的各个库版本。
我相信很多开发者都有一种烦恼,就是管理各种分裂的代码版本。一方面由于框架会不断升级,需要不断用 semver 规则升级版本,另一方面业务方又没有动力及时升级到最新版,导致框架各个库的版本事实上出现了分裂。这个事情其实是不应该发生的,就像我们用操作系统,比如大家开发业务需要跑在线上 linux 服务器上,我们会关心 linux kernel 版本么?或者用 Go 开发,我们会总是关心用什么 Go 版本么?一般都不会关心的,这跟开发业务没什么关系。我们关心的是系统提供了哪些跟业务开发相关的接口,只要接口不变且稳定,业务代码就能正常的工作。
这是为什么我们在设计框架的时候会花费很多心力保证接口稳定的原因,我们就是希望框架即操作系统,只有做到这一点,业务才能放心大胆的用框架做业务,真正把业务做到快而不糙。也正因为这一点,我们甚至于不会给框架的各个库打 tag,每次上线都必须全部将框架升级到最新版,彻底的解决了版本分裂的问题。
▍未来方向
未来我们还是有很多工作值得去做,比如完善工具链、接入更多的一些公司基础设施等。
我们不确定是否能够开源,大概率是不会开源,因为这个框架并不重要,它与滴滴各种基础设施绑定,服务于滴滴研发,重要的是设计理念和思路,大家可以用类似方法因地制宜的在自己的公司里实践这种设计思想。
今天这个活动就是一个很好的场所,我希望通过这个机会跟大家分享这样的想法,如果大家有兴趣也欢迎跟我交流,我可以帮助大家在公司里实现类似的设计。
▍Q&A
提问:我也一直在写 Go 服务,你们每一个服务启动是单进程还是多进程,每个进程怎么限制核数?
杜欢:对于 Go 来讲这个问题不是问题,一般都用单进程模式,然后通过 GOMAXPROCS 设置需要占用的核数,默认会占满机器所有的核。
提问:我看到有 70+ 个微服务,微服务之间的接口和依赖关系怎么维护?接口变更或者兼容性怎么解决?
杜欢:微服务业务层的接口变更这个事情无法避免,我们是通过 IDL 进行依赖管理,不是框架层保证,业务需要保证这个 IDL 是向前兼容的。框架能帮我们做什么呢?它可以帮我们做业务代码迁移,根据我们的设计,只要把一个名为 service 的目录进行拆分合并即可,这里面只有一个简单的类型 type Service struct {},以及很多 Service 类型的方法,每个文件都实现了这个类型的一个或多个方法,我们可以方便的整合或者拆分这个目录里面的代码,从而就能更改微服务的接口实现。
你刚刚问题是很业务的问题,怎么管理之间依赖变化,这个没有什么好办法,我们做重构的时候,还是通知上下游,这个确实不是我们真正在框架层能够解决的问题,我们只能让重构的过程变得简单一些。
提问:上下游传输 context 时设置超时时间,每一个接口超时时间是怎么设计的?
杜欢:我们设的超时时间就是通常意义上的这次请求从发起到收到应答的总时间。
提问:超时时间怎么定?各个模块超时时间不一样么?
杜欢:现在做得比较粗糙,还没有做到统一管理所有的超时时间,依然是业务方自己根据预期,在调用下游前自己在代码里面写的,希望未来这个可以做到统一管理。
提问:开发者怎么知道下游经过了怎样的处理流程,能多长时间返回呢?
杜欢:这个东西一般开发者都是知道的,因为所有业务服务接口都会有 SLA,所有服务对上游承诺 SLA 是多少预先会定好。比如一个服务接口承诺 SLA 是 90 分位 50ms,上游就会在这个基础上打一些 buffer,将调用超时设置成 70ms,比 SLA 大一点。实际中我们会结合这个服务接口在压测和线上实际表现来设置超时。我们其实很希望把 SLA 线上化管理,不过现在没有完全做到这一点。
提问:咱们这边有没有出现类似的超时情况?在测试期间或者线上?
杜欢:服务的时间超时情况非常常见,但业务影响很小,框架会自动重试。
提问:一般什么情况下会出现呢?
杜欢:最多的情况是调用外部的服务,比如我们会调用 Google Map 一些接口,他们就相对比较不稳定,调用一次可能会超过 2s 才返回结果,导致这条链路上的所有接口都会超时。
提问:超时的情况可以避免么?
杜欢:不可能完全避免。一个服务接口不可能 100% 承诺自己的处理时间,就算 SLA 是 99 分位小于 50ms,那依然有 1% 可能性会超过这个值。
▍END
杜 欢
滴滴 | R lab 高级专家工程师
先后在微软和百度任职。曾自主创业作为创始⼈和 CTO ,专注于游戏领域创新项⽬研发落地。2015 年⾄今:历任滴滴出⾏平台产品中⼼技术负责⼈、出⾏创新业务技术负责⼈、R lab 配送业务技术负责⼈。
沙龙预告:AI 在出行和云两大领域的探索和应用
李航:分布式存储 Ceph 介绍及原理架构分享
饶全成:深度解密 Go 语言之反射