ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OLAP Cube预计算机制:Rowkey、Segment与查询下压实践

OLAP Cube预计算机制:Rowkey、Segment与查询下压实践

简介:Kyligence Enterprise 3.4 中文版 API 与大数据处理文档,面向使用 Apache Kylin 做批处理、微批及流式分析的数据工程师、平台运维与 BI 集成人员,帮助其系统掌握安装部署、元数据存储、Cube 设计与 SQL 查询优化等关键环节。压缩包仅含 1 个 PDF 文件,体积约 96.06MB,内容按目录组织,覆盖 CDH、HDP、MapR、FusionInsight 等环境安装,RDBMS 元数据迁移,集群与多租户部署,Hive、Kafka、RDBMS 数据源导入,以及模型设计、度量、查询下压和 JDBC/ODBC 驱动与 BI 工具集成。已有 529 人学习,适合作为查阅手册和体系化学习材料,便于快速定位配置项、理解 API 用法并辅助排查部署与查询问题。

1. 亚秒级查询的真相:答案在查询发出之前就已经算完了

一份 3.4 版企业级 OLAP 平台的中文手册,最容易被跳过的往往是附在末尾的那份 kylin API 文档式的接口章节。多数人把它当成一张接口清单,实际上它暴露了整套系统的底层逻辑:一次聚合查询能在亚秒级返回,靠的不是查询时算得快,而是查询还没发出去,答案就已经算完并排好序放在磁盘上了。这份手册从头到尾讲的是同一件事——把「算」这个动作从查询时刻挪到构建时刻,以及挪过去之后要付出什么代价。搞懂这套预计算机制的账本,才能判断什么样的查询该交给 Cube,什么样的查询该老老实实下压回底层引擎,什么样的查询根本不该进这套体系。

2. Cube 的预计算账本:10 个维度为什么会裂出上千个 cuboid

2.1 一次 group by 的真实成本

先看一条再普通不过的查询:按省份、城市聚合销售额。在底层数据仓库里,它的执行路径是先把整张事实表扫一遍,按 (province, city) 做一次 shuffle,再在 reduce 侧累加,最后返回几百行结果。事实表假设十亿行、几十个列,即使列存加向量化,扫描成本也在 GB 量级,shuffle 还要再走一遍网络。BI 用户点一次筛选条件就触发一次,交互体验立刻崩掉。

预计算的思路是把这个过程提前:构建阶段就把 (province, city) 的每一种组合算成一行,按固定顺序排列好,压进一个列式存储文件。查询到来时,引擎不需要扫描原始数据,只需要根据 SQL 里的维度拼出一个前缀,到排好序的文件里 seek 到起点,顺序读若干行就能返回。原本 O(数据量) 的工作变成了 O(结果集大小)。

代价藏在组合数里。一个 Cube 有 N 个维度,理论上有 2^N 种维度子集,每个子集叫一个 cuboid,对应一种可能的 group by 组合。全部物化出来的空间开销大致是这样一条曲线:

维度数 Ncuboid 数 2^N每维度基数 10 时的全量行数
532约 10 万
101024约 100 亿,实际不可行
1532768天文数字

10 个维度就是 1024 个 cuboid,如果每个维度基数都不低,全量物化根本存不下。所以「Cube」这个名字容易让人误解成一块完整的东西,实际上它天生是稀疏的、被裁剪过的。裁剪靠什么,是第 3 章的事;先把 Rowkey 和 Segment 这两块地基说清楚,否则后面全是空中楼阁。

2.2 Rowkey 编码:维度顺序就是性能顺序

Cube 之所以能靠 seek 命中,是因为每一行数据都按 Rowkey 的字节序物理排序。Rowkey 由各维度的字典编码按定义顺序拼接而成,每条记录的维度值不再是原始字符串,而是一个定长整数。字典编码的好处有两层:一是把变长字符串压成定长(通常是 2 字节或 4 字节),二是保证编码后的数值序和原始字符串序一致,前缀扫描才有意义。

def build_rowkey(dimension_order, row, dicts): """按 Cube 定义里维度的先后顺序拼接 Rowkey。 dimension_order: 维度名列表,顺序即 Rowkey 前缀顺序 dicts: 每列的字典,负责 值 -> 定长整数 编码 """ parts = [] for dim in dimension_order: raw = row[dim] code = dicts[dim].encode(raw) if code is None: code = dicts[dim].null_code # 空值落到保留编码,避免破坏排序 width = dicts[dim].byte_width # 2 或 4 字节 parts.append(code.to_bytes(width, byteorder='big')) return b''.join(parts)

这段逻辑有几个容易被忽视的点。byteorder='big'不是随意选的,只有大端序才能让「字节比较结果」等于「数值比较结果」,底层存储的 key 比较器才能直接按字节比大小,不需要反序列化。null_code要单独占一个保留值,如果空值编码成 0 而 0 又是某个真实值的编码,排序结果就会串位。byte_width取决于该维度的基数,基数超过 65535 就得用 4 字节,这一步在构建字典时确定,事后改不了。

真正决定查询性能的是dimension_order。假设 Rowkey 是 (province, city, category),那么group by province的查询只需要扫描 province 前缀连续的那一段;而group by city没有前缀可用,引擎只能全扫 Cube 再做一次聚合,这时候 Cube 的优势几乎归零。我在实际项目里见过最典型的一次翻车,就是建模的人按业务表的字段顺序排了 Rowkey,把高基数的订单号排在最前面,结果所有按地区聚合的查询都在扫全表——Cube 建了,查询却比以前更慢。

提示:Rowkey 设计的一般原则是低基数、过滤频率高、常作为 group by 首列的维度放前面;高基数维度尽量往后放,或者干脆不要放进 Cube。

2.3 Segment:Cube 是一串时间切片,不是一张表

Cube 是逻辑概念,物理上它被切成若干 Segment,每个 Segment 覆盖一段时间区间。按天构建就是一天一个 Segment,按月构建就是一个月一个。这么设计的原因很实际:业务数据是持续增长的,如果每次有新数据就全量重算,构建成本会随历史数据线性上涨,谁也扛不住。切成 Segment 之后,新数据只构建新 Segment,历史 Segment 原地不动。

Segment 有自己的生命周期。刚创建时是 NEW,构建中变成 BUILDING,成功后 READY;两个相邻的小 Segment 可以合并成一个大的,状态变成 MERGED;出问题可以 DISABLED 下线,也可以 REFRESH 重算。查询时引擎先看 SQL 里的时间过滤条件,从元数据里取出每个 Segment 的起止时间,只有被时间范围覆盖到的 Segment 才会进入扫描计划。一条查最近三天的 SQL 只会碰三个 Segment,哪怕历史上有三年数据。

流式场景把这个模型压到了极限。Kafka 的数据源源不断,只能按微批切成小 Segment,可能几分钟一个。Segment 太小会带来读放大:查询要打开几十个文件、对多份聚合结果再做一次合并。所以流式 Cube 必须配一个定期合并策略,把碎片 Segment 合成大块。这一步没配好,查询延迟会随运行时间稳步上升,而且很难从监控上看出来——这是一个典型的「黑匣子」问题,表面指标都正常,就是越来越慢。

3. 聚合组与优化器:2^N 的组合爆炸靠什么被剪掉

3.1 三类维度约束:把 2^N 变成可接受的数量

既然全量物化不现实,就要给维度加约束,让引擎知道哪些 cuboid 组合在业务上根本不会出现。手册里给了三类约束,理解它们的关键是算清楚各自砍掉了多少组合。

强制维度(Mandatory)指每个 cuboid 都必须包含的维度。比如日期分区字段,任何查询都会带时间范围,那么不含日期的 cuboid 全是废的。N 个维度里标了 1 个强制维度,组合数从 2^N 直接降到 2^(N-1)。

层级维度(Hierarchy)描述的是有包含关系的字段,省-市-区是标准例子。业务上不会出现「只按区、不按省市」的查询,所以合法的组合只有 (省)、(省,市)、(省,市,区) 三种,加上空集一共 4 种。n 个层级维度从 2^n 降到 n+1。三个维度是 8 变 4,五个维度是 32 变 6,维度越多省得越狠。

联合维度(Joint)要求一组维度同进同退,要么全出现要么全不出现。典型的场景是订单的多个属性字段,业务只按整体筛选,不会单独 group by 其中一个。n 个联合维度从 2^n 降到 2。

约束类型3 个维度的组合数5 个维度的组合数适用场景
无约束832各维度独立使用
层级维度46地理、时间、品类层级
联合维度22强绑定的属性组
强制维度4(2^2)16(2^4)必然出现的时间分区

这三类约束叠加使用,效果是乘法级的。一个原本 15 个维度、32768 个 cuboid 的 Cube,加上两个层级组、一个联合组、一个强制维度,能压到几十上百个,构建时间和存储都回到可接受范围。约束本身不难配,难的是判断哪些维度真的存在层级或绑定关系——这需要对业务查询日志有实打实的了解,拍脑袋配出来的约束,通常会在某次临时分析里被打破,然后你会收到一条「为什么这个查询突然变慢了」的告警。

3.2 优化器的剪枝账:按收益成本比贪心

约束是人给的,优化器是在约束之后再做一轮自动筛选。它的输入是每个 cuboid 的预估行数和预估查询收益,输出是一份最终要物化的 cuboid 清单,受「最大维度组合数」这个配置项封顶。

def select_cuboids(candidates, max_count, stats): """贪心选择要物化的 cuboid。 candidates: 经维度约束过滤后的候选集合 max_count: 配置项上限,防止组合数失控 stats: 每个 cuboid 的预估行数、预估被查询命中的频率 """ scored = [] for cb in candidates: rows = stats.estimate_rows(cb) # 预估该 cuboid 的输出行数 hit = stats.estimate_hit_ratio(cb) # 历史查询里能被它命中的比例 cost = rows * stats.row_width(cb) # 存储 + 构建成本 scored.append((hit / max(cost, 1), cb)) scored.sort(reverse=True) # 收益成本比从高到低 chosen = [] for score, cb in scored: if len(chosen) >= max_count: break if any(is_covered(cb, c) for c in chosen): continue # 已被更大粒度的 cuboid 覆盖,跳过 chosen.append(cb) return chosen

is_covered这一步是省空间的关键:如果已经物化了 (省,市,区),那么 (省,市) 可以在这个 cuboid 上再聚合一次得到,不需要单独存一份——只要代价可接受。反过来,如果一个 cuboid 谁都覆盖不了它,而它的预估命中率又很低,那它就会被排到队尾,最终被max_count挡在门外。

这套贪心的局限也很明显:它依赖统计信息,而统计信息来自对源数据的采样。源数据分布突变(比如某天开始大量新增一个城市的订单)会让预估失效,被剪掉的 cuboid 恰恰成了热点。所以优化器给出的清单不是终点,它只是一个起点,实际跑一段时间后要拿查询日志回头校验,把频繁落空的高频查询所对应的维度组合补回去。

3.3 度量设计:精确去重为什么贵,近似去重为什么敢

维度和度量是 Cube 的两条腿。维度决定怎么分组,度量决定每组算出来的是什么。求和、计数这类可累加的度量最好办,预聚合的结果直接相加就行。麻烦的是去重计数(UV 类指标),它不满足可加性:两天的 UV 相加不等于两天的总 UV。

精确去重的做法是把每个分组下的明细值编码成 Bitmap。查询时把多个 Segment 的 Bitmap 做按位或,再数 1 的个数。结果绝对精确,代价是内存和维度值基数强相关——基数上亿的 ID 列,Bitmap 会膨胀到几百 MB 一个分组,构建阶段极容易把 executor 撑爆。

-- 近似去重:用 HyperLogLog 换空间,误差可控 SELECT province, APPROX_COUNT_DISTINCT(user_id) AS uv FROM sales_cube WHERE dt BETWEEN '2024-01-01' AND '2024-01-31' GROUP BY province; -- 精确去重:结果准确,但要求被去重列能映射成整型 SELECT province, COUNT(DISTINCT user_id) AS uv_exact FROM sales_cube WHERE dt BETWEEN '2024-01-01' AND '2024-01-31' GROUP BY province;

近似去重的底层是 HyperLogLog,每个分组固定占一小块内存(精度参数通常取 12~14,对应误差约 1.6%~0.8%),跟基数完全解耦。它敢用的前提是业务能接受误差:做趋势看板、做同比环比,1% 的偏差没人看得出来;做财务报表、做结算对账,那就必须走精确路径。

TopN 度量是另一个思路:不存全部明细,只存每个分组里排名前 N 的值和计数,查询时再对多个 Segment 的结果做一次归并。它适合「各部门销售额前 10 的商品」这类固定模式的查询,但 N 一旦设大,存储会迅速膨胀。

半累加度量(Beta)处理的是另一类问题:期末余额、库存量这种指标,跨时间相加没有意义,只能取时间维度上最后一个非空值。这类度量在 Cube 里需要专门的聚合函数支持,用普通 sum 会得到完全错误的数字,而且错得很隐蔽——数字看起来很正常,只是没人发现它不对。

4. 构建引擎与查询下压:Cube 接不住的查询甩给谁

4.1 Flat Table:把星型模型拍平在构建阶段

Cube 构建的第一步不是算聚合,而是把事实表和它关联的维度表 join 成一张宽表,行业里叫 Flat Table。这一步的意义在于把 join 的成本从查询时挪到构建时:查询阶段面对的已经是一张所有维度字段都在同一行上的大宽表,不需要再做任何关联。

代价是维度表的变更会失效。给维度表加一个字段、改一个字段的值,对应的 Flat Table 都得重刷。维度表如果变化频繁,这个模型会很痛。常见的缓解办法是把缓慢变化维度单独建模,用版本号或生效时间做区分,让变更只影响新增的分区而不是整表重刷。

Flat Table 的体积通常比原始事实表大不少,因为维度字段被冗余进了每一行。它一般以中间表的形式落在数据仓库里,构建结束后可以选择保留或清理,保留的好处是排查问题时能直接查这张表对照结果。

4.2 Spark 构建的分阶段执行

构建引擎外包给 Spark 之后,整个流程被拆成几个可以独立重试的阶段。下面这段提交参数是个典型配置,重点看几个资源项的含义:

spark-submit \ --class org.example.cube.BuildJob \ --master yarn \ --deploy-mode cluster \ --executor-memory 8g \ --executor-cores 4 \ --num-executors 20 \ --conf spark.sql.shuffle.partitions=800 \ --conf spark.yarn.executor.memoryOverhead=2g \ --conf spark.dynamicAllocation.enabled=true \ build-job.jar \ --cube sales_cube --segment 20240101-20240131

spark.sql.shuffle.partitions是最常被忽略的一项。默认值往往偏小,在几十 GB 的聚合场景下会导致单个 task 处理的数据量过大,表现为某个 stage 卡在 99% 长时间不动,最后 OOM。经验值是总 shuffle 数据量除以 200MB 左右。memoryOverhead要留足,字典编码这类操作会大量使用堆外内存,不给足会看到 executor 被 YARN kill 而日志里没有任何异常堆栈。

阶段划分大致是:抽取源数据生成 Flat Table;对每个维度列做 distinct 生成字典并编码;按优化器给出的 cuboid 清单逐个做聚合,产出排好序的中间文件;转换成列式存储格式上传到分布式文件系统;最后更新元数据、把 Segment 状态置为 READY。每个阶段有独立的输入输出快照,某个阶段失败重试时不会读到被污染的中间结果。

这里还有一个容易踩的点:构建期间源表如果还在写入,不同阶段读到的数据可能不一致。成熟的实现会在构建开始时锁定一个数据快照(按分区或按时间戳),后续所有阶段都从这个快照读,保证产出的 Cube 内部自洽。

4.3 查询下压:Cube 答不上来时的兜底路径

不是所有查询都能被 Cube 回答。用户可能用了一个 Cube 里没有的维度、算了一个没有预定义的度量、写了 Cube 不支持的函数。这时候系统面临一个选择:直接报错,还是把查询转发给底层的查询引擎去算。

def route_query(sql, cubes, pushdown_enabled): """查询路由:先找能覆盖的 Cube,找不到再看是否允许下压。""" parsed = parse(sql) for cube in cubes: if not cube.model_matches(parsed): # 表、join 关系是否匹配 continue if not cube.covers(parsed): # 维度、度量、函数是否全覆盖 continue return RewriteToCube(parsed, cube) # 命中,改写成 Cube 查询 if pushdown_enabled: return PushDown(parsed, target=get_pushdown_engine(parsed)) return Error("no realization found for this query")

covers的判定比想象中严格:维度全在、度量全在还不够,SQL 里用的函数、过滤条件的写法、甚至聚合的嵌套层级都要能映射到 Cube 的表达能力上。一个having子句写法和预定义的不一致,就可能让整个查询落空。

下压开关是把双刃剑。打开它能保证所有查询都有结果,用户不会看到报错,但代价是交互式 BI 悄悄变成了批处理——原本 0.2 秒的看板刷新变成 3 分钟,用户以为系统卡了,其实是查询被转发到了底层引擎做全表扫描。更麻烦的是这类慢查询会占用集群资源,几个并发的下压查询就能把队列堵死,影响所有正常任务。

比较务实的做法是分层:对交互式报表的连接禁用下压,让不支持的查询直接失败,倒逼建模侧补维度;对探索性分析、临时取数的连接保留下压,但配独立的资源队列和超时限制。至于允许下压到哪种引擎、超时怎么设,手册里通常会有单独的配置项,值得逐条过一遍再上线。

4.4 流式构建与微批的读放大

流式数据源的构建走的是另一条路:消息队列里的数据按时间窗口切成微批,每批产出一个很小的 Segment。好处是延迟低,分钟级就能查到新数据;坏处是 Segment 数量增长快,查询时要打开大量文件并做多路归并。

这个问题没有一劳永逸的解法,只能靠合并策略压住。常见配置是「保留最近 N 个小 Segment 保证实时性,更早的按小时或按天合并成大 Segment」。合并本身也消耗资源,频率设太高会跟构建任务抢资源,设太低读放大会拖慢查询。上线前最好拿真实数据量压一遍,看看合并周期多长时查询延迟开始明显上升。

5. 排查清单:五条手册里查不到的翻车记录

5.1 Cube 构建成功,但查询永远不命中

现象:Segment 状态是 READY,数据也确认写进去了,但查询日志里显示每次都走了下压或直接报错。

原因通常出在元数据缓存上。查询引擎为了性能会缓存模型和 Cube 的元数据,新构建的 Segment 在缓存里还是旧视图。另一种可能是时间分区对不上:查询里的时间字段和 Cube 定义里用于分区裁剪的字段不是同一个,引擎无法判断该扫哪些 Segment,索性判定为无法命中。

解决方式是先在界面上手动刷新元数据缓存,再检查 Cube 定义里的分区字段和查询条件里的过滤字段是否一致。如果是增量构建,还要确认新 Segment 的时间区间没有和老 Segment 重叠,重叠会导致查询规划阶段直接放弃。

5.2 给 Cube 加了一个维度,构建时间从 20 分钟涨到 6 小时

现象:只加了一个普通维度,cuboid 数量翻倍,构建时长涨了一个数量级,还伴随着频繁的 executor OOM。

原因是没有同步调整维度约束。新增维度如果基数很高,又没被归入任何层级组或联合组,优化器的候选集直接翻倍,而max_count上限没变,选出来的组合反而更分散,每个组合的数据量都不小,shuffle 压力集中爆发。

解决方式是加维度之前先评估基数,能归入现有层级组的就归进去,不能归的考虑是否真的需要进入 Cube。同时把max_count和 shuffle 分区数一起调,别只改一个。

5.3 元数据从分布式存储迁到关系型数据库后,重启丢了一半

现象:迁移脚本跑完,界面上 Cube 和模型都在,重启服务后一部分 Cube 消失了,或者模型关联的维度字段变成空。

原因是迁移不是简单搬表,元数据里存在跨实体的引用关系(模型引用数据源、Cube 引用模型、Segment 引用 Cube)。按表逐个导出导入,遇到外键引用顺序错乱就会丢数据。另外连接池配置没跟着调整,迁移后并发一高就报连接超时,写入中途失败也会造成半截状态。

解决方式是迁移前停写、按依赖顺序导出、迁移后做一次全量元数据一致性检查(手册的运维章节里有对应的检查工具),并且把连接池上限按实际并发量重设。

5.4 通过 REST 接口触发构建返回成功,任务却一直卡在 pending

现象:接口返回 200,任务 ID 也能查到,但状态几个小时不动,资源监控上看不到任何计算任务起来。

原因是任务提交成功不等于任务被调度。任务引擎把构建请求放进队列后,要等 YARN 分配资源才能实际执行;队列配置错误、资源组被占满、或者提交任务的用户没有对应队列权限,任务就会一直排队。接口层只负责入队,它返回的成功只代表请求被接受。

解决方式是先查任务详情里的排队原因字段,再核对资源队列的容量和使用情况。如果队列名写错了,任务不会报错,只是永远排不上,这类问题排查起来最耗时间。

5.5 行级、列级权限配好了,BI 工具里还是看到全部数据

现象:在平台上给用户配了行级过滤条件,切到 BI 工具查询,返回的却是全量数据。

原因是权限校验发生在查询入口,而 BI 工具走的连接用的是另一个账号。更隐蔽的一种情况是查询命中了缓存:缓存 key 里没有带上用户身份,前一个高权限用户查过的结果被复用给了受限用户。

解决方式是先确认 BI 连接使用的账号是否就是配了权限的那个,再检查查询缓存的 key 是否包含用户或权限上下文。如果缓存实现没有隔离身份,那就要么关掉这类查询的缓存,要么在缓存 key 里加入权限指纹——这是安全事故,不是性能问题。

6. 预计算与即席的取舍:哪些查询不该塞进 Cube

预计算的本质是用存储和构建时间换查询延迟,这个交换有明确的适用边界。高基数维度(用户 ID、订单号)、几乎不重复出现的过滤条件、随时变化的临时探索,这些放进 Cube 只会让构建成本爆炸而命中率极低。反过来,固定的报表口径、稳定的维度组合、反复被同一批看板查询的指标,才是 Cube 最该吃下的部分。

验证 Cube 是否可信,我习惯做两件事。一是对拍:随机抽几个时间区间,把 Cube 的查询结果和直接查底层明细表的结果做全量比对,count、sum、去重数逐项核。二是压测建模假设:拿历史查询日志跑一遍,统计有多少查询能被当前 Cube 覆盖,覆盖不了的里面有多少是通过下压兜底的——下压占比超过两成,说明模型设计和真实查询模式已经脱节了。

再往前一步是自动化。手工在界面上点构建、点合并、导出元数据,规模一上来必然出错。手册最后的接口章节里,项目、模型、Cube、Segment、任务这几组资源其实是同一套元数据模型的不同视图,用接口把它们串成流水线,比在界面上操作可控得多:构建失败能自动重试,Segment 数量超阈值能自动合并,元数据能定期导出做备份。接口的返回结构和元数据实体是一一对应的,读懂了实体关系,接口基本不用逐个查文档。

从那以后我每次上线一个新 Cube,都强制走一遍「日志覆盖率统计 → 抽样对拍 → 下压占比监控」这三步,哪怕业务方催得再急也不跳过。Cube 这东西建起来容易,建错了往往要等某个季度末的报表对不上账才会被发现。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进