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:
- Afegir un producte a la llista d'un usuari.
- Llistar tots els productes d'un usuari.
- Canviar un producte (el nom, la quantitat, marcar-lo com a comprat).
- Esborrar un producte.
- 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:
| Atribut | Paper | Exemple |
|---|---|---|
usuari | Clau de partició | l'identificador de l'usuari (a la lliçó 08, el sub de Cognito) |
producte | Clau d'ordenació | un UUID generat en crear-lo |
nom, quantitat, comprat, creat | Atributs 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:
UpdateItemfa upsert. Si la clau no existeix, crea l'element. Una API d'editar senseattribute_existsdeixa entrar productes fantasma amb només el campcomprat. La condició ho converteix en unConditionalCheckFailedExceptionque pots tornar com a 404.- La seguretat la fa la clau, no un
if.marcaCompratrep 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 seuusuariaquella 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
Queryva directament a una partició i llegeix només els elements d'aquella clau. Cost proporcional al que retorna.Scanllegeix 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ó
ALLmultipliquen 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ímit | Valor |
|---|---|
| Mida màxima d'un element | 400 KB (noms d'atribut inclosos) |
Dades per pàgina de Query o Scan | 1 MB; per a la resta, LastEvaluatedKey |
| Índexs secundaris globals per taula | 20 (quota per defecte) |
| Índexs secundaris locals | 5, 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ò
| Concepte | Sota 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 free | 25 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ésproducte, senseusuari) o amb el tipus equivocat. AGetItem,UpdateItemiDeleteItemla clau va sempre sencera.Attribute name is a reserved keyword.name,status,count,datei uns 500 més són paraules reservades. Fes servir àlies:"#n = :v"ambExpressionAttributeNames: { "#n": "name" }. Escriure els atributs en català t'estalvia la majoria de casos.ConditionalCheckFailedExceptionque 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
idperusuari+productevol 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
Scanamagat dins d'una funció «de llistar». BuscaScanCommandal teu codi. Cada aparició ha de tenir una justificació escrita al costat.
Exercici
La llista de la compra de classe, sencera:
- Crea la taula
llista-comprai l'índexpendentsd'aquesta lliçó. - Fes quatre Lambdes (o una sola amb rutes) per a
POST /productes,GET /productes,PUT /productes/{producte}iDELETE /productes/{producte}, darrere d'una HTTP API (lliçó 03). - 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 pelsubdel token de Cognito. - Comprova que
GET /productesfa unaQueryi no unScan, i que editar un producte inexistent retorna 404.
Repàs: quatre preguntes
- 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
Querya una sola partició. - Una
QueryambFilterExpressioncosta 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. - Quina consistència tenen els índexs secundaris globals? Sempre eventual.
- Què passa si fas
UpdateItemsobre 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.