Pourquoi l'itération dans un grand Django QuerySet consomme-t-elle des quantités massives de mémoire?

111

Le tableau en question contient environ dix millions de lignes.

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

Cela entraîne une augmentation constante de l'utilisation de la mémoire jusqu'à 4 Go environ, auquel cas les lignes s'impriment rapidement. Le long délai avant l'impression de la première ligne m'a surpris - je m'attendais à ce qu'il s'imprime presque instantanément.

J'ai aussi essayé Event.objects.iterator()qui se comportait de la même manière.

Je ne comprends pas ce que Django charge en mémoire ni pourquoi il le fait. Je m'attendais à ce que Django itère à travers les résultats au niveau de la base de données, ce qui signifierait que les résultats seraient imprimés à peu près à un rythme constant (plutôt que tous en même temps après une longue attente).

Qu'est-ce que j'ai mal compris?

(Je ne sais pas si c'est pertinent, mais j'utilise PostgreSQL.)

davidchambers
la source
6
Sur des machines plus petites, cela peut même causer immédiatement "Killed" au shell ou au serveur de django
Stefano

Réponses:

113

Nate C était proche, mais pas tout à fait.

À partir de la documentation :

Vous pouvez évaluer un QuerySet des manières suivantes:

  • Itération. Un QuerySet est itérable et il exécute sa requête de base de données la première fois que vous l'itérez. Par exemple, cela imprimera le titre de toutes les entrées de la base de données:

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

Ainsi, vos dix millions de lignes sont récupérées, toutes à la fois, lorsque vous entrez pour la première fois dans cette boucle et obtenez la forme itérative de l'ensemble de requêtes. L'attente que vous rencontrez est que Django charge les lignes de la base de données et crée des objets pour chacune d'elles, avant de renvoyer quelque chose que vous pouvez réellement parcourir. Ensuite, vous avez tout en mémoire et les résultats se répandent.

D'après ma lecture de la documentation, iterator()ne fait rien de plus que de contourner les mécanismes de mise en cache internes de QuerySet. Je pense qu'il pourrait être judicieux de faire une chose une par une, mais cela exigerait à l'inverse dix millions de visites individuelles sur votre base de données. Peut-être pas tout à fait souhaitable.

Itérer efficacement sur de grands ensembles de données est quelque chose que nous n'avons toujours pas tout à fait raison, mais il y a quelques extraits que vous pourriez trouver utiles à vos fins:

éternicode
la source
1
Merci pour cette excellente réponse, @eternicode. En fin de compte, nous sommes passés au SQL brut pour l'itération au niveau de la base de données souhaitée.
davidchambers
2
@eternicode Bonne réponse, viens de frapper ce problème. Y a-t-il une mise à jour connexe dans Django depuis?
Zólyomi István
2
La documentation depuis Django 1.11 indique que iterator () utilise des curseurs côté serveur.
Jeff C Johnson le
42

Ce n'est peut-être pas le plus rapide ou le plus efficace, mais en tant que solution toute faite, pourquoi ne pas utiliser les objets Paginator et Page de django core documentés ici:

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

Quelque chose comme ça:

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
la source
3
Petites améliorations désormais possibles depuis la publication. Paginatora maintenant la page_rangepropriété d'éviter le passe-partout. Si vous recherchez une surcharge mémoire minimale, vous pouvez utiliser object_list.iterator()ce qui ne remplira pas le cache de l'ensemble de requêtes . prefetch_related_objectsest alors requis pour la prélecture
Ken Colton
28

Le comportement par défaut de Django est de mettre en cache tout le résultat de QuerySet lorsqu'il évalue la requête. Vous pouvez utiliser la méthode iterator de QuerySet pour éviter cette mise en cache:

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

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

La méthode iterator () évalue le jeu de requêtes, puis lit les résultats directement sans faire de mise en cache au niveau QuerySet. Cette méthode se traduit par de meilleures performances et une réduction significative de la mémoire lors de l'itération sur un grand nombre d'objets auxquels vous ne devez accéder qu'une seule fois. Notez que la mise en cache est toujours effectuée au niveau de la base de données.

Utiliser iterator () réduit l'utilisation de la mémoire pour moi, mais il est toujours plus élevé que prévu. L'utilisation de l'approche paginatrice suggérée par mpaf utilise beaucoup moins de mémoire, mais est 2 à 3 fois plus lente pour mon cas de test.

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
la source
8

Ceci provient de la documentation: http://docs.djangoproject.com/en/dev/ref/models/querysets/

Aucune activité de base de données ne se produit tant que vous n’avez pas fait quelque chose pour évaluer le jeu de requêtes.

Ainsi, lorsque le print eventest exécuté, la requête se déclenche (qui est une analyse complète de la table selon votre commande) et charge les résultats. Vous demandez tous les objets et il n'y a aucun moyen d'obtenir le premier objet sans les obtenir tous.

Mais si vous faites quelque chose comme:

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

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

Ensuite, il ajoutera des décalages et des limites au sql en interne.

nate c
la source
7

Pour de grandes quantités d'enregistrements, un curseur de base de données fonctionne encore mieux. Vous avez besoin de SQL brut dans Django, le curseur Django est quelque chose de différent d'un cursur SQL.

La méthode LIMIT - OFFSET suggérée par Nate C pourrait convenir à votre situation. Pour de grandes quantités de données, il est plus lent qu'un curseur car il doit exécuter la même requête maintes et maintes fois et doit sauter de plus en plus de résultats.

Frank Heikens
la source
4
Frank, c'est définitivement un bon point mais ce serait bien de voir quelques détails de code pour avancer vers une solution ;-) (enfin cette question est assez ancienne maintenant ...)
Stefano
7

Django n'a pas de bonne solution pour récupérer des éléments volumineux de la base de données.

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 peut être utilisé pour récupérer tous les identifiants dans les bases de données, puis pour récupérer chaque objet séparément. Au fil du temps, des objets volumineux seront créés en mémoire et ne seront pas récupérés tant que la boucle n'est pas terminée. Le code ci-dessus effectue un nettoyage manuel de la mémoire après chaque 100e élément consommé.

Kracekumar
la source
StreamingHttpResponse peut-il être une solution? stackoverflow.com/questions/15359768/…
ratata
2
Cependant, cela entraînera des résultats égaux dans la base de données en fonction du nombre de boucles, j'ai peur.
raratiru
5

Parce que de cette façon, les objets d'un ensemble de requêtes sont chargés en mémoire en une seule fois. Vous devez découper votre ensemble de requêtes en petits morceaux digestibles. Le modèle pour faire cela s'appelle l'alimentation à la cuillère. Voici une brève mise en œuvre.

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

Pour l'utiliser, vous écrivez une fonction qui effectue des opérations sur votre objet:

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

et d'exécuter cette fonction sur votre jeu de requêtes:

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

Cela peut être encore amélioré avec le multitraitement pour s'exécuter funcsur plusieurs objets en parallèle.

fmalina
la source
1
On dirait que cela va être intégré à 1.12 avec iterate (chunk_size = 1000)
Kevin Parker
3

Voici une solution incluant len ​​et 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)

Usage:

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

J'utilise généralement une requête brute MySQL brute au lieu de Django ORM pour ce type de tâche.

MySQL prend en charge le mode streaming afin que nous puissions parcourir tous les enregistrements en toute sécurité et rapidement sans erreur de mémoire insuffisante.

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

Réf:

  1. Récupération de millions de lignes depuis MySQL
  2. Comment le streaming d'ensemble de résultats MySQL fonctionne-t-il par rapport à l'extraction de l'ensemble du ResultSet JDBC en une seule fois?
Tho
la source
Vous pouvez toujours utiliser Django ORM pour générer une requête. Utilisez simplement le résultat queryset.querypour votre exécution.
Pol