我看 ClickHouse 有 C++ 客户端(clickhouse-cpp),我又用过 PHP-CPP 写扩展,因而就在国庆写了 OrzClick ,一个 PHP 用的 ClickHouse 客户端。php
比较尴尬的是,我写到一半才发现 SeasClick,它也是 clickhouse-cpp 的绑定, 并且是 C 写的,感受用 PHP-CPP 我就已经输了一半呀,因此个人小目标就是性能超越 SeasClick。git
Select 结果:github
Insert 结果:shell
在 Github 搜索 clickhouse-cpp, 你会发现有两个类似的库:数据库
看 LICENSE 和开发人员的评论,能够得知 ClickHouse 官方的才是 fork。简单对比了一下代码,二者底层仍是同样的,只是功能特性有一点小小区别。数组
OrzClick 使用的是 ClickHouse/clickhouse-cpp 的 fork,而 SeasClick 是 artpaul/clickhouse-cpp 的 fork,因此你们仍是同源的,性能差别就体如今使用方式和补丁了。性能优化
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)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 引擎有这种状况。
在测试的时候,发现少许数据反而更慢,就是一字节的区别:
$ 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 封装了 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();
国庆假期经过这个项目,每样学到了一点点:
也有没作好的:
最后,从 OrzClick 这名字你就应该知道,这是出于玩和学习的目的写的,生产环境仍是建议用 SeasClick。