RustConf 2023 - Integrating Rust and Go: Lessons from Github Code Search

https://www.youtube.com/watch?v=KYdlqhb267c

我会按照您的要求整理内容并翻译为中文,不会遗漏或省略内容。以下是整理和翻译后的内容:

我叫Luke Franel。今天我要讲的是集成Rust和Go,以及我们在GitHub代码搜索中学到的一些经验教训。

如果你在过去十年左右使用过GitHub代码搜索,你可能注意到它并不是很好。它对可搜索的内容有很多限制,而且速度很慢。我的团队负责尝试解决这个问题。

我们通过从头开始构建一个新的搜索引擎来解决这个问题,这个搜索引擎专门为大规模代码搜索而设计。我们称之为Blackbird,它支持精确的子字符串匹配、正则表达式和代码导航,比如跳转到定义。而且它很快 - 你可以在几百毫秒内搜索GitHub上数十亿个文件和数百万个仓库。

当然,它是用Rust编写的,否则我今天可能就不会和你们讨论这个话题了。事实上,Rust非常适合搜索引擎。它性能高,有良好的并发支持。我们特别喜欢在需要异步任务时使用rayon。我们可以使用像Tokio这样的优秀库,并且对内存管理有完全的控制。这在我们希望保持稳定的每秒查询数,而不是因为垃圾收集而出现大的延迟峰值时非常重要。crates生态系统中有许多出色的库可以帮助我们构建搜索工具。

所以我们决定用Rust构建这个搜索引擎,但GitHub并不大量使用Rust。如果你从一个非常高的层面看GitHub的架构,它看起来是这样的:我们有github.com,我们内部称之为GitHub GitHub。它是一个15年历史的巨大的单体Rails应用程序,用Ruby编写。每天都有数百名开发人员为这个仓库贡献代码。

然而,越来越多的服务正在使用两种方法在单体应用之外编写。第一种是RPC或远程过程调用,我们使用Twitch TV的库twirp来做这个。这些外部服务通常用Ruby编写,或者越来越多地用Go编写。另一种方式是通过异步工作者,它们通过消息总线消费消息。在我们的情况下,我们使用Kafka。所以当发生某些事情时,单体应用会发布一条消息,然后消费者可以读取这些消息并对其进行处理。

Go越来越多地成为这些服务的选择,这是因为Go不仅性能高,而且在GitHub得到很好的支持。我们的部署平台原生支持它,你可以获得优秀的可观察性,包括日志记录、指标、异常跟踪和分布式追踪。使用twirp库,你可以自动生成RPC客户端和服务器。还有很多GitHub特定的库是用Go编写的,你可以使用。

但是正如我所说,我们决定用Rust编写我们的搜索引擎。所以我们需要弄清楚如何将这个Rust搜索引擎与GitHub的Ruby on Rails和Go生态系统的其余部分连接起来。这并不像这张图片那样简单。实际上,我们每个Blackbird集群都有32个索引片段,称为分片,它们都在单独的服务器上运行。所以我们需要连接所有这些,以便能够进行搜索和索引内容。

那么我们要如何做到这一点呢?我们不想在尝试从头构建搜索引擎的同时,还要弄清楚如何让Rust在这个生态系统中工作。所以我们采取的方法是使用Go中间件构建系统的第一个版本。例如,我们的爬虫是用Go编写的。它从GitHub GitHub单体应用接收事件,比如当一个仓库发生变化时,比如你向你的仓库推送代码。然后它询问git API(这也是一个高性能的Go服务)我们之前的仓库状态和当前状态之间发生了什么变化。然后我们获取与这些变化相对应的所有文件或所有内容。

然后它进行文档构建。这涉及将仓库元数据(如仓库的名称和ID)与文件中的内容和路径结合起来,还包括语言检测和符号分析等内容。一旦构建了文档,它就会将其发布到一个输出主题,这个主题被Blackbird索引分片消费。

查询的工作方式类似。当一个请求进入github.com时,它会被转发到我们的Blackbird查询服务,这个服务也是用Go编写的,负责向所有Blackbird分片扇出,收集每个分片的顶级结果,并将结果返回给用户。

需要注意的是,由于我们系统的架构方式,查询服务必须从每个单独的分片获得响应,才能获得完整的搜索结果。如果这些分片中的一个不可用,我们就无法提供结果。我们稍后会回到这一点。

正如我所说,这是我们决定如何分片我们的内容的结果。我们是按git blob对象的ID来分片的。在git中,每一块内容都有一个唯一的ID,完全由其内容决定。这是一个内容可寻址系统。所以当你有一个与全局唯一内容相关联的路径时,这没有问题,因为它将与一个路径、一块内容相关联,搜索起来会非常快。

然而,如果你有一个在多个仓库中共享的文件,它们都有相同的内容,它们都会映射到同一个分片,然后我们可能会遇到问题。这在内部的工作方式,或者说过去的工作方式是,我们有我们的内容,然后我们有一个基本上是元数据的表,其中包括路径,然后是一些关于文件的信息。

当我们添加越来越多的仓库时,这成为了一个问题,因为当一个搜索命中这些共享内容中的一个时,它会命中这个表,然后它必须扫描这个列表来找到正确的路径返回给用户,这变得非常慢。所以我们知道我们不能这样启动,我们必须想出一个解决方案。

我们称这个解决方案为Delta压缩。它的工作原理是,我们根据仓库的路径和blob tal(本质上)来查看仓库之间的相似性。如果你想象这个例子,有Linus Torvalds的Linux仓库,然后也许GitHub有一个分叉,它有几百个不同的路径,然后也许我有一个分叉,只有几个路径不同。我们想用这个Delta压缩做的是重用父仓库的内容,这样我们就不必在子仓库中索引所有内容。

要实现这一点,我们需要两个新的数据结构。第一个叫做树路径,这个相对简单,它只是一种用紧凑的二进制格式表示这种祖先关系的方法。第二个更复杂,它是几何异或过滤器。这是一种概率数据结构,专门用于计算对称集差,这是我们用来确定一个仓库与另一个仓库有多相似的度量。如果你熟悉HyperLogLog,它有点类似,但它更优化于这种集差情况。

我们知道我们必须用Rust编写这些,以便在搜索引擎中使用它们,但对于Go爬虫,我们有一个选择。我们可以用Go编写它们,或者我们可以尝试共享代码。我们决定共享代码,原因有两个。

首先,我们发现Rust非常适合算法,因为它安全、快速,而且类型系统真的可以帮助你避免犯错误。Go的类型系统没有那么健壮。我们认为,如果只有一个实现,事情会更简单。我们不必担心因为Go实现或Rust实现略有不同而导致的错误。

但是共享代码需要使用FFI。Rust和Go都有办法做到这一点。在Rust中,我们有extern “C”,在Go中,我们有cgo。但是在Go世界中有一篇著名的博客文章,Dave Cheney写的”cgo is not Go”,它列举了使用cgo时必须处理的所有奇怪的事情。如果我可以换个说法,我会说extern “C”不是Rust。当然,它是Rust,但是你喜欢Rust的所有那些伟大的东西 - 安全性和类型 - 你不能再使用了,因为它本质上是不安全的。

事实上,这两种语言都有我们喜欢的特性,可以帮助我们构建健壮的软件系统,无论是Go还是Rust。但是当你使用FFI时,你基本上可以忘记所有这些东西。我们想从Go程序向Rust程序传递一个字符串?忘了它吧。C没有安全的字符串类型,它只有指针。

所以跨越这个边界传递对象真的很复杂。不仅仅是字符串,向量、枚举、迭代器,Rust标准库中你喜欢的所有特性在跨越这个边界时都很难使用。我的同事Jason Orendorff喜欢说,FFI意味着你最喜欢的编程语言特性都被关闭了。

但我们已经承诺了,那么我们要如何让它工作呢?基本上,我们需要制作一个FFI三明治。在底层,我们有我们的Rust API。这将是漂亮的正常安全的Rust代码。然后我们必须制作一个extern “C”包装器,将这些函数暴露给C调用。然后我们需要头文件用于链接,我们使用cbindgen来做这个。这是Mozilla的一个很棒的crate。

在另一边,在Go端,我们需要使用cgo创建一个包装器来调用这些extern “C”函数,然后我们有我们的Go客户端代码。

我们的第一种方法,我喜欢称之为”Go-like API”。我这样称呼它是因为结果API使用起来真的很好,看起来就像正常的Go代码,但它有一些问题,我们稍后会讨论。我先简单介绍一下这些代码层,向你展示如何将它们组合在一起。

在我们的Rust代码中,我们有一个树路径,这是一个静态表示,它有一个字节数组,我们有可变版本,允许你向路径添加ID。树路径版本有一个获取字节的方法。

然后我们有我们的extern “C”包装器。所以我们有一个函数来创建一个新的树路径,我们有一个函数来向它推送一个ID,我们有一个函数来获取字节。因为这是C,我们需要一种方法来处理它,所以我们有tree_path_free来释放内存。

在Go端,我们首先需要告诉cgo在哪里找到这个共享库。我们只有一些编译器指令。然后我们导入C,这解锁了使用cgo的能力。Go代码本身也有一个树路径结构,它反映了Rust的结构,但它有一个指向Rust结构的指针。

这是小写的,在Go中意味着它是未导出的,所以这意味着程序中的其他部分不能访问它,除了这个结构上的方法。所以我们需要一个函数来创建树路径,因为Go是一种GC语言,我们想设置一个终结器来在我们完成时清理那个对象。我们有我们的方法调用cgo或者说extern “C”函数来推送ID和获取字节。

这就是你如何使用这个API。如我所说,这看起来就像正常的Go代码,使用起来看起来很愉快。你只需创建一个路径,然后你可以向它推送ID,然后你可以获取字节并用它们做一些事情,比如将它们保存在你的数据库中。

但是敏锐的观察者可能已经注意到这段代码有一个问题,问题就在这里。当我们使用这个时,我们将数据保存到我们的数据库中,然后我们读取它,结果是垃圾。发生了什么?这是经典的释放后使用。我们在创建树路径时设置了这个终结器,Go的GC决定在某个时候运行它,这使得我们的数据切片指向随机内存,所以它不再有效。

终结器很棘手,因为你无法控制它们何时运行,GC决定。我想明确指出,你可以让这个工作,我们确实修复了这个错误,但我们在尝试让Go和Rust以这种方式交互时遇到了很多问题。Go是一种GC语言,而Rust想要跟踪所有权,所以让它们很好地一起工作有点困难。

所以我们想出了第二种方法,我称之为”C-like API”,因为我们想让Go负责分配所有的内存。这样我们就不必担心Rust在我们不想要的时候释放它,Go GC将完全了解发生了什么。我称之为C-like API,因为我们正在改变的是一个更低级的API,我们传递指针并在Rust代码中设置它们,我们有输出参数,就像在C程序中一样。

Rust API保持不变,但我们需要一种新的方法来处理extern “C”代码。所以在这里,当我们创建一个树路径时,我们不是返回一个指针,而是接受一个输出参数和长度,我们分配那个内存,然后将其设置到那些参数中。当我们推送一个子节点时,同样地,我们必须接受原始树路径的路径和长度,然后是我们想要推送的子节点,然后是输出参数和长度,以便将数据写入其中。

Go代码看起来是这样的:我们需要一个函数来创建一个新的树路径,注意现在它不是返回一个结构体,而是返回一个字节切片。这使得使用起来不太愉快,因为我们不能再像以前那样依赖类型系统。我们现在处理的是不透明的字节。推送子节点现在也是一个函数,而不是一个方法,它将路径作为字节接收。

这是旧API与新API的对比。你可以看到,它仍然相当合理,可以处理,但data现在是一个不透明的类型,所以我们只能在脑子里而不是在编译器中保持这一点。

我们对这个结果相当满意。我们能够从技术预览阶段的600万个仓库扩展到5000万个仓库。同时,我们将总的重新索引时间控制在24小时以内。这对我们来说非常重要,因为我们喜欢能够对索引格式进行实验。我们还基于使用这种Delta压缩,将需要索引的文档数量减少了大约两倍。所以总的来说,我们对这个结果相当满意。

随着我们对FFI的信心增加,我们考虑了第三种方法来解决另一个扩展问题。为了说明这一点,让我谈谈我们的动态分片分配方法。

如果你还记得我展示这张图片时,我说过Blackbird查询服务需要联系每一个Blackbird分片才能得到一个结果,而且每个主机只有一个分片,这意味着如果一个主机宕机,我们就无法为任何查询提供服务。

这显然是不好的,对吧?你知道,有人说如果你在启动时对你的软件不感到尴尬,那你就等待得太久了。这绝对是一个让我们感到尴尬的情况。它让我们达到了技术预览阶段,但我们知道它对正式发布来说不够好,因为我们不想因为要重新编译代码来修复问题而被叫醒。

所以我们想出的方法我们称之为动态分片分配。为了说明这一点,让我展示一下我们管理应用程序中的一张图片。你可以看到这里有32个分片,每一个小的彩色方框表示一个主机。所以现在每个主机负责两个不同的分片。这样,如果我们失去一个单一的主机,我们就不必关闭整个集群。我们还有额外的主机,而不是有32个主机,我们现在有38个,那些额外的6个主机正在对索引进行维护工作。

这种方法通过一个状态机来进行这种维护工作,然后将那个分片投入服务,然后移动到下一个。但是要做到这一点,我们需要实现基本上是一个分布式共识系统,因为调用者,也就是Blackbird查询服务,需要和索引器有相同的世界视图,而分片在经历这个循环时不断移动。我们不想再实现另一个分布式共识算法,所以我们实际上只是利用我们已经有的东西,也就是Kafka。我们定期将集群的状态发布到Kafka主题,然后客户端消费该主题,达到对集群状态的相同认知。

但这意味着我们的客户端现在变得相当复杂。它在进行Kafka消费,它有HTTP客户端来发出状态请求,它在扇出请求,它需要计算负载均衡 - 我们使用最大流来在整个集群中进行负载均衡,所以它需要计算那个。它有很多东西在里面。所以我们又一次想要用Rust来做这个,这就是为什么我称我们的第三种方法为”嵌入式运行时”。

在这里,我们在Rust中打包了大量功能,然后只是在一个Go结构中放入一个指针,该指针引用了那个对象。所以Go代码完全不知道正在发生的所有细节。这个东西正在启动线程和Kafka消费,还有一个Tokio运行时,它在做很多事情,而Go完全无知无觉。

让我们看看这是如何工作的。在Rust代码中,我们有一个Blackbird客户端,这是我们要放在我们的Go结构上的顶级对象。正如你所看到的,它有一个完整的Tokio运行时,它启动那个运行时并开始执行东西。这负责在同步和异步之间搭桥。

然后我们有我们的集群客户端,这是做动态分片分配的东西。所以它启动Kafka消费者并运行那个算法,然后它有这个函数或者说方法route,它返回当前的路由,也就是说,我需要联系哪些主机来获取哪个分片。这需要在每次搜索请求之前运行,这样你就能得到世界的当前图景。

我们的extern “C”展示了我们如何结合了前两种FFI方法。我们有这个客户端,它是一个指针,有点像第一种方法,但是每当我们从客户端获取数据时,我们总是让Go分配那个内存。这样我们就避免了那些使用后释放的问题。

这里简要地展示了Go代码。你可以看到Go客户端有指向Blackbird客户端的指针,它有一个创建新客户端的函数和设置终结器的函数,这样就清理了所有资源,然后routes方法调用我在上一张幻灯片上展示的客户端routes方法,在分配输出参数后,然后将它们映射到Go结构中。

结果,我们可以并且已经在我们的集群中失去了多个服务器而没有停机。我们不再因为Go代码中静态的主机名列表来决定我们需要查询什么而感到尴尬。此外,当我们启动我们的技术预览时,我们每秒只能处理大约5个查询,现在我们使用这个动态分片分配客户端,峰值可以达到每秒超过160个查询。

当然,这不是因为我们使用FFI,但它表明我们能够使用这种方法来扩展系统。它完全没有成为这个系统的瓶颈或问题。

所以总的来说,我们对这种方法非常满意,它使我们能够将Blackbird代码搜索体验推广到github.com上。现在当你在GitHub上使用代码搜索时,你正在使用我展示的所有这些代码。

总的来说,我们对我们在Go和Rust之间互操作的能力感到满意,但我们实际上计划在未来减少Go和Rust的互操作。这是因为将会有更少的Go代码。我们正在努力将更多的Go中间件代码移植到Rust中。原因是Blackbird今天是一个巨大的分布式系统,它有大量的服务器、VM和Kafka,以及各种各样的东西,我们需要找到一种方法将代码搜索体验带到GitHub的较小部署中,比如GitHub Enterprise Server。

GitHub Enterprise Server是我们的本地产品,现在我们根本无法将整个Blackbird塞进去。所以我们想要做的是将所有这些代码移植到Rust,然后生产一个单一的二进制文件,在那个较小的环境中以较小的模式运行。当我们在GitHub Enterprise Server中时,我们不需要扩展到数亿个仓库,所以我们可以做得更简单。但如果我们只需要处理一种语言,这一切都会更容易。

所以总结一下,我们发现Rust是一种非常适合表达算法和数据结构的语言。我们感觉类型系统帮助我们对我们的代码非常有信心,集成测试也有帮助。我们还发现Go和Rust可以有效地一起工作,但边界是棘手的。我们在更简单的代码中取得了更多成功,在这些代码中我们完全依赖于Go进行分配。

我们还发现,由于共享库的原因,分发变得更加棘手。我喜欢Go和Rust的一点是,你可以轻松地制作静态二进制文件,它们非常容易部署。但是当你使用cgo时,你正在链接一个外部库,所以你必须想办法将其与你的代码打包在一起。这是许多Go程序员不喜欢cgo的原因之一。

分发的另一个问题其实是我们自己造成的,那就是我们的团队主要在Mac上开发,但我们部署到Linux。所以我们在构建系统中有特殊的技巧,只是为了让我们能在本地运行我们的软件,如果我们不这样做就不需要这些技巧。

最后,我们发现Rust可以落地并扩展。当Rust被选为Blackbird代码搜索原型的编程语言时,这是一个有风险的选择。在GitHub,不多人知道Rust,我们的部署平台也不支持它,我们的可观察性工具,所有这些东西。所以选择它是一个很大的风险。但是由于我们取得的成功,我们对Rust有了信心,我们正在将更多的系统重写为Rust,GitHub的其他团队现在也对使用Rust来解决他们的问题感兴趣。

好了,谢谢你们来听我的演讲。我还要感谢和我一起在GitHub工作的队友们。我特别要感谢Greg Orall、Jason Orendorff和Timothy Clim,他们做了大量工作来弄清楚如何有效地让这些cgo和Rust代码一起工作。谢谢。

文章目录