¿Por qué almacenar todos los datos en memoria?
Para almacenar los datos del sitio web o del backend, la mayoría de las personas sensatas optarán por una base de datos SQL.
Pero a veces surge la idea de que el modelo de datos no se adapta a SQL: por ejemplo, al construir un motor de búsqueda o un grafo social, se necesita buscar relaciones complejas entre objetos.
Lo peor es cuando trabajas en equipo y un colega no sabe hacer consultas rápidas. ¿Cuánto tiempo has gastado resolviendo problemas de N+1 y construyendo índices adicionales para que el SELECT en la página principal se ejecute en un tiempo razonable?
Otro enfoque popular es NoSQL. Hace unos años, hubo un gran bombo alrededor de este tema: para cualquier caso conveniente, se desplegaba MongoDB y nos alegrábamos con las respuestas en forma de documentos JSON. (por cierto, ¿cuántos parches tuviste que insertar debido a los enlaces cíclicos en los documentos?).
Propongo intentar otro enfoque alternativo: ¿por qué no almacenar todos los datos en la memoria de la aplicación, guardándolos periódicamente en un almacenamiento arbitrario (archivo, base de datos remota)?
La memoria se ha vuelto barata, y cualquier dato posible de la mayoría de los proyectos pequeños y medianos cabrá en 1 GB de RAM. (Por ejemplo, mi proyecto doméstico favorito — , que lleva estadísticas diarias y el historial de mis gastos, saldos y transacciones durante un año y medio, consume solo 45 MB de memoria.)
Pros:
- El acceso a los datos se vuelve más sencillo: no es necesario preocuparse por consultas, carga perezosa, peculiaridades de ORM; se trabaja con objetos C# normales;
- No hay problemas relacionados con el acceso desde diferentes hilos;
- Es muy rápido: no hay solicitudes de red, no hay traducción de código al lenguaje de consultas, no se necesita (de)serialización de objetos;
- Se puede almacenar datos en cualquier formato: ya sea en XML en disco, en SQL Server o en Azure Table Storage.
Desventajas:
- Se pierde escalabilidad horizontal, y como consecuencia no se puede realizar una implementación con cero tiempo de inactividad;
- Si la aplicación falla, se pueden perder parcialmente los datos. (Pero nuestra aplicación nunca falla, ¿verdad?)
¿Cómo funciona?
El algoritmo es el siguiente:
- Al inicio, se establece una conexión con el almacenamiento de datos y se cargan los datos;
- Se construye el modelo de objetos, índices primarios y los índices de relaciones (1:1, 1:Muchos);
- Se crea una suscripción para los cambios en las propiedades de los objetos (INotifyPropertyChanged) y para la adición o eliminación de elementos en la colección (INotifyCollectionChanged);
- Cuando se activa la suscripción, el objeto modificado se agrega a la cola para ser escrito en el almacenamiento de datos;
- Periódicamente (con un temporizador) se guardan los cambios en el almacenamiento en un hilo en segundo plano;
- Al salir de la aplicación, también se guardan los cambios en el almacenamiento.
Ejemplo de código
Agregamos las dependencias necesarias
// Основная библиотека
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.HangfireDescribimos el modelo de datos que se guardará en el almacenamiento
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; }
}Luego el modelo objeto:
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Ejemplo de relación 1: muchos
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);
}
// Acceso con búsqueda por índice
public ParentModel Parent => Single(ParentId);
protected override BaseEntity Entity => _childEntity;
}Y finalmente la clase repositorio para acceder a los datos:
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Para pruebas, permite no guardar cambios en la base
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Si se utiliza Hangfire y es necesario almacenar el modelo de datos para Hangfire en ObjectRepository
// this.RegisterHangfireScheme();
Initialize();
}
}Creamos una instancia de ObjectRepository:
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();Si en el proyecto se utilizará HangFire
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}Inserción de un nuevo objeto:
var newParent = new ParentModel()
repository.Add(newParent);Con esta llamada el objeto ParentModel se agrega tanto a la caché local como a la cola para escribir en la base de datos. Por lo tanto, esta operación toma O(1), y se puede trabajar con este objeto de inmediato.
Por ejemplo, para encontrar este objeto en el repositorio y asegurarse de que el objeto devuelto sea la misma instancia:
var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));¿Qué ocurre en este caso? Set() devuelve TableDictionary, que contiene dentro de sí ConcurrentDictionary y proporciona funcionalidad adicional para índices primarios y secundarios. Esto permite tener métodos para buscar por Id (u otros índices de usuario arbitrarios) sin tener que iterar por todos los objetos.
Al agregar objetos a ObjectRepository se añade una suscripción para el cambio de sus propiedades, por lo que cualquier cambio en las propiedades también lleva a la adición de este objeto a la cola para escribir.
La actualización de propiedades desde fuera se ve igual que trabajar con un objeto POCO:
myParent.Children.First().Property = "Valor actualizado";Un objeto se puede eliminar de las siguientes maneras:
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove(x => !x.Children.Any());Al mismo tiempo, también se añade el objeto a la cola de eliminación.
¿Cómo funciona el guardado?
ObjectRepository el cambio en los objetos rastreados (tanto adiciones o eliminaciones como cambios de propiedades) provoca un evento ModelChanged, al que está suscrito IStorage. Las implementaciones IStorage al ocurrir el evento ModelChanged acumulan cambios en 3 colas: para adiciones, para actualizaciones y para eliminaciones.
Además, las implementaciones IStorage al inicializar crean un temporizador que cada 5 segundos provoca el guardado de cambios.
Además, existe una API para forzar el guardado: ObjectRepository.Save().
Antes de cada guardado, primero se eliminan de las colas las operaciones innecesarias (por ejemplo, duplicados de eventos — cuando el objeto cambió dos veces o se realizó una rápida adición/eliminación de objetos), y solo después se realiza el guardado en sí.
En todos los casos, se guarda el objeto actual completo, por lo que puede haber situaciones en las que los objetos se guardan en un orden diferente al que cambiaron, incluso pueden guardarse versiones más nuevas de los objetos que en el momento de su adición a la cola.
¿Qué más hay?
- Todas las bibliotecas se basan en .NET Standard 2.0. Se pueden utilizar en cualquier proyecto moderno de .NET.
- La API es segura para hilos. Las colecciones internas se implementan sobre ConcurrentDictionary, los controladores de eventos tienen bloqueos o no los necesitan.
Lo único que hay que recordar es que, al finalizar la aplicación, se debe llamar a ObjectRepository.Save(); - Índices arbitrarios (requieren unicidad):
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");¿Quién lo usa?
Personalmente, comencé a usar este enfoque en todos mis proyectos de hobby, porque es conveniente y no requiere grandes gastos para escribir una capa de acceso a datos o desplegar una infraestructura pesada. Por lo general, para mí, es suficiente almacenar datos en litedb o en un archivo.
Pero en el pasado, cuando con el equipo hicimos la startup ahora desaparecida EscapeTeams (pensé que aquí estaba el dinero — pero no, otra vez experiencia) — usamos Azure Table Storage para almacenar datos.
Planes futuros
Quiero solucionar una de las principales desventajas de este enfoque: la escalabilidad horizontal. Para esto se necesita o bien transacciones distribuidas (¡sic!), o tomar la decisión de que los mismos datos de diferentes instancias no deben cambiar, o que cambien bajo el principio de "el último en llegar se queda con la razón".
Desde un punto de vista técnico, veo posible el siguiente esquema:
- Almacenar en lugar de un modelo de objetos EventLog y Snapshot
- Encontrar otras instancias (¿agregar a la configuración los puntos finales de todas las instancias? ¿descubrimiento UDP? ¿master/slave?)
- Replicar entre instancias EventLog a través de cualquiera de los algoritmos de consenso, por ejemplo RAFT.
También hay otro problema que me preocupa: la eliminación en cascada, o la detección de casos de eliminación de objetos que tienen referencias de otros objetos.
Código fuente
Si has llegado hasta aquí, lo único que queda es leer el código, que se puede encontrar en GitHub:
Fuente: habr.com
