Sviluppo web
Paginazione a cursore: perché l'offset salta righe
Su 200.000 righe l'offset costa 5,063 ms e il cursore 0,010 ms: ma il problema vero è che l'offset salta una riga quando il dato cambia.
In sintesi
- Su 200.000 righe indicizzate, una pagina a offset 180.000 costa 5,063 ms; la stessa pagina con keyset costa 0,010 ms.
- Cancellare la prima riga dopo aver letto la pagina 1 fa saltare una riga alla pagina 2 a offset; il cursore non salta nulla.
- Il cursore deve contenere l'ultima chiave di ordinamento, con un tiebreaker che renda l'ordine totale.
- Il client non deve dedurre la fine dalla lunghezza della pagina: servi next_cursor e has_more.
La paginazione a cursore legge la pagina successiva a partire dall'ultima riga vista, non contando le righe da saltare. Su 200.000 righe indicizzate, SQLite 3.51.3 impiega 5,063 ms per una pagina a offset 180.000 e 0,010 ms per la stessa pagina con keyset: la differenza è che l'offset conta, il cursore cerca.
Perché l'offset invecchia
OFFSET 180000 chiede al database di produrre e scartare 180.000 righe: il costo cresce con la profondità, non con la dimensione della pagina. Peggio, il numero di riga non identifica un punto stabile: se qualcuno cancella una riga prima della pagina corrente, la pagina successiva parte una riga più avanti e ne salta una. Se qualcuno ne inserisce una, la pagina successiva ripete una riga già vista. È il difetto che l'utente nota come contenuto mancante, non come lentezza.
Il cursore è una chiave, non un numero di pagina
Il cursore contiene l'ultima chiave di ordinamento, per esempio created_at più id come spareggio. La query diventa una ricerca nell'indice: WHERE (created_at, id) > (?, ?) con lo stesso ORDER BY. Perché funzioni, l'ordine della query e l'ordine dell'indice devono coincidere e la chiave deve essere unica: senza il tiebreaker due righe con lo stesso timestamp producono un ordine instabile e righe duplicate o perse.
I numeri, e cosa dicono
Il rapporto misurato è di circa 500 volte sulla penultima pagina e di oltre 600 sull'ultima, su un database di 14 MB che sta interamente in memoria. Su una tabella più grande e su un disco vero il divario cresce, perché l'offset legge pagine che poi scarta. Il cursore invece tocca solo l'intervallo che serve, e il costo resta quasi costante con la profondità.
EXPLAIN QUERY PLAN SELECT id, created_at FROM events WHERE (created_at, id) > ('2026-01-03T04:00:00.000Z', 180000) ORDER BY created_at, id LIMIT 20;
SELECT id, created_at FROM events WHERE (created_at, id) > ('2026-01-03T04:00:00.000Z', 180000) ORDER BY created_at, id LIMIT 20;
Cosa restituisce l'API
La risposta contiene i dati, un next_cursor opaco e un has_more esplicito. Il client non deve dedurre la fine dalla lunghezza della pagina: una pagina piena può essere l'ultima, e una pagina corta può non esserlo se il filtro ha rimosso righe. Il cursore va validato: se non si decodifica o non corrisponde all'ordinamento richiesto, rispondi 400 invece di restituire una pagina arbitraria. Metti anche un tetto al parametro limit, altrimenti il cursore diventa un modo per chiedere l'intera tabella.
Il limite: niente salto a pagina 57
Con il cursore non puoi chiedere direttamente la pagina 57, perché non esiste un punto di partenza calcolabile senza conoscere i dati. Per un pannello di amministrazione con poche migliaia di righe e numeri di pagina visibili, l'offset resta la scelta più semplice; per un feed, una lista di ordini o un'esportazione, il cursore è l'unica opzione che regge la crescita. Un cursore firmato evita che il client costruisca chiavi arbitrarie, e un cursore che contiene un timestamp va rifiutato se è nel futuro.
Da dove partire
Domande frequenti
Cosa contiene un cursore opaco?
L'ultima chiave di ordinamento, spesso codificata in base64 e firmata. Il client non deve interpretarlo, solo restituirlo; il server lo decodifica, verifica la firma e ricostruisce la query.
Posso restituire anche il totale delle righe?
Sì, ma è una seconda query e su tabelle grandi costa quanto l'offset. Se serve un contatore, calcolalo in modo approssimato o mettilo in cache; per il flusso normale bastano next_cursor e has_more.
Come torno alla pagina precedente?
Conserva il cursore di ogni pagina visitata e riusalo, oppure esegui la stessa query con l'ordine invertito e un cursore iniziale. Non ricalcolare l'offset: è il punto da cui volevi uscire.
Cosa succede se i dati cambiano mentre pagino?
Il cursore resta stabile sul punto già letto; le righe nuove compaiono solo se rientrano nell'ordinamento dopo quel punto. Le righe cancellate spariscono, e non vengono sostituite da altre, che è il comportamento prevedibile.
Meglio offset per un pannello di amministrazione?
Sì se le righe sono poche e servono i numeri di pagina. Con poche migliaia di righe i 5 ms diventano irrilevanti e il salto diretto vale più della stabilità; sopra quella soglia il cursore vince su entrambi i fronti.