¿Por qué la iteración a través de un gran Django QuerySet consume cantidades masivas de memoria?

111

La tabla en cuestión contiene aproximadamente diez millones de filas.

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

Esto hace que el uso de la memoria aumente constantemente a 4 GB aproximadamente, momento en el que las filas se imprimen rápidamente. La larga demora antes de que se imprimiera la primera fila me sorprendió: esperaba que se imprimiera casi instantáneamente.

También probé Event.objects.iterator()que se comportó de la misma manera.

No entiendo qué está cargando Django en la memoria o por qué está haciendo esto. Esperaba que Django repitiera los resultados a nivel de la base de datos, lo que significaría que los resultados se imprimirían aproximadamente a una velocidad constante (en lugar de todos a la vez después de una larga espera).

¿Qué he entendido mal?

(No sé si es relevante, pero estoy usando PostgreSQL).

Davidchambers
fuente
6
En máquinas más pequeñas, esto puede causar incluso "Muerte" de inmediato al servidor o shell de django
Stefano

Respuestas:

113

Nate C estaba cerca, pero no del todo.

De los documentos :

Puede evaluar un QuerySet de las siguientes formas:

  • Iteración. Un QuerySet es iterable y ejecuta su consulta de base de datos la primera vez que itera sobre él. Por ejemplo, esto imprimirá el título de todas las entradas en la base de datos:

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

Entonces, sus diez millones de filas se recuperan, todas a la vez, cuando ingresa por primera vez a ese bucle y obtiene la forma iterativa del conjunto de consultas. La espera que experimenta es que Django carga las filas de la base de datos y crea objetos para cada una, antes de devolver algo sobre lo que realmente puede iterar. Entonces tienes todo en la memoria y los resultados se derraman.

De mi lectura de los documentos, iterator()no hace nada más que eludir los mecanismos internos de almacenamiento en caché de QuerySet. Creo que podría tener sentido que haga una cosa de una en una, pero eso, a la inversa, requeriría diez millones de visitas individuales a su base de datos. Quizás no sea tan deseable.

Iterar sobre grandes conjuntos de datos de manera eficiente es algo que aún no hemos hecho del todo bien, pero hay algunos fragmentos que pueden resultarle útiles para sus propósitos:

eternicode
fuente
1
Gracias por la gran respuesta, @eternicode. Al final, bajamos a SQL sin formato para la iteración deseada a nivel de base de datos.
davidchambers
2
@eternicode Buena respuesta, solo golpee este problema. ¿Hay alguna actualización relacionada en Django desde entonces?
Zólyomi István
2
Los documentos desde Django 1.11 dicen que iterator () usa cursores del lado del servidor.
Jeff C Johnson
42

Puede que no sea el más rápido o el más eficiente, pero como una solución lista para usar, ¿por qué no usar los objetos Paginator y Page de django core documentados aquí?

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

Algo como esto:

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
fuente
3
Pequeñas mejoras ahora posibles desde la publicación. PaginatorAhora tiene una page_rangepropiedad para evitar repetición. Si busca una sobrecarga de memoria mínima, puede usar la object_list.iterator()que no llenará la caché del conjunto de consultas . prefetch_related_objectsluego se requiere para la captación previa
Ken Colton
28

El comportamiento predeterminado de Django es almacenar en caché todo el resultado del QuerySet cuando evalúa la consulta. Puede utilizar el método iterador de QuerySet para evitar este almacenamiento en caché:

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

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

El método iterator () evalúa el conjunto de consultas y luego lee los resultados directamente sin realizar el almacenamiento en caché en el nivel del conjunto de consultas. Este método da como resultado un mejor rendimiento y una reducción significativa de la memoria cuando se itera sobre una gran cantidad de objetos a los que solo necesita acceder una vez. Tenga en cuenta que el almacenamiento en caché todavía se realiza a nivel de la base de datos.

El uso de iterator () reduce el uso de memoria para mí, pero aún es más alto de lo que esperaba. El uso del enfoque de paginador sugerido por mpaf usa mucha menos memoria, pero es 2-3 veces más lento para mi caso de prueba.

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
fuente
8

Esto es de los documentos: http://docs.djangoproject.com/en/dev/ref/models/querysets/

En realidad, no se produce ninguna actividad en la base de datos hasta que hace algo para evaluar el conjunto de consultas.

Entonces cuando el print event se ejecuta, la consulta se activa (que es un escaneo completo de la tabla de acuerdo con su comando) y carga los resultados. Estás pidiendo todos los objetos y no hay forma de obtener el primer objeto sin obtener todos.

Pero si haces algo como:

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

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

Luego agregará compensaciones y límites al sql internamente.

nate c
fuente
7

Para grandes cantidades de registros, un cursor de base de datos funciona aún mejor. Necesita SQL sin formato en Django, el cursor de Django es algo diferente a un cursur de SQL.

El método LIMIT - OFFSET sugerido por Nate C podría ser lo suficientemente bueno para su situación. Para grandes cantidades de datos, es más lento que un cursor porque tiene que ejecutar la misma consulta una y otra vez y tiene que saltar sobre más y más resultados.

Frank Heikens
fuente
4
Frank, ese es definitivamente un buen punto, pero sería bueno ver algunos detalles del código para avanzar hacia una solución ;-) (bueno, esta pregunta es bastante antigua ahora ...)
Stefano
7

Django no tiene una buena solución para recuperar elementos grandes de la base de datos.

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 se puede usar para buscar todos los identificadores en las bases de datos y luego buscar cada objeto por separado. Con el tiempo, los objetos grandes se crearán en la memoria y no se recolectarán basura hasta que se salga del bucle for. El código anterior realiza la recolección manual de basura después de que se consume cada centésimo elemento.

Kracekumar
fuente
¿StreamingHttpResponse puede ser una solución? stackoverflow.com/questions/15359768/…
ratata
2
Sin embargo, me temo que esto dará como resultado resultados iguales en la base de datos que el número de bucles.
raratiru
5

Porque de esa manera los objetos de un conjunto de consultas completo se cargan en la memoria de una vez. Necesita dividir su conjunto de consultas en bits digeribles más pequeños. El patrón para hacer esto se llama alimentación con cuchara. Aquí hay una breve implementación.

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

Para usar esto, escribe una función que realiza operaciones en tu objeto:

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

y luego ejecutar esa función en su conjunto de consultas:

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

Esto se puede mejorar aún más con el multiprocesamiento para ejecutar funcen múltiples objetos en paralelo.

fmalina
fuente
1
Parece que esto se integrará en 1.12 con iterate (chunk_size = 1000)
Kevin Parker
3

Aquí una solución que incluye len y 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)

Uso:

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

Por lo general, uso una consulta sin procesar de MySQL en lugar de Django ORM para este tipo de tarea.

MySQL admite el modo de transmisión por secuencias para que podamos recorrer todos los registros de forma segura y rápida sin errores de memoria.

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

Árbitro:

  1. Recuperando millones de filas de MySQL
  2. ¿Cómo funciona la transmisión del conjunto de resultados de MySQL frente a la obtención de todo el conjunto de resultados de JDBC a la vez?
aunque
fuente
Todavía puede usar Django ORM para generar consultas. Solo use el resultado queryset.querypara su ejecución.
Pol