Dlaczego warto przechowywać wszystkie dane w pamięci?
Do przechowywania danych witryny lub backendu pierwszym wyborem większości zdrowo myślących ludzi będzie baza danych SQL.
Jednak czasami przychodzi do głowy myśl, że model danych nie pasuje do SQL: na przykład podczas budowania wyszukiwarki lub grafu społecznego potrzebne jest wyszukiwanie w skomplikowanych relacjach między obiektami.
Najgorzej jest, gdy pracujesz w zespole, a kolega nie potrafi budować szybkich zapytań. Ile czasu spędziłeś na rozwiązywaniu problemów N+1 i na budowaniu dodatkowych indeksów, aby SELECT na stronie głównej działał w rozsądnym czasie?
Innym popularnym podejściem jest NoSQL. Kilka lat temu było duże zamieszanie wokół tego tematu - przy każdej dogodnej okazji uruchamiano MongoDB i cieszono się odpowiedziami w postaci dokumentów JSON. (na marginesie, ile obejść trzeba było wprowadzić z powodu cyklicznych odniesień w dokumentach?).
Proponuję spróbować kolejnego, alternatywnego sposobu - dlaczego nie spróbować przechowywać wszystkich danych w pamięci aplikacji, okresowo zapisując je w dowolnym przechowalni (plik, zdalna baza danych)?
Pamięć stała się tania, a wszelkie możliwe dane większości małych i średnich projektów zmieszczą się w 1 GB pamięci. (Na przykład, mój ulubiony projekt domowy - , który prowadzi codzienną statystykę i historię moich wydatków, bilansów oraz transakcji przez półtora roku, zużywa zaledwie 45 MB pamięci.)
Zalety:
- Dostęp do danych staje się prostszy - nie trzeba przejmować się zapytaniami, leniwym ładowaniem, specyfiką ORM, praca odbywa się z zwykłymi obiektami C#;
- Brak problemów związanych z dostępem z różnych wątków;
- Bardzo szybko - brak zapytań sieciowych, brak tłumaczenia kodu na język zapytań, nie jest potrzebna (de)serializacja obiektów;
- Można przechowywać dane w dowolnym formacie - czy to w XML na dysku, w SQL Server, czy w Azure Table Storage.
Wady:
- Traci się poziome skalowanie, a w konsekwencji nie można zrealizować wdrożenia bez przestojów;
- Jeśli aplikacja upadnie - można częściowo stracić dane. (Ale nasze aplikacje nigdy nie upadają, prawda?)
Jak to działa?
Algorytm jest następujący:
- Na początku następuje nawiązanie połączenia z przechowalnią danych i załadowanie danych;
- Budowana jest model obiektowy, podstawowe indeksy oraz indeksy relacji (1:1, 1:wiele);
- Tworzenie subskrypcji na zmiany właściwości obiektów (INotifyPropertyChanged) oraz na dodawanie lub usuwanie elementów w kolekcji (INotifyCollectionChanged);
- Gdy subskrypcja zostaje wywołana — zmodyfikowany obiekt jest dodawany do kolejki zapisu w magazynie danych;
- Okresowo (za pomocą timera) w tle zachowywane są zmiany w magazynie;
- Po zamknięciu aplikacji również zapisywane są zmiany w magazynie.
Przykład kodu
Dodajemy niezbędne zależności
// Основная библиотека
Install-Package OutCode.EscapeTeams.ObjectRepository
// Хранилище данных, в котором будут сохраняться изменения
// Используйте то, которым будете пользоваться.
Install-Package OutCode.EscapeTeams.ObjectRepository.File
Install-Package OutCode.EscapeTeams.ObjectRepository.LiteDb
Install-Package OutCode.EscapeTeams.ObjectRepository.AzureTableStorage
// Опционально - если нужно хранить модель данных для Hangfire
// Install-Package OutCode.EscapeTeams.ObjectRepository.HangfireOpisujemy model danych, który będzie zapisywany w magazynie
public class ParentEntity : BaseEntity
{
public ParentEntity(Guid id) => Id = id;
}
public class ChildEntity : BaseEntity
{
public ChildEntity(Guid id) => Id = id;
public Guid ParentId { get; set; }
public string Value { get; set; }
}Następnie model obiektowy:
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Przykład relacji 1:Wiele
public IEnumerable Children => Multiple(x => x.ParentId);
protected override BaseEntity Entity { get; }
}
public class ChildModel : ModelBase
{
private ChildEntity _childEntity;
public ChildModel(ChildEntity entity)
{
_childEntity = entity;
}
public ChildModel()
{
_childEntity = new ChildEntity(Guid.NewGuid());
}
public Guid ParentId
{
get => _childEntity.ParentId;
set => UpdateProperty(() => _childEntity.ParentId, value);
}
public string Value
{
get => _childEntity.Value;
set => UpdateProperty(() => _childEntity.Value, value);
}
// Dostęp z wyszukiwaniem według indeksu
public ParentModel Parent => Single(ParentId);
protected override BaseEntity Entity => _childEntity;
}I w końcu klasa repozytorium do dostępu do danych:
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Dla testów, pozwala nie zapisywać zmian w bazie
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Jeśli używasz Hangfire i musisz przechowywać model danych dla Hangfire w ObjectRepository
// this.RegisterHangfireScheme();
Initialize();
}
}Tworzymy instancję ObjectRepository:
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();Jeśli w projekcie będzie używany HangFire
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}Wstawienie nowego obiektu:
var newParent = new ParentModel()
repository.Add(newParent);W tym wywołaniu obiekt ParentModel jest dodawany zarówno do lokalnej pamięci podręcznej, jak i do kolejki zapisu do bazy. Dlatego operacja ta zajmuje O(1) i można od razu pracować z tym obiektem.
Na przykład, aby znaleźć ten obiekt w repozytorium i upewnić się, że zwrócony obiekt jest tym samym egzemplarzem:
var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));Co się wtedy dzieje? Set() zwraca TableDictionary, który zawiera ConcurrentDictionary i zapewnia dodatkowe funkcjonalności pierwszorzędnych i drugorzędnych indeksów. Umożliwia to posiadanie metod do wyszukiwania po Id (lub innych dowolnych indeksach użytkownika) bez pełnego przeszukiwania wszystkich obiektów.
Podczas dodawania obiektów do ObjectRepository dodawana jest subskrypcja na zmianę ich właściwości, dlatego każda zmiana właściwości również prowadzi do dodania tego obiektu do kolejki zapisu.
Aktualizacja właściwości z zewnątrz wygląda tak samo, jak praca z obiektem POCO:
myParent.Children.First().Property = "Zaktualizowana wartość";Obiekt można usunąć na następujące sposoby:
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove(x => !x.Children.Any());Przy tym również następuje dodanie obiektu do kolejki usunięcia.
Jak działa zapis?
ObjectRepository zmiana obserwowanych obiektów (zarówno dodanie, jak i usunięcie oraz zmiana właściwości) wywołuje zdarzenie ModelChanged, na które subskrybował się IStorage. Realizacje IStorage w przypadku wystąpienia zdarzenia ModelChanged gromadzą zmiany w 3 kolejkach — do dodania, do aktualizacji i do usunięcia.
Realizacje IStorage podczas inicjalizacji tworzą również timer, który co 5 sekund wywołuje zapis zmian.
Dodatkowo istnieje API do wymuszenia zapisu: ObjectRepository.Save().
Przed każdym zapisem najpierw następuje usunięcie z kolejek zbędnych operacji (np. duplikaty zdarzeń — kiedy obiekt zmieniał się dwukrotnie lub szybkie dodawanie/usuwanie obiektów) i dopiero potem następuje sam zapis.
Wszystkie przypadki zapisują aktualny obiekt w całości, dlatego możliwa jest sytuacja, w której obiekty są zapisywane w innym porządku niż były zmieniane, w tym mogą być zapisywane nowsze wersje obiektów niż w chwili dodania do kolejki.
Co jest jeszcze?
- Wszystkie biblioteki są oparte na .NET Standard 2.0. Można ich używać w każdym nowoczesnym projekcie .NET.
- API jest thread-safe. Wewnętrzne kolekcje są oparte na ConcurrentDictionary, a obsługa zdarzeń ma albo blokady, albo ich nie potrzebuje.
Jedna rzecz, o której należy pamiętać — przed zakończeniem aplikacji należy wywołać ObjectRepository.Save(); - Indeksy dowolne (wymagają unikalności):
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");Kto tego używa?
Osobiście zacząłem używać tego podejścia we wszystkich projektach hobbystycznych, ponieważ jest to wygodne i nie wymaga dużych nakładów na pisanie warstwy dostępu do danych lub uruchamianie ciężkiej infrastruktury. Zwykle wystarcza mi przechowywanie danych w litedb lub w pliku.
Jednak w przeszłości, kiedy z zespołem tworzyliśmy nieistniejący już startup EscapeTeams (myślałem, że oto pieniądze — ale nie, znowu doświadczenie) — używaliśmy do przechowywania danych Azure Table Storage.
Plany na przyszłość
Chciałbym naprawić jeden z głównych minusów tego podejścia — poziomą skalowalność. W tym celu potrzebne są albo rozproszone transakcje (sic!), albo trzeba podjąć zdecydowaną decyzję, że te same dane z różnych instancji nie powinny się zmieniać, albo mogą zmieniać się według zasady „kto ostatni, ten wygrywa”.
Z technicznego punktu widzenia widzę następujący możliwy schemat:
- Przechowywać zamiast modelu obiektowego EventLog i Snapshot
- Znajdować inne instancje (dodawać w ustawieniach punkty końcowe wszystkich instancji? odkrywanie UDP? master/slave?)
- Replikować między instancjami EventLog za pomocą dowolnego z algorytmów konsensusu, na przykład RAFT.
Istnieje również inny problem, który mnie niepokoi — to kaskadowe usuwanie lub wykrywanie przypadków usuwania obiektów, do których są linki z innych obiektów.
Kod źródłowy
Jeśli dotarłeś do tego miejsca — to dalej pozostaje tylko czytać kod, można go znaleźć na GitHub:
Źródło: habr.com
