En 06-01 tomamos la decisión: el carrito y las sesiones salen de PostgreSQL. Los números que la justificaban eran contundentes —180.000 escrituras al día, el 92 % actualizaciones del mismo registro, acceso siempre por clave, cero consultas exploratorias y 78 GB de tabla de los que 55 GB son carritos abandonados de hace más de dos meses—. Esa tabla es la que dispara el VACUUM que más E/S consume de mercadofresco-pedidos.

Amazon DynamoDB es la base de datos clave-valor y documental gestionada de AWS. No hay servidores, ni versiones que actualizar, ni conexiones que agotar: hay una API HTTP y un modelo de datos que, si se usa bien, responde en menos de 10 milisegundos con independencia de si la tabla tiene mil elementos o mil millones. El «si se usa bien» es la lección entera: DynamoDB castiga sin piedad el diseño relacional y recompensa el diseño orientado al patrón de acceso. Aquí Luis modela mercadofresco-carritos desde las consultas que la aplicación hace de verdad, calcula la capacidad con las cifras del pico del viernes y activa el TTL que convierte los 55 GB de basura en un problema que ya no puede repetirse.

Aviso de coste. Las tablas bajo demanda no cuestan nada si no se usan, pero sí cuestan el almacenamiento (0,25 USD por GB y mes) y las copias PITR; las aprovisionadas cobran capacidad reservada aunque no llegue tráfico. Al terminar, borra las tablas de prueba. Datos ficticios.

Contenido

  1. Qué es DynamoDB y qué problema resuelve
  2. Modelo de datos: tabla, elemento, atributos y tipos
  3. Clave primaria: partición y ordenación
  4. Particiones, hash y claves calientes
  5. Diseñar desde las consultas, no desde las entidades
  6. Diseño de tabla única aplicado a mercadofresco-carritos
  7. Operaciones de escritura y expresiones de condición
  8. Lectura: GetItem, Query y por qué Scan casi siempre es un error
  9. Operaciones por lotes y transacciones
  10. Índices secundarios: GSI y LSI
  11. Capacidad, RCU y WCU con las cifras del viernes
  12. Ráfagas, estrangulamiento y reintentos con retroceso exponencial
  13. Consistencia eventual y consistencia fuerte
  14. TTL: el fin de los carritos zombis
  15. Streams, copias de seguridad, cifrado y tablas globales
  16. Acceso desde boto3 y desde Lambda
  17. Coste comparado con mantenerlo en RDS
  18. La migración, paso a paso
  19. Errores comunes y consejos
  20. Ejercicios
  21. Conclusión

Qué es DynamoDB y qué problema resuelve

DynamoDB es un servicio totalmente gestionado y sin servidor. No se elige un tamaño de instancia, no se aplica un parche, no se dimensiona el almacenamiento. Se crea una tabla y se escribe en ella. La diferencia estructural con RDS resume por qué encaja con el carrito:

RDS PostgreSQL DynamoDB
Unidad de escala Instancia (vertical) Particiones (horizontal, automática)
Conexiones Limitadas (200 en db.t3.small) Ninguna: peticiones HTTP firmadas
Latencia con el volumen Se degrada al crecer Constante por diseño
Consultas SQL arbitrario Solo por clave e índices definidos
Esquema Fijo Solo la clave primaria es obligatoria
Caducidad de datos Un proceso que hay que escribir TTL nativo y gratuito
Mantenimiento VACUUM, versiones, ventanas Ninguno

La fila de las conexiones importa más de lo que parece: la alarma mercadofresco-rds-conexiones-altas de 05-01 existe porque la instancia tiene un techo duro y las funciones Lambda lo rozan en el pico del viernes. DynamoDB no tiene ese concepto —cada operación es una llamada HTTP independiente autenticada con IAM—, así que mil Lambdas concurrentes no agotan nada.

Modelo de datos: tabla, elemento y atributos

Tres conceptos y ninguno más. Una tabla es una colección de elementos sin más esquema que la clave primaria. Un elemento (item) equivale a una fila y ocupa como máximo 400 KB, incluidos los nombres de atributos. Un atributo es una pareja nombre-valor, y dos elementos de la misma tabla pueden tener atributos completamente distintos.

La ausencia de esquema es libertad y es peligro. Nada impide guardar precio como número en un elemento y como cadena en otro; la validación es responsabilidad de la aplicación. En MercadoFresco esa validación vive en una única capa de acceso a datos que todo el código usa, precisamente para que no dependa de la disciplina de quien escribe cada endpoint.

Clave primaria: partición y ordenación

La clave primaria es la única estructura obligatoria y la decisión más difícil de revertir: para cambiarla hay que crear otra tabla y migrar.

Una clave simple es solo la clave de partición (PK, o hash key): identifica un elemento de forma única y se accede por igualdad exacta, nada más. Una clave compuesta añade la clave de ordenación (SK, o range key): todos los elementos con la misma PK viven juntos, físicamente ordenados por SK. Eso habilita la operación más potente de DynamoDB, recuperar de una sola vez un rango de elementos relacionados.

PK = CLIENTE#4471
  SK = CARRITO#2026-08-02T18:04:11Z
  SK = CARRITO#2026-07-28T09:12:40Z
  SK = PERFIL
  SK = SESION#a91f...

Una sola operación puede pedir «todos los elementos de CLIENTE#4471 cuya SK empiece por CARRITO#, ordenados de más reciente a más antiguo, los 5 primeros». Esa consulta es la que en PostgreSQL era un SELECT … WHERE id_cliente = ? ORDER BY fecha DESC LIMIT 5 con su índice; aquí es un acceso directo a un bloque contiguo de disco.

Tipos de datos

Categoría Tipos Notación Notas
Escalares Cadena, Número, Binario, Booleano, Nulo S, N, B, BOOL, NULL Números con 38 dígitos de precisión
Documentos Lista, Mapa L, M Anidables hasta 32 niveles
Conjuntos Cadenas, Números, Binarios SS, NS, BS Sin duplicados, sin orden

Dos avisos prácticos. No existe tipo fecha: se usan cadenas ISO 8601 (2026-08-02T18:04:11Z), que se ordenan alfabéticamente igual que cronológicamente, o números Unix cuando hay que compararlos. Y una cadena vacía se admite en atributos no clave, pero no en atributos clave: un id_sesion vacío provoca un error de validación.

Particiones, hash y claves calientes

DynamoDB reparte los datos aplicando una función hash al valor de la clave de partición. El resultado determina en qué partición física cae el elemento. Cada partición soporta como máximo 3.000 RCU y 1.000 WCU, y ese límite es el que explica casi todos los problemas de rendimiento reales.

graph LR
    A["PK = CLIENTE#4471"] -->|hash| P1[Partición 1]
    B["PK = CLIENTE#8802"] -->|hash| P2[Partición 2]
    C["PK = CLIENTE#1043"] -->|hash| P1
    D["PK = CLIENTE#9917"] -->|hash| P3[Partición 3]
    P1 --> L1["Cada partición: máx. 3.000 RCU / 1.000 WCU"]
    P2 --> L1
    P3 --> L1

Una clave caliente es un valor de PK que concentra un tráfico desproporcionado. La tabla entera puede tener capacidad de sobra y aun así estrangularse porque todo va a una partición.

Clave caliente Por qué falla Corrección
PK = FECHA#2026-08-02 para los pedidos del día Todo el tráfico del día en una partición PK = PEDIDO#<id> y un GSI para consultar por fecha
PK = ESTADO#pendiente Millones de elementos bajo un valor PK = PEDIDO#<id>, GSI disperso solo con los pendientes
PK = TIENDA#mercadofresco Una única PK para todo Repartir por cliente o producto
PK = PRODUCTO#<id> en un producto viral Un solo producto absorbe todo Repartir la escritura: PRODUCTO#<id>#<0-9> y leer las 10

En mercadofresco-carritos la PK es CLIENTE#<id>, con decenas de miles de valores distintos y tráfico repartido de forma natural. Es el caso fácil, y conviene decir que es fácil porque el patrón de acceso lo era: si el acceso hubiera sido «dame todos los carritos abandonados hoy», el diseño sería otro.

Diseñar desde las consultas, no desde las entidades

El procedimiento correcto tiene tres pasos y ninguno es dibujar entidades.

Paso 1: enumerar todas las consultas, extraídas de los registros reales de la aplicación:

# Consulta Frecuencia diaria
C1 Obtener el carrito activo de un cliente 210.000
C2 Añadir, cambiar cantidad o quitar una línea 180.000
C3 Obtener la sesión web por su identificador 340.000
C4 Listar los últimos 5 carritos de un cliente (atención al cliente) 400
C5 Marcar un carrito como convertido en pedido 4.000
C6 Borrar carritos abandonados de más de 30 días Continuo

Paso 2: para cada consulta, decidir cómo se resuelve. La regla es que toda consulta frecuente debe resolverse con GetItem o Query sobre la tabla o sobre un índice. Si alguna exige Scan, el modelo está mal. Paso 3: diseñar la clave para que eso se cumpla. Y solo entonces, mirar qué entidades han quedado.

Diseño de tabla única aplicado a mercadofresco-carritos

El diseño de tabla única (single-table design) consiste en guardar varios tipos de entidad en la misma tabla, distinguiéndolos por prefijos en la clave. Suena raro viniendo de SQL y responde a una razón muy concreta: en DynamoDB no hay JOIN, así que la forma de recuperar entidades relacionadas en una sola operación es que compartan clave de partición.

Entidad PK SK Atributos
Carrito CLIENTE#<id> CARRITO#<ts_iso> lineas (L de M), importe, estado, expira_en
Sesión CLIENTE#<id> SESION#<id_sesion> ip, agente, ultima_actividad, expira_en
Perfil ligero CLIENTE#<id> PERFIL ciudad, franja_reparto, alergenos
graph TD
    subgraph "Partición CLIENTE#4471"
        P["SK = PERFIL · Valencia · franja 18-21h"]
        C1["SK = CARRITO#2026-08-02T18:04:11Z · activo · 34,20 EUR"]
        C2["SK = CARRITO#2026-07-28T09:12:40Z · convertido · 51,90 EUR"]
        S1["SK = SESION#a91f3c · ultima_actividad 18:06:02"]
    end
    Q1["C1 y C4: Query PK + SK begins_with CARRITO#<br/>ScanIndexForward false, Limit 1 o 5"] --> C1
    Q2["C3: GetItem con PK y SK exactas"] --> S1

Con este diseño, una sola Query sobre CLIENTE#4471 devuelve el perfil, el carrito activo y las sesiones —todo lo que la tienda necesita para pintar la cabecera— en una operación y unos pocos milisegundos. En PostgreSQL eso eran tres consultas a tres tablas.

Cuándo el diseño de tabla única no compensa: cuando las entidades no se consultan juntas nunca, cuando tienen ciclos de vida y patrones de capacidad muy distintos, o cuando el equipo aún no domina el modelo. Meter pedidos y catálogo en la misma tabla «porque es la práctica recomendada» es cargar con la complejidad sin cobrar el beneficio. Aquí se justifica porque C1, C3 y C4 comparten id_cliente.

Operaciones de escritura: PutItem, UpdateItem, DeleteItem

import boto3
from datetime import datetime, timezone, timedelta
from decimal import Decimal

sesion = boto3.Session(profile_name="mercadofresco-dev", region_name="eu-west-1")
tabla = sesion.resource("dynamodb").Table("mercadofresco-carritos")

ahora = datetime.now(timezone.utc)
ts = ahora.strftime("%Y-%m-%dT%H:%M:%SZ")

# PutItem: crea el elemento o SUSTITUYE por completo el que tenga esa clave.
# Ese "sustituye por completo" es la fuente número uno de pérdidas de datos:
# los atributos que no se envían desaparecen.
tabla.put_item(Item={
    "PK": "CLIENTE#4471",
    "SK": f"CARRITO#{ts}",
    "estado": "activo",
    "importe": Decimal("0.00"),
    "lineas": [],
    # expira_en es un número Unix en segundos: es lo que el TTL sabe leer.
    "expira_en": int((ahora + timedelta(days=30)).timestamp()),
})

UpdateItem es la operación que hay que usar el 90 % de las veces: modifica atributos concretos sin leer antes el elemento y es atómica, de modo que dos peticiones concurrentes que incrementen un contador dan el resultado correcto sin bloqueos.

# Añadir una línea al carrito y sumar el importe, en una sola operación atómica.
respuesta = tabla.update_item(
    Key={"PK": "CLIENTE#4471", "SK": f"CARRITO#{ts}"},
    UpdateExpression=(
        "SET #lineas = list_append(if_not_exists(#lineas, :vacia), :linea), "
        "    #actualizado = :ahora ADD #importe :precio"
    ),
    # Los nombres van por alias porque muchas palabras son reservadas en DynamoDB
    # ("estado", "name", "size"...). Usar alias siempre evita sorpresas.
    ExpressionAttributeNames={
        "#lineas": "lineas", "#importe": "importe", "#actualizado": "actualizado_en"},
    ExpressionAttributeValues={
        ":vacia": [],
        ":linea": [{"sku": "PESC-SALM-001", "uds": 2, "precio": Decimal("12.40")}],
        ":precio": Decimal("24.80"),
        ":ahora": ts},
    ReturnValues="ALL_NEW",  # devuelve el elemento resultante, sin lectura extra
)

Los cuatro verbos: SET asigna o crea (SET estado = :s), ADD suma a un número o añade a un conjunto (ADD importe :p), REMOVE elimina un atributo o un elemento de lista (REMOVE lineas[2]) y DELETE quita elementos de un conjunto (DELETE etiquetas :e).

DeleteItem borra por clave. En mercadofresco-carritos casi no se usa: los carritos los borra el TTL y los convertidos se marcan, no se eliminan.

Expresiones de condición y escrituras condicionales

Una expresión de condición hace que la escritura solo se aplique si se cumple; si no, la operación falla con ConditionalCheckFailedException. Es el mecanismo de bloqueo optimista de DynamoDB y sustituye a los SELECT … FOR UPDATE del mundo relacional.

from botocore.exceptions import ClientError

try:
    # Marcar el carrito como convertido SOLO si sigue activo. Evita que dos
    # pulsaciones del botón "Pagar" generen dos pedidos del mismo carrito.
    tabla.update_item(
        Key={"PK": "CLIENTE#4471", "SK": f"CARRITO#{ts}"},
        UpdateExpression="SET #e = :nuevo, id_pedido = :pedido",
        ConditionExpression="attribute_exists(PK) AND #e = :esperado",
        ExpressionAttributeNames={"#e": "estado"},
        ExpressionAttributeValues={
            ":nuevo": "convertido", ":esperado": "activo", ":pedido": "PED-84213"},
    )
except ClientError as e:
    if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
        # No es un error de sistema: es el resultado correcto de una carrera.
        print("El carrito ya se había convertido; no se duplica el pedido.")
    else:
        raise

Condiciones habituales: attribute_exists(PK) para «actualizar solo si existe», attribute_not_exists(PK) para «crear solo si no existe» —el patrón de idempotencia que se retoma en 07-05—, comparadores (<, >, BETWEEN) y size().

Lectura: GetItem, Query y por qué Scan casi siempre es un error

from boto3.dynamodb.conditions import Key, Attr

# GetItem: un elemento por su clave completa. La operación más barata y rápida.
sesion_web = tabla.get_item(
    Key={"PK": "CLIENTE#4471", "SK": "SESION#a91f3c"},
    ConsistentRead=False,  # eventual: la mitad de coste, suficiente aquí
).get("Item")

# Query: elementos con la MISMA clave de partición, filtrando por la de ordenación.
# Esta es C1: el carrito activo más reciente del cliente.
resultado = tabla.query(
    KeyConditionExpression=Key("PK").eq("CLIENTE#4471") & Key("SK").begins_with("CARRITO#"),
    FilterExpression=Attr("estado").eq("activo"),
    ScanIndexForward=False,  # descendente: la SK con timestamp da el más reciente primero
    Limit=1,
)
carrito = resultado["Items"][0] if resultado["Items"] else None

La diferencia entre ambas expresiones es la que más dinero cuesta cuando no se entiende: KeyConditionExpression actúa antes de leer —elige qué se lee, solo sobre PK y SK, y cobra solo lo que devuelve—, mientras que FilterExpression actúa después —descarta resultados, admite cualquier atributo, y cobra todo lo leído se devuelva o no—. Filtrar por estado cuando la partición tiene 3 carritos es irrelevante; hacerlo sobre una partición con 50.000 elementos significa pagar 50.000 lecturas para devolver 1.

Scan lee la tabla entera y luego aplica el filtro. Con 78 GB migrados, un Scan son unos 10 millones de RCU: más de 1,25 USD por ejecución y varios minutos. Y lo peor no es el coste: es que consume la capacidad que necesitan las operaciones reales, estrangulando la tienda.

Scan es aceptable en tres casos: tablas pequeñas y estables (unos pocos miles de elementos), procesos por lotes fuera de hora con Segment/TotalSegments para paralelizar, y exportaciones puntuales. Cualquier otro uso indica que falta un índice o que la clave está mal diseñada.

Operaciones por lotes y transacciones

Operación Límite Atómica Coste Uso en MercadoFresco
BatchGetItem 100 elementos / 16 MB No Normal Cargar varios perfiles a la vez
BatchWriteItem 25 elementos / 16 MB No Normal Carga inicial de la migración
TransactWriteItems 100 elementos / 4 MB ×2 Convertir carrito y marcar sesión
TransactGetItems 100 elementos / 4 MB ×2 Lectura coherente de varios elementos

Los lotes no son atómicos: si 3 de 25 escrituras fallan, las otras 22 se aplican y las fallidas vuelven en UnprocessedItems, que hay que reintentar porque el SDK no lo hace solo.

cliente = sesion.client("dynamodb")

# TransactWriteItems: todo o nada. Convertir el carrito y cerrar la sesión deben
# ocurrir juntos. Cuesta el doble de capacidad; se usa solo cuando hace falta.
cliente.transact_write_items(TransactItems=[
    {"Update": {
        "TableName": "mercadofresco-carritos",
        "Key": {"PK": {"S": "CLIENTE#4471"}, "SK": {"S": f"CARRITO#{ts}"}},
        "UpdateExpression": "SET #e = :c",
        "ConditionExpression": "#e = :a",          # la condición aborta TODA la transacción
        "ExpressionAttributeNames": {"#e": "estado"},
        "ExpressionAttributeValues": {":c": {"S": "convertido"}, ":a": {"S": "activo"}},
    }},
    {"Update": {
        "TableName": "mercadofresco-carritos",
        "Key": {"PK": {"S": "CLIENTE#4471"}, "SK": {"S": "SESION#a91f3c"}},
        "UpdateExpression": "REMOVE carrito_activo",
    }},
])

Límite conceptual importante: una transacción de DynamoDB no admite lógica intermedia. No se puede leer, decidir en el código y escribir dentro de la misma transacción. Por eso en 06-01 se descartó DynamoDB para el flujo de confirmación de pedido.

Índices secundarios: GSI y LSI

Un índice secundario permite consultar por atributos que no son la clave primaria:

GSI (global) LSI (local)
PK del índice Cualquier atributo La misma que la tabla
SK del índice Cualquier atributo Otro atributo
Cuándo se crea En cualquier momento Solo al crear la tabla
Máximo 20 (ampliable) 5
Capacidad Propia e independiente Compartida con la tabla
Consistencia Solo eventual Eventual o fuerte
Límite de partición Ninguno 10 GB por PK

La regla práctica: usa GSI salvo que necesites lectura fuertemente consistente por un atributo alternativo. Los LSI se deciden el primer día para siempre y su límite de 10 GB por partición es una trampa silenciosa. mercadofresco-carritos necesita un GSI para C6 y para atención al cliente:

# GSI "gsi-estado-fecha": lista carritos por estado y rango de fechas sin
# recorrer la tabla. Con capacidad bajo demanda no hay que dimensionarlo.
aws dynamodb update-table --table-name mercadofresco-carritos \
  --attribute-definitions AttributeName=estado,AttributeType=S \
                          AttributeName=actualizado_en,AttributeType=S \
  --global-secondary-index-updates '[{"Create": {
      "IndexName": "gsi-estado-fecha",
      "KeySchema": [{"AttributeName": "estado", "KeyType": "HASH"},
                    {"AttributeName": "actualizado_en", "KeyType": "RANGE"}],
      "Projection": {"ProjectionType": "INCLUDE",
                     "NonKeyAttributes": ["PK", "importe"]}}}]' \
  --region eu-west-1 --profile mercadofresco-dev

Proyecciones: KEYS_ONLY copia solo las claves (mínimo almacenamiento, sirve cuando basta con localizar y luego leer), INCLUDE copia las claves más los atributos elegidos (el caso habitual) y ALL copia el elemento entero, duplicando la tabla. Un GSI con ALL sobre 78 GB añade 78 GB y consume WCU en cada escritura de la tabla base; elegir INCLUDE con los tres atributos que se pintan en pantalla suele reducir ese coste en un 80 %.

Cuidado con la clave caliente del GSI: estado tiene pocos valores distintos, así que gsi-estado-fecha concentra tráfico. Sirve para procesos por lotes de baja frecuencia; no debe usarse en el camino crítico de la tienda.

Capacidad: bajo demanda frente a aprovisionada

Bajo demanda Aprovisionada
Se paga Por petición Por capacidad reservada por hora
Escala Instantánea Con autoescalado, en minutos
Precio unitario ~6,9× más caro por operación Más barato si se aprovecha
Riesgo Factura sorpresa Estrangulamiento
Cuándo Tráfico irregular o desconocido Tráfico predecible y sostenido

Se puede cambiar de modo, pero solo una vez cada 24 horas, así que no es una palanca para el pico del viernes. Para MercadoFresco la recomendación es empezar bajo demanda durante la migración y los dos primeros meses: no conocemos el tráfico real de la tabla nueva y el coste de equivocarse con capacidad aprovisionada es estrangular el carrito un viernes. Con dos meses de métricas, se recalcula.

RCU y WCU con las cifras del viernes

Las unidades de capacidad son la moneda de DynamoDB. 1 WCU es una escritura por segundo de hasta 1 KB (una de 3,5 KB cuesta 4 WCU). 1 RCU es una lectura fuertemente consistente por segundo de hasta 4 KB, o dos lecturas eventuales (una lectura eventual de 7 KB cuesta 1 RCU). Las operaciones transaccionales, tanto de lectura como de escritura, cuestan el doble.

Cálculo para el pico del viernes, 17:00-21:00, con 900 pedidos/hora:

Operación Peticiones/s en pico Tamaño medio Consistencia Unidades
C2 Actualizar carrito 12 3 KB 12 × 3 = 36 WCU
C5 Convertir (transaccional) 0,25 3 KB 0,25 × 3 × 2 = 1,5 WCU
Escribir/refrescar sesión 20 1 KB 20 WCU
C1 Leer carrito 24 3 KB eventual 24 × (4÷4) ÷ 2 = 12 RCU
C3 Leer sesión 40 1 KB eventual 40 × 1 ÷ 2 = 20 RCU
C4 Atención al cliente 0,1 15 KB eventual 1 RCU

Totales del pico: ≈58 WCU y ≈33 RCU. Con capacidad aprovisionada y autoescalado al 70 % de utilización, se dimensionaría a unos 85 WCU y 50 RCU en la ventana del viernes, bajando a 15 WCU y 10 RCU de madrugada. La conclusión que conviene interiorizar: la carga que asfixiaba a db.t3.small cabe en menos de 100 unidades de capacidad. El problema nunca fue el volumen; fue que esas escrituras compartían motor, memoria y VACUUM con las transacciones de pedidos.

Ráfagas, estrangulamiento y reintentos con retroceso exponencial

DynamoDB acumula capacidad de ráfaga: la no usada de los últimos 300 segundos se guarda y puede consumirse de golpe. Es un colchón útil y no se debe diseñar contando con él: no está garantizado.

Cuando se supera la capacidad disponible, la operación se rechaza con ProvisionedThroughputExceededException (o ThrottlingException). Causas, en orden de frecuencia real: clave caliente, capacidad aprovisionada insuficiente, un GSI con menos capacidad que la tabla base, y crecimiento más rápido de lo que el autoescalado reacciona.

from botocore.config import Config

# El SDK reintenta solo, pero por defecto de forma conservadora. El modo
# "adaptive" ajusta el ritmo de envío al estrangulamiento observado, que es
# justo lo que se quiere en una tabla compartida por muchas Lambdas.
config = Config(
    retries={"max_attempts": 10, "mode": "adaptive"},
    connect_timeout=1, read_timeout=3,   # fallar rápido: una Lambda no debe colgarse
)
tabla = sesion.resource("dynamodb", config=config).Table("mercadofresco-carritos")

El retroceso exponencial con fluctuación (jitter) es imprescindible: sin la parte aleatoria, todos los clientes reintentan a la vez y reproducen la ráfaga que causó el problema.

Vigilancia mínima en CloudWatch hacia alertas-mercadofresco: ThrottledRequests > 0 en 5 minutos, subidas súbitas de UserErrors (casi siempre un despliegue con un fallo), SuccessfulRequestLatency p99 > 25 ms y ConsumedWriteCapacityUnits por encima del 80 % de lo aprovisionado tanto en la tabla como en cada índice.

Consistencia eventual y consistencia fuerte

Cada escritura se replica en tres AZ. Una lectura eventual puede alcanzar una réplica que aún no tiene el último cambio; el desfase típico es de menos de un segundo. La eventual es la de por defecto y cuesta 0,5 RCU por 4 KB; la fuerte (ConsistentRead=True) cuesta 1 RCU, tiene algo más de latencia y no está disponible en los GSI.

Aplicado a MercadoFresco, es la regla de 06-01 —eventual para mostrar, fuerte para decidir— hecha código: pintar la cabecera con el número de artículos va eventual, pero leer el carrito justo antes de convertirlo en pedido va fuerte, porque de esa lectura depende lo que se cobra.

TTL: el fin de los carritos zombis

Esta es la funcionalidad que por sí sola justifica la migración. El TTL (Time To Live) borra automáticamente los elementos cuyo atributo de caducidad —un número Unix en segundos— ya pasó, y el borrado es gratuito: no consume WCU.

aws dynamodb update-time-to-live --table-name mercadofresco-carritos \
  --time-to-live-specification "Enabled=true, AttributeName=expira_en" \
  --region eu-west-1 --profile mercadofresco-dev

Cuatro cosas que hay que saber para no llevarse un susto:

  1. El borrado no es inmediato. DynamoDB elimina en un plazo típico de 48 horas. Si la aplicación no debe ver lo caducado, hay que filtrarlo también en la lectura.
  2. El atributo debe ser un número en segundos. Milisegundos, o una cadena ISO, hacen que el TTL ignore el elemento silenciosamente. Es el fallo más común.
  3. Los borrados aparecen en Streams con userIdentity de tipo Service, lo que permite archivar el carrito abandonado antes de que desaparezca.
  4. No sustituye a una política de retención documentada. Bajo el RGPD hay que poder explicar cuánto se conservan los datos y por qué; el TTL implementa la política, no la define.

Frente a lo que hay hoy —un DELETE nocturno programado que cuesta E/S, bloqueos y VACUUM, del que nadie se entera cuando falla y que ha dejado crecer la tabla hasta 78 GB—, el TTL es gratuito, no puede fallar en silencio y mantiene el tamaño estable por diseño.

Streams: reaccionar a los cambios

DynamoDB Streams publica un registro ordenado de cada modificación, con retención de 24 horas y cuatro vistas posibles: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE y NEW_AND_OLD_IMAGES. Una función Lambda se suscribe al stream y reacciona a cada cambio.

Usos previstos: archivar en mercadofresco-informes-analitica los carritos que el TTL va a borrar —Sara quiere analizar el abandono en 06-04— y alimentar métricas de negocio. La orquestación de eventos a mayor escala, con enrutamiento y varios consumidores desacoplados, es EventBridge y se ve en 07-03; Streams es el mecanismo de bajo nivel ligado a esta tabla.

Copias de seguridad, cifrado y tablas globales

Copias de seguridad. PITR restaura a cualquier segundo de los últimos 35 días, se activa con aws dynamodb update-continuous-backups --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true y restaura siempre a una tabla nueva: no sobrescribe.

Cifrado. Siempre activo en reposo, sin opción de desactivarlo. Con la clave gestionada por el cliente alias/mercadofresco-datos de 04-02 se consigue lo que exige el criterio de MercadoFresco: control de la rotación, registro de cada uso en trail-mercadofresco y capacidad de revocar el acceso a los datos revocando permisos sobre la clave. Las tablas globales, por último, replican activo-activo entre regiones con resolución de conflictos por «el último que escribe gana»; MercadoFresco opera solo en eu-west-1 y no las necesita, pero son la equivalencia de Aurora Global Database (06-03) si algún día hay operación en Francia.

Acceso desde boto3 y desde Lambda

resource convierte tipos automáticamente (DecimalN) y es la interfaz para la lógica de negocio; client expone la API cruda con el descriptor explícito ({"S": "..."}) y es la única que cubre transacciones y administración. Aviso: resource exige Decimal y rechaza float con Float types are not supported. Es una molestia deliberada: los importes en coma flotante son un error en cualquier sistema que maneje dinero.

Política de mínimo privilegio para la Lambda mercadofresco-estado-pedido:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "OperacionesDelCarrito",
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:Query",
                 "dynamodb:PutItem", "dynamodb:UpdateItem"],
      "Resource": [
        "arn:aws:dynamodb:eu-west-1:111122223333:table/mercadofresco-carritos",
        "arn:aws:dynamodb:eu-west-1:111122223333:table/mercadofresco-carritos/index/*"
      ],
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": ["${aws:PrincipalTag/cliente}"]
        }
      }
    },
    {
      "Sid": "SinScanNiBorradoNiAdministracion",
      "Effect": "Deny",
      "Action": ["dynamodb:Scan", "dynamodb:DeleteTable", "dynamodb:DeleteItem"],
      "Resource": "*"
    }
  ]
}

Tres decisiones deliberadas. dynamodb:LeadingKeys limita las operaciones a las claves de partición que coinciden con una etiqueta del principal: es aislamiento por cliente a nivel de IAM, no de código. El Deny de Scan no es paranoia, es economía: impide que un despliegue apresurado meta un Scan en el camino crítico. Y el Deny de DeleteItem obliga a que el borrado sea siempre el TTL.

Limpieza. Si has creado tablas para practicar, bórralas con aws dynamodb delete-table. Una tabla bajo demanda vacía cuesta céntimos, pero una de pruebas con PITR activo y unos GB cargados sí aparece en la factura.

Coste comparado con mantenerlo en RDS

Cifras mensuales estimadas para eu-west-1, con la carga descrita:

Concepto Hoy en mercadofresco-pedidos DynamoDB bajo demanda DynamoDB aprovisionada
Escrituras (5,4 M/mes) Incluido 4,05 USD
Lectura (17,4 M/mes) Incluido 0,44 USD
Capacidad reservada ~9,20 USD (85 WCU / 50 RCU)
Almacenamiento 78 GB × 0,127 = 9,91 USD 8 GB × 0,25 = 2,00 USD 2,00 USD
PITR Incluido en RDS 1,60 USD 1,60 USD
Parte de la instancia atribuible ~35 % de 118 USD = 41,30 USD 0 0
Total ≈51,21 USD ≈8,09 USD ≈12,80 USD

Dos observaciones. Primero, 8 GB frente a 78 GB: el TTL elimina el 90 % del almacenamiento porque los carritos abandonados dejan de acumularse, y ese solo hecho ya casi paga la migración. Segundo, y más importante, la cifra de 41,30 USD no es el ahorro real: al liberar esa carga, mercadofresco-pedidos deja de necesitar el 35 % de su capacidad, lo que abre la puerta a reducir tamaño o a absorber crecimiento sin escalar. El valor de la migración no está en la línea de la factura de DynamoDB, sino en la que desaparece de la factura relacional. Y el beneficio que no aparece en ninguna columna: desaparece el VACUUM que competía por la E/S con los pedidos los viernes por la tarde.

La migración, paso a paso

# Crear la tabla bajo demanda, cifrada con la clave del proyecto y etiquetada.
aws dynamodb create-table \
  --table-name mercadofresco-carritos \
  --attribute-definitions AttributeName=PK,AttributeType=S AttributeName=SK,AttributeType=S \
  --key-schema AttributeName=PK,KeyType=HASH AttributeName=SK,KeyType=RANGE \
  --billing-mode PAY_PER_REQUEST \
  --sse-specification Enabled=true,SSEType=KMS,KMSMasterKeyId=alias/mercadofresco-datos \
  --stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=carrito Key=Propietario,Value=luis \
         Key=CentroCoste,Value=plataforma \
  --region eu-west-1 --profile mercadofresco-dev
Fase Acción Criterio de salida
1 Activar TTL, PITR y alarmas de estrangulamiento Alarmas verificadas en alertas-mercadofresco
2 Escritura doble; lectura de PostgreSQL 7 días con error de escritura <0,01 %
3 Migrar solo los carritos activos de 30 días Muestra de 1.000 claves sin discrepancias
4 Lectura de DynamoDB con reserva a PostgreSQL 7 días con reservas <0,1 %
5 Quitar reserva y escritura en PostgreSQL TiempoConfirmacionPedido p99 estable o mejor
6 Exportar sesiones a S3 y eliminarla Copia verificada; WriteIOPS de RDS a la baja

Y los 55 GB de carritos abandonados no se migran: se exportan en Parquet a mercadofresco-informes-analitica —Sara los analizará en 06-04— y se descartan: una migración es la única ocasión barata de no arrastrar la basura. Y el interruptor que hace todo esto reversible sin desplegar es el parámetro /mercadofresco/produccion/carrito/origen en Parameter Store (04-03), con valores postgres, ambos o dynamodb, leído al arrancar cada proceso y cacheado 60 segundos.

Errores Comunes y Consejos

Usar PutItem para actualizar. PutItem sustituye el elemento completo: los atributos que no envías desaparecen. Si dos procesos hacen PutItem sobre el mismo carrito, el segundo borra lo que escribió el primero. Para modificar, siempre UpdateItem.

Filtrar con FilterExpression creyendo que ahorra. El filtro se aplica después de leer y pagar. Si filtras mucho, el modelo está mal: eso es un índice que falta.

Meter un Scan en el camino crítico. Funciona perfectamente en desarrollo con 40 elementos y tumba la tienda con 4 millones. El Deny explícito de la política de IAM es la mejor prevención.

Elegir mal la clave de partición. Es la única decisión que no se puede cambiar sin migrar. Antes de crear la tabla, escribe las consultas y comprueba que todas se resuelven con GetItem o Query.

Poner el TTL en milisegundos o en ISO 8601. Debe ser un número Unix en segundos; si no lo es, el TTL no borra nada y no avisa. Y no es un temporizador exacto: tiene un plazo de hasta 48 horas, así que si la lógica exige que lo caducado no se vea, hay que filtrarlo también al leer.

Ignorar UnprocessedItems en los lotes. BatchWriteItem devuelve los elementos que no pudo escribir y no los reintenta solo. En una migración, ese descuido son carritos perdidos.

Crear GSI con proyección ALL por comodidad. Duplica el almacenamiento y consume WCU en cada escritura de la tabla base. INCLUDE con lo que de verdad se pinta suele bastar.

Consejo: mide desde el primer día. Activa ReturnConsumedCapacity="TOTAL" en desarrollo y registra las unidades consumidas por operación. Una operación que consume 40 RCU para devolver un carrito indica un problema de modelo que en producción cuesta dinero.

Consejo: usa DAX solo cuando lo demuestres. DynamoDB Accelerator es una caché en memoria delante de DynamoDB, con latencias de microsegundos y la misma API. Cuesta desde unos 90 USD/mes por nodo y solo compensa con lecturas repetitivas muy intensas. El carrito no lo es: cada cliente lee el suyo. El catálogo sí lo sería, y para eso MercadoFresco usará ElastiCache (06-05), que además sirve datos que no están en DynamoDB.

Ejercicios

Ejercicio 1: modelar el histórico de pedidos

Atención al cliente necesita una tabla mercadofresco-pedidos-consulta en DynamoDB, alimentada desde Aurora, que resuelva sin Scan estas cuatro consultas: (1) dado un id_pedido, devolver el pedido completo (18.000/día); (2) dados un cliente y un rango de fechas, listar sus pedidos de más reciente a más antiguo (2.400/día); (3) listar los pedidos en estado en_reparto de una ciudad concreta (350/día); (4) dado un id_pedido, devolver el historial de cambios de estado (1.200/día).

Define: PK y SK de la tabla, los índices necesarios con su proyección, cómo se resuelve cada consulta, y justifica por qué ninguna clave de partición es caliente.

Ejercicio 2: calcular capacidad y decidir el modo

MercadoFresco lanza una campaña de Black Friday con estas previsiones para mercadofresco-carritos: 4 horas de pico con 3.500 pedidos/hora (frente a los 900 habituales), proporción de operaciones idéntica a la del viernes normal, y tráfico normal las otras 20 horas. Precios de eu-west-1: bajo demanda 0,7452 USD por millón de escrituras y 0,1491 USD por millón de lecturas eventuales; aprovisionada 0,00065 USD por WCU-hora y 0,00013 USD por RCU-hora.

Calcula: (a) WCU y RCU necesarias en el pico; (b) el coste del día completo en modo bajo demanda; (c) el coste en modo aprovisionado con autoescalado, indicando qué valores configurarías; (d) qué modo eliges y por qué; (e) qué dos límites de DynamoDB podrían aparecer y cómo los previenes.

Ejercicio 3: diagnosticar un estrangulamiento

Tres semanas después de la migración, un viernes a las 18:20 saltan las alarmas. ThrottledRequests de mercadofresco-carritos: 4.200 en 5 minutos. ConsumedWriteCapacityUnits de la tabla: 61 WCU de 120 aprovisionadas. ConsumedWriteCapacityUnits del índice gsi-estado-fecha: 98 WCU de 100 aprovisionadas. Un despliegue de esa mañana añadió un atributo historial (lista de cambios de estado) a cada carrito, y el tamaño medio del elemento pasó de 3 KB a 11 KB. SuccessfulRequestLatency sin cambios.

Responde: (a) por qué se estrangula la tabla si consume la mitad de su capacidad; (b) qué papel juega el atributo nuevo; (c) tres medidas ordenadas de más rápida a más estructural; (d) qué habría evitado el incidente antes del despliegue.

Soluciones

Solución 1

Tabla mercadofresco-pedidos-consulta, diseño de tabla única:

Entidad PK SK
Pedido PEDIDO#<id> META
Cambio de estado PEDIDO#<id> ESTADO#<ts_iso>

Índices: gsi-cliente-fecha (PK id_cliente, SK fecha_pedido, INCLUDE de importe, estado y número de líneas) resuelve la consulta 2; gsi-reparto-ciudad (PK ciudad_estado, SK fecha_pedido, INCLUDE de id_pedido y repartidor) resuelve la 3.

Resolución: la 1 es un GetItem con PK=PEDIDO#<id>, SK=META. La 2, una Query sobre gsi-cliente-fecha con Key("id_cliente").eq(...) & Key("fecha_pedido").between(...) y ScanIndexForward=False. La 3, una Query sobre gsi-reparto-ciudad con Key("ciudad_estado").eq("VALENCIA#en_reparto"). La 4, una Query sobre la tabla con Key("PK").eq("PEDIDO#<id>") & Key("SK").begins_with("ESTADO#").

Por qué ninguna clave es caliente: PEDIDO#<id> tiene tantos valores como pedidos, con reparto uniforme. id_cliente reparte entre decenas de miles. ciudad_estado es la única con riesgo —pocas ciudades— pero es dispersa: el atributo solo se escribe mientras el pedido está en reparto y se elimina al entregarse, así que el índice contiene unos cientos de elementos en todo momento y recibe 350 consultas al día. Los índices dispersos son la técnica correcta para consultar por estados transitorios sin crear una partición caliente permanente.

Solución 2

(a) Capacidad en el pico. El factor de multiplicación es 3.500 ÷ 900 = 3,89.

Operación Peticiones/s Unidades
Actualizar carrito 46,7 140 WCU
Convertir (transaccional) 0,97 5,8 WCU
Sesiones 77,8 78 WCU
Total escritura ≈224 WCU
Leer carrito 93,4 47 RCU
Leer sesión 155,6 78 RCU
Total lectura ≈125 RCU

(b) Bajo demanda. Pico: 3,23 M escrituras y 1,80 M lecturas en 4 h. Resto del día, con la carga normal de 58 WCU y 33 RCU: 4,18 M escrituras y 2,38 M lecturas. Totales 7,41 M escrituras (5,52 USD) y 4,18 M lecturas (0,62 USD) = ≈6,14 USD el día.

(c) Aprovisionada. Con autoescalado al 70 % harían falta 320 WCU y 180 RCU en el pico, y 85/50 el resto: 4 h × (320 × 0,00065 + 180 × 0,00013) = 0,93 USD, más 20 h × (85 × 0,00065 + 50 × 0,00013) = 1,24 USD = ≈2,17 USD el día. Pero hay que programar el escalado con antelación: el autoescalado reacciona en minutos y el pico de Black Friday sube en segundos.

(d) Elección: bajo demanda. Cuesta 3,97 USD más un solo día. A cambio elimina el riesgo de estrangular el carrito en la jornada de mayor facturación del año y no exige acertar una previsión que nadie ha hecho nunca. Ahorrar 4 USD arriesgando ventas de Black Friday es el ejemplo perfecto de optimización mal dirigida; la conversación sobre capacidad aprovisionada se tiene en enero, con datos.

(e) Dos límites. Primero, la partición: 1.000 WCU y 3.000 RCU. Con CLIENTE#<id> bien repartido no es un riesgo, salvo si un proceso de pruebas concentra tráfico en pocos clientes; se previene revisando que el tráfico sintético use identificadores variados. Segundo, la capacidad de las tablas bajo demanda, que escala rápido pero puede estrangular ante multiplicaciones instantáneas muy superiores al pico previo; se previene configurando el rendimiento en caliente por adelantado —o calentando la tabla con carga creciente— y con reintentos adaptativos en el cliente.

Solución 3

(a) Por qué se estrangula la tabla. No se estrangula la tabla: se estrangula el índice. Un GSI tiene capacidad propia e independiente, y gsi-estado-fecha está al 98 % de sus 100 WCU. Cuando un GSI no puede absorber sus escrituras, DynamoDB rechaza las escrituras en la tabla base, porque no puede dejar el índice permanentemente desincronizado. La capacidad libre de la tabla es irrelevante: el cuello está aguas abajo. Es el modo de fallo de GSI más frecuente y el menos intuitivo.

(b) El atributo nuevo. El elemento pasó de 3 KB a 11 KB, así que cada escritura pasó de 3 WCU a 11 WCU: multiplicación por 3,7. Y si la proyección del GSI es ALL, el índice replica el atributo historial aunque nadie lo consulte por ahí, con lo que hereda íntegro el aumento. Se está pagando capacidad de índice para copiar datos que ninguna consulta del índice usa.

(c) Tres medidas. Inmediata (minutos): subir la capacidad aprovisionada del GSI a 300 WCU; restaura el servicio en el acto con coste añadido despreciable. A corto plazo (mismo día): como la proyección no se puede modificar, crear un GSI nuevo con INCLUDE de los atributos necesarios —sin historial—, migrar las consultas y borrar el antiguo. Estructural: sacar el historial de estados del elemento y modelarlo como elementos propios con SK = ESTADO#<ts>, como en la solución 1; el elemento vuelve a 3 KB y el historial crece sin engordar nada. Es la corrección correcta: un atributo que crece sin cota dentro de un elemento es siempre un error de modelo, y además se acercaba al límite duro de 400 KB.

(d) Qué lo habría evitado. Una prueba de carga en preproducción con el tamaño real de elemento; una alarma sobre ConsumedWriteCapacityUnits del índice al 80 %, y no solo de la tabla —el panel mercadofresco-produccion vigilaba la tabla, no el GSI—; y una revisión de diseño que hubiera detectado el atributo de crecimiento ilimitado. Las tres son baratas; el incidente fue un viernes a las 18:20.

Conclusión

El carrito y las sesiones ya no están en PostgreSQL. Sabes qué es DynamoDB y por qué encaja aquí: sin servidores, sin conexiones que agotar, con latencia constante frente al volumen y con escritura que escala horizontalmente en vez de exigir una instancia mayor. Dominas el modelo de datos —tabla, elemento de hasta 400 KB, atributos sin esquema— y la decisión más difícil de revertir: la clave primaria, con su clave de partición repartida por hash entre particiones de 3.000 RCU y 1.000 WCU, y su clave de ordenación que agrupa físicamente lo relacionado. Sabes reconocer y corregir una clave caliente, incluido el reparto de escrituras cuando un producto se vuelve viral.

Sobre todo, sabes diseñar desde las consultas: enumerar las seis consultas reales del carrito antes de dibujar nada, comprobar que todas se resuelven con GetItem o Query, y solo entonces escribir el modelo. De ahí sale mercadofresco-carritos con PK = CLIENTE#<id> y SK = CARRITO#<ts> / SESION#<id> / PERFIL, un diseño de tabla única que devuelve en una operación lo que antes eran tres consultas a tres tablas —y con el criterio para saber cuándo ese diseño no compensa.

Manejas las operaciones: UpdateItem con sus cuatro verbos en vez del PutItem que borra lo que no envías, expresiones de condición como bloqueo optimista para que dos pulsaciones del botón de pagar no generen dos pedidos, Query frente a Scan y la diferencia que más dinero cuesta —KeyConditionExpression elige lo que se lee, FilterExpression descarta lo que ya has pagado—, lotes con sus UnprocessedItems y TransactWriteItems al doble de coste y sin lógica intermedia. Y sabes cuándo un GSI y cuándo un LSI, con el efecto real de las proyecciones.

Y tienes las cuentas: 58 WCU y 33 RCU cubren el pico del viernes —la carga que asfixiaba a db.t3.small cabe en menos de 100 unidades de capacidad—, bajo demanda durante la migración porque ahorrar cuatro dólares arriesgando ventas es optimizar mal, ráfagas y estrangulamiento con reintentos adaptativos, consistencia eventual para mostrar y fuerte para decidir, y el TTL que convierte los 78 GB en 8 GB y hace que los carritos zombis no puedan volver a existir. Con Streams preparados para 07-03, PITR activo, cifrado con alias/mercadofresco-datos y una política de IAM que deniega explícitamente Scan y DeleteItem. Coste: de 51 USD al mes a 8 USD, y la desaparición del VACUUM que competía con los pedidos los viernes por la tarde.

Queda la carga principal. mercadofresco-pedidos ya no soporta el carrito, pero sigue siendo una instancia de PostgreSQL con Multi-AZ y una réplica, con un almacenamiento que crece 12 GB al mes, una conmutación por error de un par de minutos y un coste que se paga entero también de madrugada. En 06-03, «Amazon Aurora», veremos la evolución natural de esa base de datos: la separación entre cómputo y almacenamiento, el volumen distribuido en seis copias sobre tres zonas de disponibilidad, réplicas de lectura con retardo de milisegundos, Serverless v2 para que el valle nocturno deje de costar lo mismo que el pico del viernes, clonación rápida para que Luis pruebe con datos realistas sin duplicar coste, y la migración desde la RDS actual con downtime mínimo.

Curso de AWS

Módulo 1: Introducción a AWS

Módulo 2: Servicios principales de AWS

Módulo 3: Redes y entrega de contenido

Módulo 4: Seguridad e identidad

Módulo 5: Monitorización y gestión

Módulo 6: Bases de datos

Módulo 7: Integración de aplicaciones

Módulo 8: Herramientas para desarrolladores

Módulo 9: Infraestructura como código y gobierno de cuentas

Módulo 10: Contenedores en AWS

Módulo 11: Mejores prácticas y gestión de costos

© Copyright 2026. Todos los derechos reservados