StarRocks 中 PERCENTILE_CONT 函数详解:线性插值百分位计算的用法与源码实现
【免费下载链接】starrocksThe world's fastest open query engine for sub-second analytics both on and off the data lakehouse. With the flexibility to support nearly any scenario, StarRocks provides best-in-class performance for multi-dimensional analytics, real-time analytics, and ad-hoc queries. A Linux Foundation project.项目地址: https://gitcode.com/GitHub_Trending/st/starrocks
本文围绕 StarRocks 的PERCENTILE_CONT聚合函数展开:先给出函数语法、参数约束、返回值语义与完整示例,再结合 FE 的类型检查与 BE 的聚合实现源码,解释“线性插值”在引擎内部如何落地、分布式聚合中中间状态如何序列化与合并,帮助读者既能直接在生产查询中正确使用该函数,也能理解其底层算法与性能设计。
一、函数定位与语法
PERCENTILE_CONT是 StarRocks 提供的精确百分位聚合函数,用于计算一组值中某个百分比位置(percentile)上的取值。与PERCENTILE_APPROX等近似函数不同,它给出精确结果:当没有输入值恰好落在目标百分位上时,会对相邻的两个输入值做**线性插值(linear interpolation)**来得到结果。
函数语法如下:
PERCENTILE_CONT(expr, percentile)参数说明(与官方文档 percentile_cont.md 保持一致):
| 参数 | 说明 |
|---|---|
expr | 用于排序取值的表达式,必须是数值类型、DATE或DATETIME。例如想求物理成绩的中位数,就传入物理成绩列。 |
percentile | 目标百分位,取值范围为[0, 1]的常量浮点数。例如求中位数时传0.5。 |
返回值:返回指定百分位位置上的值。如果没有任何输入值恰好位于该百分位处,结果由最接近目标位置的两个输入值线性插值计算得出。
使用注意:该函数忽略 NULL 输入——NULL 值不会参与排序,也不会影响百分位计算。
二、示例:按科目求中位数
假设存在一张exam表,数据如下:
SELECT * FROM exam ORDER BY Subject; +-----------+-------+ | Subject | Score | +-----------+-------+ | chemistry | 80 | | chemistry | 100 | | chemistry | NULL | | math | 60 | | math | 70 | | math | 85 | | physics | 75 | | physics | 80 | | physics | 85 | | physics | 99 | +-----------+-------+计算每个科目的中位数(percentile = 0.5),NULL 会被自动忽略:
SELECT Subject, PERCENTILE_CONT(Score, 0.5) FROM exam GROUP BY Subject;结果:
+-----------+-----------------------------+ | Subject | percentile_cont(Score, 0.5) | +-----------+-----------------------------+ | chemistry | 90 | | math | 70 | | physics | 82.5 | +-----------+-----------------------------+逐条解读这个结果,可以更直观地理解插值规则(设去 NULL 后有n个值,u = (n - 1) × percentile,index = floor(u),结果 =a[index] + (u - index) × (a[index+1] - a[index])):
- chemistry:有效值
[80, 100],u = 1 × 0.5 = 0.5,结果 =80 + 0.5 × (100 - 80) = 90; - math:有效值
[60, 70, 85],u = 2 × 0.5 = 1,index = 1,恰好命中a[1] = 70,无需插值; - physics:有效值
[75, 80, 85, 99],u = 3 × 0.5 = 1.5,结果 =80 + 0.5 × (85 - 80) = 82.5。
这也解释了为什么对整数输入,结果可能出现小数(如82.5)——插值会落在两个相邻整数之间。
三、FE 侧:函数注册与参数校验
从源码结构看,PERCENTILE_CONT在 FE 中作为内置聚合函数注册,签名覆盖DATE、DATETIME和DOUBLE输入(输入类型 + 第二个DOUBLE常量参数),中间态类型为VARBINARY:
// PercentileCont addBuiltin(AggregateFunction.createBuiltin(FunctionSet.PERCENTILE_CONT, Lists.newArrayList(DateType.DATE, FloatType.DOUBLE), DateType.DATE, VarbinaryType.VARBINARY, false, false, false)); addBuiltin(AggregateFunction.createBuiltin(FunctionSet.PERCENTILE_CONT, Lists.newArrayList(DateType.DATETIME, FloatType.DOUBLE), DateType.DATETIME, VarbinaryType.VARBINARY, false, false, false)); addBuiltin(AggregateFunction.createBuiltin(FunctionSet.PERCENTILE_CONT, Lists.newArrayList(FloatType.DOUBLE, FloatType.DOUBLE), FloatType.DOUBLE, VarbinaryType.VARBINARY, false, false, false));见 FunctionSet.java。中间态统一为VARBINARY,意味着该函数走的是“部分聚合 → 序列化 → 最终聚合”的多阶段执行路径,这也正是后文 BE 序列化/合并逻辑存在的原因。
在类型检查阶段,优化器对第二个参数有额外约束——必须是常量。TypeChecker.java 中可以看到:
case PERCENTILE_CONT: if (!isMergeAggFn) { checkColType(arguments.get(0), aggCall, definedTypes[0], argTypes.get(0)); if (argTypes.size() == 2) { checkArgument(aggCall.getArguments().get(1).isConstant(), "%s want constant arg in %s, but input is %s", PREFIX, aggCall, aggCall.getArguments().get(1)); } } break;因此PERCENTILE_CONT(Score, p)中p若为列或非常量表达式,会在计划校验阶段直接报错。这解释了文档中 “It is a constant floating-point number from 0 to 1” 的约束来源。函数名在 FunctionSet.java 中定义为常量PERCENTILE_CONT = "percentile_cont",FunctionAnalyzer.java 等位置也将其纳入聚合函数分析流程。
四、BE 侧:百分位状态与线性插值实现
BE 中该函数由模板类PercentileContAggregateFunction实现,位于 percentile_cont.h,并在 aggregate_resolver_others.cpp 中按输入类型注册:
"percentile_cont", false, AggregateFactory::MakePercentileContAggregateFunction<TYPE_DOUBLE>()); "percentile_cont", false, AggregateFactory::MakePercentileContAggregateFunction<TYPE_DATETIME>()); "percentile_cont", false, AggregateFactory::MakePercentileContAggregateFunction<TYPE_DATE>());4.1 聚合状态:items、grid 与 rate
每个分组的状态PercentileState由三部分组成(见 percentile_cont.h):
template <LogicalType LT, typename = guard::Guard> struct PercentileState { ItemType items; // 本节点本地收到的原始值 GridType grid; // 从其他节点 merge 过来的、已排序的“行”(每行两端带哨兵) double rate = 0.0; // 目标百分位 };- 本地更新时,
update()把值直接追加进items(update_batch()对整列做memcpy批量追加); - 分布式合并时,
merge()把对端发来的有序数据追加为grid中的新行,不重新整体排序,把排序成本推迟到finalize阶段用归并解决。
init_state_if_needed()中实现了对第二个参数的运行期校验(见 percentile_cont.h):参数个数必须为 2,否则报错Percentile rate is required;rate必须落在[0, 1],否则报错Percentile rate must be between 0 and 1。
4.2 线性插值的精确公式
finalize阶段的核心计算(见 percentile_cont.h):
double u = ((double)rowsNum - 1) * rate; auto index = (size_t)u; // ... ResultType result = calculateResult<LT, InputCppType, ResultType>(junior_elm, senior_elm, u, index);其中rowsNum是去 NULL 后的总行数,junior_elm/senior_elm是有序序列中第index与第index + 1个元素。插值公式在 calculateResult 中按类型分支实现:
} else if constexpr (lt_is_arithmetic<LT>) { result = junior_elm + (u - (double)index) * (senior_elm - junior_elm); }- 数值类型:标准线性插值,结果可能为小数;
DATE类型:按儒略日差值插值(result._julian = junior._julian + (u - index) * (senior._julian - junior._julian));DATETIME类型:按 Unix 秒插值(from_unix_second(junior.to_unix_second() + (u - index) * (senior.to_unix_second() - junior.to_unix_second())))。
边界情形也有专门处理:items.size() == 1或rate == 1时直接返回最大/唯一值,rate == 0时直接返回最小值。
此外,从源码结构看,数值类型的最终输出类型由 PercentileResultLT 决定:对算术类型特化为TYPE_DOUBLE,与示例中82.5这类小数结果相吻合;DATE/DATETIME则保持原类型返回。
4.3 多段有序数据的归并查找:败者树 k 路归并
由于merge阶段只追加已排序的行而不整体重排,finalize需要在items(未排序)与grid(若干有序行)之间高效地定位第index与第index+1个元素。实现上,items在序列化前会被 pdqsort 排序(见 serialize_to_column 中的pdqsort调用),最终所有数据段都是有序的,kWayMergeSort 用**败者树(loser tree)**做 k 路归并,并在数到第goal/goal+1个元素时提前终止,避免做完整归并:
- 每一行被组织为
[min哨兵, 升序数据…, max哨兵]布局; - 代码注释特别说明:行尾和虚拟叶子不能按“值”判断(真实数据可能恰好等于类型极值,如 DATE 的
0000-01-01/9999-12-31),必须按“位置”判断,否则可能读越界; - 当
rate > 0.5且数据量较大时,reverse = true,即从序列尾部反向归并——因为目标位置靠近末尾,从尾部数只需走rowsNum - 1 - u步就能到达,显著减少归并步数(见 percentile_cont.h)。
这一设计意味着:求 99 分位(rate = 0.99)时归并从尾部正向推进,而求 1 分位时从头部推进,归并的工作量总是与min(u, rowsNum - 1 - u)量级相关,而不是与总行数线性相关。
4.4 分布式中间态的序列化格式
BE 中间态序列化为VARBINARY字节串,格式为rate(double) + total_items(size_t) + 全部元素(定长类型),见 serialize_to_column:
// should serialize: rate_size, vector_size, all vector element. size_t new_size = old_size + sizeof(double) + sizeof(size_t) + total_items_size * sizeof(InputCppType);反序列化时merge()会先校验头部长度、元素数量是否会溢出(Invalid percentile_cont merge data: ...等防御性错误),把 payload 复制为一行并加上 min/max 哨兵后放入grid,从而保证多阶段聚合的合并结果与单机一次性聚合完全一致。convert_to_serialize_format()则用于非聚合上下文,把每个输入行编码为[rate, 1, element]的单元素中间态。
五、与 percentile_disc 的区别(同文件实现)
同一个头文件还实现了PERCENTILE_DISC(见 PercentileDiscAggregateFunction),两者差异在于:percentile_disc不做插值,而是取有序序列中ceil((n-1) * rate)位置上真实存在的输入值(“choose the uppper one”),且其结果类型与输入类型完全一致。文档 percentile_disc 中对其有单独说明,实际选型时可按“允许小数/插值结果(cont)还是必须取原始值(disc)”来区分。
六、相关测试与验证路径
- BE 表达式测试:be/test/exprs/percentile_functions_test.cpp 覆盖
percentile_empty、percentile_hash等基础路径; - FE 分析/计划测试:AnalyzeAggregateTest.java、AggregateTest.java 中包含
percentile_cont的解析与计划生成用例,可用于确认函数在不同输入类型下的行为。
七、小结与使用建议
PERCENTILE_CONT(expr, p)求精确百分位,p必须是[0,1]内的常量;输入支持数值、DATE、DATETIME,NULL 被忽略。- 结果语义为线性插值:数值输入的结果类型在实现中会呈现为
DOUBLE,可能出现小数(如示例中的82.5);要求结果必须是数据集中真实存在的值时,应改用PERCENTILE_DISC。 - 从源码看,其实现采用“本地收集 + 有序行归并”的聚合状态:多阶段聚合通过
rate + count + payload的字节格式序列化,最终用败者树 k 路归并 + 提前终止(高百分位时反向归并)来定位目标元素,在大数据量下避免了全量排序的开销。 - 若只需近似结果且对延迟敏感,可对比仓库中的
PERCENTILE_APPROX/PERCENTILE_APPROX_WEIGHTED(同见 FunctionSet.java 中的注册逻辑),在精度与性能之间做取舍。
【免费下载链接】starrocksThe world's fastest open query engine for sub-second analytics both on and off the data lakehouse. With the flexibility to support nearly any scenario, StarRocks provides best-in-class performance for multi-dimensional analytics, real-time analytics, and ad-hoc queries. A Linux Foundation project.项目地址: https://gitcode.com/GitHub_Trending/st/starrocks
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考