Trabalhando com um Banco de Dados de um Aplicativo

No começo, descreverei alguns problemas e recursos ao trabalhar com o banco de dados, mostrarei falhas nas abstrações. A seguir, analisaremos uma abstração mais simples com base na imunidade.


O leitor deve estar um pouco familiarizado com os padrões Active Record , Data Maper , Identity Map e Unit of Work .


Problemas e soluções são considerados no contexto de projetos grandes o suficiente que não podem ser descartados e reescritos rapidamente.


Mapa de identidade


O primeiro problema é o problema de manter a identidade. Identidade é algo que identifica exclusivamente uma entidade. No banco de dados, essa é a chave primária e, na memória, o link (ponteiro). É bom quando os links apontam para apenas um objeto.


Para bibliotecas ruby ActiveRecord , não é assim:


post_a = Post.find 1 post_b = Post.find 1 post_a.object_id != post_b.object_id # true post_a.title = "foo" post_b.title != "foo" # true 

I.e. nós temos 2 referências a 2 objetos diferentes na memória.


Assim, podemos perder alterações se inadvertidamente começarmos a trabalhar com a mesma entidade, mas representada por objetos diferentes.


O Hibernate possui uma sessão, na verdade um cache de primeiro nível que armazena o mapeamento de um identificador de entidade em um objeto na memória. Se solicitarmos novamente a mesma entidade, obteremos um link para um objeto existente. I.e. O Hibernate implementa o padrão Mapa de Identidade .


Transações longas


Mas e se não selecionarmos por identificador? Para impedir que o estado dos objetos e o estado do banco de dados estejam fora de sincronia, o Hibernate libera antes de solicitar uma seleção.
isto é despeja objetos sujos no banco de dados para que a solicitação leia os dados acordados.


Essa abordagem obriga a manter a transação do banco de dados aberta enquanto a transação comercial está em andamento.
Se a transação comercial for longa, o processo responsável pela conexão no próprio banco de dados também ficará inativo. Por exemplo, isso pode acontecer se uma transação comercial solicitar dados pela rede ou executar cálculos complexos.


N + 1


Talvez o maior “buraco” na abstração do ORM seja o problema de consulta N + 1.


Exemplo em ruby ​​para a biblioteca ActiveRecord:


 posts = Post.all # select * from posts posts.each do |post| like = post.likes.order(id: :desc).first # SELECT * FROM likes WHERE post_id = ? ORDER BY id DESC LIMIT 1 # ... end 

ORM leva o programador à idéia de que ele simplesmente trabalha com objetos na memória. Mas funciona com um serviço disponível na rede e no estabelecimento de conexões e transferência de dados
isso leva tempo. Mesmo se a solicitação for executada 50ms, 20 solicitações serão executadas por segundo.


Dados adicionais


Diga para evitar o problema N + 1 descrito acima, você escreve
pedido :


 SELECT * FROM posts JOIN LATERAL ( SELECT * FROM likes WHERE post_id = posts.id ORDER BY likes.id DESC LIMIT 1 ) as last_like ON true; 

I.e. além dos atributos da postagem, todos os atributos do último curtido também são selecionados. Para qual entidade esses dados são mapeados? Nesse caso, você pode retornar um par da postagem e curtir, porque o resultado contém todos os atributos necessários.


Mas e se selecionássemos apenas parte dos campos ou campos que não estão no modelo, por exemplo, o número de publicações gostadas? Eles precisam ser mapeados para entidades? Talvez deixá-los apenas dados?


Estado e identidade


Considere o código js:


 const alice = { id: 0, name: 'Alice' }; 

Aqui, a referência ao objeto recebeu o nome de alice . Porque é uma constante, então não há como chamar Alice de outro objeto. Ao mesmo tempo, o próprio objeto permaneceu mutável.


Por exemplo, podemos atribuir um identificador existente:


 const bob = { id: 1, name: 'Bob' }; alice.id = bob.id; 

Deixe-me lembrá-lo de que uma entidade possui 2 identidades: um link e uma chave primária no banco de dados. E as constantes não podem parar de fazer Alice Bob, mesmo depois de salvar.


O objeto, o link ao qual chamamos alice , cumpre 2 deveres: ele modela simultaneamente identidade e estado. Um estado é um valor que descreve uma entidade em um determinado momento.


Mas e se separarmos essas duas responsabilidades e usarmos estruturas imutáveis para o estado?


 function Ref(initialState, validator) { let state = initialState; this.deref = () => state; this.swap = (updater) => { const newState = updater(state); if (! validator(state, newState) ) throw "Invalid state"; state = newState; return newState; }; } const UserState = Immutable.Record({ id: null, name: '' }); const aliceState = new UserState({id: 0, name: 'Alice'}); const alice = new Ref( aliceState, (oldS, newS) => oldS.id === newS.id ); alice.swap( oldS => oldS.set('name', 'Queen Alice') ); alice.swap( oldS => oldS.set('id', 1) ); // BOOM! 

Ref - um contêiner para um estado imutável, permitindo sua substituição controlada. Ref modela a identidade assim como nomeamos objetos. Chamamos o rio Volga, mas a todo momento ele tem um estado imutável diferente.


Armazenamento


Considere a seguinte API:


 storage.tx( t => { const alice = t.get(0); const bobState = new UserState({id: 1, name: 'Bob'}); const bob = t.create(bobState); alice.swap( oldS => oldS.update('friends', old => old.push(bob.deref.id)) ); }); 

t.get e t.create retornam uma instância de Ref .


Abrimos a transação comercial t , localizamos Alice por seu identificador, criamos Bob e indicamos que Alice considera Bob sua amiga.


O objeto t controla a criação de ref .


t pode armazenar em si o mapeamento de identificadores de entidade para o estado ref que os contém. I.e. pode implementar o mapa de identidade. Nesse caso, t atua como um cache; mediante solicitação repetida de Alice, não haverá solicitação para o banco de dados.


t possível lembrar o estado inicial das entidades para rastrear no final da transação quais alterações precisam ser gravadas no banco de dados. I.e. pode implementar a Unidade de trabalho . Ou, se o suporte do observador for adicionado ao Ref , torna-se possível redefinir as alterações no banco de dados a cada alteração no ref . Essas são abordagens otimistas e pessimistas para corrigir alterações.


Com uma abordagem otimista, você precisa acompanhar as versões de estado das entidades.
Ao mudar do banco de dados, devemos lembrar a versão e, ao confirmar as alterações, verifique se a versão da entidade no banco de dados não difere da versão inicial. Caso contrário, você precisará repetir a transação comercial. Essa abordagem permite o uso de operações de inserção e exclusão de grupos e transações de banco de dados muito curtas, o que economiza recursos.


Com uma abordagem pessimista, uma transação de banco de dados é totalmente consistente com uma transação comercial. I.e. somos forçados a retirar a conexão do pool no momento em que a transação comercial é concluída.


A API permite extrair entidades uma por vez, o que não é muito ideal. Porque implementamos o padrão do mapa de identidade , então podemos inserir o método de preload - preload na API:


 storage.tx( t => { t.preload([0, 1, 2, 3]); const alice = t.get(0); // from cache }); 

Consultas


Se não queremos transações longas, não podemos fazer seleções por uma chave arbitrária, porque a memória pode conter objetos sujos e a seleção retornará um resultado inesperado.


Podemos usar a Consulta e recuperar qualquer dado (estado) fora da transação e reler os dados enquanto estiver na transação.


 const aliceId = userQuery.findByEmail('alice@mail.com'); storage.tx( t => { const alice = t.getOne(aliceId); }); 

Assim, há uma divisão de responsabilidade. Para consultas, podemos usar mecanismos de pesquisa para escalar a leitura usando réplicas. E a API de armazenamento sempre funciona com o armazenamento principal (mestre). Naturalmente, as réplicas conterão dados desatualizados. A releitura dos dados na transação resolve esse problema.


Comandos


Há situações em que uma operação pode ser executada sem a leitura de dados. Por exemplo, deduza uma taxa mensal das contas de todos os clientes. Ou insira e atualize dados (upsert) em caso de conflito.


Em caso de problemas de desempenho, o pacote configurável do Storage and Query pode ser substituído por esse comando.


Comunicações


Se as entidades se referem aleatoriamente, é difícil garantir consistência ao alterá-las. As relações tentam simplificar, otimizar, abandonar desnecessariamente.


Os agregados são uma maneira de organizar relacionamentos. Cada agregado possui uma entidade raiz e entidades aninhadas. Qualquer entidade externa pode se referir apenas à raiz do agregado. A raiz garante a integridade de toda a unidade. Uma transação não pode cruzar um limite agregado; em outras palavras, todo o agregado está envolvido na transação.


Um agregado pode, por exemplo, consistir na Quaresma (raiz) e suas traduções. Ou a ordem e suas posições.


Nossa API trabalha com agregados inteiros. Ao mesmo tempo, a integridade referencial entre os agregados está no aplicativo. A API não suporta carregamento lento de links.
Mas podemos escolher a direção das relações. Considere o relacionamento um para muitos Usuário - Postagem. Podemos armazenar o ID do usuário na postagem, mas será conveniente? Obteremos muito mais informações se armazenarmos uma matriz de identificadores de postagem no usuário.


Conclusão


Enfatizei os problemas ao trabalhar com o banco de dados, mostrei a opção de usar imunidade.
O formato do artigo não permite revelar o tópico em detalhes.


Se você estiver interessado nessa abordagem, preste atenção no meu aplicativo de livros do zero , que descreve a criação de um aplicativo Web do zero, com ênfase na arquitetura. Ele entende o SOLID, Arquitetura Limpa, padrões de trabalho com o banco de dados. As amostras de código do livro e o próprio aplicativo são escritas na linguagem Clojure, imbuída das idéias de imunidade e da conveniência do processamento de dados.

Source: https://habr.com/ru/post/pt433100/


All Articles