Zum Inhalt springen

Datenmodellierungsmuster für Dokumentdatenbanken

Praktische Datenmodellierung für MongoDB und Dokumentdatenbanken: Einbetten vs. Referenzieren, Denormalisierung und Muster der Schemaevolution.

4 Min. Lesezeit
Diagramm zum Vergleich eingebetteter und referenzierter Dokumentstrukturen in MongoDB

Dokumentdatenbanken speichern Daten anders als relationale Datenbanken. Es gibt keine JOINs, keine Fremdschlüssel und standardmäßig keine Schemaerzwingung. Das ist keine Einschränkung — es ist ein anderer Satz von Abwägungen. Das Dokumentenmodell ist darauf optimiert, vollständige Objekte in einer einzigen Abfrage zu lesen, auf Kosten komplexerer Schreibmuster.

Die meisten Probleme mit Dokumentdatenbanken entstehen, wenn Daten relational modelliert werden. Alles in separate Sammlungen zu normalisieren und dann im Anwendungscode zu „joinen“ konterkariert den Sinn. Der richtige Ansatz ist Modellierung nach deinen Zugriffsmustern.

Einbettung vs. Referenzierung

Die fundamentale Entscheidung bei der Dokumentenmodellierung: Sollen verwandte Daten innerhalb des übergeordneten Dokuments leben (eingebettet) oder in einer separaten Sammlung (referenziert)?

tstypescript
// ❌ Übernormalisiert — relationales Denken in einer Dokumentdatenbank
// Drei Sammlungen, drei Abfragen, um eine Seitenansicht aufzubauen
const user = await db.users.findOne({ _id: userId });
const address = await db.addresses.findOne({ userId });
const preferences = await db.preferences.findOne({ userId });
 
// Im Anwendungscode zusammengesetzt:
const profile = { ...user, address, preferences };
tstypescript
// ✅ Eingebettet — ein einziger Lesezugriff liefert das vollständige Objekt
// Eine Sammlung, eine Abfrage
const profile = await db.users.findOne({ _id: userId });
 
// Dokumentenstruktur:
{
  _id: "user123",
  name: "Alice Chen",
  email: "alice@example.com",
  address: {
    street: "123 Main St",
    city: "Portland",
    state: "OR",
    zip: "97201"
  },
  preferences: {
    theme: "dark",
    language: "en",
    notifications: {
      email: true,
      push: false
    }
  }
}

Einbetten, wenn:

  • Die verwandten Daten immer zusammen mit dem übergeordneten Element auftreten (Adresse gehört zum Benutzer)
  • Die eingebetteten Daten nicht unbegrenzt wachsen
  • Du die eingebetteten Daten selten unabhängig benötigst

Wann referenzieren

Referenzieren ist richtig, wenn eingebettete Daten unbegrenzt wachsen würden, dieselben Daten in mehreren Kontexten auftreten oder das eingebettete Dokument das 16-MB-Dokumentgrößenlimit von MongoDB überschreiten würde.

tstypescript
// ❌ Unbegrenzte Arrays einbetten — Dokument wächst ewig
{
  _id: "user123",
  name: "Alice",
  orders: [
    { orderId: "ord1", total: 59.99, items: [...] },
    { orderId: "ord2", total: 129.50, items: [...] },
    // ... 10.000 weitere Bestellungen über 5 Jahre
    // Dokument überschreitet 16 MB, Abfragen werden langsamer
  ]
}
tstypescript
// ✅ Referenziert — Bestellungen in separater Sammlung
// Benutzer-Sammlung:
{
  _id: "user123",
  name: "Alice",
  email: "alice@example.com"
}
 
// Bestell-Sammlung — indiziert nach userId für schnelle Nachschläge:
{
  _id: "ord456",
  userId: "user123",
  total: 59.99,
  status: "delivered",
  createdAt: ISODate("2021-06-01"),
  items: [
    { productId: "prod1", name: "Widget", quantity: 2, price: 29.99 }
  ]
}
 
// Abfrage: aktuelle Bestellungen eines Benutzers
const orders = await db.orders
  .find({ userId: "user123" })
  .sort({ createdAt: -1 })
  .limit(10);

Referenzieren, wenn:

  • Die verwandten Daten unbegrenzt wachsen (Bestellungen, Logs, Kommentare)
  • Die verwandten Daten einen eigenen Lebenszyklus haben (Produkte existieren unabhängig von Bestellungen)
  • Du die verwandten Daten unabhängig abfragen musst (alle Bestellungen über 100 $)

Das Subset-Muster

Wenn du einige eingebettete Daten für häufige Lesevorgänge benötigst, die vollständige Menge aber zu groß ist, bette eine Teilmenge ein und referenziere die vollständige Sammlung.

tstypescript
// Produktdokument mit den 10 neuesten eingebetteten Bewertungen
{
  _id: "prod789",
  name: "Wireless Headphones",
  price: 79.99,
  rating: 4.3,
  reviewCount: 2847,
  // Neueste Bewertungen für die Produktseite einbetten
  recentReviews: [
    {
      userId: "user1",
      userName: "Bob",
      rating: 5,
      text: "Great sound quality",
      createdAt: ISODate("2021-06-07")
    },
    {
      userId: "user2",
      userName: "Carol",
      rating: 4,
      text: "Good but pricey",
      createdAt: ISODate("2021-06-05")
    }
    // ... bis zu 10 neueste Bewertungen
  ]
}
 
// Vollständige Bewertungssammlung — für Paginierung, Suche und Analyse
{
  _id: "rev123",
  productId: "prod789",
  userId: "user1",
  userName: "Bob",
  rating: 5,
  text: "Great sound quality",
  createdAt: ISODate("2021-06-07"),
  helpful: 23,
  verified: true
}
tstypescript
// Produktseite: Einzelabfrage liefert Produkt + neueste Bewertungen
const product = await db.products.findOne({ _id: productId });
// product.recentReviews ist bereits vorhanden — kein JOIN nötig
 
// Seite „Alle Bewertungen anzeigen“: paginierte Abfrage der Bewertungssammlung
const allReviews = await db.reviews
  .find({ productId })
  .sort({ createdAt: -1 })
  .skip(page * pageSize)
  .limit(pageSize);

Die Abwägung ist Schreibkomplexität. Wenn eine neue Bewertung hinzugefügt wird, aktualisierst du sowohl die Bewertungssammlung als auch das recentReviews-Array des Produkts. Das ist akzeptabel, weil Bewertungen weitaus häufiger gelesen als geschrieben werden.

Das Extended-Reference-Muster

Speichere eine Kopie häufig abgerufener Felder aus referenzierten Dokumenten, um Nachschläge für gängige Abfragen zu vermeiden.

tstypescript
// ❌ Bestellung referenziert userId — zweite Abfrage für Benutzernamen nötig
{
  _id: "ord456",
  userId: "user123",  // Benutzer muss nachgeschlagen werden, um den Namen anzuzeigen
  total: 59.99
}
 
// Anzeige „Bestellung von Alice Chen“ erfordert:
const order = await db.orders.findOne({ _id: orderId });
const user = await db.users.findOne({ _id: order.userId });
const display = `Order by ${user.name}`;
tstypescript
// ✅ Erweiterte Referenz — Kopiere die Felder, die du zur Anzeige brauchst
{
  _id: "ord456",
  userId: "user123",
  userName: "Alice Chen",  // Aus Benutzerdokument kopiert
  userEmail: "alice@example.com",  // Für E-Mail-Belege kopiert
  total: 59.99,
  createdAt: ISODate("2021-06-08")
}
 
// Einzelabfrage liefert alles Nötige zur Anzeige
const order = await db.orders.findOne({ _id: orderId });
const display = `Order by ${order.userName}`;  // Keine zweite Abfrage

Die kopierten Felder sind denormalisiert — sie können veralten, wenn der Benutzer seinen Namen ändert. Das ist bei Bestellungen akzeptabel (der Name zum Zeitpunkt des Kaufs ist entscheidend), wäre aber ein Problem für eine Echtzeit-Chat-Anzeige.

Schema-Validierung

Dokumentdatenbanken erzwingen Schemas standardmäßig nicht, aber MongoDB unterstützt JSON-Schema-Validierung, um schlechte Daten davon abzuhalten, in Sammlungen zu gelangen.

tstypescript
// Sammlung mit Schema-Validierung erstellen
await db.createCollection('users', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['name', 'email', 'createdAt'],
      properties: {
        name: {
          bsonType: 'string',
          minLength: 1,
          maxLength: 200,
        },
        email: {
          bsonType: 'string',
          pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$',
        },
        role: {
          enum: ['admin', 'user', 'moderator'],
        },
        address: {
          bsonType: 'object',
          properties: {
            street: { bsonType: 'string' },
            city: { bsonType: 'string' },
            zip: { bsonType: 'string', pattern: '^[0-9]{5}$' },
          },
        },
        createdAt: {
          bsonType: 'date',
        },
      },
    },
  },
  validationAction: 'error',  // Ungültige Dokumente ablehnen
  validationLevel: 'strict',  // Alle Einfügungen und Aktualisierungen validieren
});

Schema-Validierung erkennt Datenqualitätsprobleme auf Datenbankebene. Selbst wenn der Anwendungscode einen Fehler hat, der fehlerhafte Daten sendet, lehnt die Datenbank sie ab.

Schema-Evolution

Dokumente entwickeln sich mit den Funktionen weiter. Im Gegensatz zu relationalen Migrationen, die Tabellen verändern, entwickeln sich Dokumentenschemas durch Migrationsmuster auf Anwendungsebene.

tstypescript
// Schema-Versionsmuster — mehrere Versionen im Code behandeln
interface UserV1 {
  _id: string;
  name: string;        // einzelnes Namensfeld
  email: string;
}
 
interface UserV2 {
  _id: string;
  firstName: string;   // Name in zwei Felder aufgeteilt
  lastName: string;
  email: string;
  schemaVersion: 2;
}
 
type User = UserV1 | UserV2;
 
// Lesefunktion behandelt beide Versionen
function normalizeUser(doc: User): NormalizedUser {
  if ('schemaVersion' in doc && doc.schemaVersion === 2) {
    return {
      firstName: doc.firstName,
      lastName: doc.lastName,
      email: doc.email,
    };
  }
 
  // V1: das einzelne Namensfeld aufteilen
  const [firstName, ...rest] = doc.name.split(' ');
  return {
    firstName,
    lastName: rest.join(' ') || '',
    email: doc.email,
  };
}
 
// Faule Migration: Dokumente beim Lesen aktualisieren
async function getUser(id: string): Promise<NormalizedUser> {
  const doc = await db.users.findOne({ _id: id });
  const normalized = normalizeUser(doc);
 
  // Wenn Dokument altes Format hat, opportunistisch aktualisieren
  if (!('schemaVersion' in doc)) {
    await db.users.updateOne(
      { _id: id },
      {
        $set: {
          firstName: normalized.firstName,
          lastName: normalized.lastName,
          schemaVersion: 2,
        },
        $unset: { name: '' },
      }
    );
  }
 
  return normalized;
}

Faule Migration aktualisiert Dokumente, wenn auf sie zugegriffen wird. Mit der Zeit migrieren die meisten Dokumente zum neuen Schema. Für selten zugegriffene Dokumente kann ein Hintergrundjob die verbleibenden alten Formate durchlaufen.

Wichtige Erkenntnisse

  1. Bette zusammengehörige Daten ein — wenn du Adresse und Benutzer immer zusammen brauchst, packe sie in dasselbe Dokument
  2. Referenziere unbegrenzte Eins-zu-viele-Beziehungen — Bestellungen, Logs und Kommentare gehören in separate Sammlungen
  3. Nutze das Subset-Muster für Hot Data — bette neueste Bewertungen im Produkt ein, paginiere die vollständige Menge aus einer separaten Sammlung
  4. Denormalisiere für Leseperformance — kopiere häufig angezeigte Felder, um Nachschläge zu vermeiden, und akzeptiere Schreibkomplexität
  5. Füge Schema-Validierung hinzu — Dokumentdatenbanken sollten nicht schemalos bedeuten; validiere auf Datenbankebene
  6. Entwickle Schemas faul weiter — behandle mehrere Versionen im Code, migriere Dokumente beim Lesen
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX