Saltar al contenido
Volver

Cuando cada 'write' empieza leyendo la colección completa

Cómo la falta de índices convirtió upserts rutinarios de DocumentDB en escaneos completos, elevó el coste de I/O por encima de $3,000 al mes y ralentizó cada servicio que usaba la base de datos.

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.

Gráfica del coste mensual de DocumentDB donde el coste de I/O aumenta rápidamente desde febrero mientras el coste de las instancias permanece estable
El coste de las instancias se mantuvo estable mientras el I/O de almacenamiento se convertía en la mayor parte de la factura. Los identificadores de la cuenta y de los recursos fueron eliminados.

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.

Dashboard de throughput de DocumentDB durante seis meses donde las operaciones y el throughput de lectura aumentan rápidamente mientras la actividad de escritura se mantiene relativamente estable
Las lecturas se aceleraban mientras el volumen de escrituras se mantenía relativamente estable.

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:

  1. Crear un cluster parameter group personalizado, habilitar el profiler y asociar el grupo al clúster.
  2. Agregar una réplica y esperar hasta que mostrara el nuevo parameter group como sincronizado.
  3. Promover esa réplica mediante un failover controlado. Las conexiones existentes sufrieron una interrupción de algunos segundos mientras los clientes se reconectaban.
  4. 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ónForma del predicadoÍndice correspondiente
telemetry_30m.samplesigualdad en telemetry.device.id y telemetry.observedAt{ "telemetry.device.id": 1, "telemetry.observedAt": 1 }
telemetry_1m.samplesigualdad en deviceId + igualdad/rango en observedAt{ deviceId: 1, observedAt: 1 }
application.latest_samplesigualdad en deviceId{ deviceId: 1 }
application.change_historyigualdad 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:

Dashboard de throughput de DocumentDB donde las operaciones y el throughput de lectura caen casi a cero alrededor del 28 de julio
El conteo de VolumeReadIOPs pasó de aproximadamente 1.5 millones por cada periodo de cinco minutos de CloudWatch a casi cero después de crear los índices.

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.

Dashboard de latencia de DocumentDB donde la métrica agregada de latencia de lectura se acerca a cero al desaparecer el volumen de lecturas y la latencia de escritura mejora después del 28 de julio
Eliminar los escaneos también ayudó a las escrituras, porque los upserts dejaron de esperar una búsqueda en toda la colección.

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.

Métricas de sistema de DocumentDB donde la utilización de CPU y las conexiones a la base de datos disminuyen después de crear los índices
El clúster recuperó una cantidad considerable de capacidad cuando los escaneos salieron de la ruta crítica.

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.

Coste diario de DocumentDB antes del cambio de índices, con un total aproximado de 82 dólares y 76 dólares correspondientes al I/O de almacenamiento Coste diario de DocumentDB después del cambio de índices, con un total aproximado de 6 dólares y 12 centavos correspondientes al I/O de almacenamiento
Un día completo representativo antes y después del cambio. Los identificadores de los recursos fueron eliminados.

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:

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.


Newsletter

Recibe los próximos posts en tu correo.

Notas ocasionales sobre DevOps, plataformas, confiabilidad y el trabajo real detrás de producción. Sin ruido, solo cuando haya algo útil que compartir.

Puedes darte de baja cuando quieras. No compartiré tu correo.



Post anterior
Privado por defecto, accesible por diseño