做者介绍: hawkingrei(王维真),中间件高级开发工程师,开源爱好者,TiDB & TiKV Contributor。 WaySLOG(雪松),Rust 铁粉一枚,专一中间件,bug creator。前端
本文根据 hawkingrei & WaySLOG 在 首届 RustCon Asia 大会 上的演讲整理。git
今天咱们会和你们聊聊 Rust 在咱们公司的二三事,包括在公司产品里面用的两个工具,以及雪松(WaySLOG)作的 Cache Proxy —— Aster 的一些经验。github
十年前,我司刚刚成立,那时候其实不少人都喜欢用 PHP 等一些动态语言来支持本身的早期业务。用动态语言好处在于开发简单,速度快。可是动态语言对代码质量、开发的水平的要求不是很高。因此我来到公司之后的第一个任务就是把咱们的 PHP 改写成 Golang 业务。在我看了当时 PHP 的代码之后的感觉是:动态语言一时爽,代码重构火葬场。由于早期我司仍是我的网站,PHP 代码质量比较差,代码比较随意,整套系统作在了一个单体的软件里,咱们称这个软件是一个全家桶,全部的业务都堆在里面,比较恶心。因此致使早期我司的服务质量也是很是差,观众给咱们公司一个绰号叫「小破站」。web
可是随着规模愈来愈大,还上市了,若是还停留在「小破站」就十分不妥,所以咱们开始用 Golang 对服务进行一些改进,包括开发一些微服务来稳定咱们的业务。经过这些改造也得到了很好的一个效果,由于 Golang 自己很是简洁,是一个带 GC 的语言,同时还提供了 goroutine 和 channel 一些功能,能够很方便的实现异步操做。但随着业务规模变大,也出现了一些 Golang 没法支持的一些状况。因而,咱们将目光转向了 Rust。redis
Remote Cache 是咱们第一个 Rust 服务。该服务是咱们公司内部的一套 Cache 服务。算法
在具体介绍这个服务以前,先介绍一下背景。首先在咱们内部,咱们的代码库并不像普通的一些公司一个项目一个库,咱们是大仓库,按语言分类,把全部相同语言的一个业务代码放到一个仓库里,同时在里面还会封装一些同一种语言会用到的基础库,第三方的依赖放在一个库里面。这样全部的业务都放在一个仓库,致使整个仓库的体积很是巨大,编译也会花不少的时间,急需优化。缓存
此时,咱们用到了两个工具—— Bazel 和 Gradle,这两个编译工具自带了 Remote Cache 功能。好比你在一台机器上编译之后,而后换了台机器,你还能够从新利用到上次编译的一个中间结果继续编译,加快编译的速度。安全
还有一个是叫 Prow 的分布式 CI/CD 系统,它是构建在 K8s 上运行的一套系统,来进行咱们的一个分布式编译的功能,经过上面三个工具就能够来加速咱们大仓库的一个编译的效率。可是,你们也看到了,首先中间一个工具,Bazel 跟 Gradle 他须要上传个人一个中间产物。这样就须要远端有一个服务,能够兜住上传结果,当有编译任务时,会把任务分布在一个 K8s 集群里面,就会同时有大量的请求,这样咱们就须要有个 Remote Cache 的服务,来保证全部任务的 cache 请求。同时,由于咱们使用了 Bazel 跟 Gradle,因此在办公网里面,不少开发也须要去访问咱们的 Remote Cache 服务,来进行编译加速。服务器
因此对咱们 Remote Cache 服务的负担实际上是很重的。在咱们早期的时候,由于一些历史缘由,咱们当时只有一台服务器,同时还要承担平均天天 5000-6000 QPS 的请求,天天的量大概是 3TB 左右,而且仓库单次编译的大小还会不断的增长,因此对 Remote Cache 服务形成很大压力。框架
咱们当时在想如何快速解决这个问题,最开始咱们的解决方法是用 K8s 的 Greenhouse 开源服务(github.com/kubernetes/…)。
刚开始用的时候还挺好的,可是后来发现,他已经不太能知足咱们的需求,一方面是咱们天天上传的 Cache 量比较大,同时也没有进行一些压缩,它的磁盘的 GC 又比较简单,它的 GC 就是设置一个阈值,好比说个人磁盘用到了 95%,我须要清理到 80% 中止,可是实际咱们的 Cache 比较多。并且咱们编译的产物会存在一种状况,对咱们来讲并非比较老的 Cache 就没用,新的 Cache 就比较有用,由于以前提交的 Cache 在以后也可能会有所使用,因此咱们须要一个更增强大的一个 GC 的功能,而不是经过时间排序,删除老的 Cache,来进行 GC 的处理。
因而咱们对它进行了改造,开发出了 BGreenhouse,在 BGreenhouse 的改造里面,咱们增长了一个压缩的功能,算法是用的 zstd,这是 Facebook 的一个流式压缩算法,它的速度会比较快,而且咱们还增长了一个基于 bloomfilter 过滤器的磁盘 GC。在 K8s 的 Greenhouse 里面,它只支持 Bazel。在 BGreenhouse 中,咱们实现了不只让它支持 Bazel,同时也能够支持 Gradle。
最初上线的时候效果很是不错,可是后来仍是出现了一点问题(如图 5 和图 6)。你们从图中能够看到 CPU 的负载是很高的,在这种高负载下内存就会泄露,因此它就「炸」了……
咱们分析了问题的缘由,其实就是咱们当时用的压缩算法,在 Golang 里面,用的是 Cgo 的一个版本,Cgo 虽然是带了一个 go,但他并非 Go。在 Golang 里面,Cgo 和 Go 实际上是两个部分,在实际应用的时候,须要把 C 的部分,经过一次转化,转换到 Golang 里,但 Golang 自己也不太理解 C 的部分,它不知道如何去清理,只是简单的调用一下,因此这里面会存在一些很不安全的因素。同时,Golang 里面 debug 的工具,由于无法看到 C 里面的一些内容,因此就很难去作 debug 的工做,并且由于 C 跟 Golang 之间须要转换,这个过程里面也有开销,致使性能也并非很好。因此不少的时候,Golang 工程师对 Cgo 实际上是避之不及的。
在这个状况下,当时我就考虑用 Rust 来把这个服务从新写一遍,因而就有了 Greenhouse-rs。Greenhouse-rs 是用 Rocket 来写的,当中还用了 zstd 的库和 PingCAP 编写的 rust-prometheus,使用之后效果很是明显。在工做日的时间段,CPU 和内存消耗比以前明显低不少,可谓是一战成名(如图 7 和图 8 所示)。
而后咱们对比来了一下 Golang 和 Rust。虽然这两门语言彻底不同,一个是带 GC 的语言,一个是静态语言。Golang 语言比较简洁,没有泛型,没有枚举,也没有宏。其实关于性能也没什么可比性,一个带 GC 的语言的怎么能跟一个静态语言作对比呢?Rust 性能特别好。
另外,在 Golang 里面作一些 SIMD 的一些优化,会比较恶心(如图 9)。由于你必需要在 Golang 里先写一段汇编,而后再去调用这段汇编,汇编自己就比较恶心, Golang 的汇编更加恶心,由于必需要用 plan9 的一个特别的格式去写,让人完全没有写的兴趣了。
但在 Rust 里面,你能够用 Rust 里核心库来进行 SIMD 的一些操做,在 Rust 里面有不少关于 SIMD 优化过的库,它的速度就会很是快(如图 10)。通过这一系列对比,我司的同窗们都比较承认 Rust 这门语言,特别是在性能上。
以后,咱们又遇到了一个服务,就是咱们的缩略图谱,也是用 Rust 来作图片处理。缩略图谱服务的主要任务是把用户上传的一些图片,包括 PNG,JPEG,以及 WEBP 格式的图,通过一些处理(好比伸缩/裁剪),转换成 WEBP 的图来给用户作最后的展现。
可是在图片处理上咱们用了 Cgo,把一些用到的基础库进行拼装。固然一提到 Cgo 就一种不祥的预感,线上状况跟以前例子相似,负载很高,而在高负载的状况下就会发生内存泄露的状况。
因而咱们当时的想法就是把 Golang 的 Cgo 所有换成 Rust 的 FFI,同时把这个业务从新写了一遍。咱们完成的第一个工做就是写了一个缩略图的库,当时也看了不少 Rust 的库,好比说 image-rs,可是这个里面并无提供 SIMD 的优化,虽然这个库能用也很是好用,可是在性能方面咱们不太承认。
因此咱们就须要把如今市面上用的比较专业的处理 WEBP,将它的基础库进行一些包装。通常来讲,你们最开始都是用 libwebp 作一个工做库,简单的写一下,就能够自动的把一个 C++ 的库进行封装,在封装的基础上进行一些本身逻辑上的包装,这样很容易把这个任务完成。可是这里面实际上是存在一些问题的,好比说 PNG,JPEG,WEBP 格式,在包装好之后,须要把这几个库 unsafe 的接口再组装起来,造成本身的逻辑,可是这些 unsafe 的东西在 Rust 里面是须要花一些精力去作处理的, Rust 自己并不能保证他的安全性,因此这里面就须要花不少的脑力把这里东西整合好,并探索更加简单的方法。
咱们当时想到了一个偷懒的办法,就是在 libwebp 里边,除了库代码之外会提供一些 Example,里面有一个叫 cwebp 的一个命令行工具,他能够把 PNG,JPEG 等格式的图片转成 WEBP,同时进行一些缩略剪裁的工做。它里面存在一些相关的 C 代码,咱们就想能不能把这些 C 的代码 Copy 到项目里,同时再作一些 Rust 的包装?答案是能够的。因此咱们就把这些 C 的代码,放到了咱们的项目里面,用 Bindgen 工具再对封装好的部分作一些代码生成的工做。这样就基本写完咱们的一个库了,过程很是简单。
可是还有一个问题,咱们在其中用了不少 libpng、libwebp 的一些库,可是并无对这些库进行一些版本的限制,因此在正式发布的时候,运维同事可能不知道这个库是什么版本,须要依赖与 CI/CD 环境里面的一些库的安装,因此咱们就想能不能把这些 lib 库的版本也托管起来,答案也是能够的。
图 12 中有一个例子,就是 WEBP 的库是能够用 Cmake 来进行编译的,因此在个人 build.rc 里面用了一个 Cmake 的库来指导 Rust 进行 WEBP 库的编译,而后把编译的产物再去交给 Bindgen 工具进行自动化的 Rust 代码生成。这样,咱们最简单的缩略图库很快的就弄完了,性能也很是好,大概是 Golang 三倍。咱们当时测了 Rust 版本请求的一个平均的耗时,是 Golang 版本的三倍(如图 13)。
在写缩略图服务的时候,咱们是用的 Actix_Web 这个库,Greenhouse 是用了 Rocket 库,由于同时连续两个项目都使用了不一样的库,也有一种试水的意思,因此在两次试水之后我感受仍是有必要跟你们分享一下个人感觉。这两个库其实都挺好的,可是我以为 Rocket 比较简单,同时还带一些宏路由,你能够在 http handle 上用一个宏来添加你的路由,在 Actix 里面就不能够。 Actix 支持 Future,性能就会很是好,可是会让使用变得比较困难。Rocket 不支持 Future,但基本上就是一个相似同步模型的框架,使用起来更简单,性能上很通常。咱们后续计划把 Greenhouse 用 Actix_web 框架再从新写一遍,对好比下图所示。
以上就是我司两个服务的小故事和一些小经验。
前面分享了不少 Rust 的优势,例如性能很是好,可是 Rust 也有一个很困扰咱们的地方,就是他编译速度和 Golang 比起来太慢了, 在我基本上把 Rust 编译命令敲下之后,出去先转上一圈,回来的时候还不必定可以编译完成,因此咱们就想办法让 Rust 的编译速度再快一点。
首先是咱们公司的 Prow,它其实也不是我司原创,是从 K8s 社区搬过来的。Prow 的主要功能是把一个大仓库里面的编译任务经过配置给拆分出来。这项功能比较适合于大仓库,由于大的仓库里面包含了基础库和业务代码,修改基础库之后可能须要把基础库和业务代码所有再进行编译,可是若是只改了业务代码,就只须要对业务代码进行编译。另外同基础库改动之后,时还须要按业务划分的颗粒度,分散到不一样的机器上对这个分支进行编译。
在这种需求下就须要用到 Prow 分布式编译的功能,虽然叫分布式编译,但实际上是个伪分布式编译,须要提早配置好,咱们如今是在大仓库里面经过一个工具自动配置的,经过这个工具能够把一个很大规模存量的编译拆成一个个的小的编译。可是有时候咱们并必定个大仓库,可能里面只是一个很简单的业务。因此 Prow 对咱们来讲其实并不太合适。
另外介绍一个工具 Bazel,这是谷歌内部相似于 Cargo 的一个编译工具,支持地球上几乎全部的语言,内部本质是一个脚本工具,内置了一套脚本插件系统,只要写一个相应的 Rules 就能够支持各类语言,同时 Bazel 的官方又提供了 Rust 的编译脚本,谷歌官方也提供了一些相应的自动化配置生成的工具,因此 Golang 在使用的时候,优点也很明显,支持 Remote Cashe。同时 Bazel 也支持分布式的编译,能够去用 Bazel 去作 Rust 的分布式编译,而且是跨语言的,但这个功能多是实验性质的。也就是说 Rust 可能跟 Golang 作 Cgo,经过 Golang Cgo 去调 Rust。因此咱们经过 Bazel 去进行编译的工做。但缺点也很明显,须要得从零开始学 Rust 编译,必需要绕过 Cargo 来进行编译的配置,而且每一个目录层级下面的原代码文件都要写一个 Bazel 的配置文件来描述你的编译过程。
为了提高性能,就把咱们原来使用 Rust 的最大优点——Cargo 这么方便的功能直接给抹杀掉了,并且工做量也很大。因此 Bazel 也是针对大仓库使用的一个工具,咱们最后认为本身暂时用不上 Bazel 这么高级的工具。
因而咱们找了一个更加简单的工具,就是 Firefox 官方开发的 Sccahe。它在远端的存储上面支持本地的缓存,Redis,Memcache,S3,同时使用起来也很是简单,只要在 Cargo 里面安装配置一下就能够直接使用。这个工具缺点也很明显,简单的解释一下, Sccahe 不支持 ffi 里涉及到 C 的部分,由于 C 代码的 Cache 会存在一些问题,编译里开的一些 Flag 有可能也会不支持(以下图所示)。
因此最后的结论就是,若是你的代码仓库真的很大,比 TiKV 还大,可能仍是用 Bazel 更好,虽然有学习的曲线很陡,但能够带来很是好的收益和效果,若是代码量比较小,那么推荐使用 Sccahe,可是若是你很不幸,代码里有部分和 C 绑定的话,那仍是买一台更好的电脑吧。
这一部分分享的主题是「技术的深度决定技术的广度」,出处已经不可考了,但算是给你们一个启迪吧。
下面来介绍 Aster。Aster 是一个简单的缓存代理,基本上把 Corvus(原先由饿了么的团队维护)和 twemproxy 的功能集成到了一块儿,同时支持 standalone 和 redis cluster 模式。固然咱们也和 Go 版本的代理作了对比。相比之下,QPS 和 Latency 指标更好。由于我刚加入我司时是被要求写了一个 Go 版本的代理,可是 QPS 和 Latency 的性能不是很好,运维又不给咱们批机器,无奈只能是本身想办法优化,因此在业余的时间写了一个 Aster 这个项目。可是成功上线了。
图 18 是我本身写的缓存代理的进化史,Corvus 的话,自己他只支持 Redis Cluster,不支持 memcache 和是 Redis Standalone 的功能。如今 Overlord 和 Aster 都在紧张刺激的开发中,固然咱们如今基本上也开发的差很少了,功能基本上完备。
由于说到 QPS 比较高,咱们就作了一个对比,在图 19 中能够看到 QPS 维度的对比大概是 140 万比 80 万左右,在 Latency 维度上 Aster 相较于 Overlord 会更稳定,由于 Aster 没有 GC。
给你们介绍一下我在写 Aster 的时候遇到了一些问题,是某天有人给我发了图 20,是他在写 futures 的时候,遇到了一个类型不匹配的错误,而后编译报出了这么长的错误。
可能你们在写 Future 的时候都会遇到这样的问题,其实也没有特别完善的解决办案,但能够在写 Future 和 Stream 的时候尽可能统一 Item 和 Error 类型,固然咱们如今还有 failure::Error 来帮你们统一。
这里还重点提一下 SendError。SendError 在不少 Rust 的 Channal 里面都会实现。在咱们把对象 Push 进这个队列的时候,若是没有足够的空间,而且 ownership 已经移进去了,那么就只能把这个对象再经过 Error 的形式返回出来。在这种状况下,若是你不处理这个 SendError,不把里面的对象接着拿下来,就有可能形成这个对象没法获得最后的销毁处理。我在写 Aster 的时候就遇到这样的状况。
下面再分享一下我认为 Rust 相比 Golang 、 C 及其余语言更好的一个地方,就是 Drop 函数。每个 Future 最终都会关联到一个前端的一个 FD 上面,关联上去以后,咱们须要在这个 Future 最后销毁的时候,来唤醒对应的 FD ,若是中间出现了任何问题,好比 SendError 忘了处理,那么这个 Future 就会一直被销毁,FD 永远不会被唤醒,这个对于前端来讲就是个大黑盒。
因而咱们就想到用 Drop 函数维持一个命令的 Future 的引用计数,引用计数到了归零的时候,实际上就至关于这个 Future 已经彻底结束了,咱们就能够经过归零的时候来对它进行唤醒。可是一个命令可能包含不少子命令,每个子命令完成以后都要进行一次唤醒,这样代价过高,因此咱们又加入了一个计数,只有这个计数归零的时候才去唤醒一次。这样的话,效率会很高。
Aster 最初的版本性能已经很高了,接着咱们对它进行了两版优化,然而越优化性能越低,咱们感到很无奈,而后去对它作了一个 Profile,固然,如今通常我采用的手段都是 perf 或者火焰图,我在对 Rust 程序作火焰图的时候,顺手跑了个命令,perf 命令,用火焰图工具把他处理一下,最后生成出来的结果不是很理想,有不少 unknown 的函数,还有函数名及线程名显示不全的状况(如图 23)。
而后咱们开始尝试加各类各样的参数,包括 force-frame-pointers 还有 call-graph 可是最后的效果也不是很理想。直到有一天,我发现了一个叫 Cargo Flame Graph 的库,尝试跑了一下,很不幸失败了,它并无办法直接生成咱们这种代理程序的火焰图,可是在把它 CTRL-C 掉了以后,咱们发现了 stacks 文件。若是你们熟悉火焰图生成的话,对 stacks 确定是很熟悉的。而后咱们就直接用火焰图生成工具,把它再从新展开。此次效果很是好,基本上就把全部的函数都打全了(如图 24)。
这个时候咱们就能够针对这个火焰图去找一下咱们系统的瓶颈,在咱们测 benchmark 的时候,发现当处理有几万个子命令的超长命令的时候,Parser 由于缓存区读不完,会来回重试解析,这样很是消耗 CPU 。因而咱们请教了 DC 老师,让 DC 老师去帮咱们写一个不带回溯的、带着状态机的 Parser。
这种解法对于超长命令的优化状况很是明显,基本上就是最优了,可是由于存了状态,因此它对正常小命令优化的耗时反而增长了。因而咱们就面临一个取舍,要不要为了 1% 的超长命令作这个优化,而致使 99% 的命令处理都变慢。咱们以为不必,最后咱们就也舍去了这种解法,DC 老师的这个 Commit 最终也没有合进个人库,固然也很惋惜。
咱们作 Profile 的时候发现系统的主要瓶颈是在于syscall,也就是 readfrom 和 sendto 这两个 syscall 里面。
这里插入一个知识点,就是所谓的零拷贝技术。
在进行 syscall 的时候,读写过程当中实际上经历了四次拷贝,首先从网卡 buffer 拷到内核缓存区,再从内核缓存区拷到用户缓存区,若是用户不拷贝的话,就去作一些处理而后再从用户缓冲区拷到内核缓存区,再从内核缓存区再把他写到网卡 buffer 里面,最后再发送出去,总共是四次拷贝。有人提出了一个零拷贝技术,能够直接用 sendfile() 函数经过 DMA 直接把内核态的内存拷贝过去。
还有一种说法是,若是网卡支持 SCATTER-GATHER 特性,实际上只须要两次拷贝(以下图右半部分)。
可是这种技术对咱们来讲其实没有什么用,由于咱们仍是要把数据拷到用户态缓冲区来去作一些处理的,不可能不处理就直接日后发,这个是交换机干的事,不是咱们服务干的事。
那么有没有一种技术既能把数据拷到用户态又能快速的处理?有的,就是 DPDK。
接下来我为你们简单的介绍一下 DPDK,由于在 Aster 里面没有用到。DPDK 有两种使用方式,第一种是经过 UIO,直接劫持网卡的中断,再把数据拷到用户态,而后再作一些处理(如图 28)。这样的话,实际上就 bypass 了 syscall。
第二个方式是用 Poll Model Driver(如图 29)。这样就有一颗 CPU 一直轮循这个网卡,让一颗 CPU 占用率一直是百分之百,可是总体效率会很高,省去了中断这些事情,由于系统中断仍是有瓶颈的。
这就是咱们今天的分享内容,谢谢你们。