Intermedio9 min di lettura

DynamoDB JOIN: come unire le tabelle (e perché di solito non puoi)

Non esiste JOIN in DynamoDB. L'API non ha un operatore di join, il modello dati non ha chiavi esterne e — la parte che sorprende la maggior parte delle persone — nemmeno PartiQL, il livello di query in stile SQL, ne aggiunge uno. Una SELECT PartiQL legge esattamente una tabella.

Se vieni da un database relazionale, questo è il primo muro contro cui sbatti. Questa guida copre il perché di quel muro, le quattro cose che gli sviluppatori fanno al suo posto, l'unico caso in cui hai davvero bisogno di un vero join — e come eseguirne uno.

DynamoDB può fare join?

No. DynamoDB non può unire le tabelle — né tramite l'API di basso livello (GetItem / Query / Scan / BatchGetItem), né tramite PartiQL, né tramite alcun query planner integrato, perché non esiste. Ogni lettura mappa su una singola tabella o su uno dei suoi indici; combinare due tabelle su una chiave corrispondente è qualcosa che fai nella tua app dopo che DynamoDB ha restituito gli elementi, mai al suo interno.

  • DynamoDB non ha l'operatore JOIN. Non l'ha mai avuto.
  • La SELECT di PartiQL è solo a singola tabella — la grammatica è letteralmente SELECT … FROM {{table}}[.{{index}}], e puntarla a due tabelle restituisce ValidationException: Only Select from a Single Table or index supported.
  • La soluzione che AWS consiglia è non aver bisogno di un join: denormalizza, oppure usa il single-table design in modo che gli elementi correlati vivano in una sola partizione che recuperi in un'unica richiesta.
  • Per il caso reale cross-tabella / ad hoc, esegui il join fuori da DynamoDB — nella tua app o con uno strumento che lo fa per te.

Perché DynamoDB non ha join

Un JOIN SQL chiede al database di leggere più tabelle e assemblarle al momento della query. La guida di AWS alla modellazione di dati relazionali ne spiega il costo: una query come

SELECT * FROM Orders
  INNER JOIN Order_Items ON Orders.Order_ID = Order_Items.Order_ID
  INNER JOIN Products    ON Products.Product_ID = Order_Items.Product_ID
  ORDER BY Quantity_on_Hand DESC

è flessibile, ma "ogni join nella query aumenta la complessità di runtime della query perché i dati di ciascuna tabella devono essere preparati e poi assemblati". Quel lavoro non è limitato — il suo costo dipende dai dati, non dalla query — che è esattamente la proprietà che DynamoDB rifiuta di avere.

Quindi AWS ha progettato il vincolo a monte. DynamoDB è, nelle loro parole, "costruito per minimizzare entrambi i vincoli [CPU e rete] eliminando i JOIN (e incoraggiando la denormalizzazione dei dati) e ottimizzando l'architettura del database per rispondere completamente a una query dell'applicazione con una singola richiesta a un elemento". Queste sono le qualità che garantiscono una latenza di pochi millisecondi a qualsiasi scala: il costo di runtime di una lettura DynamoDB è costante indipendentemente dalla dimensione della tabella. Non c'è un motore di join né un concetto di chiave esterna su cui pianificare — per progettazione.

"Ma PartiQL è SQL, di sicuro fa i join?"

No. PartiQL ti dà la sintassi SELECT / INSERT / UPDATE / DELETE su DynamoDB, ma è SQL-compatibile, non SQL. La grammatica ufficiale di SELECT è:

SELECT  {{expression}}  [, ...]
FROM    {{table}}[.{{index}}]
[ WHERE {{condition}} ]
[ ORDER BY {{key}} [DESC|ASC], ... ]

FROM prende una tabella (facoltativamente uno dei suoi indici). Non c'è una seconda tabella in FROM, nessun JOIN, nessuna subquery, nessuna CTE. Punta PartiQL a due tabelle e DynamoDB la rifiuta (segnalato su AWS re:Post):

ValidationException: Only Select from a Single Table or index supported

Se vuoi il ragionamento completo sul perché PartiQL assomiglia a SQL ma non può comportarsi come tale, vedi PartiQL vs SQL.

I 4 workaround che gli sviluppatori usano davvero

1. Denormalizzare (copiare i dati dentro)

Memorizza i campi che altrimenti uniresti direttamente sull'elemento. Un Order porta con sé uno snapshot di customerName e shippingAddress invece di un customerId da risolvere in seguito. Una lettura, nessun join.

Il costo è il fan-out in scrittura: quando la sorgente cambia aggiorni ogni copia (in genere tramite un handler di DynamoDB Streams). Stai scambiando la complessità in lettura con la complessità in scrittura — di solito un buon affare per un'app con molte letture.

2. Single-table design (pre-join nella partizione)

Metti le entità correlate in una sola tabella sotto una chiave di partizione condivisa, in modo che una collezione di elementi sia il risultato unito. Un cliente e tutti i suoi ordini condividono PK = "CUSTOMER#42"; una sola Query restituisce l'elemento cliente più ogni elemento ordine — il "join" è già avvenuto al momento della scrittura.

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (il cliente)
→ CUSTOMER#42 / ORDER#1001   (un ordine)
→ CUSTOMER#42 / ORDER#1002   (un ordine)

Questa è la risposta canonica di DynamoDB alle relazioni uno-a-molti. Guida completa in single-table design.

3. Join lato applicazione (due letture, ricucite nel codice)

Leggi dalla tabella A, prendi le chiavi che hai ottenuto, leggi dalla tabella B e unisci i due set di risultati nella tua applicazione. È la logica del join relazionale — solo che gira nel tuo codice invece che nel database:

// "Recupera ogni ordine con il nome del suo cliente" — il join manuale.
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});

const customers = await Promise.all(
  orders.map((o) => ddb.getItem({TableName: 'Customers', Key: {id: o.customerId}}))
);

const joined = orders.map((o, i) => ({
  ...o,
  customerName: customers[i].Item?.name
}));

Va bene per un fan-out piccolo. Con molti ordini diventa un problema N+1 — una lettura per elencare gli ordini, poi una lettura per ordine — che è lento e consuma capacità di lettura. BatchGetItem (il prossimo) collassa quella seconda ondata in un unico round-trip.

4. BatchGetItem (un round-trip, più tabelle)

BatchGetItem è il punto più vicino in cui l'API arriva a "toccare due tabelle in una volta": una richiesta restituisce "gli attributi di uno o più elementi da una o più tabelle", fino a 100 elementi o 16 MB per chiamata, qualunque limite venga raggiunto per primo. Riduce i round-trip di un join lato app — ma non è un join. Tu "identifichi gli elementi richiesti tramite chiave primaria"; non c'è una condizione ON né alcun matching relazionale. Devi comunque conoscere le chiavi in anticipo e ricucire tu stesso le risposte.

Quando un vero JOIN è inevitabile

I quattro workaround coprono bene i percorsi di lettura in produzione. Dove cadono è la query ad hoc, esplorativa, analitica — quella per cui non hai modellato:

  • "Quali clienti nell'UE hanno effettuato un ordine superiore a 500 $ il mese scorso?" su una tabella Orders e una tabella Customers.
  • Un controllo una tantum di qualità dei dati che unisce due tipi di entità.
  • Report e aggregati (GROUP BY, SUM, COUNT) — per i quali DynamoDB non ha alcun operatore.

Queste sono esattamente le query che non puoi pre-cuocere in una partizione, perché per definizione non sapevi che le avresti poste. L'istinto relazionale — scrivere un JOIN — è quello giusto qui. DynamoDB semplicemente non può servirlo nativamente, e nemmeno PartiQL.

La solita risposta pesante è esportare su S3 e fare query con Athena, oppure incanalare in un data warehouse. È corretto per la vera analitica su larga scala, ma è un sacco di idraulica per una domanda a cui vuoi rispondere ora, sulla tua tabella in produzione.

Eseguire un vero JOIN con il Workbench SQL di DynoTable

DynoTable è un client DynamoDB desktop il cui Workbench SQL esegue vero SQL — incluso JOIN, GROUP BY e funzioni di aggregazione — sulle tue tabelle DynamoDB. Legge gli elementi tramite la normale API DynamoDB, poi esegue le parti relazionali della query nel client. Così puoi scrivere:

SELECT  c.name, SUM(o.total) AS spend
FROM    Customers c
JOIN    Orders o ON o.customerId = c.id
WHERE   c.region = 'EU'
GROUP BY c.name
HAVING  SUM(o.total) > 500

— e ottenere un set di risultati, su tabelle che non hanno alcuna relazione definita e con un query engine che non ha la keyword JOIN.

L'onesta avvertenza — "all'interno delle regole di pattern di accesso di DynamoDB": il Workbench legge comunque tramite DynamoDB, quindi un join illimitato è una lettura illimitata. Le query più veloci sono quelle in cui la clausola WHERE (o l'attributo ON del join) colpisce una chiave di partizione o un GSI su almeno un lato, così DynamoDB esegue una Query invece di uno scan completo della tabella prima che il join venga eseguito. Il Workbench non abroga i vincoli di questa guida — ti permette solo di porre la domanda SQL invece di scrivere a mano la ricucitura, e ti dice cosa sta facendo sotto il cofano.

È l'unico "sì, puoi fare il join" che è davvero vero: PartiQL e il NoSQL Workbench di AWS — il cui operation builder è limitato a operazioni del piano dati a singola tabella (Query / Scan / GetItem) — si fermano entrambi al muro della singola tabella, come la maggior parte degli altri client GUI. Vedi come DynoTable si confronta come GUI DynamoDB.

FAQ

PartiQL supporta JOIN? No. La SELECT di PartiQL legge una singola tabella (o uno dei suoi indici). Una query multi-tabella restituisce ValidationException: Only Select from a Single Table or index supported. Lo stesso muro del resto dell'API.

Puoi unire due tabelle DynamoDB in una sola query? Non nativamente. L'API DynamoDB non ha alcuna istruzione che legga due tabelle e le faccia corrispondere su una chiave. BatchGetItem può leggere elementi da più tabelle in una richiesta, ma non ha una condizione ON — restituisce gli elementi che hai nominato per chiave primaria e lascia a te il matching. Un vero JOIN … ON … avviene solo fuori da DynamoDB: nella tua app o nel Workbench SQL di DynoTable.

Puoi unire una tabella al suo GSI? No — un Global Secondary Index non è una tabella separata a cui ti unisci; è una vista alternativa per chiave degli stessi elementi. In una data SELECT fai Query sulla tabella oppure sull'indice, non su entrambi uniti. Un GSI ti permette di raggiungere gli elementi tramite una chiave diversa, il che spesso elimina del tutto la necessità di un join.

Puoi unire due account AWS (o due tabelle in account diversi)? Non nativamente, e nemmeno con BatchGetItem — una singola richiesta non può attraversare più credenziali, e non esiste una primitiva di join cross-account. Dovresti leggere ogni tabella con le credenziali del proprio account e unire i risultati nella tua applicazione o in uno strumento come il Workbench di DynoTable.

La denormalizzazione è davvero meglio di un join? Per il carico di lavoro target di DynamoDB — letture prevedibili ad alto volume — sì. Sposti il costo al momento della scrittura (e accetti una certa duplicazione dei dati) in cambio di letture in singola richiesta che scalano in modo piatto. La guida single-table design copre i compromessi.


Costruire a mano le chiavi e le condizioni per queste letture è macchinoso — l'expression builder genera per te la sintassi KeyConditionExpression / FilterExpression, e DynoTable esegue il vero SQL quando un workaround non basta.

Aggiornato