7.1 Waarom is het nodig

We hebben alle eigenschappen van ACID, hun doel en use cases tot in detail besproken. Zoals u kunt zien, bieden niet alle databases ACID-garanties, waardoor ze worden opgeofferd voor betere prestaties. Daarom kan het heel goed gebeuren dat er in uw project een database wordt geselecteerd die geen ACID biedt en dat u enkele van de noodzakelijke ACID-functionaliteit aan de applicatiekant moet implementeren. En als uw systeem is ontworpen als microservices, of een ander soort gedistribueerde toepassing, wordt wat een normale lokale transactie in één service zou zijn nu een gedistribueerde transactie - en verliest natuurlijk zijn ACID-karakter, zelfs als de database van elke individuele microservice is ACID.

Ik wil u geen uitputtende handleiding geven over het maken van een transactiebeheerder, simpelweg omdat het te groot en ingewikkeld is, en ik wil alleen een paar basistechnieken behandelen. Als we het niet hebben over gedistribueerde applicaties, dan zie ik geen reden om te proberen ACID volledig te implementeren aan de applicatiekant als je ACID-garanties nodig hebt - het zal immers in alle opzichten gemakkelijker en goedkoper zijn om een ​​kant-en-klare oplossing te nemen ( dat wil zeggen een database met ACID).

Maar ik wil u graag enkele technieken laten zien die u zullen helpen bij het uitvoeren van transacties aan de applicatiekant. Het kennen van deze technieken kan je tenslotte helpen in verschillende scenario's, zelfs die waarbij niet noodzakelijkerwijs transacties betrokken zijn, en kan je een betere ontwikkelaar maken (ik hoop het).

7.2 Basistools voor transactieliefhebbers

Optimistische en pessimistische blokkering. Dit zijn twee soorten sloten op sommige gegevens die tegelijkertijd toegankelijk zijn.

Optimistgaat ervan uit dat de waarschijnlijkheid van gelijktijdige toegang niet zo groot is, en doet daarom het volgende: leest de gewenste regel, onthoudt het versienummer (of tijdstempel, of checksum / hash - als u het gegevensschema niet kunt wijzigen en een kolom voor versie kunt toevoegen of tijdstempel), en voordat wijzigingen in de database voor deze gegevens worden geschreven, wordt gecontroleerd of de versie van deze gegevens is gewijzigd. Als de versie is gewijzigd, moet u het gecreëerde conflict op de een of andere manier oplossen en de gegevens bijwerken ("commit"), of de transactie terugdraaien ("rollback"). Het nadeel van deze methode is dat het gunstige voorwaarden schept voor een bug met de lange naam “time-of-check to time-of-use”, afgekort als TOCTOU: de status kan veranderen in de tijd tussen check en write. Ik heb geen ervaring met optimistische vergrendeling,

Als voorbeeld vond ik een technologie uit het dagelijks leven van een ontwikkelaar die zoiets als optimistische vergrendeling gebruikt - dit is het HTTP-protocol. Het antwoord op het initiële HTTP GET-verzoek KAN een ETag-header bevatten voor volgende PUT-verzoeken van de client, die de client KAN gebruiken in de If-Match-header. Voor de GET- en HEAD-methoden stuurt de server de gevraagde bron alleen terug als deze overeenkomt met een van de ETags die hij kent. Voor PUT en andere onveilige methoden wordt de bron ook alleen in dit geval geladen. Als je niet weet hoe ETag werkt, is hier een goed voorbeeld van het gebruik van de "feedparser"-bibliotheek (die helpt bij het ontleden van RSS en andere 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!' 

De pessimist daarentegen gaat uit van het feit dat transacties vaak op dezelfde gegevens "ontmoeten", en om zijn leven te vereenvoudigen en onnodige race-omstandigheden te vermijden, blokkeert hij eenvoudigweg de gegevens die hij nodig heeft. Om het vergrendelingsmechanisme te implementeren, moet u ofwel een databaseverbinding onderhouden voor uw sessie (in plaats van verbindingen uit een pool te halen - in welk geval u hoogstwaarschijnlijk met optimistische vergrendeling zult moeten werken), of een ID voor de transactie gebruiken , die onafhankelijk van de verbinding kan worden gebruikt. Het nadeel van pessimistische vergrendeling is dat het gebruik ervan de verwerking van transacties in het algemeen vertraagt, maar u kunt rustig zijn over de gegevens en echt geïsoleerd raken.

Een bijkomend gevaar schuilt echter in de mogelijke impasse, waarin verschillende processen wachten op door elkaar vergrendelde resources. Voor een transactie zijn bijvoorbeeld resources A en B nodig. Proces 1 heeft resource A bezet en proces 2 heeft resource B bezet. Geen van beide processen kan doorgaan met uitvoeren. Er zijn verschillende manieren om dit probleem op te lossen - ik wil nu niet in details treden, dus lees eerst Wikipedia, maar kortom, er is de mogelijkheid om een ​​vergrendelingshiërarchie te creëren. Als je dit begrip nader wilt leren kennen, dan nodigen we je uit om je hersens te breken over het “Dinning Philosophers Problem” (“dining filosofen probleem”).

Hier is een goed voorbeeld van hoe beide sluizen zich in hetzelfde scenario zullen gedragen.

Over implementaties van sloten. Ik wil niet in details treden, maar er zijn sluisbeheerders voor gedistribueerde systemen, bijvoorbeeld: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotentie van operaties

Idempotente code is over het algemeen een goede gewoonte, en dit is precies het geval wanneer het voor een ontwikkelaar goed zou zijn om dit te kunnen doen, ongeacht of hij transacties gebruikt of niet. Idempotentie is de eigenschap van een bewerking om hetzelfde resultaat te produceren wanneer die bewerking opnieuw op een object wordt toegepast. De functie werd aangeroepen - gaf het resultaat. Weer gebeld na een seconde of vijf - gaf hetzelfde resultaat. Als de gegevens in de database zijn gewijzigd, zal het resultaat natuurlijk anders zijn. Gegevens in derde systemen zijn mogelijk niet afhankelijk van een functie, maar alles wat dat wel doet, moet voorspelbaar zijn.

Er kunnen verschillende manifestaties van idempotentie zijn. Een daarvan is slechts een aanbeveling voor het schrijven van uw code. Weet je nog dat de beste functie degene is die één ding doet? En wat zou een goede zaak zijn om unit tests voor deze functie te schrijven? Houd je je aan deze twee regels, dan vergroot je al de kans dat je functies idempotent zijn. Om verwarring te voorkomen, zal ik verduidelijken dat idempotente functies niet noodzakelijkerwijs "puur" zijn (in de zin van "functiezuiverheid"). Pure functies zijn die functies die alleen werken op de gegevens die ze bij de invoer hebben ontvangen, zonder ze op enigerlei wijze te wijzigen en het verwerkte resultaat terug te geven. Dit zijn de functies waarmee u uw toepassing kunt schalen met behulp van functionele programmeertechnieken. Aangezien we het hebben over enkele algemene gegevens en een database, is het onwaarschijnlijk dat onze functies zuiver zijn,

Dit is een pure functie:


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

Maar deze functie is niet puur, maar idempotent (trek alstublieft geen conclusies over hoe ik code schrijf uit deze stukken):


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

In plaats van veel woorden, kan ik gewoon vertellen hoe ik gedwongen werd om idempotente programma's te leren schrijven. Ik werk veel met AWS, zoals je inmiddels kunt zien, en er is een service genaamd AWS Lambda. Met Lambda hoeft u niet voor servers te zorgen, maar gewoon code te laden die wordt uitgevoerd als reactie op bepaalde gebeurtenissen of volgens een schema. Een gebeurtenis kan bestaan ​​uit berichten die worden afgeleverd door een berichtenmakelaar. In AWS is deze makelaar AWS SNS. Ik denk dat dit zelfs voor degenen die niet met AWS werken duidelijk moet zijn: we hebben een makelaar die berichten verzendt via kanalen ("onderwerpen"), en microservices die op deze kanalen zijn geabonneerd, ontvangen berichten en reageren er op de een of andere manier op.

Het probleem is dat SNS berichten "minstens één keer" aflevert ("minstens één keer bezorgd"). Wat betekent het? Dat je Lambda-code vroeg of laat twee keer wordt aangeroepen. En het gebeurt echt. Er zijn een aantal scenario's waarin uw functie idempotent moet zijn: als er bijvoorbeeld geld van een rekening wordt afgeschreven, kunnen we verwachten dat iemand hetzelfde bedrag twee keer opneemt, maar we moeten ervoor zorgen dat dit echt 2 onafhankelijke tijden zijn - met andere woorden, dit zijn 2 verschillende transacties, en geen herhaling van één.

Voor de verandering zal ik nog een voorbeeld geven - het beperken van de frequentie van verzoeken aan de API ("snelheidsbeperking"). Onze Lambda ontvangt een event met een bepaalde user_id waarvoor gecontroleerd moet worden of de gebruiker met die ID zijn aantal mogelijke verzoeken aan sommige van onze API's heeft uitgeput. We zouden in DynamoDB van AWS de waarde van de gemaakte oproepen kunnen opslaan en deze bij elke oproep naar onze functie met 1 kunnen verhogen.

Maar wat als deze Lambda-functie twee keer door dezelfde gebeurtenis wordt aangeroepen? Trouwens, heb je aandacht besteed aan de argumenten van de functie lambda_handler(). Het tweede argument, context in AWS Lambda, wordt standaard gegeven en bevat verschillende metadata, waaronder de request_id die wordt gegenereerd voor elke unieke oproep. Dit betekent dat we nu, in plaats van het aantal gemaakte oproepen in de tabel op te slaan, een lijst met request_id kunnen opslaan en bij elke oproep zal onze Lambda controleren of het gegeven verzoek al is verwerkt:

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

Aangezien mijn voorbeeld eigenlijk van internet is gehaald, zal ik een link naar de originele bron achterlaten, vooral omdat deze wat meer informatie geeft.

Weet je nog dat ik eerder zei dat zoiets als een uniek transactie-ID kan worden gebruikt om gedeelde gegevens te vergrendelen? We hebben nu geleerd dat het ook gebruikt kan worden om operaties idempotent te maken. Laten we eens kijken op welke manieren u dergelijke ID's zelf kunt genereren.