Le Blog Amazon Web Services

Stratégies de stockage de gros objets pour Amazon DynamoDB

Nos clients utilisent Amazon DynamoDB comme base de données principale pour leurs applications critiques. DynamoDB est conçue pour délivrer une performance constante quelque soit l’échelle d’utilisation. Pour bénéficier de ce niveau de performance, l’une des tâches les plus importante est la modélisation des accès à vos données et en particulier la définition du mode de gestion des gros objets avec DynamoDB. Il est ainsi important d’avoir défini cette stratégie qui concerne les objets plus gros que le maximum de 400 Ko afin d’éviter des comportements inattendus et pour garantir que votre solution reste performante au fur et à mesure que son usage augmente.

Dans cet article, nous vous partageons différentes options pour gérer de gros objets avec DynamoDB ainsi que les avantages et inconvénients de chaque approche. Vous trouverez du code exemple pour chaque option afin de vous aider à les mettre en œuvre dans vos applications.

Pour commencer, avec DynamoDB, un élément est un ensemble d’attributs. Chaque attribut possède un nom et une valeur. Le nom ainsi que la valeur comptent dans la taille totale d’un élément. Dans le contexte de cet article, un gros objet correspond à un élément dont la taille est du-delà du maximum autorisé soit 400 Ko. Cet élément peut contenir des attributs de type chaîne de caractères longues, un objet binaire ou tout autre type de données supporté par DynamoDB dont la taille dépassera la taille maximale.

Vue générale

Cet article présente plusieurs approches que vous pouvez mettre en œuvre pour gérer de gros objets au sein de votre application s’appuyant sur DynamoDB. Vous devez avoir des connaissances de bases sur DynamoDB. Si vous découvrez depuis peu ce service, nous vous recommandons de suivre le guide de démarrage avec Amazon DynamoDB.

Les différentes approches qui vont être présentées sont les suivantes :

Déploiement des exemples

Pour illustrer chaque approche, vous trouverez une modèle SAM (AWS Serverless Application Model (SAM)) dans ce référentiel Github. Vous pouvez utiliser ces exemples comme une référence pour votre propre mise en œuvre. Ces exemples sont tous écrits en Node.JS mais un équivalent des techniques utilisées existe dans la plupart des langages de programmation.

Pour déployer le modèle SAM, vous pouvez cloner ce référentiel Github et suivre les instructions incluses.

Option 1 : Comportement par défaut

Le comportement par défaut est de rejeter les éléments dont la taille est au-delà du maximum, ce qui peut tout à fait être un choix valide. Dans ce cas, vous retournez une erreur à l’appelant indiquant que la taille de l’élément est trop grande. Il lui appartient donc d’implémenter le bon comportement qui peut consister à découper l’élément en plusieurs parties, ou envoyer l’élément dans une « Dead Letter Queue » ou encore retourner une exception indiquant que l’élément est trop gros.

Dans l’exemple inclus dans le référentiel, vous pouvez constater ce comportement en exécutant la fonction AWS Lambda qui a été déployée par le modèle. Cet exemple inclut un exemple d’événement qui va au-delà de la taille maximum (420 Ko soit 20 ko de plus). Si vous déclenchez l’appel à la fonction avec ce paramètre, vous obtiendrez une erreur de type ValidationException de DynamoDB avec un message vous indiquant que la taille maximum autorisée est dépassée, comme indiqué dans la Figure 1 ci-après :

Figure 1 : Exception DynamoDB

Figure 1 : Exception DynamoDB

Cette approche a le mérite de ne nécessiter aucune modification de la logique côté serveur et donc le coût et la complexité associés restent faibles. De plus, les requêtes qui échouent avec cette validation n’utilisent aucune unité de demande d’écriture (Write Capacity Unit ou WCU) de la table concernée. L’inconvénient de cette approche est qu’elle peut ne pas satisfaire les besoins de vos utilisateurs, nous vous recommandons donc d’explorer les autres options décrites ci-après.

Option 2 : Stocker les gros objets dans Amazon S3 avec un lien vers DynamoDB

Une autre option pour stocker les gros objets consiste à utiliser un autre service de stockage et à utiliser un lien vers cet objet depuis DynamoDB. Amazon S3 est tout à fait adapté pour stocker de tels objets grâce à sa forte durabilité et son faible coût. Cette approche consiste tout d’abord à stocker l’objet dans un bucket S3 puis à créer un élément DynamoDB avec un attribut contenant l’URL S3 de l’objet. Vous pouvez ensuite utiliser cette URL pour retourner une URL pré-signée à l’appelant. Ceci permet de mieux sécuriser l’accès à l’objet et également de réduire les coûts de calcul en ne téléchargeant pas l’objet côté serveur. L’architecture de cette approche est décrite dans la figure 2 suivante.

Architecture du stockage des gros objets dans Amazon S3

Figure 2 : Architecture du stockage des gros objets dans Amazon S3.

Ce pattern d’architecture est couramment utilisé dans des solutions d’indexation de contenu pour lesquelles le contenu peut être de tailles variées, de type semi-structuré et est stocké dans un bucket S3. DynamoDB constitue ainsi un index utilisé pour effectuer des recherches rapides des gros objets. Vous trouverez davantage d’information sur cette pattern dans cet article (article en anglais).

Dans le référentiel d’exemple, deux fonctions illustrent cette approche. Une première qui écrit l’objet dans le bucket S3 et un lien dans DynamoDB, une seconde qui lit l’élément dans DynamoDB et retourne une URL pré-signée à l’appelant. Si vous effectuez l’appel à cette fonction avec les mêmes paramètres que dans la première approche (c-a-d avec un objet de taille supérieure à 400Ko), alors vous constaterez que l’appel est réussi, comme décrit dans la figure 3 qui suit.

Figure 3 : Élément écrit avec succès

Ensuite, si vous exécutez la fonction de lecture, vous récupérez une URL pré-signée, comme décrit dans la figure 4 qui suit.

Figure 4 : Generation de l’URL pré-signée

Cette URL temporaire permet d’accéder de façon sécurisée à une ressource privée stockée dans un bucket S3. Si vous copiez/collez cette URL dans votre navigateur, vous pourrez récupérer l’objet stocké dans le bucket S3 comme décrit dans la figure 5 qui suit.

Figure 5 : Récupération de l’objet en utilisant une URL pré-signée.

Un des avantages de cette approche est que vous pouvez stocker des données de n’importe quelle taille dans Amazon S3 (jusqu’à 5 To par objet). Comme vous ne manipulez que l’URL S3 de l’objet, vous consommerez moins d’unités de demande de lecture/écriture et vous optimiserez ainsi à la baisse vos coûts DynamoDB .

L’inconvénient de cette approche est qu’un second appel est nécessaire pour récupérer le gros objet ajoutant ainsi une potentielle latence et complexité. Il y a également des coûts associés avec le stockage et la récupération de données depuis S3 (voir la fiche tarifaire sur S3 pour plus d’informations).

Option 3: Répartir les gros objets dans des collections d’éléments

Une autre approche consiste à répartir un gros objet dans une collection d’éléments plus petits mais partageant la même clé de partitionnement. Dans ce cas, la clé de partitionnement joue le rôle d’un compartiment qui contient toutes les parties de l’objet original sous la forme d’éléments séparés.

Il ya plusieurs manières de répartir de gros éléments en plusieurs éléments d’une collection. La méthode traditionnelle (si l’objet contient une délimitation naturelle comme un objet JSON avec beaucoup d’attributs) consiste à grouper les attributs en différents éléments. Cela permet de récupérer uniquement les attributs souhaités (modulo le groupe) réduisant ainsi les coûts d’I/O. Par exemple, considérons l’élément suivant :

Clé de partitionnement Donnée
document-a {“Nom”: “Paul “Dupont,“Genre”: “Femme”,“Entreprise”: « AnyCompany »,“Email »: “pauld@example.com »,“Téléphone: « +33 (0) 1-23-45-67-89”,“Notes »: « Lorem ipsum dolor … « }

Si la taille de cet élément fait 10 Ko, 1.5 unités de demande de lecture seront nécessaire pour réaliser une seule lecture éventuellement cohérente de cet élément.

Clé de partitionnement Clé de tri Donnée
document-a Nom Paul Dupont
document-a Genre Homme
document-a Entreprise AnyCompany
document-a Email pauld@example.com
document-a Téléphone +33 (0) 1-23-45-67-89
document-a Notes Lorem ipsum dolor …

Avec cette structure, tout attribut peut être récupéré et consommer 0.5 RCU pour chaque lecture éventuellement cohérente permettant ainsi de réduire la capacité de la table et donc son coût. L’ensemble de la collection d’éléments peut aussi être récupérée grâce à la clé de partitionnement.

Cette structure peut cependant s’avérer coûteuse si plusieurs éléments doivent être récupérés (ou mis à jour) fréquemment. Le coût initial d’écriture plus important doit également être considéré si il y a beaucoup d’attributs. Une optimisation possible consiste à grouper les attributs en fonction de leur fréquence de lecture ou de mise à jour. Par exemple, si tous les attributs d’une personne ne changent quasiment pas mais que le contenu de l’attribut “notes” change souvent alors vous pourriez structurer la collection d’élément de la façon suivante :

Lorem ipsum dolor …

Clé de partitionnement Clé de tri nom Genre Entreprise Email Téléphone Notes
document-a Personne Paul Dupont Homme AnyCompany pauld@example.com +33 (0) 1-23-45-67-89
document-a Notes

Avec cette approche, les attributs consultés moins fréquemment peuvent être accédés avec une seule requête de 0.5 RCU (Read Capcity Unit) et peuvent être modifiés avec une seule WCU (Write Capacity Unit) comme ils représentent moins de 1Ko de données. Si l’élément d’origine contient des centaines ou des milliers d’attributs, vous pouvez créer autant de groupes d’attributs que nécessaire afin de minimiser le nombre de WCU utilisées.

Si l’attribut “Notes” est plus grand que la taille maximale (400 Ko) vous pouvez également découper sa valeur en plusieurs parties pouvant individuellement être enregistrées dans un élément DynamoDB. Vous pourrez alors reconstituer par concaténation l’élément original lors de la récupération de l’élément et son renvoi à l’appelant.

Dans le référentiel d’exemple, la fonction d’écriture reçoit une chaîne de caractères et la découpe en autant de sous-parties que nécessaire pour les stocker dans DynamoDB en respectant la taille maximale. La fonction créée un élément dédié avec la même clé de partitionnement. Cet élément est identifié de façon unique par un indice automatiquement incrémenté et utilisé comme clé de tri, voir l’article sur l’utilisation de clés de tri pour plus de détails (article en anglais).

Dans cet exemple, chaque élément est sauvegardé dans la boucle pour des questions de simplicité. Pour des raisons de performance, vous pouvez également construire l’ensemble des éléments séparément puis les enregistrer dans DynamoDB en une seule opération avec l’API BatchWriteItem, ou avec l’API TransactWriteItem si vous souhaitez réaliser une opération transactionnelle. Ces opérations par lot sont limitées à 25 éléments par appel (ou 16 Mo de données totales pour l’API BatchWriteItem ou encore 4 Mo de données totales pour l’API TransactWriteItem), vous devrez donc inclure une logique particulière dans votre code pour gérer ces limites.

La figure 6 suivante illustre ce cas avec deux éléments possédant la même clé de partitionnement mais avec des clés de tri différentes.

Figure 6 : Éléments DynamoDB

Pour lire cet objet, vous devez reconstituer ses différentes parties. La récupération des différentes parties est aisé car DynamoDB vous permet de récupérer toutes les parties d’un objet en un seul appel en spécifiant uniquement la clé de partitionnement. En utilisant ainsi l’API Query pour récupérer les résultats, vous avez la garantie que les différentes parties de votre objet seront traitées dans l’ordre numérique lié à la clé de tri. Vous pourrez ensuite côté serveur concaténer ces différentes valeurs afin de masquer ce détail d’implémentation à l’appelant.

Ce pattern d’implémentation permet également de gérer la prévisualisation d’un objet. En retournant le premier élément d’une collection comme un aperçu de l’objet complet, vous pourriez ainsi ajouter un bouton dans l’interface graphique permettant de déclencher le chargement complet dudit objet. Ceci permettrait de réduire le nombre de requêtes réalisées au minimum et donc de réduire le coût global de la solution.

Les inconvénients de cette approche sont l’ajout de complexité côté serveur pour gérer le découpage et le ré-assemblage des différentes parties de l’objet ainsi que le coût de test associé pour éviter toute perte de données. Pour gérer de très gros objets (qui nécessiteraient trop de sous parties), nous vous recommandons d’utiliser un espace de stockage externe supplémentaire comme discuté pour l’approche 2 ou de le combiner avec l’approche 4 afin de réduire la capacité (RCU/WCU) requise pour chaque élément.

Option 4: Compresser les gros objets

Dans cette dernière approche, nous allons explorer comment compresser les gros objets en utilisant deux algorithmes de compression différents et comparer les avantages et inconvénients de chacun.

Tout d’abord, considérons la librairie zlib incluse par défaut dans le framework Node.JS. Dans le référentiel d’exemple, vous trouverez un répertoire zlib contenant deux fonctions lambda, l’une pour écrire des données et l’autre pour les lire. En réutilisant les mêmes paramètres que précédemment dans l’approche 1, vous pouvez exécuter la fonction d’écriture (qui va utiliser la fonction gzip) et obtenir le résultat illustré dans la figure 7 suivante :

Figure 7: Écriture/Compression avec la fonction Gzip

Ensuite, en utilisant la fonction de lecture qui va décompresser la chaîne de caractères, vous obtiendrez le résultat tel qu’illustré dans la figure 8 :

Dans un second temps, nous allons utiliser la librairie snappy. Dans le référentiel d’exemple, vous trouverez un sous répertoire Snappy contenant deux fonctions lambda, l’une pour écrire et l’autre pour écrire. En réutilisant les mêmes paramètres que précédemment, vous pouvez exécuter la fonction d’écriture (qui va utiliser la fonction gzip) et obtenir le résultat illustré dans la figure 9 suivante :

Figure 9 : Écriture/Compression avec Snappy

Ensuite, en utilisant la fonction de lecture qui va donc décompresser la chaîne de caractères, vous obtiendrez le résultat tel qu’illustré dans la figure 10 :

Figure 10 : Lecture/Décompression avec Snappy

Comme vous pouvez le voir dans le tableau suivant (tests réalisés avec 100 appels), la librairie Snappy est plus rapide mettant en moyenne 180ms par appel comparé aux 440 ms pour la librairie zlib. Cependant, il faut également noter que le ratio de compression est de 50% pour Snappy (soit une taille compressée de 208 Ko) et de 66% pour zlib (soit une taille compressée de 139 Ko). Ces différences ne posent pas de problème pour notre cas d’utilisation mais dans un environnement serverless, la facturation s’effectue à une précision de l’ordre de la milliseconde. Par conséquent, si vous souhaitez optimisez vos coûts sur un environnement de ce type, nous vous recommandons d’utiliser une librairie telle que Snappy. Si par contre, vous avez la contrainte d’utiliser un environnement sans librairies tierces (zlib est intégrée en natif dans Node.JS), alors la fonction gzip est sans doute plus adaptée.

Le tableau suivant récapitule les tests et mesures effectués et donne également une projection des coûts associés :

Type de compression Taille de l’élément (Ko) Nombre de WCU DynamoDB consommées par requête Durée d’exécution (ms) Lambda par requête Coût WCU DynamoDB par mois ($) Coût d’exécution par mois ($) Coût total ($)
Non compressé 420
snappy 208 208 180 13254,93 446.76 13701,69
zlib 139 139 440 8857,87 1016.16 9864,03

Figure 11: Tableau basé sur 100 appels par seconde pour une fonction Lambda avec une mémoire de 512Mo pour un mois dans la région Paris (eu-west-3)

Il y a d’autres librairies de compression que vous pouvez utiliser (par exemple lzo ou zstandard) qui offrent des performances différentes. Soyez vigilants sur la potentielle nécessité d’acquérir une licence pour pouvoir les utiliser dans vos projets.

Un inconvénient de l’approche par compression est que cette étape ajoute du temps de calcul quelque soit l’algorithme utilisé. De plus, cette approche est applicable uniquement si la taille des données compressées reste en dessous de la taille maximale pour DynamoDB (400 ko), ce que vous pouvez ne pas pouvoir connaître à l’avance. Si la taille des données à compresser est très supérieurs à la limite maximale, une solution peut consister à combiner l’approche de la compression ainsi que celle de la répartition en sous parties.

Enfin, l’avantage de cette approche est que la compression n’est pas visible pour l’appelant et qu’elle ne nécessite pas de changer la structure de l’objet ou d’utiliser un service AWS supplémentaire pour le stockage.

Suppression des ressources

Les appels aux fonctions Lambda et l’utilisation de DynamoDB décrits dans cet article devraient être couverts par l’Amazon Web Services (AWS) free tier correspondant. Vous ne devriez pas avoir de coûts induits par ces appels à moins que vous ne dépassiez ces seuils. Ceci étant dit, les détails de la facturation de ces services sont décrits sur la page dédiée de DynamoDB et celle de Lambda. Si vous avez déployé le modèle d’exemple SAM, alors supprimez la stack AWS CloudFormation depuis le service CloudFormation de la Console AWS.

Commentaires

En fonction des caractéristiques et des modes d’accès à vos données de vos applications vous pourrez sélectionner une des approches décrites précédemment. Vous trouverez ci-après quelques remarques supplémentaires pour sélectionner la meilleure approche pour votre besoin.

Stocker les gros objets dans Amazon S3 avec un lien vers DynamoDB

  • En répartissant vos données sur plusieurs services de stockage, vous pouvez perdre la cohérence transactionnelle. AWS Step Functions peut vous aider à maintenir cette intégrité sur des systèmes distribués, par exemple en mettant en oeuvre la pattern d’architecture saga.
  • Si vous utilisez la fonctionnalité des tables globales de DynamoDB pour la réplication multi-région, nous vous conseillons de répliquer le contenu du bucket S3 vers d’autres régions en particulier pour gérer des scénarios de reprise après sinistre. Ceci peut être réalisé avec la fonctionnalité de réplication inter-région d’Amazon S3.

Répartir les gros objets dans des collections d’éléments
Compresser les gros objets

  • Si vous utilisez des index globaux ou des index locaux secondaire pour votre table DynamoDB, nous vous conseillons d’étudier si vous pouvez utiliser la projection d’attributs vers des index secondaires. En effet, utiliser la table pour des recherches de certaines parties de gros objets et s’appuyer sur les index secondaires pour les autres modes d’accès (sans les attributs projetés) permet d’optimiser des coûts sur le stockage ainsi que sur la capacité en écrite nécessaire pour propager les mises à jour des index.
  • Les opérations de requêtes et de scan avec DynamoDB peuvent extraire jusqu’à 1 Mo de données. Si le résultat de votre requête est supérieur à cette taille alors vous devrez effectuer une pagination des résultats an utilisant l’API BatchGetItem.

Compresser les gros objets

Si vous avez besoin d’utiliser des filtres sur des attributs de vos éléments dans vos requêtes, vous ne pourrez pas le faire sur des attributs contenant des données binaires (telles que des chaîne de caractères compressées) étant donné que DynamoDB ne peut pas décompresser nativement vos objets.

Conclusion

Dans de nombreux cas d’utilisation de DynamoDB, vous devez considérer comment gérer de gros objets. Prendre une décision d’architecture explicite permet de prendre en compte les limites et modes d’accès de chaque composant et de définir une architecture scalable et performante même si votre volume de données augmente. Pour approfondir les questions de conception avec DynamoDB, vous pouvez consulter l’article de blog sur comment déterminer si DynamoDB est le bon choix pour vos besoins. Pour obtenir des conseils sur la modélisation de vos données, vous pouvez consulter l’article de blog sur la modélisation de données avec l’outil NoSQL Workbench pour Amazon DynamoDB (article en anglais).

Nous vous invitons à tester vous-même les différentes approches discutées dans cet article en déployant les exemples de code fournis.

Article original publié par Josh Hart, Senior Solutions Architect, et localisé par Jean-Baptiste Guillois, Senior Solutions Architect dans les équipes AWS France.