Pagination Stratejileri Derinlemesine: Offset, Keyset ve Opaque Cursor Token
Büyük ve değişen tablolarda keyset pagination, offset'in derinlikle artan maliyetini ve sayfa kaymasını ortadan kaldırır: 2 milyon satırlık testte 5000. sayfa offset ile 375, keyset ile 6 logical read okudu. Bu yazı SQL Server'da tie-breaker'lı keyset koşulunu, HMAC ile imzalı opaque cursor'ı, önceki sayfayı ve nullable sıralama kolonu tuzağını gerçek ölçümlerle gösteriyor.
Sürüm notu: Bu yazıdaki C# kodları .NET 10 ve Entity Framework Core (EF Core) 10 ile, SQL (Structured Query Language) örnekleri SQL Server 2025 ile denenmiştir.
Önce Tahmin Et: İkinci Sayfanın İlk Satırı
İK (İnsan Kaynakları) Portalı'nın satın alma ekranı siparişleri en yeniden eskiye, sayfa başına 5 satır listeliyor. ASP.NET Core Web API (Application Programming Interface) serisindeki "ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" yazısında kurduğumuz Skip/Take pattern'iyle yazılmış; sıralama kararlı, tie-breaker olarak Id de var:
async Task<List<int>> Offset(int page) => await db.PurchaseOrders.AsNoTracking()
.Where(o => o.CompanyId == companyId)
.OrderByDescending(o => o.CreatedAt).ThenByDescending(o => o.Id)
.Skip((page - 1) * 5).Take(5)
.Select(o => o.Id).ToListAsync();
List<int> offset1 = await Offset(1);
int newId = await InsertOrder(); // bu arada biri yeni bir sipariş açıyor
List<int> offset2 = await Offset(2);
Kullanıcı birinci sayfaya bakarken biri yeni bir sipariş açıyor, sonra kullanıcı ikinci sayfaya geçiyor. İkinci sayfanın ilk satırı hangi sipariş olur? Bir de şunu düşün: aynı tablo 2 milyon satır ve bir şirketin 400.000 siparişi var. 5000. sayfayı istemek, 1. sayfayı istemekten kaç kat pahalı?
Bağlam ve Problem: Offset'in Derinlik Vergisi
İK Portalı beş şirkete hizmet veriyor ve PurchaseOrders tablosu 2 milyon satıra ulaştı; her şirketin yaklaşık 400.000 siparişi var. Toplu import edilen siparişler yüzünden aynı saniyede oluşmuş onlarca satır var, yani CreatedAt tek başına benzersiz değil. Test verisini SQL Server 2022 ile gelen GENERATE_SERIES ile ürettik:
CREATE TABLE dbo.PurchaseOrders
(
Id int IDENTITY PRIMARY KEY,
CompanyId int NOT NULL,
SupplierId int NOT NULL,
CreatedAt datetime2(0) NOT NULL,
ApprovedAt datetime2(0) NULL,
Amount decimal(19, 4) NOT NULL,
Status tinyint NOT NULL
);
/* 2 milyon satır, 5 şirket; aynı saniyede birden çok sipariş olsun diye 10 satıra bir saniye */
INSERT dbo.PurchaseOrders (CompanyId, SupplierId, CreatedAt, ApprovedAt, Amount, Status)
SELECT s.value % 5 + 5,
s.value % 300 + 1,
DATEADD(second, s.value / 10, '2024-01-01'),
CASE WHEN s.value % 4 = 0 THEN NULL ELSE DATEADD(second, s.value / 10 + 3600, '2024-01-01') END,
CAST(s.value % 9000 + 100 AS decimal(19, 4)),
s.value % 4
FROM GENERATE_SERIES(1, 2000000) AS s;
Web API serisindeki yazı offset pagination'ın temellerini, PagedResult zarfını ve güvenli sıralamayı kurmuştu; orada "derin sayfalarda keyset daha uygun" deyip bırakmıştık. Bu yazı o cümlenin devamı: offset'in maliyeti nereden geliyor, keyset nasıl kurulur, cursor istemciye nasıl güvenle verilir ve keyset'in yapamadığı şeyler neler.
Belirtiler
Liste endpoint'inin süresi sayfa numarasıyla birlikte artıyor; ilk sayfalar hızlı, son sayfalar yavaş. Sonsuz kaydırmalı bir ekranda kullanıcı aynı satırı iki kez görüyor ya da bazı satırları hiç görmüyor. Log'larda her liste isteğinin yanında bir COUNT(*) sorgusu var ve asıl sorgudan daha pahalı. Bir entegrasyon tüm siparişleri ?page=1'den başlayıp sırayla çekiyor ve gece işi her hafta biraz daha uzun sürüyor. Execution plan'da OFFSET değeri büyüdükçe okunan satır sayısının da büyüdüğü görülüyor.
Naif Çözüm ve Neden Kırılır
Baştaki sorunun gerçek cevabı:
page 1: 1999997,1999992,1999987,1999982,1999977
new order: 2000005
page 2: 1999977,1999972,1999967,1999962,1999957
1999977 numaralı sipariş birinci sayfanın son satırıydı, ikinci sayfanın da ilk satırı oldu. Yeni sipariş en başa eklendiği için bütün satırlar bir sıra kaydı ve OFFSET 5 artık bir öncekinin son satırını gösteriyor. Silinen bir sipariş olsaydı tersi olurdu: bir satır hiç görünmezdi. Offset "ilk N satırı atla" der; N satırın kendisi değiştiğinde neyi atladığını bilmez.
İkinci kırılma maliyet. Aynı şirketin siparişleri için dört sorguyu SET STATISTICS IO ON ile ölçtük. Süre yerine logical read sayısına bakıyoruz, çünkü container emülasyon altında çalışıyor ve süre ölçümü yanıltıcı; logical read ise SQL Server'ın gerçekten kaç sayfa okuduğunu gösteriyor:
DECLARE @c datetime2(0), @i int;
SELECT @c = CreatedAt, @i = Id FROM dbo.PurchaseOrders WHERE CompanyId = 7
ORDER BY CreatedAt DESC, Id DESC OFFSET 99979 ROWS FETCH NEXT 1 ROWS ONLY;
SET STATISTICS IO ON;
/* offset: 5000. sayfa (son sayfa için aynı sorgu OFFSET 399980 ile) */
SELECT Id, CreatedAt, Amount FROM dbo.PurchaseOrders WHERE CompanyId = 7
ORDER BY CreatedAt DESC, Id DESC OFFSET 99980 ROWS FETCH NEXT 20 ROWS ONLY;
/* keyset: 4999. sayfanın son satırından sonrası */
SELECT TOP (20) Id, CreatedAt, Amount FROM dbo.PurchaseOrders
WHERE CompanyId = 7 AND (CreatedAt < @c OR (CreatedAt = @c AND Id < @i))
ORDER BY CreatedAt DESC, Id DESC;
SELECT COUNT(*) FROM dbo.PurchaseOrders WHERE CompanyId = 7;
/* index yok: dört sorgunun dördü de aynı */
Table 'PurchaseOrders'. Scan count 11, logical reads 10834, physical reads 0, ...
Index yokken her sorgu tabloyu baştan sona tarıyor ve sıralıyor; birinci sayfa da 5000. sayfa da 10.834 sayfa okuyor. İlk iş doğru index'i koymak:
CREATE INDEX IX_PurchaseOrders_Company_CreatedAt_Id
ON dbo.PurchaseOrders (CompanyId, CreatedAt DESC, Id DESC)
INCLUDE (Amount);
/* offset, 1. sayfa */
Table 'PurchaseOrders'. Scan count 1, logical reads 4, physical reads 0, ...
/* offset, 5000. sayfa */
Table 'PurchaseOrders'. Scan count 1, logical reads 375, physical reads 0, ...
/* offset, 20000. sayfa (OFFSET 399980) */
Table 'PurchaseOrders'. Scan count 1, logical reads 1450, physical reads 0, ...
/* keyset, 4999. sayfadan sonra */
Table 'PurchaseOrders'. Scan count 2, logical reads 6, physical reads 0, ...
/* COUNT(*) */
Table 'PurchaseOrders'. Scan count 1, logical reads 1451, physical reads 0, ...
Artık birinci sayfa 4 sayfa okuyor, ama offset'in derinlik vergisi görünür hâle geldi: 5000. sayfa 375, son sayfa 1.450 sayfa. SQL Server atlanan 99.980 satırı da index üzerinde tek tek yürüyor. Keyset aynı satırlara 6 sayfa okuyarak ulaşıyor ve bu sayı sayfa derinliğinden bağımsız. COUNT(*) ise son sayfa kadar pahalı: toplam sayıyı göstermek için her istekte şirketin bütün index aralığı okunuyor.
Seçilen kolonlar index'te yoksa fark çok daha sertleşiyor. Aynı iki sorguyu SELECT * ile çalıştırdık:
/* SELECT *, offset 5000. sayfa */
Table 'PurchaseOrders'. Scan count 11, logical reads 10834, physical reads 0, ...
/* SELECT *, keyset 4999. sayfadan sonra */
Table 'PurchaseOrders'. Scan count 2, logical reads 74, physical reads 0, ...
Offset için SQL Server 99.980 satırın her biri için tabloya geri dönmek (key lookup) yerine bütün tabloyu taramayı seçti. Keyset ise yalnız 20 satır için lookup yaptı ve 74 sayfada bitti.
Seçenekler
Offset ve Toplam Sayı
Skip/Take artı COUNT(*). Sayfa numarası gösteren, "7. sayfaya git" diyebilen klasik grid. Küçük ve orta tablolarda, yönetim ekranlarında hâlâ en doğal seçenek. Bedeli derin sayfalarda artan okuma, her istekte bir COUNT(*) ve veri değişirken kayan sayfalar.
Offset ve "Daha Fazla Var mı"
Toplam sayıyı bırakıp pageSize + 1 satır istemek; fazladan satır geldiyse bir sonraki sayfa var demektir. COUNT(*) maliyetini sıfırlar, "toplam 400.000 kayıt" bilgisini kaybettirir. Derinlik vergisi ve kayma problemi aynen kalır.
Keyset (Seek) Pagination
"SQL Performance Explained" kitabının ve use-the-index-luke.com sitesinin yazarı Markus Winand'ın "no offset" çağrısındaki yöntem: "ilk N satırı atla" yerine "son gördüğüm satırdan sonrakileri getir". Sıralama CreatedAt DESC, Id DESC ise bir sonraki sayfa (CreatedAt, Id) < (@lastCreatedAt, @lastId) koşuluyla, yani son satırın değerlerinden küçük olanlarla gelir. Index'te doğrudan o noktaya seek eder; sayfa derinliği maliyeti değiştirmez ve araya eklenen satırlar kaymaya yol açmaz. Bedeli rastgele sayfaya atlayamamak ve sıralama kolonları için doğru index'i kurmak zorunda olmak.
Opaque Cursor Token
Keyset'in sıralama değerlerini (createdAt, id, yön) istemciye ayrı ayrı query parametresi olarak değil, tek bir opak token olarak vermek. İstemci token'ın içini bilmez ve bilmek zorunda değildir; sunucu sıralama kolonlarını değiştirdiğinde API sözleşmesi değişmez. Token imzalanırsa istemcinin onu değiştirip başka bir şirketin verisine ya da desteklenmeyen bir sıralamaya kayması da engellenir. Seçtiğimiz seçenek keyset ile imzalı opaque cursor'ın birleşimi.
Uygulama: Keyset, İmzalı Cursor ve Önceki Sayfa
Satır Karşılaştırması SQL Server'da Yok
Standart SQL'deki satır değeri karşılaştırması (row value comparison) keyset koşulunu tek satırda yazmayı sağlar ve PostgreSQL bunu destekler. SQL Server'da denediğimizde:
SELECT TOP (20) Id FROM dbo.PurchaseOrders
WHERE CompanyId = 7 AND (CreatedAt, Id) < ('2024-01-02T17:40:10', 1500102)
ORDER BY CreatedAt DESC, Id DESC;
Msg 4145, Level 15, State 1, Line 2
An expression of non-boolean type specified in a context where a condition is expected, near ','.
Bu yüzden koşulu açık hâliyle yazıyoruz: CreatedAt < @c OR (CreatedAt = @c AND Id < @i). Endişe şu olabilir: OR index kullanımını bozmaz mı? Gerçek execution plan'a baktık:
|--Top(TOP EXPRESSION:((20)))
|--Index Seek(OBJECT:([HrPortal].[dbo].[PurchaseOrders].[IX_PurchaseOrders_Company_CreatedAt_Id]),
SEEK:([HrPortal].[dbo].[PurchaseOrders].[CompanyId]=(7) AND [HrPortal].[dbo].[PurchaseOrders].[CreatedAt]=[@c] AND [HrPortal].[dbo].[PurchaseOrders].[Id] < [@i]
OR [HrPortal].[dbo].[PurchaseOrders].[CompanyId]=(7) AND [HrPortal].[dbo].[PurchaseOrders].[CreatedAt] < [@c]) ORDERED FORWARD)
SQL Server OR'un iki kolunu aynı index üzerinde iki ayrı seek aralığına çevirdi; ölçümdeki "Scan count 2" bu iki aralık. Index'i EF Core modelinde de tanımlıyoruz; IsDescending EF Core 7'den beri var:
modelBuilder.Entity<PurchaseOrder>(e =>
{
e.Property(o => o.CreatedAt).HasColumnType("datetime2(0)");
e.Property(o => o.ApprovedAt).HasColumnType("datetime2(0)");
e.Property(o => o.Amount).HasPrecision(19, 4);
e.HasIndex(o => new { o.CompanyId, o.CreatedAt, o.Id })
.IsDescending(false, true, true)
.HasDatabaseName("IX_PurchaseOrders_Company_CreatedAt_Id")
.IncludeProperties(o => o.Amount);
});
Pager
public sealed record OrderCursor(int CompanyId, DateTime CreatedAt, int Id, bool Backward);
public sealed record OrderRow(int Id, DateTime CreatedAt, decimal Amount);
public sealed record CursorPage<T>(IReadOnlyList<T> Items, string? Next, string? Previous);
public sealed class PurchaseOrderPager(HrPortalDbContext db, CursorCodec codec)
{
public IQueryable<PurchaseOrder> BuildQuery(int companyId, OrderCursor? cursor)
{
IQueryable<PurchaseOrder> query = db.PurchaseOrders.AsNoTracking().Where(o => o.CompanyId == companyId);
if (cursor is { Backward: false } after)
{
// SQL Server'da (CreatedAt, Id) < (@c, @i) yazılamaz; açık hâli:
query = query.Where(o => o.CreatedAt < after.CreatedAt
|| (o.CreatedAt == after.CreatedAt && o.Id < after.Id));
}
else if (cursor is { Backward: true } before)
{
query = query.Where(o => o.CreatedAt > before.CreatedAt
|| (o.CreatedAt == before.CreatedAt && o.Id > before.Id));
}
return cursor is { Backward: true }
? query.OrderBy(o => o.CreatedAt).ThenBy(o => o.Id)
: query.OrderByDescending(o => o.CreatedAt).ThenByDescending(o => o.Id);
}
public async Task<CursorPage<OrderRow>> GetPageAsync(int companyId, string? token, int pageSize, CancellationToken ct = default)
{
OrderCursor? cursor = null;
if (token is not null && (!codec.TryDecode(token, out cursor) || cursor.CompanyId != companyId))
{
throw new InvalidCursorException();
}
List<OrderRow> rows = await BuildQuery(companyId, cursor)
.Take(pageSize + 1)
.Select(o => new OrderRow(o.Id, o.CreatedAt, o.Amount))
.ToListAsync(ct);
// bir fazla satır istedik: "daha fazla var mı" sorusunun COUNT'suz cevabı
bool more = rows.Count > pageSize;
if (more)
{
rows.RemoveAt(rows.Count - 1);
}
bool backward = cursor?.Backward ?? false;
if (backward)
{
rows.Reverse();
}
bool hasNext = backward || more;
bool hasPrevious = backward ? more : cursor is not null;
return new CursorPage<OrderRow>(
rows,
hasNext && rows.Count > 0 ? codec.Encode(new OrderCursor(companyId, rows[^1].CreatedAt, rows[^1].Id, false)) : null,
hasPrevious && rows.Count > 0 ? codec.Encode(new OrderCursor(companyId, rows[0].CreatedAt, rows[0].Id, true)) : null);
}
}
Üç karar var. Birincisi tie-breaker: CreatedAt benzersiz olmadığı için sıralama ve koşul Id'yi de içeriyor; yoksa aynı saniyedeki siparişlerden bazıları sayfa sınırında kaybolur. İkincisi pageSize + 1: fazladan bir satır istemek, COUNT(*) olmadan "sonraki sayfa var mı" sorusunu cevaplıyor. Üçüncüsü önceki sayfa: geri giderken koşulu ve sıralamayı ters çeviriyoruz (> ve ASC), gelen satırları da bellekte ters çevirip ekrandaki sıraya döndürüyoruz.
Baştaki senaryoyu keyset ile tekrarladık:
CursorPage<OrderRow> p1 = await pager.GetPageAsync(companyId, null, 5);
newId = await InsertOrder();
CursorPage<OrderRow> p2 = await pager.GetPageAsync(companyId, p1.Next, 5);
CursorPage<OrderRow> back = await pager.GetPageAsync(companyId, p2.Previous, 5);
page 1: 1999997,1999992,1999987,1999982,1999977
new order: 2000006
page 2: 1999972,1999967,1999962,1999957,1999952
previous of page 2: 1999997,1999992,1999987,1999982,1999977 (prev cursor: yes)
cursor: eyJjb21wYW55SWQiOjcsImNyZWF0ZWRBdCI6IjIwMjQtMDEtMDNUMDc6MzM6MTciLCJpZCI6MTk5OTk3NywiYmFja3dhcmQiOmZhbHNlfQ.4Q2eJGhy9FqMlgBq98TSWA
Yeni sipariş araya girdiği hâlde ikinci sayfa 1999972'den başladı; tekrar yok. İkinci sayfadan geri dönünce birinci sayfanın aynı beş satırı geldi. "Previous" cursor'ın hâlâ dolu olması da doğru: araya giren 2000006 numaralı sipariş birinci sayfanın da önünde. EF Core'un ürettiği SQL, elle yazdığımız sorgunun aynısı:
DECLARE @p int = 21;
DECLARE @companyId int = 7;
DECLARE @after_CreatedAt datetime2 = '2024-01-03T07:33:17.0000000';
DECLARE @after_Id int = 1999977;
SELECT TOP(@p) [p].[Id], [p].[CreatedAt], [p].[Amount]
FROM [PurchaseOrders] AS [p]
WHERE [p].[CompanyId] = @companyId AND ([p].[CreatedAt] < @after_CreatedAt OR ([p].[CreatedAt] = @after_CreatedAt AND [p].[Id] < @after_Id))
ORDER BY [p].[CreatedAt] DESC, [p].[Id] DESC
Cursor'ı İmzalamak
Token'ın içinde şirket id'si, sıralama değerleri ve yön var. Base64url ile kodlanmış JSON (JavaScript Object Notation), istemci tarafından kolayca okunur ve değiştirilir; bu yüzden Secure Hash Algorithm 256-bit (SHA-256) tabanlı Hash-based Message Authentication Code (HMAC), yani HMAC-SHA256 ile imzalıyoruz:
public sealed class CursorCodec(byte[] key)
{
public string Encode<T>(T payload)
{
byte[] json = JsonSerializer.SerializeToUtf8Bytes(payload, JsonSerializerOptions.Web);
byte[] mac = HMACSHA256.HashData(key, json).AsSpan(0, 16).ToArray();
return $"{Base64Url.EncodeToString(json)}.{Base64Url.EncodeToString(mac)}";
}
public bool TryDecode<T>(string token, [NotNullWhen(true)] out T? payload)
{
payload = default;
string[] parts = token.Split('.');
if (parts.Length != 2)
{
return false;
}
try
{
byte[] json = Base64Url.DecodeFromChars(parts[0]);
byte[] mac = Base64Url.DecodeFromChars(parts[1]);
byte[] expected = HMACSHA256.HashData(key, json).AsSpan(0, 16).ToArray();
if (!CryptographicOperations.FixedTimeEquals(mac, expected))
{
return false;
}
payload = JsonSerializer.Deserialize<T>(json, JsonSerializerOptions.Web);
return payload is not null;
}
catch (FormatException)
{
return false;
}
}
}
FixedTimeEquals imza karşılaştırmasında zamanlama farkından bilgi sızmasını engeller. Token'ı bir şirkete bağlamak da önemli: kayıtlı bir token'ı başka bir tenant'ın isteğinde kullanmak, keyset koşulunun başka bir veri kümesine uygulanması demek. Kurcalanmış token ve başka şirketin token'ı ile yaptığımız deneme:
tampered: The cursor is invalid or belongs to another query.
other company: The cursor is invalid or belongs to another query.
İmzalamak şifrelemek değildir; token'ın içi hâlâ okunabilir. Sıralama değerleri hassas veri içeriyorsa (örneğin maaşa göre sıralanan bir çalışan listesi) token'ı ASP.NET Core Data Protection ile şifrelemek gerekir. Kriptografik temeller için OWASP (Open Worldwide Application Security Project) serisindeki "OWASP A04: Cryptographic Failures (1) — Encoding, Hashing, Encryption ve HTTPS" yazısına (HTTPS: Hypertext Transfer Protocol Secure) bakabilirsin.
Endpoint
app.MapGet("/purchase-orders", async (string? cursor, int? pageSize, PurchaseOrderPager pager, CancellationToken ct) =>
{
const int companyId = 7; // demo: gerçek uygulamada tenant claim'inden
try
{
return Results.Ok(await pager.GetPageAsync(companyId, cursor, Math.Clamp(pageSize ?? 20, 1, 100), ct));
}
catch (InvalidCursorException ex)
{
return Results.Problem(statusCode: 400, title: ex.Message);
}
});
GET /purchase-orders?pageSize=2
{"items":[{"id":1999997,"createdAt":"2024-01-03T07:33:19","amount":2097.0000},{"id":1999992,"createdAt":"2024-01-03T07:33:19","amount":2092.0000}],"next":"eyJjb21wYW55SWQiOjcsImNyZWF0ZWRBdCI6IjIwMjQtMDEtMDNUMDc6MzM6MTkiLCJpZCI6MTk5OTk5MiwiYmFja3dhcmQiOmZhbHNlfQ.4awJs1kf-r5bnhE02E8EKQ","previous":null}
GET /purchase-orders?cursor=abc.def
400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"The cursor is invalid or belongs to another query.","status":400,"traceId":"0HNPSVJEUOV8B:00000002"}
İlk iki sipariş aynı saniyede oluşmuş: tie-breaker'ın neden şart olduğunun canlı örneği. Bozuk bir cursor 500 değil, 400 alıyor; bu bir istemci hatası. Yanıtta toplam sayı yok, yalnız next ve previous.
Angular: Sonsuz Kaydırma
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
export interface CursorPage<T> { items: T[]; next: string | null; previous: string | null; }
export interface OrderRow { id: number; createdAt: string; amount: number; }
@Injectable({ providedIn: 'root' })
export class PurchaseOrderFeed {
private readonly http = inject(HttpClient);
readonly rows = signal<OrderRow[]>([]);
readonly loading = signal(false);
readonly hasMore = signal(true);
private next: string | null = null;
// liste sonuna yaklaşınca (IntersectionObserver) çağrılır
loadMore(): void {
if (this.loading() || !this.hasMore()) {
return;
}
this.loading.set(true);
const params: Record<string, string> = this.next ? { pageSize: '50', cursor: this.next } : { pageSize: '50' };
this.http.get<CursorPage<OrderRow>>('/api/purchase-orders', { params }).subscribe({
next: page => {
this.rows.update(rows => [...rows, ...page.items]);
this.next = page.next;
this.hasMore.set(page.next !== null);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
// filtre veya sıralama değişince cursor geçersizdir: baştan başla
reset(): void {
this.rows.set([]);
this.next = null;
this.hasMore.set(true);
}
}
İstemci cursor'ı yorumlamaz, yalnızca saklar ve geri gönderir. Filtre ya da sıralama değişince eski cursor anlamsızdır; reset listeyi ve cursor'ı temizler. Sunucu tarafında da cursor'ın içine filtrenin bir özetini koyup uyuşmazlıkta 400 dönmek, istemci hatalarını erken yakalar.
Nullable Sıralama Kolonu
Sıralama kolonu nullable olduğunda keyset koşulu sessizce satır kaybeder. Siparişleri onay tarihine göre (ApprovedAt DESC) dolaşan iki versiyonu bir saatlik bir aralıkta denedik. SQL Server DESC sıralamada NULL'ları en sona koyar:
async Task<int> WalkApprovals(bool nullSafe)
{
int count = 0;
(DateTime? ApprovedAt, int Id)? last = null;
while (true)
{
IQueryable<PurchaseOrder> q = approvals;
if (last is { } c)
{
q = !nullSafe
? q.Where(o => o.ApprovedAt < c.ApprovedAt || (o.ApprovedAt == c.ApprovedAt && o.Id < c.Id))
: c.ApprovedAt is not null
// NULL'lar en sonda: henüz NULL bölgesine gelmedik, NULL'lar hâlâ önümüzde
? q.Where(o => o.ApprovedAt < c.ApprovedAt || (o.ApprovedAt == c.ApprovedAt && o.Id < c.Id) || o.ApprovedAt == null)
: q.Where(o => o.ApprovedAt == null && o.Id < c.Id);
}
var rows = await q.OrderByDescending(o => o.ApprovedAt).ThenByDescending(o => o.Id)
.Take(500).Select(o => new { o.ApprovedAt, o.Id }).ToListAsync();
if (rows.Count == 0)
{
return count;
}
count += rows.Count;
last = (rows[^1].ApprovedAt, rows[^1].Id);
}
}
naive walk: 5400 rows
null-safe walk: 7200 rows
COUNT(*): 7200, of which NULL: 1800
Naif sürüm 1.800 onaylanmamış siparişi hiç göstermedi: ApprovedAt < @c karşılaştırması NULL için hiçbir zaman doğru olmaz. Null-safe sürüm cursor'ın hangi bölgede olduğunu biliyor: NULL olmayan bölgedeyken NULL'lar hâlâ önde, NULL bölgesine girince yalnız Id ile ilerleniyor. Daha basit yollar da var: kolonu NOT NULL yapmak ya da sıralamayı nullable olmayan bir kolona (CreatedAt) taşımak. "Sentinel Value'lar" yazısında anlattığımız gibi NULL yerine 9999-12-31 gibi bir değer koymak ise sorunu çözmez, yalnızca saklar.
Trade-off Tablosu
| Kriter | Offset + COUNT | Offset + hasMore | Keyset (açık parametre) | Keyset + imzalı cursor |
|---|---|---|---|---|
| Veri bütünlüğü | Değişen veride kayma, tekrar | Değişen veride kayma, tekrar | Kayma yok | Kayma yok, tenant'a bağlı |
| Sorgu karmaşıklığı | Düşük | Düşük | Orta (OR koşulu, ters yön, NULL) | Orta + token kodlama/imza |
| Performans | Derinlikle artar + her istekte COUNT | Derinlikle artar | Derinlikten bağımsız | Derinlikten bağımsız |
| Geri dönülebilirlik | Rastgele sayfaya atlanır | Rastgele sayfaya atlanır | Yalnız ileri/geri | Yalnız ileri/geri, sözleşme sabit |
| Ekip bilişsel yükü | Düşük | Düşük | Orta | Orta (key yönetimi) |
Ne Zaman Kullanma
Kullanıcı gerçekten rastgele sayfaya atlamak istiyorsa keyset yanlış araç. Muhasebe ekibinin "son sayfaya git" ya da "12. sayfaya dön" dediği bir yönetim ekranında offset doğru seçimdir; derinlik vergisi birkaç bin satırlık filtrelenmiş listelerde hissedilmez.
Kullanıcının istediği herhangi bir kolona göre sıralayabildiği gridlerde keyset her sıralama kombinasyonu için ayrı bir index ister. On kolonlu bir gridde bu mümkün değil; o durumda en sık kullanılan iki üç sıralama için keyset, geri kalanı için offset artı makul bir sayfa sınırı daha gerçekçi.
İş birimi "toplam kayıt sayısı" görmek zorundaysa ve tablo büyükse, her istekte COUNT(*) yerine cache'lenmiş ya da yaklaşık bir sayı (örneğin "10.000+") düşün. Kesin sayı yalnız gerçekten gerektiğinde, ayrı bir istekle hesaplansın.
Küçük tablolarda (birkaç bin satır, nadiren değişen) bütün bu yapı fazladan karmaşıklık. Web API serisindeki offset pattern'i yeterli.
Kontrol Listesi
- Sıralama her zaman benzersiz bir tie-breaker (
Id) ile bitiyor ve keyset koşulu aynı kolonları aynı sırayla kullanıyor. - Filtre + sıralama kolonları için composite index var; sık seçilen kolonlar
INCLUDEile kapsanıyor. - Keyset koşulu SQL Server'da açık
ORbiçiminde yazılıyor ve execution plan'da seek olduğu doğrulandı. - "Sonraki sayfa var mı"
pageSize + 1ile cevaplanıyor;COUNT(*)her istekte çalışmıyor. - Cursor opak, base64url, HMAC ile imzalı ve tenant'a (gerekirse filtreye) bağlı; bozuk cursor 400 alıyor.
- Önceki sayfa ters koşul ve ters sıralama ile alınıp bellekte çevriliyor.
- Nullable sıralama kolonu varsa
NULLbölgesi ayrıca ele alınıyor ya da kolonNOT NULLyapılıyor. pageSizeiçin üst sınır var; istemci filtreyi değiştirince cursor'ı sıfırlıyor.
İlgili Pattern'ler ve Kaynaklar
Bu yazı ASP.NET Core Web API serisindeki "ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" yazısının derinleşmiş hâli; offset, PagedResult ve güvenli dinamik sıralama orada. Serideki "Multi-Tenancy'de Veri İzolasyonu: Database-per-Tenant, Shared Schema ve Row-Level Security" yazısı cursor'ı neden tenant'a bağladığımızı, "Sentinel Value'lar: 0, -1, "1900-01-01" ve 9999-12-31'in Gizli Maliyeti" yazısı da nullable sıralama kolonunu sahte bir değerle doldurmanın neden çözüm olmadığını anlatıyor. Index ve sorgu performansı için EF Core serisindeki "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısına bakabilirsin. Kaynaklar: Markus Winand'ın use-the-index-luke.com'daki "Paging Through Results" bölümü ve "No Offset" yazısı; Microsoft Learn'deki EF Core "Pagination" sayfası (offset ve keyset karşılaştırması); SQL Server için "SELECT - ORDER BY Clause" (OFFSET-FETCH) ve GENERATE_SERIES dokümantasyonu; base64url için RFC (Request for Comments) 4648, HMAC için RFC 2104.
Küçük Bir Deneme
★☆☆ IX_PurchaseOrders_Company_CreatedAt_Id index'ini sil ve keyset sorgusunu tekrar ölç. Logical read sayısı ne oldu? Sonra index'i INCLUDE olmadan oluştur ve SELECT Id, CreatedAt, Amount sorgusunu tekrar ölç.
★★☆ OrderCursor'a filtre özetini ekle: SupplierId filtresiyle alınmış bir cursor, farklı SupplierId ile kullanılınca 400 dönsün. Özeti cursor'ın içine nasıl koyarsın ki token gereksiz büyümesin?
★★★ Sıralamayı istemcinin seçebileceği iki seçenekle genişlet: createdAt ve amount. Her seçenek için doğru index'i kur, cursor'a sıralama adını ekle ve amount sıralamasında aynı tutarlı yüzlerce sipariş olduğunda sayfaların kaymadığını ve tekrar etmediğini bir testle göster.
Büyük listeleri artık güvenle okuyoruz. Serinin son yazısında ters yöne gideceğiz: 20.000 satırlık bir çalışan listesini Excel ya da CSV (Comma-Separated Values) dosyasından içeri almak, staging tablosu, satır bazlı validation ve kullanıcıya hangi satırın neden reddedildiğini söyleyen bir hata dosyası.