Avancé6 min de lecture

Imposer l'unicité sur plusieurs attributs dans DynamoDB

DynamoDB garantit l'unicité pour exactement une chose : la clé primaire. Il n'y a pas de contrainte UNIQUE (email), pas de UNIQUE (username), et rien qui couvre deux attributs. En venant de SQL, cette absence est la première surprise — et le premier endroit où les gens livrent discrètement une race condition.

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

DynamoDB n'a pas de contrainte UNIQUE au-delà de la clé primaire, tu dois donc imposer 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 gardé par attribute_not_exists. La collision que le moteur impose déjà devient ta contrainte.

  • Il n'y a pas de contrainte d'unicité — seule la clé primaire est imposée unique par le moteur. Tout autre attribut « doit être unique » est ton boulot.
  • 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 email est-il pris ? » en une collision de clé que le moteur impose déjà.
  • Écris-les atomiquement avec TransactWriteItems. Une transaction, chaque put gardé par attribute_not_exists, pour que tous les marqueurs et le vrai enregistrement commitent ensemble ou aucun.
  • Ne fais pas check-then-write. Une lecture-avant-insertion est une race manuelle ; deux signups concurrents lisent tous les deux « libre » et écrivent tous les deux.

Pourquoi l'approche évidente est fausse

L'instinct est de faire un Query (ou pire, un Scan) sur l'email, ne rien voir, puis PutItem le nouveau compte. C'est une race check-then-act.

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

Un GSI sur email ne te sauve pas non plus. Les GSI sont à cohérence à terme, donc la lecture qui garde 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éliser chaque contrainte comme un item marqueur

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

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

Pour un signup qui doit garder à la fois email et username uniques, trois items se déplacent ensemble — clés dans une disposition single-table (voir single-table design) :

ItemPKSKObjectif
Enregistrement de compteACCT#a1f9c3PROFILELe vrai compte
Verrou emailUNIQ#EMAIL#ada@lovelace.ioLOCKRéserve l'email
Verrou usernameUNIQ#HANDLE#adaLOCKRéserve le username

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

Écrire les trois atomiquement

TransactWriteItems applique jusqu'à 100 écritures comme une seule unité tout-ou-rien. Garde 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 email, le verrou handle ou le compte lui-même — DynamoDB rollback toute la transaction et lève une TransactionCanceledException. Pas de signup partiel, pas de 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, un second signup avec le même email écrase silencieusement le premier verrou. Avec, le put refuse, la transaction s'annule, et ton appli fait remonter « email déjà utilisé ».

Construire la ConditionExpression et la map de valeurs à la main, c'est là que les fautes de frappe se glissent. Le DynamoDB Expression Builder émet la condition et l'Item typé pour chaque put pour que tu puisses coller une transaction correcte directement dans ton appel SDK.

Lis l'échec, ne le devine pas

Quand la transaction est annulée, DynamoDB renvoie un tableau CancellationReasons positionnellement — une entrée par item, dans l'ordre de la requête. Un ConditionalCheckFailed en slot 1 signifie que l'email est pris ; le slot 2 signifie que le username l'est. Remappe le slot vers une erreur précise au niveau du champ plutôt qu'un « échec du signup » générique.

Inspecter les verrous dans DynoTable

Les items marqueurs sont invisibles dans l'UI de ton appli — c'est de la plomberie. Quand un signup échoue mystérieusement, tu as besoin de voir si le verrou existe réellement.

Ouvre la table dans DynoTable et Query le préfixe UNIQ#. Le compte et ses deux items de verrou se trouvent ensemble, donc un signup bloqué (un verrou laissé derrière par une suppression ratée) est évident d'un coup d'œil.

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

Garder les verrous honnêtes au changement et à la suppression

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

  • Changer l'email. 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 le compte. Supprime l'item de compte et les deux items de verrou en une transaction, sinon tu échoueras un verrou qui bloque la valeur à jamais.
  • Réessayer en sûreté. Passe un ClientRequestToken pour qu'une transaction renvoyée (après un blip réseau) soit idempotente plutôt qu'une double écriture.

Le piège est de traiter le verrou comme du fire-and-forget. Un verrou créé au signup mais jamais supprimé à la suppression du compte est une valeur que personne ne peut jamais réutiliser — et ça ne se montrera pas avant qu'un vrai utilisateur ne puisse pas réclamer son propre ancien handle.

Étapes suivantes

Les marqueurs d'unicité sont un pattern single-table, donc ils se trouvent naturellement à côté de tes autres items — lis single-table design pour la disposition des clés, et Query vs Scan pour ne jamais atteindre un Scan afin de vérifier un verrou. Le pattern a d'abord été déroulé dans la session AWS re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions.

Rédige les puts gardés par condition avec le DynamoDB Expression Builder, puis essaie DynoTable pour inspecter les items de verrou sur ta propre table.

Mis à jour