Навошта захоўваць усе дадзеныя ў памяці?
Для захоўвання дадзеных сайта або бэкэнда першым жаданнем большасці разважных людзей абярэ SQL базу дадзеных.
Але часам у галаву прыходзіць думка што мадэль дадзеных не падыходзіць для SQL: напрыклад, пры пабудове пошуку ці сацыяльнага графа патрэбен пошук па складаных сувязях паміж аб'ектамі.
Горш за ўсё сітуацыя, калі працуеце ў камандзе, і калега не ўмее будаваць хуткія запыты. Колькі часу вы патрацілі на вырашэнне праблем N+1 і на пабудову дадатковых індэксаў, каб SELECT на галоўнай старонцы адпрацоўваў за разумны час?
Іншым папулярным падыходам з'яўляецца NoSQL. Некалькі гадоў таму быў вялікі хайп вакол гэтай тэмы - для любога зручнага выпадку разгортвалі MongoDB і радаваліся адказам у выглядзе json-дакументаў. (дарэчы, колькі мыліц прыйшлося ўставіць з-за цыклічных спасылак у дакументах?).
Я прапаную паспрабаваць яшчэ адзін, альтэрнатыўны спосаб - чаму б не паспрабаваць захоўваць усе дадзеныя ў памяці прыкладання, перыядычна захоўваючы ў адвольнае сховішча (файл, выдаленая база дадзеных)?
Памяць стала таннай, а любыя магчымыя дадзеныя большасці малых і сярэдніх праектаў улезуць у 1 Гб памяці. (Напрыклад, мой любімы хатні праект -
Плюсы:
- Доступ да дадзеных становіцца прасцей - не трэба клапаціцца аб запытах, лянівай загрузцы, асаблівасцях ORM, праца адбываецца са звычайнымі C# аб'ектамі;
- Няма праблем, звязаных з доступам з розных плыняў;
- Вельмі хутка - няма сеткавых запытаў, адсутнічае трансляцыя кода ў мову запытаў, не патрэбна (дэ)серыялізацыя аб'ектаў;
- Дапушчальна захоўваць дадзеныя ў любым выглядзе - хоць у XML на дыску, хоць у SQL Server, хоць у Azure Table Storage.
Мінусы:
- Губляецца гарызантальнае маштабаванне, і як следства нельга зрабіць zero downtime deployment;
- Калі прыкладанне ўпадзе - можна часткова страціць дадзеныя. (Але ж наша дадатак-то ніколі не падае, праўда?)
Як гэта працуе?
Алгарытм наступны:
- На старце усталёўваецца злучэнне са сховішчам дадзеных, і адбываецца загрузка дадзеных;
- Будуецца аб'ектная мадэль, першасныя індэксы, і індэксы адносін (1: 1, 1: Many);
- Ствараецца падпіска на змены ўласцівасцяў аб'ектаў (INotifyPropertyChanged) і на даданне ці выдаленне элементаў у калекцыю (INotifyCollectionChanged);
- Пры спрацоўванні падпіскі - які змяніўся аб'ект дадаецца ў чаргу на запіс у сховішча дадзеных;
- Перыядычна (па таймеры) у фонавым патоку захоўваюцца змены ў сховішча;
- Пры выхадзе з дадатку таксама захоўваюцца змены ў сховішча.
Прыклад кода
Дадаем неабходныя залежнасці
// Основная библиотека
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.Hangfire
Апісваем мадэль дадзеных, якая будзе захоўвацца ў сховішчы
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; }
}
Затым аб'ектную мадэль:
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Пример связи 1:Many
public IEnumerable<ChildModel> Children => Multiple<ChildModel>(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
}
// Доступ с поиском по индексу
public ParentModel Parent => Single<ParentModel>(ParentId);
protected override BaseEntity Entity => _childEntity;
}
І нарэшце сам клас-рэпазітар для доступу да дадзеных:
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Для тестов, позволяет не сохранять изменения в базу
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Если используется Hangfire и необходимо хранить модель данных для Hangfire в ObjectRepository
// this.RegisterHangfireScheme();
Initialize();
}
}
Ствараем асобнік ObjectRepository:
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();
Калі ў праекце будзе выкарыстоўвацца HangFire
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}
Устаўка новага аб'екта:
var newParent = new ParentModel()
repository.Add(newParent);
Пры гэтым выкліку аб'ект ParentModel дадаецца і ў лакальны кэш, і ў чаргу на запіс у базу. Таму гэтая аперацыя займае O(1), і з гэтым аб'ектам можна адразу працаваць.
Напрыклад, каб знайсці гэты аб'ект у рэпазітары і пераканацца што які вярнуўся аб'ект з'яўляецца тым жа асобнікам:
var parents = repository.Set<ParentModel>();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));
Што пры гэтым адбываецца? Set () вяртае TableDictionary, які змяшчае ў сабе ConcurrentDictionary і дае дадатковы функцыянал першасных і другасных індэксаў. Гэта дазваляе мець метады для пошуку па Id (ці іншым адвольным карыстацкім азначнікам) без поўнага перабору ўсіх аб'ектаў.
Пры даданні аб'ектаў у ObjectRepository дадаецца падпіска на змену іх уласцівасцяў, таму любая змена ўласцівасцяў таксама прыводзіць даданню гэтага аб'екта ў чаргу на запіс.
Абнаўленне ўласцівасцяў звонку выглядае гэтак жа, як і праца з POCO-аб'ектам:
myParent.Children.First().Property = "Updated value";
Выдаліць аб'ект можна наступнымі спосабамі:
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove<ParentModel>(x => !x.Children.Any());
Пры гэтым таксама адбываецца даданне аб'екта ў чаргу на выдаленне.
Як працуе захаванне?
ObjectRepository пры змене адсочваных аб'ектаў (як даданне ці выдаленне, так і змена ўласцівасцяў) выклікае падзею ModelChanged, на якое падпісаны IStorage. Рэалізацыі IStorage пры ўзнікненні падзеі ModelChanged складаюць змены ў 3 чарзе - на даданне, на абнаўленне, і на выдаленне.
Таксама рэалізацыі IStorage пры ініцыялізацыі ствараюць таймер, які кожныя 5 секунд выклікае захаванне змен.
Акрамя таго існуе API для прымусовага выкліку захавання: ObjectRepository.Save().
Перад кожным захаваннем спачатку адбываецца выдаленне з чэргаў бессэнсоўных аперацый (напрыклад дублікаты падзей - калі аб'ект змяняўся двойчы або хуткае даданне/выдаленне аб'ектаў), і толькі потым само захаванне.
Ва ўсіх выпадках захоўваецца актуальны аб'ект цалкам, таму магчымая сітуацыя, калі аб'екты захоўваюцца ў іншым парадку, чым мяняліся, у тым ліку могуць захоўвацца навейшыя версіі аб'ектаў, чым на момант дадання ў чаргу.
Што яшчэ ёсць?
- Усе бібліятэкі заснаваны на .NET Standard 2.0. Можна выкарыстоўваць у любым сучасным. NET праекце.
- API струменебяспечны. Унутраныя калекцыі рэалізаваны на базе ConcurrentDictionary, апрацоўшчыкі падзей маюць або блакіроўкі, або не маюць патрэбы ў іх.
Адзінае пра што варта памятаць - пры завяршэнні прыкладання выклікаць ObjectRepository.Save(); - Адвольныя індэксы (патрабуюць унікальнасць):
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");
Хто гэта выкарыстоўвае?
Асабіста я пачаў выкарыстоўваць гэты падыход ва ўсіх хобі-праектах, таму што гэта зручна, і не патрабуе вялікіх выдаткаў на напісанне пласта доступу да дадзеных ці разгортвання цяжкай інфраструктуры. Асабіста мне, як правіла, дастаткова захоўвання дадзеных у litedb або ў файле.
Але ў мінулым, калі з камандай рабілі цяпер спачылы стартап EscapeTeams (думаў вось яны, грошы - ан не, зноў вопыт) - выкарыстоўвалі для захоўвання дадзеных Azure Table Storage.
Планы на будучыню
Жадаецца паправіць адзін з асноўных мінусаў дадзенага падыходу - гарызантальнае маштабаванне. Для гэтага патрэбныя альбо размеркаваныя транзакцыі (sic!), альбо прыняць валявое рашэнне, што адны і тыя ж дадзеныя з розных інстансаў змяняцца не павінны, альбо няхай мяняюцца па прынцыпе "хто апошні - той і мае рацыю".
З тэхнічнага пункту гледжання я бачу магчымай наступную схему:
- Захоўваць замест аб'ектнай мадэлі EventLog і Snapshot
- Знаходзіць іншыя інстансы (дадаваць у налады канчатковыя кропкі ўсіх інстансаў? udp discovery? master/slave?)
- Рэпліцыраваць паміж інстансамі EventLog праз любы з алгарытмаў кансэнсусу, напрыклад RAFT.
Гэтак жа існуе яшчэ адна праблема, якая мяне турбуе - гэта каскаднае выдаленне, або выяўленне выпадкаў выдалення аб'ектаў, на якія ёсць спасылкі з іншых аб'ектаў.
зыходны код
Калі вы дачыталі да сюды - то далей застаецца чытаць толькі код, яго можна знайсці на GitHub:
Крыніца: habr.com