Warum alle Daten im Speicher aufbewahren?
Für die Speicherung von Website- oder Backend-Daten würden die meisten vernünftigen Menschen zunächst eine SQL-Datenbank wählen.
Aber manchmal kommt der Gedanke auf, dass das Datenmodell nicht für SQL geeignet ist: Zum Beispiel, wenn es darum geht, eine Suche oder ein soziales Netzwerk zu erstellen, benötigt man eine Suche nach komplexen Beziehungen zwischen Objekten.
Am schlimmsten ist es, wenn man im Team arbeitet und ein Kollege keine schnellen Abfragen erstellen kann. Wie viel Zeit haben Sie damit verbracht, die Probleme N+1 zu lösen und zusätzliche Indizes zu erstellen, damit der SELECT-Befehl auf der Hauptseite in angemessener Zeit bearbeitet wird?
Ein weiterer beliebter Ansatz ist NoSQL. Vor einigen Jahren gab es viel Hype um dieses Thema – bei jeder Gelegenheit wurde MongoDB eingerichtet, und alle freuten sich über die Antworten in Form von JSON-Dokumenten. (Übrigens, wie viele Workarounds mussten wegen zyklischer Verweise in den Dokumenten eingesetzt werden?).
Ich schlage vor, einen weiteren alternativen Ansatz auszuprobieren – warum nicht alle Daten im Anwendungs-Speicher aufbewahren und sie regelmäßig in ein beliebiges Repository (Datei, Remote-Datenbank) speichern?
Speicher ist billig geworden, und alle erforderlichen Daten der meisten kleinen und mittelständischen Projekte passen in 1 GB RAM. (Zum Beispiel, mein Lieblingsprojekt zu Hause — , der tägliche Statistiken und eine Historie meiner Ausgaben, Salden und Transaktionen über anderthalb Jahre hinweg erfasst, benötigt nur 45 MB RAM.)
Vorteile:
- Der Zugriff auf Daten wird einfacher — man muss sich nicht um Abfragen, verzögertes Laden oder die Besonderheiten von ORM kümmern, die Arbeit erfolgt mit normalen C#-Objekten;
- Es gibt keine Probleme mit dem Zugriff aus verschiedenen Threads;
- Sehr schnell — keine Netzwerkabfragen, keine Übersetzung des Codes in die Abfragesprache, keine (De-)Serialisierung von Objekten erforderlich;
- Es ist erlaubt, Daten in beliebiger Form zu speichern — sei es XML auf der Festplatte, SQL Server oder Azure Table Storage.
Nachteile:
- Horizontales Skalieren geht verloren, und folglich ist ein Zero Downtime Deployment nicht möglich;
- Wenn die Anwendung ausfällt — können Daten teilweise verloren gehen. (Aber unsere Anwendung stürzt doch niemals ab, oder?)
Wie funktioniert das?
Das Verfahren ist wie folgt:
- Beim Start wird eine Verbindung zum Datenspeicher hergestellt und die Daten werden geladen;
- Es wird ein objektmodell gebaut, primäre Indizes und Beziehungsindizes (1:1, 1:n);
- Ein Abonnement für Änderungen an Objekteigenschaften (INotifyPropertyChanged) sowie für das Hinzufügen oder Entfernen von Elementen in einer Sammlung (INotifyCollectionChanged) wird erstellt.
- Wenn das Abonnement ausgelöst wird, wird das geänderte Objekt in die Warteschlange zum Speichern in der Datenablage hinzugefügt.
- Änderungen werden regelmäßig (im Hintergrund-Thread über einen Timer) in der Datenablage gespeichert.
- Änderungen werden auch beim Verlassen der Anwendung in der Datenablage gespeichert.
Beispielcode
Fügen Sie die erforderlichen Abhängigkeiten hinzu.
// Основная библиотека
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.HangfireBeschreiben Sie das Datenmodell, das in der Datenablage gespeichert werden soll.
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; }
}Dann das Objektmodell:
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Beispiel für eine 1:Viele Beziehung
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);
}
// Zugriff mit Indexsuche
public ParentModel Parent => Single(ParentId);
protected override BaseEntity Entity => _childEntity;
}Und schließlich die Repository-Klasse für den Datenzugriff:
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Für Tests, ermöglicht es, keine Änderungen in der Datenbank zu speichern
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Wenn Hangfire verwendet wird und das Datenmodell für Hangfire im ObjectRepository gespeichert werden muss
// this.RegisterHangfireScheme();
Initialize();
}
}Erstellen eines Instanz von ObjectRepository:
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();Wenn im Projekt HangFire verwendet wird
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}Einfügen eines neuen Objekts:
var newParent = new ParentModel()
repository.Add(newParent);Bei diesem Aufruf wird das Objekt ParentModel wird sowohl im lokalen Cache als auch in die Warteschlange für die Datenbank geschrieben. Daher hat dieser Vorgang O(1) und dieses Objekt kann sofort verwendet werden.
Um beispielsweise dieses Objekt im Repository zu finden und sicherzustellen, dass das zurückgegebene Objekt dasselbe Exemplar ist:
var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));Was passiert dabei? Set<ParentModel>() gibt er TableDictionary<ParentModel>, der enthält ConcurrentDictionary<ParentModel, ParentModel> und bietet zusätzliche Funktionalitäten für primäre und sekundäre Indizes. Dadurch stehen Methoden zur Verfügung, um nach Id (oder anderen beliebigen benutzerdefinierten Indizes) zu suchen, ohne alle Objekte vollständig durchzuscannen.
Beim Hinzufügen von Objekten zu ObjectRepository wird ein Abonnement zur Änderung ihrer Eigenschaften hinzugefügt, sodass jede Änderung der Eigenschaften auch zur Aufnahme dieses Objekts in die Schreibwarte führt.
Die Aktualisierung der Eigenschaften von außen sieht genau so aus wie die Arbeit mit einem POCO-Objekt:
myParent.Children.First().Property = "Updated value";Ein Objekt kann auf folgende Weisen entfernt werden:
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove<ParentModel>(x => !x.Children.Any());Dabei wird das Objekt auch in die Warteliste zur Löschung eingereiht.
Wie funktioniert das Speichern?
ObjectRepository bei Änderungen an überwachten Objekten (sowohl beim Hinzufügen oder Entfernen als auch bei Änderungen der Eigenschaften) wird ein Ereignis ModelChanged, auf das IStorage, reagiert. Die Implementierungen IStorage sammeln bei Eintreten des Ereignisses die Änderungen in drei Warteschlangen — für Hinzufügungen, Aktualisierungen und Löschungen. ModelChanged Änderungen werden in drei Warteschlangen gespeichert – für Hinzufügungen, Aktualisierungen und Löschungen.
Auch die Implementierungen IStorage Bei der Initialisierung wird ein Timer erstellt, der alle 5 Sekunden die Änderungen speichert.
Außerdem gibt es eine API für den erzwungenen Speicheraufruf: ObjectRepository.Save().
Vor jedem Speicheraufruf werden zuerst sinnlose Operationen (z. B. duplizierte Ereignisse – wenn das Objekt zweimal geändert wurde oder Objekte schnell hinzugefügt/entfernt wurden) aus den Warteschlangen entfernt, und erst dann erfolgt das eigentliche Speichern.
In allen Fällen wird das aktuelle Objekt vollständig gespeichert, sodass es möglich ist, dass Objekte in einer anderen Reihenfolge gespeichert werden als sie geändert wurden. Es können auch neuere Versionen von Objekten gespeichert werden, als zum Zeitpunkt der Hinzufügung zur Warteschlange.
Was gibt es noch?
- Alle Bibliotheken basieren auf .NET Standard 2.0. Sie können in jedem modernen .NET-Projekt verwendet werden.
- Die API ist thread-sicher. Die internen Sammlungen basieren auf ConcurrentDictionary, die Ereignisbehandler haben entweder Sperren oder benötigen diese nicht.
Das einzige, woran man denken sollte, ist, beim Beenden der Anwendung ObjectRepository.Save(); - Willkürliche Indizes (erfordern Einzigartigkeit):
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");Wer nutzt das?
Ich persönlich habe begonnen, diesen Ansatz in all meinen Hobbyprojekten zu verwenden, weil es bequem ist und keine großen Investitionen in die Erstellung einer Datenspeicherschicht oder den Aufbau einer schweren Infrastruktur erfordert. Für mich persönlich reicht in der Regel die Speicherung von Daten in litedb oder in einer Datei aus.
In der Vergangenheit, als wir mit dem Team das inzwischen eingestellte Startup EscapeTeams (dachte ich, hier kommt das Geld – aber nein, wieder nur Erfahrung) betrieben haben, haben wir Azure Table Storage zur Datenspeicherung verwendet.
Zukunftspläne
Einen der Hauptnachteile dieses Ansatzes möchte ich beheben – das horizontale Scaling. Dafür sind entweder verteilte Transaktionen nötig (sic!), oder man muss die bewusste Entscheidung treffen, dass die gleichen Daten aus verschiedenen Instanzen sich nicht ändern dürfen, oder man lässt sie nach dem Prinzip „der Letzte hat Recht“ ändern.
Technisch gesehen sehe ich das folgende Schema als möglich an:
- Anstelle eines objektorientierten Modells EventLog und Snapshot speichern.
- Andere Instanzen finden (sollten wir alle Endpunkte der Instanzen in den Einstellungen hinzufügen? UDP-Discovery? Master/Slave?)
- EventLog zwischen den Instanzen über einen Konsensalgorithmus wie RAFT replizieren.
Es gibt auch ein weiteres Problem, das mich beschäftigt — die kaskadierte Löschung oder das Erkennen von Fällen, in denen Objekte gelöscht werden, auf die von anderen Objekten verwiesen wird.
Quellcode
Wenn Sie bis hierher gelesen haben — dann bleibt nur noch der Code zu lesen, den Sie auf GitHub finden können:
Quelle: habr.com
