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.

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.

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

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.

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'])
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:
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).
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.

.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.

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.

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(
So funktioniert es jetzt:


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!