Profi5 Min. Lesezeit

DynamoDB: Eindeutigkeit über mehrere Attribute

DynamoDB garantiert Eindeutigkeit für genau eine Sache: den . Es gibt keinen UNIQUE (email)-Constraint, kein UNIQUE (username) und nichts, was zwei Attribute überspannt. Wer aus SQL kommt, für den ist diese Abwesenheit die erste Überraschung — und der erste Ort, an dem Leute klammheimlich eine Race Condition ausliefern.

Wie erzwingst du einen Unique-Constraint über mehrere Attribute in DynamoDB?

DynamoDB hat keinen UNIQUE-Constraint jenseits des , also erzwingst du Eindeutigkeit selbst: Modelliere jeden geschützten Wert als eigenes Marker-Item, dessen Key der Wert ist, und schreibe dann den Datensatz und jeden Marker zusammen in einem TransactWriteItems, wobei jeder Put per attribute_not_exists abgesichert wird. Die Kollision, die die Engine ohnehin erzwingt, wird zu deinem Constraint.

  • Es gibt keinen Unique-Constraint — nur der Primary Key wird von der Engine als eindeutig erzwungen. Jedes andere „muss eindeutig sein“-Attribut ist deine Aufgabe.
  • Modelliere jede Eindeutigkeitsregel als eigenes Item. Ein dediziertes Marker-Item, dessen Key der Wert ist, den du schützt, verwandelt „ist diese E-Mail vergeben?“ in eine Key-Kollision, die die Engine ohnehin erzwingt.
  • Schreibe sie atomar mit TransactWriteItems. Eine , jeder Put per attribute_not_exists abgesichert, sodass alle Marker und der echte Datensatz zusammen committen oder keiner.
  • Kein Check-then-Write. Ein Read-before-Insert ist eine Lehrbuch-Race; zwei gleichzeitige Anmeldungen lesen beide „frei“ und schreiben beide.

Warum der naheliegende Ansatz falsch ist

Der Instinkt ist, per Query (oder schlimmer, Scan) nach der E-Mail zu suchen, nichts zu sehen und dann das neue Konto per PutItem zu schreiben. Das ist eine Check-then-Act-Race.

Zwei Personen registrieren ada@lovelace.io in derselben Millisekunde. Beide Lesevorgänge liefern leer zurück. Beide Schreibvorgänge gelingen. Du hast jetzt zwei Konten auf einer E-Mail — und nichts in der Tabelle markiert das.

Ein auf email rettet dich ebenfalls nicht. GSIs sind letztendlich konsistent, sodass der Lesevorgang, der deinen Schreibvorgang absichert, per Design veraltet sein kann. Die Lösung ist kein schnellerer Check; es ist, den Schreibvorgang selbst dazu zu bringen, sich zu weigern, auf einem vergebenen Wert zu landen.

Modelliere jeden Constraint als Marker-Item

Die Engine erzwingt bereits eine Eindeutigkeitsregel gratis: Du kannst nicht zwei Items mit demselben Key schreiben. Also kodiere jede Eindeutigkeitsregel als Key.

Schreibe neben dem echten Konto-Item ein Marker-Item pro geschütztem Attribut. Der Partition Key des Markers ist der namespaced Wert. Wenn der Wert vergeben ist, existiert der Key, und ein abgesicherter Put kann ihn nicht überschreiben.

Für eine Anmeldung, die sowohl email als auch username eindeutig halten muss, bewegen sich drei Items zusammen — verschlüsselt in einem Single-Table-Layout (siehe Single-Table-Design):

ItemPKSKZweck
Konto-DatensatzACCT#a1f9c3PROFILEDas echte Konto
E-Mail-LockUNIQ#EMAIL#ada@lovelace.ioLOCKReserviert die E-Mail
Benutzername-LockUNIQ#HANDLE#adaLOCKReserviert den Benutzernamen

Der eigene PK des Kontos ist eine generierte id (ACCT#a1f9c3) — niemals die E-Mail — sodass der Nutzer seine E-Mail später ändern kann, ohne den Primary Key umzuschreiben. Die Lock-Items tragen keine Profildaten; sie existieren nur, damit ihr Key belegt ist.

Schreibe alle drei atomar

TransactWriteItems wendet bis zu 100 Schreibvorgänge als eine Alles-oder-nichts-Einheit an. Sichere jeden Put mit attribute_not_exists(PK) ab, sodass er fehlschlägt, wenn dieser Key bereits vorhanden ist.

Wenn eine einzelne Bedingung fehlschlägt — der E-Mail-Lock, der Handle-Lock oder das Konto selbst — rollt DynamoDB die gesamte Transaktion zurück und wirft TransactionCanceledException. Keine halbe Anmeldung, kein verwaister Lock.

{
  "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)"
      }
    }
  ]
}

Die Bedingung ist der gesamte Mechanismus. Ohne attribute_not_exists überschreibt eine zweite Anmeldung mit derselben E-Mail stillschweigend den ersten Lock. Mit ihr weigert sich der Put, die Transaktion wird abgebrochen, und deine App zeigt „E-Mail bereits in Verwendung“ an.

Die ConditionExpression und die Value-Map von Hand zu bauen, ist der Ort, an dem sich Tippfehler einschleichen. Der DynamoDB Expression Builder gibt die Bedingung und das typisierte Item für jeden Put aus, sodass du eine korrekte Transaktion direkt in deinen SDK-Aufruf einfügen kannst.

Lies den Fehler, rate nicht daran herum

Wenn die Transaktion abgebrochen wird, gibt DynamoDB ein CancellationReasons-Array positionell zurück — ein Eintrag pro Item, in Request-Reihenfolge. Ein ConditionalCheckFailed in Slot 1 bedeutet, dass die E-Mail vergeben ist; Slot 2 bedeutet, dass es der Benutzername ist. Ordne den Slot einem präzisen Fehler auf Feldebene zu, statt einem generischen „Anmeldung fehlgeschlagen“.

Inspiziere die Locks in DynoTable

Die Marker-Items sind in der UI deiner App unsichtbar — sie sind Klempnerei. Wenn eine Anmeldung geheimnisvoll fehlschlägt, musst du sehen, ob der Lock tatsächlich existiert.

Öffne die Tabelle in DynoTable und mache eine Query auf das UNIQ#-Präfix. Das Konto und seine zwei Lock-Items sitzen zusammen, sodass eine steckengebliebene Anmeldung (ein Lock, der von einem verpatzten Delete zurückgelassen wurde) auf einen Blick offensichtlich ist.

DynoTable scannt die Tabelle — Konto-Items verschachtelt mit ihren UNIQ#EMAIL- und UNIQ#HANDLE-Lock-Items.
DynoTable scannt die Tabelle — Konto-Items verschachtelt mit ihren UNIQ#EMAIL- und UNIQ#HANDLE-Lock-Items.

Halte die Locks bei Änderung und Löschung ehrlich

Locks sind nicht write-once. Sie spiegeln den Live-Wert, sodass der Lebenszyklus sie synchron halten muss — jede Operation, die ein geschütztes Attribut berührt, ist ebenfalls eine Transaktion.

  • E-Mail ändern. Eine Transaktion: den neuen UNIQ#EMAIL#…-Lock mit attribute_not_exists schreiben, den alten Lock löschen, das Konto aktualisieren. Dieselbe Alles-oder-nichts-Garantie.
  • Konto löschen. Lösche das Konto-Item und beide Lock-Items in einer Transaktion, sonst strandest du einen Lock, der den Wert für immer blockiert.
  • Sicher wiederholen. Übergib ein ClientRequestToken, damit eine erneut gesendete Transaktion (nach einem Netzwerkaussetzer) idempotent statt ein Doppelschreiben ist.

Die Falle ist, den Lock als Fire-and-forget zu behandeln. Ein Lock, der bei der Anmeldung erstellt, aber bei der Konto-Löschung nie gelöscht wird, ist ein Wert, den nie jemand wiederverwenden kann — und er wird nicht auffallen, bis ein echter Nutzer seinen eigenen alten Handle nicht beanspruchen kann.

Nächste Schritte

Eindeutigkeits-Marker sind ein Single-Table-Muster, sodass sie natürlich neben deinen anderen Items sitzen — lies Single-Table-Design für das Key-Layout, und Query vs. Scan, damit du nie zu einem Scan greifst, um einen Lock zu prüfen. Das Muster wurde erstmals in AWS's Session re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions durchgegangen.

Entwirf die bedingungsgeschützten Puts mit dem DynamoDB Expression Builder, dann probiere DynoTable, um die Lock-Items gegen deine eigene Tabelle zu inspizieren.

Aktualisiert