postgres postgresql sgbd transacao

Porque usar um sistema transacional?

A resposta curta é que a sua aplicação e seu negócio precisam.

Mas a resposta completa é longa e complexa.

1 Aplicação precisa ler e escrever dados

O primeiro ponto importante é que a aplicação precisa escrever dados em algum lugar para poder lê-los em outro momento. Isso é chamado de persistência ou durabilidade e é uma característica fundamental para o seu negócio.

Sem ela, o seu salário poderia ser depositado na sua conta bancária e ser esquecido pelo sistema quando o aluguel fosse cobrado; ou o estoque da loja seria aumentado com um novo lote de produto, que seria esquecido quando o cliente tentasse finalizar a compra; ou a passagem em um assento de avião poderia ser reservada para uma pessoa e então o mesmo assento vendido para outra pessoa; ou um medicamento seria administrado para um paciente mas, o histórico dele permaneceria vazio. Todos esses casos causariam problemas e prejuízos para o seu negócio financeiro, de varejo, de serviços, médico, ou qualquer outro.

O risco ao negócio é tão grande que precisamos não só que a escrita seja feita, mas que ela seja feita com garantia de sucesso. Se a aplicação não receber essa garantia, ela ainda tem chance de refazer a operação ou até pedir para o usuário tentar novamente posteriormente. Afinal de contas, se as opções são apenas receber o salário com algumas horas de atraso ou nunca recebê-lo, todo correntista prefere a primeira opção.

Essa garantia é às vezes chamada de fsync, sync ou commit, dependendo do contexto, e precisa ser tratada corretamente por todas as camadas de hardware (disco, RAID, barramentos…), firmware (disco, controladora de storage, controladora de RAID…) e software (kernel, sistema operacional, gerenciador de volumes, RAID, SGBD, backend, frontend…) entre o usuário e os bits em disco.

Contudo, se esse fosse o único requisito, a solução seria trivial: a aplicação escreve os dados em um arquivo (o sistema de arquivos e sistema operacional garantem que a escrita foi persistida) e, posteriormente, a aplicação lê do arquivo. Como a seguir.

╔═══╗   ┏━━━┓
║App║---┃arq┃
╚═══╝   ┗━━━┛

Obs.: É claro que existem aplicações e negócios que não têm esse requisito, como algumas APIs REST, RPC, funções como serviço, caches, proxies e diversos outros exemplos stateless. Mas eles não nos interessam neste momento.

2 A aplicação precisa trabalhar com vários dados ao mesmo tempo

Então lembramos que o crédito à sua conta corrente precisa corresponder a um débito à conta corrente do seu empregador; que uma passagem em assento de avião reservada precisa corresponder a um boleto gerado e enviado para o passageiro; que itens adicionados ao estoque da loja precisam ser acompanhados de pagamentos enviados ao fornecedor, mas que itens retirados do estoque precisam ser acompanhados de um pagamento recebido do comprador; que um medicamento controlado que foi administrado a um paciente precisa sofrer baixa no estoque da farmácia do hospital.

Em suma, quase sempre precisamos trabalhar com mais do que um fragmento de dado. E esses dados precisam ser alterados garantidamente juntos e sem possibilidade de que um seja alterado sem o outro, caso contrário teremos problemas similares aos da falta de durabilidade, mas possivelmente agravados, como super-medicação, perdas no estoque, inflação e deflação da economia e outros. Ou seja, ou todas as operações são feitas completamente, ou nenhuma delas é feita. O nome dessa nova característica é atomicidade.

Agora o nosso diagrama seria:

       ┏━━━━━┓
      /┃A: $5┃
╔═══╗/ ┗━━━━━┛
║App║
╚═══╝\ ┏━━━━━┓
      \┃B: $2┃
       ┗━━━━━┛

E um programa simples que deveria mover $2 de A para B seria escrito como duas operações:

1    Início
2    A := A - $2
3
4    B := B + $2
5    Fim

Ou seja, um dado seria alterado primeiro, seguido do outro.

Mas a grande dificuldade vem quando lembramos que o programa pode ser interrompido em qualquer ponto devido a falhas de hardware ou software, falta de energia elétrica, queda de comunicação com o armazenamento ou diversas outras possíveis causas. Se a interrupção acontecer antes da linha 2 ou depois da linha 4, o estado persistido em disco é consistente, ou seja, os dados atendem todas as regras de negócio, já que o dinheiro total permaneceu igual (A + B = 5 + 2 = 7 => A + B = 3 + 4 = 7). Contudo, se a interrupção acontecer após a linha 2 e antes da linha 4, o débito foi efetivado sem um crédito em outra conta (ou o contrário se o programa fosse escrito com as linhas 2 e 4 trocadas).

A solução para esse problema é usar um log de intenção, ou log transacional, também chamado de WAL, REDO ou binlog em alguns contextos.

Uma terceira característica também é apresentada aqui, mas com pouco destaque: consistência. Ela garante que as regras de negócio sobre os dados que estamos manipulando não são violadas em nenhum momento. No exemplo da conta bancária, um banco (instituição financeira) tem um certo montante de dinheiro sob o seu controle, que é a soma do saldo de todas as contas bancárias que ele gerencia. Uma transferência entre duas contas deve debitar um valor de uma conta e creditar o mesmo valor a outra. Pela atomicidade temos a garantia de que as duas operações serão feitas, mas não necessariamente dos valores serem idênticos; pela consistência temos a garantia de que o montante sob o controle do banco permaneceu igual. Contudo, a maior parte das garantias de consistência depende das regras de negócio (como número de itens em estoque ser maior ou igual a zero ou um assento ser reservado apenas para uma pessoa) serem implementadas de alguma forma. Essas implementações e ferramentas que as apoiam são assuntos tangenciais a este assunto e são deixados para outro texto.

Obs.: Os dados podem estar em sistemas e ambientes completamente distintos. Nesse caso, usaríamos uma técnica chamada 2PC, ou commit de duas fases, que é vital para o sistema de envio de e-mails, para transferências entre bancos diferentes e outros casos. Mas para simplificar este texto, vamos considerar que todos são no mesmo sistema.

3 Várias aplicações precisam trabalhar com o mesmo dado

E outro fator entra em discussão quando lembramos que mais de um usuário precisa acessar o dado ao mesmo tempo. Isso acontece quando um item está sendo vendido por mais de um caixa e precisa ser subtraído do estoque corretamente; ou quando uma conta corrente sofre débitos e créditos de operações distintas; ou mesmo quando alguém está comprando uma passagem e outro está consultando os assentos disponíveis. Em todos esses casos os dados estão sendo requisitados ao mesmo tempo para operações distintas, que chamamos de acessos concorrentes.

Isso é particularmente perigoso nas operações de escrita, já que essas operações envolvem vários passos, especialmente: a) leitura do dado; b) alteração do dado; e c) escrita do novo valor. E o tempo entre cada passo é extremamente relevante quando temos duas ou mais operações concorrentes. Considerando um programa simples que reduz 1 item do estoque:

1    Início
2    C := Lê valor do disco
3
4    C := C - 1
5
6    Escreve valor no disco := C
7    Fim

E trabalha em duas operações concorrentes com o mesmo dado:

╔════╗
║App1║\
╚════╝ \┏━━━━┓
        ┃C: 4┃
╔════╗ /┗━━━━┛
║App2║/
╚════╝

Se as duas aplicações começarem ao mesmo tempo, farão a leitura do mesmo valor (C: 4). Cada uma, então, fará a subtração do seu próprio item do estoque (C := 4 - 1 = 3). E, por fim, as duas farão a escrita do novo valor C: 3, que não é o desejado (C: 2) para esse caso de uso. O problema claro neste cenário é que a contagem final estará errada nos casos em que o dado passou por um acesso concorrente, mas não quando executarem em momentos diferentes.

A solução é que todo acesso aos dados seja negociado entre as aplicações para que elas consigam gerar o resultado correto, independentemente de quais ou quantas delas estiverem em execução. Quando uma aplicação pretende trabalhar com um dado, ela notifica outras aplicações dessa intenção. Dentre todas as que tiverem a mesma intenção, uma seguirá com a execução da sua operação sobre os dados, enquanto as outras aguardam. Ao final da operação, a aplicação envia outra notificação anunciando que o trabalho terminou e permitindo que outra inicie a operação seguinte sobre o mesmo dado. Essa negociação é alcançada com o uso de primitivas de sincronização, como semáforos, mutexes, locks, latches, variáveis de condição, spinlocks, entre outras. O código das duas aplicações em execução concorrente ficaria:

1    Início
2    Notifica intenção de trabalhar com C e aguarda confirmação (Lock C)
3    C := Lê valor do disco
4
5    C := C - 1
6
7    Escreve valor no disco := C
8    Notifica a liberação de C (Unlock C)
9    Fim

Agora as duas aplicações em execução concorrente, apesar de iniciadas ao mesmo tempo, geram o resultado correto (C: 2):

 0 | App1                                    | App2
 1 | Início                                  | Início
 2 | Lock C (obtido)                         | Lock C (aguardando)
 3 | C := Lê valor do disco        # (C: 4)
 4 |
 5 | C := C - 1                    # (C: 3)
 6 |
 7 | Escreve valor no disco := C   # (C: 3)
 8 | Unlock C                                | Lock C (obtido)
 9 | Fim                                     | C := Lê valor do disco        # (C: 3)
10                                           |
11                                           | C := C - 1                    # (C: 2)
12                                           |
13                                           | Escreve valor no disco := C   # (C: 2)
14                                           | Unlock C
15                                           | Fim

Nesse cenário, dizemos que existe um lock no recurso C permitindo que apenas uma aplicação leia ou escreva em C. O lock foi obtido pelo App1 na linha 2, fazendo com que o App2 aguardasse. Enquanto isso, o App1 prosseguiu com o seu trabalho até o fim. O lock foi liberado pelo App1 na linha 8 e imediatamente obtido pelo App2. O App2 fez o seu trabalho e então liberou o lock na linha 14.

Assim, locks são vitais para que mais de um acesso, tanto de leituras quanto de escritas, sejam feitos de forma concorrente aos dados. É com eles que protegemos os dados de inconsistências oriundas de condições de corrida e outras anomalias naturais ao processamento concorrente. E com eles conseguimos escrever aplicações que se comportam como se estivessem sozinhas, mas que terão o resultado correto mesmo quando estiverem competindo por acessos aos dados.

Essa última característica é chamada do isolamento. Ela é mais complexa do que visto aqui, trazendo níveis de isolamento e anomalias, mas essa complexidade pode ser deixada para outro momento.

4 Várias aplicações trabalhando em vários dados

Unindo os dois últimos requisitos de várias aplicações e vários dados, assim como as características importantes, criamos o acrônimo ACID: Atomicidade, Consistência, Isolamento e Durabilidade. Essas quatro garantias juntas compõem o que chamamos de transação; e qualquer sistema que as entregue é chamado de sistema transacional. (Obs.: Esse conceito é usado em diversas áreas da computação. Por exemplo, com o git criamos sequências de transações locais, chamadas de commits, que são posteriormente agregadas para avançar o histórico de alterações de forma consistente, chamado de merge. Outro é que no kernel do Linux, o VFS, ou sistema de arquivos virtual, é uma camada de abstração sobre a qual todos os sistemas de arquivos são construídos e trabalha com transações, apesar das suas primitivas não serem disponibilizadas diretamente ao userspace. Também o distributed ledger por trás de criptomoedas trabalham com transações de duas fases com commit baseado em consenso.)

E chegamos no cenário mais completo e realista, em que diversos usuários, aplicações e instâncias de aplicações trabalham com uma vasta coleção de dados:

╔═══╗   ┏━━━┓
║App║---┃arq┃
╚═══╝\ /┗━━━┛
  |   X
╔═══╗/ \┏━━━┓
║App║---┃arq┃
╚═══╝\ /┗━━━┛
  |   X
  .  / \  .
  .  ---  .
  .       .

Contudo, é praticamente impossível fazer com que todas as aplicações que tenham interesse nos dados se conheçam e se comuniquem corretamente para sincronizar ações mantendo as garantias transacionais.

Uma forma mais efetiva de resolver esse cenário é fazer uma separação de responsabilidades em ao menos duas camadas, com uma cuidando da lógica de negócio e outra cuidando das garantias transacionais.

5 SGBD

Um Sistema de Gerenciamento de Bancos de Dados, ou SGBD, é a aplicação que cuida do gerenciamento direto dos dados em disco e que entrega à camada superior de aplicação (como um backend) apenas as garantias ACID na forma de primitivas transacionais (BEGIN, COMMIT, _ROLLBACK_…). Assim a aplicação que implementa a lógica do negócio se torna mais simples e focada.

╔═══╗     ┏━━━┓
║App║ ┌─┐ ┃arq┃
╚═══╝\│S│/┗━━━┛
      │ │
╔═══╗ │G│ ┏━━━┓
║App║-│ │-┃arq┃
╚═══╝ │B│ ┗━━━┛
  .  /│ │\  .
  .  /│D│\  .
  .   └─┘   .

O PostgreSQL é um SGBD relacional (trabalha com tabelas e relacionamentos), transacional (porque fornece as garantias ACID através de transações) e que aceita a linguagem SQL para interface entre ele e outras aplicações ou usuários.

Essas características influenciam diversos aspectos internos do PostgreSQL, como MVCC, locks, estruturas físicas e lógicas, replicação entre outros.