首页
/ Coolify 的 Laravel 数据库性能最佳实践:从 N+1 查询到索引、分块与游标迭代

Coolify 的 Laravel 数据库性能最佳实践:从 N+1 查询到索引、分块与游标迭代

2026-09-04 15:38:31作者:何举烈Damon

Coolify 是一个 Laravel 构建的自托管 PaaS 平台,其后台包含大量资源列表页、定时任务与批量 Job,数据库查询性能直接影响部署、备份与 API 响应的整体体验。本文以 Coolify 仓库内置的 Laravel 最佳实践之数据库性能规则(由 SKILL.md 列为影响优先级第 1 项)为主体,完整讲解其 8 条核心规则,并结合 Coolify 源码中的真实实现逐条印证:读完你不仅掌握 with() 预加载、chunkById()cursor() 等标准用法的正确姿势,还能看到一个生产级 Laravel 项目如何把这些规则落地到测试、Livewire 页面与队列 Job 中。

规则一:始终对关系做预加载(Eager Loading)

惰性加载(Lazy Loading)是 N+1 查询问题的根源——循环内每访问一次关系就会额外发一条查询。规则要求始终使用 with() 在初始查询时就把关系加载出来。

错误写法(执行 1 + N 条查询):

$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name;
}

正确写法(总共仅 2 条查询):

$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name;
}

进一步,预加载应当约束 select 只取需要的列(注意外键列必须保留),同时可以叠加 wherelimit 等约束:

$users = User::with(['posts' => function ($query) {
    $query->select('id', 'user_id', 'title')
          ->where('published', true)
          ->latest()
          ->limit(10);
}])->get();

在 Coolify 源码中,这种"嵌套预加载 + 列约束"的组合被大量使用。以全局搜索组件 GlobalSearch.php 为例,其内部针对不同资源类型多次使用受限的 with():

->with(['environment.project', 'previews:id,application_id,pull_request_id'])

以及 Project\Index.php 中项目列表页的写法——只取页面真正渲染的列,并在注释中明确说明这是为了避免把 servers/private keys 这类"从未被视图使用"的关系水合进 Livewire 公共状态:

$this->projects = Project::ownedByCurrentTeam()
    ->with(['environments:id,uuid,name,project_id'])
    ->withCount([...])
    ->get();

规则二:在开发环境禁止惰性加载

规则建议在 AppServiceProvider::boot() 中启用以下配置,以便在开发阶段尽早暴露 N+1 问题:

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

启用后,任何未预加载的关系一旦被访问,就会抛出 LazyLoadingViolationException

Coolify 对此采取了一种更务实的落地方式。从源码结构看,其 AppServiceProvider.phpconfigureModels() 方法中,Model::shouldBeStrict() 被刻意注释掉了,注释原文为 "Disabled because it's causing issues with the application"——即全量严格模式在大型应用中会因存量代码未完全预加载而产生误报,因此项目没有在生产/开发环境全局开启,而是把它下沉到测试层面。在 ApiSensitiveFieldsTest.php 中可以看到一个非常典型的"测试期防惰性加载"用例:

test('read token database list does not lazy load nested server relations', function () {
    $token = makeApiToken($this->user, $this->team, ['read']);

    $preventedLazyLoading = Model::preventsLazyLoading();
    Model::preventLazyLoading();

    try {
        $response = $this->withoutExceptionHandling()->withHeaders([
            'Authorization' => 'Bearer '.$token,
        ])->getJson('/api/v1/databases');
    } finally {
        Model::preventLazyLoading($preventedLazyLoading);
    }

    $response->assertStatus(200);
});

这个用例完整体现了规则的最佳实践形态:先保存原有状态(Model::preventsLazyLoading()),在 try 块中开启,finally 中恢复——若 API 序列化数据库列表时触发了任何关系惰性加载,测试会直接失败。这比在 boot() 中一刀切开启更适合 Coolify 这类迭代中的大型项目,也说明了 preventLazyLoading 既可作为全局开关,也可作为针对单一接口的"探测器"。

规则三:只 SELECT 需要的列

避免 SELECT *——尤其是表里存在大文本列或 JSON 列时。Coolify 的 activity_logapplications 等表都有大字段,这一点在其代码库中体现得尤为明显。

错误:

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

正确:

$posts = Post::select('id', 'title', 'user_id', 'created_at')
    ->with(['author:id,name,avatar'])
    ->get();

关键细节:对预加载关系指定列时,外键列(如示例中的 author 关系里的 id)必须包含在 select 列表中,否则 Eloquent 无法按外键匹配关系,$post->author 会是 null。前文 Project\Index.php 中的 'environments:id,uuid,name,project_id' 正是遵循了这一点——project_id 作为外键被保留,而 uuidname 则是视图实际用到的字段。

规则四:对大数据集使用分块(Chunking)

不要一次性 get() 几千条记录,批量处理请使用分块。

错误:

$users = User::all();
foreach ($users as $user) {
    $user->notify(new WeeklyDigest);
}

正确:

User::where('subscribed', true)->chunk(200, function ($users) {
    foreach ($users as $user) {
        $user->notify(new WeeklyDigest);
    }
});

规则还特别指出:当迭代过程中会修改记录时,应使用 chunkById(),因为标准 chunk() 基于 OFFSET 分页,行被修改/删除后偏移量会错位,导致跳行或重复:

User::where('active', false)->chunkById(200, function ($users) {
    $users->each->delete();
});

Coolify 的定时任务与 Job 几乎都遵循这一条。例如 API Token 到期预警任务 ApiTokenExpirationWarningJob.php 在遍历即将到期的 Token 并逐一发送通知(遍历中会回写 api_token_expiration_warning_sent_at 字段,属于典型的"边迭代边修改"场景)时,使用了 chunkById(100, ...):

PersonalAccessToken::query()
    ->whereNotNull('expires_at')
    ->where('expires_at', '>', now())
    ->where('expires_at', '<=', now()->addDay())
    ->whereNull('api_token_expiration_warning_sent_at')
    ->where('tokenable_type', User::class)
    ->chunkById(100, function ($tokens) {
        // ... 发送通知并更新 api_token_expiration_warning_sent_at
    });

同类写法还出现在 ScheduledJobManager.php(清理过期备份与执行记录,统一使用 self::CHUNK_SIZE 常量)和 GetInfrastructureOverview.php 等位置,说明 chunkById 是该项目处理"批处理 + 状态回写"的既定模式。

规则五:为高频查询列添加索引

对出现在 WHEREORDER BYJOINGROUP BY 子句中的列建立索引。

错误(缺少索引):

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('status');
    $table->timestamps();
});

正确(外键、状态列加索引,并为常见查询模式建复合索引):

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->index()->constrained();
    $table->string('status')->index();
    $table->timestamps();
    $table->index(['status', 'created_at']);
});

复合索引应匹配常见查询模式,例如 WHERE status = ? ORDER BY created_at——索引列顺序应与过滤列在前、排序列在后的模式一致,才能同时命中过滤与排序。

Coolify 的迁移文件中也留下了真实的索引建设案例:2024_11_11_125366_add_index_to_activity_log.php 针对 activity_log 表(操作审计日志,查询量大的典型表)做了两件事——把 properties 列从 json 升级为 jsonb,并创建 GIN 索引以加速 JSON 路径查询:

if (DB::connection()->getDriverName() !== 'pgsql') {
    return;
}

try {
    DB::statement('ALTER TABLE activity_log ALTER COLUMN properties TYPE jsonb USING properties::jsonb');
    DB::statement('CREATE INDEX idx_activity_type_uuid ON activity_log USING GIN (properties jsonb_path_ops)');
} catch (\Exception $e) {
    Log::error('Error adding index to activity_log: '.$e->getMessage());
}

这段代码展示了规则在真实项目中的两个工程细节:一是索引策略可以针对数据库驱动做条件化(getDriverName() 判断),Coolify 同时支持 PostgreSQL 与 SQLite,GIN 索引仅在 PostgreSQL 分支执行;二是 DDL 变更包裹 try/catch 并记录日志,保证迁移在部分环境下失败时不阻断后续迁移。

规则六:用 withCount() 统计关系数量

不要为了数个数而把整个关系集合加载进内存。

错误:

$posts = Post::all();
foreach ($posts as $post) {
    echo $post->comments->count();
}

正确(生成单列 comments_count,底层是一条带 GROUP BY 的聚合子查询):

$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count;
}

条件计数则通过闭包追加约束,并使用 as 别名区分:

$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => function ($query) {
        $query->where('approved', true);
    },
])->get();

Coolify 的资源列表页是 withCount() 的典型受益场景。Project\Index.php 中对同一个 Project 查询一次性附加了 9 个关系的计数(applicationsservicespostgresqlsrediskeydbsdragonfliesclickhousesmongodbsmysqlsmariadbs),列表页每行展示的"资源数量"徽标由此而来,而无需逐项目再查一次。Application.php 模型则更进一步,用全局作用域把计数固化为查询默认行为:

static::addGlobalScope('withRelations', function ($builder) {
    $builder->withCount([
        'additional_servers',
        'additional_networks',
    ]);
});

从源码结构看,addGlobalScope 会让所有 Application 查询自动带上这两个计数,避免了每个调用点重复书写——这是 withCount() 与全局作用域组合使用的一个实用形态,使用时也需注意全局作用域的存在会被隐式依赖(该技能集在 eloquent.md 中提醒:全局作用域应克制使用并加以文档说明)。

规则七:用 cursor() 做内存高效的只读迭代

对于大结果集的只读遍历,cursor() 基于 PHP Generator 一次只加载一条记录,把内存占用从 O(N) 降到 O(1)。

错误:

$users = User::where('active', true)->get();

正确:

foreach (User::where('active', true)->cursor() as $user) {
    ProcessUser::dispatch($user->id);
}

规则给出的选型口诀很清晰:只读遍历用 cursor(),需要修改记录时用 chunk() / chunkById()。Coolify 的 RegenerateSslCertJob.php 正好演示了 cursor() 的正确使用场景——批量扫描临近过期的 SSL 证书并逐一重新签发,该过程只读取证书记录(不修改遍历集合本身),但证书数量可能随服务器规模增长,因此使用游标避免一次性载入:

$query->where('valid_until', '<=', now()->addDays(14));
$query->where('is_ca_certificate', false);

$regenerated = collect();

$query->cursor()->each(function ($certificate) use ($regenerated) {
    // 查找服务器 CA 证书并调用 SSLHelper::generateSslCertificate 重新签发
    $regenerated->push($certificate);
});

注意其细节:遍历中收集结果时只把必要的 $certificate 对象压入收集器,而不是把整张证书表 get() 进来再筛选。

规则八:Blade 模板中禁止执行查询

永远不要在 Blade 模板里执行查询,数据一律由 Controller(或 Livewire 组件)准备好后传入。

错误:

@foreach (User::all() as $user)
    {{ $user->profile->name }}
@endforeach

正确:

// Controller
$users = User::with('profile')->get();
return view('users.index', compact('users'));
@foreach ($users as $user)
    {{ $user->profile->name }}
@endforeach

这条规则在 Coolify 的 Livewire 架构下同样成立,且更有现实意义: Livewire 组件的 render() 返回值会被序列化为 HTML,而组件的 mount() 中预加载的数据会进入组件公共属性并随每次请求往返序列化。Project\Index.php 中的注释——"Servers/private keys were previously hydrated into public Livewire state but never used by the view"——正是一次真实教训的存档:曾被水合进公共状态却从未被视图使用的相关关系,白白增加了序列化与传输开销。把查询收敛在 mount()/Controller 中、只传递视图真正需要的字段,是这条规则在组件化前端下的自然延伸。

规则总览与落地检查清单

汇总这条规则链(与 db-performance.md 的章节顺序一致):

场景 手段 Coolify 印证
关系中访问属性 with() 预加载,可加闭包约束列 GlobalSearch.php
开发/测试期捕获 N+1 Model::preventLazyLoading(),注意保存与恢复状态 ApiSensitiveFieldsTest.php
宽表、大 JSON 列 select() 只取所需列,保留外键 Project\Index.php
批量处理、边遍历边修改 chunkById($size, ...) ApiTokenExpirationWarningJob.php
WHERE/ORDER BY/JOIN 列 单列索引 + 复合索引,按查询模式建 add_index_to_activity_log 迁移
关系计数展示 withCount(),可带条件别名,可固化为全局作用域 Application.php
大结果集只读遍历 cursor() RegenerateSslCertJob.php
视图层 查询全部上移到 Controller/组件 Project\Index.php 的注释教训

需要说明的适用前提:本文的规则文件来自 Coolify 仓库内置的 laravel-best-practices 技能集(SKILL.md),面向 Laravel 11/12 时代的 Eloquent API(如 chunkByIdwithCountcursor 均为 Eloquent 内置能力,版本差异较小);示例中的 Post/User 等模型为文档示意,直接对照 Coolify 源码时可替换为 ApplicationProjectServer 等真实模型。Coolify 自身并未在 AppServiceProvider 中全局开启 preventLazyLoading,而是以单测形式做定点验证——如果你的项目处于全新开发阶段,可以直接按规则二在 boot() 中开启;若存量代码较多,更推荐 Coolify 这种"测试内开启、finally 恢复"的渐进式做法。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
980
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384