Ein Leitfaden für Leistungstests und -optimierung mit Python und Django
Veröffentlicht: 2022-03-11Donald Knuth sagte, dass „vorzeitige Optimierung die Wurzel allen Übels ist“. Aber es kommt eine Zeit, in der Regel in ausgereiften Projekten mit hoher Last, wenn es unvermeidlich ist, zu optimieren. In diesem Artikel möchte ich über fünf gängige Methoden zur Optimierung des Codes Ihres Webprojekts sprechen. Ich werde Django verwenden, aber die Prinzipien sollten für andere Frameworks und Sprachen ähnlich sein. In diesem Artikel werde ich diese Methoden verwenden, um die Antwortzeit einer Abfrage von 77 auf 3,7 Sekunden zu reduzieren.
Der Beispielcode stammt aus einem realen Projekt, an dem ich gearbeitet habe, und demonstriert Techniken zur Leistungsoptimierung. Falls Sie mitverfolgen und die Ergebnisse selbst sehen möchten, können Sie den Code in seinem ursprünglichen Zustand auf GitHub abrufen und die entsprechenden Änderungen vornehmen, während Sie dem Artikel folgen. Ich werde Python 2 verwenden, da einige Pakete von Drittanbietern noch nicht für Python 3 verfügbar sind.
Vorstellung unserer Anwendung
Unser Webprojekt verfolgt einfach Immobilienangebote pro Land. Daher gibt es nur zwei Modelle:
# 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)
Das abstrakte HashableModel
stellt jedem Modell, das von ihm eine hash
-Eigenschaft erbt, die den Primärschlüssel der Instanz und den Inhaltstyp des Modells enthält, bereit. Dadurch werden vertrauliche Daten wie Instanz-IDs ausgeblendet, indem sie durch einen Hash ersetzt werden. Es kann auch in Fällen nützlich sein, in denen Ihr Projekt mehrere Modelle enthält und Sie einen zentralen Ort benötigen, der das Hashing aufhebt und entscheidet, was mit verschiedenen Modellinstanzen verschiedener Klassen geschehen soll. Beachten Sie, dass Hashing für unser kleines Projekt nicht wirklich benötigt wird, da wir ohne es auskommen, aber es wird helfen, einige Optimierungstechniken zu demonstrieren, also werde ich es dabei belassen.
Hier ist die Hasher
-Klasse:
# 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]
Da wir diese Daten über einen API-Endpunkt bereitstellen möchten, installieren wir das Django REST Framework und definieren die folgenden Serialisierer und Ansichten:
# 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)
Jetzt füllen wir unsere Datenbank mit einigen Daten (insgesamt 100.000 Hausinstanzen, die mit factory-boy
generiert wurden: 50.000 für ein Land, 40.000 für ein anderes und 10.000 für ein Drittland) und sind bereit, die Leistung unserer App zu testen.
Bei der Leistungsoptimierung dreht sich alles ums Messen
Es gibt mehrere Dinge, die wir in einem Projekt messen können:
- Ausführungszeit
- Anzahl der Codezeilen
- Anzahl der Funktionsaufrufe
- Zugewiesenen Speicher
- Usw.
Aber nicht alle sind relevant, um zu messen, wie gut unser Projekt abschneidet. Im Allgemeinen gibt es zwei Hauptmetriken, die am wichtigsten sind: wie lange etwas ausgeführt wird und wie viel Speicher es benötigt.
In einem Webprojekt ist die Antwortzeit (die Zeit, die der Server benötigt, um eine durch die Aktion eines Benutzers generierte Anfrage zu empfangen, zu verarbeiten und das Ergebnis zurückzusenden) normalerweise die wichtigste Metrik, da Benutzer sich beim Warten nicht langweilen für eine Antwort und wechseln Sie zu einem anderen Tab in ihrem Browser.
In der Programmierung wird die Analyse der Projektleistung als Profiling bezeichnet. Um die Leistung unseres API-Endpunkts zu profilieren, verwenden wir das Silk-Paket. Nach der Installation und unserem Aufruf /api/v1/houses/?country=5T22RI
(der Hash, der dem Land mit 50.000 Hauseinträgen entspricht) erhalten wir Folgendes:
200 GET
/api/v1/häuser/
77292 ms insgesamt
15854 ms bei Abfragen
50004 Abfragen
Die Gesamtantwortzeit beträgt 77 Sekunden, davon entfallen 16 Sekunden auf Abfragen in der Datenbank, wo insgesamt 50.000 Abfragen durchgeführt wurden. Bei so großen Zahlen gibt es viel Raum für Verbesserungen, also fangen wir an.
1. Optimierung von Datenbankabfragen
Einer der häufigsten Tipps zur Leistungsoptimierung besteht darin, sicherzustellen, dass Ihre Datenbankabfragen optimiert sind. Dieser Fall ist keine Ausnahme. Darüber hinaus können wir verschiedene Dinge an unseren Abfragen tun, um die Antwortzeit zu optimieren.
1.1 Alle Daten auf einmal bereitstellen
Wenn Sie sich diese 50.000 Abfragen genauer ansehen, sehen Sie, dass dies alles redundante Abfragen für die Tabelle houses_country
sind:
200 GET
/api/v1/häuser/
77292 ms insgesamt
15854 ms bei Abfragen
50004 Abfragen
Bei | Tische | Schließt sich an | Ausführungszeit (ms) |
---|---|---|---|
+0:01:15.874374 | "Häuser_Land" | 0 | 0,176 |
+0:01:15.873304 | "Häuser_Land" | 0 | 0,218 |
+0:01:15.872225 | "Häuser_Land" | 0 | 0,218 |
+0:01:15.871155 | "Häuser_Land" | 0 | 0,198 |
+0:01:15.870099 | "Häuser_Land" | 0 | 0,173 |
+0:01:15.869050 | "Häuser_Land" | 0 | 0,197 |
+0:01:15.867877 | "Häuser_Land" | 0 | 0,221 |
+0:01:15.866807 | "Häuser_Land" | 0 | 0,203 |
+0:01:15.865646 | "Häuser_Land" | 0 | 0,211 |
+0:01:15.864562 | "Häuser_Land" | 0 | 0,209 |
+0:01:15.863511 | "Häuser_Land" | 0 | 0,181 |
+0:01:15.862435 | "Häuser_Land" | 0 | 0,228 |
+0:01:15.861413 | "Häuser_Land" | 0 | 0,174 |
Die Ursache dieses Problems ist die Tatsache, dass Abfragesätze in Django faul sind. Dies bedeutet, dass ein Abfragesatz nicht ausgewertet wird und erst dann in die Datenbank gelangt, wenn Sie die Daten tatsächlich abrufen müssen. Gleichzeitig erhält es nur die Daten, die Sie ihm mitgeteilt haben, und stellt nachfolgende Anfragen, wenn zusätzliche Daten benötigt werden.
Genau das ist in unserem Fall passiert. Beim Abrufen der über House.objects.filter(country=country)
festgelegten Abfrage erhält Django eine Liste aller Häuser im angegebenen Land. Beim Serialisieren einer house
benötigt HouseSerializer
jedoch die country
des Hauses, um das country
des Serialisierers zu berechnen. Da die Länderdaten nicht im Abfragesatz vorhanden sind, stellt Django eine zusätzliche Anfrage, um diese Daten abzurufen. Und das für jedes Haus im Abfragesatz – insgesamt 50.000 Mal.
Dabei ist die Lösung ganz einfach. Um alle erforderlichen Daten für die Serialisierung zu extrahieren, können Sie die Methode select_related()
für den Abfragesatz verwenden. Somit sieht unser get_queryset
aus:
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
Mal sehen, wie sich dies auf die Leistung ausgewirkt hat:
200 GET
/api/v1/häuser/
35979 ms insgesamt
102 ms bei Abfragen
4 Abfragen
Die Gesamtantwortzeit sank auf 36 Sekunden und die in der Datenbank verbrachte Zeit beträgt ~100 ms bei nur 4 Abfragen! Das sind großartige Neuigkeiten, aber wir können noch mehr tun.
1.2 Geben Sie nur die relevanten Daten an
Standardmäßig extrahiert Django alle Felder aus der Datenbank. Wenn Sie jedoch riesige Tabellen mit vielen Spalten und Zeilen haben, ist es sinnvoll, Django mitzuteilen, welche spezifischen Felder extrahiert werden sollen, damit es keine Zeit damit verschwendet, Informationen zu erhalten, die überhaupt nicht verwendet werden. In unserem Fall benötigen wir nur fünf Felder für die Serialisierung, aber wir haben 17 Felder. Es ist sinnvoll, genau anzugeben, welche Felder aus der Datenbank extrahiert werden sollen, damit wir die Antwortzeit weiter verkürzen.
Django hat die defer()
und die only()
Query-Set-Methoden, um genau dies zu tun. Der erste gibt an, welche Felder nicht geladen werden sollen, und der zweite gibt an, welche Felder nur geladen werden sollen.
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
Dadurch wurde die für Abfragen aufgewendete Zeit halbiert, was gut ist, aber 50 ms sind nicht so viel. Die Gesamtzeit ist auch leicht gesunken, aber es gibt mehr Platz, um sie zu schneiden.

200 GET
/api/v1/häuser/
33111 ms insgesamt
52 ms bei Abfragen
4 Abfragen
2. Optimierung Ihres Codes
Sie können die Datenbankabfragen nicht unendlich optimieren, und das hat unser letztes Ergebnis gerade gezeigt. Selbst wenn wir die für Anfragen aufgewendete Zeit hypothetisch auf 0 reduzieren, würden wir uns immer noch der Realität stellen müssen, eine halbe Minute auf die Antwort zu warten. Es ist an der Zeit, auf eine andere Ebene der Optimierung zu wechseln: Geschäftslogik .
2.1 Vereinfachen Sie Ihren Code
Manchmal sind Pakete von Drittanbietern mit viel Overhead für einfache Aufgaben verbunden. Ein solches Beispiel ist unsere Aufgabe, serialisierte Hausinstanzen zurückzugeben.
Django REST Framework ist großartig, mit vielen nützlichen Funktionen, die sofort einsatzbereit sind. Unser Hauptziel im Moment ist jedoch, die Antwortzeit zu verkürzen, daher ist es ein großartiger Kandidat für die Optimierung, insbesondere, weil die serialisierten Objekte recht einfach sind.
Lassen Sie uns für diesen Zweck einen benutzerdefinierten Serializer schreiben. Um es einfach zu halten, haben wir eine einzige statische Methode, die die Arbeit erledigt. In Wirklichkeit möchten Sie möglicherweise dieselben Klassen- und Methodensignaturen haben, um Serializer austauschbar verwenden zu können:
# 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 GET
/api/v1/häuser/
17312 ms insgesamt
38 ms bei Abfragen
4 Abfragen
Das sieht jetzt besser aus. Die Antwortzeit wurde fast halbiert, da wir keinen DRF-Serializer-Code verwendet haben.
Ein weiteres messbares Ergebnis – die Anzahl der gesamten Funktionsaufrufe während des Anforderungs-/Antwortzyklus – sank von 15.859.427 Aufrufen (von der Anfrage in Abschnitt 1.2 oben) auf 9.257.469 Aufrufe. Das bedeutet, dass etwa 1/3 aller Funktionsaufrufe vom Django REST Framework getätigt wurden.
2.2 Pakete von Drittanbietern aktualisieren/ersetzen
Die oben beschriebenen Optimierungstechniken sind die gebräuchlichsten, die Sie ohne gründliche Analyse und Nachdenken verwenden können. Allerdings fühlen sich 17 Sekunden immer noch ziemlich lang an; Um diese Zahl zu reduzieren, müssen wir tiefer in unseren Code eintauchen und analysieren, was unter der Haube passiert. Mit anderen Worten, wir müssen unseren Code profilieren.
Sie können die Profilerstellung selbst durchführen, indem Sie den integrierten Python-Profiler verwenden, oder Sie können einige Pakete von Drittanbietern dafür verwenden (die den integrierten Python-Profiler verwenden). Da wir silk
bereits verwenden, kann es den Code profilieren und eine binäre Profildatei generieren, die wir weiter visualisieren können. Es gibt mehrere Visualisierungspakete, die ein binäres Profil in einige aufschlussreiche Visualisierungen umwandeln. Ich werde das snakeviz
-Paket verwenden.
Hier ist die Visualisierung des Binärprofils der letzten Anfrage von oben, verbunden mit der Versandmethode der Ansicht:
Von oben nach unten ist der Aufrufstapel, der den Dateinamen, den Methoden-/Funktionsnamen mit seiner Zeilennummer und die entsprechende kumulierte Zeit anzeigt, die in dieser Methode verbracht wurde. Jetzt ist es einfacher zu sehen, dass ein Löwenanteil der Zeit für die Berechnung des Hashs aufgewendet wird (die violetten Rechtecke __init__.py
und primes.py
).
Derzeit ist dies der Hauptleistungsengpass in unserem Code, aber gleichzeitig ist es nicht wirklich unser Code – es ist ein Paket eines Drittanbieters.
In solchen Situationen gibt es eine begrenzte Anzahl von Dingen, die wir tun können:
- Suchen Sie nach einer neuen Version des Pakets (die hoffentlich eine bessere Leistung bietet).
- Finden Sie ein anderes Paket, das bei Aufgaben, die wir benötigen, besser funktioniert.
- Schreiben Sie unsere eigene Implementierung, die die Leistung des derzeit verwendeten Pakets übertrifft.
Zu meinem Glück gibt es eine neuere Version des basehash
-Pakets, das für das Hashing verantwortlich ist. Der Code verwendet v.2.1.0, aber es gibt eine v.3.0.4. Solche Situationen, in denen Sie auf eine neuere Version eines Pakets aktualisieren können, sind wahrscheinlicher, wenn Sie an einem vorhandenen Projekt arbeiten.
Wenn Sie die Versionshinweise für v.3 überprüfen, gibt es diesen speziellen Satz, der sehr vielversprechend klingt:
Eine massive Überarbeitung wurde mit den Primzahlalgorithmen durchgeführt. Einschließlich (sic) Unterstützung für gmpy2, falls es (sic) auf dem System verfügbar ist, für eine viel größere Steigerung.
Finden wir das heraus!
pip install -U basehash gmpy2
200 GET
/api/v1/häuser/
7738 ms insgesamt
59 ms bei Abfragen
4 Abfragen
Wir haben die Reaktionszeit von 17 auf unter 8 Sekunden verkürzt. Tolles Ergebnis, aber es gibt noch eine Sache, die wir uns ansehen sollten.
2.3 Refaktorieren Sie Ihren eigenen Code
Bisher haben wir unsere Abfragen verbessert, komplexen und generischen Code von Drittanbietern durch unsere eigenen, sehr spezifischen Funktionen ersetzt und Pakete von Drittanbietern aktualisiert, aber unseren bestehenden Code unverändert gelassen. Aber manchmal kann ein kleines Refactoring von bestehendem Code beeindruckende Ergebnisse bringen. Dafür müssen wir aber erneut die Profiling-Ergebnisse analysieren.
Wenn Sie genauer hinsehen, können Sie sehen, dass Hashing immer noch ein Problem ist (nicht überraschend, es ist das einzige, was wir mit unseren Daten machen), obwohl wir uns in dieser Richtung verbessert haben. Allerdings stört mich das grünliche Rechteck, das besagt, dass __init__.py
2,14 Sekunden verbraucht, zusammen mit dem gräulichen __init__.py:54(hash)
, das direkt danach kommt. Dies bedeutet, dass einige Initialisierungen sehr lange dauern.
Werfen wir einen Blick auf den Quellcode des basehash
-Pakets.
# 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
Wie Sie sehen können, erfordert die Initialisierung einer next_prime
base
; das ist ziemlich schwer, wie wir in den unteren linken Rechtecken der obigen Visualisierung sehen können.
Schauen wir uns noch einmal meine Hash
-Klasse an:
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]
Wie Sie sehen können, habe ich zwei Methoden gekennzeichnet, die bei jedem Methodenaufruf eine base36
Instanz initialisieren, was nicht wirklich erforderlich ist.
Da Hashing ein deterministisches Verfahren ist, was bedeutet, dass es für einen bestimmten Eingabewert immer denselben Hashwert erzeugen muss, können wir es zu einem Klassenattribut machen, ohne befürchten zu müssen, dass es etwas kaputt macht. Schauen wir uns an, wie es funktionieren wird:
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 GET
/api/v1/häuser/
Insgesamt 3766 ms
38 ms bei Abfragen
4 Abfragen
Das Endergebnis liegt unter vier Sekunden, was viel weniger ist als das, womit wir begonnen haben. Eine weitere Optimierung der Antwortzeit kann durch Caching erreicht werden, aber darauf gehe ich in diesem Artikel nicht ein.
Fazit
Leistungsoptimierung ist ein Prozess der Analyse und Entdeckung. Es gibt keine festen Regeln, die für alle Fälle gelten, da jedes Projekt seinen eigenen Ablauf und seine eigenen Engpässe hat. Als Erstes sollten Sie jedoch Ihren Code profilieren. Und wenn ich in einem so kurzen Beispiel die Reaktionszeit von 77 Sekunden auf 3,7 Sekunden reduzieren konnte, haben riesige Projekte noch mehr Optimierungspotenzial.
Wenn Sie daran interessiert sind, weitere Artikel zu Django zu lesen, lesen Sie die Top 10 Fehler, die Django-Entwickler machen, von Alexandr Shurigin, einem anderen Toptal-Django-Entwickler.