Nem um único ORM
Olá pessoal! Sou responsável pelo departamento de Desenvolvimento de parceiros do serviço de reservas de hotéis Ostrovok.ru . Neste artigo, gostaria de falar sobre como usamos o Django ORM em um projeto.
Na verdade, eu estava enganando, o nome deveria ter sido " Não ORM single ". Se você está se perguntando por que escrevi isso, bem como se:
- Você tem Django na pilha e deseja
Model.objects.all()
o máximo do ORM, não apenas Model.objects.all()
, - Você deseja transferir parte da lógica de negócios para o nível do banco de dados,
- Ou você deseja descobrir por que a desculpa mais frequente para desenvolvedores no B2B.Ostrovok.ru é "tão historicamente" ,
... bem vindo ao gato.

Em 2014, lançamos o B2B.Ostrovok.ru - um serviço de reservas on-line de hotéis, transfers, carros e outros serviços de viagens para profissionais do mercado de turismo (agentes de viagens, operadoras e clientes corporativos).
No B2B, projetamos e usamos com sucesso um modelo de pedido abstrato com base na meta-ordem MetaOrder
- MetaOrder
.
Uma meta-ordem é uma entidade abstrata que pode ser usada independentemente do tipo de ordem a que pertence: um hotel ( Hotel
), um serviço adicional ( Upsell
) ou um carro ( Car
). No futuro, outros tipos podem aparecer.
Isso nem sempre foi assim. Quando o serviço B2B foi lançado, somente hotéis podiam ser reservados por ele e toda a lógica de negócios estava focada neles. Muitos campos foram criados, por exemplo, para exibir as taxas de câmbio do valor da venda e do valor do reembolso da reserva. Com o tempo, percebemos a melhor forma de armazenar e reutilizar esses dados, dadas as meta-ordens. Mas o código inteiro não pôde ser reescrito e parte dessa herança entrou na nova arquitetura. Na verdade, isso levou a dificuldades nos cálculos, que utilizam vários tipos de pedidos. O que fazer - tão historicamente ...
Meu objetivo é mostrar o poder do Django ORM em nosso exemplo.
Antecedentes
Para planejar suas despesas, nossos clientes B2B realmente não tinham informações sobre quanto eles precisam pagar agora / amanhã / tarde, se eles têm dívidas pendentes em pedidos e qual é o seu tamanho, além de quanto mais eles podem gastar dentro de seus limites. Decidimos mostrar essas informações na forma de um painel - um soquete tão simples com um diagrama claro.

(todos os valores são testados e não se aplicam a um parceiro específico)
À primeira vista, tudo é bem simples - filtramos todos os pedidos do parceiro, resumimos e mostramos.
Opções de solução
Uma pequena explicação sobre como fazemos cálculos. Somos uma empresa internacional, nossos parceiros de diferentes países realizam operações - compram e revendem reservas - em diferentes moedas. Além disso, eles devem receber demonstrações financeiras na moeda escolhida (geralmente local). Seria tolice e impraticável armazenar todos os dados possíveis sobre as taxas de todas as moedas; portanto, você precisa escolher uma moeda de referência, por exemplo, o rublo. Assim, você pode armazenar as taxas de todas as moedas apenas no rublo. Assim, quando um parceiro deseja receber um resumo, convertemos os valores à taxa definida no momento da venda.
"Na testa"
De fato, este é Model.objects.all()
e o loop de condições:
Model.objects.all () com condições def output(partner_id): today = dt.date.today()
Esta consulta retornará um gerador que potencialmente contém várias centenas de reservas. Uma solicitação ao banco de dados será feita para cada uma dessas reservas e, portanto, o ciclo funcionará por um período muito longo.
Você pode acelerar um pouco as coisas adicionando o método prefetch_related
:
Em seguida, haverá um pouco menos de consultas ao banco de dados ( GenericForeignKey
na GenericForeignKey
), mas no final pararemos com seu número, porque a consulta ao banco de dados ainda será feita a cada iteração do loop.
O método de output
pode (e deve) ser armazenado em cache, mas a primeira chamada ainda cumpre a ordem de um minuto, o que é completamente inaceitável.
Aqui estão os resultados dessa abordagem:

O tempo médio de resposta é de 4 segundos e há picos chegando a 21 segundos. Muito tempo.
Não lançamos o painel para todos os parceiros e, portanto, não tínhamos muitos pedidos, mas ainda é suficiente entender que essa abordagem não é eficaz.

Os números do canto inferior direito são o número de consultas: mínimo, máximo, média, total.
Sabiamente
O protótipo da testa foi bom para entender a complexidade da tarefa, mas não ideal para uso. Decidimos que seria muito mais rápido e com menos recursos fazer várias consultas complexas no banco de dados do que muitas simples.
Solicitar plano
Os traços largos do plano de consulta podem ser descritos assim:
- coletar pedidos de acordo com as condições iniciais,
- preparar campos para o cálculo através da
annotate
, - calcular valores de campo
aggregate
pela quantidade e quantidade
Condições iniciais
Os parceiros que visitam o site podem ver informações apenas em seus contratos.
partner = query_get_one(Partner.objects.filter(id=partner_id))
Caso não desejemos mostrar novos tipos de pedidos / reservas, precisamos apenas filtrar os suportados:
query = MetaOrder.objects.filter( partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ] )
O status do pedido é importante (mais sobre Q
):
query = query.filter( Q(hotel__status__in=['completed', 'cancelled'])
Também costumamos usar solicitações pré-preparadas, por exemplo, para excluir todos os pedidos que não podem ser pagos. Há muita lógica de negócios, o que não é muito interessante para nós na estrutura deste artigo, mas, em essência, esses são apenas filtros adicionais. Um método que retorna uma consulta preparada pode ser assim:
query = MetaOrder.exclude_non_payable_metaorders(query)
Como você pode ver, este é um método de classe que também retornará um QuerySet
.
Também prepararemos algumas variáveis para construções condicionais e para armazenar resultados de cálculos:
import datetime as dt from typing.decimal import Decimal today = dt.date.today() result = defaultdict(Decimal)
Preparação de Campo ( annotate
)
Devido ao fato de termos que nos referir aos campos, dependendo do tipo de pedido, usaremos Coalesce
. Assim, podemos abstrair qualquer número de novos tipos de pedidos em um único campo.
Aqui está a primeira parte do bloco de annotate
:
Coalesce
trabalha aqui com um estrondo, porque os pedidos de hotéis têm várias propriedades especiais e, em todos os outros casos (serviços adicionais e carros), essas propriedades não são importantes para nós. É assim que o Value(ZERO)
para valores e o Value(ONE)
para taxas de câmbio aparecem. ZERO
e ONE
são Decimal('0')
e Decimal(1)
, apenas na forma de constantes. Uma abordagem amadora, mas em nosso projeto é aceita assim.
Você pode ter uma pergunta: por que não colocar alguns campos em um nível em uma meta ordem? Por exemplo, payment_pending
, que está em toda parte. De fato, com o tempo, transferimos esses campos para uma meta-ordem, mas agora o código funciona bem, portanto essas tarefas não são nossa prioridade.
Outra preparação e cálculos
Agora, precisamos fazer alguns cálculos com os valores que recebemos no último bloco de annotate
. Observe que aqui você não precisa mais estar vinculado ao tipo de pedido (exceto uma exceção).
A parte mais interessante desse bloco é o campo _reporting_currency_rate
ou a taxa de câmbio da moeda de referência no momento da venda. Os dados sobre as taxas de câmbio de todas as moedas para a moeda de referência para um pedido de hotel são armazenados em currency_data
. Este é apenas JSON. Por que mantemos isso? Este é historicamente o caso .
E aqui, ao que parece, por que não usar F
e substituir o valor da moeda do contrato? Ou seja, seria legal se você pudesse fazer isso:
F(f'currency_data__{partner.reporting_currency}')
Mas f-strings
não f-strings
suportadas em F
Embora o fato do Django ORM já tenha a capacidade de acessar campos json aninhados seja muito agradável - F('currency_data__USD')
.
E o último bloco de annotate
é o cálculo _payable_in_cur
, que será resumido para todos os pedidos. Este valor deve estar na moeda do contrato.

.annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) )
A peculiaridade do método annotate
é que ele gera uma grande quantidade de construções SELECT something AS something_else
que não estão diretamente envolvidas na solicitação. Isso pode ser visto descarregando a consulta SQL - query.__str__()
.
É assim que o código SQL gerado pelo Django ORM para base_query_annotated
. Você precisa lê-lo com frequência para entender onde pode otimizar sua consulta.
Cálculos finais
Haverá um pequeno invólucro para aggregate
, para que, no futuro, se o parceiro precisar de alguma outra métrica, ele possa ser facilmente adicionado.

def _get_data_from_query(query: QuerySet) -> Decimal: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO
E mais uma coisa: esta é a última filtragem por condição comercial, por exemplo, precisamos de todos os pedidos que precisarão ser pagos em breve.

before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) )
Depuração e verificação
Uma maneira muito conveniente de verificar a exatidão da solicitação criada é compará-la com uma versão mais legível dos cálculos.
for morder in query: payable = morder.get_payable_in_cur() payment_pending = morder.get_payment_pending() if payment_pending > today: result['payment_pending'] += payable
Você conhece o método "testa"?
Código final
Como resultado, obtivemos algo como o seguinte:
Código final def _get_data_from_query(query: QuerySet) -> tuple: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO def output(partner_id: int): today = dt.date.today() partner = query_get_one(Partner.objects.filter(id=partner_id)) query = MetaOrder.objects.filter(partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ]) result = defaultdict(Decimal) query_annoted = query.annotate( _payment_pending=Coalesce( 'hotel__payment_pending', 'car__payment_pending', 'upsell__payment_pending', ), _payment_due=Coalesce( 'hotel__payment_due', 'car__payment_due', 'upsell__payment_due', ), _refund=Coalesce( 'hotel__refund', Value(ZERO) ), _refund_currency_rate=Coalesce( 'hotel__refund_currency_rate', Value(Decimal('1')) ), _sell=Coalesce( 'hotel__sell', Value(ZERO) ), _sell_currency_rate=Coalesce( 'hotel__sell_currency_rate', Value(Decimal('1')) ), ).annotate(
É assim que funciona agora:


Conclusões
Depois de reescrever e otimizar a lógica, conseguimos fazer um tratamento bastante rápido das métricas de afiliados e reduzir bastante o número de consultas no banco de dados. A solução acabou sendo boa e reutilizaremos essa lógica em outras partes do projeto. ORM é o nosso tudo.
Escreva comentários, faça perguntas - tentaremos responder! Obrigada