Giới thiệu

Xin chào các bạn!

Vẫn trong chuyên đề cải thiện hiệu năng trang web. Hôm nay chúng ta cùng tìm hiểu cách sử dụng index trong mysql để tăng tốc các câu truy vấn đến cơ sở dữ liệu của mình.

Mình nói qua một chút về index. Index là một cấu trúc dữ liệu giúp cải thiện tốc độ truy vấn trong một bảng. Nó giống như những trang mục lục trong một cuốn sách vậy. Dựa vào mục lục thì ta sẽ biết được số trang của phần mà mình cần tìm và ngay lập tức lật đến trang đó mà không cần xem nội dung của những phần trước. Cũng giống như vậy index sẽ giúp cơ sở dữ liệu của bạn không cần phải duyệt qua tất cả các bản ghi trong bảng mà có thể trực tiếp tìm ra các giá trị phù hợp.

Bài viết hôm nay chúng ta sẽ cùng nhau tìm hiểu cách sử dụng index trong một project laravel sao cho hiệu quả nhé!

Chuẩn bị

Trong bài mình sẽ dùng:

  • Laravel 5.7
  • Cơ sở dữ liệu Mysql 5.7.24

Cơ sở dữ liệu trong bài sẽ gồm 3 bảng:

  • Bảng users: Lưu thông tin người dùng (id, name, email, password)
  • Bảng posts: Lưu thông tin bài viết (id, user_id, title, content)
  • Bảng comments: Lưu các comments của người dùng cho bài viết (id, user_id, post_id, content)

Migration của từng bảng sẽ như sau:

// database/migrations/2014_10_12_000000_create_users_table.php
Schema::create('users', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('email')->unique();
    $table->timestamp('email_verified_at')->nullable();
    $table->string('password');
    $table->rememberToken();
    $table->timestamps();
});
// database/migrations/2018_11_14_145642_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
    $table->increments('id');
    $table->string('title');
    $table->unsignedInteger('user_id');
    $table->text('content');
    $table->timestamps();
});
// database/migrations/2019_02_19_182318_create_comments_table.php
Schema::create('comments', function (Blueprint $table) {
    $table->increments('id');
    $table->unsignedInteger('user_id');
    $table->unsignedInteger('post_id');
    $table->text('content');
    $table->timestamps();
});

Mình sẽ dùng factoryseeder để tạo ra một lượng dữ liệu đủ lớn: gần 1.000 users, gần 200.000 posts và hơn 2.000.000 comments. Dữ liệu của toàn bộ database khoảng 519.8MB.

Cài đặt và cấu hình

Để debug các câu truy vấn database một cách dễ dàng ta sẽ sử dụng gói barryvdh/laravel-debugbar. Cài đặt bằng composer như sau:

composer require barryvdh/laravel-debugbar --dev

Đối với phiên bản laravel 5.7 thì mình không cần phải làm thêm gì cả. Các gói khi cài thêm sẽ được tự động kích hoạt vào project laravel của mình.

Để biết thêm nhiều thông tin hơn về cách thực hiện câu truy vấn thì mình sẽ thêm EXPLAIN vào trước mỗi câu truy vấn. Laravel debug bar đã hỗ trợ chúng ta EXPLAIN câu truy vấn. Chỉ cần bật nó lên trong file cấu hình debug bar là xong.

Đầu tiên phải publish file cấu hình lên thư mục config:

php artisan vendor:publish --provider="Barryvdh\Debugbar\ServiceProvider"

Trong file cấu hình debugbar chuyển trạng thái enabled của explain db từ false sang true

//config/debugbar.php

//.....................
        'db' => [
            'with_params'       => true,   // Render SQL with the parameters substituted
            'backtrace'         => true,   // Use a backtrace to find the origin of the query in your files.
            'timeline'          => true,  // Add the queries to the timeline
            'explain' => [                 // Show EXPLAIN output on queries
                'enabled' => true,
                'types' => ['SELECT'],     // // workaround ['SELECT'] only. https://github.com/barryvdh/laravel-debugbar/issues/888 ['SELECT', 'INSERT', 'UPDATE', 'DELETE']; for MySQL 5.6.3+
            ],
            'hints'             => true,    // Show hints for common mistakes
        ],
//.....................

Sau khi bật explain query lên thì khi thực hiện truy vấn sẽ có thông tin chi tiết cách mà query được thực hiện như thế nào hiển thị trong thanh debug.

Ý nghĩa từng thành phần của kết quả trả về bạn có thể đọc bài viết Sử dụng EXPLAIN để tối ưu câu lệnh MySQL để biết thêm chi tiết. Ở đây mình cần quan tâm các thuộc tính:

  • type cách MySQL sử dụng các bảng để thực hiện truy vấn.
  • possible_keys keys có thể được dùng bởi MySQL để tìm dòng trong bảng
  • rows số lượng bản ghi đã được duyệt để trả về kết quả

Sử dụng index

Index mặc định

Các trường khóa chính (id), trường unique (email trong bảng users) sẽ được mặc định tạo index.

Như trong bảng users của mình có 2 index

Khi một người dùng đăng nhập, laravel sẽ sử dụng email để thực hiện truy vấn lấy ra một bản ghi sau đó kiểm tra password nhập vào có khớp với mã trong trường password của bản ghi thu được không.

Do trường email đã được tạo index nên khi xem explain của truy vấn ta sẽ thấy:

  • type: const có duy nhất 1 dòng đã được đánh chỉ mục khớp với điều kiện tìm kiếm
  • possible_keys: users_email_unique tên chỉ mục đã được dùng cho việc tìm kiếm
  • rows: 1 thể hiện là chỉ có 1 bản ghi được duyệt qua khi thực hiện truy vấn này

Thời gian thực hiện truy vấn rất nhanh chỉ có 1.24ms

Bây giờ thử xóa index của cột email đi nhé

Kết quả một truy vấn đăng nhập: thời gian thực hiện truy vấn tăng lên rất nhiều. Mysql phải duyệt toàn bộ dữ liệu của bảng users để tìm ra bản ghi phù hợp.

Đây mới là bảng người dùng có hơn 1000 bản ghi. Đối với những bảng lớn hơn thì con số này quả thực là rất lớn.

Có một truy vấn, request nào cũng được thực hiện (đối với những request phải qua middle auth nhé) khi người dùng đã đăng nhập. Mỗi request gửi lên, Laravel sẽ thực hiện một truy vấn sử dụng id được lưu trong phiên đăng nhập để lấy thông tin người dùng đó từ cơ sở dữ liệu. Bạn có thể thấy truy vấn thực hiện với id rất nhanh. Mysql đã sử dụng index PRIMARY để thực hiện truy vấn.

Mình xin nói thêm một chút về những hàm lấy người dùng đang đăng nhập trong laravel Auth::user(), $request->user(). Khi gọi các hàm này thì truy vấn lấy người dùng có được thực hiện lại không? Câu trả lời là không nhé. Nó còn là một object đấy. Tức là khi bạn thay đổi thông tin $user trong bất kỳ cách gọi nào thì kết quả trả về của hàm kia cũng thay đổi theo.

Giả sử mình làm như sau:

    public function index(Request $request)
    {
        $userFromRequest = $request->user();
        $userFromRequest->name = 'Hoang Hoi';
        $userFromAuth = Auth::user();
        dd($userFromRequest, $userFromAuth);
    }

Kết quả không có truy vấn lấy người dùng nào được thực hiện thêm. Và chúng được in ra có dữ liệu giống nhau mặc dù mình chưa save() lại.

Lan man một chút thôi. Bây giờ bạn đã biết index quan trọng đến mức nào rồi phải không. Nếu không có index các trường id, email thì hệ thống của bạn chạy chậm đi rất nhiều rồi. Nhưng bạn không cần quan tâm đến những trường dữ liệu như thế này. Nó đã được tạo index theo mặc định. Bây giờ chúng ta phải tìm hiểu khi nào cần tạo index sẽ là hợp lý.

Tạo index khi cần thiết

Vậy khi nào cần thiết cần tạo index?

Đây là một câu hỏi tưởng chừng như đơn giản nhưng vô cùng hóc búa. Vì chúng ta phải cần bằng giữa cái lợi và hại khi tạo index. Index sẽ giúp truy vấn đọc (SELECT) nhanh hơn nhưng những truy vấn ghi (CREATE, UPDATE, DELETE) sẽ bị chậm đi vì phải thêm thao tác cập nhật lại dữ liệu index.

Xét trong trường hợp cụ thể: trang xem chi tiết một bài viết có load thêm commentsuser đã comment.

// app/Http/Controllers/HomeController.php
    public function post(Post $post)
    {
        $comments = $post->comments()->with('user')->paginate();
        return view('post', [
            'post' => $post,
            'comments' => $comments,
        ]);
    }

Có 4 truy vấn được thực hiện (mình bỏ truy vấn lấy người dùng đang đăng nhập) để lấy dữ liệu.

Để ý truy vấn tốn nhiều thời gian nhất là truy vấn lấy nội dung comments của bài viết. Truy vấn này đã sử dụng khóa ngoại post_id trong bảng comments để so sánh với id của bài viết hiện tại.

Xem xét explain của truy vấn ta có thể thấy:

  • type: all toàn bộ bảng được duyệt
  • possible_keys: null không có chỉ mục được dùng
  • rows: 2476184 tất nhiên số bản ghi được duyệt qua là toàn bộ số bản ghi của bảng

Thời gian truy vấn thì rất chậm, những 2.39s.

post_id chính là điều kiện để duyệt và lấy các bản ghi phù hợp. Nếu nó được tạo chỉ mục để việc tìm kiếm nhanh hơn thì tốc độ thực hiện truy vấn sẽ nhanh. Bây giờ chúng ta thử tạo index cho post_id xem sao.

Cú pháp để tạo index cho bảng như sau:

CREATE INDEX index_name ON table_name (column_list)

Trong trường hợp này mình sẽ chạy câu lệnh:

CREATE INDEX idx_comments_post_id ON comments (post_id)

Kiểm tra các index trong bảng comments ta sẽ thấy xuất hiện idx_comments_post_idindex đã được tạo thành công.

Bây giờ tải lại trang thông tin chi tiết một bài viết xem kết quả thế nào

Truy vấn lấy nội dung comments của bài viết đã được thực hiện nhanh hơn rất nhiều. Xem chi tiết có thể thấy mysql đã sử dụng index mình vừa tạo ra (idx_comments_post_id) để thực hiện truy vấn nên chỉ phải duyệt 10 bản ghi để lấy ra dữ liệu phù hợp.

Bạn có thể đánh index tất cả các khóa ngoại của mình post_id trong bảng comments, user_id trong bảng postsuser_id trong bảng comments. Nhưng bạn phải cân nhắc khi tạo nhiều index. Các truy vấn làm thay đổi dữ liệu (CREATE, UPDATE, DELETE) khiến dữ liệu index phải được tạo và sắp xếp lại sẽ trở lên chậm đi. Ở đây mình không tạo index cho user_id trong bảng comments vì mình không có ý định lọc comments của người dùng nào cả mà sẽ hiển thị hết luôn.

Một trường hợp khác: Mình cho thêm bộ lọc vào trang danh sách bài viết

Tại controller xử lý thêm các điều kiện lọc nếu có

// app/Http/Controllers/HomeController.php
    public function index(Request $request)
    {
        $conditions = [
            'user_id' => $request->get('user_id'),
            'created_at' => $request->get('created_at'),
        ];
        $posts = Post::with('user');

        if ($conditions['user_id']) {
            $posts->where('user_id', $conditions['user_id']);
        }
        if ($conditions['created_at']) {
            $posts->where('created_at', $conditions['created_at']);
        }

        $posts = $posts->orderBy('created_at', 'desc')->paginate();

        return view('home', [
            'posts' => $posts,
            'users' => User::all(),
            'conditions' => $conditions,
        ]);
    }

Mình sẽ chọn một người đăng bất kì (Ví dụ: Janick Lowe).

Chú ý câu truy vấn lấy các bài viết sẽ thêm điều kiện ``user_id= '545' và mysql đã sử dụng index có khóa là idx_posts_user_id (index mình đã tạo ở trên với các khóa ngoại) để thực hiện truy vấn. Tốc độ truy vấn khá nhanh chỉ 4.19ms.

Khi mình chỉ nhập thời gian thì thời gian truy vấn nó lại tăng lên thành 646ms.

Lý do lại cột created_at chưa được tạo index nên mysql phải duyệt toàn bộ bản ghi của bảng để tìm các giá trị phụ hợp với ngày ta nhập vào. Tạo thêm index cho cột created_at thì tốc độ lại được cải thiện hơn.

Khi mình vừa chọn user vừa nhập thời gian thì tốc độ truy vấn cũng rất nhanh chỉ mất 1.63ms. Không phải cứ nhiều điều kiện thì tốc độ sẽ giảm đâu nhé. 😃. Xem trong explain câu truy vấn thì cả 2 index (idx_posts_user_id, idx_posts_created_at) mình tạo trong bảng posts đều được sử dụng. Vậy có thể nhanh hơn nữa không? Tất nhiên là có. Mình sẽ tạo index cho cả 2 cột user_idcreated_at.

CREATE INDEX idx_posts_user_id_created_at ON posts (user_id, created_at)

Kết quả chúng ta nhận được

Câu truy vấn chỉ còn 680us để thực hiện (chưa đến 1ms). Nếu bỏ tất cả index đi thì câu truy vấn phải mất 766ms để hoàn thành. Kết quả thật khả quan phải không các bạn. Tuy nhiên như mình nói ở trên. Nếu tạo nhiều index thì thời gian để thực hiện các câu truy vấn ghi dữ liệu sẽ lâu do mysql phải làm thêm một thao tác nữa là cập nhật lại dữ liệu index. Tùy theo yêu cầu thực tế của dự án mà bạn có thể sử dụng index cho phù hợp. Thông thường các trường cần tạo index sẽ là các điều kiện trong mệnh đề where của câu truy vấn.

Tạo index trong laravel

Trong laravel việc tạo index rất đơn giản.

Để tạo index cho một cột

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

Tạo index cho nhiều cột thì ta dùng cú pháp

Schema::table('posts', function(Blueprint $table) {
    $table->index(['user_id', 'created_at']);
});

Kết luận

Trên đây là chia sẻ của mình về lợi ích của index và cách sử dụng index để tăng tốc câu truy vấn. Bài viết của mình xin kết thúc tại đây. Hi vọng sẽ giúp ích được cho các bạn. Nếu bạn thấy hay thì hãy upvote và chia sẻ cho bạn bè cùng đọc. Đừng quên Follow mình để nhận được thông báo khi mình có bài viết mới nhé. Chúc các bạn có một ngày tốt lành!