Startseite / Artikel

Warum Backend-Code, der lokal funktioniert, unter echter Produktionslast versagt.

Eine praktische Übersicht über die Annahmen bezüglich Umgebung, Datenbank, Sicherheit und Zuverlässigkeit, die unter localhost reibungslos funktionieren, aber zu Ausfällen führen, sobald echter Traffic in die Produktion eintritt.

Verlag: REACTAPP.TOP39 Min. Lesezeit8945 Wörter

Quellen: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production

Es läuft auf Ihrem Rechner einwandfrei. Aber wird es auch standhalten, wenn echter Traffic darauf einwirkt? Hier sind 10 Backend-Fehler, die eine reibungslos laufende Anwendung stillschweigend in eine Produktionskatastrophe verwandeln können.

Lokal scheint alles in Ordnung zu sein. Anfragen werden schnell beantwortet. Abfragen liefern die richtigen Daten. Das Anmelden funktioniert. Docker startet ohne Probleme. Die Tests bestehen. Dann führen Sie die Bereitstellung durch, öffnen die Live-URL – und plötzlich funktioniert etwas nicht mehr.

Mannchmal weigert sich die Anwendung, zu starten.

Mannchmal werden Benutzer von ihren Konten ausgesperrt.

Mannchmal wird die Datenbank plötzlich extrem langsam.

Mannchmal läuft alles einige Minuten lang reibungslos, bevor Verbindungsfehler auftauchen.

Und manchmal wirkt der Service auf den ersten Blick völlig in Ordnung, während er im Hintergrund stillschweigend Schäden verursacht.

Was das frustrierend macht, ist, dass die meisten dieser Fehler nichts mit komplizierten Algorithmen zu tun haben.

Sie stammen aus kleinen, unüberprüften Annahmen.

Annahmen wie zum Beispiel:

  • "Wir werden nicht so viel Traffic haben."
  • "Die Datenbank befindet sich auf diesemselben Rechner."
  • "Diese Umgebungsvariable wird immer gesetzt sein."
  • "Eine einzige Datenbankverbindung reicht aus."
  • "Anfragen werden immer schnell abgeschlossen."
  • "Auf die Festplatte gespeicherte Dateien werden später noch da sein."
  • "Niemand wird diesen Endpunkt übermäßig belasten."
  • "Die API erhält nur immer gültige Eingaben."
  • "Der Container wird niemals an Speicher mangeln."
  • "Ein laufender Prozess bedeutet eine funktionierende Anwendung."

Ihre lokale Einrichtung schützt Sie vor den meisten dieser Probleme.

In der Produktion gibt es keinen solchen Schutz.

Dieser Artikel geht auf 10 Backend-Fehler ein, die lokal auf dem Rechner problemlos funktionieren, aber nach der Veröffentlichung erheblichen Schaden anrichten.

Niemand dieser Fehler ist abstrakt oder hypothetisch.

Es handelt sich um solche Abkürzungen, die beim Fokussieren auf die Entwicklung einer Funktion völlig vernünftig erscheinen.

Genauso macht das sie gerade so riskant.

1. Umgehung von Umgebungsvariablen als optionale Konfiguration

Lassen Sie uns mit einem der häufigsten Fehler beginnen.

Nehmen wir an, Sie entwickeln eine API auf Ihrem Laptop.

Die Einrichtung der Datenbankverbindung sieht so aus:

DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000

Die Anwendung startet einwandfrei.

Alles scheint in Ordnung zu sein.

Sie bringen sie in die Produktion.

Dann, plötzlich:

Error: password authentication failed

Oder vielleicht:

JWT_SECRET is undefined

Oder:

ECONNREFUSED 127.0.0.1:5432

Ihr erster Impuls könnte sein, die Hosting-Plattform dafür verantwortlich zu machen.

Das ist selten die eigentliche Ursache.

Was tatsächlich passiert, ist, dass Ihre Anwendung stillschweigend von Einstellungen abhängt, die nur auf Ihrem Entwicklungsrechner vorhanden waren.

Die localhost-Falle

Lokal könnten Sie etwas wie Folgendes haben:

DATABASE_URL=postgresql://localhost/myapp

Während die Produktion so konfiguriert ist:

DATABASE_URL=postgresql://db.internal:5432/myapp

Das sind zwei völlig unterschiedliche Einrichtungen.

Es wird noch schlimmer, wenn Entwickler die Konfiguration direkt in den Quellcode einbauen:

const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";

Das funktioniert hervorragend.

Bis es plötzlich nicht mehr funktioniert.

Die Produktion ist nicht Ihr Laptop.

Die Datenbank könnte sich auf:

  • einem separaten Server
  • einem anderen Container
  • einem verwalteten Datenbankanbieter
  • einer privaten internen Netzwerkstruktur
  • einer anderen Verfügbarkeitszone
  • einem völlig anderen Cloud-Anbieter

finden. Ihre Anwendung benötigt eine Möglichkeit, herauszufinden, wo sich diese Datenbank tatsächlich befindet.

Die Konfiguration sollte aus der Umgebung stammen

Ein widerstandsfähigeres Muster sieht so aus:

const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  port: process.env.PORT || 5000
};

Doch es lauert hier noch ein subtilerer Fehler.

Es reicht allein nicht aus, Werte einfach aus Umgebungsvariablen abzurufen.

Man muss auch überprüfen, ob diese tatsächlich vorhanden sind.

Zum Beispiel:

const requiredEnv = [
  "DATABASE_URL",  "JWT_SECRET"
];
for (const key of requiredEnv) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

Nun weigert sich die Anwendung, zu starten, wenn eine erforderliche Einstellung fehlt.

Ein solcher frühzeitiger Fehler ist eigentlich gut.

Vergleichen Sie das mit einer Situation, in der die Anwendung normal startet und erst im Moment des ersten Anmeldungsversuchs eines Benutzers versagt.

Es ist weitaus besser, etwas wie folgendes bereits während der Bereitstellung zu erkennen:

Missing required environment variable: JWT_SECRET

als erst später durch ein wütendes Support-Ticket davon zu erfahren.

Geben Sie keine gefährlichen Standardwerte heimlich vor

Dieser Abkürzungsweg ist verlockend:

const jwtSecret = process.env.JWT_SECRET || "secret";

Er erleichtert die lokale Entwicklung.

Dies eröffnet außerdem die Tür zu einer Sicherheitslücke.

Nehmen wir an, Sie vergessen, JWT_SECRET in Ihrer Produktionsumgebung einzustellen.

Die Anwendung stürzt nicht ab.

Sie wechselt einfach stillschweigend auf:

secret

Nun signiert Ihr Live-System Tokens mit einem Wert, den jeder erraten könnte.

Eine sicherere Version sieht so aus:

if (!process.env.JWT_SECRET) {
  throw new Error("JWT_SECRET must be configured");
}

Fallback-Werte sind sinnvoll für unbedeutende Einstellungen.

Für alles, was mit Sicherheit zu tun hat, stellen sie jedoch eine Gefahr dar.

Konfigurationsvalidierung sollte beim Start erfolgen

Ein Muster, das es wert ist, übernommen zu werden, besteht darin, die Konfiguration fast wie eine erforderliche Abhängigkeit zu behandeln, die erfüllt werden muss, bevor irgendetwas anderes ausgeführt wird.

Zum Beispiel:

function loadConfig() {
  if (!process.env.DATABASE_URL) {
    throw new Error("DATABASE_URL is required");
  }
  if (!process.env.JWT_SECRET) {
    throw new Error("JWT_SECRET is required");
  }
  return {
    databaseUrl: process.env.DATABASE_URL,
    jwtSecret: process.env.JWT_SECRET,
    port: Number(process.env.PORT || 5000)
  };
}
const config = loadConfig();

Dies gibt Ihrer Anwendung einen klaren Vertrag für die Arbeit.

Entweder ist die Konfiguration gültig, oder der Prozess startet gar nicht.

Dieser Kompromiss lohnt sich durchaus.

2. Verweis auf localhost für Dienste, die außerhalb Ihrer Anwendung liegen

Dieser Fehler verdient besondere Aufmerksamkeit, denn er tritt ständig auf, sobald der Code die Maschine des Entwicklers verlässt.

Nehmen wir an, Ihr Backend kommuniziert mit PostgreSQL.

Auf Ihrem Laptop sieht die Einrichtung so aus:

Backend → localhost:5432 → PostgreSQL

Und es funktioniert einfach.

Dann konteinerisieren Sie das Backend mit Docker.

PostgreSQL läuft nun in einem separaten Container.

Vielleicht lassen Sie die Konfiguration unverändert:

DATABASE_HOST=localhost

Und dann erhalten Sie:

connection refused

Was geht hier vor?

localhost bezieht sich nicht mehr auf das, was Sie annehmen.

Von innen aus einem Container heraus:

localhost

bezieht sich auf:

diesen spezifischen Container.

Nicht auf:

den Container, in dem Ihre Datenbank läuft.

Dieses eine Unterscheidungsmerkmal falsch zu verstehen ist für einen großen Teil der Probleme bei der Docker-Einsatzumgebung verantwortlich.

Docker-Netzwerke erfordern ein anderes Denkmuster

Stellen Sie sich drei getrennte Container vor:

backend
database
redis

Man könnte sie wie folgt definieren:

services:
  backend:
    build: .
    depends_on:
      - database
      - redis
  database:
    image: postgres
  redis:
    image: redis

Von innen aus dem Backend-Container aus kann man nicht einfach auf:

localhost:5432

Stattdessen ruft man den anderen Container über seinen Service-Name auf:

database:5432

Dieselbe Logik gilt für Redis:

redis:6379

Die resultierende Netzwerktopologie sieht so aus:

backend
   |
   +---- database:5432
   |
   +---- redis:6379

Das wirkt anfangs ungewohnt, da lokale Setups in der Regel alles direkt auf dem Host-Computer ausführen. In der Produktion entstehen echte Netzwerkgrenzen zwischen den Komponenten.

Cloud-Dienste führen in dieselbe Falle

Lokal kann Ihre Anwendung auf Redis wie folgt zugreifen:

localhost:6379

Aber in der Produktion befindet sich Redis auf einem verwalteten Service an einer völlig anderen Stelle.

Die richtige Konfiguration dort könnte folgende sein:

REDIS_URL=redis://redis-production.example.com:6379

Ihre Anwendung sollte nicht wissen müssen, welcher Unterschied besteht.

Das ist die Kernidee.

Schreiben Sie Ihren Code so, dass er Konfigurationen auf diese Weise verwendet:

createRedisClient({
  url: process.env.REDIS_URL
});

Anstatt Verbindungsdaten so hardzuzoden:

createRedisClient({
  host: "localhost",
  port: 6379
});

Halten Sie die Struktur der Konfiguration in allen Umgebungen konstant

Die Werte ändern sich zwischen den Umgebungen.

Die Struktur sollte sich nicht ändern.

Zum Beispiel in der Entwicklung:

DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379

Und in der Produktion:

DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379

Derselbe Codebasis.

Andere Umgebungs-Werte.

Genauso diese Konstanz ist es, was Sie anstreben.

3. Jede Anfrage mit einer neuen Datenbankverbindung verarbeiten

Dieser spezielle Fehler kann lange Zeit völlig unsichtbar bleiben, bevor er Probleme verursacht.

Sie schreiben eine Route wie folgt:

app.get("/users", async (req, res) => {
  const client = new Client({
    connectionString: process.env.DATABASE_URL
  });
  await client.connect();
  const result = await client.query("SELECT * FROM users");
  await client.end();
  res.json(result.rows);
});

Es funktioniert einwandfrei.

Sie testen es manuell.

Die Antworten kommen schnell zurück.

Sie veröffentlichen die Anwendung.

Dann trifft echter Traffic ein.

Irgendwann tauchen Fehler wie folgt auf:

too many connections

oder:

remaining connection slots are reserved

oder Sie bemerken, dass die CPU-Nutzung Ihrer Datenbank stetig ansteigt.

Was ist die Ursache?

Jeder eingehende Anfrage eröffnet eine völlig neue Datenbankverbindung.

Mit 100 gleichzeitigen Anfragen sind das potenziell 100 separate Verbindungen.

Mit 1.000 Anfragen wird die Situation noch viel schlimmer.

Verbindungen sind aufwändig zu erstellen

Die Einrichtung einer Verbindung ist nicht kostenlos. Je nach Datenbank und Konfiguration kann dies Folgendes beinhalten:

  • Die Einrichtung der Netzwerkverbindung
  • Authentifizierung
  • Aushandlung von TLS
  • Initialisierung der Sitzung
  • Zuweisung von Ressourcen
  • Start eines Prozesses oder Threads auf der Datenbankseite
  • Das Wiederholen all dessen für jeden einzelnen Anfragen verbraucht Ressourcen. Deshalb verlassen sich Anwendungen in der Regel stattdessen auf einen Verbindungspool.

    Mit PostgreSQL sieht das so aus:

    const { Pool } = require("pg");
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
      max: 10
    });
    

    Dann wird Ihre Route wie folgt aussehen:

    app.get("/users", async (req, res) => {
      const result = await pool.query(
        "SELECT id, name, email FROM users"
      );
      res.json(result.rows);
    });
    

    Der Pool hält eine Reihe wiederverwendbarer Verbindungen bereit.

    Visuell sieht das so aus:

    ┌── connection 1
    Request ────────┼── connection 2
                    ├── connection 3
                    ├── connection 4
                    └── connection 5
    

    Anstatt dessen:

    Request 1 → new connection
    Request 2 → new connection
    Request 3 → new connection
    Request 4 → new connection
    

    Pools können auch in großem Maßstab Probleme verursachen

    Auch bei Verwendung eines Pools ist eine Fehlkonfiguration möglich.

    Nehmen wir an, Sie führen 10 Backend-Instanzen aus, und jede davon stellt ein:

    pool.max = 20
    

    Das führt zu einem potenziellen Problem:

    10 × 20 = 200
    

    Verbindungen. Wenn Ihre Datenbank die Anzahl der Verbindungen auf 100 begrenzt, überschreiten Sie bereits das Budget – obwohl die Pools-Einstellung für jede einzelne Instanz für sich genommen vernünftig erscheint.

    Genau dieses Problem tritt niemals auf einem einzelnen Entwicklerrechner auf.

    Lokal könnten Sie Folgendes beobachten:

    1 application × 10 connections = 10
    

    In der Produktion:

    10 applications × 20 connections = 200
    

    Das horizontale Skalieren Ihrer Anwendung kann unbemerkt die Last, die sie auf die Datenbank ausübt, erhöhen.

    Die Größe des Pools ist eine systemweite Frage

    Um herauszufinden, wie groß ein Pool sein sollte, müssen Sie Folgendes berücksichtigen:

    database connection limit
            ↓
    number of application instances
            ↓
    pool size per instance
            ↓
    background workers
            ↓
    admin/monitoring connections
    

    Das ist keine Entscheidung, die man willkürlich trifft.

    4. Rückgabe aller Datenbankspalten an den Client

    Dieser spezielle Fehler führt selten dazu, dass Ihre Anwendung sofort abstürzt.

    Deshalb ist er gerade so gefährlich.

    Bilden Sie sich eine Benutzer-Tabelle mit diesen Feldern vor:

    id
    name
    email
    password_hash
    phone
    address
    created_at
    updated_at
    internal_notes
    

    Sie schreiben eine Abfrage wie folgt:

    SELECT * FROM users;
    

    Und geben die Zeilen direkt an die Antwort weiter:

    res.json(result.rows);
    

    Es funktioniert. Die Frontend-Anwendung erhält ihr Benutzerobjekt. Sie können fortfahren.

    Aber im Hintergrund haben Sie gleichzeitig mehrere Probleme verursacht.

    Zuerst: Datenoffenlegung

    Die Antwort könnte so aussehen:

    {
      "id": 12,
      "name": "John",
      "email": "john@example.com",
      "password_hash": "...",
      "internal_notes": "..."
    }
    

    Auch wenn das Passwortfeld gehasht statt im Klartext vorliegt, gibt es keinen Grund, warum es jemals beim Client ankommen sollte. Zudem werden alle nur intern verwendeten Felder, an die Sie nicht gedacht haben, gleich mit übertragen.

    Zweitens: Bandbreite

    Nehmen wir an, jede Zeile wiegt 2 KB. Für tausend Zeilen bedeutet das:

    2 KB × 1,000 = 2 MB
    

    Stellen Sie sich nun vor, die Tabelle nimmt im Laufe der Zeit an Breite zu. Möglicherweise müssen Sie schließlich folgendes liefern:

    20 fields
    

    während die Benutzeroberfläche tatsächlich nur Folgendes verbraucht:

    id
    name
    avatar
    

    Die Datenbank hat keine Ahnung, was Ihre Frontend-Anwendung tatsächlich benötigt – diese Entscheidung liegt bei Ihrer API-Schicht.

    Anstatt also:

    SELECT * FROM users;
    

    sollten Sie schreiben:

    SELECT
      id,
      name,
      avatar_url
    FROM users;
    

    Nun enthält die Payload nur noch das, was dieser spezifische Endpunkt benötigt.

    Dies wird bei Verknüpfungen noch wichtiger

    Betrachten Sie eine Abfrage wie diese:

    SELECT *
    FROM users
    JOIN orders ON orders.user_id = users.id
    JOIN payments ON payments.order_id = orders.id;
    

    Diese kann eine enorme Menge an Daten zurückgeben, die Sie eigentlich nirgendwo senden wollten. Es handelt sich dabei um einen verlockenden Kurzweg, während Sie noch etwas entwickeln, wobei die Begründung etwa so lautet:

    "Ich gebe einfach alles zurück und lasse das Frontend auswählen, was es braucht."

    Gegen diesen Impuls wehren Sie sich.

    Die Struktur einer API-Antwort sollte bewusst gestaltet werden. Zum Beispiel:

    const users = result.rows.map(user => ({
      id: user.id,
      name: user.name,
      avatar: user.avatar_url
    }));
    

    Oder noch besser: Übertragen Sie diese Verantwortung auf eine eigene DTO- oder Serialisierungs-Schicht.

    Der API-Vertrag ist wichtig

    Eine für die Produktion geeignete API sollte in der Lage sein, Folgendes zu beantworten:

    Was verspricht dieser Endpunkt zurückzugeben?

    Nicht:

    Welche Spalten existieren in der zugrunde liegenden Tabelle?

    Dieser Unterschied wird umso wichtiger, je größer die Codebasis wird.

    5. Datenbank-Indizes ignorieren, bis die Produktion langsam wird

    Dies könnte eines der deutlichsten Beispiele dafür sein, dass etwas lokal völlig in Ordnung scheint, aber stillschweigend zu einem Problem in der Produktion wird.

    Nehmen wir eine Abfrage wie folgt:

    SELECT * FROM users
    WHERE email = 'john@example.com';
    

    Während Ihre Entwicklungsdatenbank folgendes hat:

    500 users
    

    gibt die Abfrage sofort Ergebnisse zurück.

    Ihre Anwendung wird veröffentlicht. Sechs Monate vergehen, und die Tabelle enthält nun:

    5 million users
    

    Auf einmal kann derselbe Abfrageauftrag erheblich langsamer werden, da die Datenbank möglicherweise einen großen Teil der Tabelle durchscannen muss, um eine Übereinstimmung zu finden.

    Die typische Reaktion ist:

    "Aber diese Abfrage war früher doch schnell!"

    Sicher – weil die Tabelle damals winzig war.

    Index existieren aus diesem Grund

    Durch Hinzufügen von etwas wie:

    CREATE INDEX idx_users_email
    ON users(email);
    

    lässt PostgreSQL passende Zeilen weitaus effizienter finden als durch einen vollständigen Tabellescan.

    Trotzdem lautet die richtige Schlussfolgerung nicht:

    Indizieren Sie einfach alles.

    Das ist an sich schon ein Fehler. Indizes sind nicht kostenlos – sie:

    • nehmen Speicherplatz ein
    • verlangsamen Schreibvorgänge
    • erfordern kontinuierliche Wartung
    • helfen nicht bei jeder Art von Abfrage
    • sind es nicht wert, zu jeder Spalte hinzugefügt zu werden

    Die richtige Frage lautet stattdessen:

    Welche Abfragen führt diese Anwendung in der Praxis tatsächlich aus?

    Index basierend auf Zugriffsmustern

    Falls Ihre Anwendung regelmäßig etwas in der Art ausführt:

    SELECT *
    FROM orders
    WHERE user_id = 123
    ORDER BY created_at DESC
    LIMIT 20;
    

    könnte ein komplexer Index in dieser Richtung sinnvoll sein:

    CREATE INDEX idx_orders_user_created
    ON orders(user_id, created_at DESC);
    

    Die richtige Wahl des Index hängt stets von Ihrem spezifischen Datenbankmotor, der Verteilung der Daten, dem Verhalten des Abfragenplaners sowie Ihrer tatsächlichen Arbeitslast ab – genau deshalb funktioniert das Indexieren nach Vermutungen nicht.

    Verwenden Sie EXPLAIN

    Eines der wertvollsten Diagnosewerkzeuge in PostgreSQL ist:

    EXPLAIN ANALYZE
    SELECT *
    FROM orders
    WHERE user_id = 123
    ORDER BY created_at DESC
    LIMIT 20;
    

    Es zeigt Ihnen genau, wie die Datenbank eine bestimmte Abfrage ausgeführt hat, und ersetzt Annahmen wie:

    Diese Abfrage sollte schnell laufen

    durch konkrete Belege:

    So handelt die Datenbank tatsächlich

    Diese Veränderung der Denkweise ist von großer Bedeutung.

    Lokale Daten können falsch sein

    Es gibt eine Regel, die man sich zu eigen machen sollte: Die Leistungsmessung anhand eines kleinen lokalen Datensatzes sagt sehr wenig darüber aus, wie derselbe Abfragedurchlauf in der Produktion abläuft.

    Eine Abfrage, die bei:

    5 ms
    

    1.000 Zeilen sofort abgeschlossen wird, kann sich völlig anders verhalten, wenn man es mit:

    10 million rows
    

    größeren Datensätzen zu tun hat.

    Eine sinnvolle Leistungsprüfung erfordert Datenvolumina, die denen entsprechen, die man in der Produktion tatsächlich vorfindet.

    6. Der Glaube, dass alles, was man auf die Festplatte speichert, dort bleibt

    Dieses Problem tritt oft bei Dateiuploads auf.

    Auf dem eigenen Rechner könnte man etwas wie folgt schreiben:

    app.post("/upload", upload.single("file"), (req, res) => {
      console.log(req.file.path);
    });
    

    Die hochgeladene Datei landet an einer Stelle wie:

    /uploads/photo.jpg
    

    Man lädt die Anwendung neu. Die Datei ist genau dort, wo man sie zurückgelassen hat. Alles scheint in Ordnung zu sein.

    Dann versenden Sie die App in einen Container oder auf eine vorübergehende Hosting-Plattform.

    Es findet ein neuer Deploy statt.

    Sofort ist die Datei verschwunden.

    Es kommen Support-Anfragen herein:

    Wo sind meine Bilder geblieben?

    Warum passiert das?

    Weil das Dateisystem auf Ihrem lokalen Rechner sich sehr von der Speicherung in vielen Produktivumgebungen unterscheidet.

    Ein Container durchläuft in der Regel einen Lebenszyklus wie folgt:

    created
    running
    destroyed
    recreated
    

    Sobald dieser Container abgebaut wird, verschwindet alles, was nur auf seiner lokalen Festplatte gespeichert wurde, zusammen mit ihm.

    Viele Hosting-Plattformen behandeln lokal geschriebene Dateien aus Designgründen als einmalig verwendbar.

    Behandeln Sie Ihren App-Server nicht als langfristigen Speicher

    Zur Verarbeitung von Uploads in der Produktion ist der übliche Ansatz, die Dateien in dedizierte Objektspeicher zu übertragen anstelle auf die eigene Festplatte des Servers.

    Das ergibt eine Architektur, die ungefähr so aussieht:

    Client
      ↓
    Backend
      ↓
    Object Storage
      ↓
    Database stores file metadata
    

    Ihre Datenbank muss hingegen nur eine Referenz speichern – etwas in der Art:

    id
    user_id
    filename
    storage_key
    content_type
    created_at
    

    Die tatsächlichen Bytes des Files befinden sich im Objektspeicherdienst und nicht in Ihrer relationellen Datenbank.

    Durch diese Aufteilung der Verantwortlichkeiten wird es viel einfacher, die Struktur zu verstehen.

    Datenbank und Speicherschicht lösen unterschiedliche Probleme

    Datenbanken sind hervorragend darin, Folgendes zu verarbeiten:

    • strukturierte Datensätze
    • Beziehungen zwischen Datensätzen
    • transaktionale Garantien
    • Abfragen und Filtern

    Objektspeicher sind dafür konzipiert, Folgendes zu speichern:

    • Bilder
    • Videos
    • PDFs und andere große Dokumente
    • im Allgemeinen statische Ressourcen

    Der Versuch, ein einziges System für beide Aufgaben zu nutzen, führt in der Regel letztendlich zu Problemen.

    Eine verwandte Falle: Das Einbringen hochgeladener Dateien in Ihre Codebasis

    Es ist nicht ungewöhnlich, Projekte in folgender Struktur vorzufinden:

    project/
      uploads/
      src/
      package.json
    

    wobei die vom Benutzer hochgeladenen Dateien direkt neben den eigenen Quelldateien der Anwendung liegen.

    Der Ansatz funktioniert schnell nicht mehr.

    Bild:

    100,000 uploaded images
    

    innerhalb Ihres Projektverzeichnisses.

    Die Bereitstellung wird äußerst langsam und unübersichtlich.

    Anwendungscode und vom Benutzer erstellte Inhalte haben völlig unterschiedliche Lebenszyklen, weshalb sie in getrennten Systemen gehören.

    7. Veröffentlichung einer API ohne Rate Limiting

    Die Arbeit lokal schafft eine irreführende Situation.

    Wahrscheinlich sind Sie der einzige Client, der auf die API zugreift.

    Sie senden:

    GET /api/users
    

    einmal ab.

    Dann wieder.

    Möglicherweise noch ein paar Mal.

    Es bricht nichts.

    In der Produktion verhält sich die API jedoch nicht so höflich.

    Ein einzelner Anrufer – aus Versehen oder absichtlich – kann in kurzer Zeit Tausende von Anfragen senden.

    Nehmen wir einen Endpunkt wie diesen:

    POST /api/login
    

    Ohne jegliche Beschränkung könnte ein Angreifer diesen mit wiederholten Anmeldungsversuchen überlasten:

    attempt 1
    attempt 2
    attempt 3
    ...
    attempt 10,000
    

    Selbst eine perfekt sichere Passwörkontrolle verhindert nicht, dass der Endpunkt insgesamt missbraucht wird.

    Rate Limiting schafft eine Kontrollstelle

    Konzeptionell funktioniert es wie folgt:

    Client
       ↓
    Rate limiter
       ↓
    Application
    

    Anstatt Anfragen unbegrenzt durchzulassen, kann man eine bestimmte Klasse von Endpunkten auf Werte wie diese beschränken:

    100 requests / minute
    

    Sobald ein Anrufer diese Grenze überschreitet, erhält er folgenden Antwort:

    429 Too Many Requests
    

    Die richtigen Werte variieren je nach Anwendungsfall – ein öffentlicher Suchendpunkt benötigt nicht denselben Limit wie beispielsweise:

    POST /login
    

    oder:

    POST /forgot-password
    

    Es geht nicht nur um bösartigen Traffic

    Rate Limits schützen außerdem vor Fehlern auf der Client-Seite.

    Stellen Sie sich ein fehlerhaftes Stück Frontend-Code vor wie:

    setInterval(() => {
      fetch("/api/notifications");
    }, 100);
    

    Diese einzige Browser-Tab ruft nun ununterbrochen Ihre API auf.

    Versetzen Sie sich nun vor, dieser Fehler tritt bei 10.000 Nutzern auf.

    Ihr Backend hat plötzlich ein ernsthaftes Problem.

    Ein Rate Limiter bietet Ihnen einen Schutz vor genau solchem unkontrollierten Verhalten.

    Rate Limiting wird schwieriger, wenn man horizontal skaliert

    Nehmen wir an, Ihre Anwendung läuft hinter drei separaten Backend-Instanzen:

    Server A
    Server B
    Server C
    

    Falls jede Instanz ihren eigenen Zähler im Speicher speichert, könnte ein Client effektiv dreimal so viel Zugriff erhalten wie vorgesehen – einmal pro Server.

    Um eine einheitliche Beschränkung bei allen Instanzen durchzusetzen, wenden Teams in der Regel einen gemeinsamen Speicher wie Redis an.

    Die Einrichtung sieht dann ungefähr so aus:

    ┌── Server A
    Client → LB ──┼── Server B
                  └── Server C
                        |
                      Redis
    

    Mit diesem gemeinsamen Zustand setzt jede Instanz konsequent dieselben Grenzen durch.

    Auch hier ist das unsichtbar, wenn man lokal einen einzigen Prozess ausführt – es gibt keinen verteilten Zustand, um sich Sorgen zu machen, solange die Produktion das Problem nicht aufwirft.

    8. Annahme, dass Anfragen immer in angemessener Zeit abgeschlossen werden

    Dieser Punkt überrascht einen oft unvorbereitet.

    Nehmen wir an, man erstellt einen Endpunkt wie folgt:

    app.get("/report", async (req, res) => {
      const data = await generateHugeReport();
      res.json(data);
    });
    

    Lokal umfasst der Bericht:

    1,000 records
    

    und wird in folgender Zeit abgeschlossen:

    300 ms
    

    Das sieht gut aus.

    In der Produktion umfasst derselbe Bericht nun:

    5 million records
    

    und die Anfrage dauert jetzt:

    45 seconds
    

    In der Zwischenzeit könnte Ihr Reverse Proxy so konfiguriert sein, dass er nach folgender Zeit aufgibt:

    30 seconds
    

    so dass der Client letztendlich Folgendes sieht:

    504 Gateway Timeout
    

    Auch wenn Ihr Backend weiterhin im Hintergrund arbeitet.

    Dieser Unterschied führt zu einem verwirrenden Fehlerverhalten.

    Lange Laufzeiten gehören nicht in den Request-Response-Zyklus

    Wenn eine Aufgabe Zeit in Anspruch nimmt, ist es in der Regel besser, sie als Hintergrundaufgabe zu behandeln, anstatt dass der Client direkt darauf wartet.

    Anstelle eines Ablaufs wie:

    Client
      ↓
    POST /generate-report
      ↓
    Wait 45 seconds
      ↓
    Response
    

    wendet man etwas wie folgt an:

    Client
      ↓
    POST /generate-report
      ↓
    Job created
      ↓
    202 Accepted
    

    wobei die eigentliche Verarbeitung getrennt stattfindet:

    Queue
      ↓
    Worker
      ↓
    Generate report
      ↓
    Store result
    

    Der Client kann anschließend den Status über etwas wie folgt abfragen:

    GET /reports/:id
    

    oder einfach benachrichtigt werden, sobald die Aufgabe abgeschlossen ist.

    Hintergrundwarteschlangen übernehmen die schwere Arbeit

    Gemeine Beispiele für Aufgaben, die außerhalb des Hauptanfragenzyklus ausgeführt werden sollten, sind:

    • E-Mails versenden
    • PDFs erstellen
  • Bildverarbeitung
  • Videoverarbeitung
  • Berichtserstellung
  • Datenimport
  • Benachrichtigungen versenden
  • Größere Exporte erstellen
  • Geplante Aufgaben ausführen
  • Durch die Ausführung dieser Aufgaben außerhalb des Hauptthreads bleibt Ihre Kern-API schnell und reaktionsfähig.

    Vergessen Sie die Zeitlimits nicht

    Jeder Aufruf, den Ihr Service an ein externes System sendet, sollte mit einem angemessenen Zeitlimit ausgestattet sein.

    Als Beispiel:

    fetch(url, {
      signal: AbortSignal.timeout(5000)
    });
    

    Falls Sie das überspringen, kann eine blockierende Abhängigkeit dazu führen, dass Ihre Anwendung ewig wartet.

    Bilden Sie sich diese Kette vor:

    API
     ↓
    Payment service
     ↓
    hangs
    

    Ihre Anfrage steckt nun fest und wartet auf etwas, das möglicherweise nie antworten wird.

    Stellen Sie sich nun vor, dass das bei Hunderten gleichzeitiger Anfragen passiert.

    Irgendwann hat Ihr Server keine verfügbaren Ressourcen mehr, um noch etwas anderes zu verarbeiten.

    Jede Abhängigkeit benötigt einen Notfallplan

    Für jeden externen Dienst, mit dem Ihre Anwendung kommuniziert, sollten Sie in der Lage sein, eine spezifische Frage zu beantworten:

    Was passiert, wenn dieser Dienst nicht antwortet?

    Nicht diese Frage:

    Was passiert, wenn alles funktioniert?

    Auf die zweite Frage lässt sich leicht antworten.

    Die erste ist es, die tatsächlich entscheidet, ob Ihr System unter Druck weiterhin funktionsfähig bleibt.

    9. Vertrauen in Eingaben des Clients

    Diesen Fehler ist es fast unangenehm zu benennen, denn der fehlerhafte Code sieht oft völlig vernünftig aus.

    Nehmen wir einen Endpunkt wie diesen:

    app.post("/users", async (req, res) => {
      const { name, email } = req.body;
      await createUser(name, email);
      res.json({
        message: "User created"
      });
    });
    

    Auf den ersten Blick scheint nichts falsch zu sein.

    Aber bedenken Sie, was passiert, wenn der Anfragekörper so aussieht:

    {
      "name": "",
      "email": "hello"
    }
    

    Oder so:

    {
      "name": null,
      "email": null
    }
    

    Oder so:

    {
      "name": "A".repeat(1000000),
      "email": "..."
    }
    

    Was passiert, wenn der Client Felder enthält, die Ihr Code nicht vorausgesehen hat?

    Beim lokalen Entwickeln senden Sie in der Regel sorgfältig konstruierte Testanfragen.

    In der Produktion erhält Ihre API alles, was jemand entscheidet, ihr zu senden.

    Die Validierung muss an der Peripherie stattfinden

    Eine gut konstruierte API überprüft die eingehenden Daten, bevor irgendeine Geschäftslogik ausgeführt wird.

    Zum Beispiel können Sie eine Struktur wie folgt definieren:

    const schema = {
      name: "string",
      email: "email"
    };
    

    Mit einer Validierungsbibliothek können Sie Regeln wie diese durchsetzen:

    name required
    name length <= 100
    email required
    email valid
    

    Es spielt kaum eine Rolle, welche spezifische Validierungsbibliothek Sie wählen.

    Wichtig ist die zugrunde liegende Regel:

    Gehen Sie niemals davon aus, dass der Client das gesendet hat, was Sie erwartet haben.

    Haben Sie dabei im Hinterkopf, dass „der Client“ sein kann:

    • Ihre eigene Frontend-Anwendung
    • Eine Mobile-App
    • Eine Integration von Drittanbietern
  • einen automatisierten Script
  • eine veraltete Version Ihrer Anwendung
  • jemand mit bösartigen Absichten
  • Klientenseitige Überprüfungen sind keine Sicherheitsschicht

    Ein häufiges Einwand ist:

    "Aber ich überprüfe das bereits auf der Frontend-Seite, in React."

    Das ist tatsächlich hilfreich für die Benutzererfahrung.

    Doch es schützt Ihren Backend-Teil nicht im Geringsten.

    Jeder kann Ihren Frontend-Code völlig ignorieren.

    Es reicht aus, Folgendes auszuführen:

    curl
    

    gerade auf Ihren API-Endpunkt gerichtet.

    Ihr Serverseitiger Code muss weiterhin selbst die Regeln durchsetzen.

    SQL-Injection ist ein damit verbundenes Risiko

    Vermeiden Sie es, Abfragen durch das Zusammenfügen von Zeichenketten zu erstellen, wie hier:

    const query = `
      SELECT *
      FROM users
      WHERE email = '${email}'
    `;
    

    Verwenden Sie stattdessen parametrisierte Abfragen:

    const result = await pool.query(
      "SELECT * FROM users WHERE email = $1",
      [email]
    );
    

    Auf diese Weise hält der Datenbanktreiber die Parameterwerte getrennt von der Struktur der Abfrage.

    Eingabenvalidierung und parametrisierte Abfragen begegnen unterschiedlichen Bedrohungen, doch ein für die Produktion geeigneter Backend benötigt beides.

    10. Der Gedanke „Anwendung läuft“ bedeutet nicht unbedingt „Anwendung ist intakt“

    Dies könnte der grundlegendste Missverständnis in dieser Liste sein.

    Ihr Server startet:

    Server listening on port 5000
    

    Die Protokolle bestätigen:

    Application started successfully.
    

    Zu diesem Zeitpunkt ist es verlockend zu denken:

    Perfekt. Wir sind fertig.

    Das ist jedoch nicht unbedingt der Fall.

    Ein Prozess kann weiterlaufen, auch wenn die Anwendung selbst im Grunde kaputt ist.

    Betrachten Sie diesen Zustand:

    Backend process: running
    Database: unavailable
    Redis: unavailable
    Payment provider: unavailable
    Queue: stuck
    

    Von der Sichtweise des Betriebssystems aus ist der Prozess am Leben und funktionsfähig.

    Ihr Überwachungsdashboard könnte Folgendes anzeigen:

    CPU: 10%
    Memory: 20%
    Process: alive
    

    In der Zwischenzeit sehen die tatsächlichen Benutzer Folgendes:

    500 Internal Server Error
    

    Health Checks füllen einen Teil der Lücke

    Ein einfacher Health-Endpoint wie:

    GET /health
    

    könnte einfach Folgendes zurückgeben:

    {
      "status": "ok"
    }
    

    Aber selbst das kann irreführend sein.

    Falls /health lediglich bestätigt, dass der HTTP-Server auf Anfragen reagiert, sagt es nichts darüber aus, ob die von Ihrer Anwendung tatsächlich genutzten Abhängigkeiten funktionieren.

    Eine vollständigere Konfiguration trennt zwei Endpunkte voneinander. Einer dient:

    /health
    

    zur einfachen Lebensfähigkeitsprüfung, und ein weiterer dient:

    /ready
    

    der Bereitschaftsprüfung.

    Konzeptionell sieht die Unterscheidung wie folgt aus:

    Liveness: "Is this process alive?"
    

    und:

    Readiness:
    "Can this instance actually serve traffic?"
    

    Prüfung der Datenbankgesundheit speziell

    Eine Bereitschaftsprüfung kann noch weiter gehen und bestätigen, dass die Datenbankverbindung tatsächlich funktioniert – beispielsweise durch Ausführung von:

    SELECT 1;
    

    Falls die Datenbank nicht erreichbar ist, kann die Anwendung sich selbst als nicht bereit markieren.

    Dadurch erhalten Orchestrierungstools die Möglichkeit, den Datenverkehr von Instanzen abzubringen, die keine Anfragen ordnungsgemäß bearbeiten können.

    Allein die Verfügbarkeit sagt wenig aus

    Ein Server kann melden:

    99.99% uptime
    

    und dennoch seinen Nutzern eine frustrierend langsame Erfahrung bieten.

    Deshalb muss die Produktivitätsüberwachung mehr als nur prüfen, ob der Prozess aktiv ist – beispielsweise auch Aspekte wie:

    • Anfragedauer
    • Fehlerrate
    • Durchsatz
    • Datenbankleistung
    • CPU-Nutzung
    • Arbeitsspeichernutzung
    • Warteschlangentiefe
    • Fehler in externen Diensten

    Ein besonders nützlicher Indikator ist:

    p95 latency
    

    Anstatt zu fragen:

    Wie hoch ist die durchschnittliche Antwortzeit?

    ist es aufschlussreicher zu fragen:

    Wie lange dauern 95 % der Anfragen?

    Durchschnittswerte neigen dazu, die schlechtesten Erfahrungen zu verbergen.

    Betrachten Sie dieses Szenario:

    99 requests = 50 ms
    1 request = 30 seconds
    

    Die durchschnittliche Antwortzeit hier mag überhaupt nicht beunruhigend erscheinen.

    Aber diese eine langsame Anfrage stellt dennoch eine echte schlechte Erfahrung für denjenigen dar, der sie gestellt hat.

    Der weitere Fehler: Alles außer den nützlichen Teilen protokollieren

    Dieser Fehler gehört auf die Liste, weil er bereits mehr als eine Debugging-Sitzung gerettet hat.

    Etwas funktioniert in der Produktion nicht mehr richtig.

    Man öffnet die Protokolle.

    Alles, was man findet, ist:

    Something went wrong
    

    Das ist die gesamte Meldung.

    Kein Anfragen-Identifikator.

    Keine Route-Informationen.

    Kein Benutzerkontext.

    Keine Stackspur.

    Keine Angabe dazu, welche Datenbankaufrufe beteiligt waren.

    Kein Zeitstempel, der präzise genug ist, um etwas damit in Verbindung zu bringen.

    Nur eine einfache Zeile mit dem Text:

    Something went wrong
    

    Zu diesem Zeitpunkt behebt man Probleme ohne wirklich verwertbare Informationen.

    Wie eine nützliche Protokolleanzeige aussieht

    Wenn etwas fehlschlägt, sind das die Fragen, auf die man eine Antwort haben möchte:

    When?
    Where?
    Which request?
    Which service?
    What failed?
    How long did it take?
    What dependency was involved?
    

    Eine ordnungsgemäß strukturierte Protokolleanzeige fängt diesen Kontext ein, etwa so:

    {
      "level": "error",
      "requestId": "abc-123",
      "route": "/api/orders",
      "method": "POST",
      "duration": 843,
      "error": "Database timeout"
    }
    

    Diese Version liefert tatsächlich etwas, mit dem man arbeiten kann.

    Protokollieren Sie niemals sensible Daten

    Gleichzeitig sollten Sie darauf achten, niemals versehentlich Folgendes aufzunehmen:

    password
    access token
    refresh token
    credit card information
    private keys
    

    Protokollierung dient dazu, Ihnen bei der Diagnose von Problemen zu helfen – nicht dazu, Angreifern ein weiteres Problem in die Hände zu spielen.

    Warum diese Probleme niemals auf Ihrem Laptop auftauchen

    Man könnte zu Recht fragen, warum so viele Entwickler immer wieder dieselben Fehler machen, wenn sie doch so häufig vorkommen.

    Die Antwort ist, dass eine lokale Einrichtung und eine Produktionsumgebung im Grunde unterschiedliche Systeme sind.

    Hier ist der Unterschied.

    Wie die lokale Entwicklung aussieht

    In der Regel hat man es mit folgendem zu tun:

    1 developer
    1 application
    1 database
    small dataset
    low traffic
    fast network
    known inputs
    stable filesystem
    single process
    

    Die Produktionsumgebung hingegen kann Folgendes beinhalten:

    thousands of users
    multiple application instances
    millions of database rows
    unpredictable traffic
    network latency
    invalid requests
    external dependencies
    ephemeral containers
    background workers
    multiple regions
    

    Das ist keine kleinere Version desselben Systems – es handelt sich um ein völlig anderes System.

    Der Quellcode bleibt möglicherweise unverändert.

    Die Umgebung drumherum jedoch nicht.

    Die Gewohnheit, nur den „glücklichen“ Ablauf zu testen

    Es ist natürlich, nur den erfolgreichen Ablauf zu testen.

    Ein typisches Beispiel:

    Create user
    ↓
    Login
    ↓
    Create order
    ↓
    Payment succeeds
    ↓
    Return response
    

    Diese Abfolge repräsentiert den besten Fall.

    Aber der Verkehr in der Produktionsumgebung beschränkt sich niemals auf solche besten Fälle.

    Man muss auch Situationen wie folgende berücksichtigen:

    What if the database is slow?
    What if Redis is unavailable?
    What if the payment service times out?
    What if the user sends invalid JSON?
    What if the request is duplicated?
    What if two requests update the same record?
    What if the process restarts?
    What if the container disappears?
    What if the database connection pool is exhausted?
    What if the user clicks the button five times?
    What if the request arrives twice?
    What if the response is lost after the database transaction succeeds?
    

    In einem verteilten System sind das keine seltenen Ausnahmefälle.

    Sie treten regelmäßig auf.

    Von „Funktioniert es?“ zu „Was passiert, wenn es fehlschlägt?“

    Sobald man Erfahrung mit Backend-Systemen sammelt, ändern sich die eigenen Instinkte.

    Zu Beginn lautet die Frage:

    Funktioniert dieser Code?

    Später wird sie zu:

    Was passiert, wenn dieser Code fehlschlägt?

    Diese zweite Frage ist weitaus nützlicher.

    Nehmen wir diese Zeile:

    const result = await paymentService.charge();
    

    Das Denken eines Anfängers endet bei:

    Falls der Aufruf erfolgreich ist, geht man weiter.

    Das denkende Vorgehen eines Profis setzt fort:

    Was passiert, wenn es zu einer Zeitüberschreitung kommt?

    Dann:

    Was passiert, wenn die Abbuchung erfolgreich ist, aber die Antwort an meine API aus Zeitgründen nicht ankommt?

    Dann:

    Was passiert, wenn der Client den Anfrageversuch wiederholt?

    Dann:

    Könnte der Kunde am Ende zweimal berechnet werden?

    An diesem Punkt geht es um die Analyse von Ausfallmöglichkeiten, was im Grunde das Herzstück der Backend-Entwicklung ist.

    Idempotenz: Was die Zahlungsabläufe sicher macht

    Um dasselbe Beispiel fortzusetzen: Stellen Sie sich vor, ein Benutzer tippt

    Pay
    

    Die Anfrage erreicht Ihren Server.

    Die Abbuchung ist erfolgreich.

    Aber die Verbindung bricht ab, bevor die Antwort zurückkehren kann.

    Der Client hat keine Möglichkeit zu erkennen, ob die Zahlung tatsächlich durchgeführt wurde, daher versucht er es erneut.

    Nun hat der Server bereits verarbeitet:

    Payment request #1 → success
    Payment request #2 → success
    

    Was leicht zu folgendem Ergebnis führen kann:

    Customer charged twice.
    

    Idempotenz existiert genau dazu, dies zu verhindern.

    Eine Idempotenzschlüssel könnte so aussehen:

    payment_7f8c123
    

    Der Client fügt diesen Schlüssel der Anfrage hinzu, und der Server speichert ihn zusammen mit dem Ergebnis dieser Operation. Wenn erneut eine Anfrage mit dem gleichen Schlüssel eingehend wird, gibt der Server einfach das gespeicherte Ergebnis zurück, anstatt die Operation ein zweites Mal auszuführen.

    In Überblicksform:

    Request
      ↓
    Idempotency key
      ↓
    Already processed?
      ├── Yes → return previous result
      └── No → process → store result
    

    Dieses Muster ist für Operationen wie folgende von großer Bedeutung:

    • Zahlungen
    • Anlegung von Bestellungen
    • Kontoinanschreibung
    • Sendung von E-Mails
    • Aufrufe an Drittanbieter-APIs

    Duplikatanfragen führen selten zu Fehlern bei lokalen Tests, treten aber ständig auf, sobald unzuverlässige Netzwerke im Spiel sind.

    Transaktionen: Etwas, das zunächst in Ordnung scheint – bis es das nicht mehr tut

    Bedenken Sie, was passiert, wenn eine Bestellung erstellt wird.

    Der Workflow kann mehrere Schritte umfassen:

    Create order
    Create order items
    Update inventory
    Create payment record
    

    Falls Sie diese Operationen nicht in einer Transaktion abwickeln, kann eine von ihnen mitten im Ablauf fehlschlagen.

    Bilden Sie sich dieses Szenario vor:

    Create order       ✓
    Create items       ✓
    Update inventory   ✓
    Payment record     ✗
    

    Zu diesem Zeitpunkt befindet sich Ihre Datenbank in einem unvollendeten Zustand.

    Transaktionen dienen dazu, Operationen zusammenzufassen, die entweder alle erfolgreich oder alle fehlgeschlagen sein müssen.

    So sieht das in der Praxis aus:

    BEGIN;
    INSERT INTO orders (...);
    INSERT INTO order_items (...);
    UPDATE inventory
    SET quantity = quantity - 1
    WHERE product_id = 123;
    COMMIT;
    

    Und wenn etwas unterwegs schiefgeht:

    ROLLBACK;
    

    Trotzdem ist es auch nicht die richtige Vorgehensweise, jede einzelne Operation in eine riesige Transaktion zu packen.

    Die Grenzen von Transaktionen erfordern sorgfältiges Design.

    Transaktionen, die zu lange laufen, können ihre eigenen Probleme mit sich bringen.

    Die Frage, die Sie sich stets stellen sollten, lautet:

    Falls das unterwegs fehlschlägt, in welchem Zustand befindet sich dann das System?

    N+1-Anfragen: Der stille Leistungskiller

    Es gibt noch ein weiteres Problem, das bei der lokalen Entwicklung oft unentdeckt bleibt.

    Nehmen wir an, Sie holen eine Liste der Benutzer:

    SELECT * FROM users LIMIT 100;
    

    Und anschließend holen Sie für jeden einzelnen Benutzer seine Bestellungen ab:

    SELECT * FROM orders WHERE user_id = ?;
    

    Das führt dazu, dass:

    1 query for users
    +
    100 queries for orders
    =
    101 queries
    

    Das ist die bekannte Falle der N+1-Anfragen.

    Falls Sie nur 10 Benutzer haben:

    11 queries
    

    ist das noch handhabbar.

    Aber wenn es auf 10.000 Benutzer skaliert:

    10,001 queries
    

    haben Sie plötzlich ein ernsthaftes Leistungsproblem.

    Die Lösung ist nicht unbedingt „alles in eine einzige große Anfrage zusammenfassen.“

    Abschließend nach Ihrer Situation könnten Sie folgende Methoden anwenden:

    • Joins
    • Eager Loading
    • Batching
  • IN (...)-Klauseln
  • Data-Ladegeräte
  • Abfragen, die speziell entwickelt wurden, um dieses Muster zu vermeiden
  • Am wichtigsten ist es, das Muster von Anfang an erkennen zu können.

    Ein Backend kann gegenüber einem kleinen lokalen Datensatz perfekt reagieren, während es unter echter Last heimlich Tausende von Abfragen auslöst.

    Paginierung ist bei großen Sammlungen nicht optional

    Ein solcher Endpunkt scheint harmlos genug zu sein:

    GET /users
    

    Und die dahinterliegende Abfrage:

    SELECT *
    FROM users;
    

    läuft einwandfrei, wenn man:

    50 users
    

    Aber was passiert, wenn man mit:

    5 million users?
    

    Es ist nicht ratsam, fünf Millionen Datensätze an einen Client zurückzusenden.

    Paginierung bietet eine Möglichkeit, dies zu begrenzen.

    Man könnte offsetbasierte Paginierung verwenden:

    GET /users?page=2&limit=50
    

    oder cursorbasierte Paginierung:

    GET /users?cursor=abc123&limit=50
    

    Die Cursor-Paginierung funktioniert in der Regel besser bei großen oder ständig sich ändernden Datensätzen, da sie einige der Leistungs- und Konsistenzprobleme umgeht, die mit sehr großen Offset-Werten entstehen.

    Auch hier liegt das Kernproblem darin:

    localhost data = small
    production data = potentially huge
    

    Ihre API muss unter Berücksichtigung dieses Problems entwickelt werden.

    Speicherverluste zeigen sich nicht immer sofort

    Das ist eine weitere Art von Fehler, der erst in der Produktion zum Vorschein kommt.

    Ihre Anwendung startet mit:

    Memory: 150 MB
    

    Eine Stunde später:

    300 MB
    

    Sechs Stunden später:

    600 MB
    

    Und schließlich:

    Out of memory
    

    Dann stürzt sie ab und startet neu.

    Zu diesem Zeitpunkt sieht alles wieder normal aus.

    Dieses Muster ist ein deutliches Zeichen dafür, dass etwas Speicher behält, den es eigentlich nicht haben sollte.

    Häufige Ursachen sind:

    • Arrays, die ohne Obergrenze wachsen
  • Caches ohne Ausschussgrenze
  • Event-Listener, die niemals bereinigt werden
  • Große Objekte, die unbeabsichtigt im Speicher verbleiben
  • Referenzen, die viel länger bestehen bleiben, als es nötig ist
  • Streams, die nicht richtig verarbeitet werden
  • Wenn man lokal testet, läuft die Anwendung selten lange genug, damit solche Probleme auftreten.

    In der Produktion läuft sie jedoch lange genug, sodass die Leckagen relevant werden.

    Deshalb ist es so wertvoll, das Verhalten einer Anwendung über längere Zeiträume zu beobachten.

    Caching kann auch in der Produktion zum Problem werden

    Auf dem Papier scheint Caching ein klarer Vorteil zu sein:

    Database → slow
    Redis → fast
    

    Deshalb ist der Instinkt, alles zu cachen.

    Aber dann ändern sich die zugrunde liegenden Daten.

    Und die API liefert weiterhin veraltete Informationen zurück.

    Dann entsteht eine Inkonsistenz wie folgt:

    Database:
    balance = 100
    Cache:
    balance = 80
    

    Die API antwortet mit:

    80
    

    Und der Benutzer möchte wissen:

    Warum ist mein Saldo falsch?

    Caching zwingt einen dazu, Fragen wie folgende zu beantworten:

    • Wann sollten Einträge ablaufen?
    • Wann wird der Cache ungültig?
    • Was ist die Notlösung, falls Redis ausfällt?
    • Was passiert, wenn zwei Server an derselben Schlüsselwert schreiben?
    • Ist es hier jemals akzeptabel, veraltete Daten bereitzustellen?
    • Was sollte unmittelbar nach einem Deployment geschehen?

    Der schwierige Teil von Caching war nie das Speichern der Daten.

    Es geht darum, den Zeitpunkt zu bestimmen, ab dem gespeicherte Daten nicht mehr vertrauenswürdig sind.

    Cache-Fehler benötigen eine Strategie

    Falls Redis unerwartet verschwindet, handelt Ihre Anwendung dann:

    fail completely
    

    oder handelt sie nicht?

    fall back to database
    

    Es gibt keine universelle Antwort – es hängt vom System ab, das Sie entwickeln.

    Wichtig ist, dass Sie diesen Anruf absichtlich tätigen und nicht zufällig.

    Geheimnisse in Git: Der Fehler, der zu einem Sicherheitsvorfall werden kann

    Das Folgende scheint gesunder Menschenverstand zu sein, passiert aber dennoch ständig.

    Sie erstellen eine Datei:

    DATABASE_PASSWORD=...
    JWT_SECRET=...
    API_KEY=...
    

    Und führen anschließend aus:

    git add .
    git commit -m "config"
    git push
    

    Zu diesem Zeitpunkt ist das Geheimnis bereits in Ihrer Git-Geschichte festgeschrieben.

    Das Löschen der Datei danach löscht es nicht – der Wert kann weiterhin aus früheren Commits wiederhergestellt werden.

    Eine bessere Vorgehensweise besteht darin, Folgendes beizubehalten:

    .env
    

    in .gitignore aufzulisten und stattdessen ein Template zu commiten:

    .env.example
    

    mit Platzhalter-Einträgen wie:

    DATABASE_URL=
    JWT_SECRET=
    REDIS_URL=
    

    Die tatsächlichen Werte sollten über die Umgebungs-Einstellungen Ihrer Deployment-Plattform oder einen speziellen Secrets-Manager eingefügt werden.

    Und falls ein echtes Geheimnis tatsächlich in einen Commit gelangt:

    Nehmen Sie nicht einfach die Zeile entfernt und halten das für erledigt.

    Wechseln Sie dieses Passwort umgehend aus.

    Betrachten Sie es als kompromittiert, egal ob Sie nachweisen können, dass darauf zugegriffen wurde oder nicht.

    CORS ist keine Authentifizierung

    Hier ist ein weiterer Punkt, der viele Menschen in die Irre führt:

    Access-Control-Allow-Origin: *
    

    CORS bestimmt, wie Browser Anfragen zwischen verschiedenen Ursprungsdomänen verarbeiten.

    Es tut nichts, um Ihre API tatsächlich zu sichern.

    Nichts hindert daran, dass ein Server-Endpunkt direkt über folgenden Weg angegriffen wird:

    curl
    Postman
    mobile apps
    backend services
    scripts
    

    CORS war niemals dazu konzipiert, als Authentifizierungsschicht zu fungieren.

    Ihre API benötigt weiterhin echte:

    • Authentifizierung
    • Berechtigung
    • Eingabenvalidierung
    • Rate Limiting
    • Zugriffskontrolle

    Sicherheit muss explizit durchgesetzt werden, nicht nur durch Browserrichtlinien impliziert.

    Authentifizierung und Berechtigung sind unterschiedlich

    Das Verwechseln dieser beiden führt zu echten Sicherheitslücken.

    Antworten auf die Authentifizierung:

    Wer sind Sie?

    Antworten auf die Autorisierung:

    Dürfen Sie das tun?

    Betrachten wir einen Antrag wie diesen:

    GET /users/123
    

    Der Benutzer könnte durchaus authentifiziert sein.

    Aber das sagt nichts darüber aus, ob ihm der Zugriff gestattet werden sollte:

    user 123
    

    Falls die Person, die den Antrag stellt, ist:

    user 456
    

    muss der Server dennoch herausfinden, ob:

    456 → allowed to access 123?
    

    Ein häufiger Fehler sieht so aus:

    if (req.user) {
      return getUser(req.params.id);
    }
    

    Der Code überprüft lediglich, ob jemand eingeloggt ist.

    Er prüft niemals, ob ihm der Zugriff auf diese spezifische Ressource gestattet ist.

    Ein sichereres Muster berücksichtigt Eigentumsverhältnisse oder explizite Berechtigungen:

    if (req.user.id !== req.params.id) {
      return res.status(403).json({
        error: "Forbidden"
      });
    }
    

    Die spezifischen Regeln variieren je nach Anwendung.

    Doch das zugrundeliegende Prinzip bleibt überall gleich:

    Das Anmelden gewährt keinen umfassenden Zugriff auf alles im System.

    Die Bereitstellung ist Teil der Anwendung

    Eine Lektion, die man früh verinnerlichen sollte, ist, dass die Bereitstellung keine separate Phase ist, die am Ende der Entwicklung hinzugefügt wird.

    Sie gehört zum eigentlichen Entwicklungsprozess dazu.

    Ihre Anwendung ist nicht nur:

    source code
    

    Sie ist eine Kombination aus:

    source code
    +
    configuration
    +
    database
    +
    network
    +
    storage
    +
    secrets
    +
    dependencies
    +
    monitoring
    +
    deployment process
    

    Jemand, der nur den Anwendungscode versteht, kann trotzdem überwältigt werden, sobald die Anwendung in die Produktion geht.

    Man muss nicht jedes Detail von Kubernetes beherrschen, um effektiv zu arbeiten.

    Doch man benötigt ein grundlegendes Verständnis für die Umgebung, in der der Code tatsächlich läuft.

    Eine Checkliste für die Produktionsbereitschaft, die ich tatsächlich verwende

    Vor der Freigabe für die Produktion hilft es, eine Checkliste wie diese durchzugehen.

    Konfiguration

    • Werden die erforderlichen Umgebungsvariablen beim Start überprüft?
    • Werden sensible Daten außerhalb des Quellcodes aufbewahrt?
    • Sind die Standardeinstellungen für die Produktion sicher und nicht nur praktisch?
    • Wird zwischen Entwicklungs- und Produktionskonfigurationen unterschieden?

    Datenbank

    • Ist ein Connection Pool eingerichtet?
    • Ist die Größe des Pools angemessen festgelegt?
    • Sind wichtige Abfragen indiziert?
    • Wurden langsame Abfragen tatsächlich analysiert?
    • Werden Transaktionen dort eingesetzt, wo sie benötigt werden?
    • Werden Migrationsänderungen mit Versionskontrolle verfolgt?

    API

    • Wird die eingehende Eingabe überprüft?
    • Werden die Antwortdaten gezielt gestaltet?
    • Werden große Ergebnismengen paginiert?
    • Sind die Größen der Anfragekörper begrenzt?
  • Sind angemessene Zeitlimits konfiguriert?
  • Sicherheit

    • Wurden Passwörter mit einem sicheren Algorithmus gehäscht?
    • Werden Geheimnisse sicher gespeichert?
    • Wird die Autorisierung tatsächlich durchgesetzt?
    • Sind SQL-Anfragen parametrisiert?
    • Wird an den entscheidenden Stellen eine Rate Limiting-Regelung angewandt?
    • Werden sensible Felder aus den Logs ausgeblendet?

    Infrastruktur

    • Hängt etwas Falsches von localhost ab?
    • Wird persistente Daten außerhalb von vorübergehenden Containern aufbewahrt?
    • Sind Health-Check-Endpunkte zugänglich?
    • Ist ein sanfter Herunterfahren implementiert?
    • Was passiert tatsächlich, wenn die Anwendung neu gestartet wird?

    Leistung

    • Wurde das System mit realistischen Datenvolumina getestet?
    • Gibt es versteckte N+1-Anfragemuster?
    • Werden aufwändige Aufgaben an Hintergrundjobs delegiert?
    • Sind Caches in ihrer Größe begrenzt?
    • Wird der Speicherverbrauch überwacht?

    Beobachtbarkeit

    • Enthalten die Fehlerprotokolle genügend Kontext, um nützlich zu sein?
    • Ist das Anfragenverfolgen eingerichtet?
    • Wird die Latenz sowie die Fehlerrate überwacht?
    • Sind Datenbankfehler in der Überwachung sichtbar?
    • Sind tatsächlich Warnungen konfiguriert?

    Schöner Herunterfahren: Die kleine Einzelheit, die viele vergessen

    Stellen Sie sich vor, Ihr Server erhält ein:

    SIGTERM
    

    Signal, weil die Hosting-Plattform die Instanz neu starten möchte.

    Falls der Prozess sofort beendet wird, können alle noch ausstehenden Anfragen fehlschlagen.

    Ein schönes Herunterfahren bewältigt das besser.

    Die Idee im Code sieht in etwa so aus:

    process.on("SIGTERM", async () => {
      console.log("Shutdown signal received");
    server.close(async () => {
        await pool.end();
        console.log("Server closed");
        process.exit(0);
      });
    });
    

    Die genaue Umsetzung unterscheidet sich je nach Framework.

    Aber die zugrundeliegende Abfolge bleibt gleich:

    Stop accepting new requests
            ↓
    Finish active requests
            ↓
    Close database connections
            ↓
    Exit
    

    Dies ist umso wichtiger, wenn Sie mehrere Instanzen der Anwendung gleichzeitig ausführen.

    Die Wiederholungslogik kann Probleme verschlimmern

    Wenn ein Aufruf an einen externen Dienst fehlschlägt, scheint es die offensichtliche Lösung zu sein, den Versuch zu wiederholen:

    try {
      await callService();
    } catch {
      await callService();
    }
    

    Das sieht für sich genommen harmlos aus.

    Aber stellen Sie sich nun tausend Anfragen vor, die alle diesem Muster folgen.

    Nehmen wir an, die Abhängigkeit beginnt langsam zu reagieren.

    Jeder dieser fehlgeschlagenen Aufrufe wird automatisch erneut versucht.

    Die bereits überlastete Abhängigkeit erhält nun noch mehr Last als zuvor.

    Dieses Muster hat einen Namen: ein Wiederholungssturm.

    Die Wiederholungslogik muss sorgfältig entworfen werden, wobei Aspekte wie folgt berücksichtigt werden müssen:

    • eine Obergrenze für die Versuche
    • exponentielles Zurücksetzen der Zeitintervalle
    • Jitter
    • sinnvolle Zeitlimits
    • Garantien für Idempotenz
    • Circuit-Breaking, wo es sinnvoll ist

    Das Wiederholen des Versuchs ist nicht automatisch ein Mechanismus zur Behebung von Problemen.

    In manchen Fällen ist es genau das, was aus einem kleinen Störungsfall einen vollständigen Ausfall macht.

    Warum Produktionsfehler sich so anders anfühlen

    Ein typischer lokaler Fehler lässt sich im Grunde wie folgt beschreiben:

    I made a mistake.
    

    Ein typischer Produktionsvorfall sieht eher so aus:

    Several individually reasonable things interacted badly.
    

    Zum Verständnis:

    Traffic increases
            ↓
    More API instances
            ↓
    More database connections
            ↓
    Database reaches connection limit
            ↓
    Queries fail
            ↓
    Requests retry
            ↓
    More load
            ↓
    System becomes even less stable
    

    Keine der einzelnen Zeilen sieht für sich genommen unbedingt gefährlich aus.

    Das eigentliche Problem liegt darin, wie sie unter Last miteinander interagieren.

    Deshalb richtet sich der Fokus in der Backend-Entwicklung im Laufe der Zeit weniger auf isolierte Funktionen und mehr auf das Verhalten des Systems als Ganzes.

    Der gefährlichste Satz in der Backend-Entwicklung

    Vielleicht ist der riskanteste Satz in der gesamten Backend-Arbeit:

    "Das wird niemand tun."

    Niemand wird:

    • 10.000 Anfragen auf einmal senden
    • Einen 500 MB großen Datei hochladen
    • Zweimal auf die Schaltfläche „Absenden“ klicken
    • Fehlerhaftes JSON senden
    • Sofort eine Million Zeilen anfordern
    • Weiterhin einen veralteten Client verwenden
    • Sie direkt auf die API zugreifen anstatt über die Benutzeroberfläche
    • Daß der externe Dienst abbricht
    • Zwei Aktualisierungen zur gleichen Zeit auslösen
    • Den Server mitten in einer Anfrage neu starten

    Aber irgendwann tut es jemand doch.

    Mannchmal aus Versehen. Manchmal wegen eines Fehlers anderswo im System. Manchmal aufgrund instabiler Netzwerke. Manchmal einfach, weil Ihr Produkt an Bedeutung gewinnt.

    In der Produktion müssen keine böswilligen Akteure vorhanden sein, damit schwache Annahmen aufgedeckt werden. Allein die enorme Skala reicht aus, um sie von selbst ans Licht zu bringen.

    Auf Fehler vorbereiten, nicht nur auf Erfolg

    Eine wichtige Veränderung in der Denkweise erfahrener Backend-Entwickler besteht darin, dass man bei der Erstellung einer Funktion nicht nur prüfen sollte, was passiert, wenn sie erfolgreich ist, sondern auch, was geschieht, falls sie fehlschlägt.

    What happens when it succeeds?
    
    What happens when it fails?
    

    Wenn Sie auf die Datenbank zugreifen, sollten Sie prüfen, was passiert, falls sie nicht erreichbar ist.

    What if it's unavailable?
    

    Wenn Sie Redis aufrufen, sollten Sie prüfen, was geschieht, falls es langsam antwortet.

    What if it's slow?
    

    Wenn Sie eine E-Mail senden, sollten Sie prüfen, was passiert, falls die Anfrage beim Anbieter abbricht.

    What if the provider times out?
    

    Wenn Sie eine Datei hochladen, sollten Sie prüfen, was geschieht, falls die Speicherung fehlschlägt, sobald der Datensatz in der Datenbank bereits geschrieben wurde.

    What if storage fails after the database record is created?
    

    Wenn Sie eine Bestellung erstellen, sollten Sie prüfen, was passiert, falls derselbe Antrag zweimal eingeht.

    What if the request is repeated?
    

    Wenn Sie eine Zahlung verarbeiten, sollten Sie prüfen, was geschieht, falls der Kunde nie eine Antwort erhält.

    What if the client never receives the response?
    

    Beim Deployen sollten Sie fragen, was passiert, wenn die neue Version beim Start abstürzt.

    What if the new version crashes?
    

    Durch kontinuierliches Stellen dieser Fragen ändert sich Ihre Architekturdesignmethode.

    Ein einfaches mentales Modell für die Entwicklung von Produktions-Backends

    Beim Erstellen einer Backend-Funktion hilft es, diese durch fünf Denkebenen zu prüfen.

    1. Korrektheit

    Führt die Funktion tatsächlich das aus, wofür sie vorgesehen ist?

    Create order → order actually gets created
    

    2. Sicherheit

    Könnte jemand eine Aktion ausführen, die ihm nicht gestattet sein sollte?

    User A → access User B's order
    

    3. Leistung

    Was passiert, wenn das Datenvolumen oder der Datenverkehr erheblich zunimmt?

    100 users → 10 million users
    

    4. Zuverlässigkeit

    Was passiert, wenn die Systeme, von denen diese Funktion abhängt, ausfallen?

    Database unavailable
    

    5. Bedienbarkeit

    Können Sie im Falle eines Defekts tatsächlich diagnostizieren und beheben?

    Request failed at 03:42:17
    

    Durchlaufen dieser fünf Ebenen führt in der Regel dazu, dass eine überraschend große Anzahl von Problemen bereits vor dem Eintreffen in die Produktion aufgedeckt wird.

    Von „Es funktioniert“ zu „Es überlebt“

    Zwischen Software, die einfach funktioniert, und Software, die dem Kontakt mit der realen Welt standhält, besteht eine echte Lücke.

    Eine lokale Build muss nur eine Sache nachweisen:

    feature works
    

    Eine Produktion Build muss mehrere Dinge nachweisen:

    feature works
    feature fails safely
    feature handles load
    feature protects data
    feature can be monitored
    feature can recover
    feature can be deployed
    feature can be rolled back
    

    Das ist ein deutlich höherer Standard, den es zu erfüllen gilt. Genau das macht die Backend-Entwicklung zu einer wirklich interessanten Disziplin.

    Die 10 Fehler nochmals

    Hier ist eine kurze Zusammenfassung dessen, was behandelt wurde.

    1. Fehlende Umgebungskonfiguration

    Gehen Sie nicht davon aus, dass die auf Ihrem Laptop vorhandene Einrichtung auch in der Produktion vorhanden ist. Überprüfen Sie bei Start die erforderlichen Umgebungsvariablen und geben Sie einen deutlichen Fehlermeldung, falls sie fehlen.

    2. Hardcoding von localhost

    In einem Container bezieht sich localhost nur auf diesen Container selbst. Verlassen Sie sich auf eine ordnungsgemäße Service-Entdeckung sowie Konfiguration, die sich je nach Umgebung anpasst.

    3. Erstellen von Datenbankverbindungen pro Anfrage

    Verwenden Sie einen Connection Pool und berücksichtigen Sie die Gesamtzahl der Verbindungen in allen laufenden Instanzen Ihrer Anwendung.

    4. Zurückgeben aller Daten aus der Datenbank

    Vermeiden Sie es aus Gewohnheit, SELECT * zu verwenden. Senden Sie nur die Felder zurück, die Ihre API-Nutzer tatsächlich benötigen.

    5. Ignorieren von Indizes

    Eine Abfrage, die bei tausend Zeilen gut funktioniert, kann bei Millionen versagen. Testen Sie mit Datenvolumina, die der Produktionsumgebung ähneln.

    6. Lokalen Speicher als dauerhaft betrachten

    Container sowie viele Cloud-Plattformen im Allgemeinen bieten keine Garantie dafür, dass lokale Dateien erhalten bleiben. Leiten Sie von Benutzern hochgeladenen Inhalt an eine für Langlebigkeit konzipierte Speicherlösung um.

    7. Keine Bandbreitenbeschränkung

    Kunden kontrollieren nicht, wie oft sie Ihre API aufrufen. Setzen Sie Grenzen für Endpunkte, die empfindlich sind oder deren Ausführung kostspielig ist.

    8. Lang andauernde synchrone Anfragen

    Intensive Verarbeitung sollte in der Regel als Hintergrundaufgabe erfolgen, nicht warten, bis eine HTTP-Anfrage abgeschickt wird.

    9. Vertrauen in Eingaben des Clients

    Überprüfen Sie alles erneut an der Grenze zum Backend. Alleinige Prüfungen am Frontend reichen niemals aus.

    10. Verwechslung von „Prozess ist aktiv“ mit „Anwendung ist gesund“

    Verlassen Sie sich auf Gesundheitsprüfungen, Überwachung, strukturierte Protokolle, Metriken und Warnmeldungen, die tatsächlich etwas bedeuten.

    Zusammenfassung

    Beunruhigend an Produktionsproblemen ist, dass fast keines auf den ersten Blick besorgniserregend erscheint.

    Ein hardcodierter localhost wirkt nicht wie ein Problem. Ein fehlender Index wirkt nicht wie ein Problem. Das Eröffnen einer neuen Datenbankverbindung pro Anfrage wirkt nicht wie ein Problem. Ein fehlender Timeout wirkt nicht wie ein Problem. Eine API-Route ohne Beschränkungen wirkt nicht wie ein Problem. Das Schreiben einer Datei direkt auf die Festplatte wirkt nicht wie ein Problem.

    Nur wenn echte Nutzer auftauchen, ändert sich das.

    Dann beginnen diese kleinen, scheinbar harmlosen Annahmen miteinander in Konflikt zu geraten. Plötzlich steht man vor:

    slow APIs
    database connection exhaustion
    memory spikes
    timeouts
    failed deployments
    missing files
    duplicate payments
    security issues
    

    Es geht nicht darum, alle denkbaren Fehler im Voraus vorherzusehen – das ist nicht möglich. Es geht darum, aufzuhören, Systeme unter der Prämisse zu bauen, dass alles immer reibungslos funktionieren wird.

    Denn das wird es nicht.

    Netzwerke versagen. Datenbanken verlangsamen sich. Container werden neu gestartet. Benutzer senden Eingaben, die man nicht erwartet hat. Abhängigkeiten weiter unten in der Kette gehen offline. Der Datenverkehr steigt ohne Vorwarnung an. Server verlieren den Speicher. Anfragen werden dupliziert. Deployments laufen schief.

    Nichts davon muss katastrophal sein, solange Ihr System von Anfang an mit diesen Möglichkeiten berücksichtigt wurde.

    Was wirklich einen Backend-System, das nur auf localhost läuft, von einem unterscheidet, das in der Produktion funktioniert, ist nicht die gewählte Programmiersprache – sondern die Denkweise hinter der Konzeption.

    Auf localhost lautet die Frage einfach: Funktioniert es?

    In der Produktion ist die wichtige Frage: Was passiert, wenn es nicht funktioniert?

    Diese zweite Frage, immer wieder gestellt, lehrt Sie viel mehr über Backend-Engineering als jedes Framework oder Tool jemals könnte.

    Falls Sie Ihr erstes Produktivsystem aufbauen, widerstehen Sie dem Drang, alles auf einmal zu reparieren. Beginnen Sie klein: Stellen Sie die Konfigurationsverwaltung richtig ein, fügen Sie eine Verbindungs-Pooling-Funktion hinzu, validieren Sie alle Eingaben, erstellen Sie Indizes entsprechend der tatsächlichen Abfragen an die Daten, handhaben Sie Zeitüberschreitungen explizit, speichern Sie Dateien am richtigen Ort, fügen Sie sinnvolle Protokollierung hinzu, überwachen Sie das Wichtige und denken Sie unterwegs über Fehlerfallszenarien nach. Wiederholen Sie diesen Prozess anschließend immer wieder.

    Produktivbereitschaft ist kein Kästchen, das man einmal ankreuzt und dann vergisst. Es ist eine Gewohnheit, die man als Entwickler im Laufe der Zeit aufbaut.

    Und allmählich wird das zum Unterschied zwischen einem System, das nur „auf meinem Rechner funktioniert“, und einem System, das tatsächlich in kritischen Situationen standhält.