5.1 A questão da simultaneidade

Vamos começar com uma teoria um pouco distante.

Qualquer sistema de informação (ou simplesmente, um aplicativo) que os programadores criam consiste em vários blocos típicos, cada um dos quais fornece uma parte da funcionalidade necessária. Por exemplo, o cache é usado para lembrar o resultado de uma operação intensiva em recursos para garantir uma leitura mais rápida dos dados pelo cliente, as ferramentas de processamento de fluxo permitem enviar mensagens a outros componentes para processamento assíncrono e as ferramentas de processamento em lote são usadas para " rake" os volumes acumulados de dados com alguma periodicidade. .

E em quase todos os aplicativos, os bancos de dados (DBs) estão envolvidos de uma forma ou de outra, que geralmente executam duas funções: armazenar dados quando recebidos de você e depois fornecê-los a você mediante solicitação. Raramente alguém pensa em criar seu próprio banco de dados, pois já existem muitas soluções prontas. Mas como você escolhe o caminho certo para sua aplicação?

Então, vamos imaginar que você tenha escrito um aplicativo com uma interface móvel que permite carregar uma lista de tarefas salvas anteriormente em casa - ou seja, ler o banco de dados e complementá-lo com novas tarefas, além de priorizar cada tarefa - de 1 (maior) a 3 (menor). Digamos que seu aplicativo móvel seja usado por apenas uma pessoa por vez. Mas agora você se atreveu a contar a sua mãe sobre sua criação e agora ela se tornou a segunda usuária regular. O que acontece se você decidir ao mesmo tempo, no mesmo milissegundo, definir alguma tarefa - "lavar as janelas" - com um grau diferente de prioridade?

Em termos profissionais, as consultas à base de dados sua e da mãe podem ser consideradas como 2 processos que fizeram uma consulta à base de dados. Um processo é uma entidade em um programa de computador que pode ser executado em um ou mais threads. Normalmente, um processo tem uma imagem de código de máquina, memória, contexto e outros recursos. Em outras palavras, o processo pode ser caracterizado como a execução de instruções de programa no processador. Quando seu aplicativo faz uma solicitação ao banco de dados, estamos falando sobre o fato de seu banco de dados processar a solicitação recebida pela rede de um processo. Se houver dois usuários sentados no aplicativo ao mesmo tempo, pode haver dois processos em qualquer momento específico.

Quando algum processo faz uma solicitação ao banco de dados, ele o encontra em um determinado estado. Um sistema com estado é um sistema que lembra eventos anteriores e armazena algumas informações, que são chamadas de "estado". Uma variável declarada como integerpode ter um estado de 0, 1, 2 ou digamos 42. Mutex (exclusão mútua) tem dois estados: bloqueado ou desbloqueado , assim como um semáforo binário ("obrigatório" vs. "liberado") e geralmente binário (binários) tipos de dados e variáveis ​​que podem ter apenas dois estados - 1 ou 0.

Com base no conceito de estado, várias estruturas matemáticas e de engenharia são baseadas, como um autômato finito - um modelo que possui uma entrada e uma saída e está em um de um conjunto finito de estados a cada momento do tempo - e o “estado ” padrão de design, no qual um objeto muda de comportamento dependendo do estado interno (por exemplo, dependendo de qual valor é atribuído a uma ou outra variável).

Assim, a maioria dos objetos no mundo das máquinas tem algum estado que pode mudar com o tempo: nosso pipeline, que processa um grande pacote de dados, gera um erro e falha , ou a propriedade do objeto Wallet, que armazena a quantidade de dinheiro que resta na conta do usuário conta, alterações após os recebimentos da folha de pagamento.

Uma transição (“transição”) de um estado para outro — digamos, de em andamento para com falha — é chamada de operação. Provavelmente , todos conhecem as operações CRUD - create, read, ou métodos HTTP semelhantes - , , , . Mas os programadores costumam dar outros nomes às operações em seu código, porque a operação pode ser mais complexa do que apenas ler um determinado valor do banco de dados - ela também pode verificar os dados e, em seguida, nossa operação, que assumiu a forma de uma função, será chamado, por exemplo, E quem realiza essas operações-funções? processos já descritos.updatedeletePOSTGETPUTDELETEvalidate()

Um pouco mais e você entenderá por que descrevo os termos com tantos detalhes!

Qualquer operação - seja uma função, ou, em sistemas distribuídos, o envio de uma requisição para outro servidor - possui 2 propriedades: o tempo de invocação e o tempo de conclusão (completion time) , que será estritamente maior que o tempo de invocação (pesquisadores da Jepsen partir das suposições teóricas de que ambos os timestamps receberão relógios imaginários, totalmente sincronizados e globalmente disponíveis).

Vamos imaginar nosso aplicativo de lista de tarefas. Você faz uma solicitação ao banco de dados por meio da interface móvel em 14:00:00.014, e sua mãe em 13:59:59.678(ou seja, 336 milissegundos antes) atualizou a lista de tarefas por meio da mesma interface, adicionando lavar pratos a ela. Levando em consideração o atraso da rede e a possível fila de tarefas para seu banco de dados, se, além de você e sua mãe, todos os amigos de sua mãe também usarem seu aplicativo, o banco de dados pode executar a solicitação da mãe depois de processar a sua. Em outras palavras, há uma chance de que dois de seus pedidos, assim como pedidos das namoradas de sua mãe, sejam enviados para os mesmos dados ao mesmo tempo (simultaneamente).

Assim, chegamos ao termo mais importante no campo de bancos de dados e aplicativos distribuídos - simultaneidade. O que exatamente pode significar a simultaneidade de duas operações? Se alguma operação T1 e alguma operação T2 forem dadas, então:

  • T1 pode ser iniciado antes do horário de início da execução T2 e finalizado entre o horário inicial e final de T2
  • O T2 pode ser iniciado antes do horário de início do T1 e finalizado entre o início e o fim do T1
  • T1 pode ser iniciado e finalizado entre o horário inicial e final da execução do T1
  • e qualquer outro cenário em que T1 e T2 tenham algum tempo de execução comum

É claro que no âmbito desta palestra, estamos falando principalmente sobre consultas que entram no banco de dados e como o sistema de gerenciamento de banco de dados percebe essas consultas, mas o termo simultaneidade é importante, por exemplo, no contexto de sistemas operacionais. Não vou me desviar muito do assunto deste artigo, mas acho importante mencionar que a concorrência da qual estamos falando aqui não está relacionada ao dilema da concorrência e concorrência e sua diferença, que é discutido no contexto de sistemas operacionais e computação de alto desempenho. O paralelismo é uma maneira de obter simultaneidade em um ambiente com vários núcleos, processadores ou computadores. Estamos falando de concorrência no sentido de acesso simultâneo de diferentes processos a dados comuns.

E o que, de fato, pode dar errado, puramente teoricamente?

Ao trabalhar com dados compartilhados, podem ocorrer vários problemas relacionados à simultaneidade, também chamados de "condições de corrida". O primeiro problema ocorre quando um processo recebe dados que não deveria ter recebido: dados incompletos, temporários, cancelados ou "incorretos". O segundo problema é quando o processo recebe dados obsoletos, ou seja, dados que não correspondem ao último estado salvo do banco de dados. Digamos que algum aplicativo retirou dinheiro da conta de um usuário com saldo zero, porque o banco de dados retornou o status da conta ao aplicativo, sem levar em consideração o último saque de dinheiro dele, que aconteceu há apenas alguns milissegundos. A situação é mais ou menos, não é?

5.2 As transações vieram para nos salvar

Para resolver esses problemas, surgiu o conceito de transação - um determinado grupo de operações sequenciais (mudanças de estado) com um banco de dados, que é uma operação logicamente única. Vou dar um exemplo com um banco novamente - e não por acaso, porque o conceito de transação surgiu, aparentemente, justamente no contexto do trabalho com dinheiro. O exemplo clássico de transação é a transferência de dinheiro de uma conta bancária para outra: você precisa primeiro sacar o valor da conta de origem e depois depositar na conta de destino.

Para que essa transação seja realizada, o aplicativo precisará realizar várias ações no banco de dados: verificar o saldo do remetente, bloquear o valor na conta do remetente, adicionar o valor à conta do destinatário e deduzir o valor do remetente. Haverá vários requisitos para tal transação. Por exemplo, o aplicativo não pode receber informações desatualizadas ou incorretas sobre o saldo - por exemplo, se ao mesmo tempo uma transação paralela terminou com erro no meio e os fundos não foram debitados da conta - e nosso aplicativo já recebeu informações que os fundos foram cancelados.

Para resolver esse problema, foi chamada uma propriedade de uma transação como “isolamento”: nossa transação é executada como se não houvesse outras transações sendo executadas no mesmo momento. Nosso banco de dados executa operações concorrentes como se as estivesse executando uma após a outra, sequencialmente - na verdade, o maior nível de isolamento é chamado Strict Serializable . Sim, o mais alto, o que significa que existem vários níveis.

"Pare", você diz. Segure seus cavalos, senhor.

Vamos lembrar como descrevi que cada operação tem um tempo de chamada e um tempo de execução. Por conveniência, você pode considerar chamar e executar como 2 ações. Em seguida, a lista classificada de todas as ações de chamada e execução pode ser chamada de histórico do banco de dados. Então o nível de isolamento da transação é um conjunto de históricos. Usamos níveis de isolamento para determinar quais histórias são "boas". Quando dizemos que uma história “quebra a serialização” ou “não é serializável”, queremos dizer que a história não está no conjunto de histórias serializáveis.

Para deixar claro de que tipo de histórias estamos falando, darei exemplos. Por exemplo, existe esse tipo de histórico - leitura intermediária . Ocorre quando a transação A tem permissão para ler dados de uma linha que foi modificada por outra transação em execução B e ainda não foi confirmada ("não confirmada") - ou seja, na verdade, as alterações ainda não foram confirmadas definitivamente por transação B, podendo cancelá-las a qualquer momento. E, por exemplo, leitura abortada é apenas nosso exemplo com uma transação de retirada cancelada

Existem várias anomalias possíveis. Ou seja, anomalias são algum tipo de estado de dados indesejado que pode ocorrer durante o acesso competitivo ao banco de dados. E para evitar certos estados indesejados, os bancos de dados usam diferentes níveis de isolamento - ou seja, diferentes níveis de proteção de dados contra estados indesejados. Esses níveis (4 partes) foram listados no padrão ANSI SQL-92.

A descrição desses níveis parece vaga para alguns pesquisadores, e eles oferecem suas próprias classificações mais detalhadas. Aconselho você a ficar atento ao já citado Jepsen, bem como ao projeto Hermitage, que visa esclarecer exatamente quais níveis de isolamento são oferecidos por SGBDs específicos, como MySQL ou PostgreSQL. Se você abrir os arquivos deste repositório, poderá ver qual sequência de comandos SQL eles usam para testar o banco de dados em busca de certas anomalias e poderá fazer algo semelhante para os bancos de dados de seu interesse). Aqui está um exemplo do repositório para mantê-lo interessado:

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

É importante entender que para um mesmo banco de dados, via de regra, pode-se optar por um dos diversos tipos de isolamento. Por que não escolher o isolamento mais forte? Porque, como tudo em informática, o nível de isolamento escolhido deve corresponder a um trade-off que estamos dispostos a fazer - no caso, um trade-off na velocidade de execução: quanto mais forte o nível de isolamento, mais lentas serão as requisições processado. Para entender qual nível de isolamento você precisa, você precisa entender os requisitos do seu aplicativo e, para entender se o banco de dados escolhido oferece esse nível, você terá que consultar a documentação - para a maioria dos aplicativos isso será suficiente, mas se você tiver alguns requisitos particularmente rígidos, é melhor organizar um teste como o que os caras do projeto Hermitage fazem.

5.3 "I" e outras letras em ACID

Isolamento é basicamente o que as pessoas querem dizer quando falam sobre ACID em geral. E é por isso que comecei a análise dessa sigla com isolamento, e não fui em ordem, como costumam fazer aqueles que tentam explicar esse conceito. Agora vamos olhar para as três letras restantes.

Lembre-se novamente do nosso exemplo com uma transferência bancária. Uma transação de transferência de fundos de uma conta para outra inclui uma operação de saque na primeira conta e uma operação de reposição na segunda. Se a operação de reabastecimento da segunda conta falhou, provavelmente você não deseja que a operação de retirada da primeira conta ocorra. Em outras palavras, ou a transação é totalmente bem-sucedida ou não ocorre, mas não pode ser feita apenas por uma parte. Essa propriedade é chamada de "atomicidade" e é um "A" em ACID.

Quando nossa transação é executada, como qualquer operação, ela transfere o banco de dados de um estado válido para outro. Alguns bancos de dados oferecem as chamadas restrições - ou seja, regras que se aplicam aos dados armazenados, por exemplo, em relação a chaves primárias ou secundárias, índices, valores padrão, tipos de coluna, etc. Portanto, ao fazer uma transação, devemos ter certeza de que todas essas restrições serão atendidas.

Essa garantia é chamada de "consistência" e uma letra Cem ACID (não confundir com a consistência do mundo dos aplicativos distribuídos, sobre o qual falaremos mais adiante). Darei um exemplo claro de consistência no sentido de ACID: um aplicativo para uma loja online deseja adicionar ordersuma linha à tabela e o ID da tabela product_idserá indicado na coluna - típico .productsforeign key

Se o produto, digamos, foi removido do sortimento e, consequentemente, do banco de dados, a operação de inserção de linha não deve ocorrer e obteremos um erro. Essa garantia, em comparação com outras, é um pouco exagerada, na minha opinião - até porque o uso ativo de restrições do banco de dados significa transferir a responsabilidade pelos dados (bem como alterar parcialmente a lógica de negócios, se estivermos falando de uma restrição como CHECK ) do aplicativo para o banco de dados, o que, como dizem agora, é exatamente isso.

E, finalmente, permanece D- "resistência" (durabilidade). Uma falha do sistema ou qualquer outra falha não deve levar à perda dos resultados da transação ou do conteúdo do banco de dados. Ou seja, se o banco de dados respondeu que a transação foi bem-sucedida, isso significa que os dados foram gravados em memória não volátil - por exemplo, em um disco rígido. A propósito, isso não significa que você verá os dados imediatamente na próxima solicitação de leitura.

Outro dia, eu estava trabalhando com o DynamoDB da AWS (Amazon Web Services), e enviei alguns dados para salvar, e depois de receber uma resposta HTTP 200(OK), ou algo parecido, resolvi verificar - e não vi isso dados no banco de dados pelos próximos 10 segundos. Ou seja, o DynamoDB confirmou meus dados, mas nem todos os nós sincronizaram instantaneamente para obter a cópia mais recente dos dados (embora possa estar no cache). Aqui subimos novamente no território da consistência no contexto dos sistemas distribuídos, mas ainda não é hora de falar sobre isso.

Agora sabemos o que são as garantias ACID. E até sabemos por que eles são úteis. Mas nós realmente precisamos deles em todas as aplicações? E se não, quando exatamente? Todos os bancos de dados oferecem essas garantias e, se não, o que eles oferecem?