A 06-01 vam prendre la decisió: la cistella i les sessions surten de PostgreSQL. Els números que la justificaven eren contundents —180.000 escriptures al dia, el 92 % actualitzacions del mateix registre, accés sempre per clau, cap consulta exploratòria i 78 GB de taula dels quals 55 GB són cistelles abandonades de fa més de dos mesos—. Aquesta taula és la que dispara el VACUUM que més E/S consumeix de mercadofresco-pedidos.

Amazon DynamoDB és la base de dades clau-valor i documental gestionada d'AWS. No hi ha servidors, ni versions per actualitzar, ni connexions per esgotar: hi ha una API HTTP i un model de dades que, si es fa servir bé, respon en menys de 10 mil·lisegons independentment de si la taula té mil elements o mil milions. El «si es fa servir bé» és la lliçó sencera: DynamoDB castiga sense pietat el disseny relacional i recompensa el disseny orientat al patró d'accés. Aquí el Luis modela mercadofresco-carritos des de les consultes que l'aplicació fa de debò, calcula la capacitat amb les xifres del pic del divendres i activa el TTL que converteix els 55 GB d'escombraries en un problema que ja no es pot repetir.

Avís de cost. Les taules sota demanda no costen res si no es fan servir, però sí que costen l'emmagatzematge (0,25 USD per GB i mes) i les còpies PITR; les provisionades cobren capacitat reservada encara que no arribi trànsit. En acabar, esborra les taules de prova. Dades fictícies.

Contingut

  1. Què és DynamoDB i quin problema resol
  2. Model de dades: taula, element, atributs i tipus
  3. Clau primària: partició i ordenació
  4. Particions, hash i claus calentes
  5. Dissenyar des de les consultes, no des de les entitats
  6. Disseny de taula única aplicat a mercadofresco-carritos
  7. Operacions d'escriptura i expressions de condició
  8. Lectura: GetItem, Query i per què Scan gairebé sempre és un error
  9. Operacions per lots i transaccions
  10. Índexs secundaris: GSI i LSI
  11. Capacitat, RCU i WCU amb les xifres del divendres
  12. Ràfegues, estrangulament i reintents amb retrocés exponencial
  13. Consistència eventual i consistència forta
  14. TTL: la fi de les cistelles zombis
  15. Streams, còpies de seguretat, xifratge i taules globals
  16. Accés des de boto3 i des de Lambda
  17. Cost comparat amb mantenir-ho a RDS
  18. La migració, pas a pas
  19. Errors habituals i consells
  20. Exercicis
  21. Conclusió

Què és DynamoDB i quin problema resol

DynamoDB és un servei totalment gestionat i sense servidor. No es tria una mida d'instància, no s'aplica cap pedaç, no es dimensiona l'emmagatzematge. Es crea una taula i s'hi escriu. La diferència estructural amb RDS resumeix per què encaixa amb la cistella:

RDS PostgreSQL DynamoDB
Unitat d'escala Instància (vertical) Particions (horitzontal, automàtica)
Connexions Limitades (200 a db.t3.small) Cap: peticions HTTP signades
Latència amb el volum Es degrada en créixer Constant per disseny
Consultes SQL arbitrari Només per clau i índexs definits
Esquema Fix Només la clau primària és obligatòria
Caducitat de dades Un procés que cal escriure TTL natiu i gratuït
Manteniment VACUUM, versions, finestres Cap

La fila de les connexions importa més del que sembla: l'alarma mercadofresco-rds-conexiones-altas de 05-01 existeix perquè la instància té un sostre dur i les funcions Lambda el frecen al pic del divendres. DynamoDB no té aquest concepte —cada operació és una crida HTTP independent autenticada amb IAM—, així que mil Lambdes concurrents no esgoten res.

Model de dades: taula, element i atributs

Tres conceptes i cap més. Una taula és una col·lecció d'elements sense més esquema que la clau primària. Un element (item) equival a una fila i ocupa com a màxim 400 KB, inclosos els noms d'atributs. Un atribut és una parella nom-valor, i dos elements de la mateixa taula poden tenir atributs completament diferents.

L'absència d'esquema és llibertat i és perill. Res no impedeix desar precio com a número en un element i com a cadena en un altre; la validació és responsabilitat de l'aplicació. A MercadoFresco aquesta validació viu en una única capa d'accés a dades que tot el codi utilitza, precisament perquè no depengui de la disciplina de qui escriu cada endpoint.

Clau primària: partició i ordenació

La clau primària és l'única estructura obligatòria i la decisió més difícil de revertir: per canviar-la cal crear una altra taula i migrar.

Una clau simple és només la clau de partició (PK, o hash key): identifica un element de manera única i s'hi accedeix per igualtat exacta, res més. Una clau composta hi afegeix la clau d'ordenació (SK, o range key): tots els elements amb la mateixa PK viuen junts, físicament ordenats per SK. Això habilita l'operació més potent de DynamoDB, recuperar d'una sola vegada un rang d'elements relacionats.

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ó pot demanar «tots els elements de CLIENTE#4471 la SK dels quals comenci per CARRITO#, ordenats de més recent a més antic, els 5 primers». Aquesta consulta és la que a PostgreSQL era un SELECT … WHERE id_cliente = ? ORDER BY fecha DESC LIMIT 5 amb el seu índex; aquí és un accés directe a un bloc contigu de disc.

Tipus de dades

Categoria Tipus Notació Notes
Escalars Cadena, Número, Binari, Booleà, Nul S, N, B, BOOL, NULL Números amb 38 dígits de precisió
Documents Llista, Mapa L, M Imbricables fins a 32 nivells
Conjunts Cadenes, Números, Binaris SS, NS, BS Sense duplicats, sense ordre

Dos avisos pràctics. No existeix el tipus data: es fan servir cadenes ISO 8601 (2026-08-02T18:04:11Z), que s'ordenen alfabèticament igual que cronològicament, o números Unix quan cal comparar-los. I una cadena buida s'admet en atributs no clau, però no en atributs clau: un id_sesion buit provoca un error de validació.

Particions, hash i claus calentes

DynamoDB reparteix les dades aplicant una funció hash al valor de la clau de partició. El resultat determina en quina partició física cau l'element. Cada partició suporta com a màxim 3.000 RCU i 1.000 WCU, i aquest límit és el que explica gairebé tots els problemes de rendiment reals.

graph LR
    A["PK = CLIENTE#4471"] -->|hash| P1[Particio 1]
    B["PK = CLIENTE#8802"] -->|hash| P2[Particio 2]
    C["PK = CLIENTE#1043"] -->|hash| P1
    D["PK = CLIENTE#9917"] -->|hash| P3[Particio 3]
    P1 --> L1["Cada particio: max. 3.000 RCU / 1.000 WCU"]
    P2 --> L1
    P3 --> L1

Una clau calenta és un valor de PK que concentra un trànsit desproporcionat. La taula sencera pot tenir capacitat de sobres i tot i així estrangular-se perquè tot va a una partició.

Clau calenta Per què falla Correcció
PK = FECHA#2026-08-02 per a les comandes del dia Tot el trànsit del dia en una partició PK = PEDIDO#<id> i un GSI per consultar per data
PK = ESTADO#pendiente Milions d'elements sota un valor PK = PEDIDO#<id>, GSI dispers només amb els pendents
PK = TIENDA#mercadofresco Una única PK per a tot Repartir per client o producte
PK = PRODUCTO#<id> en un producte viral Un sol producte ho absorbeix tot Repartir l'escriptura: PRODUCTO#<id>#<0-9> i llegir-ne les 10

A mercadofresco-carritos la PK és CLIENTE#<id>, amb desenes de milers de valors diferents i trànsit repartit de manera natural. És el cas fàcil, i convé dir que és fàcil perquè el patró d'accés ho era: si l'accés hagués estat «dona'm totes les cistelles abandonades avui», el disseny seria un altre.

Dissenyar des de les consultes, no des de les entitats

El procediment correcte té tres passos i cap no és dibuixar entitats.

Pas 1: enumerar totes les consultes, extretes dels registres reals de l'aplicació:

# Consulta Freqüència diària
C1 Obtenir la cistella activa d'un client 210.000
C2 Afegir, canviar quantitat o treure una línia 180.000
C3 Obtenir la sessió web pel seu identificador 340.000
C4 Llistar les últimes 5 cistelles d'un client (atenció al client) 400
C5 Marcar una cistella com a convertida en comanda 4.000
C6 Esborrar cistelles abandonades de més de 30 dies Continu

Pas 2: per a cada consulta, decidir com es resol. La regla és que tota consulta freqüent s'ha de resoldre amb GetItem o Query sobre la taula o sobre un índex. Si alguna exigeix Scan, el model està malament. Pas 3: dissenyar la clau perquè això es compleixi. I només llavors, mirar quines entitats han quedat.

Disseny de taula única aplicat a mercadofresco-carritos

El disseny de taula única (single-table design) consisteix a desar diversos tipus d'entitat a la mateixa taula, distingint-los per prefixos a la clau. Sona estrany venint de SQL i respon a una raó molt concreta: a DynamoDB no hi ha JOIN, així que la manera de recuperar entitats relacionades en una sola operació és que comparteixin clau de partició.

Entitat PK SK Atributs
Cistella CLIENTE#<id> CARRITO#<ts_iso> lineas (L de M), importe, estado, expira_en
Sessió CLIENTE#<id> SESION#<id_sesion> ip, agente, ultima_actividad, expira_en
Perfil lleuger CLIENTE#<id> PERFIL ciudad, franja_reparto, alergenos
graph TD
    subgraph "Particio 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 i C4: Query PK + SK begins_with CARRITO#<br/>ScanIndexForward false, Limit 1 o 5"] --> C1
    Q2["C3: GetItem amb PK i SK exactes"] --> S1

Amb aquest disseny, una sola Query sobre CLIENTE#4471 retorna el perfil, la cistella activa i les sessions —tot el que la botiga necessita per pintar la capçalera— en una operació i uns pocs mil·lisegons. A PostgreSQL això eren tres consultes a tres taules.

Quan el disseny de taula única no compensa: quan les entitats no es consulten mai juntes, quan tenen cicles de vida i patrons de capacitat molt diferents, o quan l'equip encara no domina el model. Ficar comandes i catàleg a la mateixa taula «perquè és la pràctica recomanada» és carregar amb la complexitat sense cobrar el benefici. Aquí es justifica perquè C1, C3 i C4 comparteixen id_cliente.

Operacions d'escriptura: PutItem, UpdateItem, DeleteItem

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

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

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

# PutItem: crea l'element o SUBSTITUEIX del tot el que tingui aquesta clau.
# Aquest "substitueix del tot" es la font numero u de perdues de dades:
# els atributs que no s'envien desapareixen.
taula.put_item(Item={
    "PK": "CLIENTE#4471",
    "SK": f"CARRITO#{ts}",
    "estado": "activo",
    "importe": Decimal("0.00"),
    "lineas": [],
    # expira_en es un numero Unix en segons: es el que el TTL sap llegir.
    "expira_en": int((ara + timedelta(days=30)).timestamp()),
})

UpdateItem és l'operació que cal fer servir el 90 % de les vegades: modifica atributs concrets sense llegir abans l'element i és atòmica, de manera que dues peticions concurrents que incrementin un comptador donen el resultat correcte sense bloqueigs.

# Afegir una linia a la cistella i sumar l'import, en una sola operacio atomica.
resposta = taula.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"
    ),
    # Els noms van per alies perque moltes paraules son reservades a DynamoDB
    # ("estado", "name", "size"...). Fer servir alies sempre evita sorpreses.
    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",  # retorna l'element resultant, sense lectura extra
)

Els quatre verbs: SET assigna o crea (SET estado = :s), ADD suma a un número o afegeix a un conjunt (ADD importe :p), REMOVE elimina un atribut o un element de llista (REMOVE lineas[2]) i DELETE treu elements d'un conjunt (DELETE etiquetas :e).

DeleteItem esborra per clau. A mercadofresco-carritos gairebé no es fa servir: les cistelles les esborra el TTL i les convertides es marquen, no s'eliminen.

Expressions de condició i escriptures condicionals

Una expressió de condició fa que l'escriptura només s'apliqui si es compleix; si no, l'operació falla amb ConditionalCheckFailedException. És el mecanisme de bloqueig optimista de DynamoDB i substitueix els SELECT … FOR UPDATE del món relacional.

from botocore.exceptions import ClientError

try:
    # Marcar la cistella com a convertida NOMES si continua activa. Evita que dues
    # pulsacions del boto "Pagar" generin dues comandes de la mateixa cistella.
    taula.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 resultat correcte d'una cursa.
        print("La cistella ja s'havia convertit; no es duplica la comanda.")
    else:
        raise

Condicions habituals: attribute_exists(PK) per a «actualitzar només si existeix», attribute_not_exists(PK) per a «crear només si no existeix» —el patró d'idempotència que es reprèn a 07-05—, comparadors (<, >, BETWEEN) i size().

Lectura: GetItem, Query i per què Scan gairebé sempre és un error

from boto3.dynamodb.conditions import Key, Attr

# GetItem: un element per la seva clau completa. L'operacio mes barata i rapida.
sessio_web = taula.get_item(
    Key={"PK": "CLIENTE#4471", "SK": "SESION#a91f3c"},
    ConsistentRead=False,  # eventual: la meitat de cost, suficient aqui
).get("Item")

# Query: elements amb la MATEIXA clau de particio, filtrant per la d'ordenacio.
# Aquesta es la C1: la cistella activa mes recent del client.
resultat = taula.query(
    KeyConditionExpression=Key("PK").eq("CLIENTE#4471") & Key("SK").begins_with("CARRITO#"),
    FilterExpression=Attr("estado").eq("activo"),
    ScanIndexForward=False,  # descendent: la SK amb marca de temps dona la mes recent primer
    Limit=1,
)
cistella = resultat["Items"][0] if resultat["Items"] else None

La diferència entre totes dues expressions és la que més diners costa quan no s'entén: KeyConditionExpression actua abans de llegir —tria què es llegeix, només sobre PK i SK, i cobra només el que retorna—, mentre que FilterExpression actua després —descarta resultats, admet qualsevol atribut, i cobra tot el que s'ha llegit es retorni o no—. Filtrar per estado quan la partició té 3 cistelles és irrellevant; fer-ho sobre una partició amb 50.000 elements significa pagar 50.000 lectures per retornar-ne 1.

Scan llegeix la taula sencera i després aplica el filtre. Amb 78 GB migrats, un Scan són uns 10 milions de RCU: més d'1,25 USD per execució i uns quants minuts. I el pitjor no és el cost: és que consumeix la capacitat que necessiten les operacions reals, estrangulant la botiga.

Scan és acceptable en tres casos: taules petites i estables (uns pocs milers d'elements), processos per lots fora d'hora amb Segment/TotalSegments per paral·lelitzar, i exportacions puntuals. Qualsevol altre ús indica que falta un índex o que la clau està mal dissenyada.

Operacions per lots i transaccions

Operació Límit Atòmica Cost Ús a MercadoFresco
BatchGetItem 100 elements / 16 MB No Normal Carregar diversos perfils alhora
BatchWriteItem 25 elements / 16 MB No Normal Càrrega inicial de la migració
TransactWriteItems 100 elements / 4 MB ×2 Convertir cistella i marcar sessió
TransactGetItems 100 elements / 4 MB ×2 Lectura coherent de diversos elements

Els lots no són atòmics: si 3 de 25 escriptures fallen, les altres 22 s'apliquen i les fallides tornen a UnprocessedItems, que cal reintentar perquè el SDK no ho fa sol.

client = sessio.client("dynamodb")

# TransactWriteItems: tot o res. Convertir la cistella i tancar la sessio han
# de passar juntes. Costa el doble de capacitat; nomes s'usa quan cal.
client.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 condicio avorta TOTA la transaccio
        "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ímit conceptual important: una transacció de DynamoDB no admet lògica intermèdia. No es pot llegir, decidir al codi i escriure dins de la mateixa transacció. Per això a 06-01 es va descartar DynamoDB per al flux de confirmació de comanda.

Índexs secundaris: GSI i LSI

Un índex secundari permet consultar per atributs que no són la clau primària:

GSI (global) LSI (local)
PK de l'índex Qualsevol atribut La mateixa que la taula
SK de l'índex Qualsevol atribut Un altre atribut
Quan es crea En qualsevol moment Només en crear la taula
Màxim 20 (ampliable) 5
Capacitat Pròpia i independent Compartida amb la taula
Consistència Només eventual Eventual o forta
Límit de partició Cap 10 GB per PK

La regla pràctica: fes servir GSI llevat que necessitis lectura fortament consistent per un atribut alternatiu. Els LSI es decideixen el primer dia per sempre i el seu límit de 10 GB per partició és una trampa silenciosa. mercadofresco-carritos necessita un GSI per a C6 i per a atenció al client:

# GSI "gsi-estado-fecha": llista cistelles per estat i rang de dates sense
# recorrer la taula. Amb capacitat sota demanda no cal dimensionar-lo.
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

Projeccions: KEYS_ONLY copia només les claus (mínim emmagatzematge, serveix quan n'hi ha prou de localitzar i després llegir), INCLUDE copia les claus més els atributs triats (el cas habitual) i ALL copia l'element sencer, duplicant la taula. Un GSI amb ALL sobre 78 GB hi afegeix 78 GB i consumeix WCU a cada escriptura de la taula base; triar INCLUDE amb els tres atributs que es pinten a la pantalla sol reduir aquest cost en un 80 %.

Compte amb la clau calenta del GSI: estado té pocs valors diferents, així que gsi-estado-fecha concentra trànsit. Serveix per a processos per lots de baixa freqüència; no s'ha de fer servir al camí crític de la botiga.

Capacitat: sota demanda davant de provisionada

Sota demanda Provisionada
Es paga Per petició Per capacitat reservada per hora
Escala Instantània Amb autoescalat, en minuts
Preu unitari ~6,9× més car per operació Més barat si s'aprofita
Risc Factura sorpresa Estrangulament
Quan Trànsit irregular o desconegut Trànsit predictible i sostingut

Es pot canviar de mode, però només un cop cada 24 hores, així que no és una palanca per al pic del divendres. Per a MercadoFresco la recomanació és començar sota demanda durant la migració i els dos primers mesos: no coneixem el trànsit real de la taula nova i el cost d'equivocar-se amb capacitat provisionada és estrangular la cistella un divendres. Amb dos mesos de mètriques, es recalcula.

RCU i WCU amb les xifres del divendres

Les unitats de capacitat són la moneda de DynamoDB. 1 WCU és una escriptura per segon de fins a 1 KB (una de 3,5 KB costa 4 WCU). 1 RCU és una lectura fortament consistent per segon de fins a 4 KB, o dues lectures eventuals (una lectura eventual de 7 KB costa 1 RCU). Les operacions transaccionals, tant de lectura com d'escriptura, costen el doble.

Càlcul per al pic del divendres, 17:00-21:00, amb 900 comandes/hora:

Operació Peticions/s al pic Mida mitjana Consistència Unitats
C2 Actualitzar cistella 12 3 KB 12 × 3 = 36 WCU
C5 Convertir (transaccional) 0,25 3 KB 0,25 × 3 × 2 = 1,5 WCU
Escriure/refrescar sessió 20 1 KB 20 WCU
C1 Llegir cistella 24 3 KB eventual 24 × (4÷4) ÷ 2 = 12 RCU
C3 Llegir sessió 40 1 KB eventual 40 × 1 ÷ 2 = 20 RCU
C4 Atenció al client 0,1 15 KB eventual 1 RCU

Totals del pic: ≈58 WCU i ≈33 RCU. Amb capacitat provisionada i autoescalat al 70 % d'utilització, es dimensionaria a uns 85 WCU i 50 RCU a la finestra del divendres, baixant a 15 WCU i 10 RCU de matinada. La conclusió que convé interioritzar: la càrrega que asfixiava db.t3.small cap en menys de 100 unitats de capacitat. El problema mai no va ser el volum; va ser que aquelles escriptures compartien motor, memòria i VACUUM amb les transaccions de comandes.

Ràfegues, estrangulament i reintents amb retrocés exponencial

DynamoDB acumula capacitat de ràfega: la no utilitzada dels últims 300 segons es desa i es pot consumir de cop. És un coixí útil i no s'ha de dissenyar comptant-hi: no està garantit.

Quan se supera la capacitat disponible, l'operació es rebutja amb ProvisionedThroughputExceededException (o ThrottlingException). Causes, en ordre de freqüència real: clau calenta, capacitat provisionada insuficient, un GSI amb menys capacitat que la taula base, i creixement més ràpid del que l'autoescalat reacciona.

from botocore.config import Config

# El SDK reintenta sol, pero per defecte de manera conservadora. El mode
# "adaptive" ajusta el ritme d'enviament a l'estrangulament observat, que es
# just el que es vol en una taula compartida per moltes Lambdes.
config = Config(
    retries={"max_attempts": 10, "mode": "adaptive"},
    connect_timeout=1, read_timeout=3,   # fallar rapid: una Lambda no s'ha de penjar
)
taula = sessio.resource("dynamodb", config=config).Table("mercadofresco-carritos")

El retrocés exponencial amb fluctuació (jitter) és imprescindible: sense la part aleatòria, tots els clients reintenten alhora i reprodueixen la ràfega que va causar el problema.

Vigilància mínima a CloudWatch cap a alertas-mercadofresco: ThrottledRequests > 0 en 5 minuts, pujades sobtades d'UserErrors (gairebé sempre un desplegament amb una errada), SuccessfulRequestLatency p99 > 25 ms i ConsumedWriteCapacityUnits per damunt del 80 % del que s'ha provisionat tant a la taula com a cada índex.

Consistència eventual i consistència forta

Cada escriptura es replica en tres AZ. Una lectura eventual pot arribar a una rèplica que encara no té l'últim canvi; el desfasament típic és de menys d'un segon. L'eventual és la de per defecte i costa 0,5 RCU per 4 KB; la forta (ConsistentRead=True) costa 1 RCU, té una mica més de latència i no està disponible als GSI.

Aplicat a MercadoFresco, és la regla de 06-01 —eventual per mostrar, forta per decidir— feta codi: pintar la capçalera amb el nombre d'articles va eventual, però llegir la cistella just abans de convertir-la en comanda va forta, perquè d'aquella lectura depèn el que es cobra.

TTL: la fi de les cistelles zombis

Aquesta és la funcionalitat que tota sola justifica la migració. El TTL (Time To Live) esborra automàticament els elements l'atribut de caducitat dels quals —un número Unix en segons— ja ha passat, i l'esborrat és gratuït: no consumeix 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

Quatre coses que cal saber per no endur-se un ensurt:

  1. L'esborrat no és immediat. DynamoDB elimina en un termini típic de 48 hores. Si l'aplicació no ha de veure el que ha caducat, cal filtrar-ho també a la lectura.
  2. L'atribut ha de ser un número en segons. Mil·lisegons, o una cadena ISO, fan que el TTL ignori l'element silenciosament. És l'errada més comuna.
  3. Els esborrats apareixen als Streams amb userIdentity de tipus Service, cosa que permet arxivar la cistella abandonada abans que desaparegui.
  4. No substitueix una política de retenció documentada. Sota el RGPD cal poder explicar quant es conserven les dades i per què; el TTL implementa la política, no la defineix.

Davant del que hi ha avui —un DELETE nocturn programat que costa E/S, bloqueigs i VACUUM, del qual ningú no s'assabenta quan falla i que ha deixat créixer la taula fins als 78 GB—, el TTL és gratuït, no pot fallar en silenci i manté la mida estable per disseny.

Streams: reaccionar als canvis

DynamoDB Streams publica un registre ordenat de cada modificació, amb retenció de 24 hores i quatre vistes possibles: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE i NEW_AND_OLD_IMAGES. Una funció Lambda se subscriu al stream i reacciona a cada canvi.

Usos previstos: arxivar a mercadofresco-informes-analitica les cistelles que el TTL esborrarà —la Sara vol analitzar l'abandonament a 06-04— i alimentar mètriques de negoci. L'orquestració d'esdeveniments a més escala, amb encaminament i diversos consumidors desacoblats, és EventBridge i es veu a 07-03; Streams és el mecanisme de baix nivell lligat a aquesta taula.

Còpies de seguretat, xifratge i taules globals

Còpies de seguretat. PITR restaura a qualsevol segon dels últims 35 dies, s'activa amb aws dynamodb update-continuous-backups --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true i restaura sempre a una taula nova: no sobreescriu.

Xifratge. Sempre actiu en repòs, sense opció de desactivar-lo. Amb la clau gestionada pel client alias/mercadofresco-datos de 04-02 s'aconsegueix el que exigeix el criteri de MercadoFresco: control de la rotació, registre de cada ús a trail-mercadofresco i capacitat de revocar l'accés a les dades revocant permisos sobre la clau. Les taules globals, finalment, repliquen actiu-actiu entre regions amb resolució de conflictes per «l'últim que escriu guanya»; MercadoFresco opera només a eu-west-1 i no les necessita, però són l'equivalent d'Aurora Global Database (06-03) si algun dia hi ha operació a França.

Accés des de boto3 i des de Lambda

resource converteix tipus automàticament (DecimalN) i és la interfície per a la lògica de negoci; client exposa l'API crua amb el descriptor explícit ({"S": "..."}) i és l'única que cobreix transaccions i administració. Avís: resource exigeix Decimal i rebutja float amb Float types are not supported. És una molèstia deliberada: els imports en coma flotant són un error en qualsevol sistema que manegi diners.

Política de privilegi mínim per a 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 decisions deliberades. dynamodb:LeadingKeys limita les operacions a les claus de partició que coincideixen amb una etiqueta del principal: és aïllament per client a nivell d'IAM, no de codi. El Deny de Scan no és paranoia, és economia: impedeix que un desplegament precipitat fiqui un Scan al camí crític. I el Deny de DeleteItem obliga que l'esborrat sigui sempre el TTL.

Neteja. Si has creat taules per practicar, esborra-les amb aws dynamodb delete-table. Una taula sota demanda buida costa cèntims, però una de proves amb PITR actiu i uns quants GB carregats sí que apareix a la factura.

Cost comparat amb mantenir-ho a RDS

Xifres mensuals estimades per a eu-west-1, amb la càrrega descrita:

Concepte Avui a mercadofresco-pedidos DynamoDB sota demanda DynamoDB provisionada
Escriptures (5,4 M/mes) Inclòs 4,05 USD
Lectura (17,4 M/mes) Inclòs 0,44 USD
Capacitat reservada ~9,20 USD (85 WCU / 50 RCU)
Emmagatzematge 78 GB × 0,127 = 9,91 USD 8 GB × 0,25 = 2,00 USD 2,00 USD
PITR Inclòs a RDS 1,60 USD 1,60 USD
Part de la instància atribuïble ~35 % de 118 USD = 41,30 USD 0 0
Total ≈51,21 USD ≈8,09 USD ≈12,80 USD

Dues observacions. Primer, 8 GB davant de 78 GB: el TTL elimina el 90 % de l'emmagatzematge perquè les cistelles abandonades deixen d'acumular-se, i aquest sol fet ja gairebé paga la migració. Segon, i més important, la xifra de 41,30 USD no és l'estalvi real: en alliberar aquella càrrega, mercadofresco-pedidos deixa de necessitar el 35 % de la seva capacitat, cosa que obre la porta a reduir mida o a absorbir creixement sense escalar. El valor de la migració no és a la línia de la factura de DynamoDB, sinó a la que desapareix de la factura relacional. I el benefici que no apareix en cap columna: desapareix el VACUUM que competia per l'E/S amb les comandes els divendres a la tarda.

La migració, pas a pas

# Crear la taula sota demanda, xifrada amb la clau del projecte i 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ó Criteri de sortida
1 Activar TTL, PITR i alarmes d'estrangulament Alarmes verificades a alertas-mercadofresco
2 Escriptura doble; lectura de PostgreSQL 7 dies amb error d'escriptura <0,01 %
3 Migrar només les cistelles actives de 30 dies Mostra de 1.000 claus sense discrepàncies
4 Lectura de DynamoDB amb reserva a PostgreSQL 7 dies amb reserves <0,1 %
5 Treure reserva i escriptura a PostgreSQL TiempoConfirmacionPedido p99 estable o millor
6 Exportar sesiones a S3 i eliminar-la Còpia verificada; WriteIOPS d'RDS a la baixa

I els 55 GB de cistelles abandonades no es migren: s'exporten en Parquet a mercadofresco-informes-analitica —la Sara els analitzarà a 06-04— i es descarten: una migració és l'única ocasió barata de no arrossegar les escombraries. I l'interruptor que fa tot això reversible sense desplegar és el paràmetre /mercadofresco/produccion/carrito/origen a Parameter Store (04-03), amb valors postgres, ambos o dynamodb, llegit en arrencar cada procés i desat a la memòria cau 60 segons.

Errors Habituals i Consells

Fer servir PutItem per actualitzar. PutItem substitueix l'element sencer: els atributs que no envies desapareixen. Si dos processos fan PutItem sobre la mateixa cistella, el segon esborra el que va escriure el primer. Per modificar, sempre UpdateItem.

Filtrar amb FilterExpression creient que estalvia. El filtre s'aplica després de llegir i pagar. Si filtres molt, el model està malament: això és un índex que falta.

Ficar un Scan al camí crític. Funciona perfectament en desenvolupament amb 40 elements i tomba la botiga amb 4 milions. El Deny explícit de la política d'IAM és la millor prevenció.

Triar malament la clau de partició. És l'única decisió que no es pot canviar sense migrar. Abans de crear la taula, escriu les consultes i comprova que totes es resolen amb GetItem o Query.

Posar el TTL en mil·lisegons o en ISO 8601. Ha de ser un número Unix en segons; si no ho és, el TTL no esborra res i no avisa. I no és un temporitzador exacte: té un termini de fins a 48 hores, així que si la lògica exigeix que el que ha caducat no es vegi, cal filtrar-ho també en llegir.

Ignorar UnprocessedItems als lots. BatchWriteItem retorna els elements que no ha pogut escriure i no els reintenta sol. En una migració, aquest descuit són cistelles perdudes.

Crear GSI amb projecció ALL per comoditat. Duplica l'emmagatzematge i consumeix WCU a cada escriptura de la taula base. INCLUDE amb el que de debò es pinta sol ser suficient.

Consell: mesura des del primer dia. Activa ReturnConsumedCapacity="TOTAL" en desenvolupament i registra les unitats consumides per operació. Una operació que consumeix 40 RCU per retornar una cistella indica un problema de model que en producció costa diners.

Consell: fes servir DAX només quan ho demostris. DynamoDB Accelerator és una memòria cau davant de DynamoDB, amb latències de microsegons i la mateixa API. Costa des d'uns 90 USD/mes per node i només compensa amb lectures repetitives molt intenses. La cistella no ho és: cada client llegeix la seva. El catàleg sí que ho seria, i per a això MercadoFresco farà servir ElastiCache (06-05), que a més serveix dades que no són a DynamoDB.

Exercicis

Exercici 1: modelar l'històric de comandes

Atenció al client necessita una taula mercadofresco-pedidos-consulta a DynamoDB, alimentada des d'Aurora, que resolgui sense Scan aquestes quatre consultes: (1) donat un id_pedido, retornar la comanda completa (18.000/dia); (2) donats un client i un rang de dates, llistar les seves comandes de més recent a més antiga (2.400/dia); (3) llistar les comandes en estat en_reparto d'una ciutat concreta (350/dia); (4) donat un id_pedido, retornar l'historial de canvis d'estat (1.200/dia).

Defineix: PK i SK de la taula, els índexs necessaris amb la seva projecció, com es resol cada consulta, i justifica per què cap clau de partició no és calenta.

Exercici 2: calcular capacitat i decidir el mode

MercadoFresco llança una campanya de Black Friday amb aquestes previsions per a mercadofresco-carritos: 4 hores de pic amb 3.500 comandes/hora (davant de les 900 habituals), proporció d'operacions idèntica a la del divendres normal, i trànsit normal les altres 20 hores. Preus d'eu-west-1: sota demanda 0,7452 USD per milió d'escriptures i 0,1491 USD per milió de lectures eventuals; provisionada 0,00065 USD per WCU-hora i 0,00013 USD per RCU-hora.

Calcula: (a) WCU i RCU necessàries al pic; (b) el cost del dia complet en mode sota demanda; (c) el cost en mode provisionat amb autoescalat, indicant quins valors configuraries; (d) quin mode tries i per què; (e) quins dos límits de DynamoDB podrien aparèixer i com els prevens.

Exercici 3: diagnosticar un estrangulament

Tres setmanes després de la migració, un divendres a les 18:20 salten les alarmes. ThrottledRequests de mercadofresco-carritos: 4.200 en 5 minuts. ConsumedWriteCapacityUnits de la taula: 61 WCU de 120 provisionades. ConsumedWriteCapacityUnits de l'índex gsi-estado-fecha: 98 WCU de 100 provisionades. Un desplegament d'aquell matí va afegir un atribut historial (llista de canvis d'estat) a cada cistella, i la mida mitjana de l'element va passar de 3 KB a 11 KB. SuccessfulRequestLatency sense canvis.

Respon: (a) per què s'estrangula la taula si consumeix la meitat de la seva capacitat; (b) quin paper hi juga l'atribut nou; (c) tres mesures ordenades de més ràpida a més estructural; (d) què hauria evitat l'incident abans del desplegament.

Solucions

Solució 1

Taula mercadofresco-pedidos-consulta, disseny de taula única:

Entitat PK SK
Comanda PEDIDO#<id> META
Canvi d'estat PEDIDO#<id> ESTADO#<ts_iso>

Índexs: gsi-cliente-fecha (PK id_cliente, SK fecha_pedido, INCLUDE d'import, estat i nombre de línies) resol la consulta 2; gsi-reparto-ciudad (PK ciudad_estado, SK fecha_pedido, INCLUDE d'id_pedido i repartidor) resol la 3.

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

Per què cap clau no és calenta: PEDIDO#<id> té tants valors com comandes, amb repartiment uniforme. id_cliente reparteix entre desenes de milers. ciudad_estado és l'única amb risc —poques ciutats— però és dispersa: l'atribut només s'escriu mentre la comanda està en repartiment i s'elimina en lliurar-la, així que l'índex conté uns centenars d'elements en tot moment i rep 350 consultes al dia. Els índexs dispersos són la tècnica correcta per consultar per estats transitoris sense crear una partició calenta permanent.

Solució 2

(a) Capacitat al pic. El factor de multiplicació és 3.500 ÷ 900 = 3,89.

Operació Peticions/s Unitats
Actualitzar cistella 46,7 140 WCU
Convertir (transaccional) 0,97 5,8 WCU
Sessions 77,8 78 WCU
Total escriptura ≈224 WCU
Llegir cistella 93,4 47 RCU
Llegir sessió 155,6 78 RCU
Total lectura ≈125 RCU

(b) Sota demanda. Pic: 3,23 M escriptures i 1,80 M lectures en 4 h. Resta del dia, amb la càrrega normal de 58 WCU i 33 RCU: 4,18 M escriptures i 2,38 M lectures. Totals 7,41 M escriptures (5,52 USD) i 4,18 M lectures (0,62 USD) = ≈6,14 USD el dia.

(c) Provisionada. Amb autoescalat al 70 % caldrien 320 WCU i 180 RCU al pic, i 85/50 la resta: 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 dia. Però cal programar l'escalat amb antelació: l'autoescalat reacciona en minuts i el pic de Black Friday puja en segons.

(d) Elecció: sota demanda. Costa 3,97 USD més un sol dia. A canvi elimina el risc d'estrangular la cistella en la jornada de més facturació de l'any i no exigeix encertar una previsió que ningú no ha fet mai. Estalviar 4 USD arriscant vendes de Black Friday és l'exemple perfecte d'optimització mal dirigida; la conversa sobre capacitat provisionada es té al gener, amb dades.

(e) Dos límits. Primer, la partició: 1.000 WCU i 3.000 RCU. Amb CLIENTE#<id> ben repartit no és un risc, llevat que un procés de proves concentri trànsit en pocs clients; es prevé revisant que el trànsit sintètic faci servir identificadors variats. Segon, la capacitat de les taules sota demanda, que escala ràpid però pot estrangular davant de multiplicacions instantànies molt superiors al pic previ; es prevé configurant el rendiment en calent per endavant —o escalfant la taula amb càrrega creixent— i amb reintents adaptatius al client.

Solució 3

(a) Per què s'estrangula la taula. No s'estrangula la taula: s'estrangula l'índex. Un GSI té capacitat pròpia i independent, i gsi-estado-fecha és al 98 % dels seus 100 WCU. Quan un GSI no pot absorbir les seves escriptures, DynamoDB rebutja les escriptures a la taula base, perquè no pot deixar l'índex permanentment desincronitzat. La capacitat lliure de la taula és irrellevant: el coll és aigües avall. És el mode de fallada de GSI més freqüent i el menys intuïtiu.

(b) L'atribut nou. L'element va passar de 3 KB a 11 KB, així que cada escriptura va passar de 3 WCU a 11 WCU: multiplicació per 3,7. I si la projecció del GSI és ALL, l'índex replica l'atribut historial encara que ningú no el consulti per allà, amb la qual cosa hereta íntegrament l'augment. S'està pagant capacitat d'índex per copiar dades que cap consulta de l'índex no fa servir.

(c) Tres mesures. Immediata (minuts): pujar la capacitat provisionada del GSI a 300 WCU; restaura el servei a l'acte amb cost afegit menyspreable. A curt termini (el mateix dia): com que la projecció no es pot modificar, crear un GSI nou amb INCLUDE dels atributs necessaris —sense historial—, migrar les consultes i esborrar l'antic. Estructural: treure l'historial d'estats de l'element i modelar-lo com a elements propis amb SK = ESTADO#<ts>, com a la solució 1; l'element torna a 3 KB i l'historial creix sense engreixar res. És la correcció correcta: un atribut que creix sense cota dins d'un element és sempre un error de model, i a més s'acostava al límit dur de 400 KB.

(d) Què ho hauria evitat. Una prova de càrrega en preproducció amb la mida real d'element; una alarma sobre ConsumedWriteCapacityUnits de l'índex al 80 %, i no només de la taula —el tauler mercadofresco-produccion vigilava la taula, no el GSI—; i una revisió de disseny que hagués detectat l'atribut de creixement il·limitat. Totes tres són barates; l'incident va ser un divendres a les 18:20.

Conclusió

La cistella i les sessions ja no són a PostgreSQL. Saps què és DynamoDB i per què encaixa aquí: sense servidors, sense connexions per esgotar, amb latència constant davant del volum i amb escriptura que escala horitzontalment en comptes d'exigir una instància més gran. Domines el model de dades —taula, element de fins a 400 KB, atributs sense esquema— i la decisió més difícil de revertir: la clau primària, amb la seva clau de partició repartida per hash entre particions de 3.000 RCU i 1.000 WCU, i la seva clau d'ordenació que agrupa físicament el que està relacionat. Saps reconèixer i corregir una clau calenta, inclòs el repartiment d'escriptures quan un producte es torna viral.

Sobretot, saps dissenyar des de les consultes: enumerar les sis consultes reals de la cistella abans de dibuixar res, comprovar que totes es resolen amb GetItem o Query, i només llavors escriure el model. D'aquí surt mercadofresco-carritos amb PK = CLIENTE#<id> i SK = CARRITO#<ts> / SESION#<id> / PERFIL, un disseny de taula única que retorna en una operació el que abans eren tres consultes a tres taules —i amb el criteri per saber quan aquest disseny no compensa.

Manegues les operacions: UpdateItem amb els seus quatre verbs en comptes del PutItem que esborra el que no envies, expressions de condició com a bloqueig optimista perquè dues pulsacions del botó de pagar no generin dues comandes, Query davant de Scan i la diferència que més diners costa —KeyConditionExpression tria el que es llegeix, FilterExpression descarta el que ja has pagat—, lots amb els seus UnprocessedItems i TransactWriteItems al doble de cost i sense lògica intermèdia. I saps quan un GSI i quan un LSI, amb l'efecte real de les projeccions.

I tens els comptes: 58 WCU i 33 RCU cobreixen el pic del divendres —la càrrega que asfixiava db.t3.small cap en menys de 100 unitats de capacitat—, sota demanda durant la migració perquè estalviar quatre dòlars arriscant vendes és optimitzar malament, ràfegues i estrangulament amb reintents adaptatius, consistència eventual per mostrar i forta per decidir, i el TTL que converteix els 78 GB en 8 GB i fa que les cistelles zombis no puguin tornar a existir. Amb Streams preparats per a 07-03, PITR actiu, xifratge amb alias/mercadofresco-datos i una política d'IAM que denega explícitament Scan i DeleteItem. Cost: de 51 USD al mes a 8 USD, i la desaparició del VACUUM que competia amb les comandes els divendres a la tarda.

Queda la càrrega principal. mercadofresco-pedidos ja no suporta la cistella, però continua sent una instància de PostgreSQL amb Multi-AZ i una rèplica, amb un emmagatzematge que creix 12 GB al mes, una commutació per error d'un parell de minuts i un cost que es paga sencer també de matinada. A 06-03, «Amazon Aurora», veurem l'evolució natural d'aquesta base de dades: la separació entre còmput i emmagatzematge, el volum distribuït en sis còpies sobre tres zones de disponibilitat, rèpliques de lectura amb retard de mil·lisegons, Serverless v2 perquè la vall nocturna deixi de costar el mateix que el pic del divendres, clonació ràpida perquè el Luis provi amb dades realistes sense duplicar cost, i la migració des de l'RDS actual amb temps d'aturada mínim.

Curs d'AWS

Mòdul 1: Introducció a AWS

Mòdul 2: Serveis principals d'AWS

Mòdul 3: Xarxes i lliurament de contingut

Mòdul 4: Seguretat i identitat

Mòdul 5: Monitoratge i gestió

Mòdul 6: Bases de dades

Mòdul 7: Integració d'aplicacions

Mòdul 8: Eines per a desenvolupadors

Mòdul 9: Infraestructura com a codi i govern de comptes

Mòdul 10: Contenidors a AWS

Mòdul 11: Millors pràctiques i gestió de costos

© Copyright 2026. Tots els drets reservats