Zum Inhalt springen

Das DataLoader-Muster: N+1-Abfragen ohne ORM eliminieren

N+1 ist kein ORM-Problem: implementiere das DataLoader-Batching-Muster in TypeScript und fasse redundante Abfragen jeder Datenquelle zusammen.

4 Min. Lesezeit
TypeScript-Code, der eine DataLoader-Klasse zeigt, die Datenbankabfragen bündelt, um N+1-Abfragemuster zu eliminieren

Das N+1-Abfrageproblem wird den ORMs angelastet. Lazy Loading reparieren, eager: true hinzufügen, einen JOIN verwenden. Aber N+1 ist kein ORM-Problem — es ist ein strukturelles Problem, das immer dann auftritt, wenn Daten innerhalb einer Schleife abgerufen werden. GraphQL-Resolver, REST-Endpunkte, die mehrere Services zusammensetzen, React Server Components, die datenbankseitige Aufrufe pro Zeile machen: das Muster ist überall, und der ORM ist nur der Bote.

Die Lösung ist nicht immer ein JOIN. Manchmal leben Daten in verschiedenen Services. Manchmal rufst du eine API eines Drittanbieters auf. Das DataLoader-Muster — ursprünglich aus Facebooks GraphQL-Infrastruktur — löst das auf der richtigen Abstraktionsebene, ohne deine Resolver-Grenzen einzuebnen.

Wie N+1 in der Praxis aussieht

Hier ist ein realistisches Beispiel: ein GraphQL-Resolver, der eine Liste von Blogposts zurückgibt, jeder mit einem zugehörigen Autor.

tstypescript
// ❌ N+1 — authorResolver fires once per post, 20 posts = 21 queries
const postsResolver = async () => {
  return db.query<Post[]>("SELECT * FROM posts ORDER BY created_at DESC LIMIT 20");
};
 
const authorResolver = async (post: Post): Promise<User> => {
  return db.queryOne<User>("SELECT * FROM users WHERE id = $1", [post.authorId]);
};

Jeder Resolver für sich wirkt völlig vernünftig. Das Problem zeigt sich erst zur Laufzeit unter Last. Zwanzig Posts ergeben zwanzig Autor-Abfragen zusätzlich zur anfänglichen Listenabfrage.

Der naive Fix funktioniert, wenn die Daten in derselben Datenbank liegen:

tstypescript
// ✅ For same-DB data: batch in the parent, build a lookup map
const postsWithAuthorsResolver = async () => {
  const posts = await db.query<Post[]>(
    "SELECT * FROM posts ORDER BY created_at DESC LIMIT 20"
  );
  const authorIds = [...new Set(posts.map((p) => p.authorId))];
  const authors = await db.query<User[]>(
    "SELECT * FROM users WHERE id = ANY($1)",
    [authorIds]
  );
  const byId = new Map(authors.map((a) => [a.id, a]));
  return posts.map((p) => ({ ...p, author: byId.get(p.authorId) }));
};

Aber das bricht die Resolver-Trennung, die GraphQL komponierbar macht. Und es fällt komplett auseinander, wenn die Autordaten aus einem separaten User-Service kommen.

Die Batching-Primitive

Der Kern der Sache ist, einzelne Abrufe bis zum Ende des aktuellen Event-Loop-Ticks zu verzögern und sie dann alle als einen einzigen Batch zu flushen. Genau dafür wurde queueMicrotask entwickelt.

tstypescript
type BatchFn<K, V> = (keys: readonly K[]) => Promise<Map<K, V>>;
 
class DataLoader<K, V> {
  private readonly batchFn: BatchFn<K, V>;
  private queue: Array<{
    key: K;
    resolve: (value: V) => void;
    reject: (error: Error) => void;
  }> = [];
  private scheduled = false;
  private cache = new Map<K, Promise<V>>();
 
  constructor(batchFn: BatchFn<K, V>) {
    this.batchFn = batchFn;
  }
 
  load(key: K): Promise<V> {
    // Return cached promise — same key in one request never fires twice
    const cached = this.cache.get(key);
    if (cached) return cached;
 
    const promise = new Promise<V>((resolve, reject) => {
      this.queue.push({ key, resolve, reject });
    });
 
    this.cache.set(key, promise);
 
    if (!this.scheduled) {
      this.scheduled = true;
      queueMicrotask(() => this.flush());
    }
 
    return promise;
  }
 
  private async flush(): Promise<void> {
    const batch = this.queue.splice(0);
    this.scheduled = false;
    const keys = batch.map((item) => item.key);
 
    try {
      const results = await this.batchFn(keys);
      for (const { key, resolve, reject } of batch) {
        const value = results.get(key);
        if (value !== undefined) {
          resolve(value);
        } else {
          reject(new Error(`DataLoader: no result for key "${String(key)}"`));
        }
      }
    } catch (err) {
      for (const { reject } of batch) {
        reject(err instanceof Error ? err : new Error(String(err)));
      }
    }
  }
}

load(key) wird N Mal über verschiedene Resolver aufgerufen. Alle Aufrufe innerhalb desselben Microtask-Checkpoints werden in die Warteschlange gestellt. flush() wird genau einmal ausgeführt, sendet eine einzige Batch-Anfrage und verteilt die Ergebnisse dann an die einzelnen wartenden Promises zurück.

Anbindung an eine echte Datenquelle

In der Batch-Funktion steckt die Strategie. Hier ist eine, die auf PostgreSQL basiert:

tstypescript
const batchUsers: BatchFn<string, User> = async (userIds) => {
  const rows = await db.query<User[]>(
    "SELECT * FROM users WHERE id = ANY($1)",
    [userIds]
  );
  return new Map(rows.map((u) => [u.id, u]));
};
 
const userLoader = new DataLoader(batchUsers);
 
// Each resolver calls load() — batching is transparent
const authorResolver = async (post: Post): Promise<User> => {
  return userLoader.load(post.authorId);
};

Bei einem Microservice-Aufruf sieht die Batch-Funktion identisch aus — nur die SQL-Abfrage wird gegen einen HTTP-Request getauscht:

tstypescript
const batchUsersFromService: BatchFn<string, User> = async (userIds) => {
  const response = await fetch(`${USER_SERVICE_URL}/users/batch`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ids: [...userIds] }),
  });
 
  if (!response.ok) {
    throw new Error(`User service responded with ${response.status}`);
  }
 
  const users: User[] = await response.json();
  return new Map(users.map((u) => [u.id, u]));
};

Der Resolver-Code ändert sich nicht. Die Batching-Strategie ist vollständig in der Batch-Funktion des Loaders gekapselt.

Scoping pro Request

Es gibt eine subtile Gefahr in der obigen Implementierung: die cache-Map lebt auf der Loader-Instanz. Erstellst du einen Loader im Modul-Scope, lecken zwischengespeicherte Daten aus einem Request in den nächsten.

!

Teile niemals eine DataLoader-Instanz über Requests hinweg. Der pro-Request-Cache ist ein Feature — aber nur, wenn der Loader für jeden Request-Lebenszyklus neu erstellt wird.

Das Standardmuster ist AsyncLocalStorage, um Loader auf einen Request zu scopen:

tstypescript
import { AsyncLocalStorage } from "node:async_hooks";
 
interface RequestLoaders {
  user: DataLoader<string, User>;
  post: DataLoader<string, Post>;
}
 
const loaderStorage = new AsyncLocalStorage<RequestLoaders>();
 
// Express / Fastify middleware — fresh loaders per request
export function attachLoaders(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  loaderStorage.run(
    {
      user: new DataLoader(batchUsers),
      post: new DataLoader(batchPosts),
    },
    next
  );
}
 
// Anywhere downstream in the call stack
export function getLoaders(): RequestLoaders {
  const store = loaderStorage.getStore();
  if (!store) throw new Error("getLoaders() called outside request context");
  return store;
}

Frische Loader pro Request, keine Kreuzkontamination, automatische Cache-Deduplizierung innerhalb einer einzelnen Request-Lebenszeit.

Wann Batching nicht das richtige Werkzeug ist

DataLoader reduziert N+1 auf 1, aber manchmal ist selbst eine einzelne Batch-Abfrage mehr, als du brauchst — oder weniger. Es zählt, zu wissen, wann du danach greifst.

MusterGeeignet für
DataLoader (pro-Request-Cache)Auflösen von Beziehungen in GraphQL/REST-Antworten
Redis mit TTLDaten über Requests hinweg, die selten ändern
SQL-JOINKo-lokalisierte Daten mit vorhersehbaren, gleichförmigen Zugriffsmustern
Materialisierte ViewLeseintensive Aggregationen, die nach Plan aktualisiert werden
In-Process-LRU-CacheHeiße Referenzdaten (Feature-Flags, Config, Enums)

Greife nicht zu DataLoader, wenn ein JOIN einfacher ist — es verdient seine Komplexität, wenn Daten Service-Grenzen überspannen oder wenn Resolver-Isolation wichtiger ist als rohe Abfrageeffizienz. Das Muster setzt auch voraus, dass deine Batch-Funktion beliebige Key-Sets akzeptieren kann; wenn die Downstream-API nur das Abrufen einzelner Datensätze unterstützt, löst du das falsche Problem.

Wichtige Erkenntnisse

  1. N+1 ist strukturell, nicht ORM-spezifisch — es tritt immer dann auf, wenn Daten innerhalb einer Schleife abgerufen werden, unabhängig von der Datenschicht
  2. Zuerst verzögern, dann flushen — queueMicrotask sammelt alle load()-Aufrufe innerhalb eines Ticks in einem einzigen Batch; keine manuelle Koordination nötig
  3. Die Batch-Funktion ist der Adapter — dieselbe DataLoader-Implementierung funktioniert mit SQL, HTTP, Redis oder jeder asynchronen Quelle
  4. Scoppe Loader auf den Request — ein Loader auf Modulebene liefert nachfolgenden Requests veraltete Cache-Daten; verwende AsyncLocalStorage, um frische Instanzen zu erzeugen
  5. Cache-Deduplizierung ist kostenlos — zweimaliger Aufruf von loader.load(id) mit demselben Key gibt dieselbe Promise zurück und verhindert redundante laufende Anfragen
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX