SQL优化:join估行不准选错执行计划

问题描述

数据库版本:OceanBase3.2.3.3
一个 join 查询,关联字段包含组合主键中的第1、2、4个字段,走 nested-loop join 时,被驱动表只能匹配主键的前两个字段,成本cost1较低,但实际效率不高;并且驱动表的扇出n(也就是输出行数)估行比实际小很多。在计算总成本时,join 总成本~=(驱动表成本 + n*cost1)。
在本案例中驱动表成本是固定的,执行计划中 n 的估算值只有5000,但实际值有60万,cost1=154。计算成本时,n*cost1 比实际小很多,优化器最终选择了 nested-loop join,如果被驱动表可以匹配主键的全部字段,效率是很高的,但这里由于只能匹配前两个字段,效率较差,导致整个查询耗时非常长。

分析过程

1. 分析执行计划

问题SQL如下,执行耗时 500秒+:

select
 count(*) from 
 (
  SELECT
    JGBM AS QYDJID,
    SEGMENT3 AS FNUMBER,
    PERIOD_NAME AS SSQJ,
    ...
  FROM
    (
      SELECT
        ...
      FROM
        DC_ACCOUNTBALANCE_TEMP A,
        DEF_ACCOUNTCONFIG B,
        DC_ACCOUNT C,
        NVAT_ACCANDTAXIDMAPFORP07 D,
        BI_CHOICEOFUNIT E
      WHERE
        A.SEGMENT1 = D.ZTJGBM
        AND D.SBDWID = E.SBDWID
        AND B.JGBM = E.DEPTCODE
        AND B.YXQSNY <= (
          substr(A.PERIOD_NAME, 4, 6) || substr(A.PERIOD_NAME, 1, 2)
        )
        AND (
          substr(A.PERIOD_NAME, 4, 6) || substr(A.PERIOD_NAME, 1, 2)
        ) <= B.YXJZNY
        AND C.QYDJID = B.SYZT
        AND C.FNUMBER = A.SEGMENT3
        AND C.ACCOUNTYEAR = substr(A.PERIOD_NAME, 4, 6)
        AND a.period_name = '10-2023'
    ) SUB
  GROUP BY
    JGBM,
    SEGMENT3,
    PERIOD_NAME
) X 
left join DC_ACCOUNTBALANCE A 
ON (
  A.SSQJ = X.SSQJ
  AND A.QYDJID = X.QYDJID
  AND A.FNUMBER = X.FNUMBER
);

执行计划如下(多余信息已删除),结合SQL内容进行解读:

  • X 表是 A、B、C、D、E 5张表关联的结果,然后与 A 表进行关联查询。从执行计划看,主要成本在X 表,因此先执行 X 部分确认是否慢在这部分,执行耗时只要5秒,结果有61万行,但执行计划中估行只有 5123
  • X 部分很快,慢在 A 部分,因为是 NESTED-LOOP JOIN,A 作为被驱动表会循环查询 61万次(batch_join=false),每次查询走主键,执行计划13号算子中 range_key([A.SSQJ(0x7eb5a42ec400)], [A.QYDJID(0x7eb5a42ed840)], [A.DATAUSE(0x7ec8f84434e0)], [A.FNUMBER(0x7eb5a42eec80)]), range(MIN ; MAX) 部分信息说明索引里有 4个字段,但是range_cond([A.SSQJ(0x7eb5a42ec400) = ?(0x7ec8f8451e20)], [A.QYDJID(0x7eb5a42ed840) = ?(0x7ec8f8452950)])这部分表示只能用到索引的前两个字段,这会是慢的原因吗?有个信息可以提供佐证:A:table_rows:32310843, physical_range_rows:391, logical_range_rows:391 优化器估算A表每次查询需要扫描 391 行,这个效率确实是不高的
  • 在估算 nested-loop join 的总成本时,计算逻辑是驱动表的成本+驱动表的扇出*被驱动表查询一次的成本,这个SQL中驱动表的扇出(5123)比实际值(61万)小很多,估算出的总成本比实际小很多
=================================================================================
|ID|OPERATOR               |NAME                              |EST. ROWS|COST   |
---------------------------------------------------------------------------------
|0 |SCALAR GROUP BY        |                                  |1        |3947739|
|1 | NESTED-LOOP OUTER JOIN|                                  |5123     |3947543|
|2 |  SUBPLAN SCAN         |X                                 |5123     |3154937|
|3 |   HASH GROUP BY       |                                  |5123     |3154861|
|4 |    HASH JOIN          |                                  |5123     |3149203|
|5 |     TABLE SCAN        |C                                 |81314    |31453  |
|6 |     HASH JOIN         |                                  |63573    |2940900|
|7 |      HASH JOIN        |                                  |1898     |35447  |
|8 |       TABLE SCAN      |D(IDX_ACCANDTAXIDMAPFORP07_CMB1)  |2011     |778    |
|9 |       HASH JOIN       |                                  |1736     |32462  |
|10|        TABLE SCAN     |E(IDX_BI_CHOICEOFUNIT_CMB1)       |1704     |660    |
|11|        TABLE SCAN     |B                                 |29154    |11277  |
|12|      TABLE SCAN       |A(IDX_DC_ACCOUNTBALANCE_TEMP_TEST)|639387   |2468263|
|13|  TABLE SCAN           |A                                 |1        |154    |
=================================================================================

Outputs & filters: 
-------------------------------------
 ...
  13 - output([remove_const(1)(0x7ec8f846ba40)]), filter([A.FNUMBER(0x7eb5a42eec80) = ?(0x7ec8f8453480)]), 
      access([A.FNUMBER(0x7eb5a42eec80)]), partitions(p0), 
      is_index_back=false, filter_before_indexback[false], 
      range_key([A.SSQJ(0x7eb5a42ec400)], [A.QYDJID(0x7eb5a42ed840)], [A.DATAUSE(0x7ec8f84434e0)], [A.FNUMBER(0x7eb5a42eec80)]), range(MIN ; MAX), 
      range_cond([A.SSQJ(0x7eb5a42ec400) = ?(0x7ec8f8451e20)], [A.QYDJID(0x7eb5a42ed840) = ?(0x7ec8f8452950)])

Used Hint:
...

Optimization Info:
-------------------------------------
...
A:table_rows:32310843, physical_range_rows:391, logical_range_rows:391, index_back_rows:0, output_rows:0, est_method:local_storage, optimization_method=cost_based, avaiable_index_name[DC_ACCOUNTBALANCE],...

2. 分析表的统计信息

上一步我们分析得出:X 部分查询很快,慢在A表查询,要查询 61万次。A 表查询时使用了主键的前两个字段,因此需要分析一下 A 表的统计信息,主键的4个字段的 NDV 分别是多少,结果如下:

  • SSQJ、QYDJID 两个字段的 NDV 并不高,每组值的重复次数可以通过统计信息估算:32310843/(85*972)=391,这个就是执行计划中的 physical_range_rows:391,意思就是每次查询大概要扫 391 行数据,这个效率如果只执行一次是没啥问题的,但这个SQL里需要执行61万次,总耗时就大了。
  • 另外SQL中关联字段包含了主键的3个字段,不在条件里的第3个字段 DATAUSE 实际值都为1,从逻辑上来看,SQL中加上AND A.DATAUSE = 1条件的结果不会变,这样的好处是 A 表查询时可以使用主键的所有字段,每次只需要扫1行数据,效率会高很多。另一种更好的方式是主键中去掉 DATAUSE 字段,不过OB不支持修改主键。
--查询
select column_name,num_distinct from all_tab_col_statistics where table_name='DC_ACCOUNTBALANCE';
--结果
column_name        num_distinct
SSQJ                85
QYDJID              972
DATAUSE             1
FNUMBER             2616

3. 改写1:加 AND A.DATAUSE = 1 条件

加条件后,SQL耗时从 500秒降到8秒,执行计划如下,A表每次只要扫描1行:

=================================================================================
|ID|OPERATOR               |NAME                              |EST. ROWS|COST   |
---------------------------------------------------------------------------------
|0 |SCALAR GROUP BY        |                                  |1        |3214924|
|1 | NESTED-LOOP OUTER JOIN|                                  |5123     |3214729|
|2 |  SUBPLAN SCAN         |X                                 |5123     |3154937|
|3 |   HASH GROUP BY       |                                  |5123     |3154861|
|4 |    HASH JOIN          |                                  |5123     |3149203|
|5 |     TABLE SCAN        |C                                 |81314    |31453  |
|6 |     HASH JOIN         |                                  |63573    |2940900|
|7 |      HASH JOIN        |                                  |1898     |35447  |
|8 |       TABLE SCAN      |D(IDX_ACCANDTAXIDMAPFORP07_CMB1)  |2011     |778    |
|9 |       HASH JOIN       |                                  |1736     |32462  |
|10|        TABLE SCAN     |E(IDX_BI_CHOICEOFUNIT_CMB1)       |1704     |660    |
|11|        TABLE SCAN     |B                                 |29154    |11277  |
|12|      TABLE SCAN       |A(IDX_DC_ACCOUNTBALANCE_TEMP_TEST)|639387   |2468263|
|13|  TABLE GET            |A                                 |1        |11     |
=================================================================================
Outputs & filters: 
...
13 - output([remove_const(1)(0x7eb91646c790)]), filter(nil), 
      access([A.SSQJ(0x7eb91646b730)]), partitions(p0), 
      is_index_back=false, 
      range_key([A.SSQJ(0x7eae68cec980)], [A.QYDJID(0x7eae68ceddc0)], [A.DATAUSE(0x7eae68cf05d0)], [A.FNUMBER(0x7eae68cef200)]), range(MIN ; MAX), 
      range_cond([A.DATAUSE(0x7eae68cf05d0) = 1(0x7eae68cefeb0)], [A.SSQJ(0x7eae68cec980) = ?(0x7eb916451ce0)], [A.QYDJID(0x7eae68ceddc0) = ?(0x7eb916452810)], [A.FNUMBER(0x7eae68cef200) = ?(0x7eb916453340)])
...
Optimization Info:
-------------------------------------
A:table_rows:32310843, physical_range_rows:1, logical_range_rows:1, index_back_rows:0, output_rows:1, est_method:local_storage, optimization_method=rule_based, heuristic_rule=unique_index_without_indexback

4. 改写2:加 hint 走 hash join

前面我们分析A表查询只能使用主键索引的前2个字段,效率不高,这种情况下可以看下 hash join 的执行效率,加 hint /*+ leading(X A) use_hash(A) */ 耗时只要40秒。执行计划如下,结合前面的分析进行解读:

  • 被驱动表 A 除了关联条件没有其他条件,要做全表扫描,成本很高,所以总成本也很高,并且显然比 nested-loop join 的成本高,在没有 hint 干预的情况下,优化器会选 nested-loop join
=============================================================================
|ID|OPERATOR          |NAME                              |EST. ROWS|COST    |
-----------------------------------------------------------------------------
|0 |SCALAR GROUP BY   |                                  |1        |52828380|
|1 | HASH OUTER JOIN  |                                  |5123     |52828184|
|2 |  SUBPLAN SCAN    |X                                 |5123     |3154937 |
|3 |   HASH GROUP BY  |                                  |5123     |3154861 |
|4 |    HASH JOIN     |                                  |5123     |3149203 |
|5 |     TABLE SCAN   |C                                 |81314    |31453   |
|6 |     HASH JOIN    |                                  |63573    |2940900 |
|7 |      HASH JOIN   |                                  |1898     |35447   |
|8 |       TABLE SCAN |D(IDX_ACCANDTAXIDMAPFORP07_CMB1)  |2011     |778     |
|9 |       HASH JOIN  |                                  |1736     |32462   |
|10|        TABLE SCAN|E(IDX_BI_CHOICEOFUNIT_CMB1)       |1704     |660     |
|11|        TABLE SCAN|B                                 |29154    |11277   |
|12|      TABLE SCAN  |A(IDX_DC_ACCOUNTBALANCE_TEMP_TEST)|639387   |2468263 |
|13|  TABLE SCAN      |A                                 |32310843 |12497986|
=============================================================================

总结

这是一个很经典的问题:如果 join 时关联表太多,执行计划容易选错。
原因是估算驱动表的扇出很容易产生误差,尤其join的结果作为驱动表时,相当于要估算join的结果有多少行,这个误差会更大。而优化器在估算 nested-loop join 算法的成本逻辑中,驱动表的扇出对计算结果影响很大,也就是说 nested-loop join 的成本估算结果很容易产生误差,所以执行计划容易选错。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容