Implementasi pagination standar di Django menggunakan kelas Paginator mengandalkan klausa SQL LIMIT dan OFFSET. Pendekatan ini memadai untuk dataset kecil hingga menengah. Namun, ketika ukuran tabel mencapai jutaan baris, query halaman akhir mengalami degradasi performa signifikan. Solusi teknis untuk masalah ini adalah beralih ke keyset pagination (cursor pagination).

Akar Masalah: Mengapa OFFSET Lambat pada Tabel Besar?

Saat mengeksekusi SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 1000000;, mesin database relasional (seperti PostgreSQL) tidak langsung melompat ke baris ke-1.000.001. Mesin harus membaca 1.000.020 baris dari disk atau memori, mengurutkannya, lalu mendiskualifikasi 1.000.000 baris pertama sebelum mengembalikan 20 baris yang diminta.

Biaya I/O dan CPU meningkat linear seiring membesarnya nilai OFFSET ($O(N)$). Berikut perbandingan rencana eksekusi via EXPLAIN ANALYZE pada PostgreSQL:

-- Menggunakan OFFSET besar
EXPLAIN ANALYZE
SELECT id, created_at, total_amount
FROM orders_order
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 500000;

/* Output Rencana Eksekusi:
Limit  (cost=42150.23..42151.92 rows=20 width=24) (actual time=245.812..245.820 rows=20 loops=1)
  ->  Index Scan Backward using orders_order_created_at_id_idx on orders_order  (cost=0.43..84300.45 rows=1000000 width=24) (actual time=0.035..220.150 rows=500020 loops=1)
Planning Time: 0.125 ms
Execution Time: 246.105 ms
*/

Meskipun index digunakan, mesin tetap harus memproses 500.020 tuple aktual. Latensi melonjak hingga ratusan milidetik dan berisiko menyebabkan database timeout saat beban konkurensi tinggi.

Prinsip Kerja Keyset Pagination

Keyset pagination mengeliminasi OFFSET dengan memanfaatkan nilai kolom terurut dari baris terakhir halaman sebelumnya sebagai titik awal pembacaan (cursor). Database mencari titik tersebut langsung di dalam B-Tree index menggunakan operasi perbandingan (< atau >) dengan kompleksitas $O(\log N + K)$, di mana $K$ adalah ukuran halaman.

Karena kolom penanda waktu (seperti created_at) dapat memiliki nilai duplikat, wajib menyertakan kolom unik sekunder seperti id (primary key) sebagai tie-breaker penjamin determinisme urutan data.

Bentuk query SQL target:

SELECT id, created_at, total_amount
FROM orders_order
WHERE (created_at, id) < ('2024-03-30 10:15:00+00', 984521)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Konfigurasi Composite Index di Django Models

Keyset pagination mewajibkan B-tree composite index yang mencakup semua kolom pengurutan dengan arah index yang identik agar mesin database dapat melakukan Index Range Scan tanpa alokasi memori untuk pengurutan sementara (Temporary Sort).

from django.db import models

class Order(models.Model):
    created_at = models.DateTimeField(db_index=False)
    total_amount = models.DecimalField(max_digits=12, decimal_places=2)
    status = models.CharField(max_length=32)

    class Meta:
        indexes = [
            models.Index(
                fields=['-created_at', '-id'],
                name='order_created_id_desc_idx'
            )
        ]

Pastikan eksekusi python manage.py makemigrations dan migrate dilakukan sebelum menjalankan query.

Implementasi Keyset Pagination pada Django ORM

Django ORM tidak memiliki method komparasi tuple bawaan di level QuerySet API tanpa menggunakan raw SQL atau ekspresi khusus. Namun, logika komparasi tuple (created_at, id) < (val1, val2) dapat diuraikan secara ekuivalen menggunakan kombinasi operator Q:

(created_at < cursor_created_at) OR (created_at == cursor_created_at AND id < cursor_id)

Berikut implementasi utilitas keyset paginator yang aman, modular, dan memvalidasi tipe data:

import base64
import json
from datetime import datetime
from typing import Optional, Tuple, Any
from django.db.models import Q, QuerySet

def decode_cursor(cursor_str: str) -> Tuple[datetime, int]:
    try:
        raw_data = base64.urlsafe_b64decode(cursor_str.encode('utf-8')).decode('utf-8')
        payload = json.loads(raw_data)
        created_at = datetime.fromisoformat(payload['created_at'])
        item_id = int(payload['id'])
        return created_at, item_id
    except (ValueError, KeyError, json.JSONDecodeError) as exc:
        raise ValueError("Cursor tidak valid") from exc

def encode_cursor(created_at: datetime, item_id: int) -> str:
    payload = {
        'created_at': created_at.isoformat(),
        'id': item_id
    }
    raw_data = json.dumps(payload)
    return base64.urlsafe_b64encode(raw_data.encode('utf-8')).decode('utf-8')

def paginate_keyset(
    queryset: QuerySet,
    page_size: int = 20,
    cursor: Optional[str] = None
) -> dict:
    qs = queryset.order_by('-created_at', '-id')

    if cursor:
        cursor_created_at, cursor_id = decode_cursor(cursor)
        qs = qs.filter(
            Q(created_at__lt=cursor_created_at) |
            Q(created_at=cursor_created_at, id__lt=cursor_id)
        )

    # Ambil page_size + 1 untuk mendeteksi apakah masih ada halaman berikutnya
    items = list(qs[:page_size + 1])
    has_next = len(items) > page_size
    results = items[:page_size]

    next_cursor = None
    if has_next and results:
        last_item = results[-1]
        next_cursor = encode_cursor(last_item.created_at, last_item.id)

    return {
        'results': results,
        'next_cursor': next_cursor,
        'has_next': has_next,
    }

Contoh Pemanggilan di Django Views

from django.http import JsonResponse, HttpRequest
from .models import Order
from .pagination import paginate_keyset

def order_list_view(request: HttpRequest) -> JsonResponse:
    cursor = request.GET.get('cursor')
    base_qs = Order.objects.all()

    try:
        page_data = paginate_keyset(base_qs, page_size=20, cursor=cursor)
    except ValueError:
        return JsonResponse({'error': 'Cursor invalid'}, status=400)

    response_payload = {
        'data': [
            {
                'id': order.id,
                'created_at': order.created_at.isoformat(),
                'total_amount': str(order.total_amount)
            }
            for order in page_data['results']
        ],
        'next_cursor': page_data['next_cursor'],
        'has_next': page_data['has_next']
    }
    return JsonResponse(response_payload)

Verifikasi Rencana Eksekusi: Index Range Scan

Evaluasi ulang query keyset pagination menggunakan EXPLAIN ANALYZE pada titik data yang sama (setelah baris ke-500.000):

EXPLAIN ANALYZE
SELECT id, created_at, total_amount
FROM orders_order
WHERE (created_at < '2024-02-15 08:30:11+00')
   OR (created_at = '2024-02-15 08:30:11+00' AND id < 499980)
ORDER BY created_at DESC, id DESC
LIMIT 20;

/* Output Rencana Eksekusi:
Limit  (cost=0.43..2.10 rows=20 width=24) (actual time=0.038..0.065 rows=20 loops=1)
  ->  Index Scan using order_created_id_desc_idx on orders_order  (cost=0.43..41890.12 rows=500000 width=24) (actual time=0.036..0.061 rows=20 loops=1)
Planning Time: 0.151 ms
Execution Time: 0.082 ms
*/

Waktu eksekusi turun dari 246 ms menjadi 0.082 ms. Mesin database hanya membaca tepat 20 baris tanpa harus memindai dan membuang ratusan ribu baris sebelumnya.

Trade-offs dan Batasan Desain

Keyset pagination bukan pengganti tanpa kompromi untuk seluruh skenario pagination. Pertimbangkan trade-off berikut:

  • Tidak Mendukung Lompat Halaman Bebas: User tidak dapat melompat langsung ke "Halaman 45". Navigasi terbatas pada halaman berikutnya (next) atau sebelumnya (previous). Cocok untuk antarmuka infinite scroll atau feed.
  • Ketergantungan Kuat pada Arah Index: Perubahan filter pencarian atau kolom urutan dinamis memerlukan penyesuaian komparasi filter dan keberadaan index pendukung. Jika user bebas mengurutkan berdasarkan berbagai kolom di UI, setiap kombinasi kolom membutuhkan composite index khusus.
  • Data Inkonsisten Tanpa Tie-Breaker: Jika kolom cursor tidak memiliki tie-breaker unik (hanya menggunakan created_at tanpa id), baris dengan timestamp yang sama persis akan terlewati atau muncul berulang di batas halaman.

Rekomendasi: Gunakan keyset pagination untuk API publik, feed bervolume tinggi, dan tabel transaksi audit. Pertahankan LIMIT/OFFSET hanya pada filter back-office internal yang membutuhkan angka total baris absolut dan jarang melewati halaman awal.