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 perattribute_not_existsabgesichert, 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):
| Item | PK | SK | Zweck |
|---|---|---|---|
| Konto-Datensatz | ACCT#a1f9c3 | PROFILE | Das echte Konto |
| E-Mail-Lock | UNIQ#EMAIL#ada@lovelace.io | LOCK | Reserviert die E-Mail |
| Benutzername-Lock | UNIQ#HANDLE#ada | LOCK | Reserviert 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.

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 mitattribute_not_existsschreiben, 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.


