因为看到ClickHouse有一个C++的客户端(clickhouse-cpp),我用PHP-CPP写扩展,所以写了OrzClick国庆,一个ClickHouse客户端PHP。比较尴尬的是写到一半发现了SeasClick。也是clickhouse-cpp的绑定,用C写的,感觉用PHP-CPP已经输了一半,所以我的小目标是性能上超越SeasClick。.性能测试选择结果:使用PDO访问ClickHouse的MySQL接口,查询小数据量性能较好。对于少量数据,OrzClick和SeasClick具有相似的性能。大数据时,OrzClick>SeasClick>MySQLinterfaceInsertResult:OrzClick-Indexedbenchmark是SeasClick,API最相似(见代码:12),算是达到了小目标。SeasClick和OrzClick都有API来提高插入性能。SeasClick的startWrite-write-endWrite性能非常好(图中的SeasClick-Block),InsertColumnar只有在数据量大于5千条时才能超过(图中的OrzClick-Columnar)。哪个clickhouse-cpp?在Github上搜索clickhouse-cpp,会发现两个类似的库:artpaul/clickhouse-cppClickHouse/clickhouse-cpp看LICENSE和开发者的评论就知道是官方的ClickHousefork了。简单对比一下代码,两者的底层还是一样的,只是功能特性略有不同。OrzClick使用的是ClickHouse/clickhouse-cpp的一个fork,而SeasClick是artpaul/clickhouse-cpp的一个fork,所以大家还是同源,性能差异体现在用法和补丁上。SeasClick优化后的clickhouse-cpp数据插入接口非常简单,只有一个入口方法:voidInsert(conststd::string&table_name,constBlock&block);而SeasClick拆分为:voidInsertQuery(conststd::string&query,SelectCallbackcb);voidInsertData(constBlock&block);voidInsertDataEnd();这种拆分对性能提升和扩展实现很有帮助:InsertQuery可以获取类型字段的信息,这可以简化PHP界面的使用,不像OrzClick还需要用户指定字段类型。InsertQuery+多个InsertData+InsertDataEnd可以实现连续插入,性能大大提升(见图中的SeasClick-Block)。OrzClick的优化数据访问模式。ClickHouse是一个列式存储数据库,其接口也是采用相同的设计,一次select会返回多个Block,一个Block中有多个Column,一个Column中的数据是连续存储的,Column之间是相互独立的。应用层使用的数据仍然是基于行的,所以这里我们需要对数据进行重组,将列数据转换为行数据。SeasClick是按行的,而OrzClick是按列的,这是两者之间的主要区别之一。SeasClick遍历方式区块┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────>┃1┃──>┃X┃──>┃1.2┃┃│┃┣━━━━━━━┫┫┣━━━━━━━━┫┣━-━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ORZCLICK遍历模式块┏━━━━━━━━┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┃专栏A列B列C┃╭────────────────────╮──────────────╮┃Orz──╯──┃──╮┏━━━━━━━━━┓││┏━━━━━━━━━┓┃┃││┃┃┃xx┃┃┃┃┃│┃││┃1.2┃┃┃││┣━━━━━━━━━┫┣━━━━━━━━━┫┣━━━━━━━━━┫┃┃│││┃┃┃┃┃┃┃│┃2.3┃┃┃┣━━━━━━━━━┫┣━━━━━━━━━┫┣━━━━━━━━━┫┣━━━━━━━━━┫┃vv┃v┃vv┃Z┃vv3.4┃┃?????┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┃┃━━━━━━━━━┛SeasClick的实际类似这样:for(autoi=0;iGetType().GetCode()){//每一种类型都不相同,要相关处理caseclickhouse::Type::Int8:add_assoc_long_ex(result,key,len,block[i]->As()->At(j));休息;case...//其他类型类似}}}OrzClick的实现类似这样:for(autoi=0;iGetType().GetCode()){//每个列的类型不同,所以相应地处理caseclickhouse::Type::Int8:autocol=block[i]->As();for(autoj=0;jAt(j));}休息;case...//其他类型类似}}通过对比可以看出SeasClick的内层循环有大量的switch分支跳转,而OrzClick在外层判断类型,内层循环很紧凑紧凑,没有额外的分支使用perfstat来分析,SeasClick的branches(分支)数和branch-misses数是OrzClick的两倍多:#perfstatphpselect-orzclick.php10001000Performancecounterstatsfor'phpselect-orzclick.php10001000':496.85毫秒任务时钟:u#0.340个CPU使用0个上下文切换:u#0.000K/秒0cpu-迁移:u#0.000K/秒1,977页面错误:u#0.004M/秒1,761,248,425个周期:u#3.545GHz2,601,973,475条指令:u#1.48insn每个周期487,402,260个分支:u#980.986M/sec2,879,008分支未命中:u#所有分支的0.59%#perfstatphpselect-seasclick.php10001000性能计数器统计'phpselect-seasclick.php10001000':896.48毫秒task-clock:u#0.482CPUsutilized0context-switches:u#0.000K/sec0cpu-migrations:u#0.000K/sec1,962page-faults:u#0.002M/sec3,316,728,038cycles:u#3.700GHz6,019,365,862instructions:u#1.81insnpercycle1,316,036,409branches:u#1468.0M/sec(x)10,073,424branch-misses:u#0.77%ofallbranches(3.4x)所以在select测试中,OrzClick在数据量小的时候只比SeasClick稍微好一点,但是当数据量大的时候,性能gap拉大当然有退化OrzClick不利的情况是ClickHouse返回多个Block,但每个Block只有一行。目前只有Memory引擎有这种情况。测试TCP_NODELAY时,发现少量数据比较慢,相差一个字节:$timephpinsert-orzclick.php8170100real0m3.894suser0m0.030ssys0m0.061s$timephpinsert-orzclick.phpphp8171100real0m0.422suser0m0.050ssys0m0.022s看ClickHouse的日志,处理少量数据的时间大概长了40ms(老板看到40ms估计也猜到了)。对比两者的火焰图,虽然总执行时间不同,但是各个函数所占比例接近,而且大部分都是_zend_hash_find_known_hash:问题真的出在PHP中吗?去掉clickhouse-cpp的调用,发现两种情况执行时间基本一致,也排除了PHP的可能,问题应该出在clickhouse-cpp。然后用strace跟踪,发现数据少的时候只有一个send系统调用,数据多的时候会分成两个:#8170sendto(3,"\2\0\1\0\2\377\377\377\377\0\1\352?\2u8"...,8192,MSG_NOSIGNAL,NULL,0)=8192#8171sendto(3,"\2\0\1\0\2\377\377\377\377\0\1\353?\2u8"...,22,MSG_NOSIGNAL,NULL,0)=22sendto(3,"\1\2\3\4\5\6\7\10\t\n\v\f\r\16\17\20"...,8171,MSG_NOSIGNAL,NULL,0)=81718170and8171发现这个临界点非常接近缓冲区大小8192clickhouse-cpp的。于是我尝试调整clickhouse-cpp的buffersize,确实会影响send的数量,但只是在临界点稍微改变一下,并不能解决问题。至此,基本确定是内核和协议栈的影响,于是想到了那些可能影响发送和接收延迟的配置,然后想到了TCP_NODELAY,于是做了个PR,加上clickhouse-cpp的TCP_NODELAY选项,终于测试性能稳定了。后来尝试用Off-CPU火焰图,但是只能看到recv的时候有wait,并不能直接看出原因。这种问题没有经验确实很难处理(虽然搜索TCP40ms会有结果)。PHP-CPP的损失PHP-CPP封装了ZendAPI,开发和扩展基本可以忽略Zend引擎的底层(zval、HashTable等),非常方便,但代价是更多的额外操作和性能损失。优化方式很暴力,直接修改PHP-CPP,暴露封装的zval,然后直接用ZendAPI操作。过程是先用PHP-CPP写,然后用火焰图找热点,再换成ZendAPI。比如在nestedForeach方法中,需要获取数组的值。如果你使用PHP-CPP的Value::get(),它会在最后被复制一次:Value::Value(struct_zval_struct*val,boolref){//我们必须强制引用吗?if(!ref){//我们不这样做,只需复制值ZVAL_DUP(_val,val);}批量插入时,会出现不必要的数组重复。所以这里改成zend_hash_find得到*zval,然后直接遍历:zval*item;autocolumn=zend_hash_find(Z_ARRVAL_P(data._val),key);autoht=Z_ARRVAL_P(column);ZEND_HASH_FOREACH_VAL(ht,item){回调(项目);}ZEND_HASH_FOREACH_END();结语国庆假期通过这个项目,让我稍微了解了一点:ClickHousePHP扩展开发,C++CMake性能优化,自己有没有做好:单元测试,本来想用phpt,但是没有写它,目前在tests目录下,有几个我在开发时用到的用例CI。我打算试试GitHubAction。最后,从OrzClick这个名字你应该知道,这是为了玩和学习而写的。生产环境推荐使用SeasClick。