Pull to refresh

Параметризованные запросы и производительность django orm

Reading time 7 min
Views 9.6K
Столкнувшись с существенными потерями производительности на использовании django orm, я стал искать выход из положения, рассматривая разные способы использования orm. Что у меня получилось — смотрите подкатом.


Как пишется обычный кусок кода, использующий django orm?

Как правило, этот кусок входит в некую функцию, ну например view, получает параметры и формирует результат на основании этих параметров.

В качестве примера, рассмотрим следующую элементарную ситуацию: мы хотим получить список наименований групп, в которые входит текущий пользователь. Самый простой и очевидный, приходящий в голову в первую очередь, путь сделать это — получить список групп через отношение и выяснить их имена:

def myview(request):
  u = request.user
  a = [g.name for g in u.groups.all()]
  ...


Проверим, какова будет производительность данного куска, имея в виду, что объект пользователя request.user уже получен на этапе предварительной обработки запроса.

Создадим группу thetest и присоединим к ней самого первого пользователя:
>>> u = User.objects.all()[0]
>>> g = Group(name='thetest')
>>> g.save()
>>> u.groups.add(g)
>>> u.groups.all()
[<Group: thetest>]


Я буду использовать этот кейз во всех дальнейших тестах. Поскольку все делается через shell, я также использую в них переменную u, полученную на данном этапе.

Итак, тест номер 1, выполняем задуманный кусок кода. Проверим, действительно ли он возвращает искомый список:

>>> a = [g.name for g in u.groups.all()]
>>> a
[u'thetest']


Чтобы померить производительность, выполним его 1000 раз.

>>> def test1():
...  import datetime
...  t1 = datetime.datetime.now()
...  for i in xrange(1000):
...   a = [g.name for g in u.groups.all()]
...  t2 = datetime.datetime.now()
...  print "%s" % (t2 - t1)
... 
>>> test1()
0:00:01.437324

Тысяча оборотов нашего цикла выполнялась около полутора секунд, что дает 1.5 миллисекунды на запрос.

Опытные джангописатели наверняка уже собрались ткнуть меня носом в то, что данный кусок далек от оптимальности. Действительно, можно написать на первый взгляд, более оптимальный участок кода, который выполнит те же действия, не конструируя объект группы и получая из базы только те данные, которые действительно нам нужны:

>>> a = [g['name'] for g in u.groups.values('name')]
>>> a
[u'thetest']


Что ж, померяем и этот кусок.

>>> def test2():
...  import datetime
...  t1 = datetime.datetime.now()
...  for i in xrange(1000):
...   a = [g['name'] for g in u.groups.values('name')]
...  t2 = datetime.datetime.now()
...  print "%s" % (t2 - t1)
... 
>>> test2()
0:00:01.752529

Это кажется противоестественным, но вторая версия нашего кода менее оптимальна чем первая?

В действительности, это так оно и есть. Потери на вызове values() и дополнительном анализе запроса оказались выше, чем потенциальная экономия на конструировании объекта Group и получении значений всех его полей.

Но позвольте? А зачем собственно всякий раз заново конструировать и анализировать запрос, если мы в нашем view будем всегда выполнять этот же запрос, а отличаться будет только объект пользователя, на котором этот запрос выполняется?

К сожалению, django изначально не позволяет подготовить запрос заранее, обращаясь к подготовленному запросу по мере необходимости. Нет соответствующих вызовов, да и синтаксис формирования запроса подразумевает использование только конкретных значений в качестве параметров запроса.

Придется немножко полазить по исходникам. Пользуясь случаем, хотел бы выразить признательность разработчикам django_extensions и их замечательной команде shell_plus, которая сильно облегчает интроспекцию объектов.

Оказывается, у объекта QuerySet (это тот самый, который получается например в момент обращения к objects.all()) есть свойство query, объект класса django.db.models.sql.query.Query. У которого в свою очередь, есть метод sql_with_params()

Этот метод возвращает набор параметров, полностью готовый для передачи в cursor.execute() — то есть строку выражения SQL и дополнительные параметры. Самое замечательное заключается в том, что эти самые дополнительные параметры и есть те параметры, которые передаются объекту QuerySet при его формировании:

>>> u.groups.all().values('name').query.sql_with_params()
('SELECT `auth_group`.`name` FROM `auth_group` INNER JOIN `auth_user_groups` ON (`auth_group`.`id` = `auth_user_groups`.`group_id`) WHERE `auth_user_groups`.`user_id` = %s ', (1,))


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

Для этого создадим специальный класс, скрывающий внутри все детали хака, который мы собираемся совершить.

from django.db import connection
from django.db.models.query import QuerySet,ValuesQuerySet

import django

from threading import local

class PQuery(local):
    def __init__(self,query,connection=connection,**placeholders):
        self.query = query
        self.connection = connection
        self.placeholders = placeholders
        self.replaces = {}
        sql = None
        try:
            sql = self.query.query.sql_with_params() # 1.4
        except AttributeError:
            sql = self.query.query.get_compiler(connection=self.connection).as_sql() # 1.3, lower?
        self.places = list(sql[1])
        self.sql = sql[0]
        self.is_values = isinstance(query,ValuesQuerySet)
        self.cursor = None
        for i in xrange(len(self.places)):
            x = self.places[i]
            found = False
            for p in self.placeholders:
                v = self.placeholders[p]
                if x == v:
                    found = True
                    if not p in self.replaces:
                        self.replaces[p] = []
                    self.replaces[p].append(i)
            if not found:
                raise AttributeError("The placeholder %(ph)s not found, please add some_name=%(ph)s to the list of constructor parameters" % {
                    'ph':repr(x)
                })

    def execute(self,**kw):
        try:
            for k in kw:
                for i in self.replaces[k]:
                    self.places[i] = kw[k]
        except KeyError,ex:
            raise TypeError("No such placeholder: %s" % k)
        if not self.cursor:
            self.cursor = self.connection.cursor()
        self.cursor.execute(self.sql,self.places)
        if not hasattr(self,'fldnms'):
            self.fldnms = [col[0] for col in self.cursor.description]
        if self.is_values:
            return [dict(zip(self.fldnms,row)) for row in self.cursor.fetchall()]
        return [self.query.model(**dict(zip(self.fldnms,row))) for row in self.cursor.fetchall()]

    def __call__(self,**kw):
        return self.execute(**kw)

ParametrizedQuery = PQuery # compatibility issue

UPD: 2012-08-06 19:20:00 MSK — внес поправки в код по поводу совместимости с мультитредингом, исправления мелких недочетов при выполнении сложных запросов и улучшения юзабельности.
Предыдущая версия кода
from django.db import connection
from django.db.models.query import QuerySet,ValuesQuerySet

class ParametrizedQuery:
    def __init__(self,query,connection=connection,**placeholders):
        self.query = query
        self.connection = connection
        self.placeholders = placeholders
        self.replaces = {}
        sql = self.query.query.sql_with_params()
        self.places = list(sql[1])
        self.sql = sql[0]
        self.is_values = isinstance(query,ValuesQuerySet)
        self.cursor = None
        for p in self.placeholders:
            v = self.placeholders[p]
            self.replaces[p] = self.places.index(v)

    def execute(self,**kw):
        for k in kw:
            self.places[self.replaces[k]] = kw[k]
        if not self.cursor:
            self.cursor = self.connection.cursor()
        self.cursor.execute(self.sql,self.places)
        if not hasattr(self,'fldnms'):
            self.fldnms = [col[0] for col in self.cursor.description]
        if self.is_values:
            return [dict(zip(self.fldnms,row)) for row in self.cursor.fetchall()]
        return [self.query.model(**dict(zip(self.fldnms,row))) for row in self.cursor.fetchall()]


Что делает этот класс? Он получает запрос и выковыривает из него подготовленный SQL и параметры. Мы можем сформировать такой запрос, в котором каждый из параметров, который мы собираемся подставлять, имеет специальное, известное нам заранее значение. Эти значения мы будем использовать для поиска того места, в которое мы хотим подставлять значения, передаваемые во время исполнения.

Несколько дополнительных деталей реализации также помогут нам сэкономить ресурсы.
  • Свойство fldnms содержит массив имен полей, получаемый при первом исполнении запроса. Последующие вызовы будут использовать готовый массив.
  • Свойство replaces содержит отображение имен подстановок в номера параметров.
  • Каждый объект нашего класса будет держать собственный курсор. Потенциальное ускорение от этого шага является следствием во-первых, того что создание курсора — достаточно затратная операция, а во-вторых — следующей фразы из описания pyodbc, который может быть использован в качестве бакенда базы данных: «It is also be more efficient if you execute the same SQL repeatedly with different parameters. The SQL will be prepared only once. (pyodbc only keeps the last statement prepared, so if you switch between statements, each will be prepared multiple times.)»
  • Свойство is_values поможет нам определить, что запрос не должен возвращать объект модели, что позволит сэкономить на создании такого объекта при возвращении результатов.


Немного изменим исходный запрос, чтобы было удобно подсовывать туда подстановки:

>>> q = Group.objects.filter(user__id=12345).values('name')
>>> q.query.sql_with_params()
('SELECT `auth_group`.`name` FROM `auth_group` INNER JOIN `auth_user_groups` ON (`auth_group`.`id` = `auth_user_groups`.`group_id`) WHERE `auth_user_groups`.`user_id` = %s ', (12345,))


Значение 12345 мы используем как подстановку:

>>> p = ParametrizedQuery(q,user_id=12345)
>>> [g['name'] for g in p.execute(user_id=u.id)]
[u'thetest']


При исполнении запроса p.execute() на место подстановки 12345 было подставлено реальное значение идентификатора пользователя.

Попробуем теперь посмотреть, как изменится производительность кода:

>>> def test3():
...  import datetime
...  t1 = datetime.datetime.now()
...  for i in xrange(1000):
...   a = [g['name'] for g in p.execute(user_id=u.id)]
...  t2 = datetime.datetime.now()
...  print "%s" % (t2 - t1)
... 
>>> test3()
0:00:00.217270

Вот это результат! Время исполнения запроса сократилось в 7 раз.

Как это использовать в реальном коде?

Во-первых, необходимо место, в котором подготовленный запрос мог бы храниться. Во-вторых, в какой-то момент нужно заполнить эту переменную. Например, в момент первого исполнения кода функции. Ну и в третьих, разумеется, мы используем обращение к параметризованному запросу вместо прямого исполнения запроса.

def myview(request):
 if not hasattr(myview,'query'):
  myview.query = ParametrizedQuery(Group.objects.filter(user__id=12345).values('name'),user_id=12345)
 a = [g['name'] for g in myview.query.execute(user_id=request.user.id)]
 ...


Весь код исполнялся на:
— django.VERSION=(1, 4, 0, 'final', 0)
— СУБД mysql (django.db.backends.mysql)
— table engine=MYISAM
— соединение через localhost
— Python 2.7.2+ (default, Oct 4 2011, 20:03:08) [GCC 4.6.1] on linux2
— хост Linux seva 3.0.0-22-generic #36-Ubuntu SMP Tue Jun 12 17:13:04 UTC 2012 i686 athlon i386 GNU/Linux

Комментарии специалистов приветствуются.
Tags:
Hubs:
+20
Comments 19
Comments Comments 19

Articles