7.1 De ce este necesar
Am discutat în detaliu toate proprietățile ACID, scopul și cazurile de utilizare ale acestora. După cum puteți vedea, nu toate bazele de date oferă garanții ACID, sacrificându-le pentru o performanță mai bună. Prin urmare, se poate întâmpla ca în proiectul dvs. să fie selectată o bază de date care nu oferă ACID și este posibil să fie necesar să implementați unele dintre funcționalitățile ACID necesare în partea aplicației. Și dacă sistemul dvs. este proiectat ca microservicii sau alt tip de aplicație distribuită, ceea ce ar fi o tranzacție locală normală într-un serviciu va deveni acum o tranzacție distribuită - și, desigur, își va pierde natura ACID, chiar dacă baza de date de fiecare microserviciu individual va fi ACID.
Nu vreau să vă ofer un ghid exhaustiv despre cum să creați un manager de tranzacții, pur și simplu pentru că este prea mare și complicat și vreau să acopăr doar câteva tehnici de bază. Dacă nu vorbim de aplicații distribuite, atunci nu văd niciun motiv să încercăm să implementezi ACID pe deplin pe partea aplicației dacă aveți nevoie de garanții ACID - la urma urmei, va fi mai ușor și mai ieftin în toate sensurile să luați o soluție gata făcută ( adică o bază de date cu ACID).
Dar aș dori să vă arăt câteva tehnici care vă vor ajuta să faceți tranzacții din partea aplicației. La urma urmei, cunoașterea acestor tehnici te poate ajuta într-o varietate de scenarii, chiar și în cele care nu implică neapărat tranzacții, și te poate face un dezvoltator mai bun (sper că da).
7.2 Instrumente de bază pentru iubitorii de tranzacții
Blocare optimistă și pesimistă. Acestea sunt două tipuri de blocări asupra unor date care pot fi accesate în același timp.
Optimistpresupune că probabilitatea de acces simultan nu este atât de mare și, prin urmare, face următoarele: citește linia dorită, își amintește numărul versiunii (sau marca temporală sau suma de control / hash - dacă nu puteți schimba schema de date și adăugați o coloană pentru versiune sau marca temporală), iar înainte de a scrie modificări în baza de date pentru aceste date, verifică dacă versiunea acestor date s-a schimbat. Dacă versiunea s-a schimbat, atunci trebuie să rezolvați cumva conflictul creat și să actualizați datele („commit”) sau să anulați tranzacția („rollback”). Dezavantajul acestei metode este că creează condiții favorabile pentru un bug cu denumirea lungă „time-of-check to time-of-use”, prescurtat ca TOCTOU: starea se poate schimba în perioada de timp dintre verificare și scriere. Nu am experiență cu blocarea optimistă,
De exemplu, am găsit o tehnologie din viața de zi cu zi a unui dezvoltator care folosește ceva de genul blocării optimiste - acesta este protocolul HTTP. Răspunsul la cererea HTTP GET inițială POATE include un antet ETag pentru cererile PUT ulterioare de la client, pe care clientul POT să le folosească în antetul If-Match. Pentru metodele GET și HEAD, serverul va trimite înapoi resursa solicitată numai dacă se potrivește cu unul dintre ETag-urile pe care le cunoaște. Pentru PUT și alte metode nesigure, va încărca resursa doar în acest caz. Dacă nu știți cum funcționează ETag, iată un exemplu bun de utilizare a bibliotecii „feedparser” (care ajută la analiza RSS și alte fluxuri).
>>> 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!'
Pesimistul, în schimb, pleacă de la faptul că tranzacțiile se vor „întâlni” adesea pe aceleași date, iar pentru a-și simplifica viața și a evita condițiile inutile de cursă, pur și simplu blochează datele de care are nevoie. Pentru a implementa mecanismul de blocare, fie trebuie să mențineți o conexiune la baza de date pentru sesiunea dvs. (în loc să extrageți conexiuni dintr-un pool - caz în care cel mai probabil va trebui să lucrați cu blocare optimistă), fie să utilizați un ID pentru tranzacție , care poate fi folosit indiferent de conexiune. Dezavantajul blocării pesimiste este că utilizarea acesteia încetinește procesarea tranzacțiilor în general, dar poți fi calm în privința datelor și poți obține o izolație reală.
Un pericol suplimentar, însă, pândește în eventualul impas, în care mai multe procese așteaptă resurse blocate unul de celălalt. De exemplu, o tranzacție necesită resurse A și B. Procesul 1 a ocupat resursa A, iar procesul 2 a ocupat resursa B. Niciunul dintre cele două procese nu poate continua execuția. Există diverse modalități de a rezolva această problemă - nu vreau să intru în detalii acum, așa că citiți mai întâi Wikipedia, dar pe scurt, există posibilitatea de a crea o ierarhie de blocare. Dacă doriți să cunoașteți acest concept mai detaliat, atunci sunteți invitat să vă răsfoiți „Problema filozofilor dining” („problema filosofilor dining”).
Iată un exemplu bun despre cum se vor comporta ambele încuietori în același scenariu.
În ceea ce privește implementările de încuietori. Nu vreau să intru în detalii, dar există manageri de blocare pentru sistemele distribuite, de exemplu: ZooKeeper, Redis, etcd, Consul.
7.3 Idempotenta operatiilor
Codul idempotent este, în general, o bună practică, și exact acesta este cazul când ar fi bine ca un dezvoltator să poată face acest lucru, indiferent dacă folosește sau nu tranzacții. Idempotenta este proprietatea unei operații de a produce același rezultat atunci când acea operație este aplicată din nou unui obiect. Funcția a fost numită - a dat rezultatul. Apelat din nou după o secundă sau cinci - a dat același rezultat. Desigur, dacă datele din baza de date s-au modificat, rezultatul va fi diferit. Este posibil ca datele din sisteme terțe să nu depindă de o funcție, dar orice lucru trebuie să fie previzibil.
Pot exista mai multe manifestări ale idempotnței. Una dintre ele este doar o recomandare despre cum să vă scrieți codul. Îți amintești că cea mai bună funcție este cea care face un singur lucru? Și ce ar fi un lucru bun să scrieți teste unitare pentru această funcție? Dacă respectați aceste două reguli, atunci creșteți deja șansa ca funcțiile dvs. să fie idempotente. Pentru a evita confuzia, voi clarifica faptul că funcțiile idempotente nu sunt neapărat „pure” (în sensul de „puritate a funcției”). Funcțiile pure sunt acele funcții care operează doar asupra datelor pe care le-au primit la intrare, fără a le modifica în vreun fel și a returna rezultatul procesat. Acestea sunt funcțiile care vă permit să vă scalați aplicația folosind tehnici de programare funcțională. Deoarece vorbim despre unele date generale și o bază de date, este puțin probabil ca funcțiile noastre să fie pure,
Aceasta este o funcție pură:
def square(num: int) -> int:
return num * num
Dar această funcție nu este pură, ci idempotentă (vă rugăm să nu trageți concluzii despre cum scriu codul din aceste piese):
def insert_data(insert_query: str, db_connection: DbConnectionType) -> int:
db_connection.execute(insert_query)
return True
În loc de multe cuvinte, pot doar să vorbesc despre cum am fost forțat să învăț cum să scriu programe idempotente. Lucrez mult cu AWS, după cum puteți vedea până acum, și există un serviciu numit AWS Lambda. Lambda vă permite să nu aveți grijă de servere, ci pur și simplu să încărcați codul care va rula ca răspuns la unele evenimente sau conform unui program. Un eveniment poate fi mesaje care sunt livrate de un broker de mesaje. În AWS, acest broker este AWS SNS. Cred că acest lucru ar trebui să fie clar chiar și pentru cei care nu lucrează cu AWS: avem un broker care trimite mesaje prin canale („subiecte”), iar microservicii care sunt abonate la aceste canale primesc mesaje și cumva reacționează asupra lor.
Problema este că SNS livrează mesaje „cel puțin o dată” („cel puțin o dată livrare”). Ce înseamnă? Că, mai devreme sau mai târziu, codul tău Lambda va fi apelat de două ori. Și chiar se întâmplă. Există o serie de scenarii în care funcția dvs. trebuie să fie idempotentă: de exemplu, atunci când banii sunt retrași dintr-un cont, ne putem aștepta ca cineva să retragă aceeași sumă de două ori, dar trebuie să ne asigurăm că acestea sunt într-adevăr de 2 ori independente - cu alte cuvinte, acestea sunt 2 tranzacții diferite și nu o repetare a uneia.
Pentru o modificare, voi da un alt exemplu - limitarea frecvenței solicitărilor la API („limitarea ratei”). Lambda noastră primește un eveniment cu un anumit user_id pentru care ar trebui efectuată o verificare pentru a vedea dacă utilizatorul cu acel ID și-a epuizat numărul de posibile solicitări către unele dintre API-urile noastre. Am putea stoca în DynamoDB de la AWS valoarea apelurilor efectuate și să o creștem cu fiecare apel la funcția noastră cu 1.
Dar dacă această funcție Lambda este apelată de același eveniment de două ori? Apropo, ați acordat atenție argumentelor funcției lambda_handler(). Al doilea argument, context în AWS Lambda este dat în mod implicit și conține diverse metadate, inclusiv request_id care este generat pentru fiecare apel unic. Aceasta înseamnă că acum, în loc să stocăm numărul de apeluri efectuate în tabel, putem stoca o listă de request_id și la fiecare apel Lambda nostru va verifica dacă cererea dată a fost deja procesată:
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"
})
}
Deoarece exemplul meu este de fapt preluat de pe Internet, voi lăsa un link către sursa originală, mai ales că oferă puțin mai multe informații.
Vă amintiți cum am menționat mai devreme că ceva de genul unui ID unic de tranzacție poate fi folosit pentru a bloca datele partajate? Am aflat acum că poate fi folosit și pentru a face operațiuni idempotente. Să aflăm în ce moduri poți genera singur astfel de ID-uri.