Isolation de transaction dans App Engine

Max Ross

Selon Wikipédia, le niveau d'isolation d'un système de gestion de base de données représente sa "capacité à isoler les modifications dans une transaction en cours vis-à-vis de celles effectuées dans les autres transactions conduites simultanément". L'objectif de cet article est d'expliquer l'isolation de requête et de transaction dans Cloud Datastore avec App Engine. Après avoir pris connaissance de cet article, vous comprendrez mieux le comportement des lectures et des écritures simultanées, à l'intérieur et à l'extérieur des transactions.

À l'intérieur des transactions : Serializable (sérialisable)

Du plus strict au plus permissif, les quatre niveaux d'isolation sont Serializable (sérialisable), Repeatable Read (lecture répétée), Read Committed (lecture des données validées) et Read Uncommitted (lecture des données non validées). Les transactions de datastore satisfont aux critères du niveau d'isolation Serializable. Chaque transaction est complètement isolée des autres transactions et opérations de datastore. Les transactions sur un groupe d'entités donné sont exécutées en série, les unes après les autres.

Pour en savoir plus, consultez la section Isolation et cohérence dans la documentation des transactions, ainsi que l'article Wikipédia sur l'isolation d'instantané (en anglais).

À l'extérieur des transactions : Read Committed (lecture de données validées)

Les opérations de datastore à l'extérieur des transactions se rapprochent du niveau d'isolation Read Committed. Les entités extraites du datastore par des requêtes ou des opérations get ne voient que les données validées. Les données d'une entité extraite ne sont jamais partiellement validées (certaines étant extraites avant un commit et d'autres après). Toutefois, les interactions entre les requêtes et les transactions sont légèrement plus subtiles. Pour les comprendre, nous devons examiner de plus près le processus de commit.

Processus de commit

Lorsqu'un commit est renvoyé, l'application de la transaction est garantie, mais cela ne signifie pas que les lecteurs peuvent immédiatement voir le résultat de votre écriture. L'application d'une transaction comprend deux étapes :

  • Étape A : moment auquel les modifications apportées à une entité ont été appliquées.
  • Étape B : moment auquel les modifications apportées aux index de cette entité ont été appliquées.

Affiche les flèches de progression entre la transaction de commit et les modifications visibles de l'entité pour les index et les entités visibles.

Dans Cloud Datastore, la transaction est généralement appliquée en quelques centaines de millisecondes après le renvoi du commit. Toutefois, même si elle n'est pas complètement appliquée, les lectures, les écritures et les requêtes ascendantes ultérieures reflètent toujours les résultats du commit, car ces opérations appliquent toutes les modifications en attente avant l'exécution. Néanmoins, les requêtes qui couvrent plusieurs groupes d'entités ne peuvent pas déterminer s'il existe des modifications en attente avant l'exécution et peuvent renvoyer des résultats obsolètes ou partiellement appliqués.

Si une requête recherche une entité mise à jour par sa clé une fois l'étape A passée, elle est sûre de voir la dernière version de cette entité. Cependant, en cas d'exécution simultanée d'une requête dont le prédicat (la clause WHERE, pour les adeptes SQL/GQL) n'est pas satisfait par l'entité avant la mise à jour, mais qu'il l'est après la mise à jour, l'entité ne fera partie de l'ensemble de résultats que si la requête s'exécute après que l'opération d'application a atteint l'étape B.

En d'autres termes, lors de brefs intervalles, il est possible qu'un ensemble de résultats n'inclue pas une entité dont les propriétés, conformément au résultat d'une recherche par clé, satisfont au prédicat de requête. Il est également possible qu'un jeu de résultats inclue une entité dont les propriétés, là encore, conformément au résultat d'une recherche par clé, ne satisfont pas au prédicat de la requête. Une requête ne peut pas prendre en compte les transactions situées entre l'étape A et l'étape B lors du choix des entités à renvoyer. Elle est exécutée sur des données obsolètes, mais l'utilisation d'une opération get() sur les clés renvoyées permet toujours d'obtenir la dernière version de cette entité. Ainsi, il est possible que des résultats correspondant à votre requête soient manquants ou que vous obteniez des résultats ne correspondant pas après avoir obtenu l'entité correspondante.

Dans certains cas, l'application complète des modifications en attente est garantie avant l'exécution de la requête, comme pour toute requête ascendante dans Cloud Datastore. Dans ce cas, les résultats de requête sont toujours cohérents et à jour.

Examples

Nous venons de voir une explication générale du mode d'interaction entre des mises à jour et des requêtes simultanées, mais, si vous êtes comme moi, vous préférez mettre en application ces concepts dans des exemples concrets. En voici quelques-uns. Nous allons commencer par des exemples simples pour finir par les exemples les plus intéressants.

Imaginons que nous ayons une application qui stocke des entités Person. Une entité Person comprend les propriétés suivantes :

  • Nom
  • Taille

Cette application accepte les opérations suivantes :

  • updatePerson()
  • getTallPeople(), qui renvoie toutes les personnes mesurant plus de 1,83 m.

Nous avons 2 entités "Person" dans le datastore :

  • Adam, qui mesure 1,72 m
  • Bob, qui mesure 1,85 m

Exemple 1 - Rendre Adam plus grand

Imaginons que l'application reçoit deux requêtes simultanément. La première requête met à jour la taille d'Adam, la faisant passer de 1,72 m à 1,88 m. Un vrai sursaut de croissance ! La seconde requête appelle getTallPeople(). Que renvoie getTallPeople() ?

La réponse dépend de la relation entre les deux étapes de commit déclenchées par la requête 1 et la requête getTallPeople() exécutée par la requête 2. Supposons qu'elle ressemble à ceci :

  • Requête 1, put()
  • Requête 2, getTallPeople()
  • Requête 1, put()-->commit()
  • Requête 1, put()-->commit()-->étape A
  • Requête 1, put()-->commit()-->étape B

Dans ce scénario, getTallPeople() ne renvoie que Bob. Pourquoi ? Parce que la mise à jour d'Adam, consistant à augmenter sa taille, n'a pas encore été validée. Ainsi, la modification n'est pas encore visible pour la requête émise dans la requête 2.

Supposons maintenant qu'elle ressemble à ceci :

  • Requête 1, put()
  • Requête 1, put()-->commit()
  • Requête 1, put()-->commit()-->étape A
  • Requête 2, getTallPeople()
  • Requête 1, put()-->commit()-->étape B

Dans ce scénario, la requête s'exécute avant que la requête 1 atteigne l'étape B. C'est pourquoi les mises à jour des index de l'entité Person n'ont pas encore été appliquées. Par conséquent, getTallPeople() ne renvoie que Bob. Il s'agit d'un exemple d'ensemble de résultats qui exclut une entité dont les propriétés satisfont au prédicat de requête.

Exemple 2 - Rendre Bob plus petit (Désolé, Bob)

Dans cet exemple, la requête 1 agit différemment. Au lieu d'accroître la taille d'Adam de 1,72 m à 1,88 m, elle réduit la taille de Bob de 1,85 m à 1,65 m. Là encore, que renvoie getTallPeople() ?

return?
  • Requête 1, put()
  • Requête 2, getTallPeople()
  • Requête 1, put()-->commit()
  • Requête 1, put()-->commit()-->étape A
  • Requête 1, put()-->commit()-->étape B

Dans ce scénario, getTallPeople() ne renvoie que Bob. Pourquoi ? Parce que la mise à jour de Bob, consistant à diminuer sa taille, n'a pas encore été validée. Ainsi, la modification n'est pas encore visible pour la requête émise dans la requête 2.

Supposons maintenant qu'elle ressemble à ceci :

  • Requête 1, put()
  • Requête 1, put()-->commit()
  • Requête 1, put()-->commit()-->étape A
  • Requête 1, put()-->commit()-->étape B
  • Requête 2, getTallPeople()

Dans ce scénario, getTallPeople() ne renvoie personne. Pourquoi ? Parce que la mise à jour de Bob, consistant à diminuer sa taille, a été validée pendant l'émission de notre requête dans la requête 2.

Supposons maintenant qu'elle ressemble à ceci :

  • Requête 1, put()
  • Requête 1, put()-->commit()
  • Requête 1, put()-->commit()-->étape A
  • Requête 2, getTallPeople()
  • Requête 1, put()-->commit()-->étape B

Dans ce scénario, la requête s'exécute avant l'étape B. C'est pourquoi les mises à jour des index de l'entité Person n'ont pas encore été appliquées. Par conséquent, getTallPeople() renvoie toujours Bob, mais la propriété "taille" de l'entité Person qui est renvoyée est la valeur mise à jour, soit 1,65. Il s'agit d'un exemple d'ensemble de résultats qui inclut une entité dont les propriétés ne satisfont pas au prédicat de requête.

Conclusion

Comme vous pouvez le constater dans les exemples ci-dessus, le niveau d'isolation de transaction de Cloud Datastore est assez proche du niveau Read Committed (lecture de données validées). Des différences significatives existent, bien évidemment, mais maintenant que vous avez compris ces différences et leur origine, vous voilà mieux armé pour prendre des décisions éclairées en matière de conception du datastore dans vos applications.