Zum Inhalt springen

Grundlagen des Systemdesigns, die jeder Entwickler kennen sollte

Die zentralen Konzepte skalierbaren Systemdesigns: Lastverteilung, Caching, Datenbankskalierung, Queues und die Abwägungen hinter Architektur.

3 Min. Lesezeit
Diagramm von Microservices, die über einen Load Balancer zu einem Server-Cluster gelangen, das sich zu einem CDN und einer Datenbank für Lese-Skalierung sowie zu einer Message Queue aufteilt.

Warum jeder Entwickler Systemdesign verstehen sollte

Man muss kein Architekt sein, um von Systemdesign-Denken zu profitieren. Wer versteht, wie die Teile zusammenspielen, wird auf jeder Ebene zu einem besseren Entwickler — es verändert, wie man Code schreibt, wie man Probleme in der Produktion debuggt und wie man Kompromisse bei alltäglichen Entscheidungen abwägt.

Konzept 1: Horizontale vs. vertikale Skalierung

Vertikale Skalierung — die Maschine größer machen (mehr CPU, mehr RAM). Einfach, aber mit einer Obergrenze und einem Single Point of Failure.

Horizontale Skalierung — weitere Maschinen hinzufügen. Erfordert eine zustandslose Anwendung.

Vertical:        Horizontal:
    [Big Server]     [Server] [Server] [Server]
         |               |       |       |
      [DB]           [Load Balancer]
                           |
                         [DB]

Die zentrale Erkenntnis: Zustandslose Anwendungen (bei denen keine Benutzerdaten im Speicher der Anwendung liegen) lassen sich horizontal skalieren. Speichert dein Server Sitzungsdaten im Arbeitsspeicher, kannst du denselben Benutzer nicht auf unterschiedliche Server verteilen.

Lösung: den Zustand auslagern.

tstypescript
// ❌ In-memory session — breaks horizontal scaling
const sessions = new Map<string, Session>();
app.use((req, res, next) => {
  req.session = sessions.get(req.cookies.sessionId);
  next();
});
 
// ✅ Externalized session — any server can serve any user
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
 
app.use(
  session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET,
  }),
);

Konzept 2: Caching-Ebenen

Caching ist die wirkungsvollste Performance-Optimierung auf Systemebene. Es gibt vier Stellen, an denen sich cachen lässt:

Browser Cache → CDN → Application Cache (Redis) → Database Query Cache
     ↑              ↑              ↑                        ↑
  Static assets  Static + API   Computed results      Query results
  ms access      ~10ms           ~1ms                  ~5ms

Der schwierige Teil ist nicht die Implementierung eines Caches, sondern seine Invalidierung.

tstypescript
// Cache-aside pattern — the most common approach
async function getProduct(id: string): Promise<Product> {
  const cacheKey = `product:${id}`;
 
  // 1. Try cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  // 2. Cache miss — fetch from database
  const product = await db.product.findUnique({ where: { id } });
  if (!product) throw new NotFoundError();
 
  // 3. Populate cache with TTL
  await redis.setEx(cacheKey, 3600, JSON.stringify(product)); // 1 hour TTL
 
  return product;
}
 
// Invalidate on mutation
async function updateProduct(id: string, data: Partial<Product>) {
  const product = await db.product.update({ where: { id }, data });
  await redis.del(`product:${id}`); // Invalidate cache entry
  return product;
}

Cache-Stampede: Wenn der Cache abläuft, schlagen alle Anfragen gleichzeitig fehl und treffen die Datenbank ungebremst. Abhilfe schaffen probabilistisch vorgezogenes Ablaufen oder verteilte Sperren (distributed locks).

Konzept 3: Message Queues

Warteschlangen entkoppeln Produzenten von Konsumenten und ermöglichen:

  • Asynchrone Verarbeitung — sofort antworten und im Hintergrund verarbeiten
  • Lastglättung — Traffic-Spitzen abfedern, ohne nachgelagerte Dienste zu überlasten
  • Wiederholungslogik — fehlgeschlagene Jobs lassen sich automatisch erneut ausführen
tstypescript
// Without a queue — user waits for email to send
app.post("/api/register", async (req, res) => {
  const user = await createUser(req.body);
  await sendWelcomeEmail(user.email); // Slow — blocks response
  res.json({ user });
});
 
// With a queue — response is immediate, email is async
app.post("/api/register", async (req, res) => {
  const user = await createUser(req.body);
  await queue.add("send-welcome-email", { userId: user.id }); // Fast — non-blocking
  res.json({ user });
});
 
// Worker processes jobs independently
queue.process("send-welcome-email", async (job) => {
  const user = await db.user.findUnique({ where: { id: job.data.userId } });
  await emailService.sendWelcome(user);
});

Konzept 4: Datenbankskalierung

Wenn eine einzelne Datenbank die Last nicht mehr bewältigt:

Lese-Replikate — Schreibvorgänge werden auf schreibgeschützte Replikate repliziert. Leselastige Abfragen werden an die Replikate geleitet.

tstypescript
// Primary for writes, replica for reads
const writeDb = new PrismaClient({ datasources: { db: { url: PRIMARY_URL } } });
const readDb = new PrismaClient({ datasources: { db: { url: REPLICA_URL } } });
 
async function getUserDashboard(userId: string) {
  // Read from replica — ok if slightly stale
  return readDb.user.findUnique({
    where: { id: userId },
    include: { recentOrders: { take: 10 } },
  });
}
 
async function updateUserProfile(userId: string, data: Partial<User>) {
  // Write to primary — must be consistent
  return writeDb.user.update({ where: { id: userId }, data });
}

Sharding — Daten werden anhand eines Shard-Keys auf mehrere Datenbanken verteilt (z. B. nach Bereichen der Benutzer-ID). Das erhöht die operative Komplexität erheblich — zuerst sollten Lese-Replikate zum Einsatz kommen.

Konzept 5: Das CAP-Theorem in der Praxis

In einem verteilten System kannst du immer nur zwei der drei Eigenschaften garantieren:

  • Konsistenz — jede Leseoperation liefert den zuletzt geschriebenen Wert
  • Verfügbarkeit — jede Anfrage erhält eine Antwort
  • Partitionstoleranz — das System funktioniert trotz Netzwerkausfällen

Netzwerkausfälle passieren. Während einer Partition musst du dich zwischen Konsistenz und Verfügbarkeit entscheiden.

Die meisten Webanwendungen sollten sich für Verfügbarkeit + eventuelle Konsistenz entscheiden:

tstypescript
// Eventual consistency — show cached data, sync in background
async function getInventoryCount(productId: string) {
  // Return cached count — may be slightly stale
  const cached = await redis.get(`inventory:${productId}`);
  if (cached) return parseInt(cached);
 
  // Fallback to database
  const product = await db.product.findUnique({
    where: { id: productId },
    select: { inventoryCount: true },
  });
  await redis.setEx(
    `inventory:${productId}`,
    30,
    String(product.inventoryCount),
  );
  return product.inventoryCount;
}

Banktransaktionen und medizinische Datensätze benötigen strikte Konsistenz. Der Lagerbestand eines Produkts verträgt dagegen eine Verzögerung von 30 Sekunden.

Das mentale Modell

Beim Systemdesign geht es darum zu verstehen, wo die Engpässe liegen und welche Kompromisse für deinen Anwendungsfall akzeptabel sind.

Fragen, die du dir bei jedem System stellen solltest:

  1. Wie ist das Verhältnis von Lese- zu Schreibzugriffen?
  2. Welche Latenz ist akzeptabel?
  3. Was passiert, wenn eine Komponente ausfällt?
  4. Wie sieht das Datenzugriffsmuster aus?
  5. Welche Konsistenzanforderungen gibt es?

Fang einfach an. Füge nur dann Komplexität hinzu, wenn du Beweise dafür hast, dass die einfachere Lösung zum Engpass wird. Vorzeitige Optimierung im Systemdesign — Over-Engineering für eine Skalierung, die du noch gar nicht brauchst — ist genauso gefährlich wie vorzeitige Mikrooptimierungen im Code.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX