Halaman daftar adalah fitur yang paling sering diambil begitu saja: LIMIT 10 OFFSET N, selesai. Pada halaman pertama query itu memang instan — tetapi biayanya tumbuh secara linear bersama kedalaman halaman, karena OFFSET memaksa database membaca dan membuang semua baris sebelum halaman yang diminta. Pengguna yang menelusuri sampai halaman ke-10.001, crawler yang memindai feed lama, atau API partner yang meminta data bertahap semuanya membayar tagihan yang sama dan makin mahal. Chapter ini membuktikan masalah itu pada log aktivitas 200.000 baris, lalu menggantinya dengan pola keyset pagination — melompat langsung ke posisi kursor — yang biayanya tetap kecil di kedalaman mana pun.

OFFSET: Halaman Awal yang Manis #

Domain latihannya adalah log aktivitas pengguna dengan satu jebakan yang disengaja: setiap nilai created_at dipakai tepat oleh dua baris, sehingga ada 100.000 pasangan waktu yang identik:

sql buat-tabel.sql
-- Log aktivitas pengguna: 200.000 baris di mana SETIAP menit
-- dipakai tepat dua baris -- duplikasi created_at disengaja
-- untuk menunjukkan bahaya ORDER BY yang tidak total.
CREATE TABLE aktivitas (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    pengguna text NOT NULL,
    aksi text NOT NULL,
    created_at timestamp NOT NULL
);

INSERT INTO aktivitas (pengguna, aksi, created_at)
SELECT 'user' || lpad((g % 500)::text, 4, '0'),
       (ARRAY['login','logout','beli','lihat'])[1 + g % 4],
       TIMESTAMP '2026-01-01 09:00:00' + (g % 100000) * interval '1 minute'
FROM generate_series(1, 200000) AS g;

-- Index mengikuti urutan pagination: created_at turun, id tie-breaker.
CREATE INDEX idx_aktivitas_waktu ON aktivitas (created_at DESC, id DESC);

ANALYZE aktivitas;

SELECT count(*) AS total_baris FROM aktivitas;

-- Berapa banyak nilai created_at yang muncul lebih dari sekali?
SELECT count(*) AS waktu_dengan_duplikat
FROM (SELECT created_at FROM aktivitas GROUP BY created_at HAVING count(*) > 1) x;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 200000
CREATE INDEX
ANALYZE
 total_baris 
-------------
      200000
(1 row)

 waktu_dengan_duplikat 
-----------------------
                100000
(1 row)

Dua hal penting dari output tersebut: tabel berisi 200.000 baris, dan 100.000 nilai created_at muncul lebih dari sekali — angka kedua ini akan menjadi pemeriksa penting di bagian akhir chapter. Index dibuat mengikuti persis urutan pagination (created_at DESC, id DESC) sehingga hasil terurut bisa dibaca langsung dari index tanpa sort. Dua halaman pertama dengan OFFSET terlihat sehat:

-- Halaman pertama: OFFSET 0 -- ini juga bentuk halaman pertama keyset.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 0;

-- Halaman kedua: OFFSET melewati 10 baris teratas.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 10;

Output yang Dihasilkan:

text
   id   | pengguna |  aksi  |     created_at      
--------+----------+--------+---------------------
 199999 | user0499 | lihat  | 2026-03-11 19:39:00
  99999 | user0499 | lihat  | 2026-03-11 19:39:00
 199998 | user0498 | beli   | 2026-03-11 19:38:00
  99998 | user0498 | beli   | 2026-03-11 19:38:00
 199997 | user0497 | logout | 2026-03-11 19:37:00
  99997 | user0497 | logout | 2026-03-11 19:37:00
 199996 | user0496 | login  | 2026-03-11 19:36:00
  99996 | user0496 | login  | 2026-03-11 19:36:00
 199995 | user0495 | lihat  | 2026-03-11 19:35:00
  99995 | user0495 | lihat  | 2026-03-11 19:35:00
(10 rows)

   id   | pengguna |  aksi  |     created_at      
--------+----------+--------+---------------------
 199994 | user0494 | beli   | 2026-03-11 19:34:00
  99994 | user0494 | beli   | 2026-03-11 19:34:00
 199993 | user0493 | logout | 2026-03-11 19:33:00
  99993 | user0493 | logout | 2026-03-11 19:33:00
 199992 | user0492 | login  | 2026-03-11 19:32:00
  99992 | user0492 | login  | 2026-03-11 19:32:00
 199991 | user0491 | lihat  | 2026-03-11 19:31:00
  99991 | user0491 | lihat  | 2026-03-11 19:31:00
 199990 | user0490 | beli   | 2026-03-11 19:30:00
  99990 | user0490 | beli   | 2026-03-11 19:30:00
(10 rows)

Halaman pertama hanya menyerahkan 10 baris teratas — murah. Halaman kedua membaca 20 baris dan menyerahkan 10. Pola harganya sudah terlihat: halaman ke-N membaca N × 10 baris dari index, lalu membuang (N − 1) × 10 baris di antaranya. Sepuluh baris yang dibuang tidak terasa; sejuta baris yang dibuang adalah query yang “tiba-tiba lambat padahal tidak ada yang berubah”.

Harga OFFSET yang Sebenarnya di Kedalaman #

Mari lompat ke halaman ke-10.001 — nilai OFFSET 100000 yang dihasilkan aplikasi ketika pengguna menelusuri jauh atau API meminta batch ke-10.001:

-- Lompat ke halaman ke-10.001: OFFSET harus melewati 100.000 baris.
-- Baris "kursor" di posisi 100.000 dicari dulu -- pada aplikasi nyata,
-- nilai ini datang dari baris terakhir halaman sebelumnya.
SELECT id, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 1 OFFSET 99999;

-- Halaman ke-10.001 dengan OFFSET: hasilnya benar, tapi semua
-- 100.000 baris sebelumnya ikut dibaca lalu dibuang.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;

Output yang Dihasilkan:

text
  id   |     created_at      
-------+---------------------
 50000 | 2026-02-05 02:20:00
(1 row)

   id   | pengguna |  aksi  |     created_at      
--------+----------+--------+---------------------
 149999 | user0499 | lihat  | 2026-02-05 02:19:00
  49999 | user0499 | lihat  | 2026-02-05 02:19:00
 149998 | user0498 | beli   | 2026-02-05 02:18:00
  49998 | user0498 | beli   | 2026-02-05 02:18:00
 149997 | user0497 | logout | 2026-02-05 02:17:00
  49997 | user0497 | logout | 2026-02-05 02:17:00
 149996 | user0496 | login  | 2026-02-05 02:16:00
  49996 | user0496 | login  | 2026-02-05 02:16:00
 149995 | user0495 | lihat  | 2026-02-05 02:15:00
  49995 | user0495 | lihat  | 2026-02-05 02:15:00
(10 rows)

Hasilnya benar — sepuluh baris mulai dari id 149999 — tetapi rencana eksekusinya membocorkan harganya:

-- Bukti biaya OFFSET dalam: index scan menghasilkan 100.010 baris
-- untuk menyerahkan 10 baris -- 100.000 baris dibaca lalu dibuang.
-- Nilai waktu bervariasi antar mesin dan antar eksekusi.
EXPLAIN ANALYZE
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;

Output yang Dihasilkan:

text
                                                                     QUERY PLAN                                                                      
-----------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=5676.31..5676.87 rows=10 width=31) (actual time=22.290..22.293 rows=10 loops=1)
   ->  Index Scan using idx_aktivitas_waktu on aktivitas  (cost=0.42..11352.19 rows=200000 width=31) (actual time=0.037..19.533 rows=100010 loops=1)
 Planning Time: 0.258 ms
 Execution Time: 22.310 ms
(4 rows)

Baris kuncinya: Index Scan ... rows=100010. Node scan menapaki 100.010 entri index untuk menyerahkan 10 baris ke node Limit — 100.000 baris dibaca, dibandingkan urutannya, lalu dibuang. Index sudah bekerja sempurna di sini; yang membuatnya mahal adalah perintah “lewati 100.000 baris” itu sendiri. Angka waktu pada output bervariasi antar mesin dan antar eksekusi; yang tidak bervariasi adalah pola rows=100010 untuk OFFSET 100000 — dan angka itu akan terus membesar linear seiring halaman yang lebih dalam: OFFSET 500000 berarti setengah juta baris dibuang setiap kali halaman diklik.

Keyset Pagination: Melompat lewat Kursor #

Obatnya adalah membalik cara berpikir: alih-alih “lewati N baris”, katakan “mulai dari tepat setelah baris terakhir yang sudah kulihat”. Baris terakhir halaman pertama pada output sebelumnya adalah (created_at, id) = ('2026-03-11 19:35:00', 99995) — pasangan nilai itu menjadi kursor. Perbandingannya memakai row constructor (created_at, id) < (nilai_kursor) yang membandingkan leksikografis: waktu lebih kecil menang, dan jika waktu sama, id yang lebih kecil menang — persis mengikuti urutan ORDER BY created_at DESC, id DESC:

-- Keyset pagination, halaman kedua: mulai SETELAH baris terakhir
-- halaman pertama, yaitu (created_at, id) = ('2026-03-11 19:35:00', 99995).
-- Row constructor (created_at, id) < (x, y) membandingkan leksikografis.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
WHERE (created_at, id) < (TIMESTAMP '2026-03-11 19:35:00', 99995)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Bukti kesetaraan dengan OFFSET 10 di kedua arah: nol selisih.
SELECT
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10)
    EXCEPT
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-03-11 19:35:00', 99995)
     ORDER BY created_at DESC, id DESC LIMIT 10)
  ) x) AS hanya_di_offset,
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-03-11 19:35:00', 99995)
     ORDER BY created_at DESC, id DESC LIMIT 10)
    EXCEPT
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10)
  ) x) AS hanya_di_keyset;

Output yang Dihasilkan:

text
   id   | pengguna |  aksi  |     created_at      
--------+----------+--------+---------------------
 199994 | user0494 | beli   | 2026-03-11 19:34:00
  99994 | user0494 | beli   | 2026-03-11 19:34:00
 199993 | user0493 | logout | 2026-03-11 19:33:00
  99993 | user0493 | logout | 2026-03-11 19:33:00
 199992 | user0492 | login  | 2026-03-11 19:32:00
  99992 | user0492 | login  | 2026-03-11 19:32:00
 199991 | user0491 | lihat  | 2026-03-11 19:31:00
  99991 | user0491 | lihat  | 2026-03-11 19:31:00
 199990 | user0490 | beli   | 2026-03-11 19:30:00
  99990 | user0490 | beli   | 2026-03-11 19:30:00
(10 rows)

 hanya_di_offset | hanya_di_keyset 
-----------------+-----------------
               0 |               0
(1 row)

Sepuluh baris yang dihasilkan identik dengan halaman kedua OFFSET 10 — dan kesetaraannya tidak dibuktikan lewat pandangan mata saja: kedua query EXCEPT menghasilkan nol selisih di kedua arah. Halaman pertama keyset bahkan tidak butuh WHERE sama sekali — query halaman pertama pada halaman-offset.sql di atas itulah halaman pertama keyset. Kursor baru mulai bekerja dari halaman kedua, ketika aplikasi mengirim kembali nilai (created_at, id) baris terakhir halaman sebelumnya — biasanya lewat parameter tautan ?after=2026-03-11%2019:35:00,99995 atau header API.

Kedalaman yang Sama, Biaya yang Radikal Beda #

Sekarang ujian sebenarnya: halaman ke-10.001 versi keyset, dengan kursor berupa baris posisi 100.000 — baris (50000, '2026-02-05 02:20:00') yang sudah ditangkap pada file offset-dalam.sql:

-- Keyset pagination di kedalaman yang sama: kursor adalah baris
-- posisi 100.000 ('2026-02-05 02:20:00', 50000) dari file sebelumnya.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Bukti kesetaraan dengan OFFSET 100000 di kedua arah: nol selisih.
SELECT
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 100000)
    EXCEPT
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
     ORDER BY created_at DESC, id DESC LIMIT 10)
  ) x) AS hanya_di_offset,
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
     ORDER BY created_at DESC, id DESC LIMIT 10)
    EXCEPT
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 100000)
  ) x) AS hanya_di_keyset;

Output yang Dihasilkan:

text
   id   | pengguna |  aksi  |     created_at      
--------+----------+--------+---------------------
 149999 | user0499 | lihat  | 2026-02-05 02:19:00
  49999 | user0499 | lihat  | 2026-02-05 02:19:00
 149998 | user0498 | beli   | 2026-02-05 02:18:00
  49998 | user0498 | beli   | 2026-02-05 02:18:00
 149997 | user0497 | logout | 2026-02-05 02:17:00
  49997 | user0497 | logout | 2026-02-05 02:17:00
 149996 | user0496 | login  | 2026-02-05 02:16:00
  49996 | user0496 | login  | 2026-02-05 02:16:00
 149995 | user0495 | lihat  | 2026-02-05 02:15:00
  49995 | user0495 | lihat  | 2026-02-05 02:15:00
(10 rows)

 hanya_di_offset | hanya_di_keyset 
-----------------+-----------------
               0 |               0
(1 row)

Sepuluh baris yang sama persis dengan halaman OFFSET 100000 — nol selisih di kedua arah — tetapi rencana eksekusinya sama sekali berbeda:

-- Bukti biaya keyset di kedalaman yang sama: index scan berhenti
-- setelah 10 baris -- tidak ada baris yang dibaca lalu dibuang.
-- Nilai waktu bervariasi antar mesin dan antar eksekusi.
EXPLAIN ANALYZE
SELECT id, pengguna, aksi, created_at
FROM aktivitas
WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
ORDER BY created_at DESC, id DESC
LIMIT 10;

Output yang Dihasilkan:

text
                                                                  QUERY PLAN                                                                  
----------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..1.27 rows=10 width=31) (actual time=0.050..0.059 rows=10 loops=1)
   ->  Index Scan using idx_aktivitas_waktu on aktivitas  (cost=0.42..8426.81 rows=99172 width=31) (actual time=0.049..0.057 rows=10 loops=1)
         Index Cond: (ROW(created_at, id) < ROW('2026-02-05 02:20:00'::timestamp without time zone, 50000))
 Planning Time: 0.292 ms
 Execution Time: 0.086 ms
(5 rows)

Perhatikan baris Index Cond: (ROW(created_at, id) < ROW(...)): kondisi kursor diterjemahkan menjadi posisi awal di dalam index, sehingga scan langsung menuju titik itu dan berhenti setelah 10 baris — rows=10, tanpa satu baris pun yang dibuang. Dibandingkan dengan rows=100010 pada OFFSET, ini sekitar 10.000× lebih sedikit baris yang disentuh di kedalaman yang sama, dan yang terpenting: biayanya TIDAK tumbuh — halaman ke-1, ke-10.001, maupun ke-100.001 sama-sama hanya membaca ukuran halaman. Kedua manual block di atas dicatat dari eksekusi nyata; nilai waktunya berbeda-beda antar mesin dan antar eksekusi, sedangkan jumlah baris (100010 lawan 10) adalah properti struktural kedua pola yang stabil.

Kedua pola ini layak dirangkum berdampingan:

AspekOFFSETKeyset
Biaya halaman ke-NTumbuh linear: membaca lalu membuang N × ukuran halaman barisTetap sebesar ukuran halaman, di kedalaman mana pun
Lompat ke nomor halaman tertentuBisa: OFFSET = (halaman − 1) × ukuranTidak langsung: harus menelusuri kursor bertahap
Stabilitas batas halamanBergantung pada keunikan kunci ORDER BYWajib kunci urutan total dengan tie-breaker
Peran indexMenopang urutan, tetapi tetap menapaki semua entri sebelumnyaIndex Cond langsung menuju posisi kursor
Medan terbaikLaporan administratif berhalaman sedikitFeed, scroll tanpa batas, API bertahap, crawling

Syarat Wajib: Urutan Total dan Tie-Breaker #

Keyset hanya sah di atas ORDER BY yang total — urutan yang menentukan posisi tunggal untuk setiap baris. Di sinilah 100.000 duplikasi created_at pada data latihan menjadi peringatan nyata: ORDER BY created_at DESC saja tidak total, karena dua baris ber-waktu sama bisa bertukar posisi antar eksekusi. Batas halaman yang tidak stabil berarti satu baris bisa muncul di dua halaman sekaligus, atau terlewat sama sekali — bug yang jarang terdeteksi karena datang dan pergi bersama kebetulan urutan fisik.

Kontrak lengkapnya tiga bagian dan sudah dicontohkan oleh file halaman-keyset.sql di atas. Pertama, ORDER BY created_at DESC, id DESC: kolom urutan bisnis ditambah primary key sebagai tie-breaker terakhir. Kedua, WHERE (created_at, id) < (kursor_waktu, kursor_id): row constructor yang memuat pasangan kolom sama persis dengan ORDER BY, dalam urutan yang juga sama. Ketiga, LIMIT ukuran_halaman yang tetap untuk semua halaman. Aplikasi cukup menyimpan dua nilai kursor per pengguna — bukan nomor halaman — dan mengirimkannya kembali sebagai parameter query pada tautan “berikutnya”.

Dengan pola akses yang sehat untuk daftar panjang, sisa masalah performa biasanya tinggal satu pertanyaan: query mana saja yang sebenarnya lambat? Mereka tidak datang mengadu — kita harus menangkapnya lewat statistik eksekusi dan log server, di chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- Log aktivitas pengguna: 200.000 baris di mana SETIAP menit
-- dipakai tepat dua baris -- duplikasi created_at disengaja
-- untuk menunjukkan bahaya ORDER BY yang tidak total.
CREATE TABLE aktivitas (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    pengguna text NOT NULL,
    aksi text NOT NULL,
    created_at timestamp NOT NULL
);

INSERT INTO aktivitas (pengguna, aksi, created_at)
SELECT 'user' || lpad((g % 500)::text, 4, '0'),
       (ARRAY['login','logout','beli','lihat'])[1 + g % 4],
       TIMESTAMP '2026-01-01 09:00:00' + (g % 100000) * interval '1 minute'
FROM generate_series(1, 200000) AS g;

-- Index mengikuti urutan pagination: created_at turun, id tie-breaker.
CREATE INDEX idx_aktivitas_waktu ON aktivitas (created_at DESC, id DESC);

ANALYZE aktivitas;

SELECT count(*) AS total_baris FROM aktivitas;

-- Berapa banyak nilai created_at yang muncul lebih dari sekali?
SELECT count(*) AS waktu_dengan_duplikat
FROM (SELECT created_at FROM aktivitas GROUP BY created_at HAVING count(*) > 1) x;

explain-keyset.sql

Unduh berkas
sql
-- Bukti biaya keyset di kedalaman yang sama: index scan berhenti
-- setelah 10 baris -- tidak ada baris yang dibaca lalu dibuang.
-- Nilai waktu bervariasi antar mesin dan antar eksekusi.
EXPLAIN ANALYZE
SELECT id, pengguna, aksi, created_at
FROM aktivitas
WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
ORDER BY created_at DESC, id DESC
LIMIT 10;

explain-offset-dalam.sql

Unduh berkas
sql
-- Bukti biaya OFFSET dalam: index scan menghasilkan 100.010 baris
-- untuk menyerahkan 10 baris -- 100.000 baris dibaca lalu dibuang.
-- Nilai waktu bervariasi antar mesin dan antar eksekusi.
EXPLAIN ANALYZE
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;

halaman-keyset.sql

Unduh berkas
sql
-- Keyset pagination, halaman kedua: mulai SETELAH baris terakhir
-- halaman pertama, yaitu (created_at, id) = ('2026-03-11 19:35:00', 99995).
-- Row constructor (created_at, id) < (x, y) membandingkan leksikografis.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
WHERE (created_at, id) < (TIMESTAMP '2026-03-11 19:35:00', 99995)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Bukti kesetaraan dengan OFFSET 10 di kedua arah: nol selisih.
SELECT
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10)
    EXCEPT
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-03-11 19:35:00', 99995)
     ORDER BY created_at DESC, id DESC LIMIT 10)
  ) x) AS hanya_di_offset,
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-03-11 19:35:00', 99995)
     ORDER BY created_at DESC, id DESC LIMIT 10)
    EXCEPT
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10)
  ) x) AS hanya_di_keyset;

halaman-offset.sql

Unduh berkas
sql
-- Halaman pertama: OFFSET 0 -- ini juga bentuk halaman pertama keyset.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 0;

-- Halaman kedua: OFFSET melewati 10 baris teratas.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 10;

keyset-dalam.sql

Unduh berkas
sql
-- Keyset pagination di kedalaman yang sama: kursor adalah baris
-- posisi 100.000 ('2026-02-05 02:20:00', 50000) dari file sebelumnya.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
ORDER BY created_at DESC, id DESC
LIMIT 10;

-- Bukti kesetaraan dengan OFFSET 100000 di kedua arah: nol selisih.
SELECT
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 100000)
    EXCEPT
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
     ORDER BY created_at DESC, id DESC LIMIT 10)
  ) x) AS hanya_di_offset,
  (SELECT count(*) FROM (
    (SELECT id FROM aktivitas WHERE (created_at, id) < (TIMESTAMP '2026-02-05 02:20:00', 50000)
     ORDER BY created_at DESC, id DESC LIMIT 10)
    EXCEPT
    (SELECT id FROM aktivitas ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 100000)
  ) x) AS hanya_di_keyset;

offset-dalam.sql

Unduh berkas
sql
-- Lompat ke halaman ke-10.001: OFFSET harus melewati 100.000 baris.
-- Baris "kursor" di posisi 100.000 dicari dulu -- pada aplikasi nyata,
-- nilai ini datang dari baris terakhir halaman sebelumnya.
SELECT id, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 1 OFFSET 99999;

-- Halaman ke-10.001 dengan OFFSET: hasilnya benar, tapi semua
-- 100.000 baris sebelumnya ikut dibaca lalu dibuang.
SELECT id, pengguna, aksi, created_at
FROM aktivitas
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;