Как стать автором
Обновить

Сравнение разных django filter на примере демо базы PostgreSQL

Время на прочтение6 мин
Количество просмотров11K

Вместо предисловия


Началось всё с того, что мне предложили в рамках предмета "Основы веб-программирования" поучаствовать в проекте, вместо проделывания лабораторных работ и курсовой, поскольку я заявил о том, что хотел быть делать нечто отдалённое от общего курса (и так уже достаточно знаний было по связке DRF + Vue, хотелось чего-то нового). И вот в одном из своих PR на github я решил использовать полнотекстовый поиск (задание намекало на это) для фильтрации контента, что заставило меня обратиться к документации Django в поисках того, каким же образом лучше это дело реализовать. Думаю, вы знаете большую часть из тех методов, что были там предложены (contains, icontains, trigram_similar). Все они подходят для каких-то конкретных задач, но не слишком хороши в, именно, полнотекстовом поиске. Пролистав чуть ниже, я наткнулся на раздел, в котором говорилось о взаимодействии Django и Pgsql для реализации document-based поиска, что меня привлекло, поскольку в постгре встроен инструмент для реализации этого самого [полнотекстового] поиска. И я решил, что скорее всего, django просто предоставляет API к этому поиску, исходя из чего такое решение должно работать и точнее и быстрее, чем любые другие варианты. Преподаватель мне не слишком поверил, мы с ним поспорили, и он предложил провести исследование на эту тему. И вот я здесь.


Начало работы


Первая проблема, которая передо мной встала — поиск мокапа БД, чтобы не придумать каких-нибудь непонятных штук самому и я отправился гуглить и читать вики постгреса. В итоге остановился на их демо базе о полётах по России.


Хорошо, база найдена. Теперь нужно определиться в том, какие способы фильтрации будут использоваться для сравнения. Первое, что я бы хотел использовать, разумеется, стандартный метод search из django.contrib.postgres.search. Второе — contains (ищет слово в строке) и icontains (предоставляет данные, игнорируя акценты, например: по запросу "Helen" будет результат: <Author: Helen Mirren>, <Author: Helena Bonham Carter>, <Author: Hélène Joy>), которые предоставляет сам django. Все эти способы фильтрации я так же хочу сравнить со встроенным поиском внутри postgresql. Искать я решил по таблице tickets в версии small она содержит 366733 записей. Поиск будет происходить по полю passenger_name, где, как нетрудно догадаться, содержится имя пассажира. Написано оно транслитом.


Дать django возможность работать с уже существующей БД


Вторая проблема — разрешить django только чтение данных из нашей демонстрационной БД. Покопавшись ещё в документации django я нашёл каким же образом, можно составить модельки по существующей БД, чтобы не перепечатывать ручками всё:


$ python manage.py inspectdb > models.py

При этом, разумеется, сама БД должна быть обозначена в settings.py. Всего пару ошибочек мне пришлось поправить и всё заработало как следует. Сразу после этого я решил написать простенькую вьюшку, которая сможет нам эти данные вернуть. Браузер, разумеется очень напрягся (что и не мудрено), когда я пытался открыть адрес, по которому должно было вернуться 300к+ записей, поэтому я ограничил их число для 10, чтобы удостовериться, что они там хотя бы есть. А вообще, совершенно точно понятно, что запрос лучше отправлять через curl. Это явно скушает в разы меньше оперативной памяти.


Выбор метрик


Изначально я подумал, что считать время фильтрации в питоне получится, используя таймер для получения времени исполнения скрипта, и дополнительной метрикой должно было стать время исполнения запроса через curl, поскольку это показывает приблизительное время, за которое отфильтрованные данные дойдут до конечного пользователя. Кроме этого, следует сравнивать это время с эталонным (прямым исполнением соответствующих запросов в БД).


Фильтруем в django


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


Выдержка отсюда:


A QuerySet is iterable, and it executes its database query the first time you iterate over it. For example, this will print the headline of all entries in the database:

for e in Entry.objects.all():
       print(e.headline)```


Итоговая view для contains
class TicketListView(g.ListAPIView):
    serializer_class = TicketSerializer

    def get_queryset(self):
        queryset = ''
        params = self.request.query_params

        name = params.get('name', None)

        if name:
            start_time = d.datetime.now()

            queryset = queryset.filter(passenger_name__contains=name)
            print('len of result is {} rows'.format(len(queryset)))

            end_time = d.datetime.now()

            time_diff = (end_time - start_time)
            execution_time = time_diff.total_seconds() * 1000

            print("Filter execution time {} ms".format(execution_time))

        return queryset

Contains


Начнём с contains, по сути, он работает как WHERE LIKE.

Запрос в Django ORM/Запрос в sql для contains
queryset = queryset.filter(passenger_name__contains=name)

SELECT "tickets"."ticket_no", "tickets"."book_ref", "tickets"."passenger_id", "tickets"."passenger_name", "tickets"."contact_data" FROM "tickets" WHERE "tickets"."passenger_name"::text LIKE %IVAN%

Для того, чтобы получить результат из curl я выполнял запрос следующим образом (считается в секундах):


$ curl -w "%{time_total}\n" -o /dev/null -s http://127.0.0.1:8000/api/tickets/?name=IVAN
1,242888

Свёл всё в таблице, на соответствующем листе.


Но если резюмировать — отклонение от скорости фильтрации внутри самого постгреса достаточно большое, и по факту исполнение такого запроса к серверу займёт от 140 до 1400 мс. Не претендую на истину, но работает всё приблизительно так. А время самой фильтрации через ORM займёт от 73 до 600 мс, в то время как такая же фильтрация внутри постгреса выполняется за промежуток от 55 до 100 мс.


Icontains


Icontains работает несколько по-другому (он приводит всё к одному виду, чтобы сравнение было более близким). Код для вьюшки использовался почти аналогичный, только вместо contains — icontains. Вот и вся разница.


Запрос в Django ORM/Запрос в sql для icontains
queryset = queryset.filter(passenger_name__icontains=name)

SELECT "tickets"."ticket_no", "tickets"."book_ref", "tickets"."passenger_id", "tickets"."passenger_name", "tickets"."contact_data" FROM "tickets" WHERE UPPER("tickets"."passenger_name"::text) LIKE UPPER(%IVAN%)

По итогу, отклонение в данном случае уже меньше, поскольку и сам постгрес начал тратить намного большее времени на исполнение запроса (порядка 300 мс), а исполнение такого запроса к серверу займёт у клиента от 200 до 1500 мс. Фильтрация через ORM — от до 200 до 700 мс.


Full text search (через django.contrib.postgres)


Поскольку индексов никаких создано не было, full text search довольно сильно и вполне ощутимо проигрывает прошлым вариантам. Время исполнения запроса в постгресе колеблется около 1300 мс, а запрос к серверу занимает от 1000 до 1700 мс. При этом, фильтрация через ORM — укладывается в промежуток от 1000 до 1450 мс.


Код
class TicketListView(g.ListAPIView):
    serializer_class = TicketSerializer

    def get_queryset(self):
        # queryset = Tickets.objects.all()
        queryset = ''

        params = self.request.query_params

        name = params.get('name', None)

        if name:

            start_time = d.datetime.now()

            queryset = Tickets.objects.filter(passenger_name__search=name)

            end_time = d.datetime.now()

            time_diff = (end_time - start_time)
            execution_time = time_diff.total_seconds() * 1000

            print("Filter execution time {} ms".format(execution_time))

            f = open('results.txt', 'a')

            f.write('{}'.format(execution_time))
            f.write('\n')

            f.close()

        return queryset

Full text search (через rest_framework.filters, точнее — SearchFilter)


Если не использвоать именно FTS, то результаты получаются сравнимыми с FTS внутри постгре, но хуже, чем contains и icontains. От 200 до 1710 мс.


А с использованием FTS эффективность повышается, отклонение сводится к минимальному. В среднем, это займёт от 800 до 1120 мс.


Код
...
from rest_framework import filters as f

class TicketListView(g.ListAPIView):
    queryset = Tickets.objects.all()
    serializer_class = TicketSerializer
    filter_backends = [f.SearchFilter]
    search_fields = ['@passenger_name']

Использование фильтров через модуль django-filter


Результаты почти совпали со стандартными contains и icontains, поэтому смысла детально это рассматривать не вижу. Да и в целом, модуль django-filter не показал какого-то ощутимого преимущества перед стандартными средствами фильтрации Django ORM.


Так что в итоге?


Если у вас есть большой объём данных — нужно прописывать нормальные индексы и использовать полнотекстовый поиск (разумеется, только в том случае, когда соответствует вашим целям) с радостью и счастьем, потому что он решает довольно широкий круг проблем. Но вот всегда ли в нём есть необходимость — уже решать вам. Я усвоил для себя, что в некоторых случаях (когда не стоит задачи именно полнотекстового поиска, а есть поиск по подстроке, который реализуется с помощью contains/icontains) лучше вовсе не использовать полнотекстовый поиск, потому что индексы в определённый момент начинают кушать всё больше и больше памяти вашего сервера, что, скорее всего, негативно скажется на работе вашего сервера.


В целом, моё понимание некоторых внутренних механизмов работы django благодаря этому исследованию устаканилось. И пришло, наконец, осознание разницы между поиском по подстроке и полнотекстовым поиском. Разнице в их реализации через Django ORM.

Теги:
Хабы:
-2
Комментарии4

Публикации

Истории

Работа

Python разработчик
135 вакансий

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн