Django Admin с миллионами записей — 11 практик оптимизаций для начинающих

Kate

Administrator
Команда форума
Django — самый популярный Python web-framework. За больше чем 10 лет оброс огромным слоем возможностей. Среди них можно выделить — Django Admin — это готовый CRUDL интерфейс с поиском, фильтрами и хитрыми настройками.


Каждый раз стартуя проект на Django, удивляюсь насколько круто иметь админку — web интерфейс просмотра данных. Да еще и бесплатно.


Каждый раз поддерживая проект на Django, удивляюсь, как же сложно поддерживать админку в рабочем состоянии.


В этой статье я постараюсь привести 11 практик, которые позволят избегать тормозов админки максимально долго.

Дисклеймер: эта статья была написана в марте 2017 года, на тот момент она была слаба для хабра, но прошло 4 года и теперь может найти своего начинающего django разработчика. А в Django изменилось мало.
Какие-то из практик оптимизации админки элементарные — добавить одну строчку, какие-то требуют перегрузить ряд методов, а оставшиеся — это что стоит поменять в разработке.


Практика 1 — raw_id_fields​


В любом мало-мальском проекте мы встретим модели с ForeingKey/Many2Many полями, которые очень интересно отображаются в админке. Например, ForeingKey поля отображаются как select, в котором перечислены все элементы связей:


Image of Yaktocat



Стандартный select совсем не удобен при количество связей больше 20 — нет поиска.
Изначально подход с select не практичен, и поиска нет, да и медленный он. Как это бывает — начало проекта, 10 связей, 100, 1000 иии вот страница редактирования элемента начинает грузиться не доли секунд, а уже секунды. Связей 10к, 100к и страница перестает грузиться и падает с Timeout Error.


Чтобы избежать этого и добавить поиск достаточно воспользоваться переменной raw_id_fields
Указав название поля в переменной raw_id_fields — мы перегружаем виджет отображения, который не делает лишних запросов в БД:


@admin.register(ModelA)
class ModelAAdmin(admin.ModelAdmin):
list_display = [
'value',
]

search_fields = ['value']

@admin.register(ModelB)
class ModelBAdmin(admin.ModelAdmin):
list_display = [
'name',
'data',
]

raw_id_fields = ['data', ]

Image of Yaktocat



В дополнение — используйте django-ajax-selects или django-autocomplete-light

Практика 2 — выгружайте все необходимое одним запросом​


В документации к QuerySet можно найти два метода — select_related и prefetch_related. Эти методы полезны, когда у вас есть ForeinKey/Many2Many поля и по ним что-то отображаете.


select_related в один запрос выгружает элементы ForeinKey/Many2Many (делает JOIN таблиц)

prefetch_related делает тоже самое, но не JOIN'ом, а дополнительными SELECT запросами.
Вы можете использовать эти конструкции и в админке:


select_related:


class ModelBAdmin(admin.ModelAdmin):
list_display = [
'name',
'data',
]
list_select_related = ['data', ]

prefetch_related:


class ModelBAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super(ModelBAdmin, self).queryset(request)
return qs.prefetch_related('data')

В первом и втором варианте мы подсказали админке, что нам потребуются дополнительные данные и Django ORM чуть-чуть сэкономит время.


А на самом деле, лучше избегать JOIN запросов — они тяжелые для БД и это заметно в работе админки.

Практика 3​


Отходя от ForeingKey/Many2Many связей поговорим про количество элементов в таблице.
Помните, что РСУБД не гарантирует порядок кортежей/строк? Так вот, всегда есть соблазн делать какую-то сортировку, например по времени или по ID. Если у вас мало элементов и сервер мощный, то он мгновенно делает ORDER BY по вашему полю, однако, когда записей становится много, то простой SQL запрос


SELECT
"app_modelb"."id",
"app_modelb"."name",
"app_modelb"."data_id",
"app_modela"."id",
"app_modela"."value"

FROM "app_modelb"

INNER JOIN "app_modela" ON ("app_modelb"."data_id" = "app_modela"."id")

ORDER BY
"app_modelb"."name" ASC,
"app_modelb"."id" DESC

Может занимать уже секунды или даже минуты. И здесь не поможет никакой хитрый индекс.


В Django Admin есть параметр ordering. Чтобы ваша база не пухла от странных запросов, стоит убедится что нигде не указываете этот порядок — обычно его устанавливают в самом AdminModel и в Meta у моделей.



@admin.register(ModelB)
class ModelBAdmin(admin.ModelAdmin):
list_display = [
'name',
'data',
]
ordering = []

Если вам все же потребуется сортировка — вы можете сначала выбрать нужный набор фильтров, а потом по результатам выборки сделать сортировку.


Практика 4​


Переходя от основных настроек админки перейдем к второстепенным.


Вам точно надо знать количество элементов в таблице?


В стандартной админке есть интересная штука — количество элементов в таблице.


Image of Yaktocat



Чтобы показать это число, Django генерирует запрос вида


SELECT COUNT(*) AS "__count" FROM "app_modela"

Когда у вас таблица маленькая, 1к, 10к, 100к — Count(*) работает быстро, а когда вы переходите за миллион и десятки миллионов, то безобидная операция подсчета элементов может занимать больше 30 секунд и в конечном итоге приводить к Time out error


Для РСУБД PostgreSQL и MySQL давно есть способы приблизительно подсчитать количество элементов в таблице не делая тяжелых запросов:


# mysql
SHOW TABLE STATUS LIKE table_name

# postgresql
SELECT reltuples::bigint FROM pg_class WHERE relname = table_name

Оба запроса получают информацию о количестве элементов в table_name из системной таблицы. Это значительно быстрее, чем Count запрос.


Используя эту идею, можно переопределить ChangeList админ модели. К сожалению, там не две строчки кода, поэтому скину ссылку на github, где показан пример — https://github.com/WarmongeR1/django-admin-article/blob/master/app/admin_opt.py#L58


В том же модуле есть код перегрузки пагинатора для Django Admin.


Практика 5 — 25 раз по мало < один раз по много​


Рассмотрим типичный сценарий — есть таблица юзеров и данные пользователя, например, покупки. Таблицу юзеров спокойно выводим в админку:


@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = [
'email',
'field1'
'field2'
]
search_fields = ['email', ]

Все работает отлично, в БД отправляет простой SELECT. Теперь делаем вывод второй таблицы


@admin.register(UserData)
class DataAdmin(admin.ModelAdmin):
list_display = [
'user',
'field3'
'field4'
]

Смотрим в django-debug-toolbar и видим интересный по неоптимальности запрос:


SELECT •••
FROM "table_userdata"
INNER JOIN "table_user" ON ("table_data"."user_id" = "table_user"."id")
ORDER BY "accounts_weightdata"."id" DESC
LIMIT 25

INNER JOIN с таблицей пользователей. Для маленьких таблиц это не страшно, все летает, но чем больше таблицы, тем дороже этот JOIN.


Чтобы решить эту проблему можно зайти с другой стороны и заменить долгий запрос на несколько недолгих. А именно — сделать обычный SELECT по таблице с данными, а затем отдельными запросами сходить за информацией о пользователей. (Кстати, эти SELECT'ы можно еще и в кэш положить):


@admin.register(UserData)
class DataAdmin(admin.ModelAdmin):
list_display = [
'user_email',
'field3'
'field4'
]
raw_id_fields = ['user']

def user_email(self, instance):
CACHE_KEY = 'admin:{}:instance:{}'.format(
'user',
instance.user_id
)
result = cache.get(CACHE_KEY)
if not result:
result = instance.user.email
cache.set(CACHE_KEY, result)
return result


Практика 6 — перегрузить поиск​


Админка без поиска — время на ветер.
Добавить поиск по полю — элементарно


search_fields = ['field', ]

И как это бывает — есть модель с данными пользователя и мы добавляем поиск по email/имени:


@admin.register(UserData)
class UserDataModel(admin.ModelAdmin):
list_display = ['value', ]
search_fields = ['user__email', ]

И начиаем пользоваться. Когда таблица пользователей и таблица с данными разростается, замечаем что любая попытка найти что-то приводит к Time out error.


Тут то и берем debug toolbar и смотрим нам запрос поиска:


SELECT
"user_data"."id",
"user_data"."user_id",
"user_data"."field1",
"user_data"."field2"
FROM "user_data"

INNER JOIN "user_table"
ON ("user_data"."user_id" = "user_table"."id")

WHERE
UPPER("user_table"."email"::text) LIKE UPPER('%email%')

ORDER
BY "accounts_sleepdata"."id" DESC

Обратите внимание на JOIN. Наверное вы уже запомнили, что JOIN это дорогая операция и их надо избегать. Почесав тыковку можно придти мысль — а что если как-то избежать использование таблицы пользователей или хотя бы убрать JOIN.


И у меня есть идея, как это сделать. А что если если пользователь ввел email в поисковую строку, то преобразовать его в id и уже по нему сделать поиск.


Изучая документацию Django, можно найти метод get_search_results, он дополняет QuerySet после фильтров поиском по полям.


Вот его и перегружаем



def get_user_by_email(email):
try:
return User.objects.get(email__iexact=email)
except User.DoestNotExist:
return None

class UserEmailSearchAdmin(admin.ModelAdmin):
def get_search_results(self, request, queryset, search_term):
user = get_user_by_email(search_term)
if user is not None:
queryset = queryset.filter(user_id=user.id)
use_distinct = False
else:
queryset, use_distinct = super().get_search_results(request,
queryset,
search_term)
return queryset, use_distinct

@admin.register(UserData)
class UserDataModel(UserEmailSearchAdmin):
list_display = ['value', ]
search_fields = ['user__email', ]


Вводим полноценный email — получаем оптимальный запрос.


SELECT
"user_data"."id",
"user_data"."user_id",
"user_data"."field1",
"user_data"."field2"
FROM "user_data"

INNER JOIN "user_table"
ON ("user_data"."user_id" = "user_table"."id")

WHERE
"accounts_sleepdata"."user_id" = <user_id>
ORDER
BY "accounts_sleepdata"."id" DESC

Если же вводим часть email или другую строку — то делается страшный JOIN


Практика 7 — продумай заранее индексы​


При активной разработке постоянно есть недостаток времени и каждый раз хочется где-то схалявить. Так вот, технический долг, который находится на уровне моделей — очень дорогой.
Разрабатывая фичу, продумайте несколько use case, и подумайте, как будете визуализировать результаты работы фичи, что вам понадобиться, что нет.


Лишний день при разработке структуры БД поможет сэкономить недели в будущем.


Индекс по текстовому полю ускорит поиск, вот только база (индекс) начнет расти молниеносно.


Практика 8 — не используй сложные фильтры в админке​


У Django есть удобный инструмент фильтров в админке. Он позволяет получить нужные выборки. Для выборок аля "Пользователи со статусом A" подходит хорошо. Но если вы хотите получить сложную выборку "Пользователи со статусом А, возрастом Б и не в группе С", то легко получить неоптимальный запрос вида:


SELECT *
FROM table
WHERE
id not in [1, 2, 3, ....100000...]

Научить Django ORM оптимизировать сложные запросы тяжело и не имеет смысла. Значительно проще писать голые SQL запросы.


Для этого дела для Django есть батарейка https://github.com/groveco/django-sql-explorer.


Этот инструмент предоставляет веб-интерфейс работы с SQL. Он не дотягивает до pg_admin и аналогов и умеет совсем мало — выполнять запросы, сохранять их для переиспользования и сохранять выборки в различные форматы файлов.


Чтобы внедрить — достаточно установить, определить кому будет доступ и написать готовые SQL запросы, которые ваша команда будет использовать.


Практика 9 — упрости жизнь базе​


Когда вы заходите на страницу модели в админке, вам точно надо select по всему? Может лучше вообще пустую страницу показать или за последний день?


Набор элементов для отображения определяется методом get_queryset и так его можно перегрузить:


@admin.register(ModelA)
class ModelAAdmin(admin.ModelAdmin):

def get_queryset(self, request):
if len(request.GET) == 0:
return ModelA.objects.none()
else:
return super().get_queryset(request)

В этом примере я перегрузил QuerySet по умолчанию — если открыть страницу таблицы, то увидим пустую страницу (без элементов), однако, если начнем искать — то результаты будут видны.


Практика 10 — не знаешь зачем тебе данные → не собирай их → не показывай их.​


Всегда есть соблазн, начать собирать от пользователей какую-то мелоч, которая в далеком светлом будущем может помочь улучшить продукт. Так вот — если у вас нет мыслей как использовать эти данные — они вам не нужны сейчас.


Любая модель, которую вы создали в порыве за 2 минуты, будете в будущем выпиливать несколько месяцев.


Практика 11 — группируй модели в группы по смыслу.​


Развивая продукт как монолит, мы получаем огромное количество моделей, где даже найти нужную модель тяжело. Чтобы упростить поиск — группируйте модели с помощью батарейки django-admin-tools.


Батарейка умеет делать разные дашбоарды, на которых перечень и способ отображения моделей может быть разным. Такое разделение помогает, если данные используют разные отделы компании.


Image of Yaktocat



Управление группами идет из кода — вот пример такого конфига


Выводы​


Сколько бы Django Admin не ругали или восхваляли — это интересный инструмент со множеством подводных камней. Чтобы выжать максимум пользы придется покапаться в настройках, а иногда и перегрузить методы, шаблоны.


Что касается производительности — таблица со 100 миллионами записей прекрасно открывается в Django Admin.


P.S. Разумеется, если нужны сложные выборки и таблицы, то инструменты типа Metabase или Redash, админка не заменит.

 
Сверху