[PL] Backend Entity Framework SQL

Entity Framework – siedem grzechów głównych

Object-Relational Mapping (w skrócie ORM) powstał, aby pogodzić dwa niekompatybilne ze sobą światy: obiektowy oraz relacyjny. Dzięki zastosowaniu ORM-ów programiści nie muszą martwić się ręcznym tworzeniem połączeń do bazy danych czy chociażby pisaniem klasycznych zapytań SQL. Wszystko co musimy zrobić to pobrać framework i dokonać prostej konfiguracji. Niestety jak to zwykle w życiu bywa, każdy kij ma dwa końce. I o ile ORM-y powstały, aby ułatwić pracę każdemu z nas, o tyle brak zrozumienia jak one działają może prowadzić do prawdziwej katastrofy. W dzisiejszym wpisie chciałbym przedstawić Wam siedem błędów (grzechów), które w moim odczuciu są krytyczne dla wydajności aplikacji i nie powinny być nigdzie popełnione. Jeszcze mała uwaga. Pomimo tego, że przykłady będą prezentowane z tytułowym Entity Framework, moim zdaniem część z nich można przenieść do innych frameworków.

 

Przedwczesne materializowanie zapytania

To zdecydowanie największy i zarazem najczęstszy błąd. O co więc chodzi?  W Entity Framework tworzenie zapytania bazodanowego z wykorzystaniem LINQ sprowadza się do wykonywania operacji na kolekcji IQueryable<T>. Zasada jest prosta. Tak długo jak operujemy na tym interfejsie, nasze zapytanie jest budowane w pamięci. W momencie materializowania kolekcji do postaci listy lub tablicy, zapytanie zostaje wysłane do bazy danych. Mając to na uwadze przestudiujmy poniższy kod:

 


public void Test()
{
    var context = new Ctx();

    var results = context.Books.Where(b => b.IsActive).Select(b => b.Title).ToList();

    var results = context.Books.ToList().Where(b => b.IsActive).Select(b => b.Title);
}


 

Przykład prezentuje dwa sposoby pobrania tytułów książek, które nie zostały usunięte soft deletem. Oba zbiory wynikowe są identyczne, ale sposób wykonania zapytań różni się drastycznie:

 


//Zapytanie 1

SELECT
[Extent1].[Title] AS [Title]
FROM [dbo].[Books] AS [Extent1]
WHERE [Extent1].[IsActive] = 1

&nbsp;

//Zapytanie 2

SELECT
[Extent1].[Id] AS [Id],
[Extent1].[Title] AS [Title],
[Extent1].[Genre] AS [Genre],
[Extent1].[Iso] AS [Iso],
[Extent1].[Description] AS [Description],
[Extent1].[Quantity] AS [Quantity],
[Extent1].[AuthorId] AS [AuthorId],
[Extent1].[GraphicId] AS [GraphicId],
[Extent1].[CreatedDate] AS [CreatedDate],
[Extent1].[UpdatedDate] AS [UpdatedDate],
[Extent1].[IsActive] AS [IsActive]
FROM [dbo].[Books] AS [Extent1]

 

W pierwszym przykładzie zapytanie jest takie jakiego się spodziewaliśmy, czyli pobrane zostały jedynie tytuły nieusuniętych książek. W drugim przypadku pobrane zostały wszystkie kolumny, a co gorsza wszystkich książek! Dzieje się tak, ponieważ najpierw zmaterializowaliśmy nasz rezultat, który później „obrabialiśmy” do pożądanej postaci w pamięci komputera. Skutki takiego działania mogą być zabójcze dla wydajności naszych aplikacji. Jasne, pisanie dużych zapytań jest czasochłonne i czasami męczące, ponieważ nie możemy korzystać np. z wszelakich metod rozszerzających, które sobie napiszemy, ale pisanie zapytań SELECT * jest w 99% niedopuszczalne.

 

Nieumiejętne korzystanie z lazy loading

W telegraficznym skrócie lazy loading pozwala nam na dynamiczne pobieranie brakujących danych. Wyobraźmy sobie sytuację w której dla każdej książki chcemy wypisać w konsoli jej tytuł oraz autora. Przykładowy kod wygląda następująco:

 


public void Test()
{
    var context = new Ctx();

    var books = context.Books.Where(b => b.IsActive).ToList();

    foreach (var book in books)
    {
        var text = $"Book title: {book.Title}, Author's name: {book.Author.Name}";
        Console.WriteLine(text);
    }
}

 

Cały haczyk polega na tym, że w momencie pobierania kolekcji książek nie dołączyliśmy do nich danych autora. Wobec tego każdy obiekt książki posiada właściwość nawigacyjną Author o wartości null. Z wyłączonym lazy loadingiem kod nie wykonałby się, ponieważ przy pierwszej próbie wypisania imienia autora otrzymalibyśmy NullReferenceException. W przypadku włączonego LL, po wykryciu nulla Entity Framework wykonana każdorazowo zapytanie pobierające obiekt autora, aby umieścić go jako obiekt nawigacyjny. Jest to tzw. problem N+1 zapytań. Jak temu zaradzić? Użyć Include, a najlepiej wyłączyć dodatkowo lazy loading 😉

 

SaveChanges w pętli

To punkt, który głównie odnosi się do nowej wersji EF. Załóżmy, że chcielibyśmy dokonać aktualizacji wszystkich książek: Przykładowo ustawmy ilość dostępnych sztuk na 1:

 


public void Test()
{
    var context = new Ctx();

    var books = context.Books.Where(b => b.IsActive).ToList();

    foreach (var book in books)
    {
        book.Quantity = 1;
        context.SaveChanges();
    }
}

 

Jak widać metodę SaveChanges wywołujemy w środku pętli foreach. Rezultatem byłoby zatem wykonanie n round tripów do bazy danych, a co gorsza w n różnych transakcjach. Rozwiązanie to przeniesie zapisu po wykonaniu w pętli. W Entity Framework do wersji 6 włącznie, różnica polegałaby jedynie na tym, że wszystkie round tripy odbyły by się w ramach jednej transakcji. W nowej wersji wprowadzony został batch update, dzięki któremu wszystkie zmiany zostałyby zapisane w jednym round tripie i jednej transakcji.

 

Śledzenie danych do odczytu

Tu muszę się przyznać, że niedawno zacząłem to stosować, a wszystko za sprawą tego artykułu. Dzięki metodzie AsNoTracking pobrane dane nie zostają umieszczone w naszym kontekście, a co za tym idzie wszelkie zmiany na obiektach nie są śledzone. Powinniśmy stosować to zawsze przy pobieraniu danych, które chcemy jedynie wyświetlić użytkownikowi. Dzięki temu oszczędzamy znacznie pamięć, którą normalnie musielibyśmy alokować. W skali 1 000 000 obiektów różnica może okazać się kolosalna. Przykład użycia wygląda następująco:

 

var books = context.Books.AsNoTracking().Where(b => b.IsActive).ToList();

 

Nie używanie indeksów

Niedawno napisałem na blogu, czym są indeksy i jak przyspieszają wykonywanie zapytania. Jeżeli ktoś tego wpisu nie czytał, to zachęcam do zapoznania się z nim zanim przejdzie dalej. Link macie tuW skrócie, nałożenie indeksu na kolumnie powoduje, że dane w ramach niego są posortowane. Dzięki temu podczas wykonywania zapytania optymalizator może dużo szybciej znaleźć dane (w strukturze drzewiastej), które chcemy pobrać. Możemy to porównać do książki telefonicznej. Jeżeli chcielibyśmy znaleźć numery telefonu wszystkich Kowalskich wystarczy, że udamy się pod literę K, a następnie wyszukamy nazwiska Kowalski. W przypadku, gdyby książka nie była posortowana alfabetycznie, jedynym wyjściem byłoby przekartkowanie całej książki. Jak nie trudno się domyślić, wykonanie zapytania na tabeli, która nie posiada indeksów trwa znacznie dłużej niż na tabeli, która je ma. Poniżej przykład tworzenia indeksu niezgrupowanego (ang. NonClustered) dla kolumny Genre:

 


[Index("IX_Books_Genre", IsClustered = false)]
public BookGenreEnum Genre { get; set; }

 

Zły dobór typów danych

Bardzo często programiści źle dobierają typ danych dla kolumn. Nie wiem czy wynika to z niewiedzy, czy po prostu z przekonania, że w przypadku języków wysokopoziomowych i baz danych jest to sprawa drugorzędna. Problem ten widać szczególnie przy kluczach głównych. Jaki typ jest odpowiedni? Spora część powie, że int a jeszcze inna – long. Obie odpowiedzi są jednocześnie dobre i złe. Zawsze przy projektowaniu musimy pomyśleć ilu wierszy możemy się spodziewać w danej tabeli. Jeżeli są to gatunki książek, to czy na prawdę potrzebujemy 64-bitowego longa? Nie jestem specjalistą, ale gatunków i podgatunków może być co najwyżej parę tysięcy, jak nie kilkaset. W skali 1 000 000 wierszy, rozmiar bazy danych będzie odczuwalny. Z drugiej strony zbyt „mały” typ danych też jest szkodliwy. Najlepszy przykładem jest YouTube, który musiał zamienić inta na longa, kiedy to film Gangnam Style wywołał przepełnienie integera osiągając ponad 2,147,483,647 wyświetleń. Shame on you YouTube!

 

Przecenianie INSERT

Ostatni podpunkt to klasyczny zabójca wydajności. Wyobraźmy sobie, że bibliotekarz chce dodać na raz 1 000 książek. Kod, który to realizuje wygląda tak:

 




public void Test()
{
    var context = new Ctx();
    var books = new List<BookEntity>();

    for (var i = 0; i < 1000; ++i)
    {
       books.Add(new BookEntity
       {
           Title = $"Book {i}",
           Iso = "ISO",
           Genre = BookGenreEnum.Commedy,
           Quantity = 10,
           AuthorId = 1,
           GraphicId = 1
       });
    }

    context.Books.AddRange(books);
    context.SaveChanges();
}


 

Entity Framework w tym przypadku wykona 1000 osobnych operacji INSERT. Czas wykonywania kodu może okazać się więc bardzo długi, a co najgorsze rośnie liniowo dla większych zbiorów danych. Zdecydowanie lepszym wyjściem jest zastosowanie BULK INSERT. Ja osobiście polecam Wam  bibliotekę. Twórcy przedstawili nawet wykres, który pokazuje czasy wykonania dla poszczególnych operacji:

 

chart

 

Jak widać czas wykonywania operacji BULK INSER jest niemal stały. Użycie biblioteki jest bardzo proste. Po zainstalowaniu nasz kontekst zyska nową metodę do której przekazujemy kolekcję obiektów. Nasz przykład wyglądałby zatem następująco:

 

public void Test()
{
    var context = new Ctx();
    var books = new List<BookEntity>();

    for (var i = 0; i < 1000; ++i)
    {
        books.Add(new BookEntity
       {
           Title = $"Book {i}",
           Iso = "ISO",
           Genre = BookGenreEnum.Commedy,
           Quantity = 10,
           AuthorId = 1,
           GraphicId = 1
       });
    }

    context.BulkInsert(books);
    context.SaveChanges();
}

 

To wszystko na dziś. Mam nadzieję, że wpis się przyda szczególnie początkującym. Jeżeli znacie inne grzechy ciężkie to koniecznie dajcie znać w komentarzach! Jak zwykle zachęcam do śledzenia mojego twittera oraz polubienia strony na facebooku, gdzie pojawiają się najświeższe posty i inne ciekawostki ze świata IT:)

Cześć !

  • Pingback: dotnetomaniak.pl()

  • Bardzo dobry tekst!

    Ale mam parę uwag:
    Lazy loadingu nie ma co wyłączać, po prostu trzeba nauczyć się go używać. Podobnie jak trzeba nauczyć się pisać aplikacje z interfejsem użytkownika i NIE wysyłać do GUI rekordów bazodanowych.
    Co do AsNoTracking – z milionem obiektów w EF nie będzie problemu, bo EF takiej liczby obiektów po prostu nie obsłuży. 😉
    Co do wyboru klucza głównego – jak sam zauważyłeś, gatunków i podgatunków może być co najwyżej kilka tysięcy, więc stosując long zamiast int, stracimy na tym kilka kilobajtów.
    Co do bulk insert – można się posiłkować zewnętrznymi rozwiązaniami, a można po prostu użyć ORMa, który ma to wbudowane. 🙂

    • Dariusz Pawlukiewicz

      Dawid,
      co do lazy loadingu to było to bardziej z przymrużeniem oka, ale zgadzam się, jeśli ktoś wie co robi to jest to fajna opcja. Jeśli chodzi o klucz główny to tak jak napisał @gdn – wybierając 64-bitowego longa zamiast inta przy paru milionach rekordów rozmiar może być widoczny, a mówimy tylko o jednej kolumnie. W przypadku takiej rozrzutności w obrębie całej bazy może się okazać, że po 2-3 latach nasze dane zajmują bardzo dużo miejsca. Co do bulk inserta to chyba nie widziałem gotowego rozwiązania w EF.

  • gdn

    @Dawid K
    Jeśli kluczem w tabeli z rodzajami będzie long, a nie int, to rzeczywiście ta tabela będzie miała niewiele więcej. Ale jeśli w tabeli z książkami będzie milion rekordów i każdy miał odwołanie do tabeli z rodzajami i to użycie longa zamiast inta będzie skutkowało dużo większym przyrostem. Jeszcze większy przyrost będzie jeśli pole rodzajem książki w tabeli z książkami będzie zaindeksowane, bo indeksy na longu zajmują więcej miejsca niż na incie.

  • MIBa

    co do doboru danych… może Guid?
    ( nie móię o prrzesłaiu usera na frontend ponizej )
    wchodze na stronę, a tam :
    /api/Users/2378
    to próbuję:
    /api/Users/2377
    i próbuję iterować
    w najgorszym wypadku odkrywam lukę w zabezpieczenieach, a po co ?
    /api/Users/c904387e-6fa1-4caa-95f2-3a1f9191b4d9
    nie mówię że to jest jakkolwiek zabezpieczone, ale iterować sie nie da.
    dla obiektów z wysokim „C” to jest istotne

    • Dariusz Pawlukiewicz

      MIBa,
      To o czym piszesz faktycznie mogłoby być słabe. Dlatego według mnie lepiej takich danych jak id nie zwracać bezpośrednio na klienta, a przynajmniej ograniczyć to do minimum. Dane mojego usera równie dobrze mogę pobrać GET-em bez parametru, a UserId wyciągnąć z Identity w kontrolerze. Co do Gid-a to ma on swoje plusy i minusy. Minus to zdecydowanie rozmiar oraz fakt, że nie nadaje się jako kolumna dla clustered index. Plus to jego unikalność, która może być przydatna w aplikacjach, które wymagają generowania kluczy głównych poza bazą danych np. aplikacje działające w trybie offline.

  • Super. Ogromne dzięki za informacje o bulk insert. Artykuł i podejście takie jak u mnie czyli najlepsze 🙂

    • Dariusz Pawlukiewicz

      jstadnicki,
      wielkie dzięki 🙂

  • Pingback: wyrmspel()

  • Pingback: IN Vitro ADME()

  • Pingback: cpns-2018.com()

  • Pingback: DMPK Assays()

  • Pingback: cpns 2018 news()

  • Pingback: how to make money with a iphone()

  • Pingback: Aws coehuman Diyala()

  • Pingback: lowongan kerja 2018()

  • Pingback: corporate event planning companies()

  • Pingback: find real estate agent()

  • Pingback: top real estate agents()