Eine API zu bauen, die für 100 Benutzer funktioniert, ist einfach. Eine API zu bauen, die für 100.000 oder 1 Million Benutzer funktioniert — ohne grundlegend umgeschrieben zu werden — erfordert Architekturentscheidungen, die von Anfang an getroffen werden müssen.
Dieser Leitfaden behandelt die wichtigsten Designprinzipien für skalierbare APIs.
Datenbankoptimierung: Der häufigste Bottleneck
Indizes konsequent nutzen
-- Schlechte Query: Full Table Scan
SELECT * FROM orders WHERE customer_id = 123 AND status = 'pending';
-- Index hinzufügen
CREATE INDEX idx_orders_customer_status ON orders(customer_id, status);
-- EXPLAIN ANALYZE prüfen
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 123 AND status = 'pending';
-- Ziel: Index Scan statt Seq Scan
N+1-Problem vermeiden
// Schlecht: N+1 Queries (1 Query für Users + N Queries für Orders)
const users = await db.users.findMany();
for (const user of users) {
user.orders = await db.orders.findMany({ where: { userId: user.id } });
}
// Gut: Eager Loading mit JOIN
const users = await db.users.findMany({
include: {
orders: {
where: { status: 'active' },
select: { id: true, total: true, createdAt: true }
}
}
});
Read Replicas für Lesequeries
// Trennung: Schreibzugriffe auf Primary, Lesezugriffe auf Replica
const primaryDb = new Pool({ connectionString: process.env.DATABASE_PRIMARY_URL });
const replicaDb = new Pool({ connectionString: process.env.DATABASE_REPLICA_URL });
// Lese-API-Endpunkte nutzen Replica
app.get('/api/products', async (req, res) => {
const products = await replicaDb.query('SELECT * FROM products WHERE active = true');
res.json(products.rows);
});
// Schreib-Endpunkte nutzen Primary
app.post('/api/orders', async (req, res) => {
const order = await primaryDb.query('INSERT INTO orders ...', [...]);
res.json(order.rows[0]);
});
Caching-Strategien
In-Memory-Cache mit Redis
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
async function getProduct(productId) {
const cacheKey = `product:${productId}`;
// Cache-Hit prüfen
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Cache-Miss: Datenbank abfragen
const product = await db.products.findUnique({ where: { id: productId } });
// In Cache speichern (TTL: 1 Stunde)
await redis.setex(cacheKey, 3600, JSON.stringify(product));
return product;
}
// Cache invalidieren bei Updates
async function updateProduct(productId, data) {
await db.products.update({ where: { id: productId }, data });
await redis.del(`product:${productId}`); // Cache löschen
}
HTTP-Caching mit ETags
app.get('/api/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
// ETag aus Daten-Hash generieren
const etag = crypto.createHash('md5').update(JSON.stringify(product)).digest('hex');
// Prüfen ob Client aktuelles Datenstand hat
if (req.headers['if-none-match'] === etag) {
return res.status(304).end(); // Not Modified - keine Daten übertragen
}
res.set('ETag', etag);
res.set('Cache-Control', 'private, max-age=60'); // 60 Sekunden Cache
res.json(product);
});
Rate Limiting
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Standard Rate Limit: 100 Anfragen pro 15 Minuten
const standardLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
store: new RedisStore({ client: redis }), // Über mehrere Server-Instanzen verteilt
message: { error: 'Rate limit exceeded', retryAfter: 900 },
headers: true, // X-RateLimit-* Headers setzen
});
// Strikterer Limit für Auth-Endpunkte
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
store: new RedisStore({ client: redis }),
});
app.use('/api/', standardLimiter);
app.use('/api/auth/', authLimiter);
// Per-User Rate Limiting (für authentifizierte APIs)
const userLimiter = rateLimit({
windowMs: 60 * 1000, // 1 Minute
max: 60, // 60 Anfragen/Minute pro Benutzer
keyGenerator: (req) => req.user?.id || req.ip, // Pro User statt Pro IP
store: new RedisStore({ client: redis }),
});
API-Versionierung
// Version in URL (empfohlen für öffentliche APIs)
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Version in Header (empfohlen für interne APIs)
app.use('/api', (req, res, next) => {
const version = req.headers['api-version'] || 'v1';
req.apiVersion = version;
next();
});
// Abwärtskompatibilität: V2 erweitert V1 (breaking changes vermeiden)
// V1: { id, name, price }
// V2: { id, name, price, currency, formatted_price } — additive Erweiterung
Connection Pooling
// PostgreSQL Connection Pool konfigurieren
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // Maximale gleichzeitige Verbindungen
idleTimeoutMillis: 30000, // Inaktive Verbindungen nach 30s schließen
connectionTimeoutMillis: 2000, // Timeout wenn kein Connection verfügbar
});
// Für sehr hohe Last: PgBouncer als Connection Pooler vor PostgreSQL
// PgBouncer kann Tausende gleichzeitige App-Verbindungen auf
// wenige DB-Verbindungen bündeln
Pagination richtig implementieren
// Cursor-basierte Pagination (skalierbar, konsistent)
app.get('/api/products', async (req, res) => {
const { cursor, limit = 20 } = req.query;
const where = cursor
? { id: { gt: cursor } } // Nur nach Cursor-ID
: {};
const products = await db.products.findMany({
where,
take: parseInt(limit) + 1, // +1 um zu prüfen ob es mehr gibt
orderBy: { id: 'asc' },
});
const hasMore = products.length > limit;
const items = hasMore ? products.slice(0, -1) : products;
const nextCursor = hasMore ? items[items.length - 1].id : null;
res.json({
data: items,
pagination: {
hasMore,
nextCursor,
limit: parseInt(limit),
}
});
});
Asynchrone Verarbeitung für lange Tasks
// Schwere Operationen in Queue auslagern (Bull + Redis)
import Queue from 'bull';
const exportQueue = new Queue('exports', process.env.REDIS_URL);
// API gibt sofort zurück
app.post('/api/reports/export', async (req, res) => {
const job = await exportQueue.add({
userId: req.user.id,
reportType: req.body.reportType,
filters: req.body.filters,
});
res.json({
jobId: job.id,
statusUrl: `/api/jobs/${job.id}`,
message: 'Export gestartet, Sie erhalten eine E-Mail wenn fertig'
});
});
// Worker verarbeitet Jobs asynchron
exportQueue.process(async (job) => {
const { userId, reportType, filters } = job.data;
const data = await generateReport(reportType, filters);
const url = await uploadToS3(data);
await sendEmailWithReport(userId, url);
});
Monitoring und Observability
// Prometheus-Metriken für API-Performance
import promClient from 'prom-client';
const httpRequestDuration = new promClient.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5],
});
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on('finish', () => {
end({
method: req.method,
route: req.route?.path || req.path,
status_code: res.statusCode,
});
});
next();
});
// Metrics-Endpunkt für Prometheus/Grafana
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.end(await promClient.register.metrics());
});
Skalierbarkeit ist kein Feature, das man nachträglich hinzufügen kann — sie muss in die Architektur eingebaut sein. Die Techniken in diesem Leitfaden bilden eine solide Grundlage. Wenn Sie eine API bauen, die echtem Scale standhalten muss, sprechen Sie mit uns.