Cloudflare D1
SQL
Performance
Índices
Otimização

Consultas lentas en D1: cómo diagnosticar y optimizar

Sin índices adecuados, las consultas que parecen rápidas en desarrollo con 100 líneas se vuelven lentas en producción con 100.000 y costosas, porque D1 cobra por las líneas leídas, no por las líneas devueltas.

Consultas lentas en D1: cómo diagnosticar y optimizar

SQLite tiene fama de ser una base de datos sencilla que "funciona y listo". Esta reputación se merece en los casos en los que se utiliza como base de datos integrada en aplicaciones móviles y de escritorio, donde el conjunto de datos es pequeño y el planificador de consultas tiene un trabajo fácil. En D1, este contexto cambia: tablas con cientos de miles de filas, múltiples consultas por solicitud de usuario y un modelo de precios que cobra por cada fila que el banco lee durante la ejecución de una consulta, no por cada fila devuelta a la aplicación. Una consulta mal optimizada en producción no sólo es lenta: es costosa y el costo aumenta linealmente con el volumen de datos.

EXPLICAR PLAN DE CONSULTA: diagnóstico antes de la optimización

Antes de crear cualquier índice o reescribir cualquier consulta, ejecute EXPLAIN QUERY PLAN en la consulta problemática. En D1, puede hacer esto de forma remota a través de wrangler d1 execute NOME_DO_BANCO --remote --command "EXPLAIN QUERY PLAN SELECT ...", o localmente con sqlite3 apuntando al archivo .wrangler/state/v3/d1/.

El resultado es una lista de operaciones que realizará el planificador de consultas. "Órdenes de ESCANEO DE TABLA" es la señal de advertencia: significa que la base de datos escaneará todas las filas de la tabla. "BUSCAR pedidos USANDO EL ÍNDICE idx_orders_user_id (user_id=?)" es lo que desea ver: la base de datos usa el índice para encontrar directamente las filas relevantes. "BUSCAR pedidos USANDO EL ÍNDICE idx_orders_user_date (user_id=?AND date>?)" indica que se está aprovechando un índice compuesto tanto para el filtro de igualdad como para el filtro de rango.

El error más común es crear índices después de que aparecen problemas en producción. El PLAN DE EXPLICACIÓN DE CONSULTAS debe ser parte del proceso de desarrollo: ejecutarse en cada consulta que toque tablas con más de unos pocos miles de filas antes de la primera implementación. El costo de un índice innecesario es el espacio de almacenamiento. El coste de una consulta sin índice en producción se mide en dinero.

Los patrones que generan el escaneo de la tabla en D1

Cuatro situaciones recurrentes provocan una exploración completa de la tabla. La primera es la más obvia: ausencia de índice en la columna utilizada en WHERE. SELECT * FROM orders WHERE user_id = ? sin índice en user_id lee todas las filas de la tabla para encontrar las filas del usuario. La corrección es sencilla: CREATE INDEX idx_orders_user_id ON orders(user_id).

La segunda situación es la combinación de WHERE con ORDER BY no cubierta por un índice compuesto. SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC puede usar el índice en user_id para filtrar, pero luego necesita ordenar el resultado en la memoria, una operación llamada ordenar archivos. Un índice compuesto en (user_id, created_at) elimina la clasificación de archivos porque los datos ya están ordenados por created_at dentro de cada user_id.

El tercero es LIKE con un comodín inicial. WHERE title LIKE '%termo%' no puede usar ningún índice: el comodín al comienzo de la cadena evita que la base de datos use el orden del índice para descartar líneas. Para la búsqueda de texto con comodines de dos caras, utilice las tablas virtuales FTS5: CREATE VIRTUAL TABLE posts_fts USING fts5(title, content, content=posts). Las consultas FTS5 con MATCH están indexadas y son escalables.

El cuarto es el tipo de coerción. SQLite usa afinidad de tipos: una columna definida como TEXTO puede almacenar números enteros y una comparación WHERE id = 42 con una columna de TEXTO puede no usar el índice dependiendo de cómo se ingresaron los valores. Mantener la coherencia de tipos entre el esquema, los valores insertados y las consultas es más importante en SQLite que en las bases de datos con tipificación estricta.

N+1 en D1 y el rol de db.batch()

El problema N+1 tiene una dimensión adicional en D1: cada consulta es una subsolicitud y las subsolicitudes tienen un límite de 1000 por invocación de trabajador. Un punto final que busca 50 solicitudes y luego selecciona los elementos de cada solicitud individualmente ejecuta 51 consultas (51 subsolicitudes), a costa de 51 viajes de ida y vuelta a la base de datos, cada uno de los cuales agrega latencia de red al tiempo total de respuesta.

db.batch() resuelve esto agrupando múltiples consultas en una sola subsolicitud. Todas las consultas del lote se ejecutan en un único viaje de ida y vuelta a la base de datos. El resultado es una matriz con un elemento por consulta, en el mismo orden en que fueron enviados. Para el patrón de pedidos y artículos, el lote contiene: la consulta de pedidos y una consulta con IN que cubre todos los ID de pedidos. Dos subsolicitudes en total, independientemente de cuántas solicitudes se devuelvan.

Los ORM que admiten D1, como Drizzle ORM, que tiene integración nativa, tienen opciones de carga activas que crean consultas automáticamente con JOIN o por lotes en lugar de N+1. Sin embargo, el comportamiento predeterminado de la mayoría de los ORM genera N+1 a menos que configure explícitamente la carga inmediata. Verificar el SQL generado con console.log en el entorno de desarrollo antes de pasar a producción es la forma más directa de identificar estos patrones.

El coste real de no tener índice: un ejemplo con números

Un banco D1 con 200 mil pedidos. La consulta SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20 sin índice en status realiza un escaneo completo de la tabla: 200 mil filas leídas, 20 filas devueltas. A 0,001 dólares por millón de lecturas, cada ejecución de esta consulta cuesta 0,0002 dólares.

Con 100.000 ejecuciones diarias de este punto final (común para un panel que se actualiza mediante sondeo o un panel de operaciones) el costo es de $20 por día, $600 por mes, solo para esta consulta. El índice compuesto CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC) cambia completamente el plan: la base de datos lee solo los registros con status = 'pending' usando el índice, ya ordenados por created_at. Suponiendo que hay 5.000 solicitudes pendientes, la consulta lee 5.000 filas, devuelve 20 y cuesta 0,000005 dólares por ejecución. Con carreras de $100,000 por día, el costo se reduce a $0,50 por día, $15 por mes.

El índice en sí ocupa aproximadamente entre 5 y 10 MB para 200.000 filas. A $0,75/GB-mes, este almacenamiento cuesta menos de $0,01 por mes. La diferencia entre $600/mes y $15/mes en costos de lectura, por una fracción de un centavo de inversión en almacenamiento, es el tipo de optimización que nunca debe posponerse hasta "después de que el tráfico crezca", porque cuando el tráfico crece, ya se incurrirá en el costo.

Lea también