Tối ưu hóa truy vấn dữ liệu trong Laravel Controller

Tối ưu hóa truy vấn dữ liệu trong Laravel Controller

Hiệu suất của ứng dụng và đặc biệt là những cách sử dụng tốt hơn với lớp cơ sở dữ liệu để cải thiện hiệu suất của các ứng dụng luôn là một trong những vấn đề được quan tâm hàng đầu trong các website, phần mềm của QMAS. Hôm nay tôi muốn chia sẻ một quy tắc mà chúng tôi thấy vô cùng hữu ích, và luôn áp dụng khi xây dựng các ứng dụng Laravel của mình.

Nguyên tắc đó là: “hãy tối ưu các truy vấn cơ sở dữ liệu trong phạm vi ngoài của ứng dụng càng nhiều càng tốt.

Điều này có nghĩa là gì? Theo cách diễn tả đơn giản nhất, nó có nghĩa là thiết kế ứng dụng sao cho chúng ta có thể eager loadfilter các Model Relationships ngay trong Controller.

Tôi đã thấy rất nhiều các bạn thực tập tại công ty gặp vấn đề về hiệu suất trong các ứng dụng Laravel của họ vì họ chạy các truy vấn cơ sở dữ liệu ngay trong các Model, Resources, View, hay các Serivice class Tuy nhiên, khi làm điều này, họ đã loại bỏ khả năng sử dụng trực tiếp Controller để tối ưu hóa các truy vấn cho những Request.

Hãy xem ví dụ.

Một ứng dụng đơn giản

Giả sử chúng ta có một ứng dụng thư viện gia đình đơn giản, ứng dụng này theo dõi những cuốn sách mà ai đó sở hữu. Các Model của chúng ta có thể sẽ trông như thế này:

class User extends Model
{
    public function books()
    {
        return $this->belongsToMany(Book::class)
            ->withPivot('favourite');
    }
}

class Book extends Model
{
    public function authors()
    {
        return $this->belongsToMany(Author::class);
    }
}

class Author extends Model
{
    public function books()
    {
        return $this->belongsToMany(Book::class);
    }
}

Bây giờ, hãy tưởng tượng nếu chúng ta muốn tạo một trang trong ứng dụng này để hiển thị những cuốn sách yêu thích của một người dùng nào đó. Chúng tôi có thể tạo một Controller đơn giản như sau:

class FavouriteBooksController extends Controller
{
    public function index(User $user)
    {
        return View::make('favourites', ['user' => $user]);
    }
}

Vì chỉ muốn hiển thị những cuốn sách yêu thích, nên chúng ta sẽ cần một số cách để lọc sách của người dùng để chỉ bao gồm những cuốn sách yêu thích của họ. Một cách làm phổ biến là thêm một phương thức favouriteBooks() vào model User:

class User extends Model
{
    public function books()
    {
        return $this->belongsToMany(Book::class)
            ->withPivot('favourite');
    }

    public function favouriteBooks()
    {
        return $this->books()
            ->where('favourite', true)
            ->with('authors')
            ->get();
    }
}

Ví biết rằng chúng ta sẽ cần hiển thị cả tên tác giả cuốn sách nên rất thông minh, chúng ta sử dụng luôn eager loading với model Author để tránh vấn đề N+1 query. Và cuối cùng, chúng ta tạo một Blade Template để hiển thị tất cả các cuốn sách yêu thích của người dùng.

<h1>{{ $user->name }}’s Favourite Books</h1>

@foreach($user->favouriteBooks() as $book)
    <h2>{{ $book->title }}</h2>
    <div>Authors: {{ $book->authors->implode('name', ', ') }}</div>
@endforeach

Trổng ổn đấy chứ 😎. Controller, ViewModel đều gọn gàng và dễ hiểu. Và, chúng ta thậm chí còn có một phương thức favouriteBooks() có thể được sử dụng lại ở những nơi khác trong ứng dụng. Quá tuyệt phải không?

Hà Nội không vội được đâu bạn ơi. Cứ từ từ...

Vấn đề hiệu suất

Chỉ với một vài đoạn code đơn giản bên trên. chúng ta đã gây ra 2 vấn đề về hiệu suất trong ứng dụng của mình.

Đầu tiên, chúng ta đang ép ứng dụng luôn phải thực hiện thêm các truy vấn lấy danh sách tác giả bất kể khi nào phương thức favouriteBooks() được gọi. Điều này nghĩa là ở những nơi khác của ứng dụng không cần tới danh sách tác giả này, chúng ta đang bị thừa ít nhất một truy vấn. Vấn đề còn trở nên lớn hơn nữa nếu ứng dụng của chúng ta cần thêm các mối quan hệ khác của model Book như Nhà xuất bản, hay thể loại sách...

Thứ 2, chúng ta đang tự làm khó mình khi không thể eager load tất cả các cuốn sách yêu thích cho một danh sách người dùng do favouriteBooks() sẽ luôn quét toàn bộ cơ sở dữ liệu với mỗi người dùng được lấy. Vấn đề này không xảy ra nếu trang chỉ hiển thị danh sách cho một người dùng duy nhất, nhưng sẽ gây ra vấn đề N+1 trên một trang hiển thị nhiều người dùng.

Những gì chúng ta thực sự đã tạo ra ở đây là một một phương thức được tối ưu hóa cho một trường hợp sử dụng vô cùng cụ thể trong ứng dụng mà lại ảo tưởng sức mạnh, nghĩ rằng nó có thể tái sử dụng ở một nơi nào đó. Điều này gần như được đảm bảo sẽ dẫn tới một vấn đề về hiệu suất trong tương lai.

Chuyển các tối ưu sang Controller

Vấn đề chính khi chạy các truy vấn cơ sở dữ liệu trong các model là các model này không thể biết ngữ cảnh mà nó đang được sử dụng; và do đó không thể thực hiện các tối ưu hóa cần thiết. Điều này không chỉ đúng với model, mà còn có thể áp dụng cho View, ResourcesService class...

Trong đó ở một mặt khác, các Controller thì ngược lại. Chúng hoàn toàn có khả năng nhận biết được ngữ cảnh và hiểu chính xác những dữ liệu nào được yêu cầu. Lý do này đặt chúng vào vị trí tốt nhất để thực hiện bất kỳ tối ưu hóa truy vấn dữ liệu cần thiết nào.

Bây giờ, chúng ta hãy thử tái cấu trúc lại ứng dụng bên trên và chuyển các tối hưu hóa sang cho Controller.

Trước tiên, hãy cập nhật lại phương thức favouriteBooks() để xử lý danh sách các cuốn sách sang cho bộ nhớ trong thay vì Query Builder để tránh thực hiện bất kỳ truy vấn nào trong model.

class User extends Model
{
    public function books()
    {
        return $this->belongsToMany(Book::class);
    }

    public function favouriteBooks()
    {
        // return $this->books()->where('favourite', true)->with('authors')->get();
        return $this->books->where('pivot.favourite', true);
    }
}

Xem xong đoạn code trên có thể bạn sẽ nghĩ: “Nhưng đoạn code này vẫn thực hiện truy vấn dữ liệu vì Laravel sẽ lazy load các model Book.” Bạn đoán chính xác rồi đấy. Tuy nhiên có một điểm khác biệt quan trọng ở đây: bằng cách tham chiếu đến books trong Collection thay vì Query Builder với books(), chúng ta có thể tối ưu khi nào và làm thế nào truy vấn này được chạy.

Nào, giờ hãy thực hiện những tối ưu hóa tiếp theo.

Trong Controller chúng ta sẽ sử dụng phương thức load() trên model User để eager load chỉ các cuốn sách được yêu thích của người dùng cũng như tác giả của cuốn sách đó.

class FavouriteBooksController extends Controller
{
    public function index(User $user)
    {
        $user->load(['books' => function ($query) {
            $query->where('favourite', true)->with('authors');
        }]);

        return View::make('favourites', ['user' => $user]);
    }
}

Bạn đã thấy tác dụng chưa? Việc chuyển các tối ưu hóa truy vấn dữ liệu sang Controller, cho phép chúng ta tối ưu một cách hoàn hảo cho route này. Thêm vào đó, Model của chúng ta cũng không chạy bất kỳ truy vấn nào hoặc phải lo lắng về eager loading cho mối quan hệ với Author.

Tối ưu hóa cho trang khác

Hãy cùng xem điều này sẽ diễn ra như thế nào trong một route khác. Giả sử như những người dùng này có bạn bè và họ có thể xem danh sách tất cả những người bạn của họ cùng với những cuốn sách yêu thích của những người bạn này. Chúng ta có thể sử dụng cùng một kỹ thuật để eager loading tất cả cuốn sách yêu thích của những người bạn này.

class FriendsController extends Controller
{
    public function index(User $user)
    {
        $user->load(['friends.books' => function ($query) {
            $query->where('favourite', true);
        }]);

        return View::make('friends', ['user' => $user]);
    }
}

Chúng ta có thể sử dụng lại phương thức favouriteBooks() khi liệt kê danh sách những người bạn trong View. Và, vì chúng ta đã cấu trúc lại phương thức này để nó không còn trực tiếp chạy truy vấn cơ sở dữ liệu nữa, nên chúng ta có thể yên tâm eager load tất cả cuốn sách yêu thích của những người bạn mà vẫn tránh được vấn đề N+1.

<h1>{{ $user->name }}’s Friends</h1>

@foreach($user->friends as $friend)
    <h2>{{ $friend->name }}</h2>
    <ul>
        @foreach($friend->favouriteBooks() as $book)
            <li>{{ $book->title }}</li>
        @endforeach
    </ul>
@endforeach

Bây giờ chúng ta sẽ so sánh kết quả giữa giải pháp ban đầu với giải pháp đã được tái cấu trúc. Giả sử chúng ta có 10 người bạn, mỗi người thích 5 cuốn sách và mỗi cuốn sách có 1 tác giả. Hãy cùng xem sự khác biệt là lớn đến mức nào:

  Trước Sau
Truy vấn lấy các user 2 2
Truy vấn lấy các cuốn sách 10 1
Truy vấn lấy các tác giả 10 0
Số model User 11 11
Số model Book 50 50
Số model Author 50 0

Bạn có thể thấy đã có một chiến thắng với cách biệt lớn xảy ra. Chỉ với một vài dữ liệu cỏn con, phương pháp này giúp chúng ta tiết kiệm tới 19 truy vấn và 50 model. Bạn hãy thử tưởng tượng đến các ứng dụng lớn hơn, con số này sẽ có tăng nhanh đến nhường nào.

Một nguyên tắc cơ bản

Đó là: “luôn viết các Model như thể tất cả các mối quan hệ đã được eager load sẵn”. Điều này có nghĩa là tôi sẽ không chạy các truy vấn trực tiếp bên trong Model, mà tôi chỉ làm việc với các relationship bên trong bộ nhớ.

Bằng cách này, chúng ta sẽ giữ cho các Model trở nên gọn gàng và đơn giản hơn vì chúng không còn phải quan tâm đến vấn đề hiệu suất. Quan trọng hơn nữa, bạn sẽ xây dựng ứng dụng của mình theo cách trao quyền cho các Controller để thực hiện bất kỳ tối ưu hóa truy vấn cơ sở dữ liệu nào cần thiết.

Nhưng Controller lại trở nên cồng kềnh

OK, nhưng đó là một sự đánh đổi hoàn toàn xứng đáng. Thêm vào đó, bạn có thể làm cho các Controller này sạch sẽ hơn bằng cách tạo các Scope bên trong Model. Ví dụ như này:

class Book extends Model
{
    public function scopeFavourite($query)
    {
        return $query->where('favourite', true);
    }
}

class FriendsController extends Controller
{
    public function index(User $user)
    {
        $user->load(['friends.books' => function ($query) {
            $query->favourite();
        }]);

        return View::make('friends', ['user' => $user]);
    }
}

Nếu đang sử dụng phiên bản PHP 7.4 trở lên, Controller thậm chí còn gọn gàng hơn nữa:

class FriendsController extends Controller
{
    public function index(User $user)
    {
        $user->load(['friends.books' => fn ($query) => $query->favourite()]);

        return View::make('friends', ['user' => $user]);
    }
}

Liệu có sự lặp lại code ở đây?

Bạn có thể cũng tự hỏi liệu cách tiếp cận này sẽ khiến chúng ta luôn phải lặp lại công việc đang làm trong Model và trong Controller? Chẳng hạn như phải cần đến cả favouriteBooks() trong Model và bộ lọc trong Controller chỉ để lấy ra các cuốn sách yêu thích?

Đúng và không.

Trong một số tình huống bạn sẽ lặp lại logic này. Tuy nhiên, chúng là 2 thứ hoàn toàn khác nhau. Trong ví dụ bên trên, phương thức favouriteBooks() chịu trách nhiệm cuối cùng trong việc đảm bảo rằng chỉ những cuốn sách yêu thích được lấy ra. Nó đảm bảo ứng dụng hoạt động đúng cách.

Mặt khác, việc tối ưu hóa trong Controller không chịu trách nhiệm đảm bảo ứng dụng hoạt động đúng cách, mà là dữ liệu trả về đầy đủ. Hãy coi nó như một sự cải thiện quy trình. Ứng dụng hoạt động bằng 1 trong 2 cách, nhưng sẽ tốt hơn nếu sử dụng chúng cùng với nhau. Hãy xem bảng so sánh kết quả ở bên trên để biết sự cải thiện là đáng kể như thế nào.

Đến đây tôi xin kết thúc bài viết này. Hy vọng nó sẽ giúp ích cho bạn ít nhiều trong việc cải thiện hiệu suất cho ứng dụng của mình. Hẹn gặp lại trong một bài viết khác.

Bye!

Công ty TNHH Giải pháp Website & Ứng dụng phần mềm Quang Minh

🚩 Địa chỉ
Số 81 Võ Huy Tâm, Phường Cẩm Trung, Thành phố Cẩm Phả, Tỉnh Quảng Ninh
📞 Điện thoại
(0862) 814-787
💌 Email
[email protected]
🌐 Zalo OA
https://zalo.me/369605269295116980
🌐 Facebook
https://www.facebook.com/qmasdotvn/
🌐 Twitter