CnchMergeTree is the Recommend table engine in ByteHouse Cloud Data Warehouse. It is designed to address high-performance analytical query challenges under massive data storage, helping the User gain rapid insights into big Data. It is suitable for Business Scenarios such as Log Analysis, TEA Event Analysis, Real-time data warehouse, and others that require fast aggregation and querying of large-scale datasets. This article will introduce the principles and syntax of the CnchMergeTree table engine.
A table engine is the type of a table, which determines:
The Recommend table engine in ByteHouse Cloud Data Warehouse is CnchMergeTree. In addition, there are other special types of table engines, including Hive external table, Paimon external table, Iceberg external table, MySQL external table, Kafka table, and others.
The core concept of CnchMergeTree is similar to LSM-Tree: Data is partitioned by partition key (PARTITION BY), and then stored in order by sorting key (ORDER BY). Main features include the following:
index_granularity setting when creating the table). A granule is the smallest indivisible dataset for querying Data in ByteHouse. The first row of each granule is marked by the primary key value of that row. ByteHouse creates an index file for each data part to store these marks. For each column, whether or not it is included in the primary key, ByteHouse stores similar marks. With these marks, the system can quickly locate the required data in the column files.The CnchMergeTree table engine supports the following table creation semantics:
CREATE TABLE [IF NOT EXISTS] [db.]table_name ( name1 [type1] [DEFAULT|ALIAS expr1] [compression_codec] [TTL expr1], name2 [type2] [DEFAULT|ALIAS expr2] [compression_codec] [TTL expr2], ... INDEX index_name1 expr1 TYPE type1(...) GRANULARITY value1, INDEX index_name2 expr2 TYPE type2(...) GRANULARITY value2, ) ENGINE = CnchMergeTree() ORDER BY expr [PARTITION BY expr] [CLUSTER BY (column, expression, ...) INTO value1 BUCKETS SPLIT_NUMBER value2 WITH_RANGE] [PRIMARY KEY expr] [UNIQUE KEY expr] [SAMPLE BY expr] [TTL expr] [SETTINGS name=value, ...]
The partition key is used to define partitions. A partition is a logical data set divided within a table according to specified rules. Partitions can be created by any standard, but in ByteHouse, it is recommended to use date-type fields as partition fields. To reduce the amount of data that needs to be operated on, each partition is stored separately. During queries, ByteHouse tries to use the smallest subset of these partitions. Specify during table creation using the PARTITION BY expr clause. The partition key can be any expression of columns in the table. For example, to partition by month, use the expression toYYYYMM(date); or partition by tuple expressions, such as (toMonday(date), EventType), and so on.
Caution: The value range calculated by the partition expression in the table should not be too large (recommended not to exceed ten thousand); too many partitions will occupy a large amount of memory and cause more IO and computation overhead.
A well-designed partition key can greatly reduce the amount of data that needs to be scanned during queries. Generally, consider designing the column with the most commonly used condition in queries and a value range not exceeding ten thousand as the partition key (such as date, and so on).
This can be a tuple of a group of columns or any expression. For example: ORDER BY (OrderID, Date).
If sorting is not required, use ORDER BY tuple(), and DataPart will be stored in the order of data insertion.
By default, there is no need to explicitly specify it; ByteHouse will use the sorting key as the primary key. In special scenarios where the primary key and the sort key are not consistent, the primary key must be the leftmost prefix of the sort key. For example, if the sort key is (OrderID, Date), the primary key must be OrderID and cannot be Date.
ByteHouse establishes a sparse index on the primary key, with Granule as the unit. In contrast, a dense index means that index information is created for every row.
If the search condition matches the leftmost prefix of the primary key index, the primary key index can quickly filter out the data granules that may need to be read. Compared to scanning the entire DataPart, this is usually much more efficient.
Caution: PRIMARY KEY does not guarantee uniqueness, so it is possible to insert data rows with repeated primary keys.
Partition (PARTITION BY) and primary key (PRIMARY KEY) are two different methods to accelerate data search. When defining them, it is recommended to use different columns for each to cover more search scenarios. For example, since the primary key is by default the prefix of the sort key (ORDER BY), it is recommended not to select the first column of the sort key as the partition key.
Here are some considerations for selecting a primary key:
(a, b)) filters data at too coarse a granularity, resulting in a large scope of data located during search (containing multiple Granule), you can consider adding another commonly used search column to the primary key for more accurate data filtering;ByteHouse's primary key (PRIMARY KEY) does not guarantee uniqueness. If deduplication using a unique key is required, you need to set a unique key index when creating the table. After setting a unique key, ByteHouse provides upsert update write semantics. You can efficiently update data rows according to the unique key, or during upsert, by setting the virtual column _delete_flag_=1, you can use it to delete the specified key. Queries automatically return the latest value for each unique key.
A unique key can be a tuple of columns or any expression, such as UNIQUE KEY (product_id, sipHash64(city)).
When querying by unique key, the unique key index is used to filter data and accelerate the query, so the sorting primary key can often be set to columns different from the unique key to cover more query conditions.
For more details on unique key indexes and unique key tables, refer to Unique key table, Best practices for ByteHouse unique key tables.
warning
The main difference between the primary key (PRIMARY KEY) and the unique key (UNIQUE KEY) lies in their respective purposes:
Primary Key (PRIMARY KEY): Mainly uses its sparse index to accelerate search. To filter queries frequently by a specific field, it is recommended to set that field as the primary key.
Unique key (UNIQUE KEY): Mainly used to ensure data uniqueness. In the same partition, if the written data has the same unique key as existing data, the new data will overwrite the old data.
Bucketing is commonly used in the following scenarios:
warning
Changing an existing table to add buckets only affects new partitions, but does not affect existing partitions.
For related operations on bucketing tables, refer to:
The expression used for sampling; this configuration is optional.
To use a sampling expression, the primary key must contain this expression. For example:
SAMPLE BY intHash32(UserID) ORDER BY (CounterID, EventDate, intHash32(UserID))
A list of rules specifying the duration of row storage and defining the logic for moving data fragments on disks and volumes; this configuration is optional. For more TTL usage, see 列和表的 TTL.
The expression must contain at least one column of Date or DateTime type, for example:
TTL date + INTERVAl 1 DAY
The compression_codec field can be used to configure the codec. This configuration is optional, and the default value is LZ4.
ByteHouse Support project general purpose encoding and specific encoding. General codecs are similar to default codecs (LZ4, ZSTD) and their modified versions. Specific codecs are designed to leverage the particular characteristics of data to make compression more effective.
Example:
CREATE TABLE codec_example ( date Date CODEC(Delta, ZSTD), ts DateTime CODEC(LZ4HC), float_value Float32 CODEC(NONE), double_value Float64 CODEC(LZ4HC(9)) ) ENGINE = CnchMergeTree() PARTITION BY tuple() ORDER BY date
For more table creation related configuration, such as Unique tables, bucketing tables, and others, refer to the corresponding document in Best practice for creating tables in ByteHouse.
The core value of CnchMergeTree merge lies in store in small batches, retrieve in bulk: Data is imported into the table in different batches, but merging reduces the number of files and stores data sequentially, allowing ByteHouse to fully utilize the disk's powerful sequential read capability and significantly improve search performance.
But the problem with merging is also obvious: If the background writes are too fragmented (for example, only a few hundred or dozens of rows are inserted each time), it will bring a large number of parts. The merge Mission will lead to increased CPU overhead and memory usage, resulting in query task performance falling or even errors. In addition, if too many small files slow down the merge, it will also cause performance degradation when searching for the latest data, as the parts have not yet been merged.
SELECT *? Because ByteHouse is a columnar storage database, data is stored in different column storage files (.bin). This design allows only a limited number of files to be read when searching for specified columns, accelerating the search.
If SELECT* is used, the backend needs to read all .bin column storage files, which essentially cancels the advantages brought by columnar storage.
INSERT INTO to insert data? Each INSERT INTO operation creates a new part folder, and repeatedly calling INSERT INTO results in many parts, with each part containing only a small amount of data. The backend requires a long merge process to reduce the number of parts. Problems caused:
After restricting partition, the search will only scan a limited number of part directories, reducing the amount of data scanned and greatly accelerating the search.
Currently, ByteHouse only supports partition key configuration using fields that can be converted to date (int, string, date, datetime). From a business perspective, the data volume per day or per hour is similar. Partitioning by date field ensures each partition is more balanced in size, preventing significant latency fluctuations for individual searches.
As previously mentioned, each block is sorted according to the sorting index, and a sparse index is built based on this field. As long as the query condition includes the sorting index, the MergeTree engine uses the mark in the index to map rows to data, pruning unnecessary granules and reducing the number of scanned rows, thereby lifting search performance.
If the search does not include the sorting index, only a full file scan can be performed, which is highly inefficient.