Рубрики
Без рубрики

Оптимизация Django: Или как мы избежали сбоев в памяти

(Кросс-пост из моего блога на Medium) Работа в технологическом стартапе сродни борьбе с серией пожаров, которые возникают время от времени (быстрее, если вы повторяете с головокружительной скоростью). Каждый раз…

Автор оригинала: Pritish Chakraborty.

(Кросс-пост из моего блога на Medium) Работа в технологическом стартапе сродни борьбе с серией пожаров, которые возникают время от времени (быстрее, если вы повторяете с головокружительной скоростью). Каждый раз, когда вы тушите огонь достаточно, чтобы сохранить некоторое спокойствие в течение следующих нескольких месяцев, но в глубине души вы знаете, что это еще не конец. Таким образом, становится важным выбирать свои сражения .

Проблема под рукой

Во – первых, некоторый контекст-у нас есть задача сельдерея, которая выполняется каждую ночь и выполняет некоторые трудоемкие, но важные вычисления, которые сохраняют “свежесть” определенной таблицы в базе данных. Это одна из важнейших частей нашей кодовой базы, и очень важно, чтобы эта задача успешно выполнялась каждый день. Он и раньше доставлял нам проблемы из-за относительно больших объемов данных, которые он обрабатывает, и видел, как к нему применялось несколько оптимизаций. Недавно задача полностью перестала выполняться, и я обнаружил сообщения segfault при исследовании журналов производства для другой проблемы. Рассмотрим ультра упрощенную версию кода задачи следующим образом -:

books = Book.objects.filter(some_field=some_condition)
authors = Author.objects.filter(some_other_field=some_condition)
results = {}
for x in authors:
    for y in books:
        # Insert computations here
        results[(x, y)] = True # Not that simple, but still
return results

книги и авторы являются наборами запросов Django, хотя и не являются репрезентативными для наших реальных моделей. На момент написания статьи у последнего было 2012 объектов, а у первого-около 17 тыс. объектов. С момента создания этой задачи этот двойной цикл for хорошо послужил нам. Затем мы столкнулись с первыми зацепками в памяти в прошлом году и недавними сегментами. Я решил разобрать код до самого необходимого, аналогично тому, что я показал выше, и запустить несколько тестов с помощью команды htop. Вот gif того же самого, когда я исследовал проблему на нашей промежуточной машине, которая имеет 4 гигабайта оперативной памяти, в отличие от 10 гигабайт на производстве (в соответствии с упрощенным кодом) -:

1*qrKD4p3ZDhX1S92FriIC4A.gif

Использование памяти растет довольно быстро, и прежде чем мы это осознаем, мы достигаем предела в 4 гигабайта на машине. Если бы я измерял в терминах сложности пространства, это было бы O(MN), где M и N оба (приблизительно?) линейные функции, которые отслеживают рост обоих наборов запросов с течением времени. M явно медленнее, чем N.

Пакетирование набора запросов

Затем я повторно применил первую оптимизацию, которая была применена год назад, – использование пакетных наборов запросов. В нашем скрипте есть два основных места1, где потребляется память: первое-соединитель базы данных python (в данном случае соединитель базы данных Python MySQL), который выполняет задачу извлечения результатов, а второе-кэш набора запросов. Пакетирование наборов запросов относится к оптимизации в первую очередь. Вот код с пакетной обработкой, примененный ко второму набору запросов -:

def batch_qs(qs, batch_size=1000):
    """
    Returns a (start, end, total, queryset) tuple for each batch in the given
    queryset. Useful when memory is an issue. Picked from djangosnippets.
    """
    if isinstance(qs, QuerySet):
        total = qs.count()
    else:
        total = len(qs)
    for start in range(0, total, batch_size):
        end = min(start + batch_size, total)
        yield (start, end, total, qs[start:end])

books = Book.objects.filter(some_field=some_condition)
authors = Author.objects.filter(some_other_field=some_condition)
results = {}
for x in authors:
    for _, _, _, qs in batch_qs(books, batch_size=1000):
        for y in qs:
            # Insert computations here
            results[(x, y)] = True # Not that simple, but still
return results

Мы выполняем задачу еще раз и смотрим на htop.

1*QiCnD13CH1LPkmEyEX8KHw.gif

Хмм. Скорость, с которой растет использование памяти, медленнее, чем в прошлый раз, но она неизбежно достигает предела и сегментируется. Это имеет смысл, поскольку пакетирование наборов запросов не уменьшает количество результатов, извлекаемых из базы данных. Он только пытается сохранить как можно меньше в памяти за один раз , настроив размер пакета. Это приводит нас к нашему нынешнему затруднительному положению, и второе место для оптимизации.

Природа наборов запросов

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

qs = Book.objects.filter(id=3)
# The DB query has not been executed at this point
x = qs
# Just assigning variables doesn't do anything
for x in qs:
    print x
# The query is executed at this point, on iteration
for x in qs:
    print "%d" % x.id
# The query is not executed this time, due to caching

Теперь, как кэширование входит в эту смесь? Оказывается, из-за кэша мы не можем “выбросить” (собрать мусор) пакеты, которые уже были использованы. В нашем случае мы используем каждый пакет только один раз, и кэширование их-это расточительное использование памяти. Кэш не будет очищен до конца функции. Для борьбы с этим мы используем функцию iterator()3 в наборе запросов.

...
for x in authors:
    for _, _, _, qs in books:
        for y in qs.iterator():
            # Insert computations here
            results[(x, y)] = True # Not that simple, but still
...

Давайте попробуем это сделать. Рост использования памяти должен замедлиться до минимума.

1*bkYyn6qurY06zMbSwz8b7Q.gif

Но это не так!

Каким-то образом, несмотря на использование итератора, мы храним материал где-то в памяти. Давайте внимательно рассмотрим код. Существуют ли какие-либо ссылки на объекты, которые могут помешать сборщику мусора освободить память? Оказывается, есть одно место – словарь, используемый для хранения кортежей объектов в качестве ключей.

results[(x, y)] = True

Простой тест, который я провел, состоял в том, чтобы прокомментировать эту строку и посмотреть, сколько памяти занимает задача. Использование памяти больше не росло отчаянно, а вместо этого было заморожено на определенном значении. Для каждого автора из внешнего цикла мы перебирали разные “копии” книг во внутреннем цикле. Для двух авторов A1 и A2 кортежи (A 1, B) и (A 2, B) указывали на разные копии B в памяти, из-за использования итератора . Таким образом, нам придется вновь ввести кэширование, но на наших условиях.

books = Book.objects.filter(some_field=some_condition)
authors = Author.objects.filter(some_other_field=some_condition)
results = {}
book_cache = {}
for x in authors:
    for _, _, _, qs in batch_qs(books, batch_size=1000):
        for y in qs:
            # Insert computations here
            if y.id not in book_cache:
                book_cache[y.id] = y
            cached_y = book_cache[y.id]
            results[(x, cached_y)] = True # Not that simple, but still
return results
1*u24QAuK2r831UqZENtG7ag.gif

И вот мы идем. Использование памяти растет медленно, колеблется вокруг определенных значений и через некоторое время значительно падает (не показано). Цель book_cache состоит в том, чтобы хранить каждый объект книги, который виден на одном полном проходе всех объектов книги, для повторного использования в последующих полных проходах. Объекты, на которые указывает y в этих последующих проходах, будут собраны в мусор, и будут использоваться кэшированные версии. Теперь для (A 1, B) и (A 2, B) оба B указывают на один и тот же объект в памяти. В конце концов, iterator() позволяет нам контролировать, как мы тратим нашу свободную память. Пространственная сложность этого окончательного кода будет равна O(M + N), так как у нас есть только одна копия каждого объекта книги в памяти.

Обратите внимание, что на данный момент это решает нашу проблему. По мере увеличения размера базы данных будет возникать все больше проблем. Это означает, что код не идеален, но тогда цель никогда не является “совершенством”, это “оптимизация для настоящего момента”. PS: чтобы обратиться к некоторым комментариям среды – я действительно могу хранить идентификаторы объектов, пары из них, в качестве ключей. Это было бы идеальным решением. Но это не работает для меня в этой ситуации, потому что приведенный выше код является небольшим разделом более крупного рабочего процесса, где требуются экземпляры объектов.

[1] Эффективные запросы Django с Памятью (www.poeschko.com/2012/02/memory-efficient-django-queries/) [2] Запросы Django (https://docs.djangoproject.com/en/2.0/topics/db/optimization/#understand-queryset-evaluation) [3] итератор() (https://docs.djangoproject.com/en/2.0/ref/models/querysets/#iterator)