Tantangan Skalabilitas pada Web Scraper Terdistribusi
Mengekstraksi ribuan hingga jutaan halaman web secara paralel membawa dua tantangan utama: menghindari duplikasi pemrosesan (waste resource) dan mencegah pemblokiran IP akibat membanjiri domain target (DDoS tidak disengaja). Tanpa kontrol yang ketat, worker pool terdistribusi akan saling berebut URL yang sama dan mengirimkan burst request yang memicu proteksi WAF atau HTTP 429 Too Many Requests.
Untuk membangun pipeline scraper terdistribusi yang stabil seperti pada platform ingestion Context.dev, arsitektur membutuhkan koordinasi state terpusat. Solusi paling efisien memanfaatkan Redis sebagai data store in-memory untuk menangani deduplikasi job, distributed rate limiting per domain target, serta locking mekanisme.
Arsitektur Antrian dan Deduplikasi URL
Setiap URL target dinormalisasi (menghapus tracking query parameter seperti utm_*, normalisasi skema https, dan trailing slash) sebelum di-hash menggunakan SHA-256. Nilai hash ini berfungsi sebagai identitas unik entri.
Deduplikasi Menggunakan Redis Set
Sebelum URL dimasukkan ke dalam antrian utama (misalnya Redis Stream atau Redis List scraper:jobs), pipeline memvalidasi keberadaan hash menggunakan perintah atomic SADD. Jika return value bernilai 0, URL sudah pernah atau sedang diproses, sehingga job langsung diabaikan.
// Pseudocode normalisasi dan deduplikasi job
function enqueueUrl(redisClient, rawUrl) {
const canonicalUrl = normalizeUrl(rawUrl);
const urlHash = crypto.createHash('sha256').update(canonicalUrl).digest('hex');
// SADD mengembalikan 1 jika elemen baru, 0 jika sudah ada
const isNew = await redisClient.sadd('scraper:seen_urls', urlHash);
if (!isNew) {
return null; // Duplikat, lewati
}
const job = {
id: urlHash,
url: canonicalUrl,
domain: new URL(canonicalUrl).hostname,
retries: 0
};
await redisClient.lpush('scraper:queue', JSON.stringify(job));
return job.id;
}Jika skala pipeline mencapai ratusan juta URL unik, struktur Redis Set dapat diganti dengan Bloom Filter (RedisBloom) untuk menghemat penggunaan RAM hingga lebih dari 80%, dengan toleransi False Positive yang dapat dikonfigurasi.
Per-Domain Rate Limiting dengan Token Bucket Lua Script
Rate limit tidak boleh diterapkan secara global; pembatasan harus dipetakan per Fully Qualified Domain Name (FQDN). Algoritma Token Bucket memastikan request ke domain tertentu berjalan merata dengan batas burst yang terukur.
Menjalankan kalkulasi token secara bertahap pada application layer rentan terhadap race condition antar worker. Logika rate limiting harus dieksekusi secara atomik di dalam Redis menggunakan skrip Lua:
-- Lua script: domain_token_bucket.lua
-- KEYS[1]: bucket key (misal: rate:example.com)
-- ARGV[1]: kapasitas maksimum (burst)
-- ARGV[2]: rate pengisian token per detik
-- ARGV[3]: timestamp saat ini (detik)
-- Return: 1 jika diizinkan, 0 jika limit habis
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'last_updated')
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])
if not tokens then
tokens = capacity
last_updated = now
else
local delta = math.max(0, now - last_updated)
tokens = math.min(capacity, tokens + delta * fill_rate)
last_updated = now
end
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated)
redis.call('EXPIRE', key, math.ceil(capacity / fill_rate) * 2)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_updated', last_updated)
return 0
endKoordinasi Worker: Distributed Lock per Domain
Selain membatasi throughput, domain tertentu melarang konkurensi (hanya 1 worker yang boleh scrap domain tersebut dalam satu waktu). Distributed Lock berbasis Redis SET resource_name my_random_value NX PX 30000 mencegah tumpang tindih eksekusi.
Implementasi Worker Pipeline
Worker menarik job dari antrian utama, memeriksa ketersediaan rate limit dan lock domain target. Jika domain sedang sibuk atau limit terlampaui, job dikembalikan ke antrian atau dipindahkan ke antrian penundaan (delayed queue).
// worker.js: Loop pemrosesan data scraper
async function processJob(job) {
const domainLockKey = `lock:domain:${job.domain}`;
const lockToken = crypto.randomUUID();
const lockAcquired = await redis.set(domainLockKey, lockToken, 'NX', 'PX', 5000);
if (!lockAcquired) {
// Domain sedang diproses oleh worker lain, tunda job
await redis.rpush('scraper:queue', JSON.stringify(job));
await sleep(500);
return;
}
try {
// Validasi rate limiter via Lua script yang dimuat via SCRIPT LOAD
const allowed = await redis.evalsha(tokenBucketSha, 1, `rate:${job.domain}`, 5, 1, Math.floor(Date.now() / 1000));
if (!allowed) {
await redis.rpush('scraper:queue', JSON.stringify(job));
await sleep(1000);
return;
}
// Ekstraksi data HTTP
const response = await fetch(job.url, { timeout: 10000 });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
const parsedData = parseContent(html);
await saveToDatabase(parsedData);
} catch (err) {
await handleFailure(job, err);
} finally {
// Rilis lock secara aman jika token cocok
const unlockScript = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
await redis.eval(unlockScript, 1, domainLockKey, lockToken);
}
}Mitigasi Kegagalan Parsing dan Retry Policy
Kesalahan jaringan bersifat sementara (transient), sedangkan perubahan struktur DOM bersifat permanen (deterministic failure). Penanganan kesalahan harus membedakan keduanya:
- Non-retryable Error: Kesalahan parsing struktur (missing selector) dan respon client
HTTP 404atau410langsung dialihkan ke Dead Letter Queue (DLQ) untuk inspeksi manual. Jangan membuang siklus worker untuk mencoba ulang HTML yang rusak. - Retryable Error: Masalah koneksi seperti timeout, koneksi terputus, atau respons
HTTP 429/503ditangani menggunakan strategi Exponential Backoff dengan Jitter.
Formula penentuan jeda waktu retry:
delay = Math.min(maxBackoff, baseInterval * Math.pow(2, attempt)) + Math.random() * jitterRange;Komponen random jitter sangat krusial dalam sistem terdistribusi untuk mencegah terjadinya thundering herd problem, yaitu kondisi di mana banyak worker secara bersamaan mencoba kembali request ke server target pada detik yang sama persis.
Observabilitas dan Troubleshooting
Operasional scraper berskala masif membutuhkan metrik performa real-time:
- Lag Antrian: Pantau ukuran kunci list/stream. Lonjakan ukuran antrian menandakan throughput ekstraksi lebih rendah daripada tingkat crawling link baru.
- Rasio Hit Rate Limiter: Jika rasio penolakan rate-limit terlalu tinggi, domain worker pool perlu dipisah menjadi antrian spesifik per domain (dedicated queues) daripada satu antrian tunggal (shared queue) untuk menghindari starvation URL dari domain non-limit.
- Stale Lock Detection: Pastikan durasi TTL pada lock kunci domain selalu mencakup kemungkinan response timeout terburuk dari network request agar tidak menimbulkan dead lock ketika worker mengalami crash tak terduga.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!