编程

解决 PHP 扩容问题

747 2023-11-20 02:02:00

我们有一个导出功能,可以在许多后台作业过程中创建 Excel 文件。多年来工作出色。不过,那已是过去。

我们在一个PHP项目中有一个导出功能。给定一个数据源,它将在许多后台工作的过程中构建一个 Excel 文件。一旦文件完成,就开始最后一项工作,通过电子邮件向用户发送一个标记化链接供下载。这一策略运行良好,多年来没有出现任何重大问题。也就是说,直到,正如你可能已经猜到的标题,它不能正常工作。

💡 我们通过将 PHP 单体中的一个功能重写为一个独立的、长期运行的 Rust 服务来解决内存问题。

没什么特别的

在我们进入实质之前,请允许我先介绍一下。这个到处功能经过多年的设计、建造和完善,就像其他任何功能一样。我们已经进行了定期更新,我们不得不修复偶尔出现的一两个错误。但没有一个像我们开始在最大的客户中看到的内存问题那样根深蒂固或令人挠头。

这也不是在更新后才开始发生的。原本为我们服务多年的可靠系统突然遇到了障碍。超过这个点上,就不能再正常工作了。

尽管我们很惊讶,但支持工单开始堆积起来,很明显,我们需要深入研究这个问题,并尽快找到解决方案。

更大的 Seeder

我们进行故障排除的第一步是在受控环境中重现问题。我们没有失败的测试,并且我们无法使用本地种子数据来重现问题。这不是一个好的开始。我们的 seeder 只大到可以测试一些东西并让应用感觉有点现实。

以我们最大的客户为参考,我们创建了另一个 seeder,该 seeder 将生成一个与生产中的数据库大小大致相同的数据集。这使我们能够始终如一地再现这个问题。

内存泄漏?

我只知道某个地方一定有内存泄漏。就叫它 Peter Tingle 吧。考虑到我们观察到的症状,这种假设似乎是安全的。Laravel 的队列 worker 持续运行,所以我们可能没有正确清理内存。

💡 当程序无法释放不再需要的内存时,就会发生内存泄漏,逐渐消耗越来越多的资源,直到系统变得不稳定

在接下来的几周里,我们取得了令人难以置信的进展;分析内存使用情况,探索替代策略,却一无所获。在一个必须是真正有记录的时间里,一个令人惊讶的认识击中了我们;也许这不是内存泄漏。也许这不是一个 bug。也许一切都在正常运转。

我想你可以把这段旅程称为“失败”,但我选择用“学习经验”这个词。

导出剖析

要理解这一问题,我们首先需要剖析这个过程的机制。看起来像这样:

  1. 用户初始化导出: 用户单击链接或在收件箱中设置常规导出的时间表。
  2. 导出作业链:启动后台作业链,每个作业负责打开一个 Excel 文件,将一块记录附加到文件中,然后关闭文件。
  3. 生成标记和链接: 导出完成后,会触发另一个作业发送带有标记化链接的电子邮件。此链接允许他们安全地下载导出。

导出流程

揭开罪犯的面纱

当我们剥离导出系统的各个层时,很明显这不是 bug;一切都按照设计的方式进行。

Bus::chain([
    new ExportRecords(1, 500),
    new ExportRecords(501, 1000),
    new ExportRecords(1001, 1500),
    // ...,
])->dispatch();

每个作业打开 Excel 文件,追加一块记录,然后关闭文件。这些作业中的任何一个都可能失败,这样我们就有时间修复它,然后重试失败的作业。工作链将从中断的地方恢复并完成出口。我们可以在中途结束排队的 worker,当我们重新打开它时,它会像什么都没发生一样恢复。有人可能会被数据中心的电源线绊倒,用户永远不会知道有问题。虽然这种设计具有很高的弹性,但它并不是为我们现在看到的更高的容量而设计的。

在每个作业开始时打开文件的这个看似无害的过程导致了一个重大问题。这需要将整个文件的内容加载到内存中。这意味着每个作业消耗的内存越来越多,尤其是在后半部分。一旦文件被打开,追加记录会使内存增加得更多。这就是为什么我们在容量较低时没有遇到这个问题。它没有达到内存限制。

长时运行进程

发现问题后,我们需要找到一种能够有效管理内存和处理大型数据导出需求的解决方案。换句话说,我们需要一个长时间运行的进程,在这个进程中,文件在开始时打开一次,在结束时关闭,而不是为每个记录块打开/关闭文件。这样做意味着文件的全部内容永远不会同时完全保存在内存中。一开始会“打开”一个空文件,我们会在每个区块之后收回内存,从而保持低内存使用率。

这将意味着牺牲恢复导出的能力。如果中途发生了阻止导出的事情,我们现在需要完全重启导出。

 

但是有一个问题。我们不愿意将延长超时时间去适应最大的导出量。这感觉就像是延长了事故时间。

增加 PHP 的内存限制?

我们考虑过这个问题,但时间不长。PHP 已经使用了相当多的内存。通常情况下,我们将这种内存限制用作预警信号。可能做得不对的我们过早的预警系统可能并不妥当。当然,增加内存限制会为我们争取一些时间,但我们很快就会陷入同样的境地,没有解决方案,也没有预警系统。

我们需要一个真正的修复,而不是创可贴。我开始觉得,我们需要一些也许完全不同东西…

Rust!

Rust 速度极快,内存效率高:它没有运行时或垃圾收集器,可以驱动性能关键的服务,在嵌入式设备上运行,并轻松与其他语言集成。

我们的问题的答案来自 Rust,这是一种系统编程语言,以我们的导出过程迫切需要的特性而闻名。

Rust 丰富的类型系统和所有权模型保证了内存安全和线程安全,使您能够在编译时消除许多类错误。

消除编译时常见的漏洞会让我们充满信心,尽管这对我们来说是新的。然而,这些漂亮的编译时检查是有代价的。

学习曲线

我们团队中没有人熟悉 Rust,因此采用该语言需要相当长的学习曲线。我们不仅要想办法重写我们的导出系统来解决我们看到的问题,还必须学习新的语法、新的工具和新的包(crates)生态系统。幸运的是,我们可以相互学习,并在出现错误时帮助克服错误。

我们从不害怕采用或接受有前景的新技术。如果这给了我们优势,我们不介意处理最前沿的技术的固有困难和不稳定。

在超越了最初的挫折之后,我们实际上发现 ,一旦我们习惯后,Rust 的语法和概念是有回报的。

通讯

现在我们把这个过程交给了一个完全独立的服务,问题变成了,我们如何在两者之间进行通讯?Rust 服务需要知道何时要求导出。PHP 方面需要知道文件何时准备好。我们最终做的事情相当直截了当。

当用户触发导出时,我们在数据库中创建一个 Export 记录,为其分配一个唯一的 ID。然后将 JSON 负载添加到 redis 队列中。我们的新 Rust 代理监视这个队列,并将在作业可用时处理作业。

一旦文件成功构建,我们将使用 JSON 启动一个HTTP webhook,返回到客户的实例(每个客户都有一个唯一的url),以便将电子邮件和链接发送给原始用户。

此处我们考虑使用 gRPC。Rust 和 PHP 都受支持。最后,我们需要依赖一些我们已经知道和理解的东西,这样我们才能把它带出去。也许以后吧。

结论

对我们来说,Rust 由于其内存安全性、并发支持和性能,是我们长期运行任务的更好选择。与工程中的任何事情一样,每一个决策都有取舍。使用 Rust 的决定是基于我们项目的具体要求和限制。Rust 能够有效地处理长时间运行的任务并强制执行正确性,这使它成为我们团队的理想选择。

 

PHP