工程博客 - Prexy:超快搜索和替换规则
欢迎阅读我们的第一篇工程博客。与您在其他博客中看到的内容相比,这篇博客的技术性可能会更强一些,但我们会尽最大努力让每个人都能看懂。在本文中,我们将讨论Prexy,这是 Clonable 中用于应用替换规则的一款新软件。
网站背景
作为 Clonable 的用户,您可能已经熟悉替换规则功能。 您可以利用这些搜索和替换规则,将文本或代码片段替换为自选的变体。例如,您可以利用此功能替换 API 密钥或分析 ID,从而对原始网站和翻译后的克隆站点进行不同的分析。在系统内部,这些规则同样用于将您的原始域名替换为克隆站点的域名,以及处理其他多项操作。
从 Clonable 的第一个版本开始,这个功能就从未更新过,这本身并不奇怪,因为它的工作做得很好。不过,无论是在性能还是在可用性方面,都有改进的可能。我们 Clonable 偶尔也会对产品中很久没用过的部分进行改进。例如,去年我们对所有数据基础架构进行了全面改造,使其速度更快、可扩展性更强、容错性更高,在此之前,我们还从头开始重建了 URL 翻译功能。本季度轮到了替换规则。
旧有情况和限制
在 Clonable 中,替换规则仅在最后阶段应用,即在将响应发回客户端之前。因此,在 Clonable 的第一个版本中,我们选择在 NGINX Web 服务器内通过 replace-filter-nginx-module(由 OpenResty 创建的模块)来实现这一功能。该模块使用 sregex 作为其流式正则表达式引擎,该引擎同样由 OpenResty 创建。 在此场景下,流式处理至关重要,因为我们不希望将每个响应完全加载到内存中。若如此操作,大量大体积响应可能会导致 NGINX 因内存不足而崩溃。 sregex 的第二个重要特性是能够并行处理多行内容。这是因为每个克隆站点不仅已包含一些标准规则,有时还会针对该克隆站点专门添加一些规则。如果无法并行处理这些规则,我们仍需缓冲整个响应,因为在应用第一条规则后,我们需要再次匹配响应内容以应用第二条规则。
然而,去年我们越来越多地遇到了奇怪的性能问题。这些问题通常持续时间很短,但在极端情况下,页面的响应时间可能会延长数秒。在这种峰值之后,页面往往又能正常加载,因此问题很难再现。为了进一步调试这个问题,我们在所有响应中添加了 Clonable-Timings 标头,这样我们就可以大致确定翻译过程中哪一步花费了这么多时间。结果发现,几乎在所有情况下,上游都能快速响应,翻译也相当流畅。不过,在完成翻译和完全完成请求之间存在差距,这给了我们一个提示,即这可能与替换规则有关。
NGINX 的并发模型
然而,这并没有给我们一个答案,即为什么这些延迟只是偶尔发生,为什么服务器的负载甚至没有达到其容量的一半。为了更好地理解这一现象,我们需要深入研究一下 NGINX 是如何处理工作负载的。
NGINX 采用工作者架构,包含一个主进程和多个工作者进程。连接在工作进程之间分配,以便同时处理多个请求。然而,这种架构也有一个缺点:当一个工作进程忙于处理一个请求时,分配给同一工作进程的其他请求就必须等待。这可能会导致一个大请求造成多个请求的延迟,即使其他工人无事可做也是如此。从下图中可以清楚地看到这种影响。在创建这张图片时,压力测试正在 10 个不同的连接上运行。然后,图像清晰地显示出 3 个 Worker 正在忙碌,而第 4 个 Worker 几乎什么也没做。

Prexy 的时间
由于瓶颈清晰可见,我们制定了一项改进计划。这个项目被命名为 Prexy,是 RegEx 和 Proxy 的合体。最终结果有三个主要要求:
它应该是当前设置的直接替代品。必须以相同的方式应用替代规则,以避免破坏现有设置。
替换规则必须是流式的,以防止解决方案占用过多内存。
新解决方案必须比现有解决方案更快。
首先,我们必须寻找一个能够高效处理请求的强大多线程运行时。在考虑了多个选项后,我们最终选择了 Rust 编程语言的 东京运行时。Rust 以允许编写快速程序而没有 C++ 等其他低级语言的安全风险而著称。东京运行时是一种灵活的异步运行时,适用于网络应用程序。对我们来说,东京运行时最重要的特性之一是它具有工作偷取功能。这意味着,与 NGINX 不同的是,一个请求不会被绑定到一个 Worker 上,而一个无事可做的 Worker 可以从另一个 Worker 上 "偷 "工作。这样,我们的服务器资源就能得到更好的利用。
第一个原型
在选择了底层技术后,我们决定建立一个初始原型,以便大致估算一下这个项目能给我们带来多大的速度提升,以及是否值得这么做。作为 regex 引擎(实际处理和应用规则的部分)的首个实现,我们使用了标准 regex 库(或 Rust 术语中的 crate)。与 sregex 一样,该引擎也是非回溯的,这意味着发生 ReDoS 问题的风险更低。在 ReDoS 中,正则表达式在面对输入时,评估输入所需的时间会呈指数级增长。这会对性能造成灾难性影响,并导致 cloudflare 出现 重大故障。因此,对我们来说,使用非回溯引擎非常重要。
利用这个 regex 引擎,我们构建了一个初始原型。该原型使用了硬编码规则,并且该 regex 引擎对整个响应进行了缓冲,而不是流式处理,但这足以进行初步的性能测试。
利用这个 regex 引擎,我们构建了一个初始原型。该原型使用了硬编码规则,并且该 regex 引擎对整个响应进行了缓冲,而不是流式处理,但这足以进行初步性能测试。我们的测试设置由两个虚拟机组成,其中一个虚拟机运行旧解决方案和新解决方案的设置。这样,我们就可以很容易地对两种方法进行比较。另一台服务器运行 NGINX,作为源服务器并提供测试文件。在同一台服务器上还运行着我们生成负载的工具 wrk。这样做并不完全理想,因为对于纯数据而言,最好在单独的虚拟机上运行负载生成器,但该虚拟机有足够的资源,因此 NGINX 和 wrk 不会相互干扰。
首次测试结果显而易见:Prexy 处理单个请求的速度提高了约 22 倍(见下图)。这为该项目开了最后一扇绿灯,因为有了如此大的差距,我们就有足够的空间来吸收在功能完善时可能出现的性能下降。

经过初步测试,我们实施了一系列明显的优化,例如缓存已编译的正则表达式。我们还采用了一种更有效的方式来重复使用与上游的连接。这样,我们的速度提高了约 20%。然后,我们还在峰值负载下测试了这两种解决方案,向服务器发送了尽可能多的请求。同样,差异也非常明显,Prexy 的吞吐量是旧解决方案的 25 倍。

流式 regex 引擎
该项目的要求之一是所使用的 regex 引擎必须是流式的。原型的 regex 创建不是流式的,因此需要在这方面做额外的工作。不过,regex crate 经过了高度优化,因此我们决定将其作为流引擎的基础。在通过 regex 引擎进行数据流处理时,有几件事情需要注意。首先,你不知道还会有哪些数据出现。因此,你需要从部分匹配开始工作:匹配尚未完成,但已经匹配了 1 个或多个字符。在查找完整匹配时,您必须检查是否有重叠的部分匹配,因为这些部分匹配最终也可能成为匹配(在重叠的情况下,长匹配优于短匹配)。因此,只有当前所有的部分匹配结果都不是完全匹配时,才能开始处理匹配。
进一步优化
不出所料,重建 regex 引擎对 Prexy 的性能产生了负面影响。不过,与旧解决方案相比,我们仍有很大的余地,而且通过进一步优化,我们的性能甚至超过了重建 regex 引擎之前的性能。我们应用的优化之一是记住是否将规则应用到特定文件。CSS 和 JS 文件等静态资产几乎从不更改,因此,如果规则从未在特定文件上匹配过,那么每次执行规则都是无用的。另一种方法是缓存替换结果,但这种策略的缺点是需要大量内存来存储所有文件。通过记住要应用哪些规则,我们只需将信息存储为位图,每个文件只需几个字节。
此外,我们还充分利用了 regex 引擎的优化功能,例如,在替换中不使用捕获组时,不会保留捕获组。因此,regex 引擎只需记住整个匹配的开始和结束,从而节省了工作。我们还禁用了命名捕获组,因为这在流式变体中相当复杂,而且旧的解决方案也不支持命名捕获组,所以并不一定有必要这样做。所有这些优化最终带来了以下性能:

最终结果
在对 Prexy 进行广泛测试以确保其行为与旧方案一致后,我们开始分阶段部署。大约一周内,我们逐个启用了每个克隆站点的 Prexy。在监控中,启用后的效果一目了然:响应时间缩短约 50% 的情况并不罕见;对于替换规则较少的其他客户,我们也观察到了约 10% 的性能提升。 我们自己的网站速度提升了约 30%(约 50 毫秒)。
在推出 Prexy 后,我们在整个基础设施中看到了以下不同之处。
内存消耗减少 33
CPU 使用率降低 20
响应速度提高 10-50
总而言之,我们可以说 Prexy 是一个成功的项目。通过替换过时的 NGINX 模块,我们现在可以使用更新、更高效的技术,为我们所有的客户带来明显的可衡量的差异。今后,我们将继续改进 Clonable,既要增加新功能,也要增强现有功能。
感谢您阅读这篇工程博客。如果您希望更多地了解我们产品的技术细节,请告诉我们。对这个项目感到兴奋吗?那就来看看我们的招聘页面吧:)