Kein einziger ORM

Kein einziger ORM


Hallo allerseits! Ich bin verantwortlich für die Partnerentwicklungsabteilung des Hotelreservierungsdienstes Ostrovok.ru . In diesem Artikel möchte ich darüber sprechen, wie wir Django ORM für ein Projekt verwendet haben.


Tatsächlich habe ich getäuscht, der Name hätte " Nicht ORM single ". Wenn Sie sich fragen, warum ich das geschrieben habe und ob:


  • Sie haben Django auf dem Stapel und möchten das Maximum aus ORM Model.objects.all() , nicht nur Model.objects.all() ,
  • Sie möchten einen Teil der Geschäftslogik auf die Datenbankebene übertragen.
  • Oder möchten Sie herausfinden, warum die häufigste Entschuldigung für Entwickler bei B2B.Ostrovok.ru "so historisch" ist?

... willkommen bei cat.


cdpv


2014 haben wir B2B.Ostrovok.ru gestartet - einen Online-Buchungsservice für Hotels, Transfers, Autos und andere Reisedienstleistungen für Fachleute auf dem Tourismusmarkt (Reisebüros, Betreiber und Firmenkunden).


Im B2B-Bereich haben wir ein abstraktes Bestellmodell basierend auf dem GenericForeignKey - Meta Order - MetaOrder entworfen und recht erfolgreich verwendet.


Eine Meta-Bestellung ist eine abstrakte Entität, die unabhängig von der Art der Bestellung verwendet werden kann: ein Hotel ( Hotel ), ein zusätzlicher Service ( Upsell ) oder ein Auto ( Car ). In Zukunft werden möglicherweise andere Typen angezeigt.


Das war nicht immer so. Als der B2B-Service eingeführt wurde, konnten nur Hotels über ihn gebucht werden, und die gesamte Geschäftslogik war auf sie ausgerichtet. Es wurden viele Felder erstellt, um beispielsweise die Wechselkurse des Verkaufsbetrags und des Reservierungsrückerstattungsbetrags anzuzeigen. Im Laufe der Zeit haben wir erkannt, wie diese Daten angesichts der Meta-Bestellungen am besten gespeichert und wiederverwendet werden können. Der gesamte Code konnte jedoch nicht umgeschrieben werden, und ein Teil dieses Erbes floss in die neue Architektur ein. Tatsächlich führte dies zu Schwierigkeiten bei den Berechnungen, bei denen verschiedene Arten von Aufträgen verwendet werden. Was zu tun ist - also historisch ...


Mein Ziel ist es, die Kraft von Django ORM in unserem Beispiel zu zeigen.


Hintergrund


Um ihre Ausgaben zu planen, fehlten unseren B2B-Kunden wirklich Informationen darüber, wie viel sie jetzt / morgen / später bezahlen müssen, ob sie Schulden für Bestellungen haben und wie groß diese sind und wie viel mehr sie innerhalb ihrer Grenzen ausgeben können. Wir haben uns entschlossen, diese Informationen in Form eines Dashboards anzuzeigen - einer so einfachen Steckdose mit einem übersichtlichen Diagramm.


dash1
(Alle Werte sind Testwerte und gelten nicht für einen bestimmten Partner.)


Auf den ersten Blick ist alles ganz einfach - wir filtern alle Bestellungen des Partners, fassen zusammen und zeigen.


Lösungsoptionen


Eine kleine Erklärung, wie wir Berechnungen durchführen. Wir sind ein internationales Unternehmen, unsere Partner aus verschiedenen Ländern führen Operationen - Kauf und Wiederverkauf von Reservierungen - in verschiedenen Währungen durch. Darüber hinaus müssen sie Abschlüsse in der von ihnen gewählten Währung (in der Regel lokal) erhalten. Es wäre töricht und unpraktisch, alle möglichen Daten zu den Kursen aller Währungen zu speichern. Sie müssen also eine Referenzwährung auswählen, beispielsweise den Rubel. Somit können Sie die Kurse aller Währungen nur im Rubel speichern. Wenn ein Partner eine Zusammenfassung erhalten möchte, rechnen wir die Beträge dem zum Zeitpunkt des Verkaufs festgelegten Satz um.


"In der Stirn"


Tatsächlich ist dies Model.objects.all() und die Bedingungsschleife:


Model.objects.all () mit Bedingungen
 def output(partner_id): today = dt.date.today() # query_get_one -    partner = query_get_one(Partner.objects.filter(id=partner_id)) #    -  query = MetaOrder.objects.filter(partner=partner) result = defaultdict(Decimal) for morder in query: #  ,     #     payment_pending = morder.get_payment_pending() payment_due = morder.get_payment_due() #        # (     ) payable = morder.get_payable_in_cur() #       if payment_pending > today: result['payment_pending'] += payable # ,     if payment_pending < today and payment_due > today: result['payment_due'] += payable return result 

Diese Abfrage gibt einen Generator zurück, der möglicherweise mehrere hundert Buchungen enthält. Für jede dieser Buchungen wird eine Anfrage an die Datenbank gestellt, und daher wird der Zyklus sehr lange dauern.


Sie können die Dinge etwas beschleunigen, indem Sie die Methode prefetch_related hinzufügen:


 # object -      GenericForeignKey. query = query.prefetch_related('object') 

Dann gibt es etwas weniger Abfragen an die Datenbank ( GenericForeignKey auf den GenericForeignKey ), aber am Ende werden wir bei ihrer Nummer anhalten, da die Abfrage an die Datenbank immer noch bei jeder Iteration der Schleife durchgeführt wird.


Die output kann (und sollte) zwischengespeichert werden, aber der erste Aufruf erfüllt immer noch die Größenordnung einer Minute, was völlig inakzeptabel ist.


Hier sind die Ergebnisse dieses Ansatzes:


Timing_vorher


Die durchschnittliche Antwortzeit beträgt 4 Sekunden und es gibt Spitzen, die 21 Sekunden erreichen. Ziemlich lange Zeit.


Wir haben das Dashboard nicht für alle Partner eingeführt und hatten daher nicht viele Anfragen, aber es reicht immer noch aus, um zu verstehen, dass dieser Ansatz nicht effektiv ist.


count_before
Die Zahlen unten rechts geben die Anzahl der Abfragen an: Minimum, Maximum, Durchschnitt, Gesamt.


Mit Bedacht


Der Stirnprototyp war gut für das Verständnis der Komplexität der Aufgabe, aber nicht optimal für den Einsatz. Wir haben beschlossen, dass es viel schneller und weniger ressourcenintensiv ist, mehrere komplexe Abfragen in die Datenbank zu stellen als viele einfache.


Plan anfordern


Breite Striche des Abfrageplans können folgendermaßen beschrieben werden:


  • Bestellungen gemäß den Anfangsbedingungen abholen,
  • Felder für die Berechnung durch annotate vorbereiten,
  • Feldwerte berechnen
  • aggregate nach Menge und Menge

Ausgangsbedingungen


Partner, die die Website besuchen, können nur Informationen zu ihrem Vertrag sehen.


 partner = query_get_one(Partner.objects.filter(id=partner_id)) 

Falls wir keine neuen Arten von Bestellungen / Buchungen anzeigen möchten, müssen wir nur die unterstützten filtern:


 query = MetaOrder.objects.filter( partner=partner, content_type__in=[ Hotel.get_content_type(), Car.get_content_type(), Upsell.get_content_type(), ] ) 

Der Bestellstatus ist wichtig (mehr zu Q ):


 query = query.filter( Q(hotel__status__in=['completed', 'cancelled']) #     ,    # | Q(car__status__in=[...]) ) 

Wir verwenden häufig auch vorbereitete Anfragen, um beispielsweise alle Bestellungen auszuschließen, die nicht bezahlt werden können. Es gibt eine Menge Geschäftslogik, die für uns im Rahmen dieses Artikels nicht sehr interessant ist, aber im Wesentlichen handelt es sich nur um zusätzliche Filter. Eine Methode, die eine vorbereitete Abfrage zurückgibt, sieht möglicherweise folgendermaßen aus:


 query = MetaOrder.exclude_non_payable_metaorders(query) 

Wie Sie sehen können, ist dies eine Klassenmethode, die auch ein QuerySet .


Wir bereiten auch einige Variablen für bedingte Konstruktionen und zum Speichern von Berechnungsergebnissen vor:


 import datetime as dt from typing.decimal import Decimal today = dt.date.today() result = defaultdict(Decimal) 

Feldvorbereitung ( annotate )


Aufgrund der Tatsache, dass wir uns je nach Art der Bestellung auf die Felder beziehen müssen, verwenden wir Coalesce . Auf diese Weise können wir eine beliebige Anzahl neuer Auftragstypen in einem einzigen Feld zusammenfassen.


Hier ist der erste Teil des annotate Blocks:


Zuerst kommentieren
 #     , #      from app.helpers.numbers import ZERO, ONE 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(ONE) ), _sell=Coalesce( 'hotel__sell', Value(ZERO) ), _sell_currency_rate=Coalesce( 'hotel__sell_currency_rate', Value(ONE) ), ) 

Coalesce arbeitet hier mit einem Knall, da Hotelbestellungen mehrere besondere Eigenschaften haben und in allen anderen Fällen (zusätzliche Dienstleistungen und Autos) diese Eigenschaften für uns nicht wichtig sind. So erscheinen Value(ZERO) für Beträge und Value(ONE) für Wechselkurse. ZERO und ONE sind Decimal('0') und Decimal(1) , nur in Form von Konstanten. Ein Amateur-Ansatz, aber in unserem Projekt wird er so akzeptiert.


Sie haben vielleicht eine Frage, warum nicht einige Felder in einer Meta-Reihenfolge eine Ebene höher legen? Zum Beispiel payment_pending , was überall ist. Zwar übertragen wir solche Felder im Laufe der Zeit in eine Meta-Reihenfolge, aber jetzt funktioniert der Code gut, sodass solche Aufgaben nicht unsere Priorität sind.


Eine weitere Vorbereitung und Berechnungen


Jetzt müssen wir einige Berechnungen mit den Beträgen durchführen, die wir im letzten annotate . Beachten Sie, dass Sie hier nicht mehr an die Art der Bestellung gebunden sein müssen (mit Ausnahme einer Ausnahme).


Zweite Anmerkung
 .annotate( #  _base     _sell_base=( F('_sell') * F('_sell_currency_rate') ), _refund_base=( F('_refund') * F('_refund_currency_rate') ), _payable_base=( F('_sell_base') - F('_refund_base') ), _reporting_currency_rate=Case( When( content_type=Hotel.get_content_type(), then=RawSQL( '(hotel.currency_data->>%s)::numeric', (partner.reporting_currency,), ), ), output_field=DecimalField(), default=Decimal('1'), ), ) 

Der interessanteste Teil dieses Blocks ist das Feld _reporting_currency_rate oder der Wechselkurs zur Referenzwährung zum Zeitpunkt des Verkaufs. Die Daten zu den Wechselkursen aller Währungen zur Referenzwährung für eine Hotelbestellung werden inrency_data gespeichert. Dies ist nur JSON. Warum behalten wir das? Dies ist historisch gesehen der Fall .


Und hier scheint es, warum nicht F und den Wert der Vertragswährung ersetzen? Das heißt, es wäre cool, wenn Sie dies tun könnten:


 F(f'currency_data__{partner.reporting_currency}') 

f-strings in F f-strings nicht unterstützt F Obwohl die Tatsache, dass Django ORM bereits auf verschachtelte JSON-Felder zugreifen kann, sehr erfreulich ist - F('currency_data__USD') .


Und der letzte annotate ist die _payable_in_cur Berechnung, die für alle Bestellungen summiert wird. Dieser Wert muss in der Vertragswährung angegeben werden.


dash2


 .annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) ) 

Die Besonderheit der annotate Methode besteht darin, dass sie viele SELECT something AS something_else Konstrukte generiert, die nicht direkt an der Anforderung beteiligt sind. Dies kann durch Entladen der SQL-Abfrage - query.__str__() .


So base_query_annotated der von Django ORM für base_query_annotated generierte SQL-Code base_query_annotated . Sie müssen es ziemlich oft lesen, um zu verstehen, wo Sie Ihre Abfrage optimieren können.


Endgültige Berechnungen


Es wird einen kleinen Wrapper für das aggregate , damit der Partner in Zukunft, wenn er eine andere Metrik benötigt, diese problemlos hinzufügen kann.


dash3


 def _get_data_from_query(query: QuerySet) -> Decimal: result = query.aggregate( _sum_payable=Sum(F('_payable_in_cur')), ) return result['_sum_payable'] or ZERO 

Und noch etwas - dies ist die letzte Filterung nach Geschäftsbedingungen. Zum Beispiel benötigen wir alle Bestellungen, die bald bezahlt werden müssen.


dash4


 before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) ) 

Debugging und Überprüfung


Eine sehr bequeme Möglichkeit, die Richtigkeit der erstellten Anforderung zu überprüfen, besteht darin, sie mit einer besser lesbaren Version der Berechnungen zu vergleichen.


 for morder in query: payable = morder.get_payable_in_cur() payment_pending = morder.get_payment_pending() if payment_pending > today: result['payment_pending'] += payable 

Kennen Sie die "Stirn" -Methode?


Endgültiger Code


Als Ergebnis haben wir ungefähr Folgendes erhalten:


Endgültiger Code
 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( # Calculated fields _sell_base=( F('_sell') * F('_sell_currency_rate') ), _refund_base=( F('_refund') * F('_refund_currency_rate') ), _payable_base=( F('_sell_base') - F('_refund_base') ), _reporting_currency_rate=Case( # Only hotels have currency_data, therefore we need a # check and default value When( content_type=Hotel.get_content_type(), then=RawSQL( '(hotel.currency_data->>%s)::numeric', (partner.reporting_currency,), ), ), output_field=DecimalField(), default=Decimal('1'), ), ) .annotate( _payable_in_cur=( F('_payable_base') / F('_reporting_currency_rate') ) ) before_payment_pending_query = _get_data_from_query( base_query_annotated.filter(_payment_pending__gt=today) ) after_payment_pending_before_payment_due_query = _get_data_from_query( base_query_annotated.filter( Q(_payment_pending__lte=today) & Q(_payment_due__gt=today) ) ) 

So funktioniert es jetzt:


Timing_nachher


count_after


Schlussfolgerungen


Nach dem Umschreiben und Optimieren der Logik ist es uns gelungen, Affiliate-Metriken relativ schnell zu verarbeiten und die Anzahl der Abfragen an die Datenbank erheblich zu reduzieren. Die Lösung hat sich als gut erwiesen, und wir werden diese Logik in anderen Teilen des Projekts wiederverwenden. ORM ist unser Alles.


Schreiben Sie Kommentare, stellen Sie Fragen - wir werden versuchen zu antworten! Vielen Dank!

Source: https://habr.com/ru/post/de447706/


All Articles