Прокачайте свій Django застосунок: 7 хитрих трюків для прискорення запитів до бази даних

Прокачайте свій Django застосунок: 7 хитрих трюків для прискорення запитів до бази даних
Переклад 8 хв. читання
06 вересня 2023

Оптимізація продуктивності запитів Django має вирішальне значення для створення швидких веб застосунків. У документації з оптимізації доступу до бази даних Django надає багато інструментів і методів для оптимізації запитів до бази даних. У цій статті ми розглянемо колекцію додаткових і важливих порад, які я збирав протягом багатьох років, щоб допомогти вам виявити та вирішити ваші неефективні запити Django.

Вбиваємо довготривалі запити за допомогою Statement Timeout

PostgreSQL підтримує параметр statement_timeout, який дозволяє встановити максимальний час виконання запиту. Це корисно для запобігання довготривалим запитам, які блокують дорогоцінні ресурси та сповільнюють роботу програми. Моя команда в PixieBrix зіткнулася з інцидентом, коли кілька довготривалих запитів призвели до повного відключення бази даних. Встановлення таймауту виконання запитів у налаштуваннях Django може допомогти запобігти цьому.

DATABASES = {
    "default": {
        ...
        "OPTIONS": {
            "options": "-c statement_timeout=30s",
        },
    }
}

Тепер будь-який запит, який триває довше 30 секунд, буде перерваний.

from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("select pg_sleep(31)")
# django.db.utils.OperationalError: canceling statement due to statement timeout

Кілька зауважень:

  • Згідно з документацією, якщо log_min_error_statement має значення ERROR (за замовчуванням), запит, який завершився, також буде записано як помилку query_canceled (код 57014). Ви повинні використовувати ці журнали для виявлення повільних запитів.
  • PostgreSQL підтримує встановлення таймауту для всієї бази даних, але документація не рекомендує цього робити, оскільки це може спричинити проблеми з довготривалими завданнями обслуговування, такими як резервне копіювання. Замість цього рекомендується встановлювати таймаут для кожного з'єднання, як показано вище.
  • MySQL, здається, підтримує подібний параметр max_execution_time, але я його не тестував.
  • На різних серверах таймаути операторів можуть відрізнятися. Наприклад, ви, ймовірно, захочете встановити більший таймаут для своїх celery воркерів, у порівнянні з веб-серверами. Ви можете зробити це, умовно встановивши таймаут оператора:
# https://stackoverflow.com/a/50843002/6611672
IN_CELERY_WORKER = sys.argv and sys.argv[0].endswith("celery") and "worker" in sys.argv

if IN_CELERY_WORKER:
    STATEMENT_TIMEOUT = "1min"
else:
    STATEMENT_TIMEOUT = "30s"

Контроль кількості запитів в модульних тестах за допомогою assertNumQueries

При написанні юніт-тестів важливо переконатися, що ваш код робить очікувану кількість запитів. Django надає зручний метод assertNumQueries, який дозволяє перевірити кількість запитів, зроблених вашим кодом.

class MyTestCase(TestCase)
    def test_something(self):
        with self.assertNumQueries(5)
            # code that makes 5 expected queries

Якщо ви використовуєте pytest-django, ви можете використовувати django_assert_num_queries для досягнення тієї ж функціональності.

Перехоплення N+1 запитів за допомогою nplusone

N+1 запит - це поширена проблема продуктивності, яка виникає, коли ваш код робить більше запитів до бази даних, ніж потрібно. Пакет nplusone виявляє ці помилкові запити у вашому коді. Він працює шляхом генерування NPlusOneError, коли один запит виконується багаторазово у циклі. Детальніше про це можна прочитати в попередньому пості блогу.

Хоча nplusone є незамінним інструментом, який я використовую у всіх своїх Django проектах, важливо відзначити, що пакет є недопрацьованим і ловить не всі порушення. Наприклад, я помітив, що він не працює з .only() або .defer().

for user in User.objects.defer("email"):
    # This should raise an NPlusOneError but it doesn't
    email = user.email

Через ці недоліки разом з nplusone важливо використовувати інші методи оптимізації.

Перехоплення N+1 запитів за допомогою django-zen-queries

Пакет django-zen-queries дозволяє вам контролювати, яким частинам вашого коду дозволено виконувати запити. Він включає менеджер/декоратор контексту queries_disabled(), який згенерує виняток QueriesDisabledError, коли всередині нього виконується запит. Ви можете використовувати його, щоб запобігти непотрібним запитам до попередньо вибраних об'єктів, або щоб переконатися, що запити викликаються тільки тоді, коли вони потрібні. Я використовую його для заповнення прогалин, яких не вистачає у nplusone.

Наприклад, як зазначено у попередньому розділі, nplusone не перехопить наступний N+1 запит, а django-zen-queries перехопить.

from zen_queries import fetch, queries_disabled

# The fetch function forces evaluation of the queryset, which is
# necessary before entering the queries_disabled context
qs = fetch(User.objects.defer("email"))

with queries_disabled():
    for user in qs:
        # Raises a QueriesDisabledError exception
        email = user.email

Виправлення N+1 запитів шляхом уникнення нових запитів до попередньо вибраних об'єктів

Django ORM бракує вродженої здатності розрізняти, коли дані слід отримувати безпосередньо з бази даних, а коли використовувати ті, що зберігаються у пам'яті від попередніх запитів. При роботі з попередньо вибраними об'єктами, дані завжди повинні бути в пам'яті, припускаючи, що початкова вибірка отримує всі необхідні дані. Щоб переконатися, що Django використовує дані з пам'яті та уникає зайвих запитів, ви можете використовувати стандартний Python з Django методом all() замість специфічних методів Django для набору запитів.

Наприклад, розглянемо наступний код:

for user in User.objects.prefetch_related("groups"):
    # BAD: N+1 query
    first_group = user.groups.first()

    # GOOD: Does not make a new query
    first_group = user.groups.all()[0]

qs.first() робить новий запит до бази даних, тоді як qs.all()[0] цього не робить.

Ось ще кілька прикладів:

Завжди виконує запит Не виконує запит, якщо дані було отримано заздалегідь
qs.values_list("x", flat=True) [obj.x for obj in qs.all()]
qs.values("x") [{"x": obj.x} for obj in qs.all()]
qs.order_by("x", "y") sorted(qs.all(), lambda obj: (obj.x, obj.y))
qs.filter(x=1) [obj for obj in qs.all() if obj.x == 1]
qs.exclude(x=1) [obj for obj in qs.all() if obj.x != 1]

Зауважте, що пакет nplusone має перехоплювати всі ці N+1 порушення, тому обов'язково використовуйте його.

Запобігайте отриманню великих полів, що не використовуються, за допомогою defer()

Деякі поля, такі як JSONField і TextField, вимагають дорогої обробки для завантаження в об'єкт Python. Це споживає багато пам'яті та сповільнює виконання запитів, особливо при роботі з наборами запитів, що містять кілька тисяч екземплярів або більше. Ви можете використовувати defer(), щоб запобігти отриманню цих полів і підвищити продуктивність запиту.

class Book(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    pub_date = models.DateField()
    notes = models.JSONField()

books = Book.objects.defer("content", "notes")

Крім того, ви можете використовувати метод only(), щоб явно вказати поля, які ви хочете включити в результат запиту.

books = Book.objects.only("title", "pub_date")

Проте, у ситуаціях, коли ви хочете виключити певні великі поля, використання defer() часто призводить до коротшого та ефективнішого коду.

Уникайте використання distinct() на великих полях

Метод distinct() усуває повторювані об'єкти з набору запитів, порівнюючи всі значення в наборі результатів. При застосуванні до великих полів, таких як JSONField і TextField, база даних повинна виконувати дорогі порівняння, що може призвести до уповільнення часу виконання запиту. У PixieBrix один невдалий запит distinct(), який виконувався лише десятки разів на хвилину, виявився згубним, що призвело до повної зупинки роботи бази даних.

Щоб пом'якшити цю проблему, ви можете обмежити область застосування distinct(), застосувавши її до підмножини полів. Найкращий варіант - скористатися попередньою підказкою і використати defer() для повного виключення великих полів з набору результатів:

Book.objects.filter(<filter-that-generates-duplicates>).defer("content", "notes").distinct()

Якщо вам потрібні великі поля і ви використовуєте PostgreSQL, ви можете передати позиційні аргументи, щоб вказати поля, до яких має застосовуватися DISTINCT, за допомогою distinct(*fields). Це вказує базі даних порівнювати лише вказані поля. В ідеалі ви повинні передати поле первинного ключа, але будь-яке унікальне поле буде працювати.

Book.objects.filter(<filter-that-generates-duplicates>).distinct("id")

Нехай вам буде легко виявити та оптимізувати ваші повільні запити.

Джерело: Supercharge Your Django App: 7 Sneaky Tricks to Crush Slow Database Queries
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Коментарі (1)
Щоб залишити коментар необхідно авторизуватися.

Вхід