Paginasi tampak seperti fitur yang murah: LIMIT 10 saja sudah menjaga hasil tetap kecil. Masalahnya, OFFSET tidak pernah gratis — server tetap harus membaca semua baris yang dilewati sebelum menemukan halaman yang diminta. Halaman pertama aplikasi terasa instan, halaman ke-dua puluh masih wajar, dan suatu hari pengguna menyadari halaman ke-seratus membutuhkan waktu beberapa detik — bukan karena halamannya besar, melainkan karena 999.990 baris sebelumnya dibaca lalu dibuang di setiap klik. Chapter ini membuktikan perilaku itu dengan penghitung internal MySQL, lalu memperkenalkan dua pola yang membuat halaman ke-n seringan halaman pertama: keyset pagination dan deferred join.

Menyiapkan Data Latihan #

Lima belas pesanan berurutan id = 1..15 menjadi buku mini kita, dengan halaman berisi lima baris — cukup untuk tiga halaman:

sql
DROP DATABASE IF EXISTS belajar_opt;
CREATE DATABASE belajar_opt;
USE belajar_opt;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE pesanan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  tanggal DATE NOT NULL,
  total DECIMAL(12,2) NOT NULL,
  status VARCHAR(20) NOT NULL
);

INSERT INTO pesanan (pelanggan_id, tanggal, total, status) VALUES
  (1,  '2026-01-10', 150000.00, 'selesai'),
  (2,  '2026-01-15', 250000.00, 'selesai'),
  (3,  '2026-01-20',  75000.00, 'batal'),
  (1,  '2026-02-05', 320000.00, 'selesai'),
  (4,  '2026-02-12', 125000.00, 'diproses'),
  (5,  '2026-02-18', 480000.00, 'selesai'),
  (2,  '2026-03-01', 210000.00, 'selesai'),
  (6,  '2026-03-08',  95000.00, 'batal'),
  (1,  '2026-03-15', 540000.00, 'diproses'),
  (7,  '2026-03-22', 175000.00, 'selesai'),
  (8,  '2026-04-02', 260000.00, 'selesai'),
  (3,  '2026-04-09', 310000.00, 'selesai'),
  (9,  '2026-04-16',  88000.00, 'batal'),
  (4,  '2026-04-25', 430000.00, 'selesai'),
  (10, '2026-05-01', 365000.00, 'selesai');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 15 rows affected (0.00 sec)
Records: 15  Duplicates: 0  Warnings: 0

Masalah OFFSET: Dibaca Lalu Dibuang #

Sintaks LIMIT offset, jumlah (atau LIMIT jumlah OFFSET offset) menyuruh server melewati offset baris pertama. Cara melewatinya satu-satunya: membaca baris-baris itu, menghitung, lalu membuangnya — tidak ada “lompat ke baris ke-100.000” di tabel relasional. Query ilustratif berikut menunjukkan bentuk paling ekstremnya:

sql
-- @notest
-- Ilustrasi konsep OFFSET besar — jangan dijalankan di tabel latihan 15 baris ini
SELECT id, total FROM pesanan ORDER BY id LIMIT 1000000, 10;

Query tersebut meminta 10 baris mulai posisi ke-1.000.001. Untuk memenuhinya, MySQL membaca 1.000.010 baris — sejuta baris dibaca penuh hanya untuk dibuang, lalu 10 baris terakhir dikirim. Biayanya tumbuh linier mengikuti nomor halaman: halaman 1 murah, halaman 10.000 sepuluh ribu kali lebih mahal, padahal jumlah baris hasilnya sama. Kita buktikan pada skala mini dengan penghitung internal Handler_read_* yang menghitung baris yang benar-benar disentuh engine:

sql
FLUSH STATUS;
SELECT id, total FROM pesanan ORDER BY id LIMIT 5;
SHOW STATUS LIKE 'Handler_read%';

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

+----+-----------+
| id | total     |
+----+-----------+
|  1 | 150000.00 |
|  2 | 250000.00 |
|  3 |  75000.00 |
|  4 | 320000.00 |
|  5 | 125000.00 |
+----+-----------+
5 rows in set (0.00 sec)

+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Handler_read_first    | 1     |
| Handler_read_key      | 1     |
| Handler_read_last     | 0     |
| Handler_read_next     | 4     |
| Handler_read_prev     | 0     |
| Handler_read_rnd      | 0     |
| Handler_read_rnd_next | 0     |
+-----------------------+-------+
7 rows in set (0.00 sec)
sql
FLUSH STATUS;
SELECT id, total FROM pesanan ORDER BY id LIMIT 5 OFFSET 10;
SHOW STATUS LIKE 'Handler_read%';

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

+----+-----------+
| id | total     |
+----+-----------+
| 11 | 260000.00 |
| 12 | 310000.00 |
| 13 |  88000.00 |
| 14 | 430000.00 |
| 15 | 365000.00 |
+----+-----------+
5 rows in set (0.00 sec)

+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Handler_read_first    | 1     |
| Handler_read_key      | 1     |
| Handler_read_last     | 0     |
| Handler_read_next     | 14    |
| Handler_read_prev     | 0     |
| Handler_read_rnd      | 0     |
| Handler_read_rnd_next | 0     |
+-----------------------+-------+
7 rows in set (0.00 sec)

FLUSH STATUS mereset penghitung sesi, query berjalan, lalu SHOW STATUS membaca hasilnya. Halaman pertama menyentuh lima baris (Handler_read_first = 1 sebagai pembuka, Handler_read_next = 4 melanjutkan). Halaman ketiga dengan OFFSET 10 menyentuh lima belas baris — sepuluh Handler_read_next ekstra adalah sepuluh baris yang dibaca lalu dibuang. Proporsinya persis skenario sejuta baris, hanya nolnya lebih sedikit.

Pola Keyset: Mulai dari Kursor, Bukan dari Awal #

Keyset pagination (sering disebut seek method) membalik logikanya: alih-alih “lewati N baris”, kita bilang “mulai dari baris setelah nilai terakhir halaman sebelumnya”. Kolom pengurutan harus unik dan terurut — id kita sudah sempurna karena PRIMARY KEY:

sql
SELECT id, total FROM pesanan ORDER BY id LIMIT 5;

Output yang Dihasilkan:

text
+----+-----------+
| id | total     |
+----+-----------+
|  1 | 150000.00 |
|  2 | 250000.00 |
|  3 |  75000.00 |
|  4 | 320000.00 |
|  5 | 125000.00 |
+----+-----------+
5 rows in set (0.00 sec)
sql
SELECT id, total FROM pesanan WHERE id > 5 ORDER BY id LIMIT 5;

Output yang Dihasilkan:

text
+----+-----------+
| id | total     |
+----+-----------+
|  6 | 480000.00 |
|  7 | 210000.00 |
|  8 |  95000.00 |
|  9 | 540000.00 |
| 10 | 175000.00 |
+----+-----------+
5 rows in set (0.00 sec)
sql
SELECT id, total FROM pesanan WHERE id > 10 ORDER BY id LIMIT 5;

Output yang Dihasilkan:

text
+----+-----------+
| id | total     |
+----+-----------+
| 11 | 260000.00 |
| 12 | 310000.00 |
| 13 |  88000.00 |
| 14 | 430000.00 |
| 15 | 365000.00 |
+----+-----------+
5 rows in set (0.00 sec)

Halaman pertama mengembalikan id 1–5. Kursor kita adalah nilai terakhir yang terlihat — 5 — dan halaman kedua diminta dengan WHERE id > 5: lompatan index langsung ke id = 6, tanpa menyentuh baris 1–5 lagi. Halaman ketiga memakai id > 10. Biaya setiap halaman konstan: satu lookup plus sepuluh baris, entah halaman kedua atau halaman dua juta. Kita ukur halaman ketiga untuk membandingkannya dengan versi OFFSET:

sql
FLUSH STATUS;
SELECT id, total FROM pesanan WHERE id > 10 ORDER BY id LIMIT 5;
SHOW STATUS LIKE 'Handler_read%';

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

+----+-----------+
| id | total     |
+----+-----------+
| 11 | 260000.00 |
| 12 | 310000.00 |
| 13 |  88000.00 |
| 14 | 430000.00 |
| 15 | 365000.00 |
+----+-----------+
5 rows in set (0.00 sec)

+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Handler_read_first    | 0     |
| Handler_read_key      | 1     |
| Handler_read_last     | 0     |
| Handler_read_next     | 4     |
| Handler_read_prev     | 0     |
| Handler_read_rnd      | 0     |
| Handler_read_rnd_next | 0     |
+-----------------------+-------+
7 rows in set (0.00 sec)

Handler_read_key = 1 (satu lompatan ke posisi id > 10) dan Handler_read_next = 4 — total lima baris disentuh, lawan lima belas pada versi OFFSET 10. Inilah bukti numerik bahwa halaman keyset tidak pernah membayar pajak halaman-halaman sebelumnya.

Pola ini menuntut satu hal dari aplikasi: halaman dikunjungi berurutan (atau minimal kursor tersimpan), dan nomor halaman acak (“lompat ke halaman 47”) tidak lagi tersedia gratis — menghitung posisi kursor halaman 47 tetap butuh pengetahuan 46 halaman sebelumnya. Dropdown “1 2 3 … 47 48” berganti menjadi tombol “Berikutnya/Sebelumnya”, dan untuk kolom kursor non-unique seperti tanggal, kursor harus menyertakan kolom pemecah seri (misalnya WHERE (tanggal, id) > (nilai_tanggal, nilai_id)) supaya tidak ada baris yang terlewat atau tampil dua kali.

Deferred Join: Index Dulu, Baris Belakangan #

Ketika ORDER BY memakai kolom yang bukan kunci utama, LIMIT dengan OFFSET besar menyapu baris penuh — semua kolom — padahal yang dibuang hanya butuh kolom pengurutan. Deferred join memisahkan dua pekerjaan itu: subquery kecil mencari id halaman (menyapu index ramping), join kemudian mengambil baris lengkap hanya untuk pemenangnya:

sql
SELECT o.id, o.pelanggan_id, o.tanggal, o.total
FROM pesanan o
JOIN (
  SELECT id FROM pesanan ORDER BY total DESC LIMIT 3 OFFSET 5
) AS halaman ON o.id = halaman.id
ORDER BY o.total DESC;

Output yang Dihasilkan:

text
+----+--------------+------------+-----------+
| id | pelanggan_id | tanggal    | total     |
+----+--------------+------------+-----------+
| 12 |            3 | 2026-04-09 | 310000.00 |
| 11 |            8 | 2026-04-02 | 260000.00 |
|  2 |            2 | 2026-01-15 | 250000.00 |
+----+--------------+------------+-----------+
3 rows in set (0.00 sec)
sql
EXPLAIN SELECT o.id, o.pelanggan_id, o.tanggal, o.total
FROM pesanan o
JOIN (
  SELECT id FROM pesanan ORDER BY total DESC LIMIT 3 OFFSET 5
) AS halaman ON o.id = halaman.id
ORDER BY o.total DESC;

Output yang Dihasilkan:

text
+----+-------------+------------+------------+--------+---------------+---------+---------+------------+------+----------+---------------------------------+
| id | select_type | table      | partitions | type   | possible_keys | key     | key_len | ref        | rows | filtered | Extra                           |
+----+-------------+------------+------------+--------+---------------+---------+---------+------------+------+----------+---------------------------------+
|  1 | PRIMARY     | <derived2> | NULL       | ALL    | NULL          | NULL    | NULL    | NULL       |    8 |   100.00 | Using temporary; Using filesort |
|  1 | PRIMARY     | o          | NULL       | eq_ref | PRIMARY       | PRIMARY | 4       | halaman.id |    1 |   100.00 | NULL                            |
|  2 | DERIVED     | pesanan    | NULL       | ALL    | NULL          | NULL    | NULL    | NULL       |   15 |   100.00 | Using filesort                  |
+----+-------------+------------+------------+--------+---------------+---------+---------+------------+------+----------+---------------------------------+
3 rows in set, 1 warning (0.00 sec)

Plan memperlihatkan pembagian kerjanya dalam tiga baris: blok DERIVED (id = 2) menyapu 15 baris pesanan untuk mengurutkan dan memotong 8 baris (Using filesort — 5 offset + 3 hasil), hasilnya dimaterialkan sebagai <derived2>, lalu tabel o digabung lewat eq_ref pada PRIMARY — hanya tiga baris penuh yang dibuka. Using temporary; Using filesort pada baris join adalah pengurutan akhir tiga baris hasil — murah karena kecil. Dibanding SELECT * ORDER BY total LIMIT 3 OFFSET 5 yang membuka baris penuh untuk delapan baris (lima dibuang), deferred join membuka tepat tiga. Penghematan membesar ketika baris lebar (banyak kolom TEXT) dan offset menjulang; pola ini juga berpadu baik dengan keyset — subquery cukup WHERE id > kursor ORDER BY kolom LIMIT n.

OFFSET Kecil Masih Aman #

Tidak semua paginasi butuh pembedahan. OFFSET kecil (puluhan sampai ratusan) membaca sedikit baris ekstra dan umumnya tak terasa; kesederhanaan sintaksnya sering lebih berharga. Tabel ini merangkum kapan masing-masing pola layak dipilih:

PolaBentukBiaya halaman ke-nCocok untuk
OFFSET kecilLIMIT 20 OFFSET 40Naik pelan, kecilHalaman awal, data terbatas
Keyset / seekWHERE id > kursor ORDER BY id LIMIT 20KonstanScroll tak berujung, feed, API cursor
Deferred joinJOIN (SELECT id ... LIMIT n OFFSET m)Naik, tapi sapuan index rampingSort kolom non-PK + offset sedang
OFFSET besar polosLIMIT 20 OFFSET 500000Naik linier, mahalHindari

Membersihkan Database Latihan #

sql
DROP DATABASE belajar_opt;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Paginasi yang sehat sekarang punya dua senjata: kursor keyset yang membuat setiap halaman seringan halaman pertama, dan deferred join yang memisahkan pencarian halaman dari pengambilan baris penuh. Dengan EXPLAIN, Handler counter, dan pola query di tangan, kita siap naik ke tingkat paling praktis dari section ini: mendiagnosis query yang sudah terlanjur lambat — dari slow log sampai daftar penyakit khasnya.