Optimización de Queries en ClickHouse®: Qué la Hace Rápida
390x. Esa es la diferencia entre la misma query sobre los mismos datos con dos ordenaciones de columnas distintas en la sorting key. Ni otro engine, ni otro esquema. Solo el orden de las columnas.
Este post es la parte 8 de la serie ClickHouse Deep Dive. Los siete anteriores enseñaron estructura: diseño de tablas, engines, materialización. Este va de operación. Tus tablas existen, tus queries se están ejecutando y algunas van lentas.
Tres posts anteriores de la serie vuelven a aparecer a lo largo de este. Cómo funciona ClickHouse cubre la mecánica de gránulos e índice sparse sobre la que se apoya cada diagnóstico. Data modeling cubre las decisiones de ORDER BY y particionado que ponen el techo a lo que el tuning puede arreglar. Las projections son una de las palancas de optimización, integradas más abajo en el toolkit. Los posts específicos de cada engine aparecen en línea cuando toca.
Usaremos una tabla a lo largo de todas las optimizaciones para que los ejemplos sean más fáciles de seguir:
CREATE TABLE app_events (
tenant_id UInt32,
sequence_id UInt64,
event_id String,
event_type LowCardinality(String),
user_id String,
amount_cents UInt64,
timestamp DateTime
) ENGINE = ReplacingMergeTree()
PRIMARY KEY (tenant_id, event_type)
ORDER BY (tenant_id, event_type, user_id, event_id)
PARTITION BY toYYYYMM(timestamp);
Sorting key, primary key y partition pruning
Lo primero que hay que mirar. Siempre.
ORDER BY determina la disposición física en disco. PRIMARY KEY determina qué entra en el índice sparse que ClickHouse carga en RAM para la búsqueda de gránulos. Por defecto son idénticas, pero no tienen por qué. En nuestra tabla, ORDER BY tiene cuatro columnas, pero PRIMARY KEY solo dos: tenant_id, event_type. Las columnas extra nos dan orden de clasificación y deduplicación para ReplacingMergeTree, pero para eliminar gránulos solo necesitamos las dos primeras. Menos columnas en la PRIMARY KEY significan un índice más pequeño en RAM. Con miles de millones de filas repartidos en cientos de tablas, eso se nota.
El orden de columnas dentro de tu key es donde se esconden las ganancias grandes. La documentación de ClickHouse muestra cómo cambiar el orden de columnas hizo bajar las filas escaneadas de 7,92 millones a 20.320. Mismos datos, 390x menos filas, solo por poner primero la columna de baja cardinalidad. Un equipo reportó queries 18x más rápidas y un 90% menos gránulos escaneados solo con reordenar.
La regla: las columnas por las que más filtras, luego cardinalidad ascendente.
Puedes comprobar cómo funcionan tus índices (la primary key genera un índice) añadiendo el prefijo EXPLAIN indexes = 1 a la query.
EXPLAIN indexes = 1
SELECT count()
FROM app_events
WHERE tenant_id = 1 AND event_type = 'purchase';
Granules: 42/15000 significa que estás leyendo el 0,3%. Granules: 15000/15000 significa full scan. El primer post cubrió la mecánica del índice sparse. Esta es la versión aplicada.
Partition pruning es la segunda comprobación. PARTITION BY toYYYYMM(timestamp) almacena cada mes por separado. Una query con WHERE timestamp >= '2026-01-01' AND timestamp < '2026-02-01' lee una partición en vez de las doce. Sobre-particionar (millones de customer_ids) crea presión de merges. Sub-particionar no da beneficio de pruning. La guía del post de data modeling: entre 1 y 300 GB por partición.
PREWHERE
ClickHouse tiene una optimización del read-path que puede recortar tu I/O hasta en un 95%. Está activada por defecto. La mayoría de la gente nunca ha oído hablar de ella. Esto es especialmente relevante cuando el storage y el compute están desacoplados, porque el I/O es más caro sobre S3 que sobre SSD. Si usas ObsessionDB, tenlo en mente.
PREWHERE lee primero las columnas del filtro, las evalúa gránulo a gránulo, y solo carga las columnas restantes para las filas que pasan. Si seleccionas 50 columnas pero filtras por 1, PREWHERE evita leer las otras 49 para las filas que no coinciden.
Los docs oficiales muestran 23,36 MB leídos sin PREWHERE, 6,74 MB con él. 3,5x menos datos. Y hemos visto casos mucho más extremos, hasta 20x más rápido.
Desde v23.2, optimize_move_to_prewhere está activado por defecto. ClickHouse mueve las condiciones WHERE selectivas a PREWHERE, priorizando las columnas más pequeñas. Puedes superar la optimización automática escribiendo PREWHERE a mano cuando conoces la selectividad mejor que la heurística de tamaño:
SELECT *
FROM app_events
PREWHERE tenant_id = 1
WHERE user_id = 'usr_7a250d5630b4';
tenant_id son 4 bytes y filtra la mayoría de las filas. La cadena más ancha user_id solo se lee para las supervivientes.
Una trampa de correctitud: PREWHERE se ejecuta antes de FINAL por defecto. Con ReplacingMergeTree, esto puede filtrar la fila "ganadora" y darte resultados incorrectos. Desde v25.12, apply_prewhere_after_final=1 soluciona el problema de correctitud, pero anula el sentido de PREWHERE: una vez el filtro corre después del merge, ya no te ahorras el cómputo para el que PREWHERE existía. Úsalo cuando la correctitud importe más que el coste del escaneo.
Data skipping indexes
Tu primary key es tu índice principal. Los data skipping indexes son índices secundarios: metadatos por gránulo que permiten a ClickHouse saltarse gránulos que no pueden coincidir con tu filtro.
Dos cosas se aplican a cada variante. Primero, la forma siempre es ADD INDEX name column TYPE <variante> seguido de MATERIALIZE INDEX name. La primera sentencia registra el índice para que los nuevos inserts lo pueblen; la segunda hace el backfill sobre los parts existentes. Si te saltas el paso de materialize, el índice solo cubre las filas escritas después de añadirlo, con lo que las queries históricas no ven mejora.
Segundo, todos los skip indexes necesitan que los valores de la columna indexada se agrupen en disco para que las filas de cada gránulo compartan un rango estrecho. La primary key te da eso automáticamente para las columnas del prefijo de ordenación; las columnas que crecen monótonamente (como un timestamp en una carga append-only) lo obtienen del orden de inserción. Sin ese clustering, los valores se dispersan entre gránulos y el índice no salta nada. Has pagado la sobrecarga para cero beneficio.
bloom_filter
Para columnas de alta cardinalidad fuera del ORDER BY, donde dominan los filtros de igualdad e IN. Nuestro esquema no tiene un candidato natural (todas las columnas de alta cardinalidad ya están en el prefijo de ordenación), así que imagina que añadimos una columna session_id String para rastrear sesiones de forma independiente a la actividad del usuario:
ALTER TABLE app_events
ADD INDEX idx_session_id session_id TYPE bloom_filter(0.01) GRANULARITY 4;
El tamaño importa más de lo que la gente cree. Un bloom filter hashea valores en un array de bits: array más grande, menos falsos positivos, con lo que "hazlo más grande" parece una palanca gratis, pero no lo es. ClickHouse lee el filtro cada vez que comprueba un gránulo, así que un filtro sobredimensionado convierte una búsqueda barata en I/O de verdad. El punto dulce está sobre los 8 KB por gránulo con los 8.192 filas por gránulo por defecto. Suficientes bits para que los falsos positivos sean raros y lo bastante pequeño como para que cargar el filtro cueste menos que los gránulos que te permite saltarte. En una prueba, un filtro de 262 KB corrió 17x más lento que la versión tuneada de 8 KB (4,5 vs 78 queries por segundo). El filtro hacía su trabajo; leerlo costaba más que el trabajo que ahorraba.
La sobrecarga en inserts es real. Un único bloom filter ralentiza los inserts un 45% aproximadamente. Cada función de hash adicional suma otro 8%. Operadores que funcionan: =, IN, has(). Operadores que no: !=, NOT LIKE.
minmax
minmax almacena dos valores por gránulo: el mínimo y el máximo de la columna indexada en ese bloque. En una query de rango, ClickHouse compara los límites del filtro contra el par de cada gránulo y salta cualquier gránulo cuyo rango no se solape. Sin hashing, sin matemática probabilística, un puñado de bytes de metadatos por gránulo.
La sobrecarga de escritura es esencialmente cero, con lo que la decisión es fácil: si filtras por rango una columna y la columna se agrupa con la disposición física, añádelo. sequence_id en nuestra tabla crece monótonamente a medida que llegan los eventos, así que cada part tiende a contener un rango contiguo de valores, y WHERE sequence_id BETWEEN 18000000 AND 18100000 hace pruning de los parts fuera de esa ventana. Lo mismo pasa con timestamp en la mayoría de cargas append-only. Sobre valores que no se agrupan (UUIDs, hashes, cualquier cosa barajada), minmax guarda rangos anchos por gránulo y no salta nada.
ALTER TABLE app_events
ADD INDEX idx_seq sequence_id TYPE minmax GRANULARITY 4;
set
set almacena los valores distintos que aparecen en cada bloque de gránulo, con un tope en la N que pasas como argumento. Al consultar, ClickHouse comprueba si tu valor de filtro está en ese pequeño conjunto por gránulo y salta el gránulo cuando no lo está. A diferencia de bloom_filter, la comprobación es exacta: un gránulo solo se prunea cuando el valor demostrablemente no está ahí, sin falsos positivos de los que preocuparse.
Eso lo convierte en la herramienta adecuada para columnas con cardinalidad modesta que consultas a menudo pero que no están en la sorting key: códigos de estado, regiones, tiers de tenant, nombres de plan. Elegir N es donde la gente se equivoca. El tope es por gránulo, no global: si un gránulo ve más de N valores distintos, el índice se rinde en ese gránulo y deja de saltarlo. Cinco códigos de estado globalmente pero ocho valores distintos en un gránulo ajetreado significa que set(16) es más seguro que set(8). La sobrecarga de escritura es modesta (guardar un pequeño conjunto deduplicado por gránulo), con lo que la pena por sobredimensionar N es pequeña.
Suponiendo que extendemos nuestro esquema con una columna status LowCardinality(String):
ALTER TABLE app_events
ADD INDEX idx_status status TYPE set(64) GRANULARITY 4;
text
text es el reemplazo moderno de ngrambf_v1, GA desde ClickHouse 26.2, y ya está disponible en ObsessionDB. En vez de hashear substrings en un filtro probabilístico, construye un índice invertido de verdad: tokeniza la columna una vez en el momento de la escritura, y luego guarda un mapeo de cada token a las filas que lo contienen. Las búsquedas son deterministas (sin falsos positivos, sin lecturas de gránulos desperdiciadas) y funcionan a nivel de fila en vez de a nivel de gránulo, con lo que las búsquedas selectivas tocan mucho menos dato.
Eliges el tokenizer según cómo consultes: splitByNonAlpha para texto en lenguaje natural, splitByString para payloads estructurados con delimitadores conocidos, ngrams(N) si quieres comportamiento de n-gramas sin la matemática del bloom filter, asciiCJK para texto CJK. Suponiendo que guardamos un payload crudo message String junto a las columnas estructuradas:
ALTER TABLE app_events
ADD INDEX idx_message message TYPE text(tokenizer = splitByNonAlpha);
El índice soporta hasToken, hasAnyTokens, hasAllTokens (los puntos de entrada recomendados), además de LIKE, match, startsWith y endsWith. Para cualquier nueva carga de búsqueda de texto en una versión reciente de ClickHouse, empieza aquí y solo vuelve a ngrambf_v1 si estás atado a una build antigua.
FINAL: Ya no es lento
Si alguien te dijo que evitaras FINAL, tenía razón en 2022. El consejo no se ha actualizado con el código.
| Versión | Qué mejoró |
|---|---|
| 20.5 | Se introdujo FINAL paralelo |
| 23.5 | Menor consumo de memoria |
| 24.1 | enable_vertical_final por defecto ON. Lecturas de columnas en paralelo |
| 25.6 | Los skip indexes funcionan con FINAL |
| 25.12 | Setting apply_prewhere_after_final (ver sección PREWHERE) |
| 26.2 | Decisión automática de merge entre particiones |
El setting que más importa es do_not_merge_across_partitions_select_final. Por defecto, FINAL trata cada part de la tabla como potencial fuente de duplicados y los fusiona todos en un único stream deduplicado. Cuando tu modelo de datos garantiza que una fila y cualquiera de sus duplicados siempre viven en la misma partición (un evento actualizado se queda en su mes, una actualización del estado de un usuario se queda en la partición de su tenant), ese trabajo entre particiones es pura sobrecarga. Activar el setting ejecuta FINAL por partición y une los resultados, lo que paraleliza mejor y se salta la contabilidad de duplicados entre particiones imposibles. Desde v26.2, ClickHouse toma esta decisión automáticamente según el esquema de particionado.
Los tipos de columna en tu ORDER BY también afectan directamente a la velocidad de FINAL. Para deduplicar, FINAL tiene que identificar las filas con tuplas ORDER BY coincidentes, lo que significa leer cada columna del ORDER BY de cada part y comparar valores. Los tipos más grandes se acumulan: más bytes por fila, más I/O, comparaciones más lentas. Cuando tengas elección en el momento del diseño de esquema (UInt64 vs UUID, UInt32 vs Int64, LowCardinality vs String plano), el camino de FINAL es uno de los sitios donde el ahorro se nota.
argMax todavía puede ser la mejor alternativa en algunos casos, y lo presentamos en el post de data modeling. La respuesta real depende de la selectividad del filtro, y la siguiente tabla tiene un resumen rápido. Para entender el cuadro completo, recomiendo leer el artículo original.
| Filas que sobreviven al filtro | argMax | FINAL | Ganador |
|---|---|---|---|
| ~92% (query amplia) | 1006s | 511s | FINAL |
| ~71% | 30s | 8s | FINAL |
| ~10% (selectiva) | 89s | 109s | argMax |
| Point lookup | 4s | 14s | argMax |
Algoritmos de JOIN
Seis algoritmos. El predeterminado (hash) está bien hasta que revienta por OOM, y mucha gente no sabe que puede usar otro. Todas las opciones están en la siguiente tabla.
| Algoritmo | Velocidad | Memoria | Cuándo usarlo |
|---|---|---|---|
hash | Rápido | Alta | Por defecto. Tabla derecha cabe en memoria |
parallel_hash | El más rápido | La más alta | Tabla derecha grande, multi-core |
full_sorting_merge | Competitivo | 60% menos | Tablas ordenadas por la join key |
grace_hash | Moderado | Configurable | Tabla derecha demasiado grande para memoria |
partial_merge | El más lento | La más baja | Con limitaciones de memoria |
direct | 32x más rápido | Baja | Tabla Dictionary o Join engine |
El que hay que conocer es full_sorting_merge. El hash join predeterminado construye una hash table en memoria con la tabla derecha, y luego hace stream de la izquierda buscando coincidencias. Funciona bien cuando la tabla derecha cabe cómodamente en memoria, pero la fase de construcción es single-threaded y la memoria escala con el tamaño de la tabla derecha, así que los joins de mil millones de filas tienden a atascarse ahí. full_sorting_merge toma un enfoque distinto: ordena ambas entradas por la join key, y luego las hace stream juntas emparejando filas al unísono. Sort-merge paraleliza entre cores y mantiene la memoria acotada por el buffer de merge en vez de por toda la tabla derecha. En un join de mil millones de filas, superó al hash join usando un 60% menos de memoria.
La verdadera ganancia viene cuando las entradas ya están ordenadas por la join key. Si el ORDER BY de una tabla empieza por esa key, ClickHouse puede saltarse el sort de ese lado por completo (el planner no siempre lo detecta, así que verifica con EXPLAIN PIPELINE). A menudo puedes forzar la condición. Suponiendo que también tenemos una tabla de dimensión users con key id, nuestra app_events está ordenada por (tenant_id, event_type, user_id, event_id), así que una query que fija las columnas iniciales con WHERE tenant_id = 1 AND event_type = 'purchase' aterriza en un rango de escaneo que ya está ordenado por user_id, y full_sorting_merge puede correr sin re-ordenar ninguno de los dos lados:
SET join_algorithm = 'full_sorting_merge';
SELECT e.tenant_id, e.event_type, u.name
FROM app_events e
JOIN users u ON e.user_id = u.id
WHERE e.tenant_id = 1 AND e.event_type = 'purchase';
Dos reglas más allá de la elección de algoritmo. Para los JOINs basados en hash (el predeterminado y parallel_hash), la tabla derecha se carga en memoria, así que pon siempre el lado más pequeño a la derecha. Y para búsquedas de dimensión donde la join key es única en la tabla derecha, ANY JOIN devuelve la primera coincidencia y no comprueba más; es semánticamente equivalente al ALL JOIN predeterminado cuando la key es única, pero más barato.
Antes de recurrir a un JOIN, mira la jerarquía. Cuanto más arriba en esta lista, más barata la alternativa:
- dictGet: búsquedas O(1) en memoria contra un diccionario. 6,6x más rápido que el JOIN equivalente para datos de dimensión, y el post de MVs cubrió el patrón de sustitución.
- IN subquery: cuando solo necesitas filtrar la tabla izquierda, no traer columnas de la derecha.
- Subqueries filtradas como entrada de JOIN: si tienes que hacer JOIN, minimiza lo que entra en la hash table filtrando primero el lado derecho.
JOIN (SELECT ... FROM users WHERE ...)gana aJOIN users ... WHERE users.x = ...en muchos casos porque el planner no siempre puede empujar el filtro por sí solo. - JOIN crudo: último recurso, cuando la query necesita genuinamente columnas de ambos lados y no encaja ningún camino más barato.
Projections y MVs
Ambos tienen posts dedicados, pero dada su importancia en la optimización de queries no podíamos dejarlos fuera.
Las projections le dan a una tabla un segundo orden físico sin obligarte a mantener una segunda tabla ni a reescribir queries. Declaras la projection, ClickHouse guarda una copia ordenada de las columnas proyectadas dentro de los mismos parts, y el optimizador elige el orden que haga la query actual más barata. Nuestra app_events está ordenada por (tenant_id, event_type, user_id, event_id), lo que va genial para escaneos por tipo de evento dentro de un tenant pero va lento para una query de timeline de usuario como WHERE user_id = ? ORDER BY timestamp DESC. Una projection ordenada por (user_id, timestamp) cierra ese hueco sin cambios en la aplicación. El coste es almacenamiento y amplificación de escritura: cada projection añade una copia ordenada de sus columnas y se puebla en cada insert, así que tira de ellas solo cuando un patrón de query es lo bastante frecuente como para justificar los bytes extra.
Las materialized views mueven el trabajo del momento de la query al momento del insert. Una MV es una query que corre contra cada bloque insertado en una tabla origen, con el resultado escrito en una tabla destino separada. Si tus dashboards agregan los mismos datos crudos una y otra vez (ingresos diarios por tenant, número de compras por usuario, tasa de errores por endpoint), una MV calcula esos rollups una vez a la entrada, y las queries de lectura van contra la tabla destino pre-agregada en vez de escanear los eventos crudos. Para nuestra tabla app_events, una MV que suma amount_cents por tenant y día convierte "los ingresos de este mes para el tenant X" de un escaneo de mil millones de filas en un lookup sobre un puñado de filas. El mismo patrón sirve para transformaciones como parseo, enriquecimiento o desnormalización. El tradeoff es el throughput de escritura: cada MV enganchada a una tabla corre de forma síncrona en los inserts, y el coste se acumula.
El toolkit de diagnóstico
Antes de poder arreglar una query lenta, necesitas saber cuál es lenta y dónde se va el tiempo. Tres superficies cubren la mayoría de los casos.
Encontrar queries caras. system.query_log registra cada query ejecutada con duración, memoria, filas leídas y más. Ordenar por frecuencia por duración saca a la luz las queries que realmente te cuestan, no solo las peores.
SELECT
normalized_query_hash,
count() AS executions,
avg(query_duration_ms) AS avg_ms,
count() * avg(query_duration_ms) AS total_ms,
any(query) AS sample
FROM system.query_log
WHERE type = 'QueryFinish' AND event_date >= today() - 7
GROUP BY normalized_query_hash
ORDER BY total_ms DESC
LIMIT 20;
Una query de 50ms corriendo 100K veces al día cuesta más que una de 10s corriendo dos veces, y la primera es la que merece la pena optimizar.
Entender el plan. EXPLAIN indexes = 1 muestra cuántos gránulos elimina la primary key. Granules: 42/15000 significa que estás leyendo el 0,3% de la tabla, lo cual es sano. Granules: 15000/15000 significa que la query se saltó el índice por completo: un problema de sorting key, un filtro que no coincide con el prefijo del ORDER BY, o una incompatibilidad de tipos forzando un cast implícito. EXPLAIN PIPELINE muestra los processors reales y los conteos de hilos. MergeTreeThread x 8 significa ocho lectores paralelos; MergeTreeThread x 1 significa que algo está serializando la query. EXPLAIN SYNTAX muestra la query después de las reescrituras del optimizador, útil cuando el comportamiento te sorprende.
Monitorizar la salud de los parts. system.parts te dice si la tabla en sí está sana. Miles de parts activos por partición significan que el pipeline de merges va por detrás y vas directo a la trampa de memoria de optimize_read_in_order de la siguiente sección. Una tabla sana se queda en decenas o pocos cientos de parts activos por partición.
SELECT partition, count() AS parts, formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts
WHERE table = 'app_events' AND active
GROUP BY partition
ORDER BY parts DESC;
El flujo: ordena system.query_log por total_ms, coge la que más tira, ejecuta EXPLAIN indexes = 1. Si la ratio de gránulos es mala, el problema es la sorting key o el diseño de particiones, no el texto de la query. Si los gránulos pintan bien pero la query sigue siendo lenta, EXPLAIN PIPELINE para comprobar el paralelismo. Cambia una variable cada vez, vuelve a ejecutar con SET enable_filesystem_cache = 0 para medir rendimiento en frío, y verifica con una comparación fresca de query_log.
Antipatrones
Una lista rápida de los antipatrones más habituales. Cada uno es pequeño por sí solo y caro en conjunto.
SELECT *. Almacenamiento columnar significa que cada columna en tu lista de SELECT se trae de disco. SELECT * en una tabla de 100 columnas para usar 3 es 33x más I/O que nombrar las tres. Lista siempre las columnas que de verdad necesitas.
LowCardinality sobre columnas de alta cardinalidad. LowCardinality guarda un diccionario de valores distintos por part. Por debajo de ~10K valores distintos, es una gran victoria (menos almacenamiento, comparaciones más rápidas), pero por encima de ~100K, el diccionario por part desborda (tope por defecto 8.192) y la sobrecarga del wrapper empieza a pesar más que la ganancia de compresión. Ejecuta uniqExact(col) sobre una muestra antes de aplicarlo.
Trampa de memoria de optimize_read_in_order. Este setting paraleliza entre parts, no entre hilos, con lo que el número de parts dirige la memoria, no max_threads. Cada stream asigna ~8 MB por columna: 1.803 parts × 3 columnas = 42 GB en un caso reportado. Vigila tu número de parts activos y o bien reduce parts o desactiva el setting cuando crezca por encima de unos pocos cientos por partición.
ORDER BY en subqueries. La query externa reordena de todos modos, con lo que ordenar el resultado interno es CPU puro desperdiciado. Quítalo salvo que vaya con LIMIT, donde el ORDER BY sí acota lo que sale hacia fuera.
Orden de columnas en GROUP BY. Cuando el prefijo de GROUP BY coincide con el prefijo de ORDER BY, ClickHouse agrega en streaming grupo a grupo sin construir una hash table. Desalínealo y fuerzas agregación por hash, que mantiene todos los grupos en memoria a la vez. Haz coincidir el prefijo cuando puedas, sobre todo en GROUP BYs de alta cardinalidad.
Tipos hinchados. UInt64 cuando UInt16 vale desperdicia 4x por fila, y se acumula: FINAL lee cada columna del ORDER BY para deduplicar, así que los tipos de la sort key sobredimensionados también ralentizan ese camino. Nullable añade un marcador UInt8 por fila. Elige el tipo más pequeño que encaje con el rango de valores real que esperas a lo largo de la vida de la tabla.
JOINs entre tipos incompatibles. Hacer join de LowCardinality contra String plano, o UInt64 contra Int64, paga un cast implícito por fila. En hash joins el coste es modesto; en full_sorting_merge puede romper el camino de salto del sort porque el cast materializa una columna nueva antes del merge. Mantén los tipos de las join keys idénticos en ambas tablas.
Para cerrar
Ocho posts dentro. Los primeros siete construyeron la base: cómo funciona ClickHouse, cómo modelar datos para él, y cuándo echar mano de cada engine especializado o materialización. Este es la otra cara: qué hacer cuando el esquema ya está cerrado y la query sigue yendo demasiado lenta. El toolkit, a estas alturas: diseño de sorting key, PREWHERE, skip indexes, el FINAL moderno, algoritmos de JOIN, projections, materialized views, un flujo de diagnóstico para encontrar qué va lento de verdad, y los antipatrones que silenciosamente se cargan todo lo demás.
Estas palancas funcionan en cualquier deployment de ClickHouse, incluido ObsessionDB.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com