7.1 Varför är det nödvändigt

Vi har diskuterat i detalj alla egenskaper hos ACID, deras syfte och användningsfall. Som du kan se erbjuder inte alla databaser ACID-garantier, vilket offra dem för bättre prestanda. Därför kan det mycket väl hända att en databas som inte erbjuder ACID väljs i ditt projekt, och du kan behöva implementera en del av den nödvändiga ACID-funktionaliteten på applikationssidan. Och om ditt system är designat som mikrotjänster, eller någon annan sorts distribuerad applikation, kommer vad som skulle vara en normal lokal transaktion i en tjänst nu att bli en distribuerad transaktion - och kommer naturligtvis att förlora sin ACID-karaktär, även om databasen med varje enskild mikrotjänst kommer att vara ACID.

Jag vill inte ge dig en uttömmande guide om hur du skapar en transaktionshanterare, helt enkelt för att den är för stor och komplicerad, och jag vill bara täcka några grundläggande tekniker. Om vi ​​inte pratar om distribuerade applikationer så ser jag ingen anledning att försöka implementera ACID fullt ut på applikationssidan om man behöver ACID-garantier - det blir trots allt enklare och billigare i alla avseenden att ta en färdig lösning ( det vill säga en databas med ACID).

Men jag skulle vilja visa dig några tekniker som hjälper dig att göra transaktioner på applikationssidan. När allt kommer omkring, att känna till dessa tekniker kan hjälpa dig i en mängd olika scenarier, även de som inte nödvändigtvis involverar transaktioner, och göra dig till en bättre utvecklare (jag hoppas det).

7.2 Grundläggande verktyg för transaktionsälskare

Optimistisk och pessimistisk blockering. Det här är två typer av lås på vissa data som kan nås samtidigt.

Optimistantar att sannolikheten för samtidig åtkomst inte är så stor, och därför gör den följande: läser den önskade raden, kommer ihåg dess versionsnummer (eller tidsstämpel, eller checksumma / hash - om du inte kan ändra dataschemat och lägga till en kolumn för version eller tidsstämpel), och innan du skriver ändringar i databasen för dessa data, kontrollerar den om versionen av dessa data har ändrats. Om versionen har ändrats måste du på något sätt lösa den skapade konflikten och uppdatera data (“commit”), eller återställa transaktionen (“backback”). Nackdelen med denna metod är att den skapar gynnsamma förutsättningar för en bugg med det långa namnet "time-of-check to time-of-use", förkortat TOCTOU: tillståndet kan ändras i tidsperioden mellan kontroll och skrivning. Jag har ingen erfarenhet av optimistisk låsning,

Som ett exempel hittade jag en teknik från en utvecklares dagliga liv som använder något som optimistisk låsning - det här är HTTP-protokollet. Svaret på den initiala HTTP GET-begäran KAN inkludera en ETag-huvud för efterföljande PUT-förfrågningar från klienten, som klienten KAN använda i If-Match-huvudet. För metoderna GET och HEAD kommer servern att skicka tillbaka den begärda resursen endast om den matchar en av de ET-taggar den känner till. För PUT och andra osäkra metoder kommer den bara att ladda resursen i detta fall också. Om du inte vet hur ETag fungerar, här är ett bra exempel med "feedparser"-biblioteket (som hjälper till att analysera RSS och andra flöden).


>>> 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, å andra sidan, utgår från det faktum att transaktioner ofta kommer att "träffas" på samma data, och för att förenkla sitt liv och undvika onödiga rasförhållanden, blockerar han helt enkelt den data han behöver. För att implementera låsmekanismen behöver du antingen upprätthålla en databasanslutning för din session (istället för att dra anslutningar från en pool - i vilket fall du med största sannolikhet måste arbeta med optimistisk låsning), eller använda ett ID för transaktionen , som kan användas oavsett anslutning. Nackdelen med pessimistisk låsning är att dess användning saktar ner bearbetningen av transaktioner i allmänhet, men du kan vara lugn om datan och få verklig isolering.

En ytterligare fara lurar dock i det eventuella dödläget, där flera processer väntar på resurser låsta av varandra. Till exempel kräver en transaktion resurser A och B. Process 1 har ockuperat resurs A och process 2 har ockuperat resurs B. Ingen av de två processerna kan fortsätta exekveringen. Det finns olika sätt att lösa det här problemet – jag vill inte gå in på detaljer nu, så läs Wikipedia först, men kort och gott så finns det möjlighet att skapa en låshierarki. Om du vill lära känna det här konceptet mer i detalj, då är du inbjuden att racka upp dina hjärnor över "Dining Philosophers Problem" ("matplatsfilosofers problem").

Här är ett bra exempel på hur båda låsen kommer att bete sig i samma scenario.

Angående implementeringar av lås. Jag vill inte gå in på detaljer, men det finns låshanterare för distribuerade system, till exempel: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotens av operationer

Idempotent kod är generellt sett en bra praxis, och det är precis så när det skulle vara bra för en utvecklare att kunna göra detta, oavsett om han använder transaktioner eller inte. Idempotens är egenskapen hos en operation att producera samma resultat när den operationen tillämpas på ett objekt igen. Funktionen kallades - gav resultatet. Ringde igen efter en sekund eller fem - gav samma resultat. Naturligtvis, om data i databasen har ändrats, blir resultatet ett annat. Data i tredje system kanske inte beror på en funktion, men allt som gör det måste vara förutsägbart.

Det kan finnas flera manifestationer av idempotens. En av dem är bara en rekommendation om hur du skriver din kod. Kommer du ihåg att den bästa funktionen är den som gör en sak? Och vad skulle vara bra att skriva enhetstester för den här funktionen? Om du följer dessa två regler ökar du redan chansen att dina funktioner blir idempotenta. För att undvika förvirring kommer jag att klargöra att idempotenta funktioner inte nödvändigtvis är "rena" (i betydelsen "funktionsrenhet"). Rena funktioner är de funktioner som endast fungerar på de data som de fick vid ingången, utan att ändra dem på något sätt och returnera det bearbetade resultatet. Det här är de funktioner som gör att du kan skala din applikation med hjälp av funktionella programmeringstekniker. Eftersom vi pratar om vissa allmänna data och en databas är det osannolikt att våra funktioner är rena,

Detta är en ren funktion:


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

Men den här funktionen är inte ren, utan idempotent (snälla dra inga slutsatser om hur jag skriver kod från dessa bitar):


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

Istället för en massa ord kan jag bara prata om hur jag tvingades lära mig att skriva idempotenta program. Jag jobbar mycket med AWS, som ni kan se vid det här laget, och det finns en tjänst som heter AWS Lambda. Lambda låter dig inte ta hand om servrar, utan bara ladda kod som körs som svar på vissa händelser eller enligt ett schema. En händelse kan vara meddelanden som levereras av en meddelandeförmedlare. I AWS är denna mäklare AWS SNS. Jag tycker att detta borde vara tydligt även för de som inte jobbar med AWS: vi har en mäklare som skickar meddelanden via kanaler (”ämnen”), och mikrotjänster som prenumererar på dessa kanaler tar emot meddelanden och på något sätt reagerar på dem.

Problemet är att SNS levererar meddelanden "minst en gång" ("at-least-once delivery"). Vad betyder det? Att din Lambdakod förr eller senare kommer att anropas två gånger. Och det händer verkligen. Det finns ett antal scenarier där din funktion måste vara idempotent: till exempel när pengar dras från ett konto kan vi förvänta oss att någon tar ut samma belopp två gånger, men vi måste se till att det verkligen är 2 oberoende gånger - med andra ord, detta är 2 olika transaktioner, och inte en upprepning av en.

För en förändring kommer jag att ge ett annat exempel - att begränsa frekvensen av förfrågningar till API:et ("hastighetsbegränsning"). Vår Lambda tar emot en händelse med ett visst user_id för vilket en kontroll bör göras för att se om användaren med det ID har uttömt sitt antal möjliga förfrågningar till några av våra API:er. Vi kunde lagra i DynamoDB från AWS värdet av de anrop som gjorts och öka det med 1 för varje anrop till vår funktion.

Men vad händer om denna Lambda-funktion anropas av samma händelse två gånger? Förresten, uppmärksammade du argumenten för lambda_handler()-funktionen. Det andra argumentet, kontext i AWS Lambda ges som standard och innehåller olika metadata, inklusive request_id som genereras för varje unikt anrop. Detta innebär att vi nu, istället för att lagra antalet gjorda samtal i tabellen, kan lagra en lista med request_id och vid varje samtal kommer vår Lambda att kontrollera om den givna begäran redan har behandlats:

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

Eftersom mitt exempel faktiskt är hämtat från Internet kommer jag att lämna en länk till originalkällan, speciellt eftersom den ger lite mer information.

Kommer du ihåg hur jag nämnde tidigare att något som ett unikt transaktions-ID kan användas för att låsa delad data? Vi har nu lärt oss att det också kan användas för att göra operationer idempotenta. Låt oss ta reda på på vilka sätt du kan skapa sådana ID själv.