Pengembangan Web

Keyset Pagination di MariaDB - Lanjutkan Setelah Baris Terakhir, Bukan Offset

Keyset Pagination di MariaDB - Lanjutkan Setelah Baris Terakhir, Bukan Offset

Sebuah daftar bisa terlihat stabil saat dibaca, padahal posisinya terus bergerak di balik kontrol pagination. Bayangkan halaman pertama menampilkan 20 artikel terbaru. Sebelum pembaca membuka halaman kedua, satu artikel baru terbit. Jika halaman kedua berarti "lewati 20 baris", baris baru itu menggeser batasnya. Satu baris lama dapat muncul kembali, meskipun pembaca maupun database tidak melakukan kesalahan yang terlihat jelas.

Masalah inilah yang ingin diatasi oleh keyset pagination. Alih-alih meminta MariaDB membuang sejumlah baris, aplikasi mengatakan, "lanjutkan setelah baris terakhir yang sudah aku terima." Gagasannya sederhana, tetapi penerapannya dengan benar memerlukan urutan deterministik, perbandingan yang sesuai, indeks yang berguna, dan ekspektasi jujur tentang apa yang terjadi ketika data berubah.

Artikel ini menyusun pola halaman berikutnya untuk aplikasi PHP kecil yang menggunakan MariaDB. Asumsinya adalah daftar dari terbaru ke terlama dengan kolom published_at dan id yang tidak boleh null. Artikel ini tidak menyatakan bahwa keyset pagination selalu lebih baik, ataupun bahwa pola tersebut mengubah beberapa request menjadi satu snapshot database yang tidak berubah.

Offset adalah hitungan, bukan posisi

Query yang umum untuk halaman keempat terlihat seperti ini:

SELECT id, title, published_at
FROM articles
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 20 OFFSET 60;

Referensi LIMIT MariaDB menjelaskan perilakunya secara langsung: ketika offset dan jumlah baris diberikan, baris-baris awal sebanyak nilai offset diabaikan, lalu baris berikutnya dikembalikan. Ini membuat halaman bernomor mudah dinyatakan, tetapi halaman yang jauh tetap mendeskripsikan semua baris sebelumnya sebagai pekerjaan yang harus dilewati. Karena itu, panduan pagination MariaDB memperingatkan tentang pekerjaan yang ditimbulkan oleh offset besar.

Ada masalah correctness terpisah ketika setiap request melihat daftar yang sedang berubah. Bayangkan halaman pertama mengembalikan ID 5, 4, 3. Baris baru, ID 6, disisipkan di depan. Request berikutnya menggunakan OFFSET 3, sehingga tiga baris pertama saat ini menjadi 6, 5, 4. Halaman kedua dimulai dari ID 3, yang sudah dilihat pembaca. Penghapusan sebelum batas dapat menggeser baris ke arah sebaliknya dan membuatnya terlewat.

Ini bukan berarti offset pagination rusak. Artinya, offset mendeskripsikan posisi dalam hasil pada saat setiap query dijalankan. Offset tidak membawa ingatan tentang baris mana yang mengakhiri response sebelumnya.

Pertama, buat total order

Keyset pagination memerlukan jawaban yang tidak ambigu untuk pertanyaan "apa yang datang setelah baris ini?" Mengurutkan hanya berdasarkan published_at tidak cukup ketika dua artikel dapat memiliki timestamp yang sama. Dokumentasi ORDER BY MariaDB menjelaskan bahwa ekspresi pengurutan berikutnya menyelesaikan nilai yang sama pada ekspresi sebelumnya. Menambahkan primary key yang unik menghasilkan urutan deterministik:

ORDER BY published_at DESC, id DESC

Pasangan (published_at, id) kini menjadi posisi baris untuk daftar ini. Jika halaman pertama berakhir pada timestamp 2026-10-17 10:00:00 dan ID 3, halaman berikutnya harus memilih baris dengan timestamp lebih lama, ditambah baris pada timestamp yang sama dengan ID lebih kecil:

SELECT id, title, published_at
FROM articles
WHERE status = 'published'
  AND (
    published_at < :before_time_lt
    OR (published_at = :before_time_eq AND id < :before_id)
  )
ORDER BY published_at DESC, id DESC
LIMIT 21;

Tanda perbandingan dan urutannya sama-sama mengikuti pengurutan menurun. Untuk daftar menaik, arah perbandingannya harus dibalik. Mencampur ORDER BY menurun dengan pengujian lanjutan menaik bukan hanya mengurangi performa; itu meminta baris yang salah.

Limit-nya 21 ketika halaman menampilkan 20 baris. Aplikasi merender 20 baris pertama dan hanya memakai baris tambahan untuk menentukan apakah tautan halaman berikutnya diperlukan. Dengan begitu, keputusan ini tidak memerlukan query count terpisah. Namun, cara tersebut tidak memberi tahu jumlah total halaman.

Implementasi PDO yang ringkas

Contoh berikut menggunakan query parameter yang transparan, bukan cursor opak, agar batasnya terlihat. Contoh ini mengasumsikan published_at adalah DATETIME MariaDB dengan presisi detik, id adalah bilangan bulat positif, dan $pdo sudah memakai exception mode. Authentication, authorization, output escaping, dan pengaturan koneksi adalah tanggung jawab aplikasi di sekelilingnya.

<?php
declare(strict_types=1);

$pageSize = 20;
$beforeTime = $_GET['before_time'] ?? null;
$beforeIdInput = $_GET['before_id'] ?? null;

if (($beforeTime === null) !== ($beforeIdInput === null)) {
    http_response_code(400);
    exit('Both cursor fields are required.');
}

$parameters = [];
$cursorWhere = '';

if ($beforeTime !== null) {
    $time = DateTimeImmutable::createFromFormat(
        '!Y-m-d H:i:s',
        (string) $beforeTime
    );
    $beforeId = filter_var(
        $beforeIdInput,
        FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 1]]
    );

    if ($time === false
        || $time->format('Y-m-d H:i:s') !== $beforeTime
        || $beforeId === false) {
        http_response_code(400);
        exit('Invalid cursor.');
    }

    $cursorWhere = <<<'SQL'
        AND (
          published_at < :before_time_lt
          OR (published_at = :before_time_eq AND id < :before_id)
        )
    SQL;
    $parameters = [
        'before_time_lt' => $beforeTime,
        'before_time_eq' => $beforeTime,
        'before_id' => $beforeId,
    ];
}

$sql = "
    SELECT id, title, published_at
    FROM articles
    WHERE status = 'published'
    {$cursorWhere}
    ORDER BY published_at DESC, id DESC
    LIMIT " . ($pageSize + 1);

$statement = $pdo->prepare($sql);
$statement->execute($parameters);
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);

$hasNextPage = count($rows) > $pageSize;
if ($hasNextPage) {
    array_pop($rows);
}

$nextUrl = null;
if ($hasNextPage && $rows !== []) {
    $last = $rows[array_key_last($rows)];
    $nextUrl = '/articles?' . http_build_query([
        'before_time' => $last['published_at'],
        'before_id' => $last['id'],
    ]);
}

SQL dinamis di sini memilih satu dari dua bentuk query tetap: halaman pertama atau halaman lanjutan. Nilai dari pengguna tetap menjadi bound parameter. Nilai numerik LIMIT dibangun dari konstanta di server, bukan disalin dari request.

Sebuah API dapat mengenkode timestamp dan ID menjadi satu string cursor. Encoding dapat membuat URL lebih rapi dan menyediakan ruang untuk field pengurutan tambahan, tetapi base64 bukan enkripsi dan sifat opak bukan integritas. Server tetap harus mendekode, memvalidasi, dan melakukan binding pada setiap nilai. Jika mengubah cursor dapat membuka record tenant lain atau melewati filter, authorization dan cakupan query harus mencegahnya; signed cursor dapat mendeteksi manipulasi, tetapi tidak dapat menggantikan kontrol tersebut.

Indeks harus mengikuti query, bukan kata “pagination”

Untuk query contoh, salah satu kandidat indeks yang masuk akal adalah:

CREATE INDEX idx_articles_status_published_id
ON articles (status, published_at, id);

status yang difilter dengan equality berada di depan, lalu diikuti kolom yang dipakai untuk batas terurut. Pola ini mengikuti bentuk yang dijelaskan dalam panduan pagination MariaDB. Meski begitu, ini tetap kandidat, bukan resep universal. Selectivity, filter tambahan, kolom yang dipilih, ukuran tabel, storage engine, tingkat write, dan query lain semuanya memengaruhi susunan indeks yang berguna.

Panduan indeks MariaDB menyarankan untuk mencocokkan indeks dengan WHERE dan ORDER BY, memeriksa execution plan dengan EXPLAIN, serta mengingat bahwa indeks tambahan memakai storage dan menambah pekerjaan saat write. Periksa kedua bentuk query:

EXPLAIN
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 21;

EXPLAIN
SELECT id, title, published_at
FROM articles
WHERE status = 'published'
  AND (
    published_at < '2026-10-17 10:00:00'
    OR (published_at = '2026-10-17 10:00:00' AND id < 3)
  )
ORDER BY published_at DESC, id DESC
LIMIT 21;

Sebuah plan adalah bukti untuk satu schema, distribusi data, dan versi server; plan bukan benchmark. Uji batas awal maupun batas yang dalam dengan data menyerupai production sebelum menyatakan adanya peningkatan. Panduan implementasi GitLab menekankan hal praktis yang sama: verifikasi query pertama dan query cursor, karena keberhasilan di halaman pertama tidak membuktikan bahwa kelanjutannya terindeks dengan baik.

Apa yang terjadi ketika daftar berubah?

Baris baru yang disisipkan sebelum batas tersimpan tidak menggeser batas tersebut, sehingga baris itu tidak akan membuat baris yang sudah tampil muncul kembali di halaman berikutnya. Inilah keunggulan utama dibanding menghitung dari bagian depan daftar saat ini.

Keyset pagination tidak menjanjikan bahwa setiap sesi membaca melihat daftar historis yang tidak berubah. Jika baris yang belum terlihat dihapus, baris itu tidak dapat muncul kemudian. Jika nilai pengurutan diedit, baris dapat berpindah melewati batas tersimpan dan mungkin terlewat atau ditemui dengan cara berbeda. Request HTTP yang terpisah biasanya berjalan sebagai pembacaan database yang terpisah; cursor menyimpan posisi pengurutan, bukan transaction snapshot.

Filter juga menjadi bagian dari makna posisi tersebut. Cursor yang dibuat untuk status=published&author=7 tidak seharusnya diam-diam melanjutkan query yang berbeda. Aplikasi dapat mempertahankan filter secara eksplisit di URL, memasukkan kondisi filter yang telah dinormalisasi ke signed cursor, atau menolak cursor ketika konteksnya tidak cocok. Apa pun desainnya, semua filter tetap harus diberlakukan oleh server.

Kolom pengurutan nullable membutuhkan kebijakan pengurutan null yang eksplisit, sedangkan field yang dapat berubah atau tidak unik membutuhkan tie-breaker yang cermat. Contoh dua kolom ini menghindari kerumitan tersebut dengan mewajibkan timestamp non-null dan ID unik yang non-null.

Tradeoff adalah bagian dari desain

Keyset pagination secara alami mendukung "berikutnya" dan, dengan membalik perbandingan serta pengurutan, "sebelumnya". Pola ini tidak secara alami menjawab "langsung ke halaman 417", karena nomor halaman didefinisikan oleh hitungan, bukan key baris yang sudah diketahui. Tampilan pasti seperti "item 61–80 dari 38.412" juga dapat membutuhkan query count terpisah dengan keputusan tersendiri mengenai biaya dan freshness.

Kekurangan tersebut penting pada beberapa antarmuka. Tabel administrasi dengan beberapa ratus record yang sebagian besar stabil mungkin lebih jelas dengan halaman offset bernomor. Feed aktivitas publik, arsip artikel, atau koleksi API yang terus bertambah mungkin lebih cocok dengan navigasi berbasis cursor. Pembahasan seek method oleh Markus Winand menekankan berkurangnya pekerjaan untuk membuang baris sekaligus kebutuhan membawa keyset ke seluruh lapisan aplikasi, sedangkan dokumentasi engineering GitLab secara eksplisit menganggap ketiadaan nomor halaman sebagai keterbatasan.

Tidak perlu mengganti setiap offset hanya karena keyset pagination tersedia. Pertanyaan yang berguna adalah apakah antarmuka membutuhkan lompatan bebas ke halaman bernomor, atau membutuhkan kelanjutan yang andal melalui kumpulan besar dan terurut yang terus berubah.

Checklist review

  • Query memiliki urutan eksplisit dan deterministik yang berakhir dengan tie-breaker unik.
  • Cursor membawa setiap nilai yang diperlukan untuk mereproduksi batas pengurutan tersebut.
  • Perbandingan lanjutan memakai kolom dan arah yang sama dengan ORDER BY.
  • Nilai cursor di-parse, divalidasi, dan di-bind sebagai data.
  • Authorization dan filter diterapkan ulang pada setiap request.
  • Aplikasi mengambil satu baris tambahan hanya ketika memerlukan penanda halaman berikutnya.
  • Indeks dipilih untuk bentuk query lengkap dan diperiksa pada halaman pertama maupun lanjutan.
  • Pengujian mencakup timestamp yang sama, insert sebelum batas, deletion, cursor tidak valid, dan halaman parsial terakhir.
  • Antarmuka tidak menjanjikan semantik nomor halaman atau snapshot yang tidak dapat diberikan implementasi.

Kesimpulan

Offset pagination meminta MariaDB menemukan hasil terurut saat ini dan membuang sejumlah baris dari depannya. Keyset pagination mengingat batas baris sebagai gantinya. Untuk daftar MariaDB dari terbaru ke terlama, batas itu dapat berupa pasangan (published_at, id), dengan primary key menyelesaikan timestamp yang sama dan perbandingan yang sesuai memilih baris setelahnya.

Hasilnya bukan konsistensi ajaib. Baris masih dapat diedit atau dihapus, cursor tetap perlu divalidasi, dan indeks tetap membutuhkan bukti dari schema serta plan yang sebenarnya. Pola ini juga menukar lompatan bebas antarhalaman dengan navigasi terarah. Ketika tradeoff tersebut cocok dengan antarmuka, keyset pagination memberi instruksi yang lebih jujur daripada "lewati sebanyak baris yang dahulu berada sebelum halaman ini": lanjutkan setelah baris terakhir yang benar-benar diterima pembaca.

References