This guide is intended for ByteHouse CDW (Cloud Data Warehouse) users, including data analysts, data engineers, and database administrators (DBAs), providing a systematic SQL performance optimization framework and practical methodology. Whether you are new to ByteHouse or wish to further improve the performance of existing queries, this guide will offer clear and actionable guidance.
Our goal is:
This guide is organized in a logical sequence from fundamentals to advanced, and then to practical application.
It is recommended to read from start to finish to build a complete knowledge system. Experienced users can jump directly to the chapters of interest.
The journey of a SQL query in ByteHouse typically includes the following stages:
Performance bottlenecks can occur at any stage, but the vast majority of issues are concentrated in the Execution phase, and the key to optimization lies in influencing optimizer decisions.
Before starting optimization, first locate the problem. The following are key performance metrics:
Core tools:
Suppose we have a sales record table sales, which contains the partition key dt (date).
Before optimization: A common slow search occurs when the partition is not specified, resulting in a full table scan.
SQL before optimization:
-- Intention: Query yesterday's total sales amount -- Problem: WHERE dt = ... was not used to limit the partition, resulting in a full table scan SELECT sum(amount) FROM sales;
SQL after optimization:
-- Optimization: Accurately specify the partition SELECT sum(amount) FROM sales WHERE dt = '2025-08-24';
EXPLAIN is a window for understanding how a search is executed. Through it, we can see the path the optimizer selects for our SQL. For details on the execution plan, see Execution plan EXPLAIN.
In ByteHouse, you can obtain the execution plan by directly adding the EXPLAIN ANALYZE keyword (optionally followed by Distributed and Pipeline) before the SQL.
EXPLAIN ANALYZE SELECT ...;
The output is a tree structure, read from the bottom up. Each line represents an Operator (Operator), which indicates a specific calculation step. Text indentation represents the parent-child relationship between operators.
The key to analyzing EXPLAIN ANALYZE is:
TableScan, check the number of partitions (partitions) and rows (rows) scanned, and confirm whether unnecessary data is being scanned.WHERE condition is effectively pushed down to the storage layer, so that data is filtered during the scan.Join algorithm is used, for example, whether the smaller table is used as the right table.Shuffle, Sorting, and others.TableScan: Indicates reading data from a table. The key information is the partitions and number of rows it reads. If the Data volume here far exceeds expectations, it is usually due to Partition pruning or index not being activated.Filter: Runs the Filters in the WHERE clause. Ideally, Filter should be as close as possible to TableScan.Project: Corresponds to the SELECT List, indicating which columns to output.Broadcast Join: When one table (usually the right table) is small, ByteHouse will broadcast its Complete Data to all nodes participating in the Join. This avoids redistribution (Shuffle) of the left table Data, resulting in extremely high performance.Shuffle Join: When both tables are large, Data from both tables must be rehashed and distributed (Shuffle) based on the Join Key to ensure the same Join Key is distributed to the same node. Shuffle results in a large amount of Network IO and is one of the main performance overheads.Colocation Join: When the JOIN key and the bucket key of both tables are consistent, you can use Colocation Join.Aggregation: Runs the GROUP BY Aggregate operation. ByteHouse performs two-stage aggregation: first, Partial Aggregation is carried out on each node, then the partial results are Shuffled to a few nodes for Final Aggregation to lift the Degree of parallelism.Join optimization. During the build phase of Join (Build side, usually the small table), it will generate a filter based on the Join Key, then push this filter down to the scan phase of the fact table (Probe side, usually the large table). This allows a large amount of Data that cannot Match the Join Condition to be filtered out early during the scan of the large table, greatly reducing Data transfer and Calculate volume during the Join process. Seeing Runtime Filter in EXPLAIN usually means Join performance will be Significantly lifted. It relies on statistics, and you can appropriately increase the wait_runtime_filter_timeout Parameter value.TableScan reads all Partitions.Filter Operator is far from TableScan, separated by Join or other complex Actions.Join between large and small tables is incorrect. If there is Insufficient information, you can adjust it using SQL hint.Aggregation or Join Operator takes much longer than other instances (requires query_log Analysis).Data models are the cornerstone of data warehouses, and good design is the starting point for high performance. In ByteHouse, the design of the table structure directly determines the physical storage of data and the efficiency of data scanning during queries.
Select partition keys (Partition Key) appropriately
Partition keys are the most important and effective pruning tools in ByteHouse. By specifying filter conditions for partition keys in the WHERE clause, ByteHouse can scan only the relevant partitions and skip a large amount of unrelated data.
Selection principles:
dt, event_date).-- Create an event table partitioned by day CREATE TABLE events ( event_id String, user_id UInt64, event_time DateTime, -- ... Other columns ) ENGINE = CNCHMergeTree PARTITION BY toYYYYMMDD(event_time) -- Use date as the partition key ORDER BY (user_id, event_time);
Order key (Order Key) and data distribution
The order key determines how data is physically sorted and stored within each partition. This ordering brings the following benefits:
WHERE conditions at the beginning of the order key.country, gender) before columns with higher cardinality (such as user_id) to improve compression effectiveness.Data type selection
Select the most compact and appropriate data type, which not only saves storage space but also improves computational efficiency.
UInt series instead of Int (if there are no negative numbers), and select the smallest type that can accommodate the data range, such as using UInt8 or UInt16 instead of UInt64.LowCardinality(String) or Enum type, which can be converted to dictionary encoding for storage, greatly reducing storage and accelerating filtering and aggregation. ****NULL: Define the NOT NULL property for columns whenever possible; Nullable columns incur additional storage and computational overhead.Data skipping index is a query acceleration function supported by ByteHouse. When processing large volumes of data, query performance may decline due to the need for full column scans to apply the WHERE clause. Data skipping index addresses this problem by allowing queries to skip data blocks that are confirmed not to contain matching values.
Four main parameters are involved when creating an index:
Syntax example
/*Index name: key_i_idxIndex expression: key_iType: minmaxGranularity: 1*/ INDEX key_i_idx key_i TYPE minmax GRANULARITY 1
1. Minmax (minmax)
2. Set (Set)
max_size for setting the size of the value set for each block. When max_size is 0, the value set size is unlimited. It is suitable for columns where the median value of each group of granules is clustered.max_size, the index will not be applied.3. Bloom filter types (Bloom filter types)
A Bloom filter is a compact data structure used to determine whether a member exists in a set; there may be a very small false positive rate. In the skip index, false positives only lead to reading some unnecessary blocks.
tokenbf_v1: Customized for enhanced Bloom filter functionality, suitable for String and FixedString types. It splits the input expression into a sequence of characters by non-alphanumeric characters. This index can be used for LIKE, EQUALS, in operations, and is suitable for searching a small number of specific values in unstructured logs. Its parameters include:
ngrambf_v1: The function is similar to tokenbf_v1, but adds an extra parameter, which is the size of the ngram to be indexed. It is useful for text search in languages without word breaks, such as Chinese.bloom_filter([false_positive]): General Bloom filter, the false positive probability can be specified by the false_positive parameter (range 0-1, default 0.025). Supports multiple data types, including Int*, UInt*, Float*, String, Array, and others.EXPLAIN ANALYZE indexes = 1 SQL
Examples are as follows:
First, create a database and a table to load sample data.
DROP DATABASE IF EXISTS test_si; CREATE DATABASE test_si; -- Create a table to load the numbers_source.csv dataset CREATE TABLE test_si.numbers ( `number` UInt64 ) ENGINE = CnchMergeTree ORDER BY number;
Then, use the data loading function to load the numbers_source.csv dataset into the test_si.numbers table.
Create a table without an index and insert data.
CREATE TABLE test_skipindex ( `id` UInt64, `key_i` UInt64, `p_date` Date ) ENGINE = CnchMergeTree PARTITION BY p_date ORDER BY id; INSERT INTO test_skipindex SELECT number, number, today() FROM test_si.numbers LIMIT 10000000;
Running the following query will process all 10 million rows of data.
SELECT * FROM test_skipindex WHERE key_i in (200, 700);
Define the index directly when creating the table.
CREATE TABLE test_skipindex ( `id` UInt64, `key_i` UInt64, `p_date` Date, -- Add index when creating the table INDEX key_i_idx key_i TYPE minmax GRANULARITY 1 ) ENGINE = CnchMergeTree PARTITION BY p_date ORDER BY id SETTINGS index_granularity = 8192; INSERT INTO test_skipindex SELECT number, number, today() FROM test_si.numbers LIMIT 1000000;
Running the following query again, only 8,192 rows of data are processed, and the execution speed is significantly improved.
SELECt * FROM test_skipindex WHERE key_i in (200, 700);
If the index is added after data insertion, by default, the index only applies to newly written data. For existing historical data, the materialize operation must be performed.
Add index:
ALTER TABLE test_si.test_skipindex ADD index key_i_idx key_i TYPE minmax GRANULARITY 1;
Add materialized index:
-- Build indexes for all historical data ALTER TABLE test_si.test_skipindex MATERIALIZE INDEX key_i_idx;
After execution, queries will be able to utilize the index, greatly reducing the number of data rows processed.
To build an index for only specific partitions, use the IN PARTITION clause. Only the data in the specified partitions will generate indexes, while historical data in other partitions will not be affected.
-- Assume the previous day's date is '20230927' ALTER TABLE test_si.test_skipindex MATERIALIZE INDEX key_i_idx IN PARTITION '20230927';
Use the DROP INDEX command to delete an existing index.
ALTER TABLE test_si.test_skipindex DROP INDEX key_i_idx;
When the data volume is small, the cost of building an index is not high. But if you need to build an index for all historical data, directly using MATERIALIZE INDEX may place a heavy load on the cluster. In this case, it is recommended to use IN PARTITION to build indexes partition by partition, reducing the pressure on the cluster.
When the data satisfying the query conditions has obvious "locality", you can use a tuned index to accelerate the query.
Good data models require efficient SQL to match, and following best practices can avoid many common performance pitfalls.
WHERE.WHERE clause, as this may cause index and partition pruning failure.Anti-pattern: Using functions on columns
WHERE toYYYYMM(event_time) > 202508;
Recommended: Perform calculations on values
WHERE event_time >= '2025-08-01 00:00:00' AND event_time < '2025-09-01 00:00:00';
This example uses value filters. Based on partition filtering, the engine can further prune unnecessary Parts using minmax information on the Part partition key.
For filtering on non-sorting keys, PREWHERE is more efficient than WHERE. ByteHouse will first read columns related to PREWHERE, filter the data, and then read other columns required by SELECT.
The normal engine will automatically push suitable conditions from the WHERE clause down to PREWHERE. If the business logic can determine that a certain condition has a high filtering rate, you can manually write the WHERE condition into PREWHERE, especially for MAP-type subcolumns (the engine does not automatically push these down).
SELECT order_id, user_id, total_amount, payment_method, province, items -- Read large fields last FROM order_data PREWHERE order_date BETWEEN '2024-04-01' AND '2024-06-30' -- First filter the date range AND status = 'paid' -- Filter for paid status WHERE payment_method = 'credit_card' -- Further filter by payment method AND total_amount > 1000 -- Filter by amount condition
JOIN clause and the small table on the right side. The engine usually automatically reorders them; if not, you can first collect statistics. For details, please refer to 4.1 Statistics collection.JOIN, reducing the data volume for JOIN.Theoretical knowledge needs to be combined with actual scenarios to maximize its value. This section provides specific optimization strategies and case analysis for three typical search scenarios—complex search, batch processing, and real-time search.
For ByteHouse CDW, you need to specify the virtual warehouse used to run statistics collection.
warning
This feature is currently in Beta. To use it, submit a ticket or contact the ByteHouse team to enable it.
-- Specify virtual warehouse for collecting statistics CREATE AUTO STATS *.* WITH statistics_virtual_warehouse='vw-stats-collector';
After significant changes in data (such as importing large volumes of data), you can manually trigger statistics collection once to ensure the optimizer can immediately use the latest information.
-- Manually collect statistics for a single table CREATE STATS table_name; -- Collect statistics for the entire database CREATE STATS DATABASE db_name;
rows value shown in EXPLAIN differs greatly from the actual value (by more than an order of magnitude), it usually means the statistics are inaccurate.Complex searches typically involve JOIN operations across multiple large tables, nested subqueries, CTEs, and window functions, making them hotspots for performance problems.
JOIN search Core strategy:
JOIN, use WHERE or CTE to filter out invalid data as much as possible.JOIN key types of the fact table (large table) and the dimension table (small table) are completely consistent, so that the Runtime Filter is effective and to avoid the performance overhead caused by implicit type conversion.Core strategy:
WITH clause (CTE) instead of nested subqueries. The code is clearer and also easier for the optimizer to analyze.A typical scenario is to calculate the sales amount and number of users for each city and each category.
sales table and the users table are very large. Direct JOIN leads to massive data Shuffle. count(DISTINCT) operation performs poorly on high cardinality columns.sales by week, which greatly reduces the amount of data involved in the JOIN. At the same time, use the uniqCombined approximate deduplication function to avoid the high cost of exact deduplication.SQL before optimization:
-- Directly JOIN three large tables without pre-filtering SELECT ci.city_name, p.category_name, sum(s.sale_amount) AS total_sales, count(DISTINCT s.user_id) AS total_users FROM sales s JOIN users u ON s.user_id = u.user_id JOIN cities ci ON u.city_id = ci.city_id JOIN products p ON s.product_id = p.product_id WHERE s.dt BETWEEN '2025-08-01' AND '2025-08-07' GROUP BY 1, 2;
SQL after optimization:
-- Use CTE for pre-aggregation and replace exact deduplication with approximate calculation functions WITH weekly_sales AS ( -- Step 1: Pre-aggregate and filter the largest fact table SELECT user_id, product_id, sum(sale_amount) AS sale_amount FROM sales WHERE dt BETWEEN '2025-08-01' AND '2025-08-07' GROUP BY 1, 2 ) SELECT ci.city_name, p.category_name, sum(s.sale_amount) AS total_sales, uniqCombined(s.user_id) AS approx_total_users -- Step 2: Use uniqCombined for high-performance approximate deduplication FROM weekly_sales s -- Step 3: JOIN the small aggregated result set with the dimension table JOIN users u ON s.user_id = u.user_id JOIN cities ci ON u.city_id = ci.city_id JOIN products p ON s.product_id = p.product_id GROUP BY 1, 2;
Batch processing tasks (such as ETL/ELT) are characterized by large data volumes and scheduled execution. The focus of optimization is on throughput and resource efficiency.
Core strategy:
max_insert_block_size) to generate more regular data partitions and reduce the pressure of background merge.INSERT INTO ... The bottleneck of SELECT is often on the SELECT side. Apply all SELECT optimization techniques from previous chapters.Core strategy:
This task requires cleaning and transforming the day's log data every day at midnight, then writing it into a user behavior summary daily table.
SQL before optimization:
-- A massive INSERT ... SELECT statement containing complex JOIN and transformation logic INSERT INTO user_behavior_daily SELECT user_id, toDate(event_time) AS dt, -- ... Numerous complex UDFs and multi-table JOIN logic ... FROM raw_logs WHERE toDate(event_time) = yesterday();
Optimized solution:
-- Step 1: Clean and initially process logs, write to temporary table 1 CREATE TABLE cleaned_logs_tmp ...; INSERT INTO cleaned_logs_tmp SELECT ... FROM raw_logs WHERE toDate(event_time) = yesterday(); -- Step 2: Relate dimension information, write to temporary table 2 CREATE TABLE enriched_logs_tmp ...; INSERT INTO enriched_logs_tmp SELECT ... FROM cleaned_logs_tmp JOIN users ON ...; -- Step 3: Final aggregation, write to target table INSERT INTO user_behavior_daily SELECT ... FROM enriched_logs_tmp GROUP BY ...; -- Clean up temporary tables DROP TABLE cleaned_logs_tmp; DROP TABLE enriched_logs_tmp;
Real-time query scenarios (such as online Dashboard) require extremely low query latency and high concurrency capability.
Core strategy:
SELECT optimization techniques to ensure the query makes maximum use of partitions and indexes, scanning the least amount of data.JOIN operations as much as possible, especially Shuffle Join. If a JOIN is necessary, use the wide table mode for large tables to flatten dimension information into the fact table in advance.Core strategy:
enable_intermediate_result_cache.A monitoring Global needs to refresh the latest 1 minute Core Metric every 5 seconds.
api_calls table writes tens of thousands of rows per second. Even when searching only 1 minute Data, the Aggregate calculation overhead is unacceptable under high concurrency.countState and sumState and others functions enable efficient Merge of Aggregate As a result.< 50ms, remains stable under high concurrency.SQL before optimization:
-- Aggregate directly on the origin event table SELECT status, count(*), avg(response_time) FROM api_calls WHERE event_time >= now() - INTERVAL '1 MINUTE' GROUP BY status;
Optimized solution:
-- Step 1: Create a materialized view that refreshes every 10 seconds CREATE MATERIALIZED VIEW api_calls_10s_mv ENGINE = CnchMergeTree AS SELECT toStartOfInterval(event_time, INTERVAL 10 SECOND) AS t_10s, status, countState() AS calls, sumState(response_time) AS total_response_time FROM api_calls GROUP BY t_10s, status; -- Step 2: Dashboard queries materialized view SELECT status, sum(calls), sum(total_response_time) / sum(calls) FROM api_calls_10s_mv WHERE t_10s >= toStartOfInterval(now() - INTERVAL '1 MINUTE', INTERVAL 10 SECOND) GROUP BY status;
SQL optimization is not a one-time Mission, but a continuous process. Establishing effective Monitor and manage mechanisms is key to ensuring long-term health and high efficiency of the Data warehouse.
bh_system.query_log is ByteHouse's "black box". It Record detailed information for every Search Run. This is the most important Datasource for post-performance Diagnosis and audit.
Recommend key Field:
query: Run SQL statement.type: Search Event type (such as QueryStart, QueryFinish).query_duration_ms: Total Search Time (ms).read_rows, read_bytes: Number of rows and bytes read.written_rows, written_bytes: Number of rows and bytes written.memory_usage: Peak memory usage.exception_code: Abnormal code, used to locate Failed Search.Analysis Example:
-- Find the top 10 most time-consuming queries in the past 24 hours SELECT query_duration_ms, read_rows, memory_usage, query FROM bh_system.query_log WHERE type = 'QueryFinish' AND query_start_time >= now() - INTERVAL '1 DAY' ORDER BY query_duration_ms DESC LIMIT 10;
Pass Aggregate Analysis of bh_system.query_log, you can easily locate various Problematic Search.
ProfileEvents['UserTimeMicroseconds'] + ProfileEvents['SystemTimeMicroseconds'].memory_usage.exception_code != 0.Analysis can be performed according to Metrics exposed in the Bytehouse Console. For details, please refer to Alarm.
The virtual warehouse (Virtual Warehouse, VW) is the unit for managing computing resources in ByteHouse. By properly configuring virtual warehouses, you can achieve resource isolation and elastic scaling.
L -> XL -> 2XL), suitable for complex queries that require larger memory or greater single-node computing power.max_concurrent_queries for each virtual warehouse to prevent too many queries from entering simultaneously, which would slow down all queries.max_query_cpu_seconds.