7.1 Hvorfor er det nødvendigt

Vi har diskuteret i nogen detaljer alle egenskaberne ved ACID, deres formål og anvendelsestilfælde. Som du kan se, tilbyder ikke alle databaser ACID-garantier, hvilket ofrer dem for bedre ydeevne. Derfor kan det godt ske, at en database, der ikke tilbyder ACID, bliver udvalgt i dit projekt, og du skal muligvis implementere noget af den nødvendige ACID-funktionalitet på applikationssiden. Og hvis dit system er designet som mikrotjenester eller en anden form for distribueret applikation, vil det, der ville være en normal lokal transaktion i én tjeneste, nu blive en distribueret transaktion - og vil selvfølgelig miste sin ACID karakter, selvom databasen med hver enkelt mikroservice vil være ACID.

Jeg ønsker ikke at give dig en udtømmende guide til, hvordan du opretter en transaktionsmanager, simpelthen fordi den er for stor og kompliceret, og jeg vil kun dække nogle få grundlæggende teknikker. Hvis vi ikke taler om distribuerede applikationer, så ser jeg ingen grund til at forsøge fuldt ud at implementere ACID på applikationssiden, hvis du har brug for ACID-garantier - det vil trods alt være nemmere og billigere i enhver forstand at tage en færdig løsning ( det vil sige en database med ACID).

Men jeg vil gerne vise dig nogle teknikker, der vil hjælpe dig med at foretage transaktioner på applikationssiden. Når alt kommer til alt, kan det at kende disse teknikker hjælpe dig i en række forskellige scenarier, selv dem, der ikke nødvendigvis involverer transaktioner, og gøre dig til en bedre udvikler (det håber jeg).

7.2 Grundlæggende værktøjer til transaktionselskere

Optimistisk og pessimistisk blokering. Dette er to typer låse på nogle data, der kan tilgås på samme tid.

Optimistantager, at sandsynligheden for samtidig adgang ikke er så stor, og derfor gør den følgende: læser den ønskede linje, husker dens versionsnummer (eller tidsstempel eller checksum / hash - hvis du ikke kan ændre dataskemaet og tilføje en kolonne for version eller tidsstempel), og før du skriver ændringer til databasen for disse data, tjekker den, om versionen af ​​disse data er ændret. Hvis versionen er ændret, skal du på en eller anden måde løse den oprettede konflikt og opdatere dataene (“commit”) eller rulle transaktionen tilbage (“rollback”). Ulempen ved denne metode er, at den skaber gunstige betingelser for en fejl med det lange navn "time-of-check to time-of-use", forkortet TOCTOU: tilstanden kan ændre sig i tidsrummet mellem check og skrivning. Jeg har ingen erfaring med optimistisk låsning,

Som et eksempel fandt jeg en teknologi fra en udviklers dagligdag, der bruger noget som optimistisk låsning - dette er HTTP-protokollen. Svaret på den indledende HTTP GET-anmodning KAN indeholde en ETag-header for efterfølgende PUT-anmodninger fra klienten, som klienten KAN bruge i If-Match-headeren. For GET- og HEAD-metoderne sender serveren kun den anmodede ressource tilbage, hvis den matcher et af de ETags, den kender. For PUT og andre usikre metoder vil den kun indlæse ressourcen i dette tilfælde også. Hvis du ikke ved, hvordan ETag fungerer, er her et godt eksempel ved at bruge "feedparser"-biblioteket (som hjælper med at analysere RSS og andre 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!' 

Pessimisten tager på den anden side udgangspunkt i, at transaktioner ofte vil "mødes" på de samme data, og for at forenkle sit liv og undgå unødvendige raceforhold, blokerer han blot for de data, han har brug for. For at implementere låsemekanismen skal du enten vedligeholde en databaseforbindelse til din session (i stedet for at trække forbindelser fra en pool - i hvilket tilfælde du højst sandsynligt skal arbejde med optimistisk låsning), eller bruge et ID til transaktionen , som kan bruges uanset forbindelsen. Ulempen ved pessimistisk låsning er, at brugen af ​​den bremser behandlingen af ​​transaktioner generelt, men du kan være rolig omkring dataene og få reel isolation.

En yderligere fare lurer dog i det mulige dødvande, hvor flere processer venter på ressourcer låst af hinanden. For eksempel kræver en transaktion ressource A og B. Proces 1 har optaget ressource A, og proces 2 har optaget ressource B. Ingen af ​​de to processer kan fortsætte eksekveringen. Der er forskellige måder at løse dette problem på - jeg ønsker ikke at gå i detaljer nu, så læs Wikipedia først, men kort fortalt er der mulighed for at lave et låsehierarki. Hvis du vil lære dette koncept mere detaljeret at kende, så er du inviteret til at tude over "Spisefilosofernes problem" ("spisefilosofernes problem").

Her er et godt eksempel på, hvordan begge låse vil opføre sig i samme scenarie.

Vedrørende implementeringer af låse. Jeg ønsker ikke at gå i detaljer, men der findes låseadministratorer til distribuerede systemer, for eksempel: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotens af operationer

Idempotent kode er generelt en god praksis, og det er netop tilfældet, når det ville være godt for en udvikler at kunne gøre dette, uanset om han bruger transaktioner eller ej. Idempotens er en operations egenskab til at producere det samme resultat, når denne operation anvendes på et objekt igen. Funktionen blev kaldt - gav resultatet. Ringede igen efter et sekund eller fem - gav samme resultat. Hvis dataene i databasen har ændret sig, vil resultatet naturligvis være anderledes. Data i tredje systemer afhænger muligvis ikke af en funktion, men alt, der gør, skal være forudsigeligt.

Der kan være flere manifestationer af idempotens. En af dem er blot en anbefaling til, hvordan du skriver din kode. Kan du huske, at den bedste funktion er den, der gør én ting? Og hvad ville være en god ting at skrive enhedstest til denne funktion? Hvis du overholder disse to regler, så øger du allerede chancen for, at dine funktioner bliver idempotente. For at undgå forvirring vil jeg præcisere, at idempotente funktioner ikke nødvendigvis er "rene" (i betydningen "funktionsrenhed"). Rene funktioner er de funktioner, der kun fungerer på de data, de modtog ved inputtet, uden at ændre dem på nogen måde og returnere det behandlede resultat. Det er de funktioner, der giver dig mulighed for at skalere din applikation ved hjælp af funktionelle programmeringsteknikker. Da vi taler om nogle generelle data og en database, er vores funktioner usandsynligt rene,

Dette er en ren funktion:


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

Men denne funktion er ikke ren, men idempotent (drag venligst ikke konklusioner om, hvordan jeg skriver kode fra disse stykker):


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

I stedet for en masse ord, kan jeg bare tale om, hvordan jeg blev tvunget til at lære at skrive idempotente programmer. Jeg arbejder meget med AWS, som du kan se efterhånden, og der er en tjeneste, der hedder AWS Lambda. Lambda giver dig mulighed for ikke at tage dig af servere, men blot indlæse kode, der kører som svar på nogle hændelser eller i henhold til en tidsplan. En hændelse kan være meddelelser, der leveres af en meddelelsesmægler. I AWS er ​​denne mægler AWS SNS. Jeg tror, ​​at dette burde være klart, selv for dem, der ikke arbejder med AWS: Vi har en mægler, der sender beskeder gennem kanaler ("emner"), og mikrotjenester, der abonnerer på disse kanaler, modtager beskeder og på en eller anden måde reagerer på dem.

Problemet er, at SNS leverer beskeder "mindst én gang" ("at-mindst-en gang levering"). Hvad betyder det? At din Lambda-kode før eller siden bliver kaldt to gange. Og det sker virkelig. Der er en række scenarier, hvor din funktion skal være idempotent: for eksempel, når der hæves penge fra en konto, kan vi forvente, at nogen hæver det samme beløb to gange, men vi skal sikre os, at det virkelig er 2 uafhængige gange - det er med andre ord 2 forskellige transaktioner, og ikke en gentagelse af den ene.

Til en ændring vil jeg give et andet eksempel - begrænsning af hyppigheden af ​​anmodninger til API'et ("rate limiting"). Vores Lambda modtager en begivenhed med et bestemt user_id, for hvilket der skal tjekkes, om brugeren med det ID har opbrugt sit antal mulige anmodninger til nogle af vores API'er. Vi kunne lagre værdien af ​​de foretagne opkald i DynamoDB fra AWS og øge den med 1 for hvert opkald til vores funktion.

Men hvad hvis denne Lambda-funktion kaldes af den samme hændelse to gange? Forresten, var du opmærksom på argumenterne for lambda_handler()-funktionen. Det andet argument, kontekst i AWS Lambda er givet som standard og indeholder forskellige metadata, inklusive request_id, der genereres for hvert unikt kald. Det betyder, at vi nu, i stedet for at gemme antallet af foretagede opkald i tabellen, kan gemme en liste over request_id og ved hvert opkald vil vores Lambda kontrollere, om den givne anmodning allerede er blevet behandlet:

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

Da mit eksempel faktisk er taget fra internettet, vil jeg efterlade et link til den originale kilde, især da det giver lidt mere information.

Kan du huske, hvordan jeg nævnte tidligere, at noget som et unikt transaktions-id kan bruges til at låse delte data? Vi har nu erfaret, at det også kan bruges til at gøre operationer idempotente. Lad os finde ud af, på hvilke måder du selv kan generere sådanne ID'er.