Avanzato6 min di lettura

Imporre l'unicità su più attributi in DynamoDB

DynamoDB garantisce l'unicità per una sola cosa: la . Non c'è alcun vincolo UNIQUE (email), nessun UNIQUE (username) e niente che copra due attributi. Provenendo da SQL, quell'assenza è la prima sorpresa — e il primo punto in cui le persone mettono in produzione, in silenzio, una race condition.

Come si impone un vincolo di unicità su più attributi in DynamoDB?

DynamoDB non ha alcun vincolo UNIQUE oltre alla , quindi imponi l'unicità da solo: modella ogni valore protetto come un proprio item marcatore la cui chiave è quel valore, poi scrivi il record e ogni marcatore insieme in un unico TransactWriteItems, ciascun put protetto da attribute_not_exists. La collisione che il motore già impone diventa il tuo vincolo.

  • Non esiste un vincolo di unicità — solo la chiave primaria è imposta come unica dal motore. Ogni altro attributo che "deve essere unico" è compito tuo.
  • Modella ogni regola di unicità come un item a sé. Un item marcatore dedicato la cui chiave è il valore che stai proteggendo trasforma "questa email è già presa?" in una collisione di chiave che il motore già impone.
  • Scrivili atomicamente con TransactWriteItems. Una sola , ciascun put protetto da attribute_not_exists, così tutti i marcatori e il record reale vengono committati insieme o nessuno.
  • Non fare check-then-write. Una lettura prima dell'inserimento è una race da manuale; due iscrizioni concorrenti leggono entrambe "libero" e scrivono entrambe.

Perché l'approccio ovvio è sbagliato

L'istinto è fare Query (o peggio, Scan) per l'email, non vedere nulla, poi fare PutItem del nuovo account. È una race di tipo check-then-act.

Due persone registrano ada@lovelace.io nello stesso millisecondo. Entrambe le letture restituiscono vuoto. Entrambe le scritture riescono. Ora hai due account su un'unica email — e niente nella tabella lo segnala.

Un su email non ti salva nemmeno. I GSI sono a coerenza finale, quindi la lettura che fa da guardia alla tua scrittura può essere stale per progetto. La soluzione non è un controllo più veloce; è fare in modo che sia la scrittura stessa a rifiutarsi di finire su un valore già preso.

Modella ogni vincolo come un item marcatore

Il motore già impone gratis una regola di unicità: non puoi scrivere due item con la stessa chiave. Quindi codifica ogni regola di unicità come una chiave.

Accanto all'item account reale, scrivi un item marcatore per ogni attributo protetto. La chiave di partizione del marcatore è il valore con namespace. Se il valore è preso, la chiave esiste, e un put protetto non può sovrascriverlo.

Per un'iscrizione che deve mantenere unici sia email sia username, tre item si muovono insieme — con le chiavi in un layout single-table (vedi single-table design):

ItemPKSKScopo
Record accountACCT#a1f9c3PROFILEL'account reale
Lock emailUNIQ#EMAIL#ada@lovelace.ioLOCKRiserva l'email
Lock usernameUNIQ#HANDLE#adaLOCKRiserva l'username

La PK dell'account è un id generato (ACCT#a1f9c3) — mai l'email — così l'utente può cambiare l'email in seguito senza riscrivere la chiave primaria. Gli item lock non portano dati di profilo; esistono solo perché la loro chiave sia occupata.

Scrivi tutti e tre atomicamente

TransactWriteItems applica fino a 100 scritture come un'unica unità tutto-o-niente. Proteggi ogni put con attribute_not_exists(PK) così fallisce se quella chiave è già presente.

Se anche una sola condizione fallisce — il lock dell'email, il lock dell'handle o l'account stesso — DynamoDB fa il rollback dell'intera transazione e lancia TransactionCanceledException. Nessuna iscrizione parziale, nessun lock orfano.

{
  "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 condizione è l'intero meccanismo. Senza attribute_not_exists, una seconda iscrizione con la stessa email sovrascrive silenziosamente il primo lock. Con essa, il put si rifiuta, la transazione si annulla e la tua app mostra "email già in uso".

Costruire a mano la ConditionExpression e la mappa dei valori è dove si insinuano i refusi. Il DynamoDB Expression Builder emette la condizione e l'Item tipizzato per ogni put così puoi incollare una transazione corretta direttamente nella tua chiamata SDK.

Leggi il fallimento, non tirare a indovinare

Quando la transazione viene annullata, DynamoDB restituisce un array CancellationReasons in modo posizionale — una voce per item, nell'ordine della richiesta. Un ConditionalCheckFailed nello slot 1 significa che l'email è presa; lo slot 2 significa che è l'username. Mappa lo slot di nuovo a un errore preciso, a livello di campo, invece di un generico "iscrizione fallita".

Ispeziona i lock in DynoTable

Gli item marcatore sono invisibili nell'interfaccia della tua app — sono impianto tecnico. Quando un'iscrizione fallisce misteriosamente, hai bisogno di vedere se il lock esiste davvero.

Apri la tabella in DynoTable e fai Query sul prefisso UNIQ#. L'account e i suoi due item lock stanno insieme, così un'iscrizione bloccata (un lock lasciato indietro da una delete andata storta) è ovvia a colpo d'occhio.

DynoTable mentre scansiona la tabella — gli item account intervallati con i loro item lock UNIQ#EMAIL e UNIQ#HANDLE.
DynoTable mentre scansiona la tabella — gli item account intervallati con i loro item lock UNIQ#EMAIL e UNIQ#HANDLE.

Mantieni i lock onesti su modifica ed eliminazione

I lock non sono write-once. Rispecchiano il valore attivo, quindi il ciclo di vita deve mantenerli sincronizzati — ogni operazione che tocca un attributo protetto è anche una transazione.

  • Cambio email. Una sola transazione: metti il nuovo lock UNIQ#EMAIL#… con attribute_not_exists, elimina il vecchio lock, aggiorna l'account. Stessa garanzia tutto-o-niente.
  • Elimina account. Elimina l'item account e entrambi gli item lock in una sola transazione, o abbandonerai un lock che blocca il valore per sempre.
  • Riprova in sicurezza. Passa un ClientRequestToken così una transazione reinviata (dopo un intoppo di rete) è idempotente invece di essere una doppia scrittura.

La trappola è trattare il lock come fire-and-forget. Un lock creato all'iscrizione ma mai eliminato alla rimozione dell'account è un valore che nessuno potrà mai riutilizzare — e non salterà fuori finché un utente reale non riuscirà a reclamare il suo vecchio handle.

Passaggi successivi

I marcatori di unicità sono un pattern single-table, quindi stanno naturalmente accanto ai tuoi altri item — leggi single-table design per il layout delle chiavi, e Query vs Scan così non ricorri mai a uno Scan per controllare un lock. Il pattern è stato illustrato per la prima volta nella sessione di AWS re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions.

Prepara i put protetti da condizioni con il DynamoDB Expression Builder, poi prova DynoTable per ispezionare gli item lock contro la tua tabella.

Aggiornato