news 2026/9/5 20:31:50

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Coolify 的 Laravel 数据库性能最佳实践:从 N+1 查询到索引、分块与游标迭代

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

【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify

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.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_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+1Model::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 恢复"的渐进式做法。

【免费下载链接】coolifyAn open-source, self-hostable PaaS alternative to Vercel, Heroku & Netlify that lets you easily deploy static sites, databases, full-stack applications and 280+ one-click services on your own servers.项目地址: https://gitcode.com/GitHub_Trending/co/coolify

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 20:24:24

基于Flask与Neo4j构建三国人物知识图谱:从数据抽取到可视化问答

简介&#xff1a;这是一套面向Python初学者与知识图谱入门者的实战项目资源&#xff0c;基于Flask框架构建三国演义人物关系可视化及智能问答系统&#xff0c;解决古典文学数据结构化分析与交互式探索的实际问题。资源包共364个文件&#xff0c;含12个核心Python脚本&#xff0…

作者头像 李华
网站建设 2026/9/5 20:22:29

打喷嚏时为什么要用手肘挡?从飞沫到气溶胶的喷嚏礼仪

你有没有注意过&#xff0c;当办公室里有一个人突然打喷嚏&#xff0c;大多数人在那一瞬间的动作是什么&#xff1f;手会比脑子更快地伸向口鼻&#xff0c;用手掌心捂个严实。这个动作看起来很有礼貌&#xff0c;却可能是喷嚏礼仪里最不理想的一种。经历过呼吸道传染病高发季节…

作者头像 李华
网站建设 2026/9/5 20:20:19

XMC1300驱动BLDC电机的工程实践:CCU8、霍尔接口与单电阻采样

简介&#xff1a;本资源是一套基于英飞凌XMC1300单片机的直流无刷电机驱动完整嵌入式开发工程&#xff0c;面向嵌入式初学者与电机控制开发者&#xff0c;解决BLDC电机在Cortex-M0平台上的闭环驱动实现难题&#xff0c;适用于电动自行车、智能风扇、工业风机等低功耗高性能场景…

作者头像 李华