Vés al contingut

DynamoDB: modelar per accés, no per entitats

Amb una base de dades relacional, primer dibuixes les taules i després escrius les consultes que calguin. Amb DynamoDB és al revés: primer escrius les consultes i després dissenyes la taula perquè les respongui. Si ho fas a l'inrevés, acabes llegint la taula sencera a cada petició i pagant-ho.

DynamoDB no és un MySQL sense JOIN. És un diccionari gegant i distribuït: li dones una clau i et retorna el que hi ha guardat, en mil·lisegons, tingui la taula deu files o deu mil milions. Tot el disseny gira al voltant d'aquesta clau.

L'exemple de tota la lliçó és el mateix que fem a classe: una llista de la compra amb usuaris, on cadascú veu només els seus productes.


Comença per les preguntes​

Abans d'obrir la consola, escriu què haurà de fer l'aplicació. Literalment, en una llista:

  1. Afegir un producte a la llista d'un usuari.
  2. Llistar tots els productes d'un usuari.
  3. Canviar un producte (el nom, la quantitat, marcar-lo com a comprat).
  4. Esborrar un producte.
  5. Llistar només els pendents d'un usuari, del més antic al més nou.

Fixa't que totes les preguntes comencen per «d'un usuari». Aquesta és la pista que necessites: l'usuari serà la clau de partició.


Les dues claus​

Cada element d'una taula de DynamoDB s'identifica per la seva clau primària, que pot tenir una o dues parts:

  • Clau de partició (partition key). DynamoDB en calcula un hash i decideix a quina partició física va l'element. Tots els elements amb la mateixa clau de partició viuen junts.
  • Clau d'ordenació (sort key), opcional. Ordena els elements dins de la mateixa partició i permet consultar-ne rangs: «tots», «els que comencen per», «entre aquest i aquest».

Per a la llista de la compra:

AtributPaperExemple
usuariClau de particiól'identificador de l'usuari (a la lliçó 08, el sub de Cognito)
producteClau d'ordenacióun UUID generat en crear-lo
nom, quantitat, comprat, creatAtributs normals"Llet", 2, false, "2026-10-21T18:00:00Z"

Amb aquestes dues claus, les preguntes 1 a 4 es responen sense llegir res que no sigui de l'usuari. Més endavant arreglarem la 5.

aws dynamodb create-table \
--table-name llista-compra \
--attribute-definitions \
AttributeName=usuari,AttributeType=S \
AttributeName=producte,AttributeType=S \
--key-schema \
AttributeName=usuari,KeyType=HASH \
AttributeName=producte,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region eu-west-1

Només declares els atributs que formen part d'alguna clau. La resta (nom, quantitat…) no es declaren mai: cada element pot portar els atributs que vulgui. No hi ha esquema més enllà de la clau.

PAY_PER_REQUEST és el mode sota demanda: pagues per lectura i escriptura, sense reservar capacitat. Per a un curs i per a la majoria de projectes petits, és el que vols.


El codi: les quatre operacions​

Amb l'SDK v3 i el document client, que tradueix entre objectes de JavaScript i el format intern de DynamoDB:

// src/llista.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
UpdateCommand,
DeleteCommand,
} from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "node:crypto";

// Fora del handler: es reaprofita entre invocacions (lliçó 02).
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TAULA = process.env.TAULA!;

export async function afegeix(usuari: string, nom: string, quantitat = 1) {
const element = {
usuari,
producte: randomUUID(),
nom,
quantitat,
comprat: false,
creat: new Date().toISOString(),
};
await db.send(
new PutCommand({
TableName: TAULA,
Item: element,
// Si per atzar ja existís, falla en lloc de sobreescriure'l.
ConditionExpression: "attribute_not_exists(producte)",
}),
);
return element;
}

export async function llista(usuari: string) {
const r = await db.send(
new QueryCommand({
TableName: TAULA,
KeyConditionExpression: "usuari = :u",
ExpressionAttributeValues: { ":u": usuari },
}),
);
return r.Items ?? [];
}

export async function marcaComprat(usuari: string, producte: string) {
await db.send(
new UpdateCommand({
TableName: TAULA,
Key: { usuari, producte },
UpdateExpression: "SET comprat = :si",
// Sense aquesta condició, un UpdateItem sobre una clau que no existeix
// CREA un element nou amb només aquest atribut.
ConditionExpression: "attribute_exists(producte)",
ExpressionAttributeValues: { ":si": true },
}),
);
}

export async function esborra(usuari: string, producte: string) {
await db.send(new DeleteCommand({ TableName: TAULA, Key: { usuari, producte } }));
}

Dues coses que costen d'aprendre per les males:

  • UpdateItem fa upsert. Si la clau no existeix, crea l'element. Una API d'editar sense attribute_exists deixa entrar productes fantasma amb només el camp comprat. La condició ho converteix en un ConditionalCheckFailedException que pots tornar com a 404.
  • La seguretat la fa la clau, no un if. marcaComprat rep l'usuari i el producte, i la clau és la parella. Un usuari no pot tocar el producte d'un altre encara que n'endevini l'identificador, perquè amb el seu usuari aquella clau no existeix.

La funció necessita permís per a dynamodb:PutItem, Query, UpdateItem i DeleteItem sobre aquesta taula i prou. Com es fa sense posar un asterisc, a la lliçó 06.


Query o Scan: la diferència que paga la factura​

  • Query va directament a una partició i llegeix només els elements d'aquella clau. Cost proporcional al que retorna.
  • Scan llegeix la taula sencera, element per element, i després filtra. Pagues tot el que llegeix, no el que et retorna.

Un Scan amb FilterExpression sembla una consulta, però no ho és: amb 100.000 productes a la taula, per tornar-ne els 12 d'un usuari en llegeixes 100.000. I és lent, perquè retorna com a molt 1 MB per pàgina.

A la primera versió de la llista de la compra que vam fer a classe, la ruta GET /productes feia un Scan sense filtre i no demanava autenticació: qualsevol persona amb la URL veia els productes de tothom, amb els identificadors d'usuari inclosos. No és un problema de rendiment, és una fuita de dades. Si una petició d'usuari acaba en un Scan, el disseny està malament.

Scan té usos legítims: una migració, una exportació nocturna, un script d'administració. Mai el camí normal d'una petició.


Índexs secundaris: la pregunta 5​

«Els pendents d'un usuari, del més antic al més nou.» Amb la clau primària no es pot: el producte és un UUID, que no té cap ordre útil, i comprat no és clau.

Un índex secundari global (GSI) és una segona clau sobre les mateixes dades. DynamoDB el manté sol: cada escriptura a la taula s'hi replica.

El truc aquí és un índex dispers: posem un atribut pendentDes només mentre el producte no està comprat, i l'esborrem en comprar-lo. Els elements sense l'atribut no entren a l'índex.

aws dynamodb update-table \
--table-name llista-compra \
--attribute-definitions \
AttributeName=usuari,AttributeType=S \
AttributeName=pendentDes,AttributeType=S \
--global-secondary-index-updates '[{
"Create": {
"IndexName": "pendents",
"KeySchema": [
{"AttributeName": "usuari", "KeyType": "HASH"},
{"AttributeName": "pendentDes", "KeyType": "RANGE"}
],
"Projection": {"ProjectionType": "ALL"}
}
}]' \
--region eu-west-1

A afegeix hi poses pendentDes: element.creat, i marcaComprat passa a ser SET comprat = :si REMOVE pendentDes. La consulta:

new QueryCommand({
TableName: TAULA,
IndexName: "pendents",
KeyConditionExpression: "usuari = :u",
ExpressionAttributeValues: { ":u": usuari },
ScanIndexForward: true, // del més antic al més nou
});

L'índex només conté els pendents: llegeixes exactament el que vols i res més.

Dues condicions dels GSI que has de saber:

  • Són sempre de consistència eventual. Just després d'escriure, l'índex pot trigar una fracció de segon a assabentar-se'n.
  • Cada escriptura a la taula és també una escriptura a cada índex que l'inclogui. Cinc índexs amb projecció ALL multipliquen per sis el cost d'escriure.

Una taula o moltes​

Llegiràs molt sobre el disseny de taula única: tots els tipus d'entitat (usuaris, comandes, productes) a la mateixa taula, amb claus del tipus USUARI#123 i COMANDA#456, per poder-los llegir junts amb una sola Query.

Funciona i és el que fa Amazon internament, però té un preu: necessites conèixer tots els patrons d'accés abans de començar, i cada canvi de requisits és una migració. Per aprendre i per a projectes que encara es mouen, una taula per entitat és més fàcil de llegir i de canviar. Passa a taula única quan tinguis una consulta que realment necessiti llegir dues entitats alhora i sàpigues per què.


Límits que trobaràs​

LímitValor
Mida màxima d'un element400 KB (noms d'atribut inclosos)
Dades per pàgina de Query o Scan1 MB; per a la resta, LastEvaluatedKey
Índexs secundaris globals per taula20 (quota per defecte)
Índexs secundaris locals5, i només en crear la taula

El de l'1 MB és el que fa més mal: si no mires LastEvaluatedKey, la teva API retorna «tots» els elements i en realitat en són els primers 1 MB. Funciona perfectament a desenvolupament i falla en silenci a producció.

Per guardar fitxers, imatges o documents grossos, no: van a S3 (lliçó 05) i a DynamoDB només hi guardes la clau de l'objecte.


Què costa això​

ConcepteSota demanda (Irlanda, aprox.)
Escriptura (fins a 1 KB)~0,71 $ per milió
Lectura consistent (fins a 4 KB)~0,14 $ per milió; la meitat si és eventual
Emmagatzematge~0,28 $ per GB i mes
Nivell gratuït always free25 GB d'emmagatzematge

Preus aproximats de l'octubre de 2026; AWS els va abaixar a la meitat el novembre de 2024 i els publica en dòlars. Mira la pàgina de preus abans de donar-ne cap per bo.

La llista de la compra de tota una classe, amb milers d'operacions al dia, no arriba a un cèntim al mes. Els sospitosos habituals de factura alta són els de sempre: Scan a cada petició, índexs que ningú consulta i elements grossos que es reescriuen sencers per canviar un camp.


Errors habituals​

  • ValidationException: The provided key element does not match the schema. Has passat la clau incompleta (només producte, sense usuari) o amb el tipus equivocat. A GetItem, UpdateItem i DeleteItem la clau va sempre sencera.
  • Attribute name is a reserved keyword. name, status, count, date i uns 500 més són paraules reservades. Fes servir àlies: "#n = :v" amb ExpressionAttributeNames: { "#n": "name" }. Escriure els atributs en català t'estalvia la majoria de casos.
  • ConditionalCheckFailedException que trenca l'API amb un 500. És el comportament que volies: tradueix-lo a un 404 o a un 409, no el deixis escapar.
  • El canvi de clau d'una taula que ja existeix. La clau primària no es pot modificar. Canviar id per usuari + producte vol dir taula nova i migrar les dades, i si la taula té nom fix a la plantilla, la pila no s'actualitza. Pensa la clau abans de tenir dades.
  • Un Scan amagat dins d'una funció «de llistar». Busca ScanCommand al teu codi. Cada aparició ha de tenir una justificació escrita al costat.

Exercici​

La llista de la compra de classe, sencera:

  1. Crea la taula llista-compra i l'índex pendents d'aquesta lliçó.
  2. Fes quatre Lambdes (o una sola amb rutes) per a POST /productes, GET /productes, PUT /productes/{producte} i DELETE /productes/{producte}, darrere d'una HTTP API (lliçó 03).
  3. De moment, l'usuari arriba en una capçalera x-usuari. És una trampa a posta: qualsevol pot fer-se passar per qualsevol. A la lliçó 08 el substituirem pel sub del token de Cognito.
  4. Comprova que GET /productes fa una Query i no un Scan, i que editar un producte inexistent retorna 404.
Repàs: quatre preguntes
  1. Per què a la llista de la compra la clau de partició és l'usuari i no el producte? Perquè totes les consultes comencen per «d'un usuari». Amb l'usuari de partició, cada consulta és una Query a una sola partició.
  2. Una Query amb FilterExpression costa el mateix que sense? Sí: el filtre s'aplica després de llegir. Pagues el que llegeix la clau, no el que retorna el filtre.
  3. Quina consistència tenen els índexs secundaris globals? Sempre eventual.
  4. Què passa si fas UpdateItem sobre una clau que no existeix? Crea l'element. Per evitar-ho, ConditionExpression: "attribute_exists(...)".

La IA a l'empresa, amb criteri

Cada setmana, una sola cosa que pots fer servir: la notícia que toca la teva empresa i què en faria jo, una pregunta de client, un cas real o un exercici de quinze minuts. Un correu per setmana com a molt, i te'n pots donar de baixa amb un clic.