İçeriğe geç

Sayfalama — OFFSET mi, Keyset mi?

Orta 9 dk Çok sık karşılaşılır

Önce şunu oku: SQL İndeksleme ve EXPLAIN

30 saniyede özet

OFFSET ile sayfalama basittir ama derinleştikçe her sayfa daha çok satır okur, veri değişince de satırlar tekrar görünür ya da kaybolur. Keyset sayfalama son görülen satırdan devam eder: her derinlikte aynı ucuzluk, tekrarsız sonuç.

Kalın bir romanı okuyorsun ve her akşam kaldığın yeri bulmak için sayfaları baştan saymaya başlıyorsun. İlk günlerde sorun yok. Kitabın sonlarına geldiğinde, okumaya başlamadan önce yüzlerce sayfa sayıyorsun.

  1. Bayt: Hesap hareketleri ekranı ilk sayfada hızlı. Ama müşteri eski hareketlere indikçe yavaşlıyor.

  2. Sen: Bir de şikâyet var: aynı havale iki sayfada birden görünmüş.

  3. Bayt: Aynı sorgu, yalnızca sayfa numarası farklı... nasıl olur?

  4. Bayt: Belki sorun sayfa numarasının kendisi. Veritabanı o numarayı nasıl buluyor, bakalım.

OFFSET nasıl çalışır?

OFFSET ile sayfalama"Şu kadar satırı atla, sonrakileri ver" diye sayfalama. Kolaydır ama atlanan satırlar da okunur ve veri değişince sayfalar kayar.Sözlükte gör → veritabanına “şu kadar satırı atla, sonraki 20’yi ver” der. Kurması en kolay yoldur ve kullanıcı istediği sayfa numarasına doğrudan atlayabilir.

Ama veritabanı atlanacak satırları da tek tek yürür: ilk sayfa için 20 satır okur, beş yüzüncü sayfa için 10.000 satır okuyup 9.980’ini atar.

Kafam karıştı, daha basit anlat

OFFSET, önceki bütün satırları okuyup atar. Sayfa derinleştikçe iş büyür.

Hızlı kontrolBaşlangıç

LIMIT 20 OFFSET 40 veritabanına ne der?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Neden OFFSET ile 500. sayfa ilk sayfadan çok daha yavaştır?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Veri değişince ne olur?

Müşteri 1. sayfadayken 3 yeni havale geldi. Sonra 2. sayfaya geçti (OFFSET 20). Ekranda ne görür? Cevabı göster

İlk sayfanın son 3 hareketini yeniden görür. Yeni havaleler listeyi 3 satır aşağı itti; OFFSET yine 20 satır atladığı için önceki sayfanın sonu ikinci sayfanın başına kaydı.

Sayarak değil, ayraçla devam et.
Adım adım oku
  1. OFFSET: her seferinde baştan say.
  2. Öne sayfa eklenirse okuduğun sayfa yine karşına çıkar.
  3. Keyset: kaldığın yerde bir ayraç.
  4. Sayıyla değil, son okuduğun yerden devam et.

Tersi de olur: arada bir satır silinirse liste yukarı kayar ve bir satır hiç görünmeden atlanır. OFFSET bir yer tutmaz, yalnızca bir konum sayar.

keyset sayfalamaSon görülen satırın anahtarından devam eden sayfalama (WHERE id < :son). Her derinlikte ucuzdur; belirli bir sayfaya doğrudan atlayamaz.Sözlükte gör → bunun yerine “son gördüğüm id’den eski olan 20 satırı ver” der. İndeks doğrudan o noktaya atlar; önceki satırlar okunmaz ve yeni gelenler pencereyi kaydırmaz.

Kafam karıştı, daha basit anlat

OFFSET konum sayar, veri değişince kayar. Keyset son görülen satırdan devam eder.

Hızlı kontrolOrta

Müşteri 1. sayfadayken 3 yeni hareket geldi; sonra OFFSET 20 ile 2. sayfaya geçti. Ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Keyset sayfalama bir sonraki sayfayı nasıl ister?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

Sayfa sayfa hesap hareketleri

Tohum 993097

Oynat ya da adımla: her adım bir sonraki sayfayı getirir.

Hız
Adım 0

Şu an ne oldu?

OFFSET ile · Başta (sayfa 1-5)

10.000 hareket, sayfada 20. Her adım bir sonraki sayfayı getirir.

Görevler0/3

  • Bir hareketi ekranda iki kez gösteraçık

    İpucu

    Sayfalar arasında yeni hareketler gelsin.

  • Tek bir sayfa için 10.000 satır okutaçık

    İpucu

    Derine in.

  • Derinde, yeni hareketler gelirken her sayfayı 20 satırla ve tekrarsız getiraçık

    İpucu

    Sayıyla değil, son görülen satırla devam et.

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

  1. Varsayılanla oynat. OFFSET, ilk beş sayfa: en pahalı sayfa yalnızca 100 satır okudu.
  2. “Derinde” seç. Son sayfa için 10.000 satır okundu; ekranda yine 20 satır var.
  3. “Başta” seç ve “Bu arada yeni hareket geliyor”u aç. Her yeni sayfada 3 hareket ikinci kez göründü.
  4. “Keyset” seç, derinde ve yeni hareketlerle oynat. Her sayfa 20 satır okudu ve hiçbir hareket tekrarlanmadı.
Hızlı kontrolOrta

Keyset sayfalamanın bedeli nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sayfalar arasında bir satır silinirse OFFSET ile ne olabilir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Tuzaklar

Tekrar eden sıralama değeri. Yalnızca tarihe göre sıralarsan, aynı saniyede gelen iki hareketten biri kaybolabilir. Sıralamaya benzersiz bir sütun ekle: (created_at, id) ve ikisini birlikte karşılaştır.

İndekssiz keyset. Keyset’in hızı, sıralama sütunlarındaki bir indeksten gelir. İndeks yoksa veritabanı yine bütün tabloyu tarar.

Her ekranı keyset’e çevirmek: belirli bir sayfa numarasına atlanması gereken bir yönetim tablosu için OFFSET hâlâ uygundur. Keyset en çok sonsuz kaydırma ve “daha fazla göster” ekranlarında parlar.

Kafam karıştı, daha basit anlat

Sıralamayı benzersiz yap, indeksi unutma, sayfa numarası gerekiyorsa OFFSET kalabilir.

Hızlı kontrolOrta

Keyset sayfalamada yalnızca created_at ile sıralanıyor. Aynı saniyede iki hareket olursa ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Aşağıdaki örnek bir mobil bankanın hesap hareketleri ekranından: keyset sorgusu, benzersiz sıralama, istemciye giden bir cursor"Şu kayıttan sonrakiler" diyerek sayfalama. Offset'in aksine sabit maliyetlidir ve araya kayıt girse bile atlama yapmaz; bedeli rastgele bir sayfaya atlayamamaktır.Sözlükte gör →.

Derinleş · Hesap hareketleri: keyset sayfalama ve cursor 4 dosya · ~62 satır · ilk okumada atlayabilirsin
Proje dosyaları

src/main/resources/db/migration/ V31__transactions_keyset_index.sql İndeks: hesap, tarih ve id birlikte; sorgu doğrudan son görülen satıra atlıyor.

src/main/resources/db/migration/V31__transactions_keyset_index.sql
-- Matches the ORDER BY exactly, so the database can seek straight to the cursor.
CREATE INDEX ix_tx_account_created_id
ON account_transaction (account_id, created_at DESC, id DESC);

src/main/java/com/bank/history/ TransactionRepository.java Sorgu: (tarih, id) çifti karşılaştırılıyor; aynı saniyedeki iki hareket kaybolmuyor.

src/main/java/com/bank/history/TransactionRepository.java
interface TransactionRepository extends JpaRepository<AccountTransaction, Long> {
@Query("""
select t from AccountTransaction t
where t.accountId = :accountId
order by t.createdAt desc, t.id desc
""")
List<AccountTransaction> firstPage(String accountId, Limit limit);
// (createdAt, id) < (:createdAt, :id), spelled out: ties on the timestamp are broken by id.
@Query("""
select t from AccountTransaction t
where t.accountId = :accountId
and (t.createdAt < :createdAt or (t.createdAt = :createdAt and t.id < :id))
order by t.createdAt desc, t.id desc
""")
List<AccountTransaction> pageAfter(String accountId, Instant createdAt, long id, Limit limit);
}

src/main/java/com/bank/history/ Cursor.java Cursor: son satırın tarih ve id'si istemciye opak bir metin olarak gidiyor; istemci yalnızca geri gönderiyor.

src/main/java/com/bank/history/Cursor.java
/** The last row a client saw, sent back opaquely to continue from there. */
record Cursor(Instant createdAt, long id) {
String encode() {
String raw = createdAt.toEpochMilli() + ":" + id;
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
static Cursor decode(String token) {
String[] parts = new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8).split(":");
return new Cursor(Instant.ofEpochMilli(Long.parseLong(parts[0])), Long.parseLong(parts[1]));
}
static Cursor of(AccountTransaction last) {
return new Cursor(last.getCreatedAt(), last.getId());
}
}

src/main/java/com/bank/history/ TransactionHistoryController.java Uç nokta: ilk sayfada cursor yok; sonraki sayfa için yanıtla birlikte yeni cursor dönüyor.

src/main/java/com/bank/history/TransactionHistoryController.java
@RestController
@RequestMapping("/accounts/{accountId}/transactions")
class TransactionHistoryController {
private static final int PAGE_SIZE = 20;
private final TransactionRepository transactions;
TransactionHistoryController(TransactionRepository transactions) {
this.transactions = transactions;
}
@GetMapping
HistoryPage page(@PathVariable String accountId, @RequestParam(required = false) String cursor) {
List<AccountTransaction> rows = cursor == null
? transactions.firstPage(accountId, Limit.of(PAGE_SIZE))
: pageAfter(accountId, Cursor.decode(cursor));
String next = rows.size() < PAGE_SIZE ? null : Cursor.of(rows.getLast()).encode();
return new HistoryPage(rows.stream().map(TransactionView::of).toList(), next);
}
private List<AccountTransaction> pageAfter(String accountId, Cursor after) {
return transactions.pageAfter(accountId, after.createdAt(), after.id(), Limit.of(PAGE_SIZE));
}
}

Kendini sına

Şimşek turu1/4

OFFSET 9980 LIMIT 20, veritabanına yalnızca 20 satır okutur.

Soru 1/3İleri

Keyset sorgusu yine de yavaş. En olası sebep nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Aklında kalacak üç şey

  1. 1 OFFSET, istenen sayfadan önceki bütün satırları okuyup atar. İlk sayfalar ucuzdur, ama 500. sayfa için veritabanı 10.000 satır yürür.
  2. 2 Sayfalar arasında yeni kayıt gelirse OFFSET penceresi kayar: bir önceki sayfanın sonu bir sonrakinin başında yeniden görünür.
  3. 3 Keyset sayfalama son görülen satırın anahtarından devam eder ve her sayfada yalnızca o sayfanın satırlarını okur. Bedeli, istenen bir sayfaya doğrudan atlayamamaktır.
Sonraki kapı Müşteri hareketlerinde "kira" aradı ve altı ödemesinden yalnızca dördünü buldu. Üstelik sorgu yavaştı. İkisi aynı sebepten mi? Tam Metin Arama — LIKE '%kira%' Neden Hem Yavaş Hem Eksik? · 9 dk

4 kart sonraki derste seni bekliyor

0/4 kart bu dersten toplandı