SQLite untuk queue worker masuk akal jika kebutuhan Anda sederhana: deployment minim, dependensi sedikit, dan beban kerja masih terkendali. Namun SQLite bukan message broker. Ia adalah database embedded dengan model konkurensi yang berbeda dari Redis, RabbitMQ, atau Postgres, sehingga pola implementasinya harus disesuaikan.
Masalah utama biasanya bukan “bisa atau tidak”, melainkan failure mode: lock contention, writer tunggal, job di-claim ganda, retry yang liar, dan job gagal yang tidak terlihat. Jika Anda memahami locking SQLite, mengaktifkan WAL, memberi busy timeout, dan memakai pola claim job yang aman, SQLite bisa menjadi backend queue yang cukup andal untuk aplikasi kecil hingga menengah.
Mengapa SQLite menarik untuk queue worker
Alasannya praktis:
- Tidak perlu server database terpisah untuk workload kecil.
- Mudah dibundel ke aplikasi, cocok untuk single-node service, cron worker, atau tool internal.
- Operasional sederhana: backup mudah, migrasi ringan, dan latensi lokal rendah.
Tetapi Anda harus menerima trade-off berikut:
- Satu writer pada satu waktu. Banyak worker yang aktif menulis akan saling berebut lock.
- Bukan queue broker. Tidak ada push delivery, consumer group, ack protocol, atau dead-letter queue bawaan.
- Skala horizontal terbatas, terutama jika file database berada di storage jaringan atau diakses lintas host.
Aturan praktis: jika queue Anda terutama dipakai untuk background task ringan pada satu mesin, SQLite layak dipertimbangkan. Jika Anda butuh throughput tinggi, banyak worker paralel lintas mesin, atau semantik delivery yang lebih kuat, siapkan jalur migrasi sejak awal.
Model locking SQLite yang perlu dipahami
Banyak masalah implementasi queue di SQLite berasal dari asumsi yang salah tentang konkurensi. SQLite mengizinkan banyak pembaca, tetapi penulisan tetap diserialkan. Artinya, beberapa transaksi tulis tidak akan benar-benar berjalan bersamaan; mereka akan menunggu lock atau gagal dengan kondisi database is locked bila timeout habis.
Writer tunggal dan dampaknya pada worker
Queue worker menulis cukup sering:
- meng-claim job,
- mengubah status menjadi running,
- menyimpan hasil, error, atau retry_count,
- melepaskan lease ketika selesai atau gagal.
Setiap perubahan itu adalah write. Bila Anda menjalankan banyak worker agresif yang polling terus-menerus, kontensi akan meningkat cepat. Masalah ini biasanya bukan karena satu job berat, tetapi karena terlalu banyak transaksi kecil yang saling berebut lock.
WAL mode: hampir selalu pilihan yang lebih baik
Untuk queue worker, WAL (Write-Ahead Logging) umumnya lebih cocok daripada mode journal default karena pembacaan bisa tetap berjalan saat ada penulisan. Ini membantu saat worker membaca kandidat job sementara worker lain sedang menyelesaikan update status.
Konfigurasi awal yang lazim:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;Catatan penting:
journal_mode=WALmeningkatkan konkurensi baca-tulis pada banyak kasus praktis.busy_timeoutmemberi waktu bagi koneksi untuk menunggu lock alih-alih langsung gagal.synchronous=NORMALsering dipilih untuk kompromi performa dan durabilitas, tetapi kebijakan ini tetap harus disesuaikan dengan toleransi kehilangan data Anda.
WAL bukan obat untuk semua hal. Ia tidak mengubah fakta bahwa penulisan tetap diserialkan. Jika bottleneck Anda adalah terlalu banyak write kecil dari terlalu banyak worker, WAL hanya membantu sebagian.
Busy timeout bukan retry aplikasi
busy_timeout hanya mengatur bagaimana SQLite menunggu lock internal. Ini berbeda dari retry job pada level aplikasi. Keduanya perlu:
- busy timeout: untuk konflik lock database jangka pendek.
- retry job: untuk kegagalan bisnis seperti API eksternal timeout, dependency tidak tersedia, atau error sementara lainnya.
Kesalahan umum adalah mengandalkan retry loop di aplikasi untuk error lock, tetapi membiarkan koneksi SQLite tanpa timeout yang wajar. Hasilnya adalah ledakan retry yang justru memperparah kontensi.
Skema tabel job yang praktis
Skema queue di SQLite tidak perlu rumit, tetapi harus mendukung tiga hal: claim aman, retry terjadwal, dan visibilitas kegagalan.
CREATE TABLE jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
queue TEXT NOT NULL DEFAULT 'default',
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
priority INTEGER NOT NULL DEFAULT 0,
available_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
claimed_at DATETIME,
lease_until DATETIME,
attempts INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
last_error TEXT,
idempotency_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME
);
CREATE INDEX idx_jobs_claim
ON jobs(queue, status, available_at, priority DESC, id);
CREATE INDEX idx_jobs_lease
ON jobs(status, lease_until);
CREATE UNIQUE INDEX idx_jobs_idempotency_key
ON jobs(idempotency_key)
WHERE idempotency_key IS NOT NULL;Penjelasan kolom penting:
status: misalnyapending,running,succeeded,failed.available_at: kapan job boleh diambil. Ini penting untuk delayed retry.lease_until: batas waktu claim aktif. Jika worker mati, job bisa direbut kembali setelah lease kedaluwarsa.attemptsdanmax_attempts: kontrol retry.last_error: visibilitas kegagalan untuk debugging.idempotency_key: mencegah enqueue duplikat atau membantu eksekusi aman.
Jika payload besar atau sering berubah, pertimbangkan hanya menyimpan referensi ke data utama. Menaruh payload JSON besar di setiap job memperbesar I/O dan memperlambat polling.
Pola claim job yang aman di SQLite
Bagian tersulit bukan insert job, melainkan memastikan dua worker tidak memproses job yang sama secara bersamaan. Pola aman biasanya berbasis transaksi singkat dan lease.
Tujuan pola claim
- Satu job hanya di-claim oleh satu worker pada satu waktu.
- Jika worker crash, job tidak hilang permanen.
- Transaksi claim sesingkat mungkin agar lock tidak lama ditahan.
Alur claim yang direkomendasikan
- Mulai transaksi write singkat.
- Pilih satu job yang
pendingdanavailable_at <= now, atau jobrunningdenganlease_untilyang sudah lewat jika Anda mendukung re-claim. - Update baris tersebut menjadi
running, isiclaimed_at, setlease_until, dan naikkanattempts. - Commit transaksi.
- Proses job di luar transaksi.
Contoh SQL yang realistis perlu disesuaikan dengan binding dan kemampuan driver, tetapi prinsipnya seperti ini:
BEGIN IMMEDIATE;
SELECT id
FROM jobs
WHERE queue = ?
AND status = 'pending'
AND available_at <= CURRENT_TIMESTAMP
ORDER BY priority DESC, id ASC
LIMIT 1;
-- jika ada id, claim job itu
UPDATE jobs
SET status = 'running',
claimed_at = CURRENT_TIMESTAMP,
lease_until = DATETIME(CURRENT_TIMESTAMP, '+60 seconds'),
attempts = attempts + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND status = 'pending';
COMMIT;Perhatikan bagian WHERE id = ? AND status = 'pending'. Ini mencegah overwrite buta. Jika worker lain lebih dulu meng-claim job yang sama, jumlah baris yang ter-update akan 0, dan worker ini harus mencoba lagi.
Mengapa transaksi harus singkat
Jangan pernah memproses job di dalam transaksi claim. Jika Anda menahan transaksi terbuka saat memanggil API eksternal atau menjalankan proses berat, Anda memperpanjang lock write dan membuat worker lain menumpuk di belakangnya.
Urutan yang benar:
- claim cepat dalam transaksi,
- kerja berat di luar transaksi,
- finalisasi cepat dalam transaksi baru.
Lease lebih aman daripada status saja
Jika Anda hanya punya status running tanpa lease_until, worker yang crash akan meninggalkan job menggantung selamanya. Dengan lease, job bisa diambil ulang setelah batas waktu lewat.
Lease timeout harus disesuaikan dengan durasi job:
- Terlalu pendek: job yang masih berjalan bisa dianggap mati lalu diproses ganda.
- Terlalu panjang: job benar-benar mati baru pulih setelah lama.
Untuk job panjang, tambahkan heartbeat yang secara berkala memperpanjang lease_until.
Retry, backoff, dan idempotency
Retry adalah fitur yang kelihatannya sederhana tetapi sering menjadi sumber duplikasi, banjir traffic, dan data tidak konsisten.
Kapan job perlu di-retry
Retry cocok untuk kegagalan sementara, misalnya:
- HTTP 502/503 dari layanan eksternal,
- timeout jaringan,
- rate limiting sementara,
- lock database jangka pendek di komponen lain.
Retry tidak cocok untuk error deterministik seperti payload tidak valid, referensi data tidak ada, atau invariant bisnis yang jelas gagal. Untuk kasus seperti itu, pindahkan job ke status failed dan buat terlihat oleh operator.
Gunakan backoff, jangan retry instan
Ketika job gagal sementara, update jadwalnya ke masa depan menggunakan available_at. Hindari retry instan yang hanya menghasilkan polling panas dan kontensi tambahan.
UPDATE jobs
SET status = CASE
WHEN attempts >= max_attempts THEN 'failed'
ELSE 'pending'
END,
available_at = CASE
WHEN attempts >= max_attempts THEN available_at
ELSE DATETIME(CURRENT_TIMESTAMP, '+30 seconds')
END,
lease_until = NULL,
last_error = ?,
updated_at = CURRENT_TIMESTAMP,
finished_at = CASE
WHEN attempts >= max_attempts THEN CURRENT_TIMESTAMP
ELSE NULL
END
WHERE id = ?;Backoff bisa bertahap, misalnya 10 detik, 30 detik, 2 menit, 5 menit. Tidak perlu terlalu pintar; yang penting konsisten dan membatasi ledakan retry.
Idempotency itu wajib untuk job yang menyentuh sistem eksternal
Dengan lease dan retry, ada kemungkinan job dieksekusi lebih dari sekali, terutama saat worker crash setelah aksi eksternal berhasil tetapi sebelum status job sempat ditandai selesai. Karena itu, handler job harus idempotent.
Contoh strategi:
- Simpan
idempotency_keyunik per aksi bisnis, misalnyainvoice:123:send-email. - Di sisi tujuan, gunakan endpoint atau tabel yang menolak eksekusi duplikat berdasarkan kunci tersebut.
- Jika tidak bisa, simpan log efek samping lokal dan cek sebelum menjalankan ulang.
Kesalahan umum adalah menganggap “status succeeded di database” cukup untuk mencegah duplikasi. Itu tidak membantu jika crash terjadi di antara efek samping eksternal dan update status lokal.
Visibilitas job gagal dan operasional harian
Queue tanpa visibilitas akan gagal diam-diam. SQLite memang sederhana, tetapi Anda tetap perlu metadata yang cukup untuk investigasi.
Minimal yang harus terlihat
- Jumlah job
pending,running,failed,succeeded. - Job yang
runningtetapi lease-nya sudah lewat. - Job dengan
attemptsmendekatimax_attempts. - Distribusi umur job: berapa lama job menunggu sebelum di-claim.
- Error terakhir per jenis job atau queue.
Query sederhana untuk inspeksi:
SELECT status, COUNT(*)
FROM jobs
GROUP BY status;
SELECT id, queue, attempts, lease_until, last_error
FROM jobs
WHERE status = 'running'
AND lease_until < CURRENT_TIMESTAMP
ORDER BY lease_until ASC;
SELECT id, queue, attempts, max_attempts, last_error
FROM jobs
WHERE status = 'failed'
ORDER BY updated_at DESC
LIMIT 50;Checklist observabilitas
- Log saat claim berhasil dan saat claim gagal karena konflik.
- Log durasi eksekusi job per tipe.
- Catat alasan retry dan jumlah attempt.
- Alarm untuk lonjakan job failed atau backlog pending.
- Alarm untuk job running yang lease-nya kedaluwarsa terlalu lama.
- Metrik sederhana: enqueue rate, processing rate, failure rate, retry rate.
Anda tidak harus memakai stack observabilitas besar. Bahkan dashboard internal kecil atau skrip SQL periodik sudah jauh lebih baik daripada tidak ada visibilitas sama sekali.
Anti-pattern umum saat memakai SQLite untuk queue worker
1. Polling terlalu agresif
Worker yang terus-menerus menjalankan query tanpa jeda saat queue kosong akan membebani I/O dan memicu kontensi write kecil. Gunakan jeda pendek yang adaptif, misalnya tidur lebih lama saat queue kosong dan lebih singkat saat ada backlog.
2. Menaruh pekerjaan berat di dalam transaksi
Ini anti-pattern paling merusak. Lock tertahan terlalu lama, worker lain timeout, lalu sistem terlihat “acak” padahal akar masalahnya sederhana.
3. Mengandalkan cache di sekitar queue state
Menambahkan cache atau mirror state di Redis untuk “mempercepat” antrian SQLite sering justru menambah inkonsistensi. Masalahnya bukan sekadar cache invalidation, tetapi perbedaan sumber kebenaran:
- job ada di SQLite tetapi cache bilang tidak ada,
- job sudah di-claim tetapi cache belum terbarui,
- status gagal tersimpan di DB tetapi queue index di cache tidak sinkron.
Jika SQLite adalah sumber kebenaran queue, usahakan state inti tetap hanya di SQLite. Tambahkan cache hanya untuk data turunan yang boleh basi, misalnya dashboard non-kritis, bukan untuk keputusan claim/ack.
4. Banyak worker untuk workload yang sebenarnya write-heavy
Karena writer tunggal, menambah worker tidak selalu meningkatkan throughput. Pada beban tertentu, lebih banyak worker malah berarti lebih banyak tabrakan lock, lebih banyak retry, dan throughput efektif turun.
5. Menyimpan payload sangat besar
Queue sebaiknya menyimpan instruksi kerja, bukan objek raksasa. Payload besar memperbesar file database, memperlambat checkpoint WAL, dan membuat polling kurang efisien.
Alur worker yang direkomendasikan
Implementasi yang stabil biasanya mengikuti pola berikut:
- Inisialisasi koneksi SQLite dengan WAL dan busy timeout.
- Loop worker mengambil satu job per iterasi atau batch kecil.
- Lakukan claim dalam transaksi singkat.
- Jika tidak ada job, tidur sejenak dengan backoff polling.
- Proses job di luar transaksi.
- Jika sukses, tandai
succeededdan kosongkan lease. - Jika gagal sementara, set kembali
pendingdenganavailable_atbaru. - Jika gagal permanen atau attempts habis, tandai
failed. - Untuk job lama, kirim heartbeat bila perlu.
Pseudocode ringkas:
loop:
job = claim_one_job()
if no job:
sleep(backoff_idle)
continue
try:
process(job)
mark_succeeded(job.id)
catch transient_error as e:
reschedule_with_backoff(job.id, e)
catch permanent_error as e:
mark_failed(job.id, e)
catch unknown_error as e:
reschedule_or_fail_by_policy(job.id, e)Jaga agar setiap fungsi update status membuka transaksi sesingkat mungkin. Jika job punya banyak langkah, lebih aman pecah menjadi beberapa job kecil daripada satu job panjang yang sulit diobservasi dan sulit di-retry sebagian.
Kapan SQLite masih tepat, dan kapan harus pindah
SQLite masih tepat jika:
- Aplikasi berjalan pada satu host atau satu node aktif utama.
- Volume job moderat dan tidak didominasi banyak write paralel.
- Anda ingin operasional sesederhana mungkin.
- Queue dipakai untuk tugas internal: kirim email, sinkronisasi ringan, generate report, webhook retry sederhana.
- Anda bisa menerima model at-least-once delivery dengan idempotency yang baik.
Pertimbangkan pindah ke Redis, RabbitMQ, atau Postgres jika:
- Backlog sering menumpuk karena kontensi write.
- Anda butuh banyak worker paralel lintas mesin.
- Anda memerlukan fitur broker yang lebih matang: routing, priority yang kompleks, ack semantics, dead-letter queue, delayed delivery yang kaya, atau consumer isolation.
- Waktu pemulihan dari worker crash harus lebih presisi dan dapat dipantau dengan baik.
- Observabilitas, operasional, dan SLA queue menjadi komponen bisnis inti.
Pilih yang mana?
- Redis: cocok untuk queue cepat dan sederhana, terutama jika ekosistem aplikasi Anda sudah dekat dengan Redis. Perlu disiplin pada durability dan semantik delivery sesuai alat yang dipakai.
- RabbitMQ: cocok bila Anda benar-benar butuh broker pesan dengan routing dan ack yang kuat.
- Postgres: menarik jika Anda ingin tetap memakai database relasional dengan tooling SQL yang kuat, sering memakai pola seperti job table, dan membutuhkan konkurensi tulis yang umumnya lebih longgar daripada SQLite.
Jangan pindah terlalu cepat hanya karena “queue harus pakai X”. Pindah saat ada gejala operasional nyata, bukan karena asumsi. Tetapi juga jangan memaksakan SQLite jika bottleneck-nya sudah jelas struktural.
Checklist implementasi sebelum produksi
- Aktifkan
journal_mode=WAL. - Set
busy_timeoutyang masuk akal. - Buat indeks untuk claim path dan lease recovery.
- Gunakan transaksi claim yang singkat.
- Tambahkan
lease_untiluntuk recovery dari crash. - Pastikan handler job idempotent.
- Implementasikan retry dengan backoff, bukan retry instan.
- Simpan
last_errordan metadata attempts. - Siapkan query/dashboard untuk backlog, failed jobs, dan expired lease.
- Uji skenario crash di tengah proses, bukan hanya jalur sukses.
Penutup
SQLite untuk queue worker adalah pilihan yang masuk akal bila Anda menginginkan sistem sederhana dan beban kerja masih sesuai dengan model writer tunggal SQLite. Kuncinya bukan meniru broker pesan, melainkan menyesuaikan desain: WAL, busy timeout, transaksi singkat, claim berbasis lease, retry terjadwal, dan idempotency.
Jika implementasinya disiplin, SQLite bisa melayani queue backend kecil hingga menengah dengan baik. Tetapi batas operasionalnya nyata. Saat kontensi write, kebutuhan paralelisme, atau kebutuhan semantik delivery naik, migrasi ke Redis, RabbitMQ, atau Postgres biasanya lebih sehat daripada menambal inkonsistensi dengan cache dan workaround tambahan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!