ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Hive递归查询实战:迭代轮次表驱动组织架构树展开

Hive递归查询实战:迭代轮次表驱动组织架构树展开

先说一个我上个月差点没扛住的场景。业务方拿着一个不算复杂的需求过来:给我一个员工ID,把这个员工名下所有层级的团队成员全部拉出来,不管中间隔了几级,而且要能区分每个人到底在第几层。我第一反应是这有什么难的,Hive SQL而已。结果动笔的时候发现,Hive连标准的递归CTE都不支持,而需求背后的组织树,最深已经长到了九层。

旧方案用的是我称之为“1.0”的写法:手工把组织关系表自连接N次,用UNION ALL把每一层的结果拼起来。这套写法的毛病在树只有三四层的时候还不明显,一旦层级变深、数据量变大、甚至出现管理关系录错形成的环,它就会把人折磨到怀疑人生。这篇文章就是把我重构这套逻辑的过程完整复盘一遍,讲清楚为什么我最终用“迭代轮次表+上一轮增量驱动”的方案替代了原来的固定层数展开,以及这套2.0方案里藏着哪些坑、怎么调优、什么情况下应该果断放弃Hive换别的引擎。

1. 一张员工表,为什么让SQL变得很尴尬:递归场景与Hive的能力边界

1.1 数仓里真正需要递归的五类业务

很多人一听“递归计算”就想到编程题里的阶乘和斐波那契,但在数仓日常里,递归需求比想象中常见得多。我整理了一下,下面五类业务几乎每个公司都可能遇到:

  • 组织架构树展开:给定一个部门负责人或某个层级的管理者,把他名下所有直接、间接下属全部捞出来,并标注每个人所在的层级深度。这是最典型的递归,也是我这篇文章的主案例。
  • 物料清单BOM多级展开:制造业里一个成品由多个部件组成,部件又依赖子部件,需要不断向下展开直到原材料层级,最终算出一个成品的全谱系物料清单。
  • 账户邀请/分销关系追溯:A邀请B注册,B又邀请C,需要从任意一个节点出发,找到整条邀请链路上所有用户,常用于风控、反作弊和分销奖励计算。
  • 多级路径还原:日志里只保存了“上一跳节点ID”和“当前节点ID”,要还原出一条完整调用链或访问路径,本质上就是从末节点一路向上回溯到起始节点。
  • 权限/资源目录继承:子部门继承父部门的权限和资源配额,需要逐级向上汇总并计算有效值。

这些需求有个共同点:你事先不知道数据会深到第几层。昨天这棵树还只有五层,明天组织调整后可能变成八层,写死层数的SQL必然出问题。

1.2 1.0版本:固定层数自连接的本质与隐藏成本

所谓1.0版本,核心就是“写死深度,一层一个LEFT JOIN,最后UNION ALL”。以组织架构为例,假设有一个员工关系表,字段是员工ID、员工姓名、直属上级ID,要查某个根节点下所有五层以内的下属,SQL大概长这样:

SELECT root.emp_id AS root_emp_id, l1.emp_id AS emp_id, l1.emp_name AS emp_name, 1 AS depth FROM ods_emp_relation root LEFT JOIN ods_emp_relation l1 ON l1.mgr_id = root.emp_id WHERE root.emp_id = 'E0001' UNION ALL SELECT root.emp_id AS root_emp_id, l2.emp_id AS emp_id, l2.emp_name AS emp_name, 2 AS depth FROM ods_emp_relation root LEFT JOIN ods_emp_relation l1 ON l1.mgr_id = root.emp_id LEFT JOIN ods_emp_relation l2 ON l2.mgr_id = l1.emp_id WHERE root.emp_id = 'E0001' UNION ALL -- 后面继续写第三层、第四层、第五层...

这套写法最直接的三个问题:

第一,深度写死。业务树一旦超过预设层数,数据就静默丢失,而且不容易被发现,因为SQL不会报错,只是结果少了几层人。

第二,UNION ALL部分之间会有重复。如果一个员工既是二层节点又是三层节点的子节点,不同层级的UNION片段可能出现同一个人,需要额外去重,又增加了计算量。

第三,每增加一层,都要重写一遍整条SQL。维护成本极高,换一个层级深度就得改代码,代码评审和回归测试的成本完全失控。

1.3 Hive为什么一直没做递归CTE

看到这里你可能会问:标准SQL为什么不用WITH RECURSIVE?PostgreSQL、Oracle、SQL Server、甚至一些分析型数据库都支持递归CTE,为什么Hive不行?

事实是,Hive社区多年以来一直有相关需求,但官方始终没有完整实现标准递归CTE。Hive支持的WITH语句只能做普通公共表表达式,不能做递归展开。原因也不难理解:递归CTE在底层需要反复执行子查询直到收敛,这种“迭代式执行”跟Hive以批处理任务为核心的执行模型并不匹配。Hive一条SQL最终会被编译成一个或多个分布式任务,任务之间通过落盘来传递数据,天然就很难表达“跑到某一步自动停止”的动态循环。

所以,在Hive里做递归只有三条路:要么外部程序循环调度HQL,要么用UDF/自定义逻辑在单机内做图遍历,要么干脆换引擎。2.0方案走的是第一条路,也是最贴合数仓现状的路。

2. 2.0方案:用“上一轮增量驱动”把递归翻译成可以终止的循环

2.1 核心思路:BFS式的增量轮次展开

2.0方案的核心思想非常朴素:把递归改成广度优先搜索,每一轮只处理“本轮新发现的节点”,不断向外扩展,直到某一轮发现不了任何新节点,循环自然终止。

举个例子,根节点是E0001。第一轮从E0001出发,找到所有mgr_id等于E0001的员工,这批人就是第一层;第二轮不再从头扫全表,而是只拿着第一层员工的ID,去查他们的下级,得到第二层;第三轮只拿着第二层员工去查……每一轮扫描的范围,被限制在上一轮新增的那一小批节点上,而不是整棵历史结果集。

这样做有一个非常明显的好处:输入规模被压缩到最小。每轮需要处理的数据量,约等于该层的节点数,而不是整张关系表或全量结果集。对于一棵深度几十层、单层几百人的组织树来说,每轮SQL都极其轻量。

另一个关键点是终止条件。由于每轮只处理“新发现”的节点,某一轮如果查不到任何新节点,说明已经遍历完整棵可达子树,循环就可以停了。配合一个最大深度保护参数,即使遇到异常数据也不会让循环无限跑下去。

2.2 中间表模型与字段设计

实现这套方案需要两张物理中间表,我以组织架构为例给出建表语句。先说字段设计的考量。

第一张是结果表,用来存放最终整棵树的所有节点关系。这张表要保留三样信息:从哪个根节点出发、找到了哪个员工、这个员工距离根节点多少层。之所以要带root_emp_id,是因为实际使用时很可能一次性展开多棵树,不同根节点下的同一个员工需要分别记录。

CREATE TABLE IF NOT EXISTS dwd_org_tree_result ( root_emp_id STRING COMMENT '树根节点员工ID', emp_id STRING COMMENT '子孙节点员工ID,包含根节点', emp_name STRING COMMENT '员工姓名', depth INT COMMENT '距离根节点的层数,根节点为0' ) PARTITIONED BY (etl_date STRING) STORED AS ORC;

第二张是增量表,用来存放每一轮新发现的节点。这张表比结果表多一个round_id字段,用于标识是哪一轮产生的数据。每轮迭代结束后,要先把增量数据写入这张表,再根据它的记录数决定是否继续下一轮。

CREATE TABLE IF NOT EXISTS dwd_org_tree_delta ( round_id INT, root_emp_id STRING, emp_id STRING, emp_name STRING, depth INT ) PARTITIONED BY (etl_date STRING, round_id INT) STORED AS ORC;

设计上有两个容易被忽略的细节:

  1. 增量表的round_id必须作为分区字段,而不是普通字段。因为每轮写入时,需要保证上一轮数据仍然存在,本轮数据独立可查,随时可以根据round_id做清理或者追溯。如果只做普通字段,数据越积越多,每轮判断增量和清理由都会很麻烦。

  2. 种子数据也要写入增量表,且round_id固定为0。这是我在第一版实现时踩过的大坑,后面会专门讲。简单来说,为了让所有Rounds的展开SQL共用同一套逻辑,种子数据必须和展开数据放在同一张增量表里,否则第一轮循环根本找不到驱动数据。

2.3 调度与核心SQL:从种子到终止的完整实现

整体调度我用Shell脚本配合beeline实现。环境上假设已经有了Hive或者Hive On Spark的环境,能通过beeline执行HQL。

第一步,把种子节点写入增量表和结果表。以单个根节点E0001为例:

-- 种子写入增量表,round_id固定为0 INSERT OVERWRITE TABLE dwd_org_tree_delta PARTITION (etl_date = '2025-02-01', round_id = 0) SELECT 0 AS round_id, emp_id AS root_emp_id, emp_id AS emp_id, emp_name AS emp_name, 0 AS depth FROM ods_emp_relation WHERE etl_date = '2025-02-01' AND emp_id = 'E0001'; -- 种子同步写入结果表 INSERT OVERWRITE TABLE dwd_org_tree_result PARTITION (etl_date = '2025-02-01') SELECT root_emp_id, emp_id, emp_name, depth FROM dwd_org_tree_delta WHERE etl_date = '2025-02-01' AND round_id = 0;

第二步,准备每一轮展开的HQL脚本expand_one_round.sql。这是整套方案的核心:以上一轮增量表作为驱动表,查到这些人的所有直接下级,同时排除掉已经出现在结果表里的节点,避免重复处理和环引用。

INSERT INTO TABLE dwd_org_tree_delta PARTITION (etl_date = '${ETL_DATE}', round_id = ${ROUND}) SELECT ${ROUND} AS round_id, prev_delta.root_emp_id AS root_emp_id, emp.emp_id AS emp_id, emp.emp_name AS emp_name, prev_delta.depth + 1 AS depth FROM dwd_org_tree_delta prev_delta JOIN ods_emp_relation emp ON emp.mgr_id = prev_delta.emp_id AND emp.etl_date = '${ETL_DATE}' LEFT SEMI JOIN ( SELECT root_emp_id, emp_id FROM dwd_org_tree_result WHERE etl_date = '${ETL_DATE}' ) visited ON visited.root_emp_id = prev_delta.root_emp_id AND visited.emp_id = emp.emp_id;

注意这里LEFT SEMI JOIN的去重键是(root_emp_id, emp_id)二元组,而不是单独的emp_id。如果只按emp_id去重,多个根节点同时展开时,一个员工可能属于两个不同根下的不同路径,单独用emp_id会把第二个根里本该出现的员工错误过滤掉。

第三步,在Shell里循环调用这个脚本,每轮执行完后统计增量表中本轮分区的记录数,如果为0就停止:

#!/bin/bash ETL_DATE="2025-02-01" ROOT_EMP="E0001" MAX_DEPTH=30 ROUND=1 PREV_ROUND=0 # 初始化种子,步骤见上 beeline -u "$JDBC_URL" \ --hivevar ETL_DATE="$ETL_DATE" \ --hivevar ROOT_EMP="$ROOT_EMP" \ -e "INSERT OVERWRITE TABLE dwd_org_tree_delta PARTITION (etl_date='${ETL_DATE}', round_id=0) SELECT 0, emp_id, emp_id, emp_name, 0 FROM ods_emp_relation WHERE etl_date='${ETL_DATE}' AND emp_id='${ROOT_EMP}'; INSERT OVERWRITE TABLE dwd_org_tree_result PARTITION (etl_date='${ETL_DATE}') SELECT root_emp_id, emp_id, emp_name, depth FROM dwd_org_tree_delta WHERE etl_date='${ETL_DATE}' AND round_id=0;" while [[ $ROUND -le $MAX_DEPTH ]]; do # 1. 用上一轮增量数据展开一层 beeline -u "$JDBC_URL" \ --hivevar ROUND=$ROUND \ --hivevar PREV_ROUND=$PREV_ROUND \ --hivevar ETL_DATE="$ETL_DATE" \ -f expand_one_round.sql # 2. 统计本轮新增数量 NEW_CNT=$(beeline -u "$JDBC_URL" --silent --showHeader=false \ --hivevar ETL_DATE="$ETL_DATE" \ --hivevar ROUND=$ROUND \ -e "SELECT COUNT(1) FROM dwd_org_tree_delta WHERE etl_date='${ETL_DATE}' AND round_id=${ROUND};" \ 2>/dev/null | tr -d '[:space:]') echo "round=${ROUND}, new_count=${NEW_CNT}" # 3. 终止判断 if [[ "$NEW_CNT" == "0" || -z "$NEW_CNT" ]]; then break fi # 4. 将本轮增量合并到结果表 beeline -u "$JDBC_URL" \ --hivevar ETL_DATE="$ETL_DATE" \ --hivevar ROUND=$ROUND \ -e "INSERT INTO TABLE dwd_org_tree_result PARTITION (etl_date='${ETL_DATE}') SELECT root_emp_id, emp_id, emp_name, depth FROM dwd_org_tree_delta WHERE etl_date='${ETL_DATE}' AND round_id=${ROUND};" PREV_ROUND=$ROUND ROUND=$((ROUND + 1)) done

这套循环跑完之后,dwd_org_tree_result里就是一张完整的“根节点—子孙节点—深度”关系表。业务要查任何人的所有下属,都直接查这张表,不用再临时跑递归。

3. 从1.0到2.0踩过的坑:终止条件、去重键、环引用和临时表

3.1 坑一:种子没进增量表,第一轮就空跑

第一次写循环调度时,我把种子数据只写进了结果表,没有写进增量表。然后循环从round_id=1开始,展开SQL里的驱动表是上一轮增量表,即round_id=0的数据。结果round_id=0的增量表分区是空的,第一轮展开SQL执行完统计出来0条记录,脚本直接break,整棵树只有根节点自己。

这个坑的根源在于:我没有把“种子”和“展开”统一成同一套数据流。修复方法就是上一节写的,种子数据必须同时写入增量表的round_id=0分区,让展开逻辑在Rounds=1时就能从上一轮增量表里读到驱动数据。

排查经验:遇到“结果只有根节点”这种问题,不要先去怀疑JOIN条件,先检查增量表里面有没有round_id=0的数据。绝大多数递归调度跑空,都是种子阶段没有对齐数据流。

3.2 坑二:去重键只用了emp_id,多根展开互相误伤

我在最初版本里写LEFT SEMI JOIN时,去重条件是visited.emp_id = emp.emp_id,没带root_emp_id。单棵树跑没问题,但后来要做全量组织树批量展开,把几百个根节点同时放进种子表后,问题立刻暴露。

举个例子,A根和B根是两个独立的事业部,如果某个员工因为历史数据问题同时挂在两个事业部的组织关系里,使用单emp_id去重后,哪个根先跑到这个员工,另一个根就永远看不到他了。表现在结果表上,就是B根下面的子树总比预期少几个人,而且少的都是跟A根有交集的人。

修复方法就是前面代码里的二元组去重:

LEFT SEMI JOIN ( SELECT root_emp_id, emp_id FROM dwd_org_tree_result WHERE etl_date = '${ETL_DATE}' ) visited ON visited.root_emp_id = prev_delta.root_emp_id AND visited.emp_id = emp.emp_id;

设计原则:递归展开里的“已访问节点”这个概念,必须跟你当前展开的根节点绑定。只要结果表里存在多个根,去重键就必须包含root_emp_id,否则一定会出现数据静默丢失。

3.3 坑三:管理关系成环,深度保护不能丢

正常组织架构是一棵树,但数据录入经常有脏数据,比如A的上级是B,B的上级又是A,形成两人互指的环。这种环如果不处理,大部分递归逻辑会无限循环下去;我的模板因为有MAX_DEPTH=30的保护,不会无限跑,但结果表里会出现深度异常的数据,比如depth=29还有节点。

更值得注意的细节是,我的visited去重本身就能中断环。第一轮从A出发找到B,B进入结果表;第二轮从B出发尝试找A,但A已经在结果表里,被LEFT SEMI JOIN过滤掉,第三轮就没有新节点了。所以环不会导致死循环,但它会导致环内真正的树形子孙节点丢失。

我当时的处理方案是加一个单独的“环检测SQL”,在正式展开之前先跑一遍,把互指的配对捞出来交给业务方修正:

SELECT e1.emp_id AS emp_id, e1.mgr_id AS mgr_id, e2.emp_id AS mgr_of_emp_id FROM ods_emp_relation e1 JOIN ods_emp_relation e2 ON e2.mgr_id = e1.emp_id WHERE e1.mgr_id = e2.emp_id;

这个SQL只能检测二节点环,更大的环需要更复杂的图遍历,实际业务里二节点环最常见,先把它拦住就能解决绝大部分脏数据问题。同时,MAX_DEPTH仍然建议保留,作为最后一道物理兜底。

3.4 坑四:临时表跨会话失效,中间结果必须落物理表

刚开始我图省事,把增量表定义成CREATE TEMPORARY TABLE,想着“反正就是中间结果,用完就扔”。结果跑调度脚本时,beeline每次调用-e或-f都是一个新的会话,临时表在新会话里根本不存在,第一轮展开SQL就把表找不到的错直接甩我脸上。

理解这一点后,我彻底放弃了临时表,改成落地为普通物理表,用etl_date和round_id做双层分区管理。好处有三个:

  • 每轮数据独立,排查问题时可以精确看到某一轮产生了什么;
  • 调度失败后可以从最近一轮断点续跑,不用从第一轮重来;
  • 数据血缘和审计更清晰,业务方问起来能解释每个节点是哪一轮找到的。

实操建议:如果你也打算在Shell里循环跑HQL,中间表统一用物理表,不要幻想临时表能跨会话存活。这在Hive上尤其要注意,会话隔离是硬性边界。

4. 当一棵树有50万节点:性能调优与结果校验的实战记录

4.1 性能瓶颈不在单轮,而在轮次之间的“任务开销”

很多第一次接触这套方案的人会问:每轮就查一批人的下级,数据量那么小,怎么会慢?答案是,慢的不是单轮SQL的数据量,而是每一轮都要提交一个分布式任务,经过解析、调度、资源申请、落盘,最终跑一个Spark Job或MR Job。树有几层,就要串行跑几个任务,这个开销完全线性累加。

用Spark引擎时,一个深度10层的树,就可能要串行跑10个Spark Jobs,每个Job还要经历application调度、stage划分、shuffle落盘。即使每轮只有几百条输入数据,整体完成时间也可能到分钟级。

所以在设计时,单棵树展开尽量控制在20轮以内。如果业务树深度经常超过30层,或者单层节点数大到每轮都要跑大量shuffle,建议评估换引擎,不要硬刚Hive。

4.2 大根节点倾斜怎么解:先把驱动表变小,再考虑拆分

实际数据里经常出现“超大规模根节点”,比如一个负责整个事业部的大领导,名下有几万甚至几十万员工。这种节点在迭代中会产生两类问题:每轮增量表数据量越来越大;结果表越来越大,做visited去重时的LEFT SEMI JOIN压力也随之增大。

我实测有效的优化是手动指定MapJoin,让驱动表走广播而不是shuffle。由于每轮驱动表就是上一轮新发现的节点,在大多数组织架构里规模可控:

INSERT INTO TABLE dwd_org_tree_delta PARTITION (etl_date = '${ETL_DATE}', round_id = ${ROUND}) SELECT ${ROUND} AS round_id, prev_delta.root_emp_id AS root_emp_id, emp.emp_id AS emp_id, emp.emp_name AS emp_name, prev_delta.depth + 1 AS depth FROM ( SELECT /*+ MAPJOIN(prev_delta) */ root_emp_id, emp_id, depth FROM dwd_org_tree_delta WHERE etl_date = '${ETL_DATE}' AND round_id = ${PREV_ROUND} ) prev_delta JOIN ods_emp_relation emp ON emp.mgr_id = prev_delta.emp_id AND emp.etl_date = '${ETL_DATE}' LEFT SEMI JOIN ( SELECT root_emp_id, emp_id FROM dwd_org_tree_result WHERE etl_date = '${ETL_DATE}' ) visited ON visited.root_emp_id = prev_delta.root_emp_id AND visited.emp_id = emp.emp_id;

注意如果单轮新增节点量很大,比如一次性发现了几万个新节点,MapJoin广播反而可能OOM。这时候要关掉自动MapJoin,或者调大spark.sql.autoBroadcastJoinThreshold,再不行就给驱动表按root_emp_id加随机前缀做分桶,把大表拆成多个小分片分别去重。这一步比较重,一般只有到了极端场景才需要做。

另一个实用建议是:优先保证驱动表最小。虽然LEFT SEMI JOIN去重的是全局结果表,但join的驱动侧只有上一轮新增节点,因此真正参与shuffle的数据量近似等于“新增节点数 × 其下级数”,而不是整个结果集。这也是为什么我坚持用增量表驱动,而不是拿整个结果表去扫描展开。

4.3 校验方法:闭合性校验、深度分布、断链检测

递归计算最怕的就是“不报错,但少数据”。我上线2.0模板后,每次跑完都会做三个校验,确保结果可信。

第一个是深度分布校验:

SELECT depth, COUNT(1) AS cnt FROM dwd_org_tree_result WHERE etl_date = '2025-02-01' GROUP BY depth ORDER BY depth;

正常组织树的深度分布应该是平滑递减的:第0层只有1个根,第一层可能有十几人,第二层几十人……如果某个深度的数据量突然变成0,而业务常识里那层明显应该有人,就很可能是断链或者环引用。

第二个是闭合性校验。从原始关系表里随便找一个员工的mgr_id,去结果表里反查这个员工是否真的在对应root的树上。找不到的,就是漏掉的数据:

SELECT emp.emp_id, emp.mgr_id FROM ods_emp_relation emp LEFT JOIN dwd_org_tree_result res ON res.etl_date = '2025-02-01' AND res.root_emp_id = 'E0001' AND res.emp_id = emp.emp_id WHERE emp.etl_date = '2025-02-01' AND emp.mgr_id = 'E0001' AND res.emp_id IS NULL;

通过mgr_id直接是E0001的员工,按理必然在E0001的第一层结果里。如果查出来有记录,说明展开逻辑本身有bug。

第三个是人工抽样核对。我用过最朴素但也最有效的方法:选两个已知结构的子树,比如一个6层以内的真实组织部门,跑完2.0后把结果拉到Excel里数人头,跟HR系统的组织树截图做对比。这种方法费时,但能建立信心,尤其适合首次上线新模板时。

4.4 资源参数与调度频率的取舍

Hive On Spark环境下,我给这套递归模板的建议参数是:spark.sql.shuffle.partitions不要设得太大,否则每轮都产生一堆空task;建议根据单轮驱动数据量动态调整,一般设成50~200之间。同时打开动态资源分配,让每轮任务能快速申请到Executor,也能避免空闲资源被长期占用。

调度频率上,全量组织树展开我建议放在每日低峰期,比如凌晨2点到6点之间。由于它是串行循环,一轮跑多久,整棵树就大概跑多久,高峰期跑容易和报表任务抢占资源。分批跑多个根节点虽然能并行,但会成倍增加集群压力,我通常控制在5个以内并行批次。

5. 别盲目照搬:Hive递归的适用边界和替代路线

5.1 哪些场景Hive递归尚能一战

2.0方案不是银弹。根据我的实践,下面这些条件基本满足时,Hive迭代方案是完全够用的:

  • 树的深度相对可控,通常在30层以内;
  • 计算频率是T+1批处理,可以接受分钟级到小时级的延迟;
  • 数据规模在千万级以下,单棵树节点数不超过百万;
  • 业务上只需要“某个根节点下所有可达节点”和“深度”这两个信息,不需要复杂的路径权重、最短路径、连通分量等图算法。

如果你只是把组织架构树、邀请关系树、BOM小范围展开成一张宽表,那这套Hive模板非常合适,维护成本高但逻辑透明,出了问题容易定位。

5.2 换引擎的信号与推荐路线

当条件偏离上面任何一条,我都建议评估换引擎,不要死磕Hive。具体来说:

  • 树的深度经常超过50层,或者单棵树节点数超过百万,Hive每轮一个分布式任务的模式会非常低效,这时候应该考虑Spark GraphX或者Pregel模型。GraphX的Pregel API可以把迭代过程放到内存里,避免每轮落盘,效率高一个量级。
  • 需要实时/准实时计算,比如风控里要在用户注册后秒级返回其邀请链路,Hive根本做不到,应该用Flink的图计算或把路径预计算后写入KV存储做查询。
  • 交互式多跳分析,比如分析师想随时查“A和B之间隔了几跳”,用图数据库更合适,但是要考虑数据同步链路和存储成本。
  • 单次递归逻辑极其复杂,超过简单可达性分析,包含路径去重、权重计算、环路统计等,推荐用Spark或者Python的图算法库处理成结果表,再回到数仓体系。

我做选型时常用下面这张表判断:

场景特征Hive迭代SQLSpark GraphX/Pregel图数据库/Flink
深度范围30层以内50层以上尚可无限制
实时性T+1批处理准实时秒级
数据规模百万级以下千万级以上视存储而定
算法复杂度只做可达性展开支持图算法支持复杂图查询
团队成本低,纯SQL需要Scala/Python需要额外组件运维

这张表不是绝对的,但它能帮你快速判断,在投入精力调优Hive递归之前,先确认方向对不对。

5.3 一个容易忽略的误区:递归结果要不要做成宽表

最后说一个容易被忽略的架构问题:递归计算的结果,到底是直接供业务查询,还是提前加工成宽表层?

我一开始想让业务方直接查结果表,后来发现不行。虽然dwd_org_tree_result已经能回答“某人所有下属是谁”,但业务方经常要的是“每个员工对应的所有层级上级链”,这又变成了反向递归。

后来我用这套递归模板算出一个基础关系表,再在这个关系表上做一次自关联和聚合,把每个员工到根节点的路径拼成字符串、以及员工属于哪个根节点等衍生字段,全部做成宽表供应用层查询。这样虽然多了一份存储开销,但查询侧的复杂度大大降低,业务方也满意。

这个思路可以推广到所有递归场景:递归逻辑只做一次,结果尽量资产化、宽表化,不要让每个下游都自己跑一遍递归。

我在实际使用中的体会是,如果只是为了临时查一次数据,直接跑这个循环脚本没问题;如果它是一个稳定的数仓需求,一定要把递归结果沉淀成表,并且加上校验和监控,否则后续维护会非常痛苦。

RELATED READING

延伸阅读

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