在诸多在线服务的数据分析场景中,例如游戏日志检索、实时用户信息查询等,业务方需要为多个分析服务提供基于相似 SQL 模板(SQL Pattern)的高性能查询接口。这类场景的共同特点是对 QPS(每秒查询率)和查询延迟(Latency)有着极为严苛的要求,并且期望系统具备卓越的线性扩展能力,即查询性能能够随计算资源的增加而同步增长。
从理论的层面剖析,一款能够卓越应对高并发点查询场景的分析型数据库,其核心能力必须建立在以下六大技术基石之上:
针对这些核心特性,ByteHouse 团队设计并实现了下面的几点优化手段。
优化点 | 说明 |
|---|---|
预先注册查询模(Prepared Statement) | 支持预先注册查询模板,避免对模版 SQL 的分析和优化的开销。 |
上下文锁 | 优化引擎的 context lock(上下文锁),可提高 CPU 利用率。 |
Sync pulling pipeline | 可通过在查询中设置 |
Table scan pipeline | 可通过在查询中设置 |
调整 index_granularity | 可在查询中设置 |
使用前,请确认您的引擎大于 v2.6。您可以在 ByteHouse 企业版控制台中的集群管理页面,单击集群名称,查看引擎版本。
在采用 MergeTree 系列表引擎建表时,可通过调节 MergeTree-level settings 来显著优化高并发点查性能,推荐的参数如下,供参考:
CREATE TABLE t1 ( `id` UInt64, `event_time` DateTime, `city` String, `category` String, `amount` UInt32 ) ENGINE = HaUniqueMergeTree('/clickhouse/default/t1/{shard}', '{replica}') PARTITION BY toDate(event_time) ORDER BY (city, category) UNIQUE KEY id SETTINGS index_granularity = 128, index_granularity_bytes = 0, enable_disk_based_unique_key_index = 0, load_index_granule_caches = 1, merge_selector_config = '{ "name": "dance", "max_parts_to_merge_base": 25, "min_parts_to_merge_base": 3, "min_parts_to_enable_multi_selection": 10 }';
说明
UNIQUE KEY union_id)的实时数据更新与删除,解决了社区版的痛点,极大简化了实时分析应用的开发。MergeTree 几乎无损,同时单分片写入吞吐可达 10万行/秒以上。HaUniqueMergeTree 是 ByteHouse 自研的表引擎,既保留了 ClickHouse 高效的查询性能,又支持主键更新。它解决了社区版 ClickHouse 不能支持高效更新操作的痛点,帮助业务更简单地开发实时分析应用。
HaUniqueMergeTree 引擎具有以下特点:
如需进一步了解 HaUniqueMergeTree 及使用详情,可参考HaUniqueMergeTree。
index_granularity(索引粒度)是 ClickHouse 中 MergeTree 表引擎的一个重要概念,它定义了数据被标记成索引的间隔,表数据以 index_granularity 的粒度(默认 8192)被标记成多个小区间,其中每个区间最多 8192 行数据,每个区间标记后形成一个Mark(MarkRange),通过 start 和 end 表示 MarkRange 的具体范围,数据文件也会按照 index_granularity 的间隔粒度生成压缩数据块。
如果检测到明显读放大现象,可以尝试将该参数从基准值下调至 1024 及以下(建议修改到 128),通过增加了索引标记的密度,使得引擎能够更精确地定位数据。
降低 index_granularity 可能带来一定损耗,因为更精细的索引意味着:
然而,在高并发、读密集型的点查询场景下,查询延迟是首要优化目标。因此,牺牲部分写入和合并性能以换取读取性能的巨大提升,是完全合理且高效的优化选择。
同时需要注意:表的 index_granularity 参数一经确定,便无法再次修改。
同时,建议您设置index_granularity_bytes=0,禁用 Adaptive Mark,固定 Mark 大小,减少 Mark 定位计算开销。
为HaUniqueMergeTree的唯一键维护一个 in-memory key index
设置 enable_disk_based_unique_key_index = 0。
参数 | 默认值 | 配置说明 |
|---|---|---|
enable_disk_based_unique_key_index | 默认值:1 | 0:in-memory mode。在此模式下,系统会在每张唯一键表维护一个 in-memory key index,因此能支撑的数据量受限于内存。 |
建立内存级别索引缓存
在数据导入和节点重载时,设置 load_index_granule_caches = 1,主动为每个数据分区(Part)构建索引缓存并加载到内存中,确保查询时索引无需从磁盘读取。
在执行查询时,需要添加 query-level-setting : enable_skip_index_cache 以使用构建的缓存。
参数 | 推荐值 | 配置说明 |
|---|---|---|
load_index_granule_caches | true | 表在导入 / reload 时是否构建 cache,默认为 false。 |
针对数值与日期列:利用 minmax 索引进行范围剪裁
当查询的 WHERE 条件涉及到该列时(例如 WHERE price > 100 或 WHERE event_date = '2023-10-26'),ByteHouse 引擎会首先检查这个 minmax 索引。如果一个颗粒的 [min, max] 范围与查询条件完全不相交,引擎就可以安全地、完全地跳过读取这个数据颗粒的所有数据。
INDEX number_minmax_idx number TYPE minmax GRANULARITY 1
主键和排序键默认包含此特性,但可以为其他任何查询常用的数值、日期或日期时间列手动添加 minmax 索引,以增强其在过滤条件下的性能。
针对高基数字符串列:采用 bloom_filter 索引进行存在性判断
Bloom filter 允许对集合成员进行高效的存在性测试,但存在轻微的误报风险。而在索引的使用场景,假阳性并非关键问题,其唯一影响仅是会额外读取少量非必要的数据块。
针对字符串类型的查询键,我们可在其上建立 bloomfilter 索引,使其更适配高基数字符串列的场景。
INDEX string_bf_idx string_idx TYPE bloom_filter(0.0001) GRANULARITY 1
bloom_filter(0.0001);这里的参数 0.0001 (即 0.01%) 代表了期望的误报率,值越小,索引越精确,但占用的空间也越大。
如需了解索引的更多细节,请参考HaUniqueMergeTree。
在 ClickHouse 中,每行写入操作都会按照 part 命名规则生成新的分区目录(称为 part)。这意味着即使数据所属的 partition 相同,也不会向已有的分区目录直接添加数据。
MergeTree 表参数 merge_selector_config 能够优化数据合并策略,控制分区数量和合并规模,减少查询时扫描的分区数。
子参数 | 作用 | 推荐值 |
|---|---|---|
name | 选择合并算法,dance 适合向量数据分布,平衡写入与查询性能。 | "dance" |
max_parts_to_merge_base | 单次合并的最大分区数,防止过度合并导致写入阻塞。 | 25 |
min_parts_to_merge_base | 触发合并的最小分区数,避免小分区长期存在增加扫描开销。 | 1 |
max_total_rows_to_merge | 单次合并的最大行数,避免构建过大的索引。 | 20000000 |
max_parts_to_break | 当分区数量超过该阈值时,内置策略会尝试主动拆分大分区,以维持查询性能和合并效率的平衡。 | 10000 |
enable_heuristic_to_align_parts | 禁用启发式对齐,简化合并逻辑,减少合并耗时。 | 0(禁用) |
因此我们推荐添加如下参数。在数据导入完成后,通过积极的后台合并,确保数据最终能整合进一个单一、最优化的 Part 中,减少因 Part 数量过多造成的读放大。
merge_selector_config = '{"name": "dance", "max_parts_to_merge_base": 25, "min_parts_to_merge_base": 1, "min_parts_to_enable_multi_selection": 10}'
在完成表引擎的优化后,我们也对计算引擎进行精细化配置,以最大化 CPU 利用率和并发处理能力。
对于高并发点查询,建议通过 config.xml 对服务端优化以下配置。修改配置参数的操作详情请参见修改配置参数。
<enable_use_local_lock>1</enable_use_local_lock> <max_concurrent_queries>20000</max_concurrent_queries>
参数 | 作用 | 推荐值 |
|---|---|---|
enable_use_local_lock | 将部分全局锁降级为本地锁,减少了高并发查询在访问元数据的竞争等待,直接提升了系统的并发吞吐量。 | 1(启用) |
vector_index_cache_size | 降低并发数量。 | 20000 |
uncompressed_cache_size | 存储解压缩数据的缓存大小,尽量确保热点数据块驻留在内存中。 | 根据机器内存大小调整 |
mark_cache_size | mark索引所使用的Cache大小,尽量确保热点数据的索引驻留在内存中。 | 根据机器内存大小调整 |
在 User.xml 中创建一份用于高并发点查询的 Profile , 修改配置参数的操作详情请参见修改配置参数。
<settings> <log_queries_min_type>QUERY_FINISH</log_queries_min_type> <max_threads>1</max_threads> <exchange_source_pipeline_threads>1</exchange_source_pipeline_threads> <enable_plan_cache>true</enable_plan_cache> <optimize_skip_unused_shards>true</optimize_skip_unused_shards> <enable_table_scan_build_pipeline_optimization>true</enable_table_scan_build_pipeline_optimization> <use_sync_pipeline_executor>true</use_sync_pipeline_executor> <enable_mark_bitmap_index>false</enable_mark_bitmap_index> <enable_short_circuit>true</enable_short_circuit> </settings>
配置项名称 | 作用 | 推荐值 |
|---|---|---|
log_queries_min_type | query_log 最小类型的日志,用来限制进入 query_log 的条目。 | QUERY_FINISH |
max_threads | 高并发点查询场景下,单查询单线程运行,降低线程竞争带来的性能损耗。 | 1 |
exchange_source_pipeline_threads | 配置 exchange 算子读取数据的线程数。 | 1 |
enable_plan_cache | 开启 plan 缓存,降低执行引擎构建 plan 的代价。 | true |
optimize_skip_unused_shards | 数据按 sharding_key 进行分片时开启。在 SELECT 查询时按 sharding_key 进行过滤时,优化跳过未使用的分片。 | true |
enable_table_scan_build_pipeline_optimization | 降低 TableScan 算子的 pipeline 构建开销,进一步提高 CPU 利用率。 | true |
use_sync_pipeline_executor | 点查询采用同步执行模式,减少异步调度与线程切换开销,提升 CPU 利用率。 | true |
enable_mark_bitmap_index | 在覆盖率较高的情况时规避仅使用唯一键索引读取 mark 的误报率。 | false |
enable_short_circuit | TopN 最短路径评估,用于优化布尔表达式的执行,如果确定了结果,则不再计算剩下的表达式。 | true |
预处理查询(PREPARED STATEMENT)用于高效地重复执行相同(或相似)SQL 语句,将 SQL 模板(包含占位符 ?)发送给数据库进行一次性的预编译(解析和优化),消除了服务端重复解析和优化 SQL 的开销,功能详情请参见预处理查询 PREPARED STATEMENT。
注意
目前预处理查询(PREPARED STATEMENT) 仅支持固定参数数量的 SQL。
使用示例如下:
CREATE PERMANENT PREPARED STATEMENT prep1 AS SELECT count() FROM (SELECT number FROM system.numbers LIMIT 10) WHERE number < [literal_name: DataType]; CREATE PERMANENT PREPARED STATEMENT prep1 AS SELECT count() FROM (SELECT number FROM system.numbers LIMIT 10) WHERE number < [i: UInt32];