Eksekusi ganda pada background task Celery sering terjadi pada task berdurasi panjang saat menggunakan Redis sebagai message broker. Worker kedua tiba-tiba menjalankan task yang sama persis padahal worker pertama masih memprosesnya. Masalah ini bukan bug pada kode aplikasi, melainkan ketidaksesuaian antara durasi pemrosesan task dengan parameter visibility_timeout pada Redis broker.
Akar Masalah: Bagaimana Redis Mensimulasikan Message Acknowledgment
Redis bukan native message broker seperti RabbitMQ yang memiliki protokol AMQP dan mekanisme heartbeat-based unacknowledged message tracking. Untuk mengelola siklus hidup pesan, Celery mengimplementasikan mekanisme konfirmasi manual di atas struktur data Redis.
Ketika worker mengambil task dari antrean Redis (biasanya berupa Redis List):
- Pesan dipindahkan dari antrean utama ke set khusus bernama
unackeddengan timestamp eksekusi. - Broker memulai perhitungan mundur berdasarkan nilai
visibility_timeout. - Jika task selesai sebelum batas waktu, worker mengirimkan ACK (acknowledgment) dan menghapus pesan dari set
unacked. - Jika durasi task melampaui batas
visibility_timeoutsebelum worker mengirim ACK, broker menganggap worker tersebut telah mati (crashed). Pesan otomatis dikembalikan ke antrean utama dan diambil oleh worker lain.
Akibatnya, dua worker memproses task yang sama secara bersamaan. Fenomena ini menyebabkan duplikasi transaksi database, lonjakan beban server, dan mutasi data yang tidak konsisten.
Visibility Timeout vs Prefetch Multiplier
Banyak pengembang keliru mengira duplikasi task berkaitan dengan worker_prefetch_multiplier. Keduanya mengontrol aspek antrean yang berbeda secara mendasar:
visibility_timeout: Menentukan batas waktu broker menunggu konfirmasi (ACK) sebelum pesan yang sedang diproses dialihkan ke worker lain. Pengaturan ini berada di level broker transport.worker_prefetch_multiplier: Menentukan berapa banyak pesan yang diambil dan disimpan di memori worker per proses/thread konkurensi sebelum dieksekusi. Pengaturan ini berada di level worker lokal untuk mencegah kelaparan task (task starvation).
Mengubah worker_prefetch_multiplier = 1 hanya memastikan worker tidak menimbun antrean lokal, tetapi tidak akan mencegah Redis menjadwalkan ulang task yang durasi berjalannya melampaui batas visibilitas.
Konfigurasi Tepat di Django settings.py
Penyelesaian masalah ini membutuhkan sinkronisasi antara tiga parameter: visibility_timeout, task_time_limit, dan task_acks_late. Rumus dasarnya adalah: visibility_timeout > task_time_limit > task_soft_time_limit.
# settings.py
# 1. Pastikan visibility_timeout lebih besar dari durasi terlama yang mungkin dicapai task.
# Contoh: Task maksimal berjalan 2 jam (7200 detik), set visibility_timeout ke 2.5 jam (9000 detik).
CELERY_BROKER_TRANSPORT_OPTIONS = {
'visibility_timeout': 9000,
}
# 2. Aktifkan acks_late agar pesan hanya di-ACK setelah task SELESAI dijalankan,
# bukan saat task baru diambil oleh worker.
CELERY_TASK_ACKS_LATE = True
# 3. Tetapkan batasan waktu eksekusi agar task tidak menggantung selamanya.
CELERY_TASK_SOFT_TIME_LIMIT = 7000 # Melempar exception SoftTimeLimitExceeded untuk clean-up
CELERY_TASK_TIME_LIMIT = 7200 # Hard kill proses worker jika melampaui batas ini
# 4. Hindari penumpukan pesan berdurasi panjang pada memori worker
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
Catatan Kritis: Jika Anda mengaktifkanCELERY_TASK_ACKS_LATE = Truetanpa menaikkanvisibility_timeout, potensi dobel eksekusi justru meningkat. Pesan belum di-ACK saat proses berjalan, sehingga begitu batas visibilitas terlewati, broker langsung mengirim ulang task tersebut.
Mitigasi Arsitektural: Hindari Task Raksasa
Menaikkan visibility_timeout hingga berjam-jam membawa konsekuensi negatif: jika worker benar-benar mati akibat kegagalan sistem (misalnya OOM killer atau listrik padam), pesan baru akan dijadwalkan ulang setelah waktu timeout tersebut habis. Solusi yang lebih sehat secara arsitektural adalah mendesain task berdurasi singkat.
1. Pemecahan Job Granular (Task Chunking)
Alih-alih memproses 100.000 data dalam satu task berdurasi satu jam, pecah data menjadi batch kecil yang selesai dalam hitungan detik menggunakan Celery group atau chunks:
from celery import shared_task, group
@shared_task
def process_batch(record_ids):
# Memproses sub-kumpulan data dalam rentang waktu < 30 detik
for record_id in record_ids:
handle_record(record_id)
def dispatch_records(all_ids):
chunk_size = 500
chunks = [all_ids[i:i + chunk_size] for i in range(0, len(all_ids), chunk_size)]
job = group(process_batch.s(c) for c in chunks)
job.apply_async()
2. Idempotensi Melalui Redis Distributed Lock
Untuk task yang tidak dapat dipecah, terapkan idempotensi dengan distributed lock menggunakan Redis. Jika worker kedua menerima task duplikat, lock akan mencegah eksekusi ulang:
from contextlib import contextmanager
from django.core.cache import cache
from celery import shared_task
@contextmanager
def task_lock(lock_id, expire_seconds=3600):
# cache.add bersifat atomik; bernilai False jika key sudah ada
acquired = cache.add(lock_id, 'true', timeout=expire_seconds)
try:
yield acquired
finally:
if acquired:
cache.delete(lock_id)
@shared_task(bind=True)
def generate_monthly_report(self, report_id):
lock_key = f"celery_lock:report:{report_id}"
with task_lock(lock_key, expire_seconds=3600) as acquired:
if not acquired:
# Task sudah berjalan di worker lain, lewati eksekusi
return f"Report {report_id} is already being processed."
# Eksekusi operasi berat
build_report(report_id)
return f"Report {report_id} completed."
Kesimpulan
Dobel eksekusi Celery pada broker Redis bukan disebabkan oleh bug worker, melainkan mekanisme perlindungan Redis yang mengembalikan pesan unacked ke antrean setelah visibility_timeout terlampaui. Sinkronkan konfigurasi dengan aturan visibility_timeout > task_time_limit, gunakan task_acks_late secara cermat, dan prioritaskan arsitektur task granular dengan pengamanan distributed lock untuk sistem antrean yang andal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!