How to Scale Laravel Applications for High-Traffic Production Systems
TL;DR · AI 摘要
本文提供 Laravel 应用在高流量生产环境中的扩展策略,涵盖数据库优化、Redis 使用、队列架构等实用方法。
核心要点
- 数据库优化是 Laravel 扩展的关键,需减少低效查询并添加合适的索引。
- 使用 Redis 缓存高频数据可显著降低数据库负载。
- 队列驱动架构能有效分离慢任务,提升应用响应速度。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Laravel 应用扩展策略
- 数据库优化
- 减少低效查询
- 添加索引
- 使用缓存
- Redis 使用
- 缓存高频数据
- 降低数据库负载
- 队列驱动架构
- 分离慢任务
- 提升响应速度
金句 / Highlights
值得收藏与分享的关键句。
数据库是 Laravel 应用扩展时最容易出现瓶颈的地方。
使用 Redis 缓存高频数据可显著降低数据库负载。
将慢任务放入队列可提升应用响应速度并提高系统可扩展性。
如何为高流量生产系统扩展 Laravel 应用
2026 年 6 月 11 日
/
#Laravel
Olamilekan Lamidi
你的第一个扩展问题很少会突然出现。一段时间内,一切都很顺利:页面加载速度快,数据库几乎不会感到压力,团队在不怎么考虑基础设施的情况下就能推出新功能。
然后流量开始上升。一个活动表现超预期。一个市场平台引入了一位受欢迎的卖家。一个 SaaS 产品签下几家大型企业客户。
突然之间,/dashboard 页面的加载时间从 300 毫秒变成了 2 秒。过去几秒钟就能完成的队列任务现在要等上几分钟。每天下午数据库的 CPU 使用率都会出现高峰。
于是你添加了另一个应用服务器,但响应时间几乎没有变化,因为真正的问题一直是一个大型表上的慢查询。
如果你曾在生产环境中运行过 Laravel,那么你可能经历过类似的情况。好消息是,扩展 Laravel 几乎从不需要放弃这个框架。它意味着学习压力在哪里产生,并让应用在负载下表现得更加可预测。
在本指南中,你将学习如何找到常见的瓶颈,优化数据库,有效使用 Redis,将耗时的工作转移到队列中,优化 API,以及在生产环境中监控 Laravel 应用。
这一切都不需要一次性的英雄式重构。最大的收益通常来自于实际的工作:移除低效的查询,将耗时的任务推送到队列中,添加合适的索引,小心地缓存选定的数据,并衡量每一次更改是否真的有帮助。
先决条件
如果你已经熟悉以下内容,将能从本指南中获得最大收益:
- 使用 Laravel 和 PHP 构建应用
- 编写 Eloquent 查询和数据库迁移
- 使用队列、任务和定时命令
- 阅读基本的数据库查询计划
- 将 Laravel 部署到生产服务器或平台
- 在类似生产环境的设置中使用 Redis 和 MySQL 或 PostgreSQL
目录
- 当 Laravel 应用开始增长时会发生什么
- 常见的 Laravel 瓶颈
- 如何优化数据库
- 如何使用 Redis 进行扩展
- 如何使用队列驱动架构
- 如何优化 API 性能
- 如何在生产环境中监控 Laravel
- 一个高流量 Laravel 架构示例
- 从艰难经历中学到的教训
- 上线前的扩展检查清单
- 结论
- 参考资料
当 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 次。相反,应使用急加载关系:
use App\Models\Post;
$posts = Post::with('author')
->latest()
->take(50)
->get();
foreach ($posts as $post) {
echo $post->author->name;
}在生产环境中,这些 N+1 查询非常隐蔽。它们通常隐藏在 API Resources、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\Schema\Blueprint;
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);一个需要注意的地方是:范围过于狭窄的 select 列表可能会破坏后续代码,这些代码期望加载你没有加载的列。请将这种技术应用于读取密集型的接口附近,这样收益会更加明显。
同步处理
高流量的应用需要快速的 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?->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 表中,可以从热点路径中移除一个昂贵的连接操作。你付出的代价是保持这些重复数据的一致性,这可能是一个值得的权衡。
$subscription = Subscription::where('account_id', $accountId)
->where('status', 'active')
->where('renews_at', '>=', now())
->orderBy('renews_at')
->first();养成在添加索引后运行 EXPLAIN 的习惯,以确认执行计划是否发生了变化。优化器忽略的索引只会增加写入开销,毫无意义。
有意识地使用急加载(Eager Loading)
将急加载与端点实际返回的内容匹配。对于列表端点,保持关系的浅层和限制:
$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' => fn ($query) => $query->whereNull('accepted_at'),
])
->paginate(25);你的内存占用保持稳定,这在列表页面上比在详情页面上更为重要。
在增加硬件之前优化查询
更大的数据库实例为你争取了时间。它还会掩盖那些导致你处于这种状态的低效查询,直到下一次流量激增时再次暴露它们。在增加更大机器之前,先找到你最耗资源的查询。在本地或测试环境中,记录慢查询很容易:
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function (QueryExecuted $query) {
if ($query->time > 100) {
Log::warning('Slow query detected', [
'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);它依赖于一个稳定且已索引的排序列,并使用前后游标而不是任意的页码,这通常是无限滚动源所需要的。
使用只读副本分拆读取操作
随着读取流量的增长,副本可以减轻主数据库的负载:
'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => [
env('DB_READ_HOST', '127.0.0.1'),
],
],
'write' => [
'host' => [
env('DB_WRITE_HOST', '127.0.0.1'),
],
],
'sticky' => true,
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
],sticky 选项会在同一请求中写入后保持读取操作在写入连接上,这有助于避免一些读取在写入之后的意外情况。
副本存在复制延迟,而这种延迟是重要的。除非业务流程确实可以容忍看到旧数据,否则不要将支付确认、密码更改、权限检查或任何其他对一致性敏感的操作路由到可能几秒钟就过时的副本上。
如何使用 Redis 扩展
Redis 在 Laravel 生产环境中经常承担很多任务:缓存、会话、速率限制、队列、锁和 Horizon 指标。它速度快,但仍需仔细考虑:合理设计键、设置过期策略、监控内存使用情况,以及制定真正的失效计划。
缓存
缓存那些频繁请求且可以容忍略微过时的昂贵读取操作:
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');根据配置文件(如默认、电子邮件、Webhook、文档和导入)将工作拆分,因为每个工作负载可能需要不同的工作线程数量和重试规则。保持名称有意义。在发生事件时,“文档队列落后了 20 分钟”比“默认队列变慢了”提供了更多信息。
如何使用队列驱动架构
队列是 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 队列的可见性和控制。一个典型的设置为不同的工作负载运行不同的监督器:
'production' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'emails'],
'balance' => 'auto',
'maxProcesses' => 20,
'tries' => 3,
],
'supervisor-documents' => [
'connection' => 'redis',
'queue' => ['documents'],
'balance' => 'simple',
'maxProcesses' => 5,
'tries' => 2,
'timeout' => 300,
],
],这种分离很重要:长时间运行的文档任务不应该让快速的密码重置邮件被饿死。
失败任务和重试
只有在失败是临时的情况下,重试才有帮助。重试一个永久损坏的任务只会浪费资源。对于有业务截止期限的任务,使用 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()->addSeconds(60), function () use (\)accountId) { return OrderResource::collection( Order::with('customer:id,name') ->where('account_id', $accountId) ->latest() ->paginate(50) )->response()->getData(true); }); }
注意键中的 v1。当响应格式发生变化时,增加版本号可以一次性使整个响应失效。对于任何非全局的内容,始终将键作用域限定在租户或用户上。
## 如何在生产环境中监控 Laravel
那些在客户发现问题之前就捕获问题的团队,是从各个方面收集信号的:Laravel、队列、数据库、Redis、基础设施和外部服务。
Laravel 为你提供了几个良好的起点。Horizon 显示队列吞吐量、失败任务、等待时间和工作器平衡。Telescope 展示请求详情、查询、异常、任务、邮件和缓存事件。你的日志记录了缓慢的操作、意外的重试和外部失败。你的指标跟踪延迟、错误率、队列深度、任务运行时间、数据库 CPU、锁等待、缓存命中率和 Redis 内存。你的警报将所有这些与客户实际会感受到的问题联系起来。
最后一部分往往是团队容易出错的地方。最好的警报是关于症状,而不是机器是否繁忙:比如 p95 API 延迟超过 800 毫秒持续 10 分钟,结账错误率超过 1%,邮件队列等待超过 5 分钟,数据库 CPU 超过 85% 且慢查询增加,Redis 内存超过 80%,或失败的支付 Webhook 超过阈值。
一个有用的思维模型是这样的:日志告诉你发生了什么,指标告诉你系统是否健康,而追踪告诉你时间都花在哪里了。实际上,将昂贵的业务操作用一些监控工具包装起来,会迅速带来回报:
use Illuminate\Support\Facades\Log;
$startedAt = microtime(true);
\(report = \)builder->forAccount($account)->build();
Log::info('Billing report generated', [ 'account_id' => $account->id, 'duration_ms' => (int) ((microtime(true) - $startedAt) * 1000), 'invoice_count' => $report->invoiceCount(), ]);
当凌晨 2 点有东西出错时,像这样的日志行可以告诉你哪个账户、导入或报告导致了压力。
还有一个值得内化的内容:监控等待时间,而不仅仅是吞吐量。一个队列每分钟可以处理数千个任务,但如果重要的任务在开始之前等待太久,它仍然可能是不健康的。用户感受到的是等待时间,而不是吞吐量。
## 一个高流量 Laravel 架构示例
一个高流量的 Laravel 设置通常会将四部分分离:无状态的 Web 请求、共享缓存和会话存储、异步工作器和数据库角色。
用户通过负载均衡器,负载均衡器将流量分发到一组无状态的 Laravel 应用服务器上。这些服务器使用 Redis 作为缓存、会话、速率限制、队列和 Horizon 数据。队列工作器处理缓慢或不可靠的工作。一个 MySQL 主数据库处理所有写入和任何一致性敏感的读取,而一个读取副本则吸收可以容忍一些复制延迟的读取密集型端点。
流程如下所示:
用户 -> 负载均衡器 -> 无状态的 Laravel 应用服务器 -> Redis 用于缓存、会话、速率限制、队列和 Horizon 数据 -> 主数据库用于写入和一致性敏感的读取 -> 读取副本用于安全的读取密集型端点
Redis 队列
-> 队列工作者
-> 数据库、外部 API、邮件服务提供商、对象存储及其他服务 这并不是唯一有效的架构。PostgreSQL 可以替代 MySQL,Amazon SQS 可以取代 Redis 队列,CDN 可以用于提供静态资源并缓存公开响应,对象存储应用于存储用户上传的内容。关键的原则是,每一层都有一个明确的任务,并且可以独立地进行扩展或调整。
无状态应用服务器的另一面是,任何用户在请求结束后需要的内容都必须存储在共享存储中。上传内容、生成的文件和会话状态不应存储在单个服务器的本地磁盘上,否则当负载均衡器将下一个请求发送到其他地方时,这些内容可能会从用户的视角中消失。
通过艰难教训学到的经验
1. 过早优化
这通常表现为在应用程序尚未真正了解自身之前,就构建了复杂的基础设施。
更实际的方法是:测量、排序瓶颈、解决最大的问题,然后重复。对于大多数 Laravel 应用程序来说,第一次扩展主要集中在索引、N+1 问题修复、队列分离和减少负载方面。
2. 过度缓存
缓存可以同时使系统更快,也更难以理解。一个团队将账户设置响应缓存了 30 分钟,之后又将角色更改合并到同一响应中。结果是,用户刚刚失去访问权限后,仍能看到功能,直到缓存过期。
解决方法是将稳定的账户元数据与权限敏感的状态分开。教训是,除非仔细考虑了失效策略,否则应避免缓存授权数据。
3. 缺少索引
这些索引在表达到一定大小之前是隐藏的。在开发环境中扫描 20,000 行的查询,在生产环境中可能扫描 20,000,000 行。将索引审查纳入功能开发中,并仔细规划大规模索引迁移,以避免在最糟糕的时刻锁定一个热点表。
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 进行缓存、会话、速率限制和队列。除非有明确的理由,否则设置 TTL。在相关情况下,将租户、用户、语言和版本包含在键中。监控内存和驱逐策略。避免在没有仔细失效机制的情况下缓存权限敏感的响应。防止昂贵重新计算时的缓存踩踏。
队列:根据工作负载分离队列。为每个队列配置 Horizon 监督器。有目的地设置超时、重试和退避策略。尽可能使任务幂等。对于依赖已提交数据的任务,使用 afterCommit。监控等待时间、运行时间、失败和重试。检查失败任务,而不是忽略它们。
API:使用资源来控制响应结构。限制每页的条目数。对大型信息流和日志使用游标分页。使用安全、版本化的键和短 TTL 缓存昂贵的读取。根据端点成本应用速率限制。不要返回原始的 Eloquent 模型。在边缘压缩响应。
可观测性:跟踪关键端点的 p50、p95 和 p99 延迟。按路由和任务类跟踪错误率。对队列等待时间发出警报,而不仅仅是队列大小。监控数据库的 CPU、连接、慢查询和锁等待。监控 Redis 的内存、延迟和驱逐。使用持续时间和标识符记录重要的业务操作。在发布前的夜晚测试警报,因为无声的警报比没有警报更糟糕。
结论
当您围绕数据、并发和外部依赖的真实成本进行设计时,Laravel 能够很好地运行高流量的生产系统。请务必在优化之前进行测量,因为猜测会浪费时间,并且往往会复杂化错误的层级。
首先修复数据库:索引、查询结构、分页和预加载通常能带来最大的早期收益。依靠队列来保持请求快速,并将耗时操作推送到受控的后台工作者中。有意识地缓存,使用清晰的键、合理的 TTL 和失效计划。持续监控延迟、错误、队列等待时间、数据库健康状况、Redis 内存和外部依赖。
最好的扩展性工作是实际且可重复的。您研究实际拥有的系统,去除浪费,隔离缓慢的部分,并确保自己有足够的可见性,以自信地进行下一次更改。持续这样做,您很少需要进行大规模重写。
参考资料
- Laravel 文档:Eloquent 关系
- Laravel 文档:数据库查询
- Laravel 文档:缓存
- Laravel 文档:队列
- Laravel 文档:Redis
- Laravel 文档:速率限制
- Laravel 文档:Eloquent API 资源
- Laravel Horizon 文档
- Laravel Telescope 文档
- MySQL 文档:优化
- Redis 文档
阅读更多文章。
如果这篇文章对你有帮助,请分享它。
免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始学习
ADVERTISEMENT