7.1 Pourquoi est-il nécessaire
Nous avons discuté en détail de toutes les propriétés d'ACID, de leur objectif et de leurs cas d'utilisation. Comme vous pouvez le voir, toutes les bases de données n'offrent pas les garanties ACID, les sacrifiant pour de meilleures performances. Par conséquent, il peut arriver qu'une base de données qui n'offre pas ACID soit sélectionnée dans votre projet, et vous devrez peut-être implémenter certaines des fonctionnalités ACID nécessaires du côté de l'application. Et si votre système est conçu comme des microservices, ou un autre type d'application distribuée, ce qui serait une transaction locale normale dans un service deviendra maintenant une transaction distribuée - et, bien sûr, perdra sa nature ACID, même si la base de données de chaque microservice individuel sera ACID.
Je ne veux pas vous donner un guide exhaustif sur la façon de créer un gestionnaire de transactions, simplement parce que c'est trop gros et compliqué, et je veux seulement couvrir quelques techniques de base. Si nous ne parlons pas d'applications distribuées, je ne vois aucune raison d'essayer d'implémenter pleinement ACID côté application si vous avez besoin de garanties ACID - après tout, il sera plus facile et moins cher dans tous les sens de prendre une solution toute faite ( c'est-à-dire une base de données avec ACID).
Mais je voudrais vous montrer quelques techniques qui vous aideront à effectuer des transactions du côté de l'application. Après tout, connaître ces techniques peut vous aider dans une variété de scénarios, même ceux qui n'impliquent pas nécessairement des transactions, et faire de vous un meilleur développeur (je l'espère).
7.2 Outils de base pour les amateurs de transactions
Blocage optimiste et pessimiste. Il s'agit de deux types de verrous sur certaines données accessibles en même temps.
Optimistesuppose que la probabilité d'accès simultané n'est pas si grande, et donc il fait ce qui suit : lit la ligne souhaitée, se souvient de son numéro de version (ou horodatage, ou somme de contrôle / hachage - si vous ne pouvez pas modifier le schéma de données et ajouter une colonne pour la version ou horodatage), et avant d'écrire les modifications dans la base de données pour ces données, il vérifie si la version de ces données a changé. Si la version a changé, vous devez résoudre d'une manière ou d'une autre le conflit créé et mettre à jour les données ("commit") ou annuler la transaction ("rollback"). L'inconvénient de cette méthode est qu'elle crée des conditions favorables pour un bogue avec le nom long "time-of-check to time-of-use", abrégé en TOCTOU : l'état peut changer dans la période de temps entre la vérification et l'écriture. Je n'ai aucune expérience avec le verrouillage optimiste,
À titre d'exemple, j'ai trouvé une technologie de la vie quotidienne d'un développeur qui utilise quelque chose comme le verrouillage optimiste - c'est le protocole HTTP. La réponse à la requête HTTP GET initiale PEUT inclure un en-tête ETag pour les requêtes PUT ultérieures du client, que le client PEUT utiliser dans l'en-tête If-Match. Pour les méthodes GET et HEAD, le serveur ne renverra la ressource demandée que si elle correspond à l'un des ETags qu'il connaît. Pour PUT et d'autres méthodes non sûres, il ne chargera la ressource que dans ce cas également. Si vous ne savez pas comment fonctionne ETag, voici un bon exemple utilisant la bibliothèque "feedparser" (qui aide à analyser les flux RSS et autres flux).
>>> 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!'
Le pessimiste, quant à lui, part du fait que les transactions vont souvent se "rencontrer" sur les mêmes données, et pour se simplifier la vie et éviter des conditions de course inutiles, il bloque simplement les données dont il a besoin. Afin d'implémenter le mécanisme de verrouillage, vous devez soit maintenir une connexion à la base de données pour votre session (plutôt que d'extraire des connexions d'un pool - auquel cas vous devrez probablement travailler avec un verrouillage optimiste), soit utiliser un ID pour la transaction , qui peut être utilisé quelle que soit la connexion. L'inconvénient du verrouillage pessimiste est que son utilisation ralentit le traitement des transactions en général, mais vous pouvez être calme sur les données et obtenir un véritable isolement.
Un danger supplémentaire, cependant, réside dans l'éventuelle impasse, dans laquelle plusieurs processus attendent des ressources verrouillées les unes par les autres. Par exemple, une transaction nécessite les ressources A et B. Le processus 1 a occupé la ressource A et le processus 2 a occupé la ressource B. Aucun des deux processus ne peut continuer son exécution. Il existe différentes façons de résoudre ce problème - je ne veux pas entrer dans les détails maintenant, alors lisez d'abord Wikipedia, mais en bref, il y a la possibilité de créer une hiérarchie de verrouillage. Si vous souhaitez connaître ce concept plus en détail, vous êtes invité à vous creuser la tête sur le «problème des philosophes de la restauration» («problème des philosophes de la restauration»).
Voici un bon exemple de la façon dont les deux verrous se comporteront dans le même scénario.
Concernant les implémentations de verrous. Je ne veux pas entrer dans les détails, mais il existe des gestionnaires de verrouillage pour les systèmes distribués, par exemple : ZooKeeper, Redis, etcd, Consul.
7.3 Idempotence des opérations
Le code idempotent est généralement une bonne pratique, et c'est exactement le cas lorsqu'il serait bon qu'un développeur puisse le faire, qu'il utilise ou non des transactions. L'idempotence est la propriété d'une opération de produire le même résultat lorsque cette opération est à nouveau appliquée à un objet. La fonction a été appelée - a donné le résultat. Rappelé après une seconde ou cinq - a donné le même résultat. Bien sûr, si les données de la base de données ont changé, le résultat sera différent. Les données dans les systèmes tiers peuvent ne pas dépendre d'une fonction, mais tout ce qui dépend d'une fonction doit être prévisible.
Il peut y avoir plusieurs manifestations d'idempotence. L'un d'eux est juste une recommandation sur la façon d'écrire votre code. Vous souvenez-vous que la meilleure fonction est celle qui fait une chose ? Et qu'est-ce qui serait une bonne chose d'écrire des tests unitaires pour cette fonction ? Si vous respectez ces deux règles, vous augmentez déjà les chances que vos fonctions soient idempotentes. Pour éviter toute confusion, je préciserai que les fonctions idempotentes ne sont pas nécessairement « pures » (au sens de « pureté de fonction »). Les fonctions pures sont les fonctions qui n'opèrent que sur les données qu'elles ont reçues en entrée, sans les modifier en aucune façon et en renvoyant le résultat traité. Ce sont les fonctions qui vous permettent de faire évoluer votre application à l'aide de techniques de programmation fonctionnelle. Puisque nous parlons de données générales et d'une base de données, nos fonctions ne sont probablement pas pures,
C'est une fonction pure :
def square(num: int) -> int:
return num * num
Mais cette fonction n'est pas pure, mais idempotente (veuillez ne pas tirer de conclusions sur la façon dont j'écris du code à partir de ces morceaux):
def insert_data(insert_query: str, db_connection: DbConnectionType) -> int:
db_connection.execute(insert_query)
return True
Au lieu de beaucoup de mots, je peux simplement parler de la façon dont j'ai été forcé d'apprendre à écrire des programmes idempotents. Je travaille beaucoup avec AWS, comme vous pouvez le voir maintenant, et il existe un service appelé AWS Lambda. Lambda vous permet de ne pas vous occuper des serveurs, mais simplement de charger du code qui s'exécutera en réponse à certains événements ou selon un calendrier. Un événement peut être des messages remis par un courtier de messages. Dans AWS, ce courtier est AWS SNS. Je pense que cela devrait être clair même pour ceux qui ne travaillent pas avec AWS : nous avons un courtier qui envoie des messages via des canaux (« sujets »), et les microservices qui sont abonnés à ces canaux reçoivent des messages et réagissent d'une manière ou d'une autre sur eux.
Le problème est que SNS délivre des messages "au moins une fois" ("livraison au moins une fois"). Qu'est-ce que ça veut dire? Que tôt ou tard votre code Lambda sera appelé deux fois. Et ça arrive vraiment. Il existe un certain nombre de scénarios où votre fonction doit être idempotente : par exemple, lorsque de l'argent est retiré d'un compte, nous pouvons nous attendre à ce que quelqu'un retire le même montant deux fois, mais nous devons nous assurer qu'il s'agit bien de 2 moments indépendants - en d'autres termes, il s'agit de 2 transactions différentes, et non d'une répétition d'une.
Pour changer, je vais donner un autre exemple - limiter la fréquence des requêtes à l'API ("limitation du taux"). Notre Lambda reçoit un événement avec un certain user_id pour lequel une vérification doit être effectuée pour voir si l'utilisateur avec cet ID a épuisé son nombre de demandes possibles à certaines de nos API. Nous pourrions stocker dans DynamoDB depuis AWS la valeur des appels effectués, et l'augmenter à chaque appel à notre fonction de 1.
Mais que se passe-t-il si cette fonction Lambda est appelée deux fois par le même événement ? Au fait, avez-vous fait attention aux arguments de la fonction lambda_handler(). Le deuxième argument, le contexte dans AWS Lambda, est donné par défaut et contient diverses métadonnées, y compris le request_id qui est généré pour chaque appel unique. Cela signifie que maintenant, au lieu de stocker le nombre d'appels effectués dans la table, nous pouvons stocker une liste de request_id et à chaque appel notre Lambda vérifiera si la requête donnée a déjà été traitée :
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"
})
}
Étant donné que mon exemple est en fait tiré d'Internet, je laisserai un lien vers la source originale, d'autant plus qu'il donne un peu plus d'informations.
Rappelez-vous comment j'ai mentionné plus tôt que quelque chose comme un ID de transaction unique peut être utilisé pour verrouiller les données partagées ? Nous avons maintenant appris qu'il peut également être utilisé pour rendre des opérations idempotentes. Découvrons de quelles manières vous pouvez générer vous-même de tels identifiants.