@levinzhang
2019-12-31T15:15:35.000000Z
字数 8400
阅读 766
优步在客户服务平台中,借助GraphQL构建了数据融合(data hydration)层。本文分析了原有技术方案的痛点,采用GraphQL方案后的技术架构以及收益。
本文最初发表于优步的开发者博客,由InfoQ中文站翻译分享。
每当客户通过支持工单和优步联系时,我们都希望能够快速和无缝地处理他们的问题。
为了让客户支持工单的处理过程尽可能地简化,我们的Customer Obsession工程团队设计和开发了一个帮助解决客户支持工单的新Web应用,该应用会用到从优步技术栈融合而来的聚合数据。我们设计了一个后端库来处理数据融合(data hydration),也就是获取并适配不同的数据片段以形成一个完整的联系对象(contact object)。在只有几十个服务的时候,后端库能够很好地运行,随着更多的业务线加入到平台中来,各种技术痛点随之而来,包括开发者速度的降低、扇出(fan-out)的复杂性以及错误处理。
在考虑了各种融合替代方案之后,我们选择了GraphQL,这是一个用于API的开源查询语言。GraphQL可以解释我们的数据源结构,只提供解决问题所需的信息,从而降低了客户服务平台(Customer Care Platform)的吞吐量。
集成GraphQL能够明显降低工程师添加新特性时要编写的代码数量,简化这些特性的开发,从而使优步的用户从中收益。它还使我们能够在不需要大量培训的情况下从优步的软件工程师那里吸收他们的贡献,从而提高开发人员的生产力。
Customer Obsession系统的代理一般会查看一个仪表盘,上面全是解决客户支持工单所需要的信息。我们将其称为解决方案上下文(solution context),因为它能够给代理成功解决问题所需的数据。为了将与支持代理的每一次交互都实现个性化,平台会根据大量的可用数据来构建解决方案上下文,这些数据可能包括行程的目的地或路线、支付类型,以及客户是否为合作的司机、乘客、食客或餐馆等等。
解决方案上下文可以帮助我们确定问题是否可以以自动化的方式解决,或者是否需要通过一系列应用程序内的屏幕切换获取其他的额外信息。成功的解决方案可能还需要我们的支持代理通过电话进行干预。如下的图1提供了一个自动化解决方案的示例,用户可以通过导航优步移动应用程序来更改他们的支付方式:
图1 通过优步App中的自服务流,乘客可以成功解决支付相关的问题:他们所需要做的就是选择“Support”,点击“Switch payment method”,然后从菜单列表中选择他们的支付方式。随后,客户服务平台会确认问题已经得到了解决。
鉴于优步采用的是面向服务的微服务架构,我们必须要融合(转换上游服务的响应来填充我们的数据对象)数据,这需要使用100多个上游服务的数据来解决客户的问题。我们的全球业务扩展到了餐饮(Uber Eats)、货运(Uber Freight)和电动车(Jump e-bikes)等领域,所以很重要一点就是对客户要有一个统一的视图,以便于查看他与不同业务单元和产品的交互,这样才能够确保用户在我们的平台中获得最优的体验。例如,一个客户既可能是合作的司机,也可能是食客,还可以是商务旅行者,在美国使用UberX,但在印度使用Uber Premium。我们的客户服务平台需要在讨论客户支持问题的时候,在上下文中调用最相关的数据。
随着每天支持的行程超过1400万次,我们最初的数据融合工具开始显示出它的局限性。数据源和贡献者数量的增加进一步扩展了我们的融合服务。我们还必须要考虑后端服务在创建新特性和响应高速增长时,所做的重写都需要重新进行集成。
我们的客户服务平台依赖于JavaScript编写的后端库,名为Contact-Context-Service(CCS)。CCS会从上游服务抓取信息,并基于不同的需求对数据进行融合。每个上游服务都有一个对应的上下文抓取器(context fetcher)或中间件。上下文抓取器对于简单的数据抓取来讲能够运行得非常好,比如获取优惠券和促销信息,但是对于更复杂的数据融合,我们要使用中间件处理对多个抓取器的扇出。例如,为了收集发票的信息,我们必须从支付服务、地理服务和其他服务发送和接收数据。
为了提升数据抓取的效率和性能,我们为CCS配置了Redis和内存缓存。如果CCS发现内存缓存和Redis均不可用的话,上下文抓取器会通过HTTP或TChannel调用一个远程过程调用(remote procedure call,RPC)。响应数据会由中间件和适配器进行处理,并在回调函数中返回给服务模块,如下面的图2所示:
图2 CCS使用抓取器来检索数据,并使用中间件适配来自多个抓取器的数据,随后再将其传输给缓存和微服务
CCS扇出机制使用了async.parallel和callbacks。相对于Promise和Async/Await,这种方式的优势在于它需要更少的内存和调用栈,如下面的图3所示:
图3 Callbacks需要更少的调用栈,因此相对于Async/Await和Promise,这会带来更低的延迟,如上图所示。在所有的竞争者中,Callbacks的延迟最低,最高可以超过50000个并行调用。在某些场景下,延迟的差异是非常明显的,比如在50000并行调用的场景中,callback要比Async/Await Babel快两倍还多。
但是,我们发现扇出机制的深度嵌套回调使得代码的跟踪变得非常困难。随着优步业务的增长,CCS架构中不断增加的分层开始降低开发人员的速度。另外,随着数据模型的不断增长,添加新的数据字段需要对整个数据结构有全面的了解,这样才能确定新的调用应该是串行还是并行。总而言之,旧的设计导致了资源的低效利用。
随着客户服务平台与更多的业务单元和产品集成,解决重要的痛点变得越发重要,比如:
为了改善融合的延迟,我们需要识别哪些端点需要并行化,哪些端点需要串行化。每当我们添加新的数据或重构端点的时候,我们都必须要重复这个过程,这需要过多的工程资源。
我们的客户服务平台由来自Customer Obsession团队和优步其他团队的40多位贡献者。如图2所示,在平台之前的版本中,工程师必须要实现多个分层。每当发布一个新服务的时候,我们都需要新的上游服务代理客户端、中间件、适配器,以及每个分层的测试。对于开发人员来说,这样的上线过程太耗时了。
随着数据模型的增长,我们经历了CCS层过深嵌套字段所导致的故障。在CCS中识别给定请求所需的特定字段变得越来越困难。这使得故障检测变得更加困难,并且当我们不能快速识别和修复根本原因时,会对客户体验产生负面影响。
数据融合要吸收一些基本信息,该信息来源于合作司机、乘客和交付伙伴数据模型中共享的字段。但是,在使用CCS的时候,我们必须从这三个模型中分别检索重复的字段,这会带来不必要的工作。
优步微服务体系结构的本质特点就是,当上游服务在下游服务没有进行测试的情况下改变其响应时,会带来意外之外的结果。使用CCS,我们必须手动分析每个新的上游端点,以确保它与下游兼容。我们还必须不断更新上游服务的所有非向后兼容的更改,以确保恰当地进行错误处理。随着客户服务平台的扩大,保持一致性变得尤其困难。
为了解决这些痛点,我们开始评估更健壮的数据融合策略。我们考虑在优步内部的服务通信层上构建一个图层以获取数据。但是,在研究完各种可选方案(包括GraphQL)之后,我们意识到构建自己的图层是没有必要的。
GraphQL是一个开源的工具,目前已经用到了很多行业中,它为API中可用的数据提供一个完整且可理解的描述。该功能使得API更易于随时间演化,它优化了错误处理,并且支持GraphQL Playground,这是一个用户友好的交互式开发人员工具,借助它我们能够对模式进行可视化并且能够在本地运行测试查询。
GraphQL的主要优势在于它的声明式数据抓取。在我们的使用场景中,客户服务平台基于Web的前端应用会查询GraphQL服务器以获取特定的数据。服务器了解完整的模式,因此能够基于应用的解析器(resolver)去解析查询,并准确交付所请求的数据。
GraphQL只会抓取满足查询所需的数据,不会抓取任何更多的数据,这在最大程度上减少了上游服务或源的压力。GraphQL API确保服务器会返回请求所需的准确的、结构化响应,不带有任何不必要的额外信息。
使用GraphQL能够明显减少在添加新服务和端点时工程师需要编写的代码量。工程师不再需要编写代理客户端、抓取器、中间件和适配器,他们只需要在解析器中编写一遍抓取逻辑就可以了,将旧架构中所有不必要的客户端、抓取器和中间件全部移除掉了。
为了将GraphQL与我们的客户服务平台前端集成,工程师首先需要在GraphQL服务器上定义一组模式。模式中所定义的每个类型的字段还需要一个函数或解析器,以便于调用上游服务的请求并将响应映射为对应的模式。
模式和解析器准备就绪之后,GraphQL服务器会使用它们来解析接收到的所有请求。现在,当调用者查询某个字段的时候,GraphQL调用解析器来抓取对应的数据以便于进行处理。在处理完查询中所有的字段和嵌套字段(例如,图4所示的fareAmount和currencyCode)后,GraphQL服务器为客户端返回一个结构化的响应,类似于JSON文件。
图4 GraphQL服务器根据预定义的模式解释查询,然后调用对应的解析器以抓取请求的数据并返回响应。
将GraphQL集成到客户服务平台中大大改变了以前基于CCS的系统的架构。新的架构如5所示,它能够让开发人员避免为每个新的上游服务不断编写新适配器和各种层。GraphQL不会加载完整的用户数据文件,GraphQL基于查询的声明式数据抓取调用能够快速得到所需的数据,给整个系统所带来的压力是很有限的。
图5 客户服务平台新架构,在基于Web的前端中发起查询会被路由至GraphQL服务器,GraphQL服务器依赖其解析器抓取对应的字段。
客户服务平台的架构包含如下的组件:
我们使用优步的开源、统一Web框架Fusion.js来构建GraphQL服务器和客户服务平台的前端。基于我们的需求,优步Web平台团队开发了fusion-plugin-apollo、fusion-apollo-universal-client和fusion-plugin-apollo-server来托管Web服务器和GraphQL端点。
除了服务器之外,GraphQL实现中第二个最重要的组成部分就是解析器,它们定义了平台如何抓取并渲染来自上游服务的数据。Atreyu是我们内部的一个通信层,能够帮助Web应用与上游服务进行交互,它提供了一个通用的接口,能够同时向多个服务发送请求。对于客户服务平台,我们使用了内部的通信层,借助它发送到面向服务架构API的请求。随后,如果需要的话,解析器会进行简单的转换,并将查询结果返回给前端客户端。
随着优步的不断发展,我们会为已有的产品添加新的业务线和特性。在这种情况下,我们需要添加新的上游服务,它们都需要新的通信层客户端初始代码。为了达成让工程更高效的目标,我们的Customer Obsession团队与Web平台团队协作构建了一个代码生成工具,该工具致力于达成如下三个目标:
运行完代码生成工具之后,工程师要填充与客户服务平台相关的模式和解析器,根据请求的端点结构和想要使用的特定字段指明对应的模式。在我们的新架构中,工程师可以使用自动生成的客户端代码很容易地实现解析器来调用特定的端点,不必与通信层进行交互。
例如,当我们新增一个行程服务时,代码生成工具将会读取上游服务的Thrift文件并生成RPC客户端、flow类型、插件注册、模式骨架以及解析器骨架,如下面的图6所示:
图6 代码生成工具会自动处理新增的服务。它将会读取thrift文件,然后生成五个文件,用于设置新服务与通信层的交互。
为了让客户服务平台更加全面,来自Customer Obsession团队的后端工程师已经开始着手在GraphQL之上构建一个网关。一旦该网关完成,这个特性将会帮助Customer Obsession团队在融合客户工单信息时,能够避免对外部服务不必要的重复调用。
在生成的RPC客户端中,我们实现了优步特有的日志和跟踪功能,以便于跟踪数据并确保准确性。如果在查询的处理中,出现部分错误的话,GraphQL API依然会返回部分成功的响应,我们的RPC客户端会记录从通信层请求直到服务后端的错误细节。响应中所包含的字段,如头信息、调用者、请求参数和错误信息,会帮助我们识别上游的错误。为了理解系统的运维情况,我们使用了Elastic Stack仪表盘,它能够以接近实时的速度收集、处理和展现大量的日志数据。
我们的工程师在客户服务平台的端点和服务级别实现了监控。我们会记录客户服务平台每秒钟的请求数、每个上游端点的错误数、成功/失败率以及第95个百分位(p95)的性能。我们会根据生成的p95数值调整超时配置。我们还使用Elastic Stack仪表盘来确定错误产生的原因。随着将GraphQL集成到客户服务平台中,我们利用这个机会清理掉了遗留代码,并将预期与上游服务保持一致。
图7 监控Atreyu请求的错误,显示了解析器何时失败,从而使我们能够识别GraphQL与上游服务交互的问题。
图8 端点级别的性能监控,包括错误和成功的数量(左侧的聚类图)、成功和错误的比例(中间的折线图)以及p95的请求时间(右侧的波状图)。
我们的监控系统还支持端点级别的告警。通过模板化告警,我们利用优步基于配置的告警生成命令行接口来监视告警并生成仪表盘。除此之外,该系统还利用构建自动化,不仅会校验,而且还会强制所有的告警都正确地进行了配置,从而提升对故障的响应能力。
除了更好的跟踪信息之外,相对于之前版本的解决方案,新的客户服务平台能够更容易地识别并纠正数据不准确的问题。通过改善服务器错误和请求错误,我们提升了错误处理的能力:
我们会在解析器级别处理上游错误。在接收到解析器中的上游错误后,开发人员将决定是简单地将错误抛给客户端、用错误代码和更多信息包装错误,还是返回一个nullable类型而不向客户端返回错误。
当我们的客户服务平台发送格式错误的请求时,GraphQL服务器在调用解析器和模式之前会执行预检查并解析语法错误。
作为强类型语言,GraphQL可以在输入时处理校验错误。在客户服务平台的实现中,GraphQL可以使用校验工具(如validator.js)强制进行查询校验。
使用不同的技术来解决不同类型的错误使系统更加流畅,错误更易于分类和解决。
当解析器扇出至多个上游服务时,有些字段可能无法成功抓取,这样就会出现部分错误。尽管结果不完美,但是遇到这样的错误时,我们依然可以返回有用的信息给原始客户端。GraphQL有一个nullable的概念,这意味着如果服务器端返回一个null值给声明为非nullable的字段,那么GraphQL将向最接近的nullable父字段返回一个null值。例如,Card是一个nullable类型,它有一个非nullable的字段,名为cardNumber。如果在我们的查询中,cardNumber字段得到了一个null值,那么cardNumber不能为null的事实将会导致它的父字段,也就是Card,为null。这个nullable的概念能够避免客户服务平台发布信息时缺少必备的字段,同时还能在部分失败的情况下依然可以使用精确的数据。
在使用非nullable字段时,开发人员必须确定客户端希望得到部分结果,还是希望完全不返回任何结果。nullable字段也许能够使用不完整的数据,而非nullable可能不允许这样做。我们在如下的场景下会使用非nullable字段:
我们区分了非nullable和nullable的概念,这样在利用GraphQL的nullable理念所提供的部分数据时,能够避免信息方面的问题。这一特性和GraphQL的其他特性不仅适用于客户服务平台,也适用于优步的其他业务领域。
我们持续地将利用GraphQL构建的新数据融合层用到客户服务平台前端中。我们还将此功能用到了平台的后端服务中。
因为我们的后台是使用Go语言和thrift/protobuf编写的,所以我们不能直接使用GraphQL。因此,我们使用Go为GraphQL编写了一个协议包装代理。
借助这个代理,我们可以编写一次GraphQL适配器,并在任何地方使用它。数据融合层对前端和后端贡献者都是开放的。我们只需对适配器执行一次维护或修改,就可以避免在多个服务之间重复相同的工作。我们还可以在客户服务平台的所有服务中使用GraphQL服务器的一些特性,比如缓存、日志记录、跟踪、告警和监控。
将GraphQL集成到客户服务平台之后,我们就替换掉了旧的数据融合模型。从开发人员的反馈来看,我们从如下方面都得到了改善:
在通过GraphQL提升开发人员效率之后,我们正在研究在客户服务平台上改善缓存。我们旧的数据结构通过为每个服务定义缓存配置并利用内存和Redis缓存,能够实现60%的缓存命中率。
在客户服务平台中实现查询上下文缓存可以实现更高效的缓存使用。我们计划通过DataLoader添加查询上下文缓存,DataLoader是开源的数据缓存和批处理工具,可作为GraphQL技术栈的一部分使用。这个缓存将显著提高客户服务平台的效率。与GraphQL相结合,DataLoader可以批量处理查询,从而减少对上游服务的调用次数。同样,它的缓存功能减少了对相同数据的调用次数。
客户服务平台的前端团队也在探索与优步其他团队共享模式和解析器文件的可能性。我们计划将客户服务平台的GraphQL解决方案作为服务暴露为web monorepo,这样优步的其他前端服务就可以使用它的模式和解析器了。每个前端服务随后将托管自己的GraphQL服务器。每个服务器都有自己的缓存和身份验证配置。
在客户服务平台中,GraphQL在提高客户支持查询效率方面显示了良好的前景。我们所做的任何改进都将进一步实现快速获取所需信息以有效解决用户问题的目标。