Jeder Scraper bleibt irgendwann auf halber Strecke stehen. Ein Container wird neu geplant, ein Deployment startet den Worker neu, einer Maschine geht der Speicher aus, oder jemand drückt Strg+C. Was als Nächstes passiert, entscheidet darüber, ob Ihre Daten vertrauenswürdig bleiben. Ein Scraper, der einfach wieder von vorne beginnt, ruft alles erneut ab und schreibt, sofern er nicht dafür ausgelegt wurde, alles erneut.
Ein idempotenter Scraper ist einer, bei dem die gleiche Arbeit zweimal auszuführen denselben Effekt hat wie sie einmal auszuführen. Er kann jederzeit abgebrochen werden, neu gestartet werden, und endet trotzdem mit genau einer korrekten Kopie jedes Datensatzes. Dieser Leitfaden zeigt die vier Designentscheidungen, die das sicherstellen, mit getestetem Code und den Ergebnissen, wenn man ihn zehnmal mitten im Crawl abbricht.
Die wichtigsten Erkenntnisse
- Gehen Sie davon aus, dass der Prozess zwischen zwei beliebigen Zeilen sterben kann. Entwerfen Sie so, dass ein Neustart höchstens die eine gerade in Bearbeitung befindliche Seite wiederholt.
- Geben Sie jedem Datensatz eine ID, die aus dem abgeleitet wird, was er ist, etwa aus Quelle und SKU, niemals aus dem Zeitpunkt, zu dem er gescrapt wurde. Dann ist ein wiederholtes Schreiben ein Update, kein Duplikat.
- Halten Sie die Crawl-Frontier in dauerhaftem Speicher, und markieren Sie eine URL als erledigt in derselben Transaktion, die auch ihr Ergebnis speichert.
- Verwenden Sie Leases statt Locks, sodass Arbeit, die von einem abgestürzten Worker beansprucht wurde, von selbst wieder verfügbar wird.
- In unserem Test mit 500 Produkten, bei dem der Scraper pro Durchlauf zehnmal abgebrochen wurde: Der naive Scraper endete mit 1.286 bis 2.165 Zeilen und bis zu 4,3-mal so vielen Requests; der idempotente endete mit genau 500 korrekten Zeilen und höchstens 3 wiederholten Requests.
Warum Neustarts Duplikate erzeugen
Die übliche erste Version eines Scrapers sieht so aus: bei Seite eins beginnen, der Paginierung folgen, für jedes Produkt eine Zeile einfügen, dabei fortlaufend committen. Das funktioniert einwandfrei, bis es unterbrochen wird. Beim Neustart hat er keine Erinnerung daran, wie weit er gekommen ist, also beginnt er erneut, und jedes Produkt, das er bereits gespeichert hat, wird ein zweites Mal mit einer neuen auto-inkrementierten ID eingefügt. Bricht man ihn ein paar Mal ab, enthält die Tabelle mehrere Kopien der meisten Produkte, ohne dass erkennbar ist, welche davon aktuell ist.
Nachträgliches Deduplizieren ist möglich, und Entity Resolution behandelt das, aber es kuriert nur das Symptom. Die Duplikate kosten auch Geld, bevor sie Genauigkeit kosten: Jede wiederholte Seite ist Bandbreite, die doppelt bezahlt wird, was sich direkt auf die Kosten pro sauberem Datensatz auswirkt.
Vier Entscheidungen, die einen Scraper idempotent machen
1. Datensatz-IDs aus den Daten ableiten
Die ID eines Datensatzes sollte sich daraus ergeben, was der Datensatz ist: die Quelle plus ihr natürlicher Schlüssel, etwa eine SKU, eine Listing-ID oder eine kanonische URL. Hasht man sie zusammen, erhält dasselbe Produkt bei jedem Durchlauf auf jeder Maschine immer dieselbe ID. Erneutes Schreiben wird zu einem Upsert: Existiert der Datensatz und ist er unverändert, passiert nichts; hat er sich geändert, wird er aktualisiert und der Änderungszeitpunkt vermerkt.
2. Die Frontier dauerhaft halten
Die Liste der zu besuchenden URLs und welche davon erledigt sind, gehört in die Datenbank, nicht in den Arbeitsspeicher. Das Hinzufügen einer bereits bekannten URL muss ein No-Op sein, sodass das erneute Entdecken von Links auf einer bereits verarbeiteten Listing-Seite harmlos ist.
3. Ergebnis und Fortschritt zusammen committen
Der gefährliche Moment liegt zwischen dem Speichern eines Ergebnisses und dem Festhalten, dass die URL fertig ist. Stirbt der Prozess nach dem einen und vor dem anderen, verliert ein Neustart entweder das Ergebnis oder wiederholt es. Beides in einer Datenbanktransaktion zu erledigen, beseitigt die Lücke: Entweder wird der Datensatz gespeichert und die URL als erledigt markiert, oder keins von beidem geschieht und die URL wird einfach erneut abgerufen.
4. Arbeit leasen statt sperren
Ein Worker beansprucht eine URL für eine begrenzte Zeit. Ist er fertig, wird die URL als erledigt markiert. Stürzt er ab, läuft der Lease ab, und ein anderer Worker, oder der neu gestartete, übernimmt die URL. Niemand muss den Absturz bemerken, damit die Arbeit wiederhergestellt wird.
Der Code
Das folgende Modul implementiert alle vier Punkte mit SQLite und requests. SQLite hält das Beispiel in sich geschlossen; dasselbe Design überträgt sich auf PostgreSQL oder jede Datenbank mit Transaktionen und Upserts.
import hashlib
import json
import re
import sqlite3
import time
import requests
SCHEMA = """
CREATE TABLE IF NOT EXISTS frontier (
url TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending', -- pending, leased, done
leased_until REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS records (
record_id TEXT PRIMARY KEY, -- derived from the source and its natural key
url TEXT NOT NULL,
content_hash TEXT NOT NULL,
data TEXT NOT NULL,
first_seen REAL NOT NULL,
last_changed REAL NOT NULL
);
"""
def open_db(path):
db = sqlite3.connect(path, isolation_level=None) # explicit transactions below
db.execute("PRAGMA journal_mode=WAL")
db.executescript(SCHEMA)
return db
def record_id(source, natural_key):
"""The same item always gets the same ID, however many times it is scraped."""
return hashlib.sha256(f"{source}:{natural_key}".encode()).hexdigest()[:16]
def enqueue(db, urls):
"""Adding a URL that is already known is a no-op, so re-discovering links is harmless."""
db.executemany("INSERT OR IGNORE INTO frontier (url) VALUES (?)", [(u,) for u in urls])
def lease(db, lease_seconds=60):
"""Claim one URL. A lease left behind by a crashed worker expires and the URL is retried."""
now = time.time()
row = db.execute(
"UPDATE frontier SET status = 'leased', leased_until = ? WHERE url = ("
" SELECT url FROM frontier WHERE status = 'pending' OR (status = 'leased' AND leased_until < ?) LIMIT 1"
") RETURNING url", (now + lease_seconds, now)).fetchone()
return row[0] if row else None
def complete(db, url, new_urls=(), record=None):
"""Write the result and mark the URL done in one transaction: either both happen or neither does."""
db.execute("BEGIN IMMEDIATE")
try:
enqueue(db, new_urls)
if record:
now = time.time()
payload = json.dumps(record["data"], sort_keys=True)
digest = hashlib.sha256(payload.encode()).hexdigest()
db.execute(
"INSERT INTO records (record_id, url, content_hash, data, first_seen, last_changed) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(record_id) DO UPDATE SET data = excluded.data, content_hash = excluded.content_hash, "
"last_changed = excluded.last_changed WHERE records.content_hash != excluded.content_hash",
(record["id"], url, digest, payload, now, now))
db.execute("UPDATE frontier SET status = 'done' WHERE url = ?", (url,))
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
def crawl(db, base, session=None, lease_seconds=60):
session = session or requests.Session()
enqueue(db, [f"{base}/list/0"])
while True:
url = lease(db, lease_seconds)
if url is None:
if db.execute("SELECT 1 FROM frontier WHERE status != 'done' LIMIT 1").fetchone():
time.sleep(1) # a lease is still held, perhaps by a worker that crashed; wait for it to expire
continue
return
html = session.get(url, timeout=30).text
if "/list/" in url:
links = [base + href for href in re.findall(r'href="(/(?:product|list)/\d+)"', html)]
complete(db, url, new_urls=links)
else:
sku = re.search(r'class="sku">([^<]+)<', html).group(1)
price = re.search(r'class="price">([^<]+)<', html).group(1)
complete(db, url, record={"id": record_id("example-shop", sku), "data": {"sku": sku, "price": price}})
Die Funktion crawl ist spezifisch für unsere Testseite, mit 25 Listing-Seiten, die auf 500 Produktseiten verlinken. Alles darüber ist wiederverwendbar. Zwei Details übersieht man leicht:
- Der Upsert schreibt nur, wenn sich der Inhalt geändert hat. Die
WHERE-Klausel beim Update bei Konflikt lässt einen unveränderten Datensatz in Ruhe, sodasslast_changeddas bedeutet, was es sagt, und Änderungserkennung antreiben kann. - Die Schleife stoppt nicht bei einer leeren Queue. Unsere erste Version endete, wenn keine URL geleast werden konnte, was eine Seite unfertig zurückließ, wann immer ein Absturz einen ausstehenden Lease hinterlassen hatte. Die Schleife wartet jetzt, bis jede URL erledigt ist.
Zehnmal abgebrochen
Wir betrieben eine lokale Testseite mit 25 Listing-Seiten und 500 Produktseiten, sodass ein vollständiger Crawl genau 525 Requests benötigt. Jeder Durchlauf startete den Scraper, brach ihn mit SIGKILL zu einem zufälligen Zeitpunkt zwischen 0,2 und 1 Sekunde ab, und tat das zehnmal, bevor man ihn fertig laufen ließ. Wir führten den obigen idempotenten Scraper und den zuvor beschriebenen naiven jeweils fünfmal mit denselben Abbruchzeitpunkten aus, mit einem 3-Sekunden-Lease für die idempotente Version.
| Durchlauf | Naiv: gespeicherte Zeilen | Naiv: Requests | Idempotent: gespeicherte Zeilen | Idempotent: Requests |
|---|---|---|---|---|
| 1 | 2.165 | 2.283 | 500 | 525 |
| 2 | 1.875 | 1.977 | 500 | 526 |
| 3 | 1.862 | 1.967 | 500 | 526 |
| 4 | 1.455 | 1.538 | 500 | 526 |
| 5 | 1.286 | 1.361 | 500 | 528 |
Beide Versionen speicherten schließlich alle 500 Produkte, weil jede ihren letzten Durchlauf beendete. Der Unterschied liegt in allem anderen. Der naive Scraper speicherte zwischen 2,6 und 4,3 Zeilen pro Produkt und stellte zwischen 2,6- und 4,3-mal so viele Requests wie nötig. Der idempotente speicherte genau eine Zeile pro Produkt, mit jedem Wert passend zur Quellseite, und wiederholte über zehn Abstürze hinweg höchstens drei Requests: einen für jeden Abbruch, der traf, während eine Seite gerade in Bearbeitung war. Er war außerdem in jedem Durchlauf schneller fertig, zwischen 6,8 und 8,3 Sekunden gegenüber 9,2 bis 10,8, obwohl er manchmal darauf wartete, dass der Lease eines abgestürzten Workers ablief.
Jenseits der Datenbank
Datensätze sind nicht das Einzige, was ein Scraper mehr als einmal erledigt. Dasselbe Prinzip gilt für jeden Seiteneffekt:
- Dateien. Benennen Sie heruntergeladene Bilder und Dokumente nach einem Hash ihres Inhalts oder der Datensatz-ID, sodass ein wiederholter Download überschreibt statt eine Kopie hinzuzufügen.
- Nachrichten und Webhooks. Senden Sie einen Idempotenzschlüssel, der aus dem Datensatz und seiner Version abgeleitet ist, sodass ein Consumer eine bereits verarbeitete Nachricht ignorieren kann.
- Zähler und Aggregate. Berechnen Sie sie aus gespeicherten Datensätzen neu, statt sie bei jedem Abruf zu inkrementieren, sonst bläht ein Neustart sie auf.
- Rohe Antworten. Wenn Sie rohe Antworten archivieren, fügt ein wiederholter Abruf eine zweite Erfassung hinzu, was harmlos und sogar nützlich ist, solange die extrahierten Datensätze idempotent bleiben.
Praktische Regeln
- Wählen Sie den natürlichen Schlüssel sorgfältig. Er muss an der Quelle stabil sein: eine SKU oder Listing-ID, nicht eine Position auf der Seite oder eine URL, die Tracking-Parameter trägt.
- Machen Sie Retries zuerst sicher, dann häufig. Sobald Schreibvorgänge idempotent sind, können Retry und Backoff großzügig sein, ohne die Daten zu verschmutzen.
- Bemessen Sie Leases nach der langsamsten Seite. Ein Lease, der kürzer ist als ein langsamer Abruf, lässt zwei Worker dieselbe URL verarbeiten; hier harmlos, aber verschwendete Bandbreite.
- Beobachten Sie die Wiederholungsrate. Requests pro gespeichertem Datensatz ist eine günstige Gesundheitskennzahl; ein Anstieg bedeutet Abstürze oder Lease-Probleme und gehört neben die anderen in das Überwachen einer Scraping-Pipeline.
Fazit
Ein Scraper, der nicht sicher neu gestartet werden kann, wird irgendwann seine eigenen Daten beschädigen, und zwar leise. Die Lösung ist nicht vorsichtigerer Betrieb, sondern ein Design, das Neustarts langweilig macht: IDs, die aus den Daten abgeleitet sind, eine dauerhafte Frontier, Ergebnisse und Fortschritt gemeinsam committed, und Leases, die ablaufen.
In unserem Test verwandelten diese vier Entscheidungen zehn Abstürze von bis zu 4,3-mal so vielen Zeilen und Requests in genau die richtigen 500 Datensätze und drei wiederholte Requests.