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

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

© Habrahabr.ru