Prendre en main DynamoDB — Fondamentaux

Thibault Ruby
neoxia
Published in
9 min readDec 15, 2020

En 2017 débutait chez Neoxia un projet d’envergure avec l’un de nos clients : une plateforme permettant d’interagir avec une multitudes de services Data hébergés chez différents cloud providers. Le projet ayant une vision long-terme, le nombre d’utilisateurs de la plateforme, ainsi que les fonctionnalités disponibles étaient amenées à augmenter de manière importante mais assez peu prédictible.

Pour des raisons de modularité, et de scalabilité, nous avons donc fait le choix de partir sur une application serverless avec une architecture en microservice hébergée chez AWS. Pour ce qui est du Système de Gestions de Base de Données (SGBD), avec un nombre d’utilisateurs et de fonctionnalités grandissant, le volume de données à stocker ainsi que les capacités en lecture / écriture nécessaires étaient alors elles-aussi vouées à augmenter. Il fallait alors ici aussi une solution très scalable.

De plus le contexte métier du projet étant complexe et en constante évolution, les besoins n’étaient pas gravés dans le marbre. Le Modèle Conceptuel de Données (MCD) était alors voué à évoluer voir à être retravaillé régulièrement, notamment dans les premières phases du projet. Un SGBD relationnel pouvait alors être facteur de ralentissement.

Et à cette époque le seul SGBD auto-géré par AWS à cette époque était Amazon DynamoDB, qui est un SGBD no-SQL. C’est cet ensemble de raisons qui nous ont poussés vers cette solution-ci.

A l’instar des autres SGBD no-SQL, DynamoDB a ses propres paradigmes qui nécessitent d’être bien pris en compte pour pouvoir pleinement profiter de ses avantages.

Mais alors, comment bien prendre Amazon DynamoDB en main ?

Généralités

Amazon DynamoDB est un SGBD no-SQL orienté document et key-value. Une base de données DynamoDB peut se composer de plusieurs tables.

Ce SGBD se révèle intéressant notamment lorsqu’il est nécessaire d’avoir :

  • de l’auto-scaling (que ce soit en terme d’espace de stockage, ou de capacité en lecture / écriture)
  • des débits qui peuvent être très élevés en écriture et en lecture
  • une faible latence générale sur les réponses

Toutefois, avec un Modèle Conceptuel de Données complexe, DynamoDB peut rapidement devenir très difficile à gérer, et grandement compliquer les développements de l’application interagissant avec elle.

Un cas d’utilisation pertinent de DynamoDB pourrait être par exemple un système de gestion de commandes avec un grand nombre fluctuant d’utilisateurs.

Index

Comme dans beaucoup de SGBD, une table DynamoDB utilise des index afin de stocker de manière intelligente ses données.

Un index de table peut être composé de deux clés :

  • Une clé de partition (dite HashKey ou PartitionKey, que l’on notera pk par la suite) qui est obligatoire
  • Une clé de tri (dite RangeKey ou SortKey, que l’on notera sk par la suite) qui est optionnelle

Il existe plusieurs types d’index, et une même table peut en posséder plusieurs :

  • Celui par défaut de la table (l’index “principal”)
  • Des LSI (Local Secondary Indexes), qui utilisent la même PartitionKey que l’index principal, mais utilisent une SortKey différente (jusqu’à 5 par table)
  • Des GSI (Global Secondary Indexes), qui permettent d’utiliser une autre PartitionKey que celle de l’index principal, (jusqu’à 20 par table)

Tous les documents d’une table doivent obligatoirement renseigner les clés de l’index principal, mais pas nécessairement celles des autres si elles diffèrent.

Accès aux données

Il existe deux façons d’accéder aux données d’une table DynamoDB. Le premier c’est en utilisant le scan, qui parcourt l’ensemble des données de la table, ce qui est coûteux aussi bien en termes de performances que de facturation. D’autant plus qu’il est plutôt rare que l’on souhaite accéder d’un coup à l’ensemble des données d’une table.

La seconde méthode consiste à utiliser des requêtes. Les requêtes permettent de ne sélectionner qu’une seule partie des données en filtrant sur les index de la table. C’est pour cette raison que les requêtes présentent de beaux avantages au niveau des performances (tout en étant plus économiques).

Il est donc préférable d’utiliser dès que possible des requêtes plutôt que des scans.

Afin d’effectuer une requête, il faut renseigner à minima l’index utilisé, ainsi que la valeur exacte qui doit être matchée par la PartitionKey.

Il possible de renseigner en sus, s’il y a lieu, une ou plusieurs conditions sur la SortKey parmi les suivantes :

  • Égalité / Infériorité (stricte) / Supériorité (stricte) (=, <, <=, >, >=)
  • Un encadrement de la valeur
  • Une suite de caractère à matcher par laquelle la SortKey doit débuter

Dans le cas d’une requête utilisant un autre index que le principal, les lignes ne présentant pas les deux clés de l’index utilisé ne seront pas renvoyées (indépendamment du fait que la requête porte dessus ou non).

Exemples de requêtes et d’index :

Extrait des records de la table :

Index

Index par défaut / Principal :

HashKey = pk (string)

SortKey = sk (string)

LS1 (Local Secondary Index 1)

HashKey = pk (string)

SortKey = cost (number)

GS1 (Global Secondary Index 1)

HashKey = sk (string)

RangeKey = pk (string)

Requêtes

Requête 1 :

Index utilisé : Principal

pk = ‘user-1@mail.com’

Documents retournés

{ pk: ‘user-1@mail.com’, sk: ‘employee’, role: ‘manager’ },

{ pk: ‘user-1@mail.com’, sk: ‘file_owner’, owned_file: ‘path-to-file’ },

{ pk: ‘user-1@mail.com’, sk: ‘employer’, salary: 1000}

Requête 2 :

Index utilisé : Principal

pk = ‘user-1@mail.com’

sk commence par ‘emp’

Documents retournés

{ pk: ‘user-1@mail.com’, sk: ‘employee’, role: ‘manager’ },

{ pk: ‘user-1@mail.com’, sk: ‘employer’, salary: 1000 }

Requête 3 :

Index utilisé : LS1

pk = ‘command-12’

cost > 400

Documents retournés

{ pk: ‘command-12’, sk: ‘receipt’, cost: ‘600’ }

Requête 4 :

Index utilisé : GS1

sk = ‘employee’

Documents retournés

{ pk: ‘user-1@mail.com’, sk: ‘employee’, role: ‘manager’ },

{ pk: ‘another-user-2@mail.com’, sk: ‘employee’, role: ‘chief’ },

{ pk: ‘user-3@mail.com’, sk: ‘employee’, role: ‘intern’ },

Requête 5:

Index utilisé : GS1

sk = ‘employee’

pk commence par ‘user-’

Documents retournés

{ pk: ‘user-1@mail.com’, sk: ‘employee’, role: ‘manager’ },

{ pk: ‘user-3@mail.com’, sk: ‘employee’, role: ‘intern’ }

Représentation d’entités et single table design

Utiliser plusieurs tables DynamoDB permet par exemple de séparer les différents types de documents ou d’entités stockés. Cependant il est possible d’utiliser une seule table pour stocker tous les différents types d’entités, et ça présente plusieurs avantages.

Cela permet notamment de simplifier les back-ups ainsi que les rollbacks (cet aspect ne sera pas développé ici), tout en permettant de récupérer plusieurs types de documents simultanément en une requête (voir requête 1), ce qui peut se révéler intéressant en termes de performances avec de gros volumes de données.

Mais ce n’est pas le seul intérêt. En utilisant une seule table, il devient très facile de croiser les données, et de récupérer en une seule requête spécifique la totalité des données dont aurait besoin, par exemple, un appel API.

Comme vu précédemment, accéder à des données via l’utilisation de scans est dans la très grande majorité des cas non souhaitable. L’utilisation de requêtes devant donc être la norme, l’accès aux données est lié aux index définis ainsi qu’à la manière dont ils sont utilisés.

Autrement dit, la définition des entités doit alors être pensée en fonction de la façon dont on souhaite y accéder. On parlera alors pour chaque entité de son Access Pattern associé.

Et cela est très dépendant du Modèle Conceptuel de Donnée de notre système, et des relations qui s’y trouvent. Plus concrètement : selon quel(s) attribut(s) souhaite t-on lister quelle entité.

Access pattern

Un design en single table se base sur l’utilisation d’une table à deux clés : une clé de partition (pk) et une clé de tri (sk), ainsi que sur l’utilisation d’index secondaires.

On utilisera principalement trois attributs:

  • (pk) qui sera utilisé pour stocker des identifiants uniques
  • (sk) qui sera utilisé pour stocker le type d’entité
  • (ak) qui sera utilisé pour certaines entités en tant que clé additionnelle

Ce sont ces attributs qui seront utilisés pour nos indexes :

  • Un principal avec (pk) comme PartitionKey et (sk) comme SortKey
  • Un secondaire global (GS1) avec (sk) comme PartitionKey et (pk) comme SortKey
  • Un autre secondaire global (GS2) avec (ak — pour additional key) comme PartitionKey et (sk) comme SortKey

En prenant l’exemple d’un utilisateur identifié par un mail (DynamoDB étant document oriented on ne s’attardera pas sur les attributs) on aura par exemple:

  • (pk: <mail> ; sk: ‘client’)
  • (pk: <product_id> ; sk: ‘product’)

De cette façon avec la requête :

Index utilisé : Principal

pk = <mail>

sk = ‘client’

On récupère un client précis, et avec :

Index utilisé : GS1

sk = ‘client’

On récupère la liste de tous les clients.

Relations

L’utilisation d’une base de données a de grandes chances d’introduire la gestion de relations entre certaines entités.

DynamoDB ne propose pas de gestion native et intuitive de ces dernières. Toutefois cela peut se faire en définissant des indexes répondant aux access patterns définis au préalable.

Pour l’exemple, nous allons partir sur la création de relations dans le cadre d’un site de vente en ligne.

Relations 1–1

On souhaite lier un client à un account. L’une des solutions serait de simplement fusionner les deux types de documents car un account est nécessairement lié à un client, et vis-versa. Toutefois même si c’est un besoin assez rare, on peut vouloir différencier les deux (si l’on ne souhaite pas récupérer les informations de l’account à chaque fois que l’on récupère un user par exemple).

Auquel cas on se retrouve avec les access pattern suivants :

  • (pk: <mail> ; sk: ‘user’ ; ak: <account_id>)
  • (pk: <account_id> ; sk: ‘account’ ; ak: <mail>)

De cette manière on peut récupérer l’account d’un user de la manière suivante :

Index utilisé : GS2

ak = <mail>

sk = ‘account’

Et à l’instar de l’autre requête :

Index utilisé : GS2

ak = <account_id>

sk = ‘user’

Relations 1 — *

On souhaite lier une commande à un client (une commande n’étant liée qu’à un seul client, et un client pouvant être lié à plusieurs commandes). On cherchera alors potentiellement à accéder à toutes les commandes d’un client, mais aussi à accéder au client lié à la commande.

Pour ce faire, la sk ne servira plus simplement à définir le type d’entité, mais elle permettra aussi de savoir à qui cette entité est liée. On se retrouve alors avec :

  • (pk: <command_id> ; sk: ‘command|<user_id>’)

A noter que le `|` utilisé ici est arbitraire et ne doit pas être utilisé dans les noms d’entités ou dans les IDs. Il pourrait tout à fait être remplacé par un autre caractère.

De cette façon avec la requête :

Index utilisé : Principal

pk = <command_id>

sk commence par ‘command|’

On saura alors quel est l’utilisateur à l’origine de la commande.

Et avec la requête :

Index utilisé : GS1

sk = ‘command|<mail>’

On récupérera alors l’ensemble des commandes de l’utilisateur, qui seront toutes identifiées par un command_id unique.

Relations * — *

On souhaite désormais lier des produits à des commandes.

  • (pk: <command_id> ; sk: ‘command_content|<product_id>’)

Index utilisé : Principal

pk = <command_id>

sk commence parcommand_content|’

Permet de récupérer la liste des produits contenus dans la commande <command_id>

Index utilisé : GS1

sk = ‘command_content|<product_id>’

Permet de récupérer la liste des commandes contenant ce produit.

De plus :

Index utilisé : Principal

pk: <command_id>

Permet de récupérer la commande ainsi que la liste des produits qu’elle contient.

Autres usages d’index

Les indexes peuvent aussi permettre de proposer plusieurs options de tri. On peut par exemple ajouter des champs “created_at” et “updated_at” pour les entités que l’on souhaiterait trier par date. Auquel cas il faudra ensuite créer deux nouveaux index (LSI ou GSI) utilisant l’un de ces champs comme partie de clé.

Conclusion

Pour conclure, DynamoDB est un SGBD no-SQL qui peut s’auto-scaler aussi bien au niveau du stockage que des lectures / écritures simultanées, tout en pouvant proposer des temps de réponses très faibles. De plus, il est possible d’y implémenter une grande variété de Modèles Conceptuels de Données ainsi que d’y représenter des relations, en définissant des access-pattern cohérents. Ce qui peut en faire un SGBD adapté à de nombreuses problématiques.

Toutefois, DynamoDB impose un paradigme nécessitant de repenser la manière dont on représente ses données. Et il ne propose pas de façon native de solutions permettant de vérifier beaucoup de contraintes d’intégrité. Il revient alors aux développeurs de s’assurer du respect de ces contraintes lors des interactions avec DynamoDB. Cet aspect clé de vérifications de contraintes fera l’objet d’un futur article (un peu de patience !).

Dans notre cas, cet outil convenait et convient toujours bien à notre utilisation, et une fois bien pris en main nous permet une grande flexibilité pour ce qui est des évolutions ou enrichissements de MCD (sans se poser de questions au niveau du dimensionnement ou des performances). Mais il nous a fallu quelques itérations avant d’arriver à bien l’exploiter.

La prise en main d’un nouveau SGBD, notamment comme Amazon DynamoDB n’est pas quelque chose d’anodin. Et vous, quel serait votre retour d’expérience avec Amazon DynamoDB ?

--

--