Django та PostgreSQL у 2026 році: індексація, повнотекстовий пошук і питання для співбесід
Практичний посібник з оптимізації Django та PostgreSQL: B-tree, часткові та покривні індекси, повнотекстовий пошук із SearchVector і GIN, а також питання для співбесід 2026 року.

Оптимізація Django та PostgreSQL визначає різницю між застосунком, який масштабується за межі перших тисяч користувачів, і тим, що зупиняється на кожному списковому поданні. PostgreSQL постачається з рушієм індексації та підсистемою повнотекстового пошуку, які більшість проєктів на Django залишають незадіяними, марнуючи продуктивність запитів, повернути яку не коштує нічого. Цей посібник охоплює патерни індексації, інструменти пошуку django.contrib.postgres і питання про бази даних на співбесідах, які відрізняють Django-інженера середнього рівня від сеньйора у 2026 році.
Кожен індекс прискорює читання, але сповільнює запис і споживає дисковий простір. Варто виконати EXPLAIN (ANALYZE, BUFFERS) на реальному запиті, додати індекс, а потім порівняти план. Індекс, який планувальник запитів ніколи не обирає, стає чистими накладними витратами на кожному INSERT і UPDATE.
Основи індексації PostgreSQL у Django
За замовчуванням PostgreSQL створює B-tree індекс лише для первинних ключів і стовпців із unique=True. Кожен інший стовпець, за яким виконується фільтрація чи сортування, обробляється послідовним скануванням, доки не з'явиться індекс. Django надає індекси через Meta.indexes, і цей підхід має перевагу над старішим db_index=True, оскільки дозволяє описати композитні, часткові та покривні індекси в одному місці.
Порядок стовпців усередині композитного індексу не є косметичним. B-tree може задіяти індекс для запиту лише тоді, коли фільтр використовує початковий префікс проіндексованих стовпців. Це правило докладно пояснено на Use The Index, Luke. Індекс на (status, created_at) обслуговує WHERE status = ? ORDER BY created_at, але той самий індекс не пришвидшить запит, що фільтрує лише за created_at.
# models.py
from django.db import models
class Order(models.Model):
customer_email = models.EmailField()
status = models.CharField(max_length=20)
total = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
# Одностовпцевий B-tree для точних і діапазонних пошуків за status
models.Index(fields=["status"]),
# Композитний індекс: обслуговує WHERE status = ? ORDER BY created_at DESC
# Провідний стовпець (status) має бути у фільтрі, щоб індекс задіявся
models.Index(fields=["status", "-created_at"]),
]Композитний індекс перетворює запит для дашборду на кшталт «останні замовлення в очікуванні» на єдине сканування індексу замість фільтрації з наступним сортуванням по всій таблиці.
Часткові та покривні індекси для цільових запитів
Частковий індекс охоплює лише рядки, що відповідають умові, тож він залишається невеликим і поміщається в пам'яті. Коли більшість рядків мають однакове значення, а запити зачіпають лише меншість, частковий індекс виявляється значно дешевшим за повний. Покривний індекс іде далі: додаючи неключові стовпці через include, PostgreSQL відповідає на запит безпосередньо з індексу, не звертаючись до купи таблиці. Такий шаблон доступу називають index-only scan.
# models.py
from django.db import models
from django.db.models import Q
class Order(models.Model):
# поля, визначені вище
class Meta:
indexes = [
# Частковий індекс: індексує лише рядки в очікуванні, ігноруючи завершену історію
models.Index(
fields=["created_at"],
name="pending_orders_idx",
condition=Q(status="pending"),
),
# Покривний індекс: total читається прямо з індексу (index-only scan)
models.Index(
fields=["status"],
include=["total"],
name="status_total_covering_idx",
),
]У таблиці замовлень, де 2% рядків перебувають у стані очікування, частковий індекс може бути в п'ятдесят разів меншим за повний індекс на тому самому стовпці, а планувальник тримає його гарячим у буферному кеші. Конструкцію include описано в розділі PostgreSQL index-only scans, і вона є найбільш недооціненою перевагою в застосунках Django, які агрегують числовий стовпець за фільтром статусу.
Повнотекстовий пошук у Django з PostgreSQL
Спокуса скористатися title__icontains=query виглядає зручною, проте вона змушує виконувати послідовне сканування і не може ранжувати результати за релевантністю. Повнотекстовий пошук Django обгортає рідні типи PostgreSQL tsvector і tsquery через SearchVector, SearchQuery та SearchRank. Пошуковий вектор нормалізує текст у лексеми, відкидаючи стоп-слова і зводячи слова до їхніх основ, тож пошук за «running» знаходить «run» і «ran».
# views.py
from django.contrib.postgres.search import (
SearchVector, SearchQuery, SearchRank,
)
from .models import Article
def search_articles(query_text):
query = SearchQuery(query_text, config="english")
# Вага A ставить збіги в title вище за збіги в body (вага B)
vector = (
SearchVector("title", weight="A")
+ SearchVector("body", weight="B")
)
return (
Article.objects.annotate(rank=SearchRank(vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)Параметр weight призначає групи пріоритету від A (найвищий) до D. Збіг у заголовку ранжується вище за збіг у тексті, навіть коли обидва містять пошуковий термін, що відповідає тому, як користувачі очікують поведінки пошуку. Повний API і чотирирівневу модель ваг описано в довіднику з повнотекстового пошуку Django.
GIN-індекси та SearchVectorField для швидкого пошуку
Наведений вище запит перераховує пошуковий вектор для кожного рядка під час кожного звернення, що прийнятно для кількох тисяч рядків і неприйнятно для мільйона. Промисловий шаблон зберігає вектор у полі SearchVectorField та індексує його GIN-індексом, типом індексу, який PostgreSQL використовує для складених значень на кшталт повнотекстових документів і JSONB.
# models.py
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.db import models
class Article(models.Model):
title = models.CharField(max_length=255)
body = models.TextField()
# Збережений, попередньо обчислений пошуковий документ
search_vector = SearchVectorField(null=True)
class Meta:
indexes = [
# GIN перетворює повнотекстові пошуки на логарифмічні сканування індексу
GinIndex(fields=["search_vector"]),
]Збережене поле має лишатися синхронізованим із title і body. Сигнал post_save, який записує вектор за допомогою .update(), уникає рекурсивного спрацьовування сигналу, оскільки QuerySet.update() не надсилає сигналів збереження.
# signals.py
from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article
@receiver(post_save, sender=Article)
def sync_search_vector(sender, instance, **kwargs):
Article.objects.filter(pk=instance.pk).update(
search_vector=SearchVector("title", weight="A")
+ SearchVector("body", weight="B"),
)Для команд на Django 5.0 чи новіших версіях обчислюване на рівні бази GeneratedField або тригер PostgreSQL повністю усувають зайвий UPDATE, підтримуючи стовпець усередині бази даних. Хоч би який механізм заповнював поле, запит далі фільтрує проіндексоване поле безпосередньо, і GIN-індекс перетворює те, що було повним скануванням таблиці, на логарифмічний пошук.
GIN-індекси прискорюють читання ціною реальних витрат на запис, адже кожна вставка зачіпає багато записів індексу. На таблицях з інтенсивним записом варто налаштувати параметр зберігання fastupdate і стежити за розміром списку очікування, інакше повнотекстові записи застряватимуть у черзі за обслуговуванням індексу.
Вибір правильного типу індексу PostgreSQL
B-tree є правильним значенням за замовчуванням, проте PostgreSQL надає кілька типів індексів через django.contrib.postgres.indexes, і вибір неправильного марнує диск, не пришвидшуючи жодного запиту. Вибір залежить від форми даних і оператора, який використовує запит, як задокументовано в документації про типи індексів PostgreSQL.
| Тип індексу | Найкраще для | Клас Django |
|------------|----------|--------------|
| B-tree | Рівність і діапазон для скалярних стовпців | models.Index |
| GIN | Повнотекстовий пошук, JSONB, входження в масив | GinIndex |
| BRIN | Величезні таблиці лише для додавання, упорядковані за стовпцем | BrinIndex |
| Hash | Пошуки лише за рівністю на великих стовпцях | HashIndex |
BRIN заслуговує особливої уваги для даних часових рядів. У таблиці з десятками мільйонів рядків, вставлених у порядку часових міток, BrinIndex на created_at займає кілька кілобайтів там, де B-tree знадобилися б сотні мегабайтів, оскільки BRIN зберігає лише мінімальне та максимальне значення на кожен діапазон блоків. Компроміс полягає в тому, що BRIN допомагає лише тоді, коли фізичний порядок рядків відповідає проіндексованому стовпцю, що природно виконується для журналів і таблиць подій, куди дані лише додаються.
# models.py
from django.contrib.postgres.indexes import BrinIndex
from django.db import models
class Event(models.Model):
payload = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
# Крихітний індекс для діапазонних сканувань у таблиці лише для додавання, упорядкованій за часом
BrinIndex(fields=["created_at"]),
]Відповідність типу індексу шаблону доступу є повторюваною темою на співбесідах для сеньйорів, адже вона доводить, що кандидат міркує про організацію сховища, а не рефлекторно додає B-tree скрізь.
Готовий до співбесід з Django?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Питання про бази даних на співбесідах Django у 2026 році
Питання про бази даних домінують на співбесідах для сеньйорів Django, тому що вільне володіння ORM не означає розуміння того, що саме ORM генерує. Наведені нижче питання відображають те, що комісії з найму справді перевіряють у 2026 році, і вони природно доповнюють налаштування на рівні запитів, розглянуте в посібнику з оптимізації запитів Django ORM.
Коли композитний індекс на (a, b) не допомагає запиту? Коли запит не фільтрує за a. B-tree упорядковано насамперед за провідним стовпцем, тож фільтр лише за b не може задіяти індекс. Це правило крайнього лівого префікса, і кандидати, які його пояснюють, показують розуміння структури індексу, а не завчені рецепти.
У чому різниця між select_related і prefetch_related? select_related виконує SQL JOIN і працює для зв'язків за зовнішнім ключем та один-до-одного в межах одного запиту. prefetch_related виконує другий запит і об'єднує результати в Python, що необхідно для зв'язків багато-до-багатьох і зворотних зв'язків за зовнішнім ключем. Вибір неправильного варіанта або втрачає оптимізацію, або спричиняє зайвий другий запит.
Чим icontains відрізняється від повнотекстового пошуку на рівні бази даних? icontains компілюється в ILIKE '%term%', що не може задіяти стандартний B-tree індекс і сканує кожен рядок. Повнотекстовий пошук зіставляється з попередньо обчисленим tsvector, що спирається на GIN-індекс, і ранжує результати за релевантністю. Ця відмінність є надійним сигналом того, що кандидат вийшов за межі навчальних наборів даних.
Чому count() може бути повільним на великій таблиці та які існують альтернативи? PostgreSQL не зберігає кешованого підрахунку рядків для таблиці, тож COUNT(*) сканує кожен видимий рядок в умовах MVCC. Для приблизних підрахунків запит до pg_class.reltuples миттєво повертає оцінку, а для пагінації keyset-пагінація за проіндексованим стовпцем узагалі уникає підрахунку. Структуроване відпрацювання цих шаблонів доступне в модулі співбесід про кешування Django, де лічильники на основі кешу є повторюваною темою, а також у повному навчальному курсі Django.
Що показує EXPLAIN ANALYZE, чого не показує самий лише EXPLAIN? EXPLAIN виводить оцінену планувальником вартість; EXPLAIN ANALYZE фактично виконує запит і повідомляє реальні тайминги та кількість рядків. Велика розбіжність між оціненою та фактичною кількістю рядків указує на застарілу статистику таблиці, яку виправляє ANALYZE, і є поширеною першопричиною того, що планувальник ігнорує індекс, який очевидно мав би застосуватися.
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Висновок
- Визначати індекси в
Meta.indexes, а не черезdb_index=True, щоб композитні, часткові та покривні індекси лишалися в одному місці, придатному для аудиту. - Упорядковувати стовпці композитного індексу за правилом крайнього лівого префікса: провідний стовпець має з'являтися у фільтрі запиту, щоб індекс застосувався.
- Застосовувати часткові індекси, коли запити зачіпають лише меншість рядків, як-от замовлення в очікуванні в таблиці, де переважає завершена історія.
- Додавати покривні індекси через
include, щоб уможливити index-only scans і уникати звернень до купи в агрегувальних запитах з інтенсивним читанням. - Замінювати пошук через
icontainsнаSearchVector,SearchQueryтаSearchRank, щоб безкоштовно отримати ранжування за релевантністю та стемінг. - Зберігати пошуковий документ у полі
SearchVectorField, що спирається наGinIndexі синхронізується сигналом,GeneratedFieldчи тригером бази даних, ще до того, як повнотекстовий пошук досягне промислового масштабу. - Завжди перевіряти рішення щодо індексів за допомогою
EXPLAIN (ANALYZE, BUFFERS): індекс, який планувальник ніколи не обирає, є податком під час запису без жодної вигоди під час читання.
Теги
Поділитися
Пов'язані статті

Асинхронні представлення Django та ASGI у 2026: продуктивність і питання співбесіди
Глибокий розбір асинхронних представлень Django та ASGI у 2026: як вони працюють усередині, який сервер обрати для деплою, асинхронний ORM і пастка SynchronousOnlyOperation, а також питання співбесіди.

Django 6.0 у 2026 році: складені первинні ключі, фонові завдання та питання для співбесід
Django 6.0 у 2026: складені первинні ключі через CompositePrimaryKey, вбудовані фонові завдання, template partials, CSP-middleware та питання для технічних співбесід.

Django 5.2: Власна Middleware та Обробка Сигналів для Технічних Співбесід
Повний посібник з Django 5.2: побудова власної middleware, використання сигналів post_save та pre_save, асинхронна middleware, користувацькі сигнали та типові питання для технічних співбесід з прикладами коду.