Lab práctico · Semana 4: Bases de datos: RDS, Aurora, DynamoDB, ElastiCache y compañía

DynamoDB a fondo - claves, GSI, consumo de capacidad, transacciones, TTL, Streams y PITR

⏱ 90-120 minDificultad: mediaTask statements: 3.34.32.22.1

Qué vas a construir

Una tabla de pedidos on-demand con clave compuesta y un GSI, y sobre ella verás con tus propios ojos lo que el examen pregunta de DynamoDB:

  • Cuánta capacidad consume una lectura eventual frente a una fuerte, una Query frente a un Scan y una transacción.
  • Por qué un GSI no admite lecturas fuertemente consistentes.
  • Escrituras condicionales para evitar duplicados.
  • TTL para borrar datos caducados gratis, Streams para capturar cambios y PITR para recuperar la tabla.
  • Opcional: convertirla en global table con una réplica en eu-west-1.
flowchart LR
    C["CloudShell"] -->|"PutItem / Query / TransactWriteItems"| T["Tabla Lab08Pedidos (on-demand)"]
    T --> G["GSI estado-fecha-index"]
    T -->|"cambios"| S["DynamoDB Streams (24 h)"]
    T -->|"PITR"| R["Tabla restaurada"]
    T -.->|"opcional"| GT["Réplica global en eu-west-1"]

Antes de empezar

  • Usuario de IAM Identity Center o IAM con MFA. CloudShell en eu-south-2.
  • Modelo de datos: cliente (partition key), pedido (sort key con formato fecha#id), y el GSI con estado (partition key) y fecha (sort key) para buscar pedidos por estado.
  • Coste: DynamoDB on-demand cobra por petición y por GB almacenado; para unos pocos items son céntimos. PITR y los backups se cobran por GB (aquí, KB).

Paso 1: crear la tabla

export AWS_REGION=eu-south-2
T=Lab08Pedidos

aws dynamodb create-table --table-name $T \
  --attribute-definitions \
    AttributeName=cliente,AttributeType=S \
    AttributeName=pedido,AttributeType=S \
    AttributeName=estado,AttributeType=S \
    AttributeName=fecha,AttributeType=S \
  --key-schema AttributeName=cliente,KeyType=HASH AttributeName=pedido,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --global-secondary-indexes '[{"IndexName":"estado-fecha-index","KeySchema":[{"AttributeName":"estado","KeyType":"HASH"},{"AttributeName":"fecha","KeyType":"RANGE"}],"Projection":{"ProjectionType":"ALL"}}]' \
  --tags Key=proyecto,Value=lab08

aws dynamodb wait table-exists --table-name $T
aws dynamodb describe-table --table-name $T \
  --query 'Table.{Estado:TableStatus,Modo:BillingModeSummary.BillingMode,Clave:KeySchema,GSI:GlobalSecondaryIndexes[].IndexName}'

Solo declaras en attribute-definitions los atributos que forman claves (de la tabla o de índices). El resto del item es libre.

Paso 2: insertar datos

put() {
  aws dynamodb put-item --table-name $T --item "{
    \"cliente\": {\"S\": \"$1\"}, \"pedido\": {\"S\": \"$2#$3\"},
    \"estado\": {\"S\": \"$4\"}, \"fecha\": {\"S\": \"$2\"}, \"importe\": {\"N\": \"$5\"}}"
}
put c-001 2026-09-28 p-1001 ENVIADO 42.50
put c-001 2026-09-29 p-1002 PENDIENTE 19.90
put c-001 2026-09-30 p-1003 PENDIENTE 7.25
put c-002 2026-09-30 p-2001 PENDIENTE 120.00
put c-003 2026-09-27 p-3001 ENTREGADO 64.00

Paso 3: Query, consistencia y capacidad consumida

Todos los pedidos del cliente c-001 de septiembre, con lectura eventualmente consistente (la de por defecto):

aws dynamodb query --table-name $T \
  --key-condition-expression "cliente = :c AND begins_with(pedido, :m)" \
  --expression-attribute-values '{":c":{"S":"c-001"},":m":{"S":"2026-09"}}' \
  --return-consumed-capacity TOTAL \
  --query '{Items:Count,Capacidad:ConsumedCapacity.CapacityUnits}'

La misma consulta con consistencia fuerte:

aws dynamodb query --table-name $T \
  --key-condition-expression "cliente = :c AND begins_with(pedido, :m)" \
  --expression-attribute-values '{":c":{"S":"c-001"},":m":{"S":"2026-09"}}' \
  --consistent-read \
  --return-consumed-capacity TOTAL \
  --query '{Items:Count,Capacidad:ConsumedCapacity.CapacityUnits}'

Verás que la lectura fuerte consume el doble (por ejemplo, 0,5 frente a 1 unidad): 1 RCU = 1 lectura fuerte de hasta 4 KB = 2 lecturas eventuales.

Ahora un Scan filtrado que devuelve lo mismo:

aws dynamodb scan --table-name $T \
  --filter-expression "cliente = :c" \
  --expression-attribute-values '{":c":{"S":"c-001"}}' \
  --return-consumed-capacity TOTAL \
  --query '{Items:Count,Leidos:ScannedCount,Capacidad:ConsumedCapacity.CapacityUnits}'

ScannedCount es mayor que Count: el Scan lee toda la tabla y filtra después, y pagas por todo lo leído. Con millones de items, la diferencia es enorme.

Paso 4: consultar por el GSI

Pedidos pendientes ordenados por fecha:

aws dynamodb query --table-name $T --index-name estado-fecha-index \
  --key-condition-expression "estado = :e" \
  --expression-attribute-values '{":e":{"S":"PENDIENTE"}}' \
  --query 'Items[].{Cliente:cliente.S,Pedido:pedido.S,Fecha:fecha.S}' --output table

Intenta una lectura fuerte sobre el GSI:

aws dynamodb query --table-name $T --index-name estado-fecha-index \
  --key-condition-expression "estado = :e" \
  --expression-attribute-values '{":e":{"S":"PENDIENTE"}}' \
  --consistent-read

Obtendrás un ValidationException: los GSI solo admiten lecturas eventualmente consistentes. Si necesitaras consistencia fuerte con otra sort key, tendrías que haber creado un LSI al crear la tabla.

Paso 5: escritura condicional

Evita sobrescribir un pedido que ya existe:

aws dynamodb put-item --table-name $T \
  --item '{"cliente":{"S":"c-001"},"pedido":{"S":"2026-09-30#p-1003"},"estado":{"S":"PENDIENTE"},"fecha":{"S":"2026-09-30"},"importe":{"N":"999"}}' \
  --condition-expression "attribute_not_exists(pedido)"

Falla con ConditionalCheckFailedException: el importe original (7,25) queda intacto. Es la forma de implementar idempotencia sin bloqueos.

Paso 6: transacción

Crear un pedido nuevo y sumar 1 al contador de pedidos del cliente, todo o nada:

cat > /tmp/tx.json <<'EOF'
[
  {
    "Put": {
      "TableName": "Lab08Pedidos",
      "Item": {
        "cliente": {"S": "c-002"}, "pedido": {"S": "2026-09-30#p-2002"},
        "estado": {"S": "PENDIENTE"}, "fecha": {"S": "2026-09-30"}, "importe": {"N": "33.10"}
      },
      "ConditionExpression": "attribute_not_exists(pedido)"
    }
  },
  {
    "Update": {
      "TableName": "Lab08Pedidos",
      "Key": {"cliente": {"S": "c-002"}, "pedido": {"S": "PERFIL"}},
      "UpdateExpression": "ADD numPedidos :uno",
      "ExpressionAttributeValues": {":uno": {"N": "1"}}
    }
  }
]
EOF
aws dynamodb transact-write-items --transact-items file:///tmp/tx.json \
  --return-consumed-capacity TOTAL

Mira ConsumedCapacity: las escrituras transaccionales consumen el doble que las normales. Si ejecutas el comando otra vez, la condición del Put falla y tampoco se aplica el Update (atomicidad).

Paso 7: TTL

Activa TTL con el atributo expira (epoch en segundos) e inserta un item que caduca en un minuto:

aws dynamodb update-time-to-live --table-name $T \
  --time-to-live-specification "Enabled=true, AttributeName=expira"

EXPIRA=$(( $(date +%s) + 60 ))
aws dynamodb put-item --table-name $T --item "{
  \"cliente\": {\"S\": \"sesion\"}, \"pedido\": {\"S\": \"tok-abc\"},
  \"expira\": {\"N\": \"$EXPIRA\"}}"
aws dynamodb describe-time-to-live --table-name $T

El item no desaparecerá al minuto: DynamoDB borra los caducados normalmente en unos pocos días y sin consumir capacidad de escritura. Por eso, en las consultas, filtra por expira si no quieres ver caducados.

Paso 8: DynamoDB Streams

aws dynamodb update-table --table-name $T \
  --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES
aws dynamodb wait table-exists --table-name $T
SA=$(aws dynamodb describe-table --table-name $T --query Table.LatestStreamArn --output text)

aws dynamodb update-item --table-name $T \
  --key '{"cliente":{"S":"c-001"},"pedido":{"S":"2026-09-29#p-1002"}}' \
  --update-expression "SET estado = :e" \
  --expression-attribute-values '{":e":{"S":"ENVIADO"}}'

SHARD=$(aws dynamodbstreams describe-stream --stream-arn $SA \
  --query 'StreamDescription.Shards[0].ShardId' --output text)
IT=$(aws dynamodbstreams get-shard-iterator --stream-arn $SA --shard-id $SHARD \
  --shard-iterator-type TRIM_HORIZON --query ShardIterator --output text)
aws dynamodbstreams get-records --shard-iterator $IT \
  --query 'Records[].{Evento:eventName,Antes:dynamodb.OldImage.estado.S,Despues:dynamodb.NewImage.estado.S}'

Verás un evento MODIFY con el estado antes (PENDIENTE) y después (ENVIADO). En una arquitectura real, una Lambda leería el stream para, por ejemplo, avisar al cliente. Los registros se conservan 24 horas.

Paso 9: PITR y backup bajo demanda

aws dynamodb update-continuous-backups --table-name $T \
  --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
aws dynamodb describe-continuous-backups --table-name $T \
  --query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription'

BK=$(aws dynamodb create-backup --table-name $T --backup-name lab08-manual \
  --query BackupDetails.BackupArn --output text)

Restaura el estado más reciente en una tabla nueva (tarda unos minutos):

aws dynamodb restore-table-to-point-in-time \
  --source-table-name $T --target-table-name Lab08Restaurada \
  --use-latest-restorable-time
aws dynamodb wait table-exists --table-name Lab08Restaurada
aws dynamodb scan --table-name Lab08Restaurada --select COUNT

PITR permite volver a cualquier segundo del periodo configurado (de 1 a 35 días) y siempre restaura en una tabla nueva.

Paso 10 (opcional): global table con réplica en Irlanda

Con Streams ya activado con NEW_AND_OLD_IMAGES, añade una réplica:

aws dynamodb update-table --table-name $T \
  --replica-updates '[{"Create":{"RegionName":"eu-west-1"}}]'

until [ "$(aws dynamodb describe-table --table-name $T \
  --query "Table.Replicas[?RegionName=='eu-west-1'].ReplicaStatus | [0]" --output text)" = "ACTIVE" ]; do
  sleep 20; echo -n "."; done; echo

Escribe en Irlanda y lee en España:

aws dynamodb put-item --region eu-west-1 --table-name $T \
  --item '{"cliente":{"S":"c-900"},"pedido":{"S":"2026-09-30#p-9001"},"estado":{"S":"PENDIENTE"},"fecha":{"S":"2026-09-30"}}'
sleep 5
aws dynamodb get-item --region eu-south-2 --table-name $T \
  --key '{"cliente":{"S":"c-900"},"pedido":{"S":"2026-09-30#p-9001"}}'

Ambas regiones aceptan escrituras (multi-active). Con el modo por defecto (MREC), los conflictos se resuelven con last writer wins.

Comprueba que funciona

  • La lectura fuerte consumió el doble que la eventual.
  • El Scan leyó más items de los que devolvió.
  • La lectura fuerte sobre el GSI dio ValidationException.
  • La escritura condicional repetida falló y la transacción repetida no cambió nada.
  • El stream mostró el MODIFY con imagen antigua y nueva.
  • Lab08Restaurada tiene los mismos items que la original.

Limpieza

aws dynamodb update-table --table-name $T \
  --replica-updates '[{"Delete":{"RegionName":"eu-west-1"}}]' 2>/dev/null \
  && until [ "$(aws dynamodb describe-table --table-name $T \
       --query 'length(Table.Replicas || `[]`)')" = "0" ]; do sleep 20; echo -n "."; done; echo

aws dynamodb delete-backup --backup-arn $BK
aws dynamodb delete-table --table-name Lab08Restaurada
aws dynamodb delete-table --table-name $T
aws dynamodb wait table-not-exists --table-name $T
aws dynamodb list-tables
aws dynamodb list-tables --region eu-west-1
rm -f /tmp/tx.json

Si no hiciste el paso 10, el primer comando simplemente falla y sigue. Si delete-table se queja de que la tabla aún tiene réplicas, espera a que termine el borrado de la réplica y repite.

Preguntas para pensar como arquitecto

1. La tabla tiene picos de 50 veces el tráfico medio durante campañas imprevisibles. ¿On-demand o provisioned con auto scaling?

On-demand: escala al instante sin planificar capacidad y pagas por petición. El auto scaling de provisioned reacciona en minutos (dos minutos por encima del objetivo más el retardo de las alarmas) y un pico brusco produciría throttling. Provisioned (con reserved capacity o Database Savings Plans) compensa cuando el tráfico es estable y predecible.

2. Calcula las RCU provisionadas para 200 lecturas por segundo, fuertemente consistentes, de items de 6 KB. ¿Y si fueran eventuales?

Cada lectura de 6 KB redondea a 8 KB = 2 bloques de 4 KB → 2 RCU. 200 × 2 = 400 RCU. Con lecturas eventuales, la mitad: 200 RCU.

3. Las lecturas del producto estrella de una oferta relámpago saturan una partición. ¿Qué harías?

DAX delante de la tabla: absorbe las lecturas repetidas de la clave caliente con latencia de microsegundos y reduce el consumo de lectura, sin cambiar la lógica de la aplicación. A medio plazo, revisar el diseño de la partition key para repartir mejor la carga.

4. Debes borrar automáticamente las sesiones inactivas de más de 24 horas sin pagar escrituras por ello. ¿Cómo?

TTL: guarda en cada sesión un atributo con la hora de caducidad en epoch (segundos) y actívalo en la tabla. Los borrados no consumen capacidad de escritura. Como pueden tardar unos días, filtra por ese atributo en las lecturas.

5. ¿Qué te dan las global tables que no te da una réplica de lectura entre regiones de RDS?

Escrituras en todas las regiones (activo-activo), replicación gestionada sin promociones manuales y, en modo MRSC, incluso RPO cero con lecturas fuertes en cualquier región. Con RDS, la réplica entre regiones es de solo lectura y en un desastre hay que promocionarla manualmente.


Volver al módulo