Profi5 Min. Lesezeit

Eindeutigkeit auf mehreren Attributen in DynamoDB erzwingen

DynamoDB garantiert Eindeutigkeit für genau eine Sache: den Primary Key. Es gibt keine UNIQUE (email)-Constraint, kein UNIQUE (username) und nichts, das zwei Attribute überspannt. Aus SQL kommend ist diese Abwesenheit die erste Überraschung — und die erste Stelle, an der Leute still eine Race Condition ausschiffen.

Wie erzwingt man eine Unique-Constraint auf mehreren Attributen in DynamoDB?

DynamoDB kennt keine UNIQUE-Constraint jenseits des Primary Key, also erzwingst du Eindeutigkeit selbst: Modelliere jeden geschützten Wert als eigenes Marker-Item, dessen Key dieser Wert ist, und schreibe den Datensatz und alle Marker gemeinsam in einem TransactWriteItems, jeden Put mit attribute_not_exists abgesichert. Die Kollision, die die Engine ohnehin erzwingt, wird so zu deiner Constraint.

  • Es gibt keine Unique-Constraint — nur der Primary Key wird von der Engine als eindeutig erzwungen. Jedes andere „muss eindeutig sein"-Attribut ist dein Job.
  • 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 bereits erzwingt.
  • Schreibe sie atomar mit TransactWriteItems. Eine Transaktion, jeder Put abgesichert durch attribute_not_exists, sodass alle Marker und der echte Datensatz zusammen committen oder keiner.
  • Mach kein Check-then-Write. Ein Read-before-Insert ist eine Lehrbuch-Race-Condition; 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, dann den neuen Account per PutItem zu schreiben. Das ist eine Check-then-Act-Race.

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

Ein GSI auf email rettet dich auch nicht. GSIs sind letztendlich konsistent, also kann der Lesevorgang, der deinen Schreibvorgang absichert, von Natur aus veraltet sein. 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 jede Constraint als Marker-Item

Die Engine erzwingt bereits eine Eindeutigkeitsregel kostenlos: Du kannst nicht zwei Items mit demselben Key schreiben. Kodiere also jede Eindeutigkeitsregel als Key.

Schreibe neben dem echten Account-Item ein Marker-Item pro geschütztem Attribut. Der Partition Key des Markers ist der mit Namespace versehene 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
Account-DatensatzACCT#a1f9c3PROFILEDer echte Account
E-Mail-LockUNIQ#EMAIL#ada@lovelace.ioLOCKReserviert die E-Mail
Username-LockUNIQ#HANDLE#adaLOCKReserviert den Username

Der eigene PK des Accounts ist eine generierte ID (ACCT#a1f9c3) — niemals die E-Mail — sodass der User seine E-Mail später ändern kann, ohne den Primary Key neu zu schreiben. 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 einzige Bedingung scheitert — der E-Mail-Lock, der Handle-Lock oder der Account selbst — rollt DynamoDB die ganze Transaktion zurück und wirft eine TransactionCanceledException. Keine partielle 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 still den ersten Lock. Mit ihr weigert sich der Put, die Transaktion bricht ab, und deine App zeigt „E-Mail bereits in Verwendung."

Die ConditionExpression und die Wert-Map von Hand zu bauen ist, wo 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 ihn nicht

Wenn die Transaktion abgebrochen wird, gibt DynamoDB ein CancellationReasons-Array positionell zurück — ein Eintrag pro Item, in Anfragereihenfolge. Ein ConditionalCheckFailed in Slot 1 bedeutet, die E-Mail ist vergeben; Slot 2 bedeutet, der Username ist es. Mappe den Slot zurück auf einen präzisen Fehler auf Feldebene statt auf ein generisches „Anmeldung fehlgeschlagen."

Inspiziere die Locks in DynoTable

Die Marker-Items sind in der UI deiner App unsichtbar — sie sind Plumbing. Wenn eine Anmeldung mysteriös scheitert, musst du sehen, ob der Lock tatsächlich existiert.

Öffne die Tabelle in DynoTable und mach einen Query auf das UNIQ#-Präfix. Der Account und seine beiden Lock-Items sitzen zusammen, sodass eine festsitzende Anmeldung (ein Lock, den ein verpfuschter Delete hinterlassen hat) auf einen Blick offensichtlich ist.

DynoTable scannt die Tabelle — Account-Items durchmischt mit ihren UNIQ#EMAIL- und UNIQ#HANDLE-Lock-Items.
DynoTable scannt die Tabelle — Account-Items durchmischt 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 lebenden Wert, also muss der Lebenszyklus sie synchron halten — jede Operation, die ein geschütztes Attribut berührt, ist auch eine Transaktion.

  • E-Mail ändern. Eine Transaktion: Setze den neuen UNIQ#EMAIL#…-Lock mit attribute_not_exists, lösche den alten Lock, aktualisiere den Account. Dieselbe Alles-oder-nichts-Garantie.
  • Account löschen. Lösche das Account-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, sodass eine erneut gesendete Transaktion (nach einem Netzwerkausfall) idempotent ist statt ein Doppelschreibvorgang.

Die Falle ist, den Lock als Fire-and-forget zu behandeln. Ein Lock, der bei der Anmeldung erstellt, aber bei der Account-Entfernung nie gelöscht wird, ist ein Wert, den niemand je wiederverwenden kann — und er taucht nicht auf, bis ein echter User seinen eigenen alten Handle nicht beanspruchen kann.

Nächste Schritte

Eindeutigkeits-Marker sind ein Single-Table-Muster, also sitzen sie natürlich neben deinen anderen Items — 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 zuerst in AWS' re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions-Session durchgespielt.

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

Aktualisiert