尧图精选

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

🕒 发布时间:2026/9/5 20:32:25 📁 来源:尧图网络
Coolify 的 Laravel 数据库性能最佳实践:从 N1 查询到索引、分块与游标迭代【免费下载链接】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/coolifyCoolify 是一个 Laravel 构建的自托管 PaaS 平台,其后台包含大量资源列表页、定时任务与批量 Job,数据库查询性能直接影响部署、备份与 API 响应的整体体验。本文以 Coolify 仓库内置的 Laravel 最佳实践之数据库性能规则(由 SKILL.md 列为影响优先级第 1 项)为主体,完整讲解其 8 条核心规则,并结合 Coolify 源码中的真实实现逐条印证:读完你不仅掌握with()预加载、chunkById()、cursor()等标准用法的正确姿势,还能看到一个生产级 Laravel 项目如何把这些规则落地到测试、Livewire 页面与队列 Job 中。规则一:始终对关系做预加载(Eager Loading)惰性加载(Lazy Loading)是 N1 查询问题的根源——循环内每访问一次关系就会额外发一条查询。规则要求始终使用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()中启用以下配置,以便在开发阶段尽早暴露 N1 问题:public function boot(): void { Model::preventLazyLoading(! app()-isProduction()); }启用后,任何未预加载的关系一旦被访问,就会抛出LazyLoadingViolationException。Coolify 对此采取了一种更务实的落地方式。从源码结构看,其 AppServiceProvider.php 的configureModels()方法中,Model::shouldBeStrict()被刻意注释掉了,注释原文为 Disabled because its 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开发/测试期捕获 N1Model::preventLazyLoading(),注意保存与恢复状态ApiSensitiveFieldsTest.php宽表、大 JSON 列select()只取所需列,保留外键Project\Index.php批量处理、边遍历边修改chunkById($size, ...)ApiTokenExpirationWarningJob.phpWHERE/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 恢复的渐进式做法。【免费下载链接】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),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →