Un día, mientras revisaba los costes de AWS de una cuenta de producción, encontré una factura de base de datos que había dejado de comportarse como una factura de base de datos.
Amazon DocumentDB históricamente costaba alrededor de $250 al mes (USD). Entonces el coste comenzó a subir: primero de forma gradual y después hasta superar los $3,000 mensuales.
El coste de las instancias se había mantenido casi igual. El almacenamiento tampoco era el problema. La nueva línea en la factura era el I/O.
Las operaciones de escritura no habían aumentado lo suficiente como para explicarlo. Las lecturas, en cambio, comenzaron a crecer alrededor de febrero y continuaron haciéndolo hasta que la métrica VolumeReadIOPs alcanzó aproximadamente 1.5 millones de operaciones por cada periodo de cinco minutos de CloudWatch en la vista de seis meses.
Mi primera teoría era razonable: quizá se había desplegado un servicio de reportes, un proceso de análisis de datos u otra tarea intensiva en lecturas alrededor de febrero.
Pregunté al equipo y revisé el historial de despliegues y los tickets. No había ninguna carga de trabajo nueva que explicara el cambio. Nada que pudiera señalar y decir: “Ahí está; optimizamos ese servicio y terminamos por hoy”.
La gráfica de costes me decía qué era caro, pero no qué operación lo estaba provocando. Necesitaba evidencia de los planes de ejecución.
Obtener evidencia de operaciones lentas en producción
Habilité el profiler de Amazon DocumentDB con profiler_threshold_ms configurado en 100. El profiler registra en CloudWatch Logs las operaciones que superan el umbral configurado, incluyendo su duración y el resumen del plan de ejecución.
Esta configuración vive en un cluster parameter group personalizado. En este entorno, asociar el nuevo grupo requería reiniciar las instancias de la base de datos. Habilitar una herramienta de diagnóstico se convertía así en un cambio de producción, con las mismas consideraciones de planificación y rollback que cualquier otro mantenimiento de la base de datos.
El rollout fue el siguiente:
- Crear un cluster parameter group personalizado, habilitar el profiler y asociar el grupo al clúster.
- Agregar una réplica y esperar hasta que mostrara el nuevo parameter group como sincronizado.
- Promover esa réplica mediante un failover controlado. Las conexiones existentes sufrieron una interrupción de algunos segundos mientras los clientes se reconectaban.
- Reiniciar o reemplazar las instancias restantes para que todo el clúster utilizara la misma configuración.
Elegí comenzar por la réplica para que una instancia pudiera iniciar con la nueva configuración ya aplicada antes de convertirse en writer. Así, la interrupción quedaba limitada a la promoción y la reconexión de los clientes, en lugar de incluir todo el tiempo necesario para reiniciar la instancia primaria actual.
Para esta aplicación, la breve interrupción era aceptable dentro de la ventana de mantenimiento. Si incluso esa interrupción no hubiera sido aceptable, primero habría diseñado y ensayado una ruta de migración diferente, en lugar de improvisarla en producción.
El umbral del profiler también requiere cuidado. Un valor bajo en un clúster con mucho tráfico puede generar carga y un gran volumen de logs. AWS recomienda comenzar con un umbral más alto y reducirlo gradualmente cuando sea necesario. En este caso, los 100 ms fueron una decisión deliberada, monitorizada y temporal. El profiler debía responder una pregunta concreta, no convertirse en ruido permanente.
Después de recopilar datos durante un día, los logs de operaciones lentas se veían así:
Los nombres, las rutas de los documentos, los identificadores y los timestamps que aparecen a continuación son representativos y están anonimizados.
op: update (upsert)
filter: { "telemetry.device.id": "device-****", "telemetry.observedAt": "<timestamp>" }
millis: 145883
planSummary: COLLSCAN
op: update (upsert)
filter: { "telemetry.device.id": "device-****", "telemetry.observedAt": "<timestamp>" }
millis: 99522
planSummary: COLLSCAN
op: update (upsert)
filter: { "deviceId": "device-****", "observedAt": "<timestamp>" }
millis: 17155
planSummary: COLLSCAN
op: command (count)
filter: { "deviceId": "device-****", "observedAt": { "$gte": "<timestamp>", "$lt": "<timestamp>" } }
millis: 12369
planSummary: COLLSCAN
El upsert más lento tardó casi 146 segundos. Pero el dato más importante era que todos los ejemplos terminaban con el mismo plan: COLLSCAN.
Un upsert es una lectura antes de ser una escritura
upsert es una de mis operaciones favoritas en una base de datos. Resuelve un problema muy práctico en un solo paso: si el documento existe, actualízalo; si no existe, créalo. Esa comodidad no era el problema aquí.
La función de ingesta ejecutaba un upsert por dispositivo cada minuto. Un upsert parece una operación de escritura, pero primero la base de datos debe evaluar el filtro para decidir si actualiza un documento existente o inserta uno nuevo.
Sin un índice que pueda resolver ese filtro, tomar la decisión requiere escanear la colección completa.
El multiplicador operacional era sencillo:
número de dispositivos × ejecuciones por minuto × escaneo completo = mucho I/O
A medida que la colección crecía, cada upsert tenía más documentos que revisar. La aplicación no necesitaba un nuevo servicio de reportes para provocar una explosión de lecturas. La ruta de escritura existente ya estaba generando esas lecturas y el coste de cada escritura aumentaba junto con el tamaño de la colección.
El modelo implícito había sido: “Esta carga principalmente escribe, así que no necesita índices”. El profiler mostró por qué ese modelo era incorrecto. Una escritura con un filtro también necesita una forma eficiente de encontrar su destino.
Indexar el filtro, no el nombre de la operación
Relacioné los filtros recurrentes del profiler con sus query shapes y propuse índices compuestos alineados con los predicados de igualdad y rango. Uno de los índices representativos se veía así:
// telemetry_1m.samples
db.samples.createIndex(
{
deviceId: 1,
observedAt: 1,
},
{
name: "idx_samples_device_observed_at",
background: true,
}
);
Los demás siguieron el mismo método:
| Colección | Forma del predicado | Índice correspondiente |
|---|---|---|
telemetry_30m.samples | igualdad en telemetry.device.id y telemetry.observedAt | { "telemetry.device.id": 1, "telemetry.observedAt": 1 } |
telemetry_1m.samples | igualdad en deviceId + igualdad/rango en observedAt | { deviceId: 1, observedAt: 1 } |
application.latest_samples | igualdad en deviceId | { deviceId: 1 } |
application.change_history | igualdad en accountId y changedField + rango en changedAt | { accountId: 1, changedField: 1, changedAt: 1 } |
Utilicé background builds porque las colecciones estaban activas. Esto evitaba bloquear sus operaciones normales, pero no hacía que el trabajo fuera gratuito: construir un índice consume CPU, almacenamiento local y read I/O. Monitoricé el progreso de la construcción y la capacidad disponible del clúster, y traté el pico temporal como parte del cambio.
Antes del rollout también confirmé que cada índice coincidiera con el filtro real, no con una versión recordada o simplificada. Un campo en telemetry.observedAt no es igual que uno en la raíz del documento, y un índice en la ruta equivocada no habría cambiado nada.
El día en que terminaron los escaneos
Los índices se construyeron alrededor del 28 de julio. Un pico corto en las actualizaciones, el write throughput y el tráfico de red marca el cambio; inmediatamente después, la costosa ruta de lectura colapsa:
El profiler cerró la comprobación: después de que los índices estuvieron disponibles, dejaron de aparecer entradas COLLSCAN lentas para estos query shapes.
La métrica agregada de latencia de lectura pasó de aproximadamente 5 ms hacia cero al mismo tiempo que casi desaparecía el volumen de lecturas medido. No lo interpretaría como lecturas con latencia literalmente igual a cero; principalmente confirma que el trabajo de lectura dominado por escaneos había dejado de ocurrir. La latencia de escritura mejoró de unos 1.7 ms a aproximadamente 1.1 ms.
La utilización de CPU bajó de alrededor del 60% a aproximadamente el 12%. La memoria disponible aumentó, las conexiones a la base de datos disminuyeron y la actividad prolongada del garbage collector prácticamente desapareció: distintas perspectivas del mismo trabajo que la base de datos ya no tenía que hacer.
Los datos de costes siguieron a las métricas técnicas. Antes del cambio, un día representativo costaba alrededor de $82.13: $76.00 de storage I/O y $6.09 por el uso de las instancias. Después, un día completo representativo costaba cerca de $6.26: $0.12 de storage I/O y los mismos $6.09 por las instancias.
Esto representa una reducción superior al 99.8% en el cargo diario de I/O observado y de aproximadamente el 92% en el coste diario total de la base de datos. Esperaría a cerrar un ciclo de facturación completo antes de llamarlo el ahorro mensual definitivo, pero las señales técnicas y de costes se mantuvieron estables durante varios días.
Las funciones de ingesta también dejaron de sufrir timeouts y los servicios en contenedores pasaron menos tiempo esperando las llamadas a la base de datos. El mismo desperdicio aparecía como cargos de I/O, presión de CPU, upserts lentos, reintentos y servicios downstream esperando respuestas; el coste era solo una perspectiva del problema.
Las lecciones que me llevaría son:
- Una carga intensiva en writes no es necesariamente barata en reads:
update,upsertydeletedeben encontrar los documentos antes de modificarlos. - Un ritmo estable de requests puede volverse más caro a medida que crece una colección.
- La factura identifica el recurso; los planes de ejecución conectan ese coste con el comportamiento de la aplicación.
- Tanto el diseño como el rollout de los índices necesitan evidencia de producción: predicados reales, verificación de planes, monitorización de capacidad y validación desde la base de datos hasta sus consumidores.
El incidente comenzó con una factura inesperada y ningún despliegue que la explicara. La causa resultó ser mucho más silenciosa: una ruta de escritura antigua, una colección en crecimiento y ninguna forma eficiente de encontrar el documento que quería actualizar.
Cada escritura había comenzado leyendo la colección completa.
Cuando eso dejó de ocurrir, casi todo lo demás se volvió más barato.