
REST API Tasarımında Offset mi Cursor Pagination mı? Veri Büyüklüğüne Göre Karar Matrisi

Giriş
2018 yılının Efsane Cuma (Black Friday) kampanyası sırasında, e-ticaret platformumuzun ana ürün listeleme API’si sabaha karşı 03:15’te PagerDuty üzerinden art arda 503 Service Unavailable uyarıları fırlatmaya başladı. Veritabanı CPU kullanımı %100’e sabitlenmiş, p99 gecikme süremiz 120ms bandından 4.5 saniyeye fırlamıştı. İncelemelerimiz sonucunda sorunun kaynağının agresif bir arama motoru botu olduğunu tespit ettik. Bot, ürün kataloğumuzun 80.000’inci sayfasını indekslemeye çalışıyordu ve arkada çalışan sorgu şuydu: SELECT * FROM products ORDER BY created_at DESC LIMIT 50 OFFSET 4000000;.
İlişkisel veritabanları (RDBMS) bu sorguyu çalıştırmak için 4.000.050 satırı bellek üzerinden okumak, ilk 4.000.000 satırı çöpe atmak ve geriye kalan 50 satırı dönmek zorundadır. Bu, O(N) zaman karmaşıklığına sahip yıkıcı bir işlemdir. Pagination, sadece veriyi parçalara bölerek istemciye iletme işi değildir; aynı zamanda veritabanınızı kötü niyetli veya hatalı istemci davranışlarından koruma mekanizmasıdır.
Bu karar rehberinde, production ortamında edindiğim 15 yıllık tecrübeyle, REST API tasarımlarında geleneksel Offset tabanlı pagination ile Cursor (Keyset) tabanlı pagination arasındaki yapısal farkları, PostgreSQL execution planları üzerinden analiz edeceğiz. 10.000 satırlık bir admin paneli ile 500 milyon satırlık bir IoT log tablosu için neden tamamen farklı stratejiler kurgulamanız gerektiğini somut metriklerle inceleyeceğiz.
İçindekiler
- Offset Pagination: p99 Metriklerini Neden Bozuyor?
- Cursor (Keyset) Pagination: O(1) B-Tree Taraması
- Karar Matrisi ve “When to Use” Rehberi
- Production Vaka Analizi: 1.2s’den 45ms’ye Gecikme Düşüşü
- Pratik Öneriler / Production Notları
- Sık Sorulan Sorular
- Sonuç
Offset Pagination: p99 Metriklerini Neden Bozuyor?
Offset tabanlı pagination, API dünyasının en çok bilinen ve en sık suistimal edilen kurgusudur. İstemci, API’ye ?page=5&limit=20 veya ?offset=80&limit=20 şeklinde parametreler gönderir.
Veritabanı Seviyesinde Yaşananlar
PostgreSQL, MySQL veya SQL Server fark etmeksizin, OFFSET komutu veritabanına şunu söyler: “Sorgu kriterlerine uyan kayıtları bul, sırala, baştan N tanesini atla ve sonrakileri getir.”
Eğer tablonuzda 100.000’den az kayıt varsa ve genellikle ilk 10 sayfada geziniyorsa, bu yöntem saniyede binlerce isteği kaldırabilir. Ancak veri boyutu büyüdükçe ve OFFSET değeri arttıkça felaket başlar. PostgreSQL’in B-Tree indeks mimarisi, belirli bir indexten itibaren N satır ileri zıplama yeteneğine sahip değildir. Satırları tek tek okuması gerekir.
“Offset değeri arttıkça, veritabanının CPU ve I/O maliyeti lineer olarak O(N) şeklinde artar.”
Dezavantajları
- Performans Çöküşü (Deep Paging): Yüksek sayfa numaralarında (örn: sayfa 10.000) sorgu süreleri milisaniyelerden saniyelere çıkar.
- Veri Tutarsızlığı (Phantom Reads): Kullanıcı sayfa 1’den sayfa 2’ye geçerken, sisteme yeni bir kayıt eklenirse, 1. sayfanın son kaydı 2. sayfanın başına kayar. Kullanıcı aynı veriyi iki kez görür. Veya bir kayıt silinirse, bir veriyi tamamen atlayabilir.
Cursor (Keyset) Pagination: O(1) B-Tree Taraması
Cursor pagination (veya keyset/seek pagination), sayfalar arası atlama yapmak yerine, istemcinin gördüğü son kaydın benzersiz belirtecini (cursor) referans alarak bir sonraki veri setini getirme prensibine dayanır. İstek şu formatta gelir: ?cursor=eyJpZCI6OTg3NiwidCI6MTY3ODg4NjQwMH0=&limit=50
Sistemin Çalışma Mantığı
İstemciden gelen cursor çözüldüğünde, veritabanına giden sorgu OFFSET kullanmak yerine doğrudan bir WHERE koşulu kullanır:
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2023-10-15 10:00:00', 9876)
ORDER BY created_at DESC, id DESC
LIMIT 50;
Bu sorgunun gücü, kompozit indeksler kullanıldığında ortaya çıkar. Tabloda CREATE INDEX idx_articles_created_id ON articles (created_at DESC, id DESC); şeklinde bir indeks varsa, veritabanı motoru tam olarak kaldığı noktayı B-Tree üzerinde O(log N) sürede bulur ve oradan itibaren 50 satırı okur. İlk sayfa ile 10 milyonuncu sayfanın getirilme maliyeti teorik olarak eşittir (O(1)).
Sıralama ve Tie-Breaker (Eşitlik Bozucu) Problemi
Cursor pagination implementasyonunda yapılan en büyük hata, cursor olarak sadece sıralama yapılan sütunu (örneğin created_at) kullanmaktır. Aynı saniyede atılmış iki tweet veya oluşturulmuş iki sipariş varsa, sorgu bazı kayıtları atlar. Bu nedenle cursor yapısı daima benzersiz bir sütun (genellikle PK olan id) ile desteklenmelidir. Buna tie-breaker denir.
Karar Matrisi ve “When to Use” Rehberi
Hangi yöntemi seçeceğinize karar verirken veri büyüklüğü, istemci tipi ve veri mutasyon sıklığı belirleyicidir. Aşağıdaki karar matrisi, production ortamında uygulanması gereken kuralları özetler.
| Kriter | Offset Pagination | Cursor Pagination |
|---|---|---|
| Toplam Veri Boyutu | < 100.000 satır (Küçük/Orta) | Limitsiz (Milyarlarca satır) |
| Veri Ekleme/Silme Sıklığı | Düşük (Statik veya nadir değişen veri) | Yüksek (Gerçek zamanlı akışlar, loglar) |
| UI Gereksinimi | “Sayfa 45’e Git” (Spesifik sayfaya atlama) | “Daha Fazla Yükle” (Infinite Scroll) |
| Paging Tutarlılığı | Veri kayması tolere edilebilir olmalı | Kusursuz tutarlılık gerektiren durumlar |
| Toplam Sayı (Total Count) | Genellikle COUNT(*) ile sağlanır |
Maliyetli olduğu için genellikle sunulmaz |
Özet Kural Seti
- Eğer veri setiniz küçükse (örn: Uygulama içi kategoriler, kullanıcı rolleri tablosu) ve UI tarafında 1, 2, 3… 10 şeklinde sayfa numaraları kesin olarak isteniyorsa -> Offset Pagination kullanın.
- Eğer veri setiniz bir akış (feed), log tablosu, mesajlaşma geçmişi veya IoT sensör verisi ise ve sürekli yeni veri akıyorsa -> Cursor Pagination kullanın.
- Eğer istemci bir mobil uygulama ise (Infinite scroll kullanıma en uygun senaryo) -> Cursor Pagination kullanın.
- Eğer tablonuz milyonlarca satıra ulaşma potansiyeline sahipse ve arama motorlarının taramasını istemiyorsanız -> Cursor Pagination kullanın.
Production Vaka Analizi: 1.2s’den 45ms’ye Gecikme Düşüşü
Geçtiğimiz yıl, bir lojistik firmasının kargo hareketlerini (tracking events) sunduğu public API’sinde bir refactoring gerçekleştirdik. events tablosunda 450 milyon kayıt bulunuyordu. İstemciler (mobil uygulamalar ve B2B entegratörler) sayfalama için LIMIT 100 OFFSET 50000 formatını kullanıyordu.
Önceki Durum (Offset) EXPLAIN ANALYZE Çıktısı
EXPLAIN ANALYZE SELECT * FROM events ORDER BY created_at DESC LIMIT 100 OFFSET 50000;
Plan Özeti:
Limit (cost=4250.30..4258.80 rows=100) (actual time=1205.34..1206.12 rows=100)
-> Index Scan Backward using idx_events_created_at on events
Execution Time: 1206.45 ms
Veritabanı, indeksi tersine tarayarak 50.100 satırı bellek üzerinden okudu. Sayfa sayısı arttıkça bu süre 4-5 saniyelere ulaşıyor ve veritabanı bağlantı havuzunu (connection pool) tüketiyordu.
Sonraki Durum (Cursor) EXPLAIN ANALYZE Çıktısı
API’yi cursor tabanlı yapıya geçirdik. İstemciler son gördükleri event’in created_at ve id değerlerini base64 formatında göndermeye başladı.
EXPLAIN ANALYZE SELECT * FROM events
WHERE (created_at, id) < ('2023-11-20 14:32:01.442', 9845321)
ORDER BY created_at DESC, id DESC LIMIT 100;
Plan Özeti:
Limit (cost=0.56..8.90 rows=100) (actual time=0.045..0.432 rows=100)
-> Index Scan using idx_events_cursor on events
Execution Time: 0.45 ms (p99 ağ gecikmesi dahil API yanıt süresi 45ms).
Sonuç: CPU kullanımı %32 düşüş gösterdi, veritabanı kilitlenmeleri (lock contention) tamamen ortadan kalktı ve throughput (saniyedeki işlem sayısı) 4 kat arttı.
Pratik Öneriler / Production Notları
1. Cursor Encoding ve Güvenlik
Cursor verisini istemciye asla çıplak JSON veya ham SQL parametreleri olarak dönmeyin. İstemcinin cursor yapısına bağımlı olmasını önlemek ve gelecekte yapıyı değiştirebilmek için base64 ile encode edin. Aşağıda Node.js (TypeScript) ortamında güvenli bir cursor oluşturma ve çözme örneği bulabilirsiniz:
// API'den dönecek cursor'ı oluşturma
function encodeCursor(createdAt: Date, id: number): string {
const payload = JSON.stringify({ c: createdAt.toISOString(), i: id });
return Buffer.from(payload).toString('base64url');
}
// İstemciden gelen cursor'ı çözme
function decodeCursor(cursor: string): { c: string, i: number } | null {
try {
const payload = Buffer.from(cursor, 'base64url').toString('utf-8');
return JSON.parse(payload);
} catch (err) {
// Malformed cursor durumunda fallback veya 400 Bad Request dönülmeli
return null;
}
}
2. Limit Sınırlandırması (Hard Limits)
API endpointlerinizde, istemcinin talep edebileceği maksimum limit değerini her zaman kısıtlayın. limit=10000 gibi bir talep, cursor kullansanız dahi veritabanı belleğini şişirebilir. İdeal MAX_LIMIT değeri payload büyüklüğüne göre 50 ile 100 arasında olmalıdır.
3. Offset İçin Max Sayfa Koruyucusu (Circuit Breaker)
Eğer iş gereksinimleri nedeniyle Offset kullanmak zorundaysanız (örneğin B2B entegrasyonu spesifikasyonu), API gateway veya uygulama katmanında MAX_OFFSET kuralı tanımlayın. offset > 10000 olan istekleri 400 Bad Request ile reddedin. Veritabanının çökmesi yerine istemcinin hata alması her zaman tercih edilmelidir.
4. Doğru Kompozit İndeksleri Ekleyin
Cursor sorgusunda WHERE (created_at, id) < (X, Y) ORDER BY created_at DESC, id DESC yapısını kullanıyorsanız, veritabanında bu sıraya tam uyan bir indeks olmalıdır. PostgreSQL için:
CREATE INDEX CONCURRENTLY idx_table_cursor
ON table_name (created_at DESC, id DESC);
İndeks sırası (ASC/DESC) sorgudaki ORDER BY sırası ile birebir eşleşmezse, veritabanı indeksi tarayamaz ve Memory Sort yapmak zorunda kalır. Bu da cursor’ın tüm avantajını yok eder.
Sık Sorulan Sorular
Cursor kullanırken toplam kayıt sayısını (Total Count) nasıl dönebilirim?
Milyonlarca satırlık tablolarda COUNT(*) işlemi O(N) maliyetindedir. Gerçek zamanlı ve kesin bir count dönmek mimari bir hatadır. Eğer UI tarafında bu sayıyı göstermek zorundaysanız, veritabanında triggerlar ile beslenen ayrı bir sayaç tablosu (counter table) kullanın veya Redis üzerinden tahmini bir değer (approximate count) okuyun.
İstemci 50. sayfaya doğrudan atlamak isterse ne yapmalıyım?
Cursor yapısı doğası gereği ardışık veri çekmeyi gerektirir. “Sayfa atlama” (jump-to-page) işlemi cursor ile mümkün değildir. Ancak modern UI tasarımlarında arama motorları haricinde (Google’ın arama sonuçları gibi) spesifik bir sayfaya atlama ihtiyacı giderek azalmaktadır. Arama (Search) ve Filtreleme özellikleri bu ihtiyacı ortadan kaldırır. Eğer bu mutlak bir gereksinimse, 10.000 kayıta kadar offset, sonrasında zorunlu arama/filtreleme kuralı getirebilirsiniz.
İki yönlü (Bi-directional) pagination nasıl yapılır?
Sohbet uygulamalarında olduğu gibi hem geçmişe hem de geleceğe doğru sayfalama yapmanız gerekiyorsa, iki farklı cursor tutmanız gerekir: prev_cursor ve next_cursor. İstemci yukarı kaydırdığında WHERE id > X ORDER BY id ASC, aşağı kaydırdığında WHERE id < Y ORDER BY id DESC sorgularını çalıştıracak şekilde API tasarlanmalıdır.
UUID’ler Cursor olarak kullanılabilir mi?
UUIDv4 rastgele oluşturulduğu için sıralama (sorting) garantisi vermez, dolayısıyla tek başına cursor olarak kullanılamaz. Ancak created_at ile birlikte tie-breaker olarak (created_at, uuid) kullanılabilir. Eğer tek bir sütun üzerinden cursor yapmak isterseniz, zamana dayalı sıralanabilir (time-sortable) UUIDv7, ULID veya Snowflake ID (Twitter) formatlarını tercih etmelisiniz.
Sonuç
API sayfalama stratejisi, genellikle geliştirme aşamasının başlarında, tablolar henüz boşken fazla düşünülmeden alınan bir karardır. Ancak tablo boyutları yüz binleri, milyonları aştığında, yanlış seçilmiş bir pagination yöntemi mikroservislerinizin darboğazı haline gelir. Offset tabanlı sayfalama küçük, statik ve sınırlandırılmış admin veri setlerinde meşru bir araç olmaya devam etse de; dış dünyaya açık, büyük hacimli veya sonsuz kaydırma (infinite scroll) gerektiren tüm modern REST ve GraphQL API’lerinde Cursor (Keyset) tabanlı sayfalama tartışmasız bir endüstri standardıdır.
Mimari kararlarınızı alırken, sisteminizin 1 yıl sonra ulaşacağı veri hacmini hedefleyin. Mevcut API’lerinizdeki en çok tüketilen liste endpointlerini belirleyin, veritabanı slow query loglarını inceleyerek offset kaynaklı CPU sıçramalarını tespit edin ve kritik olanları vakit kaybetmeden cursor yapısına geçirin.
Bunları da beğenebilirsiniz

ChatGPT ile Teknolojinin Geleceği ve Yazılıma Katkıları
Son günlerde gündemde olan bir konudan bahsetmek istiyorum. OpenAI tarafından geliştirilen ChatGPT kullanıma sunuldu. ChatGPT ile yapabileceklerimiz neredeyse sınırsız gibi gözüküyor. Kodlama konusunda da oldukça…

