Optymalizacja przeszukiwania kolekcji

W tym wpisie pokażemy jak możemy poprawić wydajność kodu, porównującego 2 kolekcje w .Net LINQ. Podany kod jest oczywiście tylko wycinkiem większej całości. Dla uproszczenia podajemy odczyt z bazy danych bezpośrednio - w oryginalnym kodzie odbywa się za pomocą MediatR.

Kontekst

Mamy historię transakcji, którą raz dziennie chcemy aktualizować w naszym systemie na podstawie danych dostarczonych przez zewnętrznego dostawcę. Dostawca ten nie udostępnia API, a jedynie pliki z danymi, na podstawie których chcemy aktualizować naszą bazę danych. Dostarczone dane nie posiadają unikalnego identyfikatora, dopiero ich iloczyn daje nam wartość unikalną. Dlatego porównujemy kilka wartości, aby wykluczyć możliwość duplikowania danych.

Rozwiązanie działające, ale nieoptymalne

Pierwotnie zaproponowany kod wyglądał tak:

List<HistoryModel> histories = //Zbiór informacji odczytanych z dostraczonych plików
List<HistoryModel> historiesInDb = _context.Histories.ToListAsync(); // rekordy zapisane w bazie danych
//Porównujemy rekordy i te których nie ma w bazie dodajemy do niej
List<HistoryModel> historiesToAdd = histories
  .AsParallel()
  .Where(history => !historiesFromDb.Any(h => h.Amount.Equals(history.Amount) 
    && h.Date.Equals(history.Date) 
    && h.InvoiceNo.Equals(history.InvoiceNo) 
    && h.Rodzaj.Equals(history.Rodzaj) 
    && h.Konto.Equals(history.Konto)))
  .ToList();
await _context.History.AddRangeAsync(historiesToAdd);
j += await _context.SaveChangesAsync();

To rozwiązanie działa, jednak jego problem polega na tym, że jest ono powolne (trwa około 5 minut) i wymaga dużych zasobów. Aktualizacja jest wykonywana w nocy, kiedy serwer nie jest praktycznie używany, więc zastosowanie takiego algorytmu jest akceptowalne. Jednak można ten kod napisać zdecydowanie lepiej.

Kod zoptymalizowany

List<HistoryModel> histories = //Zbiór informacji odczytanych z dostraczonych plików
//Zamiast pobierać Listę obiektów pobieramy HashSet strinów
HashSet<string> historiesFromDbKeys = _context.History
    .Select(h => $"{h.Amount}-{h.Date}-{h.InvoiceNo}-{h.Rodzaj}-{h.Konto}")
    .ToHashSet();
//Porównujemy ze sobą 2 kolekcje
List<HistoryModel> historiesToAdd = histories.AsParallel()
    .Where(history => !historiesFromDbKeys
        .Contains($"{history.Amount}-{history.Date}-{history.InvoiceNo}-{history.Rodzaj}-{history.Konto}"))
    .ToList();
//Zapisujemy do bazy danych 
await _context.History.AddRangeAsync(historiesToAdd);
j += await _context.SaveChangesAsync();

Wnioski

Jaka jest zatem różnica czasowa między pierwszym i drugim podejściem? Przy pierwszym podejściu operacja trwa około 5 minut, natomiast przy drugim około 5 sekund. Możemy więc śmiało powiedzieć, że różnica jest 60 krotna i będzie rosnąć wraz w przybywaniem nowych danych. Podany przykład dotyczy 250 tysięcy rekordów w bazie danych i 25 tysięcy rekordów, pochodzących z plików. 2500

W niektórych przypadkach można by porównać 2 kolekcje na poziomie bazy danych, jednak przy tej konkretnej sytuacji sprzętowej wydajniejsze jest przetwarzanie w pamięci aplikacji i użycie przetwarzania równoległego. W tym konkretnym przypadku aplikacja okazuje się lepiej wykorzystywać dostępne zasoby niż serwer bazy danych. Przy zmianie bazy danych na inną lub przy innej konfiguracji sprzętowej sytuacja może ulec zmianie.

Kontakt z nami

Masz pomysły, uwagi lub pytania?

Liczba wyświetleń: 0

An unhandled error has occurred. Reload 🗙