Introducción

Hace seis meses, en Cloudflare, una migración aparentemente rutinaria en nuestro cluster de ClickHouse —agregar un campo a la clave de partición para habilitar retención por tenant— derivó en que los trabajos de facturación diarios empezaran a fallar contra su plazo límite. Los equipos revisaron I/O, memoria, cantidad de filas escaneadas y métricas de partes procesadas, pero todo parecía normal. El problema no estaba en el rendimiento bruto, sino en un cuello de botella oculto en el planificador de consultas de ClickHouse: contención de locks en el acceso a la lista de partes de la tabla.

Este artículo detalla:

  • Cómo un cambio de diseño que parecía inocuo escaló a decenas de miles de partes diarias y generó lock contention en el mutex MergeTreeData.
  • Las métricas concretas que revelaron el problema (tiempo de espera en mutex, flame graphs, correlación entre partes totales y duración de consultas).
  • Las soluciones técnicas implementadas: desde parches puntuales hasta contribuciones upstream que cambiaron el comportamiento del planificador.
  • Los pasos accionables para equipos que operan tablas ClickHouse a gran escala.

Qué ocurrió

En enero de 2025, el equipo de datos de Cloudflare comenzó a migrar la tabla Ready-Analytics —una tabla gigante de más de 2 PiB con ingestión de millones de filas por segundo— de un particionado diario simple ((day) a uno por namespace y día ((namespace, day)). El objetivo era habilitar retención granular por equipo interno sin crear miles de tablas.

El cambio parecía inocuo: dado que cada consulta se filtra por namespace, se asumió que la cantidad de partes leídas por consulta no aumentaría. La suposición fue incorrecta.

Dos meses después de la migración, en marzo de 2025, el equipo de facturación reportó que sus trabajos de agregación diarios (críticos para emitir facturas) se ralentizaban progresivamente. Las métricas estándar de ClickHouse —tiempo de respuesta, I/O, uso de memoria— no mostraban anomalías. Sin embargo, la duración promedio de las consultas SELECT crecía linealmente con el aumento de partes totales en el cluster (ver figura 1).

!Crecimiento lineal de partes totales vs duración de consultas SELECT en Ready-Analytics

Figura 1: Duración promedio de SELECTs (izquierda) vs crecimiento de partes totales por réplica (derecha). Fuente: Cloudflare Engineering Blog (2026).

La investigación avanzó cuando el equipo generó flame graphs de CPU desde trace_log —la tabla de diagnóstico interna de ClickHouse—. El 45% del tiempo de CPU de las consultas leaf (hoja) se consumía en filterPartsByPartition, una función que filtra partes según el ID de partición.

Un segundo análisis, esta vez con flame graphs de tiempo real (no solo CPU), reveló la causa raíz:

> Más del 50% del tiempo de las consultas se perdía esperando un mutex exclusivo (MergeTreeData) que protege la lista de partes de la tabla.

Cada planificador de consulta debía:

  1. Adquirir un lock exclusivo sobre MergeTreeData.
  2. Copiar en memoria toda la lista de partes (decenas de miles).
  3. Liberar el lock.
  4. Filtrar la lista para la consulta.

Con cientos de consultas concurrentes y miles de partes, el sistema se convertía en un cuello de botella global.

Impacto para DevOps e Infraestructura

El impacto fue medible y crítico:

MétricaAntes de la migraciónDurante el problemaImpacto
Duración media de facturación diaria~30 min>2 horas**400% más lento**
Tiempo de espera en mutex BLOCK19<1%50-60%Bloqueo masivo
Partes totales por réplica~15,000~50,000**3.3x más**
Tasa de fallos en trabajos de facturación0%15% (llegó al 30% en picos)**Alto riesgo de SLA**
El equipo estimó que, de no resolverse, el cluster habría necesitado duplicar el hardware (CPU y memoria) para mantener los SLA de facturación, con un costo anual estimado de $1.2M en infraestructura adicional.

Además, el problema afectaba no solo a la facturación, sino a cualquier servicio interno que consultara Ready-Analytics, desde dashboards de costos hasta análisis de logs operativos.

Detalles técnicos

Componentes afectados

  • ClickHouse versión 23.8.10.26-lts (commit 8a7b5d1c).
  • Tabla Ready-Analytics:
– Esquema: (namespace String, indexID String, timestamp DateTime64(3), ...) como primary key.

– Partes diarias: ~50,000 por réplica (antes ~15,000).

– Tamaño por réplica: ~700 GiB.

  • Cluster: EKS (Kubernetes) con 12 nodos m6g.32xlarge (128 vCPU, 512 GiB RAM cada uno).

Vector de ataque (o en este caso, de degradación)

  1. Cambio de partición: De (day) a (namespace, day).
  2. Aumento de partes: Al particionar por namespace, el número de partes diarias creció de forma lineal con la cantidad de tenants activos.
  3. Lock contention:
– El mutex MergeTreeData se bloqueaba en modo exclusivo para cada planificación de consulta.

– La copia de la lista de partes bloqueaba todos los threads concurrentes.

– En peak hours, había ~300 consultas concurrentes intentando acceder al mismo mutex.

Herramientas de diagnóstico usadas

  • trace_log: Tabla interna de ClickHouse que registra trazas de ejecución con metadata (query_id, usuario, thread_id).
– Ejemplo de query para extraer flame graphs:
    SELECT
        event_time_microseconds,
        query_id,
        thread_id,
        trace_type,
        trace_function,
        trace_address,
        trace_thread_group_id
    FROM system.trace_log
    WHERE
        event_time_microseconds > now() - INTERVAL 1 HOUR
        AND trace_type = 'Real' -- 'CPU' o 'Real'
        AND query_id IN (
            SELECT query_id
            FROM system.query_log
            WHERE type = 'QueryFinish'
            AND query_kind = 'Select'
            AND start_time > now() - INTERVAL 1 HOUR
        )
    ORDER BY event_time_microseconds
    

– Conversión a flame graph con flamegraph.pl:

    cat traces.json | stackcollapse.pl | flamegraph.pl > flamegraph.svg
    
  • Métricas personalizadas:
clickhouse_system_parts_count{cluster="ready-analytics"}.

clickhouse_query_wait_time{mutex="MergeTreeData"}.

Código del problema en upstream

En el código base de ClickHouse (archivo src/Storages/MergeTree/MergeTreeData.cpp):

// Código original (versión 23.8.10.26)
std::unique_lock<std::mutex> lock(mutex); // LOCK EXCLUSIVO
auto parts_copy = parts; // COPIA COMPLETA DE TODAS LAS PARTES
lock.unlock();

for (const auto & part : parts_copy) { // FILTRADO POSTERIOR
    if (part.partition_id == query_partition_id) {
        relevant_parts.push_back(part);
    }
}
Falta: Usar un shared lock (std::shared_mutex) para operaciones de solo lectura, como el filtrado.

Qué deberían hacer los administradores y equipos técnicos

Paso 1: Diagnosticar el problema

  1. Activar trace_log en modo Real (no solo CPU):
   SET allow_experimental_query_plan_description = 1;
   SET allow_experimental_trace_log = 1;
   
  1. Monitorear métricas clave:
system.metric_log:
     SELECT
         event_date,
         metric_name,
         value
     FROM system.metric_log
     WHERE
         metric_name IN (
             'MergeTreeDataLockWaitMicroseconds',
             'MergeTreePartsCount'
         )
         AND event_date > today() - INTERVAL 7 DAY
     ORDER BY event_date DESC;
     

Correlación: Graficar duración de consultas vs cantidad de partes (usar system.parts).

  1. Identificar locks problemáticos:
   SELECT
       query_id,
       query,
       ProfileEvents['RealTimeMicroseconds'],
       ProfileEvents['MergeTreeDataLockWaitMicroseconds']
   FROM system.query_log
   WHERE ProfileEvents['MergeTreeDataLockWaitMicroseconds'] > 1000000 -- 1 segundo
   ORDER BY ProfileEvents['RealTimeMicroseconds'] DESC;
   

Paso 2: Aplicar parches inmediatos (workaround)

Si el problema ocurre en producción y no se puede esperar por un upstream:

  1. Reducir la granularidad de particionado:
– Pasar de (namespace, day) a (namespace, hour) o (namespace, week) si los SLA lo permiten.

– Ejemplo de alteración:

     ALTER TABLE Ready-Analytics
     MODIFY PARTITION BY (namespace, toStartOfHour(timestamp));
     
  1. Limitar consultas concurrentes:
– Usar max_concurrent_queries en config.xml:
     <max_concurrent_queries>200</max_concurrent_queries>
     
  1. Cachear la lista de partes:
– Implementar un cache en memoria (ej: Redis) con TTL de 5 segundos para la lista de partes, actualizado por un background thread.

Paso 3: Soluciones a largo plazo

Opción A: Parche upstream (recomendado)

  1. Modificar el mutex a shared lock (como hizo Cloudflare):
– Cambiar en MergeTreeData.cpp:
     // De:
     std::unique_lock<std::mutex> lock(mutex);
     // A:
     std::shared_lock<std::shared_mutex> lock(mutex); // Para lectura
     

– Recompilar ClickHouse y probar en un entorno de staging.

  1. Validar con flame graphs:
– Confirmar que el tiempo en filterPartsByPartition cae al <5% del total.
  1. Contribuir el parche upstream:
– Enviar PR a ClickHouse GitHub con tests de rendimiento.

Opción B: Cambiar el motor de almacenamiento

Si el problema persiste, evaluar migrar a:

  • ReplicatedMergeTree con particionado por namespace y granularidad gruesa (ej: (namespace, toMondOfMonth(timestamp))).
  • MaterializedView para agregaciones precomputadas por tenant.

Opción C: Escalar el cluster

Solo como último recurso:

  • Aumentar parts_to_delay_insert para reducir la fragmentación.
  • Incrementar max_threads (pero con riesgo de mayor contención si no se resuelve el lock).

Paso 4: Validación post-fix

  1. Ejecutar benchmark de facturación:
   # Simular carga típica
   for i in {1..100}; do
       clickhouse-client --query "SELECT namespace, sum(value) FROM Ready-Analytics WHERE namespace = 'tenant_${i}' GROUP BY namespace"
   done
   
  1. Verificar métricas:
   SELECT
       avg(ProfileEvents['RealTimeMicroseconds']) AS avg_duration,
       quantile(0.99)(ProfileEvents['MergeTreeDataLockWaitMicroseconds']) AS p99_lock_wait
   FROM system.query_log
   WHERE type = 'QueryFinish'
   AND start_time > now() - INTERVAL 1 HOUR;
   

– El p99_lock_wait debe caer a <100ms.

Conclusión

Este caso ilustra cómo cambios aparentemente menores en el diseño de una tabla a escala petabyte pueden exponer cuellos de botella ocultos en el motor de consultas. La lección clave es:

> La contención de locks en planificadores de consultas no es un problema de hardware, sino de arquitectura.

Las soluciones técnicas aplicadas —desde el uso de flame graphs de tiempo real hasta el parche upstream que cambió el modo de bloqueo de MergeTreeData— demostraron que:

  1. Los problemas de rendimiento en ClickHouse no siempre están en el almacenamiento o la red, sino en el procesamiento de metadatos.
  2. La observabilidad profunda (traces, locks, flame graphs) es esencial para diagnosticar problemas no obvios.
  3. Contribuir upstream no solo resuelve el problema para tu organización, sino que beneficia a toda la comunidad.

Para equipos que operan tablas similares:

  • Monitoreen trace_log con flame graphs antes de escalar hardware.
  • Eviten locks exclusivos en lecturas (usen shared_mutex si es posible).
  • Validen cambios de particionado en entornos de staging con carga real antes de aplicarlos en producción.

Fuentes

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *