7.1 Warum ist es notwendig?

Wir haben alle Eigenschaften von ACID, ihren Zweck und ihre Anwendungsfälle ausführlich besprochen. Wie Sie sehen, bieten nicht alle Datenbanken ACID-Garantien und müssen diese zugunsten einer besseren Leistung opfern. Daher kann es durchaus vorkommen, dass in Ihrem Projekt eine Datenbank ausgewählt wird, die kein ACID bietet, und Sie möglicherweise einige der erforderlichen ACID-Funktionalitäten auf der Anwendungsseite implementieren müssen. Und wenn Ihr System als Microservices oder eine andere Art verteilter Anwendung konzipiert ist, wird das, was eine normale lokale Transaktion in einem Dienst wäre, nun zu einer verteilten Transaktion – und verliert natürlich ihren ACID-Charakter, selbst wenn die Datenbank von Jeder einzelne Mikrodienst wird ACID sein.

Ich möchte Ihnen keine erschöpfende Anleitung zum Erstellen eines Transaktionsmanagers geben, weil er einfach zu umfangreich und zu kompliziert ist, und ich möchte nur einige grundlegende Techniken behandeln. Wenn wir nicht über verteilte Anwendungen sprechen, sehe ich keinen Grund, zu versuchen, ACID vollständig auf der Anwendungsseite zu implementieren, wenn Sie ACID-Garantien benötigen – schließlich wird es in jeder Hinsicht einfacher und billiger sein, eine vorgefertigte Lösung zu verwenden ( das heißt, eine Datenbank mit ACID).

Ich möchte Ihnen jedoch einige Techniken zeigen, die Ihnen bei der Durchführung von Transaktionen auf der Anwendungsseite helfen. Schließlich kann Ihnen die Kenntnis dieser Techniken in einer Vielzahl von Szenarien helfen, auch solchen, die nicht unbedingt Transaktionen beinhalten, und Sie zu einem besseren Entwickler machen (ich hoffe es).

7.2 Grundlegende Tools für Transaktionsliebhaber

Optimistisches und pessimistisches Blockieren. Hierbei handelt es sich um zwei Arten von Sperren einiger Daten, auf die gleichzeitig zugegriffen werden kann.

Optimistgeht davon aus, dass die Wahrscheinlichkeit des gleichzeitigen Zugriffs nicht so groß ist, und macht daher Folgendes: liest die gewünschte Zeile, merkt sich ihre Versionsnummer (oder Zeitstempel oder Prüfsumme/Hash – wenn Sie das Datenschema nicht ändern und eine Spalte für die Version hinzufügen können oder Zeitstempel) und bevor Änderungen für diese Daten in die Datenbank geschrieben werden, prüft es, ob sich die Version dieser Daten geändert hat. Wenn sich die Version geändert hat, müssen Sie den entstandenen Konflikt irgendwie lösen und die Daten aktualisieren („Commit“) oder die Transaktion zurücksetzen („Rollback“). Der Nachteil dieser Methode besteht darin, dass sie günstige Bedingungen für einen Fehler mit dem langen Namen „time-of-check to time-of-use“, abgekürzt TOCTOU, schafft: Der Zustand kann sich in der Zeitspanne zwischen Prüfung und Schreiben ändern. Ich habe keine Erfahrung mit optimistischem Sperren,

Als Beispiel habe ich eine Technologie aus dem täglichen Leben eines Entwicklers gefunden, die so etwas wie optimistisches Sperren verwendet – das ist das HTTP-Protokoll. Die Antwort auf die anfängliche HTTP-GET-Anfrage KANN einen ETag-Header für nachfolgende PUT-Anfragen vom Client enthalten, den der Client im If-Match-Header verwenden KANN. Bei den Methoden GET und HEAD sendet der Server die angeforderte Ressource nur zurück, wenn sie mit einem der ihm bekannten ETags übereinstimmt. Bei PUT und anderen unsicheren Methoden wird die Ressource auch in diesem Fall nur geladen. Wenn Sie nicht wissen, wie ETag funktioniert, finden Sie hier ein gutes Beispiel für die Verwendung der „feedparser“-Bibliothek (die beim Parsen von RSS und anderen Feeds hilft).


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

Der Pessimist hingegen geht davon aus, dass Transaktionen oft auf denselben Daten „treffen“, und um sein Leben zu vereinfachen und unnötige Wettlaufbedingungen zu vermeiden, blockiert er einfach die Daten, die er benötigt. Um den Sperrmechanismus zu implementieren, müssen Sie entweder eine Datenbankverbindung für Ihre Sitzung aufrechterhalten (anstatt Verbindungen aus einem Pool abzurufen – in diesem Fall müssen Sie höchstwahrscheinlich mit optimistischer Sperrung arbeiten) oder eine ID für die Transaktion verwenden , die unabhängig von der Verbindung verwendet werden kann. Der Nachteil des pessimistischen Sperrens besteht darin, dass seine Verwendung die Verarbeitung von Transaktionen im Allgemeinen verlangsamt. Sie können jedoch beruhigt mit den Daten umgehen und eine echte Isolation erreichen.

Eine zusätzliche Gefahr lauert jedoch im möglichen Deadlock, bei dem mehrere Prozesse auf voneinander gesperrte Ressourcen warten. Beispielsweise erfordert eine Transaktion die Ressourcen A und B. Prozess 1 hat Ressource A belegt, und Prozess 2 hat Ressource B belegt. Keiner der beiden Prozesse kann die Ausführung fortsetzen. Es gibt verschiedene Möglichkeiten, dieses Problem zu lösen – ich möchte jetzt nicht ins Detail gehen, also lesen Sie zuerst Wikipedia, aber kurz gesagt, es besteht die Möglichkeit, eine Sperrenhierarchie zu erstellen. Wenn Sie dieses Konzept näher kennenlernen möchten, dann sind Sie herzlich eingeladen, sich über das „Dinning Philosophers Problem“ („Problem der Essensphilosophen“) den Kopf zu zerbrechen.

Hier ist ein gutes Beispiel dafür, wie sich beide Sperren im selben Szenario verhalten.

In Bezug auf Implementierungen von Sperren. Ich möchte nicht ins Detail gehen, aber es gibt Sperrmanager für verteilte Systeme, zum Beispiel: ZooKeeper, Redis, etcd, Consul.

7.3 Idempotenz von Operationen

Idempotenter Code ist im Allgemeinen eine gute Praxis, und das ist genau dann der Fall, wenn es für einen Entwickler gut wäre, dies tun zu können, unabhängig davon, ob er Transaktionen verwendet oder nicht. Idempotenz ist die Eigenschaft einer Operation, dasselbe Ergebnis zu erzielen, wenn diese Operation erneut auf ein Objekt angewendet wird. Die Funktion wurde aufgerufen – lieferte das Ergebnis. Nach einer oder fünf Sekunden erneut angerufen - ergab das gleiche Ergebnis. Wenn sich die Daten in der Datenbank geändert haben, ist das Ergebnis natürlich anders. Daten in Drittsystemen dürfen nicht von einer Funktion abhängen, aber alles, was davon abhängt, muss vorhersehbar sein.

Es kann verschiedene Erscheinungsformen von Idempotenz geben. Eine davon ist lediglich eine Empfehlung zum Schreiben Ihres Codes. Erinnern Sie sich, dass die beste Funktion diejenige ist, die eine Sache tut? Und was wäre sinnvoll, Unit-Tests für diese Funktion zu schreiben? Wenn Sie diese beiden Regeln beachten, erhöhen Sie bereits die Wahrscheinlichkeit, dass Ihre Funktionen idempotent sind. Um Verwirrung zu vermeiden, möchte ich klarstellen, dass idempotente Funktionen nicht unbedingt „rein“ (im Sinne von „Funktionsreinheit“) sind. Reine Funktionen sind Funktionen, die nur mit den Daten arbeiten, die sie am Eingang erhalten haben, ohne diese in irgendeiner Weise zu verändern und das verarbeitete Ergebnis zurückzugeben. Dies sind die Funktionen, mit denen Sie Ihre Anwendung mithilfe funktionaler Programmiertechniken skalieren können. Da es sich um einige allgemeine Daten und eine Datenbank handelt, ist es unwahrscheinlich, dass unsere Funktionen rein sind.

Dies ist eine reine Funktion:


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

Aber diese Funktion ist nicht rein, sondern idempotent (bitte ziehen Sie aus diesen Teilen keine Rückschlüsse darauf, wie ich Code schreibe):


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

Anstelle vieler Worte kann ich einfach darüber sprechen, wie ich gezwungen wurde, zu lernen, wie man idempotente Programme schreibt. Ich arbeite viel mit AWS, wie Sie inzwischen sehen können, und es gibt einen Dienst namens AWS Lambda. Mit Lambda können Sie sich nicht um Server kümmern, sondern einfach Code laden, der als Reaktion auf bestimmte Ereignisse oder nach einem Zeitplan ausgeführt wird. Bei einem Ereignis kann es sich um Nachrichten handeln, die von einem Nachrichtenbroker zugestellt werden. In AWS ist dieser Broker AWS SNS. Ich denke, dass dies auch für diejenigen klar sein sollte, die nicht mit AWS arbeiten: Wir haben einen Broker, der Nachrichten über Kanäle („Themen“) sendet, und Microservices, die diese Kanäle abonniert haben, empfangen Nachrichten und reagieren irgendwie darauf.

Das Problem besteht darin, dass SNS Nachrichten „mindestens einmal“ zustellt („at-least-once-delivery“). Was bedeutet das? Dass Ihr Lambda-Code früher oder später zweimal aufgerufen wird. Und es passiert wirklich. Es gibt eine Reihe von Szenarien, in denen Ihre Funktion idempotent sein muss: Wenn beispielsweise Geld von einem Konto abgebucht wird, können wir davon ausgehen, dass jemand den gleichen Betrag zweimal abhebt, aber wir müssen sicherstellen, dass dies wirklich zwei unabhängige Male sind – mit anderen Worten, es handelt sich um zwei verschiedene Transaktionen und nicht um die Wiederholung einer.

Zur Abwechslung gebe ich noch ein weiteres Beispiel – die Begrenzung der Häufigkeit von Anfragen an die API („Rate Limiting“). Unser Lambda empfängt ein Ereignis mit einer bestimmten user_id, für das eine Prüfung durchgeführt werden sollte, um festzustellen, ob der Benutzer mit dieser ID die Anzahl möglicher Anfragen an einige unserer APIs ausgeschöpft hat. Wir könnten den Wert der getätigten Aufrufe in DynamoDB von AWS speichern und ihn mit jedem Aufruf unserer Funktion um 1 erhöhen.

Was aber, wenn diese Lambda-Funktion zweimal von demselben Ereignis aufgerufen wird? Haben Sie übrigens auf die Argumente der Funktion lambda_handler() geachtet? Das zweite Argument, der Kontext, wird in AWS Lambda standardmäßig angegeben und enthält verschiedene Metadaten, einschließlich der request_id, die für jeden einzelnen Aufruf generiert wird. Das bedeutet, dass wir jetzt, anstatt die Anzahl der getätigten Aufrufe in der Tabelle zu speichern, eine Liste von request_id speichern können und unser Lambda bei jedem Aufruf prüft, ob die gegebene Anfrage bereits verarbeitet wurde:

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 mein Beispiel tatsächlich aus dem Internet stammt, werde ich einen Link zur Originalquelle hinterlassen, zumal diese etwas mehr Informationen enthält.

Erinnern Sie sich daran, wie ich zuvor erwähnt habe, dass so etwas wie eine eindeutige Transaktions-ID zum Sperren gemeinsam genutzter Daten verwendet werden kann? Wir haben jetzt gelernt, dass es auch verwendet werden kann, um Operationen idempotent zu machen. Lassen Sie uns herausfinden, wie Sie solche IDs selbst generieren können.