Prendre en main DynamoDB — Vérification de contraintes

Thibault Ruby
neoxia
Published in
11 min readJun 24, 2021

Dans l’article précédent (ici), nous avons abordé les fondamentaux de Amazon DynamoDB, ou comment représenter nos données dans un design n’utilisant qu’une seule table.

DynamoDB possède de nombreux avantages en termes de performances et de scalabilité. Toutefois, en dehors des contraintes sur les clés des index et les index eux-même, DynamoDB ne propose pas nativement de vérifications de contraintes sur les données. Et pourtant il existe de nombreux types de contraintes que l’on souhaiterait voir vérifiées au sein de nos Modèles Conceptuels de Données (MCD) et de leur implémentation.

Vu que ces contraintes ne seront pas vérifiées par le Système de Gestion de Base de Données (SGBD) lui-même et peuvent être parfois difficile à appliquer avant modification en base, nous ne parlerons pas de contraintes d’intégrité.

Nous trouverons :

  • le formatage des entités (présence de tous les champs obligatoire, le type de champs, etc),
  • la vérification que les relations sont cohérentes et que les référencements se font toujours vers des éléments existants (insertion après coup)
  • et celles pour s’assurer que les valeurs des données dupliquées (dénormalisées pour des raisons de performances) soient à jour.

Les différentes méthodes de vérifications

Ces vérifications de contraintes doivent être mises en place et gérées par les développeurs interagissant avec la base DynamoDB. Ces vérifications peuvent être effectuées de plusieurs manières pouvant être complémentaires :

  • au niveau de l’application interagissant avec la base DynamoDB,
  • via des DynamoDB-Streams, qui ont un rôle similaire à celui de Trigger SQL pour des bases relationnelles,
  • en utilisant des scripts ou des routines,
  • ou en faisant appel à des outils externes.

Au niveau de l’application

La façon la plus intuitive de vérifier que vos contraintes sont bien respectées est de le faire au niveau de l’application (ou des applications) interagissant avec DynamoDB.

Cette méthode est simple à mettre en place et permet entre autres de renvoyer des messages d’erreurs pertinents via vos API. Cela permet d’appliquer les contraintes avant modification en base.

Toutefois il est courant de devoir vérifier une même contrainte à plusieurs endroits. Il revient alors au développeur d’une part de porter une attention particulière à la factorisation du code de vérification de contraintes, et d’autre part d’appeler ces vérifications à chaque endroit de son application où cela fait sens.

Via les DynamoDB Streams

Bases

DynamoDB propose la création de DynamoDB-Streams. Ces streams, pouvant rappeler les triggers SQL, prennent la forme d’une fonction Amazon Lambda appelée lors d’évènements de modification, suppression, ou d’insertion sur une table. Ils permettent l’application et la vérification de contraintes après modification en base.

Cela permet de s’assurer que les vérifications sont bien faites à chaque fois (plus besoin de penser à tous les cas d’utilisation dans vos applications où tel type d’entité est mis à jour par exemple) et que la cohérence des données soit maintenue au fur et à mesure. Il est donc intéressant de les utiliser dès que possible.

Mais contrairement aux vérifications effectuées au niveau de l’application qui permettent de s’assurer du respect des contraintes avant modification (insertion, modification ou suppression) de la table, les streams interviennent après. C’est pourquoi les contraintes vérifiées par stream ne sont appliquées qu’après exécution du stream. De surcroît, les streams subissent les limitations inhérentes aux Lambdas, avec notamment 15 minutes d’exécution maximum.

Les streams jouant un rôle clé dans la vérification de contraintes, il est important de mettre en place une stratégie de monitoring. Nous stockons par exemple dans une table à part les erreurs survenant durant l’exécution de nos streams (avec le type d’évènement, l’enregistrement affecté, etc), et nous sommes alertés dès qu’un certain nombre d’erreurs surviennent.

Fonctionnement

Les fonctions Lambdas de stream reçoivent en entrée un événement de modification de DynamoDB qui contient les informations suivantes :

  • est-ce une insertion, une suppression ou une modification ?
  • quels sont les enregistrements qui ont été affectés, avec leurs nouvelles et anciennes valeurs ?

A partir de là, nous avons une fonction Lambda appelée lors des modifications sur notre table unique qui procède de la manière suivante : en fonction de l’évènement reçu, en se basant sur nos design pattern (voir le premier article), nous effectuons les actions associées. Le tout au sein de la même fonction Lambda.

Lorsque l’application se complexifie, le nombre de tâches à gérer via les streams va aussi augmenter. C’est pourquoi il est important de chercher à découper le plus possible le travail des streams. Par exemple, avec la situation suivante :

  • la modification d’un document de type A doit déclencher la modification d’un document de type B,
  • qui elle-même doit déclencher la modification d’un document de type C.

Dans cette situation, on va privilégier une approche qui va nécessiter deux appels distincts du stream.

De plus, afin de découper le travail au niveau architectural il est possible de concevoir une fonction Lambda orchestrant des appels à d’autres fonctions Lambdas qui seraient spécialisées.

Cette démarche a aussi pour intérêt de simplifier le monitoring avec métriques et des flux de logs CloudWatch séparés par type d’événement. Cela permet d’accélérer la détection d’erreurs et le débogage.

Délégation du travail de la Lambda de stream à d’autres Lambdas

En utilisant des scripts, des routines ou des outils externes

Il est aussi possible de vérifier régulièrement (avec des Lambdas planifiées, etc) ou ponctuellement l’état des données en créant des scripts interagissant directement avec votre base DynamoDB, ou en utilisant des outils externes (comme Snowflake par exemple).

Même si cette manière de faire ne permet pas d’assurer en permanence d’un état pleinement cohérent des données, elle peut être une méthode complémentaire intéressante pour s’assurer du respect de certaines contraintes notamment les plus critiques, en plus d’assurer une reprise sur erreur en cas d’échecs passés.

Comment vérifier les différentes contraintes ?

Le formatage des entités

Malgré l’utilisation d’un Système de Gestion de Base de Données (SGBD) document-oriented, vouloir s’assurer de la présence de champs, du type de ces champs, du nombre d’éléments d’une liste, etc. est quelque chose d’assez courant (au moins pour une partie de nos entités).

Ce genre de contraintes devant être vérifiées avant insertion, elles ne peuvent alors pas être gérées via l’utilisation de DynamoDB-streams. Le formatage à posteriori n’étant pas tout le temps possible, autant ne pas insérer d’entité qu’il faudrait par la suite supprimer car non correctement formatées.

Si ces contraintes sont critiques pour le bon fonctionnement de votre (ou vos) application(s), les vérifier uniquement ponctuellement via des scripts ou des routines n’est pas une solution envisageable.

C’est pourquoi ce type de vérification doit être effectué au niveau de l’application avant insertion. Il convient, comme indiqué précédemment, de prendre soin de factoriser le code de vérification de contrainte et les endroits dans vos applications qui insèrent ou modifient le même type d’entité.

Pour une application en microservices il s’agit par exemple de regrouper les appels par type d’entité.

Les relations

Exemple de modèle conceptuel de données avec relations

Pour cette partie-ci utilisons le modèle de données ci-dessus: un utilisateur a sa configuration qui lui est propre, et plusieurs commentaires à son nom.

Il peut appartenir à un groupe, groupe qui peut contenir plusieurs utilisateurs.

Un utilisateur, au même titre qu’un groupe, peut avoir un ensemble de permissions.

Les contraintes référentielles

Lorsque l’on représente des relations, la première chose dont il faut s’assurer c’est que les références présentes dans les différentes relations sont cohérentes.

Pour cela, il faut effectuer des vérifications avant toute insertion ou modification (donc au niveau de l’application) dans la base de données sur tous les champs devant référencer une autre entité.

Par exemple: avant d’insérer ou de mettre à jour un utilisateur devant référencer un groupe, il faut vérifier que ce groupe existe bel et bien.

De même, avant d’insérer un commentaire, une entité “transitoire” liant un utilisateur et un groupe, ou une permission d’utilisateur, il faut s’assurer que ce dernier existe bien auparavant (c’est l’équivalent en SQL des vérifications de contraintes d’intégrités référentielles soit de clé étrangère)

Il faut aussi effectuer les traitements nécessaires après suppression d’une entité (au niveau des DynamoDB streams donc). Notamment supprimer les relations dépendantes de l’entité supprimée.

En l’occurrence :

  • la configuration de l’utilisateur,
  • les commentaires de l’utilisateur,
  • les permissions de l’utilisateur,
  • les entités “transitoires” montrant son appartenance à un groupe

Les contraintes relationnelles “fonctionnelles” ou “filles”

Il peut y avoir dans certains cas des contraintes relationnelles qui ne sont pas représentées dans le MCD. On utilisera ici le terme de relations “filles”.

Par exemple, si un groupe possède une permission, il faut que tous ses membres le possèdent aussi, sans pour autant empêcher les utilisateurs d’avoir leurs permissions propres.

D’un point de vue pratique, lorsque l’on veut vérifier si un utilisateur possède une permission, la manière la plus rapide de faire est la suivante : regarder les permissions de l’utilisateur, puis effectuer la jointure entre l’utilisateur et ses groupes et enfin récupérer les permissions des différents groupes.

Le problème de cette solution est que trois requêtes différentes doivent être exécutées séquentiellement à chaque vérification :

  • récupérer les permissions de l’utilisateur,
  • puis récupérer les groupes dont l’utilisateur est membre,
  • enfin récupérer les permissions des groupes

Ce qui induit un certain temps pour récupérer notre information et par conséquent de la latence pour l’utilisateur.

Une solution permettant de proposer une meilleure latence et de meilleures performances à l’utilisateur final est de dupliquer de manière asynchrone les permissions des groupes en créant des relations “filles” (duplicata) pour les utilisateurs. Ainsi le travail en écriture (ajout ou retrait de permissions et ajout ou retrait de membre) s’en retrouve plus long mais réalisé de manière asynchrone donc non-perceptible par l’utilisateur venant d’effectuer l’une des quatres opérations précisées ci-dessus.

Pour ce faire, il faut mettre en place quatres vérifications via l’utilisation d’un DynamoDB Stream.

Dans un premier temps, lors de l’ajout d’une permission à un groupe, il faut donner aux utilisateurs membres cette même permission (en créant une nouvelle entité de permission ou en mettant à jour une existante selon l’implémentation) en indiquant qu’elle provient de ce groupe (via un champ ou par le type d’entité directement).

Dans un deuxième temps, à l’instar de l’ajout, lors de la suppression d’une permission à un groupe, il faut aussi la retirer aux membres (seulement celles provenant de ce groupe).

Et de la même manière, lorsqu’un utilisateur est ajouté à un groupe, il faut lui transférer les permissions de ce groupe, et vis-versa lorsqu’un utilisateur est retiré d’un groupe.

L’inconvénient de cette solution est qu’il peut y avoir de très courtes périodes d’incohérence (avec des utilisateurs n’ayant pas encore récupéré les droits d’un groupe par exemple) le temps d’écrire ces permissions répercutées. Ces périodes d’incohérences varient en fonction du nombre de relations “filles” à écrire, allant de quelques millisecondes à quelques minutes si un groupe contient des dizaines de milliers d’utilisateurs.

Cependant, dans notre cas d’usage il n’y a alors plus qu’à effectuer une seule requête ne renvoyant que les informations pertinentes lors des vérifications d’autorisation.

Il faut toutefois prendre en compte que des groupes comportant un nombre important d’utilisateurs rendent de nombreuses écritures de documents (de permissions) nécessaires. Cela peut avoir un impact sur la facturation, la charge en écriture, ainsi que sur la probabilité d’occurrence d’erreurs de synchronisation. Pour pallier ces inconvénients, il est possible d’utiliser des queues Amazon Simple Queue Service (SQS) comme tampon pour les écritures pour avoir un meilleur contrôle et assurer la reprise sur erreur.

Le cas de la dénormalisation

Par moments, il peut être utile de dupliquer l’information pour améliorer les performances en évitant de multiplier les appels dans la mesure où l’on ne peut pas faire de jointure dans nos requêtes, mais aussi parfois pour simplifier le code (praticité et efficience).

Exemple: lorsqu’on référence un utilisateur avec son email, on peut aussi souhaiter récupérer son nom.

A l’inverse de la clé de l’entité référencée, cette donnée dupliquée peut changer avec le temps et doit être maintenue. Pour ce faire, il faut considérer ces champs dupliqués comme étant en lecture seule. Il faut impérativement n’avoir qu’une source de vérité unique qui est l’entité référencée.

A chaque modification de l’entité référencée il faut, en cas de changement de valeur du champ dupliqué, répercuter le changement à chaque endroit. Les streams DynamoDB sont donc les plus adaptés pour cette tâche.

Pour reprendre l’exemple précédent: si l’on souhaite avoir l’information du nom de l’utilisateur dupliquée au sein d’un commentaire, lorsqu’un utilisateur voit son nom mise à jour, il faut via un stream DynamoDB mettre à jour l’information dans les commentaires référençant cet utilisateur. Mais il ne faut en aucun cas mettre à jour le nom de l’entité utilisateur si le nom est modifié dans l’entité de commentaire (ce qui ne devrait pas arriver par ailleurs).

Champs calculés

Certaines informations peuvent être pré-calculées de manière asynchrone pour des raisons de performances (à l’instar des champs dénormalisés).

Par exemple, si l’on souhaite avoir le nombre d’un type d’entité présent en base en récupérant une seule information, sans avoir à tous les récupérer pour les compter.

Dans ce genre de cas où il n’est pas forcément nécessaire que la valeur calculée soit en permanence synchrone avec l’état de la base de donnée, il est possible de la maintenir via une routine appelée à intervalles réguliers.

Dans les cas où une synchronisation plus forte est nécessaire, avec l’état de la base de données, il faut à l’aide de DynamoDB streams maintenir la valeur des champs calculés.

Audits

Les vérifications de contraintes étant dévolues aux développeurs, des erreurs ou des oublis peuvent survenir. Afin de pallier ces potentiels manquements, il est possible de réaliser de manière ponctuelle ou régulière des audits afin de vérifier la cohérence des données.

Comme abordé plus tôt dans l’article, plusieurs outils existent pour cela : utiliser des routines (sous forme de Lambda par exemple), ou via des scripts manuels.

Concevoir un outil réalisant un audit complet de l’ensemble des contraintes est extrêmement coûteux aussi bien en termes de temps de développement que de ressources.

De plus, l’objectif étant de vérifier que le code de production maintient bien les contraintes appliquées, il faut éviter de le réutiliser dans le cadre d’un audit. Plus le nombre de vérifications est important, plus les chances d’avoir des erreurs dans le code d’audit sont grandes. En plus de cela, cet outil doit être mis à jour à chaque nouvelle modification, suppression, ou addition de contraintes.

C’est pourquoi je recommande de réaliser uniquement des audits partiels qui ne visent à vérifier qu’une partie des contraintes, voire, un outil d’audit par contrainte.

En se concentrant dans un premier temps uniquement sur les contraintes les plus critiques (touchant au système de permissions, etc).

Conclusion

Lorsque l’on interagit avec une base de données DynamoDB, la mise en place des vérifications de contraintes est primordiale. Afin d’être certain de ne pas en oublier, il est important de maintenir à jour un schéma de données (formatage, relations, données dénormalisées), ainsi que de la documentation (champs calculés, etc).

Tableau récapitulatif

Il est possible de compléter ces vérifications de contraintes avec des audits réguliers ou ponctuels des contraintes les plus critiques. Toutefois cela peut-être laborieux et coûteux. Surtout, ces audits ne peuvent se substituer à la mise en place de vérifications au fur et à mesure.

Et vous, avez-vous d’autres retours d’expérience sur vos vérifications de contraintes avec DynamoDB ?

--

--