Kod Savaşları: ORM mi, Saf SQL mi? – Masadaki Tercüman
Eloquent ile saf SQL'i aynı MariaDB'de dört senaryoda ölçtüm. En hızlı ve en yavaş satırın ikisi de elle yazılmış SQL çıktı; farkı ORM değil, okunan sorgu planı belirledi.
Bu yazının İngilizcesi de var →
Bu blogda 2023'te kendi ORM'imi yazdım. Bir yıl sonra N+1 yazısında JOIN'i "en temel yöntem" diye ilk sıraya koydum. Bu hafta ikisini aynı masaya oturtup ölçtüm ve iki yazıda da bir yerde fazla emin konuşmuşum.
Kod Savaşları'nın bu turunda ORM vs SQL tartışmasını taraf tutarak değil, sayaçla yapıyoruz. Bir tarafta Laravel'in ORM'i Eloquent, öbür tarafta düz PDO ile yazılmış saf SQL. Aynı MariaDB, aynı veri, dört senaryo.
Metaforumuz tercüman. Kılıçlar çekildi, iki taraf masaya oturdu: bir yanda nesnelerle konuşan PHP kodunuz, öbür yanda tablolarla konuşan veritabanı. ORM aradaki tercüman; saf SQL yazmak ise karşı tarafın dilini kendiniz konuşmak. Ölçümün kısa hâli: tercümanı kötü kullanan da kaybediyor, dili yanlış konuşan da.
Masanın Kurulumu#
Ölçümleri kendi sandbox'ımda yaptım: PHP 8.3.6 CLI, MariaDB 10.11.14, Eloquent 13.38.0 (Laravel'in tamamı olmadan, Capsule ile tek başına), bağlantı Unix soketi üzerinden. Veri 2.000 müşteri ve 100.000 sipariş:
CREATE TABLE musteriler (
id INT UNSIGNED PRIMARY KEY,
ad VARCHAR(80) NOT NULL,
sehir VARCHAR(40) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE siparisler (
id INT UNSIGNED PRIMARY KEY,
musteri_id INT UNSIGNED NOT NULL,
durum VARCHAR(20) NOT NULL,
tutar DECIMAL(10,2) NOT NULL,
olusturma DATETIME NOT NULL,
KEY (musteri_id),
KEY (olusturma),
FOREIGN KEY (musteri_id) REFERENCES musteriler(id)
) ENGINE=InnoDB;
Her senaryo bir ısınma turundan sonra 5 ile 30 kez çalıştı; tablolardaki süre medyan. Üç ayrı koşuda milisaniyeler biraz oynadı, aralık verdiğim yerler bu yüzden. Sorgu sayılarını Eloquent'in sorgu günlüğü ve aşağıdaki sayaç verdi.
Teknik Detay: Soket bağlantısında bir sorgunun ağ maliyeti neredeyse sıfır. Uygulama ile veritabanı ayrı makinelerdeyse her sorguya bir gidiş-dönüş eklenir. Yani N+1'in buradaki rakamı gerçek hayatta iyimser kalır.
Birinci Tur: Son 50 Sipariş ve Müşteri Adı#
En sıradan liste ekranı: son 50 sipariş, yanında müşterinin adı.
| Yöntem | Sorgu | Süre (medyan) |
|---|---|---|
| Eloquent, tembel yükleme | 51 | 13,8–15,5 ms |
Eloquent, with('musteri') |
2 | 2,3–3,3 ms |
| Elle yazılmış JOIN | 1 | 171–200 ms |
Eloquent join() |
1 | 183–198 ms |
| Elle JOIN, siparişler önce seçilerek | 1 | 0,31–0,33 ms |
Tercüman cümle cümle çevirince#
İlk satır bilinen hikâye. $siparis->musteri->ad masum görünüyor ama döngüdeki her sipariş için tercüman ayrı bir cümle kuruyor: liste sorgusu artı 50 müşteri sorgusu. with() ile aynı iş iki sorguya iniyor; biri siparişler, öbürü where id in (...) ile müşteriler.
Acı Gerçek: N+1 yazısında with() için "veriyi tek bir sorguda getirir" yazmışız. Sayaç iki diyor. Eager loading bir JOIN değil; ikinci sorgu, birincinin getirdiği anahtarlarla atılıyor.
Tembel yüklemeyi geliştirme ortamında kapatmak Eloquent'te tek satır:
Model::preventLazyLoading();
Denedim. İki siparişlik listede $s->musteri çağrısı şu hatayı fırlattı: Attempted to lazy load [musteri] on model [Siparis] but lazy loading is disabled. Tek kayıt dönen sorguda ise hata gelmedi. Kaynak koda bakınca sebep belli: Builder::hydrate() bu bayrağı yalnızca birden fazla satır geldiğinde koyuyor. Tek kayıtta N+1 olmaz, mantıklı. Ama testinizdeki fikstür tek satırlıksa "testte hata yoktu" cümlesi hiçbir şey kanıtlamıyor.
Kendi dilinizi yanlış konuşunca#
Asıl sürpriz üçüncü satırda. "Tek sorgu her zaman iki sorgudan iyidir" diye elle yazdığım JOIN, ORM'in iki sorgusundan 50 kattan fazla yavaş çıktı. ANALYZE FORMAT=JSON ne olduğunu söylüyor: MariaDB önce 2.000 müşterinin hepsini okudu, her biri için siparişlere indi, 100.000 siparişi geçici tabloya koyup sıraladı. Dokunulan sayfa sayısı 204.137. Oysa olusturma index'i oradaydı ve sondan 50 satır okumak 118 sayfaya mal oluyordu.
Eloquent'in join()'i de aynı plana düştü. Şaşırtıcı değil, o da aynı SQL'i üretiyor. Tercümana yanlış cümleyi söylerseniz yanlış cümleyi doğru çevirir.
Düzeltme, siparişleri önce kendi index'iyle seçtirip müşteriyi sonra bağlamak:
SELECT s.id, s.tutar, m.ad
FROM (
SELECT id, musteri_id, tutar, olusturma
FROM siparisler
ORDER BY olusturma DESC
LIMIT 50
) s
JOIN musteriler m ON m.id = s.musteri_id
ORDER BY s.olusturma DESC;
Süre 0,32 ms; siparişlerde 118, müşterilerde 100 sayfa. STRAIGHT_JOIN ile join sırasını zorlamak da aynı planı verdi, ama o ipucu optimizer'ın elini kalıcı olarak bağlıyor; veri dağılımı değişince bugün doğru olan sıra yarın yanlış olabilir. Bu plan MariaDB 10.11.14'te ve bu veride çıktı; başka sürümde ya da başka dağılımda optimizer başka bir yol seçebilir. Bu yüzden ders sürümden bağımsız. Index seçimi yazısında olduğu gibi, plan okunmadan "tek sorgu" bir erdem değil.
İkinci Tur: 10.000 Satırı Belleğe Almak#
| Yöntem | Sorgu | Süre (medyan) | Ek bellek |
|---|---|---|---|
Eloquent get() |
1 | 133–181 ms | 16,5 MiB |
Eloquent toBase()->get() |
1 | 10–18 ms | 5,9 MiB |
Eloquent lazy(1000) |
11 | 160–177 ms | 1,7 MiB |
PDO fetchAll() |
1 | 11–12,5 ms | 5,5 MiB |
Tercümanın ücreti burada görünüyor. Aynı satırlardan 10.000 model nesnesi kurmak, düz dizi almaktan on kattan fazla uzun sürdü ve üç kat bellek yedi. toBase() aynı sorguyu model kurmadan çalıştırıyor ve PDO'ya yakın kalıyor. lazy() ise süreyi değil belleği kurtarıyor: 1.000'erlik parçalarla 11 sorgu, bellekte hiçbir anda binden fazla model yok. Laravel dokümanı cursor() için tek sorgu ve bellekte tek model diyor; aynı yerde ilişkileri eager yükleyemediğini de yazıyor.
Gerçek: Tercüman tek cümlede pahalı değil. On bin cümlede pahalı.
Üçüncü Tur: Rapor Masası#
Üçüncü çeyrekte şehir bazında ciro. Dönemdeki sipariş sayısı 31.795.
// Her şeyi tercümana çevirtip hesabı PHP'de yapmak
$ciro = Siparis::with('musteri')
->whereBetween('olusturma', ['2026-07-01', '2026-09-30 23:59:59'])
->get()
->groupBy(fn ($s) => $s->musteri->sehir)
->map(fn ($grup) => $grup->sum('tutar'));
// Hesabı veritabanına yaptırmak
$ciro = DB::select("
SELECT m.sehir, SUM(s.tutar) AS ciro
FROM siparisler s
JOIN musteriler m ON m.id = s.musteri_id
WHERE s.olusturma >= '2026-07-01' AND s.olusturma < '2026-10-01'
GROUP BY m.sehir
");
Birincisi 1.263 ms ve 64,2 MiB ek bellek; ikincisi 146 ms ve 0,04 MiB. Toplamayı ikisi de yapıyor, ama birinde 31.795 satır kabloyu geçip nesneye dönüşüyor, ötekinde beş satır.
Sonuç: Raporda ORM'den çıkmak yenilgi değil. Eloquent bu kapıyı DB::select ve selectRaw ile zaten açık bırakıyor.
Dördüncü Tur: Toplu Güncelleme#
1-14 Ocak arasında açılıp hâlâ yeni durumunda bekleyen 1.217 siparişi hazirlaniyor yapmak. Her ölçümden sonra işlem geri alındı.
// Her modeli tek tek kaydetmek
foreach ($hedef->get() as $s) {
$s->durum = 'hazirlaniyor';
$s->save();
}
// Tek UPDATE
$hedef->update(['durum' => 'hazirlaniyor']);
Birincisi 383 ms ve 1.218 sorgu, ikincisi 16 ms ve tek sorgu. Fark yalnızca hızda değil: modele bağladığım updating dinleyicisi birincide 1.217 kez çalıştı, ikincide hiç. Laravel dokümanı bunu açıkça yazıyor: toplu güncellemede saving, saved, updating ve updated olayları tetiklenmez, çünkü modeller hiç okunmaz. Denetim kaydını ya da önbellek temizliğini o olaya bağladıysanız, hızlı yol o işi sessizce atlıyor.
Doctrine ORM'in dokümanı bu konuda daha da açık sözlü. Toplu işlemler sayfasının ilk uyarısı, bir ORM aracının toplu ekleme, güncelleme ve silme için öncelikli olarak uygun olmadığını, her veritabanının bu işler için kendi en etkili yolu bulunduğunu söylüyor. Tercümanın kendi kartvizitinde "bu işi bana vermeyin" yazıyor.
İki Tür Tercüman: Active Record ve Data Mapper#
Eloquent ile benim 2023'teki Model sınıfım aynı aileden: Active Record. Nesne hem veriyi taşıyor hem kendini kaydediyor ($musteri->save()). Doctrine ORM ise Data Mapper: varlık sınıfları veritabanından habersiz, değişiklikleri EntityManager'ın içindeki Unit of Work topluyor ve flush() çağrısında yazıyor; aynı kaydı iki kez istediğinizde Identity Map size aynı nesneyi veriyor.
| Eloquent | Doctrine ORM | |
|---|---|---|
| Desen | Active Record | Data Mapper + Unit of Work |
| Son sürüm | 13.38.0 (20.10.2026) | 3.7.5 (09.10.2026) |
| PHP | ^8.3 | ^8.1 |
| Lisans | MIT | MIT |
Doctrine'i bu ölçümde kurmadım; tablolardaki rakamlar yalnızca Eloquent'e ait.
Tercüman sizi bir konuda gerçekten koruyor: parametre bağlama. Laravel'in sorgu oluşturucu dokümanı değerlerin PDO ile bağlandığını, DB::raw ve whereRaw gibi ham ifadelerde ise SQL enjeksiyonuna karşı güvence veremeyeceğini yazıyor. Kendi ORM'imin find() metodu orderby değerini olduğu gibi sorguya ekliyordu; o değer kullanıcıdan geliyorsa tercüman değil, kapıyı açık bırakan bir bekçi. Saf SQL'de kural aynı: değer ? ile bağlanır, sütun ve sıralama yönü beyaz listeden seçilir.
Saf SQL Tarafındakiler İçin: Masaya Sayaç Koyun#
Çerçevesiz PHP yazıyorsanız N+1'den muaf değilsiniz; yalnızca kimse size haber vermiyor. Aynı liste ekranını düz PDO ile döngü içinde yazdığımda yine 51 sorgu çıktı. PDO'ya kendi ifade sınıfınızı verip her execute() çağrısını saymak kırk küsur satır:
<?php
declare(strict_types=1);
/** Her execute() çağrısını sayan PDOStatement. */
final class SayacliIfade extends PDOStatement
{
protected function __construct(private SorguSayaci $sayac) {}
public function execute(?array $params = null): bool
{
$this->sayac->kaydet($this->queryString);
return parent::execute($params);
}
}
/** İstek boyunca çalışan sorguları sayar; aynı kalıp eşiği aşarsa işaretler. */
final class SorguSayaci
{
/** @var array<string, int> */
private array $kaliplar = [];
public function __construct(private int $esik = 10) {}
public function bagla(PDO $pdo): void
{
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [SayacliIfade::class, [$this]]);
}
public function kaydet(string $sql): void
{
// Sayıları ve tırnak içini at: "id = 7" ile "id = 8" aynı kalıp sayılsın.
$kalip = preg_replace(["/'[^']*'/", '/\b\d+\b/'], ['?', '?'], $sql) ?? $sql;
$this->kaliplar[$kalip] = ($this->kaliplar[$kalip] ?? 0) + 1;
}
/** @return array<string, int> eşiği aşan kalıplar */
public function supheliler(): array
{
return array_filter($this->kaliplar, fn (int $n): bool => $n > $this->esik);
}
public function toplam(): int
{
return array_sum($this->kaliplar);
}
}
Kullanımı:
$sayac = new SorguSayaci(esik: 10);
$sayac->bagla($pdo);
// ... sayfa çalışır ...
foreach ($sayac->supheliler() as $kalip => $adet) {
error_log("$adet kez: $kalip");
}
Liste ekranında çıktı tek satır oldu: 50 kez: SELECT ad FROM musteriler WHERE id = ?. İki sınırı var. Yalnızca prepare() ile hazırlanıp execute() edilen ifadeleri sayıyor; PDO::query() ve exec() sayaca girmiyor (denedim). Bir de PHP dokümanına göre ATTR_STATEMENT_CLASS kalıcı (persistent) bağlantılarla kullanılamıyor.
Hangi İş Hangi Masada?#
| İş | Tercih | Dikkat |
|---|---|---|
| Form, CRUD, ilişki gezintisi | ORM | preventLazyLoading() geliştirmede açık |
| Liste ekranı | ORM + with() ya da seçilmiş sütunlu JOIN |
JOIN'in planını ANALYZE ile okuyun |
| Binlerce satırlık okuma | toBase(), lazy() ya da PDO |
Model gerekmiyorsa kurmayın |
| Rapor, toplama | SQL (GROUP BY) |
ORM içinden DB::select yeter |
| Toplu güncelleme | Tek UPDATE |
Model olayları çalışmaz |
| Çerçevesiz PHP | PDO + hazırlanmış ifade | Sorgu sayacı takın |
Masadan Kalkarken#
Boolean mı, 1/0 mı turunda kazananı verinin kendisi seçiyordu. Bu turda kazananı ne ORM seçti ne SQL. En hızlı satır, planı okunarak yazılmış tek bir sorgu oldu; en yavaş satır da yine elle yazılmış bir sorgu.
Tercüman iyi çevirir ama ne söyleyeceğinizi sizin yerinize seçmez. Karşı tarafın dilini bilmek de tek başına sizi akıcı yapmaz; neyi söylediğinizi duymanız gerekir.
Kılıçları kınına koyun, sayacı masaya.
Yorumlar
0 yorum
Yorum yazın