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
end

Koordinasi 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 404 atau 410 langsung 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/503 ditangani 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.