# DynamoDB-Datenmodellierung: Single-Table-Design

URL: https://www.mikebild.dev/de/blog/dynamodb-datenmodellierung-single-table/

Wer in DynamoDB den Partition Key festlegt, hat damit bereits entschieden, welche Abfragen später billig sind und welche gar nicht gehen. Es gibt keinen Optimizer, der eine ungeschickte Wahl im Nachhinein rettet, und keinen server-seitigen Join, der zwei Tabellen zur Laufzeit verbindet. Die Frage „Wo trage ich die Fremdschlüssel ein?“ hat schlicht keine Antwort – die Beziehung muss im Key stehen, bevor das erste Item geschrieben wird.

Damit dreht sich 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 schreibe ich 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.

## Die drei Bausteine, mit denen alles anfängt

Single-Table steht und fällt mit drei Grundbausteinen. Solange die nicht sitzen, hängt jede Beschreibung des Musters in der Luft.

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, die Aufgabenverteilung im Überblick:

- 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:n-Beziehungen 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.

```mermaid
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:

```text
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:

```json
{
  "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:

```ts
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:

```ts
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

Die `FilterExpression` sieht aus wie eine günstige Abkürzung, ist aber keine. 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. Single-Table wird auch dort eingeführt, wo es vor allem im Konferenzvortrag beeindruckend aussah – und handelt der Anwendung dann Komplexität ein, die ihr niemand zurückzahlt. Deshalb lohnt der ehrliche Blick auf die Motivation.

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 Texte zu DynamoDB ich gerne weiterreiche, nennt einen weiteren 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 GraphQL und DynamoDB auf AWS konkret zusammenspielen, habe ich in [GraphQL-APIs mit AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/) 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 folgt dann aus dem Wunsch, zusammengehörige Daten mit einem Query und ohne Join zu lesen – und nicht aus dem Reiz des Musters selbst.

Pragmatisch führt der Weg über drei Schritte: 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

- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-aws-serverless.html
- Repository und begleitender Artikel: [GraphQL-APIs mit AppSync und DynamoDB](https://www.mikebild.dev/de/blog/appsync-dynamodb-graphql-api/)
- AWS Docs, Core Components (Tables, Items, Keys, Item Collections): https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html
- AWS Docs, Query-API: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Query.html
- AWS Docs, Global Secondary Indexes: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GSI.html
- AWS Docs, Local Secondary Indexes: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LSI.html
- AWS Docs, Best Practices und relationale Modellierung: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-relational-modeling.html
- Alex DeBrie, „The What, Why, and When of Single-Table Design“: https://www.alexdebrie.com/posts/dynamodb-single-table/
- Alex DeBrie, „How to model one-to-many relationships“: https://www.alexdebrie.com/posts/dynamodb-one-to-many/
- AWS Database Blog, „Single-table vs. multi-table design in Amazon DynamoDB“: https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/
