# PHP ile Go'yu aynı OAuth2 API'de ölçtüm: 10.000 yazmada fark yok, 50.000 okumada var

> Aynı OAuth2 + PostgreSQL API'si PHP-FPM, FrankenPHP ve Go ile dört çekirdekte ölçüldü: sabit hedef hızda gecikme, tavan ve istek başına CPU.

- Tür: Ölçüm
- Soru: 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.
- 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.
- Metrikler: 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
- Ölçüm tarihi: 2026-09-16
- Güven: Orta güven
- Durum: Yürürlükte
- Program: Servis & yük
- 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: Go, PHP, FrankenPHP, PHP-FPM, PostgreSQL, OAuth2, JWT, Docker, nginx
- Tekrarlamak için: ./bench/build.sh && ./bench/verify.sh && STAMP=$(date -u +%F) ./bench/run.sh --all
- Kaynak kodu: https://github.com/muhammetsafak/php-go-bench
- Ham veri: https://github.com/muhammetsafak/php-go-bench/tree/main/results/2026-09-16
- Ham veri lisansı: https://github.com/muhammetsafak/php-go-bench/blob/main/LICENSE
- Yayın: 2026-09-16
- Kaynak: https://www.muhammetsafak.com.tr/research/php-go-oauth2-postgres-yuk-testi/
- Dil: tr-TR
- Yazar: Muhammet Şafak

---
"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](https://github.com/muhammetsafak/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 |

Doğrulama senaryosu kendi başına bir hedef değil: OAuth2'nin veritabanından bağımsız maliyetini ayırmak için var.

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.

Kaynak: 5 tekrarın medyanı, open loop, 256 bağlantı, 60 sn — bench/report.mjs

|  | Yalnız doğrulama | Doğrulama + okuma | Doğrulama + yazma |
| --- | --- | --- | --- |
| Hedef | 50000 | 50000 | 10000 |
| Go | 49999 | 49999 | 10000 |
| FrankenPHP (worker) | 33089 | 22859 | 10000 |
| PHP-FPM | 30606 | 23528 | 10000 |

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.

Kaynak: Sabit hız fazı, 5 tekrarın medyanı

|  | 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 |

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× |

CPU zamanı hesabı: hedef × istek başına CPU. Instance hesabı: hedef ÷ dört çekirdekli bir instance'ın tavanı × 4. Go'nun CPU zamanı hücresi dışındaki bütün hücreler ölçüm değil, ölçümden yapılan hesap; yatay ölçeklemenin doğrusal olduğunu varsayıyor. Veritabanı çekirdekleri dahil değil.

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 |

Veritabanı konteynerinin cgroup CPU sayacı, cevaplanan istek sayısına bölündü; sabit hız fazı, 5 tekrarın medyanı. Veritabanı her adayda aynı dört çekirdekte ve aynı ayarlarla koştu.

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.

Kaynak: 16/64/128/256 bağlantılık süpürmenin en iyi hücresi, 3 tekrarın en iyisi, 15 sn

|  | Yalnız doğrulama | Doğrulama + okuma | Doğrulama + yazma |
| --- | --- | --- | --- |
| Go | 83526 | 59004 | 43942 |
| FrankenPHP (worker) | 32953 | 22613 | 22874 |
| PHP-FPM | 31157 | 24437 | 21857 |

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ç |

Adayın cgroup'undaki anonim belleğin tepe değeri, sabit hız fazı. Hiçbiri 1 GiB'lık sınırın yanına yaklaşmadı.

> **Sonuç**
>
> Saniyede 10.000 yazma hedefiniz varsa PHP'den Go'ya geçmek kapasite tablonuzu
> değiştirmiyor: üçü de bir iki çekirdekle yetişiyor. 50.000 okuma hedefinde ise
> ölçtüğüm iki PHP runtime'ı da Go'ya ayırdığınızın iki katından fazla çekirdek
> istiyor. Kendi hedefiniz için hesap, hedef hız × istek başına CPU; FrankenPHP'de
> buna boşta kalan çekirdeği de ekleyin.

## 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 … TEMPLATE` ile
  birebir kopyalanıyor ve `pg_prewarm` ile önbelleğe çekiliyor. Ardından
  `CHECKPOINT` alı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ı

1. **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.
2. **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.
3. **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.
4. **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.
5. **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](https://github.com/muhammetsafak/php-go-bench/blob/main/results/2026-09-16/EXCLUDED.md)
   dosyasında yazıyor.
6. **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](/research/php-framework-yuk-testi/) kaydında.

Kendi sayınızı üretmek için
[php-go-bench](https://github.com/muhammetsafak/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.
