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、getLambdaArgumentTypesIFunctionBase绑定具体参数类型后固化返回类型创建执行对象getReturnType、prepare、getResultTypeIExecutableFunction真正逐批执行数据计算executeImpl、executeWithoutLowCardinalityColumns可以这样理解IFunctionOverloadResolver是函数名的“前台接待”它看了一下来访的参数规格决定派哪个具体实现去干活IFunctionBase是“工单”记录了这次调用的参数类型和返回类型IExecutableFunction是“工人”拿着工单对数据块动手。实际编码时大多数函数并不需要重复实现全部三层。源码里通常的做法是重载解析器继承IFunctionOverloadResolver只实现getFunctionForArguments可执行对象继承IExecutableFunction或更常用的IFunction它内部默认实现了很多转发逻辑重点写getReturnTypeImpl和executeImpl。2.3 函数签名与重载同名函数的隐藏家族ClickHouse里很多同名函数其实是“一个家族”。比如substring既能接受字符串参数也能接受FixedStringif函数更特殊它的两个分支必须返回兼容类型。这些重载不是靠函数名区分而是靠IFunctionOverloadResolver内部对参数类型做模式匹配。阅读源码时最该关注的函数签名信息都在getReturnTypeImpl里。它通过DataTypes参数列表判断当前调用是否合法并计算出返回值类型。这里有个很实用的经验如果你自定义函数的参数类型是String但调用方传入了LowCardinality(String)那么你在解析层通常要先做removeLowCardinality否则执行层拿到的列类型可能不是你预期的。3. 函数执行链路从FunctionFactory到executeImpl3.1 查询解析时发生了什么一条SQL里的函数调用首先会被解析成AST节点例如FunctionNode。接着在查询分析阶段优化器会拿着函数名到FunctionFactory里注册的解析器集合中查找。FunctionFactory是一个单例内部维护了一个std::unordered_mapString, 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_sharedFunctionFoo(); } 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_sharedDataTypeUInt64(); } 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 namemy_udf/name return_typeUInt64/return_type argument nameinput_str/name typeString/type /argument formatTSV/format commandpython3 /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), flushTrue)这种方式的优点是迭代快不需要重新编译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_sharedFunctionHexScore(); } 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_sharedDataTypeUInt64(); } ColumnPtr executeImpl( const ColumnsWithTypeAndName arguments, const DataTypePtr , size_t input_rows_count) const override { const auto col assert_castconst 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_castunsigned char(s.data[j]); res_data[i] acc; } return result_col; } }; void registerFunctionHexScore(FunctionFactory factory) { factory.registerFunctionFunctionHexScore(); } }注意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.registerFunctionFunctionHexScore(); }这还不够。真正的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支持配置常驻模式脚本启动一次后不断从标准输入读取命令块。配合typeexecutable_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你会发现很多概念早已通透。