Penggunaan klausa LIMIT ... OFFSET ... adalah pendekatan standar yang paling sering ditemui saat membuat paginasi REST API. Namun, pada tabel PostgreSQL dengan jutaan baris data, pendekatan ini mengalami degradasi performa drastis. Mesin database tetap harus membaca, mengurutkan, dan membuang ratusan ribu baris data di memori sebelum mengembalikan baris yang diminta.

Keyset pagination (dikenal juga sebagai cursor-based pagination) menyelesaikan masalah ini secara mendasar. Dengan memanfaatkan nilai kolom unik dari baris terakhir sebagai penanda (kursor), pencarian halaman berikutnya diubah menjadi operasi perbandingan indeks konstan O(1) atau O(log N).

Akar Masalah OFFSET pada Tabel Skala Besar

Ketika klien mengeksekusi query berikut:

SELECT id, title, created_at 
FROM posts 
ORDER BY created_at DESC, id DESC 
LIMIT 20 OFFSET 500000;

PostgreSQL tidak langsung melompat ke baris ke-500.001. Database harus memindai 500.020 baris data dari indeks atau disk, menampungnya di dalam buffer cache, membuang 500.000 baris pertama, dan mengembalikan 20 baris sisanya. Proses ini menyebabkan buffer pool churn, utilisasi I/O tinggi, dan lonjakan latensi respons API.

Perbandingan Eksekusi Query (EXPLAIN ANALYZE)

Rencana eksekusi untuk OFFSET 500.000:

Limit  (cost=45832.10..45833.93 rows=20 width=48) (actual time=142.821..142.827 rows=20 loops=1)
  ->  Index Scan using idx_posts_created_at_id on posts  (cost=0.43..91664.20 rows=1000000 width=48) (actual time=0.045..112.430 rows=500020 loops=1)
Planning Time: 0.124 ms
Execution Time: 143.150 ms

Sedangkan dengan pendekatan Keyset Pagination:

SELECT id, title, created_at 
FROM posts 
WHERE (created_at, id) < ('2023-10-24 10:15:30+00', 499980) 
ORDER BY created_at DESC, id DESC 
LIMIT 20;

Rencana eksekusi keyset query:

Limit  (cost=0.43..2.26 rows=20 width=48) (actual time=0.038..0.045 rows=20 loops=1)
  ->  Index Scan using idx_posts_created_at_id on posts  (cost=0.43..91664.20 rows=500000 width=48) (actual time=0.037..0.043 rows=20 loops=1)
        Index Cond: (ROW(created_at, id) < ROW('2023-10-24 10:15:30+00'::timestamptz, 499980))
Planning Time: 0.141 ms
Execution Time: 0.065 ms

Query keyset langsung mengarahkan pointer B-Tree ke lokasi record yang ditentukan, menghilangkan beban pemindaian baris-baris sebelumnya.

Desain Composite Index di PostgreSQL

Keyset pagination mewajibkan pengurutan deterministik. Nilai yang digunakan untuk perbandingan harus unik. Jika pengurutan menggunakan kolom non-unik seperti created_at, kita harus menyertakan kolom unik seperti id sebagai tie-breaker.

CREATE TABLE posts (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Composite Index untuk mendukung urutan (created_at DESC, id DESC)
CREATE INDEX idx_posts_created_at_id ON posts (created_at DESC, id DESC);

PostgreSQL mendukung perbandingan tuple (created_at, id) < ($1, $2) secara native, yang kompatibel dengan traversal indeks komposit multi-kolom di atas.

Implementasi Rust dengan Actix Web dan SQLx

Berikut implementasi production-ready tanpa unwrap(), mencakup decoding kursor base64, query parameterized, dan struktur response standar.

1. Model Cursor dan Serialization

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct Cursor {
    pub created_at: DateTime<Utc>,
    pub id: i64,
}

impl Cursor {
    pub fn encode(&self) -> Result<String, serde_json::Error> {
        let json = serde_json::to_string(self)?;
        Ok(base64::encode(json))
    }

    pub fn decode(encoded: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
        let bytes = base64::decode(encoded)?;
        let cursor: Self = serde_json::from_slice(&bytes)?;
        Ok(cursor)
    }
}

2. Penanganan Error Kustom

use actix_web::{http::StatusCode, HttpResponse, ResponseError};
use std::fmt;

#[derive(Debug)]
pub enum ApiError {
    DatabaseError(sqlx::Error),
    InvalidCursor,
}

impl fmt::Display for ApiError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::DatabaseError(_) => write!(f, "Kesalahan internal database"),
            Self::InvalidCursor => write!(f, "Format kursor tidak valid"),
        }
    }
}

impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        match self {
            Self::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::InvalidCursor => StatusCode::BAD_REQUEST,
        }
    }

    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status_code()).json(serde_json::json!({
            "error": self.to_string()
        }))
    }
}

impl From<sqlx::Error> for ApiError {
    fn from(err: sqlx::Error) -> Self {
        Self::DatabaseError(err)
    }
}

3. Handler Actix Web dan Query Execution

Untuk mendeteksi apakah masih ada halaman lanjutan tanpa melakukan query COUNT(*), ambil data sebanyak limit + 1. Jika data yang dikembalikan melebihi limit, berarti record berikutnya tersedia.

use actix_web::{web, HttpResponse};
use sqlx::{FromRow, PgPool};

#[derive(Debug, Deserialize)]
pub struct PaginationParams {
    pub cursor: Option<String>,
    pub limit: Option<i64>,
}

#[derive(Debug, Serialize, FromRow)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Serialize)]
pub struct PaginatedResponse<T> {
    pub data: Vec<T>,
    pub next_cursor: Option<String>,
    pub has_more: bool,
}

pub async fn get_posts(
    pool: web::Data<PgPool>,
    query: web::Query<PaginationParams>,
) -> Result<HttpResponse, ApiError> {
    let page_size = query.limit.unwrap_or(20).clamp(1, 100);
    let fetch_limit = page_size + 1;

    let cursor = match &query.cursor {
        Some(c) => Some(Cursor::decode(c).map_err(|_| ApiError::InvalidCursor)?),
        None => None,
    };

    let mut posts: Vec<Post> = match cursor {
        Some(c) => {
            sqlx::query_as!(
                Post,
                r#"
                SELECT id, title, created_at 
                FROM posts 
                WHERE (created_at, id) < ($1, $2)
                ORDER BY created_at DESC, id DESC 
                LIMIT $3
                "#,
                c.created_at,
                c.id,
                fetch_limit
            )
            .fetch_all(pool.get_ref())
            .await?
        }
        None => {
            sqlx::query_as!(
                Post,
                r#"
                SELECT id, title, created_at 
                FROM posts 
                ORDER BY created_at DESC, id DESC 
                LIMIT $1
                "#,
                fetch_limit
            )
            .fetch_all(pool.get_ref())
            .await?
        }
    };

    let has_more = posts.len() as i64 > page_size;
    if has_more {
        posts.truncate(page_size as usize);
    }

    let next_cursor = if has_more {
        posts.last().and_then(|last_post| {
            Cursor {
                created_at: last_post.created_at,
                id: last_post.id,
            }
            .encode()
            .ok()
        })
    } else {
        None
    };

    Ok(HttpResponse::Ok().json(PaginatedResponse {
        data: posts,
        next_cursor,
        has_more,
    }))
}

Trade-offs dan Keterbatasan

  • Tidak ada lompatan halaman acak: Klien tidak bisa langsung menuju ke halaman 15 tanpa mengambil halaman 1 sampai 14 secara sekuensial.
  • Ketergantungan Indeks Ketat: Query harus mencocokkan susunan kolom composite index secara persis. Jika filter pencarian bersifat dinamis, setiap kombinasi kolom pengurutan memerlukan composite index yang sesuai.
  • Kompleksitas Arah Navigasi (Bidirectional): Implementasi tombol previous page membutuhkan inversi operator perbandingan (>) dan pembalikan urutan hasil di layer aplikasi sebelum dikembalikan ke klien.