Prečo uchovávať všetky dáta v pamäti?
Na ukladanie dát webu alebo backendu si väčšina rozumných ľudí ako prvú voľbu vyberie SQL databázu.
Ale niekedy sa objaví myšlienka, že dátový model nie je vhodný pre SQL: napríklad pri budovaní vyhľadávania alebo sociálneho grafu potrebujeme vyhľadávanie zložitých vzťahov medzi objektmi.
Najhoršie je, keď pracujete v tíme a kolega nevie vytvárať rýchle dotazy. Koľko času ste strávili riešením problémov N+1 a vytváraním dodatočných indexov, aby SELECT na hlavnej stránke fungoval v rozumnom čase?
Iným populárnym prístupom je NoSQL. Pred niekoľkými rokmi bol okolo tohto témy veľký rozruch — pri každej vhodnej príležitosti sme nasadili MongoDB a tešili sa z odpovedí vo forme json-dokumentov. (inak, koľko obmedzení ste museli zaviesť kvôli cyklickým odkazom v dokumentoch?).
Navrhujem vyskúšať ďalší alternatívny prístup — prečo neskúsiť uchovávať všetky dáta v pamäti aplikácie a občas ich ukladať do ľubovoľného úložiska (súbor, vzdialená databáza)?
Pamäť sa stala lacnou a takmer všetky možné dáta väčšiny malých a stredných projektov sa zmestia do 1 GB pamäte. (Napríklad, môj obľúbený domáci projekt — , ktorý sleduje denné štatistiky a históriu mojich výdavkov, zostatkov a transakcií za jeden a pol roka, spotrebováva iba 45 MB pamäte.)
Výhody:
- Prístup k dátam sa stáva jednoduchším — netreba sa zaoberať dotazmi, lenivým načítavaním, špecifikami ORM, práca sa vykonáva s obyčajnými C# objektami;
- Žiadne problémy s prístupom z rôznych vláken;
- Veľmi rýchlo — žiadne sieťové požiadavky, absentuje preklad kódu do jazyka dotazov, nie je potrebná (de)serializácia objektov;
- Je prípustné uchovávať dáta v akomkoľvek formáte — či už v XML na disku, SQL Serveri alebo Azure Table Storage.
Nevýhody:
- Stráca sa horizontálne škálovanie, a ako následok nie je možné vykonať zero downtime deployment;
- Ak aplikácia spadne — môžeme časť dát stratiť. (Ale naša aplikácia predsa nikdy nepadá, však?)
Ako to funguje?
Algoritmus je nasledovný:
- Na začiatku sa nadväzuje spojenie s úložiskom dát a dochádza k načítaniu dát;
- Buduje sa objektový model, primárne indexy a indexy vzťahov (1:1, 1:Many);
- Vytvára sa predplatné na zmeny vlastností objektov (INotifyPropertyChanged) a na pridávanie alebo odstraňovanie prvkov v kolekcii (INotifyCollectionChanged);
- Pri spustení predplatného sa zmenený objekt pridáva do radu na zápis do úložiska dát;
- Pravidelne (podľa časovača) sa v pozadí ukladajú zmeny do úložiska;
- Pri ukončení aplikácie sa taktiež ukladajú zmeny do úložiska.
Príklad kódu
Pridávame potrebné závislosti
// Основная библиотека
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.HangfireOpisujeme model dát, ktorý sa bude ukladať do úložiska
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; }
}Potom objektový model:
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Príklad vzťahu 1:Many
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
}
// Prístup s hľadaním podľa indexu
public ParentModel Parent => Single(ParentId);
protected override BaseEntity Entity => _childEntity;
}A nakoniec, samotná trieda-repozitár pre prístup k dátam:
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Na testy, umožňuje neuložiť zmeny do databázy
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Ak sa používa Hangfire a je potrebné uložiť model dát pre Hangfire v ObjectRepository
// this.RegisterHangfireScheme();
Initialize();
}
}Vytvárame inštanciu ObjectRepository:
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();Ak sa v projekte bude používať HangFire
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}Vložení nového objektu:
var newParent = new ParentModel()
repository.Add(newParent);Pri tomto volaní objekt ParentModel pridáva sa do lokálneho cache a do frontu na zápis do databázy. Preto táto operácia trvá O(1) a s týmto objektom sa dá okamžite pracovať.
Napríklad, aby sme našli tento objekt v úložisku a uistili sa, že vrátený objekt je tým istým exemplárom:
var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));Čo sa pritom deje? Set() vracia TableDictionary, ktorý obsahuje v sebe ConcurrentDictionary a poskytuje ďalšiu funkčnosť primárnych a sekundárnych indexov. To umožňuje mať metódy na vyhľadávanie podľa Id (alebo iných ľubovoľných používateľských indexov) bez úplného prechádzania všetkými objektmi.
Pri pridávaní objektov do ObjectRepository sa pridáva odber na zmenu ich vlastností, takže akékoľvek zmeny vlastností vedú aj k pridaniu tohto objektu do frontu na zápis.
Aktualizácia vlastností zvonka vyzerá rovnako, ako práca s POCO objektom:
myParent.Children.First().Property = "Updated value";Objekt je možné vymazať nasledujúcimi spôsobmi:
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove(x => !x.Children.Any());Pri tom sa tiež objekt pridáva do frontu na vymazanie.
Ako funguje uloženie?
ObjectRepository pri zmene sledovaných objektov (či už pri pridávaní, odstraňovaní alebo zmene vlastností) vyvoláva udalosť ModelChanged, na ktorú je prihlásený IStorage. Implementácie IStorage pri vzniku udalosti ModelChanged zhromažďujú zmeny do 3 frontov — na pridanie, na aktualizáciu a na vymazanie.
Tiež implementácie IStorage pri inicializácii vytvárajú časovač, ktorý každých 5 sekúnd vyvoláva uloženie zmien.
Okrem toho existuje API na nútené vyvolanie uloženia: ObjectRepository.Save().
Pred každým uložením sa najprv odstraňujú z frontov bezpredmetné operácie (napríklad duplicity udalostí — keď sa objekt menil dvakrát alebo rýchle pridávanie/odstraňovanie objektov), a potom nasleduje samotné uloženie.
Vo všetkých prípadoch sa ukladá aktuálny objekt v celku, preto môže nastať situácia, kedy sa objekty ukladajú v inom poradí, ako sa menili, vrátane toho, že sa môžu ukladať novšie verzie objektov, ako v čase pridania do frontu.
Čo je ešte?
- Všetky knižnice sú založené na .NET Standard 2.0. Môže sa používať v akomkoľvek modernom .NET projekte.
- API je thread-safe. Interné kolekcie sú implementované na báze ConcurrentDictionary, správcovia udalostí majú buď zamykania, alebo ich nepotrebujú.
Jediné, na čo treba pamätať — pri ukončení aplikácie zavolať ObjectRepository.Save(); - Ďalšie indexy (vyžadujú jedinečnosť):
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");Kto to používa?
Osobne som začal používať tento prístup vo všetkých svojich hobby projektoch, pretože je to pohodlné a nevyžaduje veľké náklady na písanie vrstvy prístupu k údajom alebo nasadzovanie ťažkej infraštruktúry. Osobne mi zvyčajne stačí ukladanie údajov v litedb alebo v súbore.
Ale v minulosti, keď sme s tímom robili teraz už neexistujúci startup EscapeTeams (myslel som si, že toto je cesta k peniazom — a nie, opäť skúsenosť) — používali sme na ukladanie údajov Azure Table Storage.
Plány do budúcnosti
Rád by som opravil jednu z hlavných nevýhod tohto prístupu — horizontálne škálovanie. Na to sú potrebné buď distribuované transakcie (sic!), alebo prijať rozhodnutie, že rovnaké údaje z rôznych inštancií sa nebudú meniť, alebo nech sa menia podľa princípu „kto posledný, ten má pravdu“.
Z technického hľadiska vidím ako možnú nasledujúcu schému:
- Ukladať namiesto objektového modelu EventLog a Snapshot
- Nájsť iné inštancie (pridať do nastavení koncové body všetkých inštancií? UDP objavovanie? master/slave?)
- Replikovať medzi inštanciami EventLog pomocou akéhokoľvek z konsenzuálnych algoritmov, napríklad RAFT.
Je tu aj iný problém, ktorý ma trápi — kaskádové mazanie alebo zistenie prípadov mazania objektov, na ktoré sú odkazy z iných objektov.
Zdrojový kód
Ak ste dočítali až sem — zostáva už len čítať kód, ten nájdete na GitHub:
Zdroj: habr.com
