EF Core Sayfalama(Pagination) Stratejileri

Giriş — Neden sayfalama (pagination) önemli?
EF Core ile büyük veri kümeleriyle çalışırken sayfalama veri taşımını, gecikmeyi ve kullanıcı deneyimini doğrudan etkiler. Örneğin bir API ya da sonsuz kaydırma (infinite scroll) uygulamasında yanlış strateji seçmek:
- Sorgu performansını düşürür,
- Sunucu maliyetini artırır,
- Kullanıcıya tutarsız sonuçlar gösterebilir.
Öncelikle, iki yaygın yaklaşımı ele alacağız: Offset Pagination (Skip + Take) ve Keyset Pagination (cursor-based, seek). Ardından, nasıl uygulayacağınızı, hangi senaryolarda hangi yöntemi seçmeniz gerektiğini ve dikkat etmeniz gereken ayrıntıları göstereceğim.
İçindekiler (kısa)
- Offset Pagination nedir? (Artıları / Eksileri)
- Keyset Pagination nedir? (Artıları / Eksileri)
- EF Core ile örnekler (kod)
- Keyset implementasyonu adım adım (cursor oluşturma, composite key, descending)
- Geriye doğru sayfalama / previous page sorunları
- Performans ve indeksleme notları
- Edge-case’ler: silinme/ekleme, tutarlılık, transaction
- API tasarımı önerileri (nextCursor, hasMore)
- Karar tablosu: hangi yöntemi seçmeli?
- Özet ve uygulanabilir kontrol listesi
1) Offset Pagination (Skip + Take) — Nedir ve neden kolaydır?
Tanım: Veritabanı sonuçlarından OFFSET (atlanan satır sayısı) kadar atlar, sonra LIMIT/FETCH kadar alır. EF Core’da Skip() ve Take() şeklinde kullanılır.
EF Core örneği:
var customers = await db.Customers
.AsNoTracking()
.OrderBy(c => c.Id)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);Avantajlar
- Kod basit ve anlaşılır.
- Sayfa numarası mantığı (page 1, 2, 3) doğrudur; SEO / public sayfalarda URL’lerle kolay uyum sağlar.
- Küçük datasetlerde performans ve karmaşıklık sorunu yoktur.
Dezavantajlar
- Performans: Veritabanı genellikle atlanan tüm satırları okur; büyük offset’lerde sorgu yavaşlar.
- Tutarlılık: Veri değişirse (insert/delete) sayfalar kayabilir; aynı sorguya farklı sonuçlar gelebilir.
- Ölçeklenebilirlik: API veya sonsuz kaydırma (infinite scroll) için kötüleşir.
- Kaynak tüketimi: Özellikle yüksek sayfalı isteklerde CPU/IO artar.
SQL örneği (Postgres/MySQL):
SELECT * FROM Customers
ORDER BY Id
OFFSET 10000 LIMIT 20;2) Keyset Pagination (Cursor-based / Seek) — Nedir ve neden hızlıdır?
Tanım: Sıralamaya göre “son görülen” kayıt temel alınır ve bundan sonraki kayıtlar getirilir. Skip yok; WHERE ile arama yapılır. Yani WHERE id > lastId veya kompleks durumlarda WHERE (a < lastA) OR (a = lastA AND b < lastB) gibi.
Basit EF Core örneği (tek sütun):
var customers = await db.Customers
.AsNoTracking()
.OrderBy(c => c.Id)
.Where(c => c.Id > lastId)
.Take(pageSize)
.ToListAsync(ct);Avantajlar
- Performans: İndex seek kullanır; milyonlarca satırda bile hızlıdır.
- Tutarlılık: Veri eklense bile “son görülen” noktadan devam eder; sayfa kaymaları azalır.
- İyi deneyim: Infinite scroll ve API feed’leri için ideal.
Dezavantajlar
- URL / SEO: Sayfa numarası yerine cursor/token kullanmak SEO için daha az okunur.
- Complexity: Composite ordering, ters yönlü sayfalama (previous) ve cursor yönetimi ek çaba gerektirir.
- Eksik kayıt riski: Eğer kullanıcı bir sayfayı kaydedip daha sonra geri gelirse, aradaki yeni kayıtlar gösterilmeyebilir — ama bu genelde feed senaryolarında kabul edilir.
3) Keyset’in doğru uygulanması: temel kurallar
- Deterministik sıralama kullanın. Yani
ORDER BYmutlaka unique tie-breaker içermeli (ör.CreatedAt DESC, Id DESC). - Indexed columns: ORDER BY yaptığınız sütun(lar) üzerinde uygun indeks olmalı.
- Cursor: API client’a yalnızca "son görülen" değerleri verin (ör.
lastCreatedAt+lastId) ya da güvenli bir token (Base64 + imza) kullanın. - Direction: Yükselen/azalan sıralama uyumlu WHERE koşulları hazırlayın.
- Sayfa boyutu sabit kalmalı ve mantıklı limit koyun.
4) EF Core: Kompozit sıralama (tarih + id) örneği
Feed'lerde genelde timestamp kullanılır. Ancak aynı timestamp’e sahip birden çok kayıt olabilir. Bu yüzden Id second key olarak eklenir.
Descending örnek (yeni olan en başta):
// lastCursor: { lastCreatedAt: DateTime, lastId: long }
var results = await db.Posts
.AsNoTracking()
.Where(p => p.CreatedAt < lastCreatedAt
|| (p.CreatedAt == lastCreatedAt && p.Id < lastId))
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(pageSize)
.ToListAsync(ct);Not: DateTime karşılaştırmalarında UTC kullanın. Ayrıca == DateTime karşılaştırması güvenli mi diye kontrol edin; genelde CreatedAt veritabanında kesin bir değerse güvenlidir.
5) Cursor token oluşturma ve güvenli taşıma (C# örneği)
Açık ID yerine token kullanmak isteyebilirsiniz. Aşağıda basit, URL-safe Base64 token örneği:
using System;
using System.Text;
using System.Text.Json;
public record CursorDto(DateTime CreatedAt, long Id);
public static class CursorHelper
{
public static string Encode(CursorDto cursor)
{
var json = JsonSerializer.Serialize(cursor);
var bytes = Encoding.UTF8.GetBytes(json);
return Convert.ToBase64String(bytes)
.Replace('+','-').Replace('/','_').TrimEnd('=');
}
public static CursorDto Decode(string token)
{
string padded = token.Length % 4 == 0 ? token : token + new string('=', 4 - token.Length % 4);
var base64 = padded.Replace('-','+').Replace('_','/');
var bytes = Convert.FromBase64String(base64);
var json = Encoding.UTF8.GetString(bytes);
return JsonSerializer.Deserialize<CursorDto>(json)!;
}
}Kullanım: nextCursor = CursorHelper.Encode(new CursorDto(last.CreatedAt, last.Id));
Güvenlik: Daha güvenli hale getirmek için token’ı HMAC ile imzalayın ve sunucu tarafında doğrulayın.
6) Geriye doğru sayfalama (previous page) — nasıl yapılır?
Keyset doğal olarak ileri (next) sayfalama için uygundur. Ancak önceki sayfayı desteklemek isterseniz:
- Ters sıralama ile
pageSizekadar kayıt çekin (ör.OrderByCreatedAt ASC), - Sonra sonuçları tersine çevirip istemciye gönderin.
prevCursoroluşturmak için ters sorguda dönen verinin son öğesini kullanın.
Örnek akış (descending feed için "geri"):
- Client:
prevCursorgönderir. - Server:
WHERE (CreatedAt > prevCreatedAt) OR (CreatedAt == prevCreatedAt AND Id > prevId),OrderByascending on keys,Take(pageSize), reverse results before returning.
Bu yaklaşım iki sorgu tipini yönetmeyi gerektirir. Kod karmaşıklığı artar ama kullanıcı deneyimi düzelir.
7) Performans: OFFSET vs KEYSET — ne fark eder?
- Offset: Çok büyük
OFFSETdeğerleri veritabanının atladığı satırları taramasına neden olur. İndex olsa bile genelde DB engine O(N + pageSize) çalışır. Bu, sayfa numarası arttıkça maliyet artar. - Keyset: İndex seek ile çalışır. Maliyet genelde O(log N + pageSize) civarındadır. Yani büyük veri setlerinde sabit ve düşük gecikme sunar.
Ölçek örneği: Bir tablo 10 milyon satırsa ve kullanıcı 1000. sayfayı isterse, offset bazlı sorgu 1000 * pageSize satırı atlamak zorunda kalabilir; keyset ise doğrudan index üzerinden devam eder.
Ölçüm önerisi: EXPLAIN ANALYZE, SET STATISTICS IO ON, Query Plan’ı inceleyin. Böylece offset’in tarama yaptığını ve keyset’in seek kullandığını görebilirsiniz.
8) İndeksleme ipuçları
ORDER BYyapılan sütun(lar) için bileşik indeks oluşturun. Örn:(CreatedAt DESC, Id DESC).- Eğer sorguda
WHEREkoşulunda farklı sütunlar kullanıyorsanız, uygun filtrelemeyi kapsayan indeksler eklemeyi düşünün. - Kaplayan indeks (covering index) kullanmak, disk okumasını azaltır.
9) Edge-case’ler ve tutarlılık
- Yeni kayıtlar: Keyset, zaten görülen kaydın “sonraki” öğelerinden devam eder. Yani, oturum sırasında ortaya çıkan yeni kayıtlar bazen kullanıcının sayfalarında görünmeyebilir. Bunun feed için genellikle kabul edilebilir.
- Silinmiş kayıtlar: Eğer bir kayıt silinirse, cursor’da verilen son id silinmiş olabilir; bu durumda sorgu yine normal çalışır; sadece aynı kaydı tekrar görmezsiniz.
- Atomic snapshot gereksinimi: Kesin bir, sabit snapshot istiyorsanız (örneğin raporlama), transaction snapshot isolation kullanın. Ancak bu performansı etkileyebilir.
- Veri türü hassasiyeti: Tarihlerde kellik olmaması için UTC ve yeterli hassasiyette timestamp kullanın.
10) API Tasarımı: Örnek JSON yanıt
Kullanıcıya döndüğünüz API cevabında şunlar olsun:
{
"items": [ /* array of resources */ ],
"pageSize": 20,
"nextCursor": "eyJ... (token)",
"prevCursor": "eyJ... (token) or null",
"hasMore": true
}Açıklama:
nextCursor: sonraki sayfanın başlangıcı için gerekli token.prevCursor: eğer destekliyorsanız; yoksa null.hasMore: küçük bir optimizasyon, istemcinin cursor sorgulamadan önce bileceği bilgi.
11) Hangi yöntemi ne zaman seçmeli? (Karar tablosu)
- Küçük veri / admin panel / raporlama / SEO amaçlı paginasyon → Offset tercih edilebilir.
- Büyük veri, API feed, sonsuz kaydırma, düşük gecikme gerektiren servisler → Keyset tercih edilmeli.
- Karma kullanım (ör. public statik listeler + internal feed) → Her ikisini de destekleme seçeneğini düşünün; farklı endpoint’ler kullanın.
12) Pratik kontrol listesi (Checklist before switching)
- [ ] ORDER BY sütunları kesin ve deterministik mi?
- [ ] Bu sütun(lar) üzerinde uygun indeks var mı?
- [ ] Cursor’ı nasıl taşıyacağımı (plain values mı, token mı) belirledim mi?
- [ ] Prev page ihtiyacı var mı? Varsa implement planı hazır mı?
- [ ] Token’ları HMAC ile imzalamak istiyor muyum? (güvenlik)
- [ ] Test: EXPLAIN/EVALUATE ile query planlarını karşılaştırdınız mı?
13) Örnek: EF Core’da hem Offset hem Keyset gösteren kısa karşılaştırma
// Offset
var page = await db.Posts
.OrderBy(p => p.Id)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Keyset (ascending id)
var page = await db.Posts
.Where(p => p.Id > lastId)
.OrderBy(p => p.Id)
.Take(pageSize)
.ToListAsync();Composite descending örnek (kısa):
// lastCursor holds lastCreatedAt and lastId
var page = await db.Posts
.Where(p => p.CreatedAt < lastCreatedAt
|| (p.CreatedAt == lastCreatedAt && p.Id < lastId))
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(pageSize)
.ToListAsync();14) Sonuç — Özet ve tavsiyeler
- Offset: Basit, okunur ve küçük datasetler için uygundur. Ancak büyük veride ölçeklenmez.
- Keyset: Yüksek performans, tutarlılık ve ölçeklenebilirlik sağlar. Karmaşık ama güçlüdür.
- Karar: Uygulamanızın kullanım şekline göre karar verin. API feed ve infinite scroll varsa keyset büyük ihtimalle daha iyi seçimdir. Yönetici panellerinde ve sayfa numarasının gerektiği yerlerde offset yeterlidir.
- Uygulama ipucu: Mümkünse test ortamında her iki yöntemi de denetleyin. Query planlarını, gecikmeyi ve kaynak tüketimini ölçün. Gerçek dünya verisiyle test etmek kritik öneme sahiptir.
Ek kaynak önerileri (okuma)
- Microsoft EF Core: Querying & Pagination (docs) —
https://learn.microsoft.com/ef/core/querying/pagination - Use The Index, Luke! — derin SQL indeks rehberleri (örn. keyset/seek hakkında iyi makaleler) —
https://use-the-index-luke.com/ - Pagination patterns for APIs — birçok blog ve makale var; “keyset vs offset pagination” diye arama yapabilirsiniz.
Uygulanabilir hızlı ipuçları (kısa)
- Yeni projelerde feed API’leri için baştan keyset planlayın.
- ORDER BY’da daima benzersiz ikinci anahtar (ID) kullanın.
- Cursor token’ları URL-safe ve imzalı tutun.
- Index’leri kontrol edin.
EXPLAINile doğrulayın. - Prev page gerekliyse ters sorguyu ve reversing tekniğini uygulayın.
Eğer istersen:
- Senin veri modeline göre pratik bir EF Core implementasyonu yazayım (ör.
Poststablosu, createdAt+id ile keyset) ve API endpoint örneği oluşturayım. - Ya da performans testi için örnek SQL/benchmark komutlarını hazırlayıp nasıl ölçüm yapacağını göstereyim.
Hangi adımı istersin — örnek kod + endpoint, yoksa token imzalama ve prev page implementasyonu mu?





