Pourquoi stocker toutes les données en mémoire ?
Pour le stockage des données d'un site ou d'un backend, la première instinct de la plupart des personnes sensées serait de choisir une base de données SQL.
Mais parfois, l'idée que le modèle de données ne convient pas à SQL vient à l'esprit : par exemple, pour la construction d'un moteur de recherche ou d'un graphe social, il est nécessaire de rechercher à travers des relations complexes entre les objets.
La pire situation est lorsque vous travaillez en équipe et qu'un collègue ne sait pas construire des requêtes rapides. Combien de temps avez-vous passé à résoudre les problèmes N+1 et à construire des index supplémentaires pour que le SELECT sur la page principale s'exécute dans un temps raisonnable ?
Une autre approche populaire est NoSQL. Il y a quelques années, il y avait un grand engouement autour de ce sujet — pour toute occasion pratique, MongoDB était déployé et nous étions contents des réponses sous forme de documents json. (au fait, combien de rustines avez-vous dû mettre à cause des références circulaires dans les documents ?).
Je propose d'essayer une autre méthode alternative — pourquoi ne pas essayer de stocker toutes les données en mémoire de l'application, en les enregistrant périodiquement dans un stockage arbitraire (fichier, base de données distante) ?
La mémoire est devenue bon marché, et toutes les données possibles de la plupart des petits et moyens projets peuvent tenir dans 1 Go de mémoire. (Par exemple, mon projet domestique préféré — , qui tient des statistiques quotidiennes et un historique de mes dépenses, soldes et transactions depuis un an et demi, consomme seulement 45 Mo de mémoire.)
Avantages :
- L'accès aux données devient plus simple — pas besoin de se soucier des requêtes, du chargement paresseux, des particularités des ORM, le travail se fait avec des objets C# ordinaires ;
- Aucun problème lié à l'accès à partir de différents threads ;
- Très rapide — il n'y a pas de requêtes réseau, pas de translation de code en langage de requêtes, pas besoin de (dé)sérialiser les objets ;
- Il est acceptable de stocker des données sous n'importe quelle forme — que ce soit en XML sur disque, dans SQL Server, ou dans Azure Table Storage.
Inconvénients :
- On perd l'évolutivité horizontale, et par conséquent, il n'est pas possible de faire un déploiement sans arrêt ;
- Si l'application échoue — il est possible de perdre partiellement des données. (Mais après tout, notre application ne plante jamais, n'est-ce pas ?)
Comment cela fonctionne ?
L'algorithme est le suivant :
- Au démarrage, une connexion est établie avec le stockage de données, et les données sont chargées ;
- Un modèle objet est construit, des index primaires, et des index de relations (1:1, 1:Many) sont créés ;
- Un abonnement est créé pour les modifications des propriétés des objets (INotifyPropertyChanged) et pour l'ajout ou la suppression d'éléments dans la collection (INotifyCollectionChanged);
- Lorsqu'un abonnement est déclenché, l'objet modifié est ajouté à la file d'attente pour être enregistré dans le stockage de données;
- Périodiquement (selon un minuteur), les modifications sont sauvegardées dans le stockage en arrière-plan;
- Lorsque vous quittez l'application, les modifications sont également sauvegardées dans le stockage.
Exemple de code
Ajoutons les dépendances nécessaires
// Основная библиотека
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.HangfireDécrivons le modèle de données qui sera sauvegardé dans le stockage
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; }
}Ensuite, le modèle d'objet :
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Exemple de relation 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);
}
// Accès avec recherche par index
public ParentModel Parent => Single(ParentId);
protected override BaseEntity Entity => _childEntity;
}Et enfin, la classe de référentiel pour l'accès aux données :
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Pour les tests, permet de ne pas sauvegarder les modifications dans la base
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Si Hangfire est utilisé et qu'il est nécessaire de sauvegarder le modèle de données pour Hangfire dans ObjectRepository
// this.RegisterHangfireScheme();
Initialize();
}
}Créons une instance d'ObjectRepository :
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();Si HangFire est utilisé dans le projet
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}Insertion d'un nouvel objet :
var newParent = new ParentModel()
repository.Add(newParent);En appelant cela, l'objet ParentModel Il est ajouté à la fois dans le cache local et dans la file d'attente pour l'écriture dans la base de données. Par conséquent, cette opération prend O(1), et vous pouvez immédiatement travailler avec cet objet.
Par exemple, pour trouver cet objet dans le dépôt et s'assurer que l'objet retourné est le même exemplaire :
var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));Que se passe-t-il alors ? Set() retourne TableDictionary, qui contient ConcurrentDictionary et fournit des fonctionnalités supplémentaires pour les index primaires et secondaires. Cela permet d'avoir des méthodes pour rechercher par Id (ou d'autres index utilisateur arbitraires) sans avoir à parcourir tous les objets.
Lors de l'ajout d'objets à ObjectRepository , un abonnement est ajouté pour suivre les modifications de leurs propriétés, de sorte que toute modification des propriétés entraîne également l'ajout de cet objet dans la file d'attente pour l'écriture.
La mise à jour des propriétés de l'extérieur se fait de la même manière que le travail avec un objet POCO :
myParent.Children.First().Property = "Valeur mise à jour";Vous pouvez supprimer un objet de plusieurs manières :
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove(x => !x.Children.Any());Cela ajoute également l'objet à la file d'attente pour la suppression.
Comment fonctionne la sauvegarde ?
ObjectRepository la modification des objets suivis (comme l'ajout ou la suppression, ainsi que la modification des propriétés) déclenche un événement ModelChanged, auquel est abonné IStorage. Les implémentations IStorage lors de la survenance de l'événement ModelChanged placent les modifications dans 3 files d'attente — pour l'ajout, la mise à jour et la suppression.
Les implémentations IStorage au moment de l'initialisation créent également un minuteur qui appelle la sauvegarde des modifications toutes les 5 secondes.
De plus, il existe une API pour forcer l'appel de la sauvegarde : ObjectRepository.Save().
Avant chaque sauvegarde, on supprime d'abord des files d'attente les opérations inutiles (comme les événements dupliqués — lorsque l'objet a été modifié deux fois ou lors d'ajouts/suppressions rapides d'objets), puis la sauvegarde proprement dite.
Dans tous les cas, l'objet actuel est sauvegardé intégralement, donc il est possible que les objets soient sauvegardés dans un ordre différent de celui dans lequel ils ont été modifiés, y compris des versions plus récentes d'objets, par rapport au moment où ils ont été ajoutés à la file d'attente.
Qu'y a-t-il d'autre ?
- Toutes les bibliothèques sont basées sur .NET Standard 2.0. Elles peuvent être utilisées dans n'importe quel projet .NET moderne.
- L'API est thread-safe. Les collections internes sont basées sur ConcurrentDictionary, les gestionnaires d'événements utilisent soit des verrouillages, soit n'en ont pas besoin.
Une chose à garder à l'esprit est qu'il faut appeler ObjectRepository.Save(); - Indexes arbitraires (nécessitent l'unicité) :
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");Qui utilise cela ?
Personnellement, j'ai commencé à utiliser cette approche dans tous mes projets personnels, car c'est pratique et ne nécessite pas de dépenses importantes pour écrire une couche d'accès aux données ou déployer une infrastructure lourde. Pour moi, il suffit généralement de stocker les données dans litedb ou dans un fichier.
Mais dans le passé, lorsque mon équipe a créé la startup aujourd'hui disparue EscapeTeams (je pensais que l'argent était là — mais non, juste de l'expérience) — nous avons utilisé Azure Table Storage pour stocker les données.
Plans pour l'avenir
Je voudrais résoudre l'un des principaux inconvénients de cette approche : le scalabilité horizontale. Pour cela, nous avons besoin soit de transactions distribuées (sic !), soit de prendre la décision volontaire que les mêmes données de différentes instances ne doivent pas changer, soit elles peuvent changer selon le principe « dernier arrivé, premier servi ».
Sur le plan technique, je vois le schéma suivant comme possible :
- Stocker au lieu du modèle d'objet EventLog et Snapshot
- Trouver d'autres instances (ajouter dans les paramètres les points de terminaison de toutes les instances ? découverte UDP ? maître/esclave ?)
- Répliquer entre instances l'EventLog via n'importe quel algorithme de consensus, par exemple RAFT.
Il y a aussi un autre problème qui me préoccupe — la suppression en cascade, ou la détection des cas de suppression d'objets auxquels d'autres objets font référence.
Code source
Si vous êtes arrivé jusqu'ici — il ne reste plus qu'à lire le code, que vous pouvez trouver sur GitHub :
Source : habr.com
