Avanzado6 min de lectura

Unicidad en varios atributos de DynamoDB

DynamoDB garantiza la unicidad de exactamente una cosa: la . No hay restricción UNIQUE (email), ni UNIQUE (username), ni nada que abarque dos atributos. Viniendo de SQL, esa ausencia es la primera sorpresa — y el primer sitio donde la gente se lleva calladamente una condición de carrera a producción.

¿Cómo se impone una restricción de unicidad sobre varios atributos en DynamoDB?

DynamoDB no tiene ninguna restricción UNIQUE más allá de la , así que impones la unicidad tú mismo: modela cada valor protegido como su propio elemento marcador cuya clave es ese valor, luego escribe el registro y cada marcador juntos en un único TransactWriteItems, cada put protegido por attribute_not_exists. La colisión que el motor ya impone se convierte en tu restricción.

  • No hay restricción de unicidad — solo la clave primaria la impone el motor como única. Cualquier otro atributo que "debe ser único" es cosa tuya.
  • Modela cada regla de unicidad como su propio elemento. Un elemento marcador dedicado cuya clave es el valor que proteges convierte "¿está cogido este email?" en una colisión de clave que el motor ya impone.
  • Escríbelos atómicamente con TransactWriteItems. Una , cada put protegido por attribute_not_exists, de modo que todos los marcadores y el registro real se confirman juntos o ninguno lo hace.
  • No compruebes-y-luego-escribas. Una lectura antes de insertar es una condición de carrera de manual; dos registros concurrentes leen ambos "libre" y ambos escriben.

Por qué el enfoque obvio es incorrecto

El instinto es hacer Query (o peor, Scan) del email, no ver nada y luego PutItem de la nueva cuenta. Eso es una carrera comprobar-y-actuar.

Dos personas registran ada@lovelace.io en el mismo milisegundo. Ambas lecturas devuelven vacío. Ambas escrituras tienen éxito. Ahora tienes dos cuentas con un mismo email — y nada en la tabla lo señala.

Un sobre email tampoco te salva. Los GSI son eventualmente consistentes, así que la lectura que controla tu escritura puede estar obsoleta por diseño. El arreglo no es una comprobación más rápida; es hacer que la propia escritura se niegue a aterrizar sobre un valor cogido.

Modela cada restricción como un elemento marcador

El motor ya impone una regla de unicidad gratis: no puedes escribir dos elementos con la misma clave. Así que codifica cada regla de unicidad como una clave.

Junto al elemento de cuenta real, escribe un elemento marcador por cada atributo protegido. La clave de partición del marcador es el valor con espacio de nombres. Si el valor está cogido, la clave existe, y un put protegido no puede sobrescribirla.

Para un registro que debe mantener únicos tanto email como username, tres elementos se mueven juntos — con clave en un diseño de tabla única (ver diseño de tabla única):

ElementoPKSKPropósito
Registro de cuentaACCT#a1f9c3PROFILELa cuenta real
Bloqueo de emailUNIQ#EMAIL#ada@lovelace.ioLOCKReserva el email
Bloqueo de usuarioUNIQ#HANDLE#adaLOCKReserva el nombre de usuario

El propio PK de la cuenta es un id generado (ACCT#a1f9c3) — nunca el email — para que el usuario pueda cambiar su email más tarde sin reescribir la clave primaria. Los elementos de bloqueo no llevan datos de perfil; existen solo para que su clave esté ocupada.

Escribe los tres atómicamente

TransactWriteItems aplica hasta 100 escrituras como una unidad todo-o-nada. Protege cada put con attribute_not_exists(PK) para que falle si esa clave ya está presente.

Si falla cualquiera de las condiciones — el bloqueo de email, el bloqueo de nombre o la propia cuenta — DynamoDB revierte toda la transacción y lanza TransactionCanceledException. Ni registro parcial, ni bloqueo huérfano.

{
  "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 condición es todo el mecanismo. Sin attribute_not_exists, un segundo registro con el mismo email sobrescribe en silencio el primer bloqueo. Con ella, el put se niega, la transacción se cancela y tu app muestra "el email ya está en uso".

Construir la ConditionExpression y el mapa de valores a mano es donde se cuelan los errores de tecleo. El constructor de expresiones de DynamoDB emite la condición y el Item tipado de cada put para que pegues una transacción correcta directamente en tu llamada al SDK.

Lee el fallo, no lo adivines

Cuando la transacción se cancela, DynamoDB devuelve un array CancellationReasons posicionalmente — una entrada por elemento, en el orden de la petición. Un ConditionalCheckFailed en la posición 1 significa que el email está cogido; la posición 2 significa que lo está el nombre de usuario. Mapea la posición de vuelta a un error preciso, a nivel de campo, en lugar de un genérico "el registro falló".

Inspecciona los bloqueos en DynoTable

Los elementos marcador son invisibles en la UI de tu app — son fontanería. Cuando un registro falla misteriosamente, necesitas ver si el bloqueo existe de verdad.

Abre la tabla en DynoTable y haz Query del prefijo UNIQ#. La cuenta y sus dos elementos de bloqueo están juntos, así que un registro atascado (un bloqueo dejado atrás por un borrado fallido) es obvio de un vistazo.

DynoTable escaneando la tabla — elementos de cuenta intercalados con sus elementos de bloqueo UNIQ#EMAIL y UNIQ#HANDLE.
DynoTable escaneando la tabla — elementos de cuenta intercalados con sus elementos de bloqueo UNIQ#EMAIL y UNIQ#HANDLE.

Mantén los bloqueos honestos al cambiar y borrar

Los bloqueos no son de una sola escritura. Reflejan el valor vivo, así que el ciclo de vida tiene que mantenerlos sincronizados — cada operación que toca un atributo protegido es también una transacción.

  • Cambiar email. Una transacción: pon el nuevo bloqueo UNIQ#EMAIL#… con attribute_not_exists, borra el bloqueo antiguo, actualiza la cuenta. La misma garantía todo-o-nada.
  • Borrar cuenta. Borra el elemento de cuenta y ambos elementos de bloqueo en una transacción, o dejarás un bloqueo varado que bloquea el valor para siempre.
  • Reintenta con seguridad. Pasa un ClientRequestToken para que una transacción reenviada (tras un corte de red) sea idempotente en lugar de una doble escritura.

La trampa es tratar el bloqueo como algo de "poner y olvidar". Un bloqueo creado en el registro pero nunca borrado al eliminar la cuenta es un valor que nadie podrá reutilizar jamás — y no saldrá a la luz hasta que un usuario real no pueda reclamar su propio nombre de usuario antiguo.

Próximos pasos

Los marcadores de unicidad son un patrón de tabla única, así que conviven de forma natural junto a tus otros elementos — lee diseño de tabla única para el diseño de claves, y Query vs Scan para que nunca recurras a un Scan para comprobar un bloqueo. El patrón se recorrió por primera vez en la sesión de AWS re:Invent / AWS Summit 2018 DAT374 — DynamoDB Transactions.

Redacta los puts protegidos por condición con el constructor de expresiones de DynamoDB, luego prueba DynoTable para inspeccionar los elementos de bloqueo contra tu propia tabla.

Actualizado