Coolify 的 Laravel 数据库性能最佳实践:从 N+1 查询到索引、分块与游标迭代
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 只取需要的列(注意外键列必须保留),同时可以叠加 where、limit 等约束:
$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.php 的 configureModels() 方法中,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_log、applications 等表都有大字段,这一点在其代码库中体现得尤为明显。
错误:
$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 作为外键被保留,而 uuid、name 则是视图实际用到的字段。
规则四:对大数据集使用分块(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 是该项目处理"批处理 + 状态回写"的既定模式。
规则五:为高频查询列添加索引
对出现在 WHERE、ORDER BY、JOIN、GROUP 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 个关系的计数(applications、services、postgresqls、redis、keydbs、dragonflies、clickhouses、mongodbs、mysqls、mariadbs),列表页每行展示的"资源数量"徽标由此而来,而无需逐项目再查一次。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(如 chunkById、withCount、cursor 均为 Eloquent 内置能力,版本差异较小);示例中的 Post/User 等模型为文档示意,直接对照 Coolify 源码时可替换为 Application、Project、Server 等真实模型。Coolify 自身并未在 AppServiceProvider 中全局开启 preventLazyLoading,而是以单测形式做定点验证——如果你的项目处于全新开发阶段,可以直接按规则二在 boot() 中开启;若存量代码较多,更推荐 Coolify 这种"测试内开启、finally 恢复"的渐进式做法。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00