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, админка не заменит.

Комментарии (10)


  1. tumbler
    12.10.2021 11:43
    +2

    1. danilovmy
      12.10.2021 20:49

      эээ ребят, ModelAdmin.show_full_result_countNew in Django 1.8 (April 1, 2015)


      1. tumbler
        13.10.2021 07:40

        Этого недостаточно, к сожалению.

        show_full_result_count скрывает только общее число результатов из отфильтрованных. Что делать, если даже с учетом фильтров остаются миллионы записей, документация Django умалчивает. В результате - запрос COUNT с учетом фильтра, текст вверху вида "2575 results (Show all)" и пагинатор на 100500 страниц после списка. Хотя нет. В результате - 504.


  1. danilovmy
    12.10.2021 21:48
    +4

    недавно делал два доклада тут и тут, как раз про админку Django.

    1. Стандартный select / raw_id_fields в принципе то да, для новой джанго завезли уже select2 из коробки. Работает отлично. Правда, ломает grapelli, если у кого стоял.

    2. Фраза "выгружайте все необходимое одним запросом несовместима" с prefetch_related. Данные полей prefetch_related догружаются после отдельными запросами. Я бы в статье добавил активное использование "only", вот эта штука здорово заставляет подумать и получить только то, что надо.

    3. Стоит убедится, что нигде не указываете ordering. Увы, даже если нигде ничего не указано, джанго приклеит .order_by(model._meta.pk.name)

    4. SHOW TABLE STATUS LIKE table_name, не работает в случае предварительных фильтров. В случае мультитеннантности на разнобазовых запросах, придется переделывать запрос.

    5. 25 раз по мало < 1 раз много. Согласен, другой вопрос, если данные второй таблицы точно присутствуют, то можно переопределить тип джоина на INNER JOIN. для больших таблиц может быть быстрее.

    6. перегрузить поиск. Прям да. В одном из моих проектов только этим и спасались. Только конечно все намного глобальнее. Сначала собирали ID по моделям из ModelAdmin.search_fields и потом filter(linked_model__id_in=id_set).

    7. продумай заранее индексы. Спорно, поскольку заранее "соломку" не всегда успеешь подстелить. Про индексы в таблице с 62mln строк была Альбина Альмухаметова на pycon 2021 с докладом "Оптимизация i-запросов в Django+Postgres". От себя скажу, что один раз в моей жизни для убыстрения запросов индекс пришлось удалять.

    8. сложные фильтры в админке. Бывает. Бизнесу не прикажешь работать проще. Но посмотреть на запросы стоит, иногда можно сформировать зпрос проще, чем это делает родной ORM

    9. Работает только с недавнего момента. В ранних версиях Django возвращает EmptyQuerySet вместо type(super().get_queryset(request)). Если класс был переопределен, то все ломалось.

    10. Не знаешь зачем тебе данные, не показывай. Понимаю что речь немного о другом, но сюда подходит текст про only. Этот метод точно заставить подумать "нужны ли мне все данные которые я получаю из базы".

    11. Группируй модели в группы по смыслу. Вместо предложенной сомнительной батарейки можно использовать смысловое группирование по множественным SiteAdmins. Я в своих проектах использую именно эту возможность Django.

    Мой вывод, что Django Contrib Admin + прямые руки помогут и графики показать и запросы сложные сделать и лишние батарейки деинсталлировать. Для красоты мне не хватает анимаций и переходов "из коробки", это пришлось допиливать другими средствами.

    Спасибо @WarmongeR. Жаль я не прочел это в 2017. Пришлось разбираться самому.


    1. tumbler
      13.10.2021 07:43

      Отличное дополнение! А где можно почитать про множественные SiteAdmins?


      1. danilovmy
        13.10.2021 15:03
        +1

        Можно посмотреть меня на видео с PyCon второй день, первое выступление. Вроде видео уже есть. Или подождать когда я выложу статью. Увы в документации минимум.


      1. danilovmy
        17.11.2021 16:36
        +1

        привет, есть видео моего доклада https://www.youtube.com/watch?v=8v2uaeV8MZo, первая половина про множественные SiteAdmins


    1. Jsty
      13.10.2021 09:16

       один раз в моей жизни для убыстрения запросов индекс пришлось удалять.

      Можно больше подробностей? Крайне необычный кейс.


      1. danilovmy
        13.10.2021 15:01

        django_admin_log индекс на какое-то из строковых obj_repr или message. При высокой частоте записи в лог.

        Почему так, человечьим языком обьясняется тут


  1. Tiendil
    13.10.2021 19:14
    +2

    Главная оптимизация Django админки, когда в ней надо работать с миллионами записей, — не использовать админку Django. Это хороший признак того, что пора писать свою специализированную под конкретные задачи штуку. Не обязательно с нуля, конечно.