你的第一个扩展问题通常不会突然出现。在一段时间内,一切都很正常:页面加载速度快,数据库几乎不费什么劲就能处理所有请求,团队也可以在不太考虑基础设施的情况下持续开发新功能。

然后,访问量开始上升。某个营销活动取得了意想不到的好效果,某个市场平台上出现了一位非常受欢迎的卖家,某款SaaS产品也获得了几家企业客户的订单。

突然间,/dashboard页面的加载时间从300毫秒增加到了2秒钟,那些原本能在几秒钟内完成的任务现在却要等待数分钟才能完成。每天下午,数据库的CPU使用率都会急剧上升。

于是你又添加了一台应用服务器,但响应时间几乎没有任何变化——因为真正的问题其实在于某个大型表上的查询效率过低。

如果你曾经在生产环境中使用过Laravel,那么你很可能经历过类似的情况。好消息是,扩展Laravel应用程序几乎从来不需要放弃这个框架本身。关键在于找出压力产生的根源,并让应用程序在负载较大时仍能保持稳定的运行状态。

在本指南中,你将学习如何识别常见的性能瓶颈、优化数据库配置、有效利用Redis、将耗时的任务放入队列中处理、优化API接口,以及如何在生产环境中监控Laravel应用程序的运行情况。

所有这些优化措施都不需要进行大规模的代码重构。真正的成效往往来自于一些实际的操作:消除低效的查询语句、将耗时的任务放入队列中、为数据库添加合适的索引、谨慎地选择哪些数据需要缓存,以及验证每一次修改是否真的带来了性能提升。

先决条件

如果你已经具备以下能力,那么你将会从本指南中获得最大的收益:

  • 使用Laravel和PHP开发应用程序

  • 编写Eloquent查询语句以及进行数据库迁移操作

  • 使用队列、作业任务以及定时执行的命令

  • 能够阅读基本的数据库查询计划

  • 能够将Laravel应用程序部署到生产服务器或平台上

  • 能够在类似生产环境的配置中同时使用Redis、MySQL或PostgreSQL

目录

当Laravel应用程序开始扩展时会发生什么

流量会改变系统的运行方式,因为它会将那些细微的低效率问题转化为永久性的成本。如果一个查询只需要80毫秒的时间来执行,那么每小时执行几百次的话并不会造成什么问题;但是,如果在一个每分钟被访问数千次的页面上,每个页面视图都要执行30次这样的查询,那么这个查询就会成为导致系统性能下降的瓶颈。

这种压力通常会出现在一些可以预见的地方。请求量增加意味着需要更多的PHP工作进程、更多的数据库连接、更大的队列容量,以及更多的Redis操作。

无论是MySQL还是PostgreSQL,数据库往往是第一个出现问题的组件。当数据生成的速度快于工作进程处理这些数据的速度时,队列就会堵塞。只有当访问频率保持较高且缓存未命中的情况得到有效控制时,缓存才能发挥真正的作用。而如果对所有系统资源进行水平扩展,那些编写得不够规范的代码也很可能会导致高昂的云服务费用。

因此,进行扩展工作之前,首先需要进行测量,而不是凭猜测来决定方案。在做出任何更改之前,你必须明确知道哪些环节已经达到了饱和状态:是请求处理所需的CPU资源、数据库I/O操作、锁竞争情况、Redis的响应延迟、队列长度,还是外部API的负载问题,又或者是数据包的大小超出了限制。

在一个正在扩展的Laravel应用程序中,一个典型的请求会经过多个处理环节。用户发出请求后,负载均衡器会将请求路由到应用服务器;Laravel会首先检查Redis中是否已经有缓存结果;如果没有找到缓存数据,就会查询数据库,将计算得到的结果存储回Redis中,并将那些需要较长时间才能完成的操作放入队列中。之后会有工作进程来处理这些任务,而Laravel则会立即返回响应给用户。

这里有一个重要的点:增加应用服务器的数量对于那些执行速度缓慢的查询、缺少索引的问题,或者队列负担过重的情况,并没有任何帮助。只有当这些服务器背后的共享资源也能跟上扩展的速度时,水平扩展才能真正发挥效果。

Laravel中常见的瓶颈问题

Laravel本身很少会导致扩展方面的问题。大多数问题都是出在应用程序代码与数据库、网络以及后台工作进程之间的交互方式上。

N+1查询问题

最常见的导致性能问题的就是N+1查询。例如,如果你加载了一组模型对象,然后对每个对象中的某个关联关系都进行了一次查询操作:

use App\Models\Post;

$posts = Post::latest()-->take(50)-->get();

foreach (\(posts as \)post) {
    echo $post->author->name;
}

这样实际上进行了51次查询:一次是获取所有帖子的信息,另外50次是分别获取每条帖子的作者信息。而如果改为一次性加载所有关联关系的数据,情况就会不同:

use AppModels\Post;

$posts = Post::with('author')
    ->latest()
    ->take(50)
    ->get();

foreach (\(posts as \)post) {
    echo $post->author->name;
}

在生产环境中,这类问题往往比较隐蔽。它们常常隐藏在API接口、Blade模板组件以及授权验证逻辑中,因此从控制器层面很难发现这些关联关系的访问操作。

\(orders = Order::where('account_id', \)accountId) ->where('status', 'paid') ->whereBetween('created_at', [\(start, \)end]) ->latest() ->paginate(50);

如果orders表中有数百万条记录,且没有合适的复合索引,数据库将会扫描远超过实际需要的数据量。因此,需要为那些在查询中经常被使用的字段创建索引:

use Illuminate\Database.Migrations\Migration;
use Illuminate\Database.SchemaBlueprint;
use Illuminate\Support\Facades Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->index(['account_id', 'status', 'created_at']);
        });
    }

    public function down(): void
    {
        Schema::table('orders', function (Blueprint $table) {
            $table->dropIndex(['account_id', 'status', 'created_at'];
        });
    }
};

不过,索引并不会免费产生。它们会占用存储空间,并且会降低数据写入的速度。因此,只有对于那些在查询中确实会被频繁使用的字段,才需要创建索引,而不是为所有可能出现在where子句中的字段都创建索引。

效率低下的“贪婪加载”机制

有时候,人们也会走向另一个极端:为了“以防万一”,会加载所有的关联数据,但这会导致内存消耗增加,并且还会传输那些请求根本不会使用的数据:

$users = User::with([
'profile',
'teams',
'roles.permissions',
'invoices.lineItems.product',
])-->get();

对于显示单个用户信息的管理员页面来说,这种做法可能没有问题;但在列表页面上,这样做就会造成性能瓶颈。因此,应该限制“贪婪加载”的范围,只选择真正需要的字段:

$users = User::query()
->select(['id', 'name', 'email'])
->with([
'profile:id,user_id,avatar_url',
'teams:id,name',
])
->latest()
->paginate(25);

需要注意的是:过于狭小的选择范围可能会导致后续代码无法使用那些没有被加载的字段。因此,这种技术只适用于那些读取操作占主导地位的接口,在这些场景中,使用这种技术确实能够带来显著的性能提升。

同步处理

高流量的应用程序需要快速完成Web请求的处理。然而,发送电子邮件、生成PDF文件、调用第三方API、调整图片大小以及构建导出文件等内容,通常不属于请求处理的范畴。如果将这些操作包含在请求处理流程中,就会影响应用程序的性能:

public function store(Request $request)
{
\(order = Order::create(\)request->validated());

Mail::to(\(order->user)->send(new OrderReceipt(\)order));

return response()->json($order, 201);
}

因此,应该将这些任务放入队列中异步处理:

public function store(StoreOrderRequest $request)
{
\(order = Order::create(\)request->validated());

SendOrderReceipt::dispatch($order->id);

return response()->json([
'id' => $order->id,
'status' => 'accepted',
], 202);
}

现在,你的响应时间不再受邮件服务提供商的影响。如果该提供商在下午的运行速度较慢,系统会自动缓冲这些延迟,因此用户无需等待。

大型数据量

过大的JSON响应数据会对整个处理流程造成影响:无论是应用服务器进行序列化处理,还是网络传输,或是客户端解析数据,都会遇到困难。一个常见的错误是本应返回摘要信息,却实际返回了整个模型:

return User::with('orders', 'invoices', 'teams')->findOrFail($id);

因此,应该明确定义API资源的结构:

use Illuminate\Http\Resources.Json\JsonResource;

class UserSummaryResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'avatar_url' => $this->profile ? $this->avatar_url : '',
            'plan' => $this->subscription_plan,
        ];
    }
}

明确规定的响应结构能够使端点的性能更容易预测,同时避免不必要的数据耦合。

代价高昂的联接操作

联接操作确实很有用,但当在大型表上进行这样的操作时,尤其是当这些操作针对未建立索引的列进行排序或过滤时,会严重消耗数据库的性能:

$rows = DB::table('orders')
    ->join('users', 'users.id', '=', 'orders.user_id')
    ->join('accounts', 'accounts.id', '=', 'users.account_id')
    ->where('accounts.region', 'us-east')
    ->where('orders.status', 'paid')
    ->orderByDesc('orders.created_at')
    ->limit(100)
    ->get();

在大规模应用中,你可能需要对某些数据进行反规范化处理,或者预先计算出用于报告的统计数据,甚至将分析功能完全从主事务数据库中分离出来。不要把反规范化视为一种妥协或失败的表现。例如,将account_id这样的字段复制到orders表中,就可以避免进行代价高昂的联接操作。为此所付出的代价就是需要确保这些重复数据的一致性,但这种权衡往往是值得的。

如何优化数据库

当Laravel应用程序运行速度变慢时,首先应该检查数据库是否存在问题。

根据实际查询模式添加索引

在优化数据库之前,先分析慢查询日志、数据库性能指标以及相关追踪数据,而不是凭直觉进行判断。如果应用程序经常需要按账户查找用户的活跃订阅信息,就应该为这种查询模式创建相应的复合索引:

Schema::table('subscriptions', function (Blueprint $table) {
    $table->index(['account_id', 'status', 'renews_at']);
});

然后编写相应的查询语句,确保它们能够利用这些索引来提高性能:

$subscription = Subscription::where('account_id', accountId)
    ->where('status', 'active')
    ->where('renews_at', ›= now())
    ->orderBy('renews_at')
    ->first();

养成在添加索引后运行 EXPLAIN 命令的习惯,以确认查询计划是否发生了变化。如果优化器忽略了某个索引,那么使用该索引只会增加数据写入的开销。

有意地使用“急切加载”机制

确保“急切加载”所获取的数据与接口实际返回的内容相匹配。对于列表类型的接口,应尽量保持关联关系的简单性,并对其范围进行限制:

$projects = Project::query()
    ->select(['id', 'account_id', 'name', 'updated_at'])
    ->withCount('openTasks')
    ->with([
        'owner:id,name',
    ])
    ->where('account_id', $accountId)
    ->latest('updated_at')
    ->paginate(30);

当只需要获取某个数值时,使用 withCount 会比加载整个关联关系来计算该数值更高效:

$teams = Team::query()
    ->withCount([
        'members',
        'invitations as pending_invitations_count' => function (\(query) => \)query->whereNull('accepted_at'),
    ])
    ->paginate(25);

这样做可以确保内存使用量保持稳定,而在列表页面上,这一点尤为重要。

在添加硬件设备之前先优化查询语句

升级数据库服务器确实能提升处理效率,但那些效率低下的查询语句依然会存在,直到流量激增时才会再次暴露出来。因此在考虑购买更强大的硬件设备之前,首先要找出那些消耗最多资源的查询语句。在本地开发环境或测试环境中,记录这些慢速查询语句非常容易:

use Illuminate\Database\EventsQUERYExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::listen(function (QueryExecuted $query) {
    if ($query->time > 100) {
        Log::warning('检测到慢速查询', [
            'sql' => $query->toRawSql(),
            'time_ms' => $query->time,
        ]);
    }
});

不过在生产环境中使用这种方法时要格外小心。因为绑定变量中可能包含敏感数据,而大量日志记录本身也可能会影响系统性能。

通过分块处理方式来操作大型表格

在进行批量处理时,绝对不要将整个大型表格加载到内存中:

User::where('is_active', true)
    ->chunkById(1000, function ($users) {
        foreach (\(users as \)user) {
            RefreshUserSearchIndex::dispatch($user->id);
        }
    });

当数据在处理过程中可能会发生变化时,chunkById 方法比基于偏移量的分块方式更安全,因为这种方法会跟踪最后访问过的记录ID,而不是使用固定的偏移量。对于数据量非常大的情况,可以考虑将记录流式传输或分批写入文件。

对于数据量较大的数据流,应使用游标分页方式

对于需要滚动浏览的数据列表,如果使用基于偏移量的分页方式,用户滚动得越深,查询速度就会越慢,因为数据库仍然需要跳过那些不需要返回的记录。因此对于数据流、审计日志、消息记录和时间线等内容来说,使用游标分页方式通常会更加合适。

$events = AuditEvent::query()
    ->where('account_id', $accountId)
    ->orderByDesc('id')
    ->cursorPaginate(50);

return AuditEventResource::collection($events);

这种机制依赖于一个稳定且经过索引处理的排序列,并使用“下一个”/“上一个”游标来代替随意指定的页码——而这正是无限滚动功能所通常需要的。

带有读取复制的拆分读取机制

随着读取流量的增加,副本可以分担主服务器的负担:

如何利用Redis进行扩展

在Laravel的生产环境中,Redis通常会承担很多任务:缓存、会话管理、速率限制、队列处理、锁机制以及Horizon指标的收集与展示。虽然Redis运行速度很快,但在使用它时仍然需要仔细规划:比如键的设计要合理,设置合适的过期策略,定期监控内存使用情况,并制定有效的数据失效处理方案。

缓存

对于那些被频繁请求且可以容忍数据稍有过时的操作,应该使用缓存机制:

use Illuminate\Support\Facades CACHE;

$stats = Cache::remember(
"accounts:{$account->id}:dashboard-stats",
now() -> addMinutes(5),
fn () => DashboardStats::forAccount($account) -> calculate()
);

较短的缓存有效期往往能产生显著的效果。设置为五分钟的缓存机制,就可以避免成千上万的重复查询操作,同时确保大多数数据展示页面上的信息都是最新的。

当数据在某个特定事件发生之后发生了变化时,应该立即更新缓存中的数据:

Order::created(function (Order $order) {
Cache::forget("accounts:{$order->account_id}:dashboard-stats");
});

只有当缓存键的设计具有可预测性,并且数据的失效处理机制是基于具体的业务事件来进行的,缓存机制才能发挥出最佳的效果。

会话管理

对于那些水平扩展的应用服务器来说,基于文件的会话存储方式存在一定的缺陷:因为下一个请求可能会被发送到一台从未保存过该会话信息的服务器上。因此,应该将会话数据存储在Redis或数据库中,这样任何一台服务器都能够处理所有的请求。

SESSION_DRIVER=redis
CACHE_STORE=redis
QUEUE_CONNECTION=redis

速率限制

速率限制能够保护您免受恶意客户、无限循环以及高负载端点的影响:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(120)->by(
optional(\(request->user())->>id ?: \)request->ip()
);
});

对于那些使用频率较高的接口,应该设置更严格的限制:

RateLimiter::for('exports', function (Request $request) {
return Limit::perHour(10)->by($request->user()-->id);
});

可以根据业务需求来设定不同的限制规则。登录、搜索、导出以及Webhook接口通常不需要相同的限制机制。

队列

Redis是一种常用的队列后端,因为它运行速度很快,而且Horizon框架也对其提供了良好的支持:

QUEUE CONNECTION=redis

可以从请求中将任务分配到相应的队列中:

GenerateInvoicePdf::dispatch($invoice->id)
->>onQueue('documents');

可以根据不同的工作类型来划分队列,例如defaultemailswebhooksdocumentsimports,因为不同类型的任务可能需要不同的处理线程数和重试策略。为这些队列起有意义的名称非常重要——在出现问题时,“documents队列延迟了20分钟”这一信息比“default队列运行缓慢”要有用得多。

如何使用基于队列的架构

队列是Laravel中最强大的扩展工具之一。它们能够让应用程序快速接收任务,并以可控的方式异步处理这些任务,从而提高系统的吞吐量。此外,当第三方API出现故障时,队列系统能够自动重试任务,而不会占用PHP-FPM的处理资源。

Laravel的队列机制

一个优秀的任务应该体积小、具有幂等性,并且可以安全地被重新执行:

use App\Mail(OrderReceiptMail;
use App\Models.Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation.Queue Queueable;
use Illuminate\Support\Facades-Mail;

class SendOrderReceipt implements ShouldQueue
{
use Queueable;

public int $tries = 3;
public int $backoff = 60;

public function __construct(public int $orderId)
{
}

public function handle(): void
{
$order = Order::with('user')->findOrFail($this->orderId);

Mail::to($order->user)->send(new OrderReceiptMail($order));
}
}

在将任务分配到队列时,应该只传递任务的ID,而不是整个Eloquent模型。因为在任务执行之前,模型结构可能会发生变化,而将整个模型序列化会使得数据量过大。对于外部API调用,还需要设置超时机制,并防止重复处理相同的任务。

use App\Models(Order;
use App\Services\CrmClient;
use Illuminate\Contracts_QUEUE\ShouldQueue;
use Illuminate\Foundation\Queue.Queueable;

class SyncOrderToCrm implements ShouldQueue
{
use Queueable;

public int $tries = 3;
public int $backoff = 60;

public function __construct(public int $orderId)
{
}

public function handle(CrmClient $crm): void
{
$order = Order::findOrFail($this->orderId);

if ($order->crm_synced_at) {
return;
}

$crm->upsertOrder($order->external_reference, [
'total' => $order->total,
'status' => $order->status,
});

$order->forceFill(['crm_synced_at' => now()]) -> save();
}
}

检查`crm_synced_at`字段的意义就在于这一点。在实际情况中,作业会被执行多次,而幂等性正是确保重试不会导致重复计费或重复同步的关键。

Horizon

Horizon使您能够监控并控制Redis队列。典型的配置方式为不同的工作负载使用不同的监管进程:

失败的任务与重试机制

只有当故障是暂时性的时候,重试才会起到作用。对于那些已经永久性出现问题的任务来说,重复执行它们只会浪费系统资源。对于那些有截止日期的任务,应该使用`retryUntil`方法:

use DateTime;
use Throwable;

public function retryUntil(): DateTime
{
return now() -> addMinutes(30);
}

public function failed(Throwable $exception): void
{
ImportBatch::whereKey($this->batchId) -> update([
'status' => 'failed',
'failed_reason' => $exception->getMessage(),
]);
}

使用`failed`方法可以将任务失败的状态记录下来,以便人类工作人员能够看到这些信息。不过,请注意:对于那些需要与第三方服务交互的任务,千万不要设置无限次的重试次数。

队列监控

需要同时跟踪队列的深度、等待时间、失败率以及处理耗时等指标。仅仅关注队列的深度是不足以了解整体运行情况的。当队列深度开始增加时,应该系统地分析其中的原因:负责处理任务的进程是否能够跟上新进入队列的任务数量?如果队列长度持续增长,就需要检查单个任务的处理耗时情况。如果导致延迟的原因是数据库查询,那么就应该优化查询语句或减少并发处理的进程数;如果问题出在第三方API上,就可以设置重试间隔时间或使用断路器机制来防止系统过载;如果任务的执行瓶颈在于CPU资源,那就需要增加处理进程的数量,或者将任务分解为更小的部分来处理。

不过,对于“增加更多工作人员”这种冲动还是要谨慎对待。如果不先检查数据库就盲目添加员工,反而可能会使问题变得更加严重。更多的员工意味着会有更多的并发查询、更多的锁操作,而当主服务器本身就已经不堪重负时,这些因素只会进一步加大它的压力。

如何优化API性能

API之所以需要得到特别关注,是因为客户端会反复调用它们,而且随着时间的推移,传输的数据量也会逐渐增加。

API资源

合理配置资源有助于确保响应结果的结构清晰明了:

class OrderResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status,
            'total' => $this->total,
            'placed_at' => $this->>created_at-&toIso8601String(),
            'customer' => new CustomerSummaryResource($this->whenLoaded('customer')),
        ];
    }
}

whenLoaded这个方法起到了关键作用。当关联数据并没有被预先加载时,它会防止系统自动触发不必要的查询操作:

$orders = Order::query()
    ->with('customer:id,name')
    ->where('account_id', $accountId)
    ->>latest()
    ->>paginate(50);

return OrderResource::collection($orders);

分页功能

如果返回无限制数量的数据,很容易导致API性能问题。只有当客户端需要处理大量数据时,才会发现这种问题的存在。

$perPage = min((int) request('per_page', 50), 100);

\(orders = Order::where('account_id', \)accountId)
    ->>latest()
    ->>paginate($PerPage);

应该对每页显示的数据量进行限制。如果客户端确实需要所有数据以便进行导出操作,那么应该将这个任务设置为异步处理,而不是让服务器生成一个庞大的同步响应结果。

响应优化

对于那些没人会读取的字段,就不要再把它们包含在返回的结果中。在以数据读取为主的接口中,只选择真正需要的列,可以显著降低数据库I/O操作以及序列化所需的资源消耗:

$products = Product::query()
    ->>select(['id', 'name', 'slug', 'price', 'thumbnail_url'])
    ->>where('is_visible', true)
    ->>orderBy('name')
    ->>paginate(40);

在Web服务器或负载均衡器上启用数据压缩功能也是一个值得考虑的措施。JSON格式的数据压缩效果非常好,而且这种设置通常只需要进行简单的配置调整,就能带来显著的性能提升。

速率限制

在设计API的速率限制机制时,应该考虑到用户的身份验证需求以及接口本身的资源消耗情况:

Route::middleware(['auth:sanctum', 'throttle:api'])
    ->group(function () {
        Route::get('/orders', [OrderController::class, 'index']);
        Route::post('/exports/orders', [OrderExportController::class, 'store'])
            ->>middleware('throttle:exports');
    });

这种设计将随意浏览行为与耗时较长的数据导出操作置于不同的规则之下,因此某个高负载用户不会影响其他所有用户的正常使用。

缓存API响应

对于那些计算成本较高、但可以容忍数据稍显陈旧的响应,应该进行缓存处理:

public function index(Request $request)
{
    $accountId = $request->user()->account_id;
    $page = $request->integer('page', 1);

    $cacheKey = "api:accounts:{$accountId}:orders:v1:page:$page";

    return Cache::remember($cacheKey, now() + 60 seconds, function () use ($accountId) {
        return OrderResource::collection()
            ->with('customer:id,name')
            ->where('account_id', $accountId)
            ->latest()
            ->paginate(50)
            ->response()->getData(true);
    });
}

请注意键值对中的v1这个版本号。通过更改这个版本号,当响应格式发生变化时,可以一次性使整个响应失效。对于那些并非真正全局性的数据,应该将其缓存键的范围限定在特定的租户或用户范围内。

如何在生产环境中监控Laravel应用

那些能在客户发现问题之前就发现异常的团队,通常会从各个地方收集相关信息:包括Laravel应用程序本身、队列系统、数据库、Redis、基础设施以及外部服务。

Laravel为开发者提供了几个有用的工具来帮助进行监控。Horizon可以显示队列的处理效率、失败的任务数量、等待时间以及工作进程的分配情况;Telescope则能展示请求详情、查询记录、异常信息、任务执行情况、邮件发送记录以及缓存操作的相关数据。日志文件能够记录那些运行缓慢的操作、意外的重试事件以及外部服务出现的故障;各种指标则可以帮助分析延迟时间、错误率、队列长度、任务执行耗时、数据库CPU使用率、锁等待时间、缓存命中率以及Redis内存占用情况。而警报系统则能将这些监控数据与客户实际会感受到的问题联系起来。

在实施这些监控措施时,团队们常常会犯一些错误。最有效的警报应该是那些能够反映具体问题的警报,而不是仅仅说明机器是否处于忙碌状态。例如:当API的延迟时间超过800毫秒并且这种情况持续了10分钟以上,或者结账过程中的错误率超过了1%,又或者邮件队列的等待时间超过了5分钟,再或者数据库的CPU使用率超过了85%且查询速度明显变慢,又或者Redis内存占用率超过了80%,又或者支付相关的Webhook出现了故障,这些情况都应该被视为需要立即处理的警报。

一个有用的思维模型是:日志文件可以告诉你发生了什么;指标数据可以帮助你判断系统是否运行正常;而跟踪信息则能帮助你了解具体的资源消耗情况。在实际开发中,将那些耗时较长的业务操作纳入监控体系之中,通常会带来立竿见影的效果:

use Illuminate\Support\Facades\Log;

$startedAt = microseconds(true);

报告中 = builder->forAccount($account)->build();

Log::info('账单生成报告', [
    'account_id' => $account->id,
    'duration_ms' => (int) ((microseconds(true) - $startedAt) * 1000),
    'invoice_count' => $report->invoiceCount(),
]);

如果在凌晨2点某个功能出现了故障,这样的日志记录就能帮助你快速找出是哪个账户、哪项导入操作或哪份报告导致了这个问题。

还有一点需要牢记:应该关注等待时间,而不仅仅是吞吐量。即使一个队列每分钟能够处理数千个请求,但如果重要的请求需要等待太长时间才能被处理,那么这个系统仍然属于“不健康”的状态。用户感受到的是等待时间,而不是系统的吞吐能力。

高流量场景下的Laravel架构示例

在高流量的Laravel应用环境中,通常会将系统分为四个部分:无状态的Web请求处理、共享缓存与会话存储机制、异步工作进程以及数据库角色划分。

用户首先访问负载均衡器,负载均衡器会将请求分发到多台无状态的Laravel应用服务器上。这些服务器使用Redis来存储缓存数据、会话信息、实现速率限制功能、管理队列任务以及处理Horizon相关的数据。异步工作进程则会负责处理那些执行速度较慢或不可靠的任务。MySQL主数据库用于处理所有的写操作以及那些对数据一致性要求较高的读操作,而MySQL读副本则负责处理那些以读操作为主的请求,即使存在一定的复制延迟也不会影响用户体验。

整个系统的工作流程如下:

用户
  -> 负载均衡器
  -> 无状态的Laravel应用服务器
  -> Redis:用于缓存、会话存储、速率限制、队列管理以及Horizon数据
  -> 主数据库:用于写操作及对数据一致性要求高的读操作
  -> 读副本数据库:用于处理以读操作为主的请求

Redis队列
  -> 异步工作进程
  -> 数据库、外部API、邮件服务提供商、对象存储服务等

这种架构并不是唯一的可行方案。实际上,PostgreSQL也可以替代MySQL;Amazon SQS可以用来替换Redis队列;CDN可以用于提供静态资源并缓存公共响应数据;对象存储服务则可用于存储用户上传的文件。关键在于,系统的每一层都应该有明确的职责,并且能够独立地进行扩展或优化。

无状态应用服务器的一个缺点是:用户在请求完成之后所需要的任何数据都必须存储在共享存储系统中。上传的文件、生成的文档以及会话状态等信息绝对不能保存在单台服务器的本地磁盘上,因为一旦负载均衡器将请求分发到其他服务器上,这些数据就可能会从用户的视角中“消失”。

惨痛的经验教训

1. 过早进行优化

这种问题通常表现为在应用程序还无法清楚地了解自身的运行状况时,就匆忙构建复杂的基础设施。

实际操作中,采用以下方法会更为有效:首先测量系统中的瓶颈所在,然后优先解决最严重的问题,之后再重复这个过程。对于大多数Laravel应用来说,第一阶段的优化工作通常包括优化索引结构、采用N+1算法解决问题、分离队列任务以及精简数据传输量。

2. 过度使用缓存

虽然缓存能够提高系统的运行速度,但同时也可能使系统变得更难以理解和管理。曾经有一个团队将账户设置相关的响应数据缓存了30分钟,后来又将角色变更信息也添加到了这个缓存中。结果就是,那些刚刚被限制访问权限的用户,在缓存失效之前仍然能够看到这些被禁用的功能。

为了解决这个问题,他们将稳定的账户元数据与需要权限控制的敏感数据分离开来存储。这个经验告诉我们:除非你已经仔细考虑过如何处理缓存数据的失效问题,否则绝对不要对授权相关的数据进行缓存处理。

3. 缺失的索引

这些索引在表格的大小超过某个阈值之前是隐藏的。在开发环境中,一个查询可能只需要扫描20,000行数据,但在生产环境中则可能需要扫描2000万行数据。因此,应该将索引优化工作纳入日常开发流程中,并且要谨慎规划大规模的索引迁移操作,以避免在最关键的时刻导致系统出现故障。

4. 队列负担过重

队列的作用并不是删除任务,而是将它们暂时存放起来。最常见的错误就是让某个耗时较多的任务阻塞了其他所有任务的执行。例如,当大量CSV数据被导入系统时,默认队列就会被填满,导致那些需要发送密码重置邮件的任务无法及时被处理。因此,为不同的任务设置独立的队列是预防这类问题的有效方法。

5. 大型事务

长时间运行的事务会长时间占用系统资源,从而导致更大的故障风险。在事务中执行某些操作尤其危险,因为工作者可能在事务提交之前就抢先处理了这些操作:

DB::transaction(function () use ($request) {
    $order = Order::create([...]);
    \(order->items()->createMany(\)request->items);

    GenerateInvoicePdf::dispatch($order->id);
    SyncOrderToCrm::dispatch($order->id);
});

对于任何依赖于已提交数据才能完成的操作,都应该使用事务提交后的回调机制来执行这些操作:

GenerateInvoicePdf::dispatch($order->id)->afterCommit();
SyncOrderToCrm::dispatch($order->id)->afterCommit();

请确保事务的范围仅限于那些确实需要原子性操作的数据,而不要包括其他不必要的数据。

6. 将症状误认为是根本原因

这种做法往往会导致不必要的麻烦和额外的成本。例如,如果系统延迟是由于某个接口端点执行了300次查询所导致的,那么增加应用服务器的数量只会进一步增加数据库的压力;同样地,如果某些任务的执行速度较慢是因为外部API对请求进行了速率限制,那么增加工作者的数量反而会使得故障发生的频率更高。

良好的扩展性测试会不断追问以下几个问题:是哪项资源已经达到了饱和状态?是哪个接口端点、任务、租户或查询导致了这个问题?在处理用户请求的过程中,这项操作真的有必要吗?我能否减少它的执行频率、延迟其执行时间、将其结果缓存起来,或者将其与其他操作隔离开来执行?如何才能确定所做的这些改变是否真正起到了改善系统性能的作用?

上线前的扩展性检查清单

在进行大规模发布、开展流量促销活动或在企业范围内推广新功能之前,请务必仔细检查这份清单。

应用程序与运行环境:在部署过程中,請缓存配置文件、路由规则和视图内容。将`APP_DEBUG`设置为`false`,并启用OPcache。尽量缩短Web请求的处理时间,将耗时较长的操作放入队列中处理。上传文件应存储在对象存储系统中,而不是应用服务器的磁盘上。确保服务器是无状态的,并为每个外部HTTP请求设置超时时间。

数据库:首先查看那些执行速度较慢的查询日志。对于那些需要处理大量数据、进行联接操作或排序操作的字段,请添加相应的索引。检查控制器、资源文件、配置文件和视图代码中是否存在N+1查询这类问题。为所有列表接口端点设置分页功能。在批量处理数据时,可以使用`chunkById`方法或游标来提高效率。避免在事务中执行耗时较长的操作或进行外部调用。确认你的备份和恢复流程能够正常运行。如果使用了数据副本,请测试系统在数据过期后的表现是否仍然正常。

Redis与缓存: 在适合使用的地方,利用Redis来实现缓存、会话管理、速率限制以及队列功能。除非有明确的理由不这样做,否则应为缓存设置过期时间。在相关情况下,键中应包含租户信息、用户身份、地区设置以及版本号。密切关注内存使用情况以及数据的淘汰策略。对于那些涉及权限控制的响应数据,务必在适当的时候进行失效处理,以避免因缓存导致不必要的重新计算操作。

队列: 根据工作负载的不同来划分队列,并为每个队列配置相应的Horizon监控组件。要明确设置超时时间、重试次数以及退避策略。在条件允许的情况下,确保作业具有幂等性。对于那些依赖于已提交数据的作业,可以使用`afterCommit`方法来处理这些数据。需要密切监控作业的等待时间、执行耗时、失败情况以及重试次数。对于失败的作业,不要直接忽略它们,而应该认真分析原因。

API设计: 使用资源配置机制来控制响应数据的格式。对`per_page`参数设置上限。对于数据量较大的列表或日志信息,采用游标分页技术。对于那些需要频繁读取的数据,可以使用带有版本标识的安全键以及较短的过期时间来进行缓存处理。根据接口的访问成本来实施速率限制措施。不要直接返回原始的Eloquent模型对象,而是在前端对响应数据进行压缩处理。

可观测性: 关注那些关键接口的p50、p95和p99延迟指标。按路由和作业类型来统计错误发生率。在队列等待时间超过预设阈值时及时发出警报,而不仅仅是关注队列的大小。密切监控数据库的CPU使用情况、连接数、慢查询次数以及锁等待时间。同时也要关注Redis的内存使用情况、延迟值以及数据的淘汰机制。对于重要的业务操作,要记录其执行耗时及相关标识信息。在正式上线之前,一定要先测试警报机制,因为无声无息的警报其实比根本没有警报更糟糕。

结论

当你在设计系统时真正考虑到数据处理成本、并发处理能力以及外部依赖关系的影响时,Laravel能够很好地运行在高流量的生产环境中。不过,在进行优化之前,一定要先进行充分的测量,因为盲目猜测只会浪费时间,还可能会让问题变得更加复杂。

首先优化数据库:为表创建索引、优化查询语句、采用分页技术以及合理使用预加载机制,这些措施通常能够带来立竿见影的效果。利用队列来保证请求的处理速度,将那些耗时较长的任务放到后台进程中去处理。在进行缓存操作时,要确保键的唯一性、设置合理的过期时间,并制定明确的失效策略。持续关注系统的延迟情况、错误率、队列等待时间、数据库健康状况以及Redis的内存使用情况等。

最好的扩展方案应该是实用且可重复的。你需要仔细研究现有的系统结构,消除浪费,找出性能瓶颈所在,然后才能有信心地进行下一步优化。只要坚持这样的迭代过程,你就很少需要对系统进行大规模的重写。

参考资料

Comments are closed.