Jak profilować zapytania w Entity Framework?

Wprowadzenie

Entity Framework jest bardzo fajnym narzędziem, znacznie ułatwiającym pracę z bazami danych. Budowanie zapytań SQL na podstawie Linq, tworzenie struktury bazy danych na podstawie obiektów w C# czy mechanizm migracji powodują, że wielu programistów .NET wybiera właśnie Entity Framework. Niestety narzędzie to ma również swoje ciemne strony. Wystarczy chociażby wrócić do wpisu, w którym poruszałem problem aktualizowania oraz usuwania danych.

Pracując z Entity Framework, należy analizować, jakie operacje są wykonywane na bazie. Bardzo łatwo na przykład o wykonywanie zbyt dużej liczby operacji na bazie danych czy wykonywanie ich w nieefektywny sposób. Dlatego warto od czasu do czasu (a najlepiej przez cały czas) korzystać z jakiegoś profilera, w którym będziemy dokładnie widzieli, co dzieje się w bazie. Można skorzystać z jakiegoś profilera do silnika bazy danych, którego używamy. Czasami lepszym rozwiązaniem jest skorzystanie z profilera dedykowanego dla Entity Framework, który daje większe możliwości. W tym wpisie właśnie pokażę jeden z dostępnych profilerów – EFProf – http://hibernatingrhinos.com/products/efprof.

We wpisie przyjrzymy się również temu, jakie są najczęściej popełniane błędy, które może nam wyłapać profiler. Zobaczysz też, w jaki sposób można je poprawić, aby aplikacja działała wydajniej.

EFProf

Entity Framework Profiler jest jednym z profilerów dostępnych dla Entity Framework. Używam go od wielu lat i jestem zadowolony z jego działania. Jest on płatny, ale można też przetestować go za darmo przez 30 dni.

Profiler udostępnia szereg informacji o wykonywanych zapytaniach. Mamy przede wszystkim listę wszystkich obiektów kontekstowych oraz zapytań wykonywanych przez poszczególne obiekty:

efprof 1

Widzimy również informacje o poszczególnych zapytaniach: ich treść, czas, liczbę zwróconych wierszy. Dodatkowo mamy też informację o stosie wywołań. Jest to istotne, szczególnie gdy korzystamy z lazy loading. Wtedy możemy bardzo łatwo znaleźć miejsca, w których wykonywane są dodatkowe zapytania (szczególnie gdy w aplikacji występuje problem N + 1 zapytań, o którym będzie mowa w dalszej części wpisu). Możemy również łatwo przejść do miejsca w kodzie w Visual Studio z profilera (klikamy dwa razy na wywołanie w stosie).

Skorzystanie z profilera w aplikacji jest bardzo proste. Wystarczy dodać referencje do biblioteki HibernatingRhinos.Profiler.Appender.dll i przy starcie aplikacji wywołać metodę HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize();

public class MvcApplication : System.Web.HttpApplication
{
protected void Application_Start()
{
HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize();
AutofacConfig.Configure();
AreaRegistration.RegisterAllAreas();
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
RouteConfig.RegisterRoutes(RouteTable.Routes);
BundleConfig.RegisterBundles(BundleTable.Bundles);
}
}

Profiler udostępnia też sporo ciekawych statystyk oraz umożliwia analizę zapytań.

Alerty

Kolejną bardzo fajną funkcjonalnością (z której korzystam najczęściej) są alerty. Rozpoczęcie prazy z Entity Framework jest bardzo łatwe, ale niestety równie łatwo o popełnienie prostych błędów, które później dość mocno rzutują na wydajność aplikacji. Właśnie EFProf potrafi wyłapywać takie sytuacje i informuje nas o tym właśnie w formie alertów.

Na powyższym zrzucie ekranu, w liście obiektów kontekstowych widać kolorowe kropki (szara – problemy mniej istotne oraz czerwona – problemy bardzo istotne). Informują one o tym, że dany kontekst wykonał problematyczne zapytanie lub zapytania. Podobne kropki widać w liście wykonanych zapytań. Po wybraniu zapytania możemy przejrzeć alerty w zakładce Alerts pod listą zapytań:

efprof 2

Jak widać na powyższym zrzucie, zaznaczone zapytanie wygenerowało dwa alerty. Jeden bardzo istotny (SELECT N + 1 – kolor czerwony) oraz drugi mniej istotny (Unbounded result set – kolor szary). Profiler udostępnia linki (read more), które wskazują na artykuły z informacją o błędzie mogącym mieć konsekwencje związane z działaniem aplikacji oraz pokazują, jak je naprawić.

Zobaczmy kilka najczęściej popełnianych błędów.

Darmowy kurs Visual Studio

Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.

Przygotowałem 30 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE.

Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.

Quiz C#

Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?

SELECT N + 1

Lazy Loading jest mechanizmem, który ułatwia tworzenie aplikacji. Nie musimy pisać kolejnych zapytań, aby pobrać powiązane dane. Niestety korzystanie z Lazy Loadingu na dłuższą metę może mieć sporo konsekwencji w wydajności. Jednym z problemów z Lazy Loading jest problem N + 1 zapytań. Najlepiej ów problem zobaczyć na przykładzie.

W przykładowej aplikacji przygotowałem dwie klasy, który posłużą nam do testów. Jest to klasa Category oraz klasa Product. Są one powiązane między sobą relacją jeden do wielu:

public class Category : BaseModel
{
public string Name { get; set; }
public virtual ICollection<Product> Products { get; set; }
}
view raw Category.cs hosted with ❤ by GitHub
public class Product : BaseModel
{
public string Name { get; set; }
public int CategoryId { get; set; }
public virtual Category Category { get; set; }
}
view raw Product.cs hosted with ❤ by GitHub

Dane z bazy są pobierane za pomocą prostego repozytorium:

public partial class CategoryRepository : Repository<Category>, ICategoryRepository
{
public CategoryRepository(Lazy<DataContext> db)
: base(db)
{
}
public override IEnumerable<Category> GetAllActive()
{
return DataContext.Set<Category>()
.Where(e => e.IsActive)
.ToList();
}
}

Testowa akcja w kontrolerze zwraca w formie jsona listę wszystkich kategorii z listą produktów przypisaną do każdej z kategorii. Kontroler pobiera dane za pomocą repozytorium oraz mapuje je z wykorzystaniem Automappera do viewmodeli i zwraca z akcji:

public class CategoriesController : Controller
{
private Lazy<ICategoryRepository> _categoryRepository;
protected ICategoryRepository CategoryRepository
{
get { return _categoryRepository.Value; }
}
private Lazy<IMapper> _mapper;
protected IMapper Mapper
{
get { return _mapper.Value; }
}
public CategoriesController(Lazy<ICategoryRepository> categoryRepository,
Lazy<IMapper> mapper)
{
_categoryRepository = categoryRepository;
_mapper = mapper;
}
public ActionResult LazyLoading()
{
var categories = CategoryRepository.GetAllActive();
var viewModel = Mapper.Map<IEnumerable<CategoryViewModel>>(categories);
return Json(viewModel, JsonRequestBehavior.AllowGet);
}
}
public class CategoryViewModel
{
public string Name { get; set; }
public IEnumerable<ProductViewModel> Products { get; set; }
}
public class CategoryProfile : Profile
{
public CategoryProfile()
{
CreateMap<Category, CategoryViewModel>();
CreateMap<Product, ProductViewModel>();
}
}

Wykonanie testowej akcji w profilerze wygląda tak:

efprof 3

Jak widać, wykonanych zostało łącznie 11 zapytań (aplikacja podczas tworzenia struktury bazy danych dodaje testowe dane z wykorzystaniem biblioteki Bogus). Pierwsze zapytanie zwraca listę aktywnych kategorii, natomiast każde kolejne zwraca listę produktów z poszczególnych kategorii. Jest to, jak można zauważyć, bardzo nieefektywne. Z jednej strony powoduje, że akcja wykonuje się dużo dłużej. Z drugiej strony natomiast baza danych jest bardziej obciążona, przez co może obsłużyć mniejszą liczbę użytkowników.

Profiler podpowiada nam, w którym miejscu naszego kodu wykonywane są dodatkowe zapytania (powyższy zrzut ekranu pokazuje zakładkę Stack Trace drugiego zapytania). Jak widać, zapytanie odbywa się podczas mapowania i powodowane jest przez Lazy Loading – podczas mapowania właściwości Products z klasy Category. Użycie profilera tutaj jest o tyle przydatne, że w praktyce nie mamy jawnie wywołanego użycia właściwości Products, przez co można łatwo to przegapić. Wielokrotnie widziałem projekty, w których pod spodem było wykonywane takie mapowanie z dodatkowymi zapytaniami, a programiści nie byli tego świadomi do momentu uruchomienia profilera.

SELECT N + 1 – rozwiązanie

Problem ten można rozwiązać na kilka sposób. Najprostszym i najszybszym, który nie wymaga zbyt wielu zmian w kodzie, jest skorzystanie z metody Include. Metoda ta powoduje, że powiązane encje zostaną załadowane w jednym zapytaniu. W przykładzie dodałem do repozytorium nową metodę (GetAllActiveWithProducts), która zwraca listę aktywnych kategorii z od razu załadowanymi produktami (Early Loading). W kontrolerze dodałem drugą akcję (EarlyLoading), która zwraca tego samego jsona, ale wykorzystuje nową metodę z repozytorium:

public partial class CategoryRepository : Repository<Category>, ICategoryRepository
{
public CategoryRepository(Lazy<DataContext> db)
: base(db)
{
}
public override IEnumerable<Category> GetAllActive()
{
return DataContext.Set<Category>()
.Where(e => e.IsActive)
.ToList();
}
public IEnumerable<Category> GetAllActiveWithProducts()
{
return DataContext.Categories.Include(c => c.Products).Where(c => c.IsActive);
}
}
public class CategoriesController : Controller
{
private Lazy<ICategoryRepository> _categoryRepository;
protected ICategoryRepository CategoryRepository
{
get { return _categoryRepository.Value; }
}
private Lazy<IMapper> _mapper;
protected IMapper Mapper
{
get { return _mapper.Value; }
}
public CategoriesController(Lazy<ICategoryRepository> categoryRepository,
Lazy<IMapper> mapper)
{
_categoryRepository = categoryRepository;
_mapper = mapper;
}
// GET: Categories
public ActionResult Index()
{
var categories = CategoryRepository.GetAllActive(1, 10);
return View(categories);
}
public ActionResult LazyLoading()
{
var categories = CategoryRepository.GetAllActive();
var viewModel = Mapper.Map<IEnumerable<CategoryViewModel>>(categories);
return Json(viewModel, JsonRequestBehavior.AllowGet);
}
public ActionResult EarlyLoading()
{
var categories = CategoryRepository.GetAllActiveWithProducts();
var viewModel = Mapper.Map<IEnumerable<CategoryViewModel>>(categories);
return Json(viewModel, JsonRequestBehavior.AllowGet);
}
}

Wykonanie nowej akcji w profilerze wygląda tak:

efprof 4

Tym razem wykonało się tylko jedno zapytanie, które zwraca dane z dwóch tabel z użyciem LEFT OUTER JOIN.

Unbounded Result Set

Kolejnym często popełnianym błędem jest nielimitowanie liczby zwracanych danych. W testowych bazach danych na ogół nie mamy zbyt wielu danych, przez co bardzo łatwo o przegapienie tego, że jakiś widok może mieć problem z wydajnością, gdy danych będzie zbyt dużo. Podczas testów, gdy widok wyświetlał 10 rekordów, wszystko było w porządku, natomiast po wdrożeniu u klienta nagle zaczynają się problemy, ponieważ aplikacja pobiera 100 tys. obiektów, które następnie próbuje wyświetlić.

Dlatego warto zawsze w jakiś sposób ograniczać liczbę zwracanych rekordów. EFProf przypomina nam o tym w formie alertu. Najlepiej zobaczyć to na przykładzie. Akcja Index z ProductController zwraca listę wszystkich produktów w bazie:

public class ProductsController : Controller
{
private Lazy<IProductRepository> _productRepository;
protected IProductRepository ProductRepository
{
get { return _productRepository.Value; }
}
private Lazy<IMapper> _mapper;
protected IMapper Mapper
{
get { return _mapper.Value; }
}
public ProductsController(Lazy<IProductRepository> productRepository,
Lazy<IMapper> mapper)
{
_productRepository = productRepository;
_mapper = mapper;
}
// GET: Products
public ActionResult Index()
{
var products = ProductRepository.GetAllActive();
var viewModels = Mapper.Map<IEnumerable<ProductViewModel>>(products);
return Json(viewModels, JsonRequestBehavior.AllowGet);
}
}

Wykonanie akcji Index w profilerze wygląda tak:

efprof 5

Widać, że wykonało się jedno zapytanie, które ma dwa alerty: Large number of rows returned oraz Unbounded result set. Pierwszy wyświetlił się, ponieważ zapytanie zwróciło wiele danych (2300 wierszy). Co istotne, wyświetla się ono, gdy mamy wiele danych w bazie. Nie zobaczymy go, gdy danych jest mało (zobacz pierwszy przykład – ów alert nie pojawił się, gdy wyświetlanych było 10 kategorii). Przy małych testowych bazach możemy w ogóle nie zobaczyć tego alertu.

Myślę, że drugi alert jest istotniejszy. Pojawia się, gdy w zapytaniu nie ma ograniczenia liczby danych. Warto za każdym razem zastanowić się, czy liczba danych w produkcyjnych aplikacjach będzie mała czy duża. Na przykład w tabeli typowo słownikowej (np. status zamówienia) nie będziemy mieć dużej liczby danych, więc nie musimy się tym problemem przejmować. Natomiast w przypadku produktów – już tak.

Unbounded Result Set – rozwiązanie

Najprostszym rozwiązaniem tego problemu jest pobieranie danych w paczkach (np. po 100 elementów). Od strony interfejsu użytkownika możemy wykorzystać stronicowanie lub coraz popularniejsze w ostatnim czasie wirtualne ładowanie danych (aplikacja doładowuje nowe dane, gdy użytkownik dojdzie do końca wcześniej załadowanych danych).

W przykładzie dodałem proste stronicowanie:

public class ProductsController : Controller
{
private Lazy<IProductRepository> _productRepository;
protected IProductRepository ProductRepository
{
get { return _productRepository.Value; }
}
private Lazy<IMapper> _mapper;
protected IMapper Mapper
{
get { return _mapper.Value; }
}
public ProductsController(Lazy<IProductRepository> productRepository,
Lazy<IMapper> mapper)
{
_productRepository = productRepository;
_mapper = mapper;
}
// GET: Products
public ActionResult Index()
{
var products = ProductRepository.GetAllActive();
var viewModels = Mapper.Map<IEnumerable<ProductViewModel>>(products);
return Json(viewModels, JsonRequestBehavior.AllowGet);
}
public ActionResult IndexWithPagination()
{
var products = ProductRepository.GetAllActive(1, 20);
var viewModels = Mapper.Map<IEnumerable<ProductViewModel>>(products);
return Json(viewModels, JsonRequestBehavior.AllowGet);
}
}
public partial class ProductRepository : Repository<Product>, IProductRepository
{
public ProductRepository(Lazy<DataContext> db)
: base(db)
{
}
public override IEnumerable<Product> GetAllActive(int page, int pageSize)
{
return DataContext.Set<Product>()
.Where(e => e.IsActive)
.OrderBy(e => e.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize).ToList();
}
}

Wynik wykonania akcji w profilerze:

efprof 6

Tym razem nie mamy już alertów i widać, że liczba zwróconych wierszy jest znacznie mniejsza – nie będzie powodować problemów z wyświetlaniem danych w aplikacji.

Przykład

Tradycyjnie na githubie (https://github.com/danielplawgo/EFProfExample) znajduje się przykład do wpisu. Po jego pobraniu należy w web.config ustawić connection stringa do bazy testowej. Pierwsze uruchomienie aplikacji spowoduje wygenerowanie struktury bazy danych oraz dodanie danych testowych. Na stronie głównej znajdują się linki do poszczególnych akcji, które możesz przetestować.

Podsumowanie

Entity Framework jest bardzo fajnym narzędziem, które lubię wykorzystywać w projektach. Bardzo przydatny jest przede wszystkim mechanizm migracji. Niestety nieumiejętne korzystanie z Entity Framework może spowodować sporo problemów. Dlatego warto od czasu do czasu przeanalizować to, jak z niego korzystamy. Do tego przydaje się dedykowany profiler, który może znaleźć problemy i nas o nich poinformować. Na przykładzie pokazałem Ci dwa najczęściej popełniane błędy, ale EFProf potrafi jeszcze znacznie więcej.

5 thoughts on “Jak profilować zapytania w Entity Framework?

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.