data.mdx.frontmatter.hero_image

Jak ubiłem serwer bazy danych (prawie)

2018-11-01 | .NET | bd90

Wyobraźmy sobie świat, w którym rodzi się programista idealny. W momencie natrafienia na dany język łapie składnie, wykorzystanie, dobre praktyki, a haków unika niczym ognia. Mając 4 lata sam jeden jest wstanie napisać algorytm rozpoznawania twarzy i to bez kontroli wersji i kolorowania składni. Nie popełnia żadnych błędów, choć nie wymyślania nowatorskich rozwiązań. I pewnie szefom teamów developerskim otwierają się koperty pełne multisportów by obsypać pierwszego z nowej rasy Homo Programikus. To jednak tylko bajka. My, ludzcy programiści, popełniamy błędy. Nie tylko, choć głównie, podczas swoich pierwszych kroków. Jak najlepiej nauczyć się ich unikać? Zmieniając branże lub studiując studia przypadków, jak ten poniżej.

W tym artykule chciałbym wam przedstawić krótką historię o tym ,jak o mały włos nie ubiłem bazy danych. Historia w moim przypadku zakończyła się bez większych szkód, jednak mogło być znacznie gorzej gdybym szybko nie znalazł błędu w kodzie. Tak wiec teraz przejdźmy do opisu problemu i rozwiązania. Dla potomnych!

Etap I - Problem

Niestety, nie mogę wam przedstawić problemu jeden do jednego. Zobrazuje go na podstawie innej domeny. Powiedzmy, że prowadzimy serwis typu iTunes. Mamy w bazie mnóstwo piosenek, ponieważ małego serwisu byśmy nie prowadzili (mamy swoje ambicje :) Pewnego dnia przychodzi do nas Product Manager i mówi, że do godziny 15 potrzebuje podstrony umożliwiającej filtrowanie piosenek wybranego artysty zgodnie z datą nagrania. Patrzymy na zegarek... 12.30... hmmm czerwona lampka się świeci, że trochę mało czasu, jednak feature nie jest zbyt skomplikowany. Przecież używamy ORM-a, gdzie za pomocą LINQ wystarczy kilka wywołań funkcji i myk zrobione. Podejmujemy decyzję: "Challange Accepted!"

Patrzymy na naszą encję, która w dużym uproszczeniu wygląda następująco

public class Song
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Artist { get; set; }
    public DateTime DateAdded { get; set; }
    public DateTime DateCreated { get; set; }
}

Etap II Błąd

No to jedziemy! Czytamy dokumentację EntityFramework. Znajdujemy tam wsparcie funkcji Where z LINQ. Piszemy pierwszą implementację wyglądająca mniej więcej tak:

using (var context = new DemoContext())
{
    var songs = context.Songs
        .ToList()
        .Where(x => x.Artist == "Ghost")
        .Where(x => x.DateCreated == DateTime.Today.AddDays(-7))
        .Select(x => new { x.Id, x.Title })
        .ToList();
}

Kod się kompiluje, działa, zwraca poprawny wynik. Przekazujemy dane do kontrolera. Klepiemy szybki UI na bootstrapie w panelu admina i zadowoleni wracamy do swoich zadań.

Nagle przychodzi sms: coś nie dobrego dzieje się z bazą danych! Po wprowadzeniu feature-a CPU skoczyło o kilka procent. Ale dlaczego? Przecież wszystko było dobrze, sprawdziliśmy na własnej maszynie, na testowej bazie danych... nawet udało nam się napisać test jednostkowy gdzie zamockowaliśmy EntityFramework. Co tu się #odjaniepawliło?

Etap III Żal za grzechy

Już tak niewiele brakowało, aby wyjść do domu... :( No nic, czas na kubek ciepłego kakała i rozpoczęcie debugowania. Pierwsza myśl, sprawdźmy jakie zapytanie jest generowane do bazy danych. Tak więc odpalamy projekt, przeklikujemy się przez zakładki Console i Debug ale nie ma tam żadnych informacji na temat wykonywanych zapytań SQL. No dobra, skoro nikt tego nie skonfigurował, to czas najwyższy. Po odszukaniu dbContext i zmodyfikowaliśmy go tak, by korzystał z LoggerFactory.

public class DemoContext : DbContext
{
    public DbSet<Song> Songs { get; set; }
    
    public static readonly LoggerFactory _myLoggerFactory = 
        new LoggerFactory(new[] { 
            new Microsoft.Extensions.Logging.Debug.DebugLoggerProvider() 
        });

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseLoggerFactory(_myLoggerFactory)
            .UseSqlServer(@"Server=localhost;User ID=sa;Password=yourStrong(!)Password;Initial Catalog=EFTest;");
        base.OnConfiguring(optionsBuilder);
    }
}

Oczywiście instancja logger-a była pobierana z DI a connection string pobierany z konfiguracji projektu. Ponieważ odtwarzam błąd w środowisku aplikacji konsolowej, zdecydowałem się je dodać tutaj, aby jak najbardziej uprościć kod aplikacji.

Wróćmy jednak do drugiej osoby liczby mnogiej. Wtem, nagle, jak znikąd, zauważyliśmy potworka, który został wygenerowany przez ORM.

SELECT [s].[Id], [s].[Artist], [s].[DateAdded], [s].[DateCreated], [s].[Title]
FROM [Songs] AS [s]

Składnia ta powodowała, że do aplikacji była zaciągana cała baza danych. Filtrowanie wyników następowało w samej aplikacji, co niepotrzebnie zużywa dużo pamięci. Zapomniałem, że wykonanie na dbContext metody ToList od razu ładuje cała bazę :( Chwila nieuwagi, która mogła być bardzo kosztowna. Na testowej bazie danych było zbyt mało rekordów, aby zauważyć znaczącą różnicę.

Etap IV Postanowienie poprawy

Zrobienie tylko jednej małej zmiany znacząco poprawiło wydajność aplikacji i zmniejszyło obciążenie CPU bazy danych. Wystarczyło w LINQ przerobić wywołanie metody ToList na rzecz AsQueryable.

using (var context = new DemoContext())
{
    var songs = context.Songs
        .AsQueryable()
        .Where(x => x.Artist == "Ghost")
        .Where(x => x.DateCreated == DateTime.Today.AddDays(-7))
        .Select(x => new { x.Id, x.Title })
        .ToList();
}

Teraz EntityFramework mógł wygenerować dużo bardziej wydajne zapytanie SQL.

SELECT [x].[Id], [x].[Title]
FROM [Songs] AS [x]
WHERE ([x].[Artist] = N'Ghost') AND ([x].[DateCreated] = DATEADD(day, -7E0, CONVERT(date, GETDATE())))

Co ciekawe, dzięki wykorzystaniu metody SELECT z LINQ, Entity Framework potrafił od razu, na bazie danych, wykonać projekcje danych. Dlatego zostały pobrane tylko kolumny używane w kodzie.

Podsumowanie

Ten artykuł ma na celu uświadomienie, że w pośpiechu robimy różne dziwne rzeczy, które potem mają negatywny wpływ na stabilność, wydajność czy też utrzymanie projektu. Starajcie się zawsze podążać za standardem. Nie zależnie od tempa pracy zastanówcie się, czy macie odpowiednią ilość czasu na stworzenie feature-a, jego testy i wdrożenie.

Mam nadzieje że artykuł się podobał. Jeżeli macie jakieś inne ciekawe historię tego typu to śmiało możecie się nimi podzielić w komentarzach :)

Do Następnego!

Cześć

By Bd90 | 01-11-2018 | .NET