Um guia para teste e otimização de desempenho com Python e Django
Publicados: 2022-03-11Donald Knuth disse que “a otimização prematura é a raiz de todo mal”. Mas chega um momento, geralmente em projetos maduros com altas cargas, em que é inevitável a necessidade de otimização. Neste artigo, gostaria de falar sobre cinco métodos comuns para otimizar o código do seu projeto web. Vou usar o Django, mas os princípios devem ser semelhantes para outros frameworks e linguagens. Neste artigo, usarei esses métodos para reduzir o tempo de resposta de uma consulta de 77 para 3,7 segundos.
O código de exemplo é adaptado de um projeto real com o qual trabalhei e demonstra técnicas de otimização de desempenho. Caso você queira acompanhar e ver os resultados você mesmo, você pode pegar o código em seu estado inicial no GitHub e fazer as alterações correspondentes enquanto segue o artigo. Estarei usando o Python 2, pois alguns pacotes de terceiros ainda não estão disponíveis para o Python 3.
Apresentando nosso aplicativo
Nosso projeto web simplesmente rastreia as ofertas de imóveis por país. Portanto, existem apenas dois modelos:
# houses/models.py from utils.hash import Hasher class HashableModel(models.Model): """Provide a hash property for models.""" class Meta: abstract = True @property def hash(self): return Hasher.from_model(self) class Country(HashableModel): """Represent a country in which the house is positioned.""" name = models.CharField(max_length=30) def __unicode__(self): return self.name class House(HashableModel): """Represent a house with its characteristics.""" # Relations country = models.ForeignKey(Country, related_name='houses') # Attributes address = models.CharField(max_length=255) sq_meters = models.PositiveIntegerField() kitchen_sq_meters = models.PositiveSmallIntegerField() nr_bedrooms = models.PositiveSmallIntegerField() nr_bathrooms = models.PositiveSmallIntegerField() nr_floors = models.PositiveSmallIntegerField(default=1) year_built = models.PositiveIntegerField(null=True, blank=True) house_color_outside = models.CharField(max_length=20) distance_to_nearest_kindergarten = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_school = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_hospital = models.PositiveIntegerField(null=True, blank=True) has_cellar = models.BooleanField(default=False) has_pool = models.BooleanField(default=False) has_garage = models.BooleanField(default=False) price = models.PositiveIntegerField() def __unicode__(self): return '{} {}'.format(self.country, self.address)
O abstrato HashableModel
fornece a qualquer modelo que herde dele uma propriedade de hash
que contém a chave primária da instância e o tipo de conteúdo do modelo. Isso oculta dados confidenciais, como IDs de instância, substituindo-os por um hash. Também pode ser útil nos casos em que seu projeto tem vários modelos e você precisa de um local centralizado que decida o que fazer com diferentes instâncias de modelo de diferentes classes. Observe que, para nosso pequeno projeto, o hash não é realmente necessário, pois podemos lidar sem ele, mas ajudará a demonstrar algumas técnicas de otimização, então vou mantê-lo lá.
Aqui está a classe Hasher
:
# utils/hash.py import basehash class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
Como gostaríamos de servir esses dados por meio de um endpoint de API, instalamos o Django REST Framework e definimos os seguintes serializadores e visualizações:
# houses/serializers.py class HouseSerializer(serializers.ModelSerializer): """Serialize a `houses.House` instance.""" id = serializers.ReadOnlyField(source="hash") country = serializers.ReadOnlyField(source="country.hash") class Meta: model = House fields = ( 'id', 'address', 'country', 'sq_meters', 'price' )
# houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() serializer = self.serializer_class(queryset, many=True) return Response(serializer.data)
Agora, preenchemos nosso banco de dados com alguns dados (um total de 100.000 instâncias domésticas geradas usando factory-boy
: 50.000 para um país, 40.000 para outro e 10.000 para um terceiro país) e estamos prontos para testar o desempenho do nosso aplicativo.
Otimização de desempenho tem tudo a ver com medição
Existem várias coisas que podemos medir em um projeto:
- Tempo de execução
- Número de linhas de código
- Número de chamadas de função
- Memória alocada
- etc.
Mas nem todos eles são relevantes para medir o desempenho do nosso projeto. De um modo geral, existem duas métricas principais que são as mais importantes: quanto tempo algo é executado e quanta memória ele precisa.
Em um projeto web, o tempo de resposta (o tempo necessário para o servidor receber uma requisição gerada pela ação de algum usuário, processá-la e devolver o resultado) costuma ser a métrica mais importante, pois não deixa os usuários entediados enquanto esperam para obter uma resposta e alternar para outra guia em seu navegador.
Na programação, analisar o desempenho do projeto é chamado de perfil. Para traçar o perfil do desempenho do nosso endpoint de API, usaremos o pacote Silk. Após instalá-lo e fazer nossa chamada /api/v1/houses/?country=5T22RI
(o hash que corresponde ao país com 50.000 entradas de casa), obtemos isto:
200 GANHAR
/api/v1/casas/
77292ms no total
15854ms em consultas
50004 consultas
O tempo total de resposta é de 77 segundos, dos quais 16 segundos são gastos em consultas no banco de dados, onde foram feitas um total de 50.000 consultas. Com números tão grandes, há muito espaço para melhorias, então vamos começar.
1. Otimizando consultas de banco de dados
Uma das dicas mais frequentes sobre otimização de desempenho é garantir que suas consultas de banco de dados sejam otimizadas. Este caso não é exceção. Além disso, podemos fazer várias coisas sobre nossas consultas para otimizar o tempo de resposta.
1.1 Forneça todos os dados de uma vez
Observando mais de perto quais são essas 50.000 consultas, você pode ver que todas são consultas redundantes para a tabela houses_country
:
200 GANHAR
/api/v1/casas/
77292ms no total
15854ms em consultas
50004 consultas
No | Tabelas | Associações | Tempo de execução (ms) |
---|---|---|---|
+0:01:15.874374 | "casas_país" | 0 | 0,176 |
+0:01:15.873304 | "casas_país" | 0 | 0,218 |
+0:01:15.872225 | "casas_país" | 0 | 0,218 |
+0:01:15.871155 | "casas_país" | 0 | 0,198 |
+0:01:15.870099 | "casas_país" | 0 | 0,173 |
+0:01:15.869050 | "casas_país" | 0 | 0,197 |
+0:01:15.867877 | "casas_país" | 0 | 0,221 |
+0:01:15.866807 | "casas_país" | 0 | 0,203 |
+0:01:15.865646 | "casas_país" | 0 | 0,211 |
+0:01:15.864562 | "casas_país" | 0 | 0,209 |
+0:01:15.863511 | "casas_país" | 0 | 0,181 |
+0:01:15.862435 | "casas_país" | 0 | 0,228 |
+0:01:15.861413 | "casas_país" | 0 | 0,174 |
A origem desse problema é o fato de que, no Django, os conjuntos de consultas são preguiçosos . Isso significa que um conjunto de consultas não é avaliado e não atinge o banco de dados até que você realmente precise obter os dados. Ao mesmo tempo, ele obtém apenas os dados que você solicitou, fazendo solicitações subsequentes se forem necessários dados adicionais.
Foi exatamente o que aconteceu no nosso caso. Ao obter a consulta definida por meio de House.objects.filter(country=country)
, o Django obtém uma lista de todas as casas no país fornecido. No entanto, ao serializar uma instância de house
, HouseSerializer
requer a instância de country
da casa para calcular o campo de country
do serializador. Como os dados do país não estão presentes no conjunto de consultas, o django faz uma solicitação adicional para obter esses dados. E faz isso para todas as casas no conjunto de consultas - são 50.000 vezes no total.
A solução é muito simples, no entanto. Para extrair todos os dados necessários para serialização, você pode usar o método select_related()
no conjunto de consultas. Assim, nosso get_queryset
ficará assim:
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return queryset
Vamos ver como isso afetou o desempenho:
200 GANHAR
/api/v1/casas/
35979ms no total
102ms em consultas
4 consultas
O tempo de resposta geral caiu para 36 segundos e o tempo gasto no banco de dados é de ~100ms com apenas 4 consultas! Isso é uma ótima notícia, mas podemos fazer mais.
1.2 Forneça apenas os dados relevantes
Por padrão, o Django extrai todos os campos do banco de dados. No entanto, quando você tem tabelas enormes com muitas colunas e linhas, faz sentido dizer ao Django quais campos específicos extrair, para que ele não gaste tempo para obter informações que não serão usadas. No nosso caso, precisamos apenas de cinco campos para serialização, mas temos 17 campos. Faz sentido especificar exatamente quais campos extrair do banco de dados, para reduzir ainda mais o tempo de resposta.
O Django tem os métodos defer()
e only()
query set para fazer exatamente isso. O primeiro especifica quais campos não devem ser carregados e o segundo especifica quais campos devem ser carregados apenas .
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country)\ .select_related('country')\ .only('id', 'address', 'country', 'sq_meters', 'price') return queryset
Isso reduziu o tempo gasto em consultas pela metade, o que é bom, mas 50ms não é muito. O tempo total também caiu um pouco, mas há mais espaço para cortá-lo.

200 GANHAR
/api/v1/casas/
33111ms no total
52ms em consultas
4 consultas
2. Otimizando seu código
Você não pode otimizar infinitamente as consultas do banco de dados, e nosso último resultado mostrou isso. Mesmo que hipoteticamente diminuamos o tempo gasto em consultas para 0, ainda enfrentaríamos a realidade de esperar meio minuto para obter a resposta. É hora de mudar para outro nível de otimização: lógica de negócios .
2.1 Simplifique seu código
Às vezes, pacotes de terceiros vêm com muita sobrecarga para tarefas simples. Um exemplo é nossa tarefa de retornar instâncias de casa serializadas.
O Django REST Framework é ótimo, com muitos recursos úteis prontos para uso. No entanto, nosso principal objetivo agora é reduzir o tempo de resposta, por isso é um ótimo candidato para otimização, especialmente porque os objetos serializados são bastante simples.
Vamos escrever um serializador personalizado para essa finalidade. Para simplificar, teremos um único método estático que faz o trabalho. Na realidade, você pode querer ter as mesmas assinaturas de classe e método para poder usar serializadores de forma intercambiável:
# houses/serializers.py class HousePlainSerializer(object): """ Serializes a House queryset consisting of dicts with the following keys: 'id', 'address', 'country', 'sq_meters', 'price'. """ @staticmethod def serialize_data(queryset): """ Return a list of hashed objects from the given queryset. """ return [ { 'id': Hasher.from_pk_and_class(entry['id'], House), 'address': entry['address'], 'country': Hasher.from_pk_and_class(entry['country'], Country), 'sq_meters': entry['sq_meters'], 'price': entry['price'] } for entry in queryset ] # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer plain_serializer_class = HousePlainSerializer # <-- added custom serializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() data = self.plain_serializer_class.serialize_data(queryset) # <-- serialize return Response(data)
200 GANHAR
/api/v1/casas/
17312ms no total
38ms em consultas
4 consultas
Isso parece melhor agora. O tempo de resposta foi quase reduzido pela metade devido ao fato de não termos empregado código serializador DRF.
Outro resultado mensurável – o número total de chamadas de função feitas durante o ciclo de solicitação/resposta – caiu de 15.859.427 chamadas (da solicitação feita na seção 1.2 acima) para 9.257.469 chamadas. Isso significa que cerca de 1/3 de todas as chamadas de função foram feitas pelo Django REST Framework.
2.2 Atualizar/substituir pacotes de terceiros
As técnicas de otimização descritas acima são as mais comuns, aquelas que você pode fazer sem uma análise e reflexão completas. No entanto, 17 segundos ainda parecem muito longos; para reduzir esse número, precisaremos mergulhar mais fundo em nosso código e analisar o que acontece nos bastidores. Em outras palavras, precisaremos traçar o perfil do nosso código.
Você pode fazer a criação de perfil por conta própria, usando o criador de perfil Python integrado ou pode usar alguns pacotes de terceiros para isso (que estão usando o criador de perfil Python integrado). Como já usamos silk
, ele pode perfilar o código e gerar um arquivo de perfil binário, que podemos visualizar melhor. Existem vários pacotes de visualização que transformam um perfil binário em algumas visualizações perspicazes. Eu estarei usando o pacote snakeviz
.
Aqui está a visualização do perfil binário da última requisição acima, enganchada no método dispatch da view:
De cima para baixo está a pilha de chamadas, exibindo o nome do arquivo, o nome do método/função com seu número de linha e o tempo cumulativo correspondente gasto nesse método. Agora é mais fácil ver que a maior parte do tempo é dedicada ao cálculo do hash (os retângulos __init__.py
e primes.py
de cor violeta).
Atualmente, este é o principal gargalo de desempenho em nosso código, mas ao mesmo tempo não é realmente nosso código—é um pacote de terceiros.
Em tais situações, há um número limitado de coisas que podemos fazer:
- Verifique se há uma nova versão do pacote (que esperamos ter um desempenho melhor).
- Encontre outro pacote com melhor desempenho nas tarefas que precisamos.
- Escreva nossa própria implementação que superará o desempenho do pacote que usamos atualmente.
Felizmente para mim, existe uma versão mais recente do pacote basehash
que é responsável pelo hash. O código usa v.2.1.0, mas existe uma v.3.0.4. Tais situações, quando você consegue atualizar para uma versão mais recente de um pacote, são mais prováveis quando você está trabalhando em um projeto existente.
Ao verificar as notas de lançamento da v.3, há esta frase específica que parece muito promissora:
Uma grande revisão foi feita com os algoritmos de primalidade. Incluindo (sic) suporte para gmpy2 se estiver disponível (sic) no sistema para um aumento muito maior.
Vamos descobrir isso!
pip install -U basehash gmpy2
200 GANHAR
/api/v1/casas/
7738ms no total
59ms em consultas
4 consultas
Reduzimos o tempo de resposta de 17 para menos de 8 segundos. Ótimo resultado, mas há mais uma coisa que devemos observar.
2.3 Refatore seu próprio código
Até agora, melhoramos nossas consultas, substituímos códigos complexos e genéricos de terceiros por nossas próprias funções muito específicas e atualizamos pacotes de terceiros, mas deixamos nosso código existente intocado. Mas às vezes uma pequena refatoração do código existente pode trazer resultados impressionantes. Mas, para isso, precisamos novamente analisar os resultados da criação de perfil.
Olhando mais de perto, você pode ver que o hash ainda é um problema (não surpreendentemente, é a única coisa que fazemos com nossos dados), embora tenhamos melhorado nessa direção. No entanto, o retângulo esverdeado que diz que __init__.py
consome 2,14 segundos me incomoda, junto com o acinzentado __init__.py:54(hash)
que vem logo após. Isso significa que alguma inicialização leva muito tempo.
Vamos dar uma olhada no código-fonte do pacote basehash
.
# basehash/__init__.py # Initialization of `base36` class initializes the parent, `base` class. class base36(base): def __init__(self, length=HASH_LENGTH, generator=GENERATOR): super(base36, self).__init__(BASE36, length, generator) class base(object): def __init__(self, alphabet, length=HASH_LENGTH, generator=GENERATOR): if len(set(alphabet)) != len(alphabet): raise ValueError('Supplied alphabet cannot contain duplicates.') self.alphabet = tuple(alphabet) self.base = len(alphabet) self.length = length self.generator = generator self.maximum = self.base ** self.length - 1 self.prime = next_prime(int((self.maximum + 1) * self.generator)) # `next_prime` call on each initialized instance
Como você pode ver, a inicialização de uma instância base
requer uma chamada da função next_prime
; isso é bastante pesado, como podemos ver nos retângulos inferiores esquerdos da visualização acima.
Vamos dar uma olhada na minha classe Hash
novamente:
class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() # <-- initializing on each method call content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() # <-- initializing on each method call unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
Como você pode ver, eu rotulei dois métodos que estão inicializando uma instância base36
em cada chamada de método, o que não é realmente necessário.
Como o hash é um procedimento determinístico, o que significa que para um determinado valor de entrada ele deve sempre gerar o mesmo valor de hash, podemos torná-lo um atributo de classe sem temer que ele quebre algo. Vamos conferir como ele vai funcionar:
class Hasher(object): base36 = basehash.base36() # <-- initialize hasher only once @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return cls.base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): unhashed = '%09d' % cls.base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
200 GANHAR
/api/v1/casas/
3766ms no total
38ms em consultas
4 consultas
O resultado final é inferior a quatro segundos, o que é muito menor do que o que começamos. Otimização adicional do tempo de resposta pode ser alcançada usando o cache, mas não abordarei isso neste artigo.
Conclusão
A otimização de desempenho é um processo de análise e descoberta. Não há regras rígidas que se apliquem a todos os casos, pois cada projeto tem seu próprio fluxo e gargalos. No entanto, a primeira coisa que você deve fazer é perfilar seu código. E se em um exemplo tão curto consegui reduzir o tempo de resposta de 77 segundos para 3,7 segundos, grandes projetos têm ainda mais potencial de otimização.
Se você estiver interessado em ler mais artigos relacionados ao Django, confira os 10 principais erros que os desenvolvedores do Django cometem pelo colega desenvolvedor do Toptal Django Alexandr Shurigin.