El Problema: Un Cambio de Partición Rompió Nuestro Pipeline de Facturación
En Cloudflare, usamos ClickHouse para procesar datos de uso que alimentan la facturación, detección de fraude y más. Nuestro sistema interno, Ready-Analytics, almacena más de cien petabytes en docenas de clusters. En 2022, creamos un modelo simplificado: los equipos envían datos a una única tabla gigante, diferenciada por un campo namespace. La clave primaria es (namespace, indexID, timestamp). Funcionó de maravilla—hasta que intentamos resolver una limitación crítica.
El Cuello de Botella de la Retención
El sistema original tenía una política única de retención de 31 días para todos los namespaces. Algunos equipos necesitaban años de datos (cumplimiento legal), otros solo unos días. Esto forzaba a muchos equipos a evitar Ready-Analytics y usar una configuración manual compleja. Necesitábamos retención por namespace.
La “Solución Obvia”
Cambiamos la clave de partición de solo day a (namespace, day). Esto permitía eliminar particiones por namespace. Nuestro razonamiento: como toda consulta filtra por namespace, el número de partes leídas por consulta no cambiaría, por lo que el rendimiento no se vería afectado. Estábamos equivocados.
La Lentitud: Un Misterio se Despliega
En marzo de 2025, el equipo de facturación reportó que los trabajos de agregación diaria se estaban volviendo progresivamente más lentos. I/O, memoria, filas escaneadas—todo normal. Sin embargo, las consultas tardaban más. Graficamos la duración de la consulta contra el número total de partes por réplica y vimos una clara correlación lineal. ¿Pero por qué? Si no estábamos leyendo más partes, ¿por qué su existencia nos ralentizaba?
La Investigación: Los Flame Graphs Revelan la Verdad
Usamos el trace_log nativo de ClickHouse para generar flame graphs. El primer gráfico (CPU) mostró un 45% del tiempo gastado en filterPartsByPartition. Una pequeña reordenación de heurísticas dio una mejora del 5%—íbamos por buen camino, pero nos faltaba el problema real.
Luego cambiamos a traces Real (muestreando todos los hilos, incluidos los que están esperando). La revelación: más de la mitad de la duración de la consulta se gastaba esperando por un solo mutex (MergeTreeData) que protege la lista de partes activas. Cada hilo tenía que:
- Adquirir un lock exclusivo.
- Copiar la lista entera de partes.
- Liberar el lock.
- Filtrar la copia.
Con decenas de miles de partes y cientos de consultas concurrentes, todas estaban haciendo fila única.
Las Correcciones: Tres Parches
Contribuimos las tres optimizaciones al upstream de ClickHouse (PR #85535, disponible desde la versión 25.11).
Optimización 1: Shared Lock (std::shared_lock)
El planificador de consultas solo lee la lista de partes—nunca la modifica. Usar un lock exclusivo era excesivo. Cambiamos a un shared lock, permitiendo que múltiples planificadores entraran en la sección crítica simultáneamente. Resultado: la contención de lock desapareció, las duraciones de consulta cayeron de inmediato.
Optimización 2: Copia Diferida del Vector
Incluso con el shared lock, el siguiente cuello de botella era copiar el vector gigante de partes. Copiar un vector con decenas de miles de elementos cientos de veces por segundo suma. Creamos una instantánea compartida de solo lectura; solo las operaciones que modifican la lista de partes regeneran el caché. Resultado: otra ganancia significativa de rendimiento.
Optimización 3: Búsqueda Binaria para Filtrado de Partes
Meses después, con el conteo de partes creciendo (de 30k a 160k por réplica), el rendimiento degradó de nuevo—pero más lentamente. El código de filtrado aún hacía un barrido lineal. Como la lista de partes está ordenada por la clave de partición (namespace primero), implementamos una búsqueda binaria en el namespace. Resultado: la duración de las consultas cayó un 50%, y la correlación con el número de partes finalmente se rompió.
Limitaciones y Precauciones
- La búsqueda binaria no generaliza para condiciones de consulta arbitrarias (ej.:
namespace IN (5,10)). Estamos explorando enfoques más genéricos, como extender el caché de condiciones de consulta. - La sobrecarga de ZooKeeper también creció con el número de partes—nuestro clúster ZooKeeper llegó a 100 GB. Ese es un desafío aparte.
- Pregunta de arquitectura a largo plazo: ¿Fue esta estrategia de particionamiento la elección correcta? Ganamos tiempo, pero quizás una arquitectura diferente (ej.: tabla por namespace) sea necesaria eventualmente.
Próximos Pasos para Aprender
- Lee el PR upstream #85535 para detalles de implementación.
- Explora el
trace_logde ClickHouse y la generación de flame graphs—es una herramienta de debugging poderosa. - Estudia patrones de contención de lock en bases de datos OLAP; el mismo patrón puede aparecer en PostgreSQL, MySQL, etc.
- Para más sobre estrategias de particionamiento en ClickHouse, checa nuestro artículo relacionado Cómo la Protección Avanzada de Navegación Verifica URLs Sin Comprometer la Privacidad.
Conclusión
Este fue un caso clásico de un cambio bien intencionado chocando con un cuello de botella oculto y no obvio. La lección principal: nunca asumas que métricas de consulta sin cambios significan rendimiento del sistema sin cambios. La contención de lock y la copia de estructuras de datos pueden destruir silenciosamente el throughput. Los tres parches—shared lock, copia diferida, búsqueda binaria—restauraron nuestro pipeline de facturación y nos dieron un camino escalable hacia adelante. Esperamos que esta inmersión profunda te ayude a evitar trampas similares.
Este artículo está basado en una historia real de debugging de Cloudflare. Para más sobre IA en el borde e inferencia en tiempo real, ve NVIDIA TensorRT Edge-LLM: Ejecutando Grandes Modelos de IA en Vehículos Autónomos y Robots.

Código Central: Los Tres Parches
// Optimización 1: Usar shared_lock en lugar de lock exclusivo
// Archivo: src/Storages/MergeTree/MergeTreeData.cpp
// Antes:
std::lock_guard lock(mutex);
auto parts = getParts();
// Después:
std::shared_lock lock(mutex);
auto parts = getParts();
// Optimización 2: Copia diferida de la lista de partes
// Archivo: src/Storages/MergeTree/MergeTreeData.cpp
// Antes: todo planificador copia el vector completo
std::lock_guard lock(mutex);
std::vector<PartPtr> allParts = dataParts; // O(N) copia
// Después: snapshot compartido de solo lectura, regenerado solo en mutación
std::shared_lock lock(mutex);
const auto& sharedParts = getPartsShared(); // sin copia
// El filtrado devuelve un nuevo vector solo con partes relevantes
auto filteredParts = filterParts(sharedParts, queryInfo);
// Optimización 3: Búsqueda binaria para filtrado de partes
// Archivo: src/Storages/MergeTree/MergeTreeDataPart.cpp
// Antes: barrido lineal sobre todas las partes
for (const auto& part : allParts) {
if (matchPartition(part, partitionId)) {
result.push_back(part);
}
}
// Después: búsqueda binaria en el namespace (primera columna de la clave de partición)
// Asume partes ordenadas por (namespace, day)
auto range = std::equal_range(allParts.begin(), allParts.end(),
namespaceId, compareByNamespace);
for (auto it = range.first; it != range.second; ++it) {
// Solo verifica condiciones restantes (ej.: rango de días)
if (matchDayRange(*it, timeRange)) {
result.push_back(*it);
}
}

Impacto en el Rendimiento en Números
| Optimización | Duración Promedio Antes | Duración Promedio Después | Mejora |
|---|---|---|---|
| Shared Lock | 2.3s | 0.9s | 61% |
| Copia Diferida | 0.9s | 0.5s | 44% |
| Búsqueda Binaria | 0.5s (tras 6 meses) | 0.25s | 50% |
Nota: el conteo de partes creció de 30k a 160k por réplica durante el año. Sin los parches, la duración de la consulta habría escalado linealmente.

Principales Lecciones
- Siempre haz profiling con traces reales (wall-clock), no solo traces de CPU. La contención de lock es invisible en los flame graphs de CPU.
- Shared locks para caminos de solo lectura son una corrección simple y de alto impacto.
- Copia diferida evita que la sobrecarga O(N) contamine todas las consultas.
- Búsqueda binaria en datos ordenados puede transformar un filtrado O(N) en O(log N).
- Monitorea el número total de partes como un indicador adelantado de la salud de la planificación de consultas.