Bir çekirdek Go'da 14.330, PHP-FPM'de 5.152 OAuth2 isteği taşıyor
Her istekte RS256 bearer token doğrulayıp PostgreSQL'e bir satır yazan ya da okuyan aynı API, bir, iki ve dört çekirdekte Go, PHP-FPM ve FrankenPHP worker ile saniyede kaç karma istek taşıyor?
Bulgu
Dört çekirdekte Go 57.321, FrankenPHP 25.659, PHP-FPM 20.606 karma istek taşıdı; çekirdek başına 14.330, 6.415 ve 5.152. Kapasite planına giren sayı bu değil, istek başına uygulama CPU'su: Go 66,8, FrankenPHP 110,7, PHP-FPM 187,7 mikrosaniye. FrankenPHP doyduğunda dört çekirdeğin ancak 2,84'ünü kullanabiliyor — Go 3,83, PHP-FPM 3,87. Darboğaz veritabanı değil: aynı dört çekirdekte PostgreSQL tek başına saniyede 68.212 satır yazıyor, yani en hızlı adayın karma tavanının üstünde.
- Çekirdek başına kapasite · Go
- 14.330 istek/sn
- Çekirdek başına kapasite · PHP-FPM
- 5.152 istek/sn
- İstek başına CPU · Go → PHP-FPM
- 66,8 → 187,7 µs
- FrankenPHP · doyumda çekirdek kullanımı
- %71
- PostgreSQL · kendi INSERT tavanı
- 68.212 satır/sn
Yöntem
Aynı sözleşme üç kez yazıldı ve her istekte RS256 bearer token doğrulandı (imza, iss, aud, exp, nbf, scope). Adaylar Go 1.27.1 (net/http, pgx, golang-jwt), nginx + php-fpm üzerinde PHP 8.5.10 ve FrankenPHP 1.12.7 worker modunda PHP 8.5.10; framework yok, iki PHP adayı aynı sınıfı çalıştırıyor. Yük karma: iki open-loop üreteç aynı anda, biri okuyup biri yazarak, her biri kendi access token'ıyla. Her aday 1, 2 ve 4 sabitlenmiş çekirdekte ve o çekirdek bütçesindeki kendi en iyi havuz boyutuyla ölçüldü; havuz boyutu ayrı bir fazda 8/16/32/64 süpürülerek seçildi. Yayınlanan sayı kapalı çevrimde doyum tavanıdır: hücre başına 5 tekrar, her tekrardan önce uygulama kodu çalıştırmayan bir nginx aynı çekirdeklerde beklenen hızın %115'iyle sınandı ve 45 tekrarın 45'i bu kapıdan geçti, en iyi tekrar raporlandı. CPU ve bellek cgroup sayaçlarından, ana makine yükü her tekrarda ayrıca kaydedildi. Koşunun tamamı 333 yük ölçümü ve 110.617.180 yanıt; yayınlanan tavan fazı bunların 45'i ve 18.551.833 yanıtı. Hiçbir ölçümde non-2xx yanıt ya da transport hatası görülmedi.
- Ölçüm tarihi
- Yayın
dün ölçüldü
Ortam
- Go
- 1.27.1 · net/http · pgx 5.11.0 · golang-jwt 5.3.1
- PHP
- 8.5.10 · opcache açık · JIT kapalı · firebase/php-jwt 7.1.1
- PHP-FPM
- nginx 1.26.3 + php-fpm aynı konteynerde · pm=static
- FrankenPHP
- 1.12.7 (Caddy 2.11.4) · worker modu · ZTS
- Veritabanı
- PostgreSQL 17.11 · 1.000.000 satır · synchronous_commit açık
- Yük üreteci
- oha 1.15.0 · iki eşzamanlı üreteç · 64 bağlantı/üreteç
- Donanım
- Apple M4 Pro · 12 çekirdek · 24 GB · macOS 27.0
- Sanallaştırma
- Docker Desktop 29.8.0 · 12 vCPU / 7,75 GB · aarch64
- Çekirdek ayrımı
- aday 0-3 · PostgreSQL 4-7 · yük 8-11
- Tahminci
- hücre başına 5 tekrar, üreteç yoklamasından geçenlerin en iyisi
Teknolojiler
Tekrarlamak için
./bench/build.sh && ./bench/verify.sh && STAMP=$(date -u +%F) ./bench/capacity.sh --all Saniyede on binlerce isteği karşılayacak bir OAuth2 servisi için Go önerdiğimde ekibin sorusu haklıydı: ne kadar kazanıyoruz? “Go daha hızlı” cevabı bu soruyu karşılamıyor, çünkü kapasite tablosuna yazılan şey saniyede istek değil, çekirdek. Bu yüzden aynı API’yi üç kez yazdım ve üçünü de bir, iki ve dört çekirdekte, yarısı okuma yarısı yazma olan aynı trafiğin altında ölçtüm. Ham koşuların hepsi php-go-bench deposunda duruyor.
Ne ölçtüm
Üç uç nokta, üç adayda da birebir aynı sözleşmeyle çalışıyor. Hepsi önce bearer
token’ı doğruluyor: RS256 imzası, iss, aud, exp, nbf ve uç noktaya göre
scope. Doğrulama hiçbir adayda önbelleğe alınmıyor.
| Aday | HTTP | Veritabanı idiom | Havuz boyutu (1c / 2c / 4c) |
|---|---|---|---|
| Go | net/http | pgx, bağlantı başına statement önbelleği | 16 / 64 / 64 |
| FrankenPHP (worker) | Caddy, gömülü | PDO, worker ömrü boyunca hazırlanmış statement | 8 / 8 / 8 |
| PHP-FPM | nginx, aynı çekirdek bütçesinde | PDO, tek gidiş-dönüşlük parametreli çağrı | 8 / 16 / 32 |
Yük karma: iki open-loop üreteç aynı anda koşuyor, biri GET /events/{id} ile
milyon satırlık tablodan birincil anahtarla tek satır okuyor, diğeri
POST /events ile tek INSERT … RETURNING yazıyor. İkisi de kendi access
token’ını gönderiyor.
Burada yayınlanan “kapasite” bir doyum tavanıdır, bir servis seviyesi değildir. Yani adayın yanıtları yetiştirdiği en yüksek hızdır; “p99 şu eşiğin altında kalarak” güvencesi taşımaz. Bunu ayrıca ölçmeye üç kez çalıştım ve üçünde de bu tezgâhta güvenilmez sonuç aldım; nedeni aşağıda, sınırlar bölümünde. Tavanın yanına o tavandaki gecikmeyi de bastım, böylece sayının neye mal olduğunu görebilirsiniz.
Çekirdek başına kapasite
Karma OAuth2 trafiğinde doyum tavanı
Her sütun, o adayın o çekirdek bütçesinde yetiştirdiği toplam istek/sn. Okuma ve yazma birlikte.
- Go
- FrankenPHP (worker)
- PHP-FPM
Kaynak: Hücre başına 5 tekrar, üreteç yoklamasından geçenlerin en iyisi — bench/capacity-report.mjs
Veri tablosu
| Seri | 1 çekirdek | 2 çekirdek | 4 çekirdek |
|---|---|---|---|
| Go | 16.883 | 29.449 | 57.321 |
| FrankenPHP (worker) | 6.544 | 14.306 | 25.659 |
| PHP-FPM | 6.218 | 11.211 | 20.606 |
Grafik tarayıcıda çizilir; aşağıdaki tablo aynı veriyi taşır.
Dört çekirdekte Go, PHP-FPM’in 2,78 katını, FrankenPHP’nin 2,23 katını taşıyor. Çekirdek başına çevirdiğinizde fark daha okunur hâle geliyor: Go 14.330, FrankenPHP 6.415, PHP-FPM 5.152 istek.
Üçü de çekirdek eklendikçe doğrusala yakın ölçekleniyor ama hiçbiri tam doğrusal değil. Bir çekirdekten dörde çıkarken Go teorik dördün %84,9’unu, PHP-FPM %82,8’ini alıyor. FrankenPHP’nin %98’i yanıltıcı: paydası, tek çekirdekte zaten düşük olan kendi rakamı.
| Aday | Tavan (4c) | Çekirdek başına | Tavandaki p50 (okuma/yazma) | Tavandaki p99 | Tepe RSS |
|---|---|---|---|---|---|
| Go | 57.321/sn | 14.330 | 1,81 / 2,42 ms | 4,69 / 5,41 ms | 30,4 MiB |
| FrankenPHP (worker) | 25.659/sn | 6.415 | 4,77 / 4,96 ms | 8,80 / 9,06 ms | 60,3 MiB |
| PHP-FPM | 20.606/sn | 5.152 | 6,03 / 6,24 ms | 8,27 / 8,45 ms | 60,0 MiB |
Tabloya yazacağınız sayı bu değil
Dört çekirdekteki 57.321 yalnız dört çekirdek için doğru. Kendi hedefinize taşıyan sayı çekirdek başına istek değil, istek başına CPU:
İstek başına uygulama CPU'su, doyum tavanında
Adayın cgroup CPU sayacı, cevaplanan istek sayısına bölündü. Veritabanının CPU'su dahil değil.
- Go
- FrankenPHP (worker)
- PHP-FPM
µs düşük olan iyi Kaynak: Doyum tavanı koşuları, en iyi geçerli tekrar
Veri tablosu
| Seri | 1 çekirdek | 2 çekirdek | 4 çekirdek |
|---|---|---|---|
| Go | 57,8 µs | 63,65 µs | 66,77 µs |
| FrankenPHP (worker) | 82,77 µs | 100,68 µs | 110,74 µs |
| PHP-FPM | 157,07 µs | 173,83 µs | 187,74 µs |
Grafik tarayıcıda çizilir; aşağıdaki tablo aynı veriyi taşır.
Bu sayı hedef hızla çarpıldığında gereken çekirdeği veriyor. Saniyede 30.000 karma istek için Go’da hesap 30.000 × 66,8 µs = 2,0 çekirdek, FrankenPHP’de 3,3, PHP-FPM’de 5,6.
Üç adayda da istek başına CPU çekirdek sayısı arttıkça yükseliyor — Go’da 57,8’den 66,77’ye, PHP-FPM’de 157,07’den 187,74’e. Bu, ölçeklemenin neden doğrusal olmadığının sayaç tarafındaki karşılığı: eşzamanlılık arttıkça bağlam değişimi, kilit ve önbellek kaçırma maliyeti istek başına payı büyütüyor. Planlama yaparken hedeflediğiniz instance boyutunun sütununu kullanın, tek çekirdeğin sayısını dörtle çarpmayın.
FrankenPHP’nin kullanamadığı çekirdekler
FrankenPHP’nin istek başına CPU’su PHP-FPM’inkinin %41 altında (110,74’e karşı 187,74 µs). Bu tasarruf kapasiteye tam dönüşmüyor, çünkü doyduğunda çekirdeklerini bitiremiyor:
| Aday | 1 çekirdekte kullanılan | 2 çekirdekte | 4 çekirdekte |
|---|---|---|---|
| Go | 0,98 (%98) | 1,87 (%93,5) | 3,83 (%95,8) |
| PHP-FPM | 0,98 (%98) | 1,95 (%97,5) | 3,87 (%96,8) |
| FrankenPHP (worker) | 0,54 (%54) | 1,44 (%72) | 2,84 (%71) |
Tek çekirdekte FrankenPHP, PHP-FPM’den yalnız %5 daha fazla istek taşıyor (6.544’e karşı 6.218) — oysa istek başına CPU’su neredeyse yarısı. Aradaki kazancın tamamı boşta kalan çekirdekte kayboluyor. İki ve dört çekirdekte darboğaz gevşiyor ve fark açılıyor (%28 ve %25), ama üçte birlik bütçe hâlâ kullanılmıyor.
Darboğazın nerede olduğunu bu kayıt ölçmedi. Havuz boyutu değil: 8, 16, 32 ve 64 worker denendi ve FrankenPHP her çekirdek bütçesinde 8’i seçti, yani daha fazla eşzamanlılık ona yardım etmiyor. Caddy ile PHP thread’leri arasındaki geçişte ya da ZTS derlemesinde olabilir. Pratik sonuç şu: FrankenPHP’ye geçmek aynı işi daha az CPU zamanıyla yapıyor, ama ödediğiniz çekirdeğin üçte birini size geri vermiyor.
Darboğaz veritabanı değil
FrankenPHP’nin boşta bıraktığı 1,16 çekirdek uygulama tarafında. Peki aynı yükü altta taşıyan PostgreSQL nerede duruyor?
| Ölçüm | Değer | Kullanılan çekirdek (4 üzerinden) |
|---|---|---|
| PostgreSQL tek başına, pgbench INSERT | 68.212 satır/sn | 3,45 |
| Go doyarken veritabanının yaptığı iş | 24.930 yazma + 32.392 okuma/sn | 2,34 |
| FrankenPHP doyarken | 12.575 yazma + 13.084 okuma/sn | 1,11 |
| PHP-FPM doyarken | 10.128 yazma + 10.478 okuma/sn | 1,46 |
En hızlı aday tavanındayken bile PostgreSQL dört çekirdeğinin 2,34’ünü kullanıyor ve kendi tavanı o noktanın epey üstünde. Yani bu iş yükünde seçim tamamen uygulama runtime’ında: veritabanını değiştirmek kapasite tablosunu kıpırdatmıyor, dili değiştirmek 2,78 kat kıpırdatıyor.
Bir ayrıntı yine de veritabanı tarafında görünüyor. İstek başına PostgreSQL CPU’su Go’da 40,75 µs, FrankenPHP’de 43,2 µs, PHP-FPM’de 71,07 µs. Statement’ı tekrar kullanabilen iki aday birbirine yakın; kullanamayan PHP-FPM veritabanına istek başına %74 daha fazla iş yaptırıyor. Muhtemel sebep her sorgunun yeniden ayrıştırılıp planlanması; bu kayıt bunu ayrıca ölçmedi.
Her adaya kendi en iyi havuzu
Bu tür karşılaştırmalara gelen ilk itiraz “PHP’yi kötü ayarlamışsın” olur. Bu yüzden havuz boyutu ölçümün öncesinde ayrı bir faz olarak süpürüldü: her aday, her çekirdek bütçesinde 8, 16, 32 ve 64 ile ikişer kez tam gaz koşturuldu ve kendi en iyisiyle ölçüme girdi. Havuz boyutu aynı zamanda veritabanı bağlantı tavanıdır, yani üç adayda da tek bir düğme.
| Aday | 1 çekirdek | 2 çekirdek | 4 çekirdek | En kötü seçimin bedeli |
|---|---|---|---|---|
| Go | 16 | 64 | 64 | %70,6 (4c: 8 worker 32.666, 64 worker 55.715) |
| PHP-FPM | 8 | 16 | 32 | %30,7 (1c: 64 worker 4.734, 8 worker 6.188) |
| FrankenPHP (worker) | 8 | 8 | 8 | %29,6 (2c: 32 worker 11.252, 8 worker 14.580) |
Sabit bir sayı üçünden hiçbirinin en iyisi değil ve yön de aynı değil: Go çekirdek arttıkça daha fazla eşzamanlılık istiyor, iki PHP adayı istemiyor. Tek çekirdekte 64 worker’lı php-fpm, 8 worker’lıdan %24 daha az iş çıkarıyor.
Hedefinizi çekirdeğe çevirmek
| Hedef hız | Go | FrankenPHP | PHP-FPM |
|---|---|---|---|
| 10.000 istek/sn | 1 | 1 | 1 |
| 20.000 istek/sn | 1 | 1 | 1 |
| 30.000 istek/sn | 1 | 2 | 2 |
| 50.000 istek/sn | 1 | 2 | 3 |
| 100.000 istek/sn | 2 | 4 | 5 |
Nasıl ölçtüm
- Bütçe. Docker VM’inin 12 vCPU’su ayrık üç kümeye bölündü: aday 0-3, PostgreSQL 4-7, yük üreteci 8-11. php-fpm’in nginx’i de adayın bütçesinin içinde; Go ve FrankenPHP HTTP’yi kendileri karşılıyor. Bellek her çekirdek bütçesinde 1 GiB sabit, böylece eksen yalnız çekirdek.
- Her hücre aynı yerden başlıyor. Her hücre, tohumlanmış veritabanının
CREATE DATABASE … TEMPLATEile alınmış bir kopyasıyla başlıyor; kopyapg_prewarmile önbelleğe çekiliyor,CHECKPOINTalınıyor ve taze bir aday konteyneri açılıp ısıtılıyor. - Üreteç yoklaması. Her tekrardan önce, uygulama kodu çalıştırmayan bir nginx adayın kendi çekirdeklerine konuyor ve aynı iki üreteçle adayın beklenen tavanının %115’i isteniyor. Yoklamanın altında kalan tekrar adayı değil üreteci ölçmüş olur ve elenir. 45 tekrarın 45’i geçti.
- Tahminci en iyi tekrar. Ölçüm bir dizüstünde koşuyor ve makinedeki her iş ölçümden düşüyor; girişim yalnız aşağı çekebilir. Bu yüzden her tekrarın yanına macOS’un o anki yükü de kaydedildi.
- Bağımsız doğrulama. Aynı tavanlar, on iki saat önce farklı bir bağlantı sayısıyla koşan havuz süpürmesinde de ölçüldü ve iki bağımsız ölçüm birkaç yüzde içinde örtüştü.
Sınırlar ve dürüstlük notları
- Servis seviyeli kapasite ölçülemedi. “p99 10 ms’nin altında kalarak en
fazla kaç istek” sorusunu üç ayrı yöntemle ölçmeye çalıştım; üçü de bu
tezgâhta güvenilmez çıktı ve elendi. Sebep, open-loop ölçümde sınırın bir
eğim değil uçurum olması: servis yavaşlayınca yük üreteci takvimden geri
kalıyor ve gecikme düzeltmesi bunu üste yazıyor; uçurumun yeri de tekrarlar
arasında geziyor. Aynı hücrenin tekrarları beş kata kadar ayrıştı. Elenen üç
denemenin ham verisi ve teşhisi
EXCLUDED.md
dosyasında duruyor. Bu yüzden yayınlanan sayı doyum tavanıdır ve
confidencealanımedium. - Tek makine, dizüstü. 12 vCPU’nun tamamı Docker VM’ine verildiği için macOS’ta koşan her iş doğrudan ölçümden düşüyor. Bu bir varsayım değil, ölçülmüş bir olay: bir koşu sırasında ana makinede headless Chromium süreçleri on çekirdeğin üstünü kullandı ve aynı Go servisi 57.321 yerine 17.490 istek/sn ölçüldü. O koşu atıldı, yoklama kapısı ve ana makine yükü kaydı bu yüzden eklendi.
- Karışım %50/%50. Kendi trafiğiniz okuma ağırlıklıysa üç adayın da tavanı yükselir, çünkü okuma, yazmadan ucuz. Oran sizin profilinizle değişir; istek başına CPU’yu okuma ve yazma için ayrı ayrı ölçmek bu kaydın kapsamında değildi.
- Disk garantisi doğrulanmadı. Docker Desktop VM diskindeki
fsync’in çıplak donanımla aynı garantiyi verip vermediğini kontrol etmedim. Mutlak yazma rakamları iyimser olabilir; üç adayın karşılaştırması etkilenmiyor, çünkü üçü aynı veritabanına yazıyor. - Kapsam dışı. Framework’ler, PHP JIT, Swoole ile RoadRunner, PostgreSQL’e alternatif veritabanları, token üreten authorization server ve kodu Go’ya taşımanın ekip maliyeti ölçülmedi. Framework’lerin kendi maliyeti PHP framework yük testi kaydında.
Kendi sayınızı üretmek için php-go-bench deposunu kendi token biçiminiz ve kendi sorgunuzla, kendi donanımınızda koşturun. Tablonuza yazacağınız değer istek başına CPU; gerisi çarpma.
İlgili yazılar
18.750 istek/sn: kesin, tekrarlanmış ve üç kat yanlış
Aynı koşunun iki fazı aynı servisi üç kat farklı ölçtü. Yanlış olanı yakalayan şey daha iyi bir istatistik değil, aynı şeyi ölçen ikinci bir yöntemdi.
Go'da HTTP servisi yazmak: standart kütüphane yeter mi
Go'nun net/http paketiyle küçük bir HTTP servisi kurarken neyin geldiğini, neyin gelmediğini ve framework eşiğini tartışıyorum.
Go'nun standart kütüphanesiyle ne kadar uzağa gidilir
Go'nun zengin standart kütüphanesinin gerçekte ne kadar yol kat ettirdiğini, harici bağımlılık ekleme kararını somut örneklerle tartıyorum.