13 poin oleh GN⁺ 3 hari lalu | 1 komentar | Bagikan ke WhatsApp
  • Saat mengoperasikan job queue di Postgres, masalah klasik berupa akumulasi dead tuple dan table bloat yang ditimbulkannya dapat menurunkan kinerja; artikel ini merangkum penyebab dan cara mengatasinya
  • Tabel queue umumnya memiliki sebagian besar baris yang berputar singkat dalam pola insert-read-delete, sehingga ukurannya tampak tetap tetapi throughput kumulatifnya sangat besar
  • Dalam struktur MVCC Postgres, baris yang dihapus tidak langsung dibuang, melainkan tersisa sebagai dead tuple yang harus dibersihkan; tugas ini ditangani oleh autovacuum
  • Jika transaksi yang berjalan lama atau query analitik yang saling overlap menahan MVCC horizon, autovacuum tidak bisa membersihkan dead tuple sehingga performa queue menurun
  • Fitur Traffic Control milik PlanetScale (bagian dari ekstensi Insights) ditawarkan sebagai cara praktis untuk mengatasi masalah ini lewat pembatasan resource per kelas query

Karakteristik workload queue

  • Ciri khas tabel queue adalah sebagian besar barisnya bersifat sementara (transient) — diinsert, dibaca satu kali, lalu dihapus
  • Ukuran tabel nyaris tetap, tetapi throughput kumulatifnya sangat besar
  • Keuntungan utama menaruh job queue di Postgres adalah memungkinkan sinkronisasi status job dan logika DB lain dalam transaksi yang sama
    • jika job gagal, seluruh transaksi akan di-rollback
    • jika memakai layanan queue eksternal, sinkronisasi dengan status transaksi aplikasi menjadi lebih rumit

Contoh tabel queue dan cara kerja worker

  • Skema dasar yang ditunjukkan dalam artikel
CREATE TABLE jobs (  
  id BIGSERIAL PRIMARY KEY,  
  run_at TIMESTAMPTZ DEFAULT now(),  
  status TEXT DEFAULT 'pending',  
  payload JSONB  
);  
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';  
  • Worker membuka transaksi dan mengunci pending job tertua dengan FOR UPDATE SKIP LOCKED untuk mencegah pemrosesan ganda
  • Jika pekerjaan berhasil, lakukan DELETE lalu COMMIT; jika gagal, transaksi di-rollback sehingga baris itu kembali terlihat bagi worker lain
  • Transaksi ini harus dijaga sesingkat mungkin — makin lama terbuka, makin besar hambatannya terhadap vacuum (contoh di artikel memakai worker sub-milidetik)

Bukan performa dasarnya yang jadi masalah

  • Sudah terdokumentasi bahwa Postgres mampu menangani job queue berskala besar, jadi kapabilitas dasarnya bukan masalah
  • Masalah sesungguhnya adalah hidup berdampingan dengan workload lain yang bersaing di DB yang sama
  • Kesehatan tabel queue tidak hanya ditentukan oleh konfigurasinya sendiri, tetapi juga oleh perilaku semua transaksi yang berjalan di instance Postgres yang sama
  • Artikel berfokus pada trafik query yang saling berebut di primary (dampak replica dan replication slot dibahas terpisah)

Masalah sebenarnya: pembersihan dead tuple

  • Postgres memakai MVCC untuk mempertahankan beberapa versi dari baris yang sama — baris yang dihapus tidak langsung dibuang, tetapi hanya ditandai terhapus dan menjadi tidak terlihat bagi transaksi baru
  • Baris yang tersisa seperti ini disebut dead tuple dan dibersihkan lewat operasi vacuum
  • Dead tuple tidak muncul di hasil SELECT, tetapi tetap menimbulkan biaya
    • Sequential scan: executor membaca dead tuple dari heap page, mengecek visibilitasnya, lalu membuangnya
    • Index scan (seperti pada queue dengan ORDER BY run_at LIMIT 1): indeks B-tree menumpuk referensi ke dead tuple, sehingga pemindaian ikut melewati entri yang menunjuk ke baris yang sudah tak terlihat
  • Setiap dead index entry memicu I/O tambahan; tidak terlihat oleh aplikasi, tetapi biayanya bisa meningkat tajam seiring jumlah dead tuple
  • Siklus pembersihan ditentukan oleh autovacuum_naptime (default 1 menit), sedangkan apakah vacuum dijalankan bergantung pada autovacuum_vacuum_threshold dan autovacuum_vacuum_scale_factor

Mekanisme internal dead tuple

  • Ada 3 metadata baris yang penting
    • ctid: lokasi fisik tuple di heap (page, offset)
    • xmin: ID transaksi (XID) yang menginsert baris ini
    • xmax: ID transaksi yang menghapus/mengunci baris ini; 0 berarti belum ditandai terhapus
  • Saat mengambil 3 pending job, executor bisa saja harus memindai 6 dead tuple yang sebelumnya sudah dihapus lalu hanya mengembalikan 3 baris
  • Pada indeks juga sama: jika leaf entry menunjuk ke heap tuple yang sudah dead, kerja sia-sia saat scan akan terus menumpuk
  • Jika DB lebih lambat membersihkan daripada menghasilkan dead tuple, sistem masuk ke lintasan kegagalan
  • Cluster Postgres yang dituning dengan baik dapat menangani throughput queue hingga puluhan ribu per detik

Saat autovacuum menjadi tidak berdaya

  • Penyebab utama autovacuum gagal membersihkan dead tuple
    • table lock tertentu menghalangi cleanup
    • konfigurasi autovacuum yang tidak tepat
    • paling sering, transaksi aktif mencegah reclaim dead tuple
  • Postgres tidak akan mem-vacuum dead tuple yang masih mungkin terlihat oleh transaksi aktif
    • transaksi aktif tertua menentukan cutoff → MVCC horizon
    • sampai transaksi itu selesai, semua dead tuple setelah snapshot tersebut harus dipertahankan
  • Satu transaksi tunggal berdurasi 2 menit bisa mengunci horizon selama 2 menit
  • Pola kegagalan yang sama juga bisa timbul dari query berdurasi menengah yang saling overlap
    • contoh: 3 query analitik masing-masing 40 detik dijalankan selang-seling tiap 20 detik; tiap query memang tidak timeout, tetapi selalu ada satu yang aktif sehingga horizon tidak pernah maju
  • Dalam filosofi “Just use Postgres” yang menaruh banyak workload di satu DB, masalahnya bukan pemrosesan job cepat itu sendiri, melainkan query lambat yang overlap sehingga pembersihan dead tuple tertunda

Alat yang ada dan keterbatasannya

  • Opsi tuning autovacuum: autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
  • Timeout untuk membatasi query lama
    • statement_timeout (Postgres 7.3): menghentikan statement SQL individual yang melewati batas waktu
    • idle_in_transaction_session_timeout (9.6): menghentikan sesi yang terlalu lama idle di dalam transaksi
    • transaction_timeout (17.0): menghentikan transaksi aktif maupun nonaktif yang melampaui waktu yang ditentukan
  • Timeout ini hanya menargetkan durasi query tunggal, bukan concurrency atau biaya eksekusi, sehingga tidak cocok untuk menghentikan workload yang terus-menerus menahan MVCC horizon
  • Yang dibutuhkan adalah pemisahan per kelas trafik, sehingga workload prioritas tinggi tetap berjalan sementara tingkat penggunaan resource workload prioritas rendah dapat diatur

Database Traffic Control™

  • Fitur ini merupakan bagian dari ekstensi Insights yang dikembangkan PlanetScale dan khusus untuk PlanetScale Postgres
  • Digunakan untuk mengontrol performa dan penggunaan resource query individual secara rinci
  • Dengan Resource Budget, query target diberi batas resource → dapat diblokir saat melampaui batas
  • Strategi penyelesaiannya adalah membatasi jumlah dan frekuensi query lambat yang overlap, agar autovacuum punya ruang untuk membersihkan dead tuple dengan laju yang memadai
  • Query yang diblokir tidak ditolak permanen, tetapi harus dicoba ulang, sehingga logika retry di aplikasi menjadi penting
  • Pendekatannya adalah meratakan kecepatan eksekusi sambil mempertahankan total volume pekerjaan

Konfigurasi demo dan latar belakang

  • Ide artikel ini berasal dari blog Brandur Leach tahun 2015 berjudul "Postgres Job Queues & Failure By MVCC"
    • mencatat failure mode fatal pada job queue berbasis Postgres
    • menyertakan test bench yang membuktikan bagaimana transaksi yang tidak ditutup menahan MVCC horizon dan mencegah cleanup
  • Test bench aslinya tersedia di brandur/que-degradation-test

Mereproduksi masalah (berdasarkan Postgres 18)

  • Pengujian asli berbasis Ruby + gem Que v0.x + Postgres 9.4
  • Penulis menulis ulang dengan TypeScript + Bun untuk mengisolasi dan memverifikasi perilaku pada level SQL
  • Mempertahankan pola recursive CTE yang sama seperti Que, schema yang sama, producer rate, durasi kerja, jumlah worker, dan pola long-runner yang sama
  • Dijalankan di cluster PlanetScale PS-5 (mulai dari $5/bulan)
  • Hasilnya: penurunan performa yang terlihat, tetapi masih terkendali
    • pengujian asli mendorong DB ke death spiral dalam 15 menit, tetapi pada PS-5 worker queue tetap mendekati 0 selama 15 menit
    • namun dead tuple terus meningkat secara linear, menandakan masalah yang sama akan muncul lagi jika durasinya diperpanjang
    • berkat perbaikan pembersihan indeks B-tree (bottom-up deletion untuk version churn, penghapusan dead index tuple berbasis scan, dan lain-lain), masalah ini berkurang tetapi belum hilang

Upaya perbaikan: SKIP LOCKED + batch processing

  • Ada 2 perbaikan modern yang belum ada pada 2015
    1. FOR UPDATE SKIP LOCKED — mengganti seluruh recursive CTE dengan satu SELECT, sambil melewati baris yang sudah dikunci worker lain
    2. Batch processing (10 job per transaksi) — memproses 10 job dalam sekali lock acquisition, sehingga biaya index scan bisa tersebar
  • Kondisi tetap sama: worker 8, producer 50 job/detik, work 10ms, long-runner mulai setelah 45 detik
  • Hasil utama
Metrik original (recursive CTE) enhanced (SKIP LOCKED + batch)
Baseline lock time 2–3ms 1.3–3.0ms
End lock time (typical) 10–34ms 9–29ms
Worst spike 84.5ms (dead tuple 33k) 180ms (dead tuple 24k)
Queue depth 0–100 (oscillating) 0 (sebagian besar waktu)
Dead tuples at end 42,400 42,450
Throughput ~89/s ~50/s
  • Kurva degradasinya hampir sama — keduanya memindai indeks B-tree yang sama dan menemui dead tuple yang sama
  • Perbedaan throughput bukan karena strategi lock, melainkan karena desain pengujian (worker CTE mengambil job lebih cepat dari producer, sedangkan worker batch mengosongkan queue lalu tidur backoff)
  • Kesimpulannya: desain queue yang 10 tahun lalu bisa mematikan DB dalam 15 menit sekarang bisa bertahan lebih lama, tetapi masalah dasarnya tetap ada — jika dinaikkan ke 500 job/detik, masalah akan muncul lebih cepat

Menyelesaikannya dengan Traffic Control

  • Resource Budget menyediakan kontrol berikut
    • Server share & burst limit: proporsi resource server dan laju konsumsinya
    • Per-query limit: batas waktu query berdasarkan penggunaan server dalam satuan detik
    • Maximum concurrent workers: rasio terhadap worker process yang tersedia
  • Penargetan query umumnya dilakukan lewat metadata pada tag SQLCommenter (misalnya action=analytics)
  • Alih-alih long-runner yang akan tertangkap idle_in_transaction_session_timeout, artikel memicu degradasi dengan skenario yang lebih realistis, yaitu query analitik aktif yang saling overlap (kasus yang tidak bisa ditangkap session timeout)
  • Maximum concurrent workers untuk query action=analytics dibatasi ke 1 worker (max_worker_processes 25%) → hanya 1 query analitik yang boleh berjalan bersamaan
  • Untuk memicu death spiral dalam jendela 15 menit, producer dinaikkan menjadi 800 job/detik
  • Dari EC2 ke DB PlanetScale yang sama, workload "enhanced" dijalankan dua kali
    • 800 job/detik
    • 3 query analitik berdurasi 120 detik dijalankan bersamaan dan disusun agar terus overlap
    • berlangsung selama 15 menit
  • Perbandingan hasil
Metrik Traffic Control nonaktif Traffic Control aktif
Queue backlog 155,000 jobs 0 jobs
Lock time 300ms+ 2ms
Dead tuples at end 383,000 0–23,000 (cycling)
Analytics queries 3 concurrent, overlapping 1 at a time, 2 retrying
VACUUM effectiveness Blocked (horizon tertahan) Normal (ada jeda cleanup di antara query)
Outcome Death spiral Completely stable
  • Traffic Control secara langsung membatasi concurrency workload tertentu, memberikan kontrol yang tidak bisa dicapai hanya dengan tuning autovacuum atau timeout
  • Laporan analitik tetap berjalan selama kapasitas memungkinkan dan 15 pekerjaan selesai dalam 15 menit, sementara queue tetap sehat sepanjang waktu

Ringkasan

  • Masalah MVCC dead tuple pada queue berbasis Postgres bukan peninggalan 2015 semata
  • Postgres modern memberi ruang napas lebih besar lewat perbaikan B-tree dan SKIP LOCKED, tetapi mekanisme dasarnya tetap sama
    • jika VACUUM gagal membersihkan dead tuple, jumlahnya akan terus menumpuk
    • jika transaksi yang lama berjalan atau saling overlap menahan MVCC horizon, VACUUM tidak bisa membersihkannya
  • Dalam lingkungan “Just use Postgres” yang menaruh queue, analitik, dan logika aplikasi di satu DB, ini bukan risiko teoritis, melainkan kondisi operasional sehari-hari
  • Bentuk berbahayanya bukan crash dramatis, melainkan keseimbangan yang diam-diam memburuk — lock time perlahan naik, job makin lambat, dan alarm tidak berbunyi
  • Alat timeout di Postgres tidak bisa membedakan kelas workload atau membatasi concurrency
  • Jika queue dijalankan berdampingan dengan workload lain, tindakan paling efektif adalah memastikan VACUUM dapat tetap mengejar laju perubahan, dan Traffic Control menyederhanakan hal itu

1 komentar

 
GN⁺ 3 hari lalu
Komentar Hacker News
  • Postgres masih memiliki masalah vacuum horizon. Ini adalah situasi ketika query yang berjalan lama menghalangi vacuum pada tabel yang berubah cepat. Masalah ini sudah dikenal luas sejak 2015. Postgres bawaan tidak punya alat yang bagus untuk mengatasinya, tetapi versi kustom perusahaan penulis memiliki fitur untuk menyelesaikannya. Kesimpulannya, tetap bukan ide yang bijak untuk mencampur pekerjaan panjang bergaya OLAP dan pekerjaan cepat bergaya queue dalam instance Postgres yang sama. Bergantung pada kebutuhan, memakai message queue seperti 0MQ atau RMQ bisa menjadi solusi yang lebih mudah

    • Kecuali database memberi kendali yang sangat rinci atas cache atau buffer pool, mencampur beban kerja yang berbeda seperti ini selalu merupakan pilihan yang buruk. Tidak ada cara untuk mencegah tabel analitik mengotori seluruh cache
  • Artikelnya cukup bagus, tetapi ada beberapa hal yang perlu dikritisi.

    1. Penjelasan tentang MVCC horizon tampak saling bertentangan. Jika transaksi dimulai pada waktu yang berbeda, snapshot-nya akan berbeda, dan setelah transaksi pertama selesai, vacuum seharusnya bisa berjalan
    2. Masalah performa pada query SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED; memang nyata, tetapi bisa dikurangi dengan menambahkan kolom yang naik secara monoton dan membuat indeks di atasnya. Dengan begitu, dead tuple tidak perlu dipertimbangkan, dan dampaknya lebih ke pemborosan ruang saja, sementara performa baca tidak terlalu menurun. Namun, bagaimana menjamin kenaikan monoton saat ada penulisan paralel bergantung pada desain aplikasi
    3. Singkatnya, pelajarannya adalah “jangan gunakan transaksi yang sangat panjang dan frekuensi transaksi yang sangat tinggi secara bersamaan di Postgres”
    • Bahkan jika indeks ditambahkan, Postgres tetap harus menyimpan dead tuple sampai benar-benar dibersihkan. Mungkin ada sedikit peningkatan kecepatan, tetapi pada akhirnya disk akan penuh, dan vacuum pada tabel lain juga bisa terganggu
  • Saya penulisnya. Silakan tanya apa saja

    • Implementasi queue di blog itu tampaknya mempertahankan transaksi selama pekerjaan diproses. Saya paham saran untuk membuatnya sesingkat mungkin, tetapi saya penasaran apakah transaksi panjang itu sendiri bisa dihindari dengan memperbarui kolom status menjadi “processing”
    • Saya penasaran apakah sudah dicoba mengatur fillfactor tabel queue ke nilai di bawah 100 dan mengujinya. Dengan HOT update, perubahan status bisa memakai kembali ruang mati tanpa membuat entri indeks baru, jadi sepertinya bisa menunda munculnya masalah
    • Saya penasaran apa bedanya solusi ini dibandingkan menggunakan pg_squeeze. Kami juga mengalami masalah yang sama di sistem queue kami dan sedang menguji pg_squeeze, dan sejauh ini tampaknya bekerja cukup baik
  • Ini memang terdengar seperti iklan, tetapi akan menyenangkan jika ada sedikit penjelasan tentang pendekatan teknis penyelesaiannya

  • Postgres benar-benar bisa melakukan banyak hal. Orang-orang memilih Kafka atau SQS, tetapi sebenarnya cukup banyak pekerjaan yang bisa ditangani dengan Graphile Worker saja

    • Filosofi “mari lakukan semuanya di Postgres” bagus untuk menjaga kesederhanaan pada skala kecil atau menengah, tetapi ketika skala membesar, lebih baik memberi database SQL sesedikit mungkin tanggung jawab. Karena di sebagian besar sistem, itulah bagian yang menjadi bottleneck
    • SQS sangat sederhana dan mudah diintegrasikan dalam lingkungan AWS. Kafka jauh lebih kompleks, tetapi merupakan pilihan yang hebat jika memang membutuhkan fiturnya. Namun beban operasionalnya besar, dan ada banyak proyek yang mengadopsinya sekadar untuk nilai CV. Sebaliknya, SQS dipakai sebagai alat yang praktis. Tetapi jika ingin keluar dari AWS, ketergantungan itu bisa menjadi masalah
  • Di Postgres, masalahnya menjadi jauh lebih serius saat melakukan update pada baris. Jika hanya memakai insert dan delete, sistem bisa bertahan cukup lama

    • Di Postgres, UPDATE pada dasarnya adalah gabungan INSERT dan DELETE