Tony Wang13 Min. LesezeitWie du 10 Millionen Websites in 10 Minuten scrapst
Ein System-Design-Walkthrough: die Queue, die Worker-Flotte, die Sprachwahl und das Ressourcen-Tuning hinter einem verteilten Web-Scraper mit 16.000 Req/s.
Zehn Millionen Websites in zehn Minuten ergeben rund 16.700 Requests pro Sekunde, durchgehend über die vollen zehn Minuten gehalten. Keine einzelne Maschine schafft das, und kein cleverer Einzel-Request bringt dich dorthin. Das Einzige, was funktioniert, ist eine Flotte aus günstigen, identischen Workern, die aus einer gemeinsamen Queue ziehen — wobei der Gesamt-Throughput mit der Anzahl der Worker steigt und jeder Worker verschwinden kann, ohne Arbeit zu verlieren.
Dieser Beitrag baut dieses System durchgängig auf: die entkoppelte Architektur, die Sprachwahl, die die Concurrency günstig macht, die Handvoll Einstellungen pro Worker, die entscheiden, ob eine zusätzliche Box tatsächlich Throughput bringt, und das Failure-Handling, das den Betrieb dabei am Laufen hält. Der Worker ist ein kleines Open-Source-Binary, das du auf deinem eigenen Laptop laufen lassen kannst, sodass jeder Schritt reproduzierbar statt nur behauptet ist.
Es ist ein Throughput-Problem, kein Request-Problem
Fang bei der Arithmetik an, denn sie legt alles andere fest: 10.000.000 URLs ÷ 600 Sekunden ≈ 16.667 Requests/Sekunde. Eine einzelne gut getunte Box, die ~500 gleichzeitige, I/O-gebundene Probes hält — jede ein bis zwei Sekunden lang — landet irgendwo bei ein paar Tausend Requests pro Sekunde. Das Ziel sind also grob vier bis zehn Boxen, sofern der Throughput sauber skaliert, wenn du sie hinzufügst. Die ganze Aufgabe des folgenden Designs ist es, dieses »sofern« zu verdienen: die Flotte nahezu linear skalieren zu lassen und sie während des Laufs am Leben zu halten.
Die Architektur: eine entkoppelte Pipeline
Alles hängt an einer Idee — entkopple die Stufen, sodass sie sich nie gegenseitig blockieren, und halte den gesamten dauerhaften Zustand an einem Ort. Worker werden entbehrlich; die Queue wird zum System of Record.
- Seed-Loader. Streamt die URL-Liste in Chunks in die Queue — lade nie 10M Zeilen in den Speicher. Mach ihn idempotent (
SADDin ein Redis-Set oder Push auf eine Liste), sodass ein Re-Run fortsetzt, statt doppelt zu enqueuen. - Work-Queue (Redis). Das Herzstück. Sie entkoppelt Producer von Consumern, fängt Bursts ab (Backpressure) und macht das ganze System at-least-once: Ein Worker popt einen Job, und stirbt er mitten drin, wird der Job einfach von einem anderen Worker erneut gepopt. Ergänze ein
SEToder einen Bloom-Filter für die Dedup besuchter URLs und eine Dead-Letter-Queue für Jobs, die ihre Retries aufgebraucht haben. - Worker-Flotte. Zustandslose Pods, die identischen Code ausführen. Jeder puffert einen kleinen Batch (~100–200 Jobs), verarbeitet sie mit interner Concurrency, schreibt die Ergebnisse per Bulk und synct seine Statistiken periodisch sowie beim Shutdown. Skalieren heißt einfach »Pods hinzufügen«. Ein Crash heißt einfach »die anderen machen weiter«.
- Ergebnis-Senke. Jeder Store, der gebatchte Writes nimmt (Elasticsearch, Postgres
COPY, Parquet-Dateien), verschlüsselt auf einer idempotenten ID wiedomain|run_id, sodass ein erneut verarbeiteter Job an Ort und Stelle überschreibt, statt zu duplizieren.
Beachte: Es gibt keinen zentralen »Coordinator«, der Echtzeit-Scheduling betreibt. Die Queue ist die Koordination. Genau das lässt die Flotte horizontal skalieren, ohne ein zerbrechliches Hirn in der Mitte.
Wähle die Sprache für den Engpass
Die Achse, die das entscheidet, ist nicht die reine Einzel-Request-Geschwindigkeit. Fetching ist I/O-gebunden — du verbringst die ganze Zeit damit, aufs Netzwerk zu warten — also zählt, wie günstig eine Runtime Tausende Requests gleichzeitig in der Luft hält, wie gut sie jeden Core nutzt und wie klein sie ausliefert. Die Leute greifen zuerst zu »Was nutzt weniger Speicher?«, also fangen wir dort an — und die Antwort ist nicht die, die die Memes vorhersagen.
- Go ist hier der Weg des geringsten Widerstands — wenn auch nicht aus dem üblicherweise genannten Grund. Goroutines sind Green Threads (~2–8 KB Stack pro Stück, gemultiplext auf OS-Threads mit einem eingebauten Netzwerk-Poller), also schreibst du schlichten, synchron aussehenden Code, und der Scheduler parkt sie bei I/O, ohne einen Thread zu binden; 100k+ Requests pro Box in der Luft sind unspektakulär. Seine echten Vorteile sind echtes Multi-Core geschenkt (kein GIL, keine Prozess-Flotte) und das Deployment — ein einzelnes statisches Binary in einem ~10–20 MB Scratch-Image mit Millisekunden-Cold-Start, was »einen Pod hinzufügen« günstig macht. Was kein automatischer Vorteil ist, ist der Speicher; siehe die Messung weiter unten.
- Python ist konkurrenzfähiger als sein Ruf.
asyncio+aiohttpliefern echte I/O-Concurrency und, wie sich herausstellt, einen wirklich schlanken Speicher-Footprint. Seine Kosten liegen anderswo: Es ist ein Event-Loop pro Core (kooperativ — ein einziger blockierender Call bringt den ganzen Loop ins Stocken), sodass die Nutzung all deiner Cores bedeutet, eine Flotte von Prozessen laufen zu lassen, und das Image ist Interpreter-plus-Abhängigkeiten (Hunderte MB) mit langsamem Cold-Start. Zum GIL: Der No-GIL-Free-Threaded-Build wurde in Python 3.14 offiziell unterstützt (weiterhin optional), aber er hilft vor allem beim CPU-gebundenen Multithreading — für I/O-Fan-out war das GIL nie der Limiter, also ändert er für einen Fetcher wenig.
| Runtime | Concurrency-Primitive | Multi-Core | Image / Cold Start | Am besten bei |
|---|---|---|---|---|
| Go | Goroutine (Green Thread + Netpoller) | eingebaut | statisches Binary, ~10–20 MB, ms | I/O-Fan-out bei hoher Dichte (dieser Build) |
| Rust | async Task (tokio) | eingebaut | klein, ms, kein GC | die Ressourcenkurve am härtesten auspressen |
| Python | asyncio Task (1 Loop / Core) | Multi-Prozess | Interpreter + Deps, 100e MB, langsam | extraktions- oder ML-lastige Pipelines |
| Node | Event-Loop Promise | worker_threads | Runtime + Deps, mittel | ein JS-Shop-Team, moderate Skalierung |
| JVM (Loom) | virtueller Thread | eingebaut | JRE + Jar, groß, Warmup | eine bestehende JVM-Plattform |
Also was nutzt wirklich weniger Speicher? Wir haben es gemessen
Die Volksweisheit lautet »Goroutines sind winzig, also nippt Go am RAM«. Das stimmt für eine nackte Goroutine und ist falsch für eine Verbindung. Wir hielten 10.000 gleichzeitige HTTP-Requests gegen einen lokalen Server offen und protokollierten den maximalen Resident-Speicher (reines HTTP, kein TLS — ein sauberer Footprint-Test, kein voller Scrape):
| 10.000 gleichzeitige Verbindungen | Peak RSS |
|---|---|
Go — Default (GOGC=100) | 319 MB |
Go — GOMEMLIMIT=200MiB | 218 MB |
Go — GOGC=20 | 201 MB |
Python — asyncio + aiohttp | 200 MB |
Out of the box nutzte Go ~1,6× den Speicher von Python, nicht weniger. Jede Go-Verbindung trägt einen gewachsenen Goroutine-Stack plus die Read- und Write-Buffer von net/http, und der Garbage Collector hält standardmäßig grob den doppelten Live-Heap resident — dieser letzte Teil ist das »Resident-Speicher verdoppelt sich«, dem du im Tuning-Abschnitt wieder begegnest. Der Fix ist derselbe Hebel: den GC deckeln (GOMEMLIMIT oder ein niedrigeres GOGC), und Go fällt auf Gleichstand mit Python.
Speicher ist also nicht der Grund, hier Go zu wählen — ungetunt ist er ein Grund dagegen. Wähle Go für die CPU- und Multi-Core-Effizienz, das winzige statische Image und den niedrigen Basis-Footprint (hier 8 MB im Leerlauf gegenüber Pythons 28 MB); liefere es nur nicht mit weit offenem GC aus.
Die ehrliche Notluke: Wenn die teure Stufe die Extraktion ist — pandas, BeautifulSoup, ein LLM-Call — kann Pythons Ökosystem dominieren, und der richtige Zug ist, die Pipeline zu splitten (Go-Fetcher, die Python-Extraktoren füttern), statt eine Sprache zu zwingen, beides zu tun. Passe die Sprache an die Stufe an, die tatsächlich der Engpass ist.
Und hier ist der Teil, der sich wirklich geändert hat. Diese Entscheidung wurde früher durch »schreib, was das Team schon kann« beigelegt, weil das Einarbeiten in Go für eine High-Throughput-Komponente Wochen kostete — also blieb ein Python-Shop bei Python, selbst wo eine schlankere Runtime zur Aufgabe passte. LLM-gestütztes Coding hat diese Einarbeitung weitgehend aufgelöst. Ein Modell scaffoldet den Goroutine-Pool, den getunten http.Transport, die Channel-und-Semaphore-Backpressure und das Dockerfile idiomatisch, selbst für ein Team, das nie Go ausgeliefert hat. Das richtige Werkzeug für die Aufgabe ist jetzt ein realistischer Standard statt eines Luxus.
Der Worker: eine günstige, ehrliche Probe
Die Arbeitseinheit ist bewusst klein. Hier ist die Baseline, ausgeführt mit crawlora-deadweb — der offenen Go-Probe, die wir als Worker nutzen — auf einer einzelnen Maschine, bevor wir sie verteilen:
cat domains.txt | crawlora-deadweb --json --concurrency 50 > out.ndjson
Was in einem guten Worker steckt, sind nur zwei sorgfältig ausgeführte Dinge:
- Hohe Concurrency, weil die Arbeit I/O-gebunden ist. Ein begrenzter Pool von Goroutines (oder asyncio-Tasks) hält Hunderte Requests pro Box in der Luft. Die CPU ist meist im Leerlauf; das Netzwerk ist die Aufgabe.
- Ein ehrliches, günstiges Urteil pro URL. DNS → TCP-Connect → ein
GET /, dann das Ergebnis labeln und weiter. Entscheidend: Verbrenne das Budget nicht damit, Hosts zu wiederholen, die wirklich weg sind: kein DNS oder eine abgelehnte Verbindung bedeutet tot — verwirf ihn; ein403/429bedeutet lebendig, lehnt aber diesen Client ab — ein völlig anderes Problem, das auch ein Retry nicht behebt. Diese auseinanderzuhalten ist der Großteil dessen, was einen großen Lauf davon abhält, Stunden an Leichen zu verschwenden.
Die fünf Einstellungen, die einen Worker schnell machen
Eine Flotte skaliert nur linear, wenn jede Box getunt ist; sonst fügst du Boxen hinzu, und sie stürzen sich alle in denselben gemeinsamen Engpass. Fünf Stellschrauben tragen fast alles:
| Stellschraube | Typisch | Warum es zählt |
|---|---|---|
| Concurrency / Worker | 50–500 in der Luft | I/O-gebunden: Die Box wartet aufs Netzwerk, nicht auf die CPU |
| Verbindungs-Reuse | MaxIdleConnsPerHost erhöhen, Keep-Alive an | Spare bei jedem Request einen frischen TCP- + TLS-Handshake |
| DNS | Cache + mehrere Resolver | Millionen Lookups schmelzen einen einzelnen Resolver — das ist der versteckte Engpass |
| Timeout pro Versuch | ein paar Sekunden, 1 transienter Retry | Ein langsamer Host darf nie einen Concurrency-Slot halten |
| Write-Batch | Bulk alle ~100 Ergebnisse / ~2s | Die Senke ist nach DNS der zweite Engpass — schreibe nie eine Zeile nach der anderen |
Wenn ein großer Crawl mysteriös langsam ist, lautet die Antwort DNS weit öfter, als die Leute erwarten. Cache aggressiv, verteile Lookups auf mehrere Resolver und betreibe auf jedem Node einen lokalen Caching-Resolver.
Skaliere raus: füge Boxen hinzu, beobachte den Engpass
Mit einem getunten Worker ist Skalieren größtenteils eine Replica-Zahl. In einem echten Lauf leerte das Hochstufen der Fetch-Flotte von 4 auf 10 Pods die Queue in Stunden statt an einem Tag. Die Form, die du anpeilst:
Auf Kubernetes ist die Hygiene klein, aber tragend: Setze imagePullPolicy: Always, damit ein neu gepushtes :latest tatsächlich ausrollt, gib den Pods echte Requests/Limits und treibe optional einen HPA über die Queue-Tiefe statt über CPU. Und übershard nicht: Der Throughput ist durch die langsamste gemeinsame Ressource begrenzt, wenn die Kurve also flach abknickt, skaliert das Hinzufügen von Boxen den Engpass, nicht die Arbeit — behebe zuerst DNS oder die Senke.
Halte es am Laufen: Ausfall ist der Normalfall
Bei zehn Millionen Requests passieren seltene Ereignisse ständig — ein Worker wird OOMKilled, ein Deploy rollt mitten im Lauf, ein Host fängt an, Timeouts zu produzieren. Hochverfügbarkeit ist kein Feature, das du dranschraubst; es ist die Menge an Defaults, die jedes davon zu einem Nicht-Ereignis machen:
| Wenn das passiert | Was dich rettet |
|---|---|
| Ein Worker crasht mitten im Job | At-least-once-Queue popt den Job an einen anderen Worker |
| Dieselbe URL wird zweimal verarbeitet | Idempotenter Write, verschlüsselt auf einer stabilen domain + run_id ID — der Retry überschreibt, kein Duplikat |
| Ein Rolling Deploy startet jeden Pod neu | Sauberer SIGTERM-Drain: aufhören zu ziehen, laufende Arbeit abschließen, Statistiken flushen |
| Eine Poison-URL verkeilt einen Worker | Timeout pro Versuch + ein Retry-Budget → Dead-Letter-Queue |
| Ein Host oder die Senke lässt jeden Call scheitern | Circuit Breaker löst aus; der Worker recycelt nach N Tasks |
| Der 10M-Burst bedroht den Rest deiner Infra | Eigener Namespace + Ressourcen-Quota + non-preempting Priority-Class |
Der rote Faden: Zustandslose Worker plus eine dauerhafte Queue bedeuten, dass es keinen Single Point of Failure gibt. Die Queue ist das Einzige, das überleben muss, und sie ist das eine Ding, das du replizierst.
Der Teil, der tatsächlich kaputtgeht: Ressourcen
In der Praxis scheitert ein System in diesem Maßstab selten an der Logik. Es scheitert am Speicher — eine Restart-Schleife aus OOMKills (Exit 137). Die Ursache ist fast immer dieselbe: Die Runtime ist nicht cgroup-aware, also sieht sie in einem Container die Cores und den RAM des Nodes statt das Limit des Pods. Bei Go vor 1.25 defaultet GOMAXPROCS auf die Core-Zahl des Nodes (überparallelisiert GC und TLS-Handshakes gegen ein fraktionales CPU-Cap), und es gibt keine Speichergrenze, sodass sich der Heap fast verdoppeln kann, jenseits des Limits, bevor der GC läuft.
Die Fixes, geordnet danach, wie viel sie uns gebracht haben:
| Einstellung | Wert | Effekt |
|---|---|---|
GOMAXPROCS | = das Container-CPU-Limit | Stoppt das Überparallelisieren von GC + Handshakes gegen ein fraktionales CPU-Cap. Der mit Abstand größte Hebel — er beendete die OOM-Schleife und senkte das RSS pro Pod von ~730 MiB auf ~150–420 MiB. |
GOMEMLIMIT | ~85% des Speicherlimits | Eine weiche Grenze, die den GC laufen lässt, bevor der Kernel den Pod killt |
automaxprocs + automemlimit | beide blank-importieren | Das Binary liest beim Start die cgroup und tunt beides selbst, für jedes Limit |
--concurrency | auf das CPU-Cap getunt, nicht höher | Jenseits des Caps ist mehr Concurrency nur Contention |
GOGC=off | lass es | Es geht bei einem allokationsintensiven Allocator wie diesem nach hinten los |
Die Lektion verallgemeinert sich über Go hinaus: Egal welche Runtime, dimensioniere ihre Parallelität und ihren Speicher am Quota des Containers, nicht des Hosts. (Für Python: dimensioniere den Prozess-/Worker-Pool am CPU-Quota, nicht an os.cpu_count(), und beobachte das RSS pro Worker.)
Erkenne, wann du wirklich fertig bist
Zwei Zählfallen erwischen jeden auf der Ziellinie:
Und skaliere die Flotte nicht in dem Moment auf null, in dem die Queue leer ist. Worker puffern voraus, also SIGTERMt ein Killen bei queue == 0 sie mitten im Buffer und verwirft laufende Arbeit — eine Unterzählung. Warte, bis processed ≈ target (gegen die Senke bestätigt), und skaliere erst dann herunter.
Sei ein guter Bürger
Nichts davon ist eine Erlaubnis, das Web zu hämmern. Halte Rate-Caps pro Host ein, respektiere robots.txt und die Nutzungsbedingungen, sende nur unauthentifizierte GETs und identifiziere deinen Bot. Eine Liste von zehn Millionen unterschiedlichen Hosts verteilt die Last natürlich; zehn Millionen Seiten auf einer Site brauchen echtes Throttling pro Host. Und fahre keinen breiten Fan-out von einer einzelnen nackten Cloud-IP — sich von einer einzigen ungemanagten Adresse mit vielen unterschiedlichen Hosts zu verbinden, sieht aus wie ein Netzwerk-Scan und beschert dir Abuse-Reports. Nutze kontrollierten Egress oder einen gemanagten Pool.
Der Worker ist Open Source
crawlora-deadweb ist die kleine Go-Probe, die oben als Worker verwendet wird — DNS, TCP, ein ehrliches GET, dann ein Urteil. Lass es auf einer eigenen Liste laufen oder lies sein classify-Package, um zu sehen, wie die Arbeitseinheit gebaut ist. Dasselbe Nur-so-weit-wie-erzwungen-eskalieren-Muster ist das, was Crawlora als gehostete API betreibt.
Häufig gestellte Fragen
Wie scrapst du 10 Millionen Websites in 10 Minuten?
Nicht von einer Maschine und nicht mit einem clevereren Request — du betreibst eine horizontal skalierte Flotte zustandsloser Worker, die URLs aus einer gemeinsamen Queue ziehen. Zehn Millionen in zehn Minuten sind rund 16.700 Requests pro Sekunde; wenn jede getunte Box ein paar Hundert gleichzeitige I/O-gebundene Requests hält, sind das grob vier bis zehn Worker, sofern der Throughput sauber mit der Box-Anzahl skaliert.
Welche Architektur wird für großangelegtes Web Scraping verwendet?
Eine entkoppelte Pipeline: Ein Loader streamt die URL-Liste in eine dauerhafte Queue (Redis), eine Flotte zustandsloser Worker-Pods zieht Jobs und probt jede URL, und die Ergebnisse werden per Bulk in eine Senke mit idempotenten IDs geschrieben. Die Queue ist das einzige zustandsbehaftete Stück, also sind Worker entbehrlich und das System ist at-least-once — die Jobs eines gecrashten Workers werden einfach von einem anderen Worker erneut gepopt.
Ist Go oder Python besser für Web Scraping im großen Maßstab?
Für hochgradig gleichzeitiges, I/O-gebundenes Fetching sind Gos echte Stärken die echte Multi-Core-Nutzung (kein GIL) und ein einzelnes kleines statisches Binary mit schnellem Cold-Start — nicht der Speicher. In einem 10.000-Verbindungen-Test nutzte Gos Default-net/http rund das 1,6-Fache des RAM von Pythons asyncio + aiohttp (319 MB gegenüber 200 MB), bis sein Garbage Collector mit GOMEMLIMIT gedeckelt wurde, was es auf Gleichstand brachte. Python braucht einen Prozess pro Core, ist aber schlank beim Speicher; wenn Extraktion oder ML die schwere Stufe ist, kann sein Ökosystem gewinnen — also splitte die Pipeline mit Go-Fetchern und Python-Extraktoren.
Warum wird mein verteilter Scraper ständig OOMKilled?
Meist, weil die Runtime nicht cgroup-aware ist: In einem Container sieht sie die Cores und den RAM des Nodes, nicht das Limit des Pods. Setze bei Go GOMAXPROCS auf das CPU-Limit und GOMEMLIMIT auf etwa 85% des Speicherlimits, oder blank-importiere automaxprocs und automemlimit, damit es sich selbst tunt. GOMAXPROCS auf das Cap zu setzen ist typischerweise der mit Abstand größte Fix — er beendete in unserem Lauf eine Restart-Schleife und senkte den Speicher pro Pod von ~730 MiB auf ~150–420 MiB.
Wie machst du einen Web-Scraper hochverfügbar?
Halte Worker zustandslos und lege den gesamten dauerhaften Zustand in die Queue, sodass jeder Worker sterben kann, ohne Arbeit zu verlieren (at-least-once). Mach Writes idempotent (verschlüsselt auf domain|run_id), sodass erneutes Verarbeiten sicher ist, draine sauber bei SIGTERM, sende dauerhaft scheiternde Jobs an eine Dead-Letter-Queue und betreibe die Flotte in einem isolierten Namespace mit einer non-preempting Priority-Class, sodass ein Burst nie den Rest deiner Infrastruktur aushungert.