18 个优化 Laravel 数据库查询的技巧
发布于 作者 Srinath Reddy Dudi
如果你的应用程序运行缓慢或执行了大量的数据库查询,请按照以下性能优化技巧来改善应用程序加载时间。
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 eloquentforeach (Post::cursor() as $post){ // Process a single post} // when using query builderforeach (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 }});
chunk
和 chunkById
之间的主要区别在于,chunk 基于 offset
和 limit
进行检索。而 chunkById
基于 id
字段检索数据库结果。这个 id 字段通常是一个整数字段,在大多数情况下它是一个自动递增字段。
chunk
和 chunkById
执行的查询如下所示。
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 builderforeach ($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 postsselect * 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 postsselect * 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 postsselect * 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 postsselect * 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 1select * 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
选项卡,它将显示您的应用程序执行的所有查询。