如何优化 PHP Laravel Web 应用程序以获得高性能?

提升 Laravel 应用速度的技巧

Laravel 框架功能强大,但速度并非其强项。本文将探讨一些优化技巧,帮助你提升 Laravel 应用的运行速度。

现今,PHP 开发人员几乎都接触过 Laravel 。 初级或中级开发者喜欢它提供的快速开发体验,而资深开发者则因市场需求而不得不学习它。

不可否认的是,Laravel 的出现为 PHP 生态系统注入了新的活力(如果没有 Laravel,我可能早就放弃 PHP 了)。

Laravel 团队对自身能力的自信可见一斑。

为了简化开发过程,Laravel 在幕后做了大量工作,以确保开发者能够轻松舒适地工作。 然而,这些“神奇”功能在运行时,实际上需要执行大量的底层代码。 即使是简单的异常追踪,也会发现层层嵌套的函数调用。

例如,一个视图中的编译错误可能需要追踪 18 个函数调用。 我个人遇到过 40 个的情况,如果你使用了其他库和插件,函数调用数量很容易会更多。

关键在于,默认情况下,这种层层堆叠的代码结构会导致 Laravel 运行缓慢。

Laravel 究竟有多慢?

客观地说,这个问题很难回答,原因有很多。

首先,没有公认的、客观的衡量 Web 应用程序速度的标准。 与什么相比更快或更慢? 在什么条件下?

其次,Web 应用程序的性能依赖于多种因素(如数据库、文件系统、网络和缓存等),因此单独谈论框架速度是不严谨的。一个数据库性能很差的 Web 应用,即使框架本身速度很快,整体速度也会很慢。

正是这种不确定性使得基准测试变得流行。 尽管基准测试本身可能意义不大(参考 这里这里),它们至少提供了一个参考框架,帮助我们不至于迷失方向。 因此,在谨慎看待基准测试的前提下,让我们对 PHP 框架的速度有一个粗略的概念。

根据这个比较权威的 GitHub 资源,以下是 PHP 框架的性能排名:

你可能很难在图中找到 Laravel 的身影,因为它排名非常靠后。是的,Laravel 的性能在这些框架中垫底! 当然,这些框架中的大多数并不实用,甚至不常用,但这确实表明,与其他更流行的框架相比,Laravel 的运行速度相对较慢。

通常,这种“缓慢”在日常应用中不会很明显,因为我们日常的 Web 应用程序很少会达到很高的并发量。 但是,一旦并发量增加(例如,超过 200-500 个并发),服务器就会开始变得吃力,甚至崩溃。 此时,即使投入更多的硬件也无济于事,基础设施成本会迅速攀升。

不过,请不要灰心! 本文的重点不是揭示问题,而是提供解决方案。

好消息是,你可以通过多种方式来提升 Laravel 应用的运行速度。 而且提升的幅度可以达到数倍。 这并非玩笑。 通过优化,你可以使相同的代码库运行得更快,并每月节省数百美元的基础设施/托管成本。 那么,具体应该如何做呢? 让我们开始吧。

四种类型的优化

在我看来,PHP 应用程序的优化可以分为四个不同的层面:

  • 语言层面: 使用更新版本的 PHP,并避免使用会降低代码运行速度的特定功能或编码风格。
  • 框架层面: 这也是本文的重点。
  • 基础设施层面: 调整 PHP 进程管理器、Web 服务器、数据库等。
  • 硬件层面: 切换到更好、更快、更强大的硬件托管服务商。

所有这些类型的优化都有其用武之地(例如,PHP-fpm 优化非常重要且有效)。 但本文将专注于第二类优化:与框架相关的优化。

顺便说一句,这些编号背后没有任何依据,也不是公认的标准。我只是随意编排的。 请不要引用我说“我们需要在服务器上进行 3 类优化”,否则你的团队领导会杀了你,然后找到我,再把我杀了。 😀

现在,我们终于来到了本文的核心内容。

注意 N+1 数据库查询问题

N+1 查询问题是使用 ORM 时经常遇到的一个问题。 Laravel 提供了强大的 ORM(Eloquent),它非常方便易用,以至于我们常常忽略了底层发生了什么。

考虑一个非常常见的场景:显示某个客户列表下的所有订单列表。 这在电商系统以及任何需要显示与某些实体相关的所有实体的报告界面中很常见。

在 Laravel 中,我们可以这样编写一个控制器函数:

class OrdersController extends Controller 
{
    // ... 
    public function getAllByCustomers(Request $request, array $ids) {
        $customers = Customer::findMany($ids);        
        $orders = collect(); // new collection
        
        foreach ($customers as $customer) {
            $orders = $orders->merge($customer->orders);
        }
        
        return view('admin.reports.orders', ['orders' => $orders]);
    }
}

这段代码看起来简洁优雅,但它是一种灾难性的编程方式。

原因如下。

当我们要求 ORM 查找给定的客户时,会生成一个类似这样的 SQL 查询:

SELECT * FROM customers WHERE id IN (22, 45, 34, . . .);

这完全符合预期。 结果,所有返回的行都存储在控制器函数内的 $customers 集合中。

现在,我们逐个遍历每个客户并获取他们的订单。 这将执行以下查询:

SELECT * FROM orders WHERE customer_id = 22;

这个查询执行的次数与客户数量相等。换句话说,如果我们需要获取 1000 个客户的订单数据,则执行的数据库查询总数将为 1(用于获取所有客户的数据)+ 1000(用于获取每个客户的订单数据)= 1001。 这就是 N+1 问题的由来。

我们能做得更好吗? 当然可以! 通过使用预加载(eager loading),我们可以强制 ORM 执行 JOIN 操作,并在单个查询中返回所有需要的数据! 就像这样:

$orders = Customer::findMany($ids)->with('orders')->get();

生成的数据结构当然是嵌套的,但是可以轻松地提取订单数据。 在这种情况下,生成的单个查询如下:

SELECT * FROM customers INNER JOIN orders ON customers.id = orders.customer_id WHERE customers.id IN (22, 45, . . .);

显然,单个查询比一千个额外查询要高效得多。 想象一下,如果有 10,000 个客户需要处理会发生什么! 或者如果我们还想显示每个订单中包含的商品,那就更糟糕了!请记住,预加载技术几乎总是一个好主意。

缓存配置!

Laravel 灵活性高的原因之一是它拥有大量的配置文件。 你想更改图像的存储方式或位置吗?

只需更改 config/filesystems.php 文件即可(至少在本文撰写之时)。 你想使用多个队列驱动程序吗? 可以随意在 config/queue.php 中描述它们。 粗略统计,框架的不同方面共有 13 个配置文件,保证你不会失望。

考虑到 PHP 的运行机制,每次有新的 Web 请求进入时,Laravel 都会被唤醒,启动所有组件,并解析所有这些配置文件,以确定如何执行这次请求。 然而,这些配置信息通常不会频繁更改。 在每个请求上都重新解析配置是一种不必要的浪费,解决方法是 Laravel 提供的简单命令:

php artisan config:cache

此命令会将所有可用的配置文件组合成一个文件,并将其缓存到某个位置以便快速检索。 当下次有 Web 请求时,Laravel 将直接读取这个缓存文件并启动。

也就是说,配置缓存是一项非常微妙的操作,可能会导致错误。 最大的陷阱是,一旦你执行了这个命令,除了配置文件之外的任何地方对 env() 函数的调用都将返回 null!

仔细想想,这确实有道理。 如果你使用了配置缓存,你就是在告诉框架,“我相信我已经正确配置了一切,我 100% 确定我不希望它们发生变化。” 换句话说,你希望环境保持静态,而这正是 .env 文件的作用。

综上所述,以下是一些关于配置缓存的铁律:

  • 只在生产环境中使用此功能。
  • 只有在你非常确定要冻结配置时才使用此功能。
  • 如果出现问题,请使用 php artisan cache:clear 命令撤销配置缓存。
  • 祈祷不会给业务带来太大的损失!

减少自动加载的服务

为了提供便利,Laravel 在启动时会加载大量服务。 这些服务在 config/app.php 文件中的 ‘providers’ 数组中定义。 以下是我的环境中的服务提供者列表:

/*
    |--------------------------------------------------------------------------
    | Autoloaded Service Providers
    |--------------------------------------------------------------------------
    |
    | The service providers listed here will be automatically loaded on the
    | request to your application. Feel free to add your own services to
    | this array to grant expanded functionality to your applications.
    |
    */

    'providers' => [

        /*
         * Laravel Framework Service Providers...
         */
        Illuminate\Auth\AuthServiceProvider::class,
        Illuminate\Broadcasting\BroadcastServiceProvider::class,
        Illuminate\Bus\BusServiceProvider::class,
        Illuminate\Cache\CacheServiceProvider::class,
        Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
        Illuminate\Cookie\CookieServiceProvider::class,
        Illuminate\Database\DatabaseServiceProvider::class,
        Illuminate\Encryption\EncryptionServiceProvider::class,
        Illuminate\Filesystem\FilesystemServiceProvider::class,
        Illuminate\Foundation\Providers\FoundationServiceProvider::class,
        Illuminate\Hashing\HashServiceProvider::class,
        Illuminate\Mail\MailServiceProvider::class,
        Illuminate\Notifications\NotificationServiceProvider::class,
        Illuminate\Pagination\PaginationServiceProvider::class,
        Illuminate\Pipeline\PipelineServiceProvider::class,
        Illuminate\Queue\QueueServiceProvider::class,
        Illuminate\Redis\RedisServiceProvider::class,
        Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
        Illuminate\Session\SessionServiceProvider::class,
        Illuminate\Translation\TranslationServiceProvider::class,
        Illuminate\Validation\ValidationServiceProvider::class,
        Illuminate\View\ViewServiceProvider::class,

        /*
         * Package Service Providers...
         */

        /*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,
        App\Providers\AuthServiceProvider::class,
        // App\Providers\BroadcastServiceProvider::class,
        App\Providers\EventServiceProvider::class,
        App\Providers\RouteServiceProvider::class,

    ],

我数了一下,一共列出了 27 个服务! 你可能需要所有这些服务,但这不太可能。

例如,我目前正在构建一个 REST API,这意味着我不需要会话服务提供者或视图服务提供者等。此外,因为我按照自己的方式处理某些事情,而不是遵循框架的默认设置,我还可以禁用身份验证服务提供者、分页服务提供者和翻译服务提供者等。 总而言之,几乎一半的服务对于我的用例来说是不必要的。

仔细检查你的应用程序。 它是否需要所有这些服务提供商? 但是,请务必不要盲目地注释掉这些服务并将其发布到生产环境! 在部署之前,请运行所有测试,并在开发和测试环境中手动检查,并且要非常谨慎。 🙂

明智地使用中间件堆栈

当你需要对传入的 Web 请求进行一些自定义处理时,创建新的中间件就是答案。 在 app/Http/Kernel.php 文件中将中间件添加到 web 或 api 堆栈中似乎非常方便,这样它就可以在整个应用程序中使用,即使它没有执行任何侵入性的操作(例如日志记录或通知)。

然而,随着应用程序的增长,如果每个请求都经过所有这些全局中间件,即使没有业务逻辑,也会对应用程序性能产生负面影响。

换句话说,请注意添加或应用新中间件的位置。 全局添加某些东西可能更方便,但从长远来看,性能损失非常大。 我知道,如果每次有新需求时都需要有选择地应用中间件,你会感到很麻烦,但我建议你忍受这种麻烦!

避免使用 ORM(有时)

虽然 Eloquent 使数据库交互的许多方面变得非常愉快,但它也牺牲了速度。 作为映射器,ORM 不仅需要从数据库中获取记录,还需要实例化模型对象并使用列数据填充它们。

因此,如果你执行简单的 $users = User::all() 操作并且有 10,000 个用户,那么框架将从数据库中获取 10,000 行,并在内部执行 10,000 次 new User() 操作,并使用相关数据填充它们的属性。 这在幕后完成了大量的工作。 如果数据库成为应用程序的瓶颈,那么绕过 ORM 有时是一个不错的选择。

对于复杂的 SQL 查询,尤其如此。 在这种情况下,你可能需要在闭包上编写闭包才能获得高效的查询。 在这种情况下,首选执行 DB::raw() 并手动编写查询。

根据 这个 性能研究,即使是简单的插入操作,随着记录数量的增加,Eloquent 的性能也会下降:

尽可能使用缓存

Web 应用程序优化的最佳秘诀之一是缓存。

简单来说,缓存意味着预先计算并存储昂贵的结果(在 CPU 和内存使用方面),并在重复相同查询时直接返回它们。

例如,在电商网站中,可能有 200 万种商品。大多数情况下,人们感兴趣的是新上架的、价格在一定范围内、适合特定年龄段的商品。 查询数据库以获取这些信息是一种浪费,因为查询结果通常不会频繁更改,因此最好将这些结果存储在可以快速访问的位置。

Laravel 内置了对多种缓存类型的支持 缓存. 除了使用缓存驱动程序并从头开始构建缓存系统之外,你可能还想使用一些 Laravel 包来促进 模型缓存, 查询缓存 等。

但请注意,除了某些简单的用例外,预构建的缓存包可能会导致比它们解决的问题更多的问题。

首选内存缓存

当你在 Laravel 中缓存某些内容时,你有很多选项来存储需要缓存的计算结果。 这些选项也称为 缓存驱动程序. 虽然使用文件系统来存储缓存结果是可行的,但这并非缓存的真正意义所在。

理想情况下,你应该使用内存缓存(完全存储在 RAM 中),例如 Redis、Memcached 和 MongoDB 等。这样,在高负载下,缓存才能发挥重要作用,而不会成为瓶颈本身。

现在,你可能会认为拥有 SSD 磁盘与使用 RAM 几乎相同,但两者相差甚远。 即使是非正式的 基准 测试也显示,RAM 的速度是 SSD 的 10-20 倍。

在缓存方面,我最喜欢的系统是 Redis。 它快得令人难以置信(每秒 100,000 次读取操作很常见),对于非常大的缓存系统,可以轻松地扩展为 集群

缓存路由

就像应用程序配置一样,路由也不会随时间发生太多变化,因此也是缓存的理想选择。 如果你像我一样无法忍受大文件,并且最终将 web.php 和 api.php 分割成多个文件,则尤其如此。 Laravel 提供一个命令可以将所有可用的路由打包,并将其保存在方便的位置以供将来访问:

php artisan route:cache

当您最终添加或更改路由时,只需执行以下操作:

php artisan route:clear

图片优化和 CDN

图像是大多数 Web 应用程序的核心和灵魂。 它们也是最大的带宽消耗者,也是应用程序/网站运行缓慢的最大原因之一。 如果你只是简单地将上传的图像存储在服务器上,并通过 HTTP 响应发送回给用户,那么你就错过了大量的优化机会。

我的第一个建议是不要在本地存储图像。 因为存在数据丢失的问题需要处理,而且根据你的客户所在的地理区域,数据传输可能会非常缓慢。

相反,寻求像 Cloudinary 这样的解决方案,它可以实时自动调整图像大小并优化图像。

如果这不可行,请使用 Cloudflare 之类的工具来缓存和提供图像,同时将它们存储在你的服务器上。

即使这也不可能,稍微调整你的 Web 服务器软件以压缩资源并引导访问者的浏览器缓存内容,也会产生很大的不同。 以下是 Nginx 配置的片段:

server {
   # file truncated
    
    # gzip compression settings
    gzip on;
    gzip_comp_level 5;
    gzip_min_length 256;
    gzip_proxied any;
    gzip_vary on;

   # browser cache control
   location ~* .(ico|css|js|gif|jpeg|jpg|png|woff|ttf|otf|svg|woff2|eot)$ {
         expires 1d;
         access_log off;
         add_header Pragma public;
         add_header Cache-Control "public, max-age=86400";
    }
}

我知道图像优化与 Laravel 无关,但这是一个如此简单而强大的技巧(而且经常被忽视),所以我不得不提一下。

自动加载器优化

自动加载是 PHP 中一个简洁的功能,可以说它使这门语言免于厄运。 然而,通过解密给定的命名空间字符串来查找和加载相关类的过程需要时间,并且可以在需要高性能的生产部署中避免。 幸运的是,Laravel 再次为此提供了一个简单的命令:

composer install --optimize-autoloader --no-dev

与队列交朋友

队列 可以让你在处理需要几毫秒才能完成的任务时,不会阻塞主线程。 一个很好的例子是发送电子邮件。Web 应用程序中一个广泛使用的场景是在用户执行某些操作时发送通知邮件。

例如,在一家新推出的公司中,你可能希望在有人下的订单超过特定金额时通知公司领导(大约 6-7 个电子邮件地址)。 假设你的电子邮件网关可以在 500 毫秒内响应你的 SMTP 请求,这意味着用户需要等待 3-4 秒才能收到订单确认,这是一种非常糟糕的用户体验。

补救措施是在任务到来时将其存储在队列中,告知用户一切正常,然后在后台处理这些任务。 如果发生错误,排队任务可以在失败之前重试几次。

虽然排队系统使设置变得稍微复杂(并增加了一些监控开销),但它在现代 Web 应用程序中是必不可少的。

资源优化 (Laravel Mix)

对于 Laravel 应用程序中的任何前端资源,请确保有一个管道可以编译和缩小所有资源文件。 对于那些熟悉 Webpack、Gulp、Parcel 等打包系统的人来说,这并不难理解。 但如果你还没有这样做,那么 Mix 是一个可靠的建议。

Mix 是一个轻量级的 Webpack 包装器,可以处理所有用于生产的 CSS、SASS 和 JS 文件。 一个典型的 .mix.js 文件可以像下面这样简单,但仍然可以发挥巨大作用:

const mix = require('laravel-mix');

mix.js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css');

当你准备好部署到生产环境并运行 npm run production 时,它会自动处理导入、缩小、优化等一系列操作。 Mix 不仅处理传统的 JS 和 CSS 文件,还处理应用程序工作流程中可能包含的 Vue 和 React 组件。

更多信息请参考这里

总结

性能优化与其说是科学,不如说是一门艺术。 你需要知道如何做以及做多少,而不仅仅是做什么。 也就是说,在 Laravel 应用程序中,你可以优化很多方面,而且有很多可以优化的点。

但无论你做什么,我都想给你一些建议。 优化应该在有充分理由的情况下进行,而不是因为它听起来很酷,或者因为你过于担心那些只有 10 个用户的应用程序的性能问题。

如果你不确定是否需要优化你的应用程序,那么你可能不需要这样做。 一个可以正常工作的应用程序比一个经过过度优化但运行不稳定的应用程序要好得多。

而且,为了帮助你从 Laravel 新手成长为大师,请查看 这个在线课程.

祝你的应用程序运行得更快、更流畅!🙂