Jeder verteilte Crawler erlebt irgendwann den gleichen schlechten Nachmittag. Ein Parser wird langsamer, weil eine Site plötzlich riesige Seiten ausliefert. Die Fetcher fetchen weiter mit voller Geschwindigkeit, weil ihnen niemand gesagt hat, es nicht zu tun. Die Queue zwischen ihnen wächst um Millionen von Einträgen, der Speicherverbrauch steigt, ein Knoten stürzt ab, seine Arbeit wird auf die anderen umverteilt, und die Retries bringen auch diese zu Fall. Währenddessen erhält genau die eine Site, die das alles ausgelöst hat, mehr Traffic als je zuvor.
Nichts davon ist ein Fehler in einer einzelnen Komponente. Es ist das Fehlen von Flusskontrolle zwischen ihnen. Ein Crawler ist eine Pipeline aus Stufen mit sehr unterschiedlichen Geschwindigkeiten, und ohne eine Möglichkeit für eine langsame Stufe, einer schnellen zu sagen, sie solle warten, gewinnt die schnelle, bis alle verlieren.
Dieser Beitrag handelt davon, dieses Feedback einzubauen: woher die Signale kommen, wohin sie gelangen müssen, und die wenigen Regeln, die dafür sorgen, dass ein Crawler sanft degradiert, statt zusammenzubrechen.
Ein Crawler ist eine Pipeline, und die Stufen sind sich über die Geschwindigkeit uneinig
Zieht man die Details ab, haben die meisten Crawler vier Stufen:
| Stufe | Was sie begrenzt | Wie sie bei Überlastung versagt |
|---|---|---|
| Frontier und Scheduler | Fast nichts, es werden nur URLs ausgewählt | Gibt Arbeit schneller aus, als alles Nachgelagerte aufnehmen kann |
| Fetcher | Ziel-Sites, Proxy-Kapazität, Plan-Limits | Timeouts, Throttling, Blocks |
| Parsen und Rendern | CPU, Speicher, Headless-Browser-Slots | Wachsende Latenz, dann Speichererschöpfung |
| Deduplizierung und Storage | Schreibkapazität der Datenbank | Langsame Schreibvorgänge, Lock-Contention, Rückstände |
Der Frontier läuft nahezu kostenlos, daher ist er immer die schnellste Stufe. Die anderen drei sind jeweils durch etwas anderes begrenzt, und die Grenze verschiebt sich: Eine Site wird langsamer, ein Seitentyp wird schwerer, eine Datenbank beginnt zu komprimieren. Flusskontrolle bedeutet, dass jeweils die aktuell langsamste Stufe das Tempo für alle vorgibt.
Die Alternative ist, dass das Tempo von der Stufe vorgegeben wird, der als Erster der Speicher ausgeht.
Regel 1: Jede Queue ist begrenzt
Eine unbegrenzte Queue zwischen zwei Stufen ist kein Puffer. Es ist ein Versprechen, jede beliebige Menge an überschüssiger Arbeit aufzunehmen, das keine Maschine einhalten kann. Es verschleiert auch das Problem. Die vorgelagerte Stufe sieht bei jedem Enqueue Erfolg, während die Queue still wächst, und wenn schließlich jemand nachsieht, wartet der älteste Eintrag schon seit Stunden.
Eine begrenzte Queue macht aus derselben Situation ein Signal. Wenn sie voll ist, blockiert der Producer oder erhält eine explizite Ablehnung. Die Verzögerung pflanzt sich stromaufwärts fort, eine Stufe nach der anderen, bis sie den Frontier erreicht, der einfach eine Zeit lang keine URLs mehr ausgibt. Nichts geht verloren und nichts explodiert.
In einem einzelnen Prozess ist das ein Argument für einen Queue-Konstruktor:
import asyncio
async def fetcher(frontier: asyncio.Queue, parsed: asyncio.Queue, fetch):
while True:
url = await frontier.get()
try:
page = await fetch(url)
# Blocks when the parse queue is full: a slow parser slows fetching.
await parsed.put(page)
finally:
frontier.task_done()
async def parser(parsed: asyncio.Queue, store):
while True:
page = await parsed.get()
try:
await store(page)
finally:
parsed.task_done()
async def run(urls, fetch, store, fetchers=16, parsers=4):
frontier = asyncio.Queue(maxsize=1000)
parsed = asyncio.Queue(maxsize=200)
workers = [asyncio.create_task(fetcher(frontier, parsed, fetch)) for _ in range(fetchers)]
workers += [asyncio.create_task(parser(parsed, store)) for _ in range(parsers)]
for url in urls:
await frontier.put(url) # blocks when the frontier is full
await frontier.join()
await parsed.join()
for w in workers:
w.cancel()
Über Maschinen hinweg gilt dasselbe Prinzip, nur der Mechanismus ändert sich: eine Broker-Queue mit Längenlimit und Ablehnungsrichtlinie, ein Stream mit Consumer-Lag, auf den tatsächlich reagiert wird, oder ein datenbankgestützter Frontier, der URLs nur an Worker mit freier Kapazität ausgibt. Bemesse die Grenzen nach der Latenz, die du tolerieren kannst, nicht nach dem Speicher, den du hast. Wenn die Parse-Stufe 200 Seiten pro Sekunde verarbeitet und du 10 Sekunden Warteschlangenzeit akzeptierst, fasst die Queue etwa 2.000 Einträge. Alles Größere verschiebt nur den Moment, in dem du es merkst.
Regel 2: Pull statt Push
Die sauberste Backpressure ist die, die man umsonst bekommt. Wenn Worker um Arbeit bitten, wenn sie Kapazität haben, statt dass ein Koordinator ihnen Arbeit zuweist, fragt ein langsamer Worker einfach seltener nach. Das System kann ihm nicht mehr schicken, als er verarbeiten kann, weil er nie danach gefragt hat.
Push-basierte Arbeit zwingt den Koordinator zum Raten. Er muss die Last jedes Workers verfolgen, und er wird genau in dem Moment falsch raten, in dem sich die Last ändert. Pull-basierte Designs, ob Worker Elemente aus einer Queue leasen oder explizite Credits an ihre vorgelagerte Stufe vergeben, verschieben diese Entscheidung zu der einzigen Komponente, die die Antwort kennt.
Leases brauchen ein Ablaufdatum. Ein Worker, der stirbt, während er Arbeit hält, darf sie nicht ewig halten, also kehren geleaste Elemente nach einem Timeout in die Queue zurück. Setze dieses Timeout nach dem langsamsten legitimen Fetch, nicht nach dem Durchschnitt, sonst wird gesunde, aber langsame Arbeit zweimal vergeben.
Regel 3: Queue pro Host, nicht eine globale Queue
Eine einzelne globale Fetch-Queue hat einen Fehlermodus, auf den Crawler ständig stoßen: Head-of-Line-Blocking. Wenn die nächsten tausend URLs alle zu einer langsamen oder throttelnden Site gehören, warten am Ende alle Fetcher auf diese Site, während Arbeit für schnelle, gesunde Sites dahinter liegen bleibt.
Partitioniere die Fetch-Stufe nach Host oder nach welcher Einheit auch immer ein Ziel Rate-Limits anwendet. Jeder Host bekommt seine eigene Queue und sein eigenes Concurrency-Limit, und Fetcher wählen aus Hosts, die derzeit Platz haben. Eine einzelne kämpfende Site verlangsamt dann nur sich selbst.
Hier liegt auch die Höflichkeit begründet. Ein Limit pro Host ist zugleich Flusskontrolle für dich und Rücksichtnahme gegenüber der Site. Wie dieses Limit überhaupt gesetzt wird und was die eigenen Signale der Site darüber verraten, wird in Rate Limiting und Request Throttling behandelt.
Regel 4: Mache Limits pro Host adaptiv
Ein festes Concurrency-Limit pro Host ist in beide Richtungen falsch. Zu niedrig verschwendet Kapazität bei einer Site, die mehr vertragen könnte. Zu hoch drückt weiter auf eine Site, die schon zurückdrängt, was daraus, dass temporäres Throttling zu einem Block wird.
Die gut erprobte Antwort ist die, die TCP für Congestion verwendet: additive increase, multiplicative decrease. Jeder Erfolg erhöht das Limit ein wenig. Jedes Throttling oder Timeout halbiert es. Das Limit pendelt sich knapp unter dem ein, was die Site toleriert, und bewegt sich, wenn sich die Site ändert.
import asyncio
class HostLimiter:
"""Adaptive concurrency for one host: additive increase, multiplicative decrease."""
def __init__(self, start=4, floor=1, ceiling=32):
self.limit = start
self.floor = floor
self.ceiling = ceiling
self.in_flight = 0
self._cond = asyncio.Condition()
async def acquire(self):
async with self._cond:
await self._cond.wait_for(lambda: self.in_flight < int(self.limit))
self.in_flight += 1
async def release(self, outcome):
async with self._cond:
self.in_flight -= 1
if outcome == "ok":
self.limit = min(self.ceiling, self.limit + 1 / max(1, int(self.limit)))
elif outcome in ("throttled", "timeout"):
self.limit = max(self.floor, self.limit / 2)
self._cond.notify_all()
Die Erhöhung erfolgt bewusst langsam, etwa ein zusätzlicher Slot pro vollständiger Runde von Erfolgen, und die Absenkung bewusst scharf. Die Asymmetrie ist Absicht: Die Toleranzgrenze einer Site zu überschreiten kostet weit mehr, als sie zu unterschreiten. Behalte pro Host eine feste Obergrenze, die du selbst festlegst, damit sich ein adaptives Limit niemals über das hinaus verhandeln kann, was du für vernünftig hältst.
Regel 5: Wisse, welches Signal woher kam
Nicht jedes “langsamer machen” bedeutet dasselbe, und wenn man sie gleich behandelt, wird der Druck an die falsche Stelle geleitet.
| Signal | Woher es stammt | Was es verlangsamen sollte |
|---|---|---|
429, steigende Latenz, Challenge-Seiten von einer Site | Das Ziel | Nur den Limiter dieses Hosts |
| Parse-Queue voll, Storage hinkt nach | Deine eigene Pipeline | Fetching global, dann den Frontier |
| Concurrency-Cap bei einer Scraping-API | Dein Plan | Gesamte In-Flight-Requests an diese API |
| Quota oder Credits erschöpft | Dein Plan | Alles, bis der Zyklus zurücksetzt oder du aufstockst |
Shifters Web Scraping API zum Beispiel gibt 429 Too Many Requests zurück, wenn du das Concurrency-Cap deines Plans überschreitest, und 509, wenn Credits erschöpft sind. Das Erste ist ein Flusskontrollsignal über dich selbst, nicht über irgendein Ziel, es gehört also in einen globalen Limiter für Calls an die API, nicht in irgendeinen Limiter pro Host. Das Zweite ist eine Stoppbedingung. Wird eines davon mit dem eigenen 429 einer Site verwechselt, throttelt ein Crawler gesunde Sites für ein Problem, das nichts mit ihnen zu tun hat.
Retries sind Last
Der häufigste Weg, wie ein Crawler seine eigene Backpressure aushebelt, sind Retries. Ein Fetch schlägt fehl, der Worker versucht es sofort erneut, der Retry schlägt ebenfalls fehl, weil sich die Ursache nicht geändert hat, und jeder Worker, der dasselbe tut, vervielfacht den Traffic genau in dem Moment, in dem ein Ziel, oder deine eigene Stufe, am wenigsten in der Lage ist, ihn zu tragen.
- Retries durchlaufen dieselbe Admission Control wie Erstversuche. Ein Retry, der den Limiter pro Host umgeht, ist eine Umgehung deiner eigenen Flusskontrolle.
- Backoff mit Jitter, damit eine Gruppe von Workern, die gemeinsam gescheitert ist, nicht gemeinsam erneut versucht.
- Gib jedem Job ein Retry-Budget, und verfolge Retries als Anteil an den Gesamtanfragen. Wenn dieser Anteil steigt, verbraucht das System seine Kapazität für Fehlschläge.
- Schichte keine Retry-Ebenen übereinander. Die Web Scraping API versucht fehlgeschlagene Fetches, CAPTCHAs und vorübergehende Zielfehler bereits bis zu dreimal mit unterschiedlichen Proxies erneut, bevor sie einen Fehler zurückgibt, ohne zusätzliche Kosten. Aggressive clientseitige Retries darauf vervielfachen die Versuche, statt Resilienz hinzuzufügen.
- Versuche niemals erneut, was nicht gelingen kann. Authentifizierungs- und Konfigurationsfehler schlagen jedes Mal auf die gleiche Weise fehl.
Wenn du nicht mithalten kannst, wirf gezielt Last ab
Manchmal ist die ehrliche Antwort, dass der Crawler mehr Arbeit hat, als er erledigen kann. Backpressure verlangsamt dann alles gleichmäßig, was oft das schlechteste Ergebnis ist: Jeder Job wird spät fertig, auch die, auf die es ankommt.
Load Shedding macht die Wahl explizit. Gib der Arbeit eine Priorität, und wenn Queues über eine Schwelle hinaus voll bleiben, verwirf oder verschiebe die niedrigst priorisierten Einträge am Frontier, bevor sie einen Fetch kosten. Das Aktualisieren einer volatilen Preisseite ist mehr wert als die erneute Prüfung einer Archivseite, die sich seit einem Jahr nicht geändert hat. Welche Seiten das Budget verdienen, ist ein eigenes Problem und Thema von kostenbewusster Crawl-Planung.
Miss den Druck, nicht nur den Durchsatz
Durchsatz-Graphen sehen bis kurz vor dem Zusammenbruch gut aus. Die Metriken, die aufbauenden Druck zeigen, sind andere:
- Queue-Tiefe und das Alter des ältesten Eintrags, pro Stufe. Das Alter zählt mehr als die Tiefe: eine tiefe Queue, die schnell abläuft, ist gesund, eine flache voller veralteter Einträge ist es nicht.
- In-Flight-Requests und das aktuelle adaptive Limit, pro Host. Ein Limit, das sich immer wieder halbiert, ist eine Site, die zurückdrängt.
- Admission-Ablehnungen und blockierte Puts, pro Stufe. Das ist Backpressure, die ihre Aufgabe erfüllt. Ein plötzlicher Anstieg zeigt, welche Stufe jetzt der Flaschenhals ist.
- Retry-Anteil, pro Host und insgesamt.
Ein fallendes Limit pro Host zusammen mit steigenden Block- und Challenge-Raten bedeutet meist, dass sich eine Site gegen dich wendet, statt nur langsam zu sein. Wie man diese Signale zu einem einzigen Urteil pro Site verdichtet, wird in Aufbau eines Target Health Score behandelt. Das breitere Set an Pipeline-Metriken findet sich in Monitoring einer Web-Scraping-Pipeline.
Skalieren nach außen beseitigt nicht die Notwendigkeit von Flusskontrolle
Mehr Worker hinzuzufügen hebt die Obergrenze der Fetch-Stufe an. Es erhöht nicht die Toleranz irgendeines Ziels, deine Parse-Kapazität oder die Limits deines Plans. Ein Crawler, der Fetcher automatisch anhand der Queue-Tiefe hochskaliert, ohne Limits pro Host und begrenzte nachgelagerte Queues, skaliert direkt in alle Einschränkungen gleichzeitig hinein. Wenn du auf Kubernetes läufst, skaliere anhand der oben genannten Signale statt allein nach CPU; die Mechanik wird in Skalieren von Residential-Proxy-Scraping mit Kubernetes behandelt.
Dasselbe gilt über Regionen hinweg. Dauerhafte Queues und Backpressure zwischen Regionen sind es, die verhindern, dass ein regionaler Ausfall zu einem globalen wird, wie in Residential-Proxy-Failover für Multi-Region-Pipelines beschrieben.
Fazit
Flusskontrolle ist keine Optimierung, die man hinzufügt, sobald ein Crawler groß geworden ist. Sie entscheidet darüber, ob ein Crawler unter Belastung langsamer wird oder zusammenbricht. Die Regeln sind wenige: Begrenze jede Queue, lass Worker pullen, partitioniere nach Host, passe Limits pro Host scharf nach unten und langsam nach oben an, leite jedes Verlangsamungssignal an die Stufe, die es beschreibt, behandle Retries als Last, und wirf minderwertige Arbeit bewusst ab.
Ein so gebauter Crawler hat noch eine Eigenschaft, die es wert ist zu haben. Wenn eine Site anfängt zu kämpfen, bemerkt es der Crawler und lässt von sich aus nach, was gut für die Site ist und, nicht zufällig, gut für die Chancen des Crawlers, auch nächste Woche noch willkommen zu sein.