经过上一期进行 TiDB 相关的性能优化后,我们达到了约 200K IPS 的关口,但是离我们的目标 500K IPS 仍然有 2.5 倍的差距。根据上期描述的 TiDB 对横向扩展的初步评估发现,为了达到目标我们可能还要再对硬件进行 3 倍扩展。这在 eBay 目前的环境下是非常困难的事情。因为,eBay 的云基础架构正在从基于虚拟机转向 Kubernetes 容器化的过程中。公司更鼓励在容器化环境下做新的开发或扩容,因此任何大规模的虚拟机硬件扩容都需要经过层层审批。另一方面,当前 TiDB 在 Kubernetes 环境下(尤其是跨 Kubernetes 集群的部署和运维),在社区版还没有实现生产化。因此我们遇到了既不能在现有虚拟机环境下扩容,也不敢轻易采用 Kubernetes 的困境。然而,通过对业务场景的研究得知,我们的最高吞吐量需求的查询是一个典型的基于 item ID 查详情的 Key-value(以下简称为 KV)查询。因此,我们引入以 NoSQL 作为 TiDB 的缓存层的方案,这成为了我们提高整体系统性能的“银弹”。我们选用了 eBay 自研的 NuKV 数据库来实现缓存层。NuKV 是一个类似 Memcache 的分布式 KV 数据库。由于其相对简单的架构以及内存存储的特点,它的查询性能非常高,使得我们并不需要太大的 NuKV 集群,便可以实现 500K IPS 的查询需求。⑤怎么衔接 NuKV 的 KV 查询到 TiDB 的 SQL 查询?下面让我们详细解说这些方面的考虑和设计以保障系统的正确性和高性能。一是比较被动的。当请求没有命中缓存时,会去主数据库拉取数据来填充缓存,并通过较短的缓存失效时间来保障主数据库和缓存的数据一致性。这种方案的优点是:缓存通常只会存储少量的“热”数据,因为少量,所以极限并发性能极高;但缺点是:有可能会出现因缓存不命中,在极端情况下会产生缓存击穿,而导致主数据库崩溃的风险。另一种是比较主动的。每当有主数据库数据更新时,我们便更新缓存。因为缓存数据能被实时更新,所以缓存失效时间可以设置得非常长。这种方案的优点是:缓存和主数据库数据高度一致;但缺点是:要存储全量数据,导致大量”冷“数据浪费宝贵内存。另一个潜在问题是:如果出现缓存漏更新的情况(例如丢数据或 IO 异常等),就会产生因缓存失效时间过长而导致的数据不一致问题。因为系统设计的初衷之一是要能捕捉关键商品的数据更新(比如价格变动),方案一即便把缓存失效时间设为只有几分钟,也破坏了我们原来对客户的数据延迟性保证,同时更短的失效时间也会带来更低的缓存命中率,从而降低系统整体性能。所以我们选择了方案二,但我们还需要解决如何保证 TiDB 和 NuKV 之间的数据一致性的问题。1.2.3 主数据库 TiDB 和缓存层 NuKV 的数据一致性保障
我们的主要方案仍然是基于 CDC(Change Data Capture)[1]的同步方案。和 Oracle Golden Gate 类似,TiDB 有一个 TiCDC 的组件,会将对 TiDB 的更新事件发布出来。我们会有一个程序来消费这些更新事件,并同步更新到 NuKV。但是和同步 Oracle 到 TiDB 一样,因为纯流式处理 Exactly Once 实现的开销,我们没有使用。因此在一些异常情况时,还是可能存在丢数据、漏更新的情况。在“亿优百倍|商品数据服务百倍性能优化之路”(点击阅读)提到我们使用每日校正的方案来修补丢失数据。但是这个方案能工作取决于我们能拿到 TiDB 的全量数据(TiDB 支持 Spark 批处理读取)并和真实数据做对比。然而 NuKV(也包括很多主流 NoSQL 方案)并没有提供任何方案让我们拿到全量数据,因此无法做校正。我们就此选择了一个折中的办法:通过把缓存失效时间设置为 14 天(可变参数)上下一个随机值,来强迫所有缓存数据一定会在 14 天左右失效,再通过命中主数据来填充。这样一定程度上保证了主数据库 TiDB 和缓存层 NuKV 数据的最终一致性。根据上文对一些核心模块和思考点的描述,我们的缓存层架构如下图所示。主要分为“读组件”和“写组件”。读组件负责读取缓存,或当缓存不命中时进行主数据库读取和填充;写组件主要通过 TiCDC 来同步更新缓存。
前文说到我们引入了自己的 SQL 方言 MQL 来作为查询的接口。在简单场景下,我们的服务层(图 1 中的 MQL Query Interface)只要下放 SQL 执行给 TiDB 就好了。但是我们现在引入了 NuKV 缓存,SQL 语句需要翻译成 NuKV 的 KV 查询。语法解析和翻译是否会对性能造成影响?另外缓存的更新,数据库和服务之间的网络 IO 会对整体延迟和性能带来多大影响?下文我们将回答这些问题,并分享在服务层发现的一些性能瓶颈及解决方案。为了方便理解我们先来简单介绍一下 MQL。MIS 以 MQL 作为交互语言,MQL 是 MIS 自定义的一个 SQL 方言。目前我们将 MQL 封装为一个独立运行的无状态服务,以对外暴露查询能力。我们主要采用 Apache Calcite[2]作为基础框架,提供了基础的 SQL 方言定制与解析以及关系代数的通用处理与优化框架。这套系统的特色不仅仅在于以 MQL 作为交互语言,在此之上我们还提供了数据业务结构感知的查询优化、对不同数据存储查询的统一抽象和多数据库系统间的联动查询。下图是 MQL 的整体处理流程。
在优化器(Optimizer)中,我们会根据查询的特点(如点查、二级索引查询、分析性查询等)来选择合适的数据库或者数据库的组合。之后会针对查询的结构、数据库的特点以及数据本身业务逻辑提供的潜在能力,来补充优化查询。然后再转交给处理器(Processor)来执行查询。而在一些具体的数据库之上,我们也做了一层简单的适配处理,使得在不丧失数据库本身通用性的前提下,提供一些额外的功能。目前的适配(如上图中的 Adaptor 组件)主要专注于:在 TiDB 与 NuKV 两路提供统一的 UDF 支持以及封装 NuKV 的访问接口,来使得我们能够以统一的接口来访问。上述可以发现,MQL 的整体处理流程足够强大,但也因此变得复杂。而复杂意味着潜在的高 CPU 消耗。在实际开发测试过程中,我们的确发现了不少新的性能问题,我们通过例行的性能测试来对新上线功能做 Benchmark,把性能下降问题的归因尽量缩小。在系统不断迭代的过程中,因为新层的引入(如 MQL),和新功能的开发(如在 MQL 中支持自定义 UDF),让我们系统的整体性能有所下降。我们团队主要在提高 IO 和 CPU 执行效率两方面做了各种优化,来重新达到我们 500K IPS 的目标。对 MQL 的解析、验证和优化是 CPU 消耗中的一大热点。一个 4 核 8GB 内存的机器上,在占用全部的 CPU 资源下,单个服务节点提供的最大吞吐是 500QPS。但是因为 CPU 负载过高,此时请求延迟会高达 200ms 左右。经过进一步的 CPU Profiling,我们发现 CPU 主要消耗在 MQL 的处理上。如下图所示,热点堆栈主要在使用 Calcite 进行语法解析上。
通常这种情况在数据库系统中的应对方法是:在一个 Session 中提供 SQL 预处理的支持。这样可以使得用户能够先显式地创建一个查询模式,让数据库先行将前期的语句(statement)进行解析,基础优化工作完成后,只需要对这个模式传入不同的参数便能以较小的代价重复利用解析优化的结果。但是在 MIS 项目中,我们发现实现预处理并没有那么容易。因为我们对用户提供的接口是基于 HTTP 协议的。如果实现预处理,我们就需要将执行计划(Execution Plan)进行序列化后,通过 Session 在节点间完成共享。而执行计划本身的内部状态(如 SQL 的语法树)是难以序列化的。对此,我们实现了一种通过缓存来减少 SQL 解析的方案。用户的请求通常只会有有限种模式,所以大部分的 MQL 解析工作其实是没必要的重复执行。于是我们在 MIS 中引入了执行计划缓存的机制——将相同结构请求的验证与优化结果缓存起来。我们在解析完 MQL 后得来的抽象语法树 (Abstract Syntax Tree,AST)上做一层轻量处理之后,就能够获得一个查询的结构签名。这个结构签名可以用来作为缓存判定时的唯一标识。
那么我们是如何处理 MQL 的 AST 来获得一个结构签名的呢。这个变换的核心在于参数剥离。简单来说,我们对 MQL 的 AST 做了如下变换:
如上图所示,无论用户输入的 MQL 中 item_id 为多少,都可以将其映射到同一个请求模版上,于是我们就可以将这个结构作为 Key 来查找执行计划缓存。在缓存的执行计划结果中,会包含一个完全对应优化后 SQL 的字节码(Java bytecode),这段字节码是运行时生成并编译的,对外是一个实现了可枚举接口的类。所以后续执行计划的过程就是传入必要参数实例化并迭代。MQL 由这段生成的代码来驱动对 TiDB 或者 NuKv 的访问,同时也驱动必要 UDF 的调用和相同模板变换语句的过滤。然而还有一个问题,AST 本身作为一个内部的树状结构,除了结构复杂以外,每一个节点上都会带许多处理时的临时标注信息。这使得直接对 AST 的相等判断存在了一定的难度。而解决这个问题的方式也很简单,直接将参数剥离后的 AST 重新渲染成 SQL 文本,并以此作为 Key。Calcite 本身提供的渲染 AST 到 SQL 的能力已经比较成熟了。当我们控制好渲染 SQL 的参数后,就能保证对同样的模式都能获得同样的 SQL 字符串。这种方法,在不失精度的前提下,剔除了 AST 上存在的不必要信息。而且,由于字符串的存储和相等判断是非常高效的,这也进一步提高了缓存的查询效率。另一个间接的好处是为调试提供了“便利”——SQL 文本是非常方便人阅读的,通常看一眼缓存的 Dump 或在编辑器中查找一下文本就能定位到对应的缓存记录,然后进一步调试。在 MIS 的场景下,UDF(User Defined Function)目前还是由我们根据客户的需求实现的业务功能扩展。UDF 的重要性在于它让我们能够以简单的接口和交互方式将许多复杂的和领域相关的计算包装供下游使用。在 SQL 语言基础上提供 UDF,是强化 MIS 系统功能的重要入口,同时使用上也比较自然。目前 MIS 中实现的 UDF 有计算税率、计算显示价格、渲染链接等。比如在上文执行计划缓存所给的例子中(如上图 5),我们使用了 rich_eek_info 来获取电子商品的能源标识。而在这个简单的调用背后是我们整理出来的各个数据源的一个复杂计算的整合过程。又如 cal_vat 负责计算欧洲增值税,背后也有很多复杂的业务及制度逻辑。我们可以看到 cal_vat 是带参数的,VAT(Value Add Tax 增值税)的计算需要 site_id 和 user_id 两个参数。这种传参的能力使得用户能在一定程度上定制计算过程。那么不管是想要哪个地区上的 VAT 还是想要一个请求调用多次 cal_vat 来计算多个 user 的 VAT,都可以表达出来。那么回到 UDF 支持的性能问题上,在最初的实现过程中,每一次 UDF 的调用,都会蕴含一次 UDF 的解析,CPU 的消耗在性能测试下急剧提高。通过 Profiling,我们发现,对 UDF Class 扫描和对具体的 UDF 方法处理时的反射操作是 CPU 耗时最为严重的地方之一,如下图 ReflectUtil 所在的位置。
这些反射操作并不是没有用处的,我们需要知道这些方法上的参数与返回值类型才能够在 MQL 处理时做出正确的校验和调用。不过这些反射也并不是每次请求时都需要执行一遍。UDF 本身作为代码的一部分,在发布(Release)之后是不会动态变化的,所以我们将这些操作转为初始化的操作,只在程序启动的时候做一次,后续每一个请求直接使用预处理后的 UDF,那么在请求的时候这部分反射操作就可以省去了。经过测试,调整之后的 MQL 服务性能与加 UDF 之前的几乎没有区别。在具体 UDF 实现过程中,为了满足各种各样的业务需求,我们引入了一些 eBay 内部以及开源的依赖库。然而很多时候,某些依赖库并没有针对高性能的场景进行特殊的优化。这导致这些依赖库某些类的实现成为了 MIS 性能的瓶颈。例如,为了实现欧洲商品税的复杂逻辑,我们引入了一个 eBay 内部算税的库。然而在第二天的例行性能测试中,我们发现最高吞吐量直接腰斩。如下图性能采样的热力图所示,红色区域清楚的表明了性能的瓶颈出现在依赖库的堆栈中(com/ebay/tax…)。顺着有性能问题的堆栈逐层分析,我们发现问题点在于:引入的依赖包使用了一个日志类来记录日志。该日志类在每次记录日志时会去调取当前的堆栈信息,而这一调用是非常消耗 CPU 时间的。而且日志记录又是一个频繁发生的动作,这直接导致了性能瓶颈的出现。
向依赖库的作者提出改进的要求虽然不失为一种解决方法,但是这个过程往往十分漫长,以至于无法满足 MIS 快速的迭代开发需求。而直接在依赖库源码上进行二次开发也不是一个长久兼容易维护的好方法。因此,针对这一问题,我们使用 MAVEN SHADE 插件,通过仅重新实现有性能问题的依赖库类并重新打包替换,来快速地解决依赖库带来的性能瓶颈问题。借助 MAVEN SHADE 插件灵活的配置(如下图所示),这一解决方案有效地平衡了解决问题的效率和后期的维护成本,使得在依赖库作者解决性能问题前 MIS 能够满足对于性能的要求。
在引入了 NuKV 做为缓存层后,为了优化服务和 NuKV 之间的 IO,降低整条链路的数据传输量,我们选择 Protobuf[3]作为序列化的方案。Protobuf 做为业界广泛使用的序列化方案,提供了优秀的空间效率,经济的 CPU 占用以及卓越的稳定性。相比于 JSON(JavaScript Object Notation),Protobuf 使用二进制存储原始数据,并且只使用一个数字做为字段的标识。在空间上面,二进制的存储和数字字段的标识更加紧凑且精确;另一方面,以数字字段标识构造的 Map 结构使得我们可以在极少的空间消耗下最大可能地保证了 Schema 变更前后的兼容性。经过测试,与 JSON 相比,使用 Protobuf 来序列化数据使得我们在 NuKV 上的存储空间占用减少了 66%(未经压缩的情况下)。若用 JSON 来存储一条记录(存了一个 item_id 下的所有 SKU,每个 SKU 有 70 多个字段),则需要约 2,000 字节,而 Protobuf 只需要约 600 字节。使用 NuKV 作为缓存层,极大地减轻了 TiDB 的负载并提高了 MIS 整个系统的承载能力。然而缓存层的存在又引入新的问题:TiDB 作为类 SQL 数据库天然支持数据的批量查询和更改,而 NuKV 作为一种键值对类型数据中间件、查询和更改只能按照主键逐一进行。这种访问模式上的差异引入了一个新的性能问题:如果我们仅仅是简单的将原有的批量查询和更新转化成在 NuKV 上的顺序同步查询和更改操作,那么整个查询和更新过程的延迟就将变成每一次 NuKV 上操作延迟的顺序叠加。而根据我们的观察,NuKV 操作延迟在 P99 及 P999 上的表现较差,有时甚至能到 50ms。这种延时的叠加在批量操作数据的条数增多时将变得非常可观,并会吞噬掉引入缓存层带来的性能改进,使得整体操作延迟在最坏情况下(P95,P99)表现较差。以一个简单的例子(如下图所示)来说明这个问题。假设用户的批量查询中共包含 10 次单条查询,在系统中我们以顺序查询的方式访问 NuKV,当查询过程中出现两次异常延迟时,总的批量查询延迟就会扩大到 140ms。
对于 MIS,批量查询和更新(尤其是批量查询)是下游客户的一大主要需求。在某些情况下,一次批量查询会有大于 20 条数据。而我们观察到的(P95, P99)延迟表现不尽如人意。为了解决这一问题并充分利用 NuKV 高并发的性能特点,我们将对 NuKV 的操作由同步改为异步,并在批量查询与更新时,将多个操作请求并发地提交给 NuKV。如下图所示,通过这一优化,我们成功地将经过缓存层的批量查询由原来同步顺序执行时大于 100ms 的巨大延迟降低到 50ms 的低延迟。
3.2.3 eBay 商品图片 URL 的 IO 优化
由于 MIS 中存储的数据量非常庞大,任何一个可以减少存储空间的优化都是值得尝试的。其中一个例子便是我们对商品图片 URL 存储的优化。在 eBay,我们用一个叫 Zoom 的系统来存储图片,eBay 网站上的大部分图片都存储于该系统中,Zoom 系统提供一个 GUID 来标识每个图片,并有定义清晰的 API 参数来对图片进行缩放、转换等。这样,我们只需要存储该图片在 Zoom 中的 GUID,而非冗长的 URL,具体的图片 URL 可以在之后通过固定的字符串模版动态渲染获得。由于一个商品通常有好几张图片,经过实验评估,将这些 Zoom 系统中的图片 URL 缩减成 GUID 可以为我们节省了约 1/3 的整体存储空间。存储空间的优化意味着更好的 IO 和内存使用率,同时也节省了 MIS 系统内部网络的数据交换带来的带宽压力。经过一系列优化后,一次服务调用主要包含了 UDF 的处理、数据库调用、IO 操作和少量 MQL 的执行。服务层 CPU 分布(如下图所示)大致呈现为:没有明显的 CPU 热点,同时尽可能地保证了低延迟、高吞吐。
最终,我们在 2021 年九月达到了稳定的 QPS/IPS 目标。如下图所示,在 Cache Miss Rate 为 3% 左右的场景下,约有 51K QPS 的请求打到 MQL 服务层,即到下层数据库的访问有 510K IPS 的吞吐量,而 P95 的响应时间控制在 50ms 左右。
图 12 50K QPS 即 510K IPS 吞吐量“亿优百倍”系列文总共 3 篇,分享了 eBay 智能营销部门工作中在营销商品数据服务系统的架构、设计、代码方面的一些理解和研究,到现在就正式收官啦🎉🎉🎉!希望能给大家带来一些启发,也欢迎大家一起探讨。[1]https://en.wikipedia.org/wiki/Change_data_capture[2]https://calcite.apache.org/[3]https://developers.google.com/protocol-buffers
本文版权和/或知识产权归 eBay Inc 所有。如需引述,请和我们联系 DL-eBay-mkt-mis-pub@ebay.com。本文旨在进行学术探讨交流,如您认为某些信息侵犯您的合法权益,请联系我们 DL-eBay-mkt-mis-pub@ebay.com,并在通知中列明国家法律法规要求的必要信息,我们在收到您的通知后将根据国家法律法规尽快采取措施。
最后修改时间:2022-02-10 10:19:57
文章转载自
PingCAP,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。