1. 树形结构为什么要展开:从组织架构到评论盖楼的实际场景
很多数仓同学第一次看到“hive实现树形结构展开”这个需求时,心里想的都是:这不就是递归查询吗?但真正动手就会发现,Hive 在这方面比常见的关系型数据库麻烦得多。先别急着写 SQL,我们要先搞清楚“展开”到底要展开成什么样。
1.1 一张父子表到底能表达什么
我在某电商项目里处理过不少这类表,最常见的就是商品类目表。表结构并不复杂,核心就三个字段:
create table ods_category ( category_id string comment '类目ID', parent_id string comment '父类目ID,根类目为NULL', category_name string comment '类目名称' ) partitioned by (dt string);数据长这样:一级类目“男装”下挂“上衣”,“上衣”下挂“T恤”,“T恤”下挂“短袖T恤”。这种结构就是典型的树。树形结构展开通常有两种目标需求。
第一种是“路径展开”:希望把每条根到叶的路径输出成一行或一个数组,比如男装 -> 上衣 -> T恤 -> 短袖T恤,这样报表里可以直接做类目层级筛选、透视。
第二种是“关系展开”:希望得到一张宽表,里面记录“每个节点拥有哪些祖先/后代”“两个节点之间的层级距离是多少”,这样后续做统计时不需要每层 JOIN,直接查这张关系表就行。
这两种需求在数仓里特别常见。除了商品类目,还有区域维表(省、市、区、街道)、组织架构表(集团、公司、部门、小组)、评论回复楼中楼(评论、回复、回复的回复)。它们的数据规模、树的深度、变更频率不一样,但展开思路是相通的。
1.2 Hive 为什么不能像关系型数据库那样直接递归
如果你用过 MySQL 8,可能会想当然地写WITH RECURSIVE ...。Hive 没有这个语法。Hive 的 CTE(WITH 子句)是普通 CTE,不是递归 CTE,你没法在 WITH 里面引用自己。
很多人一开始不理解为什么 Hive 不提供递归语法。往深了说,Hive 的底层是把 SQL 翻译成 MapReduce 任务,而递归查询意味着每一层计算都依赖上一层的结果,任务之间天然有串行依赖,这与 Hive 擅长的“大规模并行处理”是冲突的。所以 Hive 的社区一直没有把标准 SQL 的递归语法完整移植过来。
那实际项目中怎么办?无非三条路:用有限次自连接展开固定层数、用外部脚本循环执行增量 SQL 模拟迭代、或者用 UDF 在函数内部做真正的递归。这里先约定一下,下文说的“展开”,默认计入全部子孙节点或全部祖先节点,而不是只展开一层。如果业务只需要一层父子关联,那直接用left join就能解决,根本不需要进来折腾。
搞清楚要做什么、为什么 Hive 做不到,后面选方案时才不会乱。
2. 固定层数的展开:多重自连接的用法与边界
如果树的最大深度是已知的,比如业务上规定类目最多五层,那最省事的方案就是多重自连接。这个方法不烧脑,运行效率也直观,但有它的边界。
2.1 从根到叶子:一层层 LEFT JOIN 的写法
假设我们最多展开三层,先看标准写法。以类目表为例,我习惯先把表按天分区过滤一次,再拿同一张表连续 JOIN 自己:
select c1.category_id as level1_id, c1.category_name as level1_name, c2.category_id as level2_id, c2.category_name as level2_name, c3.category_id as level3_id, c3.category_name as level3_name from ods_category c1 left join ods_category c2 on c2.parent_id = c1.category_id and c2.dt = '2024-06-01' left join ods_category c3 on c3.parent_id = c2.category_id and c3.dt = '2024-06-01' where c1.dt = '2024-06-01' and c1.parent_id is null;这段 SQL 的逻辑很直白:先拿到所有一级类目,再找一级类目下面的二级类目,接着找二级类目下面的三级类目。每展开一次就多一个 JOIN,查询结果天然就是每一行对应一条“根到叶子”的路径。如果某个中间节点没有更深的子节点,那后续列会用 NULL 补上,不会丢行。
如果树是反着存的,比如我们要从任意一个节点往上找它的所有父级,写法方向反过来即可:
select c3.category_id as leaf_id, c2.category_id as parent_id, c2.category_name as parent_name, c1.category_id as root_id, c1.category_name as root_name from ods_category c3 left join ods_category c2 on c2.category_id = c3.parent_id left join ods_category c1 on c1.category_id = c2.parent_id where c3.dt = '2024-06-01';这种往上的写法在“已知某个节点,查它属于哪个大类”的场景里非常实用。
2.2 最容易踩的坑:深度超过预定义层
多重自连接最大的问题是“预定义层数”。我在实际项目中遇到过非常典型的翻车案例:某业务同学信誓旦旦说类目最多三级,结果运营在后台临时加了一级“内容场专区”,硬生生把深度撑到四级。跑完任务一看,新增的四级类目全部消失了,报表里直接少了一整层的数据。
原因很简单:JOIN 层数写死成三层,第四层没有对应的c4去承接,深层节点自然就被丢掉了。
这类问题很难靠 SQL 本身兜底,通常要做两个前置动作:
- 写一个校验 SQL,统计当前数据里的最大深度。比如通过迭代法计算深度,而不是靠业务同学拍脑袋。
- 在正式展开前对最大深度做监控,一旦超过预设层数就告警,而不是让数据悄悄残缺。
另外还要注意 JOIN 条件里不能漏掉分区条件。Hive 里同一张表做多重自连接时,如果不把dt条件显式写在 ON 里,引擎可能会走全表扫描,数据量大时能把你集群跑崩。分区表插入时容易忘记,这是老手也会犯的低级错误。
2.3 固定层级补全:层数不足时怎么对齐报表列
有时候报表要求必须展示固定层级的列名,比如无论实际深度是多少,都要求第 1 列是一级类目、第 2 列是二级类目。如果树只有两层,“三级类目”列就全是 NULL,这在报表工具里通常会显示成空单元格。
我的处理办法是在展开之后再做一次 NVL 兜底:把 NULL 的值回填成当前已知的最深层名称,再加一个real_depth字段标明真实层级。比如:
select c1.category_id as level1_id, c2.category_id as level2_id, coalesce(c3.category_id, c2.category_id) as level3_id, case when c3.category_id is null then 2 else 3 end as real_depth from ...别看这是个小细节,很多报表“为什么总数对不上”的排查根源就出在这里:深层节点没有 NULL 化,而是被聚合函数偷偷过滤掉了。补全规则要在最开始和数据产品对齐,否则后面返工成本很高。
3. 不限制层数:增量叠加展开法的完整思路
固定自连接只适合“深度确定”的树。遇到组织架构这种可能随时增加层级、又没有明确上限的场景,就要用增量叠加法。这是我在生产中用的最多的一套做法,也是解决“hive实现树形结构展开”最通用的方案。
3.1 核心思想:用迭代代替递归
增量叠加法的本质是:把“递归查询”改成“循环迭代”。每一次迭代只向外扩展一层,并把扩展结果追加到一张中间路径表里。这张路径表的每一行表示“从某个祖先节点到某个后代节点”的一条已知路径,外加一个depth字段表示距离。
递归查询的终止条件是“遍历完整棵树”,迭代法的终止条件是“第 N 轮没有新增路径”。两者逻辑等价,但迭代法每一步都是普通 SQL,能跑在分布式引擎上,不会因为深度太深而出现无效递归。
我把这比喻成拼火车:一开始只有相邻车厢之间的连接,第一轮找到每节车厢的下一个,第二轮把两节车厢串成一整列,第三轮再把三节车厢串起来。只要下一轮还有新的拼接可能,就继续跑。
3.2 准备两张表:原始树表与路径中间表
沿用前面的类目表,我们先建一张路径中间表:
create table if not exists dwd_category_path ( ancestor_id string comment '祖先节点ID', descendant_id string comment '后代节点ID', depth int comment '层级距离,1表示父子直连' ) partitioned by (dt string);首轮生成所有父子直连关系:
insert overwrite table dwd_category_path partition (dt = '2024-06-01') select parent_id as ancestor_id, category_id as descendant_id, 1 as depth from ods_category where dt = '2024-06-01' and parent_id is not null;这一步相当于把整棵树的所有“直接边”全部列出来。比如“男装”和“上衣”之间有一条记录(男装, 上衣, 1)。
3.3 每一轮只扩展最新一层
然后我们通过外部调度循环执行下面的增量批次。以第二轮为例,目标是找到所有距离为 2 的祖先后代对:
insert into table dwd_category_path partition (dt = '2024-06-01') select p.ancestor_id, c.category_id, p.depth + 1 from dwd_category_path p join ods_category c on c.parent_id = p.descendant_id and c.dt = '2024-06-01' where p.dt = '2024-06-01' and p.depth = 1;这里的关键是p.depth = 1,它保证第二轮只处理上一轮新增的“末端节点”,不会把整张中间表全部重扫一遍,避免重复膨胀。
在测试环境里,我更倾向于用 Shell 脚本控制循环。思路是每一轮先统计本轮会产生多少新路径,如果为 0 就退出;同时设置一个最大迭代上限,比如 30 层,防止出现循环引用时任务永远跑不完:
#!/bin/bash DT="2024-06-01" MAX_LEVEL=30 for ((i=1; i<=MAX_LEVEL; i++)); do NEW_CNT=$(hive -S -e " select count(*) from dwd_category_path p join ods_category c on c.parent_id = p.descendant_id and c.dt = '$DT' where p.dt = '$DT' and p.depth = $i;" 2>/dev/null) if [ "$NEW_CNT" -eq 0 ]; then echo "第 $i 轮无新增路径,终止迭代" break fi hive -e " insert into table dwd_category_path partition (dt = '$DT') select p.ancestor_id, c.category_id, p.depth + 1 from dwd_category_path p join ods_category c on c.parent_id = p.descendant_id and c.dt = '$DT' where p.dt = '$DT' and p.depth = $i;" done这里有个容易被忽视的问题:NEW_CNT统计的语句其实会执行一次完整的 JOIN,只是不落库;如果数据量很大,这个计数操作本身会占用资源。更稳妥的做法是先跑一条explain看逻辑,或者干脆只在生产环境保留“插入+影响行数”的日志,用插入后的中间表行数变化来判断是否继续。
3.4 怎么把路径拼成可读的层级列
迭代结束后,一张dwd_category_path表里已经有所有祖先后代对。但业务往往想看的是从根到某个节点的完整名称链。Hive 里没有内置的tree_path聚合函数,我有两种常用办法。
- 如果确定一个节点拥有的祖先条数有限,直接和类目表 JOIN,再把叶子节点分组聚合,用
concat_ws拼接名称。 - 如果树的深度特别深,用
lateral view explode配合sort_array也不复杂。
比如,想给每个节点拼上“一级类目/二级类目/三级类目”的完整路径名称,可以先把路径表按后代节点聚合:
select path.descendant_id, sort_array(collect_list( concat_ws(':', lpad(cast(path.depth as string), 3, '0'), c.category_name) )) as name_order_list from dwd_category_path path join ods_category c on c.category_id = path.ancestor_id where path.dt = '2024-06-01' and c.dt = '2024-06-01' group by path.descendant_id;再用concat_ws('/')拼接。为什么这里要lpad补零?因为sort_array按字符串排序,如果深度是 1、10、2,字符串排序会变成 1、10、2,补零后才符合真实层级顺序。这个坑我踩过一次,后来凡是做路径排序都条件反射式地补零。
增量叠加法的优点是把递归变成可观测、可重跑的流转过程。缺点是脚本要管理,阶段表要清空,一轮一轮执行的时间也不会太短。但它的扩展性最好,树有几百万条记录、深度几十层都能扛住。
4. 更灵活的做法:Python UDF 递归展开
如果树的深度不确定,同时你希望一次 SQL 拿到“每条根到叶的完整路径”,可以考虑用 Python UDF。很多团队不推荐 UDF,是因为会对集群资源产生额外压力;但在树形展开这个场景里,UDF 的代码直观性有明显优势。
4.1 为什么还需要 UDF
增量叠加法虽然通用,但它有个小毛病:最终拿到的是祖先后代关系表,如果想输出根到叶的整条路径,还需要额外拼接和排序。而 Python UDF 里用 DFS 或者 BFS 遍历一次,输出天然就是一个数组,一步到位。
当然,UDF 不是银弹。Hive 的TRANSFORM通常是把每一行交给子进程处理,子进程之间无法共享内存。如果我们要在 Python 里构建完整树,就必须保证所有节点数据进入同一个 reducer。这限制了它适用的数据规模,但树形表本身具备明显层级关系,常见的组织架构、中小型类目表也就几十万行,单 reducer 完全能扛住。
4.2 TRANSFORM 实现的完整示例
先把脚本打成文件,在 Hive 里声明使用。树的原始输入格式是三个字段:节点 ID、父节点 ID、节点名称。用制表符分隔。
#!/usr/bin/env python3 import sys children = {} nodes = {} roots = set() for line in sys.stdin: line = line.strip() if not line: continue parts = line.split('\t') if len(parts) < 3: continue node_id, parent_id, node_name = parts[0], parts[1], parts[2] nodes[node_id] = node_name children.setdefault(node_id, []) if parent_id == 'NULL' or parent_id == '' : roots.add(node_id) else: children.setdefault(parent_id, []).append(node_id) def dfs(node_id, path): path.append(node_id) if not children.get(node_id): # 输出根到叶子的完整节点ID路径 print('\t'.join(path)) else: for child in children.get(node_id): dfs(child, path) path.pop() for root in roots: if root in nodes: dfs(root, [])注意在 Hive 的 SQL 里调用:
add file /path/to/tree_expand.py; set mapreduce.job.reduces=1; select transform(category_id, parent_id, category_name) using 'python3 tree_expand.py' as (path string) from ods_category where dt = '2024-06-01';输出第一行可能是A001 B001 C001,表示从根节点 A001 到叶子 C001 的完整路径。如果要连名字一起输出,可以在脚本里把node_id换成node_name,或者多输出一个映射表键值。
4.3 单 reducer 的坑和改写思路
单 reducer 最大的问题不是慢,而是倾斜。假如有一个根节点下直接挂了几百万个叶子,那么这一个 reducer 要处理的数据量会远超其他 reducer。真遇到这种情况,我会改用另一种写法:不追求“整棵树的 DFS 路径”,而是把树先切分成“大节点下的子树”,每个子树单独用一个 mapper。
切分办法是:先跑一个 SQL 找到直接挂在根节点下的一级子树 ID,然后按子树 ID 过滤原始表,把同一个子树的数据发到一个 reducer。这种做法实现起来要写额外的查询,但如果树的形状极不均匀,这是唯一能压住资源的路。
还有一点容易踩坑:TRANSFORM输入输出默认按制表符分割,节点名称里如果含有\t,会让字段错位。树形表里的名称一般不会有制表符,但架不住某些业务系统会写入特殊字符。我会在脚本入口做清洗,把非法字符替换成空格,保证字段数稳定。
4.4 如果只是临时查某个节点,别用 UDF
有一次运营临时让我查“某个二级类目下到底挂了多少个不同层级的叶子”,我当时条件反射写了迭代脚本,跑完才发现数据量很小。后来同事提醒:你可以写一个十几行的 Python 脚本本地跑,甚至直接在 SQL 里用where category_id in (...)把子节点一个个拉出来。
确实,如果只查一棵子树、数据量又小,直接用多次子查询也能解决:
select category_id from ods_category where dt = '2024-06-01' and parent_id in ( select category_id from ods_category where dt = '2024-06-01' and parent_id in ( select category_id from ods_category where dt = '2024-06-01' and category_id = 'target_id' ) );这种写法不适合通用化,但胜在“分析问题时最快”。我一般会把它当作验证手段,验证 UDF 或迭代法算出来的叶子集合是否一致。
5. 展开过程中的脏数据与性能陷阱
不管选哪种方案,树形展开真正难的不是写 SQL,而是处理数据本身的问题。这一节我集中讲几个高频坑。
5.1 循环引用怎么处理
树结构数据如果是从业务库同步来的,偶尔会有脏数据:节点 A 的父节点是 B,节点 B 的父节点是 A,形成一个环。遇到这种情况,增量叠加法会死循环(每一轮都有一条新的路径,永远停不下来),Python UDF 会栈溢出。排查起来特别隐蔽,因为抽样数据很可能碰不到这两个节点。
预防手段是在源头检查。如果已经进入了展开流程,至少做两个兜底:
- 迭代法必须设最大深度上限,比如 30。大多数业务树超过 30 层一定有问题,超限就直接告警,而不是让调度无限跑。
- UDF 的 DFS 维护一个
visited集合,一旦发现某个节点在递归路径上重复出现,就跳过该分支并打印一条警告到 stderr。
在 Python 脚本里加 visited 很简单,但很多裸写的递归脚本没有这一步,所以无数树形展开任务会神秘地卡死。别问我是怎么知道的。
5.2 数据倾斜:超级父节点导致单轮 JOIN 爆炸
自连接和迭代法都可能遇到倾斜。比如“根节点”下直接挂了全平台 80% 的类目,那么第一层自连接时,所有子节点都会和同一个父节点 ID 关联,所有数据会落在同一个 reduce 上。
如果树的 fan-out 非常大,有几种缓解思路:
- 先对
descendant_id做分桶或加盐,把一个大父节点的子节点切到多个桶,再分别展开。 - 用
map join把小的维度表分发到每个 mapper,在 map 端完成连接,减少 shuffle。 - 迭代法里每轮只处理
depth = 当前层的增量,避免反复扫全表,能显著降低中间数据量。
加盐在纯树形展开里不算好写,但如果真遇到倾斜,往往只能用这个办法。也可以先统计每个父节点的子节点数量,对超大节点单独建一个分区表去展开,最后合并结果。这种“分治”思路听着麻烦,实际节省的时间非常可观。
5.3 一轮一轮 insert 产生的小文件问题
增量循环法有个副作用:每轮都会insert into一张分区表,Hive 默认可能生成大量小文件。10 轮迭代、每轮 10 个 reduce,最终一个分区下可能有 100 多个碎片文件,后续查询和统计全部受影响。
我的建议是不要直接在生产表上反复增删,而是用一个临时输出表跑循环,循环结束后一次性insert overwrite到正式路径表:
insert overwrite table dwd_category_path partition (dt = '2024-06-01') select /*+ REPARTITION(1) */ ancestor_id, descendant_id, depth from tmp_category_path where dt = '2024-06-01';这里REPARTITION(1)是比较粗暴的做法,适合路径表规模可控的场景。如果表非常大,就把1换成目标文件数,或者用distribute by控制粒度。
5.4 结果正确性验证的“三个数字”
展开完成后不能直接落地产出报表,应该校验结果。我一般看三个数字:
- 路径总条数是否等于“树中所有父子路径”加上“所有跨层路径”。如果树的节点数是 N,有效父子边数是 E,路径表理论上行数至少是 E。
- 每个节点的祖先数量是否等于它到根的距离。可以抽样几个叶子节点人工核对。
- 叶子节点总数是否和一张已知的叶子快照对得上。对不上就说明展开过程丢失了深层叶子,多半是层数上限或脏数据导致的。
这三个数字能拦截大多数低级错误。
6. 三种方案怎么选:参考组合而不是单选题
前面说了三种主流路线:固定自连接、增量叠加、Python UDF。实际项目里不一定要纠结“哪个最先进”,更应该看数据特征和团队运维能力。
6.1 方案对比表
| 方案 | 适用深度 | 数据规模 | 优点 | 主要代价 |
|---|---|---|---|---|
| 多重自连接 | 固定且不超过 5~10 层 | 适合大多数维度表 | 一个 SQL 能跑完,逻辑最直观 | 层数写死,超深丢数据,JOIN 膨胀 |
| 增量叠加法 | 深度未知,可设上限 | 千万级以内仍可行 | 分布式友好,可增量可断点重建 | 需要外部脚本循环,调度复杂,多轮生成小文件 |
| Python UDF | 深度未知 | 节点总量几十万以内更稳 | 一次 DFS 拿完整路径,代码可读性高 | 单 reducer 有倾斜风险,子进程内存受限制 |
6.2 我推荐的生产组合
在团队维护成本高的前提下,我一般这样定方案:
- 深度确定、且业务变更不频繁:优先多重自连接。配合最大深度监控,足够覆盖 70% 的维度表场景。
- 深度不确定、但节点总量不大:优先 Python UDF。代码量小,方便迭代和调试。
- 深度不确定、节点总量又很大:只能上增量叠加法。把循环脚本封装成调度任务,配上超时控制。
- 如果数据已经跑在 Spark 上,也可以考虑用 Spark 的迭代式 DataFrame 或 GraphX。但那是另一个话题,Hive 场景下就不展开了。
6.3 如果让我重新做一次
我把最后这段留给踩坑经历。如果让我重新设计一个树形展开任务,我会先花半小时做三件事:确认最大深度是否真的有上限、确认路径是“根到叶”还是“所有祖先后代对”、确认有没有循环引用脏数据。这三件事里任何一件没做好,后面跑得多快都是白搭。
增量叠加法虽然需要写脚本,但它有个额外好处:每轮结果都能落表,中间产物保留着,排查数据问题时能直接看到“哪一层断开”。Python UDF 反而因为输出太干净,出了问题比较难定位。所以我会在正式生产环境优先考虑增量叠加,把 UDF 留在探索验证阶段。
树形展开这个需求,本质上不难,难的是你的 SQL 对数据形态有多少预判。多花点时间把边界条件想清楚,能省掉一整周的返工。