Django — самый популярный Python web-framework. За больше чем 10 лет оброс огромным слоем возможностей. Среди них можно выделить — Django Admin — это готовый CRUDL интерфейс с поиском, фильтрами и хитрыми настройками.
Каждый раз стартуя проект на Django, удивляюсь насколько круто иметь админку — web интерфейс просмотра данных. Да еще и бесплатно.
Каждый раз поддерживая проект на Django, удивляюсь, как же сложно поддерживать админку в рабочем состоянии.
В этой статье я постараюсь привести 11 практик, которые позволят избегать тормозов админки максимально долго.
Дисклеймер: эта статья была написана в марте 2017 года, на тот момент она была слаба для хабра, но прошло 4 года и теперь может найти своего начинающего django разработчика. А в Django изменилось мало.
Какие-то из практик оптимизации админки элементарные — добавить одну строчку, какие-то требуют перегрузить ряд методов, а оставшиеся — это что стоит поменять в разработке.
Практика 1 — raw_id_fields
В любом мало-мальском проекте мы встретим модели с ForeingKey/Many2Many полями, которые очень интересно отображаются в админке. Например, ForeingKey поля отображаются как select, в котором перечислены все элементы связей:
Стандартный 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', ]
В дополнение — используйте 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
Переходя от основных настроек админки перейдем к второстепенным.
Вам точно надо знать количество элементов в таблице?
В стандартной админке есть интересная штука — количество элементов в таблице.
Чтобы показать это число, 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
.
Батарейка умеет делать разные дашбоарды, на которых перечень и способ отображения моделей может быть разным. Такое разделение помогает, если данные используют разные отделы компании.
Управление группами идет из кода — вот пример такого конфига
Выводы
Сколько бы Django Admin не ругали или восхваляли — это интересный инструмент со множеством подводных камней. Чтобы выжать максимум пользы придется покапаться в настройках, а иногда и перегрузить методы, шаблоны.
Что касается производительности — таблица со 100 миллионами записей прекрасно открывается в Django Admin.
P.S. Разумеется, если нужны сложные выборки и таблицы, то инструменты типа Metabase или Redash, админка не заменит.
Комментарии (10)
danilovmy
12.10.2021 21:48+4недавно делал два доклада тут и тут, как раз про админку Django.
Стандартный select / raw_id_fields в принципе то да, для новой джанго завезли уже select2 из коробки. Работает отлично. Правда, ломает grapelli, если у кого стоял.
Фраза "выгружайте все необходимое одним запросом несовместима" с prefetch_related. Данные полей prefetch_related догружаются после отдельными запросами. Я бы в статье добавил активное использование "only", вот эта штука здорово заставляет подумать и получить только то, что надо.
Стоит убедится, что нигде не указываете ordering. Увы, даже если нигде ничего не указано, джанго приклеит .order_by(model._meta.pk.name)
SHOW TABLE STATUS LIKE table_name, не работает в случае предварительных фильтров. В случае мультитеннантности на разнобазовых запросах, придется переделывать запрос.
25 раз по мало < 1 раз много. Согласен, другой вопрос, если данные второй таблицы точно присутствуют, то можно переопределить тип джоина на INNER JOIN. для больших таблиц может быть быстрее.
перегрузить поиск. Прям да. В одном из моих проектов только этим и спасались. Только конечно все намного глобальнее. Сначала собирали ID по моделям из ModelAdmin.search_fields и потом filter(linked_model__id_in=id_set).
продумай заранее индексы. Спорно, поскольку заранее "соломку" не всегда успеешь подстелить. Про индексы в таблице с 62mln строк была Альбина Альмухаметова на pycon 2021 с докладом "Оптимизация i-запросов в Django+Postgres". От себя скажу, что один раз в моей жизни для убыстрения запросов индекс пришлось удалять.
сложные фильтры в админке. Бывает. Бизнесу не прикажешь работать проще. Но посмотреть на запросы стоит, иногда можно сформировать зпрос проще, чем это делает родной ORM
Работает только с недавнего момента. В ранних версиях Django возвращает EmptyQuerySet вместо type(super().get_queryset(request)). Если класс был переопределен, то все ломалось.
Не знаешь зачем тебе данные, не показывай. Понимаю что речь немного о другом, но сюда подходит текст про only. Этот метод точно заставить подумать "нужны ли мне все данные которые я получаю из базы".
Группируй модели в группы по смыслу. Вместо предложенной сомнительной батарейки можно использовать смысловое группирование по множественным SiteAdmins. Я в своих проектах использую именно эту возможность Django.
Мой вывод, что Django Contrib Admin + прямые руки помогут и графики показать и запросы сложные сделать и лишние батарейки деинсталлировать. Для красоты мне не хватает анимаций и переходов "из коробки", это пришлось допиливать другими средствами.
Спасибо @WarmongeR. Жаль я не прочел это в 2017. Пришлось разбираться самому.
tumbler
13.10.2021 07:43Отличное дополнение! А где можно почитать про множественные SiteAdmins?
danilovmy
13.10.2021 15:03+1Можно посмотреть меня на видео с PyCon второй день, первое выступление. Вроде видео уже есть. Или подождать когда я выложу статью. Увы в документации минимум.
danilovmy
17.11.2021 16:36+1привет, есть видео моего доклада https://www.youtube.com/watch?v=8v2uaeV8MZo, первая половина про множественные SiteAdmins
Jsty
13.10.2021 09:16один раз в моей жизни для убыстрения запросов индекс пришлось удалять.
Можно больше подробностей? Крайне необычный кейс.
Tiendil
13.10.2021 19:14+2Главная оптимизация Django админки, когда в ней надо работать с миллионами записей, — не использовать админку Django. Это хороший признак того, что пора писать свою специализированную под конкретные задачи штуку. Не обязательно с нуля, конечно.
tumbler
https://github.com/just-work/django-admin-countless Админка без count.
danilovmy
эээ ребят, ModelAdmin.show_full_result_count
New in Django 1.8 (April 1, 2015)tumbler
Этого недостаточно, к сожалению.
show_full_result_count скрывает только общее число результатов из отфильтрованных. Что делать, если даже с учетом фильтров остаются миллионы записей, документация Django умалчивает. В результате - запрос COUNT с учетом фильтра, текст вверху вида "2575 results (Show all)" и пагинатор на 100500 страниц после списка. Хотя нет. В результате - 504.