1. 为什么最终会走到“读函数源码”这一步
我第一次在ClickHouse里正视“源码”这两个字,是因为一段分位数查询的语义和文档对不上。文档里写得很模糊,而实际结果在高并发场景下会因采样参数出现微妙差异。查了半天配置和索引,最后不得不跳到src/Functions里翻聚合函数的实现,才找到问题根源。从那次之后我就发现,想真正驾驭ClickHouse的查询逻辑、做深度调优或者绕过某些框架限制,搞懂函数在源码里如何注册、如何被转发、最终如何落到每一列上执行,比翻一百篇性能调优经验都管用。
1.1 文档之外的行为差异
ClickHouse官方文档覆盖面已经很大了,但函数行为描述往往偏向“你能拿它做什么”,很少解释“它在什么条件下会改变行为”。比如字符串函数里带Nullable和不带Nullable的参数,执行路径完全不同;部分函数在常量参数下会走特殊优化分支,在列参数下又走另一套逻辑。如果不看源码,你很难理解为什么同样的函数,有时快得离谱,有时又慢得让人想砸键盘。
文档不会告诉你哪些函数是“半确定性”的,也不会告诉你now64这类时间函数在不同分布式副本之间可能产生不一致结果。只有打开实现,看到它内部如何标记isDeterministic、如何声明isSuitableForConstantFolding,你才能真正预判一个函数在预聚合、物化视图、分布式查询中的表现。
1.2 EXPLAIN看不到的那一层
日常排查慢查询,大家都会用EXPLAIN PIPELINE、EXPLAIN SYNTAX,但这两个命令只能看到查询计划层面的算子,看不到函数内部的列操作。比如arrayMap里面到底怎么处理数组偏移量,groupArray在内存里如何维护状态,EXPLAIN一概不展示。
这时源码就是唯一的说明书。函数内部的executeImpl会告诉你它拿到的ColumnWithTypeAndName是什么形态,是ColumnVector、ColumnString还是ColumnNullable。看到这一层,很多性能问题就迎刃而解:慢不是函数名称的问题,而是你传入的列类型触发了低效分支。
1.3 系统自带的函数并不够用
实际业务里,总有那么几个函数是ClickHouse原生没有的。可能是某种私有的编码格式解析,可能是和内部系统交互的加解密算法。这时候必须自己写UDF。但写UDF之前如果不动源码,就很容易踩到接口设计的坑:比如不清楚参数类型解析机制,不清楚为什么函数必须同时支持Nullable包装,更不清楚注册表里有哪些隐藏约束。
基于这些真实的痛点,我把自己研究ClickHouse函数源码和自定义UDF的完整过程整理了出来。这篇内容既适合想搞懂函数执行原理的中间层开发者,也适合准备在项目里落地自定义函数的业务团队。
2. 源码里的函数体系:文件组织、注册表与IFunction抽象
2.1 函数文件布局:先找到入口
ClickHouse的绝大多数内置函数实现都集中在src/Functions目录下。不要指望这个目录按函数名整齐排列,真实的布局是“主题相近的文件放一起”。比如array*.cpp是一组,string*.cpp是一组,formatDateTime.cpp、parseDateTimeBestEffort.cpp这类时间处理函数各自独立。
文件名称只是方便人类阅读,真正决定“函数能不能被调用”的是注册机制。ClickHouse在src/Functions/registerFunctions.cpp里集中调用了所有registerFunction<...>(),例如:
void registerFunctions() { registerFunctionArrayFilling(); registerFunctionArrayJoin(); registerFunctionArraySort(); registerFunctionStringFunctions(); // 还有几十个类似的注册入口 }每个具体的registerFunctionXxx()内部,会调用FunctionFactory::instance().registerFunction(...)。所以源码里新增一个函数时,除了写实现类,至少还要做两件事:写注册函数、把注册函数挂到registerFunctions()里。
2.2 IFunction家族的三层抽象
很多刚接触源码的人会被IFunction、IFunctionOverloadResolver、IFunctionBase绕晕。其实这三层的分工非常清晰:
| 抽象层 | 职责 | 典型方法 |
|---|---|---|
| IFunctionOverloadResolver | 根据传入参数的数量和类型,选择合适的函数变体 | getFunctionForArguments、getLambdaArgumentTypes |
| IFunctionBase | 绑定具体参数类型后,固化返回类型,创建执行对象 | getReturnType、prepare、getResultType |
| IExecutableFunction | 真正逐批执行数据计算 | executeImpl、executeWithoutLowCardinalityColumns |
可以这样理解:IFunctionOverloadResolver是函数名的“前台接待”,它看了一下来访的参数规格,决定派哪个具体实现去干活;IFunctionBase是“工单”,记录了这次调用的参数类型和返回类型;IExecutableFunction是“工人”,拿着工单对数据块动手。
实际编码时,大多数函数并不需要重复实现全部三层。源码里通常的做法是:重载解析器继承IFunctionOverloadResolver,只实现getFunctionForArguments;可执行对象继承IExecutableFunction或更常用的IFunction(它内部默认实现了很多转发逻辑),重点写getReturnTypeImpl和executeImpl。
2.3 函数签名与重载:同名函数的隐藏家族
ClickHouse里很多同名函数其实是“一个家族”。比如substring,既能接受字符串参数,也能接受FixedString;if函数更特殊,它的两个分支必须返回兼容类型。这些重载不是靠函数名区分,而是靠IFunctionOverloadResolver内部对参数类型做模式匹配。
阅读源码时,最该关注的函数签名信息都在getReturnTypeImpl里。它通过DataTypes参数列表,判断当前调用是否合法,并计算出返回值类型。这里有个很实用的经验:如果你自定义函数的参数类型是String,但调用方传入了LowCardinality(String),那么你在解析层通常要先做removeLowCardinality,否则执行层拿到的列类型可能不是你预期的。
3. 函数执行链路:从FunctionFactory到executeImpl
3.1 查询解析时发生了什么
一条SQL里的函数调用,首先会被解析成AST节点,例如FunctionNode。接着在查询分析阶段,优化器会拿着函数名到FunctionFactory里注册的解析器集合中查找。FunctionFactory是一个单例,内部维护了一个std::unordered_map<String, Creator>,其中Creator是函数工厂函数,负责创建IFunctionOverloadResolver实例。
这里有个容易忽略的细节:注册表里函数名是大小写敏感的,而且创建函数时不会做参数校验。参数是否正确,是后续交给IFunctionOverloadResolver来处理的。所以你在SELECT FOO(1)时如果报“Unknown function”,多半是大小写或函数名拼写问题。
3.2 从解析器到执行对象
一旦确定了具体的解析器,查询分析器会调用它的getFunctionForArguments,得到两个东西:一个是基类IFunctionBase,一个是可能存在的Lambda参数信息。IFunctionBase会提前计算出返回类型,方便后续的类型检查和常量折叠。
真正跑数据时,表达式执行器拿到IFunctionBase后,调用prepare()创建IExecutableFunction。这个方法很关键,因为有些函数可以在这里对常量参数做预处理,提前生成中间表或者缓存哈希表,避免每一批数据都重复计算。
3.3 executeImpl:所有计算的家
函数计算的核心方法是:
ColumnPtr executeImpl( const ColumnsWithTypeAndName & arguments, const DataTypePtr & result_type, size_t input_rows_count) const;arguments是当前数据块的输入列集合,result_type是提前算好的返回值类型,input_rows_count是当前批处理的行数。函数实现要构造一列相同行数的新列并返回。
因为ClickHouse是向量化执行引擎,executeImpl一次处理一批数据,通常几千到几万行。这意味着你写的循环应该针对数组批量操作,而不是逐行调用函数。比如处理字符串时,写一个for (size_t i = 0; i < input_rows_count; ++i)循环,在循环内部取第i行的指针和数据长度,这样效率远高于逐行创建对象。
一个完整的最小执行对象模板大致长这样:
class FunctionFoo : public IFunction { public: static FunctionPtr create() { return std::make_shared<FunctionFoo>(); } String getName() const override { return "foo"; } size_t getNumberOfArguments() const override { return 1; } bool isSuitableForShortCircuitArgumentsExecution() const override { return true; } DataTypePtr getReturnTypeImpl(const DataTypes & arguments) const override { return std::make_shared<DataTypeUInt64>(); } ColumnPtr executeImpl( const ColumnsWithTypeAndName & arguments, const DataTypePtr &, size_t input_rows_count) const override { const auto & col = arguments[0].column; // 从列里取数据,逐元素计算,构造结果列 auto result_col = ColumnUInt64::create(input_rows_count); auto & data = result_col->getData(); for (size_t i = 0; i < input_rows_count; ++i) { // 计算逻辑 data[i] = 0; } return result_col; } };getNumberOfArguments规定了最少参数个数,如果用了变长参数,就得重写getArgumentsLength相关方法。getName返回的函数名要和注册时保持一致。
3.4 列类型适配:别被Nullable隔断
源码里很多函数会先处理Nullable,再剥壳计算。你如果直接实现executeImpl而不考虑Nullable参数,经常会在运行时收到ColumnNullable,导致getData()断言失败。一个稳妥的做法是在解析层用IFunction的默认包装机制:把你自己的函数包成NullMap包装版本,或者手动判断参数里是否有Nullable列,再决定是否需要做空值过滤。
实际上,ClickHouse自带的函数大量使用FunctionNullableWrapper、ExecutableFunctionNullable这些包装类。它们的工作方式是:如果某一行为空,就不调用底层逻辑,直接把结果对应的空值标记置为1。这种方式既保证了空值语义,又不会让每个函数内部重复处理空值。
4. 自定义UDF的两条路线:源码级函数与外部可执行脚本
4.1 官方UDF的“外部脚本”玩法
ClickHouse官方支持通过EXECUTABLE表函数定义外部UDF。原理很简单:在配置文件中声明一个可执行程序,运行查询时ClickHouse启动该程序,把输入数据通过标准输入按块喂给它,然后从标准输出读取结果。配置文件示例:
<user_defined_executable_functions> <function> <name>my_udf</name> <return_type>UInt64</return_type> <argument> <name>input_str</name> <type>String</type> </argument> <format>TSV</format> <command>python3 /opt/udf/my_udf.py</command> </function> </user_defined_executable_functions>脚本端只需要按格式读入:
import sys for line in sys.stdin: value = line.rstrip('\n') print(len(value), flush=True)这种方式的优点是迭代快,不需要重新编译ClickHouse,适合调试算法原型。缺点也很明显:进程启动和进程间通信开销大,不能向量化,每条记录都要经过序列化/反序列化。实际压测中,同样一个简单的哈希计算,外部UDF的吞吐量通常只有内置函数的几十分之一,延迟也会高出几个数量级。
4.2 源码级UDF:真正性能可控的方案
源码级UDF就是在ClickHouse内部实现一个IFunction,编译进clickhouse可执行文件。它和内置函数完全同权,可以享受向量化执行、常量化折叠、列存访问,以及多线程并行处理。缺点是修改后要重新编译,开发调试链路长,对编译环境和C++水平有一定要求。
生产环境里,凡是核心链路的高频计算,我都建议用源码级UDF。外部脚本适合低频、小数据量、算法频繁变化的场景。两者不是替代关系,而是互补关系。
4.3 选型判据
根据我的经验,可以按下面几张表快速判断:
| 场景 | 选择 | 理由 |
|---|---|---|
| 线上核心计算逻辑 | 源码级UDF | 性能可控,能利用向量化 |
| 临时探索性分析 | 外部脚本UDF | 快速改动,不用编译 |
| 需要引用第三方Python库 | 外部脚本UDF | 直接复用生态 |
| 超大字符串处理 | 源码级UDF | 避免拷贝和序列化开销 |
另外要注意,外部脚本UDF在分布式表查询时,会分发到每个节点分别执行,脚本必须是幂等的、无状态的。否则同一份数据在不同节点上可能算出不同结果。
5. 手写一个生产可用的源码级UDF
为了演示完整的开发链路,我来实现一个相对简单但实用的函数:hexScore,输入一个字符串,输出它内部字节的异或校验值。虽然业务价值不高,但覆盖了参数解析、列读取、结果列构造和注册测试全部环节。
5.1 创建实现文件
在src/Functions下新建FunctionHexScore.cpp。文件结构如下:
#include <Columns/ColumnString.h> #include <Columns/ColumnUInt64.h> #include <DataTypes/DataTypesNumber.h> #include <Functions/IFunction.h> #include <Functions/FunctionFactory.h> namespace DB { namespace ErrorCodes { extern const int NUMBER_OF_ARGUMENTS_DOESNT_MATCH; } class FunctionHexScore : public IFunction { public: static constexpr auto name = "hexScore"; static FunctionPtr create(ContextPtr) { return std::make_shared<FunctionHexScore>(); } String getName() const override { return name; } size_t getNumberOfArguments() const override { return 1; } DataTypePtr getReturnTypeImpl(const DataTypes & arguments) const override { if (arguments.size() != 1) throw Exception("hexScore requires exactly one argument", ErrorCodes::NUMBER_OF_ARGUMENTS_DOESNT_MATCH); return std::make_shared<DataTypeUInt64>(); } ColumnPtr executeImpl( const ColumnsWithTypeAndName & arguments, const DataTypePtr &, size_t input_rows_count) const override { const auto & col = assert_cast<const ColumnString &>(*arguments[0].column); auto result_col = ColumnUInt64::create(input_rows_count); auto & res_data = result_col->getData(); for (size_t i = 0; i < input_rows_count; ++i) { const auto & s = col.getDataAt(i); uint64_t acc = 0; for (size_t j = 0; j < s.size; ++j) acc = acc * 131 + static_cast<unsigned char>(s.data[j]); res_data[i] = acc; } return result_col; } }; void registerFunctionHexScore(FunctionFactory & factory) { factory.registerFunction<FunctionHexScore>(); } }注意create函数的签名在新版本里通常带ContextPtr。如果编译报错函数签名不匹配,看一眼同目录下最近新增函数的写法,保持一致即可。
5.2 注册进函数工厂
在新版本里,ClickHouse的函数注册函数是模块化的,每个registerFunctionXxx由具体.cpp文件导出。在src/Functions/registerFunctions.cpp里找到合适位置,加入:
void registerFunctionHexScore(FunctionFactory & factory);然后在registerFunctions()中调用:
registerFunctionHexScore();如果你在改造一个较老的版本,可能还需要检查src/Functions/CMakeLists.txt,不过现代版本基本都是通配符自动收集源文件,不需要逐个添加。
5.3 编译与最小验证
编译ClickHouse是个体力活,建议用Ninja增量编译:
mkdir -p build && cd build cmake .. ninja clickhouse只要没有接口签名问题,编译完成后就可以启动本地验证:
./build/programs/clickhouse local --query "SELECT hexScore('hello'), hexScore('world')"如果能得到两个不同的稳定值,说明函数已经注册成功。接下来可以在tests/queries/0_stateless目录下新增测试:
SELECT hexScore('') = 0; SELECT hexScore('abc') = hexScore('abc');对应创建同名.reference文件,里面写期望输出。之后用clickhouse-test跑一遍,确保不会破坏已有功能。
5.4 从IFunction到兼容Nullable
上面这个函数有个问题:如果传入NULL,会直接抛异常。现实使用中,表字段经常是Nullable的。要支持空值,最简单的做法是利用系统自带的FunctionNullableWrapper。在executeImpl里保留上面逻辑,同时在注册函数时把它包装成可空的:
void registerFunctionHexScore(FunctionFactory & factory) { factory.registerFunction<FunctionHexScore>(); }这还不够。真正的Nullable支持一般通过IFunction内部提供的默认机制实现:参数列是ColumnNullable时,底层的ColumnString被嵌套在内部,直接assert_cast会失败。你可以先用arguments[0].type判断是否isNullable,再做分支处理。实践中最常见的方式是把IFunction的子类包在一个专门处理Nullable的包装类里面,ClickHouse源码里前缀带“Nullable”的函数基本都是这个套路。
6. 把函数写进生产环境前:性能教训与常见坑
6.1 外部UDF进程启动开销是最大的隐形杀手
不少人第一次用外部UDF时会写一个python3 /tmp/udf.py,结果在线上查询里被调用几十万次,进程启动时间直接吃满CPU。实际上EXECUTABLE支持配置常驻模式:脚本启动一次后不断从标准输入读取命令块。配合type="executable_pool"可以让一组进程常驻复用,大幅降低进程启动开销。
但我还是坚持认为,外部UDF只适合做离线探索。线上服务如果必须用Python生态的算法,建议用executable_pool,并且每次调用返回的数据规模控制在几千行以内,否则进程通信反压会拖垮整个查询。
6.2 函数内创建大量临时对象会让内存追踪形同虚设
源码级UDF虽然性能好,但如果实现时在循环体内频繁构造std::string或std::vector,内存分配器会频繁向arena申请内存。ClickHouse的内存追踪会把这些都算到查询内存里,一旦超过max_memory_usage查询直接失败。
合理做法是复用列缓冲。比如要拼接字符串时,先计算出总长度,再一次性写入结果列:
auto res = ColumnString::create(); res->reserve(input_rows_count); for (...) { res->insertData(...); }而不是每行push_back一个临时字符串再转存。这个习惯对内置函数开发同样适用。
6.3 不要在executeImpl里做跨批次聚合
executeImpl是逐批执行的,如果函数需要在多个批次之间维护状态,比如“累计前3行”“取全列最大值”,在函数内部做是不安全的。ClickHouse不会保证同一个函数的多次executeImpl调用发生在同一线程,也不会保证调用顺序。
正确的思路是:如果要做跨行状态计算,优先用AggregateFunction接口,也就是我们自己实现一个聚合状态类,通过AggregateFunctionXXX注册为聚合函数。自定义UDF里的“U”通常指普通标量函数,不是聚合函数。两者选型完全不同,别混用。
6.4 别忽略ShortCircuit和常量折叠
ClickHouse会尝试对常量参数做计算折叠,也就是说如果某列是常量值,函数会在运行前直接算好结果。前提是函数声明了自己的确定性属性。默认IFunction的isDeterministic是 true,但如果你的函数内部依赖环境变量或者当前时间,必须重写为false。否则一旦被物化视图或投影缓存,结果可能不正确。
同时要注意isSuitableForShortCircuitArgumentsExecution。ClickHouse的短路求值会在计算and、or时跳过某些参数,如果你的函数有副作用(比如日志输出),要谨慎声明。
6.5 注册表里的名字冲突
如果函数名已经存在,注册时ClickHouse会抛出异常。所以自定义函数起名最好加项目前缀,比如app1_hexScore。官方函数大部分是小写,前缀能有效避免冲突。另外,函数名在system.functions里会自动出现,但不会自动同步到其它节点。修改函数逻辑后,所有节点需要同时更新二进制文件,否则分布式查询可能出现不同节点返回不同结果的情况。这一点在做灰度发布时尤其要小心,尽量先升级所有节点再切换SQL。
7. 一个真实场景的排障过程:为什么我的UDF慢得离谱
之前我在某项目中实现过一个自定义函数,用于把订单ID字符串转成订单维度编号并附加哈希。写完编译、注册都很顺利,但上线后单查询耗时从2秒涨到8秒。当时第一反应是并行度不够,后来通过采样分析发现,函数的输入列是LowCardinality(String)。
因为没做低基数字典解包,我的executeImpl里遍历到的行数虽然是实际行数,但每取一次值都会触发字典查找,代价比直接访问ColumnString的连续缓冲区高了几倍。解决办法是在executeImpl入口处对输入做removeLowCardinality,把列转换成普通ColumnString再进行计算。这个优化做完,查询耗时直接降到接近原生函数水平。
这个案例说明,源码级函数开发不仅要关注算法本身,还要理解列类型对底层访问模式的影响。同样一个循环,列类型不同,性能差一个数量级是完全正常的。
8. 关于函数分析的一些个人的体会
读了这么久源码,最大的收获不是学会了写UDF,而是建立起了“查询计划到列操作”的映射直觉。遇到慢查询时,我能大概猜出哪一步是函数实现问题,哪一步是数据分布问题,而不是盲目加索引或者改并行度。
如果你也想从源码层面理解ClickHouse函数体系,建议从最常用的几个函数下手,比如length、substring、equals。先看它们的类型推导逻辑,再看executeImpl如何读取列数据。等你熟悉了ColumnString、ColumnVector、ColumnNullable的关系之后,再去看arrayJoin这类复杂执行器,就顺理成章了。最后再动手写自定义UDF,你会发现很多概念早已通透。