Doppelte CAPTCHA-Anfragen kosten Sie doppelt – obwohl das zweite Ergebnis mit dem ersten identisch ist. Die zuverlässige Gegenmaßnahme ist eine Deduplizierungsschicht: Sie erkennt gleichartige Anfragen anhand eines eindeutigen Schlüssels, blockt Parallelaufrufe per Lock und gibt allen Wartenden dieselbe Lösung zurück. Das Ergebnis: weniger belegte Threads, gespartes Guthaben und geringere Latenz. Dieser Leitfaden zeigt zwei erprobte Umsetzungen – mit Redis und mit PostgreSQL Advisory Locks.
Warum Duplikate Threads und Guthaben kosten
CaptchaAI rechnet Thread-basiert ab: Ein Thread ist ein CAPTCHA in Bearbeitung, und jeder Plan – von BASIC (15 $/Monat, 5 Threads) bis ENTERPRISE (300 $/Monat, 200 Threads) – enthält unbegrenzte Lösungen pro Thread. Eine doppelte Anfrage kostet also nicht extra pro Lösung, blockiert aber einen Thread, der parallel eine echte Abfrage bearbeiten könnte. Bei mehreren Workern summiert sich das schnell zu spürbarem Durchsatzverlust.
Vier typische Muster erzeugen diese Duplikate:
| Szenario | Ursache | Verschwendung |
|---|---|---|
| Erneuter Versuch vor Eintreffen des Ergebnisses | Zu aggressive Wiederholungslogik | 2–5× belegte Threads pro CAPTCHA |
| Mehrere Worker, dasselbe Ziel | Keine Koordination zwischen den Workern | Parallel verschwendete Lösungen |
| Seitenaktualisierung löst neu aus | Frontend-Wiederholung bei Timeout | Zusätzliche Lösung pro Aktualisierung |
| Erneut zugestellte Queue-Nachricht | At-least-once-Zustellgarantie | Doppelte Lösung pro Wiederholung |
Den Deduplizierungsschlüssel entwerfen
Die gesamte Logik hängt an einem stabilen Schlüssel: Zwei Anfragen sind genau dann identisch, wenn sie denselben Schlüssel erzeugen. Leiten Sie ihn aus den Anfrageparametern ab und hashen Sie das Ergebnis, damit der Schlüssel kurz und kollisionsarm bleibt:
import hashlib
def dedup_key(method, sitekey, pageurl):
"""Generate a deduplication key for a CAPTCHA solve request."""
raw = f"{method}:{sitekey}:{pageurl}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
Welche Komponenten in den Schlüssel gehören, hängt vom CAPTCHA-Typ ab. Nehmen Sie alle Felder auf, die die Lösung eindeutig bestimmen – aber keine, die sie zufällig unterscheiden würden (dazu unten mehr zum Proxy):
| CAPTCHA-Typ | Schlüsselkomponenten |
|---|---|
| reCAPTCHA v2 | method + sitekey + pageurl |
| reCAPTCHA v3 | method + sitekey + pageurl + action |
| Cloudflare Turnstile | method + sitekey + pageurl |
| Bild-CAPTCHA | method + Hash von body (Bildinhalt) |
Bei reCAPTCHA v3 ist die action entscheidend: Dieselbe Seite kann für Login und Checkout unterschiedliche Actions führen, die getrennt gelöst werden müssen. Bei Bild-CAPTCHAs ersetzt der Inhalts-Hash den Sitekey – gleiche Bilder liefern dieselbe Lösung.
Deduplizierung mit Redis
Redis eignet sich ideal, weil SET mit TTL atomar ist und sich als verteiltes Lock einsetzen lässt. Der erste Worker markiert den Schlüssel als solving, alle folgenden Worker warten auf das Ergebnis, statt selbst zu übermitteln. Nach der Lösung liegt das Token kurz im Cache – so kurz, dass es innerhalb seiner Gültigkeit wiederverwendet, aber nicht abgelaufen zurückgegeben wird.
import os
import time
import json
import hashlib
import redis
import requests
r = redis.Redis(
host=os.environ.get("REDIS_HOST", "localhost"),
port=int(os.environ.get("REDIS_PORT", 6379)),
decode_responses=True
)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Dedup window: how long to consider a request "in progress"
DEDUP_TTL = 180 # seconds
def dedup_key(method, sitekey, pageurl, extra=""):
raw = f"{method}:{sitekey}:{pageurl}:{extra}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
def solve_with_dedup(sitekey, pageurl, method="userrecaptcha"):
key = dedup_key(method, sitekey, pageurl)
# Check if this request is already being solved
existing = r.get(key)
if existing:
state = json.loads(existing)
if state["status"] == "solving":
# Wait for the result
return wait_for_result(key)
elif state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_cache"}
elif state["status"] == "error":
pass # Allow retry on error
# Mark as solving
r.set(key, json.dumps({"status": "solving", "started": time.time()}), ex=DEDUP_TTL)
# Submit to CaptchaAI
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": method,
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
r.set(key, json.dumps({"status": "error", "error": data.get("request")}), ex=30)
return {"error": data.get("request")}
captcha_id = data["request"]
# Poll for result
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get",
"id": captcha_id, "json": 1
}).json()
if result.get("status") == 1:
solution = result["request"]
# Cache the result for other workers (short TTL since tokens expire)
r.set(key, json.dumps({
"status": "solved",
"solution": solution,
"solved_at": time.time()
}), ex=60) # Cache result for 60 seconds
return {"solution": solution, "source": "api"}
if result.get("request") != "CAPCHA_NOT_READY":
r.set(key, json.dumps({
"status": "error", "error": result.get("request")
}), ex=30)
return {"error": result.get("request")}
r.set(key, json.dumps({"status": "error", "error": "TIMEOUT"}), ex=30)
return {"error": "TIMEOUT"}
def wait_for_result(key, timeout=120):
"""Wait for another worker to finish solving."""
start = time.time()
while time.time() - start < timeout:
data = r.get(key)
if data:
state = json.loads(data)
if state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_wait"}
if state["status"] == "error":
return {"error": state.get("error", "UNKNOWN")}
time.sleep(2)
return {"error": "DEDUP_WAIT_TIMEOUT"}
Dasselbe Muster in Node.js – nützlich, wenn Ihre Worker als AWS-Lambda-Funktionen oder in einem Express-Dienst laufen:
const Redis = require("ioredis");
const axios = require("axios");
const crypto = require("crypto");
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const DEDUP_TTL = 180;
function dedupKey(method, sitekey, pageurl) {
const raw = `${method}:${sitekey}:${pageurl}`;
const hash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);
return `captcha:dedup:${hash}`;
}
async function solveWithDedup(sitekey, pageurl, method = "userrecaptcha") {
const key = dedupKey(method, sitekey, pageurl);
// Check existing
const existing = await redis.get(key);
if (existing) {
const state = JSON.parse(existing);
if (state.status === "solving") return await waitForResult(key);
if (state.status === "solved") return { solution: state.solution, source: "dedup_cache" };
}
// Mark as solving
await redis.set(key, JSON.stringify({ status: "solving", started: Date.now() }), "EX", DEDUP_TTL);
// Submit
const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: { key: API_KEY, method, googlekey: sitekey, pageurl, json: 1 },
});
if (submit.data.status !== 1) {
await redis.set(key, JSON.stringify({ status: "error", error: submit.data.request }), "EX", 30);
return { error: submit.data.request };
}
const captchaId = submit.data.request;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const poll = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
});
if (poll.data.status === 1) {
await redis.set(key, JSON.stringify({ status: "solved", solution: poll.data.request }), "EX", 60);
return { solution: poll.data.request, source: "api" };
}
if (poll.data.request !== "CAPCHA_NOT_READY") {
await redis.set(key, JSON.stringify({ status: "error", error: poll.data.request }), "EX", 30);
return { error: poll.data.request };
}
}
await redis.set(key, JSON.stringify({ status: "error", error: "TIMEOUT" }), "EX", 30);
return { error: "TIMEOUT" };
}
async function waitForResult(key, timeout = 120000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const data = await redis.get(key);
if (data) {
const state = JSON.parse(data);
if (state.status === "solved") return { solution: state.solution, source: "dedup_wait" };
if (state.status === "error") return { error: state.error };
}
await new Promise((r) => setTimeout(r, 2000));
}
return { error: "DEDUP_WAIT_TIMEOUT" };
}
Ohne Redis: PostgreSQL Advisory Locks
Wenn Sie ohnehin PostgreSQL betreiben und keine zusätzliche Infrastruktur einführen möchten, erledigen Advisory Locks dieselbe Koordination. pg_try_advisory_lock versucht nicht-blockierend, das Lock zu greifen; scheitert das, wartet der Worker blockierend auf das Lock und liest anschließend das zwischengespeicherte Ergebnis:
import psycopg2
def solve_with_pg_dedup(conn, sitekey, pageurl):
"""Use PostgreSQL advisory locks for deduplication."""
# Generate a numeric lock key from the dedup key
lock_id = hash(f"{sitekey}:{pageurl}") & 0x7FFFFFFF
cursor = conn.cursor()
# Try to acquire advisory lock (non-blocking)
cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
acquired = cursor.fetchone()[0]
if not acquired:
# Another worker is solving — wait for result
cursor.execute("SELECT pg_advisory_lock(%s)", (lock_id,))
# Lock acquired means other worker finished — check cache
cursor.execute(
"SELECT solution FROM captcha_cache "
"WHERE sitekey = %s AND pageurl = %s "
"AND created_at > NOW() - INTERVAL '60 seconds'",
(sitekey, pageurl)
)
row = cursor.fetchone()
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
if row:
return {"solution": row[0], "source": "pg_cache"}
return {"error": "NO_CACHED_RESULT"}
try:
# Solve the CAPTCHA
solution = solve_via_api(sitekey, pageurl)
if solution:
cursor.execute(
"INSERT INTO captcha_cache (sitekey, pageurl, solution) "
"VALUES (%s, %s, %s)",
(sitekey, pageurl, solution)
)
conn.commit()
return {"solution": solution} if solution else {"error": "SOLVE_FAILED"}
finally:
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
Wichtig ist das finally: Wird das Lock nach einer Exception nicht freigegeben, blockiert es alle nachfolgenden Worker. Behandeln Sie die Freigabe deshalb immer in einem finally-Block oder Context-Manager.
Einsparungen messen
Ohne Kennzahlen wissen Sie nicht, ob sich der Zusatzaufwand lohnt. Zählen Sie pro Quelle mit – api für echte Lösungen, dedup_cache und dedup_wait für eingesparte:
def track_dedup_stats(source):
"""Increment counters for dedup tracking."""
today = time.strftime("%Y-%m-%d")
r.hincrby(f"dedup:stats:{today}", source, 1)
r.expire(f"dedup:stats:{today}", 7 * 86400)
def get_dedup_report():
today = time.strftime("%Y-%m-%d")
stats = r.hgetall(f"dedup:stats:{today}")
total = sum(int(v) for v in stats.values())
saved = int(stats.get("dedup_cache", 0)) + int(stats.get("dedup_wait", 0))
return {
"total_requests": total,
"deduplicated": saved,
"savings_pct": f"{saved / total * 100:.1f}%" if total else "0%",
"breakdown": stats
}
Schon eine Dedup-Quote von 10 % gibt bei hohem Durchsatz spürbar Threads frei. Verfolgen Sie die Kennzahl über mehrere Tage, bevor Sie entscheiden, ob sich die Deduplizierung im konkreten Workflow rechnet.
Betrieb im DACH-Umfeld
Für einen verteilten Worker-Pool bietet sich in der Region ein günstiger Redis-Knoten bei Hetzner, netcup oder IONOS an; das Deployment lässt sich sauber über GitLab CI abbilden, das in vielen deutschen Unternehmen neben GitHub Actions Standard ist. Ein Hinweis zur DSGVO: Sitekey und Page-URL sind keine personenbezogenen Daten – deren Caching ist unkritisch. Sobald Sie jedoch Anfrage-Metadaten mitloggen, die IP-Adressen der Endnutzer enthalten, gelten diese als personenbezogen; prüfen Sie dann Rechtsgrundlage und Aufbewahrungsfrist. Setzen Sie Cache-TTLs bewusst kurz, dann speichern Sie ohnehin nur das Minimum.
Fehlerbehebung
| Problem | Ursache | Lösung |
|---|---|---|
| Doppelte CAPTCHA-Aufrufe trotz Lock | Race-Condition beim gleichzeitigen Prüfen | Atomare Operation nutzen: SET NX in Redis oder SELECT … FOR UPDATE in der Datenbank |
| Wartender Worker läuft in ein Timeout | Lösender Worker abgestürzt | TTL auf dem solving-Status (180 s) läuft automatisch ab und gibt frei |
| Veraltetes Ergebnis aus dem Cache | Token abgelaufen, Cache noch gültig | Cache-TTL kürzer als die Token-Gültigkeit setzen (60 s bei reCAPTCHA) |
| Dedup-Schlüssel läuft zu früh ab | TTL zu kurz gewählt | TTL über die erwartete Token-Gültigkeit hinaus setzen |
Häufige Fragen
Wie lange sollte der Dedup-Schlüssel gültig sein?
Zwei TTLs sind zu unterscheiden: Der solving-Status sollte etwas länger leben als eine typische Lösung dauert (hier 180 Sekunden), damit Wartende nicht zu früh aufgeben. Das gecachte Token dagegen bekommt eine kurze TTL – 60 Sekunden für reCAPTCHA –, weil das Token sonst nach Ablauf noch aus dem Cache zurückgegeben würde.
Redis oder PostgreSQL – was passt besser?
Redis, wenn Sie ohnehin einen Cache betreiben oder viele kurzlebige Locks über mehrere Dienste hinweg brauchen. PostgreSQL Advisory Locks, wenn Sie keine zusätzliche Infrastruktur einführen wollen und Ihre Worker bereits eine gemeinsame Datenbankverbindung teilen. Funktional lösen beide dieselbe Koordinationsaufgabe.
Spart Deduplizierung bei Thread-basierter Abrechnung überhaupt Geld?
Indirekt. CaptchaAI rechnet pro Thread ab, nicht pro Lösung – ein Duplikat kostet also keinen Aufpreis pro Anfrage, belegt aber einen Thread. Weniger Duplikate bedeuten mehr freie Threads für echte Abfragen, also höheren Durchsatz auf demselben Plan. Wer sonst upgraden müsste, spart damit real.
Sollte ich den Proxy in den Schlüssel aufnehmen?
Nein. Das Lösungstoken funktioniert unabhängig davon, über welchen Proxy es erzeugt wurde. Nähmen Sie den Proxy in den Schlüssel auf, würden identische Anfragen mit unterschiedlichen Proxys als verschieden gelten – und die Deduplizierung liefe ins Leere.
Verwandte Leitfäden
- CAPTCHA-Token in Redis zwischenspeichern und wiederverwenden
- Verteilte Verarbeitung mit Redis-Queue und CaptchaAI
- Parallele oder sequenzielle CAPTCHA-Lösung im Vergleich