7.1 Perché è necessario

Abbiamo discusso in dettaglio tutte le proprietà di ACID, il loro scopo e i casi d'uso. Come puoi vedere, non tutti i database offrono garanzie ACID, sacrificandole per prestazioni migliori. Pertanto, potrebbe accadere che nel progetto sia selezionato un database che non offre ACID e potrebbe essere necessario implementare alcune delle funzionalità ACID necessarie sul lato dell'applicazione. E se il tuo sistema è progettato come microservizi o qualche altro tipo di applicazione distribuita, quella che sarebbe una normale transazione locale in un servizio diventerà ora una transazione distribuita e, ovviamente, perderà la sua natura ACID, anche se il database di ogni singolo microservizio sarà ACID.

Non voglio darti una guida esaustiva su come creare un gestore di transazioni, semplicemente perché è troppo grande e complicato, e voglio solo coprire alcune tecniche di base. Se non stiamo parlando di applicazioni distribuite, non vedo alcun motivo per provare a implementare completamente ACID sul lato dell'applicazione se hai bisogno di garanzie ACID - dopotutto, sarà più facile ed economico in tutti i sensi prendere una soluzione già pronta ( cioè un database con ACID).

Ma vorrei mostrarti alcune tecniche che ti aiuteranno a fare transazioni sul lato dell'applicazione. Dopotutto, conoscere queste tecniche può aiutarti in una varietà di scenari, anche quelli che non implicano necessariamente transazioni, e renderti uno sviluppatore migliore (lo spero).

7.2 Strumenti di base per gli amanti delle transazioni

Blocco ottimista e pessimista. Questi sono due tipi di blocchi su alcuni dati a cui è possibile accedere contemporaneamente.

Ottimistapresuppone che la probabilità di accesso simultaneo non sia così grande, e quindi fa quanto segue: legge la riga desiderata, ricorda il suo numero di versione (o timestamp, o checksum / hash - se non puoi cambiare lo schema dei dati e aggiungi una colonna per la versione o timestamp) e prima di scrivere modifiche al database per questi dati, controlla se la versione di questi dati è cambiata. Se la versione è cambiata, è necessario in qualche modo risolvere il conflitto creato e aggiornare i dati ("commit") o ripristinare la transazione ("rollback"). Lo svantaggio di questo metodo è che crea condizioni favorevoli per un bug con il lungo nome “time-of-check to time-of-use”, abbreviato in TOCTOU: lo stato può cambiare nel periodo di tempo tra check e write. Non ho esperienza con il blocco ottimistico,

Ad esempio, ho trovato una tecnologia della vita quotidiana di uno sviluppatore che utilizza qualcosa come il blocco ottimistico: questo è il protocollo HTTP. La risposta alla richiesta HTTP GET iniziale PUÒ includere un'intestazione ETag per le successive richieste PUT dal client, che il client PUÒ utilizzare nell'intestazione If-Match. Per i metodi GET e HEAD, il server restituirà la risorsa richiesta solo se corrisponde a uno degli ETag che conosce. Per PUT e altri metodi non sicuri, caricherà la risorsa solo in questo caso. Se non sai come funziona ETag, ecco un buon esempio usando la libreria "feedparser" (che aiuta ad analizzare RSS e altri feed).


>>> 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!' 

Il pessimista, invece, parte dal fatto che le transazioni spesso si “incontreranno” sugli stessi dati, e per semplificarsi la vita ed evitare inutili race condition, si limita a bloccare i dati di cui ha bisogno. Per implementare il meccanismo di blocco, è necessario mantenere una connessione al database per la sessione (piuttosto che estrarre le connessioni da un pool, nel qual caso molto probabilmente dovrai lavorare con il blocco ottimistico) o utilizzare un ID per la transazione , che può essere utilizzato indipendentemente dalla connessione. Lo svantaggio del blocco pessimistico è che il suo utilizzo rallenta l'elaborazione delle transazioni in generale, ma puoi stare tranquillo sui dati e ottenere un vero isolamento.

Un ulteriore pericolo, tuttavia, si annida nel possibile deadlock, in cui diversi processi attendono risorse bloccate l'una dall'altra. Ad esempio, una transazione richiede le risorse A e B. Il processo 1 ha occupato la risorsa A e il processo 2 ha occupato la risorsa B. Nessuno dei due processi può continuare l'esecuzione. Esistono vari modi per risolvere questo problema: non voglio entrare nei dettagli ora, quindi leggi prima Wikipedia, ma in breve, esiste la possibilità di creare una gerarchia di blocchi. Se vuoi conoscere questo concetto in modo più dettagliato, allora sei invitato a scervellarti sul "problema dei filosofi da pranzo" ("problema dei filosofi da pranzo").

Ecco un buon esempio di come si comporteranno entrambi i blocchi nello stesso scenario.

Per quanto riguarda le implementazioni dei blocchi. Non voglio entrare nei dettagli, ma esistono gestori di lock per sistemi distribuiti, ad esempio: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotenza delle operazioni

Il codice idempotente è generalmente una buona pratica, e questo è esattamente il caso in cui sarebbe utile per uno sviluppatore essere in grado di farlo, indipendentemente dal fatto che utilizzi o meno le transazioni. L'idempotenza è la proprietà di un'operazione di produrre lo stesso risultato quando tale operazione viene applicata nuovamente a un oggetto. La funzione è stata chiamata - ha dato il risultato. Chiamato di nuovo dopo un secondo o cinque - ha dato lo stesso risultato. Naturalmente, se i dati nel database sono cambiati, il risultato sarà diverso. I dati in sistemi terzi potrebbero non dipendere da una funzione, ma tutto ciò che fa deve essere prevedibile.

Ci possono essere diverse manifestazioni di idempotenza. Uno di questi è solo una raccomandazione su come scrivere il tuo codice. Ricordi che la funzione migliore è quella che fa una cosa? E quale sarebbe una buona cosa scrivere unit test per questa funzione? Se aderisci a queste due regole, aumenti già la possibilità che le tue funzioni siano idempotenti. Per evitare confusione, chiarirò che le funzioni idempotenti non sono necessariamente “pure” (nel senso di “purezza della funzione”). Le funzioni pure sono quelle funzioni che operano solo sui dati che hanno ricevuto in ingresso, senza modificarli in alcun modo e restituendo il risultato elaborato. Queste sono le funzioni che ti consentono di ridimensionare la tua applicazione utilizzando tecniche di programmazione funzionale. Poiché stiamo parlando di alcuni dati generali e di un database, è improbabile che le nostre funzioni siano pure,

Questa è una funzione pura:


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

Ma questa funzione non è pura, ma idempotente (per favore non trarre conclusioni su come scrivo codice da questi pezzi):


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

Invece di molte parole, posso solo parlare di come sono stato costretto a imparare a scrivere programmi idempotenti. Lavoro molto con AWS, come puoi vedere ormai, e c'è un servizio chiamato AWS Lambda. Lambda ti consente di non occuparti dei server, ma semplicemente di caricare il codice che verrà eseguito in risposta ad alcuni eventi o secondo una pianificazione. Un evento può essere costituito da messaggi recapitati da un broker di messaggi. In AWS, questo broker è AWS SNS. Penso che questo dovrebbe essere chiaro anche per chi non lavora con AWS: abbiamo un broker che invia messaggi tramite canali (“topic”), e i microservizi che sono iscritti a questi canali ricevono messaggi e in qualche modo su di essi reagiscono.

Il problema è che SNS consegna i messaggi "almeno una volta" ("consegna almeno una volta"). Cosa significa? Che prima o poi il tuo codice Lambda verrà chiamato due volte. E succede davvero. Esistono diversi scenari in cui la tua funzione deve essere idempotente: ad esempio, quando il denaro viene prelevato da un conto, possiamo aspettarci che qualcuno prelevi lo stesso importo due volte, ma dobbiamo assicurarci che si tratti davvero di 2 volte indipendenti - in altre parole, si tratta di 2 transazioni diverse e non di una ripetizione di una.

Tanto per cambiare, fornirò un altro esempio: limitare la frequenza delle richieste all'API ("rate limiting"). La nostra Lambda riceve un evento con un determinato user_id per il quale occorre verificare se l'utente con quell'ID ha esaurito il suo numero di possibili richieste ad alcune delle nostre API. Potremmo archiviare in DynamoDB da AWS il valore delle chiamate effettuate e aumentarlo ad ogni chiamata alla nostra funzione di 1.

Ma cosa succede se questa funzione Lambda viene chiamata due volte dallo stesso evento? A proposito, hai prestato attenzione agli argomenti della funzione lambda_handler(). Il secondo argomento, il contesto in AWS Lambda, viene fornito per impostazione predefinita e contiene vari metadati, incluso request_id generato per ogni chiamata univoca. Ciò significa che ora, invece di memorizzare il numero di chiamate effettuate nella tabella, possiamo memorizzare un elenco di request_id e ad ogni chiamata il nostro Lambda verificherà se la richiesta data è già stata elaborata:

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"
    	})
	}

Poiché il mio esempio è effettivamente preso da Internet, lascerò un collegamento alla fonte originale, soprattutto perché fornisce qualche informazione in più.

Ricordi come ho detto prima che qualcosa come un ID transazione univoco può essere utilizzato per bloccare i dati condivisi? Ora abbiamo imparato che può essere utilizzato anche per rendere le operazioni idempotenti. Scopriamo in che modo puoi generare tu stesso tali ID.