Django select_related, korzystać czy nie korzystać; oto jest pytanie!

Django select_related, korzystać czy nie korzystać; oto jest pytanie!

Prędzej czy później każdy programista stanie przed koniecznością optymalizacji aplikacji. Nie należy ona do najprostszych i często może zostać przeprowadzona na kilka różnych sposobów. Dziś przyjrzymy się funkcji, która niejednokrotnie znacznie przyśpieszy ładowanie strony napisanej z wykorzystaniem Django – select_related.

Jak działa select_related?

Zasada działania funkcji select_related jest stosunkowo prosta - pozwala zmniejszyć liczbę zapytań do bazy danych, poprzez połączenie wielu pojedynczych zapytań w jedno złożone dzięki czemu skraca się czas ładowania strony. Omówię to zagadnienie na praktycznym przykładzie.

Przykład

Zakładamy sklep internetowy oferujący projekty domów. Każdy dom ma wiele pól referujących do innych obiektów, takich jak: garaż, dach, piwnica, autor, rodzaj ogrzewania itd., które w tym przypadku będą polami typu ForeignKey). Użytkownik naszej strony podaje jeden z parametrów wymarzonego domu i klika „wyszukaj”, a do bazy wysyłane jest zapytanie:

houses = House.objects.filter(param=value)

następnie potrzebujemy informacji do uzupełnienia tabeli z podstawowymi parametrami wyszukanych domów, np.:

roof = house.roof.angle (kąt nachylenia dachu) 
heating_type = house.heating_type.name (rodzaj ogrzewania) 
garage = house.garage.area() (powierzchnia garażu)

Jeśli mieliśmy szczęście i w wynikach wyszukiwania pojawi się jeden obiekt strona odświeży się w mgnieniu oka. Pamiętajmy jednak, że pod płaszczem powyższych pythonowych funkcji kryją się 4 zapytania do bazy danych.

SELECT ••• FROM app_name_house WHERE "house"."param" = 'value'

W tym momencie mamy listę houses zawierającą jeden obiekt house, jednakże jego pola: roof, heating_type i garage zawierają jedynie informację o id tych obiektów w odpowiedniej tabeli, załóżmy, że są to odpowiednio: X, Y, Z. W takiej sytuacji, dla każdego z obiektów na liście houses wysyłane są trzy zapytania:

roof:SELECT ••• FROM app_name_roof WHERE "roof"."id" = X
heating_type:SELECT ••• FROM app_name_heating WHERE "heating_type"."id" = Y
garage: SELECT ••• FROM app_name_garage WHERE "model_name"."id" = Z

Nie wygląda to specjalnie groźnie, w końcu czym dla dzisiejszych nośników danych są cztery zapytania? Musimy jednak spojrzeć na to zagadnienie z szerszej perspektywy. Nie możemy zakładać, że wynik będzie zawierał jeden obiekt, znacznie częściej będziemy operować na liście obiektów, np. stu, tysiąca, 10 tysięcy czy większej liczbie, i tu pojawiają się - pozostając w tematach budowlanych - „schody”. Zakładając, że lista wyników obejmuje 1000 domów, a my potrzebujemy trzech parametrów dla każdego z nich, liczba zapytań wzrośnie do 3001, co w zauważalny sposób spowolni stronę. Dokładna wartość zależy między innymi od „rozmiarów” obiektów. Zaskakujący może być fakt, że w omawianym przykładzie rodzaj dysku twardego zastosowanego w komputerze nie ma większego wpływu na przebieg wspomnianej operacji, co sprawdziłem na 3 typach dysków:

  • HDD (5400 rpm) (odczyt/zapis: sekwencyjny (MB/s): 76/70, czas dostępu (ms): 18.946ms / 5.210ms)
  • SSD I (odczyt/zapis: sekwencyjny (MB/s): 482/486, czas dostępu (ms): 0.158ms / 0.043ms)
  • SSD II (odczyt/zapis: sekwencyjny (MB/s): 2400/1020, czas dostępu (ms): 0.066ms / 0.037ms)

 

HDD

SSD I

SSD II

Liczba zapytań

3001

Czas ładowania strony (s)

64.87

63.49

63.18

Czas wykonywania zapytań (s)

0.32

0.30

0.29

Do pomiaru czasu wykorzystałem Django Debug Toolbar

Stosowanie select_related

Zapewne wielu osobom intuicja podpowiada, że coś tu się nie zgadza, niemożliwe, aby współczesne komputery wykonywały tak proste operacje przez kilkadziesiąt sekund. Tak też jest w rzeczywistości, to idealny przykład na zastosowanie funkcji select_related. Wykonajmy ponownie zapytanie do bazy, tym razem z wykorzystaniem wspomnianej funkcji.

houses = House.objects.select_related('roof', 'heating_type', 'garage').filter(param=value)

Okazuje się, że liczba zapytań do bazy zmalała do… jednego, które wygląda następująco:

SELECT ••• FROM "app_name_house" INNER JOIN "app_name_furniture" ON
("app_name_house"."roof_id" = "app_name_roof"."id")
INNER JOIN "app_test_heatingtype" ON
("app_name_house"."heating_type_id" = "app_name_heatingtype"."id")
INNER JOIN "app_name_room" ON
("app_name_house"."garage_id" = "app_name_garage"."id")
WHERE "app_name_house"."param" = 'value'

Znacząco zmniejszył się również czas wczytywania strony:

 

HDD

SSD I

SSD II

Liczba zapytań

1

Czas ładowania strony (s)

0.00248

0.00264

0.00251

Czas wykonywania zapytań (s)

0.001

0.001

0.001

Jak nie stosować select_related

Patrząc na powyższą tabelę może pojawić się pokusa, aby funkcję select_related stosować zawsze i wszędzie, albo np. stworzyć listę pól typu ForeignKey danego obiektu i podać ją jako argument do funkcji. Niestety nie jest to takie proste i prawdopodobnie spowoduje efekt odwrotny od oczekiwanego. Może się okazać, że mimo takiej samej liczby zapytań, wyciągniemy mnóstwo danych, które są nam teraz zbędne, co ostatecznie spowolni stronę. Aby zobrazować wspomniane zjawisko postanowiłem utworzyć 10 tysięcy obiektów modelu House, dodać pole tekstowe do 10 modeli, do których referencję posiada ten model, a następnie uzupełnić je jedną stroną „Lorem ipsum” i wyciągnąć jednym zapytaniem. Różnice w czasie wykonywania operacji zawarłem w poniższej tabeli.

Liczba zapytań

1

Liczba pobieranych obiektów

10000

Liczba pól typu ForeignKey

13

Wykorzystywane pola ForeignKey

0

3

Pola podane w funkcji select_related

0

3

8

14

3

8

13

Czas ładowania po uzupełnieniu pól tekstowych (s)

0.42

1.17

2.41

3.05

1.66

2.67

3.33

Co warto zapamiętać?

  • Funkcji select_related używamy tylko wtedy, gdy wykonujemy operacje na polach typu ForeignKey.
  • Jako argument funkcji podajemy tylko te pola, na których planujemy przeprowadzać dowolne operacje.
  • Umiejętne stosowanie select_related może znacznie skrócić czas wczytywania strony…
  • …a nieumiejętne znacznie go wydłużyć. :)

Zdjęcie autorstwa Kevin Ku Pexels