Durch

Keith Williams

12.07.2025

Auffinden und Beheben von O(n²)-Leistungsengpässen: Lektionen aus den Algorithmusoptimierungen von Cal.com

Ein tiefer Einblick in die Art und Weise, wie wir schlecht funktionierende Algorithmen in einer realen Produktionscodebasis erkannt, analysiert und optimiert haben.

Einführung

Leistungsengpässe in Planungssystemen können den Unterschied zwischen einem reaktionsfähigen Benutzererlebnis und Benutzern, die Ihre Plattform frustriert verlassen, bedeuten. Bei Cal.com haben wir kürzlich mehrere kritische O(n²)-Algorithmen aufgedeckt und optimiert, die Verlangsamungen verursachten, als unsere Benutzerbasis und Datenvolumen zunahmen.

In diesem Artikel werde ich vier reale Algorithmusoptimierungen aus dem Code von Cal.com teilen. Sie erfahren, wie wir O(n²)-Komplexität erkannt haben, welche Optimierungsstrategien am besten funktionierten und wie wir automatisierte Prüfungen eingeführt haben, um zukünftige Rückschritte zu verhindern.

Das Problem: Wie unschuldiger Code zu O(n²) wird

Wenn Planungsplattformen wachsen, ist es überraschend einfach, dass unschuldig aussehende verschachtelte Schleifen stille Leistungskiller werden. Betrachten Sie dieses reale Beispiel aus unserem Code:

// O(n²): Every available slot is checked against every busy period
availableTimeSlots.map(slot => {
  return busy.some(busyTime => checkOverlap(slot, busyTime));
});

Bei 100 Slots und 50 belegten Zeiträumen bedeutet das 5.000 Überschneidungsprüfungen. In größerem Maßstab – 500 Slots und 200 belegte Zeiträume – explodiert es auf 100.000 Operationen. Das ist eine zwanzigfache Erhöhung der Rechenlast bei nur fünffacher Datenvergrößerung.

Fallstudie 1: Optimierung der Slot-Konfliktprüfung

Das ursprüngliche Problem

Die Funktion checkForConflicts verwendete verschachtelte Iterationen, um zu prüfen, ob Buchungsslots mit belegten Zeiträumen konfligierten. Dies wurde schnell zu einem Leistungsproblem bei großen Zeitplänen.

Ursprüngliche Implementierung: O(n²) verschachtelte Schleifen

function checkForConflicts({ busy, time, eventLength }) {
  return busy.some(busyTime => {
    const slotStart = time.valueOf();
    const slotEnd = slotStart + eventLength * 60 * 1000;
    const busyStart = dayjs.utc(busyTime.start).valueOf();
    const busyEnd = dayjs.utc(busyTime.end).valueOf();
    return !(busyEnd <= slotStart || busyStart >= slotEnd);
  });
}

availableTimeSlots = availableTimeSlots
  .map((slot) => {
    if (
      !checkForConflicts({
        time: slot.time,
        busy: busySlotsFromReservedSlots,
        ...availabilityCheckProps,
      })
    ) {
      return slot;
    }
    return undefined;
  })

Optimierte Implementierung: O(n log n) mit Sortierung und frühem Ausstieg

function checkForConflicts({ sortedBusyTimes, time, eventLength }) {
  const slotStart = time.valueOf();
  const slotEnd = slotStart + eventLength * 60 * 1000;

  for (const busyTime of sortedBusyTimes) {
    if (busyTime.start >= slotEnd) {
      break;
    }
    if (busyTime.end <= slotStart) {
      continue;
    }
    return true;
  }
  return false;
}

Was sich geändert hat:
Wir sortierten die belegten Zeiten nach ihren Startzeiten, was es uns ermöglichte, nicht überlappende Perioden zu überspringen und frühzeitig auszusteigen, wenn keine Konflikte möglich waren. Das Ergebnis war ein dramatischer Rückgang der Anzahl unnötiger Vergleiche.

Auswirkung:
Die Komplexität sank von O(verfügbare Slots × belegte Zeiträume) zu O(n log n), und die meisten Überprüfungen wurden in typischen Fällen vollständig übersprungen.

Fallstudie 2: Datumsbereichsüberschneidung mit der Zwei-Zeiger-Technik

Das ursprüngliche Problem

Die Funktion intersect verwendete zuvor verschachtelte Schleifen, um Überschneidungen über die Verfügbarkeit mehrerer Benutzer zu finden.

Ursprüngliche Implementierung: O(n²) verschachtelte Zuordnungen

commonAvailability.forEach(commonRange => {
  userRanges.forEach(userRange => {
    const intersection = getIntersection(commonRange, userRange);
    if (intersection !== null) {
      intersectedRanges.push(intersection);
    }
  });
});

Optimierte Implementierung: O(n log n) mit Zwei-Zeiger-Durchlauf

const sortedRanges = ranges.map(userRanges =>
  userRanges
    .map(r => ({
      ...r,
      startValue: r.start.valueOf(),
      endValue: r.end.valueOf()
    }))
    .sort((a, b) => a.startValue - b.startValue)
);

let commonIndex = 0;
let userIndex = 0;

while (commonIndex < commonAvailability.length && userIndex < userRanges.length) {
  const commonRange = commonAvailability[commonIndex];
  const userRange = userRanges[userIndex];

  const intersectStartValue = Math.max(commonRange.startValue, userRange.startValue);
  const intersectEndValue = Math.min(commonRange.endValue, userRange.endValue);

  if (intersectStartValue < intersectEndValue) {
    intersectedRanges.push(createIntersection(commonRange, userRange));
  }

  if (commonRange.endValue <= userRange.endValue) {
    commonIndex++;
  } else {
    userIndex++;
  }
}

Was sich geändert hat:
Durch Sortierung und den Einsatz von zwei Zeigern, um die Bereiche durchzugehen, eliminierten wir die Notwendigkeit für verschachtelte Iterationen. Dies reduzierte die Komplexität von quadratisch auf nahezu linear für die meisten praktischen Eingaben.

Fallstudie 3: Intervallbaum für Redundante Bereichsfilterung

Das ursprüngliche Problem

Die Funktion filterRedundantDateRanges prüfte jeden Bereich gegen jeden anderen, um zu sehen, ob einer den anderen enthält. Für große Zeitpläne wurde dies extrem langsam.

Ursprüngliche Implementierung: O(n²) Enthaltenheitsprüfung

return dateRanges.filter((range, index) => {
  return !dateRanges.some((otherRange, otherIndex) => {
    if (index === otherIndex) return false;
    return isCompletelyContained(range, otherRange);
  });
});

Optimierte Implementierung: O(n log n) mit einem Intervallbaum

export function filterRedundantDateRanges(dateRanges) {
  const sortedRanges = [...dateRanges].sort((a, b) => a.start.valueOf() - b.start.valueOf());
  const intervalNodes = createIntervalNodes(
    sortedRanges,
    (range) => range.start.valueOf(),
    (range) => range.end.valueOf()
  );
  const intervalTree = new IntervalTree(intervalNodes);
  const searchAlgorithm = new ContainmentSearchAlgorithm(intervalTree);

  return sortedRanges.filter((range, index) => {
    const containingIntervals = searchAlgorithm.findContainingIntervals(
      range.start.valueOf(),
      range.end.valueOf(),
      index
    );
    return containingIntervals.length === 0;
  });
}

Was sich geändert hat:
Anstatt des brutalen Vergleichs verwendeten wir eine Intervallbaumstruktur, um schnelle O(log n) Enthaltenheitsabfragen zu ermöglichen. Dies ist ein klassischer Ansatz in der computationalen Geometrie, der Wunder für Planungsanwendungen wirkt.

Leistungsergebnisse: Vorher und Nachher

Unsere Benchmarks zeigten signifikante Verbesserungen:

  • Slot-Konfliktprüfung: Reduziert von O(n²) auf O(n log n), wodurch 100 Slots und 50 belegte Zeiträume etwa zehnmal schneller wurden.

  • Datumsbereichsüberschneidung: Von O(n²) auf O(n log n) gesunken, wodurch hunderte von Datumsbereichen innerhalb von Millisekunden verarbeitet werden können.

  • Redundante Bereichsfilterung: Von O(n²) auf O(n log n) reduziert, mit einer bis zu fünfzigfachen Beschleunigung für Tausende von Datumsbereichen.

  • Generelle Bereichsüberschneidung: Bei vielen Mustern sahen wir Verbesserungen von bis zu fünfundzwanzig Mal.


Einfache Techniken zur Erkennung von O(n²)-Algorithmen

Problematische Muster erkennen

Achten Sie auf:

  • Verschachtelte Array-Iterationen, insbesondere .map, .forEach, .filter innerhalb einer anderen.

  • Array-Methoden wie .some, .find oder .filter innerhalb von Schleifen oder Array-Rückrufen.

  • Verkettete Filter oder verschachtelte Zuordnungen über große Listen.

Skalierbarkeit explizit testen

Schreiben Sie Leistungstests, die Ihre Funktion auf Daten zunehmender Größe ausführen und die Ausführungszeiten protokollieren. Wenn die Zeit mit zunehmender Datenmenge quadratisch wächst, haben Sie ein O(n²)-Problem.

Statische Analyse automatisieren

Fügen Sie Lint-Regeln oder statische Analysen hinzu, die:

  • Funktionen mit verschachtelten Schleifen oder verschachtelten Array-Methoden kennzeichnen.

  • Rekursion ohne Memoisierung identifizieren.

  • Funktionen mit mehreren verschachtelten .some, .find oder .filter-Aufrufen erkennen.

Bewährte Strategien zum Ersetzen von O(n²)-Code

  • Verwenden Sie die binäre Suche für Abfragen in sortierten Arrays.

  • Verwenden Sie Zwei-Zeiger-Techniken zum Zusammenführen oder Schneiden sortierter Sequenzen.

  • Verwenden Sie baumbasierte Datenstrukturen für Intervallabfragen oder hierarchische Daten.

Automatisierung von Leistungsprüfungen

Fügen Sie Leistungsbenchmarks in Ihre CI-Pipeline ein, die kritische Algorithmen auf realistischen Daten ausführen. Vergleichen Sie die Ausführungszeiten bei jedem Pull-Request und blockieren Sie Merges, die sich zurückentwickeln. Fügen Sie benutzerdefinierte Lint-Regeln hinzu, um offensichtliche O(n²)-Muster daran zu hindern, jemals in die Produktion zu gelangen. Implementieren Sie eine Laufzeitüberwachung für kritische Pfade, um neue Rückschritte zu erkennen, bevor Benutzer sie bemerken.

Wichtige Erkenntnisse

  • Vorbeugen ist besser als heilen. Automatisierte Prüfungen erkennen O(n²)-Algorithmen, bevor sie Probleme verursachen.

  • Immer profilieren, bevor Sie optimieren. Verwenden Sie Tests, um tatsächliche Engpässe zu finden.

  • Wählen Sie die richtigen Datenstrukturen für Ihre Bedürfnisse. Sortierte Arrays, Zwei-Zeiger-Ansätze und Bäume sind unverzichtbar.

  • Testen Sie mit realistischen, groß angelegten Daten. Kleine Tests können große Probleme verbergen.

  • Überwachen Sie die Produktion. Automatisierte Warnungen können Rückschritte frühzeitig erkennen.

Fazit

Die von uns bei Cal.com vorgenommenen Optimierungen zeigen, dass durch die Anwendung bewährter algorithmischer Techniken erhebliche Leistungsgewinne möglich sind. Kombinieren Sie Codeüberprüfung, intelligente Datenstrukturen und automatisierte Überwachung, um Ihre Anwendung schnell zu halten, während sie wächst.

Leistung sollte in jede Entwicklungsstufe eingebaut werden, von Codeüberprüfungen, die verdächtige Muster kennzeichnen, bis hin zu CI-Pipelines, die Skalierungstests ausführen, und zur Laufzeitüberwachung, die Rückschritte erkennt.

Der schnelle Algorithmus von heute kann leicht zu einem Engpass von morgen werden, wenn Ihre Daten wachsen. Bleiben Sie wachsam, testen Sie in großem Maßstab und seien Sie immer bereit zu optimieren.