资讯详情

资讯详情

Oracle层次查询CONNECT BY性能优化实战:从执行计划到递归WITH

1. 优化前的第一课先搞懂CONNECT BY到底慢在哪前几天有朋友拿了一段Oracle层次查询的SQL来问我说数据量就几万行加了START WITH之后跑起来却像老牛拉车有时候甚至直接把人等崩溃。我一看那段SQL典型的CONNECT BY写法表结构也合理索引也建了可执行计划一出来问题立刻摆到了台面上——嵌套循环一层套一层CPU烧到冒烟IO也没闲着典型的“单表递归遍历被放大成了全表反复扫描”的现场。先说结论Oracle里的层次查询最常用的语法是CONNECT BY PRIOR配套START WITH指定根节点、LEVEL控制递归深度、CONNECT_BY_ROOT取根节点值、SYS_CONNECT_BY_PATH拼接路径这套组合拳本身没有问题问题往往出在“你让它怎么走”上。我见过太多人把START WITH写得宽泛到几乎等于没过滤然后PRIOR关联条件又用不上索引直接导致每往下一层就要把整张表扫一遍最终递归N层等于全表扫N次这种写法哪怕表只有几万行也会慢得离谱。所以我建议大家拿到任何层次查询问题第一步不是急着改SQL而是先弄清楚执行计划里那几步到底在干什么。Oracle的CONNECT BY在内部本质上是一个递归的嵌套循环操作每一步回溯父行、查找子行都需要一次索引或全表扫描。如果PRIOR条件的驱动列上没有索引或者索引选择性太差那每一层都是灾难。换句话说层次的深度决定了嵌套的层数而每一层的扫描成本决定了整个查询的最终成本两者一乘就是你看不见的黑洞。我在实际排查中通常会做三件事第一先看START WITH过滤出来的根节点数量根节点如果占据全表一半数据那这个查询理论上本来就该是全表级别的工作量建立合适的根过滤是第一优化点第二看PRIOR关联列上的索引是否存在以及执行计划里有没有走Index Range Scan而不是Table Access Full第三看Level深入后每层返回行数的衰减速度如果衰减很慢说明树结构本身就接近扁平的大宽树这时候要重新审视有没有必要全量遍历。为了让大家理解得更直观我用一个模拟场景来说明。假设有一张组织维度表字段就是常见的ID、PID、NAMEID是主键PID是父节点ID顶层PID为空这样一个标准的邻接表模型。网上很多人说这种表查层级只能用CONNECT BY实际上Oracle对CONNECT BY的递归扫描依然依赖PRIOR列上的索引结构所谓“树索引”并不存在你给PID建上普通B树索引就已经是很大的进步。再说一点容易被忽略的事实CONNECT BY的递归方向直接影响性能。从根往下走PRIOR ID PID适合根少、分支深的情况因为根节点基数小每层保持低扇出扫描次数少从某个子节点往上追溯PRIOR PID ID则非常适合“找祖先链”的场景这类查询往往只需要一条路径却经常被人写成把整棵树都遍历一遍再过滤这属于典型的“不知道自己要什么”。先搞清楚方向再谈优化顺序不能反。2. 用执行计划拆解层次查询的瓶颈2.1 从一个慢SQL的执行计划说起我拿一个简化的案例来拆。假设有一张业务表XZ_ORG结构大概是ID、PID、ORG_NAME数据量二十万行层级最深大概六层。最初的查询需求是找出某个根节点下面的所有子节点并且要求带上每个子节点的层级号和根节点的名称。大多数人第一版SQL会写成SELECT ID, PID, ORG_NAME, LEVEL AS LVL, CONNECT_BY_ROOT ORG_NAME AS ROOT_NAME FROM XZ_ORG START WITH ID :ROOT_ID CONNECT BY PRIOR ID PID这段SQL看起来没有任何语法问题但跑起来未必快。我在那个模拟环境里执行了一下发现执行计划里出现了大量的INDEX FULL SCAN或者TABLE ACCESS FULL。原因很简单虽然ID是主键PID上也建了索引但Oracle在CONNECT BY的递归驱动上并不总是能自动选择最优索引路径特别是当优化器评估的基数不准确时它可能选择一个全索引扫描作为递归子查询的基表。疑问来了明明PID有索引为什么不用道理在于PRIOR ID PID这个条件在递归时是动态传入的每次递归的绑定变量值都不一样优化器在生成递归子计划时拿不到具体的值只能靠统计信息估算。如果PID的直方图缺失或者统计信息过期优化器会认为返回的行数很大从而放弃索引扫描转头去做全表扫描。这张表如果还带着大量频繁更新的业务数据统计信息不准几乎是必然的。2.2 优化器统计信息是大多数慢查询的真凶我处理过的很多案例里层次查询慢的最常见原因根本不是SQL写法的问题而是统计信息长期不更新。厂商的报表系统夜间批量跑数日间又不断产生增量统计信息收集作业却只在周末跑一次等到某个层级查询突然变得极慢时DBA打开执行计划一看Cardinality偏差了几十倍甚至上百倍这种情况用脚趾头想也知道是统计信息过期了。解决方案也直接对相关表收集统计信息同时把直方图也带上。对大表来说建议用DBMS_STATS.GATHER_TABLE_STATS的默认采样即可但如果有特殊的谓词条件比如PID字段大量为空值就需要考虑粒度设置。空值在ORACLE的索引中默认不存储这直接带来一个坑——如果START WITH中过滤的是PID IS NULL那么即便PID上有索引Oracle也无法通过正常的索引扫描快速定位这些根节点。不少人在这里栽跟头明明索引建得好好的顶层节点查询就是不走索引。解决这个问题的办法是在PID列上建立一个包括NULL值的函数索引比如对PID做NVL处理后再建索引或者更推荐直接在设计表结构时把顶层的PID设为一个特殊的默认值比如0而不是NULL这样索引扫描就能正常命中。我建议大家在日常巡检时把层次查询相关的表单独列出来至少每周做一次统计信息刷新。不要偷懒不要觉得数据量小不重要递归查询对基数的敏感程度远超普通多表关联有时候一个错误的Rows估算就能让执行时间翻十几倍。另一个常见瓶颈是PRIOR条件关联列的类型隐式转换。比如ID字段是VARCHAR2而传入的绑定参数是NUMBER或者START WITH中写了ID 123而不是ID 123这会导致索引失效执行计划里出现FULL TABLE SCAN的警告。不要觉得这是小事我见过生产环境里一个CONNECT BY查询从毫秒级劣化到秒级最后查来查去就是_ID和传入参数的类型不匹配_只是在SQL里写了个看似无关痛痒的拼接条件。3. 层次查询优化的几个有效手段3.1 让递归方向顺着索引走把方向理顺是性价比最高的优化手段之一。以找子节点为例标准的向下遍历写法是CONNECT BY PRIOR ID PID这个条件的意思是“当前行的ID等于下一行的PID”每次递归都要从PID列上查找等于当前ID的记录。因此PID列上有索引是最基础的要求。但如果你反过来需求是找到某个节点的所有父级那你应该写CONNECT BY PRIOR PID ID每次通过ID主键定位父节点递归次数就等于树的深度每一层都是主键点查几乎最优。很多人习惯一套写法吃遍所有需求我见过有人为了“省事”无论找父找子都写成向下遍历然后靠START WITH过滤出最终需要的节点。试想一下要找出某个节点的一条祖先链你却先把整棵树几万个节点全部遍历一遍再在结果集里用WHERE过滤一个路径上的ID这相当于你想找一本放在书架上的书却先把整个图书馆的书都搬下来再一本一本地翻。方向固定为向上走之后理论上只需沿着主键索引一路点查每层一条记录性能差距是数量级的。另外还要留个心眼树的层级方向有时候会影响Level的计算。向上遍历时根节点就是你给定的起始节点Level从1开始向上数第几代一目了然这对某些层级计数需求反而更方便。所以别把“向下查”当成唯一标准先分析业务模型里真正的遍历方向是最重要的前置判断。3.2 递归WITH也能胜任而且更好控不少人对Oracle的认知还停留在CONNECT BY是唯一解但实际上从11gR2开始Oracle已经支持递归公共表表达式也就是递归WITH在部分场景下它的效率和可控性比CONNECT BY更好。递归WITH的精髓是把每轮递归生成的集合当作下一轮递归的输入每轮都是基于集合的操作优化器可以更好地决定连接顺序和访问路径而不像CONNECT BY那样有比较固定的嵌套循环结构。拿同一个“找指定根下所有子节点”的需求举例递归WITH的写法长这样WITH RECURSIVE_ORG (ID, PID, ORG_NAME, LVL, ROOT_NAME) AS ( SELECT ID, PID, ORG_NAME, 1, ORG_NAME FROM XZ_ORG WHERE ID :ROOT_ID UNION ALL SELECT CHILD.ID, CHILD.PID, CHILD.ORG_NAME, PARENT.LVL 1, PARENT.ROOT_NAME FROM XZ_ORG CHILD JOIN RECURSIVE_ORG PARENT ON CHILD.PID PARENT.ID ) SELECT * FROM RECURSIVE_ORG这套写法的好处很明显递归部分变成了一个显式的JOIN优化器可以根据统计信息和索引情况自由选择嵌套循环还是哈希连接而且可以手动给它加HINTS比如LEADING、USE_NL或者USE_HASH。我在一次真实业务优化中把一张数据量接近百万的配置表从CONNECT BY改成递归WITH配合PID索引和适当的HINT时间从17秒压到了不到3秒提升幅度相当可观。但要注意递归WITH并非在所有情况下都优于CONNECT BY。如果数据量很小树的层次非常浅CONNECT BY的固定写法带来的额外开销几乎可以忽略此时强行改成WITH反而增加了阅读难度。而且递归WITH的迭代终止条件需要特别注意一旦业务数据里存在环路它会陷入死循环必须在递归成员里加上终止条件或环路检测。相比之下CONNECT BY NOCYCLE能直接识别环路并停止这是CONNECT BY在逻辑健壮性上的独特优势。我用一句话概括递归WITH更适合性能敏感、结构规整的场景CONNECT BY更适合语法简洁、自带环路处理的场景二者应该按需选用。3.3 剪枝和过滤越早越好层次查询的优化关键词不是“查得快”而是“查得少”。很多人写CONNECT BY时习惯在最后一层用WHERE过滤结果集比如先遍历出整棵大树再在WHERE里把不符合条件的枝叶摘掉这种思路在数据量小的时候看不出问题数据量一上来就是灾难。正确的做法是把能前置的过滤尽量前置。根节点如果能往小范围收敛就在START WITH里把根的范围写清楚如果业务上明确只需要特定类型或状态的子节点就在递归成员条件里加过滤比如CONNECT BY PRIOR ID PID AND STATUS ACTIVE这样遍历过程中就把无效分支直接掐断整体扫描量会大幅下降。有人担心在递归条件里加业务过滤会不会漏数据会但是多数业务需求本来就不需要全量全状态的树走到这一步反而精准匹配了需求。我举一个实际例子某公司的权限数表有几十万条记录要查询某个人可见的菜单树原SQL没有在CONNECT BY条件里区分菜单类型结果每次都要遍历几十万行再从结果里去过滤慢得令人发指。后来在CONNECT BY条件中加入TYPE条件又配合了一个过滤掉已删除节点的小逻辑扫描量立刻降到一个很小的范围查询时间从十几秒降到了几百毫秒。这就是“查得少”比“查得快”更有效的典型证明。4. 基于代价的优化决策该不该全量展开整棵树4.1 理解层次深度与结果集规模的关系层次查询的性能和结果集规模强相关这点不用多说却需要在优化之前做个量化估计。我常用的方法是把树的最大深度和每层的平均扇出数相乘大体估算出遍历路径的数量级。比如一棵根节点为1第二层有10个子节点第三层每个又有10个子节点四层下来就是11010010001111个节点这还不大但如果每层扇出是100四层下来就是一百多万查询时间就不可能好看。当扇出数很大时能做的优化就很有限了因为结果集本身就是这么大的量数据库无论怎么加快每一行的获取速度总时间仍然下不来。这种情况下我通常会反向思考业务真的需要一次性展开整棵树吗很多场景其实只需要前两三层或者只关心某个分支子树完全可以按需加载。把“一次全量展开”改成“按需递归加载”后原本一个大查询变成多个小查询单个请求的响应时间能压缩到完全可接受的范围。如果确实需要全量数据做后台计算那就要考虑资源问题了。几十万上百万节点的树在OLTP环境里高频跑这种查询再优化也是给数据库添负担。这种场景就该考虑结果缓存、物化视图或者定期抽取到分析库而不是一味压榨SQL本身。别把宝全压在执行计划调整上架构层面的合理分流往往比SQL微调立竿见影得多。4.2 利用CONNECT_BY_ISLEAF和CONNECT_BY_ISCYCLE优化逻辑伪列CONNECT_BY_ISLEAF和CONNECT_BY_ISCYCLE常常被忽略但它们在某些场景下能大幅简化业务逻辑。CONNECT_BY_ISLEAF会标记当前行是否是叶子节点如果某个需求只需要树的叶子节点做汇总那么加上WHERE CONNECT_BY_ISLEAF 1就能在遍历时跳过非叶子的展开相当于只保留最末级分支。虽然数据库内部依旧会遍历所有节点才能判断叶子状态但结果集缩小后后续的排序、聚合、传输成本都会明显下降。CONNECT_BY_ISCYCLE配合NOCYCLE使用则能解决环形数据带来的死循环问题。比如业务表里存在脏数据A的父节点是BB的父节点又是A出现环路时普通CONNECT BY会无限递归。NOCYCLE会让Oracle在检测到环路时返回一条标记为存在环路的记录并停止继续展开CONNECT_BY_ISCYCLE 1就表示该行产生了循环。这种做法既能保证查询不卡死又能把脏数据暴露出来供业务修正。我在实际排查中多次依靠这个伪列定位到有问题的父子关系效率远高于写复杂的自连接去查环路。如果你用的是递归WITH环路检测要靠自己手工写判断条件比如记录当前路径上的ID集合下一条要加入的ID如果已经出现就停止扩展。这个逻辑虽然不难写但代码量明显比CONNECT BY NOCYCLE要多这也是为什么在数据质量没有保障的环境里我更倾向于用CONNECT BY的原因之一。5. 别忘了排序带来的隐性成本层次查询的展示场景通常要求子节点按一定顺序排列于是很多人顺手写了ORDER BY。普通ORDER BY对一个结果集排序没什么问题但如果结果集有几十万行排序带来的临时表空间消耗和CPU开销就不容小觑尤其是Oracle需要做全内存排序或磁盘排序的时候。实际上在层级结构里如果你要的是同级节点之间按某个字段排序应该用ORDER SIBLINGS BY而不是ORDER BY前者能在递归过程中保持兄弟节点的排序关系而后者会彻底打乱层级顺序还得额外排序一次。用的不对等于是双倍浪费。我见过一个很典型的案例某报表查询需要对一棵组织树按节点编码排序结果大家习惯性地在语句最后写了ORDER BY ORG_CODEOracle先递归生成全部结果集再按编码做全排序几万行数据还能接受数据涨到几十万行后排序空间直接吃紧。后来改成ORDER SIBLINGS BY ORG_CODE递归过程中每个父节点下的子节点按编码排好了省掉了最后这个大排序性能提升非常明显。还有一个隐藏成本是SYS_CONNECT_BY_PATH。这个函数能拼出从根到当前节点的完整路径用起来很爽但每行都要执行一次字符串拼接深度越大路径越长扫描量一大字符串内存分配也会成为负担。很多场景其实只需要知道当前节点的父节点或者用CONNECT_BY_ROOT取根节点即可不一定非要拼全路径。我在优化一条查询时把SYS_CONNECT_BY_PATH去掉换成只在叶子上用CONNECT_BY_ROOT取根名称做汇总结果内存消耗直接降了一半。取舍原则很简单不让查询做你实际上用不到的工作。6. 实测优化前后对比与操作记录为了演示优化效果我用一个模拟环境做了一次完整的对比测试。表还是那张组织表数据量20万行树的最大深度6层PID有索引统计信息刚收集完环境是单实例的数据库。初始SQL就是这样SELECT ID, PID, ORG_NAME, LEVEL, SYS_CONNECT_BY_PATH(ORG_NAME, /) AS PATH FROM XZ_ORG START WITH PID IS NULL CONNECT BY PRIOR ID PID ORDER BY ORG_NAME;这条SQL的执行时间在测试环境里跑了大约11秒。执行计划里出现了对PID索引的INDEX FULL SCAN最后一次排序用了2.3MB的临时表空间。问题清晰可见全树遍历、全结果排序、路径字符串拼接三个成本叠加。我做的优化分四步走。第一步把ORDER BY改成ORDER SIBLINGS BY ORG_NAME因为业务上根本不需要最终全排序只需要兄弟节点保持有序第二步把SYS_CONNECT_BY_PATH去掉因为前端只关心树的展示顺序和层级路径可以由前端根据ID拼接不依赖数据库生成第三步把START WITH改成ID :指定根节点而不是PID IS NULL的全根扫描因为接口每次调用只查一棵子树第四步在PID索引上加了一个压缩属性减少索引块的空洞率提高扫描效率。优化后的SQLSELECT ID, PID, ORG_NAME, LEVEL FROM XZ_ORG START WITH ID :ROOT_ID CONNECT BY PRIOR ID PID ORDER SIBLINGS BY ORG_NAME;同样的环境执行时间从11秒降到了0.8秒结果集大小因为只查指定子树也从二十万行缩到了几千行。这个案例最有参考价值的地方在于优化不全是把某个技术点做精更关键的是把业务不需要的工作砍掉少干活比干活快永远更划算。如果确实需要查全树那也有办法。我测试过把CONNECT BY换成递归WITH并强制走哈希连接的方式在20万行全树展开的场景下执行时间大约在4秒左右比原SQL的11秒提升明显但依然比“只查部分子树”的0.8秒慢得多。所以优先缩小业务范围再考虑优化执行方式这个顺序一定不要颠倒。7. 环路、缺失统计信息和错误结果几个容易忽略的坑先聊环路问题。邻接表模型最大的隐患就是数据出现循环引用一旦业务接口录入了A的父节点是B、B的父节点是A的数据普通CONNECT BY就会无限递归最终把临时表空间打爆。很多新手不了解NOCYCLE的用法遇到卡死就只能干瞪眼重启会话。实际上只需要在CONNECT BY后面跟上NOCYCLE关键字Oracle在检测到循环路径后会自动终止该分支的继续递归其他正常分支不受影响查询能正常返回。代价是Oracle需要额外记录一条路径上已经访问过的节点标识内存开销略有增加但相比死循环导致会话崩溃这点代价完全可以接受。顺便说一句如果要查出具体是哪几行数据构成了环路可以加CONNECT_BY_ISCYCLE字段并在外查询里过滤就能标出产生循环的那个节点定位脏数据非常方便。这个技巧在处理权限树、组织树等高度依赖外部维护数据来源的表时堪称救命稻草。再聊统计信息缺失。前文已经提到过直方图和统计信息对基数估算的影响但很多人意识不到递归部分对统计信息的依赖程度比普通查询高得多。CONNECT BY的执行计划里递归子查询的基表行数估算直接来自全表统计信息不会因为有WHERE条件就重新采样这和普通查询里谓词估算逻辑不一样。所以这张表的统计信息一旦严重失真整个递归计划就跟着歪再好的索引也发挥不出来。最典型的表现就是执行计划里出现Cardinality为1的奇怪估算然后Oracle偏偏选了全表扫描实际跑起来报表全表才几百万行直接给你扫出十几秒的延迟。遇到这种情况第一件事就是对表重新收集统计信息通常能解决大半问题。最后讲一个很容易踩的坑CONNECT BY输出的LEVEL和树的实际深度定义不一致导致业务逻辑出错。多数树的定义里根节点是第0层还是第1层各业务系统都不一样Oracle里LEVEL起始值是1如果业务规定根节点是0那需要在外层查询减1。这类问题不算性能问题但排查起来比性能问题更隐蔽因为结果只是“差了一层”不仔细看根本发现不了。建议在开发阶段就用一个已知深度的测试数据把LEVEL基数锁定下来这个约定一定要写进设计文档避免后续接手的人误会。8. 同类场景的优化套路总结其实我上面讲的这些方法并不只适用于组织树凡是邻接表模型都能复用同一套思路。典型的场景包括商品类目树、区域层级、菜单权限、BOM物料清单、流程节点依赖关系等这些表的结构基本一致都是ID和PID两个字段加各种业务属性查询需求也无非是查子树、查祖先链、判断层级关系、做叶子节点汇总。套路归纳下来是五条一是确认遍历方向根少向下点查向上二是保证PRIOR关联列有索引且统计信息新鲜三是能用边遍历边过滤就不要先遍历后过滤四是排序需求尽量用ORDER SIBLINGS BY避免无谓的整体排序五是能按需加载就不要全量展开按需加载是最大的性能杠杆。这五条优先级有先后前两条是地基后三条是进阶。地基没打好后面做得再花哨也白搭。我刚入行那会儿也迷信各种HINT和复杂写法总觉得优化就该有奇技淫巧后来踩过几次坑才明白大部分层次查询的慢问题都出在最朴素的地方索引没建对、统计信息过期、过滤太晚、结果集取多了。把功夫花在刀刃上效果往往朴素得让人不敢相信。9. 个人实操中反复验证过的几条建议到了分享建议环节我也不提什么宏观方法论就说几条自己反复验证过有用的点。第一Oracle层次查询的调优一定要先看执行计划里的Rows估算值和实际返回行数的差距。差距大就说明统计信息有问题先收集统计信息再谈索引。不要一上来就怀疑SQL写法很多问题只是信息不新鲜而已。第二PID上建索引时如果表的数据量很大且顶层PID大量为NULL强烈建议考虑给PID列加一个函数索引或者把顶层值单独设为特殊标记否则START WITH查顶层节点很难走索引命中会白白全表扫一次。这个细节很可能成为你优化路上最值得记住的一个坑。第三递归WITH适合在查询逻辑需要多轮聚合或者复杂递归计算时使用普通的树展开用CONNECT BY就够了不要为了追求“新技术”而强行改写。数据库的世界里没有银弹适合场景的才是最好的。第四生产环境里如果有人跟你说某条层次查询“以前很快现在很慢”不要怀疑Oracle出了毛病先去看执行计划是否发生了变化。递归查询的执行计划对统计信息的变动尤其敏感日期变了、数据分布变了、新增了一个索引都可能导致优化器另辟蹊径而这个路径往往不是最优的。第五如果查询频繁且结果集变化不频繁考虑给结果建立物化视图或定期快照。一棵树的结构数据短时间不变却每隔几秒就被人查一次全量这就是典型的缓存场景。把相同的结果缓存起来比每次现算要靠谱得多。这个思路很多人懒得做是因为总觉得“再优化下SQL就能扛住”但访问频率一高扛住的只是数据库的运气不是架构的合理性。我个人的做法是单个查询超过500毫秒且每秒钟被调用超过一次就认真考虑引入缓存或预计算。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →