你想要的 ClickHouse 優化,都在這裡。 ClickHouse 是 OLAP(Online analytical processing)資料庫,以速度見長[1]。ClickHouse 為什麼能這麼快?有兩點原因[2]: 架構優越 列式存儲 索引 數據壓縮 向量化執行 資源利用 關註底層細節... ...
你想要的 ClickHouse 優化,都在這裡。
ClickHouse 是 OLAP(Online analytical processing)資料庫,以速度見長[1]。ClickHouse 為什麼能這麼快?有兩點原因[2]:
-
架構優越 -
列式存儲 -
索引 -
數據壓縮 -
向量化執行 -
資源利用 -
關註底層細節
但是,資料庫設計再優越也拯救不了錯誤的使用方式,本文以 MergeTree 引擎家族為例講解如何對查詢優化。
ClickHouse 查詢執行過程
⚠️ 本節基於 ClickHouse 22.3 版本分析
clickhouser-server
啟動後會在 while 迴圈中等待請求,接收到查詢後會調用executeQueryImpl()
行數構建 AST、優化並生成執行計劃 pipeline,最後在executeImpl()
中多線程執行 DAG 獲取結果,這篇文章只關心 SQL 執行,省略掉網路交互部分,查詢執行流程如下圖所示:
SQL 的解析優化和編譯原理息息相關,本節將包含大量編譯原理和代碼細節,屬擴展知識。
詞法解析和語法解析
ClickHouse 拿到需要執行的 SQL,首先需要將 String 格式的字元串解析為它能理解的數據結構,也就是 AST 和執行計劃。構造 AST 部分代碼如下所示:
// src/Interpreters/executeQuery.cpp
static std::tuple<ASTPtr, BlockIO> executeQueryImpl()
{
// 構造Parser
ParserQuery parser(end, settings.allow_settings_after_format_in_insert);
// 將SQL轉為抽象語法樹
ast = parseQuery(parser, begin, end, "", max_query_size, settings.max_parser_depth);
// 設置query的上下文,比如SETTINGS
...
if (async_insert)
{
...
} else {
// 生成interpreter實例
interpreter = InterpreterFactory::get(ast, context, SelectQueryOptions(stage).setInternal(internal));
// interpreter優化AST並返回執行計劃
res = interpreter->execute();
}
// 返回抽象語法樹和執行計劃
return std::make_tuple(ast, std::move(res));
}
值得一提的是,解析 SQL 生成語法樹這是編譯原理中詞法分析和語法分析部分覆蓋的事情。詞法分析只是簡單拆解數據流為一個個 token,而語法分析分為自頂向下和自底向上兩種方式,常見的語法分析方式也分為手寫語法分析(往往是自頂向下的有限狀態機,遞歸下降分析)和語法分析工具(往往是自底向上,如 Flex、Yacc/Bison 等)。
手寫語法分析比起語法分析工具有幾個優勢(當然要寫得好的情況):
-
性能更好。可以優化熱點路徑等 -
診斷和錯誤恢復更清晰明瞭。手寫狀態機可以完全掌控系統狀態,錯誤處理更容易 -
簡單。不需要掌握新語法
ClickHouse 解析 SQL 的函數如下所示:
// src/Parsers/parseQuery.cpp
ASTPtr tryParseQuery()
{
// 將SQL拆分為token流
Tokens tokens(query_begin, all_queries_end, max_query_size);
IParser::Pos token_iterator(tokens, max_parser_depth);
// 將token流解析為語法樹
ASTPtr res;
const bool parse_res = parser.parse(token_iterator, res, expected);
return res;
}
可以看到先將 SQL 字元串拆解為 token 流(詞法分析),再調用perser.parse()
函數進行語法分析,它的實現如下:
// src/Parsers/ParserQuery.cpp
bool ParserQuery::parseImpl(Pos & pos, ASTPtr & node, Expected & expected)
{
ParserQueryWithOutput query_with_output_p(end, allow_settings_after_format_in_insert);
ParserInsertQuery insert_p(end, allow_settings_after_format_in_insert);
ParserUseQuery use_p;
ParserSetQuery set_p;
ParserSystemQuery system_p;
ParserCreateUserQuery create_user_p;
ParserCreateRoleQuery create_role_p;
ParserCreateQuotaQuery create_quota_p;
ParserCreateRowPolicyQuery create_row_policy_p;
ParserCreateSettingsProfileQuery create_settings_profile_p;
ParserCreateFunctionQuery create_function_p;
ParserDropFunctionQuery drop_function_p;
ParserDropAccessEntityQuery drop_access_entity_p;
ParserGrantQuery grant_p;
ParserSetRoleQuery set_role_p;
ParserExternalDDLQuery external_ddl_p;
ParserTransactionControl transaction_control_p;
ParserBackupQuery backup_p;
bool res = query_with_output_p.parse(pos, node, expected)
|| insert_p.parse(pos, node, expected)
|| use_p.parse(pos, node, expected)
|| set_role_p.parse(pos, node, expected)
|| set_p.parse(pos, node, expected)
|| system_p.parse(pos, node, expected)
|| create_user_p.parse(pos, node, expected)
|| create_role_p.parse(pos, node, expected)
|| create_quota_p.parse(pos, node, expected)
|| create_row_policy_p.parse(pos, node, expected)
|| create_settings_profile_p.parse(pos, node, expected)
|| create_function_p.parse(pos, node, expected)
|| drop_function_p.parse(pos, node, expected)
|| drop_access_entity_p.parse(pos, node, expected)
|| grant_p.parse(pos, node, expected)
|| external_ddl_p.parse(pos, node, expected)
|| transaction_control_p.parse(pos, node, expected)
|| backup_p.parse(pos, node, expected);
return res;
}
可以發現 ClickHouse 將 Query 分為了 18 種類型(截止 2022-11-12 日),每種 Query 都有自己的 Parser,通過關鍵詞匹配構造 AST 上的節點,最終生成語法樹。遞歸下降部分超綱了,這裡就不鋪開講。
優化器
經過語法分析後生成的 AST 並不是執行最優解,ClickHouse 包含大量基於規則的優化(rule based optimization),每個 Query 會遍歷一遍優化規則,將滿足的情況進行不改變查詢語義地重寫。
每一種 Query 類型都有對應的 Interpreter,後文都以 Select 查詢舉例,代碼如下:
// src/Interpreters/InterpreterFactory.cpp
std::unique_ptr<IInterpreter> InterpreterFactory::get()
{
...
if (query->as<ASTSelectQuery>())
{
return std::make_unique<InterpreterSelectQuery>(query, context, options);
}
...
}
在InterpreterSelectQuery
類的構造函數中將 AST 優化、重寫,代碼詳見src/Interpreters/InterpreterSelectQuery.cpp
,這裡只畫流程圖:
是否初始化 settings 優化 with 優化 joins 謂詞下推將 where 下推到 prewhere 是否要再次優化檢查 storage 許可權生成 analysis_result 和 result_header
構造執行計劃
src/Interpreters/InterpreterSelectQuery.cpp
文件InterpreterSelectQuery::executeImpl()
方法將優化分析得到的中間數據輔助生成最終的執行計劃,代碼如下:
// src/Interpreters/InterpreterSelectQuery.cpp
void InterpreterSelectQuery::executeImpl()
{
...
// 個人理解針對EXPLAIN PLAN,只構建執行計劃不執行
if (options.only_analyze)
{
...
}
else
{
// 從磁碟讀取所需列,註意這一行,後文跳轉進去分析
executeFetchColumns(from_stage, query_plan);
}
if (options.to_stage > QueryProcessingStage::FetchColumns)
{
// 在分散式執行Query時只在遠程節點執行
if (expressions.first_stage)
{
// 當storage不支持prewhere時添加FilterStep
if (!query_info.projection && expressions.filter_info)
{
...
}
if (expressions.before_array_join)
{
...
}
if (expressions.array_join)
{
...
}
if (expressions.before_join)
{
...
}
// 可選步驟:將join key轉為一致的supertype
if (expressions.converting_join_columns)
{
...
}
// 添加Join
if (expressions.hasJoin())
{
...
}
// 添加where
if (!query_info.projection && expressions.hasWhere())
executeWhere(query_plan, expressions.before_where, expressions.remove_where_filter);
// 添加aggregation
if (expressions.need_aggregate)
{
executeAggregation(
query_plan, expressions.before_aggregation, aggregate_overflow_row, aggregate_final, query_info.input_order_info);
/// We need to reset input order info, so that executeOrder can't use it
query_info.input_order_info.reset();
if (query_info.projection)
query_info.projection->input_order_info.reset();
}
// 準備執行:
// 1. before windows函數
// 2. windows函數
// 3. after windows函數
// 4. 準備DISTINCT
if (expressions.need_aggregate)
{
// 存在聚合函數,在windows函數/ORDER BY之前不執行
}
else
{
// 不存在聚合函數
// 存在windows函數,應該在初始節點運行
// 並且,ORDER BY和DISTINCT依賴於windows函數,這裡也不能運行
if (query_analyzer->hasWindow())
{
executeExpression(query_plan, expressions.before_window, "Before window functions");
}
else
{
// 沒有windows函數,執行before ORDER BY、準備DISTINCT
assert(!expressions.before_window);
executeExpression(query_plan, expressions.before_order_by, "Before ORDER BY");
executeDistinct(query_plan, true, expressions.selected_columns, true);
}
}
// 如果查詢沒有GROUP、HAVING,有ORDER或LIMIT,會在遠程排序、LIMIT
preliminary_sort();
}
// 在分散式執行Query時只在初始節點執行或optimize_distributed_group_by_sharding_key開啟時
if (expressions.second_stage || from_aggregation_stage)
{
if (from_aggregation_stage)
{
// 遠程節點聚合過,這裡啥也不幹
}
else if (expressions.need_aggregate)
{
// 從不同節點拉取數據合併
if (!expressions.first_stage)
executeMergeAggregated(query_plan, aggregate_overflow_row, aggregate_final);
if (!aggregate_final)
{
// 執行group by with totals/rollup/cube
...
}
// 添加Having
else if (expressions.hasHaving())
executeHaving(query_plan, expressions.before_having, expressions.remove_having_filter);
}
// 報個錯
else if (query.group_by_with_totals || query.group_by_with_rollup || query.group_by_with_cube)
throw Exception("WITH TOTALS, ROLLUP or CUBE are not supported without aggregation", ErrorCodes::NOT_IMPLEMENTED);
// 準備執行:
// 1. before windows函數
// 2. windows函數
// 3. after windows函數
// 4. 準備DISTINCT
if (from_aggregation_stage)
{
if (query_analyzer->hasWindow())
throw Exception(
"Window functions does not support processing from WithMergeableStateAfterAggregation",
ErrorCodes::NOT_IMPLEMENTED);
}
else if (expressions.need_aggregate)
{
executeExpression(query_plan, expressions.before_window,
"Before window functions");
executeWindow(query_plan);
executeExpression(query_plan, expressions.before_order_by, "Before ORDER BY");
executeDistinct(query_plan, true, expressions.selected_columns, true);
}
else
{
if (query_analyzer->hasWindow())
{
executeWindow(query_plan);
executeExpression(query_plan, expressions.before_order_by, "Before ORDER BY");
executeDistinct(query_plan, true, expressions.selected_columns, true);
}
else
{
// Neither aggregation nor windows, all expressions before
// ORDER BY executed on shards.
}
}
// 添加order by
if (expressions.has_order_by)
{
// 在分散式查詢中,沒有聚合函數卻有order by,將會在遠端節點order by
...
}
// 多source order by優化
...
// 多條流時再次執行distinct
if (!from_aggregation_stage && query.distinct)
executeDistinct(query_plan, false, expressions.selected_columns, false);
// 處理limit
...
// 處理projection
...
// 處理offset
...
}
// 需要子查詢結果構建set
if (!subqueries_for_sets.empty())
executeSubqueriesInSetsAndJoins(query_plan, subqueries_for_sets);
}
}
其中InterpreterSelectQuery::executeFetchColumns()
函數是讀取所需列的階段。從代碼中可以看到它也做了很多的優化:
-
count()
優化 -
只有 LIMIT 情況的優化 -
quota
限制
可以看到:
-
limit 大部分情況下是計算完成後再執行,而 quota 是在讀取數據時執行的 -
加速的關鍵是減少讀入的數據量,也就是說善用索引 -
用 count()
、count(1)
和count(*)
,ClickHouse 都有優化,但不要count(any_field)
索引設計
索引是 ClickHouse 快速查詢最重要的一環,分為主鍵索引(sparse indexes)和跳錶索引(data skipping indexes)。在執行查詢時,索引命中順序如下圖所示:
Partition Key MinMax IndexPartitionPrimary Key Sparse IndexData Skipping Indexes
詳見代碼:
// src/Processors/QueryPlan/ReadFromMergeTree.cpp
MergeTreeDataSelectAnalysisResultPtr ReadFromMergeTree::selectRangesToRead()
{
...
try
{
// 使用partition by選取需要parts
MergeTreeDataSelectExecutor::filterPartsByPartition(...);
// 處理抽樣
...
// 使用主鍵索引和跳錶索引
result.parts_with_ranges = MergeTreeDataSelectExecutor::filterPartsByPrimaryKeyAndSkipIndexes(...);
}
catch(...)
{
...
}
...
}
值得註意的是,主鍵的 sparse index 使用二分查找直接縮小範圍到所需要的 parts,而跳錶索引就需要在選出來的 parts 里,每 n 個(用戶自定義)granules 就需要比較 n 次。
最佳實踐:
partition by 需要一個可以轉為時間的列,比如 Datatime、Date 或者時間戳,而如果 primary key 中也有時間欄位,可以使用同一個欄位避免查詢時需要同時指定兩個時間欄位。比如:指定為數據處理時間。
Partition
首先要辨析 part 和 partition 的區別,ClickHouse 應用層面定義了 partition,用戶指定 partition by 關鍵詞設置不同的 partition,但是 partition 只是邏輯分區。真正存儲到磁碟時按 part 來存儲,每一個 part 一個文件夾,裡面存儲不同欄位的.mrk
和.bin
文件,以及一個minmax_{PARTITION_KEY_COLUMN}.idx
文件,不同 part 的 minmax 作為一個索引存儲於記憶體。
當查詢的 WHERE 帶有 partition key 時,首先會比較每一個 part 的 minmax 索引過濾不相關 parts。之後再根據 PARTITION BY 定義的規則過濾不相關 partition。
可是 partition 不是越小越好。
partitioning 並不會加速查詢(有主鍵存在),過小的 partition 反而會導致大量的 parts 無法合併(MergeTree 引擎家族會在後臺不斷合併 parts),因為屬於不同 partition 的 parts 無法合併。[5]
最佳實踐[6]:
-
一個(Replicated)MergeTree 的 partition 大概 1 ~ 300GB -
Summing/ReplacingMergeTree 的 partition 大概 400MB ~ 40GB -
查詢時涉及儘量少 partition -
插入時最好只有 1 ~ 2 個分區 -
一張表維持 100 個分區以內
Primary key index
主鍵是 ClickHouse 最重要的索引,沒有之一。好的主鍵應該能有效排除大量無關的數據 granules,減少磁碟讀取的位元組數。
先講幾個主鍵的背景知識:
-
主鍵用於數據排序 -
ClickHouse 講數據按主鍵排序,再按 index_granularity
設置的大小(預設 8192)將數據分為一個個 granules[7] -
每個 granules 的第一行作為主鍵索引中的一個元素[8] -
查詢時在主鍵上使用二分查找跳過無關 granules[9] -
主鍵只能通過首碼命中索引[10] -
每一個 part 內的 .bin
文件存儲了 n 個 granules,用.mrk
文件記錄每一個 granules 在.bin
文件的地址偏移[11] -
ClickHouse 會在後臺不斷合併同一個 partition 的不同 parts,直到大小/分佈達到“預期”
主鍵的選擇應該儘可能考慮周全,因為主鍵是無法修改的,只能建新表後數據遷移。
最佳實踐[12](針對(Replicated)MergeTree 引擎):
-
選擇永遠會用於過濾條件的列 -
越重要的、基數越低的放左邊 -
主鍵中不要出現兩個高基數欄位,一般最後一列可以為總體增長的時間欄位 -
將行的特征欄位加入,將相似的行放一起,提高壓縮率 -
若主鍵包含主從關係,主放左邊,從放右邊
Data skipping indexes
最後一步是跳錶索引,這個沒有太多可以講的地方,和其他資料庫相同,跳錶索引用於儘量減少讀取的行數。具體參看官方文檔。
配置優化
配置優化分為兩部分,全局配置優化和 MergeTree 表配置優化。
全局配置優化
參看Altinity選擇性配置優化項。
這裡寫三個推薦的配置:
-
添加 force_index_by_date
和force_primary_key
避免全盤讀取 -
調整記憶體配置,參考Altinity -
系統表添加 TTL 和 ttl_only_drop_parts
表配置
表配置優化
除了全局配置,MergeTree 引擎家族每張表也有自己的配置項。[13]
推薦設置如下配置:
-
ttl_only_drop_parts=1
。只有 parts 中所有數據都過期了才會 DROP,可以有效減少TTL_MERGE
發生的頻率,降低磁碟負載。 -
merge_with_ttl_timeout=86400
。配合上一項配置,將 TTL 檢查調整為 1 天一次(預設 4 小時一次)。 -
use_minimalistic_part_header_in_zookeeper=1
。可以有效降低 Zookeeper 負載,避免 Zookeeeper 成為性能瓶頸(插入)。
欄位優化
除了索引、分區和配置外,還有表欄位可以優化。接下來將講述 Schema 類型、CODEC 和緩存三個方面。
註意,儘量避免使用 Null,在 ClickHouse 中 Null 會用一個單獨 Null masks 文件存儲哪些行為 Null[14],因此讀取某個普通欄位只需要.bin
和.mrk
兩個文件,而讀取 Nullable 欄位時需要.bin
、.mrk
和 masks 文件。社區查詢驗證,最高會有 2 倍性能損失。[15]
Schema 類型
使用 ClickHouse 存儲時,一般用戶都會創建大寬表,包含大量數值、字元串類型的欄位。這裡提及兩種 Schema 類型[16],沒有哪個更優越,由讀者執行評估業務適合哪一種。
平鋪欄位
這是我們主表正在使用的類型,將可能用到的欄位預留平鋪,除了一系列基礎欄位外,增加大量metric1
, metric2
...metricN
和tag1
, tag2
...tagN
等等欄位。
優點:
-
簡單 -
只讀取所需要的列,非常高效 -
每個指標、標記都可以有特殊類型 -
適合密集記錄(所有預留欄位幾乎全用上)
缺點:
-
添加欄位需要改變 schema -
預留欄位不能過多,最多 100 ~ 200 個 -
如果使用很稀疏,會創建大量 sparse file 欄位 -
需要標識“數據缺失”的情況(Null 或者預設值) -
讀取的列越多,需要讀取文件越多,IO 次數越多
arrays/nested/map 欄位
這是我們 ctree 功能正在使用的類型。將業務欄位塞入嵌套數據類型中,比如 array、nested struct 和 map。後文以 array 舉例:metric_array
、tag_array
。
優點:
-
動態擴展 -
ClickHouse 有大量高效的相關處理函數,甚至可以針對 Array、Map 設置索引 -
適合稀疏記錄(每行存儲少量值,儘管總基數很高)
缺點:
-
只需要其中一個 metric/tag 時,需要將整個 array 全部讀入記憶體 -
不通用,與其他系統交互時比較麻煩。比如 spark 使用 jdbc 時,嵌套類型無法支持比如 array(array(string)) -
不通意義的值存儲在相同欄位,壓縮率變低 -
需要不同類型的預留欄位時需要創建不同類型
總結
關於 Schema 設計這裡,讀者可以考慮 28 原則,理論上 80%查詢只會用到 20%的業務欄位,因此可以將使用頻率高的業務欄位平鋪,將使用頻率低的欄位放入嵌套結構中。
CODEC
CODEC 分為壓縮演算法 CODEC、存儲格式 CODEC 和加密 CODEC,一般可以組合一起使用。在 ClickHouse 中,未顯示指定 CODEC 的欄位都會被分配一個 DEFAULT 預設 CODEC LZ4(除非用戶修改 clickhouse 配置 compression 部分[17])。
壓縮演算法 CODEC 的選擇是一個平衡板問題,更高的壓縮度可以有更少的 IO 但是更高的 CPU,更低的壓縮度有更多的 IO 但是更少的 CPU。這需要讀者根據部署機器配置自行選擇合適的壓縮演算法和壓縮等級。
這裡提供兩個判斷策略:
-
存在索引的欄位可以設置更高的壓縮等級 -
用於 where 條件的欄位應該設置更低壓縮等級
存儲格式 CODEC 主要是Delta
、DoubleDelta
、Gorilla
、FPC
和T64
幾種。
-
Delta
存儲行之間的變化值,適合變化較小且比較固定的列,比如時間戳。需要配合 ZSTD 使用 -
DoubleDelta
存儲Delta
的Delta
。適合變化很慢的序列 -
Gorilla
適合不怎麼變動的 integer、float 類型[18] -
FPC
適合於 float 類型,由於我們未使用 float 欄位這裡略過 -
T64
存儲編碼範圍內最大、最小值,以轉為 64bit 存儲,適合較小的 integer 類型
擴展閱讀:
緩存
mark_cache_size
可以調整.mrk
文件的緩存大小,預設為 5GB。適當調大可以減少查詢時 IO 次數,有效降低磁碟壓力。[19]
-
欄位越多, .mrk
文件越大 -
index_granularity
與.mrk
文件大小成負相關
可以通過如下 SQL 查詢當前所有表的 parts 信息:
SELECT
database,
table,
count() AS parts,
uniqExact(partition_id) AS partition_cnt,
sum(rows),
formatReadableSize(sum(data_compressed_bytes) AS comp_bytes) AS comp,
formatReadableSize(sum(data_uncompressed_bytes) AS uncomp_bytes) AS uncomp,
uncomp_bytes / comp_bytes AS ratio,
formatReadableSize(sum(marks_bytes) AS mark_sum) AS marks,
mark_sum / uncomp_bytes AS mark_ratio
FROM cluster(default_cluster, system.parts)
WHERE active
GROUP BY
database,
table
ORDER BY comp_bytes DESC
可以通過如下查詢獲取當天 mrk 緩存命中情況:
WITH (ProfileEvents.Values[indexOf(ProfileEvents.Names, 'MarkCacheHits')]) AS MARK_CACHE_HITS
SELECT
toHour(event_time) AS time,
countIf(MARK_CACHE_HITS != 0) AS hit_query_count,
count() AS total_query_count,
hit_query_count / total_query_count AS hit_percent,
avg(MARK_CACHE_HITS) AS average_hit_files,
min(MARK_CACHE_HITS) AS minimal_hit_files,
max(MARK_CACHE_HITS) AS maximal_hit_files,
quantile(0.5)(MARK_CACHE_HITS) AS "50",
quantile(0.9)(MARK_CACHE_HITS) AS "90",
quantile(0.99)(MARK_CACHE_HITS) AS "99"
FROM clusterAllReplicas('default_cluster', system.query_log)
WHERE event_date = toDate(now())
AND (type = 2 OR type = 4)
AND query_kind = 'Select'
GROUP BY time
ORDER BY time ASC
以及如下查詢獲取當前 mrk 緩存記憶體占用情況:
SELECT formatReadableSize(value)
FROM asynchronous_metrics
WHERE metric = 'MarkCacheBytes'
以及 mrk 緩存具體緩存多少文件:
SELECT value
FROM asynchronous_metrics
WHERE metric = 'MarkCacheFiles'
除此之外,ClickHouse 還可以調整uncompressed_cache
緩存一定量原始數據於記憶體中。[20]但是這個緩存只對大量短查詢有效,對於 OLAP 來說,查詢千奇百怪,不太建議調整這個配置。
業務優化
到了最難的部分,由於接下來的部分和不同業務息息相關,為了講解我們業務上的優化,我先介紹下我們業務情況:
QAPM 主打應用性能監控,主要分為指標、個例兩張表。個例表包含更多基礎欄位,一般用戶展示;指標表主要用於聚合計算。
首先確定主鍵,毋庸置疑的前兩個一定是
-
app_id。放首位,因為可能存在同一個產品不同功能聯動的情況,比如會話分析 -
category。放第二位,因為功能之間獨立,大量查詢只涉及單功能
指標沒有特征鍵值,因此只添加處理時間作為第三個主鍵。
對於指標表,設置的主鍵為:app_id, category, entrance_time
個例存在特征 feature,由於:
-
大量查詢都包含 feature_md5 -
feature 是行的特征,相同的特征表明兩行相似,
將特征的 md5 增加到主鍵中,用於加速查詢、提高壓縮率。但是這裡有兩個方向:
-
若 feature_md5 是高基數、大量長尾的欄位 -
設置的主鍵為: app_id, category, intDiv(entrance_time, 3600000), feature_md5
-
若 feature_md5 基數可以降低到千、萬量級 -
設置的主鍵為: app_id, category, feature_md5, entrance_time
分區鍵設置為`PARTITION BY intDiv(entrance_time, 2592000000)
鑒於SAMPLE BY
需要將 xxHash 欄位放在主鍵中,主鍵都包含高基數欄位,就不設置抽樣鍵,而是在需要的時候軟抽樣[21]:
SELECT count() FROM table WHERE ... AND cityHash64(some_high_card_key) % 10 = 0; -- Deterministic
SELECT count() FROM table WHERE ... AND rand() % 10 = 0; -- Non-deterministic
插入優化
數據插入看起來和查詢性能沒什麼聯繫,但是有間接影響。不合理的插入會導致更多的寫盤、更多的數據 merge 甚至有可能插入失敗,影響讀盤性能。
聚合寫入
ClickHouse 作為 OLAP 並不適合小批量、大併發寫入,相反而適合大批量、小併發寫入,官方建議插入數據每批次至少 1000 行,或者每秒鐘最多 1 次插入。[22]
這一小節我想強調原子(Atomic Insert)寫入的概念:一次插入創建一個數據 part。
前文提及,ClickHouse 一個 part 是一個文件夾,後臺有個 merge 線程池不斷 merge 不同的 part。原子插入可以減少 merge 次數,讓 ClickHouse 負載更低,性能更好。
原子寫入的充分條件[23]:
-
數據直接插入 MergeTree
表(不能有 Buffer 表) -
數據只插入一個 partition(註意前文提到的 partition 和 part 的區別) -
對於 INSERT FORMAT -
插入行數少於 max_insert_block_size
(預設 1048545) -
關閉並行格式化 input_format_parallel_parsing=0
-
對於 INSERT SELECT -
插入行數少於 max_block_size
-
小 block 被合併到合適的 block 大小 min_insert_block_size_rows
andmin_insert_block_size_bytes
-
MergeTree
表不包含物化視圖
這裡貼一下我們生產的配置(users.xml)。
經過統計,個例表每行大約 2KB,指標表每行大約 100B(未壓縮)。
設置min_insert_block_size_rows
為 10000000,指標會先滿足這個條件,大概一個 block 原始大小 1GB。設置min_insert_block_size_bytes
為 4096000000,個例會先滿足這個條件,大概一個 block 原始大小 1G,約 1024000 行。
這三個配置項是客戶端配置,需要在插入的 session 中設置,而不是在那幾個.xml
中配置。
max_insert_block_size: 16777216
input_format_parallel_parsing: 0
min_insert_block_size_rows: 10000000
min_insert_block_size_bytes: 1024000000
註意,min_insert_block_size_rows
和min_insert_block_size_bytes
是“或”的關係:
// src/Interpreters/SquashingTransform.cpp
bool SquashingTransform::isEnoughSize(size_t rows, size_t bytes) const
{
return (!min_block_size_rows && !min_block_size_bytes)
|| (min_block_size_rows && rows >= min_block_size_rows)
|| (min_block_size_bytes && bytes >= min_block_size_bytes);
}
讀寫分離
⚠️:本方案並沒有經過生產驗證,酌情考慮
ClickHouse 有 Shard 和 Replica 可以配置,作用如下圖所示:
所謂讀寫分離也就是將 Shard 分為兩半,一半隻用於查詢,只要讓分散式表查詢都導入到 Shard1 即可(在users.xml
中配置load_balancing
為first_or_random
);一半用於寫入,插入的程式手動控制插入 Shard2 的節點,由 ClickHouse 的 ReplicatedMergeTree 不同 Shard 數據依靠 zookeeper 自動同步的策略將數據同步到 Shard1。[24]
這種策略有天然的缺陷:
-
寫的那半 Shard 持續有一定量(不會很高)的資源消耗用於寫入 -
讀的那半 Shard 會有資源消耗用於同步寫入(由於不用處理,會比直接寫入的情況資源消耗更低),但是讀請求會導致資源消耗突增 -
併發增加時性能不如混合情況,因為讀寫分離相當於將讀資源砍半