Paginación y cursores
1) Por qué se necesita paginación
La paginación limita la cantidad de datos transmitidos y renderizados por el cliente, reduce la carga de almacenamiento/redes y establece una forma determinista de «caminar» por la colección. En sistemas reales, la paginación no es solo 'page = 1 & limit = 50', sino un conjunto de contratos protocolarios e invariantes de consistencia.
Objetivos modelo:- Control de latencia y memoria por solicitud.
- Navegación estable cuando se cambia el conjunto de datos (inserción/eliminación).
- Posibilidad de reanudar desde el asiento (resumen).
- Caché y precarga (prefetch).
- Protección contra el abuso (rate limiting, backpressure).
2) Modelos de paginación
2. 1 OFFSET/LIMIT (página)
La idea es: «saltar N líneas, volver M».
Pros: simplicidad, compatible con casi cualquier SQL/NoSQL.
- Degradación lineal: grandes OFFSET conducen al escaneo completo/skip-cost.
- Inestabilidad en las inserciones/eliminaciones entre consultas (los desplazamientos «flotan»).
- Es difícil garantizar una «renovabilidad» precisa.
sql
SELECT
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 1000 LIMIT 50;
2. 2 Cursor/Keyset/Seek-paginación
La idea es «continuar con la clave K». Un cursor es una posición en un conjunto ordenado.
Ventajas:- O (1) acceder a la continuación si hay un índice.
- Estabilidad en los cambios de colección.
- Mejor latencia en «páginas» profundas.
- Se necesitan llaves de clasificación estrictamente definidas, únicas y monótonas.
- Más difícil de implementar y depurar.
sql
-- Resumption after steam (created_at, id) = (:last_ts,:last_id)
SELECT
FROM orders
WHERE (created_at, id) < (:last_ts,:last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
2. 3 Continuation tokens (tokens opacos)
Idea: el servidor devuelve un token opaque en el que se codifica la «posición» (y posiblemente el estado de los shardos/filtros). El cliente no entiende las vísceras y simplemente devuelve el token para la página siguiente.
Ventajas: flexibilidad, posibilidad de cambiar el circuito sin romper la API.
Contras: gestión de la vida útil de los tokens, compatibilidad con los deployes.
2. 4 Cursores temporales y lógicos
Tiempo-basado: «todas las entradas hasta T», el cursor es una marca de tiempo (adecuado para subprocesos append-only).
Log-sequence/offset-based: cursor - offset (Kafka offset, journal seq).
Global monotonic IDs: Snowflake/UUIDv7 como claves ordenables para un seek estable.
3) Diseño de cursos y tokens
3. 1 Propiedades de un buen cursor
Opacidad (opaque): el cliente es independiente del formato.
Autoría/integridad: firma HMAC para evitar la sustitución/manipulación.
Contexto: incluye ordenamiento, filtros, versión del esquema, tenant/shard.
Vida útil: TTL e «inapelable» (no-replay) cuando se cambian los índices/derechos de acceso.
Tamaño: compacto (<= 1-2 KB), adecuado para URL.
3. 2 Formato de token
Pila recomendada: JSON → compresión (zstd/deflate) → Base64URL → HMAC.
Estructura de la carga útil (ejemplo):json
{
"v": 3 ,//token version
"sort": ["created_at:desc","id:desc"],
"pos": {"created_at":"2025-10-30T12:34:56. 789Z","id":"987654321"},
"filters": {"user_id":"42","status":["paid","shipped"]},
"tenant": "eu-west-1",
"shards": [{"s ": "a, "" hi":"..."}] ,//optional: cross-shard context
"issued_at": "2025-10-31T14:00:00Z",
"ttl_sec": 3600
}
En la parte superior se añade 'mac = HMAC (secret, payload)' y todo está codificado en un solo token de cadena.
3. 3 Seguridad
Firmar (HMAC/SHA-256).
Cifrado opcional (AES-GCM) si hay valores sensibles (PII).
Validación en el servidor: versión, TTL, credenciales de usuario (RBAC/ABAC).
4) Coherencia e invariantes
4. 1 Clasificación estable
Utilice el determinismo completo: 'ORDER BY ts DESC, id DESC'.
La clave de ordenación debe ser única (añadir 'id' como tiebreaker).
El índice debe coincidir con la clasificación (índice covering).
4. 2 Imágenes (snapshot) y aislamiento
Para páginas «sin saltar», utilice el snapshot de lectura consistente (MVCC/txid).
Si el snapshot no es práctico (datos caros/muchos), formula el contrato: «el cursor devuelve los elementos, estrictamente anteriores a las posiciones». Es natural para las noticias.
4. 3 Inserciones/eliminaciones entre páginas
El modelo Seek minimiza los «duplicados/pases».
Documentar el comportamiento al eliminar/cambiar: se permiten «agujeros» raros entre las páginas, pero no «hacia atrás en el tiempo».
5) Indexación y esquemas de identificación
Los índices compuestos están estrictamente en orden de clasificación: '(created_at DESC, id DESC)'.
ID monótono: Snowflake/UUIDv7 dan orden en el tiempo → aceleran seek.
Teclas de acceso rápido: Distribuya la clave de shard (por ejemplo, 'tenant _ id', 'region') y ordene dentro de la barra.
Generadores ID: evite colisiones y «relojes del futuro» (clock skew) - sincronización de tiempo, «regresión» en saltos NTP.
6) Paginación cruzada-charda
6. 1 Esquemas
Scatter-Gather: consultas paralelas a todas las chardas, cursos de búsqueda locales, luego k-way merge en el agregador.
Por-Shard Cursors: el token contiene posiciones para cada chard.
Bounded fan-out: limite el número de chardos en un solo paso (rate limiting/timeout budget).
6. 2 Tokens para multi-shard
Almacene la matriz '{shard _ id, last_pos}'. En el siguiente paso, renueve por cada shard activo y vuelva a pellizcar, dando una página ordenada globalmente.
7) Contratos de protocolo
7. 1 REST
Consulta:
GET /v1/orders? limit=50&cursor=eyJ2IjoiMyIsInNvcnQiOiJjcmVh... (opaque)
Respuesta:
json
{
"items": [ /... / ],
"page": {
"limit": 50,
"next_cursor": "eyJ2IjozLCJwb3MiOiJjcmVh...==",
"has_more": true
}
}
Recomendaciones:
- 'limit' con el límite superior (por ejemplo, max = 200).
- 'next _ cursor' falta si 'has _ more = false'.
- Idempotencia GET, capacidad de respuesta en caché sin 'next _ cursor' (primera página con filtros fijos y snapshot).
7. 2 GraphQL (enfoque Relay)
Contrato típico de 'conexión':graphql type Query {
orders(first: Int, after: String, filter: OrderFilter): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge {
node: Order!
cursor: String! // opaque
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
'cursor' debe ser opaco y estar firmado; no use «Base64 crudo (id)» sin HMAC.
7. 3 gRPC
Utilice 'page _ size' y 'page _ token':proto message ListOrdersRequest {
string filter = 1;
int32 page_size = 2;
string page_token = 3; // opaque
}
message ListOrdersResponse {
repeated Order items = 1;
string next_page_token = 2; // opaque bool has_more = 3;
}
7. 4 Subprocesos y WebSockets
Para cintas continuas: el cursor como "el último offset/ts' visto.
Admita 'resume _ from' en el reconnect:json
{ "action":"subscribe", "topic":"orders", "resume_from":"2025-10-31T12:00:00Z#987654321" }
8) Caché, pre-inicio, CDN
ETag/If-None-Match para la primera página con filtros estables.
Cache-Control con TTL corto (por ejemplo, 5-30 s) para listas públicas.
Prefetch: devuelve 'next _ cursor' y sugerencias ('Link: rel =' next'), el cliente puede preinstalar la siguiente página.
Variaciones: considere 'filter/sort/locale/tenant' como la clave de la pieza de caché.
9) Gestión de carga y límite
El límite superior es 'limit', por ejemplo 200.
Backpressure server-side: si el tiempo de consulta es> budget, reduzca el 'limit' en la respuesta (y notifique explícitamente al cliente el tamaño real de la página).
Rate limits por usuario/token/tenant.
Timeout/Retry: pausa exponencial, consultas repetidas idempotentes.
10) Aspectos UX
Skroll versus numeración: desplazamiento infinito → cursores; páginas numeradas → offset (pero explique la inexactitud al actualizar los datos).
Botón «volver al lugar»: almacena la pila de cursores del cliente.
Páginas en blanco: si 'has _ more = false', no muestre el botón 'Más'.
Límites estables: muestre el 'total' exacto sólo si es barato (de lo contrario, el 'approx _ total' aproximado).
11) Pruebas y casos de edge
Listas de cheques:- Ordenación estable: los elementos con el mismo 'ts' no «parpadean».
- Inserciones/desinstalaciones: no aparecen repeticiones en la unión de páginas.
- Cambio de filtros entre páginas: el token debe ser rechazado como «obsoleto/incompatible».
- TTL token: error correcto después de la fecha de vencimiento.
- Mayor profundidad: la latencia no crece linealmente.
- Multishard: orden merge correcto, sin starvation de los «lentos» chardos.
python
Generate N entries with random inserts between calls
Verify that all pages are merged = = whole ordered fetch
12) Observabilidad y SLO
Métricas:- 'list _ request _ latency _ ms' (P50/P95/P99) a lo largo de la página.
- 'seek _ index _ hit _ ratio' (porcentaje de solicitudes que han salido en el índice de cobertura).
- 'next _ cursor _ invalid _ rate' (errores de validación/TTL/firma).
- 'merge _ fanout' (cole-in de las chardas involucradas por página).
- 'duplicates _ on _ boundary' y 'gaps _ on _ boundary' (un detalle en telemetría de cliente).
- Correlacionar 'cursor _ id' en los logs, enmascarar el payload.
- Tegire los durmientes: 'page _ size', 'source _ shards', 'db _ index _ used'.
- Disponibilidad: 99. 9% en métodos 'List'.
- Latencia: P95 <200 ms para 'page _ size <= 50' en una bola local.
- Error de señal: <0. 1% del total de llamadas.
13) Migración e interoperabilidad
Incluya la 'v' en el token y mantenga las versiones anteriores de las semanas N.
Cuando cambie las claves de clasificación, envíe el error «suave» '409 Conflict' con una indicación para realizar un anuncio fresco sin cursor.
Caso catastrófico (revuelo de todos los tokens): cambia 'signing _ key _ id' y rechaza los antiguos.
14) Ejemplos de implementaciones
14. 1 Generación de token (pseudocódigo)
python payload = json. dumps({...}). encode()
compressed = zlib. compress(payload)
mac = hmac_sha256(signing_key, compressed)
token = base64url_encode(mac + compressed)
14. 2 Validación de token
python raw = base64url_decode(token)
mac, compressed = raw[:32], raw[32:]
assert mac == hmac_sha256(signing_key, compressed)
payload = json. loads(zlib. decompress(compressed))
assert now() - payload["issued_at"] < payload["ttl_sec"]
assert payload["filters"] == req. filters
14. 3 Consulta Seek con clave compuesta
sql
-- Page # 1
SELECT FROM feed
WHERE tenant_id =:t
ORDER BY ts DESC, id DESC
LIMIT:limit;
-- Next pages, continued after (ts0, id0)
SELECT FROM feed
WHERE tenant_id =:t
AND (ts <:ts0 OR (ts =:ts0 AND id <:id0))
ORDER BY ts DESC, id DESC
LIMIT:limit;
15) Seguridad y cumplimiento
No incluya campos crudos en los tokens de los que pueda derivar PII.
Firme y limite la TTL.
Intenta que los tokens sean intolerantes entre los usuarios (encaja 'sub/tenant/roles' en el payload y verifica al validar).
Lógica sólo los hashes de tokens.
16) Errores frecuentes y anti-patrones
Base64 (id) como cursor: fácil de falsificar/recoger, rompe el contrato al cambiar la clasificación.
Ausencia de tie-breaker: 'ORDER BY ts DESC' sin 'id' → duplicados/saltos.
Cambio de filtros entre páginas sin discapacidad de token.
OFFSET profundo: lento e impredecible.
Tokens sin versión y TTL.
17) Mini checklist de implementación
1. Identifique la clasificación y agregue un tie-breaker único.
2. Cree un índice de cobertura bajo este orden.
3. Seleccione el modelo: seek + token opaco.
4. Implemente la firma (y, si es necesario, el cifrado) del token.
5. Plantar TTL y versionar.
6. Articular y documentar los contratos 'has _ more', 'next _ cursor'.
7. Piense en el esquema cruzado (si es necesario) y k-way merge.
8. Agregue métricas, alertas y SLO.
9. Cubra las pruebas de límite de página con property-based.
10. Describa la estrategia de migración de tokens.
18) Recomendaciones breves sobre la elección de un enfoque
Directorios/búsquedas donde el «número de página» y el total aproximado son importantes: permitamos 'OFFSET/LIMIT' + caché; Informe que el total es aproximado.
Cintas, análisis, listas profundas, RPS alto: sólo cursor/seek.
Colecciones chardeadas/distribuidas: por-shard cursors + merge token.
Flujos/CDC: cursores como offsets/ts con reanudación.
19) Ejemplo de acuerdo API (resumen)
`GET /v1/items? limit=50&cursor=...`
La respuesta siempre incluye 'page. limit`, `page. has_more', opcional 'page. next_cursor`.
El cursor es opaco, firmado, c TTL.
La clasificación es determinista: 'ORDER BY created_at DESC, id DESC'.
Comportamiento en cambios de conjunto: los elementos no «retroceden» con respecto al cursor.
Las métricas y los errores están estandarizados: 'invalid _ cursor', 'expired _ cursor', 'mismatch _ filters'.
Este artículo proporciona principios arquitectónicos y patrones listos para diseñar una paginación que sigue siendo rápida, predecible y segura incluso en entornos de big data, charding y conjuntos de registros que cambian activamente.