Avançado6 min de leitura

Impondo unicidade em múltiplos atributos no DynamoDB

O DynamoDB garante unicidade para exatamente uma coisa: a chave primária. Não há restrição UNIQUE (email), nem UNIQUE (username), e nada que abranja dois atributos. Vindo do SQL, essa ausência é a primeira surpresa — e o primeiro lugar onde as pessoas silenciosamente lançam uma condição de corrida.

Como impor uma restrição de unicidade em múltiplos atributos no DynamoDB?

O DynamoDB não tem restrição UNIQUE além da chave primária, então você impõe a unicidade por conta própria: modele cada valor protegido como seu próprio item marcador cuja chave é esse valor, depois escreva o registro e todos os marcadores juntos em um único TransactWriteItems, cada put protegido por attribute_not_exists. A colisão que o engine já impõe torna-se a sua restrição.

  • Não existe restrição de unicidade — só a chave primária é imposta como única pelo engine. Todo outro atributo "precisa ser único" é trabalho seu.
  • Modele cada regra de unicidade como seu próprio item. Um item marcador dedicado cuja chave é o valor que você está protegendo transforma "esse email está em uso?" em uma colisão de chave que o engine já impõe.
  • Escreva-os atomicamente com TransactWriteItems. Uma transação, cada put protegido por attribute_not_exists, para que todos os marcadores e o registro real sejam commitados juntos ou nenhum seja.
  • Não verifique-depois-escreva. Uma leitura-antes-de-inserir é uma corrida de manual; dois cadastros concorrentes ambos leem "livre" e ambos escrevem.

Por que a abordagem óbvia está errada

O instinto é fazer Query (ou pior, Scan) pelo email, não ver nada, depois PutItem da nova conta. Isso é uma corrida verificar-depois-agir.

Duas pessoas registram ada@lovelace.io no mesmo milissegundo. Ambas as leituras retornam vazio. Ambas as escritas têm sucesso. Você agora tem duas contas em um email — e nada na tabela sinaliza isso.

Um GSI em email também não te salva. GSIs são eventualmente consistentes, então a leitura que controla sua escrita pode estar desatualizada por design. A correção não é uma verificação mais rápida; é fazer a própria escrita se recusar a cair em um valor já tomado.

Modele cada restrição como um item marcador

O engine já impõe uma regra de unicidade de graça: você não pode escrever dois itens com a mesma chave. Então codifique toda regra de unicidade como uma chave.

Ao lado do item de conta real, escreva um item marcador por atributo protegido. A chave de partição do marcador é o valor com namespace. Se o valor está tomado, a chave existe, e um put protegido não pode sobrescrevê-la.

Para um cadastro que precisa manter tanto email quanto username únicos, três itens se movem juntos — chaveados em um layout single-table (veja single-table design):

ItemPKSKPropósito
Registro da contaACCT#a1f9c3PROFILEA conta real
Trava de emailUNIQ#EMAIL#ada@lovelace.ioLOCKReserva o email
Trava de usernameUNIQ#HANDLE#adaLOCKReserva o username

O próprio PK da conta é um id gerado (ACCT#a1f9c3) — nunca o email — para que o usuário possa mudar seu email depois sem reescrever a chave primária. Os itens de trava não carregam dados de perfil; eles existem só para que sua chave esteja ocupada.

Escreva os três atomicamente

TransactWriteItems aplica até 100 escritas como uma unidade tudo-ou-nada. Proteja cada put com attribute_not_exists(PK) para que ele falhe se aquela chave já estiver presente.

Se qualquer condição falhar — a trava de email, a trava de handle ou a própria conta — o DynamoDB reverte a transação inteira e lança TransactionCanceledException. Sem cadastro parcial, sem trava órfã.

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

A condição é o mecanismo inteiro. Sem attribute_not_exists, um segundo cadastro com o mesmo email sobrescreve silenciosamente a primeira trava. Com ela, o put se recusa, a transação cancela, e seu app exibe "email já em uso".

Construir a ConditionExpression e o mapa de valores à mão é onde os erros de digitação se infiltram. O DynamoDB Expression Builder emite a condição e o Item tipado para cada put, para que você cole uma transação correta direto na sua chamada de SDK.

Leia a falha, não chute sobre ela

Quando a transação é cancelada, o DynamoDB retorna um array CancellationReasons posicionalmente — uma entrada por item, na ordem da requisição. Um ConditionalCheckFailed no slot 1 significa que o email está tomado; o slot 2 significa que o username está. Mapeie o slot de volta para um erro preciso, em nível de campo, em vez de um genérico "cadastro falhou".

Inspecione as travas no DynoTable

Os itens marcadores são invisíveis na UI do seu app — são encanamento. Quando um cadastro falha misteriosamente, você precisa ver se a trava de fato existe.

Abra a tabela no DynoTable e dê Query no prefixo UNIQ#. A conta e seus dois itens de trava ficam juntos, então um cadastro travado (uma trava deixada para trás por uma exclusão malfeita) fica óbvio à primeira vista.

DynoTable realizando um scan na tabela — itens de conta intercalados com seus itens de trava UNIQ#EMAIL e UNIQ#HANDLE.
DynoTable realizando um scan na tabela — itens de conta intercalados com seus itens de trava UNIQ#EMAIL e UNIQ#HANDLE.

Mantenha as travas honestas na mudança e exclusão

Travas não são escreva-uma-vez. Elas espelham o valor vivo, então o ciclo de vida tem que mantê-las em sincronia — toda operação que toca um atributo protegido também é uma transação.

  • Mudar email. Uma transação: put da nova trava UNIQ#EMAIL#… com attribute_not_exists, exclua a trava antiga, atualize a conta. Mesma garantia tudo-ou-nada.
  • Excluir conta. Exclua o item de conta e ambos os itens de trava em uma transação, ou você vai deixar encalhada uma trava que bloqueia o valor para sempre.
  • Repita com segurança. Passe um ClientRequestToken para que uma transação reenviada (após uma falha de rede) seja idempotente em vez de uma escrita dupla.

A cilada é tratar a trava como dispare-e-esqueça. Uma trava criada no cadastro mas nunca excluída na remoção da conta é um valor que ninguém jamais pode reutilizar — e isso não vai aparecer até um usuário real não conseguir reivindicar seu próprio handle antigo.

Próximos passos

Marcadores de unicidade são um padrão single-table, então ficam naturalmente ao lado dos seus outros itens — leia single-table design para o layout das chaves, e Query vs Scan para que você nunca recorra a um Scan para verificar uma trava. O padrão foi percorrido pela primeira vez na sessão re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions da AWS.

Rascunhe os puts protegidos por condição com o DynamoDB Expression Builder, depois experimente o DynoTable para inspecionar os itens de trava contra sua própria tabela.

Atualizado