Avanzado6 min de lectura

Imponer unicidad sobre múltiples atributos en DynamoDB

DynamoDB garantiza la unicidad de exactamente una cosa: la clave primaria. No hay restricción UNIQUE (email), ni UNIQUE (username), ni nada que abarque dos atributos. Si vienes de SQL, esa ausencia es la primera sorpresa — y el primer sitio donde la gente despliega en silencio una condición de carrera.

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

DynamoDB no tiene restricción UNIQUE más allá de la clave primaria, así que la unicidad la gestionas tú: modela cada valor protegido como su propio item marcador cuya clave es ese valor, luego escribe el registro y todos los marcadores juntos en un único TransactWriteItems, con 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 está forzada a ser única por el motor. Cualquier otro atributo "debe ser único" es tu trabajo.
  • Modela cada regla de unicidad como su propio item. Un item marcador dedicado cuya clave es el valor que proteges convierte "¿este email está ocupado?" en una colisión de clave que el motor ya impone.
  • Escríbelos atómicamente con TransactWriteItems. Una transacción, cada put protegido por attribute_not_exists, para que todos los marcadores y el registro real se confirmen juntos o ninguno lo haga.
  • No hagas check-then-write. Una lectura antes de insertar es una condición de carrera de manual; dos registros concurrentes ambos leen "libre" y ambos escriben.

Por qué el enfoque obvio está mal

El instinto es hacer un Query (o peor, un Scan) buscando el email, no ver nada, y luego PutItem la nueva cuenta. Eso es una carrera de check-then-act.

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 email — y nada en la tabla lo señala.

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

Modela cada restricción como un item marcador

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

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

Para un registro que debe mantener únicos tanto email como username, tres items se mueven juntos — con clave en una disposición de tabla única (ver single-table design):

ItemPKSKPropósito
Registro de cuentaACCT#a1f9c3PROFILELa cuenta real
Bloqueo de emailUNIQ#EMAIL#ada@lovelace.ioLOCKReserva el email
Bloqueo de usernameUNIQ#HANDLE#adaLOCKReserva el username

La propia PK de la cuenta es un id generado (ACCT#a1f9c3) — nunca el email — para que el usuario pueda cambiar su email después sin reescribir la clave primaria. Los items 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 de todo o nada. Protege cada put con attribute_not_exists(PK) para que falle si esa clave ya está presente.

Si alguna condición falla — el bloqueo de email, el bloqueo de handle o la cuenta misma — DynamoDB revierte la transacción entera y lanza TransactionCanceledException. Sin registro parcial, sin 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 "email ya en uso".

Lee el fallo, no lo adivines

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

Inspecciona los bloqueos en DynoTable

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

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

DynoTable escaneando la tabla — items de cuenta intercalados con sus items de bloqueo UNIQ#EMAIL y UNIQ#HANDLE.
DynoTable escaneando la tabla — items de cuenta intercalados con sus items 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 en 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. Misma garantía de todo o nada.
  • Borrar cuenta. Borra el item de cuenta y ambos items de bloqueo en una sola transacción, o dejarás varado un bloqueo que bloquea el valor para siempre.
  • Reintenta de forma segura. 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 fire-and-forget. Un bloqueo creado en el registro pero nunca borrado al eliminar la cuenta es un valor que nadie podrá reutilizar — y no aparecerá hasta que un usuario real no pueda reclamar su propio handle antiguo.

Próximos pasos

Los marcadores de unicidad son un patrón de tabla única, así que se sitúan de forma natural junto a tus otros items — lee single-table design para la disposición de claves, y Query vs Scan para que nunca recurras a un Scan para comprobar un bloqueo. El patrón se presentó 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 DynamoDB Expression Builder, luego prueba DynoTable para inspeccionar los items de bloqueo contra tu propia tabla.

Actualizado