Tugas pertama saya di kerjaan pertama bukan bikin fitur. Sistemnya sudah jalan, menerima notifikasi dari luar lewat webhook, dan ditangani langsung oleh handler di aplikasi Laravel-nya. Selama sepi nggak ada masalah.
Begitu rame, BOOM, sistemnya kewalahan dan sebagian request-nya di-drop. Request datang lebih cepat daripada yang bisa dikerjakan, dan yang nggak sempat dikerjakan hilang begitu saja — nggak ada yang menyimpannya untuk dicoba lagi nanti.
Poin Penting
- Antrian bikin pekerjaan selesai cepat; antrian nggak bikin pekerjaan selesai benar.
- Update yang datang tidak berurutan bikin nilai lama menang, dan stok gudang yang salah bikin barang keburu kebeli.
- Retry itu keadaan normal, bukan tanda sistemnya rusak — jadi yang harus dipastikan bukan "jangan dobel", tapi "dobel nggak mengubah apa pun".
- Yang menentukan siapa pemilik keputusan itu: aplikasimu, atau database.
Keputusan pertama saya, cukup mahal
Saya memutuskan membangun pub/sub-nya sendiri dari nol, pakai RabbitMQ dan pika. Alasannya waktu itu masuk akal, dan kayaknya semua orang di posisi saya akan punya alasan yang sama: kontrol. Saya mau tahu persis apa yang terjadi di antara pesan datang dan pekerjaan selesai.
Yang saya dapat: pekerjaan yang tidak selesai. Antrian, reconnect, ack, retry, urutan, semuanya harus saya tulis sendiri — dan setiap bagian yang belum saya tulis adalah bagian yang belum jalan. Saya capek sendiri, dan sistemnya nggak pernah sempat saya deploy.
Kalau kamu sedang di posisi itu, satu hal yang saya harap kamu percaya tanpa harus kena dulu: yang sedang kamu bangun bukan "sistem antrian". Yang sedang kamu bangun adalah seluruh daftar masalah yang orang lain sudah selesaikan, dan kamu akan ketemu satu per satu, di produksi, sendirian.
Yang akhirnya dipakai
Saya belajar Celery dan RabbitMQ, lalu memasangnya di depan FastAPI. Di situ semuanya jadi enak: pekerjaan dikirim ke queue, worker mengerjakan, dan kalau gagal, ya dicoba lagi. Yang tadinya berminggu-minggu jadi beberapa hari, dengan kode yang jauh lebih sedikit.
Dan di situ juga saya salah sangka sekali lagi: saya pikir masalahnya selesai. Beberapa waktu memang iya.
Gejala pertama: stok yang ketinggalan beberapa langkah
Lalu muncul laporan soal stok yang nggak sesuai. Bentuknya begini: masuk dua update untuk barang yang sama — stok 2, lalu stok 0. Hasil akhirnya harusnya 0.
Yang terjadi: 2 Worker mengerjakan update, yang 0 duluan, selesai, lalu update yang 2 datang dan menimpa angkanya. Nggak ada satu pun dari dua update itu yang salah; yang salah cuma urutannya. Hasilnya? barangnya zero stock tapi masih bisa dibeli.
Ini belum bencana. Angkanya telat, dan beberapa saat kemudian dia benar lagi.
Gejala kedua: stok 0 yang nggak ter-update, dan barang yang keburu kebeli
Yang bencana itu yang ini: stoknya sudah 0, tapi angkanya nggak ter-update, dan beberapa barang keburu kebeli orang.
Di sini saya baru sadar dua-duanya satu akar, dan akarnya bukan antriannya. Antrian bikin pekerjaan selesai cepat; antrian nggak bikin pekerjaan selesai benar. Aplikasi saya menulis nilai — "stok = 4" — dan nilai itu benar atau salah tergantung kapan dia datang dan kapan dia selesai. Selama yang menentukan cuma urutan kedatangan, sistemnya benar karena kebetulan, bukan karena dirancang begitu.
Ada bentuk kedua yang saya alami juga, di bagian sistem yang lain: dua worker menyentuh baris yang sama pada saat yang bersamaan, dan yang menulis belakangan bukan yang datanya paling baru. Gejalanya identik — angka yang tersimpan bukan angka yang benar — dan penyebabnya juga identik: angkanya dihitung di aplikasi, bukan di database.
Dan Celery menambahkan satu sifat yang memperparah semuanya: kalau sebuah task gagal, dia dicoba lagi. Retry itu fitur, bukan bug. Dia cuma jadi masalah kalau pekerjaannya nggak aman dijalankan dua kali.
Yang saya ubah
Dua perubahan, dan dua-duanya satu gagasan: kalau sebuah kejadian punya waktu, jangan biarkan yang tua menang.
1. Update stok ikut membawa waktu event-nya. Setiap update membawa timestamp dari event-nya, dan cuma update yang lebih baru dari yang sudah tersimpan yang diterapkan. Yang lebih tua ditolak. Jadi urutan kedatangan nggak lagi menentukan hasil akhir — yang menentukan adalah waktu kejadiannya.
2. Mekanismenya dipastikan idempoten. Setelah itu, retry Celery berhenti jadi hal yang bikin saya deg-degan: mengerjakan ulang pekerjaan yang sama nggak mengubah apa pun. Ini bagian yang paling sering dilewatkan orang, karena retry terasa seperti "kalau gagal, coba lagi" — padahal artinya "pekerjaan ini kemungkinan besar dijalankan lebih dari sekali, dan itu harus aman".
3. Panggilan ke storefront pakai retry, dan penerimanya idempotent. Storefront-nya backend yang beda, jadi arahnya push: kita yang memanggil mereka. Panggilan itu sering gagal, dan jawabannya adalah retry — yang cuma aman kalau sisi penerimanya bisa menerima panggilan yang sama dua kali tanpa mengubah apa pun. Untungnya di situ memang sudah begitu.
Hasilnya, dan yang saya bawa sampai sekarang
Sistemnya masih jalan sampai sekarang, sudah dua tahun, dan sudah lewat banyak update.
Soal skala, ini bagian yang angkanya saya sebut dari ingatan, bukan dari dashboard: sekitar 24 queue yang jalan, rata-rata 8 worker per queue — ada yang cuma 1 untuk notifikasi, ada yang sampai 24. Dulu saya pernah menyebut puluhan ribu task per jam; kalau semuanya dihitung, seingat saya angkanya bisa jutaan task per jam.
Tiga hal yang saya bawa dari kejadian itu:
- Alat yang benar untuk masalah yang benar. Keputusan paling mahal dalam cerita ini bukan bug stoknya. Keputusan paling mahal adalah memutuskan membangun pub/sub sendiri. Sekarang, sebelum membangun sesuatu dari nol, saya selalu mulai dengan mencari tahu siapa yang sudah selesai membangunnya.
- Retry itu keadaan normal, bukan tanda sistemnya rusak. Di sistem yang punya antrian, pekerjaan yang sama akan dikerjakan lebih dari sekali. Itu bukan kalimat soal kemungkinan — itu kepastian yang cuma menunggu waktu.
- Yang harus kamu pastikan bukan "jangan sampai dobel", tapi "dobel itu nggak mengubah apa pun". Yang pertama mustahil dijamin. Yang kedua cuma butuh satu keputusan, dan keputusan itu soal siapa yang memutuskan sebuah pekerjaan sudah pernah diterapkan — aplikasimu, atau database.
Tiga cara kejadian yang sama sampai ke kamu
Kalau saya rapikan pelajaran di atas, bentuknya jadi tiga sudut. Stok gudang tadi cuma salah satunya:
- Datang lebih dari sekali. Gateway mengirim ulang karena jawabannya nggak sampai. Ini yang saya kira satu-satunya masalah sampai saya kena bentuk berikutnya — lab-nya ada di sini, lengkap dengan angka hasil pengukuran tiap perbaikan.
- Datang dari dua jalur. Webhook yang telat, plus job rekonsiliasi pagi yang keburu menutup hari itu: dua kunci, satu fakta, dan nggak ada satu pun dari dua jalur itu yang tahu jalur satunya ada. Sudut ini juga sudah jadi lab.
- Datang tidak berurutan. Update yang datang belakangan membawa data yang lebih tua, dan yang lebih tua menang. Sudut ini yang saya alami sendiri di stok gudang.
Tiga-tiganya akarnya satu: keputusan "kejadian ini sudah pernah diterapkan atau belum" diambil berdasarkan kedatangan pesannya, di aplikasi. Selama itu belum dipindahkan ke tempat yang bisa memutuskan dengan benar — database, dengan kunci dan patokan waktu yang dimiliki faktanya sendiri — kamu akan ketemu sudut keempat, kelima, dan seterusnya.
Dua lab yang sudah ada adalah repo yang bisa kamu jalankan sendiri di laptop, dan dua-duanya punya garis akhir yang diperiksa mesin, bukan diperiksa perasaan.