PHP ile Go'yu aynı OAuth2 API'de ölçtüm: 10.000 yazmada fark yok, 50.000 okumada var
Her istekte OAuth2 token'ı doğrulayıp PostgreSQL'e yazan ya da okuyan aynı API, dört çekirdekte PHP-FPM, FrankenPHP worker ve Go ile saniyede 10.000 yazmayı ve 50.000 okumayı kaç CPU'yla karşılıyor?
Bulgu
Saniyede 10.000 yazmada üç aday da hedefi beş tekrarın beşinde tutturdu, p99 üçünde de 2,5 ms'yi aşmadı: bu yükte dil bir kapasite kalemi değil. Saniyede 50.000 okumayı dört çekirdekte yalnız Go tutturdu (p99 6,45 ms); PHP-FPM 23.528'de, FrankenPHP 22.859'da kaldı. İstek başına CPU okumada Go'da 64, FrankenPHP'de 117, PHP-FPM'de 168 mikrosaniye. 50.000 okuma için instance hesabı Go'ya 3,4, iki PHP adayına 8,2–8,8 çekirdek yazdırıyor. FrankenPHP'nin CPU tasarrufu kapasiteye dönmüyor: doygunken dört çekirdeğin birini boşta bırakıyor.
- 10.000 yazma/sn · üç aday da tuttu
- p99 ≤ 2,50 ms
- 50.000 okuma/sn · yalnız Go tuttu
- p99 6,45 ms
- Okuma başına CPU · Go → PHP-FPM
- 64 → 168 µs
- Doğrulama tavanı · Go / en iyi PHP
- 83.526 / 32.953
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 ve iki PHP adayı aynı sınıfı çalıştırıyor. Her aday dört sabitlenmiş çekirdek, 1 GiB bellek ve en fazla 32 veritabanı bağlantısı aldı; php-fpm'in nginx'i de bu bütçenin içinde. PostgreSQL 17.11 ayrı dört çekirdekte, oha 1.15.0 ayrı dört çekirdekte koştu. Her blok, önbelleğe çekilmiş 1.000.000 satırlık tablonun birebir kopyasıyla ve taze bir aday konteyneriyle başladı. Sabit hız fazı open loop modunda koştu (oha -q, latency correction, 256 bağlantı, 60 sn, 5 tekrar) ve medyan raporlandı. Tavan fazı closed loop modunda 16/64/128/256 bağlantıyla koştu (15 sn, 3 tekrar) ve en iyi tekrar raporlandı. CPU ve bellek her koşuda cgroup sayaçlarından okundu. Her bloktan önce uygulama kodu çalıştırmayan bir nginx, adayın çekirdeklerinde 50.000/sn ile sınandı; 24 bloğun hepsinde en az 49.980/sn verdi. Toplam 153 yük ölçümü, 123 milyon yanıt, sıfır non-2xx.
- Ölçüm tarihi
- Yayın
bugü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 · 32 worker
- FrankenPHP
- 1.12.7 (Caddy 2.11.4) · worker modu · 32 worker · ZTS
- Veritabanı
- PostgreSQL 17.11 · 1.000.000 satır · en fazla 32 bağlantı · synchronous_commit açık
- Yük üreteci
- oha 1.15.0 · open loop + latency correction · 256 bağlantı
- 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
- Tekrar
- sabit hız 5 × 60 sn (medyan) · tavan 3 × 15 sn (en iyisi)
Teknolojiler
Tekrarlamak için
./bench/build.sh && ./bench/verify.sh && STAMP=$(date -u +%F) ./bench/run.sh --all “Go’ya geçersek kaç sunucu kazanırız?” sorusuna genellikle “Go daha hızlı” cevabı veriliyor. Bu cevapla kapasite tablosu doldurulamıyor. Bu kayıtta aynı OAuth2 korumalı API’yi PHP-FPM, FrankenPHP worker ve Go ile üç kez yazdım. Üçünü aynı dört çekirdekte, aynı PostgreSQL’e karşı ölçtüm ve farkı kendi hedefinize göre çekirdeğe çevirebileceğiniz bir sayıya indirdim: istek başına CPU. 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.
| Senaryo | İstek | Doğrulamadan sonra ne oluyor | Hedef hız |
|---|---|---|---|
| Doğrulama | GET /auth | hiçbir şey — veritabanı yok | 50.000/sn |
| Okuma | GET /events/{id} | 1.000.000 satırlık tablodan birincil anahtarla tek satır | 50.000/sn |
| Yazma | POST /events | JSON gövde doğrulaması, tek INSERT … RETURNING | 10.000/sn |
Framework yok. İki PHP adayı aynı Api.php sınıfını çalıştırıyor, yalnız giriş
dosyaları farklı. Her aday, süreç ya da istek ömrünün izin verdiği en hızlı
veritabanı idiom’unu kullanıyor:
- Go: pgx, prepared statement’ları bağlantı başına önbelleğe alıyor.
- FrankenPHP worker: statement’ı bir kez hazırlayıp tekrar kullanıyor.
- php-fpm: bir isteğin ömrü statement tutmaya yetmediği için sorguyu tek gidiş-dönüşlük parametreli çağrıyla gönderiyor.
Hedef hızda: 10.000 yazma herkes için kolay, 50.000 okuma değil
Sabit hedef hızda tutturulan istek/sn
Yazmada dört sütun aynı boyda. İki PHP adayı doğrulamada hedefin %61–66'sında, okumada yarısı civarında kalıyor; tutturamadıkları istekler yük üretecinin kuyruğunda bekliyor.
- Hedef
- Go
- FrankenPHP (worker)
- PHP-FPM
Kaynak: 5 tekrarın medyanı, open loop, 256 bağlantı, 60 sn — bench/report.mjs
Veri tablosu
| Seri | Yalnız doğrulama | Doğrulama + okuma | Doğrulama + yazma |
|---|---|---|---|
| Hedef | 50.000 | 50.000 | 10.000 |
| Go | 49.999 | 49.999 | 10.000 |
| FrankenPHP (worker) | 33.089 | 22.859 | 10.000 |
| PHP-FPM | 30.606 | 23.528 | 10.000 |
Grafik tarayıcıda çizilir; aşağıdaki tablo aynı veriyi taşır.
Saniyede 10.000 yazmada üç aday da hedefi beş tekrarın beşinde tutturdu. Üç adayın p99 değerleri yan yana: FrankenPHP 1,81 ms, PHP-FPM 1,88 ms, Go 2,50 ms. Bu yükte en düşük p99 bir PHP adayından geldi; Go ile aradaki fark da bir milisaniyenin altında. Bu hedefte dil, kapasite tablonuzda bir satır bile değil.
Hedef 50.000 okumaya çıkınca tablo değişiyor. Go beş tekrarın beşinde 50.000’i p99 6,45 ms ile tutturdu. PHP-FPM 23.528’de kaldı ve dört çekirdeğin 3,9’unu kullandı. FrankenPHP 22.859’da kaldı ama 2,7 çekirdekle; bu farka aşağıda döneceğim. Open loop ölçümde tutturulamayan istek kuyrukta bekliyor. Bu yüzden iki PHP adayının doğrulama ve okumadaki gecikmesi (p99 20–32 saniye) bir servis gecikmesi değil; yalnız “bu hedef bu bütçeyle karşılanamaz” anlamına geliyor.
Farkı belirleyen: istek başına CPU
İstek başına uygulama CPU'su
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: Sabit hız fazı, 5 tekrarın medyanı
Veri tablosu
| Seri | Yalnız doğrulama | Doğrulama + okuma | Doğrulama + yazma |
|---|---|---|---|
| Go | 47 µs | 64 µs | 96 µs |
| FrankenPHP (worker) | 88 µs | 117 µs | 146 µs |
| PHP-FPM | 130 µs | 168 µs | 207 µ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. Adayın hedefi tutturduğu her ölçümde hesap, sayacın gösterdiği çekirdekle örtüşüyor. Go’da okuma için hesap 50.000 × 64 µs = 3,2 çekirdek, sayaç 3,20. PHP-FPM’de yazma için hesap 10.000 × 207 µs = 2,07 çekirdek, sayaç da 2,07.
50.000 okuma için iki farklı hesap yapılabiliyor ve iki PHP adayı bu iki hesapta farklı yere düşüyor:
| 50.000 okuma/sn | CPU zamanı hesabı | Go'ya oranı | Instance hesabı | Go'ya oranı |
|---|---|---|---|---|
| Go | 3,2 çekirdek (ölçüldü) | 1,0× | 3,4 çekirdek | 1,0× |
| FrankenPHP (worker) | 5,8 çekirdek | 1,8× | 8,8 çekirdek | 2,6× |
| PHP-FPM | 8,4 çekirdek | 2,6× | 8,2 çekirdek | 2,4× |
PHP-FPM’de iki hesap aynı yere çıkıyor, çünkü PHP-FPM dört çekirdeğin tamamını kullanıyor. FrankenPHP’de ise çıkmıyor.
CPU nereye gidiyor: dörtte üçü token’a
Doğrulama senaryosu veritabanına hiç gitmiyor, bu yüzden bir okuma isteğinin CPU’sunun ne kadarının OAuth2’ye gittiğini ayırmayı sağlıyor. Üç adayda da oran aynı bantta: token doğrulaması, okuma isteğinin uygulama CPU’sunun Go’da %73’ünü, FrankenPHP’de %75’ini, PHP-FPM’de %77’sini oluşturuyor. Oran yaklaşık, çünkü doğrulama yanıtı okuma yanıtından küçük; yine de kapasite açısından sonuç net. Bu API’de satırı okumak değil, RS256 imzasını doğrulamak pahalı. Dillerin arasındaki farkın büyük kısmı da imza doğrulamanın kendisinden geliyor: Go 47 µs, PHP-FPM 130 µs.
Veritabanının tarafı ayrı bir satır. Aynı sorgu için PostgreSQL’in harcadığı CPU’yu istek başına böldüğümde üç aday iki gruba ayrıldı:
| PostgreSQL CPU / istek | Okuma | Yazma |
|---|---|---|
| Go | 34 µs | 48 µs |
| FrankenPHP (worker) | 35 µs | 51 µs |
| PHP-FPM | 53 µs | 81 µs |
Statement’ı tekrar kullanan iki aday (Go ve FrankenPHP) birbirine çok yakın. Kullanamayan PHP-FPM ise veritabanına istek başına Go’ya göre okumada %56, yazmada %69 daha fazla iş yaptırıyor. Muhtemel sebep, her sorgunun her seferinde yeniden ayrıştırılıp planlanması; bu kayıt bunu ayrıca ölçmedi. Kapasite tablosunda bunun anlamı şu: PHP-FPM’den Go’ya geçiş, uygulama çekirdeklerinin yanında veritabanı çekirdeğini de azaltıyor. FrankenPHP’ye geçiş de aynı kazancın veritabanı tarafını büyük ölçüde sağlıyor.
FrankenPHP’nin tasarrufu kapasiteye dönmüyor
Worker modu PHP’nin istek başına CPU’sunu üç senaryoda da PHP-FPM’e göre üçte bir civarında düşürüyor (okumada 168’den 117 µs’ye). Tavan ise neredeyse kıpırdamıyor:
Dört çekirdekte tavan: saniyede karşılanan istek
Go, veritabanı olmadan 83.526'ya çıkıyor, iki PHP adayı 33.000'in altında kalıyor. Yazma satırı veritabanının I/O'suna bağlı ve gürültülü; aşağıdaki nota bakın.
- Go
- FrankenPHP (worker)
- PHP-FPM
Kaynak: 16/64/128/256 bağlantılık süpürmenin en iyi hücresi, 3 tekrarın en iyisi, 15 sn
Veri tablosu
| Seri | Yalnız doğrulama | Doğrulama + okuma | Doğrulama + yazma |
|---|---|---|---|
| Go | 83.526 | 59.004 | 43.942 |
| FrankenPHP (worker) | 32.953 | 22.613 | 22.874 |
| PHP-FPM | 31.157 | 24.437 | 21.857 |
Grafik tarayıcıda çizilir; aşağıdaki tablo aynı veriyi taşır.
FrankenPHP doygunken bile dört çekirdeğin ancak 2,2 ile 2,9’unu kullanıyor; PHP-FPM aynı koşularda 3,3 ile 4,0 arasında. Yani darboğaz CPU değil, başka bir yerde: 32 worker’lık havuzda, Caddy ile PHP thread’leri arasındaki geçişte ya da ZTS derlemesinde olabilir. Bu kayıt darboğazın nerede olduğunu ölçmedi. Pratik sonuç şu: FrankenPHP’ye geçmek aynı işi daha az CPU zamanıyla yapıyor, ama bu bütçede aynı instance’tan daha fazla istek almıyor.
Bellek tarafında sıralama başka:
| Aday | Tepe RSS | Not |
|---|---|---|
| Go | ~27 MiB | tek süreç, 32 bağlantılık havuz |
| PHP-FPM | ~63 MiB | 32 worker + 2 nginx worker |
| FrankenPHP (worker) | ~137 MiB | 32 worker thread ve Caddy, tek süreç |
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 dört çekirdeğinin içinde; Go ve FrankenPHP HTTP’yi kendileri karşılıyor.
- Her blok aynı yerden başlıyor. Tablo
CREATE DATABASE … TEMPLATEile birebir kopyalanıyor vepg_prewarmile önbelleğe çekiliyor. ArdındanCHECKPOINTalınıyor, taze aday konteyneri açılıyor ve her senaryo 5 saniye ısıtılıyor. Tabloyu değiştiren tek senaryo yazma olduğu için hep sonda koşuyor. - Referans ölçümü. Her bloktan önce, uygulama kodu çalıştırmayan bir nginx adayın çekirdeklerine konup saniyede 50.000 istekle sınandı. Yük üreteci bu hızı bile tutturamasaydı hiçbir aday tutturamazdı. En düşük değer 49.980/sn oldu.
- İki tahminci. Sabit hız fazında hedef sabit olduğu için tekrarların medyanı raporlandı. Tavan fazında makine sakinleştirilemediği ve girişim throughput’u yalnız aşağı çekebildiği için en iyi tekrar raporlandı. İki faz birbirini doğruluyor: PHP-FPM doğrulamada tavanda 31.157, sabit hızın medyanında 30.606 verdi; FrankenPHP 32.953 ve 33.089.
Sınırlar ve dürüstlük notları
- Tek makine, dizüstü. Aday, veritabanı ve yük üreteci ayrı çekirdeklerde ama tek bir VM’de ve tek bir Linux kernel’inde koşuyor; ağ Docker bridge’i, gerçek bir kablo değil. 12 vCPU’nun performans çekirdeğine ya da verimlilik çekirdeğine denk gelmesini macOS belirliyor.
- Yazma tavanı gürültülü. Go’nun yazma tavanı tekrarlar arasında 9.649 ile 43.942 arasında gezdi. O koşularda ne aday ne veritabanı CPU’da doymuştu; sınır büyük olasılıkla VM diskindeki WAL yazımı, ama bu kayıt onu doğrudan ölçmedi. Bu yüzden yazma tavanını karşılaştırma için değil, bağlam için okuyun. Sabit 10.000 yazma sonuçları bu gürültüden etkilenmiyor: 15 koşunun 15’i hedefi tuttu.
- 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. - Tek token. Her istekte aynı token gönderildi. Adaylar doğrulamayı önbelleğe almıyor, ama gerçek bir resource server farklı token’lar görür.
- Bir blok yeniden ölçüldü. Tavan fazında FrankenPHP’nin 3. tekrarı sürerken makine uykuya geçti. Blok baştan ölçüldü. Kesilen bloğun ham dosyaları kenara alınamadı ve yeniden ölçüm aynı adlarla onların üzerine yazdı. Bunun nasıl olduğu EXCLUDED.md dosyasında yazıyor.
- Kapsam dışı. Framework’ler, Swoole ile RoadRunner, JIT, 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
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.