5.1 La question de la simultanéité

Commençons par une petite théorie lointaine.

Tout système d'information (ou simplement, une application) que les programmeurs créent se compose de plusieurs blocs typiques, dont chacun fournit une partie des fonctionnalités nécessaires. Par exemple, le cache sert à mémoriser le résultat d'une opération gourmande en ressources pour assurer une lecture plus rapide des données par le client, les outils de traitement de flux permettent d'envoyer des messages à d'autres composants pour un traitement asynchrone, et les outils de traitement par lots sont utilisés pour " ratisser" les volumes accumulés de données avec une certaine périodicité. .

Et dans presque toutes les applications, les bases de données (DB) sont impliquées d'une manière ou d'une autre, qui remplissent généralement deux fonctions : stocker les données lorsqu'elles sont reçues de vous et vous les fournir ultérieurement sur demande. Rarement quelqu'un pense à créer sa propre base de données, car il existe déjà de nombreuses solutions toutes faites. Mais comment choisir celui qui convient à votre application ?

Alors, imaginons que vous ayez écrit une application avec une interface mobile qui vous permet de charger une liste de tâches précédemment enregistrées dans la maison - c'est-à-dire de lire à partir de la base de données et de la compléter avec de nouvelles tâches, ainsi que de hiérarchiser chaque spécifique tâche - de 1 (le plus élevé) à 3 (le plus bas). Imaginons que votre application mobile ne soit utilisée que par une seule personne à la fois. Mais maintenant, vous avez osé parler de votre création à votre mère, et maintenant elle est devenue la deuxième utilisatrice régulière. Que se passe-t-il si vous décidez en même temps, dans la même milliseconde, de définir une tâche - "laver les vitres" - à un degré de priorité différent ?

En termes professionnels, vos requêtes de base de données et celles de votre mère peuvent être considérées comme 2 processus qui ont effectué une requête dans la base de données. Un processus est une entité d'un programme informatique qui peut s'exécuter sur un ou plusieurs threads. En règle générale, un processus possède une image de code machine, de la mémoire, un contexte et d'autres ressources. En d'autres termes, le processus peut être caractérisé comme l'exécution d'instructions de programme sur le processeur. Lorsque votre application fait une demande à la base de données, nous parlons du fait que votre base de données traite la demande reçue sur le réseau à partir d'un processus. S'il y a deux utilisateurs assis dans l'application en même temps, il peut y avoir deux processus à un moment donné.

Lorsqu'un processus fait une demande à la base de données, il la trouve dans un certain état. Un système avec état est un système qui se souvient des événements précédents et stocke certaines informations, appelées "état". Une variable déclarée comme integerpeut avoir un état de 0, 1, 2, ou disons 42. Mutex (exclusion mutuelle) a deux états : verrouillé ou déverrouillé , tout comme un sémaphore binaire ("requis" contre "libéré") et généralement binaire types de données (binaires) et variables qui ne peuvent avoir que deux états - 1 ou 0.

Sur la base du concept d'état, plusieurs structures mathématiques et d'ingénierie sont basées, telles qu'un automate fini - un modèle qui a une entrée et une sortie et se trouve dans l'un d'un ensemble fini d'états à chaque instant - et le "état ” modèle de conception, dans lequel un objet change de comportement en fonction de l'état interne (par exemple, en fonction de la valeur attribuée à l'une ou l'autre variable).

Ainsi, la plupart des objets du monde de la machine ont un état qui peut changer au fil du temps : notre pipeline, qui traite un gros paquet de données, génère une erreur et tombe en échec , ou la propriété de l'objet Wallet, qui stocke le montant d'argent restant dans le compte de l'utilisateur. compte, change après les reçus de paie.

Une transition ("transition") d'un état à un autre, par exemple, de en cours à échoué , est appelée une opération. Probablement, tout le monde connaît les opérations CRUD - create, read, update, deleteou des méthodes HTTP similaires - POST, GET, PUT, DELETE. Mais les programmeurs donnent souvent d'autres noms aux opérations dans leur code, car l'opération peut être plus complexe que la simple lecture d'une certaine valeur de la base de données - elle peut également vérifier les données, puis notre opération, qui a pris la forme d'une fonction, s'appellera, par exemple, Et validate()qui exécute ces opérations-fonctions ? processus déjà décrits.

Encore un peu, et vous comprendrez pourquoi je décris les termes avec tant de détails !

Toute opération - qu'il s'agisse d'une fonction, ou, dans les systèmes distribués, d'envoyer une requête à un autre serveur - a 2 propriétés : le temps d'invocation et le temps de réalisation (completion time) , qui sera strictement supérieur au temps d'invocation (des chercheurs de Jepsen partir des hypothèses théoriques selon lesquelles ces deux horodatages recevront des horloges imaginaires, entièrement synchronisées et disponibles dans le monde entier).

Imaginons notre application de liste de tâches. Vous faites une demande à la base de données via l'interface mobile dans 14:00:00.014, et votre mère dans 13:59:59.678(c'est-à-dire 336 millisecondes avant) a mis à jour la liste de tâches via la même interface, en y ajoutant la vaisselle. Compte tenu du délai du réseau et de l'éventuelle file d'attente de tâches pour votre base de données, si, en plus de vous et de votre mère, tous les amis de votre mère utilisent également votre application, la base de données peut exécuter la requête de la mère après avoir traité la vôtre. En d'autres termes, il est possible que deux de vos demandes, ainsi que les demandes des copines de votre mère, soient envoyées aux mêmes données en même temps (simultanément).

Nous sommes donc arrivés au terme le plus important dans le domaine des bases de données et des applications distribuées - la concurrence. Que peut signifier exactement la simultanéité de deux opérations ? Si une opération T1 et une opération T2 sont données, alors :

  • T1 peut être démarré avant l'heure de début de l'exécution de T2 et terminé entre l'heure de début et de fin de T2
  • T2 peut être commencé avant l'heure de début de T1 et terminé entre le début et la fin de T1
  • T1 peut être démarré et terminé entre l'heure de début et de fin de l'exécution de T1
  • et tout autre scénario où T1 et T2 ont un temps d'exécution commun

Il est clair que dans le cadre de cette conférence, nous parlons principalement des requêtes entrant dans la base de données et de la manière dont le système de gestion de base de données perçoit ces requêtes, mais le terme de concurrence est important, par exemple, dans le contexte des systèmes d'exploitation. Je ne m'écarterai pas trop du sujet de cet article, mais je pense qu'il est important de mentionner que la concurrence dont nous parlons ici n'est pas liée au dilemme de la concurrence et de la concurrence et de leur différence, qui est discuté dans le contexte des systèmes d'exploitation et du calcul haute performance. Le parallélisme est un moyen d'obtenir la simultanéité dans un environnement avec plusieurs cœurs, processeurs ou ordinateurs. On parle de concurrence au sens d'accès simultané de différents processus à des données communes.

Et qu'est-ce qui, en fait, peut mal tourner, purement théoriquement ?

Lorsque l'on travaille sur des données partagées, de nombreux problèmes liés à la concurrence, également appelés "race conditions", peuvent survenir. Le premier problème survient lorsqu'un processus reçoit des données qu'il n'aurait pas dû recevoir : données incomplètes, temporaires, annulées ou autrement "incorrectes". Le deuxième problème est lorsque le processus reçoit des données obsolètes, c'est-à-dire des données qui ne correspondent pas au dernier état enregistré de la base de données. Supposons qu'une application ait retiré de l'argent du compte d'un utilisateur avec un solde nul, car la base de données a renvoyé le statut du compte à l'application, sans tenir compte du dernier retrait d'argent, qui s'est produit il y a quelques millisecondes. La situation est moyenne, n'est-ce pas ?

5.2 Les transactions sont venues nous sauver

Afin de résoudre de tels problèmes, le concept de transaction est apparu - un certain groupe d'opérations séquentielles (changements d'état) avec une base de données, qui est une opération logiquement unique. Je vais donner à nouveau un exemple avec une banque - et pas par hasard, car le concept de transaction est apparu, apparemment, précisément dans le contexte du travail avec de l'argent. L'exemple classique d'une transaction est le transfert d'argent d'un compte bancaire à un autre : vous devez d'abord retirer le montant du compte source, puis le déposer sur le compte cible.

Pour que cette transaction soit effectuée, l'application devra effectuer plusieurs actions dans la base de données : vérifier le solde de l'expéditeur, bloquer le montant sur le compte de l'expéditeur, ajouter le montant sur le compte du destinataire et déduire le montant de l'expéditeur. Il y aura plusieurs exigences pour une telle transaction. Par exemple, l'application ne peut pas recevoir d'informations obsolètes ou incorrectes sur le solde - par exemple, si en même temps une transaction parallèle s'est terminée par une erreur à mi-parcours et que les fonds n'ont pas été débités du compte - et notre application a déjà reçu des informations que les fonds ont été radiés.

Pour résoudre ce problème, une propriété d'une transaction telle que « l'isolement » a été invoquée : notre transaction est exécutée comme s'il n'y avait pas d'autres transactions en cours au même moment. Notre base de données effectue des opérations simultanées comme si elle les exécutait les unes après les autres, de manière séquentielle - en fait, le niveau d'isolation le plus élevé est appelé Strict Serializable . Oui, le plus élevé, ce qui veut dire qu'il y a plusieurs niveaux.

« Arrête », dis-tu. Tenez vos chevaux, monsieur.

Rappelons-nous comment j'ai décrit que chaque opération a un temps d'appel et un temps d'exécution. Pour plus de commodité, vous pouvez envisager d'appeler et d'exécuter comme 2 actions. Ensuite, la liste triée de toutes les actions d'appel et d'exécution peut être appelée l'historique de la base de données. Ensuite, le niveau d'isolement de la transaction est un ensemble d'historiques. Nous utilisons des niveaux d'isolement pour déterminer quelles histoires sont "bonnes". Lorsque nous disons qu'une histoire "casse la sérialisabilité" ou "n'est pas sérialisable", nous voulons dire que l'histoire n'est pas dans l'ensemble des histoires sérialisables.

Pour bien faire comprendre de quel genre d'histoires nous parlons, je vais donner des exemples. Par exemple, il existe une telle sorte d'histoire - lecture intermédiaire . Cela se produit lorsque la transaction A est autorisée à lire les données d'une ligne qui a été modifiée par une autre transaction en cours d'exécution B et qui n'a pas encore été validée ("non validée") - c'est-à-dire, en fait, que les modifications n'ont pas encore été finalement validées par transaction B, et il peut à tout moment les annuler. Et, par exemple, la lecture abandonnée n'est que notre exemple avec une transaction de retrait annulée

Plusieurs anomalies sont possibles. Autrement dit, les anomalies sont une sorte d'état de données indésirable qui peut se produire lors d'un accès concurrentiel à la base de données. Et afin d'éviter certains états indésirables, les bases de données utilisent différents niveaux d'isolation, c'est-à-dire différents niveaux de protection des données contre les états indésirables. Ces niveaux (4 éléments) ont été répertoriés dans la norme ANSI SQL-92.

La description de ces niveaux semble vague à certains chercheurs, et ils proposent leurs propres classifications, plus détaillées. Je vous conseille de prêter attention au projet Jepsen déjà mentionné, ainsi qu'au projet Hermitage, qui vise à clarifier exactement quels niveaux d'isolation sont offerts par des SGBD spécifiques, tels que MySQL ou PostgreSQL. Si vous ouvrez les fichiers de ce référentiel, vous pouvez voir quelle séquence de commandes SQL ils utilisent pour tester la base de données pour certaines anomalies, et vous pouvez faire quelque chose de similaire pour les bases de données qui vous intéressent). Voici un exemple du référentiel pour vous intéresser :

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

Il est important de comprendre que pour la même base de données, en règle générale, vous pouvez choisir l'un des nombreux types d'isolation. Pourquoi ne pas choisir l'isolant le plus solide ? Car, comme tout en informatique, le niveau d'isolation choisi doit correspondre à un compromis que l'on est prêt à faire - en l'occurrence, un compromis de vitesse d'exécution : plus le niveau d'isolation est fort, plus les requêtes seront lentes traité. Pour comprendre le niveau d'isolation dont vous avez besoin, vous devez comprendre les exigences de votre application, et pour comprendre si la base de données que vous avez choisie offre ce niveau, vous devrez consulter la documentation - pour la plupart des applications, cela suffira, mais si vous avez des exigences particulièrement strictes, il est préférable d'organiser un test comme ce que font les gars du projet Hermitage.

5.3 "I" et autres lettres dans ACID

L'isolement est essentiellement ce que les gens veulent dire lorsqu'ils parlent d'ACID en général. Et c'est pour cette raison que j'ai commencé l'analyse de cet acronyme par l'isolement, et je n'y suis pas allé dans l'ordre, comme le font habituellement ceux qui tentent d'expliquer ce concept. Regardons maintenant les trois lettres restantes.

Reprenez notre exemple avec un virement bancaire. Une opération de transfert de fonds d'un compte à un autre comprend une opération de retrait sur le premier compte et une opération de réapprovisionnement sur le second. Si l'opération de réapprovisionnement du deuxième compte a échoué, vous ne souhaitez probablement pas que l'opération de retrait du premier compte se produise. En d'autres termes, soit la transaction réussit complètement, soit elle ne se produit pas du tout, mais elle ne peut être effectuée que pour une partie. Cette propriété s'appelle "atomicité", et c'est un "A" dans ACID.

Lorsque notre transaction est exécutée, alors, comme toute opération, elle transfère la base de données d'un état valide à un autre. Certaines bases de données offrent des soi-disant contraintes - c'est-à-dire des règles qui s'appliquent aux données stockées, par exemple, concernant les clés primaires ou secondaires, les index, les valeurs par défaut, les types de colonnes, etc. Ainsi, lors d'une transaction, nous devons être sûrs que toutes ces contraintes seront remplies.

Cette garantie s'appelle "cohérence" et une lettre Cdans ACID (à ne pas confondre avec la cohérence du monde des applications distribuées, dont nous parlerons plus tard). Je vais donner un exemple clair de cohérence au sens d'ACID : une application pour une boutique en ligne souhaite ajouter ordersune ligne à la table, et l'ID de la table product_idsera indiqué dans la colonne - typique .productsforeign key

Si le produit, par exemple, a été supprimé de l'assortiment et, par conséquent, de la base de données, l'opération d'insertion de ligne ne devrait pas se produire et nous obtiendrons une erreur. Cette garantie, comparée à d'autres, est un peu tirée par les cheveux, à mon avis - ne serait-ce que parce que l'utilisation active des contraintes de la base de données signifie un transfert de responsabilité pour les données (ainsi qu'un transfert partiel de la logique métier, si nous parlons de une contrainte telle que CHECK ) de l'application à la base de données, ce qui, comme on dit maintenant, est juste ainsi.

Et enfin, il reste D- "résistance" (durabilité). Une défaillance du système ou toute autre défaillance ne doit pas entraîner la perte des résultats des transactions ou du contenu de la base de données. Autrement dit, si la base de données a répondu que la transaction a réussi, cela signifie que les données ont été enregistrées dans une mémoire non volatile, par exemple sur un disque dur. Soit dit en passant, cela ne signifie pas que vous verrez immédiatement les données lors de la prochaine demande de lecture.

L'autre jour, je travaillais avec DynamoDB d'AWS (Amazon Web Services) et j'ai envoyé des données à enregistrer, et après avoir reçu une réponse HTTP 200(OK), ou quelque chose comme ça, j'ai décidé de le vérifier - et je n'ai pas vu ça données dans la base de données pour les 10 prochaines secondes. Autrement dit, DynamoDB a validé mes données, mais tous les nœuds ne se sont pas instantanément synchronisés pour obtenir la dernière copie des données (bien qu'elles aient pu se trouver dans le cache). Là encore, nous sommes montés sur le territoire de la cohérence dans le cadre des systèmes distribués, mais le moment d'en parler n'est toujours pas venu.

Nous savons maintenant ce que sont les garanties ACID. Et nous savons même pourquoi ils sont utiles. Mais en avons-nous vraiment besoin dans chaque application ? Et sinon, quand exactement ? Toutes les DB offrent-elles ces garanties, et si non, qu'offrent-elles à la place ?