7.1 Por que é necessário

Discutimos com algum detalhe todas as propriedades do ACID, seu propósito e casos de uso. Como você pode ver, nem todos os bancos de dados oferecem garantias ACID, sacrificando-as para um melhor desempenho. Portanto, pode acontecer de um banco de dados que não oferece ACID ser selecionado em seu projeto e você pode precisar implementar algumas das funcionalidades ACID necessárias no lado do aplicativo. E se o seu sistema for projetado como microsserviços ou algum outro tipo de aplicativo distribuído, o que seria uma transação local normal em um serviço agora se tornará uma transação distribuída - e, é claro, perderá sua natureza ACID, mesmo que o banco de dados de cada microsserviço individual será ACID.

Não quero dar a você um guia exaustivo sobre como criar um gerenciador de transações, simplesmente porque é muito grande e complicado, e só quero cobrir algumas técnicas básicas. Se não estamos falando de aplicativos distribuídos, não vejo razão para tentar implementar ACID totalmente no lado do aplicativo se você precisar de garantias ACID - afinal, será mais fácil e barato em todos os sentidos adotar uma solução pronta ( ou seja, um banco de dados com ACID).

Mas gostaria de mostrar algumas técnicas que irão ajudá-lo a fazer transações no lado do aplicativo. Afinal, conhecer essas técnicas pode te ajudar em diversos cenários, mesmo aqueles que não envolvem necessariamente transações, e te tornar um desenvolvedor melhor (espero que sim).

7.2 Ferramentas básicas para amantes de transações

Bloqueio otimista e pessimista. Estes são dois tipos de bloqueios em alguns dados que podem ser acessados ​​ao mesmo tempo.

Otimistaassume que a probabilidade de acesso simultâneo não é tão grande e, portanto, faz o seguinte: lê a linha desejada, lembra seu número de versão (ou timestamp, ou checksum / hash - se você não puder alterar o esquema de dados e adicionar uma coluna para a versão ou timestamp), e antes de gravar alterações no banco de dados para esses dados, ele verifica se a versão desses dados foi alterada. Se a versão mudou, você precisa resolver de alguma forma o conflito criado e atualizar os dados ("commit") ou reverter a transação ("rollback"). A desvantagem desse método é que ele cria condições favoráveis ​​para um bug com o nome longo “time-of-check to time-of-use”, abreviado como TOCTOU: o estado pode mudar no período de tempo entre a verificação e a gravação. Não tenho experiência com bloqueio otimista,

Como exemplo, encontrei uma tecnologia do dia a dia de um desenvolvedor que usa algo como bloqueio otimista - esse é o protocolo HTTP. A resposta à solicitação HTTP GET inicial PODE incluir um cabeçalho ETag para solicitações PUT subsequentes do cliente, que o cliente PODE usar no cabeçalho If-Match. Para os métodos GET e HEAD, o servidor enviará de volta o recurso solicitado apenas se ele corresponder a um dos ETags que conhece. Para PUT e outros métodos inseguros, ele também carregará o recurso apenas neste caso. Se você não sabe como o ETag funciona, aqui está um bom exemplo usando a biblioteca "feedparser" (que ajuda a analisar RSS e outros feeds).


>>> import feedparser 
>>> d = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml') 
>>> d.etag 
'"6c132-941-ad7e3080"' 
>>> d2 = feedparser.parse('http://feedparser.org/docs/examples/atom10.xml', etag=d.etag) 
>>> d2.feed 
{} 
>>> d2.debug_message 
'The feed has not changed since you last checked, so the server sent no data.  This is a feature, not a bug!' 

O pessimista, por outro lado, parte do fato de que as transações geralmente “se encontram” nos mesmos dados e, para simplificar sua vida e evitar condições de corrida desnecessárias, ele simplesmente bloqueia os dados de que precisa. Para implementar o mecanismo de bloqueio, você precisa manter uma conexão de banco de dados para sua sessão (em vez de obter conexões de um pool - caso em que você provavelmente terá que trabalhar com bloqueio otimista) ou usar um ID para a transação , que pode ser usado independentemente da conexão. A desvantagem do bloqueio pessimista é que seu uso retarda o processamento das transações em geral, mas você pode ficar tranquilo com os dados e obter um isolamento real.

Um perigo adicional, no entanto, espreita no possível impasse, no qual vários processos aguardam por recursos travados entre si. Por exemplo, uma transação requer os recursos A e B. O processo 1 ocupou o recurso A e o processo 2 ocupou o recurso B. Nenhum dos dois processos pode continuar a execução. Existem várias maneiras de resolver esse problema - não quero entrar em detalhes agora, então leia a Wikipedia primeiro, mas, em resumo, existe a possibilidade de criar uma hierarquia de bloqueio. Se você quiser conhecer esse conceito com mais detalhes, então está convidado a quebrar a cabeça com o “Dinning Philosophers Problem” (“problema dos filósofos do jantar”).

Aqui está um bom exemplo de como os dois bloqueios se comportarão no mesmo cenário.

Em relação a implementações de bloqueios. Não quero entrar em detalhes, mas existem gerenciadores de bloqueio para sistemas distribuídos, por exemplo: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotência das operações

O código idempotente geralmente é uma boa prática, e é exatamente esse o caso quando seria bom para um desenvolvedor poder fazer isso, independentemente de usar transações ou não. Idempotência é a propriedade de uma operação de produzir o mesmo resultado quando essa operação é aplicada a um objeto novamente. A função foi chamada - deu o resultado. Chamado novamente após um segundo ou cinco - deu o mesmo resultado. Obviamente, se os dados no banco de dados forem alterados, o resultado será diferente. Dados em terceiros sistemas podem não depender de uma função, mas qualquer coisa que dependa deve ser previsível.

Pode haver várias manifestações de idempotência. Um deles é apenas uma recomendação sobre como escrever seu código. Você lembra que a melhor função é aquela que faz uma coisa só? E o que seria bom escrever testes de unidade para esta função? Se você aderir a essas duas regras, já aumenta a chance de suas funções serem idempotentes. Para evitar confusão, esclarecerei que funções idempotentes não são necessariamente “puras” (no sentido de “pureza de função”). Funções puras são aquelas funções que operam apenas nos dados que receberam na entrada, sem alterá-los de forma alguma e retornando o resultado processado. Estas são as funções que permitem escalar seu aplicativo usando técnicas de programação funcional. Como estamos falando de alguns dados gerais e de um banco de dados, é improvável que nossas funções sejam puras,

Esta é uma função pura:


def square(num: int) -> int: 
	return num * num 

Mas esta função não é pura, mas idempotente (por favor, não tire conclusões sobre como eu escrevo o código dessas peças):


def insert_data(insert_query: str, db_connection: DbConnectionType) -> int: 
  db_connection.execute(insert_query) 
  return True 

Em vez de muitas palavras, posso apenas falar sobre como fui forçado a aprender a escrever programas idempotentes. Eu trabalho muito com AWS, como você pode ver agora, e existe um serviço chamado AWS Lambda. O Lambda permite que você não cuide dos servidores, mas simplesmente carregue o código que será executado em resposta a alguns eventos ou de acordo com uma programação. Um evento pode ser mensagens entregues por um agente de mensagens. Na AWS, esse corretor é o AWS SNS. Acho que isso deve ficar claro até para quem não trabalha com AWS: temos um broker que envia mensagens por meio de canais (“tópicos”), e os microsserviços que estão inscritos nesses canais recebem mensagens e de alguma forma reagem a eles.

O problema é que o SNS entrega mensagens "pelo menos uma vez" ("at-least-once delivery"). O que isso significa? Que mais cedo ou mais tarde seu código Lambda será chamado duas vezes. E realmente acontece. Existem vários cenários em que sua função precisa ser idempotente: por exemplo, quando o dinheiro é retirado de uma conta, podemos esperar que alguém retire o mesmo valor duas vezes, mas precisamos garantir que sejam realmente 2 vezes independentes - em outras palavras, são 2 transações diferentes e não uma repetição de uma.

Para variar, darei outro exemplo - limitar a frequência de solicitações à API ("limitação de taxa"). Nosso Lambda recebe um evento com um determinado user_id para o qual deve ser feita uma verificação para ver se o usuário com esse ID esgotou seu número de solicitações possíveis para alguma de nossas APIs. Poderíamos armazenar no DynamoDB da AWS o valor das chamadas feitas e aumentá-lo a cada chamada para nossa função em 1.

Mas e se essa função do Lambda for chamada pelo mesmo evento duas vezes? A propósito, você prestou atenção nos argumentos da função lambda_handler(). O segundo argumento, context no AWS Lambda, é fornecido por padrão e contém vários metadados, incluindo o request_id que é gerado para cada chamada exclusiva. Isso significa que agora, ao invés de armazenar o número de chamadas feitas na tabela, podemos armazenar uma lista de request_id e a cada chamada nosso Lambda irá verificar se a requisição dada já foi processada:

import json
import os
from typing import Any, Dict

from aws_lambda_powertools.utilities.typing import LambdaContext  # needed only for argument type annotation
import boto3

limit = os.getenv('LIMIT')

def handler_name(event: Dict[str: Any], context: LambdaContext):

	request_id = context.aws_request_id

	# We find user_id in incoming event
	user_id = event["user_id"]

	# Our table for DynamoDB
	table = boto3.resource('dynamodb').Table('my_table')

	# Doing update
	table.update_item(
    	Key={'pkey': user_id},
    	UpdateExpression='ADD requests :request_id',
    	ConditionExpression='attribute_not_exists (requests) OR (size(requests) < :limit AND NOT contains(requests, :request_id))',
    	ExpressionAttributeValues={
        	':request_id': {'S': request_id},
        	':requests': {'SS': [request_id]},
        	':limit': {'N': limit}
    	}
	)

	# TODO: write further logic

	return {
    	"statusCode": 200,
    	"headers": {
        	"Content-Type": "application/json"
    	},
    	"body": json.dumps({
        	"status ": "success"
    	})
	}

Como meu exemplo foi retirado da Internet, deixarei um link para a fonte original, principalmente porque fornece um pouco mais de informação.

Lembre-se de como mencionei anteriormente que algo como um ID de transação exclusivo pode ser usado para bloquear dados compartilhados? Agora aprendemos que ele também pode ser usado para tornar as operações idempotentes. Vamos descobrir de que maneiras você mesmo pode gerar esses IDs.