Load Testing Docker Swarm: Kenapa Menambah Replica Tidak Menaikkan Throughput

Foto oleh Robert Scoble via Openverse (CC BY 2.0)
Menggandakan replica membagi konkurensi HTTP yang masuk ke dua instance, sehingga memangkas separuh kedalaman antrean per replica dan menurunkan tail latency p95 (dari 8.40 detik menjadi 4.50 detik pada kasus ini). Throughput adalah metrik berbeda yang dibatasi oleh database PostgreSQL bersama, yang tidak bertambah kapasitasnya hanya karena ditambahkan satu replica aplikasi lagi. Throughput hanya naik 9.3 persen karena jalur tulis database, bukan lapisan HTTP, adalah batasan sebenarnya.
Ambil sampel pg_stat_activity yang di-join dengan pg_locks saat beban sedang berjalan, filter session yang granted-nya false. Jika banyak session konsisten terblokir pada tuple lock terhadap tabel dan baris yang sama, baris itulah yang menyerialkan setiap penulisan yang menyentuhnya. Pada kasus ini, enam sampai delapan session berulang kali terblokir pada satu baris di tabel tenant_config yang diperbarui oleh setiap request.
Tidak. Row-level locking di PostgreSQL hanya mengizinkan satu penulis memegang lock eksklusif pada satu baris dalam satu waktu, tidak peduli berapa banyak koneksi, ukuran pool, atau proxy pooling di depannya. Dalam riset ini, sweep pool HikariCP, perubahan fillfactor, dan transaction pooling PgBouncer semuanya gagal menaikkan throughput di atas 2.27 iterasi per detik, dan PgBouncer justru membuatnya 13 persen lebih buruk karena overhead per-query.
Tidak selalu, terutama ketika bottleneck-nya adalah shared resource yang diperebutkan semua replica, seperti satu baris database, satu key cache bersama, atau rate limit API eksternal bersama. Horizontal scaling membantu ketika batasannya adalah CPU per instance atau konkurensi koneksi, tapi tidak berguna, bahkan bisa merugikan, ketika batasannya berada di hilir pada resource bersama yang terserialisasi.
Dalam riset ini, tiga perubahan pada satu replica yang belum disetel menaikkan throughput 32 persen dan memangkas latency p95 57 persen: mengatur synchronous_commit menjadi off untuk melewati jeda fsync per commit, menaikkan shared_buffers dan wal_buffers untuk mengurangi I/O disk dan menghaluskan flush WAL, serta memperlebar connection pool HikariCP dari 5 menjadi 10 per replica menggunakan aturan ukuran standar jumlah core dikali dua ditambah jumlah spindle.

Foto oleh Robert Scoble via Openverse (CC BY 2.0)
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 di Commsult Indonesia pada Ontego Traces, sebuah SaaS logistik Java Spring Boot yang berjalan di Docker Swarm untuk pasar Jerman. 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.
Ontego Traces adalah platform Java Spring Boot enam-service yang dibangun oleh Commsult AG. 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 harus saya selesaikan, bukan bug tertentu.
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.
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.
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.
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.
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.
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.
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.
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.