DataTables Performans Optimizasyonu

100.000 satırlık bir tabloda sunucu taraflı DataTables performansı: indeksleme, önbelleğe alınmış gerçek sayım ve derin sayfalama maliyeti — her sayı ölçülerek yazıldı.

PHP 8 PDO MySQL 8 InnoDB Ajax DataTables Bootstrap 5
Seviye
İleri
Dosya
26
Kod satırı
~10.763
Proje boyutu
1.9 MB
Veritabanı
cy_datatable
Lisans
MIT
İnceleme
0
Beğeni
0
Yayın

Ekran Görüntüleri 2 görsel

Bu Örnek Ne Yapıyor?

  • 100.000 satırlık gerçek veri üzerinde çalışan, ölçülmüş bir performans örneği
  • İstemci taraflı 18,11 MB JSON'a karşı sunucu taraflı 4,8 KB — 3.860 kat daha az veri
  • Toplam sayı information_schema tahmini değil, önbelleğe alınmış gerçek COUNT(*)
  • İndeks kararlarının her biri gerekçesiyle yazılı; sıralama kararlılığı tuzağı ayrıca ele alınmış
  • LIKE '%...%' aramasının neden indeks kullanamadığı ve ne yapıldığı ölçümle gösterilmiş
  • Derin sayfalamanın (OFFSET 99.000) maliyeti eğri hâlinde verilmiş
  • Sıcak istekte tipik sayfa süresi 2,14 ms; her değer 5 çalıştırmanın medyanı

Nerede İşe Yarar?

  • Yüz binlerce kayıt tutan sipariş, log veya işlem listelerinin sayfalanması
  • İstemci taraflı bir tablonun mobilde çökmesi üzerine sunucu taraflıya geçiş
  • Var olan bir DataTables ekranında yavaş sıralama ve arama sorunlarının teşhisi
  • Hangi sütuna indeks konacağına karar verirken ölçüm yöntemi olarak kullanılması
  • Raporlama ekranlarında sayfa sayısının neden yanlış çıktığının anlaşılması

Gereksinimler

  • PHP 8.0+ · MySQL 5.7+ / MySQL 8 veya MariaDB 10.3+ · Apache/mod_rewrite · Seed için komut satırı erişimi

Nasıl Kurulur?

  1. Proje dosyalarını web köküne kopyalayın (örn. XAMPP'ta htdocs veya kendi web sunucunuzun kök dizini)
  2. cy_datatable.sql dosyasını içe aktarın — şemayı ve indeksleri oluşturur
  3. system/config.php içindeki DB_HOST, DB_NAME, DB_USER ve DB_PASS değerlerini düzenleyin
  4. seed.php dosyasını çalıştırarak 100.000 örnek siparişi üretin (birkaç dakika sürebilir)
  5. Uygulamayı açın; üstteki rozette sorgu süresini ve gerçek kayıt sayısını görürsünüz

Veritabanı şeması projedeki cy_datatable.sql dosyasında.

Nasıl Çalışıyor?

Çılgın Yazılım

Yüksek Hacimli DataTables Performansı

100.000 satırlık bir tabloda sunucu taraflı DataTables. İndeksleme · Ertelenmiş join · Önbelleğe alınmış gerçek sayım · Ölçülebilir performans

cilginyazilim.com · MIT Lisansı · 🇬🇧 English



100.000 satırlık tablo, üstünde sorgu süresi rozetiyle

Yukarıdaki kare bu deponun tamamını özetler: tabloda 100.000 sipariş var ve sayfa 2,88 ms'de geldi — rozetteki "100.000" da information_schema tahmini değil, sayfalamayı fiilen süren gerçek COUNT(*)'tır (bkz. Karar 1).

Bu depo hem çalışan bir örnek hem de bir performans günlüğüdür: buradaki her sayı ölçüldü, hiçbiri tahmin değil.



İçindekiler





Neden sunucu taraflı işleme zorunlu?


DataTables'ı "istemci taraflı" kullanmak, tüm satırları tarayıcıya göndermek demektir. 100.000 satır için bunun ne anlama geldiğini ölçtük:

Tüm tabloyu gönder (istemci taraflı)Tek sayfa gönder (sunucu taraflı)
JSON boyutu18,11 MB4,8 KB
gzip'li boyut2,06 MB~1,5 KB
Veritabanından çekme514 ms1,2 ms
PHP tarafında JSON kodlama167 ms~0 ms
PHP bellek tepe noktası122 MB< 1 MB

3.860 kat daha az veri. Üstelik bu tablo yalnızca sunucu tarafını ölçüyor; tarayıcı o 18 MB'ı indirdikten sonra 100.000 `` düğümü de oluşturmak zorunda kalır — mobilde sekmenin çökmesi için fazlasıyla yeterli.

Sunucu taraflı işlemede tarayıcı yalnızca gördüğü 25 satırı alır; filtreleme, sıralama ve sayfalama veritabanında, indeksler üzerinde yapılır. Bu deponun konusu, o "veritabanında yapılan iş"in doğru yapılmasıdır.



Ölçülmüş performans tablosu


100.000 satır, MySQL 8 / InnoDB, XAMPP, PHP 8.2. Her değer 5 çalıştırmanın medyanı, sunucu içi süre (timings.total_ms).

SenaryoÖnceSonra (soğuk)Sonra (sıcak)Kazanç
Filtresiz ilk sayfa55,75 ms56,98 ms2,14 ms26×
Sıralama: Ürün251,32 ms58,77 ms2,08 ms121×
Sıralama: Tarih53,46 ms62,27 ms1,99 ms27×
Arama: Zeynep (tam tarama)349,64 ms239,58 ms4,96 ms70×
Arama: SP0000050000 (tam eşleşme)424,55 ms52,65 ms2,11 ms201×
Filtre: status53,50 ms113,99 ms2,55 ms21×
Filtre: category79,23 ms76,45 ms3,75 ms21×
Derin sayfa (OFFSET 99.000)240,25 ms111,21 ms61,99 ms3,9×

"Soğuk" ve "sıcak" ne demek? Soğuk = sayım önbelleği boş, COUNT(*) gerçekten çalışıyor. Sıcak = sayım önbellekten geliyor (0,12 ms). Gerçek kullanımda ilk istek soğuk, ardından gelen tüm sayfalama/sıralama istekleri sıcaktır — sütun başlığına tıklayan ya da sayfa değiştiren kullanıcının gördüğü süre "sıcak" sütunudur. İkisini de veriyoruz çünkü yalnızca sıcak sayıları göstermek, önbelleğin maliyetini gizlemek olurdu.

Derin sayfalama eğrisi (sıcak):

OFFSET01.00025.00050.00099.000
Süre2,02 ms2,77 ms19,29 ms40,45 ms65,22 ms

Karar 1 — Tahmini sayım mı, önbelleğe alınmış gerçek sayım mı?

Bulunan hata

Bu depo eskiden toplam satır sayısını information_schema.TABLES.TABLE_ROWS üzerinden tahmini okuyordu. Kodun kendi yorumu şunu iddia ediyordu:
"Ama SAYFALAMA MANTIĞI için (kaç sayfa var?) kullanılmaz — o her zaman recordsFiltered'a, yani GERÇEK bir COUNT(*) sonucuna dayanır."

Bu iddia yanlıştı. Filtre yokken kod $recordsFiltered = $recordsTotal yapıyordu; yani tahmin doğrudan sayfalamayı sürüyordu. recordsFiltered, DataTables'ın "kaç sayfa var?" sorusunu cevapladığı alandır. Ölçüldü:

Gerçek COUNT(*)                      : 100.000
Yanıttaki recordsFiltered (tahmin)   :  99.316
id ASC son erişilebilir sayfadaki son id:  99.325
Gerçek MAX(id)                       : 100.000
→ 675 satıra HİÇBİR ŞEKİLDE ulaşılamıyordu.

Üstelik bu, filtre uygulanmamış varsayılan görünümde — yani herkesin ilk gördüğü ekranda — oluyordu. Filtre uygulanınca gerçek COUNT(*) çalıştığı için sorun kayboluyordu; tam olarak en çok kullanılan ekran bozuktu.

"ANALYZE TABLE çalıştır, düzelir" neden yanlış?

Denendi. ANALYZE TABLE orders sonrası tahmin 99.579'a düştü — yani fark 310'dan 421'e ÇIKTI. Tahmin, InnoDB'nin rastgele indeks sayfası örneklemesinden gelir; "daha taze" olması "daha doğru" olması demek değildir. Aynı tabloda tek bir çalışma oturumu içinde tahminin 99.690 → 99.579 → 99.316 diye gezindiğini ölçtük; tablo hiç değişmemişti, yalnızca ALTER TABLE ve ANALYZE TABLE istatistikleri yeniden hesaplattı.

Verilen karar

**Sayfalamayı süren sayı her zaman gerçek COUNT(*)'tır; maliyeti dosya önbelleği karşılar.** information_schema tahmini artık uygulamanın hiçbir yerinde okunmuyor.

Bir ara sürümde tahmin silinmemiş, arayüzde gerçek sayının yanında karşılaştırma rozeti olarak bırakılmıştı — "iki sayıyı yan yana görmek öğreticidir" gerekçesiyle. Bu, geliştirme sürecinin dürüst bir parçası olduğu için burada da kayıtlı: rozet gösterdiği andan itibaren tahmin sabitti (aynı tabloda değişmiyordu), yani her istekte yeniden okumanın kazandırdığı hiçbir şey yoktu — yalnızca information_schema sorgusunun kendi maliyetini (~0,8 ms) ödüyorduk. Rozet arayüzden kaldırıldı, sorgu da onunla birlikte gitti; bu bölümdeki ölçümler ise kalıcı bir uyarı olarak kalıyor, çünkü aynı hataya bir dahaki sefere düşmemek için "neden information_schema'ya güvenilemez" bilgisinin bir yerde yazılı durması gerekiyor.

Önbelleğin ödünleşmesi (gizlemiyoruz)

SELECT COUNT() FROM orders 47 ms sürüyor. InnoDB'de COUNT() hazır bir sayaçtan okunmaz (MyISAM'da okunurdu); en küçük ikincil indeksin tamamı taranır. Her isteğe 47 ms eklemek, 2 ms'ye indirdiğimiz sayfayı yeniden 49 ms yapardı. Dosya önbelleğinden okumak 0,12 ms — 390 kat ucuz.

Peki önbellek de "yanlış olabilir", tahminden farkı ne?

information_schema tahminiÖnbelleğe alınmış COUNT(*)
Ne kadar yanlış?Bilinemez (%0,3–%50)En fazla TTL kadar eski
Yönü belli mi?Hayır, iki yönde de saparEvet, yalnızca geride kalır
Kendiliğinden düzelir mi?HayırEvet, TTL dolunca kesinleşir
ANALYZE TABLE düzeltir mi?Hayır (ölçüldü, kötüleşti)İlgisiz
Yazma sonrası anında doğrulanabilir mi?HayırEvet (count_cache_forget())

Fark esastır: tahmin kalıcı ve yönü bilinmeyen bir hatadır; önbellek sınırlı ve kendiliğinden kapanan bir gecikmedir. Bu proje salt okunur olduğu için tablo istekler arasında değişmez ve önbellekteki sayı her zaman doğrudur. Yazma yapan bir sistemde COUNT_CACHE_TTL'i düşürün veya INSERT/DELETE sonrası count_cache_forget() çağırın — seed.php tam olarak bunu yapar.

Filtreli sayımlar da önbelleğe alınır. Bir arama sonucunda 40 sayfa geziyorsanız eski kod her sayfada 175 ms'lik bir COUNT(*) daha ödüyordu — aynı sayıyı 40 kez yeniden hesaplamak için.



Karar 2 — LIKE '%...%' neden indeks kullanamaz, ne yaptık?


Neden kullanamaz?


B-tree indeksler bir sözlük gibi soldan sağa sıralıdır. Sözlükte "ile başlayan" kelimeleri bulmak kolaydır; "içinde geçen" kelimeleri bulmak için sözlüğü baştan sona okumanız gerekir. MySQL de aynı durumdadır:

WHERE customer_name LIKE 'Zeynep%'   -- ✅ indeks ARALIĞI (range) — 6,15 ms
WHERE customer_name LIKE '%Zeynep%'  -- ❌ tam tarama (ALL)      — 77,79 ms

Bulunan hata


Eski kod, arama kutusuna ne yazılırsa yazılsın üç sütunu %...% ile sarıyordu. En can sıkıcı sonuç:

LIKE '%SP0000050000%'          → 415,53 ms   type=ALL,   key=NULL, rows=99.316
order_number = 'SP0000050000'  →   0,49 ms   type=const, key=uniq_orders_number, rows=1

848 kat fark. uniq_orders_number benzersiz indeksi zaten oradaydı — kod onu kullanmıyordu, çünkü birebir eşleşen bir sipariş numarasını da %...% içine sarıyordu.

Verilen karar: yazılanın biçimine bakıp yolu seçmek

classify_search() üç yol tanır:
YazılanYolSQLSüre
SP0000050000 (SP + 10 hane)exactorder_number = ?2,11 ms
SP000005 (SP + 1–9 hane)prefixorder_number LIKE 'SP000005%'3,65 ms
Zeynep, ÇILGIN, Kablosuzscan3 sütunda LIKE '%...%'4,96 ms (sıcak) / 239 ms (soğuk)

Hangi yolun seçildiği ekranda yazar ("Arama yolu: tam tarama (LIKE %…%)"). Maliyet gizlenmiyor.

Neden genel aramayı da hızlandırmadık? — FULLTEXT denendi ve REDDEDİLDİ

FULLTEXT(customer_name, product) indeksi gerçekten eklendi ve ölçüldü. Reddedilme gerekçeleri, kendi ölçümlerimizle:

1. Kelime ortasından eşleşemiyor. FULLTEXT kelimeleri belirteç (token) olarak indeksler; %eyne% gibi bir arama karşılığı yoktur:

LIKE '%eyne%'                 → 5.003 satır   (Zeynep'in ortası)
MATCH ... AGAINST ('eyne*')   →     0 satır

Kullanıcı "ÇILGIN" yazdığında bunu soyad olarak arar — müşteri adının sonunda. Doğru sonucu vermeyen hızlı bir arama, yavaş bir aramadan daha kötüdür.

2. Beklenenin aksine daha yavaş çıktı. idx_orders_date eklendikten sonra optimizasyon MySQL'in lehine döndü: sık eşleşen bir aramada sıralama indeksini takip edip 25 satır bulunca erken durabiliyor.

Arama 'Zeynep' — veri sorgusu:
  LIKE '%Zeynep%'  :  3,09 ms
  FULLTEXT MATCH   : 25,36 ms

3. Sipariş numaralarını kapsamıyor, minimum belirteç uzunluğu 3 (innodb_ft_min_token_size), ve altıncı bir indeksin yazma maliyeti var.

Dürüst sınır: scan yolu hâlâ tam tarama yapar ve soğuk hâlde ~240 ms sürer. Bunu çözmedik, ehlileştirdik: hızlı yollar en sık aramaları boşalttı, sayım önbelleğe alındı (aynı aramada sayfa gezinmek 5 ms), ve bu yol ayrıca ve daha dar bir hız sınırına tabi. 100.000 satırın çok ötesinde gerçek bir "içinde geçen" araması gerekiyorsa doğru araç Elasticsearch/Meilisearch gibi bir arama motorudur — MySQL değil.



Karar 3 — Derin sayfalamanın maliyeti


LIMIT 25 OFFSET 99000, MySQL'e "99.025 satır üret, ilk 99.000'ini AT" demektir. Atılan satırların tam satır verisi de okunur — 8 sütun, hepsi boşuna.

Verilen karar: ertelenmiş join (deferred join / late row lookup)

Önce yalnızca id'ler, kapsayıcı (order_date, id) indeksinden seçilir — bu alt sorgu tabloya hiç dokunmaz (Using index). Sonra kalan 25 id için tam satır birincil anahtardan çekilir (25 kez type=eq_ref):

SELECT o.id, o.order_number, ...
  FROM orders o
  JOIN (SELECT id FROM orders
         ORDER BY order_date DESC, id DESC
         LIMIT 25 OFFSET 99000) k ON k.id = o.id
 ORDER BY o.order_date DESC, o.id DESC

OFFSETDüz OFFSETErtelenmiş joinKazanç
00,65 ms0,93 ms(0,3 ms daha yavaş)
1.00066,39 ms1,36 ms49×
25.000136,07 ms15,99 ms8,5×
50.000165,21 ms30,71 ms5,4×
99.000252,98 ms61,26 ms4,1×

İlk sayfada 0,3 ms daha yavaş olduğunu da yazıyoruz — join'in kendi maliyeti var ve ilk sayfada atılacak satır yok. 25. satırdan sonraki her yerde kazandırıyor.

Neden keyset (seek) sayfalama değil?

Keyset sayfalama (WHERE order_date < ? LIMIT 25) OFFSET'i tamamen ortadan kaldırır ve her sayfada sabit sürede çalışır. Teknik olarak üstün çözüm budur ve 61 ms'yi 0,6 ms yapardı.

Uygulamadık, çünkü DataTables arayüzü kullanıcıya sayfa numarası verir: "sayfa 2.847'ye git" diyebilirsiniz. Keyset sayfalamada 2.847. sayfanın başlangıç anahtarı bilinmez — oraya ancak 2.846 sayfayı sırayla geçerek ulaşılır. Yani keyset, "sonraki/önceki" düğmeleri ve sonsuz kaydırma için doğrudur; sayfa numarası veren bir arayüzle bağdaşmaz.

Bu bilinçli bir ödünleşmedir: sayfa numaralarından vazgeçebiliyorsanız keyset'e geçin.



İndeks kararları


Her indeksin bir bedeli vardır. Ölçtük: tüm ikincil indeksler açıkken 20.000 satırlık toplu INSERT 2.262 ms; idx_orders_amount ve idx_orders_customer düşürüldüğünde 1.267 ms. Yazma maliyeti neredeyse iki katı. Tabloda 11,5 MB veri karşılığında 33,2 MB indeks var.

Bu bedeli ödemek için her indeksin okumada ne kazandırdığını göstermek zorundayız. DataTables'ta her sütun başlığı tıklanabilir; indekssiz bir sütuna sıralama, her tıklamada 100.000 satırlık bir filesort demektir.

SütunİndeksSüre
idPRIMARY0,56 ms
order_numberuniq_orders_number0,63 ms
customer_nameidx_orders_customer0,59 ms
categoryidx_orders_category0,59 ms
amountidx_orders_amount0,54 ms
statusidx_orders_status_date0,64 ms
order_dateYOKTUidx_orders_date eklendi53,32 ms → 0,61 ms
productYOKTUidx_orders_product eklendi253,61 ms → 0,70 ms

idx_orders_date (order_date, id) — bu turun en kritik bulgusu

Arayüzün varsayılan sıralaması order_date DESC. Yani herkesin ilk gördüğü ekran. Bu indeks yokken:

ORDER BY order_date DESC LIMIT 25
→ type=ALL, key=NULL, Using filesort, 54,45 ms

MySQL 100.000 satırın tamamını okuyup sıralıyor, sonra 25'ini gösteriyordu. Varsayılan ekranın 55 ms'sinin neredeyse tamamı buydu.

Neden (status, order_date) bu işi görmedi? En soldaki sütun kuralı: bileşik bir indeks yalnızca soldan başlayan öneklerıyle kullanılabilir. (status, order_date) indeksi önce status'e göre sıralıdır; order_date her status bloğunun içinde sıralıdır. 5 status değeri = tarihe göre sıralı 5 ayrı blok; bunları birleştirmek sıralama yapmakla aynı şeydir.

Neden sonuna id? (1) Sıralama kararlılığı (aşağıya bakın). (2) Kapsayıcılık: ertelenmiş join'in alt sorgusu yalnızca id seçer; (order_date, id) bu alt sorguyu tabloya hiç dokunmadan karşılar.

Ölçüldü ve bilerek EKLENMEDİ: (category, order_date)

idx_orders_date ile        : 1,00 ms
(category, order_date) ile : 0,72 ms
0,28 ms için altıncı bir ikincil indeksin yazma maliyetini ödemek mantıklı değil. İndeks eklemek refleks değil, karardır.

ENUM (status) vs VARCHAR (category)

Yakın seçicilikte ölçüldü — beklenenin aksine fark küçük:
indeksli COUNTindeks devre dışı (tam tarama)
status = 'kargoda' (ENUM, 14.839 satır)11,54 ms50,54 ms
category = 'Kitap' (VARCHAR, 19.953 satır)16,61 ms52,94 ms

ENUM'un asıl kazancı hız değil, depolama (satır içinde 1 bayt) ve veri bütünlüğüdür — geçersiz bir durum değeri veritabanı seviyesinde reddedilir. category'nin VARCHAR kalması da bilinçlidir: kategoriler işletme tarafından eklenip çıkarılan veridir; her yeni kategori için ALTER TABLE gerektiren bir ENUM, üretimde kilitlenmeye yol açar.



Sıralama kararlılığı — ince ama pahalı tuzak


Sayfalamanın kararlı olması için ORDER BY'ın satırları tekilleştiren bir anahtarla bitmesi gerekir. Aksi hâlde eşit değerli satırların sırası garanti değildir ve sayfa 2'ye geçtiğinizde sayfa 1'de gördüğünüz bir satırı tekrar görebilirsiniz. Bu tabloda somut: aynı (status, order_date) çiftini paylaşan 3.645 grup var.

Bunu düzeltmek için her sıralamaya , id eklemek cazip ama yanlıştır — ve indeksi bozar. Kendi düzeltmemizde bu tuzağa düştük, ölçtük, geri aldık:

ORDER BY status, id              → 64,95 ms   (Using filesort)  ❌
ORDER BY status, order_date, id  →  0,93 ms   (Using index)     ✅

ORDER BY order_number, id        → 127,76 ms  (Using filesort)  ❌
ORDER BY order_number            →   0,84 ms  (Using index)     ✅

Neden? InnoDB'de her ikincil indeksin sonuna birincil anahtar zaten eklenir; idx_orders_customer aslında (customer_name, id)'dir, bu yüzden ORDER BY customer_name, id o indeksin doğrudan önekidir. Ama idx_orders_status_date (status, order_date, id)'dir — ORDER BY status, id bu indeksin öneki değildir, aradaki order_date atlanmıştır. Ters yönde order_number zaten UNIQUE olduğu için tekilleştiricidir; , id eklemek hiçbir şey kazandırmaz ama optimizasyonu bozar.

Doğru kural: kararlılık için gereken en kısa anahtar listesini, o sütuna hizmet eden indeksin kendi sırasını izleyerek kur. Kod bunu sort_keys() içinde sütun bazlı yapar.



Güvenlik katmanları


Bu bir vitrin uygulaması olsa da, güvenlik açığı olan bir örnek kopyalanır. Bulunan ve kapatılan her şey ölçümüyle:

1. Dosya erişimi (.htaccess hiç yoktu)

AdresÖnceSonra
/system/config.php200 (her çağrıda DB bağlantısı açıyordu)403
/system/function.php200403
/cy_datatable.sql200 (şema indirilebiliyordu)403
/.gitignore200403
/assets/js/200 (klasör listesi)403
/README.md200403

system/.htaccess beyaz liste kullanır (Require all denied + yalnızca ajax.php açık). Kara liste yazsaydık projeye yarın eklenen her dosya varsayılan olarak açık olurdu. Güvenlikte varsayılanın yönü, kuralın kendisinden önemlidir.

İkinci katman: dosyaların içindeki CY_APP kontrolü. .htaccess yalnızca Apache'de ve AllowOverride açıkken okunur; nginx'e taşındığında PHP kontrolü çalışır.

2. Güvenlik başlıkları (dördü de yoktu)

X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy ve bir CSP eklendi. CSP'de 'unsafe-inline' yok: sayfadaki tek satır içi `` bloğu her istekte değişen bir nonce ile imzalanır.

3. Oturum sabitleme (session fixation) — gerçekten çalışıyordu

Uydurma bir kimlikle denendi:

Cookie: PHPSESSID=cytest26664ffa2245a4e620ee
→ Sunucu bu kimliği KABUL ETTİ, yeni Set-Cookie basmadı,
  o kimlikle CSRF token üretti, /system/ajax.php HTTP 200 döndü.

session.use_strict_mode=1 ile kapatıldı. Aynı testte artık sunucu yeni bir kimlik basıyor ve uydurma kimlikle yapılan istek 403 dönüyor.

Çerez bayrakları da yoktu (PHPSESSID=...; path=/). Şimdi: HttpOnly; SameSite=Lax ve HTTPS altında otomatik Secure.

4. CSRF reddi 419 → 403

419 resmî bir HTTP kodu değildir ve bu kurulumdaki Apache onu sessizce 500'e çeviriyordu (ölçüldü). Yani gövde doğru mesajı taşırken durum kodu "sunucu çöktü" diyordu. 403 ile değiştirildi.

5. Hız sınırı (hiç yoktu)

list uç noktası kimlik doğrulaması istemez ama pahalıdır. Kimliği doğrulanmamış bir istekle sunucu zamanı yaktırmak ucuz bir DoS kaldıracıdır.

Sınır ölçülerek belirlendi. Gerçek Chrome'da, gerçek arayüz sürülüp XHR'ler sayıldı: 9,1 saniyelik kesintisiz yoğun kullanımda 11 istek (1 açılış, 3 sayfalama, 3 sıralama, 2 filtre, 2 arama). Kritik ayrıntı: o 2 arama isteği 14 tuş vuruşundan doğdu — arama 350 ms geciktirmeli (debounce). Geciktirme olmasaydı aynı yazma 14 istek ve 14 tam tablo taraması üretirdi.

Bu tempo dakikaya vurulduğunda ~72 istek/dk eder (bir insanın değil, 400 ms'de bir tıklayan bir betiğin temposu).

KovaSınırDoğrulama
list (tüm istekler)120 / dk72 istek → hepsi 200. 121. istekte 429 + Retry-After: 58
search (yalnızca tam tarama)30 / dk31. taramada 429; 60 tam-eşleşme araması → hepsi 200

İkinci, dar sınır yalnızca pahalı yola uygulanır: indeksli sipariş no araması bu sınıra takılmaz.

Hız sınırı CSRF'den sonra çalışır. Önce olsaydı, token'ı olmayan bir saldırgan sayacı doldurup meşru kullanıcıyı kilitleyebilirdi — sınırın kendisi bir saldırı aracına dönerdi.

6. Zaten sağlam olanlar (regresyon testinde doğrulandı)

  • SQL enjeksiyonu yok: sütun adları beyaz listeden seçilir, order[0][column]=99&dir=DROP → 200, güvenli varsayılan.
  • XSS yok: e() sunucuda uygulanır; ` içeren müşteri adı yanıta &lt;script&gt; olarak döner.
  • length=999999 → 500 satırla kesilir. start=-5max(0, ...).
  • seed.php web'den erişilemez (PHP_SAPI !== 'cli' → 403). Yıkıcı bir betiktir (TRUNCATE`).

API sözleşmesi

Tek uç nokta: POST system/ajax.php. Salt okunur — CRUD yoktur.

İstek

DataTables'ın standart sunucu taraflı parametrelerine ek olarak:
AlanTipAçıklama
drawintDataTables sayacı — yarış koruması, olduğu gibi yansıtılır
startintAtlanacak satır (max(0, …))
lengthintSayfa uzunluğu (üst sınır 500)
search[value]stringArama metni — yolu classify_search() seçer
order[0][column]intSütun indeksi (0–7), beyaz listeden çözülür
order[0][dir]stringasc \desc (başka her şey desc)
category_filterstringKategori adı
status_filterstringORDER_STATUSES anahtarı
csrf_tokenstringZorunlu (veya X-CSRF-Token başlığı)

Yanıt

{
  "draw": 7,
  "recordsTotal": 100000,
  "recordsFiltered": 100000,
  "data": [[99695, "SP0000099695", "Taha YILDIRIM", "…"]],
  "timings": {
    "count_total_ms": 0.31,
    "count_filtered_ms": 0,
    "data_query_ms": 1.24,
    "total_ms": 1.55
  },
  "meta": {
    "count_cached": true,
    "search_mode": "scan",
    "search_label": "tam tarama (LIKE %…%)"
  }
}
timings ve meta DataTables sözleşmesinin parçası değildir — ekrandaki rozetleri besleyen öğretici eklerdir.

Yarış durumu (race) nasıl önleniyor?

Kullanıcı hızlıca sayfa değiştirdiğinde geç gelen eski yanıt yenisinin üstüne binebilir. Koruma protokol seviyesindedir: istemci her çizimde draw sayacını artırır, DataTables gelen yanıtın draw'ı mevcut sayaçtan küçükse yanıtı sessizce atar. DataTables 1.13.6 kaynağında doğrulandı:

if (+r < t.iDraw) return;

Sunucunun tek görevi değeri olduğu gibi yansıtmaktır — kod bunu yapar (ölçüldü: draw=7"draw": 7, tam sayı tipinde). İki ayrı oturumla eşzamanlı istek testi yapıldı; yanıtlar sıra değiştirse bile koruma çalışır.


HTTP durum kodları


KodNe zaman
200Başarılı listeleme
403CSRF token geçersiz/eksik, ya da system/ dosyalarına doğrudan erişim
405POST dışında bir yöntem
429Hız sınırı aşıldı — Retry-After başlığı ve retry_after alanı ile
500Beklenmeyen sunucu/veritabanı hatası

Not: Bu kurulumdaki Apache 419'u tanımıyor ve sessizce 500'e çeviriyor (ölçüldü). CSRF reddi için doğru kod zaten 403'tür.

Veritabanı şeması

CREATE TABLE `orders` (
  `id`             INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `order_number`   CHAR(12)     NOT NULL,   -- 'SP' + 10 hane, sabit uzunluk
  `customer_name`  VARCHAR(120) NOT NULL,
  `product`        VARCHAR(150) NOT NULL,
  `category`       VARCHAR(60)  NOT NULL,
  `amount`         DECIMAL(10,2) NOT NULL,  -- para için DOĞRU tip (FLOAT değil)
  `status`         ENUM('beklemede','hazirlaniyor','kargoda','teslim_edildi','iptal')
                     NOT NULL DEFAULT 'beklemede',
  `order_date`     DATE         NOT NULL,
  `created_at`     TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,

  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_orders_number`   (`order_number`),
  KEY `idx_orders_date`         (`order_date`, `id`),
  KEY `idx_orders_status_date`  (`status`, `order_date`),
  KEY `idx_orders_category`     (`category`),
  KEY `idx_orders_amount`       (`amount`),
  KEY `idx_orders_customer`     (`customer_name`),
  KEY `idx_orders_product`      (`product`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
amount için DECIMAL: FLOAT/DOUBLE'da yuvarlama hataları birikir; büyük veri setlerinde bu hatalar toplamda anlamlı hâle gelir.

Dosya yapısı

datatable-performance/
├── index.php                  ← Arayüz: performans rozeti + tablo
├── cy_datatable.sql           ← Şema VE indeksler (veri YOK) + indeks gerekçeleri
├── seed.php                   ← CLI veri üretici (php seed.php [satır_sayısı])
├── .htaccess                  ← Dizin listeleme kapalı, .sql/.md engelli, güvenlik başlıkları
├── system/
│   ├── .htaccess              ← BEYAZ LİSTE: yalnızca ajax.php açık
│   ├── config.php             ← Oturum güvenliği, DB, sınır/TTL sabitleri
│   ├── function.php           ← Sayım önbelleği, arama yönlendirme, ertelenmiş join, hız sınırı
│   └── ajax.php               ← TEK uç nokta: list (salt okunur)
└── assets/
    ├── css/style.css          ← Sayfaya özel stiller (cilginyazilim.css'e dokunulmaz)
    ├── js/table.js            ← DataTables kurulumu + rozet güncelleme + hata dallanması
    └── images/screenshot.png

Hangi fonksiyon ne işe yarar?

FonksiyonDosyaGörevi
handle_list()ajax.phpTek uç nokta; parametreleri doğrular, WHERE kurar, süreleri ölçer
count_cached()function.phpGerçek COUNT(*), dosya önbellekli (47 ms → 0,12 ms)
count_cache_forget()function.phpYazma sonrası önbelleği boşaltır (seed.php çağırır)
classify_search()function.phpArama metnine bakıp exact / prefix / scan yolunu seçer
build_page_sql()function.phpErtelenmiş join sorgusunu kurar
sort_keys()function.phpSütun bazlı, indeksi bozmayan kararlı sıralama anahtarları
rate_limit()function.phpflock() kilitli, dosya tabanlı kayan pencere sayacı
security_headers()function.phpCSP dahil güvenlik başlıkları (nonce ile)
require_csrf()function.phpCSRF doğrulaması, reddi 403
e()function.phpSunucu taraflı HTML kaçışlama (XSS)

Kurulum

Gereksinimler: PHP 8.1+, MySQL 5.7+ / MariaDB 10.3+, Apache (mod_headers önerilir).

cd C:/xampp/htdocs
git clone https://github.com/CilginYazilim/datatable-performance.git
cd datatable-performance

# 1) Şemayı ve indeksleri oluştur
mysql -u root -p < cy_datatable.sql

# 2) Örnek veri üret (komut satırından, tarayıcıdan DEĞİL)
php seed.php 100000

http://localhost/datatable-performance/

Canlıya alırken system/config.php içindeki APP_DEBUGfalse yapın.

Daha büyük bir veri setiyle deneyin: php seed.php 500000 çalıştırıp aynı senaryoları tekrarlayın; rozetlerdeki sayıların nasıl değiştiğini izleyin. Özellikle derin sayfalama ve scan araması eğrisi öğreticidir.



Özelleştirme


İstediğinizNereye bakın
Sayım önbelleği ömrüCOUNT_CACHE_TTLsystem/config.php
Hız sınırlarıRATE_LIMIT_LIST, RATE_LIMIT_SEARCHsystem/config.php
Sayfa uzunluğu tavanıMAX_PAGE_LENGTHsystem/config.php
Arama geciktirme süresiSEARCH_DEBOUNCE_MSassets/js/table.js
Durum listesi/renkleriORDER_STATUSESsystem/config.php
Yeni sıralanabilir sütun$sortableColumns (ajax.php) + sort_keys() (function.php) + indeks
Arama yollarıclassify_search()system/function.php

Yeni bir sıralanabilir sütun eklerken: mutlaka bir indeks ekleyin ve sort_keys() içindeki karşılığını EXPLAIN ile doğrulayın. İndekssiz bir sütun, her tıklamada 100.000 satırlık bir filesort demektir (ölçüldü: 253 ms).

Örnek kullanım alanları

  • Sipariş / fatura yönetimi — yüz binlerce kayıt, sipariş no ile hızlı erişim
  • Log ve denetim izi (audit trail) görüntüleyicileri — çok yazma, çok okuma, derin sayfalama
  • E-ticaret yönetim panelleri — kategori + durum filtreleri, tarih sıralaması
  • CRM müşteri listeleri — isimde arama, Türkçe karakterli veri
  • Finansal işlem dökümleriDECIMAL tutar, tutara göre sıralama
  • Stok / envanter tabloları — ürün adında arama, kategori filtresi
  • Performans öğrenme materyali — indeks kararlarının etkisini canlı ölçerek görmek

Test edildi

Bu turda yapılan tüm değişiklikler ölçülerek doğrulandı: her sütunda çift yönlü sıralama, kategori + durum filtreleri, Türkçe karakterli müşteri adında arama (ÇILGIN, ŞAHİN, ÖZTÜRK, Ayşe), filtre + sıralama + sayfalama bileşimleri, arama yolu yönlendirmesi, sınır değerleri, XSS ve SQL enjeksiyonu regresyonu, hız sınırının meşru kullanımı bozmadığı, ve eşzamanlı isteklerde yarış koruması.

Lisans

MIT — dilediğiniz gibi indirip kullanabilirsiniz.

Çılgın Yazılım · cilginyazilim.com · github.com/CilginYazilim/datatable-performance

Kaynak Kod soldaki ağaçtan bir dosya seçin

  • assets
    • css
      • bootstrap.min.css 227.5 KB
      • cilginyazilim.css 19.4 KB
      • dataTables.bootstrap5.min.css 11.7 KB
      • style.css 3.5 KB
    • images
      • logo.png 70.4 KB
      • screenshot-search.png 432.6 KB
      • screenshot.png 474.4 KB
    • js
      • bootstrap.bundle.js 203.2 KB
      • dataTables.bootstrap5.min.js 2.3 KB
      • jquery-3.7.0.js 278.3 KB
      • jquery.dataTables.min.js 85.1 KB
      • table.js 7.7 KB
  • system
    • .htaccess 1.8 KB
    • ajax.php 10.4 KB
    • config.local.php 150 B
    • config.local.php.example 1.3 KB
    • config.php 9.9 KB
    • function.php 23.2 KB
  • .gitignore 1.1 KB
  • .htaccess 3.8 KB
  • cy_datatable.sql 8.6 KB
  • index.php 8.7 KB
  • LICENSE 1.1 KB
  • README.en.md 29 KB
  • README.md 30.8 KB
  • seed.php 6.8 KB
Dosya seçilmedi
İncelemek istediğiniz dosyayı soldaki ağaçtan seçin.

Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).

Paylaş:

Sık Sorulan Sorular

Tablo birkaç bin satırı geçtiğinde. İstemci taraflı kullanım bütün satırları tarayıcıya göndermek demektir: 100.000 satır için ölçülen değerler 18,11 MB JSON, 514 ms veritabanı süresi ve 122 MB PHP belleğidir. Sunucu taraflı işlemede tarayıcı yalnızca gördüğü 25 satırı alır (4,8 KB); filtreleme, sıralama ve sayfalama indeksler üzerinde veritabanında yapılır.

Hayır. InnoDB'de TABLE_ROWS bir tahmindir ve DataTables'ın recordsFiltered alanı doğrudan "kaç sayfa var?" sorusunu cevaplar. Bu depo eskiden tahmini kullanıyordu ve filtre yokken tahmin doğrudan sayfalamayı sürüyordu. Doğru yol, gerçek COUNT(*) sonucunu kısa süreli önbelleğe almaktır: soğuk istekte sayım gerçekten çalışır, ardından gelen sayfalama ve sıralama istekleri önbellekten 0,12 ms'de döner.

Baştaki joker karakter B-tree indeksini kullanılamaz hâle getirir; veritabanı tam tarama yapmak zorunda kalır. Ölçümde "Zeynep" araması 349,64 ms sürüyordu. Tam eşleşen değerlerde (sipariş numarası gibi) indeks devreye girer ve aynı sorgu 424,55 ms'den 2,11 ms'ye iner — 201 kat.

OFFSET 99.000 demek, veritabanının atlayacağı 99.000 satırı yine de okuması demektir. Ölçülen eğri şudur: OFFSET 0'da 2,02 ms, 25.000'de 19,29 ms, 50.000'de 40,45 ms, 99.000'de 65,22 ms. Bu maliyet gizlenemez; gerçek çözüm anahtar tabanlı sayfalamadır, ama DataTables arayüzü sayfa numarası istediği için burada maliyet ölçülüp belgelenmiştir.

Soğuk, sayım önbelleğinin boş olduğu ve COUNT(*) sorgusunun gerçekten çalıştığı ilk istektir. Sıcak, sayımın önbellekten geldiği sonraki isteklerdir. Gerçek kullanımda ilk istek soğuk, sütun başlığına tıklayan veya sayfa değiştiren kullanıcının gördüğü bütün istekler sıcaktır. İkisi birlikte verilir, çünkü yalnızca sıcak sayıları göstermek önbelleğin maliyetini gizlemek olurdu.

Yorumlar 0 konuşma

Bu kod örneğine henüz yorum yapılmamış. Takıldığınız bir yer veya merak ettiğiniz bir ayrıntı varsa ilk soruyu siz sorun.

Soru Sor veya Yorum Yaz

Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.

Yayınlanmaz; yalnızca yanıt bildirimi için kullanılır.
En az 10 karakter.

İlgili Kod Örnekleri