Udostępnij za pośrednictwem


Antywzorzec dotyczący dużej liczby operacji we/wy

Skumulowany efekt wysyłania wielu żądań we/wy może mieć znaczący wpływ na wydajność i czas odpowiedzi.

Opis problemu

Wywołania sieciowe i inne operacje we/wy są z założenia wolniejsze niż zadania obliczeniowe. Każde żądanie operacji we/wy zwykle generuje znaczne obciążenie, dlatego skumulowany efekt wielu takich operacji może spowolnić system. Poniżej przedstawiono niektóre typowe przyczyny występowania dużej liczby operacji we/wy.

Odczytywanie poszczególnych rekordów z bazy danych i ich zapisywanie za pomocą odrębnych żądań

Poniższy przykład dotyczy odczytywania danych z bazy danych produktów. Są trzy tabele: Product, ProductSubcategory i ProductPriceListHistory. Kod zawiera szereg zapytań, które pobierają wszystkie produkty z podkategorii razem z informacjami o cenach:

  1. Wykonaj zapytanie względem podkategorii z tabeli ProductSubcategory.
  2. Znajdź wszystkie produkty z tej podkategorii, wykonując zapytanie na tabeli Product.
  3. Dla każdego produktu wykonaj zapytanie względem cen na tabeli ProductPriceListHistory.

Aplikacja używa platformy Entity Framework w celu wykonywania zapytań na bazie danych. Pełny przykład można znaleźć tutaj.

public async Task<IHttpActionResult> GetProductsInSubCategoryAsync(int subcategoryId)
{
    using (var context = GetContext())
    {
        // Get product subcategory.
        var productSubcategory = await context.ProductSubcategories
                .Where(psc => psc.ProductSubcategoryId == subcategoryId)
                .FirstOrDefaultAsync();

        // Find products in that category.
        productSubcategory.Product = await context.Products
            .Where(p => subcategoryId == p.ProductSubcategoryId)
            .ToListAsync();

        // Find price history for each product.
        foreach (var prod in productSubcategory.Product)
        {
            int productId = prod.ProductId;
            var productListPriceHistory = await context.ProductListPriceHistory
                .Where(pl => pl.ProductId == productId)
                .ToListAsync();
            prod.ProductListPriceHistory = productListPriceHistory;
        }
        return Ok(productSubcategory);
    }
}

W tym przykładzie przedstawiono ten problem jawnie, ale może on być zamaskowany w przypadku niejawnego pobierania pojedynczych rekordów podrzędnych w danym rozwiązaniu mapowania obiektowo-relacyjnego. Zjawisko to nosi nazwę „problemu N+1”.

Implementowanie pojedynczej operacji logicznej jako serii żądań HTTP

To często zdarza się, gdy deweloperzy próbują stosować model zorientowany obiektowo i traktują obiekty zdalne jak lokalne obiekty w pamięci. Może to powodować zbyt intensywną komunikację dwustronną. Na przykład następujący internetowy interfejs API udostępnia poszczególne właściwości obiektów User za pośrednictwem pojedynczych metod HTTP GET.

public class UserController : ApiController
{
    [HttpGet]
    [Route("users/{id:int}/username")]
    public HttpResponseMessage GetUserName(int id)
    {
        ...
    }

    [HttpGet]
    [Route("users/{id:int}/gender")]
    public HttpResponseMessage GetGender(int id)
    {
        ...
    }

    [HttpGet]
    [Route("users/{id:int}/dateofbirth")]
    public HttpResponseMessage GetDateOfBirth(int id)
    {
        ...
    }
}

Od strony technicznej metoda ta jest poprawna, ale w przypadku większości klientów prawdopodobnie trzeba pobrać kilka właściwości dla każdego obiektu User. W efekcie kod klienta jest podobny do poniższego.

HttpResponseMessage response = await client.GetAsync("users/1/username");
response.EnsureSuccessStatusCode();
var userName = await response.Content.ReadAsStringAsync();

response = await client.GetAsync("users/1/gender");
response.EnsureSuccessStatusCode();
var gender = await response.Content.ReadAsStringAsync();

response = await client.GetAsync("users/1/dateofbirth");
response.EnsureSuccessStatusCode();
var dob = await response.Content.ReadAsStringAsync();

Odczytywanie i zapisywanie do pliku na dysku

Operacje we/wy na plikach wymagają otwarcia pliku i przejścia do odpowiedniego miejsca przed odczytem lub zapisem danych. Po zakończeniu tej operacji można zamknąć plik w celu oszczędzania zasobów systemu operacyjnego. Aplikacja, która stale odczytuje małe ilości danych i zapisuje je do pliku, powoduje znaczne obciążenie we/wy. Żądania zapisu małych porcji danych mogą również powodować fragmentację pliku, co dodatkowo spowalnia kolejne operacje we/wy.

W poniższym przykładzie obiekt Customer jest zapisywany do pliku za pomocą obiektu FileStream. Po utworzeniu obiektu FileStream plik jest otwierany, a po usunięciu tego obiektu plik jest zamykany. (Instrukcja using automatycznie usuwa FileStream obiekt). Jeśli aplikacja wielokrotnie wywołuje tę metodę w miarę dodawania nowych klientów, obciążenie we/wy może szybko wzrosnąć.

private async Task SaveCustomerToFileAsync(Customer customer)
{
    using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        byte [] data = null;
        using (MemoryStream memStream = new MemoryStream())
        {
            formatter.Serialize(memStream, customer);
            data = memStream.ToArray();
        }
        await fileStream.WriteAsync(data, 0, data.Length);
    }
}

Jak rozwiązać ten problem

Upakowanie danych w większe, rzadsze żądania pozwala zmniejszyć liczbę żądań we/wy.

Dane z bazy danych pobieraj w jednym zapytaniu, a nie w kilku mniejszych zapytaniach. Oto poprawiona wersja kodu, który pobiera informacje o produktach.

public async Task<IHttpActionResult> GetProductCategoryDetailsAsync(int subCategoryId)
{
    using (var context = GetContext())
    {
        var subCategory = await context.ProductSubcategories
                .Where(psc => psc.ProductSubcategoryId == subCategoryId)
                .Include("Product.ProductListPriceHistory")
                .FirstOrDefaultAsync();

        if (subCategory == null)
            return NotFound();

        return Ok(subCategory);
    }
}

W przypadku internetowych interfejsów API postępuj zgodnie z zasadami projektowania REST. Poniżej przedstawiono poprawioną wersję internetowego interfejsu API z wcześniejszego przykładu. Zamiast oddzielnych metod GET dla każdej właściwości używana jest jedna metoda GET, która zwraca obiekt User. Powoduje to wydłużenie treści odpowiedzi na jedno żądanie, ale liczba wywołań interfejsu API w poszczególnych klientach prawdopodobnie się zmniejszy.

public class UserController : ApiController
{
    [HttpGet]
    [Route("users/{id:int}")]
    public HttpResponseMessage GetUser(int id)
    {
        ...
    }
}

// Client code
HttpResponseMessage response = await client.GetAsync("users/1");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadAsStringAsync();

W przypadku operacji we/wy na plikach rozważ buforowanie danych w pamięci, a następnie ich zapisywanie do pliku w jednej operacji. Taka metoda zmniejsza obciążenie związane z wielokrotnym otwieraniem i zamykaniem pliku oraz ułatwia ograniczenie fragmentacji pliku na dysku.

// Save a list of customer objects to a file
private async Task SaveCustomerListToFileAsync(List<Customer> customers)
{
    using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        foreach (var customer in customers)
        {
            byte[] data = null;
            using (MemoryStream memStream = new MemoryStream())
            {
                formatter.Serialize(memStream, customer);
                data = memStream.ToArray();
            }
            await fileStream.WriteAsync(data, 0, data.Length);
        }
    }
}

// In-memory buffer for customers.
List<Customer> customers = new List<Customers>();

// Create a new customer and add it to the buffer
var customer = new Customer(...);
customers.Add(customer);

// Add more customers to the list as they are created
...

// Save the contents of the list, writing all customers in a single operation
await SaveCustomerListToFileAsync(customers);

Kwestie wymagające rozważenia

  • W pierwszych dwóch przykładach liczba wywołań operacji we/wy jest mniejsza, ale ilość pobieranych informacji jest większa. Musisz wziąć pod uwagę zależności związane z tymi czynnikami. Poprawne rozwiązanie będzie zależeć od wzorców rzeczywistego użycia. Być może w przykładzie zawierającym internetowy interfejs API w większości przypadków klienci potrzebują tylko nazwy użytkownika. W takiej sytuacji sensowne wydaje się jej udostępnienie w oddzielnym wywołaniu interfejsu API. Aby uzyskać więcej informacji, zobacz antywzorzec Nadmiarowe pobieranie.

  • Do odczytywania danych nie używaj żądań we/wy o dużym rozmiarze. Aplikacja powinna pobierać tylko te informacje, które najprawdopodobniej zostaną użyte.

  • Czasami warto podzielić informacje dotyczące obiektu na dwie części: często używane dane, które pojawiają się w większości żądań, i rzadko używane dane. Często zdarza się, że te pierwsze stanowią stosunkowo małą część wszystkich danych obiektu, dlatego zwracanie tylko tych danych może znacznie zmniejszyć obciążenie we/wy.

  • Zapisując dane, unikaj blokowania zasobów dłużej niż jest to konieczne, aby zmniejszyć prawdopodobieństwo rywalizacji o nie podczas długotrwałej operacji. Jeśli operacja zapisu obejmuje wiele plików, usług lub magazynów danych, korzystaj z ostatecznie spójnych metod. Zobacz Wskazówki dotyczące spójności danych.

  • Awaria procesu stanowi zagrożenie dla danych buforowanych w pamięci. Jeśli występują momenty, gdy ilość przesyłanych danych rośnie lawinowo, lub dane są stosunkowo rozrzedzone, bezpieczniejsze może być ich buforowanie w trwałej kolejce zewnętrznej, takiej jak usługa Event Hubs.

  • Weź pod uwagę buforowanie danych pobieranych z usługi lub bazy danych. Może to zmniejszyć liczbę operacji we/wy dzięki wyeliminowaniu ponownych żądań tych samych danych. Aby uzyskać więcej informacji, zobacz Najlepsze rozwiązania dotyczące buforowania.

Jak wykryć problem

Objawy występowania wielu operacji we/wy to duże opóźnienie i niska przepływność. Użytkownicy końcowi mogą zgłaszać wydłużenie czasu odpowiedzi lub błędy związane z przekroczeniem limitu czasu usług, spowodowane nasileniem rywalizacji o zasoby we/wy.

Aby ułatwić ustalenie przyczyn tych problemów, można wykonać następujące kroki:

  1. Monitoruj procesy systemu produkcyjnego, aby dowiedzieć się, które operacje mają długi czas odpowiedzi.
  2. Wykonaj testy obciążenia dla każdej operacji zidentyfikowanej w poprzednim kroku.
  3. W ramach tych testów zbierz dane telemetryczne żądań dostępu do danych w każdej operacji.
  4. Zbierz szczegółowe statystyki każdego żądania wysyłanego do magazynu danych.
  5. Utwórz profil aplikacji w środowisku testowym, aby ustalić potencjalne wąskie gardła dla operacji we/wy.

Poszukaj dowolnych z tych objawów:

  • Wiele żądań we/wy o małym rozmiarze dotyczących tego samego pliku.
  • Wiele żądań sieciowych o małym rozmiarze wysyłanych przez wystąpienie aplikacji do tej samej usługi.
  • Wiele żądań o małym rozmiarze wysyłanych przez wystąpienie aplikacji do tego samego magazynu danych.
  • Obciążenie aplikacji i usług operacjami we/wy.

Przykładowa diagnostyka

Poniższe sekcje dotyczą stosowania tych kroków w przedstawionym wcześniej przykładzie obejmującym wysyłanie zapytań do bazy danych.

Test obciążeniowy aplikacji

Na wykresie są widoczne wyniki testowania obciążenia. Mediana czasu odpowiedzi na jedno żądanie jest mierzona w dziesiątkach sekund. Z wykresu wynika, że opóźnienie jest bardzo duże. Jeśli liczba użytkowników wynosi 1000, czas oczekiwania na wyniki zapytania może sięgać niemal minuty.

Kluczowe wskaźniki wyników testu obciążenia przykładowej aplikacji z dużą liczbą operacji we/wy

Uwaga

Aplikacja została wdrożona jako aplikacja internetowa usługi Azure App Service za pomocą usługi Azure SQL Database. W teście użyto symulowanego obciążenia odpowiadającego maksymalnie 1000 równoczesnych użytkowników. Bazę danych skonfigurowano pod kątem puli połączeń obsługującej do 1000 równoczesnych połączeń, aby zmniejszyć wpływ rywalizacji o połączenia na wyniki.

Monitorowanie aplikacji

Pakiet zarządzania wydajnością aplikacji (APM) umożliwia przechwytywanie i analizowanie kluczowych metryk, które mogą identyfikować czatty we/wy. Waga metryk zależy od obciążenia we/wy. W tym przykładzie godne uwagi są zapytania do bazy danych.

Na poniższej ilustracji przedstawiono wyniki wygenerowane przy użyciu rozwiązania APM New Relic. Przy maksymalnym obciążeniu średni czas odpowiedzi bazy danych na jedno żądanie sięgał 5,6 s. Podczas testu system był w stanie obsłużyć średnio 410 żądań na minutę.

Przegląd ruchu wysyłanego do bazy danych AdventureWorks2012

Zbieranie szczegółowych informacji dotyczących dostępu do danych

Wnikliwa analiza danych monitorowania ujawnia, że aplikacja wykonuje trzy różne instrukcje SQL SELECT. Odpowiadają one żądaniom wygenerowanym przez program Entity Framework, służącym do pobierania danych z tabel ProductListPriceHistory, Product i ProductSubcategory. Ponadto instrukcja SELECT w zapytaniu pobierającym dane z tabeli ProductListPriceHistory jest wykonywana o rząd wielkości częściej niż inne instrukcje.

Zapytania wykonywane w testowanej przykładowej aplikacji

Widać, że przedstawiona wcześniej metoda GetProductsInSubCategoryAsync wykonuje 45 zapytań SELECT. Każde zapytanie powoduje otwarcie nowego połączenia SQL przez aplikację.

Statystyki zapytań w testowanej przykładowej aplikacji

Uwaga

Na ilustracji przedstawiono informacje o śledzeniu dla najwolniejszego wystąpienia operacji GetProductsInSubCategoryAsync w teście obciążeniowym. W środowisku produkcyjnym warto badać ślady najwolniejszych wystąpień, aby ustalić, czy występuje wzorzec, który może wnieść dodatkowe informacje o problemie. Zwracanie uwagi tylko na wartości średnie może spowodować przeoczenie problemów, które znacznie pogorszą sytuację w warunkach obciążenia.

Na następnej ilustracji przedstawiono instrukcje SQL, które rzeczywiście zostały użyte. Zapytanie, które pobiera informacje o cenach, jest uruchamiane dla każdego produktu z podkategorii produktów. Użycie sprzężenia pozwoliłoby znacznie zmniejszyć liczbę wywołań bazy danych.

Szczegóły zapytań w testowanej przykładowej aplikacji

Śledzenie zapytań SQL w rozwiązaniu mapowania obiektowo-relacyjnego, takim jak Entity Framework, może zapewnić wgląd w przekształcanie wywołań programowych w instrukcje SQL oraz wskazać obszary, w których można zoptymalizować dostęp do danych.

Implementowanie rozwiązania i weryfikowanie wyniku

Umieszczenie wywołania w programie Entity Framework pozwoliło uzyskać następujące wyniki.

Kluczowe wskaźniki wyników testu obciążenia przykładowej aplikacji z dużą liczbą operacji we/wy i podzielonym na części interfejsem API

Test obciążeniowy został wykonany na tym samym wdrożeniu przy użyciu tego samego profilu obciążenia. Tym razem na wykresie widać znacznie mniejsze opóźnienie. Nastąpiło skrócenie średniego czasu żądania przy 1000 użytkowników — z niemal minuty na 5–6 s.

Teraz system obsługuje średnio 3970 żądań na minutę w porównaniu do 410 żądań we wcześniejszym teście.

Przegląd transakcji przy podzielonym na części interfejsie API

Ze śledzenia instrukcji SQL wynika, że wszystkie dane są pobierane w jednej instrukcji SELECT. Mimo że to zapytanie jest znacznie bardziej skomplikowane, jest wykonywane tylko raz podczas operacji. Korzystanie ze złożonych sprzężeń może być kosztowne, ale systemy relacyjnych baz danych są zoptymalizowane pod kątem tego typu zapytań.

Szczegóły zapytań przy podzielonym na części interfejsie API