Tối ưu hóa queryset với select_related và prefetch_related

Like bài viết:0 likes

Tối ưu hóa ứng dụng là vấn đề rất quan trọng trong phát triển ứng dụng. Django hỗ trợ nhiều cách để cải thiện hiệu suất ứng dụng. Một trong những cách đó là cải thiện truy vấn Queryset. Và trong bài viết này chúng ta sẽ thực hiện để tối ưu hóa queryset bằng select_relatedprefetch_related.

 

Giới thiệu

select_related và prefetch_related

select_relatedprefetch_related cả hai đều là phương thức trong Queryset được Django hỗ trợ giúp cải thiện hiệu suất truy vấn bằng cách join các bảng lại với nhau để thực hiện truy vấn đồng thời.

  • select_related được sử dụng với OneToManyOneToOne ( ForeignKeyOneToOneField trong Django )
  • prefetch_related được sử dụng với OneToMany, ManyToMany ( ForeignKey truy vấn ngược và ManyToMany trong Django )

Chúng ta cùng thực hiện ví dụ sau để hiểu rõ cách thức hoạt động của cả hai. Với Model như sau:

Model

blog/models.py

from django.db import models


class User(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Profile(models.Model):
    address = models.CharField(max_length=100, blank=True)
    user = models.OneToOneField(User, on_delete=models.CASCADE)


class Category(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Post(models.Model):
    title = models.CharField(max_length=100)
    owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts_created')
    categories = models.ManyToManyField(Category, related_name='posts')

    def __str__(self):
        return self.title

 

Chuẩn bị

Code và hướng dẫn cách chạy các bạn xem ở Gihub

Database

Đầu tiên để có được dữ liệu sử dụng mình đã viết sẵn script để load dữ liệu vào. Các bạn thêm dữ liệu bằng câu lệnh sau:

python manage.py load_items

Mình sẽ nói thêm về cách thực hiện tùy chỉnh câu lệnh manage này ở bài viết khác vì đây không phải mục đích chính của bài viết này.

Sau khi chạy câu lệnh này các bạn sẽ thấy :

Như vậy đã có sẵn  50 users, 50 profiles, 20 categories, 500 posts.

Debugger

Để biết có bao nhiêu query đã chạy và thời gian chạy của mỗi Function mình tạo sẵn một decorator như sau:

blog/decorators.py

import time
import functools

from django.db import connection, reset_queries


def debugger_queries(func):

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        reset_queries()

        start = time.perf_counter()
        start_queries = len(connection.queries)

        result = func(*args, **kwargs)

        end = time.perf_counter()
        end_queries = len(connection.queries)

        print(f"Function: {func.__name__}")
        print(f"Queries: {end_queries - start_queries}")
        print(f"Time Finished: {(end - start):.2f}s")

        return result

    return wrapper

Mình sẽ giải thích tạo decorator trong Django ở bài viết khác vì đây không phải nội dung chính của bài viết này.

Để sử dụng decorator này bạn thêm vào trước mỗi Function như sau:

@debugger_queries
def my_function():
    ...

Thực hiện query đầu tiên

Mình sẽ thực hiện truy vấn dữ liệu như sau:

blog/queries.py

from .models import User, Category, Post
from .decorators import debugger_queries


@debugger_queries
def post_list():
    queryset = Post.objects.all()

    posts = []

    for post in queryset:
        posts.append({
            'id': post.id,
            'title': post.title,
        })

    return posts

Kiểm tra bằng cách chạy debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import (post_list)


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        post_list()

Chạy trên manage để kiểm tra:

python manage.py run_debugger

Kết quả:

Function: post_list
Queries: 1
Time Finished: 0.01s

Giải thích: Kết quả ở đây chỉ thực hiện một query là all tổng hợp tất cả các posts.

Bây giờ chúng ta sẽ thực hiện truy vấn khác như sau:

blog/queries..py

@debugger_queries
def post_list_bad():
    queryset = Post.objects.all()

    posts = []

    for post in queryset:
        posts.append({
            'id': post.id,
            'title': post.title,
            'owner': post.owner.name,
        })

    return posts

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import (post_list, post_list_bad)


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        # post_list()
        post_list_bad()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: post_list_bad
Queries: 501
Time Finished: 0.25s

Giải thích: Sau khi thực hiện thêm truy vấn tới owner mà số query đã nhảy lên tới 501. Lý do tại vì một truy vấn để thực hiện all tất cả posts và mỗi post lại thực hiện thêm 1 query tới ForeignKey là owner nữa vì thế là 501 queries.

Chúng ta biết rằng với ForeinKey thì có thể sử dụng select_related để cải thiện truy vấn. Chúng ta sẽ thực hiện như sau:

select_related với ForeignKey

Tạo query mới :

blog/queries..py

@debugger_queries
def post_list_select_related_good():
    queryset = Post.objects.select_related("owner")

    posts = []

    for post in queryset:
        posts.append({
            'id': post.id,
            'title': post.title,
            'owner': post.owner.name,
        })

    return posts

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import post_list_select_related_good


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        post_list_select_related_good()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: post_list_select_related_good
Queries: 1
Time Finished: 0.01s

Giải thích: Khi sử dụng select_related với một hoặc nhiều tham số django sẽ join các bảng của đối tượng đó vào cùng một query. Ở đây sẽ join bảng của owner tức là bảng User vào.

 Và Post là liên kết ForeignKey với User nên chúng ta có thể sử dụng select_related

select_related với OneToOneField

Tạo query mới :

blog/queries..py

@debugger_queries
def user_list_bad():
    queryset = User.objects.all()

    users = []

    for user in queryset:
        users.append({
            'id': user.id,
            'name': user.name,
            'address': user.profile.address,
        })

    return users

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import user_list_bad


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        user_list_bad()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: user_list_bad
Queries: 51
Time Finished: 0.03s

Giải thích: Ở đây sẽ thực hiện một query là all để lấy tất cả users. Và mỗi user lại thực hiện thêm một query tới profile. Và vì có 50 users nên nó đã thực hiện 51 queries.

Sử dụng select_related 

Tiến hành áp dụng select_related với OneToOneField cũng giống như với ForeignKey

Tạo query mới :

blog/queries..py

@debugger_queries
def user_list_select_related_good():
    queryset = User.objects.select_related("profile")

    users = []

    for user in queryset:
        users.append({
            'id': user.id,
            'name': user.name,
            'address': user.profile.address,
        })

    return users

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import user_list_select_related_good


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        user_list_select_related_good()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: user_list_select_related_good
Queries: 1
Time Finished: 0.00s

Giải thích: Khi sử dụng select_related sẽ join bảng của profile tức là bảng Profile vào nên chỉ cần thực hiện 1 query.

Về cơ bản sử dụng select_related là như vậy, lưu ý là các bạn có thểm thêm nhiều tham số vào trong select_related để chọn nhiều trường ví dụ :

queryset = Post.objects.select_related("owner", "owner__profile")

Bây giờ chúng ta sẽ tìm hiểu với prefetch_related:

prefetch_related với ManyToManyField

Tạo query mới :

blog/queries..py

@debugger_queries
def post_list_bad_2():
    queryset = Post.objects.all()

    posts = []

    for post in queryset:
        catgories = [category.name for category in post.categories.all()]
        posts.append({
            'id': post.id,
            'title': post.title,
            'catgories': catgories,
        })

    return posts

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import post_list_bad_2


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        post_list_bad_2()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: post_list_bad_2
Queries: 501
Time Finished: 0.30s

Giải thích:  Ở đây sẽ thực hiện một query là all để lấy tất cả posts. Và mỗi post lại thực hiện thêm một query tới category. Và vì có 500 posts nên nó đã thực hiện 501 queries.

Sử dụng prefetch_related

Tạo query mới :

blog/queries..py

@debugger_queries
def post_list_prefetch_related_good():
    queryset = Post.objects.prefetch_related("categories")

    posts = []

    for post in queryset:
        catgories = [category.name for category in post.categories.all()]
        posts.append({
            'id': post.id,
            'title': post.title,
            'catgories': catgories,
        })

    return posts

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import post_list_prefetch_related_good


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        post_list_prefetch_related_good()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: post_list_prefetch_related_good
Queries: 2
Time Finished: 0.19s

Giải thích:  Ở đây sẽ thực hiện một query là để tập hợp tất tất cả ( all ) từ bảng Category nhờ prefetch_related. Và một query nữa để all tất lại với tất cả posts

prefetch_related với ForeignKey đảo ngược

prefetch_related được sử dụng với ForeignKey đảo ngược như sau:

blog/queries..py

@debugger_queries
def post_list_prefetch_related_good_2():
    queryset = User.objects.prefetch_related('posts_created')

    users = []

    for user in queryset:
        posts = [post.title for post in user.posts_created.all()]
        users.append({
            'id': user.id,
            'name': user.name,
            'posts': posts,
        })

    return users

 

prefetch_related với hàm Prefetch

Bây giờ chúng ta sẽ thực hiện một ví dụ khác khi sử dụng prefetch_related không đúng cách:

Tạo query mới :

blog/queries..py

@debugger_queries
def post_list_prefetch_related_bad():
    queryset = Post.objects.prefetch_related("categories")

    posts = []

    for post in queryset:
        catgories = [category.name for category in post.categories.filter(name__startswith="c")]
        posts.append({
            'id': post.id,
            'title': post.title,
            'catgories': catgories,
        })

    return posts

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import post_list_prefetch_related_bad


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        post_list_prefetch_related_bad()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: post_list_prefetch_related_bad
Queries: 502
Time Finished: 0.40s

Giải thích:  Vì sao chúng ta đã sử dụng prefetch_related mà số queries lại là 502 mà không phải là 2? Vậy 500 queries này đến từ đâu?

Đó là mặc định khi sử dụng prefetch_related("categories") nó sẽ join kết quả từ bảng Category với truy vấn là Category.objects.all(). Tuy nhiên, chúng ta lại thực hiện với truy vấn khác là filter(name__startswith="c") chính vì thế prefetch_related sẽ không join kết quả đúng cho chúng ta.

Để bảo với prefetch_related join đúng với truy vấn chúng ta mong muốn thực hiện với Prefetch:

Sử dụng với Prefetch

Tạo query mới :

blog/queries..py

from django.db.models import Prefetch

@debugger_queries
def post_list_prefetch_related_good_3():
    queryset = Post.objects.prefetch_related(
            Prefetch("categories", queryset=Category.objects.filter(name__startswith="c"))
        )

    posts = []

    for post in queryset:
        catgories = [category.name for category in post.categories.all()]
        posts.append({
            'id': post.id,
            'title': post.title,
            'catgories': catgories,
        })

    return posts

Chỉnh sửa debugger:

blog/management/commands/run_debugger.py

from django.core.management.base import BaseCommand

from blog.queries import post_list_prefetch_related_good_3


class Command(BaseCommand):

    def handle(self, *args, **kwargs):
        post_list_prefetch_related_good_3()

Kiểm tra:

python manage.py run_debugger

Kết quả :

Function: post_list_prefetch_related_good_3
Queries: 2
Time Finished: 0.17s

Giải thích:  Nhờ Prefetch giúp cho prefetch_related join đúng truy vấn mà chúng ta mong muốn.

Sử dụng cả select_related và prefetch_related

Chúng ta có thể kết hợp cả select_related với prefetch_related cùng một lúc. Ví dụ như sau:

blog/queries..py

queryset = User.objects.prefetch_related('posts_created',
                        'posts_created__categories').select_related('profile')

 

Kết luận

Tài liệu tham khảo thêm:

Code xem tại Github