18 个优化 Laravel 数据库查询的技巧

发布于 作者

18 Tips to Optimize Your Laravel Database Queries image

如果你的应用程序运行缓慢或执行了大量的数据库查询,请按照以下性能优化技巧来改善应用程序加载时间。

1. 检索大型数据集

此技巧主要关注在处理大型数据集时改善应用程序的内存使用情况。

如果你的应用程序需要处理大量记录,而不是一次性检索所有记录,你可以检索结果的子集并分组处理它们。

为了从名为 posts 的表中检索大量结果,我们通常会像下面这样操作。

$posts = Post::all(); // when using eloquent
$posts = DB::table('posts')->get(); // when using query builder
 
foreach ($posts as $post){
// Process posts
}

上面的示例将从 posts 表中检索所有记录并进行处理。如果这个表有 100 万行怎么办?我们很快就会耗尽内存。

为了避免在处理大型数据集时出现问题,我们可以检索结果的子集并按以下方式处理它们。

选项 1:使用 chunk

// when using eloquent
$posts = Post::chunk(100, function($posts){
foreach ($posts as $post){
// Process posts
}
});
 
// when using query builder
$posts = DB::table('posts')->chunk(100, function ($posts){
foreach ($posts as $post){
// Process posts
}
});

上面的示例从 posts 表中检索 100 条记录,处理它们,检索另外 100 条记录,并处理它们。这个迭代将继续,直到所有记录都被处理。

这种方法会产生更多数据库查询,但更节省内存。通常,大型数据集的处理应该在后台进行。因此,在后台运行时进行更多查询是可以接受的,以避免在处理大型数据集时耗尽内存。

选项 2:使用 cursor

// when using eloquent
foreach (Post::cursor() as $post){
// Process a single post
}
 
// when using query builder
foreach (DB::table('posts')->cursor() as $post){
// Process a single post
}

上面的示例将执行一个数据库查询,从表中检索所有记录,并逐个实例化 Eloquent 模型。这种方法只执行一个数据库查询来检索所有帖子。但它使用 php 生成器 来优化内存使用情况。

何时可以使用它?

虽然这极大地优化了应用程序级别的内存使用情况,但由于我们从表中检索所有条目,因此数据库实例上的内存使用情况仍然会更高。

如果运行应用程序的 Web 应用程序内存较少,而数据库实例内存较多,那么最好使用游标。但是,如果数据库实例没有足够的内存,那么最好坚持使用 chunks。

选项 3:使用 chunkById

// when using eloquent
$posts = Post::chunkById(100, function($posts){
foreach ($posts as $post){
// Process posts
}
});
 
// when using query builder
$posts = DB::table('posts')->chunkById(100, function ($posts){
foreach ($posts as $post){
// Process posts
}
});

chunkchunkById 之间的主要区别在于,chunk 基于 offsetlimit 进行检索。而 chunkById 基于 id 字段检索数据库结果。这个 id 字段通常是一个整数字段,在大多数情况下它是一个自动递增字段。

chunkchunkById 执行的查询如下所示。

chunk

select * from posts offset 0 limit 100
select * from posts offset 101 limit 100

chunkById

select * from posts order by id asc limit 100
select * from posts where id > 100 order by id asc limit 100

通常,使用带偏移量的限制速度较慢,我们应该尽量避免使用它。这篇文章详细解释了使用偏移量的問題。

由于 chunkById 使用 id 字段(它是一个整数),并且查询使用 where 子句,因此查询速度会快得多。

何时可以使用 chunkById?

  • 如果你的数据库表有一个 主键列 列,它是一个自动递增字段。

2. 只选择你需要的列

通常,要从数据库表中检索结果,我们会执行以下操作。

$posts = Post::find(1); //When using eloquent
$posts = DB::table('posts')->where('id','=',1)->first(); //When using query builder

上面的代码将产生以下查询

select * from posts where id = 1 limit 1

如你所见,查询执行的是 select *。这意味着它正在从数据库表中检索所有列。如果我们确实需要表中的所有列,那么这样做是可以的。

相反,如果我们只需要特定的列(id、title),我们可以像下面这样只检索这些列。

$posts = Post::select(['id','title'])->find(1); //When using eloquent
$posts = DB::table('posts')->where('id','=',1)->select(['id','title'])->first(); //When using query builder

上面的代码将产生以下查询

select id,title from posts where id = 1 limit 1

3. 当你只需要从数据库中检索一列或两列时使用 pluck

此技巧更多地关注从数据库检索结果后所花费的时间。它不会影响实际的查询时间。

如上所述,要检索特定的列,我们会执行以下操作

$posts = Post::select(['title','slug'])->get(); //When using eloquent
$posts = DB::table('posts')->select(['title','slug'])->get(); //When using query builder

执行上面的代码时,它会在幕后执行以下操作。

  • 在数据库上执行 select title, slug from posts 查询
  • 为检索到的每一行创建一个新的 Post 模型对象(对于查询构建器,它会创建一个 PHP 标准对象)
  • 使用 Post 模型创建一个新集合
  • 返回集合

现在,要访问结果,我们可以执行以下操作

foreach ($posts as $post){
// $post is a Post model or php standard object
$post->title;
$post->slug;
}

以上方法额外增加了为每一行实例化 Post 模型以及为这些对象创建集合的开销。如果你确实需要 Post 模型实例而不是数据,那么这样做是最好的。

但如果只需要这两个值,你可以执行以下操作。

$posts = Post::pluck('title', 'slug'); //When using eloquent
$posts = DB::table('posts')->pluck('title','slug'); //When using query builder

执行上面的代码时,它会在幕后执行以下操作。

  • 在数据库上执行 select title, slug from posts 查询
  • 创建一个数组,使用 title 作为 数组值,使用 slug 作为 数组键
  • 返回数组(数组格式:[ slug => title, slug => title ]

现在,要访问结果,我们可以执行以下操作

foreach ($posts as $slug => $title){
// $title is the title of a post
// $slug is the slug of a post
}

如果你想只检索一列,你可以执行以下操作

$posts = Post::pluck('title'); //When using eloquent
$posts = DB::table('posts')->pluck('title'); //When using query builder
foreach ($posts as $title){
// $title is the title of a post
}

以上方法避免了为每一行创建 Post 对象。从而减少了内存使用量和处理查询结果所花费的时间。

我建议只在新代码中使用以上方法。我个人认为,回头重构代码以遵循以上技巧并不值得花费时间。只有当代码处理大型数据集或你有空闲时间时才重构现有代码。

4. 使用查询而不是集合来计数行

要计算表中的总行数,我们通常会执行以下操作

$posts = Post::all()->count(); //When using eloquent
$posts = DB::table('posts')->get()->count(); //When using query builder

这将生成以下查询

select * from posts

以上方法将从表中检索所有行,将它们加载到 集合 对象中,然后计算结果。当数据库表中的行较少时,这可以正常工作。但随着表的增长,我们很快就会耗尽内存。

与以上方法不同,我们可以直接在数据库本身中计算总行数。

$posts = Post::count(); //When using eloquent
$posts = DB::table('posts')->count(); //When using query builder

这将生成以下查询

select count(*) from posts

在 SQL 中计数行是一个缓慢的过程,当数据库表中包含大量行时性能很差。最好尽量避免计数行。

5. 通过预加载关系来避免 N+1 查询

你可能已经听过这个技巧无数次了。所以我会尽量简短明了。假设你遇到了以下场景

class PostController extends Controller
{
public function index()
{
$posts = Post::all();
return view('posts.index', ['posts' => $posts ]);
}
}
// posts/index.blade.php file
 
@foreach($posts as $post)
<li>
<h3>{{ $post->title }}</h3>
<p>Author: {{ $post->author->name }}</p>
</li>
@endforeach

上面的代码检索所有帖子并在网页上显示帖子标题及其作者,并假设你的帖子模型有一个 author 关系。

执行上面的代码将导致运行以下查询。

select * from posts // Assume this query returned 5 posts
select * from authors where id = { post1.author_id }
select * from authors where id = { post2.author_id }
select * from authors where id = { post3.author_id }
select * from authors where id = { post4.author_id }
select * from authors where id = { post5.author_id }

如您所见,我们有一个查询用于检索帖子,还有 5 个查询用于检索帖子的作者(因为我们假设有 5 个帖子)。因此,对于检索到的每个帖子,它都会进行一次单独的查询来检索其作者。

因此,如果有 N 个帖子,它将进行 N+1 个查询(1 个查询用于检索帖子,N 个查询用于检索每个帖子的作者)。这通常称为 N+1 查询问题。

为了避免这种情况,请按照以下方法急切加载帖子上的作者关系。

$posts = Post::all(); // Avoid doing this
$posts = Post::with(['author'])->get(); // Do this instead

执行上述代码将导致执行以下查询。

select * from posts // Assume this query returned 5 posts
select * from authors where id in( { post1.author_id }, { post2.author_id }, { post3.author_id }, { post4.author_id }, { post5.author_id } )

6. 急切加载嵌套关系

从上面的例子中,假设作者属于一个团队,并且您希望也显示团队名称。因此,在 Blade 文件中,您将按以下方式执行。

@foreach($posts as $post)
<li>
<h3>{{ $post->title }}</h3>
<p>Author: {{ $post->author->name }}</p>
<p>Author's Team: {{ $post->author->team->name }}</p>
</li>
@endforeach

现在执行以下操作

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

将导致以下查询

select * from posts // Assume this query returned 5 posts
select * from authors where id in( { post1.author_id }, { post2.author_id }, { post3.author_id }, { post4.author_id }, { post5.author_id } )
select * from teams where id = { author1.team_id }
select * from teams where id = { author2.team_id }
select * from teams where id = { author3.team_id }
select * from teams where id = { author4.team_id }
select * from teams where id = { author5.team_id }

如您所见,即使我们急切加载了 `authors` 关系,它仍然在进行更多的查询。因为我们没有急切加载 `authors` 上的 `team` 关系。

我们可以通过执行以下操作来解决此问题。

$posts = Post::with(['author.team'])->get();

执行上面的代码将导致运行以下查询。

select * from posts // Assume this query returned 5 posts
select * from authors where id in( { post1.author_id }, { post2.author_id }, { post3.author_id }, { post4.author_id }, { post5.author_id } )
select * from teams where id in( { author1.team_id }, { author2.team_id }, { author3.team_id }, { author4.team_id }, { author5.team_id } )

通过急切加载嵌套关系,我们将查询总数从 11 个减少到 3 个。

7. 如果只需要 ID,则不要加载 belongsTo 关系

假设您有两个表 `posts` 和 `authors`。Posts 表有一个 `author_id` 列,它表示 authors 表上的 belongsTo 关系。

要获取帖子的作者 ID,我们通常会执行以下操作

$post = Post::findOrFail(<post id>);
$post->author->id;

这将导致执行两个查询。

select * from posts where id = <post id> limit 1
select * from authors where id = <post author id> limit 1

相反,您可以通过执行以下操作直接获取作者 ID。

$post = Post::findOrFail(<post id>);
$post->author_id; // posts table has a column author_id which stores id of the author

何时可以使用上述方法?

当您确信如果在 posts 表中引用了作者表,则该表中始终存在一行时,可以使用上述方法。

8. 避免不必要的查询

通常,我们会进行一些不必要的数据库查询。请考虑以下示例。

<?php
 
class PostController extends Controller
{
public function index()
{
$posts = Post::all();
$private_posts = PrivatePost::all();
return view('posts.index', ['posts' => $posts, 'private_posts' => $private_posts ]);
}
}

上述代码从两个不同的表(例如:`posts`、`private_posts`)中检索行并将它们传递给视图。视图文件如下所示。

// posts/index.blade.php
 
@if( request()->user()->isAdmin() )
<h2>Private Posts</h2>
<ul>
@foreach($private_posts as $post)
<li>
<h3>{{ $post->title }}</h3>
<p>Published At: {{ $post->published_at }}</p>
</li>
@endforeach
</ul>
@endif
 
<h2>Posts</h2>
<ul>
@foreach($posts as $post)
<li>
<h3>{{ $post->title }}</h3>
<p>Published At: {{ $post->published_at }}</p>
</li>
@endforeach
</ul>

如您所见,`$private_posts` 仅对 `admin` 用户可见。其余用户无法看到这些帖子。

这里的问题是,当我们执行以下操作时

$posts = Post::all();
$private_posts = PrivatePost::all();

我们进行了两个查询。一个是获取 `posts` 表中的记录,另一个是获取 `private_posts` 表中的记录。

`private_posts` 表中的记录仅对 `admin 用户` 可见。但即使这些记录对所有用户不可见,我们仍然在进行查询来检索这些记录。

我们可以修改我们的逻辑以避免此额外查询。

$posts = Post::all();
$private_posts = collect();
if( request()->user()->isAdmin() ){
$private_posts = PrivatePost::all();
}

通过将我们的逻辑更改为上述方式,我们对管理员用户进行了两次查询,对所有其他用户进行了查询。

9. 合并类似的查询

有时我们需要进行查询,以从同一表中检索不同类型的行。

$published_posts = Post::where('status','=','published')->get();
$featured_posts = Post::where('status','=','featured')->get();
$scheduled_posts = Post::where('status','=','scheduled')->get();

上述代码从同一表中检索具有不同状态的行。该代码将导致进行以下查询。

select * from posts where status = 'published'
select * from posts where status = 'featured'
select * from posts where status = 'scheduled'

如您所见,它对同一表进行了三次不同的查询,以检索记录。我们可以重构此代码以仅进行一次数据库查询。

$posts = Post::whereIn('status',['published', 'featured', 'scheduled'])->get();
$published_posts = $posts->where('status','=','published');
$featured_posts = $posts->where('status','=','featured');
$scheduled_posts = $posts->where('status','=','scheduled');
select * from posts where status in ( 'published', 'featured', 'scheduled' )

上述代码进行一次查询以检索所有具有指定状态的帖子,并通过其状态过滤返回的帖子,为每个状态创建单独的集合。因此,我们仍然将拥有三个不同的变量及其状态,并将仅进行一次查询。

10. 为频繁查询的列添加索引

如果您通过向基于 `string` 的 `column` 添加 `where` 条件来进行查询,最好向该列添加索引。使用索引列查询行时,查询速度会快得多。

$posts = Post::where('status','=','published')->get();

在上面的示例中,我们通过向 `status` 列添加 where 条件来查询记录。我们可以通过添加以下数据库迁移来提高查询性能。

Schema::table('posts', function (Blueprint $table) {
$table->index('status');
});

11. 使用 simplePaginate 而不是 Paginate

对结果进行分页时,我们通常会执行以下操作

$posts = Post::paginate(20);

这将进行两个查询,第一个查询用于检索分页结果,第二个查询用于计算表中行的总数。计算表中的行是一个缓慢的操作,会对查询性能产生负面影响。

那么 Laravel 为何要计算行的总数?

为了生成分页链接,Laravel 计算了行的总数。因此,当生成分页链接时,您会事先知道将有多少页,以及上一页的页码是多少。因此,您可以轻松地导航到您想要的任何页面。

另一方面,执行 `simplePaginate` 不会计算行的总数,并且查询速度将比 `paginate` 方法快得多。但是,您将失去了解最后一页页码并能够跳转到不同页面的功能。

如果您的数据库表包含太多行,最好避免使用 `paginate` 并改为使用 `simplePaginate`。

$posts = Post::paginate(20); // Generates pagination links for all the pages
$posts = Post::simplePaginate(20); // Generates only next and previous pagination links

何时使用 paginate 与 simplePaginate?

查看下面的比较表,并确定 paginate 或 simplePaginate 是否适合您

paginate / simplePaginate
数据库表只有几行,不会变得很大 paginate / simplePaginate
数据库表包含很多行,并且增长很快 simplePaginate
必须为用户提供跳转到特定页面的选项 paginate
必须向用户显示结果总数 paginate
没有积极使用分页链接 simplePaginate
UI/UX 不会受到将编号分页链接切换到下一页/上一页分页链接的影响 simplePaginate
使用“加载更多”按钮或“无限滚动”进行分页 simplePaginate

12. 避免使用前导通配符(LIKE 关键字)

当尝试查询与特定模式匹配的结果时,我们通常会使用以下方法

select * from table_name where column like %keyword%

上述查询将导致进行完全表扫描。如果我们知道关键字出现在列值的开头,我们可以按以下方式查询结果。

select * from table_name where column like keyword%

13. 避免在 where 子句中使用 SQL 函数

最好避免在 where 子句中使用 SQL 函数,因为它们会导致完全表扫描。让我们看看下面的示例。为了根据特定日期查询结果,我们通常会执行以下操作

$posts = POST::whereDate('created_at', '>=', now() )->get();

这将导致类似于以下的查询

select * from posts where date(created_at) >= 'timestamp-here'

上述查询将导致进行完全表扫描,因为在评估 `date` 函数之前,不会应用 where 条件。

我们可以重构此代码以避免使用 `date` SQL 函数,如下所示

$posts = Post::where('created_at', '>=', now() )->get();
select * from posts where created_at >= 'timestamp-here'

14. 避免向表中添加太多列

最好限制表中的列总数。像 MySQL 这样的关系数据库可以用来将具有太多列的表拆分为多个表。它们可以通过使用其主键和外键进行连接。

向表中添加太多列会增加单个记录的长度,并且会减慢表扫描速度。当您执行 `select *` 查询时,您最终会检索到许多您实际上并不需要的列。

15. 将具有文本数据类型的列分离到自己的表中

此技巧来自个人经验,不是架构数据库表的一种标准方法。我建议仅在您的表包含太多记录或会快速增长时才遵循此技巧。

如果表包含存储大量数据的列(例如:数据类型为 TEXT 的列),最好将它们分离到自己的表中或分离到不太常被查询的表中。

当表包含大量数据的列时,单个记录的大小会变得非常大。我个人观察到它影响了我们其中一个项目中的查询时间。

假设您有一个名为 `posts` 的表,其中有一个名为 `content` 的列,用于存储博客文章内容。博客文章的内容将非常庞大,而且通常只有在有人查看此特定博客文章时才需要此数据。

因此,将此列与 posts 表分离将大大提高在存在太多帖子时的查询性能。

16. 从表中检索最新行的更好方法

当我们想要从表中检索最新行时,我们通常会执行以下操作

$posts = Post::latest()->get();
// or $posts = Post::orderBy('created_at', 'desc')->get();

上述方法将生成以下 SQL 查询。

select * from posts order by created_at desc

该查询基本上是根据 created_at 列按降序对行进行排序。由于 created_at 列是基于字符串的列,因此通常通过这种方式对结果进行排序会比较慢。

如果您的数据库表具有自动递增的主键 ID,那么在大多数情况下,最新行将始终具有最高的 ID。由于 ID 字段是整数字段,也是主键,因此根据此键对结果进行排序速度要快得多。因此,检索最新行的更好方法如下所示。

$posts = Post::latest('id')->get();
// or $posts = Post::orderBy('id', 'desc')->get();
select * from posts order by id desc

17. 优化 MySQL 插入操作

到目前为止,我们一直在研究如何优化用于从数据库中检索结果的 `select` 查询。在大多数情况下,我们只需要优化读取查询。但有时我们发现需要优化 `insert` 和 `update` 查询。我在一篇关于 优化 MySQL 插入操作 的有趣文章中找到了帮助优化缓慢插入和更新操作的内容。

18. 检查和优化查询

在优化 Laravel 中的查询时,没有一个通用的解决方案。只有您知道您的应用程序在做什么,它进行了多少个查询,以及其中有多少个实际上正在使用。因此,检查您的应用程序执行的查询将有助于您确定并减少执行的查询总数。

有一些工具可以帮助您检查在每个页面上执行的查询。

**注意:** 建议不要在生产环境中运行任何这些工具。在生产应用程序上运行这些工具会导致应用程序性能下降,并且如果被入侵,未经授权的用户将获得对敏感信息的访问权限。

  • **Laravel Debugbar** - Laravel debugbar 具有一个名为 `database` 的选项卡,它将显示您访问页面时执行的所有查询。访问您的应用程序中的所有页面,并查看在每个页面上执行的查询。
  • Clockwork - Clockwork 与 Laravel debugbar 相似。但它不是将工具栏注入您的网站,而是通过访问 yourappurl/clockwork开发者工具窗口 或独立 UI 中显示调试信息。
  • Laravel Telescope - Laravel Telescope 是在本地开发 Laravel 应用程序时一个很棒的调试助手。安装 Telescope 后,您可以通过访问 yourappurl/telescope 访问仪表盘。在 Telescope 仪表盘中,前往 queries 选项卡,它将显示您的应用程序执行的所有查询。
Srinath Reddy Dudi photo

Srinath 是一位全栈 Web 和开源软件开发人员,他热爱 Laravel 框架。他每天在 twitter 上分享 Laravel 技巧。

Cube

Laravel 新闻

加入 40,000 多名其他开发者,不要错过任何新的技巧、教程等等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和本地和生产环境的即时反馈进行微调。

Tinkerwell
No Compromises logo

绝不妥协

Joel 和 Aaron,两位来自 No Compromises 播客的经验丰富的开发人员,现在可以为您的 Laravel 项目雇用。 ⬧ 固定价格 7500 美元/月。 ⬧ 没有冗长的销售流程。 ⬧ 没有合同。 ⬧ 100% 退款保证。

绝不妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,以确保您的 Web 应用程序成功。

Kirschbaum
Shift logo

Shift

运行旧版本的 Laravel?即时、自动化的 Laravel 升级和代码现代化,让您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

只需每月 2500 美元,即可为您的项目配备经验丰富的 Laravel 开发人员,拥有 4-6 年的经验。获得 160 小时的专业知识和 15 天无风险试用。立即安排通话!

Bacancy
Lucky Media logo

Lucky Media

立即获得幸运 - Laravel 开发的理想选择,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar:Laravel 电子商务

Laravel 的电子商务。一个开源软件包,为 Laravel 带来了现代无头电子商务功能的强大功能。

Lunar:Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 工作板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit:Laravel SaaS 启动工具包

SaaSykit 是一个 Laravel SaaS 启动工具包,它包含运行现代 SaaS 所需的所有功能。支付、漂亮的结帐、管理面板、用户仪表盘、身份验证、现成的组件、统计数据、博客、文档等等。

SaaSykit:Laravel SaaS 启动工具包
Rector logo

Rector

您的无缝 Laravel 升级合作伙伴,降低成本,加速创新,帮助企业成功。

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成,增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务性、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解 MongoDB 的灵活、现代数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的无依赖项输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel 提示构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包为您的 Laravel 应用程序添加评论

阅读文章