Achttausend CAPTCHAs pro Sekunde sind 28,8 Millionen pro Stunde und 691 Millionen pro Tag. Eine Zahl dieser Größe wird üblicherweise als Eigenschaft eines Solvers zitiert, als wäre die Antwort ein schnelleres Modell oder eine größere Maschine. Das ist sie nicht. Lange bevor das eine Rolle spielt, sind 8.000 Solves pro Sekunde ein Concurrency-Budget, und die Größe dieses Budgets passt auf einen Bierdeckel.
Durchsatz ist Concurrency geteilt durch Service-Zeit. Wenn ein Solve zwei Sekunden dauert und jede Sekunde 8.000 davon fertig werden sollen, müssen 16.000 dauerhaft offen sein. Nichts an dieser Zahl hängt davon ab, wie clever der Solver ist. Sie folgt aus der Latenz, die Sie nicht entfernen können.
Das verschiebt das eigentliche Engineering-Problem. Interessant ist nicht der Solve, sondern die Control Plane darum herum. Irgendetwas muss Arbeit annehmen, sie begrenzen, Zehntausende Operationen gleichzeitig offen halten ohne umzufallen, und sich selbst gut genug messen, damit Sie benennen können, welche Stufe das Limit setzt.
Die begleitende Go-Implementierung in ScraperHub/how-we-solve-8000-captchas-per-second baut diese Control Plane in vier Dateien: eine begrenzte Queue, einen Worker-Pool mit Backpressure, ein austauschbares Solver Interface mit einer Mock- und einer Crawlbase-Implementierung, und einen Metrics-Collector, der Solves pro Sekunde samt p50 und p99 ausgibt. Dieser Beitrag geht sie durch, führt beide Benchmarks aus und rechnet dann ehrlich nach, was diese Läufe über 8.000 beweisen und was nicht.
- Durchsatz ist Concurrency geteilt durch Service-Zeit. Bei zwei Sekunden pro Solve bedeuten 8.000 pro Sekunde 16.000 gleichzeitig offene Requests.
- Die Go-Control-Plane ist nicht der Engpass. Der Mock-Lauf hält 32.507 simulierte Solves pro Sekunde auf einer Maschine, das sind 0,6% unter seiner eigenen rechnerischen Obergrenze.
- Der Mock kann Ihnen keinen echten Tail zeigen. Sein p99 ist konstruktionsbedingt auf Basislatenz plus Jitter begrenzt.
- Zwischen 6 Workern und 16.000 offenen Operationen bricht dreierlei zuerst: der voreingestellte HTTP-Connection-Pool, der unbegrenzte Latenz-Slice und die Annahme, ein Prozess sei das Deployment.
- Die Solve-Stufe ist der Teil, den man besser nicht selbst baut. Genau den übernimmt die Crawling API.
Solver Interface steckt, und jedes Ergebnis läuft in einem einzigen Collector zusammen. Die Obergrenze der Queue ist der Backpressure-Mechanismus.Durchsatz ist Concurrency geteilt durch Service-Zeit
Little's Law ist der gesamte Kapazitätsplan in einer Zeile. Die Zahl der gleichzeitig offenen Operationen ist die Abschlussrate multipliziert mit der Dauer je Operation. Umgestellt auf die Frage hier: die Rate, die Sie halten können, ist Ihre Concurrency geteilt durch Ihre Service-Zeit.
Das lohnt sich an den Zahlen des Repositorys zu prüfen, statt sie zu glauben. Der dokumentierte Mock-Lauf nutzt 256 Worker und meldet ein p50 von 7,83 ms. Rechnen wir: 256 Worker bei 7,83 ms pro Solve ergeben rechnerisch 32.695 Solves pro Sekunde. Gemessen wurden 32.507. Queue, Results-Channel, Fan-in-Collector und dessen Mutex kosten zusammen 0,57% der theoretischen Obergrenze.
Rechnen Sie dasselbe in die andere Richtung, wird aus dem Titel dieses Beitrags eine Hardware-Bestellung.
| Zielrate | Service-Zeit | Benötigte Concurrency | Was das bedeutet |
|---|---|---|---|
| 32.507/s | 7,83 ms (simuliert) | 256 | 256 Goroutinen auf einer Maschine |
| 8.000/s | 1,9 s (gemessen, echter Pfad) | 15.200 | eine Flotte, kein Prozess |
| 8.000/s | 2 s (runde Zahl) | 16.000 | gleiches Ergebnis, einfacher zu rechnen |
Die beiden Zeilen zeigen, wo die Schwierigkeit tatsächlich liegt. Simulierte Arbeit ist pro Vorgang 250 Mal schneller als ein echter Netzwerk-Roundtrip, deshalb braucht der lokale Benchmark drei Größenordnungen weniger Concurrency für eine höhere Zahl. Alles Schwierige an 8.000 echten Solves pro Sekunde sind die 16.000 offenen Sockets, nicht die 8.000 Solves.
Vier Dateien, ein Interface
Das kanonische lauffähige Modul ist das Verzeichnis final/ des begleitenden Repositorys. Es ist bewusst klein gehalten.
pipeline.go bounded queue, worker pool, results fan-in solver.go the Solver interface and its two implementations metrics.go throughput and latency percentiles main.go load harness and flags config.go token and target URL from the environment
Das Solver Interface ist die Nahtstelle, die den Rest testbar macht. Die Pipeline erfährt nie, ob ein Challenge an eine lokale Simulation oder quer über das Internet ging; sie reicht Arbeit ein und protokolliert, was zurückkam. Genau das erlaubt es, die Orchestrierung bei 32.000 Operationen pro Sekunde ohne Netzwerk zu vermessen und dieselbe Pipeline anschließend auf einen echten Endpunkt zu richten und zuzusehen, wie die Zahlen aus benennbaren Gründen einbrechen.
Schritt 1: die Queue und der Worker-Pool
Ein Challenge ist ein Struct und die Queue ist ein gepufferter Channel. Die Kapazität des Channels ist der komplette Backpressure-Mechanismus: sobald der Puffer voll ist, blockiert das Senden des Producers, bis ein Worker einen Platz freigibt, sodass ein überlastetes System seine Aufnahme drosselt statt seinen Heap wachsen zu lassen.
Quelle: final/pipeline.go
func NewPipeline(workers, queueSize int, solver Solver, metrics *Metrics, target string) *Pipeline { return &Pipeline{ workers: workers, queue: make(chan Challenge, queueSize), // bounded => backpressure results: make(chan Result, queueSize), solver: solver, metrics: metrics, target: target, } }
Jeder Worker ist eine Goroutine, die über diesen Channel iteriert. Das Iterieren über einen Channel ist gleichzeitig das Shutdown-Protokoll: der Producer schließt die Queue, wenn ihm die Arbeit ausgeht, die Schleifen leeren den Rest und beenden sich, und sync.WaitGroup sagt dem Aufrufer, wann der letzte fertig ist.
func (p *Pipeline) worker(ctx context.Context, id int, wg *sync.WaitGroup) { defer wg.Done() for ch := range p.queue { started := time.Now() err := p.solver.Solve(ctx, ch) p.results <- Result{ ID: ch.ID, OK: err == nil, Latency: time.Since(started), Worker: id, } } }
Beachten Sie, was der Worker nicht tut: er berührt das Metrics-Struct nicht. Er meldet ein Result und macht weiter. Eine einzige Collector-Goroutine leert den Results-Channel in den Collector, sodass die Zähler genau einen Schreiber haben und der heiße Pfad ein Channel-Send bleibt.
Schritt 2: das Solver-Interface und seine zwei Implementierungen
Die Pipeline hängt von zwei Methoden ab. Das ist der ganze Vertrag.
Quelle: final/solver.go
type Solver interface { Solve(ctx context.Context, c Challenge) error Name() string }
MockSolver vertritt die Solve-Stufe mit einer Basislatenz, gleichverteiltem Jitter und einer Fehlerrate und macht keine Netzwerkaufrufe. Es existiert, damit Sie die Orchestrierung isoliert messen können.
func (s *MockSolver) Solve(ctx context.Context, _ Challenge) error { d := s.Base if s.Jitter > 0 { d += time.Duration(rand.Int63n(int64(s.Jitter))) } select { case <-time.After(d): case <-ctx.Done(): return ctx.Err() } if s.FailRate > 0 && rand.Float64() < s.FailRate { return errors.New("mock solve failed") } return nil }
CrawlbaseSolver ist der echte Pfad. Er sendet die Ziel-URL an die Crawling API, wo CAPTCHA- und Anti-Bot-Behandlung als Teil des Fetch passieren, und behandelt eine saubere 200 mit vollständig geleertem Body als abgeschlossene Operation. Das Leeren nach io.Discard ist kein Schönheitsfehler: ein nicht gelesener Body kann nicht in den Connection-Pool zurückgegeben werden, was gleich noch sehr wichtig wird.
func (s *CrawlbaseSolver) Solve(ctx context.Context, c Challenge) error { endpoint := fmt.Sprintf( "https://api.crawlbase.com/?token=%s&url=%s", url.QueryEscape(s.Token), url.QueryEscape(c.URL), ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } resp, err := s.Client.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("crawlbase status %d", resp.StatusCode) } return nil }
Gleiche Signatur, völlig andere Physik. Der Worker-Pool kann sie nicht unterscheiden, und genau das ist der Punkt.
Schritt 3: Durchsatz und Tail gemeinsam messen
Eine Durchsatzzahl allein verdeckt die interessanten Fehler. Eine Pipeline kann einen gesunden Mittelwert melden, während ein Teil der Requests um eine Größenordnung länger braucht als der Rest, und in einem parallelen System sind genau diese langsamen Requests jene, die Ihr Concurrency-Budget blockieren. Deshalb hält der Collector jede Latenz fest und berechnet daraus Perzentile.
Quelle: final/metrics.go
func (m *Metrics) Report() Report { total := m.successes + m.failures elapsed := m.end.Sub(m.start) rate := 0.0 if elapsed > 0 { rate = float64(total) / elapsed.Seconds() } return Report{ Total: total, Successes: m.successes, Failures: m.failures, Elapsed: elapsed, SolvesPerSecond: rate, P50: m.percentile(50), P99: m.percentile(99), } }
Vier Signale kommen dabei heraus: Gesamtvolumen, die Aufteilung in Erfolge und Fehler, p50 und p99. Dieses Paar macht das Tuning der Worker-Anzahl zu einer Messung statt zu einer Vermutung. Fügen Sie Worker hinzu und beobachten Sie beides: steigt die Rate, während p99 stabil bleibt, wird die zusätzliche Concurrency aufgenommen. Flacht die Rate ab, während p99 weiter steigt, stauen Sie sich weiter unten und zusätzliche Worker machen es jetzt schlimmer.
Schritt 4: das Load-Harness
main.go verdrahtet die Teile und legt die Regler auf die Kommandozeile, während config.go Token und Ziel-URL über einen kleinen .env Loader ohne Abhängigkeiten aus der Umgebung liest.
Quelle: final/main.go
metrics := NewMetrics(*requests) pipeline := NewPipeline( *workers, *queueSize, solver, metrics, cfg.TargetURL, ) report := pipeline.Run( context.Background(), *requests, ) fmt.Println(report)
Die Flags sind -solver, -requests, -workers, -queue, -base-ms, -jitter-ms, und -fail-rate. Die Voreinstellungen sind 20.000 Requests, 256 Worker, eine Queue von 1.024, eine Basislatenz von 5 ms mit 5 ms Jitter und eine Fehlerrate von 1%.
Was der Mock-Lauf beweist und was nicht
Testen Sie zuerst die Control Plane unter Last, ohne Netzwerk im Weg:
go run . -requests 20000 -workers 256 -queue 1024
solver=mock requests=20000 workers=256 queue=1024 target=https://example.com total=20000 ok=19804 fail=196 elapsed=615ms solves/sec=32507 p50=7.83ms p99=10.354ms
Drei Dinge in dieser Ausgabe lohnen genaues Lesen, denn zwei davon sind Bestätigungen und eines ist eine Grenze.
32.507 gegen eine Obergrenze von 32.695. Die Orchestrierung kostet 0,57%. Channel-Sends, ein Mutex und ein Fan-in auf einer Goroutine sind nicht der Ort, an dem ein Durchsatzproblem wohnt, und jetzt haben Sie den Beleg statt der Vermutung.
196 Fehler von 20.000 sind 0,98%, gegenüber einer konfigurierten Fehlerrate von 1%. Der Fehlerpfad wird also durchlaufen und korrekt gezählt. Für einen sauberen Lauf übergeben Sie -fail-rate 0 , aber ein Solver, der nie fehlschlägt, ist nicht der, den Sie ausliefern.
Ein p99 von 10,354 ms ist kein Tail. Die Service-Zeit des Mocks ist 5 ms plus gleichverteilter Jitter unter 5 ms, also sind 10 ms ein hartes rechnerisches Maximum. Das gemessene p99 liegt 354 Mikrosekunden über einer Grenze, die in der Simulation eingebaut ist. Das ist eine Scheduling-Messung, keine Latenzverteilung. Echte Tails bestehen aus DNS, TLS-Handshakes, Retries, einem langsamen Origin und einer unglücklichen IP, und nichts davon existiert in diesem Lauf. Einem p99 aus einem Mock zu glauben ist der einfachste Weg, in der Produktion überrascht zu werden.
Der Mock-Benchmark ist der richtige erste Schritt und ein schlechter letzter. Er beweist, dass Queue und Pool Zehntausende Operationen pro Sekunde auf einer Maschine verbuchen können, und genau das wollen Sie wissen, bevor Sie ihnen die Schuld geben. Über die Stufe, die tatsächlich zwei Sekunden braucht, sagt er nichts.
Der echte Pfad ist netzwerkgebunden
Jetzt dieselbe Pipeline, derselbe Code, gegen einen echten Endpunkt. Absichtlich klein:
go run . -solver crawlbase -requests 12 -workers 6 -queue 32
solver=crawlbase requests=12 workers=6 queue=32 total=12 ok=12 fail=0 elapsed=3.38s solves/sec=4 p50=1.924091s p99=2.409689s
Vier Solves pro Sekunde, und der gemeldete Wert ist 3,55, gerundet vom Format-String. Zwölf Messwerte sind viel zu wenig für ein sinnvolles Perzentil, nehmen Sie das p50 also als Größenordnung: ein Solve über den echten Pfad braucht etwa zwei Sekunden, und der Großteil davon ist der Fetch und die Anti-Bot-Arbeit am anderen Ende, nicht irgendetwas in Go.
Diese eine Zahl ist die, auf die es bei der Kapazitätsplanung ankommt, und sie ist die, die der Mock Ihnen nicht geben kann. Zwei Sekunden Service-Zeit machen aus 8.000 pro Sekunde 16.000 gleichzeitige Operationen. Der Vergleich lautet nicht Mock 32.507 gegen echt 4; er lautet: eine Control Plane mit drei Größenordnungen Luft sitzt vor einer Abhängigkeit, die das eigentliche Budget setzt.
Dreierlei bricht zwischen 6 Workern und 16.000 offenen Operationen
Das Beispiel läuft mit 6 Workern. Die Produktion läuft im vierstelligen Bereich. Das sind die drei Wände, gegen die Sie unterwegs laufen, in der Reihenfolge, in der es passiert.
Der Connection-Pool zuerst
NewCrawlbaseSolver baut seinen Client auf die gewöhnliche Weise:
Client: &http.Client{Timeout: 30 * time.Second}
Kein Transport Feld bedeutet http.DefaultTransport, und http.DefaultTransport hält zwei Idle-Verbindungen pro Host. Das ist DefaultMaxIdleConnsPerHost, und der Wert ist 2, solange es net/http gibt. Bei sechs Workern gegen einen API-Host merkt das niemand. Bei zweitausend Workern gegen einen API-Host werden alle Verbindungen außer zwei abgebaut, sobald die Antwort gelesen ist, sodass fast jeder Solve einen neuen TCP-Handshake und einen neuen TLS-Handshake bezahlt, bevor er ein Byte senden kann. Sie haben einer Zwei-Sekunden-Operation ein bis zwei Roundtrips hinzugefügt, CPU für Handshakes verbrannt und beginnen ohne Grund, ephemere Ports durchzurotieren.
Dimensionieren Sie den Transport für die Concurrency, die Sie wirklich wollen:
transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = workers transport.MaxIdleConnsPerHost = workers // default is 2 transport.MaxConnsPerHost = workers // 0 means unlimited transport.IdleConnTimeout = 90 * time.Second client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
Auch MaxConnsPerHost zu setzen ist der Weg, Backpressure Ende zu Ende ehrlich zu machen. Ohne das versucht ein Worker-Pool von 16.000 bereitwillig, 16.000 Sockets zu öffnen. Mit dem Limit blockieren Worker stattdessen auf eine Verbindung, was ein deutlich besseres Fehlerverhalten ist als erschöpfte File-Deskriptoren. Erhöhen Sie auch das Prozess-Limit, denn die Voreinstellung des Betriebssystems liegt weit unter vierstelligen Socket-Zahlen.
Der Latenz-Slice wird zum Speicherleck
Der Collector hängt pro Solve eine time.Duration an und alloziert den Slice vorab auf die Request-Anzahl. Für einen Benchmark mit 20.000 Requests sind das 160 KB und ein Sortieren, das niemandem auffällt. In einem Dienst hört das Wachstum nie auf.
Bei 8.000 Solves pro Sekunde sind acht Byte pro Messwert 62,5 KB pro Sekunde, 230 MB pro Stunde und 5,5 GB pro Tag. Schlimmer noch: percentile kopiert bei jedem Aufruf den gesamten Slice und sortiert die Kopie, sodass ein Report über eine Stunde Verkehr 28,8 Millionen Elemente sortiert und dabei den Speicherbedarf verdoppelt. Für ein Load-Harness, dessen ganze Aufgabe es ist, jeden Messwert eines begrenzten Laufs zu behalten, ist das richtig, und für alles Langlebige völlig falsch.
Die Lösung ist die übliche: ein Latenz-Histogramm fester Größe mit Bucket-Zählern, das pro Reporting-Fenster zurückgesetzt oder rotiert wird. Perzentile werden zu einer Interpolation über Zähler statt zu einer Sortierung über die Historie, der Speicherbedarf wird konstant, und die Zahlen hören auf zu lügen, sobald ein Lauf seine Allokation überdauert.
Ein Prozess ist nicht mehr das Deployment
Sechzehntausend offene Requests sind kein Problem der Goroutine-Anzahl. Goroutinen sind billig; die Sockets, die TLS-Sessions, die File-Deskriptoren und die Netzwerkkarte davor sind es nicht. Jenseits einiger Tausend gleichzeitiger Verbindungen zu einem Ziel muss sich die Form ändern: viele Worker-Instanzen, eine gemeinsame Queue davor, Ergebnisse, die in etwas Dauerhaftes strömen.
+----------------+ | shared queue | bounded, same as the channel +-------+--------+ | +--------------+--------------+ | | | v v v worker group worker group worker group | | | +--------------+--------------+ | v solve path (API) | v result stream
Das Modell übersteht den Umzug, weil nichts darin einen einzelnen Prozess voraussetzte. Ein Go-Channel wird zur gemeinsamen Queue, eine Goroutine wird zur Worker-Instanz, das Fan-in wird zur Metrics-Pipeline, und queue to workers to solver to results liest sich auf beiden Skalen gleich. Das ist das eigentliche Argument dafür, die Control Plane so klein zu halten: es sind dieselben vier Zuständigkeiten, ob Sie 256 Worker oder 8 Instanzen mit je 2.000 fahren. Dieselbe Überlegung findet sich ausführlicher in unserem Beitrag über den Aufbau einer verteilten Crawling-Engine.
Die Control Plane ist der Teil, den man selbst schreiben sollte. Die Solve-Stufe nicht: CAPTCHA- und Anti-Bot-Behandlung passieren innerhalb des Fetch, hinter rotierenden Residential-IPs, und kommen als eine saubere Antwort zurück, die Ihr Worker entweder zählt oder erneut versucht. Richten Sie dieselbe Pipeline darauf und starten Sie kostenlos mit 1.000 Requests, ohne Karte.
Queue-Größe und Worker-Anzahl haben verschiedene Aufgaben
Diese zwei Flags werden zusammen getunt und ständig verwechselt.
Die Worker-Anzahl setzt die Concurrency. Sie entscheidet, wie viele Solver-Operationen Sie gleichzeitig offen haben wollen, und das ist nach Little's Law der einzige Hebel, der Ihre haltbare Rate verändert.
Die Queue-Größe setzt die Pufferung. Sie entscheidet, wie viel vorübergehendes Ungleichgewicht zwischen Ankünften und Kapazität Sie aufnehmen können, bevor der Producer zu blockieren beginnt.
Eine größere Queue kauft null Durchsatz. Wenn Worker 1.000 Operationen pro Sekunde schaffen und Producer 2.000 erzeugen, entscheidet die Queue-Tiefe nur, wie viele Sekunden vergehen, bis die Queue voll ist. Sie läuft voll, der Producer blockiert, und Backpressure kommt genau dort an, wo sie hingehört. Die Queue zu dimensionieren ist in Wahrheit eine Entscheidung über Burst-Toleranz und darüber, wie alt ein wartender Eintrag werden darf.
Tunen Sie zuerst den Mock, wo Iteration nichts kostet: -base-ms verändert die simulierte Service-Zeit, -jitter-ms fügt Varianz hinzu, und -fail-rate lässt Sie den Fehlerpfad unter Last beobachten. Diese drei Regler reproduzieren die meisten Verhaltensweisen, die Sie interessieren, bevor ein einziger echter Request rausgeht.
Was in die Produktion mitgenommen wird
Begrenzen Sie die Arbeit
Eine unbegrenzte Queue verhindert keine Überlast; sie verbirgt die Überlast, bis der Speicher zum Fehlermechanismus wird, und fällt dann auf einmal aus, ohne brauchbares Signal. Eine begrenzte Queue verwandelt dieselbe Überlast in Backpressure, und die ist sichtbar, überlebbar und messbar.
Halten Sie den Solver hinter einem Interface
Die Pipeline soll Arbeit bewegen, keine Meinungen darüber haben, wie Arbeit erledigt wird. Die zwei Implementierungen hier sind das Argument: dieselbe Control Plane wurde bei 32.507 Operationen pro Sekunde belastet und dann auf eine echte API gerichtet, ohne eine Zeile in pipeline.go.
Messen Sie die Stufe, nicht das System
Durchsatz und Tail-Latenz bedeuten nur gemeinsam etwas, und nur, wenn Sie wissen, welche Stufe sie erzeugt hat. Der Worker-Pool kann nie mehr nutzbaren Durchsatz freilegen, als der Solver hält, deshalb ist eine Rate, die auf zusätzliche Worker nicht mehr reagiert, eine Aussage über die Abhängigkeit. Unsere Notizen zur Skalierung von Web-Scraping-Projekten und diese Fallstudie über 1 Milliarde Requests pro Monat kommen beide auf dieselbe Gewohnheit zurück, pro Stufe zu instrumentieren.
Das begleitende Repository ausführen
Das Repository benötigt Go 1.22 oder neuer. Der Mock-Pfad braucht kein Konto; der Crawlbase-Pfad braucht ein Token.
git clone https://github.com/ScraperHub/how-we-solve-8000-captchas-per-second.git cd how-we-solve-8000-captchas-per-second/final cp .env.example .env # only needed for the crawlbase solver go build -o captcha-pipeline .
Zwei Umgebungsvariablen, beide gelesen von config.go:
| Variable | Zweck |
|---|---|
CRAWLBASE_TOKEN |
Token für den Pfad -solver crawlbase . Fehlt es, wird CRAWLBASE_TOKEN is required for the crawlbase solver ausgegeben und der Lauf beendet. |
TARGET_URL |
URL, die der Crawlbase-Solver pro Challenge abruft. Voreinstellung ist https://example.com. |
final/ ist das kanonische lauffähige Modul und steps/ enthält schreibgeschützte Zwischenstände der Datei, die in jedem der Schritte oben eingeführt wurde, sodass Sie die Pipeline auch so lesen können, wie sie nach Schritt 1 aussah, und nicht nur im Endzustand.
| Abschnitt | Codepfad |
|---|---|
| Schritt 1: die Queue und der Worker-Pool | final/pipeline.go |
| Schritt 2: das Solver-Interface und seine zwei Implementierungen | final/solver.go |
| Schritt 3: Durchsatz und Tail gemeinsam messen | final/metrics.go |
| Schritt 4: das Load-Harness |
final/main.go, final/config.go
|
Fazit
Was es braucht, um 8.000 CAPTCHAs pro Sekunde zu verarbeiten, sind 16.000 gleichzeitig offene Operationen, und jeder schwierige Teil folgt aus dieser einen Zahl und nicht aus dem Lösen selbst.
Die Go-Control-Plane ist die leichte Hälfte, und die Messungen sagen das auch: eine begrenzte Queue, ein Worker-Pool, ein Interface mit zwei Methoden und ein Fan-in-Collector laufen bei 32.507 simulierten Operationen pro Sekunde 0,6% unter ihrer rechnerischen Obergrenze. Die schwere Hälfte ist, vierstellig viele echte Verbindungen offen zu halten, die Instrumentierung dabei speicherkonstant zu halten und das Ganze über Instanzen zu verteilen, sobald einer Maschine die Sockets ausgehen.
Das nützliche Ergebnis dieser Übung ist also keine Worker-Anzahl. Es sind zwei Zahlen, die Sie verteidigen können, Service-Zeit und Concurrency, und eine Pipeline, die gut genug instrumentiert ist, um zu sagen, welche Stufe das Limit besitzt. Haben Sie die, wird die Zielrate eine Kapazitätsentscheidung. Fehlen sie, bleibt sie eine Vermutung mit einer großen Zahl daneben.
Häufig gestellte Fragen
Löst der Beispielbefehl 8.000 echte CAPTCHAs pro Sekunde?
Nein, und das ist auch nicht seine Aufgabe. Der Mock-Befehl meldet 32.507 simulierte Solves pro Sekunde ohne jedes Netzwerk, und der Crawlbase-Befehl im Repository nutzt eine absichtlich winzige Last von 12 Requests und meldet etwa 4 pro Sekunde, weil er netzwerkgebunden ist. Die Zahl 8.000 beschreibt die Architektur im Produktionsmaßstab, also viele Worker-Instanzen an einer gemeinsamen Queue, nicht einen lokalen Prozess. Was das Beispiel liefert, sind die zwei Eingangsgrößen, mit denen Sie dieses Deployment dimensionieren: die Luft der Control Plane und die Service-Zeit des echten Pfades.
Wie viele Worker brauche ich für 8.000 Solves pro Sekunde?
Teilen Sie die Zielrate durch die Abschlussrate eines einzelnen Workers. Bei etwa zwei Sekunden pro Solve schafft ein Worker 0,5 pro Sekunde, also brauchen 8.000 pro Sekunde rund 16.000 gleichzeitig offene Worker. Ob das 8 Instanzen mit je 2.000 oder 16 mit je 1.000 sind, ist eine Frage von Sockets, Deskriptoren und Blast-Radius, nicht von Go. Messen Sie Ihr eigenes p50 gegen Ihre eigenen Ziele, bevor Sie der Zwei-Sekunden-Zahl vertrauen.
Warum überhaupt mit einem Mock-Solver benchmarken?
Um herauszufinden, ob Ihr eigener Code der Engpass ist, bevor Sie eine Abhängigkeit beschuldigen. Der Mock entfernt das Netzwerk und simuliert den Solve mit konfigurierbarer Latenz, Jitter und Fehlerrate, was Queue, Worker-Pool und Fan-in isoliert. Hier zeigte er, dass die Orchestrierung 0,57% der theoretischen Obergrenze kostet, ein Durchsatzdefizit im echten Lauf liegt also beweisbar nicht in der Control Plane.
Kann ich dem p99 eines Mock-Laufs trauen?
Nein. Die Service-Zeit des Mocks ist eine Basislatenz plus begrenzter Jitter, sein Worst Case steht also rechnerisch fest: 5 ms plus höchstens 5 ms ergibt eine Grenze von 10 ms, und das gemessene p99 von 10,354 ms ist diese Grenze plus Scheduling-Aufwand. Ein echter Tail kommt aus DNS, TLS, Retries und langsamen Origins, und nichts davon simuliert der Mock. Nutzen Sie Mock-Perzentile, um Scheduling-Probleme in Ihrem eigenen Code zu erkennen, und für nichts anderes.
Warum eine begrenzte Queue statt einer unbegrenzten?
Weil eine unbegrenzte Queue die Überlast nicht beseitigt, sondern das Symptom nur in den Speicher verlagert und den Ausfall verzögert, bis er nicht mehr behebbar ist. Läuft eine begrenzte Queue voll, warten Producer auf Worker, und das ist Backpressure: der Druck wandert zurück zu dem, der die Arbeit erzeugt, während die wartende Arbeit unter einer Grenze bleibt, die Sie bewusst gewählt haben.
Wie soll ich die Worker-Anzahl tunen?
Erhöhen Sie -workers auf dem Mock-Pfad und beobachten Sie Durchsatz und p99 zusammen. Solange die Rate steigt und p99 flach bleibt, wird die zusätzliche Concurrency aufgenommen. Wenn die Rate abflacht und p99 weiter steigt, ist die nachgelagerte Stufe zur Grenze geworden und weitere Worker vertiefen nur eine Queue, die Sie nicht sehen. Wechseln Sie dann zum echten Solver und wiederholen Sie das, denn die beiden Kurven haben völlig verschiedene Formen.
Was macht der Crawlbase-Solver genau?
Er sendet die Ziel-URL an die Crawling API und behandelt eine saubere HTTP 200 mit geleertem Body als abgeschlossenen Solve. Die CAPTCHA- und Anti-Bot-Arbeit passiert innerhalb dieses Fetch und nicht in Ihrem Prozess, weshalb ein "Solve" auf diesem Pfad ein Request und eine Statusprüfung ist. Ansätze für dasselbe Problem von der Aufrufer-Seite behandelt unser Leitfaden zum Umgehen von CAPTCHAs beim Web Scraping.
Crawlen Sie jede Website im großen Maßstab, ohne gegen die Infrastruktur zu kämpfen.
Crawlbase übernimmt Proxys, Fingerprints und CAPTCHAs, damit Ihr Team Datenpipelines ausliefert, statt Crawl-Infrastruktur zu pflegen. 1.000 Anfragen kostenlos, keine Karte erforderlich.
