Häufige Missverständnisse zu Node.js und Datenbanken, die Fehler in der Produktion verursachen
Erfahren Sie, warum async/await, Connection Pooling und ORMs nicht automatisch Rennbedingungen, Verbindungserschöpfung oder SQL-Injection in Node.js-Anwendungen verhindern.
Node.js und Datenbanken haben eine schwierige Beziehung, die auf einer gemeinsamen Falle beruht: Es ist so einfach, etwas Funktionsfähiges zum Laufen zu bringen, dass Entwickler aufgrund dieses ersten Erfolgs Annahmen treffen und diese nie wieder in Frage stellen. Diese Annahmen funktionieren gut, bis echter Traffic, gleichzeitige Benutzer oder größere Datenvolumina die Kluft zwischen dem, was als wahr erschien, und dem, was tatsächlich geschah, aufzeigen. Im Folgenden werden fünf der schädlichsten Missverständnisse zusammen mit einer Erklärung dafür dargestellt, was in jedem Fall tatsächlich vor sich geht.
Die Annahme: Die Verwendung von async/await schützt Datenbankoperationen automatisch vor Race Conditions.
Was tatsächlich zutrifft: async/await lässt asynchrone Codeblöcke beim Lesen einfach wie sequentiell erscheinen. Es bietet keine Garantie für Atomität bei Datenbankoperationen, und zwei separate Anfragen können weiterhin so miteinander verknüpft werden, dass ein falsches Ergebnis entsteht.
// looks sequential, isn't safe under concurrency
async function reserveSeat(eventId, seatNumber) {
const seat = await db.query(
"SELECT status FROM seats WHERE event_id = $1 AND number = $2",
[eventId, seatNumber]
);
if (seat.status === "available") {
await db.query(
"UPDATE seats SET status = 'reserved' WHERE event_id = $1 AND number = $2",
[eventId, seatNumber]
);
}
}
Zwei separate Anfragen können jeweils einen SELECT-Befehl ausführen, jeweils den Sitz als available erkennen und anschließend denselben Sitz buchen. Das passiert, weil await die Ausführung nur für die Anfrage pausiert, die ihn ausgelöst hat – es verhindert nicht, dass eine zweite, unabhängige Anfrage zwischen einem Lesen und dem entsprechenden Schreiben eingefügt wird. Die eigentliche Lösung lässt sich nicht durch eine Korrektur auf JavaScript-Ebene erreichen; sie muss auf der Datenbankebene erfolgen, entweder durch eine atomare bedingte Aktualisierung oder durch eine Transaktion mit ordnungsgemäßem Zeilenverschlüsselung.
async function reserveSeat(eventId, seatNumber) {
const result = await db.query(
`UPDATE seats SET status = 'reserved'
WHERE event_id = $1 AND number = $2 AND status = 'available'
RETURNING *`,
[eventId, seatNumber]
);
return result.rowCount > 0; // false means someone beat you to it
}
async/await ist nichts anderes als syntaktischer Zucker zum Arbeiten mit Promises. Es wurde nie dafür konzipiert, die Konkurrenzsicherheit zu gewährleisten, und genau das Fehlverständnis, es würde dies tun, führt zu Fehlern durch doppelte Buchungen.
Die Annahme: Da Node auf einem einzigen Thread läuft, ist das Connection Pooling nicht so entscheidend wie in einer mehrthreadigen Sprache.
Was tatsächlich zutrifft: Die eintretige Natur von Node bezieht sich auf die Ausführung von JavaScript, nicht darauf, wie die Datenbank I/O verarbeitet. Ein einzelner Node-Prozess kann problemlos Hunderte von Datenbankabfragen gleichzeitig ausführen, wobei jede davon eine echte Netzwerkverbindung zu einer tatsächlichen Datenbank darstellt, die für jede Abfrage geöffnet, aufrechterhalten und schließlich geschlossen werden muss.
// a new connection per query, under real traffic, this collapses fast
async function getUser(id) {
const conn = await mysql.createConnection(config);
const [rows] = await conn.query("SELECT * FROM users WHERE id = ?", [id]);
await conn.end();
return rows[0];
}
Jeder Aufruf einer Funktion wie createConnection beinhaltet einen TCP-Handshake sowie einen Authentifizierungs Schritt, und nahezu jede Datenbank setzt eine feste Obergrenze dafür fest, wie viele Verbindungen sie gleichzeitig akzeptieren darf. Connection Pooling verringert diese Überhead-Kosten, indem es eine Gruppe von Verbindungen im Voraus offen hält und sie nach Bedarf zur Verfügung stellt:
const pool = mysql.createPool({ ...config, connectionLimit: 10 });
async function getUser(id) {
const [rows] = await pool.query("SELECT * FROM users WHERE id = ?", [id]);
return rows[0];
}
Das Konkurrenzmodell von Node ist genau der Grund, warum Connection Pooling wichtig ist – und nicht ein Grund, darauf zu verzichten. Ein einzelner Node-Prozess kann tatsächlich und tut dies auch, in jedem beliebigen Moment Dutzende von Abfragen parallel ausführen.
Die Annahme: Ein ORM beseitigt völlig die Notwendigkeit, sich mit SQL-Injection auseinanderzusetzen.
Was tatsächlich zutrifft: Der Schutz gilt nur solange der Code innerhalb der eigenen Abfragenkonstruktions-API des ORMs bleibt. Er verschwindet sofort, sobald eine rohe Abfrage geschrieben wird oder ein WHERE-Klausel durch Zeichenkettenverknüpfung erstellt wird – etwas, das häufiger passiert, als man denkt, insbesondere wenn eine Abfrage komplex genug wird, sodass die Abstraktionen des ORMs einschränkend wirken.
// still vulnerable, ORM or not
const results = await sequelize.query(
`SELECT * FROM users WHERE email = '${userInput}'`
);
Die Sicherheit eines ORMs ergibt sich konkret aus den parametrisierten Abfragen, die dahinter laufen, und nicht aus einem universellen Schutzmechanismus, der dem Code überallhin folgt. Sobald SQL als einfache Zeichenkette konstruiert wird, ist dieser Schutz bereits verloren gegangen, unabhängig davon, ob ein ORM darüber liegt:
const results = await sequelize.query(
"SELECT * FROM users WHERE email = :email",
{ replacements: { email: userInput }, type: QueryTypes.SELECT }
);
Die wirklich gültige Regel: Eingaben des Benutzers dürfen niemals direkt in eine Abfragesuche eingefügt werden, unabhängig davon, welche Abstraktionsschicht zwischen dem Code und der rohen SQL-Datei liegt.
Die Annahme: Ein unverarbeiteter Fehler bei einem Datenbankaufruf landet automatisch im Fehlerbehandlungs-Middleware von Express.
Was tatsächlich zutrifft: Die eingebaute Fehlerbehandlung von Express fängt synchron ausgelöste Ausnahmen innerhalb der Route-Handler sowie Fehler auf, die explizit über next(err) übergeben werden. Sie fängt jedoch nicht automatisch abgelehnte Promises aus async-Route-Handlern ein, es sei denn, die verwendete Express-Version unterstützt dieses Verhalten nativ oder wurde so konfiguriert, dass es manuell behandelt wird.
// on many Express setups, a rejected promise here never reaches your error handler
app.get("/users/:id", async (req, res) => {
const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
res.json(user);
});
Falls die Promise dieser Abfrage abgelehnt wird und es nichts gibt, das dies auffängt, entsteht eine unverarbeitete Promise-Ablehnung – was in aktuellen Node-Versionen dazu führen kann, dass der gesamte Prozess abstürzt. Dadurch werden alle anderen zu diesem Zeitpunkt verarbeiteten Anfragen beeinträchtigt, nicht nur diejenige, die zum Fehler geführt hat.
app.get("/users/:id", async (req, res, next) => {
try {
const user = await db.query("SELECT * FROM users WHERE id = $1", [req.params.id]);
res.json(user);
} catch (err) {
next(err); // now Express's error handler actually sees it
}
});
Das Manuell-Einwickeln jeder asynchronen Route wird schnell mühsam, weshalb es sich lohnt, bereits früh im Projekt entweder einen leichten Middleware-Wrapper oder eine Express-Version mit eingebauter Unterstützung für asynchrone Fehler einzurichten, anstatt anzunehmen, dass Fehler von selbst richtig weitergeleitet werden.
Die Annahme: Eine Abfrage, die bei lokaler Entwicklung schnell läuft, wird auch in der Produktion genauso gut funktionieren.
Was tatsächlich zutrifft: Lokale Entwicklungsdatenbanken sind in der Regel klein, bestenfalls nur unvollständig indiziert und laufen auf Hardware, der keine wirkliche Belastung ausgesetzt wird. Eine Abfrage, die zehntausend Zeilen auf einem Laptop durchsucht, und dieselbe Abfrage, die zehn Millionen Zeilen in der Produktivumgebung durchsucht, sind aus praktischer Sicht im Grunde unterschiedliche Abfragen – auch wenn der SQL-Text identisch ist.
// fine with 500 test rows, a real problem with 5 million production rows
const orders = await db.query(
"SELECT * FROM orders WHERE customer_email = $1 ORDER BY created_at DESC"
);
Fehlt ein Index auf customer_email, führt diese Abfrage zu einem vollständigen Tabellenscan. Der Unterschied zwischen „sofortiger Ausführung“ und „mehreren Sekunden Dauer“ hängt ausschließlich von der Größe der Tabelle ab – etwas, was lokale Entwicklungsumgebungen fast nie genau widerspiegeln. Die wirklich schützende Vorgehensweise besteht nicht darin, den Code anders zu schreiben, sondern darin, Tests mit Datenmengen durchzuführen, die denen in der Produktion ähneln, oder zumindest EXPLAIN auf einer tabellengroßen Datenmenge in der Produktion auszuführen, bevor man annimmt, dass etwas, das lokal funktioniert, Rückschlüsse auf das Verhalten unter echter Last zulässt.
Was alle fünf Aspekte miteinander verbindet
Jeder dieser Missverständnisse hat seine Wurzel in derselben Ursache: Etwas schien zu funktionieren, und dieser scheinbare Erfolg wurde zu einer Regel erhoben, anstatt als ein einzelnes Ergebnis erkannt zu werden, das einfach nicht fehlgeschlagen ist. Node und eine Datenbank sind zwei unterschiedliche Systeme, die über ein Netzwerk miteinander kommunizieren, jeweils mit eigenen Garantien und eigenen Möglichkeiten zum Versagen – und die lesbare Syntax von JavaScript beseitigt diese Trennung nicht nur deshalb, weil sie den Code leichter verständlich macht. Die tatsächlichen Lösungen sind selten kompliziert. Die wahre Fähigkeit besteht darin, zu erkennen, welche Annahme von vornherein in Frage gestellt werden sollte.
Verwandte Literatur
- Gemeine Verständnissfehler von async/await, die Produktionsfehler verursachen – Erklärt neun subtile Missverständnisse bezüglich async/await – von Rennbedingungen bis hin zu unverarbeiteten Ablehnungen –, die JavaScript-Anwendungen in der Praxis heimlich zerstören.
- Fünf tauschende Node.js-Fehler, die unauffallig durch die Kodebewertung kommen – Zeigt fünf reale Node.js-Fehler, die forEach, schwimmende Promises und flache Kopien betreffen, um zu verstehen, warum Code, der einwandfrei läuft, dennoch in der Produktion versagen kann.