Чому ітерація через великий Django QuerySet споживає величезну кількість пам'яті?


111

Розглянута таблиця містить приблизно десять мільйонів рядків.

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

Це призводить до того, що використання пам'яті постійно збільшується до 4 Гб або близько того, після чого рядки швидко друкуються. Тривала затримка перед друком першого ряду мене здивувала - я очікував, що він надрукується майже миттєво.

Я також спробував, Event.objects.iterator()що поводився так само.

Я не розумію, що Django завантажує в пам'ять або чому це робить. Я очікував, що Джанго повторить результати на рівні бази даних, що означатиме, що результати будуть надруковані приблизно з постійною швидкістю (а не відразу відразу після тривалого очікування).

Що я неправильно зрозумів?

(Я не знаю, чи це актуально, але я використовую PostgreSQL.)


6
На менших машинах це навіть може негайно спричинити "вбивство" в оболонці або сервері джанго
Стефано

Відповіді:


113

Нейт С був близько, але не зовсім.

З документів :

Оцінити набір запитів можна наступними способами:

  • Ітерація. QuerySet є ітерабельним, і він виконує свій запит до бази даних при першому повторенні над ним. Наприклад, це надрукує заголовок усіх записів у базі даних:

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

Таким чином, ви отримуєте десять мільйонів рядків, і все відразу, коли ви вперше входите в цю петлю і отримуєте ітераційну форму запиту. Зачекання, яке ви відчуваєте, - це Django завантаження рядків бази даних та створення об'єктів для кожного з них, перш ніж повернути те, що ви можете насправді повторити. Тоді у вас є все на пам’ять, і результати випливають.

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

Ефективне використання великих наборів даних - це те, що ми все ще не зрозуміли, але є деякі фрагменти, які можуть бути корисними для ваших цілей:


1
Дякую за чудову відповідь, @eternicode. Врешті-решт ми перейшли до сировини SQL для бажаної ітерації на рівні бази даних.
Давидчам

2
@eternicode Приємна відповідь, просто натисніть на це питання. Чи є якесь пов’язане оновлення у Django з тих пір?
Zólyomi István

2
Документи, починаючи з Django 1.11, кажуть, що iterator () використовує курсори на стороні сервера.
Джефф Джонсон

42

Це може бути не найшвидшим чи найефективнішим, але як готове рішення, чому б не використовувати об'єкти "Пагінатор та сторінку" Джанго та задокументовані тут:

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Тоді потрібно для попереднього вибору
Кен Колтон

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 - це щось інше, ніж курсор 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 може використовуватися для отримання всіх ідентифікаторів у базах даних, а потім отримання кожного об'єкта окремо. З часом великі об’єкти будуть створюватися в пам'яті і не буде зібрано сміття, поки цикл не буде випущений. Вищевказаний код здійснює ручне збирання сміття після використання кожного 100-го предмета.


Чи може streamingHttpResponse бути рішенням? stackoverflow.com/questions/15359768 / ...
Ratata

2
Однак це призведе до рівних звернень до бази даних, як і кількість циклів, я боюся.
raratiru

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 з ітерацією (chunk_size = 1000)
Кевін Паркер

3

Ось рішення, що включає len and 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()

Довідка:

  1. Отримання мільйона рядків з MySQL
  2. Як виконує потокове набір результатів MySQL проти отримання всього JDBC ResultSet одразу

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