Avancé7 min de lecture

Unicité sur plusieurs attributs dans DynamoDB

DynamoDB garantit l'unicité pour exactement une chose : la . Il n'existe aucune contrainte UNIQUE (email), aucune UNIQUE (username), et rien qui couvre deux attributs. Quand tu viens de SQL, cette absence est la première surprise — et le premier endroit où l'on livre discrètement une situation de compétition.

Comment imposer une contrainte d'unicité sur plusieurs attributs dans DynamoDB ?

DynamoDB n'a aucune contrainte UNIQUE au-delà de la , tu imposes donc l'unicité toi-même : modélise chaque valeur protégée comme son propre item marqueur dont la clé est cette valeur, puis écris l'enregistrement et chaque marqueur ensemble dans un seul TransactWriteItems, chaque put protégé par attribute_not_exists. La collision que le moteur impose déjà devient ta contrainte.

  • Il n'existe aucune contrainte d'unicité — seule la clé primaire est imposée unique par le moteur. Tout autre attribut « qui doit être unique » est ton travail.
  • Modélise chaque règle d'unicité comme son propre item. Un item marqueur dédié dont la clé est la valeur que tu protèges transforme « cet e-mail est-il pris ? » en une collision de clé que le moteur impose déjà.
  • Écris-les de façon atomique avec TransactWriteItems. Une seule , chaque put protégé par attribute_not_exists, pour que tous les marqueurs et l'enregistrement réel soient validés ensemble ou pas du tout.
  • Ne fais pas vérifier-puis-écrire. Une lecture-avant-insertion est une situation de compétition d'école ; deux inscriptions concurrentes lisent toutes deux « libre » et écrivent toutes deux.

Pourquoi l'approche évidente est mauvaise

L'instinct est de faire un Query (ou pire, un Scan) sur l'e-mail, de ne rien voir, puis un PutItem du nouveau compte. C'est une situation de compétition vérifier-puis-agir.

Deux personnes inscrivent ada@lovelace.io à la même milliseconde. Les deux lectures reviennent vides. Les deux écritures réussissent. Tu as maintenant deux comptes sur un seul e-mail — et rien dans la table ne le signale.

Un sur email ne te sauve pas non plus. Les GSI sont en cohérence à terme, si bien que la lecture qui conditionne ton écriture peut être périmée par conception. La solution n'est pas une vérification plus rapide ; c'est de faire en sorte que l'écriture elle-même refuse d'atterrir sur une valeur prise.

Modélise chaque contrainte comme un item marqueur

Le moteur impose déjà gratuitement une règle d'unicité : tu ne peux pas écrire deux items avec la même clé. Encode donc chaque règle d'unicité comme une clé.

À côté de l'item de compte réel, écris un item marqueur par attribut protégé. La clé de partition du marqueur est la valeur préfixée par un espace de noms. Si la valeur est prise, la clé existe, et un put protégé ne peut pas l'écraser.

Pour une inscription qui doit garder à la fois email et username uniques, trois items se déplacent ensemble — clés dans une disposition à table unique (voir conception à table unique) :

ItemPKSKRôle
Enregistrement de compteACCT#a1f9c3PROFILELe compte réel
Verrou e-mailUNIQ#EMAIL#ada@lovelace.ioLOCKRéserve l'e-mail
Verrou nom d'utilisateurUNIQ#HANDLE#adaLOCKRéserve le nom d'utilisateur

La PK propre au compte est un id généré (ACCT#a1f9c3) — jamais l'e-mail — pour que l'utilisateur puisse changer d'e-mail plus tard sans réécrire la clé primaire. Les items de verrou ne portent aucune donnée de profil ; ils existent uniquement pour que leur clé soit occupée.

Écris les trois de façon atomique

TransactWriteItems applique jusqu'à 100 écritures comme une seule unité tout-ou-rien. Protège chaque put avec attribute_not_exists(PK) pour qu'il échoue si cette clé est déjà présente.

Si une seule condition échoue — le verrou e-mail, le verrou de nom d'utilisateur ou le compte lui-même — DynamoDB annule toute la transaction et lève TransactionCanceledException. Aucune inscription partielle, aucun verrou orphelin.

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "ACCT#a1f9c3"},
          "SK": {"S": "PROFILE"},
          "email": {"S": "ada@lovelace.io"},
          "username": {"S": "ada"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#EMAIL#ada@lovelace.io"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    },
    {
      "Put": {
        "TableName": "accounts",
        "Item": {
          "PK": {"S": "UNIQ#HANDLE#ada"},
          "SK": {"S": "LOCK"}
        },
        "ConditionExpression": "attribute_not_exists(PK)"
      }
    }
  ]
}

La condition est tout le mécanisme. Sans attribute_not_exists, une seconde inscription avec le même e-mail écrase silencieusement le premier verrou. Avec elle, le put refuse, la transaction s'annule, et ton application fait remonter « e-mail déjà utilisé ».

Construire à la main le ConditionExpression et la table de valeurs, c'est là que les fautes de frappe se glissent. Le Générateur d'expressions DynamoDB émet la condition et l'Item typé pour chaque put, afin que tu puisses coller une transaction correcte directement dans ton appel de SDK.

Lis l'échec, ne le devine pas

Lorsque la transaction est annulée, DynamoDB renvoie un tableau CancellationReasons de manière positionnelle — une entrée par item, dans l'ordre de la requête. Un ConditionalCheckFailed en position 1 signifie que l'e-mail est pris ; la position 2 signifie que c'est le nom d'utilisateur. Relie la position à une erreur précise, au niveau du champ, plutôt qu'à un générique « échec de l'inscription ».

Inspecte les verrous dans DynoTable

Les items marqueurs sont invisibles dans l'interface de ton application — ils relèvent de la plomberie. Quand une inscription échoue mystérieusement, tu dois voir si le verrou existe réellement.

Ouvre la table dans DynoTable et fais un Query sur le préfixe UNIQ#. Le compte et ses deux items de verrou sont côte à côte, si bien qu'une inscription bloquée (un verrou laissé derrière par une suppression ratée) saute aux yeux.

DynoTable en train de scanner la table — les items de compte entremêlés avec leurs items de verrou UNIQ#EMAIL et UNIQ#HANDLE.
DynoTable en train de scanner la table — les items de compte entremêlés avec leurs items de verrou UNIQ#EMAIL et UNIQ#HANDLE.

Garde les verrous fidèles à la modification et à la suppression

Les verrous ne s'écrivent pas une fois pour toutes. Ils reflètent la valeur en vigueur, si bien que le cycle de vie doit les garder synchronisés — chaque opération qui touche un attribut protégé est aussi une transaction.

  • Changer d'e-mail. Une transaction : pose le nouveau verrou UNIQ#EMAIL#… avec attribute_not_exists, supprime l'ancien verrou, mets à jour le compte. Même garantie tout-ou-rien.
  • Supprimer un compte. Supprime l'item de compte et les deux items de verrou dans une seule transaction, sinon tu abandonneras un verrou qui bloque la valeur pour toujours.
  • Réessayer sans risque. Passe un ClientRequestToken pour qu'une transaction renvoyée (après une coupure réseau) soit idempotente plutôt qu'une double écriture.

Le piège est de traiter le verrou comme du poser-et-oublier. Un verrou créé à l'inscription mais jamais supprimé à la suppression du compte est une valeur que plus personne ne peut réutiliser — et cela ne se verra pas jusqu'à ce qu'un vrai utilisateur ne puisse pas récupérer son propre ancien nom d'utilisateur.

Étapes suivantes

Les marqueurs d'unicité sont un pattern de table unique, ils s'installent donc naturellement à côté de tes autres items — lis conception à table unique pour la disposition des clés, et Query vs Scan pour ne jamais te tourner vers un Scan afin de vérifier un verrou. Le pattern a d'abord été détaillé lors de la session AWS re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions.

Rédige les puts protégés par condition avec le Générateur d'expressions DynamoDB, puis essaie DynoTable pour inspecter les items de verrou sur ta propre table.

Mis à jour