Laravel 数据库性能优化实战(二):关联查询性能优化入门


我们继续介绍如何在 Laravel 项目中优化数据库查询,今天要给大家演示的是如何通过渴求式加载和指定查询字段降低内存使用率并提升查询效率。

模型数据准备工作

开始之前,我们在演示项目中新建一个 Post 模型类和对应的数据表:

php artisan make:model Post -mc

上述 Artisan 命令会同时创建 Post 模型类和对应的 posts 数据表迁移文件,以及 PostController 控制器类。

编写新增的 posts 数据表迁移文件代码如下:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->bigInteger('user_id')->unsigned()->index();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

我们为 posts 表设置了如下的数据表结构:

CREATE TABLE `posts` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `title` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
  `content` text COLLATE utf8mb4_unicode_ci NOT NULL,
  `user_id` bigint unsigned NOT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `posts_user_id_index` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

为了提升连接查询效率,我们为与 users 表关键的外键 user_id 字段设置了普通索引。这个外键关联是通过在 Post 模型类中定义 author 关联方法实现的(用户与文章之间是一对多关联):

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasFactory;

    public function author()
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}

接下来我们为 Post 模型类定义模型工厂:

php artisan make:factory PostFactory

编写这个模型工厂实现代码如下:

<?php

namespace Database\Factories;

use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class PostFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Post::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'title' => $this->faker->sentence,
            'content' => $this->faker->paragraph,
            'user_id' => User::factory()
        ];
    }
}

然后在 database/seeders/DatabaseSeeder.php 中通过 PostFactory 模型工厂填充数据库数据:

<?php

namespace Database\Seeders;

use App\Models\Post;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        Post::factory()->count(10000)->create();
    }
}

最后通过数据库迁移命令重建数据库并填充数据:

php artisan migrate:refresh --seed

填充完成后,就做好了 Eloquent 模型数据准备工作。

通过分页列表获取文章数据

接下来,我们在 PostController 中编写列表方法 index 代码如下:

<?php

namespace App\Http\Controllers;

use App\Models\Post;

class PostController extends Controller
{
    public function index()
    {
        $posts = Post::orderByDesc('created_at')->paginate(100);
        return view('post.index', ['posts' => $posts]);
    }
}

这里我们通过 Eloquent 分页器获取根据创建时间逆序排序的 100 篇文章数据,在视图模板 resources/views/post/index.blade.php 中进行渲染:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            文章列表
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
                <div class="flex justify-center">
                    <table class="w-full m-8">
                        <thead>
                        <tr>
                            <th class="px-4 py-2">ID</th>
                            <th class="px-4 py-2">Title</th>
                            <th class="px-4 py-2">Author</th>
                            <th class="px-4 py-2">Created At</th>
                        </tr>
                        </thead>
                        <tbody>
                        @foreach ($posts as $post)
                            <tr>
                                <td class="border px-4 py-2">{{ $post->id }}</td>
                                <td class="border px-4 py-2">{{ $post->title }}</td>
                                <td class="border px-4 py-2">{{ $post->author->name }}</td>
                                <td class="border px-4 py-2">{{ $post->created_at->diffForHumans() }}</td>
                            </tr>
                        @endforeach
                        </tbody>
                    </table>
                </div>

                <div class="max-w-full m-8">
                    {{ $posts->links() }}
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

在文章列表中,我们使用了动态属性获取与文章模型关联的用户模型信息:

-w1437

通过渴求式加载提升查询性能

我们将这种「偷懒式」的关联查询称之为「懒惰式加载」,当以「懒惰式加载」获取关联模型数据的时候,存在 N+1 查询问题(每次在循环体内的文章模型实例上进行一次关联查询,这里 N = 100,所以存在 100 次用户表查询,加上文章表的 1 次分页查询,总共是 101 次查询,故而称之为 N+1),可以看到「懒惰式加载」不管是从内存使用效率、还是查询性能(为了提升查询性能,应该尽量减少与数据库的交互次数)来讲,都是不太好的。

要解决这个问题,需要通过「渴求式加载」在进入循环体之前,通过文章 ID 一次性查询出所有的关联用户模型数据,这样就可以避免 N+1 查询问题了,在 Laravel 中我们可以这样实现渴求式加载:

public function index()
{
    $posts = Post::with('author')->orderByDesc('created_at')->paginate(100);
    return view('post.index', ['posts' => $posts]);
}

刷新文章列表页,可以看到到无论内存使用还是查询性能都得到了大幅优化,尤其是查询性能,耗时降到了原来的八分之一:

-w1428

通过指定查询字段进一步优化

除此之外,我们还可以在查询时通过指定查询数据列进一步优化内存使用和查询性能,这对一些包含较大字段(比如文章表的文章内容字段)的数据表而言,尤其显著,在指定查询字段的时候,可以指定模型本身的查询字段,还可以指定关联模型的查询字段:

public function index()
{
    $posts = Post::with('author:name')
        ->select(['id', 'title', 'user_id', 'created_at'])
        ->orderByDesc('created_at')
        ->paginate(100);
    return view('post.index', ['posts' => $posts]);
}

由于本示例项目数据表本身数据量较小,字段长度也不大,所以可以看到查询性能有了小幅提升,内存使用率上未见明显优化:

-w1430

关于指定查询字段与数据库查询性能优化的底层原理,请参考高性能 MySQL 实战索引篇的介绍。

你也可以使用一些文章内容字段更长的测试数据进行对比测试,比如学院君网站文章表为例进行测试,效果就很明显:

-w1423

-w1431

上图是未指定查询字段,下图是指定了查询字段,可以看到,这里在不存在关联查询的情况下,仅仅通过指定查询字段就大幅提升了查询性能和内存使用率,因为这里影响性能对比的核心指标是文章内容字段值的长度,学院君网站上的文章很多都比较长,这里给我们的启示是:在做列表查询时,排除列表渲染不需要的详情内容字段往往可以大幅提升数据库查询性能。

嵌套关联查询优化

有时候 Eloquent 模型之间存在嵌套关联,比如对于一个博客应用,有用户表、文章表、评论表,用户与文章和评论之间都是一对多关联、文章和评论之间也存在一对多关联,如果要获取指定文章下某个评论的用户信息,就是一个嵌套的关联查询了。

对于嵌套的关联查询,只需要按照层级进行渴求式加载即可(在进行关联查询时,可以通过数组一次性指定多个关联模型的渴求式加载):

$posts = Post::with(['author:id,name', 'comments', 'comments.author:id,name']);

在嵌套关联的渴求式加载中,仍然支持指定要查询的字段。

对于一个已加载的模型实例,要对其关联模型进行渴求式加载,可以通过 load 方法实现「懒惰渴求式加载」:

public function show(Post $post)
{
    $post->load(['author:id,name', 'comments', 'comments.author:id,name']);
    ...
}

其优化效果和正常的渴求式加载一样。


点赞 取消点赞 收藏 取消收藏

<< 上一篇: Laravel 数据库性能优化实战(一):测量数据库性能指标

>> 下一篇: Laravel 数据库性能优化实战(三):聚合统计查询性能优化