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

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

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

Во-первых, некоторый контекст — у нас есть эта задача Celery, которая запускается каждую ночь и выполняет некоторые трудоемкие, но важные вычисления, которые сохраняют «свежесть» определенной таблицы в базе данных. Это одна из важнейших частей нашей кодовой базы, и важно, чтобы эта задача успешно выполнялась каждый день. Раньше он доставлял нам проблемы из-за относительно больших объемов данных, которые он обрабатывает, и к нему применялось более одной оптимизации. Недавно задача перестала полностью выполняться, и я обнаружил сообщения о 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

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

1*qrKD4p3ZDhX1S92FriIC4A.gif

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

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

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

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

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

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

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

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()³ в наборе запросов.

...
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 кортежи (A1, B) и (A2, 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 в этих последующих проходах будет собираться мусор, и будут использоваться кэшированные версии. Теперь для (A1, B) и (A2, B) оба B указывают на один и тот же объект в памяти. В конце концов, iterator() позволяет нам контролировать, как мы тратим нашу свободную память. Объемная сложность этого окончательного кода будет O(M + N), так как у нас есть только одна копия каждого объекта книги в памяти.

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

[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] итератор() (

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *