Dlaczego iteracja przez duży zestaw Django QuerySet zużywa ogromne ilości pamięci?

111

Omawiana tabela zawiera około dziesięciu milionów wierszy.

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

Powoduje to, że użycie pamięci stale rośnie do około 4 GB, w którym to momencie wiersze są drukowane szybko. Zaskoczyło mnie długie opóźnienie przed wydrukowaniem pierwszego wiersza - spodziewałem się, że wydrukuje się niemal natychmiast.

Próbowałem też, Event.objects.iterator()który zachowywał się w ten sam sposób.

Nie rozumiem, co Django ładuje do pamięci ani dlaczego to robi. Spodziewałem się, że Django przejrzy wyniki na poziomie bazy danych, co oznaczałoby, że wyniki będą drukowane z mniej więcej stałą szybkością (a nie wszystkie naraz po długim oczekiwaniu).

Co ja źle zrozumiałem?

(Nie wiem, czy jest to istotne, ale używam PostgreSQL).

davidchambers
źródło
6
Na mniejszych maszynach może to nawet spowodować natychmiastowe „zabicie” powłoki lub serwera django
Stefano

Odpowiedzi:

113

Nate C był blisko, ale niezupełnie.

Z dokumentów :

Możesz ocenić QuerySet w następujący sposób:

  • Iteracja. QuerySet jest iterowalny i wykonuje zapytanie do bazy danych przy pierwszej iteracji. Na przykład spowoduje to wydrukowanie nagłówka wszystkich wpisów w bazie danych:

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

Tak więc dziesięć milionów wierszy jest pobieranych naraz, kiedy po raz pierwszy wchodzisz do tej pętli i uzyskujesz iteracyjną postać zestawu zapytań. Poczekanie, którego doświadczasz, to ładowanie przez Django wierszy bazy danych i tworzenie obiektów dla każdego z nich, przed zwróceniem czegoś, co można faktycznie iterować. Wtedy masz wszystko w pamięci, a wyniki się rozlewają.

Z mojego czytania dokumentacji iterator()wynika , że nie robi nic poza obejściem wewnętrznych mechanizmów buforowania QuerySet. Myślę, że może to mieć sens, aby zrobić jedną rzecz po drugiej, ale to z kolei wymagałoby dziesięciu milionów pojedynczych trafień w twojej bazie danych. Może nie wszystko to pożądane.

Wydajne iterowanie po dużych zbiorach danych to coś, czego wciąż nie udało nam się osiągnąć, ale jest tam kilka fragmentów, które mogą się przydać:

eternicode
źródło
1
Dzięki za świetną odpowiedź, @eternicode. W końcu zeszliśmy do surowego SQL dla pożądanej iteracji na poziomie bazy danych.
davidchambers
2
@eternicode Dobra odpowiedź, po prostu trafiłam w ten problem. Czy od tego czasu w Django jest jakaś powiązana aktualizacja?
Zólyomi István
2
Dokumentacja od Django 1.11 mówi, że iterator () używa kursorów po stronie serwera.
Jeff C Johnson,
42

Może nie być szybszym lub najbardziej wydajnym, ale jako gotowe rozwiązanie, dlaczego nie skorzystać z obiektów Paginator i Page w django core, udokumentowanych tutaj:

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

Coś takiego:

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
mpaf
źródło
3
Małe ulepszenia są teraz możliwe od postu. Paginatorma teraz page_rangewłaściwość pozwalającą uniknąć schematu. Jeśli szukasz minimalnego narzutu pamięci, możesz użyć, object_list.iterator()który nie wypełni pamięci podręcznej zestawu zapytań . prefetch_related_objectsjest wtedy wymagany do pobrania wstępnego
Ken Colton,
28

Domyślnym zachowaniem Django jest buforowanie całego wyniku QuerySet podczas oceny zapytania. Aby uniknąć tego buforowania, możesz użyć metody iteratora QuerySet:

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

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

Metoda iterator () ocenia zestaw zapytań, a następnie bezpośrednio odczytuje wyniki bez wykonywania buforowania na poziomie QuerySet. Ta metoda zapewnia lepszą wydajność i znaczne zmniejszenie ilości pamięci podczas iteracji na dużej liczbie obiektów, do których wystarczy uzyskać dostęp tylko raz. Zwróć uwagę, że buforowanie jest nadal wykonywane na poziomie bazy danych.

Korzystanie z iteratora () zmniejsza zużycie pamięci, ale nadal jest wyższe niż się spodziewałem. Korzystanie z metody paginatora sugerowanej przez mpaf zużywa znacznie mniej pamięci, ale w przypadku mojego przypadku testowego jest 2-3 razy wolniejsze.

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
Luke Moore
źródło
8

To pochodzi z dokumentacji: http://docs.djangoproject.com/en/dev/ref/models/querysets/

Żadna aktywność w bazie danych nie występuje, dopóki nie wykonasz czegoś w celu oceny zestawu zapytań.

Po print eventuruchomieniu zapytania zostaje uruchomione zapytanie (co oznacza pełne skanowanie tabeli zgodnie z poleceniem) i wczytuje wyniki. Prosisz o wszystkie przedmioty i nie ma sposobu, aby zdobyć pierwszy przedmiot bez zdobycia ich wszystkich.

Ale jeśli zrobisz coś takiego:

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

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

Następnie wewnętrznie doda przesunięcia i ograniczenia do sql.

nate c
źródło
7

W przypadku dużej liczby rekordów kursor bazy danych działa jeszcze lepiej. Potrzebujesz surowego SQL w Django, kursor Django jest czymś innym niż kursor SQL.

Metoda LIMIT - OFFSET zasugerowana przez Nate'a C. może być wystarczająco dobra w twojej sytuacji. W przypadku dużych ilości danych jest wolniejszy niż kursor, ponieważ musi w kółko uruchamiać to samo zapytanie i przeskakiwać coraz więcej wyników.

Frank Heikens
źródło
4
Frank, to zdecydowanie dobra uwaga, ale byłoby miło zobaczyć kilka szczegółów kodu, aby popchnąć w kierunku rozwiązania ;-) (cóż, to pytanie jest teraz dość stare ...)
Stefano
7

Django nie ma dobrego rozwiązania do pobierania dużych elementów z bazy danych.

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")

Value_list może służyć do pobierania wszystkich identyfikatorów z baz danych, a następnie pobierania każdego obiektu oddzielnie. Z biegiem czasu w pamięci będą tworzone duże obiekty, które nie będą zbierane do czasu zakończenia pętli for. Powyższy kod wykonuje ręczne czyszczenie pamięci po zużyciu każdej setnej pozycji.

Kracekumar
źródło
Czy streamingHttpResponse może być rozwiązaniem? stackoverflow.com/questions/15359768/…
ratata
2
Obawiam się jednak, że spowoduje to równe trafienia w bazie danych, co liczba pętli.
raratiru
5

Ponieważ w ten sposób obiekty dla całego zestawu zapytań są ładowane do pamięci jednocześnie. Musisz podzielić swój zestaw zapytań na mniejsze, strawne kawałki. Sposób na to nazywa się karmieniem łyżką. Oto krótka realizacja.

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

Aby tego użyć, napiszesz funkcję, która wykonuje operacje na twoim obiekcie:

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

a następnie uruchom tę funkcję na swoim zestawie zapytań:

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

Można to dodatkowo ulepszyć dzięki wieloprocesorowemu wykonywaniu funcna wielu obiektach równolegle.

fmalina
źródło
1
Wygląda na to, że zostanie to wbudowane w 1.12 za pomocą iteracji (chunk_size = 1000)
Kevin Parker
3

Oto rozwiązanie zawierające len i 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)

Stosowanie:

events = batch(Event.objects.all())
len(events) == events.count()
for event in events:
    # Do something with the Event
daniusz
źródło
0

Zwykle używam surowego zapytania MySQL zamiast Django ORM do tego rodzaju zadań.

MySQL obsługuje tryb przesyłania strumieniowego, dzięki czemu możemy bezpiecznie i szybko przechodzić przez wszystkie rekordy bez błędu braku pamięci.

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()

Odniesienie:

  1. Pobieranie milionów wierszy z MySQL
  2. Jak działa przesyłanie strumieniowe zestawu wyników MySQL w porównaniu z pobieraniem całego zestawu wyników JDBC na raz
Chociaż
źródło
Nadal możesz używać Django ORM do generowania zapytań. Po prostu użyj wyników queryset.querydo wykonania.
Pol