Ringkasan Utama
Saat load testing platform logistik Java Spring Boot di Docker Swarm, menggandakan replica memangkas latency p95 hingga separuh tapi throughput hanya naik 9 persen, sebab setiap request memperbarui baris PostgreSQL yang sama. Menyetel PostgreSQL dan HikariCP pada satu replica saja justru mengungguli dua replica yang disetel, membuktikan horizontal scaling tidak bisa memperbaiki row lock pada shared resource.
Selama skripsi S1 saya di Swiss German University, saya melakukan riset performa bergaya SRE pada sebuah SaaS logistik Java Spring Boot yang berjalan di Docker Swarm. Platform ini belum pernah diuji beban sama sekali. Setiap service berjalan sebagai satu replica saja, tidak ada yang tahu titik jebolnya, dan satu-satunya sinyal yang ada hanyalah log tanpa data waktu respons. Saya membangun setup load testing berbasis k6 di sekitar jalur tulis tersibuk platform ini, endpoint Create Tour, dengan ekspektasi bahwa solusi paling jelas untuk performa lambat adalah menambah replica. Hasilnya justru membantah asumsi itu, dan menelusuri sebabnya membawa saya dari perintah scale Docker Swarm sampai ke satu baris yang terkunci di tabel PostgreSQL.
Tanpa Baseline, Tanpa Tahu Titik Jebolnya
Platform yang diteliti adalah sistem Java Spring Boot enam-service yang berjalan di Docker Swarm. Sebelum proyek ini, satu-satunya observability yang ada adalah agregasi log, yang mencatat error tapi tidak pernah mencatat response time atau throughput. Setiap service, termasuk Tour Data Provider yang menerima setiap tour baru, berjalan sebagai satu replica container di Docker Swarm. Trace sebulan sebelumnya pada health endpoint menunjukkan downtime yang terlihat dan lonjakan response time di atas 18 detik saat pengujian tidak terkontrol sebelumnya, tapi tidak ada yang bisa memastikan beban seperti apa yang bisa ditanggung platform dengan aman, atau layer mana yang akan jebol duluan. Ketiadaan baseline itulah masalah sebenarnya yang ingin saya selesaikan, bukan bug tertentu.
Kenapa Endpoint Create Tour
Saya memilih endpoint Create Tour di Tour Data Provider sebagai target dengan informasi paling kaya. Setiap panggilan memvalidasi token OAuth2 ke Keycloak, mendeserialisasi payload bersarang berisi stop, order, dan order position, mengeksekusi 87 pernyataan INSERT PostgreSQL dalam satu transaksi, dan mempublikasikan pesan ke antrean Artemis setelah berhasil. Satu endpoint ini menguji autentikasi, persistensi mendalam, dan messaging asinkron sekaligus dalam satu jalur request, sehingga bottleneck di lapisan mana pun akan muncul di sini lebih dulu.
Menyiapkan k6 dengan Payload Aman GDPR
Saya mengambil payload Create Tour produksi asli dan menghapus setiap field PII, nomor pelanggan, nama, email, alamat, ID order, menggantinya dengan null atau nilai sintetis sambil tetap mempertahankan struktur sembilan stop bersarang yang sama. Ini penting karena payload yang disederhanakan atau palsu akan melewati jalur kode serialisasi dan persistensi yang justru ingin saya uji. Setiap eksperimen komparatif menjalankan skrip k6 yang identik untuk 10 virtual user selama 120 detik; fase pilot menunjukkan hasil yang tidak terbedakan di bawah 10 VU dan CPU load generator naik di atas 70 persen di atas 20 VU, sehingga 10 VU menjadi profil tetap untuk setiap eksperimen berikutnya.
Jika Anda melakukan load testing pada platform yang menyentuh data pelanggan, anonimkan payload asli yang sudah ditangkap, jangan menulis payload sintetis dari nol. Mempertahankan kedalaman nesting dan struktur field yang sama adalah yang membuat load testing benar-benar menguji kode serialisasi dan persistensi asli, bukan versi yang disederhanakan.
H1: Menggandakan Replica Memangkas Latency tapi Nyaris Tidak Menaikkan Throughput
Hipotesis pertama saya sederhana: menggandakan replica di bawah 10 virtual user seharusnya memangkas tail latency secara signifikan. Dan benar, secara dramatis. Response time p95 turun dari 8.40 detik pada satu replica menjadi 4.50 detik pada dua replica, peningkatan 1.87 kali, dan response time maksimum juga turun hampir separuh. Tapi throughput, angka yang menentukan berapa banyak tour yang benar-benar bisa diproses platform per detik, hanya bergerak dari 1.72 menjadi 1.88 iterasi per detik: kenaikan 9.3 persen untuk dua kali lipat compute. Kemenangan latency itu nyata: load balancer round-robin Docker Swarm membagi antrean 10 virtual user bersamaan ke dua instance alih-alih satu, yang memangkas separuh konkurensi per replica dan mempersingkat waktu tunggu request paling lambat. Tapi ada sesuatu di hilir lapisan aplikasi yang membatasi berapa banyak tour yang bisa selesai per detik, tidak peduli berapa banyak replica yang menangani lapisan HTTP.
Menyingkirkan Message Broker Lebih Dulu
Sebelum menyentuh database, saya memeriksa tersangka alternatif yang paling jelas: antrean pesan tempat endpoint ini mempublikasikan setiap tour yang berhasil dibuat. Saya memantau statistik antrean setiap 2 detik selama kedua run. Rate publish adalah 57 pesan berbanding 54 dalam 120 detik yang sama, praktis tidak berbeda, dan antrean habis dalam sekitar 10 detik pada kedua kasus dengan satu consumer aktif. Consumer yang jenuh akan menunjukkan antrean yang terus menumpuk; tidak ada tanda itu. Broker dalam kondisi sehat. Bottleneck berada di suatu tempat antara aplikasi dan database.
-- 1. PostgreSQL runtime tuning applied via ALTER SYSTEM (no restart needed
-- for synchronous_commit; shared_buffers/wal_buffers need a restart)
ALTER SYSTEM SET synchronous_commit = off; -- skip per-COMMIT fsync wait
ALTER SYSTEM SET wal_buffers = '64MB'; -- was 4MB default
ALTER SYSTEM SET shared_buffers = '1GB'; -- was 128MB, ~25% of host RAM
SELECT pg_reload_conf();
# 2. HikariCP pool widened from 5 to 10 per replica
# (core_count x 2) + effective_spindle_count = (4 x 2) + 1 = 9, rounded to 10
hikari:
maxLifeTime: 600000
minimum-idle: 10
maximum-pool-size: 10
# Result on a SINGLE replica: 1.72 -> 2.27 iter/s (+32%), p95 8.40s -> 3.65s (-57%)
# That single tuned replica beat every multi-replica config tested, including
# 2 tuned replicas (2.00 iter/s, p95 5.43s) -- which is where the story gets interesting.
Menyetel PostgreSQL di Satu Replica Mengalahkan Scaling ke Dua
Alih-alih menambah replica, saya menyetel instance PostgreSQL bersama dan connection pool-nya. Tiga perubahan, ditunjukkan di bawah, diterapkan tanpa menyentuh kode aplikasi: mematikan synchronous_commit untuk melewati jeda fsync per commit, memperbesar shared_buffers dan wal_buffers agar lebih banyak data tersimpan di memori dan flush WAL lebih halus, serta memperlebar pool HikariCP dari 5 menjadi 10 koneksi per replica, mengikuti aturan standar jumlah core dikali dua ditambah jumlah spindle untuk host 4-core. Pada satu replica saja yang tidak diubah selain penyetelan ini, throughput naik dari 1.72 menjadi 2.27 iterasi per detik, kenaikan 32 persen, sementara latency p95 turun dari 8.40 menjadi 3.65 detik, penurunan 57 persen. Satu replica yang disetel itu mengalahkan setiap konfigurasi multi-replica yang saya uji, termasuk dua replica dengan penyetelan yang persis sama, yang hanya mencapai 2.00 iterasi per detik dengan p95 5.43 detik. Memperbaiki batasan yang sebenarnya mengungguli menggandakan compute yang berdiri di depannya.
Dua replica yang disetel ternyata lebih lambat dari satu. Sebelum penyetelan, bottleneck-nya adalah latency fsync WAL dan pool 5-koneksi yang kelaparan, sehingga scaling ke dua replica menggandakan koneksi yang tersedia dan sedikit membantu. Setelah penyetelan menghilangkan batasan itu, dua replica justru menggandakan jumlah koneksi yang memperebutkan sesuatu yang tidak bisa diperbaiki dengan penyetelan: satu row lock. Jangan berasumsi solusi yang berhasil di satu lapisan akan terus berhasil setelah batasan di bawahnya dihilangkan.
Menemukan Hot Row dengan pg_stat_activity dan pg_locks
Setelah broker dinyatakan bersih dan PostgreSQL disetel, saya mengambil sampel pg_stat_activity yang di-join dengan pg_locks setiap 3 detik selama run dua-replica berjalan untuk melihat persis session mana yang terblokir dan pada apa. Setiap sampel menunjukkan hasil yang sama: enam sampai delapan session terjebak pada tuple lock, semuanya menjalankan UPDATE yang identik pada tabel tenant_config. Lingkungan staging memiliki 40 baris di tabel itu, satu per tenant, dan setiap request load test menggunakan tenant key yang sama, sehingga setiap transaksi Create Tour yang berjalan bersamaan memperebutkan lock eksklusif pada satu baris yang sama. Tabel itu sudah memiliki rasio HOT update 98.9 persen, artinya PostgreSQL sudah menggunakan ulang ruang heap seefisien mungkin; biayanya bukan di storage, tapi di lock itu sendiri.
-- Sampled every 3s during a 2-replica run to see who is blocked and on what
SELECT
blocked.pid,
blocked.wait_event,
blocked_locks.locktype,
blocked_locks.relation::regclass AS locked_table,
blocked.query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked
ON blocked.pid = blocked_locks.pid
WHERE NOT blocked_locks.granted
LIMIT 10;
-- Every sample looked the same: 6-8 sessions stuck on a tuple lock,
-- all running the identical UPDATE:
--
-- pid | wait_event | locktype | locked_table | query
-- -----+------------+----------+----------------+----------------------------
-- 203 | tuple | tuple | tenant_config | update tenant_config set
-- 350 | tuple | tuple | tenant_config | modified_at=$1, tenant=$2,
-- 349 | tuple | tuple | tenant_config | tenant_info=$3, tenant_key=$4
-- 199 | tuple | tuple | tenant_config | where id=$5
-- 201 | tuple | tuple | tenant_config |
-- 198 | tuple | tuple | tenant_config |
-- 347 | tuple | tuple | tenant_config |
--
-- tenant_config had 40 rows (one per tenant). Every load-test request used
-- tenantKey = "BEHNGT" -- one row, serialised by Postgres row-level locking.
Batas yang Tidak Bisa Dilewati Penyetelan Apa Pun
Setelah tahu baris itu adalah batasannya, saya mencoba setiap opsi infrastruktur yang tersedia. Sweep ukuran pool pada dua replica yang disetel menemukan ukuran pool 4 per replica, 8 koneksi total, sebagai konfigurasi paling tidak buruk pada 2.18 iterasi per detik dengan p95 4.31 detik, masih 4 persen di bawah hasil satu-replica yang disetel dan 18 persen lebih buruk pada p95. Mengatur fillfactor tabel menjadi 50 untuk memberi ruang lebih pada HOT update tidak memberi perbaikan yang terukur. Merutekan lewat PgBouncer dalam mode transaction-pooling justru menurunkan throughput sekitar 13 persen, karena overhead per-query-nya berlipat ganda di sepanjang 87 pernyataan INSERT dalam setiap transaksi. Tidak ada yang melewati 2.27 iterasi per detik. Angka itu bukan setelan PostgreSQL; itu adalah laju maksimum di mana transaksi bisa memperoleh dan melepas satu row lock, satu per satu, tidak peduli bagaimana infrastruktur di sekelilingnya diatur.
Konfigurasi satu-replica yang disetel, bukan konfigurasi dua-replica, akhirnya menjadi rekomendasi produksi: throughput lebih baik, latency lebih baik, dan biaya compute setengahnya. Kadang solusi tercepat bukan menambah kapasitas, tapi membaca tabel lock.
Pelajarannya: Horizontal Scaling Tidak Memperbaiki Shared Resource
Menambah replica meringankan bottleneck apa pun yang berada di lapisan konkurensi HTTP, dan itu kemenangan yang nyata dan terlihat: tail latency di proyek ini turun hampir separuh. Tapi replica berbagi database yang sama, dan ketika batasan sebenarnya adalah satu baris yang harus diperbarui oleh setiap request, lebih banyak replica hanya berarti lebih banyak proses mengantre untuk lock yang sama. Tidak ada parameter PostgreSQL, ukuran connection pool, setelan storage, atau proxy pooling yang melewati batas itu, karena tak satu pun dari mereka menyentuh apa yang ditegakkan oleh row-level locking: hanya satu penulis yang memegang lock eksklusif pada satu baris dalam satu waktu. Satu-satunya perbaikan nyata ada di level aplikasi: merestrukturisasi penulisan itu agar request yang berjalan bersamaan berhenti memperebutkan baris yang sama, meng-cache-nya di memori aplikasi, memecahnya per tenant, atau menghapus penulisan itu dari transaksi sama sekali. Mendiagnosis itu berarti melewati dashboard, melewati metrik connection pool, sampai ke query langsung terhadap pg_locks. Di situlah biasanya bottleneck sebenarnya pada sistem yang berat menulis benar-benar bersembunyi.
Sumber
- Site reliability engineering, Wikipedia
- Multiversion concurrency control (MVCC), Wikipedia
- Referensi konfigurasi Write Ahead Log (synchronous_commit, wal_buffers), dokumentasi resmi PostgreSQL
- Connection pool HikariCP, GitHub (brettwooldridge/HikariCP)
- Ringkasan Swarm mode, dokumentasi resmi Docker

