Intermedio6 min de lectura

Contadores de referencias en DynamoDB

Un contador de referencias es un número que almacenas en un item padre y que rastrea cuántos items hijo apuntan a él — likes en un post, miembros en un workspace, respuestas en un comentario. Lo mantienes porque contar los hijos en cada lectura es demasiado caro.

¿Cómo se mantiene un contador en DynamoDB?

Almacena el total acumulado como un número en el item padre y actualízalo en la misma escritura que crea el hijo. Un hace que ambos aterricen o ninguno, y una condición en la escritura del hijo evita que los reintentos cuenten de más — así un solo GetItem devuelve un recuento exacto.

  • No cuentes los hijos en tiempo de lectura. Una Query para contar likes paga por cada item de like que escanea. Almacena el total en el post y lee un solo item en su lugar.
  • Mantén el contador donde se escribe el hijo, no después. Increméntalo en la misma operación que crea el hijo para que ambos nunca se desincronicen.
  • Usa una transacción cuando la escritura y el incremento tocan items distintos. Un like es un item, el contador vive en otro — TransactWriteItems hace que ambos aterricen o ninguno.
  • El footgun es contar de más. Un like reintentado o duplicado que vuelve a ejecutar el incremento infla el número. Protege la escritura del hijo con una condición.

Por qué contar siquiera

Viniendo de SQL, nunca almacenarías un contador de likes — harías SELECT COUNT(*) FROM likes WHERE post_id = ? y dejarías que un índice lo abaratara. DynamoDB no tiene un COUNT(*) que se salte la lectura de items.

Una Query sobre los likes de un post lee — y factura — cada item de like en esa partición, aunque solo quieras el número. En un post viral eso son miles de RCUs para responder "¿cuántos likes?". Ese es el footgun de lectura que los contadores de referencias existen para matar.

Así que desnormalizas: almacenas el total acumulado en el propio post. Leer el contador se convierte en un solo GetItem. El coste es que ahora te toca a ti mantenerlo exacto.

Modela los items

Dos tipos de item comparten una partición para que el post y sus likes estén en una colección de items. Claves inventadas:

Post item
PKSKattributes
POST#a91fMETAlikeTally (Number), body, authorId, createdAt
Like item
PKSKattributes
POST#a91fLIKE#USER#7c20likedAt

El atributo likeTally en el item META es el contador de referencias. Cada item LIKE# es un hijo. Poner ambos bajo PK = "POST#a91f" significa que una sola Query puede obtener el post y a quienes le dieron like juntos cuando sí quieres la lista.

Incrementa el contador atómicamente

DynamoDB incrementa un número con una expresión de update ADD (o SET x = x + :n) — esto es un contador atómico: DynamoDB aplica el delta del lado servidor sin que leas primero el valor actual, así que los incrementos concurrentes no se aplastan entre sí. (AWS: contadores atómicos)

El problema: dar like a un post son dos escrituras a dos items — crear el item LIKE#, y añadir 1 a likeTally en META. Si el like aterriza pero el incremento falla, el recuento está mal para siempre. Necesitas ambos o ninguno.

Eso es lo que garantiza TransactWriteItems — todo o nada a través de múltiples items, y cancela toda la transacción si algún item se modifica concurrentemente (AWS: bloqueo pesimista con transacciones):

{
  "TransactItems": [
    {
      "Put": {
        "TableName": "Social",
        "Item": {
          "PK": {"S": "POST#a91f"},
          "SK": {"S": "LIKE#USER#7c20"},
          "likedAt": {"N": "1750636800"}
        },
        "ConditionExpression": "attribute_not_exists(SK)"
      }
    },
    {
      "Update": {
        "TableName": "Social",
        "Key": {
          "PK": {"S": "POST#a91f"},
          "SK": {"S": "META"}
        },
        "UpdateExpression": "ADD likeTally :one",
        "ExpressionAttributeValues": {":one": {"N": "1"}}
      }
    }
  ]
}

El Put y el Update se confirman juntos. Si alguno falla, DynamoDB revierte ambos y devuelve una TransactionCanceledException.

Protégete del recuento doble

El bug real no es un like a medio escribir — la transacción evita eso. Es el mismo usuario dando like dos veces, o un reintento del cliente reproduciendo la petición. Cada repetición añade otro 1, y likeTally se desvía silenciosamente por encima del recuento real.

La ConditionExpression: attribute_not_exists(SK) en el Put es la protección. Si el item LIKE# de ese usuario ya existe, la condición del Put falla, toda la transacción se cancela y — críticamente — el ADD nunca se ejecuta. Un like por usuario, impuesto por la clave.

Construye y copia estas expresiones de update y condición — con los ExpressionAttributeValues correctos y la protección attribute_not_exists — en el constructor de expresiones de DynamoDB en lugar de ensamblar el JSON a mano.

El unlike, y el coste

Quitar un like es la imagen espejo: Delete del item LIKE# con ConditionExpression: attribute_exists(SK), y ADD likeTally :minusOne en la misma transacción. La condición impide que un doble unlike lleve el recuento a negativo.

Conoce el precio. Una escritura transaccional cuesta 2 WCUs por item para items de hasta 1 KB — una para preparar, una para confirmar — frente a 1 WCU de una escritura simple. Un like son dos items, así que cada like son aproximadamente cuatro WCUs. Barato por acción, pero vale la pena saberlo antes de que un post de celebridad reciba una tormenta de likes.

Míralo en DynoTable

Cuando sospechas que un recuento se ha desviado, quieres comparar el likeTally almacenado contra el número real de hijos LIKE# — sin ejecutar una query de recuento en producción.

El item META del post junto a sus hijos LIKE# en una colección de items, para que puedas comparar a ojo el recuento almacenado contra el recuento real de hijos.
El item META del post junto a sus hijos LIKE# en una colección de items, para que puedas comparar a ojo el recuento almacenado contra el recuento real de hijos.

Para una verdadera reconciliación a lo largo de un conjunto acotado de posts — "¿qué recuentos no coinciden con sus conteos de hijos?" — el SQL Workbench de DynoTable ejecuta el GROUP BY y el join en cliente sobre las filas que has cargado, algo que PartiQL plano no puede expresar.

Trampas y próximos pasos

  • No mantengas el contador fuera de banda (un Lambda que recuenta cada noche). Es un parche sobre un camino de escritura que debió ser transaccional desde el principio.
  • Vigila las particiones calientes. Un solo post salvajemente popular concentra cada like — y cada incremento de recuento — en una sola clave de partición. El recuento es correcto; la partición igual puede throttlear.
  • Reconcilia rara vez, repara con cirugía. La deriva debería ser casi cero si cada mutación está condicionada. Trata un desajuste como un bug a encontrar, no un número a sobrescribir.

Lectura relacionada: diseño de tabla única para entender por qué el post y los likes comparten partición, y Query vs Scan para entender por qué contar hijos en tiempo de lectura es el patrón que estás evitando.

Luego descarga DynoTable para inspeccionar estas colecciones de items y verificar tus recuentos contra tus propias tablas.

Actualizado