Problema y alcance
Un servicio de pedidos multi-tenant utiliza PostgreSQL. Su tabla orders contiene 200 millones de filas y recibe inserciones y cambios de estado continuos. Una consola de operaciones filtra los pedidos por tenant y estado, siempre ordena por created_at DESC, id DESC y devuelve como máximo 50 filas por página. Tanto created_at como id son no nulos e inmutables tras su creación; id es único. El producto requiere navegación a la página siguiente y a la página anterior, pero no requiere un salto directo a la página 500.
Diseñe la API de paginación, el payload del cursor, las consultas y el índice. El diseño debe manejar marcas de tiempo de creación idénticas, paginación profunda, inserciones y eliminaciones entre solicitudes, cambios en la pertenencia por estado, alteración del cursor y navegación en ambas direcciones. También debe distinguir entre un recorrido en vivo y un snapshot fijo.
Los 200 millones de filas y el tamaño de página de 50 filas son supuestos de entrevista. La habilidad fundamental es traducir la semántica de la API en una ruta de acceso a la base de datos, un límite de concurrencia y un plan de verificación, por lo que esta es una pregunta de backend. La replicación entre regiones, el sharding y el estado de la lista en el lado del cliente quedan fuera del alcance de la primera ronda.
Qué evalúa el entrevistador
La primera señal es si el candidato define la consistencia antes de elegir un algoritmo. “Sin duplicados ni omisiones” necesita un objeto: ¿significa evitar los desplazamientos causados por la paginación por offset, o toda la sesión de navegación debe comportarse como un único snapshot de base de datos? Un cursor normal puede reanudarse después de un límite de ordenamiento; no congela eliminaciones, cambios de estado u otros campos de filtrado.
La segunda señal es un orden total. Si la consulta ordena únicamente por created_at, varios pedidos pueden empatar y la base de datos puede organizarlos de forma arbitraria. Un id único como segunda clave de ordenamiento, con ambos valores en el cursor, identifica con precisión la posición posterior a la última fila de la página anterior.
La tercera señal es si la consulta, el índice y el cursor implementan el mismo orden. tenant_id y status son filtros de igualdad. created_at y id forman el límite de rango y el orden de clasificación. Por lo tanto, un índice B-tree candidato es (tenant_id, status, created_at DESC, id DESC). Reemplazar la palabra “offset” por “cursor” es incompleto sin el predicado de comparación, el índice y la consulta inversa.
Finalmente, el entrevistador debería escuchar un contrato concreto y un plan de pruebas. Una respuesta sólida vincula los filtros y el alcance de autorización en un cursor firmado, limita el tamaño de página y prueba inserciones concurrentes, la eliminación de la fila de límite, marcas de tiempo empatadas y recorridos de ida y vuelta hacia adelante y hacia atrás. También mantiene la paginación por offset como una opción válida para conjuntos de datos pequeños y estables que requieren saltos a números de página, en lugar de tratar una sola técnica como universalmente correcta.
Preguntas para clarificar
- ¿Es esta una lista en vivo o un snapshot fijo? Una lista en vivo puede reflejar algunos cambios en páginas posteriores. Un snapshot fijo requiere que el recorrido observe un solo conjunto, generalmente a través de datos versionados, un resultado materializado o un mecanismo de snapshot que persista entre solicitudes. Los costos y las políticas de expiración difieren.
- ¿Los usuarios deben saltar a páginas arbitrarias? La paginación por keyset es adecuada para la navegación secuencial siguiente y anterior, pero “página 500” por sí sola no revela un límite de clave. Si los saltos de página son obligatorios, conserve el offset para una profundidad limitada o precalcule anclajes.
- ¿Pueden cambiar los campos de ordenamiento o filtrado?
created_atyiddeben permanecer estables. Ordenar por unupdated_atmutable, precio o puntuación permite que un registro cruce un límite ya visitado. Los cambios de estado también alteran la pertenencia al resultado y deben formar parte del contrato público. - ¿El producto necesita un total exacto y una última página? Obtener una fila adicional puede determinar
hasNextPage; untotalCountexacto es un costo de agregación independiente. Una interfaz de carga continua no debería forzar un conteo completo en cada página. - ¿Cuánto tiempo puede permanecer válido un cursor? Los cambios de esquema, los cambios en las reglas de filtrado, la rotación de claves de firma y la retención de snapshots afectan la validez. El servidor necesita un error de expiración y una forma explícita de reiniciar desde la primera página.
- ¿Pueden cambiar el acceso o los permisos del tenant mientras se pagina? Un cursor nunca reemplaza la autenticación y autorización para la solicitud actual. Cada página vuelve a verificar el principal actual, y el cursor está vinculado a filtros normalizados y al alcance para que no pueda reutilizarse entre tenants.
Respuesta en 30 segundos
“Primero confirmaría que se trata de un recorrido ordenado en vivo y no de un snapshot de base de datos entre solicitudes. Usaría created_at DESC, id DESC inmutable, con el ID único desempatando las marcas de tiempo. Un cursor de página siguiente transporta los dos últimos valores de la página anterior, una versión y una huella digital de los filtros, utilizando luego codificación segura para URL y una firma HMAC. La consulta aplica (created_at, id) < (cursor_time, cursor_id), el mismo orden descendente y LIMIT 51; devuelve 50 filas y usa la fila adicional para hasNextPage. El índice correspondiente es (tenant_id, status, created_at DESC, id DESC). Para la página anterior, invierto la comparación y el orden de la base de datos, y luego invierto la respuesta. Las inserciones más recientes no desplazan las páginas posteriores, pero las eliminaciones y los cambios de estado no se congelan. Los snapshots estrictos necesitan datos versionados o una sesión materializada. Probaría marcas de tiempo empatadas, escrituras concurrentes, eliminación de límites, alteración del cursor y planes de páginas profundas”.
Solución paso a paso
Comience definiendo el contrato de la API. La primera página no tiene cursor. La navegación hacia adelante acepta after; la navegación hacia atrás acepta before; una solicitud no puede contener ambos. El tamaño de página predeterminado es 50, con un rango impuesto por el servidor de 1 a 100. Una respuesta puede tener esta estructura:
{
"data": [],
"pageInfo": {
"startCursor": "...",
"endCursor": "...",
"hasNextPage": true,
"hasPreviousPage": false
}
}startCursor y endCursor provienen de la primera y última fila devueltas. Solicite 51 filas y devuelva solo 50 para determinar hasNextPage. Si el indicador de dirección opuesta debe ser exacto, ejecute una pequeña consulta indexada EXISTS desde el otro límite. Inferirlo únicamente a partir de la presencia de un parámetro after puede ser incorrecto después de que se hayan eliminado todas las filas anteriores.
Reemplazar los offsets posicionales por un límite compuesto
La consulta para la primera página es:
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
AND status = $2
ORDER BY created_at DESC, id DESC
LIMIT 51;Para la página siguiente, coloque el par final (created_at, id) de la página anterior en un predicado de estricto menor que:
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
AND status = $2
AND (created_at, id) < ($3, $4)
ORDER BY created_at DESC, id DESC
LIMIT 51;PostgreSQL compara constructores de filas de izquierda a derecha y se detiene en el primer par desigual. Eso coincide con el límite lexicográfico de las dos claves de ordenamiento descendente. Definir ambas columnas como no nulas evita el resultado desconocido que una comparación de filas puede producir cuando llega a NULL. El id no tiene que codificar tiempo; solo tiene que proporcionar un desempate único y estable dentro de un mismo valor de created_at.
El índice correspondiente es:
CREATE INDEX CONCURRENTLY orders_tenant_status_created_id_idx
ON orders (tenant_id, status, created_at DESC, id DESC);Las columnas de igualdad iniciales restringen el escaneo a un solo tenant y estado. Las dos columnas finales proporcionan el límite y el orden. Agregar o no campos de respuesta como columnas incluidas depende del ancho medido de la fila, las búsquedas en heap y la amplificación de escritura; copiar cada campo de respuesta en INCLUDE no es una opción predeterminada. Los cambios frecuentes de estado también actualizan este índice, por lo que debe medir la latencia de escritura, el volumen de WAL y el tamaño del índice.
Hacer que el cursor sea evolutivo y resistente a manipulaciones
El cursor es un protocolo opaco, no una solicitud para que el cliente ensamble un valor created_at. Su payload lógico podría ser:
{
"v": 1,
"createdAt": "2026-07-16T02:30:00.123Z",
"id": "ord_01J...",
"filterHash": "sha256:...",
"expiresAt": "2026-07-17T02:30:00Z"
}El servidor aplica codificación segura para URL a un payload canónico y adjunta un HMAC. Base64 es codificación, no confidencialidad. Si los valores del límite son confidenciales, no los exponga en el token del cliente; almacénelos en el servidor bajo un ID de cursor aleatorio en su lugar. filterHash debe cubrir al menos la versión del ordenamiento, el filtro de estado y un identificador del alcance de autorización. Cada solicitud se autoriza contra el principal actual antes de que el servidor verifique la firma, la versión, la expiración y la huella digital de los filtros. Cualquier discrepancia devuelve una respuesta invalid_cursor estable y requiere reiniciar desde la primera página; no debe aplicar silenciosamente el cursor a filtros nuevos.
El campo de versión permite futuros cambios de ordenamiento o codificación. Durante la rotación de claves, el verificador puede aceptar tanto la clave de verificación actual como la anterior durante el tiempo de vida máximo del cursor, mientras firma los nuevos cursores únicamente con la clave actual. Los logs registran la categoría de error y la versión del cursor, no el cursor completo ni los filtros en texto plano.
Implementar la página anterior de forma simétrica
La primera fila de la página actual es el límite para navegar hacia registros más recientes. Cambie el predicado a > y consulte en orden ascendente para que la base de datos devuelva primero las 50 filas más cercanas a ese límite:
SELECT id, created_at, total_cents, status
FROM orders
WHERE tenant_id = $1
AND status = $2
AND (created_at, id) > ($3, $4)
ORDER BY created_at ASC, id ASC
LIMIT 51;Después de eliminar la fila de sondeo número 51, el servidor invierte el resultado y devuelve el orden descendente público. Mantener el orden descendente con LIMIT seleccionaría las primeras 50 filas de todo el conjunto de resultados, no la página inmediatamente anterior al límite. Ambas direcciones deben compartir la misma huella digital de filtros y versión de ordenamiento.
Declarar las garantías bajo cambios concurrentes
Bajo la semántica en vivo, el cursor significa “continuar con las filas estrictamente por debajo de este límite inmutable”. Si llegan 100 pedidos más recientes después de que el usuario lee la página uno, la página dos aún se reanuda desde el límite anterior. Las nuevas filas no empujan la fila final de la página uno a la página dos. Eliminar la fila del límite después tampoco interrumpe el recorrido porque el cursor conserva los valores; la consulta no necesita encontrar esa fila nuevamente.
Esto no es un snapshot fijo. Un pedido no leído que cambia de otro estado al estado seleccionado puede aparecer más adelante si su posición de ordenamiento está por debajo del límite actual. Un pedido que sale del conjunto de filtros no aparecerá. Las eliminaciones pueden reducir las filas observadas por el recorrido. Colocar la “hora de inicio de la primera página” en el cursor solo limita las inserciones más recientes; no congela eliminaciones ni cambios de estado.
Si una exportación, conciliación o auditoría debe contener a todos los miembros de un conjunto fijo, una opción es un resultado materializado de corta duración que almacene los ID de los pedidos coincidentes y su orden. Otra es una lectura sobre datos versionados con soporte de viaje en el tiempo (time-travel). Un trabajo en segundo plano controlado también puede retener un snapshot de base de datos. Cada opción agrega costo de almacenamiento, tiempo de vida de la transacción o limpieza, y necesita una expiración de sesión. El recorrido por keyset en vivo generalmente se adapta a una lista interactiva; las cargas de trabajo de auditoría ameritan un flujo de trabajo de snapshot independiente.
Mantener la paginación por offset dentro de su límite útil
La paginación por offset es simple, admite saltos directos a páginas y se mapea naturalmente a números de página. Funciona para tablas administrativas pequeñas y estables o para un resultado de búsqueda previamente congelado. Sus costos son dobles: PostgreSQL aún debe calcular y descartar las filas anteriores a OFFSET, por lo que offsets más profundos generalmente requieren más trabajo; las inserciones y eliminaciones concurrentes también cambian los números de posición, lo que puede repetir u omitir una fila.
La paginación por keyset ancla el trabajo a un límite de valor indexado, por lo que una página profunda no escanea ni descarta cada fila precedente simplemente porque el número de offset sea grande. No puede saltar a una página arbitraria sin un ancla. La regla de decisión es directa: use keyset para recorridos secuenciales profundos sobre datos cambiantes; use offset para navegación por número de página acotada sobre datos pequeños o congelados; agregue un mecanismo de snapshot, independientemente de cualquiera de los dos estilos de paginación, cuando todo el conjunto deba permanecer fijo.
Demostrar el contrato con casos adversarios
Verifique la pertenencia y el orden antes de medir la latencia:
- Inserte 120 pedidos con el mismo
created_at. Confirme que elidúnico evita duplicados entre páginas y que el recorrido combinado es estrictamente descendente. - Lea la página uno, luego inserte 100 pedidos más nuevos. Reproduzca el desplazamiento posicional con offset y confirme que la página dos de keyset no repite una fila de la página uno.
- Elimine la última fila de la página uno antes de continuar y confirme que el límite almacenado aún devuelve la página siguiente correcta.
- Cambie el
statusde un pedido no leído entre solicitudes y confirme que el resultado sigue la semántica en vivo documentada en lugar de reportarse como un snapshot. - Modifique un byte del cursor, reutilícelo entre tenants, cambie el filtro de estado y envíe una versión expirada. Cada una de estas solicitudes debe ser rechazada.
- Avance tres páginas y retroceda tres páginas, verificando los ID y el orden. Incluya tamaños de resultado de 0, 1, 50 y 51.
- Ejecute
EXPLAIN (ANALYZE, BUFFERS)para la primera página, la segunda página y un límite profundo. Confirme el índice compuesto esperado, un recuento de filas escaneadas cercano al tamaño de página y registre el p95 de lectura, el p95 de escritura, el volumen de WAL y el tamaño del índice.
Ejemplo de una respuesta sólida
“Primero delimitaría la garantía. Esta lista de operaciones necesita navegación secuencial en vivo, sin saltos directos de página y sin un snapshot de base de datos a lo largo de varias solicitudes HTTP. Por lo tanto, usaría paginación por keyset en lugar de offset.
El orden es created_at DESC, id DESC inmutable. El ID es el desempate único; sin él, los pedidos creados en el mismo milisegundo no tienen un orden estable. Obtengo 51 filas y devuelvo 50. El cursor siguiente almacena la marca de tiempo y el ID de la última fila, y la siguiente consulta usa (created_at, id) < (?, ?) con exactamente el mismo orden. Los filtros de igualdad van primero en (tenant_id, status, created_at DESC, id DESC). Para la página anterior, la primera fila actual es el límite; uso >, consulto en orden ascendente las 51 filas más cercanas y luego invierto la respuesta.
El cursor contiene una versión, los dos valores de límite, una huella digital de filtros y una expiración. Está codificado en modo seguro para URL y firmado con HMAC. El servidor autoriza cada página nuevamente y rechaza cualquier cursor alterado, expirado o con discrepancia de filtros. No depende de que la fila de límite siga existiendo, por lo que eliminar esa fila no detiene la navegación.
En cuanto a la consistencia, me comprometo a evitar duplicados causados por desplazamientos de offset. Los pedidos más nuevos insertados después de la página uno no ingresan a páginas posteriores, pero los cambios de estado y las eliminaciones aún pueden alterar el conjunto no leído. Si la conciliación necesita un snapshot fijo, crearía una sesión de exportación materializada o leería datos versionados; agregar una marca de tiempo a un cursor ordinario no constituye un snapshot completo.
Finalmente, probaría marcas de tiempo empatadas, inserciones concurrentes, eliminación de límites, cambios de estado, alteración del cursor y recorridos de ida y vuelta hacia adelante y hacia atrás. Luego compararía los planes para la página uno, la página dos y un límite profundo, además del efecto del nuevo índice en la latencia de escritura y WAL”.
Errores comunes
- Codificar en Base64 un número de página → El servidor sigue ejecutando
OFFSET, por lo que ni el rendimiento ni la desviación posicional cambian → Haga que el cursor represente un límite de ordenamiento indexado. - Usar únicamente
created_at→ Las marcas de tiempo empatadas no forman un orden total y pueden repetirse u omitirse → Agregue unidúnico y estable tanto a la consulta como al cursor. - Usar un predicado que no coincide con el orden de clasificación → El límite ya no representa el orden público, creando superposiciones o huecos → Derive
ORDER BY, los operadores de comparación y el índice a partir de un único orden lexicográfico. - Tratar Base64 como protección contra alteraciones → Un cliente puede alterar la marca de tiempo, el ID o el alcance del filtro → Use un HMAC para la integridad y vuelva a ejecutar la autorización actual.
- Dejar los filtros fuera de la vinculación del cursor → Reutilizar el cursor de un pedido pendiente para una consulta de pedidos pagados genera vacíos inexplicables → Incluya los filtros normalizados, la versión del ordenamiento y el alcance en la huella digital firmada.
- Mantener el orden descendente para un LIMIT de página anterior → La consulta devuelve el inicio de todo el conjunto en lugar del segmento contiguo al límite → Invierta el orden de la base de datos, recorte e invierta la respuesta.
- Afirmar que keyset es un snapshot completo → Las eliminaciones, los cambios de estado y las claves de ordenamiento mutables aún alteran la pertenencia → Publique una semántica en vivo; agregue snapshots versionados o materializados cuando se requiera consistencia estricta.
- Contar el resultado completo en cada página → La paginación se vuelve más rápida mientras que
totalCountse convierte en la nueva ruta lenta → Trate el conteo como una funcionalidad de producto independiente, almacenable en caché o aproximada. - Probar solo la primera página → El límite compuesto se ejercita por primera vez en la página dos, donde puede surgir un desajuste del índice → Inspeccione los planes de ejecución para las dos primeras páginas, páginas profundas y en ambas direcciones.
Preguntas de seguimiento
¿Qué sucede si el producto debe ordenar por un total_cents mutable?
El cursor compuesto puede convertirse en (total_cents, id), pero eso solo desempata. No evita que un monto editado mueva una fila a través de un límite ya visitado. Si el reordenamiento en vivo es aceptable, documente posibles duplicados u omisiones y deduplique por ID en el cliente. Si la estabilidad es obligatoria, congele el valor de ordenamiento para la sesión de navegación, materialice la lista de ID o haga que las ediciones creen nuevas versiones. La garantía de clave inmutable ya no aplica.
¿Qué pasa si los usuarios deben saltar directamente a la página 500?
La paginación por keyset no conoce el límite de la página 500. Primero pregunte si la necesidad real es una fecha, un número de pedido o una etiqueta de página; las fechas y los números de pedido pueden convertirse en anclas de búsqueda indexadas. Si las páginas numéricas son obligatorias, límite la profundidad del salto y use offset dentro de ese rango, o guarde anclas de página periódicas y aplique keyset desde la más cercana. Las anclas también envejecen bajo datos en vivo, por lo que necesitan una versión o un identificador de snapshot.
¿Puede esta API exportar los 200 millones de filas completos?
El cursor de corta duración y la semántica en vivo de una API interactiva no son adecuados para una exportación de auditoría prolongada. Cree un trabajo de exportación en segundo plano, lea un snapshot fijo o límite de versión en fragmentos de una clave primaria inmutable, escriba en almacenamiento de objetos y persista puntos de control y recuentos de validación. Los reintentos, la expiración, los límites de recursos y las comprobaciones de finalización tendrán así su propio ciclo de vida en lugar de vincular indefinidamente un cursor HTTP al índice en línea y al formato de firma.
¿Cómo rotar las claves de firma durante la paginación?
Incluya una versión o un identificador de clave en el cursor. Durante el tiempo de vida máximo del cursor, el verificador conserva tanto la clave de verificación actual como la anterior, mientras que los nuevos cursores se firman únicamente con la clave actual. Elimine la clave antigua después de ese período. Si un incidente de seguridad requiere una revocación inmediata, devuelva invalid_cursor de forma consistente y reinicie al cliente en la página uno en lugar de aceptar una clave comprometida para una navegación transparente.
¿Por qué funciona eliminar la fila del límite mientras que cambiar su clave de ordenamiento no?
La siguiente consulta solo necesita los dos valores almacenados en el cursor; nunca tiene que volver a encontrar la fila del límite, por lo que la eliminación no altera el predicado de estricto menor que. Modificar una clave de ordenamiento traslada el mismo registro de un lado del límite al otro. Puede volver a entrar en un rango futuro o saltar de un rango no leído a uno ya visitado. Los valores de ordenamiento estables son un requisito de corrección; la existencia continua de la fila del límite no lo es.