主頁 > 資料庫 > ClickHouse 查詢優化詳細介紹

ClickHouse 查詢優化詳細介紹

2023-02-15 08:37:52 資料庫

 

你想要的 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 等),

  • 曾經 GCC 使用 yacc/bison 作為語法決議器,在 3.x 某個版本之后改為手寫遞回下降語法分析[3]
  • clang 一直是手寫遞回下降語法分析[4]

手寫語法分析比起語法分析工具有幾個優勢(當然要寫得好的情況):

  • 性能更好,可以優化熱點路徑等
  • 診斷和錯誤恢復更清晰明了,手寫狀態機可以完全掌控系統狀態,錯誤處理更容易
  • 簡單,不需要掌握新語法

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限制

可以看到:

  1. limit 大部分情況下是計算完成后再執行,而 quota 是在讀取資料時執行的
  2. 加速的關鍵是減少讀入的資料量,也就是說善用索引
  3. 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 引擎):

  1. 選擇永遠會用于過濾條件的列
  2. 越重要的、基數越低的放左邊
  3. 主鍵中不要出現兩個高基數欄位,一般最后一列可以為總體增長的時間欄位
  4. 將行的特征欄位加入,將相似的行放一起,提高壓縮率
  5. 若主鍵包含主從關系,主放左邊,從放右邊

Data skipping indexes

最后一步是跳表索引,這個沒有太多可以講的地方,和其他資料庫相同,跳表索參考于盡量減少讀取的行數,具體參看官方檔案,

配置優化

配置優化分為兩部分,全域配置優化和 MergeTree 表配置優化,

全域配置優化

參看Altinity選擇性配置優化項,

這里寫三個推薦的配置:

  1. 添加force_index_by_dateforce_primary_key避免全盤讀取
  2. 調整記憶體配置,參考Altinity
  3. 系統表添加 TTL 和ttl_only_drop_parts表配置

表配置優化

除了全域配置,MergeTree 引擎家族每張表也有自己的配置項,[13]

推薦設定如下配置:

  1. ttl_only_drop_parts=1,只有 parts 中所有資料都過期了才會 DROP,可以有效減少TTL_MERGE發生的頻率,降低磁盤負載,
  2. merge_with_ttl_timeout=86400,配合上一項配置,將 TTL 檢查調整為 1 天一次(默認 4 小時一次),
  3. 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],沒有哪個更優越,由讀者執行評估業務適合哪一種,

平鋪欄位

這是我們主表正在使用的型別,將可能用到的欄位預留平鋪,除了一系列基礎欄位外,增加大量metric1metric2...metricNtag1tag2...tagN等等欄位,

優點:

  • 簡單
  • 只讀取所需要的列,非常高效
  • 每個指標、標記都可以有特殊型別
  • 適合密集記錄(所有預留欄位幾乎全用上)

缺點:

  • 添加欄位需要改變 schema
  • 預留欄位不能過多,最多 100 ~ 200 個
  • 如果使用很稀疏,會創建大量 sparse file 欄位
  • 需要標識“資料缺失”的情況(Null 或者默認值)
  • 讀取的列越多,需要讀取檔案越多,IO 次數越多

arrays/nested/map 欄位

這是我們 ctree 功能正在使用的型別,將業務欄位塞入嵌套資料型別中,比如 array、nested struct 和 map,后文以 array 舉例:metric_arraytag_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 主要是DeltaDoubleDeltaGorillaFPCT64幾種,

  • Delta存盤行之間的變化值,適合變化較小且比較固定的列,比如時間戳,需要配合 ZSTD 使用
  • DoubleDelta存盤DeltaDelta,適合變化很慢的序列
  • Gorilla適合不怎么變動的 integer、float 型別[18]
  • FPC適合于 float 型別,由于我們未使用 float 欄位這里略過
  • T64存盤編碼范圍內最大、最小值,以轉為 64bit 存盤,適合較小的 integer 型別

擴展閱讀:

  • Altinity Blog: New Encodings to Improve ClickHouse Efficiency
  • Altinity Wiki: Codecs sped

快取

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,由于:

  1. 大量查詢都包含 feature_md5
  2. 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 and min_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_rowsmin_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_balancingfirst_or_random);一半用于寫入,插入的程式手動控制插入 Shard2 的節點,由 ClickHouse 的 ReplicatedMergeTree 不同 Shard 資料依靠 zookeeper 自動同步的策略將資料同步到 Shard1,[24]

這種策略有天然的缺陷:

  • 寫的那半 Shard 持續有一定量(不會很高)的資源消耗用于寫入
  • 讀的那半 Shard 會有資源消耗用于同步寫入(由于不用處理,會比直接寫入的情況資源消耗更低),但是讀請求會導致資源消耗突增
  • 并發增加時性能不如混合情況,因為讀寫分離相當于將讀資源砍半

??:或許可以配置兩邊 Shard 資源不一致來解決問題,比如寫入的 Shard 資源拉低,專用于處理資料插入;讀的 Shard 資源更高,專門用于處理突增并發流量,

BufferEngine

Buffer 并不推薦常規業務使用,只有在迫切需要查詢實時性+插入無法大批量預聚合時使用:

  • 無法 atomic insert
  • 即使使用 BufferEngine,資料插入也至少 1000 行每次,或者每秒鐘最多 1 次插入[25]

KafkaEngine+MV

該部分待補充,想看的同學可以在評論區踢踢 ??

預聚合

預聚合有三種方法,ETL、物化視圖和投影,他們的區別如下[26]:

  ETL MV Projections
Realtime no yes yes
How complex queries can be used to build the preaggregaton any complex very simple
Impacts the insert speed no yes yes
Are inconsistancies possible Depends on ETL. If it process the errors properly - no. yes (no transactions / atomicity) no
Lifetime of aggregation any any Same as the raw data
Requirements need external tools/scripting is a part of database schema is a part of table schema
How complex to use in queries Depends on aggregation, usually simple, quering a separate table Depends on aggregation, sometimes quite complex, quering a separate table Very simple, quering the main table
Can work correctly with ReplacingMergeTree as a source Yes No No
Can work correctly with CollapsingMergeTree as a source Yes For simple aggregations For simple aggregations
Can be chained Yes (Usually with DAGs / special scripts) Yes (but may be not straightforward, and often is a bad idea) No
Resources needed to calculate the increment May be signigicant Usually tiny Usually tiny

在我們業務中,個例是不應該預聚合的,因為資料需要被拉取展示而不用計算,指標需要聚合,資料量較大,每次實時計算對 ClickHouse 負載太大,

其實還有一種聚合方式,過期資料聚合,可以參考,同樣限制要求 group by 的鍵值為主鍵前綴,

在我們業務使用時,什么時候用哪一個呢?

  1. 需要針對某個功能加速時,可以考慮物化視圖/投影
  2. 全表預聚合加速查詢,需要使用 ETL

資源控制

最后,為了避免集群被某個查詢、插入弄垮,需要合理安排記憶體使用,需要給訪問賬戶分權限,在我們業務分為:

  • default:最高級賬號,不使用
  • root:資料插入,配置聚合寫入部分的幾個配置項
  • monitor:內部開發使用,權限較高
  • viewer:web 使用,添加大量限制

viewer賬戶配置如下所示:

<yandex>
    <profiles>
        <query>
            <max_memory_usage>10000000000</max_memory_usage>
            <max_memory_usage_for_all_queries>100000000000</max_memory_usage_for_all_queries>
            <max_rows_to_read>1000000000</max_rows_to_read>
            <max_bytes_to_read>100000000000</max_bytes_to_read>
            <max_rows_to_group_by>1000000</max_rows_to_group_by>
            <group_by_overflow_mode>any</group_by_overflow_mode>
            <max_rows_to_sort>1000000</max_rows_to_sort>
            <max_bytes_to_sort>1000000000</max_bytes_to_sort>
            <max_result_rows>100000</max_result_rows>
            <max_result_bytes>100000000</max_result_bytes>
            <result_overflow_mode>break</result_overflow_mode>
            <max_execution_time>60</max_execution_time>
            <min_execution_speed>1000000</min_execution_speed>
            <timeout_before_checking_execution_speed>15</timeout_before_checking_execution_speed>
            <max_columns_to_read>25</max_columns_to_read>
            <max_temporary_columns>100</max_temporary_columns>
            <max_temporary_non_const_columns>50</max_temporary_non_const_columns>
            <max_subquery_depth>2</max_subquery_depth>
            <max_pipeline_depth>25</max_pipeline_depth>
            <max_ast_depth>50</max_ast_depth>
            <max_ast_elements>100</max_ast_elements>
            <readonly>1</readonly>
        </query>
    </profiles>
</yandex>

同時建議設定 quota,減少大量讀盤計算、LIMIT 少量資料回傳的情況發生,

 

我們是 CSIG 性能工程二組 QAPM 團隊,QAPM 時一款應用性能監控工具,覆寫 android、ios、小程式、mac 和 win 多端,已有騰訊會議、優衣庫等大用戶接入,值得信賴,歡迎同事試用我們 QAPM 產品~跳轉鏈接

在 ClickHouse 優化程序遇到無數的問題,卡在 ClickHouse 自身監控無法覆寫的角落時,全靠性能工程三組員工的 Drop(雨滴)工具的鼎力相助,高效直觀監控 CVM 各項指標,降低優化門檻,助力業務增效~跳轉鏈接

參考

腳注

[1]https://clickhouse.com/docs/en/faq/general/why-clickhouse-is-so-fast/
[2]https://benchmark.clickhouse.com
[3]https://gcc.gnu.org/wiki/New_C_Parser
[4]https://clang.llvm.org/features.html
[5]https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree/#mergetree-data-storage
[6]https://kb.altinity.com/engines/mergetree-table-engine-family/pick-keys/#partition-by
[7]https://clickhouse.com/docs/en/guides/improving-query-performance/sparse-primary-indexes/sparse-primary-indexes-design/#data-is-organized-into-granules-for-parallel-data-processing
[8]https://clickhouse.com/docs/en/guides/improving-query-performance/sparse-primary-indexes/sparse-primary-indexes-design/#the-primary-index-has-one-entry-per-granule
[9]https://clickhouse.com/docs/en/guides/improving-query-performance/sparse-primary-indexes/sparse-primary-indexes-design/#the-primary-index-is-used-for-selecting-granules
[10]https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree/#primary-keys-and-indexes-in-queries
[11]https://clickhouse.com/docs/en/guides/improving-query-performance/sparse-primary-indexes/sparse-primary-indexes-design/#mark-files-are-used-for-locating-granules
[12]https://kb.altinity.com/engines/mergetree-table-engine-family/pick-keys/#how-to-pick-an-order-by--primary-key
[13]https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree/#settings
[14]https://clickhouse.com/docs/en/sql-reference/data-types/nullable/#storage-features
[15]https://groups.google.com/g/clickhouse/c/AP2FbQ-uoj8
[16]https://kb.altinity.com/altinity-kb-schema-design/best-schema-for-storing-many-metrics-registered-from-the-single-source/
[17]https://clickhouse.com/docs/en/operations/server-configuration-parameters/settings/#server-settings-compression
[18]http://www.vldb.org/pvldb/vol8/p1816-teller.pdf
[19]https://clickhouse.com/docs/en/operations/server-configuration-parameters/settings/#server-mark-cache-size
[20]https://clickhouse.com/docs/en/operations/caches/
[21]https://kb.altinity.com/altinity-kb-queries-and-syntax/altinity-kb-sample-by/#sample-emulation-via-where-condition
[22]https://clickhouse.com/docs/en/about-us/performance/#performance-when-inserting-data
[23]https://github.com/ClickHouse/ClickHouse/issues/9195#issuecomment-587500824
[24]https://www.jianshu.com/p/c3a4cc528ce8
[25]https://github.com/ClickHouse/ClickHouse/issues/11783#issuecomment-647778852
[26]https://kb.altinity.com/altinity-kb-schema-design/preaggregations/
作者:oliverdding

本文來自博客園,作者:古道輕風,轉載請注明原文鏈接:https://www.cnblogs.com/88223100/p/ClickHouse-query-optimization-in-detail.html

轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/543934.html

標籤:大數據

上一篇:SQL執行慢的原因分析以及調優手段

下一篇:倒帶ChunJun,同心前行|2022年度回顧&2023年共建規劃

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • GPU虛擬機創建時間深度優化

    **?桔妹導讀:**GPU虛擬機實體創建速度慢是公有云面臨的普遍問題,由于通常情況下創建虛擬機屬于低頻操作而未引起業界的重視,實際生產中還是存在對GPU實體創建時間有苛刻要求的業務場景。本文將介紹滴滴云在解決該問題時的思路、方法、并展示最終的優化成果。 從公有云服務商那里購買過虛擬主機的資深用戶,一 ......

    uj5u.com 2020-09-10 06:09:13 more
  • 可編程網卡芯片在滴滴云網路的應用實踐

    **?桔妹導讀:**隨著云規模不斷擴大以及業務層面對延遲、帶寬的要求越來越高,采用DPDK 加速網路報文處理的方式在橫向縱向擴展都出現了局限性。可編程芯片成為業界熱點。本文主要講述了可編程網卡芯片在滴滴云網路中的應用實踐,遇到的問題、帶來的收益以及開源社區貢獻。 #1. 資料中心面臨的問題 隨著滴滴 ......

    uj5u.com 2020-09-10 06:10:21 more
  • 滴滴資料通道服務演進之路

    **?桔妹導讀:**滴滴資料通道引擎承載著全公司的資料同步,為下游實時和離線場景提供了必不可少的源資料。隨著任務量的不斷增加,資料通道的整體架構也隨之發生改變。本文介紹了滴滴資料通道的發展歷程,遇到的問題以及今后的規劃。 #1. 背景 資料,對于任何一家互聯網公司來說都是非常重要的資產,公司的大資料 ......

    uj5u.com 2020-09-10 06:11:05 more
  • 滴滴AI Labs斬獲國際機器翻譯大賽中譯英方向世界第三

    **桔妹導讀:**深耕人工智能領域,致力于探索AI讓出行更美好的滴滴AI Labs再次斬獲國際大獎,這次獲獎的專案是什么呢?一起來看看詳細報道吧! 近日,由國際計算語言學協會ACL(The Association for Computational Linguistics)舉辦的世界最具影響力的機器 ......

    uj5u.com 2020-09-10 06:11:29 more
  • MPP (Massively Parallel Processing)大規模并行處理

    1、什么是mpp? MPP (Massively Parallel Processing),即大規模并行處理,在資料庫非共享集群中,每個節點都有獨立的磁盤存盤系統和記憶體系統,業務資料根據資料庫模型和應用特點劃分到各個節點上,每臺資料節點通過專用網路或者商業通用網路互相連接,彼此協同計算,作為整體提供 ......

    uj5u.com 2020-09-10 06:11:41 more
  • 滴滴資料倉庫指標體系建設實踐

    **桔妹導讀:**指標體系是什么?如何使用OSM模型和AARRR模型搭建指標體系?如何統一流程、規范化、工具化管理指標體系?本文會對建設的方法論結合滴滴資料指標體系建設實踐進行解答分析。 #1. 什么是指標體系 ##1.1 指標體系定義 指標體系是將零散單點的具有相互聯系的指標,系統化的組織起來,通 ......

    uj5u.com 2020-09-10 06:12:52 more
  • 單表千萬行資料庫 LIKE 搜索優化手記

    我們經常在資料庫中使用 LIKE 運算子來完成對資料的模糊搜索,LIKE 運算子用于在 WHERE 子句中搜索列中的指定模式。 如果需要查找客戶表中所有姓氏是“張”的資料,可以使用下面的 SQL 陳述句: SELECT * FROM Customer WHERE Name LIKE '張%' 如果需要 ......

    uj5u.com 2020-09-10 06:13:25 more
  • 滴滴Ceph分布式存盤系統優化之鎖優化

    **桔妹導讀:**Ceph是國際知名的開源分布式存盤系統,在工業界和學術界都有著重要的影響。Ceph的架構和演算法設計發表在國際系統領域頂級會議OSDI、SOSP、SC等上。Ceph社區得到Red Hat、SUSE、Intel等大公司的大力支持。Ceph是國際云計算領域應用最廣泛的開源分布式存盤系統, ......

    uj5u.com 2020-09-10 06:14:51 more
  • es~通過ElasticsearchTemplate進行聚合~嵌套聚合

    之前寫過《es~通過ElasticsearchTemplate進行聚合操作》的文章,這一次主要寫一個嵌套的聚合,例如先對sex集合,再對desc聚合,最后再對age求和,共三層嵌套。 Aggregations的部分特性類似于SQL語言中的group by,avg,sum等函式,Aggregation ......

    uj5u.com 2020-09-10 06:14:59 more
  • 爬蟲日志監控 -- Elastc Stack(ELK)部署

    傻瓜式部署,只需替換IP與用戶 導讀: 現ELK四大組件分別為:Elasticsearch(核心)、logstash(處理)、filebeat(采集)、kibana(可視化) 下載均在https://www.elastic.co/cn/downloads/下tar包,各組件版本最好一致,配合fdm會 ......

    uj5u.com 2020-09-10 06:15:05 more
最新发布
  • day02-2-商鋪查詢快取

    功能02-商鋪查詢快取 3.商鋪詳情快取查詢 3.1什么是快取? 快取就是資料交換的緩沖區(稱作Cache),是存盤資料的臨時地方,一般讀寫性能較高。 快取的作用: 降低后端負載 提高讀寫效率,降低回應時間 快取的成本: 資料一致性成本 代碼維護成本 運維成本 3.2需求說明 如下,當我們點擊商店詳 ......

    uj5u.com 2023-04-20 08:33:24 more
  • MySQL中binlog備份腳本分享

    關于MySQL的二進制日志(binlog),我們都知道二進制日志(binlog)非常重要,尤其當你需要point to point災難恢復的時侯,所以我們要對其進行備份。關于二進制日志(binlog)的備份,可以基于flush logs方式先切換binlog,然后拷貝&壓縮到到遠程服務器或本地服務器 ......

    uj5u.com 2023-04-20 08:28:06 more
  • day02-短信登錄

    功能實作02 2.功能01-短信登錄 2.1基于Session實作登錄 2.1.1思路分析 2.1.2代碼實作 2.1.2.1發送短信驗證碼 發送短信驗證碼: 發送驗證碼的介面為:http://127.0.0.1:8080/api/user/code?phone=xxxxx<手機號> 請求方式:PO ......

    uj5u.com 2023-04-20 08:27:27 more
  • 快取與資料庫雙寫一致性幾種策略分析

    本文將對幾種快取與資料庫保證資料一致性的使用方式進行分析。為保證高并發性能,以下分析場景不考慮執行的原子性及加鎖等強一致性要求的場景,僅追求最終一致性。 ......

    uj5u.com 2023-04-20 08:26:48 more
  • sql陳述句優化

    問題查找及措施 問題查找 需要找到具體的代碼,對其進行一對一優化,而非一直把關注點放在服務器和sql平臺 降低簡化每個事務中處理的問題,盡量不要讓一個事務拖太長的時間 例如檔案上傳時,應將檔案上傳這一步放在事務外面 微軟建議 4.啟動sql定時執行計劃 怎么啟動sqlserver代理服務-百度經驗 ......

    uj5u.com 2023-04-20 08:26:35 more
  • 云時代,MySQL到ClickHouse資料同步產品對比推薦

    ClickHouse 在執行分析查詢時的速度優勢很好的彌補了MySQL的不足,但是對于很多開發者和DBA來說,如何將MySQL穩定、高效、簡單的同步到 ClickHouse 卻很困難。本文對比了 NineData、MaterializeMySQL(ClickHouse自帶)、Bifrost 三款產品... ......

    uj5u.com 2023-04-20 08:26:29 more
  • sql陳述句優化

    問題查找及措施 問題查找 需要找到具體的代碼,對其進行一對一優化,而非一直把關注點放在服務器和sql平臺 降低簡化每個事務中處理的問題,盡量不要讓一個事務拖太長的時間 例如檔案上傳時,應將檔案上傳這一步放在事務外面 微軟建議 4.啟動sql定時執行計劃 怎么啟動sqlserver代理服務-百度經驗 ......

    uj5u.com 2023-04-20 08:25:13 more
  • Redis 報”OutOfDirectMemoryError“(堆外記憶體溢位)

    Redis 報錯“OutOfDirectMemoryError(堆外記憶體溢位) ”問題如下: 一、報錯資訊: 使用 Redis 的業務介面 ,產生 OutOfDirectMemoryError(堆外記憶體溢位),如圖: 格式化后的報錯資訊: { "timestamp": "2023-04-17 22: ......

    uj5u.com 2023-04-20 08:24:54 more
  • day02-2-商鋪查詢快取

    功能02-商鋪查詢快取 3.商鋪詳情快取查詢 3.1什么是快取? 快取就是資料交換的緩沖區(稱作Cache),是存盤資料的臨時地方,一般讀寫性能較高。 快取的作用: 降低后端負載 提高讀寫效率,降低回應時間 快取的成本: 資料一致性成本 代碼維護成本 運維成本 3.2需求說明 如下,當我們點擊商店詳 ......

    uj5u.com 2023-04-20 08:24:03 more
  • day02-短信登錄

    功能實作02 2.功能01-短信登錄 2.1基于Session實作登錄 2.1.1思路分析 2.1.2代碼實作 2.1.2.1發送短信驗證碼 發送短信驗證碼: 發送驗證碼的介面為:http://127.0.0.1:8080/api/user/code?phone=xxxxx<手機號> 請求方式:PO ......

    uj5u.com 2023-04-20 08:23:11 more