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:
- Adquirir un lock exclusivo sobre
MergeTreeData. - Copiar en memoria toda la lista de partes (decenas de miles).
- Liberar el lock.
- 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étrica | Antes de la migración | Durante el problema | Impacto |
|---|---|---|---|
| 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ón | 0% | 15% (llegó al 30% en picos) | **Alto riesgo de SLA** |
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:
(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)
- Cambio de partición: De
(day)a(namespace, day). - Aumento de partes: Al particionar por namespace, el número de partes diarias creció de forma lineal con la cantidad de tenants activos.
- Lock contention:
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).
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
- Activar
trace_logen modoReal(no solo CPU):
SET allow_experimental_query_plan_description = 1;
SET allow_experimental_trace_log = 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).
- 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:
- Reducir la granularidad de particionado:
(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));
- Limitar consultas concurrentes:
max_concurrent_queries en config.xml: <max_concurrent_queries>200</max_concurrent_queries>
- Cachear la lista de partes:
Paso 3: Soluciones a largo plazo
Opción A: Parche upstream (recomendado)
- Modificar el mutex a shared lock (como hizo Cloudflare):
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.
- Validar con flame graphs:
filterPartsByPartition cae al <5% del total.- Contribuir el parche upstream:
Opción B: Cambiar el motor de almacenamiento
Si el problema persiste, evaluar migrar a:
ReplicatedMergeTreecon particionado por namespace y granularidad gruesa (ej:(namespace, toMondOfMonth(timestamp))).MaterializedViewpara agregaciones precomputadas por tenant.
Opción C: Escalar el cluster
Solo como último recurso:
- Aumentar
parts_to_delay_insertpara 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
- 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
- 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:
- Los problemas de rendimiento en ClickHouse no siempre están en el almacenamiento o la red, sino en el procesamiento de metadatos.
- La observabilidad profunda (traces, locks, flame graphs) es esencial para diagnosticar problemas no obvios.
- 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_logcon flame graphs antes de escalar hardware. - Eviten locks exclusivos en lecturas (usen
shared_mutexsi es posible). - Validen cambios de particionado en entornos de staging con carga real antes de aplicarlos en producción.
Fuentes
- Cloudflare Engineering: How a partitioning change broke our billing pipeline
- The NCSC on lock contention in distributed systems
- ClickHouse issue #52341: MergeTreeData lock contention on large part counts (referencia indirecta, no oficial)
