Ketika Laravel Horizon terlihat sehat tetapi worker-nya terus restart, CPU normal, namun penggunaan RAM naik perlahan sampai melewati batas, masalahnya sering bukan di Horizon itu sendiri. Dalam banyak kasus, akar masalahnya ada pada job yang tidak melepas memori, memproses data terlalu besar dalam satu proses, atau menggunakan library pihak ketiga yang menyimpan state di memori lebih lama dari yang dibutuhkan.

Gejala yang umum adalah: worker memproses beberapa job dengan lancar, lalu penggunaan memori terus meningkat, supervisor Horizon me-restart proses, kemudian siklus itu terulang. Kadang aplikasi tetap berfungsi, tetapi throughput turun, antrean menumpuk, dan server jadi tidak efisien.

Artikel ini fokus pada kasus nyata yang sering memicu kebocoran atau pembengkakan memori di worker Horizon: memuat file besar, image processing, collection besar, static state, dan library pihak ketiga yang tidak melepas resource. Kita juga akan membahas cara profiling sederhana, penggunaan batas memori, serta desain job yang lebih hemat.

Memahami Kenapa RAM Worker Horizon Bisa Terus Naik

Horizon menjalankan worker queue berbasis proses PHP yang hidup cukup lama. Ini berbeda dari request HTTP biasa yang umurnya pendek. Pada request web, begitu respons selesai, proses atau konteks eksekusi akan dibersihkan. Pada worker queue, satu proses dapat mengeksekusi banyak job secara berurutan.

Konsekuensinya, jika sebuah job:

  • menyimpan objek besar di memori,
  • membuat referensi yang tidak terlepas,
  • menggunakan cache statis di level proses,
  • atau memanggil ekstensi/library yang tidak membebaskan resource dengan baik,

maka penggunaan memori bisa bertahan sampai job berikutnya. Dari luar, ini tampak seperti memory leak, walaupun secara teknis bisa berupa kombinasi antara alokasi besar, fragmentasi memori, dan objek yang masih direferensikan.

Catatan penting: tidak semua kenaikan memori adalah leak murni. Kadang worker hanya menahan memori lebih lama dari yang diharapkan, atau PHP allocator tidak segera mengembalikan memori ke sistem operasi. Namun secara operasional, hasil akhirnya sama: worker makin gemuk dan harus di-restart.

Tanda-Tanda Job yang Bermasalah

1. Worker restart berulang pada pola yang konsisten

Jika restart terjadi setiap beberapa puluh atau ratus job, kemungkinan ada akumulasi memori antarkerja. Jika restart selalu muncul saat tipe job tertentu aktif, itu sinyal yang lebih kuat.

2. Job yang memproses file besar

Contoh klasik adalah job yang membaca file CSV, JSON, PDF, atau arsip ZIP berukuran besar langsung ke memori. Pemanggilan seperti file_get_contents() untuk file besar atau parsing seluruh file sekaligus dapat langsung menaikkan penggunaan RAM beberapa kali lipat, terutama jika data diubah lagi menjadi array atau collection.

3. Image processing

Gambar resolusi tinggi sering terlihat kecil di disk, tetapi sangat besar saat didekode di memori. Satu gambar JPEG 8 MB bisa menjadi puluhan megabyte saat dibuka untuk resize, crop, watermark, atau konversi format. Jika job memproses banyak image dalam satu proses tanpa membersihkan resource, memori worker akan cepat naik.

4. Collection besar dan eager loading berlebihan

Memuat ribuan atau jutaan record ke collection lalu melakukan transformasi berlapis adalah sumber masalah yang sangat umum. Bahkan jika query database berhasil cepat, representasi objek Eloquent dan relasi yang ikut dimuat bisa sangat mahal di memori.

5. Static state dan singleton yang menyimpan data

Properti static, cache in-memory, registry global, atau service singleton yang menampung hasil proses sebelumnya dapat bertahan sepanjang umur worker. Di aplikasi web biasa ini sering tidak terasa, tetapi pada worker long-running efeknya nyata.

6. Library pihak ketiga

Beberapa library parser dokumen, image toolkit, PDF renderer, atau SDK tertentu menggunakan ekstensi native atau mekanisme internal yang tidak selalu melepaskan resource dengan baik. Dari sisi aplikasi, Anda mungkin sudah unset() variabel, tetapi memori proses tetap tidak turun signifikan.

Cara Profiling Sederhana untuk Menemukan Job yang Leak

Anda tidak selalu membutuhkan profiler kompleks di tahap awal. Pendekatan paling praktis adalah mencatat penggunaan memori sebelum dan sesudah job, lalu mencari job yang menambah RAM secara konsisten.

Logging memori di dalam job

Tambahkan logging sederhana pada job yang dicurigai:

public function handle(): void
{
    $before = memory_get_usage(true);
    $beforePeak = memory_get_peak_usage(true);

    logger()->info('Job mulai', [
        'job' => static::class,
        'memory_before_mb' => round($before / 1024 / 1024, 2),
        'peak_before_mb' => round($beforePeak / 1024 / 1024, 2),
    ]);

    // proses utama job

    gc_collect_cycles();

    $after = memory_get_usage(true);
    $afterPeak = memory_get_peak_usage(true);

    logger()->info('Job selesai', [
        'job' => static::class,
        'memory_after_mb' => round($after / 1024 / 1024, 2),
        'peak_after_mb' => round($afterPeak / 1024 / 1024, 2),
        'delta_mb' => round(($after - $before) / 1024 / 1024, 2),
    ]);
}

Jika sebuah job selesai tetapi delta_mb terus positif dan bertambah pada eksekusi berikutnya dalam worker yang sama, itu kandidat utama.

Gunakan event queue untuk observasi terpusat

Anda juga bisa mencatat memori lewat event seperti JobProcessing dan JobProcessed agar tidak mengubah setiap job satu per satu. Simpan identitas job, nama queue, durasi, dan penggunaan memori. Dengan begitu Anda bisa melihat pola pada tipe job tertentu.

Bandingkan perilaku per jenis job

Jangan hanya melihat rata-rata semua job. Kelompokkan berdasarkan class job. Sering kali hanya satu atau dua job yang menyebabkan sebagian besar restart worker.

Perhatikan peak memory, bukan hanya memory akhir

Job yang selesai dengan penggunaan memori rendah tetap bisa berbahaya jika peak memory-nya tinggi dan sering memicu restart. Ini umum pada image processing atau parsing file besar.

Penyebab Umum dan Cara Memperbaikinya

Memuat file besar: gunakan streaming atau chunking

Kesalahan umum:

$content = file_get_contents($path);
$rows = json_decode($content, true);

Pendekatan ini memuat seluruh file ke memori. Untuk file besar, lebih aman gunakan streaming, pembacaan baris demi baris, atau pecah pekerjaan menjadi beberapa job. Untuk CSV, baca per baris. Untuk data database, gunakan chunk(), chunkById(), atau cursor() sesuai kebutuhan.

Contoh lebih hemat untuk query besar:

User::query()
    ->where('active', true)
    ->chunkById(500, function ($users) {
        foreach ($users as $user) {
            // proses ringan per user
        }
    });

Mengapa ini bekerja? Karena Anda membatasi jumlah objek yang hidup di memori pada satu waktu. chunkById() juga lebih aman untuk dataset yang berubah selama iterasi dibanding offset tradisional.

Image processing: batasi satuan kerja dan bersihkan resource

Job pengolahan gambar sebaiknya tidak memproses terlalu banyak file sekaligus. Jika satu job menerima 100 gambar lalu me-resize semuanya, memori akan melonjak. Lebih baik satu job untuk satu file, atau satu job untuk batch kecil.

Jika menggunakan library image, pastikan ada langkah eksplisit untuk menghancurkan atau menutup resource bila API library menyediakan mekanisme itu. Pada beberapa toolkit berbasis ekstensi native, unset($image) saja belum tentu cukup. Cek dokumentasi library untuk metode destroy, clear, atau close.

public function handle(): void
{
    $image = $this->imageService->open($this->path);

    try {
        $image->resize(1920, 1080);
        $image->save($this->output);
    } finally {
        if (method_exists($image, 'destroy')) {
            $image->destroy();
        }
        unset($image);
        gc_collect_cycles();
    }
}

Jika library tetap menahan memori, strategi praktisnya adalah paksa daur ulang worker lebih cepat dengan --max-jobs atau --memory.

Collection besar: hindari toArray() dan transformasi berlapis

Masalah lain yang sering muncul adalah pola seperti ini:

$items = Order::with(['customer', 'lines.product'])->get();
$data = $items->map(...)->filter(...)->groupBy(...)->toArray();

Setiap tahap bisa membuat struktur data baru. Jika dataset besar, memori membengkak. Solusinya:

  • ambil hanya kolom yang diperlukan,
  • kurangi relasi eager loading yang tidak esensial,
  • gunakan chunk atau cursor,
  • proses dan simpan hasil per batch, bukan di akhir semuanya.

Jika hasil akhir harus berupa file ekspor besar, lebih aman tulis bertahap ke storage daripada menumpuk semua data dulu di array.

Static state: hindari cache proses yang tidak terkontrol

Contoh buruk:

class ExpensiveMapper
{
    protected static array $cache = [];

    public static function map(array $payload): array
    {
        self::$cache[] = $payload;
        return $payload;
    }
}

Di worker long-running, properti statis seperti ini akan terus tumbuh. Gunakan cache eksternal bila memang dibutuhkan, atau pastikan state dibersihkan setelah job selesai. Hindari menyimpan payload besar di singleton service container tanpa alasan kuat.

Library pihak ketiga: isolasi dan ukur

Jika Anda curiga pada library tertentu, buat job uji kecil yang hanya memanggil library itu berulang kali dengan input yang sama. Bila memori tetap naik, masalah kemungkinan ada di sana. Solusi yang sering realistis:

  • jalankan tipe job tersebut di queue terpisah,
  • beri batas max-jobs lebih rendah,
  • gunakan worker dengan batas memori lebih ketat,
  • upgrade atau ganti library jika ada isu yang sudah dikenal.

Konfigurasi Proteksi: --memory, --max-jobs, dan Strategi Daur Ulang Worker

Walaupun akar masalah sebaiknya tetap diperbaiki, pembatasan umur worker adalah lapisan perlindungan yang penting.

--memory

Opsi ini menetapkan batas memori worker. Ketika batas terlewati, worker akan keluar dan supervisor akan menjalankannya kembali. Ini mencegah proses tumbuh tanpa batas.

php artisan horizon
php artisan queue:work --memory=256

Nilai tepatnya tergantung jenis job. Untuk job ringan, batas rendah membantu mendeteksi masalah lebih cepat. Untuk image processing atau file besar, batas terlalu rendah bisa menyebabkan restart terlalu sering.

--max-jobs

Opsi ini memaksa worker berhenti setelah memproses sejumlah job tertentu, meskipun batas memori belum tercapai.

php artisan queue:work --max-jobs=100

Ini sangat berguna jika ada akumulasi kecil per job yang sulit dihilangkan sepenuhnya. Misalnya satu job hanya menambah beberapa ratus kilobyte, tetapi setelah ratusan job worker tetap menjadi gemuk.

Kapan memilih memory limit vs max-jobs?

  • Gunakan --memory jika pola lonjakan RAM tidak menentu atau sangat tergantung ukuran input.
  • Gunakan --max-jobs jika ada kenaikan bertahap dan konsisten antarjob.
  • Gunakan keduanya jika Anda ingin perlindungan ganda: jumlah job dibatasi, dan lonjakan ekstrem tetap tertahan.

Dalam praktik, kombinasi keduanya sering paling stabil. Untuk job yang berat, Anda juga bisa menaruhnya di supervisor/queue terpisah agar tidak mengganggu job lain yang ringan.

Desain Job yang Lebih Hemat Memori

1. Satu job, satu tanggung jawab kecil

Jangan membuat satu job yang mengunduh file, mengekstrak, mem-parse, mentransformasi, lalu mengirim hasil sekaligus. Pecah menjadi beberapa tahap. Selain lebih hemat memori, ini juga memudahkan retry dan observabilitas.

2. Kirim referensi, bukan payload besar

Jangan serialisasi data besar ke dalam payload queue jika cukup mengirim ID, path file, atau pointer ke object storage. Payload besar memperberat serialisasi, deserialisasi, dan penggunaan memori di worker.

3. Ambil data selektif

Gunakan select() untuk mengambil kolom yang dibutuhkan. Hindari * jika tabel besar dan hanya beberapa field yang dipakai.

4. Lepaskan referensi secepat mungkin

Setelah batch selesai diproses, hilangkan referensi variabel besar, lalu panggil gc_collect_cycles() pada titik yang relevan. Ini bukan obat semua masalah, tetapi membantu pada object graph yang kompleks atau closure yang saling mereferensikan.

5. Pisahkan queue berdasarkan karakteristik beban

Queue untuk email, notifikasi, dan job ringan sebaiknya dipisah dari queue untuk image processing, import file, atau rendering dokumen. Dengan begitu Anda bisa memberi batas memori dan jumlah job yang berbeda untuk masing-masing kelas beban.

Checklist Debugging yang Praktis

  1. Identifikasi tipe job yang aktif saat RAM worker naik.
  2. Catat memory_get_usage(true) dan memory_get_peak_usage(true) sebelum/sesudah job.
  3. Periksa apakah job memuat file besar, image, atau collection besar.
  4. Tinjau penggunaan static, singleton, dan cache in-memory.
  5. Uji library pihak ketiga secara terisolasi.
  6. Gunakan chunk(), chunkById(), atau streaming untuk dataset besar.
  7. Pecah job besar menjadi unit yang lebih kecil.
  8. Terapkan --memory dan --max-jobs sebagai pengaman operasional.
  9. Pisahkan queue berat ke worker tersendiri.

Penutup

Jika worker Horizon terus naik RAM-nya lalu restart berulang, fokus investigasi sebaiknya diarahkan ke job yang berjalan di dalam worker, bukan hanya ke konfigurasi Horizon. Pola paling umum adalah pemrosesan file besar, image processing, collection besar, static state yang tertinggal, dan library pihak ketiga yang tidak melepas resource secara bersih.

Mulailah dengan profiling sederhana. Setelah job penyebab ditemukan, perbaiki desainnya: gunakan streaming atau chunking, batasi ukuran kerja per job, hindari menahan data besar terlalu lama, dan isolasi beban berat ke queue khusus. Setelah itu, gunakan --memory dan --max-jobs sebagai pagar pengaman agar worker tetap stabil di lingkungan produksi.

Pendekatan ini tidak hanya mengurangi restart berulang, tetapi juga meningkatkan throughput, mempermudah scaling, dan membuat sistem queue lebih dapat diprediksi saat beban naik.