Por que armazenar todos os dados na memória?
Para armazenar os dados do site ou do backend, a primeira escolha da maioria das pessoas sensatas seria um banco de dados SQL.
Mas às vezes vem à cabeça que o modelo de dados não se encaixa no SQL: por exemplo, ao construir uma busca ou um gráfico social, é necessário buscar por relações complexas entre os objetos.
O pior é quando você trabalha em equipe e um colega não sabe construir consultas rápidas. Quanto tempo você gastou resolvendo problemas de N+1 e na criação de índices adicionais para que o SELECT na página principal fosse executado em um tempo razoável?
Outra abordagem popular é o NoSQL. Alguns anos atrás, havia uma grande expectativa em torno desse tema — para qualquer situação conveniente, era comum implementar o MongoDB e ficar feliz com as respostas no formato de documentos JSON. (aliás, quantos gambiarras você teve que fazer por causa de referências cíclicas nos documentos?).
Eu proponho tentar mais uma abordagem alternativa — por que não tentar armazenar todos os dados na memória do aplicativo, salvando periodicamente em um armazenamento arbitrário (arquivo, banco de dados remoto)?
A memória ficou barata, e a maioria dos dados de pequenos e médios projetos caberá em 1 GB de memória. (Por exemplo, meu projeto doméstico favorito — , que mantém estatísticas diárias e o histórico de meus gastos, saldos e transações ao longo de um ano e meio, consome apenas 45 MB de memória.)
Acesso direto aos sistemas de cabos transpacíficos. Ping mínimo para a China, Japão, Austrália e costa oeste dos EUA.
- O acesso aos dados se torna mais fácil — não precisamos nos preocupar com consultas, carregamento preguiçoso, peculiaridades da ORM, o trabalho é feito com objetos C# normais;
- Não há problemas relacionados ao acesso de diferentes threads;
- É muito rápido — não há solicitações de rede, ausência de tradução de código em linguagem de consulta, não é necessário (de)serializar objetos;
- É aceitável armazenar dados em qualquer formato — seja em XML no disco, seja no SQL Server, seja no Azure Table Storage.
Pontos negativos:
- A escalabilidade horizontal é perdida, e, como consequência, não é possível realizar uma implantação com zero tempo de inatividade;
- Se o aplicativo falhar — alguns dados podem ser perdidos. (Mas o nosso aplicativo nunca falha, certo?)
Como funciona?
O algoritmo é o seguinte:
- No início, uma conexão é estabelecida com o armazenamento de dados, e os dados são carregados;
- Uma modelo de objeto é construído, índices primários e índices de relacionamento (1:1, 1:Many) são criados;
- Uma assinatura é criada para alterações nas propriedades dos objetos (INotifyPropertyChanged) e para a adição ou remoção de elementos em uma coleção (INotifyCollectionChanged);
- Quando a assinatura é acionada, o objeto modificado é adicionado à fila para gravação no armazenamento de dados;
- Periodicamente (por meio de um timer), as alterações são salvas no armazenamento em um processo de fundo;
- Ao sair do aplicativo, as alterações também são salvas no armazenamento.
Exemplo de código
Adicionamos as dependências necessárias
// Основная библиотека
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.HangfireDescrevemos o modelo de dados que será salvo no armazenamento
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; }
}Em seguida, o modelo de objeto:
public class ParentModel : ModelBase
{
public ParentModel(ParentEntity entity)
{
Entity = entity;
}
public ParentModel()
{
Entity = new ParentEntity(Guid.NewGuid());
}
public Guid? NullableId => null;
// Exemplo de relação 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);
}
// Acesso com busca por índice
public ParentModel Parent => Single(ParentId);
protected override BaseEntity Entity => _childEntity;
}E finalmente a própria classe repositório para acesso aos dados:
public class MyObjectRepository : ObjectRepositoryBase
{
public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)
{
IsReadOnly = true; // Para testes, permite não salvar alterações no banco
AddType((ParentEntity x) => new ParentModel(x));
AddType((ChildEntity x) => new ChildModel(x));
// Se o Hangfire for utilizado e for necessário armazenar o modelo de dados para o Hangfire no ObjectRepository
// this.RegisterHangfireScheme();
Initialize();
}
}Criamos uma instância de ObjectRepository:
var memory = new MemoryStream();
var db = new LiteDatabase(memory);
var dbStorage = new LiteDbStorage(db);
var repository = new MyObjectRepository(dbStorage);
await repository.WaitForInitialize();Se o projeto utilizar HangFire
public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository)
{
services.AddHangfire(s => s.UseHangfireStorage(objectRepository));
}Inserindo um novo objeto:
var newParent = new ParentModel()
repository.Add(newParent);Com essa chamada, o objeto ParentModel é adicionado tanto ao cache local quanto à fila de gravação no banco de dados. Portanto, essa operação leva O(1), e esse objeto pode ser trabalhado imediatamente.
Por exemplo, para encontrar esse objeto no repositório e garantir que o objeto retornado seja a mesma instância:
var parents = repository.Set();
var myParent = parents.Find(newParent.Id);
Assert.IsTrue(ReferenceEquals(myParent, newParent));O que acontece nessa situação? Set() fundos! E ainda recebe elogios de 75-90% dos participantes, embora o desenvolvimento do projeto claramente tenha desacelerado. O mais surpreendente é que todos foram TableDictionary, que contém dentro de si ConcurrentDictionary e oferece funcionalidades adicionais de índices primários e secundários. Isso permite ter métodos para busca por Id (ou outros índices personalizados) sem percorrer todos os objetos.
Ao adicionar objetos ao ObjectRepository uma assinatura para a modificação de suas propriedades é adicionada, portanto, qualquer modificação nas propriedades também resulta na adição desse objeto à fila de gravação.
A atualização das propriedades externamente se parece exatamente com o trabalho com um objeto POCO:
myParent.Children.First().Property = "Updated value";Um objeto pode ser removido das seguintes maneiras:
repository.Remove(myParent);
repository.RemoveRange(otherParents);
repository.Remove(x => !x.Children.Any());Nesse caso, também acontece a adição do objeto à fila de exclusão.
Como funciona a gravação?
ObjectRepository a alteração dos objetos rastreados (tanto a adição ou remoção quanto a alteração de propriedades) acionam o evento ModelChanged, para o qual está inscrito IStorage. As implementações IStorage ao ocorrer o evento ModelChanged acumulam as mudanças em 3 filas — para adição, para atualização e para remoção.
Além disso, as implementações IStorage na inicialização criam um temporizador, que a cada 5 segundos chama a gravação das mudanças.
Além disso, existe uma API para chamada forçada de gravação: ObjectRepository.Save().
Antes de cada gravação, primeiro ocorre a remoção das operações desnecessárias das filas (como duplicatas de eventos — quando o objeto foi alterado duas vezes ou adicionações/remover rápidas de objetos), e apenas depois a própria gravação.
Em todos os casos, o objeto atual é salvo completamente, portanto, pode haver uma situação em que os objetos são salvos em uma ordem diferente da que foram alterados, incluindo versões mais novas dos objetos podendo ser salvas em um momento posterior à adição na fila.
O que mais há?
- Todas as bibliotecas são baseadas no .NET Standard 2.0. Podem ser usadas em qualquer projeto moderno do .NET.
- A API é thread-safe. As coleções internas são implementadas com base em ConcurrentDictionary, os manipuladores de eventos têm bloqueios ou não precisam deles.
A única coisa a lembrar é que, ao encerrar a aplicação, deve-se chamar ObjectRepository.Save(); - Índices arbitrários (exigem unicidade):
repository.Set<ChildModel>().AddIndex(x => x.Value);
repository.Set<ChildModel>().Find(x => x.Value, "myValue");Quem usa isso?
Pessoalmente, comecei a usar essa abordagem em todos os meus projetos de hobby, porque é conveniente e não requer grandes gastos para escrever uma camada de acesso a dados ou implantar uma infraestrutura pesada. Para mim, geralmente é suficiente armazenar dados no litedb ou em um arquivo.
Mas no passado, quando com a equipe fizemos a agora extinta startup EscapeTeams (pensei que era isso, o dinheiro — mas não, foi só mais uma experiência) — usamos o Azure Table Storage para armazenar dados.
Planos futuros
Quero consertar uma das principais desvantagens dessa abordagem — a escalabilidade horizontal. Para isso, são necessárias transações distribuídas (sic!), ou decidir que os mesmos dados de diferentes instâncias não devem mudar, ou permitir que mudem pelo princípio do "quem chega por último, tem razão".
Do ponto de vista técnico, vejo a seguinte estrutura como possível:
- Armazenar em vez de um modelo de objeto EventLog e Snapshot
- Encontrar outras instâncias (adicionar nos parâmetros as endpoints de todas as instâncias? descoberta UDP? mestre/escravo?)
- Replicar entre instâncias EventLog por meio de qualquer um dos algoritmos de consenso, como RAFT.
Também existe outro problema que me preocupa — a exclusão em cascata, ou a detecção de casos de exclusão de objetos que têm referências de outros objetos.
Código fonte
Se você leu até aqui — então agora só falta ler o código, que você pode encontrar no GitHub:
Fonte: habr.com
