OrzClick: 國慶寫個 ClickHouse 客戶端

原由

我看 ClickHouse 有 C++ 客戶端(clickhouse-cpp),我又用過 PHP-CPP 寫擴展,因而就在國慶寫了 OrzClick ,一個 PHP 用的 ClickHouse 客戶端。php

比較尷尬的是,我寫到一半才發現 SeasClick,它也是 clickhouse-cpp 的綁定, 並且是 C 寫的,感受用 PHP-CPP 我就已經輸了一半呀,因此個人小目標就是性能超越 SeasClickgit

性能測試

Select 結果:
Select 結果github

  • 使用 PDO 訪問 ClickHouse 的 MySQL 接口,查詢小量數據性能更好
  • 小量數據時,OrzClick 和 SeasClick 性能相近,數據大時 OrzClick > SeasClick > MySQL 接口

Insert 結果:
Insert 結果shell

  • OrzClick-Indexed 對標的是 SeasClick,API 最相近(可看代碼:1 2),算是達到了小目標
  • SeasClick 和 OrzClick 都有提升插入性能的 API,SeasClick 的 startWrite-write-endWrite 性能很是好(圖上的 SeasClick-Block),OrzClick 的 InsertColumnar 只有數據量大於 5 千時才能超過它(圖上的 OrzClick-Columnar)

哪一個 clickhouse-cpp ?

在 Github 搜索 clickhouse-cpp, 你會發現有兩個類似的庫:數據庫

LICENSE 和開發人員的評論,能夠得知 ClickHouse 官方的纔是 fork。簡單對比了一下代碼,二者底層仍是同樣的,只是功能特性有一點小小區別。數組

OrzClick 使用的是 ClickHouse/clickhouse-cpp 的 fork,而 SeasClick 是 artpaul/clickhouse-cpp 的 fork,因此你們仍是同源的,性能差別就體如今使用方式和補丁了。性能優化

SeasClick 的優化

clickhouse-cpp 的數據插入接口很是簡單,就一個入口方法:函數

void Insert(const std::string& table_name, const Block& block);

而 SeasClick 把它拆分紅:性能

void InsertQuery(const std::string& query, SelectCallback cb);
void InsertData(const Block& block);
void InsertDataEnd();

這個拆分對性能提高、擴展實現有很大幫助:單元測試

  • InsertQuery 能夠拿到字段的類型信息,能夠簡化 PHP 接口的使用,不像 OrzClick 同樣須要用戶指定字段類型
  • InsertQuery + 屢次 InsertData + InsertDataEnd 能夠實現連續插入,性能提高巨大(見圖上的 SeasClick-Block)

OrzClick 的優化

數據訪問模式

ClickHouse 是個列式存儲的數據庫,而它的接口也使用了一樣的設計,一次 select 會返回多個 Block,Block 裏有多個 Column,一個 Column 裏的數據是連續存放的,Column 間是相互獨立的。

應用層使用數據仍是按行爲主,因此這裏要從新組織一下數據,把列式數據轉成行式數據。 SeasClick 是按行處理,而 OrzClick 是按列處理,這是二者的主要區別之一。

SeasClick 遍歷模式                
        Block                                              
        ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓  
        ┃     Column A      Column B      Column C      ┃  
        ┃                                               ┃  
        ┃    ┏━━━━━━━━━┓   ┏━━━━━━━━━┓   ┏━━━━━━━━━┓    ┃  
Seas─╮──┃───>┃ 1       ┃──>┃ X       ┃──>┃ 1.2     ┃    ┃  
     │  ┃    ┣━━━━━━━━━┫   ┣━━━━━━━━━┫   ┣━━━━━━━━━┫    ┃  
     ╰──┃───>┃ 2       ┃──>┃ Y       ┃──>┃ 2.3     ┃    ┃  
        ┃    ┣━━━━━━━━━┫   ┣━━━━━━━━━┫   ┣━━━━━━━━━┫    ┃  
        ┃    ┃ 3       ┃   ┃ Z       ┃   ┃ 3.4     ┃    ┃  
        ┃    ╏         ╏   ╏         ╏   ╏         ╏    ┃  
        ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛  
     

                          OrzClick 遍歷模式             
        Block                         
        ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓     
        ┃     Column A      Column B      Column C      ┃     
     ╭───────────────────╮─────────────╮                ┃     
Orz ─╯──┃─╮  ┏━━━━━━━━━┓ │ ┏━━━━━━━━━┓ │ ┏━━━━━━━━━┓    ┃     
        ┃ │  ┃ 1       ┃ │ ┃ X       ┃ │ ┃ 1.2     ┃    ┃     
        ┃ │  ┣━━━━━━━━━┫ │ ┣━━━━━━━━━┫ │ ┣━━━━━━━━━┫    ┃     
        ┃ │  ┃ 2       ┃ │ ┃ Y       ┃ │ ┃ 2.3     ┃    ┃     
        ┃ │  ┣━━━━━━━━━┫ │ ┣━━━━━━━━━┫ │ ┣━━━━━━━━━┫    ┃     
        ┃ V  ┃ 3       ┃ V ┃ Z       ┃ V ┃ 3.4     ┃    ┃     
        ┃    ╏         ╏   ╏         ╏   ╏         ╏    ┃     
        ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

SeasClick 的實現相似這樣:

for (auto i = 0; i < block.GetRowCount(); i++) {         // 外層按行遍歷
    for (auto j = 0; j < block.GetColumnCount(); j++) {  // 行內再按列遍歷
        switch (block[i]->GetType().GetCode()) {         // 每一列類型都不一樣,要相應處理
            case clickhouse::Type::Int8:
                add_assoc_long_ex(result, key, len, block[i]->As<clickhouse::ColumnInt8>()->At(j));
            break;
            case ...// 其餘類型相似
        }
    }
}

OrzClick 的實現相似這樣:

for (auto i = 0; i < block.GetColumnCount(); i++) { //  外層按列遍歷
    switch (block[i]->GetType().GetCode()) {        // 每一列類型都不一樣,要相應處理
        case clickhouse::Type::Int8:
            auto col =  block[i]->As<clickhouse::ColumnInt8>();
            for (auto j = 0; j < block.GetRowCount(); j++) { // 列內再按行遍歷
                add_assoc_long_ex(result, key, col->At(j));
            }
        break;
        case ...// 其餘類型相似
    }
}

對比一下能夠看到 SeasClick 的內層循環會有大量的 switch 分支跳轉,而 OrzClick
在外層判斷了類型,內層循環很是緊湊,沒有多餘的分支。

用 perf stat 分析一下,SeasClick 分支數(branches)、分支預測錯誤數(branch-misses)都在 OrzClick 的 2 倍以上:

# perf stat php select-orzclick.php 1000 1000

 Performance counter stats for 'php select-orzclick.php 1000 1000':

        496.85 msec task-clock:u              #    0.340 CPUs utilized
             0      context-switches:u        #    0.000 K/sec
             0      cpu-migrations:u          #    0.000 K/sec
         1,977      page-faults:u             #    0.004 M/sec
 1,761,248,425      cycles:u                  #    3.545 GHz
 2,601,973,475      instructions:u            #    1.48  insn per cycle
   487,402,260      branches:u                #  980.986 M/sec
     2,879,008      branch-misses:u           #    0.59% of all branches

# perf stat php select-seasclick.php 1000 1000

 Performance counter stats for 'php select-seasclick.php 1000 1000':

        896.48 msec task-clock:u              #    0.482 CPUs utilized
             0      context-switches:u        #    0.000 K/sec
             0      cpu-migrations:u          #    0.000 K/sec
         1,962      page-faults:u             #    0.002 M/sec
 3,316,728,038      cycles:u                  #    3.700 GHz
 6,019,365,862      instructions:u            #    1.81  insn per cycle
 1,316,036,409      branches:u                # 1468.000 M/sec           (2.7x)
    10,073,424      branch-misses:u           #    0.77% of all branches (3.4x)

因此在 select 測試中,數據量少的時候 OrzClick 只比 SeasClick 略好,但數據量大了性能差距就拉開了。

固然也有退化到 OrzClick 不利的狀況,就是 ClickHouse 返回多個Block,但每一個 Block 都只有一行,目前只發現 Memory 引擎有這種狀況。

TCP_NODELAY

在測試的時候,發現少許數據反而更慢,就是一字節的區別:

$ time php insert-orzclick.php 8170 100

real    0m3.894s
user    0m0.030s
sys 0m0.061s

$ time php insert-orzclick.php 8171 100

real    0m0.422s
user    0m0.050s
sys 0m0.022s

看 ClickHouse 日誌,處理少許數據反而時多用了 40ms 左右的時間(大佬們看到 40 ms 就大概猜到了吧)。

對比二者的火焰圖,雖然執行的總時間不一樣,可是各類函數的比例是接近的, 大頭都是 _zend_hash_find_known_hash:


難道問題真在 PHP?我移除掉 clickhouse-cpp 的調用,發現兩種狀況執行時間基本相同,這也就排除掉 PHP 的可能性,問題應該出在 clickhouse-cpp。

再用 strace 跟蹤,發現數據少的時候是隻有一個 send 系統調用,多的時候會分紅兩個:

# 8170
sendto(3, "\2\0\1\0\2\377\377\377\377\0\1\352?\2u8"..., 8192, MSG_NOSIGNAL, NULL, 0) = 8192

# 8171
sendto(3, "\2\0\1\0\2\377\377\377\377\0\1\353?\2u8"..., 22, MSG_NOSIGNAL, NULL, 0) = 22
sendto(3, "\1\2\3\4\5\6\7\10\t\n\v\f\r\16\17\20"..., 8171, MSG_NOSIGNAL, NULL, 0) = 8171

8170 和 8171 這個臨界點,發現和 clickhouse-cpp 的緩衝區大小 8192 很接近。因而我試着調整 clickhouse-cpp 緩衝區大小,的確會影響 send 的次數,但只是臨界點有點變化,不能解決問題。

至此基本能夠肯定是內核和協議棧的影響,因而想有那些配置可能影響發送、 接收延遲,而後就想到了 TCP_NODELAY,因而我提了個 PR,給 clickhouse-cpp 加上了 TCP_NODELAY 選項,測試性能終於穩定了。

後來我又嘗試用 Off-CPU 火焰圖,只能看到在 recv 時有等待,還不能直接看出緣由,這種問題沒經驗真不易處理(雖然搜索 TCP 40ms 就有結果)。

PHP-CPP 損耗

PHP-CPP 封裝了 Zend API,開發擴展基本能夠不考慮 Zend 引擎低層(zval、HashTable 等等),很是方便,代價就是更多額外操做和性能損耗。

優化方式很是暴力,直接修改 PHP-CPP,暴露出被封裝的 zval,而後直接用 Zend API 操做。過程就是先用 PHP-CPP 寫,而後用火焰圖發現熱點,而後替換成 Zend API。

例如在 nestedForeach 方法裏,須要獲取數組的值,若是用 PHP-CPP 的 Value::get() 最後會複製一次:

Value::Value(struct _zval_struct *val, bool ref)
{
    // do we have to force a reference?
    if (!ref)
    {
        // we don't, simply duplicate the value
        ZVAL_DUP(_val, val);
    }

批量插入的時候,就會有沒必要要的數組複製。因此這裏改爲 zend_hash_find 拿到 *zval,而後直接遍歷:

zval *item;
auto column = zend_hash_find(Z_ARRVAL_P(data._val), key);
auto ht = Z_ARRVAL_P(column);

ZEND_HASH_FOREACH_VAL(ht, item) {
    callback(item);
}
ZEND_HASH_FOREACH_END();

結束語

國慶假期經過這個項目,每樣學到了一點點:

  • ClickHouse
  • PHP 擴展開發
  • C++
  • CMake
  • 性能優化

也有沒作好的:

  • 單元測試,原本想用 phpt,但沒寫,目前在 tests 目錄有幾個我開發時用的用例子
  • CI,準備試試 GitHub Action

最後,從 OrzClick 這名字你就應該知道,這是出於玩和學習的目的寫的,生產環境仍是建議用 SeasClick。

相關文章
相關標籤/搜索