Лишние join в SQL запросах

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

class ForumPlugin(models.Model):
    name = models.CharField(
        null=False, 
        blank=False, 
        max_length=50, 
        unique=True, 
        verbose_name=_('name')
    )

class Thread(MPTTModel):   
    parent = TreeForeignKey(
        'self', 
        null=True, 
        blank=True, 
        related_name='children', 
        verbose_name=_('parent thread')
    )
    plugin = models.ForeignKey(
        ForumPlugin, 
        null=True, 
        blank=True, 
        related_name='threads', 
        verbose_name=_('plugin')
    )

При выполнении выборки

Thread.objects.filter(plugin__isnull=True, parent__isnull=True)

Djando формирует такой запрос:

SELECT `forum_thread`.`id`, `forum_thread`.`parent_id`, `forum_thread`.`plugin_id`, `forum_thread`.`lft`, `forum_thread`.`rght`, `forum_thread`.`tree_id`, `forum_thread`.`level` FROM `forum_thread` LEFT OUTER JOIN `forum_thread` T2 ON (`forum_thread`.`parent_id` = T2.`id`) LEFT OUTER JOIN `forum_forumplugin` ON (`forum_thread`.`plugin_id` = `forum_forumplugin`.`id`) WHERE (T2.`id` IS NULL AND `forum_forumplugin`.`id` IS NULL AND ) ORDER BY `forum_thread`.`id

Естественно время исполнения такого запроса увеличивается на несколько порядков, что при больших таблицах может быть критично. Так на моем проекте на таблице порядка 20-30к записей такая выборка вместо положенной 1мс выполняется от 100мс до 300мс, что увеличивает время генерации страницы вдвое.

К сожалению бага разработчикам ORM известна уже четыре года и имеет долгую и печальную историю.

В данный момент присутствует во всех стабильных версиях, в том числе в 1.4.3. Предполагается, что в 1.5 она наконец-то будет исправлена.

В качестве обходного пути советуют использовать двойное отрицание:

Thread.objects.exclude(plugin__isnull=False, parent__isnull=False)

но мне не удалось на практике таким способом избавится от проблемы. Обращение напрямую к полю parent_id также не помогает.

Будьте внимательны при проектировании моделей и старайтесь учитывать эту особенность Django, и избегать выборок по внешним ключам с использованием NULL условий.

UPD: Найдено решение:

Category.objects.extra(where=['parent_id IS NULL'])

Использовать raw sql это конечно дурной тон, но судя по всему это единственное решение.
Поделиться публикацией

Комментарии 25

  • НЛО прилетело и опубликовало эту надпись здесь
      0
      да, собственно именно parent у меня и дает всю тяжесть, Что характерно, если NULL нет, а идет сравнение с числовым значением по ID, то при обращении по parent_id никаких проблем нет.
      +1
      Вот это да. Не знал, спасибо.
      Удалось вот так:
      Category.objects.extra(where=['parent_id IS NULL'])
      

      SELECT `category`.`id`, ... FROM `category` WHERE parent_id IS NULL ORDER BY `category`.`tree_id` ASC, `category`.`lft` ASC
      
        –6
        Ребзя! ORM зло. Ее можно использовать на небольших проектах, аля бложик и т.д. куда идеально ложится модель Active Record. Если у вас проектирование идет от данных, т.е. схема БД проектируется более тщательно, то тут ORM не прокатит.
          +4
          Вы только что сказали что-то типа: «Пацаны, ООП зло, оно используется только для небольших проектов, гостевухи там всякие, а на большие проекты вы код проектируете более тщательно, там нужно только ассемблер, только хардкор!»

          На ОРМ надейся, а сам не плошай — так лучше будет.
            –1
            Это я и хотел донести)
          • НЛО прилетело и опубликовало эту надпись здесь
              +2
              Я тоже видел большие проекты на том же Hibernate (Java). И что толку. Около 5000 таблиц, база под 300 Гб. Практически над каждым действием орм написан ручной запрос, потому что сам Hibernate генерирует такую портянку что ваш DBA вас просто повесит. В итоге в таких проектах смысл использования ORM сводится только ради использования ORM. Т.к. все запросы все равно переписаны вручную.
              • НЛО прилетело и опубликовало эту надпись здесь
                  +1
                  Вообще то нет. Не скажу за Hibernate, но занимался исследованием эффективности запросов — и они вполне себе ничего.
                  И мне вот интересно что вы будете делать с «внезапным» переездом проекта с MySQL на SQLLite.Счастливой отладки без ORM.

                  Я уж молчу о том, что свобода от написания JOIN очень сильно облегчает процесс разработки когда ты и жнец и швец и на дуде игрец. Не буду считать на столько быстрее написать parent__plugin_id чем делать JOIN таблицы самой к себе и проверки поля вручную.

                  Django кстати говоря крайне удобен тем, что вы не обязаны использовать его целиком. Если вам не нравятся его модели, вы можете вообще ими не пользоваться, при этом все остальное будет работать так же.
              0
              Спасибо за заметку! От себя могу добавить ещё одну особенность — при удалении объектов через ORM когда мы пишем
              SomeModel.objects.filter(...).delete()
              

              Django почему-то сначала делает SELECT и выбирает все записи, которые соответствуют условию, указанному в filter, а уже потом только DELETE. То есть как RAW SQL это выглядит примерно как-то так:
              SELECT "some_table"."id", ... FROM "some_table" WHERE ...
              DELETE FROM "some_table" WHERE "some_table"."id" IN (...)
              

              Причем в результатах выборки первого SELECT присутствуют все поля, а не только поле primary key.
              Наверное, создатели движка хотели таким образом предусмотреть какую-то исключительную ситуацию, но вообще говоря в большинстве-то случаев первый SELECT казалось бы лишний.
                +2
                На каждый обьект джанга кидает pre_delete, post_delete, от этого зависит много логики. Поэтому такая выборка и поэтому со всеми полями. Но да, хотелось бы видеть:

                SomeModel.objects.filter(...).delete(skip_signals=True)
                  0
                  Точно! Совсем запамятовал про сигналы… теперь все встает на свои места.
                    0
                    Еще не вникал в тему, но читал что при таком удалении сигналы не генерируются. По крайней мере delete обработчик модели не вызывается. В мануале советуют удалять по одной.
                      –1
                      Дак вникните, откройте документацию по сигналам. Хотя согласен, что в описании QuerySet.delete() стоило бы и о сигналах написать.
                        0
                        Тогда не понятно, что же им помешало вызвать delete обработчик модели, ежели они все равно их выбирают.
                          +1
                          Если удалять через object.delete(), то будет по одному запросу на каждый обьект. Джанга умно собирает все суб-дерево удаляемых обьектов, вместе с их ForeignKey зависимостями, затем удаляет все через несколько batch delete запросов. Так гораздо эффективней. Почитать можно в django.db.models.deletion
                    +1
                    Есть ещё такой момент — django, по-умолчанию, удаляет связанные объекты, причем на уровне приложения, а не sql(sqlite, afaik, не поддерживает delete cascade)
                    0
                    А что EXPLAIN говорит? А то (особенно с учётом фичи join removal в PostgreSQL 9.0) это может оказаться не такой уж и проблемой…
                      0
                      А что может сказать EXPLAIN? Что вопрос плохо составлен? Это и так очевидно. Про PostgreSQL не знаю, я им не пользуюсь. У меня везде MySQL при том не самый свежий.
                        0
                        в MariaDB тоже есть «join removal» — только называется по другому, table elimination.

                        Но тут дело в другом. Сам запрос-то правильный? А то мне кажется, что нет. То есть, я не знаю, что надо получить, но запрос с join-ами явно не эквивалентен запросу с WHERE parent_id IS NULL. Так что join-ы там по-любому не лишние. С точки зрения DBMS. И никто их автоматически не уберет.

                        Хотя там, наверно, ORM свои дополнительные условия накладывает, при учете которых запросы становятся эквивалентными, так?
                          0
                          Конечно неправильный. Об этом то и статья, если вы не заметили. Об избыточных Join которые совсем не нужны.
                            0
                            Я имел в виду, что «неправильный» в смысле, дает некорректный результат. А «добавляет избыточный join» — это я воспринимаю как «напрасно усложняет запрос, но результат остается правильный».

                            То есть для меня «неправильный ответ» — это все-таки гораздо более сильная степень «неправильности», чем просто «работает дольше, чем нужно».
                              0
                              Это я вам и без Explain скажу. В нормальных условиях возвращает правильный результат. Но эти join именно избыточны и увеличиваю время исполнения на порядок. А лишние 100-200мс при формировании страницы вполне может оказаться критичной проблемой.
                      • НЛО прилетело и опубликовало эту надпись здесь

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

                      Самое читаемое