Por que a iteração por meio de um grande Django QuerySet consumindo grandes quantidades de memória?

111

A tabela em questão contém cerca de dez milhões de linhas.

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

Isso faz com que o uso da memória aumente continuamente para 4 GB ou mais, momento em que as linhas são impressas rapidamente. O longo atraso antes da impressão da primeira linha me surpreendeu - eu esperava que fosse imprimir quase instantaneamente.

Eu também tentei Event.objects.iterator()qual se comportou da mesma maneira.

Não entendo o que o Django está carregando na memória ou por que está fazendo isso. Eu esperava que o Django iterasse os resultados no nível do banco de dados, o que significaria que os resultados seriam impressos em uma taxa aproximadamente constante (ao invés de todos de uma vez após uma longa espera).

O que eu entendi mal?

(Não sei se é relevante, mas estou usando PostgreSQL.)

davidchambers
fonte
6
Em máquinas menores, isso pode até mesmo causar "Mortos" no shell ou servidor django
Stefano

Respostas:

113

Nate C estava perto, mas não exatamente.

Dos documentos :

Você pode avaliar um QuerySet das seguintes maneiras:

  • Iteração. Um QuerySet é iterável e executa sua consulta de banco de dados na primeira vez que você itera sobre ele. Por exemplo, isso imprimirá o título de todas as entradas no banco de dados:

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

Portanto, seus dez milhões de linhas são recuperadas, todas de uma vez, quando você entra naquele loop pela primeira vez e obtém a forma iterativa do queryset. A espera que você experimenta é o Django carregando os registros do banco de dados e criando objetos para cada um, antes de retornar algo que você possa realmente iterar. Aí você tem tudo na memória e os resultados surgem.

Pela minha leitura dos documentos, iterator()nada mais faz do que contornar os mecanismos de cache interno do QuerySet. Acho que pode fazer sentido fazer um por um, mas isso exigiria, ao contrário, dez milhões de acessos individuais em seu banco de dados. Talvez não seja tão desejável.

A iteração em grandes conjuntos de dados com eficiência é algo que ainda não entendemos muito bem, mas existem alguns trechos que podem ser úteis para seus objetivos:

eternicode
fonte
1
Obrigado pela ótima resposta, @eternicode. No final, caímos no SQL bruto para a iteração no nível do banco de dados desejada.
davidchambers
2
@eternicode Boa resposta, basta abordar este problema. Existe alguma atualização relacionada no Django desde então?
Zólyomi István
2
A documentação desde Django 1.11 diz que iterator () usa cursores do lado do servidor.
Jeff C Johnson
42

Pode não ser o mais rápido ou eficiente, mas como uma solução pronta, por que não usar os objetos Paginator e Page do core django documentados aqui:

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

Algo assim:

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
fonte
3
Pequenas melhorias agora são possíveis desde a postagem. Paginatoragora tem uma page_rangepropriedade para evitar boilerplate. Se estiver em busca de overhead mínimo de memória, você pode usar o object_list.iterator()que não irá preencher o cache do queryset . prefetch_related_objectsé então necessário para pré
Ken Colton
28

O comportamento padrão do Django é armazenar em cache todo o resultado do QuerySet quando ele avalia a consulta. Você pode usar o método iterador do QuerySet para evitar este armazenamento em cache:

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

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

O método iterator () avalia o queryset e então lê os resultados diretamente, sem fazer o cache no nível QuerySet. Este método resulta em melhor desempenho e uma redução significativa na memória ao iterar sobre um grande número de objetos que você só precisa acessar uma vez. Observe que o armazenamento em cache ainda é feito no nível do banco de dados.

Usar iterator () reduz o uso de memória para mim, mas ainda é maior do que eu esperava. Usar a abordagem do paginador sugerida pelo mpaf usa muito menos memória, mas é 2-3x mais lento para o meu caso de teste.

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

Isso é dos documentos: http://docs.djangoproject.com/en/dev/ref/models/querysets/

Nenhuma atividade de banco de dados realmente ocorre até que você faça algo para avaliar o queryset.

Portanto, quando o print eventé executado, a consulta é acionada (que é uma varredura completa da tabela de acordo com seu comando) e carrega os resultados. Você está pedindo todos os objetos e não há como obter o primeiro objeto sem obter todos eles.

Mas se você fizer algo como:

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

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

Em seguida, ele adicionará offsets e limites ao sql internamente.

nate c
fonte
7

Para grandes quantidades de registros, um cursor de banco de dados desempenho ainda melhor. Você precisa de SQL puro no Django, o cursor do Django é algo diferente de um cursor SQL.

O método LIMIT-OFFSET sugerido por Nate C pode ser bom o suficiente para sua situação. Para grandes quantidades de dados, é mais lento do que um cursor porque tem que executar a mesma consulta indefinidamente e saltar mais e mais resultados.

Frank Heikens
fonte
4
Frank, esse é definitivamente um bom ponto, mas seria bom ver alguns detalhes do código para avançar em direção a uma solução ;-) (bem, essa questão é bem antiga agora ...)
Stefano
7

Django não tem uma boa solução para buscar itens grandes do banco de dados.

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 pode ser usado para buscar todos os ids nos bancos de dados e então buscar cada objeto separadamente. Com o tempo, objetos grandes serão criados na memória e não serão coletados como lixo até que o loop seja encerrado. O código acima faz a coleta de lixo manual após cada centésimo item ser consumido.

Kracekumar
fonte
O streamingHttpResponse pode ser uma solução? stackoverflow.com/questions/15359768/…
ratata
2
No entanto, isso resultará em acertos iguais no banco de dados como o número de loops, estou com medo.
raratiru
5

Porque dessa forma os objetos de um queryset inteiro são carregados na memória de uma só vez. Você precisa dividir seu queryset em pedaços menores de digestão. O padrão para fazer isso é chamado de alimentação com colher. Aqui está uma breve implementação.

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 isso, você escreve uma função que executa operações em seu objeto:

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

e execute essa função no seu queryset:

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

Isso pode ser melhorado ainda mais com multiprocessamento para executar funcem vários objetos em paralelo.

Fmalina
fonte
1
Parece que isso será integrado ao 1.12 com iterate (chunk_size = 1000)
Kevin Parker
3

Aqui, uma solução incluindo len e 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
fonte
0

Eu geralmente uso a consulta bruta do MySQL em vez do Django ORM para esse tipo de tarefa.

O MySQL oferece suporte ao modo de streaming para que possamos percorrer todos os registros com segurança e rapidez, sem erros de falta de memória.

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. Recuperando milhões de linhas do MySQL
  2. Como o streaming do conjunto de resultados do MySQL funciona em comparação com a busca de todo o ResultSet JDBC de uma vez
Tho
fonte
Você ainda pode usar Django ORM para gerar consulta. Apenas use o resultante queryset.querypara em sua execução.
Pol