Paginación Determinista por Cursores (Keyset Pagination) de Alto Rendimiento en PostgreSQL

sql 9 de noviembre de 2026 1 lecturas
Implementación de paginación por cursores determinista (Keyset Pagination) en PostgreSQL para consultas masivas con costo O(1) sin degradación de OFFSET.
# Paginación Keyset de Alto Rendimiento en PostgreSQL: De O(N) a O(1) en Conjuntos de Datos Masivos En el diseño de arquitecturas de bases de datos relacionales y el desarrollo de APIs públicas o de consumo intensivo, la paginación es uno de los componentes de ingeniería más frecuentemente implementados y, a su vez, uno de los que peor escalan cuando se diseñan de manera ingenua. Durante décadas, el estándar indiscutido en la industria ha sido la paginación basada en desplazamiento y límite (`OFFSET` y `LIMIT`). Sin embargo, en bases de datos transaccionales con tablas que superan los cientos de miles o millones de registros (como tablas de auditoría, telemetría, logs de seguridad, eventos de eventos o transacciones de comercio electrónico), `OFFSET` se convierte rápidamente en un cuello de botella crítico que satura los buffers de memoria compartida, dispara el I/O en disco y degrada la latencia hasta provocar caídas del pool de conexiones. Este snippet expone la arquitectura, mecánica interna, comparación de planes de ejecución (`EXPLAIN ANALYZE`) y consideraciones de diseño del patrón **Keyset Pagination** (también conocido como *Cursor-based Pagination* o paginación orientada a cursor), alcanzando un tiempo de respuesta estrictamente constante $O(1)$ sin importar la profundidad de la navegación. --- ## 1. La Anatomía del Problema: Por Qué OFFSET Destruye el Rendimiento Para entender por qué `OFFSET` falla catastróficamente en producción, es imprescindible examinar cómo el motor de almacenamiento de PostgreSQL ejecuta dicha instrucción internamente. Cuando un cliente HTTP o una aplicación solicita una página profunda, por ejemplo: ```sql SELECT id, title, created_at FROM audit_logs ORDER BY created_at DESC LIMIT 20 OFFSET 500000; ``` A nivel de motor relacional, PostgreSQL no tiene una forma mágica de "saltar" instantáneamente hasta la tupla 500,001 en el disco o en el índice. En su lugar, el planificador debe: 1. Escanear o recorrer el índice o la tabla secuencialmente para localizar y ordenar las primeras **500,020 tuplas**. 2. Leer y cargar en memoria cada uno de esos 500,000 registros previos. 3. Descartar las primeras 500,000 filas leídas. 4. Entregar al cliente únicamente las 20 filas restantes especificadas en el `LIMIT`. A medida que el valor de `OFFSET` ($N$) crece, el coste computacional y de I/O escala de forma lineal: $$\text{Costo Computacional} = O(N + M)$$ donde $N$ representa el desplazamiento de filas a omitir y $M$ la cantidad de filas devueltas (`LIMIT`). Si la tabla cuenta con 10 millones de registros y un usuario o un crawler automatizado navega a la página 5,000, la base de datos estará leyendo millones de filas inútiles en cada request, provocando contención severa de buffers (`shared_buffers`) y lecturas aleatorias de disco. Adicionalmente, `OFFSET` sufre del problema de **inconsistencia por desplazamiento de ventana** (Phantom Reads y Data Drifting): si mientras un usuario pagina se insertan o eliminan registros en la base de datos, los elementos se desplazan, ocasionando que el cliente vea elementos repetidos en la siguiente página o se salte tuplas sin advertirlo. --- ## 2. El Paradigma de Keyset Pagination: Búsqueda Indexada Directa O(1) La paginación Keyset erradica por completo la necesidad de un desplazamiento (`OFFSET`). En lugar de decirle a la base de datos "sáltate $N$ filas", le proporcionamos un punto de referencia unívoco e inmutable: **el cursor del último registro procesado en la página anterior**. Con este dato, la consulta se transforma de un escaneo y descarte a una búsqueda por rango directa (`Index Range Scan`) utilizando cláusulas `WHERE`: ```sql SELECT id, title, created_at FROM audit_logs WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20; ``` Bajo este enfoque: - El planificador utiliza el índice B-Tree existente para hacer un salto directo (`B-Tree Traversal`) a la clave exacta donde finalizó la página previa. La altura de un árbol B-Tree es $O(\log K)$, donde $K$ es el número total de tuplas indexadas (habitualmente una profundidad de 3 a 4 niveles). - Una vez ubicado el cursor, el motor lee de forma contigua en el índice exactamente las 20 filas requeridas por el `LIMIT` y se detiene de inmediato. - El costo computacional es estrictamente independiente de la profundidad de la paginación: $$\text{Costo Keyset} = O(\log K + M) \approx O(1)$$ Tanto la página 1 como la página 100,000 demoran exactamente el mismo tiempo de ejecución (habitualmente sub-milisegundo). --- ## 3. El Requisito de Determinismo: Columnas Clave Compuestas Un error recurrente al implementar paginación por cursor es utilizar un único campo temporal (como `created_at`). En sistemas transaccionales con concurrencia media o alta, es perfectamente posible que múltiples registros compartan exactamente la misma marca de tiempo (`timestamp`), incluso con precisión de microsegundos. Si paginamos únicamente con: ```sql WHERE created_at < :last_created_at ``` Todas las tuplas concurrentes que compartan ese mismo microsegundo exacto serán omitidas, generando pérdida silenciosa de registros. Por esta razón, **el cursor debe ser estrictamente determinista y totalizador**. Se debe componer la clave de ordenamiento con una columna unívoca e invariable, típicamente la clave primaria (`id` UUID o BIGINT): 1. **Criterio de orden principal:** `created_at DESC` 2. **Criterio de desempate:** `id DESC` Para respaldar esta consulta con la máxima eficiencia, es mandatorio crear un índice compuesto cuyo orden de columnas coincida con la cláusula `ORDER BY`: ```sql CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_logs_cursor ON audit_logs (created_at DESC, id DESC); ``` La directiva `CONCURRENTLY` en PostgreSQL asegura que el índice se construya en segundo plano sin adquirir bloqueos de escritura exclusivos (`ShareLock`) en tablas en producción activa. --- ## 4. Comparación de Tuplas ANSI vs Cláusulas OR PostgreSQL soporta la comparación de filas o tuplas (Row-Value Comparisons): ```sql WHERE (created_at, id) < (:last_created_at, :last_id) ``` Esta sintaxis es estándar ANSI SQL y PostgreSQL la traduce de forma limpia a un escaneo de índice multicomponente en B-Tree. No obstante, si el backend utiliza ORMs antiguos o frameworks que no compilan nativamente la sintaxis de tuplas a nivel de Query Builder, se puede expresar de forma idéntica mediante álgebra booleana: ```sql WHERE created_at < :last_created_at OR (created_at = :last_created_at AND id < :last_id) ``` Ambas formulaciones son semánticamente equivalentes y el optimizador de PostgreSQL 14 en adelante es capaz de aprovechar el índice compuesto en ambos escenarios. --- ## 5. Cuándo Usar Keyset vs Cuándo Mantener OFFSET A pesar de la superioridad técnica abrumadora de Keyset Pagination en términos de I/O y latencia, cada patrón resuelve necesidades específicas: | Criterio de Selección | OFFSET / LIMIT Tradicional | Keyset / Cursor Pagination | | :--- | :--- | :--- | | **Complejidad Algorítmica** | $O(N)$ (Degradación severa con páginas profundas) | $O(1)$ (Latencia constante en cualquier nivel) | | **Consumo de I/O y Buffer** | Alto; lee y descarta cientos de miles de tuplas | Mínimo; solo lee las tuplas solicitadas | | **Salto Arbitrario a Página N** | Soportado (ej: ir directamente a página 84) | No soportado directamente (solo anterior / siguiente) | | **Consistencia ante Inserciones Concurrentes** | Pobre (produce registros duplicados o saltados) | Alta (la ventana queda anclada al valor de la tupla) | | **Casos de Uso Ideales** | Tablas de configuración pequeñas (< 5,000 filas), interfaces con número de página fijo | Feeds infinitos (Infinite Scroll), APIs REST de consumo masivo, Webhooks, telemetría y tablas de auditoría | --- ## 6. Serialización del Cursor en APIs REST / GraphQL En la arquitectura de software de una API moderna, nunca se deben exponer directamente los campos internos de la base de datos al cliente. La buena práctica consiste en codificar el cursor en una cadena opaca en formato **Base64** o JWT firmado: ```json { "data": [...], "pagination": { "has_next_page": true, "end_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0xMS0wOVQxNTozMDowMC4xMjNaIiwiaWQiOiJjNGE4YjcxZC04NGY5LTRkNmItOTUyZC04ODdlMzhjZjFhMjAifQ==" } } ``` El cliente envía este `end_cursor` en el siguiente parámetro `?after=...`, el backend decodifica los valores `created_at` e `id`, los valida estrictamente con un esquema tipado (ej. Zod) y ejecuta la consulta SQL parametrizada de forma blindada contra SQL Injection y contención de recursos.
-- ============================================================================
-- Patrón: Paginación Determinista por Clave de Cursor (Keyset Pagination)
-- Motor: PostgreSQL 14+ / 17 / 18
-- Benchmark de Rendimiento: O(1) tiempo de respuesta vs O(N) de OFFSET/LIMIT tradicional
-- ============================================================================

-- 1. Estructura de índice compuesta B-Tree optimizada
-- Vital: El orden de las columnas en el índice debe coincidir exactamente con el ORDER BY
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_logs_cursor 
ON audit_logs (created_at DESC, id DESC);

-- 2. Lectura inicial (Página 1): Obtener primeros 20 registros
SELECT 
    id,
    entity_name,
    action,
    payload,
    created_at
FROM 
    audit_logs
ORDER BY 
    created_at DESC, 
    id DESC
LIMIT 20;

-- 3. Lectura de Páginas Siguientes (Deep Pagination):
-- Suponiendo que el último registro de la página anterior obtuvo:
-- :last_created_at = '2026-11-09 15:30:00.123456+00'
-- :last_id         = 'c4a8b71d-84f9-4d6b-952d-887e38cf1a20'
--
-- Sintaxis Tupla (Row Comparison): Elegante, estándar ANSI SQL y utiliza Index Scan puro
SELECT 
    id,
    entity_name,
    action,
    payload,
    created_at
FROM 
    audit_logs
WHERE 
    (created_at, id) < (:last_created_at, :last_id)
ORDER BY 
    created_at DESC, 
    id DESC
LIMIT 20;

-- 4. Alternativa equivalente sin comparación de tuplas (para máxima portabilidad entre ORMs):
/*
SELECT 
    id,
    entity_name,
    action,
    payload,
    created_at
FROM 
    audit_logs
WHERE 
    created_at < :last_created_at
    OR (created_at = :last_created_at AND id < :last_id)
ORDER BY 
    created_at DESC, 
    id DESC
LIMIT 20;
*/
¿Qué te pareció?
🔥 Brillante 0
💡 Me sirvió 0
🚀 A otro nivel 0

¿Te resultó útil este snippet? Explora más código y soluciones en AndresSY.dev.

Volver a Snippets