EF Core (Entity Framework Core) ile sayfalama yapmanın en bilinen yolu OrderBy ardından Skip ve Take kullanmaktır; bu, SQL tarafında OFFSET ve LIMIT karşılığına çevrilir. Resmî belgeler ise büyük tablolarda ve "sonraki sayfa / önceki sayfa" gezintisinde bunun yerine keyset (seek) sayfalamayı önerir: atlanacak satırları bir ofsetle değil, son okunan kaydın değerlerine dayanan bir WHERE koşuluyla geçersiniz. Hangi yöntemi seçerseniz seçin, sıralamanın tamamen benzersiz olması gerekir.
Bu yazıda iki yöntemin nasıl çalıştığını, ofset sayfalamanın iki temel zayıflığını, keyset sayfalamada birden fazla anahtarla çalışmayı, gereken indeksleri ve sık yapılan sıralama hatalarını anlatıyoruz. Sorgu sonucunu sınırlamanın genel performans etkisi EF Core performans optimizasyonu yazısında, temel LINQ operatörleri EF Core LINQ sorguları yazısında ele alınıyor.
Sayfalama neden gerekli?
Belgelerin verimli sorgulama sayfası önemli bir noktaya dikkat çeker: bir sorgu varsayılan olarak filtreyle eşleşen bütün satırları döndürür. Dönen satır sayısı veritabanındaki gerçek veriye bağlı olduğu için ne kadar veri yükleneceğini, sonuçların ne kadar bellek tutacağını ve bu sonuçları işlemenin (örneğin ağ üzerinden bir tarayıcıya göndermenin) ne kadar ek yük getireceğini önceden bilemezsiniz. Test veritabanları genellikle az veri içerdiğinden testte her şey iyi görünür, sorunlar sorgu gerçek veri üzerinde çok sayıda satır döndürmeye başladığında ortaya çıkar.
Bu yüzden sonuç sayısını sınırlamak çoğu zaman düşünmeye değer. En basit önlem bir Take eklemektir; belgeler en azından kullanıcı arayüzünde veritabanında daha fazla satır olabileceğini gösteren bir mesaj göstermeyi, tam bir çözüm olarak da sayfalamayı önerir:
var blogs25 = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.Take(25)
.ToListAsync();Sayfalama, sonuçların hepsini birden almak yerine sayfa sayfa almak demektir ve genellikle kullanıcının sonraki veya önceki sayfaya geçebildiği bir arayüzle birlikte kullanılır. Kendi sunucunuzda çalışan bir uygulamada bu yalnızca kullanıcı deneyimi değil, bellek ve veritabanı yükü açısından da bir güvenlik payıdır: tek bir isteğin binlerce satırı belleğe yüklemesi, sunucunun aynı anda karşılayabileceği istek sayısını doğrudan etkiler.
Altın kural: sıralama tamamen benzersiz olmalı
Belgeler sayfalama sayfasına bir uyarıyla başlar: hangi yöntemi kullanırsanız kullanın, sıralamanızın tamamen benzersiz olduğundan emin olun. Örneğin sonuçları yalnızca tarihe göre sıralıyorsanız ve aynı tarihe sahip birden fazla kayıt varsa, iki sayfalama sorgusu bu kayıtları farklı sırayla döndürebilir ve bazı kayıtlar atlanabilir. Tarih ve kimliğe (ya da başka bir benzersiz özellik veya özellik birleşimine) göre sıralamak sıralamayı benzersiz kılar ve sorunu önler.
Aynı uyarının ikinci yarısı da en az bunun kadar önemlidir: ilişkisel veritabanları varsayılan olarak hiçbir sıralama uygulamaz, birincil anahtara göre bile. OrderBy yazmadan Skip ve Take kullanmak, satırların hangi sırayla geleceğini veritabanına bırakmak demektir; bu da aynı sorgunun iki çalıştırması arasında farklı sonuçlar görmenize yol açabilir.
Dinamik sıralama yapan uygulamalarda bu kural kolayca ihlal edilir. Belgelerin interceptor sayfasındaki örnekte müşteriler, adı çalışma zamanında verilen bir özelliğe göre EF.Property<object>(e, sortProperty) ile sıralanıp Skip(page * 20).Take(20) ile sayfalanır. Sıralama City gibi tekrar eden değerler içeren bir özelliğe göre yapılırsa sıralama kararlı olmaz ve kullanıcı sayfalar arasında gezinirken eksik ya da tekrarlanan sonuçlar görebilir. En basit düzeltme her sorguya birincil anahtara göre ikincil bir sıralama eklemektir:
return context.Customers
.OrderBy(e => EF.Property<object>(e, sortProperty))
.ThenBy(e => e.Id)
.Skip(page * 20).Take(20).ToListAsync();Belgeler aynı ikincil sıralamayı tüm sorgulara otomatik eklemek için bir IQueryExpressionInterceptor örneği de gösterir; ancak ifade ağaçlarıyla çalışmanın zor olduğunu ve çoğu durumda sorguyu doğrudan değiştirmenin daha basit olduğunu açıkça belirtir. Interceptor'ların genel yapısı EF Core loglama ve interceptor yazısında anlatılıyor. Aynı örnekteki bir ipucu da dikkate değer: indekslenmemiş kolonlara göre sıralama yavaş olabilir.
Ofset sayfalama: Skip ve Take

Veritabanlarında sayfalamanın yaygın yolu Skip ve Take operatörleridir. Sayfa boyutu 10 olduğunda üçüncü sayfa şöyle alınır:
var position = 20;
var nextPage = await context.Posts
.OrderBy(b => b.PostId)
.Skip(position)
.Take(10)
.ToListAsync();Bu teknik çok sezgiseldir ve rastgele bir sayfaya atlamayı kolaylaştırır; sayfa numarasından ofseti hesaplamak yeterlidir. Ancak belgeler bu yöntemin iki ciddi eksikliğini listeler.
Birinci sorun: atlanan satırlar da işlenir
Veritabanı, uygulamaya döndürmese bile ilk 20 kaydı yine de işlemek zorundadır. Bu, atlanan satır sayısıyla birlikte artan ve azımsanmayacak bir hesaplama yükü oluşturabilir. İlk sayfalarda fark hissedilmez; kullanıcı ya da bir tarayıcı botu yüzlerce sayfa ileriye gittiğinde her istek, döndürmediği binlerce satırı da taramak zorunda kalır.
İkinci sorun: eşzamanlı değişikliklerde kayma
Kullanıcı sayfalar arasında gezinirken araya bir güncelleme girerse sayfalama bazı kayıtları atlayabilir ya da iki kez gösterebilir. Belgelerdeki örnekte kullanıcı ikinci sayfadan üçüncüye geçerken bir kayıt silinirse bütün sonuç kümesi bir satır yukarı kayar ve bir kayıt hiç görünmez. Kayıt eklenmesi de tersine bir etki yaparak bir kaydın iki sayfada birden görünmesine yol açabilir. Sık güncellenen listelerde bu, kullanıcıya tutarsız bir deneyim sunar.
Keyset sayfalama: WHERE ile atlamak

Belgelerin önerdiği alternatif, keyset ya da seek tabanlı sayfalama olarak adlandırılır ve satırları bir ofset yerine bir WHERE koşuluyla atlar. Son getirilen kaydın ofsetini değil ilgili değerlerini hatırlarsınız ve o satırdan sonraki satırları istersiniz. Önceki sayfadaki son kaydın kimliği 55 ise sorgu şöyledir:
var lastId = 55;
var nextPage = await context.Posts
.OrderBy(b => b.PostId)
.Where(b => b.PostId > lastId)
.Take(10)
.ToListAsync();Belgelere göre PostId üzerinde bir indeks tanımlıysa bu sorgu çok verimlidir ve daha düşük kimlik değerlerinde gerçekleşen eşzamanlı değişikliklerden etkilenmez. Veritabanı indeksi kullanarak doğrudan 55'ten sonraki konuma gider ve oradan 10 satır okur; önceki sayfalardaki satırları taramak zorunda kalmaz. Önceki sayfadan bir kayıt silinse bile sonraki sayfanın başlangıç noktası değişmez, çünkü başlangıç bir konumla değil bir değerle tanımlanmıştır.
Pratikte bu, istemcinin bir sonraki isteğinde sayfa numarası yerine son gördüğü kaydın anahtar değerini göndermesi demektir. Geri yönde gezinti için aynı mantık ters çevrilir: sıralama azalan yapılır ve koşul "ilk kayıttan küçük" olur; sonuçları ekranda göstermeden önce yeniden doğru sıraya koymanız gerekir.
Keyset sayfalamanın sınırı: rastgele erişim yok
Keyset sayfalama, kullanıcının ileri ve geri gittiği arayüzler için uygundur, ancak kullanıcının herhangi bir sayfaya doğrudan atladığı rastgele erişimi desteklemez. Rastgele erişim için ofset sayfalama gerekir. Belgeler, ofset sayfalamanın eksiklikleri nedeniyle rastgele erişimin gerçekten gerekli olup olmadığını dikkatle düşünmenizi, sonraki ve önceki sayfa gezintisinin yetip yetmediğini değerlendirmenizi önerir. Gerekliyse sağlam bir uygulama, sonraki ve önceki sayfaya geçişte keyset, başka bir sayfaya atlamada ofset kullanabilir.
Birden fazla sayfalama anahtarı
Keyset sayfalamada çoğu zaman birden fazla özelliğe göre sıralamak gerekir. Tarihe göre sıralanan bir listede tarih benzersiz olmadığı için kimlik ikinci anahtar olarak eklenir. Belgelerdeki örnek:
var lastDate = new DateTime(2020, 1, 1);
var lastId = 55;
var nextPage = await context.Posts
.OrderBy(b => b.Date)
.ThenBy(b => b.PostId)
.Where(b => b.Date > lastDate || (b.Date == lastDate && b.PostId > lastId))
.Take(10)
.ToListAsync();Koşul iki parçalıdır: ya tarih son tarihten büyüktür, ya da tarih aynıdır ve kimlik son kimlikten büyüktür. Bu, sonraki sayfanın tam olarak öncekinin bittiği yerden devam etmesini sağlar. Sıralamaya yeni anahtarlar eklendikçe koşula yeni parçalar eklenir. Koşuldaki karşılaştırma yönünün sıralama yönüyle aynı olması gerekir; azalan bir tarih sıralamasında "büyüktür" yerine "küçüktür" kullanırsınız.
Belgeler bir not daha ekler: çoğu SQL veritabanı bunun daha basit ve daha verimli bir sürümünü row value sözdizimiyle destekler; örneğin WHERE (Date, Id) > (@lastDate, @lastId). EF Core şu an bunu LINQ sorgularında ifade etmeyi desteklemez; konu GitHub'da 26822 numaralı kayıtla izlenmektedir. Bu nedenle LINQ tarafında yukarıdaki açık || biçimini yazmanız gerekir.
Sayfalama ve indeksler
Her sorguda olduğu gibi doğru indeksleme iyi performans için hayati önemdedir. Belgeler sayfalama sıralamanıza karşılık gelen indekslerin bulunmasını ister; birden fazla kolona göre sıralıyorsanız bu kolonları kapsayan bir bileşik (composite) indeks tanımlayabilirsiniz. Yukarıdaki tarih ve kimlik örneği için Fluent API ile bileşik indeks şöyle tanımlanır:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasIndex(p => new { p.Date, p.PostId });
}İndeks sayfasına göre çoğu veritabanında indeksteki her kolon artan ya da azalan olabilir. Tek kolonlu indekslerde bu genellikle önemli değildir, veritabanı indeksi gerektiğinde ters yönde dolaşabilir. Bileşik indekslerde ise sıra performans açısından kritik olabilir ve bir indeksin sorguda kullanılıp kullanılmaması arasındaki farkı belirleyebilir. Genel kural, indeks kolonlarının sıralamasının sorgunuzdaki ORDER BY ile uyumlu olmasıdır. Örneğin en yeni kayıtlar önce gelecek şekilde tarihe göre azalan, kimliğe göre artan sıralıyorsanız .IsDescending(true, false) ile kolon bazında yön verebilirsiniz.
İndekslerin genel yapılandırması EF Core index ve unique index yazısında anlatılıyor. Veritabanının bir sorguda indeksi gerçekten kullanıp kullanmadığını anlamak için yürütme planına bakmanız gerekir; bu, veritabanı tarafının konusudur ve PostgreSQL için PostgreSQL performans optimizasyonu yazısında ele alınıyor.
Ofset ve keyset karşılaştırması

| Özellik | Ofset (Skip/Take) | Keyset (WHERE ile) |
|---|---|---|
| SQL karşılığı | OFFSET ve LIMIT | WHERE koşulu ve LIMIT |
| Atlanan satırların maliyeti | Atlanan satır sayısıyla artar | İndeks varsa atlama maliyeti yok |
| Eşzamanlı ekleme ve silme | Kayıt atlanabilir veya tekrarlanabilir | Önceki satırlardaki değişikliklerden etkilenmez |
| Rastgele sayfaya atlama | Destekler | Desteklemez |
| İstemcinin tuttuğu bilgi | Sayfa numarası | Son kaydın sıralama değerleri |
| Benzersiz sıralama | Gerekli | Gerekli |
İlişkili verilerle sayfalama
Sayfalanan sorguya ilişkili koleksiyonlar da dahil edildiğinde dikkat edilmesi gereken iki konu vardır. Birincisi, filtered include özelliğiyle bir navigation'daki ilişkili kayıtları da sınırlayabilirsiniz; belgelerdeki örnekte her blogun yazıları OrderByDescending ve Take(5) ile beşe indirilir ve yükleme yine tek bir gidiş dönüşte yapılır. Bu konu Include ve eager loading yazısında anlatılıyor.
İkincisi split query kullanımıdır. EF Core 10 sürüm notlarına göre önceki sürümlerde AsSplitQuery, Include, OrderBy ve Take birlikte kullanıldığında, ilişkili kayıtları okuyan ikinci sorgunun içine gömülen alt sorgudaki sıralama kimlik kolonunu içermiyordu ve bu durum yanlış verilerin dönmesine yol açabiliyordu. EF Core 10 sıralamanın sorgular arasında tutarlı olmasını sağlayarak bunu düzeltti. Benzersiz olmayan bir kolona göre sıralayıp sayfaladığınız split query'lerde bu, doğrudan sayfalamanın doğruluğunu ilgilendirir; ayrıntılar split query ve AsSplitQuery yazısında.
Mini örnek: "daha fazla yükle" listesi
Bir blogda yazıları en yeniden eskiye doğru, her seferinde 20 kayıt olacak şekilde "daha fazla yükle" düğmesiyle gösterdiğinizi düşünün. Rastgele sayfa atlaması gerekmediği için keyset sayfalama doğal seçimdir. İlk istekte sorgu yalnızca sıralama ve Take(20) içerir: OrderByDescending(p => p.Date).ThenByDescending(p => p.PostId). Sonuçların son kaydının Date ve PostId değerleri istemciye bir sonraki istek için verilir.
Sonraki istekte bu iki değer koşula dönüşür. Sıralama azalan olduğu için karşılaştırmalar da ters çevrilir: p.Date < lastDate || (p.Date == lastDate && p.PostId < lastId). Bu koşul, belgelerdeki artan sıralı örneğin aynı mantıkla yazılmış azalan karşılığıdır. Veritabanında Date ve PostId kolonlarını kapsayan ve sıralama yönüyle uyumlu bir bileşik indeks varsa her istek, liste ne kadar uzarsa uzasın, yalnızca ihtiyacı olan satırları okur. Kullanıcı listeyi okurken yeni bir yazı yayımlanırsa bu yazı listenin başında, yani zaten geçilmiş bölgede yer alır ve sonraki sayfaların içeriğini kaydırmaz.
Aynı listeyi salt okunur gösterecekseniz sorguyu takipsiz (no-tracking) çalıştırmak ve yalnızca ekranda kullanılan kolonları projeksiyonla almak ek kazanç sağlar. Bu iki teknik sırasıyla AsNoTracking ve EF Core performans yazılarında anlatılıyor.
Sık yapılan hatalar
- OrderBy olmadan Skip ve Take: İlişkisel veritabanları varsayılan sıralama uygulamaz. Sıralamasız sayfalama, aynı isteğin farklı sonuçlar döndürmesine yol açabilir.
- Benzersiz olmayan sıralama: Yalnızca tarih, şehir ya da ad gibi tekrar eden bir kolona göre sıralamak kayıtların sayfalar arasında atlanmasına veya tekrarlanmasına neden olabilir. Her zaman benzersiz bir anahtarı ikincil sıralama olarak ekleyin.
- Keyset koşulunu sıralamadan farklı yazmak: Koşuldaki karşılaştırma yönü ve kolon sırası,
OrderByveThenByile birebir uyumlu olmalıdır. - İndeksi unutmak: Keyset sayfalamanın verimliliği sıralamaya karşılık gelen bir indekse bağlıdır. İndeks olmadan veritabanı yine bütün tabloyu tarayabilir.
- Büyük ofsetleri sınırlamamak: Rastgele sayfa erişimi sunuyorsanız çok büyük ofsetlerin veritabanına yükünü düşünün; belgelerin önerdiği karma yaklaşım ileri ve geri gezintide keyset kullanmaktır.
- Sayfayı ToList ile çekip bellekte bölmek: Bütün sonuçları yükleyip uygulama tarafında
SkipveTakeuygulamak sayfalamanın amacını ortadan kaldırır; operatörler sorgu çalışmadan önce,IQueryableüzerinde uygulanmalıdır.
Sık Sorulan Sorular
EF Core'da Skip ve Take neden yavaşlıyor?
Çünkü veritabanı, döndürmediği satırları da işlemek zorundadır ve bu yük atlanan satır sayısıyla artar. Sonraki ve önceki sayfa gezintisi yeterliyse keyset sayfalamaya geçin ve sıralama kolonlarınız için uygun bir indeks tanımlayın.
Keyset sayfalamada belirli bir sayfaya atlayabilir miyim?
Hayır, keyset sayfalama rastgele erişimi desteklemez. Belgeler, rastgele erişim gerçekten gerekiyorsa ileri ve geri gezintide keyset, başka bir sayfaya atlamada ofset kullanan karma bir uygulamayı önerir.
Row value sözdizimini LINQ ile yazabilir miyim?
Belgelere göre EF Core şu an WHERE (Date, Id) > (@lastDate, @lastId) biçimini LINQ sorgularında ifade etmeyi desteklemez. Bunun yerine her anahtar için açık karşılaştırmaları || ve && ile birleştiren koşulu yazmanız gerekir.
Keyset sayfalama için hazır bir kütüphane var mı?
Belgelerin ek kaynaklar bölümünde keyset sayfalamayı kolaylaştıran topluluk kütüphaneleri olarak MR.EntityFrameworkCore.KeysetPagination ve MR.AspNetCore.Pagination anılır. Bunlar EF Core'un parçası değildir; kullanmadan önce kendi belgelerini inceleyin.
Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.
İlgili içerikler: EF Core öğrenme rehberi · EF Core index · EF Core performans · Split query · AsNoTracking