post

DynamoDB-Datenmodellierung: Single-Table-Design

DynamoDB belohnt ein anderes Denken als eine relationale Datenbank: nicht vom Datenmodell her, sondern von den Zugriffsmustern. Single-Table-Design fasst mehrere Entitätstypen über zusammengesetzte Keys in einer Tabelle zusammen.

DynamoDB-Datenmodellierung: Single-Table-Design

In meinen AWS-Schulungen erlebe ich immer wieder denselben Moment. Jemand mit solider SQL-Erfahrung öffnet die DynamoDB-Konsole, legt eine Tabelle an und fragt: „Wo trage ich die Fremdschlüssel ein? Wie mache ich den Join zwischen Nutzern und ihren Beiträgen?" Die kurze Antwort lautet: gar nicht. Die längere Antwort ist der eigentliche Lernstoff – und sie dreht die gewohnte Reihenfolge um.

Bei einer relationalen Datenbank entwerfe ich zuerst ein sauberes, normalisiertes Modell und überlege mir die Abfragen später. Der Optimizer sorgt schon dafür, dass die meisten Queries irgendwie funktionieren. Bei DynamoDB funktioniert das nicht. Hier entwerfe ich von den Zugriffsmustern her: Ich schreibe zuerst auf, welche Fragen die Anwendung an die Daten stellen wird, und leite daraus das Key-Schema ab. Das Datenmodell ist das Ergebnis dieser Muster, nicht ihr Ausgangspunkt. Wer diese Richtung verinnerlicht, für den wird Single-Table-Design plötzlich nachvollziehbar statt exotisch.

Die drei Bausteine, mit denen alles anfängt

Bevor ich über Single-Table rede, kläre ich in der Schulung die Grundbausteine. Ohne sie bleibt jede Erklärung des Musters in der Luft hängen.

Eine DynamoDB-Tabelle hat einen Primary Key in einer von zwei Formen. Entweder ein Simple Primary Key – nur ein Partition Key (PK, historisch „hash key"). Oder ein Composite Primary Key aus Partition Key plus Sort Key (SK, „range key"). Bei der zusammengesetzten Variante muss die Kombination aus PK und SK pro Item eindeutig sein, der PK allein darf sich wiederholen. Genau das ist der Hebel für alles Weitere.

Der Partition Key bestimmt über einen internen Hash, auf welcher physischen Partition ein Item landet. Der Sort Key ordnet die Items innerhalb einer Partition und erlaubt Range-Bedingungen. Alle Items mit demselben Partition Key bilden eine Item Collection – und diese Item Collection ist der eigentliche Kern von Single-Table-Design.

Die Query-API liest immer genau eine Item Collection, also genau einen PK-Wert. Optional schränkt eine Bedingung auf den Sort Key das Ergebnis ein, mit Operatoren wie =, <, <=, >, >=, BETWEEN oder begins_with. Ein Aufruf liefert bis zu 1 MB, danach wird über LastEvaluatedKey paginiert. Der Gegensatz dazu ist der Scan, der die ganze Tabelle durchgeht – das will ich im Produktivbetrieb praktisch nie.

Damit die Rollen klar bleiben, hier die Verteilung, die ich an die Wand male:

  • PK bestimmt Partition und Verteilung. Jede Query braucht genau einen PK-Wert – er entscheidet, welche Item Collection überhaupt gelesen wird.
  • SK ordnet und filtert innerhalb der Collection, über Range-Bedingungen und begins_with. Damit modelliere ich 1
    und Hierarchien.
  • GSI (Global Secondary Index) definiert eine neue PK/SK-Kombination für zusätzliche Zugriffsmuster. Jederzeit hinzufügbar, nur eventually consistent, mit eigenem Durchsatz.

Der LSI (Local Secondary Index) ist der Kontrastfall: gleicher PK wie die Basistabelle, nur ein alternativer SK. Er unterstützt strong consistency, muss aber schon bei der Tabellenerstellung feststehen und bindet die Item Collection je PK an eine 10-GB-Grenze. Im Zweifel nehme ich den GSI.

Warum es keinen Join gibt – und was stattdessen passiert

DynamoDB kennt keine server-seitigen Joins. Das klingt zunächst nach einem Mangel, ist aber eine bewusste Entscheidung für vorhersagbare Latenz. Der Trick: Ich verbinde die Daten nicht beim Lesen, sondern beim Schreiben. Wenn verschiedene Entitätstypen denselben Partition Key tragen, liegen sie in derselben Item Collection und lassen sich mit einem einzigen Query zusammen lesen. Das ist Pre-Joining – die Beziehung ist schon im Key-Schema angelegt, bevor der erste Lesezugriff passiert.

Nehmen wir ein kleines CMS mit Nutzern, deren Beiträgen und den Kommentaren zu einem Beitrag. Das zentrale Zugriffsmuster lautet: „Hole einen Nutzer und alle seine Beiträge in einem Aufruf." Genau daraus leite ich das Key-Design ab, nicht aus einem ER-Diagramm.

flowchart LR
  A["Zugriffsmuster:<br/>Hole User + seine Posts"] --> B["PK wählen:<br/>USER#id"]
  B --> C["SK-Prefix wählen:<br/>USER# und POST#"]
  C --> D["Query auf PK = USER#123<br/>liefert eine Item Collection"]
  D --> E["Ergebnis:<br/>User-Item + alle Post-Items"]

Die Ableitungsrichtung ist wichtig: Ich starte links beim Muster und arbeite mich zum Key vor. Das ist die Denkbewegung, die relationale Entwickler am Anfang irritiert.

Ein Single-Table-Layout

Single-Table-Design heißt: mehrere Entitätstypen in einer einzigen Tabelle, zusammengehalten über generische, zusammengesetzte Keys. Statt sprechender Attributnamen wie UserId nutze ich bewusst generische Namen wie PK und SK, denn dieselbe Spalte trägt je nach Entitätstyp unterschiedliche Bedeutungen. Für das CMS sieht ein Layout so aus:

PK              SK                    weitere Attribute
--------------  --------------------  -----------------------------
USER#123        USER#123              name, email, createdAt
USER#123        POST#2021-09-01T...   title, body, status
USER#123        POST#2021-09-14T...   title, body, status
POST#987        COMMENT#2021-09-15..  author, text
POST#987        COMMENT#2021-09-16..  author, text

Der Nutzer und seine Beiträge teilen sich den Partition Key USER#123 und bilden damit eine Item Collection. Das User-Item hat einen SK, der den PK wiederholt (USER#123), die Beiträge nutzen den Prefix POST# gefolgt von einem Zeitstempel. Der Zeitstempel im Sort Key sortiert die Beiträge nebenbei chronologisch – ganz ohne zusätzlichen Index.

Für das Muster „Hole alle Kommentare zu einem Beitrag" reicht der Primärindex nicht, weil Kommentare unter einem anderen PK sinnvoll gebündelt sind. Hier kommt ein GSI ins Spiel. Ich lege zusätzliche Attribute GSI1PK und GSI1SK an und projiziere darüber einen Index, der Post-zu-Comment rückwärts lesbar macht. Das ist die Idee des Inverted Index: ein GSI mit vertauschten Rollen, um eine Beziehung aus der Gegenrichtung zu lesen.

Die Tabelle selbst definiere ich mit generischen Keys und einem GSI. Ein Ausschnitt der CreateTable-Parameter:

{
  "TableName": "cms",
  "BillingMode": "PAY_PER_REQUEST",
  "AttributeDefinitions": [
    { "AttributeName": "PK", "AttributeType": "S" },
    { "AttributeName": "SK", "AttributeType": "S" },
    { "AttributeName": "GSI1PK", "AttributeType": "S" },
    { "AttributeName": "GSI1SK", "AttributeType": "S" }
  ],
  "KeySchema": [
    { "AttributeName": "PK", "KeyType": "HASH" },
    { "AttributeName": "SK", "KeyType": "RANGE" }
  ],
  "GlobalSecondaryIndexes": [
    {
      "IndexName": "GSI1",
      "KeySchema": [
        { "AttributeName": "GSI1PK", "KeyType": "HASH" },
        { "AttributeName": "GSI1SK", "KeyType": "RANGE" }
      ],
      "Projection": { "ProjectionType": "ALL" }
    }
  ]
}

BillingMode: PAY_PER_REQUEST ist der On-Demand-Modus, der seit Ende 2018 verfügbar ist. Für Beispiele und schwankende Last nehme ich ihn gerne, weil ich mich nicht um vorab reservierte Kapazität kümmern muss. Bei einem GSI wähle ich über ProjectionType bewusst, welche Attribute mitkommen – KEYS_ONLY, INCLUDE oder ALL. ALL ist bequem, kostet aber mehr Speicher und Schreib-Durchsatz.

Der Query über eine Item Collection

Jetzt der Teil, der die ganze Modellierung rechtfertigt. Mit dem SDK v3 und dem DynamoDBDocumentClient aus @aws-sdk/lib-dynamodb hole ich alle Beiträge eines Nutzers mit genau einem Aufruf:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

async function getPostsByUser(userId: string) {
  const result = await client.send(
    new QueryCommand({
      TableName: "cms",
      KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
      ExpressionAttributeValues: {
        ":pk": `USER#${userId}`,
        ":prefix": "POST#",
      },
    })
  );

  return result.Items ?? [];
}

Die KeyConditionExpression adressiert genau eine Item Collection über PK = :pk und schneidet mit begins_with(SK, :prefix) die Beiträge heraus. DynamoDB liest nur die Items, die auf POST# passen – der Prefix wirkt direkt auf dem Sort Key, nicht als nachträglicher Filter. Will ich den Nutzer samt aller Beiträge, lasse ich die begins_with-Bedingung weg und bekomme die komplette Collection in einem Rutsch.

Für das Rückwärtsmuster über den Inverted Index ändert sich nur wenig. Ich adressiere den GSI und nutze dessen Keys:

async function getCommentsByPost(postId: string) {
  const result = await client.send(
    new QueryCommand({
      TableName: "cms",
      IndexName: "GSI1",
      KeyConditionExpression: "GSI1PK = :g AND begins_with(GSI1SK, :prefix)",
      ExpressionAttributeValues: {
        ":g": `POST#${postId}`,
        ":prefix": "COMMENT#",
      },
    })
  );

  return result.Items ?? [];
}

Das ist im Kern dieselbe Query-Mechanik, nur auf einem anderen Index. Wer das Primärmuster verstanden hat, überträgt es ohne Umgewöhnung auf den GSI.

Der Fallstrick mit dem FilterExpression

Ein Missverständnis, das ich in fast jeder Schulung ausräume: Die FilterExpression ist keine günstige Abkürzung. Anders als die KeyConditionExpression filtert sie erst, nachdem die Items gelesen wurden. Das heißt, ich bezahle die Read Capacity Units auch für die Items, die der Filter anschließend wegwirft. Schlimmer noch, in Kombination mit Limit kann ich leere Seiten zurückbekommen, obwohl es passende Daten gibt – weil das Limit vor dem Filter greift.

Die Konsequenz ist klar: Selektivität gehört ins Key-Design, nicht in den Filter. Wenn ich häufig nach einem Kriterium filtere, sollte dieses Kriterium in den Sort Key oder in einen GSI wandern. FilterExpression nutze ich nur für seltene, unkritische Verfeinerungen am Rand.

Wann Single-Table lohnt – und wann es überzogen ist

So elegant das Muster ist, es ist kein Selbstzweck. Ich sehe regelmäßig Projekte, die Single-Table einführen, weil es in einem Konferenzvortrag beeindruckend aussah, und die sich damit unnötige Komplexität einhandeln. Deshalb ordne ich die Motivation immer ehrlich ein.

Single-Table lohnt sich, wenn die Zugriffsmuster stabil und bekannt sind und wenn es darauf ankommt, viele zusammengehörige Daten mit möglichst wenigen Roundtrips zu lesen. Der Gewinn ist Performance und Skalierung bei vorhersagbarer Latenz, nicht Speicherersparnis. Datenduplizierung und Denormalisierung nehme ich dabei bewusst in Kauf – das ist ein Feature, kein Versehen.

Überzogen ist der Ansatz in mehreren Situationen:

  • Die Zugriffsmuster sind früh im Projekt noch unklar oder ändern sich häufig. Single-Table macht nachträgliche Änderungen der Muster teuer, weil das Key-Schema tief in den Daten verankert ist.
  • Die Anwendung braucht Ad-hoc-Auswertungen oder Analytics. Ohne SQL wird die Export- und Reporting-Story mühsam, und ein Single-Table-Layout mit vielen Entitätstypen macht das nicht einfacher.

Alex DeBrie, dessen Schriften ich in dem Zusammenhang gerne empfehle, nennt einen weiteren interessanten Fall: GraphQL. Ein GraphQL-Server löst Felder ohnehin einzeln über Resolver auf, sodass der große Vorteil des Ein-Query-Ladens verpufft. Dort gibt ein Multi-Table-Ansatz oft mehr Flexibilität. Wie sich GraphQL und DynamoDB auf AWS konkret zusammenspielen lassen, habe ich in GraphQL-APIs mit AppSync und DynamoDB beschrieben.

Ein technisches Detail, das bei vielen Entitätstypen in einer Tabelle zum Problem wird, sind DynamoDB Streams: AWS empfiehlt, pro Shard nicht mehr als zwei gleichzeitige Consumer zu betreiben, sonst drohen Throttling-Effekte. Wenn unterschiedliche Entitäten unterschiedliche Verarbeitungslogik brauchen, drängen sich schnell mehr Consumer auf, als ein Stream verträgt.

Ein Wort zu den harten Grenzen

Damit die Modellierung nicht an physischen Limits scheitert, halte ich ein paar Zahlen präsent, die 2021 stabil gelten. Ein Item darf maximal 400 KB groß sein. Ein Query liefert pro Aufruf höchstens 1 MB, danach wird paginiert. BatchGetItem holt bis zu 100 Items oder 16 MB. Pro Tabelle sind standardmäßig bis zu 20 GSIs und maximal 5 LSIs möglich. Diese Grenzen zwingen zu bewusstem Design – etwa dazu, sehr große oder unbegrenzt wachsende Item Collections gar nicht erst entstehen zu lassen.

Fazit

Der entscheidende Umbau findet im Kopf statt, nicht im Code. Wer DynamoDB wie eine relationale Datenbank behandelt, kämpft gegen das System. Wer bei den Zugriffsmustern beginnt und daraus Partition Key, Sort Key und passende GSIs ableitet, arbeitet mit ihm. Single-Table-Design ist dann kein Selbstzweck, sondern die logische Konsequenz aus dem Wunsch, zusammengehörige Daten mit einem Query und ohne Join zu lesen.

Ich rate in Schulungen zu einem pragmatischen Weg: Erst die Zugriffsmuster aufschreiben, dann das Key-Schema entwerfen, dann prüfen, ob ein einziger Primärindex plus wenige GSIs die Muster abdeckt. Wenn die Muster noch wackeln oder Ad-hoc-Analysen zentral sind, ist ein einfacheres Multi-Table-Layout die ehrlichere Wahl. Das Ziel ist nie das eleganteste Diagramm, sondern die Anwendung, die unter Last vorhersagbar bleibt.

Weiterführende Quellen

Kommentare