使用Apache Spark构建多年数据回归测试与模拟框架
Vivek Yadav,Stripe的工程经理,分享了他在构建基于多年数据的测试系统方面的经验。他深入探讨了为何选择Apache Spark来创建这样一个系统,以及它如何融入“传统”的工程实践。
核心要点
- 在支付等高监管要求的行业,能够使用多年数据进行系统测试是改变游戏规则的。
- 为了在实践中可行,设计能够在合理时间内运行测试的测试系统至关重要。
- 确保测试数据不包含任何个人身份信息(PII)。
- 为避免影响生产系统,需要创建一个独立的、隔离的系统,并定期从生产环境更新其“黄金”数据集。
- 一个精心设计的系统具有多种用途,不仅限于测试;例如,它还可以用于运行不同参数的模拟。
访谈内容
Olimpiu Pop: 大家好,我是InfoQ编辑Olimpiu Pop,我面前是Vivek,他带来了一个来自Stripe的精彩案例。Vivek,你能先简单介绍一下自己吗?
Vivek Yadav: 是的,大家好。我是Vivek Yadav,是Stripe的工程经理。我在Stripe工作了八年,担任过工程师和工程经理等多种角色。大部分时间里,我专注于Stripe的一个特定领域,即Stripe如何向我们的用户计费。
Olimpiu Pop: 谢谢。Stripe是一家令人敬畏的公司,在全球支付系统中占有重要份额,所以我非常好奇你们实际构建了什么。但在此之前,你能简要介绍一下你是如何进入软件工程、软件或计算机科学领域的吗?因为我们每个人都有不同的故事。
Vivek Yadav: 当然。我的故事可能不太寻常,直到大学选修计算机科学课程之前,我都没接触过计算机。我在乡村长大,没有机会接触电脑或类似技术。我在学校成绩不错,到了该上大学的时候,我问周围的人:“嘿,我应该学什么?”幸运的是,有人建议了计算机科学,从那以后我就一直很喜欢这个领域。
行星级规模的多年数据回归测试 [01:45]
Olimpiu Pop: 好的。我们今天讨论这个的原因是,你以一种非传统的方式使用了Apache Spark,不是人们通常的用法。你们试图解决什么问题?
Vivek Yadav: 是的,完全正确。这是每个大规模系统都会反复遇到的常见场景,即迁移。几年后我们重写一个系统,因为原有系统无法扩展。系统的输入和输出通常保持不变,但其内部需要重写,因为业务逻辑变得越来越复杂,我们需要重构抽象。这样做时,我们需要确保之前的输入和输出基本不受影响。我们的系统之前能处理的事情,迁移后必须能以同样的方式继续处理,不影响用户,但同时能容纳更多内容。
在我们的用例中,我们正在进行一次迁移,需要确保其安全可靠。为了验证这一点,我们使用过去多年的数据测试了新代码。我们处理的是金钱,所以必须格外小心,确保一切正确。假设我们有一个生产规模系统,你提供一个实时服务,设计用于处理特定类型的负载。如果你想用过去三年、五年的数据来测试那个服务,那将花费大量时间。
但我们想出了一个有趣的策略,本质上是:“嘿,我们能否利用Apache Spark进行这种测试?”这具体如何实现呢?现在,如果你以简化形式看待每个服务,你会发现每个服务实际上都在做一些I/O和一些业务逻辑。你收到一个请求,应用一些业务逻辑,然后发回响应。中间可能会进行一些数据库写入,有时你还有一系列这样的事件链,涉及输入、业务处理和输出。然后输出被业务处理的下一个步骤接收,依此类推。
你可以这样看待你的服务:它们基本上是一系列带有输入和输出的业务逻辑操作。以非常简化的形式来看,这与Spark非常相似。在服务中,你的输入/输出发生在收到请求(无论是gRPC请求还是API请求)和发送响应时。而在Spark中,你的输入和输出发生在Hive表、HDFS文件或S3之上,允许你批量读取。你不是一次处理一个请求,而是批量读取、批量写入,并在其间执行业务逻辑。
Olimpiu Pop: 让我在这里打断一下,以确保我理解正确。过度简化地说,你实际上做的是回归测试。这种回归测试确保了Stripe每年可能处理的数十亿笔交易被用作数据来验证一切是否正常运行。然后,再过度简化一下,你有一个框架,允许你在具有输入和输出的地方测试这些事务,本质上是为了确保处理过程的一致性。
Vivek Yadav: 是的,完全正确。这样做需要在编写服务时遵循一定的规范。如果你能把服务有效地编写成,可以将其核心逻辑视为一个库。该库在启动时接受一些配置,然后处理请求。它暴露一些方法,允许你创建一个API,你调用处理过程并收到响应。现在,同一个库也可以用Spark包装器包装。如果我们有一个服务,我们可以在多个环境中部署它。我们可以有一个生产环境,在那里修改输入和输出。我们有一个队列环境;我们改变输入/输出、数据库,等等。你还有本地测试环境,在那里你可以覆盖一些冲突并修改输入和输出。
类似地,Spark只是另一个可以执行相同代码的环境,不同之处在于你的输入和输出发生在Hive表或S3文件之上,而你的配置本质上也是一次时间设置。关键点是将代码组织成一个库,然后在周围添加I/O层。现在,一种类型的I/O使其成为一个服务,另一种类型的I/O使其成为一个Spark作业。
一旦设置就绪,你就可以在几小时内处理多年的数据,因为使用Spark,你可以大规模并行运行。然而,如果你处理的是数据库或类似的东西,那么速度就太慢了,无法如此快速地扩展。
Olimpiu Pop: 好的,大致上,我听到你说的是,数据被分割到不同的桶(buckets)中,作为不同类型数据的容器,然后利用Spark的能力,你可以并行化所有操作,并在时间上处理得相当不错。这样,原本需要很长时间的任务,你可以在半天或一天内完成。
Apache Spark批量处理,而非一次一操作 [06:52]
Vivek Yadav: 是的,没错。Spark的关键之处在于,它不是一次读取和写入一个请求,而是从S3批量读取并批量写回S3。因此,这里的另一个关键组成部分是:这种测试场景并不适用于所有应用程序;然而,如果你的数据已经存储在S3中,它尤其适合。在许多生产场景中,实时服务会持续运行,但你也会将数据副本存储在存储中用于各种分析目的。
在Stripe的情况下,并非所有数据都在S3中;然而,在许多场景中,我们需要进行额外的分析,因此有大量数据存储在S3中。在我们的案例中,我们已经将请求和响应的副本存储在S3中,所以在上面运行Spark是一个非常自然的选择。我们已经有其他Spark作业为了不同目的在该数据上进行处理。
Olimpiu Pop: 好的,这原本是我的下一个问题,但你已经说出来了。过度简化一下,它就像一个断言语句。你拥有请求和响应,所以你实际上是在使用相同的请求推送过去,然后进行比较。这个系统的输出会是什么?因为多年的数据意味着可能数十亿笔交易或其他什么。通常运行一组测试时,你会得到:这些场景不是你所期望的。但如果运行数十亿次,你将需要滚动很长的列表来检查一切,那么输出是什么?
Vivek Yadav: 当然。这里有很多用例,但我们的第一个用例只是迁移和该迁移的回测。在回测的情况下,期望是我们已经有了之前的生产请求响应,我们现在用新代码回放过去的请求,获得新的响应。然后,我们只需要比较新的响应数据集和旧的响应数据集,确保它们相同。基本上,你可以在上面编写一个自定义的差异作业或通用作业,它会比较两者并给出:“嘿,差异在哪里,哪些行不同?”然后你可以分析这些行,修复你的代码,重复这个过程。
Olimpiu Pop: 从生命周期来看,经典的部署生命周期是怎样的,它在哪个环节发生?就像你之前提到的,是在预演环境中,还是说因为你处在一个监管严格的领域,我预计你不能简单地去做持续部署、发布等等,所以我猜这会是一个非常沙箱化的空间。
Vivek Yadav: 绝对是,是的。在Stripe的环境或任何其他受监管的环境中,所有运行的代码都在高度受控的环境中执行。这些代码没有运行在我的本地机器或任何类似的东西上。Stripe提供了(其他类似性质的公司也提供)一个默认符合所有法规的开发环境,允许你在其中运行代码。
顺便说一下,这完全不影响生产,即使你使用了某种生产数据。当我在这里说生产数据时,我想澄清的是,这些数据也没有任何可识别客户的信息。所有相关的工作在数据进入冷存储等之前就已经完成了,Stripe有出色的安全性等等。在数据到达任何人可以真正检查的地步之前,每一项合规工作都已经完成。这里没有可识别的信息。它甚至不是交易数据,而是比交易更深一层的网络成本数据。
Olimpiu Pop: 为了确保清晰,没有涉及PII,因为数据是匿名的,所以没有问题。此外,考虑到你构建的系统的重要性,所有工作都在一个独立的环境中完成,以确保不影响生产。一切都是隔离的,所以一切都位于安全的位置。
Vivek Yadav: 是的,是的。
Olimpiu Pop: 好的。试着可视化整个架构,你有存储桶,S3(如果我没理解错的话),所以AWS在那一边被使用。然后你在网络层面有数据进出,接着使用Apache Spark作为这两个点之间的粘合剂,然后你分析在回归测试方面是否有不符合预期的情况,你只是观察那个,然后深入分析。
Vivek Yadav: 是的。
Olimpiu Pop: 这是日常工程师做的,还是你们有一个专门的工作组,包括QA或测试工程师?谁是这个框架的用户?
Vivek Yadav: 这个框架的用户是提供不同服务的工程师。工程师负责测试自己的代码。特别是在这个场景中,我们并不是依赖QA或测试工程师。工程师对他们的工作负有端到端的责任,所以如果他们创建了某个东西,比如说,我作为工程师负责迁移它,那么我基本上就使用这个框架来确保我的迁移是正确的,并调试我编写的任何代码。
这个框架还支持许多其他场景。我们为迁移创建了这个基于Spark的测试解决方案,但后来我们意识到“嘿,这也可以用于其他一些场景”。
另一个常见场景是(再次说明,这并非广泛部署,并非所有服务都适用),在我们的案例中,每当我对我的服务进行代码更改时(这不是迁移,而是一个小的代码更改),我们已经有一个“黄金”数据集,比如说几百万个请求和几百万个响应。当我提交代码时,我基本上可以标记:“嘿,你能确保这个代码是安全的吗?”这将在GitHub上触发一个Spark作业,该作业读取请求、这些预期的响应以及新的响应,然后确保一切正确,将差异结果附加回PR,以确保即使是小的代码更改也是准确的,这一切在几分钟内完成。
多层次的质量保证 [13:25]
Olimpiu Pop: 这改变了局面,因为通常,如果你谈论的是最后的验证步骤,那么通宵运行或采用不同的方式是没问题的。但如果你讨论的是作为开发人员的反馈循环,那么你就需要考虑另一个角度。这就是我之前关于耗时多少的问题,因为通常工程师抱怨耗时太长,然后他们就会分心。但如果只需要几分钟,那还是可以的,如果你讨论的是黄金数据集的话。这个数据集是否始终保持不变,还是会不断更新?
Vivek Yadav: 我们从相同的数据集开始,随着时间的推移,数据的形态会变化等等,我们意识到“嘿,我们需要不时刷新那个黄金数据集”,所以现在我们有一个脚本,它会基于最新数据和后续更新重新生成黄金数据集。
Olimpiu Pop: 好的,很好。感谢分享。现在,正如我们提到的,开发人员更接近内部反馈循环。你如何看待开发人员?我现在要谈及你的工程度量技能,因为到目前为止我们讨论的都是工程方面。你的度量标准是什么?代码库是如何改进的?你看到了更少的缺陷吗?你看到了更快乐的开发人员吗?这是在当前背景下应该考虑的两个关键点。
Vivek Yadav: 是的,这里有两个关键点。从开发人员的角度来看,一个关键方面是拥有信心,即代码审查者在评估代码质量,他们可以比以前更少地关注错误。所以,如果你在审查代码,并且已经通过了一堆测试,而且该代码已经针对数百万笔交易运行过,并且“嘿,这运行良好”。这让你对代码的正确性有一定的信心,然后你就可以继续进行基于质量的审查。所以,一部分是对系统更有信心。
第二件事,也是我们实际将结果附加到代码审查的原因,是我们希望在生产之前捕捉回归错误。单元测试和集成测试能做的只有那么多。所以我们只是希望多一层安全性,在代码进入生产系统并上线之前捕捉问题。问题仍然会发生,但这在减少因代码更改而产生的问题数量方面是一个巨大的进步。
Olimpiu Pop: 看看从那个角度来看是什么样的,因为你有不同的测试方式,有测试金字塔。然后有人说反向测试金字塔,还有一些人说测试菱形以及其他各种几何形状和图形,我不知道,还有宝石等等。但你提到了另外两种。
一种是单元测试,顾名思义,这是针对非常小的代码片段,它允许你展示代码在你作为开发人员的隔离环境中是如何运行的。但是你还有很多其他东西,比如集成测试,然后是回归测试等等。那么情况如何?你有单元测试,你的目标是什么?好的,也许是20%、15%的单元测试覆盖率,其他则更多是基于直觉,或者你只是专注于那个?
Vivek Yadav: 我想说这真的因用例而异,第一部分,它也因你所工作的系统的成熟度水平而异。我给你另一个例子。我现在正在做一个绿地项目。项目还没有交到用户手中。我们正在快速迭代等等,所以现在我们并不真正关心我们的单元测试覆盖率是多少。我们的关键点是:“嘿,让我们准备一个足够坚实的基础,真正服务于一些核心目的”,就在我们面向用户之前,我们才开始进行单元测试。
现在,这也取决于系统是否会处理资金流动,或者系统只是一个内部工具或其他东西?如果某物要处理资金流动,再多的测试也不为过。你基本上会全力以赴,尝试拥有最高的测试覆盖率等等。但如果说它是一个不那么严肃的系统,那么你必须小心分配多少资源给测试。我没有一个现成的答案。它真的根据你面临的场景而变化。
Olimpiu Pop: 好的,所以你是根据现有情况和结果等进行调整。
Vivek Yadav: 当然。任何涉及用户和资金流动的事情,肯定会得到最高级别的测试。
复用现有数据和工具 [17:59]
Olimpiu Pop: 通常这些测试会在某个时候出现在管理层的仪表板上。数字是如何改进的?你说这是一个巨大的规模,但你应该期待什么?你也说过这不是你在每种系统中都使用的东西,你应该在什么系统中使用它?
Vivek Yadav: 是的,我认为这是一个基于Spark的测试JVM服务的方案。所以这适用于你的代码已经编写成可以在JVM系统中运行的场景。所以如果它是Scala代码或Java代码,那将在JVM系统中工作。这是一部分。第二部分是,这个方案能够运行得很好。我会说从开发成本来看很“便宜”,如果你已经将数据复制到S3作为正常ETL过程的一部分,很多Stripe类型的公司都有那些ETL过程,很多数据已经在S3系统中可用,所以你已经有数据可以在上面进行读写。最后,我会说如果你的作业没有进行太多的状态查询,我的意思是,有些服务在“给我这个客户信息,给我那个用户信息,有一些条件”等方面非常复杂,然后做一些事情。这不适用于那种情况。
但有些服务非常,可以这么说,是自包含的。在我们的案例中,我们有两个服务——我可以举个例子——是交易发生时,这个交易的网络成本会是多少?网络成本本质上是Visa或MasterCard或任何其他网络对该交易收取的费用。我们不需要调用任何人。所有这些都是基于一组配置。这些配置相当庞大。有几千条规则来决定这些成本,但它们非常自包含。你不需要调用,你只需加载它们一次,它就会运行,你周围有一些围绕它们编写的逻辑。类似地,这是一个交易,Stripe会为此交易向用户收取多少费用?这也是一个非常相似的计算。同样,它不需要太多的外部查询。所以,如果你的服务更自包含,并且不过多依赖于其他服务,这非常非常适合。
Olimpiu Pop: 好的。让我总结几点。做一个与编程语言的类比,我们通常说命令式编程语言有很多状态,对吧?你有很多东西要管理。如今有了函数式,你只是在讨论输入和输出,基本上你根据不同的数据集,有类似的事情。你对输入应用函数,得到一个输出,大致上这就是基于Spark的系统的工作方式,但并非总是如此。
现在你在讨论中又增加了两点。它是为你的网络成本优化的,网络成本将是提供商以及你无法控制的东西,在这种情况下是MasterCard或Visa或任何其他提供商,或者是创建银行和用户之间交互的实体。然后那是有大量的配置允许你这样做,并且配置因情况而异,这就是你想改变、适应的地方。所以这是一个完全不同的视角,因为你可能必须匹配输入和输出,但也匹配配置。所以这有点更指数级,而不是线性的。
使用真实世界数据,你也可以将测试用于模拟 [21:28]
Vivek Yadav: 在我们的案例中,配置是这样的。Visa、MasterCard对某个事物收取什么费用。我们并不是实时调用它们。它们基本上是发布它们的规则,比如我们将如何向你收费,你可以将这些规则编码到你的系统中。这些配置,我们有运营团队将来自Visa和MasterCard的配置翻译成我们系统可以执行的内部规则。本质上有几千行规则,而不是其他东西,所以它们非常自包含,一旦翻译完成,就可以加载到一个会话的内存中。这并没有真正增加系统的复杂性。
但这实际上让我想到了同一个东西的另一个有趣用例,即这些配置会不时更改。所以最终,我们尝试找出这个基于Spark的测试的一个用例是回归测试,但同一个东西的另一个用例是“如果……会怎样”的测试,基本上意味着你在这个世界中用这些规则来模拟。如果规则从这组规则变成那组规则,那么输出会有什么变化?举个例子,假设你的电话公司每月收费20美元,这对你的年度预算有一些影响。如果你的电话公司开始每月收费40美元,对你的预算会有什么影响?这就是“如果……会怎样”的测试,配置更改会对输出产生什么影响?
Olimpiu Pop: 你提到系统带来的附加价值的一个方面是回归测试,它使用你已有的数据,没有任何麻烦。我们目前提到的另一件事更像是异常情况,你只是在思考的事情,或者一个更好的术语可能是混沌工程,你只是抛出不同的东西,看看在那种情况下情况如何,然后这更多地进入Spark实际用于分析的领域,所以你只是在做预测。所以这不仅用于工程,业务方也可以提出“如果……会怎样”的问题并提供更好的答案。好的,这很有趣。
Vivek Yadav: 是的,让它更具体一点,举个例子,我们有一个场景,网络规则将从10月开始更改,当前规则是这样,新规则是那样,然后我们需要向我们的财务团队或用户提供预测,你的底层成本将如何变化?我们可以对新规则和旧规则在相同数据上运行,找出差异,然后基于此,我们可以对功能将如何表现给出相当准确的估计,这是我们允许用户拥有的功能之一。
Olimpiu Pop: 好的。从业务影响的角度来看公司,这非常有用,因为它将为你提供对不同事物的更深入的看法。你提到了S3,这种实现非常依赖于数据在S3中。如果我们有,你提到了“如果……会怎样”的测试,所以让我们在这个领域做一个“如果……会怎样”的测试,如果我们有不同类型的数据容器、数据库等等,你也提到了Hive。作为开发人员,我应该如何处理?我想使用别的东西。
Vivek Yadav: 是的,我想说只要Spark能以高效的方式从那个数据集读取,它就能工作。当我说高效的方式时,基本上Spark擅长从磁盘并行读取,对吧?所以如果你让Spark从JDBC连接或数据库读取,它会读,但不会高效。所以,只要你能利用Spark的效率,它就能运行。底层数据集可以是任何HDFS解决方案。
设计测试系统时,请考虑I/O操作效率 [25:11]
Olimpiu Pop: 好的。我听到你说的是,只要你有高效的读取方式,可能通过某种适配器或其他东西,Spark就有,它就会高效。仅以你的例子来说,JDBC,可能会受到它可以拥有的连接数量的限制,以及与数据库相关的所有其他因素,所以,那将成为一个瓶颈。在你的案例中,如果数据集的数目有限,那可能行得通。它可能会工作,但那违背了目的,因为你通常会采用它,对于那种问题来说,它太重量级了。谢谢。关于你的系统,还有什么我应该问但没问到的吗?
Vivek Yadav: 我认为这里另一个有趣的情况是,运行这个非常便宜。与任何同等规模的测试相比,如果你需要一天内回测三年的数据,用Spark或类似系统之外的任何东西来做都会非常昂贵。
Olimpiu Pop: 好的。但为了更具体,首先,替代方案是什么?因为我猜当你做这个决定时,你并不仅仅说“好吧,我们已经有了,就这么做吧”。有哪些替代方案?
Vivek Yadav: 当然。我们最初考虑的其他替代方案本质上是使用像Postgres或Mongo这样的数据库,你把所有数据转储进去,为了测试需要的天数,你大规模扩展那个数据库。所以你启动数据集数据库,加载数据,扩展数据库以加载所有数据,然后在那上面运行测试。你基本上可以在你的服务上写一个东西来运行这个测试,但这需要大量的设置和拆除,因为你不想长期维护那个数据库,仅仅为了测试目的,实施成本太高,基础设施成本也太高。而在Spark的情况下,我们的实施成本几乎为零,因为我们已经有Spark基础设施等等,这对Spark来说微不足道。对Spark来说,这是非常微不足道的负载。
Olimpiu Pop: 好的,所以你提到的是,目前行业中有一种趋势是使用临时测试,也就是创建一堆测试环境,开发环境用于内循环,外循环使用临时环境,就这样。你说的是,你已经有这个东西了,所以实现新的东西没有意义,而且Spark由于其分析目标而非常重量级,因此很容易适应。然后我们说数据量级,以这个数据量为例。给我们一个大概的估计,我们谈论的是什么数量级,单位是什么,数十亿行还是什么?
Vivek Yadav: 我们系统的规模大约是4000多亿行,并且还在增长。就数据大小而言,这取决于投影,但大约在2 TB到5 TB之间。
Olimpiu Pop: 好的。那是很多数据,所以这可能是一件你只想做的事情,我不知道。我现在也在考虑天气预测和所有这些事情,那里有大量数据,从规模上来说会是这样,因为我认为没有那么多人关注那些事情,并且需要那么准确的预测。好的,很有趣。你们公司非常注重学习。我知道Strike让我印象深刻的一件事是写作文化,你们有大量东西被写下来,然后迭代改进,并关注业务影响。当前的系统如何改进?别告诉我这是一个完美的系统,因为我不相信。
Vivek Yadav: 当然不是,不。我们的系统,可以说各方面都有很大的改进空间。在我们当前的系统中,老实说,所有的改进都在解决方案的业务方面。所以当我说解决方案的业务方面时,就像我谈到的核心系统是我们如何向用户计费,或者我们如何估计我们的底层成本,网络成本?总是出现很多不同形态的成本,系统不断被扩展。目前,系统是从这个角度设计的。你有一个输入以非常函数式的方式进来。你有一个输入进来,应用一些逻辑,然后有一个输出出去,对吧?
两个不同的请求彼此独立创建。它们互不相关,但有时会出现新的用例,新请求的成本实际上取决于你之前看到的一些请求,你需要关心状态,而我们今天在这方面做得不太好。状态管理基本上以一种方式稍微发生在系统之外。如果我们能以某种方式将其纳入框架,那对我们的用户来说会更容易。
Olimpiu Pop: 好的,意思是用户,也就是组织中实际进行预测和使用的业务部门?
Vivek Yadav: 是的。
Olimpiu Pop: 好的,很好。谢谢。通常我会与工程师讨论如何更贴近产品,现在你谈论的是产品,好吧,业务要更工程化。感谢你的时间。祝你好运,感谢分享你的想法。
Vivek Yadav: 谢谢你,Olimpiu。
关于作者
Vivek Yadav目前是Stripe的工程经理,他在过去的八年里在工程和领导岗位上工作。他专注于Stripe的计费平台,并且进入科技领域的方式不同寻常,在大学之前,他在一个没有技术接触机会的乡村长大,直到大学才第一次接触计算机。