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 durchattribute_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):
| Item | PK | SK | Zweck |
|---|---|---|---|
| Account-Datensatz | ACCT#a1f9c3 | PROFILE | Der echte Account |
| E-Mail-Lock | UNIQ#EMAIL#ada@lovelace.io | LOCK | Reserviert die E-Mail |
| Username-Lock | UNIQ#HANDLE#ada | LOCK | Reserviert 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.

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 mitattribute_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.


