Почему итерация через большой Django QuerySet потребляет огромное количество памяти?


111

Рассматриваемая таблица содержит примерно десять миллионов строк.

for event in Event.objects.all():
    print event

Это приводит к неуклонному увеличению использования памяти до 4 ГБ или около того, после чего строки печатаются быстро. Длительная задержка перед печатью первой строки меня удивила - я ожидал, что она распечатается почти мгновенно.

Я также пробовал, Event.objects.iterator()который вел себя так же.

Я не понимаю, что Django загружает в память и почему он это делает. Я ожидал, что Django будет перебирать результаты на уровне базы данных, что означало бы, что результаты будут выводиться примерно с постоянной скоростью (а не все сразу после длительного ожидания).

Что я неправильно понял?

(Не знаю, актуально ли это, но я использую PostgreSQL.)


6
На меньших машинах это может даже сразу вызвать "Killed" для оболочки или сервера django
Стефано

Ответы:


113

Нейт Си был близок, но не совсем.

Из документов :

Вы можете оценить QuerySet следующими способами:

  • Итерация. QuerySet является итеративным, и он выполняет свой запрос к базе данных при первом проходе по нему. Например, это напечатает заголовок всех записей в базе данных:

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

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

Из того, что я читал в документации, iterator()ничего не делает, кроме как обойти внутренние механизмы кэширования QuerySet. Я думаю, что было бы разумно делать это по отдельности, но для этого, наоборот, потребовалось бы десять миллионов отдельных обращений к вашей базе данных. Может быть, не все так желательно.

Эффективный перебор больших наборов данных - это то, что мы до сих пор не совсем поняли, но есть некоторые фрагменты, которые могут оказаться полезными для ваших целей:


1
Спасибо за отличный ответ, @eternicode. В конце концов, мы перешли к необработанному SQL для желаемой итерации на уровне базы данных.
davidchambers

2
@eternicode Хороший ответ, просто нажмите на эту проблему. Есть ли с тех пор какие-либо связанные обновления в Django?
Zólyomi István

2
В документации, начиная с Django 1.11, говорится, что iterator () действительно использует курсоры на стороне сервера.
Джефф Джонсон,

42

Может быть, не самым быстрым или эффективным, но в качестве готового решения почему бы не использовать объекты Paginator и Page из ядра django, описанные здесь:

https://docs.djangoproject.com/en/dev/topics/pagination/

Что-то вроде этого:

from django.core.paginator import Paginator
from djangoapp.models import model

paginator = Paginator(model.objects.all(), 1000) # chunks of 1000, you can 
                                                 # change this to desired chunk size

for page in range(1, paginator.num_pages + 1):
    for row in paginator.page(page).object_list:
        # here you can do whatever you want with the row
    print "done processing page %s" % page

3
Небольшие улучшения теперь возможны после публикации. Paginatorтеперь имеет page_rangeсвойство избегать шаблонов. Если вы ищете минимальные накладные расходы на память, вы можете использовать, object_list.iterator()который не будет заполнять кеш набора запросов . prefetch_related_objectsзатем требуется для предварительной выборки
Ken Colton

28

По умолчанию Django кэширует весь результат QuerySet при оценке запроса. Вы можете использовать метод итератора QuerySet, чтобы избежать этого кеширования:

for event in Event.objects.all().iterator():
    print event

https://docs.djangoproject.com/en/dev/ref/models/querysets/#iterator

Метод iterator () оценивает набор запросов, а затем считывает результаты напрямую, не выполняя кэширование на уровне QuerySet. Этот метод приводит к повышению производительности и значительному сокращению памяти при итерации большого количества объектов, к которым вам нужно получить доступ только один раз. Обратите внимание, что кеширование по-прежнему выполняется на уровне базы данных.

Использование iterator () уменьшает использование памяти для меня, но все равно выше, чем я ожидал. Использование подхода paginator, предложенного mpaf, использует гораздо меньше памяти, но в моем тестовом примере это в 2-3 раза медленнее.

from django.core.paginator import Paginator

def chunked_iterator(queryset, chunk_size=10000):
    paginator = Paginator(queryset, chunk_size)
    for page in range(1, paginator.num_pages + 1):
        for obj in paginator.page(page).object_list:
            yield obj

for event in chunked_iterator(Event.objects.all()):
    print event

8

Это из документов: http://docs.djangoproject.com/en/dev/ref/models/querysets/

Фактически никаких действий с базой данных не происходит, пока вы не сделаете что-нибудь для оценки набора запросов.

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

Но если вы сделаете что-то вроде:

Event.objects.all()[300:900]

http://docs.djangoproject.com/en/dev/topics/db/queries/#limiting-querysets

Затем он добавит смещения и ограничения в sql внутри.


7

Для большого количества записей курсор базы данных работает еще лучше. Вам нужен необработанный SQL в Django, Django-cursor - это нечто иное, чем SQL-курсор.

Метод LIMIT - OFFSET, предложенный Nate C, может быть достаточно хорош для вашей ситуации. Для больших объемов данных он работает медленнее, чем курсор, потому что ему приходится выполнять один и тот же запрос снова и снова и переходить через все больше и больше результатов.


4
Фрэнк, это определенно хороший момент, но было бы неплохо увидеть некоторые детали кода, чтобы подтолкнуть к решению ;-) (ну, этот вопрос уже довольно старый ...)
Стефано

7

У Django нет хорошего решения для извлечения больших элементов из базы данных.

import gc
# Get the events in reverse order
eids = Event.objects.order_by("-id").values_list("id", flat=True)

for index, eid in enumerate(eids):
    event = Event.object.get(id=eid)
    # do necessary work with event
    if index % 100 == 0:
       gc.collect()
       print("completed 100 items")

values_list можно использовать для получения всех идентификаторов в базах данных, а затем для получения каждого объекта отдельно. Со временем в памяти будут создаваться большие объекты, которые не будут собираться сборщиком мусора, пока цикл for не будет завершен. Приведенный выше код выполняет ручную сборку мусора после использования каждого 100-го элемента.


Может ли streamingHttpResponse стать решением? stackoverflow.com/questions/15359768/…
ratata

2
Однако это приведет к тому, что количество попаданий в базу данных будет равно количеству циклов, я боюсь.
раратиру 08

5

Потому что таким образом объекты для всего набора запросов загружаются в память сразу. Вам нужно разбить ваш запрос на более мелкие удобоваримые части. Схема для этого называется кормлением с ложечки. Вот краткая реализация.

def spoonfeed(qs, func, chunk=1000, start=0):
    ''' Chunk up a large queryset and run func on each item.

    Works with automatic primary key fields.

    chunk -- how many objects to take on at once
    start -- PK to start from

    >>> spoonfeed(Spam.objects.all(), nom_nom)
    '''
    while start < qs.order_by('pk').last().pk:
        for o in qs.filter(pk__gt=start, pk__lte=start+chunk):
            yeild func(o)
        start += chunk

Чтобы использовать это, вы пишете функцию, которая выполняет операции с вашим объектом:

def set_population_density(town):
    town.population_density = calculate_population_density(...)
    town.save()

а затем запустите эту функцию в своем запросе:

spoonfeed(Town.objects.all(), set_population_density)

Это можно улучшить с помощью многопроцессорной обработки для funcпараллельного выполнения нескольких объектов.


1
Похоже, это будет встроено в 1.12 с помощью iterate (chunk_size = 1000)
Кевин Паркер

3

Вот решение, включающее len и count:

class GeneratorWithLen(object):
    """
    Generator that includes len and count for given queryset
    """
    def __init__(self, generator, length):
        self.generator = generator
        self.length = length

    def __len__(self):
        return self.length

    def __iter__(self):
        return self.generator

    def __getitem__(self, item):
        return self.generator.__getitem__(item)

    def next(self):
        return next(self.generator)

    def count(self):
        return self.__len__()

def batch(queryset, batch_size=1024):
    """
    returns a generator that does not cache results on the QuerySet
    Aimed to use with expected HUGE/ENORMOUS data sets, no caching, no memory used more than batch_size

    :param batch_size: Size for the maximum chunk of data in memory
    :return: generator
    """
    total = queryset.count()

    def batch_qs(_qs, _batch_size=batch_size):
        """
        Returns a (start, end, total, queryset) tuple for each batch in the given
        queryset.
        """
        for start in range(0, total, _batch_size):
            end = min(start + _batch_size, total)
            yield (start, end, total, _qs[start:end])

    def generate_items():
        queryset.order_by()  # Clearing... ordering by id if PK autoincremental
        for start, end, total, qs in batch_qs(queryset):
            for item in qs:
                yield item

    return GeneratorWithLen(generate_items(), total)

Использование:

events = batch(Event.objects.all())
len(events) == events.count()
for event in events:
    # Do something with the Event

0

Я обычно использую необработанный исходный запрос MySQL вместо Django ORM для такого рода задач.

MySQL поддерживает режим потоковой передачи, поэтому мы можем быстро и безопасно перебирать все записи без ошибок нехватки памяти.

import MySQLdb
db_config = {}  # config your db here
connection = MySQLdb.connect(
        host=db_config['HOST'], user=db_config['USER'],
        port=int(db_config['PORT']), passwd=db_config['PASSWORD'], db=db_config['NAME'])
cursor = MySQLdb.cursors.SSCursor(connection)  # SSCursor for streaming mode
cursor.execute("SELECT * FROM event")
while True:
    record = cursor.fetchone()
    if record is None:
        break
    # Do something with record here

cursor.close()
connection.close()

Ref:

  1. Получение миллиона строк из MySQL
  2. Как работает потоковая передача набора результатов MySQL по сравнению с одновременной выборкой всего набора результатов JDBC

Вы все еще можете использовать Django ORM для генерации запроса. Просто используйте результат queryset.queryдля своего выполнения.
Pol,
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.