Menemukan Bottleneck Row-Lock Postgres dengan Prometheus + InfluxDB

Foto oleh alfonso benayas via Openverse (CC BY 2.0)
k6 punya dukungan bawaan yang memang dirancang untuk mengalirkan metrik load-test per-request ke InfluxDB, sementara metrik container dan JVM dari cAdvisor dan Spring Boot Actuator dirancang di sekitar model scrape berbasis pull milik Prometheus. Daripada memaksa satu tool meniru protokol tool lain, tiap sumber metrik memakai backend yang memang dibuat untuknya, dan keduanya ditampilkan di satu dashboard Grafana agar operator bisa mengorelasikannya pada satu sumbu waktu.
Menjalankan Prometheus, Grafana, InfluxDB, dan load generator di host yang sama dengan layanan yang sedang dibenchmark berarti stack observability itu berebut CPU dan memori dengan layanan tersebut, yang merusak pengukuran yang sedang dilakukan. Menjaga monitoring di droplet DigitalOcean terpisah, dengan federasi metrik lewat reverse proxy Nginx yang dikunci, menjaga anggaran resource penuh host aplikasi untuk layanan yang diuji.
Dengan menjalankan query pg_stat_activity yang di-join dengan pg_locks secara berulang saat beban berjalan, biasanya setiap beberapa detik, untuk melihat sesi mana yang aktif, mana yang terblokir, dan persis lock serta baris apa yang mereka tunggu. Dalam investigasi ini, query tersebut konsisten menunjukkan 6 sampai 8 sesi terblokir pada exclusive tuple lock terhadap baris yang sama di tabel tenant_config.
Latensi, traffic, error, dan saturasi memberitahu bahwa sistem sedang tidak sehat dan kira-kira menunjuk ke satu lapisan, tapi mereka tidak bisa melihat ke dalam lock manager database. Lonjakan latensi dengan pemakaian CPU yang hanya moderat menyingkirkan komputasi mentah sebagai penyebab, tapi menentukan baris atau query spesifik mana yang memblokir semuanya membutuhkan introspeksi live ke state sesi dan lock database itu sendiri, bukan sekadar metrik dashboard.
Workload-nya mempublikasikan pesan ke antrean Artemis setelah setiap penulisan sukses, sehingga konsumer yang jenuh bisa terlihat identik dengan bottleneck database dari luar. Sampling jumlah pesan antrean setiap 2 detik dan memastikannya tetap nol sepanjang test menyingkirkan broker hanya dalam hitungan menit, sebelum menghabiskan waktu untuk investigasi lebih dalam di level database.

Foto oleh alfonso benayas via Openverse (CC BY 2.0)
Ringkasan Utama
Dashboard golden-signal (latensi, traffic, error, saturasi) bisa menunjukkan sistem sedang bermasalah, tapi tidak menunjukkan letak bottleneck-nya. Untuk melacak bottleneck concurrency Postgres, diperlukan sampling langsung pg_stat_activity yang di-join dengan pg_locks selama load test berjalan, cara yang mengungkap satu exclusive tuple lock pada satu baris tenant_config yang memblokir 6-8 sesi sekaligus.
Sebagai bagian dari riset skripsi saya di SGU bersama Commsult Indonesia mengenai Ontego Traces, cukup banyak waktu proyek yang saya habiskan bukan untuk menulis skrip load-test atau logika autoscaler, melainkan menyusun lapisan observability yang membuat semua data dari komponen lain itu masuk akal. Tulisan ini membahas lapisan tersebut: kenapa saya memisahkannya menjadi dua backend alih-alih satu, kenapa lapisan itu berjalan di host yang sepenuhnya terpisah dari layanan yang sedang diuji, dan proses investigasi sesungguhnya yang membawa saya dari "ada yang lambat" menjadi "baris spesifik di tabel spesifik ini yang menjadi batasannya". Angka hasil load-test dan perilaku autoscaler sudah saya bahas di tulisan lain, jadi di sini saya hanya menyinggungnya sekilas sebagai konteks dan fokus pada desain observability serta metode diagnosisnya.
Di awal saya sempat mencoba memasukkan semua metrik ke satu time-series store dan langsung menemui ketidakcocokan bentuk data. k6, tool load-testing yang menjalankan eksperimen, dirancang untuk mengalirkan metrik granular per-request seperti http_req_duration dan jumlah iterasi ke InfluxDB sebagai target output bawaan. Metrik infrastruktur punya bentuk yang sama sekali berbeda: CPU dan memori container dari cAdvisor, serta heap JVM, garbage collection, dan statistik connection-pool HikariCP dari Spring Boot Actuator, yang keduanya secara alami menggunakan model scrape berbasis pull milik Prometheus. Daripada memaksa satu tool meniru protokol tool lain, saya membiarkan tiap sisi memakai backend yang memang dirancang untuknya, lalu menampilkan keduanya di dashboard Grafana yang sama, pada sumbu waktu yang sama. Satu keputusan ini ternyata jauh lebih berpengaruh dibanding panel dashboard apa pun yang saya bangun di atasnya.
Setiap run k6 menulis langsung ke instance InfluxDB melalui flag output bawaan k6, sehingga persentil response time, error rate, dan throughput sudah tersedia sejak test dimulai, bukan setelah selesai. Ini penting karena survei Giamattei dkk. tahun 2024 terhadap tool monitoring DevOps menemukan bahwa sebagian besar pipeline hanya mengumpulkan metrik performa setelah satu run selesai — celah inilah yang ditutup oleh dashboard live: operator yang mengamati run dapat melihat p95 naik secara real time, bukan menemukannya di laporan setelah test berakhir.
cAdvisor mengekspos CPU dan memori level container sebagai target scrape Prometheus, dan Spring Boot Actuator mengekspos hal yang sama untuk heap JVM, jeda garbage collection, serta ukuran dan pemakaian connection pool HikariCP. Keduanya di-scrape dengan interval singkat dan difederasi ke instance Prometheus milik host monitoring, sehingga operator yang menatap dashboard Grafana saat load test berlangsung bisa melihat latensi aplikasi di satu panel dan CPU container yang sedang naik di panel tepat sebelahnya, pada timestamp yang sama. Korelasi inilah inti dari semuanya: lonjakan latensi dengan CPU datar menceritakan hal yang sama sekali berbeda dari lonjakan latensi dengan CPU mentok di 100%.
Kalau anggaran Anda hanya cukup untuk menambah satu hal ke setup load-testing yang sudah ada, tambahkan metrik infrastruktur pada sumbu waktu yang sama dengan metrik latensi, bukan dashboard terpisah. Nilainya hampir seluruhnya ada pada kemampuan melihat korelasi secara instan, bukan pada salah satu set metrik itu sendiri.
Aplikasi yang diuji berjalan di instance Google Cloud Platform dengan 4 vCPU dan 8GB RAM yang menjalankan Docker Swarm manager beserta container layanan. Load generator, InfluxDB, Prometheus, dan Grafana semuanya berjalan di droplet DigitalOcean terpisah dengan 2 vCPU dan 2GB RAM. Ini keputusan yang disengaja, bukan sekadar penghematan biaya belakangan: stack observability yang berebut CPU dan memori dengan layanan yang sedang diuji akan merusak pengukurannya sendiri. Kalau cAdvisor, Prometheus, dan Grafana ikut memakan siklus CPU di mesin yang sama yang sedang dibenchmark, setiap angka latensi yang mereka laporkan sebagian sebenarnya mengukur overhead mereka sendiri. Saya memverifikasi jejak cAdvisor di host aplikasi tetap di bawah 1% CPU, tapi angka itu hanya bermakna karena komponen yang lebih berat, yaitu load generator dan database time-series, memang sejak awal tidak pernah berada di host itu.
Karena Prometheus perlu men-scrape endpoint metrik host aplikasi sementara kedua host berada di penyedia cloud berbeda, saya memasang reverse proxy Nginx di depan instance Prometheus host aplikasi dan membatasinya agar hanya menerima koneksi dari alamat IP droplet monitoring. Prometheus milik host monitoring kemudian melakukan federasi dari proxy tersebut pada interval scrape reguler, menarik masuk data cAdvisor dan Actuator tanpa mengekspos endpoint monitoring host aplikasi ke internet terbuka. Ini konfigurasi yang kecil, tapi justru bagian yang membuat pemisahan dua-host benar-benar berfungsi end-to-end, bukan sekadar memindahkan masalahnya.
Dashboard golden-signal itu perlu tapi tidak cukup untuk mencari akar penyebab bottleneck di bawah lapisan aplikasi. Kalau latensi Anda terus naik tapi golden signals terlihat ambigu, langkah berikutnya bukan dashboard yang lebih canggih, melainkan sampling sesi dan lock secara live terhadap backend stateful apa pun yang berada di hilir layanan Anda.
Dengan kedua backend aktif di satu dashboard, kerangka standar SRE berupa empat golden signals — latensi, traffic, error, dan saturasi — sepenuhnya tercakup: k6 memberi latensi, traffic, dan error, sementara cAdvisor dan Actuator memberi saturasi. Tapi mengamati keempatnya selama load test hanya memberitahu saya bahwa sistem sedang kesulitan. p95 yang naik melewati 8 detik dengan CPU di level moderat memberitahu saya bahwa bottleneck bukan soal komputasi mentah, tapi tidak satu pun dari keempat sinyal itu menunjukkan dependensi hilir mana yang sebenarnya menjadi batasan. Celah itulah yang harus diisi oleh introspeksi langsung ke database dan message broker.
Setelah broker bersih, saya beralih langsung ke Postgres. Setiap 3 detik selama run load test dua-replika, saya menjalankan query pg_stat_activity yang di-join dengan pg_locks untuk melihat persis sesi backend mana yang aktif, mana yang menunggu, dan menunggu apa. Polanya langsung terlihat dan konsisten: 6 sampai 8 sesi terblokir pada setiap sampel, semuanya menunggu exclusive tuple lock pada baris yang sama di tabel tenant_config. Query yang memblokir selalu statement UPDATE yang sama, dijalankan oleh setiap request dalam workload terhadap tabel yang di lingkungan staging hanya punya segelintir baris tenant berbeda, artinya semua traffic konkuren menyempit lewat lock satu baris itu. Ini bukan masalah performa query atau masalah indexing; ini masalah desain concurrency, dan tidak ada tuning connection-pool atau perubahan jumlah replika yang mengubahnya, karena batasannya berada di level baris, ditegakkan oleh mekanisme locking MVCC milik Postgres sendiri, bukan di level CPU atau koneksi.
-- Sampled every 3s during load test: who is blocked, and on what row?
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
blocked.query AS blocked_query,
blocked.wait_event_type,
blocked.wait_event,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocking.state AS blocking_state,
locks.relation::regclass AS locked_relation,
locks.mode AS lock_mode,
locks.locktype
FROM pg_stat_activity blocked
JOIN pg_locks locks
ON locks.pid = blocked.pid AND NOT locks.granted
JOIN pg_locks blocking_locks
ON blocking_locks.locktype = locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM locks.tuple
AND blocking_locks.pid != locks.pid
AND blocking_locks.granted
JOIN pg_stat_activity blocking
ON blocking.pid = blocking_locks.pid
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocked.query_start;
-- Output during the load test consistently showed 6-8 rows,
-- all pointing at the same tenant_config tuple:
-- blocked_pid | locked_relation | lock_mode | blocking_query
-- -------------+-----------------+-------------------+------------------------------------------
-- 18422 | tenant_config | ExclusiveLock | UPDATE tenant_config SET modified_at=...
-- 18430 | tenant_config | ExclusiveLock | UPDATE tenant_config SET modified_at=...
-- 18441 | tenant_config | ExclusiveLock | UPDATE tenant_config SET modified_at=...
Jalankan query ini pada interval yang ketat, setiap 2 sampai 3 detik, sepanjang durasi load test, bukan sekali saja setelah selesai. pg_locks dan pg_stat_activity hanya melaporkan lock yang sedang dipegang atau ditunggu saat itu juga, jadi lonjakan kontensi yang sudah selesai sedetik lalu tidak akan terlihat begitu Anda menghentikan test dan menjalankan query secara interaktif setelahnya.
Sebelum menyentuh database, saya ingin menyingkirkan Artemis message broker sebagai tersangka, karena setiap request sukses dalam workload mempublikasikan satu pesan ke antrean setelah jalur penulisan selesai. Saya melakukan sampling MESSAGE_COUNT antrean setiap 2 detik sepanjang run load test dan mengamatinya tetap nol di hampir setiap polling, dengan laju publish yang cocok erat dengan laju ack konsumer. Konsumer yang jenuh akan menunjukkan antrean yang menumpuk sepanjang run; tidak ada itu. Pemeriksaan sederhana ini menyingkirkan satu subsistem penuh hanya dalam beberapa menit dan mencegah saya mengejar jalan buntu tuning broker yang tidak akan mengubah apa pun.
Pelajaran umumnya berlaku di luar proyek ini: empat golden signals adalah alat triase, memberitahu bahwa sistem sedang tidak sehat dan kira-kira di lapisan mana, tapi melacak bottleneck concurrency sampai ke satu baris spesifik membutuhkan query langsung ke state sesi dan lock milik database itu sendiri saat beban benar-benar sedang berjalan. Dashboard metrik statis tidak bisa melihat antrean lock; hanya introspeksi live yang bisa.
Kalau Anda sedang menyiapkan observability untuk proyek load-testing, dua keputusan yang akan saya ulangi tanpa ragu adalah: jaga stack monitoring tetap terpisah secara fisik dari sistem yang diuji, dan perlakukan dashboard golden-signal sebagai langkah triase pertama, bukan langkah diagnosis terakhir. Ketika latensi memburuk dan panel CPU, memori, serta error-rate Anda tidak menunjuk pada tersangka yang jelas, tool berikutnya yang harus Anda ambil adalah query live terhadap view sesi dan lock database Anda, disampling berulang kali selama beban berjalan, bukan panel Grafana baru.