부제: Partition Wise Join의 성능

 

DBA나 튜닝 컨설턴트들에게 PARTITION WISE JOIN(이후 PWJ)를 설명해 보라고 하면, 언제나 PARALLEL PQ_DISTRIBUTE 힌트를 언급한다. 이상한 일이다. PWJ PARALLEL + PQ_DISTRIBUTE 힌트 조합과 상관없이 독립적으로 존재한다. 그럼에도 불구하고 PWJ를 설명하기 위해 두 힌트를 항상 끌어들인다. 마치 PWJ 기능이 두 힌트에 종속되기라도 하는 것처럼 말이다. 그 이유는 메뉴얼을 포함한 거의 모든 튜닝책에 위의 두 힌트를 사용해서 PWJ를 설명하고 있기 때문이다.

 

 

말도 안 되는 소리
이제는 PWJ를 설명 할 때, PARALLEL PQ_DISTRIBUTE 힌트와 연계하여 설명하지 말기 바란다. 이렇게 연계하여 설명하는 것은 근본원리를 모르면서 활용하려고 하는 것이다. 더 잘못된 것은 성능이 개선되는 이유는 조인할 PARTITION을 미리 짝지어 놓았기 때문(Partition Pair)에 조인이 빠르다고 설명한다. 잘못된 설명이다. 성능문제의 대부분이 두 가지 관점(BLOCK I/O PGA 사용량)으로 결정된다는 것은 상식이다. 그런데 두 가지 관점을 설명하지도 않고 PARTITION을 미리 짝지어 놓았기 때문에 빠르다는 것은 어불성설이다.

 

 

지금까지의 PWJ의 정의를 과감히 버려라. 그리고 지금부터 PWJ를 재정의 해보자. PWJ를 다시 설명하지 않으면 다음의 세가지를 알 수 없다.

 

1. PARALLEL 힌트와 PQ_DISTRIBUTE 힌트를 사용하지 않고 PWJ를 설명 할 수 있다.
2. PWJ
를 사용함으로써 개선된 성능을 BLOCK I/O로 나타낼 수 있다.
3. PWJ
를 사용함으로써 개선된 성능을 PGA 사용량으로 나타낼 수 있다.

 

1. 번은 개념을 설명하는 것이다. 2,3번은 성능향상을 수치로 나타낼 수 있느냐는 것이다. 아마 기존의 상식으로는 힘들것이다. 따라서 이글의 목적은 위의 세 가지를 이해하고 나타낼 수 있는 능력을 기르는 것이다

 

 

먼저 파티션 테이블 두 개(SALES_PT,SALES_ORDER_PT)를 만들고, 파티션이 되지 않은 테이블 두 개(SALES_NO_PT, SALES_ORDER_NO_PT)를 만든다. SALES_PT SALES_NO_PT의 차이점은 전자는 파티셔닝이 적용되고 후자는 적용되지 않았다는 것이다. SALES_ORDER_PT SALES_ORDER_NO_PT의 차이점도 마찬가지다.

 

 

테이블 생성 스크립트 다운로드

 

Table Generation.SQL

환경: 오라클 11.2.0.1 
     
모든 테이블은 대략 92만 건이다.
      SALES_PT, SALES_ORDER_PT 두 테이블의 파티션 키 컬럼은 TIME_ID 이다. 

 

 

먼저 파티션이 되지 않은 테이블 두 개를 이용하여 HASH JOIN으로 실행해보자.

 

SELECT /*+ LEADING(T)  */ COUNT(*)                                                       
  FROM SALES_NO_PT S, SALES_ORDER_NO_PT T                                                
 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')                                      
   AND S.PROD_ID = T.PROD_ID                                                             
   AND S.CUST_ID = T.CUST_ID                                                             
   AND S.TIME_ID = T.TIME_ID                                                             
   AND S.CHANNEL_ID = S.CHANNEL_ID                                                       
   AND S.PROMO_ID = T.PROMO_ID  ;                                                          
                                                                      

------------------------------------------------------------------------------------------
| Id  | Operation           | Name              | Starts | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |                   |      1 |      1 |00:00:05.20 |    9898 |
|   1 |  SORT AGGREGATE     |                   |      1 |      1 |00:00:05.20 |    9898 |
|*  2 |   HASH JOIN         |                   |      1 |    154 |00:00:05.20 |    9898 |
|*  3 |    TABLE ACCESS FULL| SALES_ORDER_NO_PT |      1 |     88 |00:00:03.12 |    5457 |
|   4 |    TABLE ACCESS FULL| SALES_NO_PT       |      1 |    918K|00:00:01.20 |    4441 |
------------------------------------------------------------------------------------------

                                                                                                                                                                                     

Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                         
   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."TIME_ID"="T"."TIME_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   3 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

파티션이 아님으로 PWJ가 발생하지 않았다. ID 4 BLOCK I/O(Buffers) 4441임을 주목하라. 또한 후행테이블의 건수(A-Row) 918K임을 기억하라. 이제 PWJ 를 실행할 차례다.

 

 

SELECT /*+ LEADING(T) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

AND S.PROMO_ID = T.PROMO_ID ;

 

 

--------------------------------------------------------------------------------------------------------

| Id  | Operation            | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   1 |  SORT AGGREGATE      |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   2 |   PARTITION RANGE ALL|                |      1 |     1 |    28 |    154 |00:00:00.18 |    6263 |

|*  3 |    HASH JOIN         |                |     28 |       |       |    154 |00:00:00.18 |    6263 |

|*  4 |     TABLE ACCESS FULL| SALES_ORDER_PT |     28 |     1 |    28 |     88 |00:00:00.10 |    5947 |

|   5 |     TABLE ACCESS FULL| SALES_PT       |      1 |     1 |    28 |  60608 |00:00:00.02 |     316 |

--------------------------------------------------------------------------------------------------------

 

Outline Data

-------------

 

  /*+

      BEGIN_OUTLINE_DATA

      ...생략

      PX_JOIN_FILTER(@"SEL$1" "S"@"SEL$1")

      END_OUTLINE_DATA

  */

 

Predicate Information (identified by operation id):

---------------------------------------------------

   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND

              "S"."PROMO_ID"="T"."PROMO_ID")

   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

 

 

조인된 두 테이블은 모두 파티셔닝 되었다. 따라서 PARTITION RANGE ALL이 두 번 나와야 됨에도 불구하고 ID 2을 보면 단 한번만 나온다. PWJ가 실행되었다는 증거다. PWJ가 실행되지 않은 경우와 BLOCK I/O를 비교해 보면 14배 이상 차이가 난다. 바로 이 것이 FULL PWJ가 실행되면 성능에 유리한 이유다. FULL PWJ가 실행되면 후행 테이블의 파티션을 모두 읽을 필요가 없다. 왜냐하면 이미 선행테이블(BUILD INPUT)을 읽는 과정에서 어떤 파티션을 액세스 할 것인지 결정 되었기 때문이다. 따라서 후행테이블의 건수도 918K건이 아니라 59K(60608)에 불과한 것이다.

 

 

위의 Outline Data를 보고 혹자는 'PX_JOIN_FILTER 때문에 PARTITION PRUNING이 일어난 것이다' 고 의혹을 제기한다. 하지만 PX_JOIN_FILTER FULL PWJ는 아무 상관이 없다. 왜냐하면 아래와 같이 NO_PX_JOIN_FILTER 힌트를 사용해서 Filter를 제거해도 PWJ의 효과가 유지되기 때문이다. 

 

 

SELECT /*+ LEADING(T) NO_PX_JOIN_FILTER(S) */ COUNT(*)                                                   

  FROM SALES_PT S, SALES_ORDER_PT T                                                                     

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')                                                     

  AND S.PROD_ID = T.PROD_ID                                                                              

  AND S.CUST_ID = T.CUST_ID                                                                             

  AND S.TIME_ID = T.TIME_ID                                                                              

  AND S.CHANNEL_ID = S.CHANNEL_ID                                                                       

  AND S.PROMO_ID = T.PROMO_ID      ;                                                                     

                                                                                                      

--------------------------------------------------------------------------------------------------------

| Id  | Operation            | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |

--------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   1 |  SORT AGGREGATE      |                |      1 |       |       |      1 |00:00:00.18 |    6263 |

|   2 |   PARTITION RANGE ALL|                |      1 |     1 |    28 |    154 |00:00:00.18 |    6263 |

|*  3 |    HASH JOIN         |                |     28 |       |       |    154 |00:00:00.18 |    6263 |

|*  4 |     TABLE ACCESS FULL| SALES_ORDER_PT |     28 |     1 |    28 |     88 |00:00:00.10 |    5947 |

|   5 |     TABLE ACCESS FULL| SALES_PT       |      1 |     1 |    28 |  60608 |00:00:00.02 |     316 |

--------------------------------------------------------------------------------------------------------

                                                                                                        

Outline Data                                                                                             

-------------                                                                                           

                                                                                                         

  /*+                                                                                                   

      BEGIN_OUTLINE_DATA                                                                                

      IGNORE_OPTIM_EMBEDDED_HINTS                                                                       

      OPTIMIZER_FEATURES_ENABLE('11.2.0.1')                                                             

      DB_VERSION('11.2.0.1')                                                                             

      ALL_ROWS                                                                                          

      OUTLINE_LEAF(@"SEL$1")                                                                            

      FULL(@"SEL$1" "T"@"SEL$1")                                                                        

      FULL(@"SEL$1" "S"@"SEL$1")                                                                        

      LEADING(@"SEL$1" "T"@"SEL$1" "S"@"SEL$1")                                                          

      USE_HASH(@"SEL$1" "S"@"SEL$1")                                                                    

      END_OUTLINE_DATA                                                                                   

  */                                                                                                    

                                                                                                        

Predicate Information (identified by operation id):                                                     

---------------------------------------------------                                                     

   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID"

               AND "S"."PROMO_ID"="T"."PROMO_ID")                                                       

   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

 

 

 

위의 SQL에서 NO_PX_JOIN_FILTER를 사용하여 FILTER를 제거시켰지만 여전히 PWJ가 실행되었다. 따라서 PX_JOIN_FILTER FULL PWJ의 원리가 아니다. PWJ의 성능향상 원리는 T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD') 조건에 있다. 다시 말해 이 조건 때문에 후행 테이블은 2001년도 1분기 파티션만 읽으면 되는 것이다. 중요한 점은 T.ORDER_DT 컬럼이 Partition Key 컬럼이 아님에도 성능이 향상되었다는 점이다. 아래의 SQL이 그것을 증명한다.

 

 

SELECT /*+ FULL(SALES) */ COUNT(*)                  

  FROM SALES_PT PARTITION (ST_Q1_2001);

 

-----------------------------------------------------------------------------------------------------

| Id  | Operation               | Name     | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |

-----------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT        |          |      1 |       |       |      1 |00:00:00.05 |     316 |

|   1 |  SORT AGGREGATE         |          |      1 |       |       |      1 |00:00:00.05 |     316 |

|   2 |   PARTITION RANGE SINGLE|          |      1 |    17 |    17 |  60608 |00:00:00.04 |     316 |

|   3 |    TABLE ACCESS FULL    | SALES_PT |      1 |    17 |    17 |  60608 |00:00:00.02 |     316 |

-----------------------------------------------------------------------------------------------------

 

 

PWJ의 성능개선 원리는 선행집합의 Filter에 있다
FULL PWJ가 발생했을 때와 같이 정확히 316 BLOCK 만 읽었다. PWJ가 실행됨으로써 BLOCK I/O 관점의 성능개선사항은 명확해졌다. 선행집합의 FILTER가 후행집합의 BLOCK I/O를 결정한다는 것이다. 다시 말해 선행집합은 T.ORDER_DT = 상수조건에 상관없이 모든 파티션을 ACCESS 해야 한다. 하지만 후행집합은 T.ORDER_DT = 상수조건에 만족하는 파티션만 ACCESS 하는 것이 PWJ의 성능개선 원리이다.

 

 

Partial PWJ의 성능개선 원리도 FULL PWJ와 같다
FULL PWJ의 성능개선 원리와 Partial PWJ의 성능개선 원리는 같다. 하지만 처리방식이 다르다. Partial PWJ는 내부적으로Bloom Pruning을 이용한다. Bloom Filter를 이용하여 후행테이블의 조인건수를 줄일 수 있는데, Bloom Pruning도 같은 메커니즘을 이용하여 후행 테이블의 파티션 Access 개수를 최소화 하는 것이다. 아래의 SQL Partial PWJ 예제이며, Bloom Pruning을 이용하여 후행집합의 파티션 Access 개수를 최소화 하고 있다. 

 

 

ALTER SESSION SET "_bloom_pruning_enabled" = TRUE;

 

SELECT /*+ LEADING(T) USE_HASH(S) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_NO_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID        ;

---------------------------------------------------------------------------------------------------------------

| Id  | Operation                     | Name             | Starts| Pstart| Pstop | A-Rows|   A-Time  | Buffers|

---------------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT              |                  |      1|       |       |      1|00:00:00.64|    5773|

|   1 |  SORT AGGREGATE               |                  |      1|       |       |      1|00:00:00.64|    5773|

|*  2 |   HASH JOIN                   |                  |      1|       |       |    154|00:00:00.64|    5773|

|   3 |    PART JOIN FILTER CREATE    | :BF0000          |      1|       |       |     88|00:00:00.52|    5457|

|*  4 |     TABLE ACCESS FULL         | SALES_ORDER_NO_PT|      1|       |       |     88|00:00:00.52|    5457|

|   5 |    PARTITION RANGE JOIN-FILTER|                  |      1|:BF0000|:BF0000|  60608|00:00:00.05|     316|

|   6 |     TABLE ACCESS FULL         | SALES_PT         |      1|:BF0000|:BF0000|  60608|00:00:00.03|     316|

---------------------------------------------------------------------------------------------------------------

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     

Predicate Information (identified by operation id):                                   
---------------------------------------------------                                   
   2 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID"             
                AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."PROMO_ID"="T"."PROMO_ID")    
   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))
                                                  

 

                                                                                          

FULL PARTITION WISE JOIN PARTIAL PARTITION WISE JOIN의 성능개선 원리는 같으며, BLOCK I/O도 동일하다. 다른 점은 PARTIAL PARTITION WISE JOINBloom Pruning를 이용했다는 것뿐이다. 다시 말해, 아래처럼 Bloom Pruning 기능을 사용할 수 없게 된다면 PARTIAL PARTITION WISE JOIN시 성능향상(후행 집합의 BLOCK I/O 감소)을 기대할 수 없다.   

 

 

ALTER SESSION SET "_bloom_pruning_enabled" = false;

 

SELECT /*+ LEADING(T) USE_HASH(S) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_NO_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

------------------------------------------------------------------------------------------------------------
| Id  | Operation             | Name              | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |
------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT      |                   |      1 |       |       |      1 |00:00:01.22 |   10341 |
|   1 |  SORT AGGREGATE       |                   |      1 |       |       |      1 |00:00:01.22 |   10341 |
|*  2 |   HASH JOIN           |                   |      1 |       |       |    154 |00:00:01.22 |   10341 |
|*  3 |    TABLE ACCESS FULL  | SALES_ORDER_NO_PT |      1 |       |       |     88 |00:00:00.54 |    5457 |
|   4 |    PARTITION RANGE ALL|                   |      1 |     1 |    28 |    918K|00:00:00.31 |    4884 |
|   5 |     TABLE ACCESS FULL | SALES_PT          |     28 |     1 |    28 |    918K|00:00:00.15 |    4884 |
------------------------------------------------------------------------------------------------------------
                                                                                                            
Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                                                                             
   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."TIME_ID"="T"."TIME_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   3 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

_bloom_pruning_enabled 파라미터를 false로 놓고 실행하자 SALES_PT 테이블의 모든 파티션을 Access하게 되었다. 이에 따라 성능도 저하된다. PARTIAL PARTITION WISE JOIN Bloom Pruning를 이용하고 있다는 증거이다.

 

 

Partition PairPWJ의 성능개선 원리가 아니다
많은 책에서 PWJ를 설명하기 위해 Partition Pair라는 용어를 사용하고 있다. 하지만 Partition Pair PWJ의 원리가 아니라, 조인 시 선행집합에 PARTITION PRUNING이 발생한 것뿐이다. 아래의 SQL을 실행시켜 보자.

 

 

SELECT /*+ LEADING(T) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND T.TIME_ID BETWEEN TO_DATE('20010101', 'YYYYMMDD') AND TO_DATE('20011231', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

-------------------------------------------------------------------------------------------------------------

| Id  | Operation                 | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |
-------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT          |                |      1 |       |       |      1 |00:00:00.08 |    1984 |
|   1 |  SORT AGGREGATE           |                |      1 |       |       |      1 |00:00:00.08 |    1984 |
|   2 |   PARTITION RANGE ITERATOR|                |      1 |    17 |    20 |    154 |00:00:00.08 |    1984 |
|*  3 |    HASH JOIN              |                |      4 |       |       |    154 |00:00:00.08 |    1984 |
|*  4 |     TABLE ACCESS FULL     | SALES_ORDER_PT |      4 |    17 |    20 |     88 |00:00:00.02 |    1668 |
|*  5 |     TABLE ACCESS FULL     | SALES_PT       |      1 |    17 |    20 |  60608 |00:00:00.02 |     316 |
-------------------------------------------------------------------------------------------------------------
                                                                                                            
Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                         
   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   4 - filter(("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss')                     
                AND "T"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss')))              
   5 - filter("S"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

 

T.TIME_ID 조건에 의해서 선행집합에 PARTITION PRUNING이 발생하여 전체 파티션이 아닌 2001년도 파티션 4개만 읽으면 된다. 이에 따라 선행 테이블의 I/O 5947에서 1668로 줄어들었다. 비록 선행집합의 I/O가 줄어들었지만, PWJ의 성능 개선 원리인 '선행집합의 FILTER 조건이 후행집합의 BLOCK I/O를 결정한다'는 변치 않는다. 즉 선행테이블의 두 조건인 T.ORDER_DT = 조건과 T.TIME_ID BETWEEN 조건의 교집합이 후행테이블의 ACCESS 범위가 되는 것이다. 만약 PWJ의 성능개선 원리가 없고 Partition Pair만 존재했다면 후행집합이 2001년에 해당하는 파티션 4개를 모두 읽어야 할 것이다. 하지만 위에서 보는 것처럼 후행집합은 단 하나의 파티션만 Access 한다.

 

물론 아무런 조건 없이 Partition Key 조건만 있다면 아래처럼 Partition Pair가 되기는 한다.

 

 

SELECT /*+ LEADING(T) USE_HASH(S) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.TIME_ID BETWEEN TO_DATE('20010101', 'YYYYMMDD') AND TO_DATE('20011231', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID = T.TIME_ID

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

-------------------------------------------------------------------------------------------------------------
| Id  | Operation                 | Name           | Starts | Pstart| Pstop | A-Rows |   A-Time   | Buffers |
-------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT          |                |      1 |       |       |      1 |00:00:00.61 |    3022 |
|   1 |  SORT AGGREGATE           |                |      1 |       |       |      1 |00:00:00.61 |    3022 |
|   2 |   PARTITION RANGE ITERATOR|                |      1 |    17 |    20 |    464K|00:00:00.58 |    3022 |
|*  3 |    HASH JOIN              |                |      4 |       |       |    464K|00:00:00.50 |    3022 |
|*  4 |     TABLE ACCESS FULL     | SALES_ORDER_PT |      4 |    17 |    20 |    259K|00:00:00.05 |    1668 |
|*  5 |     TABLE ACCESS FULL     | SALES_PT       |      4 |    17 |    20 |    259K|00:00:00.05 |    1354 |
-------------------------------------------------------------------------------------------------------------
                                                                                                            
Predicate Information (identified by operation id):                                                         
---------------------------------------------------                                                         
   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND
              "S"."PROMO_ID"="T"."PROMO_ID")                                                                
   4 - filter("T"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     
   5 - filter("S"."TIME_ID"<=TO_DATE(' 2001-12-31 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))                     

 

 

 

T.ORDER_DT 조건을 삭제하고 T.TIME_ID 조건만 있으므로 정확히 두 테이블은 Pair이다. 즉 두 테이블은 2001년 파티션을 각각 4개씩 읽었다. PWJ 발생시 BLOCK I/O 관점의 성능개선 원리는 FULL PWJ이냐 아니면 Partial PWJ이냐에 따라 변치 않으며 아래처럼 정의 할 수 있다.

 

1. 선행집합의 파티션 ACCESS 범위는 PARTITION KEY 조건에 의한 Partition Pruning에 의해 결정된다. 하지만 이 기능은 PWJ의 장점이 아니다. 왜냐하면 조인이 아닌 경우도 실행되기 때문이다.


2. 후행집합의 파티션 ACCESS 범위는 선행집합의 모든 FILTER에 의해 결정된다. 이 기능이야 말로 Partition Wise Join의 장점이다. 왜냐하면 조인에 의해서 성능이 향상되기 때문이다 

                                                                                                                                                                                                                                                                                    

 

PWJ 성능향상의 두 번째 원리 

이제 BLOCK I/O관점에서 성능개선 원리가 밝혀졌으므로, PGA 사용량 관점에서 PWJ의 성능개선 원리를 나타내 보자.

 

 

SELECT /*+ LEADING(T)  */ COUNT(*)

  FROM SALES_NO_PT S, SALES_ORDER_NO_PT T

 WHERE S.PROD_ID = T.PROD_ID

   AND S.CUST_ID = T.CUST_ID

   AND S.TIME_ID = T.TIME_ID

   AND S.CHANNEL_ID = S.CHANNEL_ID

   AND S.PROMO_ID = T.PROMO_ID   ;

-----------------------------------------------------------------------------------------------------
| Id  | Operation           | Name              | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-----------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT    |                   |      1 |      1 |00:00:04.22 |    9898 |          |
|   1 |  SORT AGGREGATE     |                   |      1 |      1 |00:00:04.22 |    9898 |          |
|*  2 |   HASH JOIN         |                   |      1 |   1418K|00:00:04.00 |    9898 |   54M (0)|
|   3 |    TABLE ACCESS FULL| SALES_ORDER_NO_PT |      1 |    918K|00:00:00.40 |    5457 |          |
|   4 |    TABLE ACCESS FULL| SALES_NO_PT       |      1 |    918K|00:00:00.36 |    4441 |          |
-----------------------------------------------------------------------------------------------------
                                                                                                    
Predicate Information (identified by operation id):                                                 
---------------------------------------------------                                                 
   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID"                           
               AND "S"."TIME_ID"="T"."TIME_ID" AND "S"."PROMO_ID"="T"."PROMO_ID")                   

  

 

PWJ가 실행되지 않는 경우 PGA 54MB나 소모하였다. 이제 PWJ를 실행시켜 성능이 얼마나 개선되는지 알아보자.

 

  

SELECT /*+ LEADING(T)  */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE S.PROD_ID = T.PROD_ID

   AND S.CUST_ID = T.CUST_ID

   AND S.TIME_ID = T.TIME_ID

   AND S.CHANNEL_ID = S.CHANNEL_ID

   AND S.PROMO_ID = T.PROMO_ID   ;

--------------------------------------------------------------------------------------------------------------

| Id  | Operation            | Name           | Starts | Pstart| Pstop| A-Rows |   A-Time  | Buffers|Used-Mem |

---------------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT     |                |      1 |       |      |      1 |00:00:04.48|   10795|         |

|   1 |  SORT AGGREGATE      |                |      1 |       |      |      1 |00:00:04.48|   10795|         |

|   2 |   PARTITION RANGE ALL|                |      1 |     1 |    28|   1418K|00:00:04.26|   10795|         |

|*  3 |    HASH JOIN         |                |     28 |       |      |   1418K|00:00:03.69|   10795|5008K (0)|

|   4 |     TABLE ACCESS FULL| SALES_ORDER_PT |     28 |     1 |    28|    918K|00:00:00.34|    5947|         |

|   5 |     TABLE ACCESS FULL| SALES_PT       |     16 |     1 |    28|    918K|00:00:00.34|    4848|         |

---------------------------------------------------------------------------------------------------------------

                                                                                                                                                                                                                                                                                                            

Predicate Information (identified by operation id):                             
---------------------------------------------------                             
   3 - access("S"."TIME_ID"="T"."TIME_ID" AND "S"."PROD_ID"="T"."PROD_ID"       
               AND "S"."CUST_ID"="T"."CUST_ID" AND "S"."PROMO_ID"="T"."PROMO_ID")

  

 

PWJ를 실행하니 PGA 사용량이 11배나 줄어들었다. PWJ란 덩치가 큰 테이블 두 개를 조인시켜야 될 때, 작은 여러 개의 파티션으로 쪼개서 각각 조인시킴으로써 조인의 성능을 향상 시키는 것이다. 이렇게 되면 당연히 PGA 사용량이 급격히 줄어들 것이다.

 

 

한가지 주의 사항이 있다. PWJ가 발생하려면 파티션 기준 컬럼으로 양측 집합을 조인해야 한다. 그런데 파티션 Key 조인 컬럼을 아래처럼 가공하게 되면 PWJ가 발생되지 않으므로 주의하기 바란다 

 

  

 

SELECT /*+ LEADING(T) */ COUNT(*)

  FROM SALES_PT S, SALES_ORDER_PT T

 WHERE T.ORDER_DT = TO_DATE('20010101', 'YYYYMMDD')

  AND S.PROD_ID = T.PROD_ID

  AND S.CUST_ID = T.CUST_ID

  AND S.TIME_ID + 1 = T.TIME_ID + 1  --파티션 기준컬럼 가공

  AND S.CHANNEL_ID = S.CHANNEL_ID

  AND S.PROMO_ID = T.PROMO_ID     ;

 

---------------------------------------------------------------------------------------------------------------

| Id  | Operation             | Name           | Starts| Pstart| Pstop|A-Rows |   A-Time   |Buffers |Used-Mem |

---------------------------------------------------------------------------------------------------------------

|   0 | SELECT STATEMENT      |                |      1|       |      |     1 |00:00:02.23 |  10831 |         |

|   1 |  SORT AGGREGATE       |                |      1|       |      |     1 |00:00:02.23 |  10831 |         |

|*  2 |   HASH JOIN           |                |      1|       |      |   154 |00:00:02.23 |  10831 |1210K (0)|

|   3 |    PARTITION RANGE ALL|                |      1|     1 |    28|    88 |00:00:00.10 |   5947 |         |

|*  4 |     TABLE ACCESS FULL | SALES_ORDER_PT |     28|     1 |    28|    88 |00:00:00.10 |   5947 |         |

|   5 |    PARTITION RANGE ALL|                |      1|     1 |    28|   918K|00:00:00.70 |   4884 |         |

|   6 |     TABLE ACCESS FULL | SALES_PT       |     28|     1 |    28|   918K|00:00:00.33 |   4884 |         |

---------------------------------------------------------------------------------------------------------------

 

Predicate Information (identified by operation id):                                                                  

---------------------------------------------------                                                                   

   2 - access("S"."PROD_ID"="T"."PROD_ID" AND "S"."CUST_ID"="T"."CUST_ID" AND                                        

              INTERNAL_FUNCTION("S"."TIME_ID")+1=INTERNAL_FUNCTION("T"."TIME_ID")+1 AND "S"."PROMO_ID"="T"."PROMO_ID")

   4 - filter("T"."ORDER_DT"=TO_DATE(' 2001-01-01 00:00:00', 'syyyy-mm-dd hh24:mi:ss'))

 

 

 

FULL PWJ를 가능하게 하는 파라미터는 _full_pwise_join_enabled 이며  Default True이다. Partial PWJ를 가능하게 하는 기능은 Bloom Pruning이며 파라미터는 _bloom_pruning_enabled이다. 이 파라미터의 Default값은 True이다.

 

 

결론

PARTITION WISE JOIN을 설명하는데 PARALLEL + PQ_DISTIRBUTE 힌트 조합은 필요 없다PARALLEL Operation을 사용할 때 옵티마이저가 잘못된 분배방식을 사용하여 PARTITION WISE JOIN이 실행되지 못할 수 있다. 이때 사용할 수 있는 힌트가 PQ_DISTIRBUTE이며, 이것은 활용법일 뿐이다.

 

 
PARTITION PAIR라는 용어 때문에 미리 짝지어 놓았다고 상상함으로써, 마치 조인되는 양측 Partition이 항상 Pair하게 I/O를 한다고 잘못 생각하게 만든다. PWJ의 성능상 장점은 선행집합의 FILTER에 의해서 후행집합에 Access할 파티션의 개수가 줄어든다는 것이다. 따라서 I/O PAIR하게 발생되지 않는다. 'PARTITION PAIR로 동작한다'라는 개념은 선행집합에 조건이 하나도 없는 경우이거나 혹은 Partition Key로만 조건이 들어오는 경우뿐이다. 다시 말해 선행집합에 Partition Key조건 이외의 조건이 있다면 PARTITION PAIR를 보장하지 않는다
.   

 

 

PWJ가 실행될 때 성능개선사항 세 가지

첫 번째, 조인 선행집합에 Partition Key 조건이 있으면 Partition Access 범위도 줄어듦으로 BLOCK I/O량도 줄어든다.

 

두 번째, 선행집합의 모든 FILTER(Partition Key 조건을 포함한)에 의해서 후행집합의 Partition Access 범위가 줄어듦으로 BLOCK I/O량이 줄어든다.

 

세 번째, JOIN시 큰 테이블을 상대적으로 작은 파티션으로 쪼개어 각각 조인함으로써 PGA 사용량이 감소된다. 

 

 

첫 번째 개선사항을 정확히 말하면 PWJ의 기능이 아니라 일반적인 Partition Pruning에 의한 성능향상이다. Partition Wise Join의 성능개선은 조인에 의해서 성능이 향상되어야 함으로 두 번째, 세 번째가 진정한 PWJ의 성능개선사항이다.

 

이제 필자의 차례는 끝나고, 여러분의 차례이다. 주위 사람들에게 PWJ의 정의를 다시 알려주기 바란다.

 

Posted by extremedb
,

SQL에서 DISTINCT의 위치는 중요하다. DISTINCT가 메인쿼리에 위치하면 조인이 모두 처리된 후 DISTINCT가 실행된다.
그 반대로 각각의 집합을 DISTINCT 한 후에 조인한다면 양측 집합의 건수가 줄어들므로 조인의 부하가 줄어든다. 그런 관점에서 보면 아래의 SQL은 최악이다.
 

환경: ORACLE 11.2

SELECT /*+ qb_name(MAIN) LEADING(S@INLINE) USE_NL(C@MAIN) */
       DISTINCT c.channel_id, c.channel_desc, s.prod_id, s.promo_id
   FROM channels c,
        (SELECT /*+ qb_name(INLINE) NO_MERGE */
                s.channel_id, s.prod_id, promo_id
           FROM sales_t s
          WHERE prod_id BETWEEN 13 AND 15) s
  WHERE c.channel_id = s.channel_id ;


 
---------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
---------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |             |      1 |     22 |00:00:00.22 |   22222 |          |
|   1 |  HASH UNIQUE                  |             |      1 |     22 |00:00:00.22 |   22222 | 1271K (0)|
|   2 |   NESTED LOOPS                |             |      1 |  17778 |00:00:00.21 |   22222 |          |
|   3 |    NESTED LOOPS               |             |      1 |  17778 |00:00:00.16 |    4444 |          |
|*  4 |     TABLE ACCESS FULL         | SALES_T     |      1 |  17778 |00:00:00.11 |    4440 |          |
|*  5 |     INDEX UNIQUE SCAN         | CHANNELS_PK |  17778 |  17778 |00:00:00.03 |       4 |          |
|   6 |    TABLE ACCESS BY INDEX ROWID| CHANNELS    |  17778 |  17778 |00:00:00.03 |   17778 |          |
---------------------------------------------------------------------------------------------------------
 
Predicate Information (identified by operation id):
---------------------------------------------------
   4 - filter(("PROD_ID">=13 AND "PROD_ID"<=15))
   5 - access("C"."CHANNEL_ID"="S"."CHANNEL_ID")

 

위의 SQL을 보면 인라인뷰 S에 미리 건수를 줄이지 않아서 조인이 17778번 발생하였다. 다시 말해 조인하기 전에 인라인뷰 S DISTINCT 작업이 있었다면 조인을 22번만 하면 된다따라서 전체 DISTINCT 작업은 필요 없다. 아래는 튜닝된 SQL이다.

SELECT /*+ qb_name(main) */
       c.channel_id, c.channel_desc, s.prod_id, s.promo_id
   FROM channels c,
        (SELECT /*+ qb_name(inline) */
                DISTINCT s.channel_id, s.prod_id, promo_id
           FROM sales_t s
          WHERE prod_id BETWEEN 13 AND 15) s
  WHERE c.channel_id = s.channel_id ; 


 
--------------------------------------------------------------------------------------------------------
| Id  | Operation                    | Name        | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
--------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT             |             |      1 |     22 |00:00:00.12 |    4466 |          |
|   1 |  NESTED LOOPS                |             |      1 |     22 |00:00:00.12 |    4466 |          |
|   2 |   NESTED LOOPS               |             |      1 |     22 |00:00:00.12 |    4444 |          |
|   3 |    VIEW                      |             |      1 |     22 |00:00:00.12 |    4440 |          |
|   4 |     HASH UNIQUE              |             |      1 |     22 |00:00:00.12 |    4440 | 1264K (0)|
|*  5 |      TABLE ACCESS FULL       | SALES_T     |      1 |  17778 |00:00:00.11 |    4440 |          |
|*  6 |    INDEX UNIQUE SCAN         | CHANNELS_PK |     22 |     22 |00:00:00.01 |       4 |          |
|   7 |   TABLE ACCESS BY INDEX ROWID| CHANNELS    |     22 |     22 |00:00:00.01 |      22 |          |
--------------------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------
   5 - filter(("PROD_ID">=13 AND "PROD_ID"<=15))
   6 - access("C"."CHANNEL_ID"="S"."CHANNEL_ID")

 

미리 건수를 줄였으므로 22번만 조인하여 BLOCK I/O 22222에서 4466으로 약 4~5배 줄어들었다. 이런 SQL 튜닝은 오라클 11.2에서는 더 이상 필요 없다. 아래의 SQL을 보자.

SELECT /*+ qb_name(main) */
       DISTINCT c.channel_id, c.channel_desc, s.prod_id, s.promo_id
   FROM channels c,
        (SELECT /*+ qb_name(inline) */
                s.channel_id, s.prod_id, promo_id
           FROM sales_t s
          WHERE prod_id BETWEEN 13 AND 15) s
  WHERE c.channel_id = s.channel_id ;


 
-------------------------------------------------------------------------------------------------------------
| Id  | Operation                     | Name            | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-------------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT              |                 |      1 |     22 |00:00:00.09 |    4466 |          |
|   1 |  HASH UNIQUE                  |                 |      1 |     22 |00:00:00.09 |    4466 | 1218K (0)|
|   2 |   NESTED LOOPS                |                 |      1 |     22 |00:00:00.09 |    4466 |          |
|   3 |    NESTED LOOPS               |                 |      1 |     22 |00:00:00.09 |    4444 |          |
|   4 |     VIEW                      | VW_DTP_2F839831 |      1 |     22 |00:00:00.09 |    4440 |          |
|   5 |      HASH UNIQUE              |                 |      1 |     22 |00:00:00.09 |    4440 | 1283K (0)|
|*  6 |       TABLE ACCESS FULL       | SALES_T         |      1 |  17778 |00:00:00.08 |    4440 |          |
|*  7 |     INDEX UNIQUE SCAN         | CHANNELS_PK     |     22 |     22 |00:00:00.01 |       4 |          |
|   8 |    TABLE ACCESS BY INDEX ROWID| CHANNELS        |     22 |     22 |00:00:00.01 |      22 |          |
-------------------------------------------------------------------------------------------------------------

Outline Data
-------------
  /*+
      BEGIN_OUTLINE_DATA
      ...생략 
      PLACE_DISTINCT(@"SEL$8FA4BC11" "S"@"INLINE")--> 2 DISTINCT를 추가한 뷰 VW_DTP_2F839831를 만듦
      ...생략
      MERGE(@"INLINE")                            --> 1 먼저 MERGE를 진행함
      ...생략
      END_OUTLINE_DATA
  */
 
Predicate Information (identified by operation id):
---------------------------------------------------
   6 - filter(("PROD_ID"<=15 AND "PROD_ID">=13))
   7 - access("C"."CHANNEL_ID"="ITEM_1")

 

SQL이 비효율 적으로 작성되었지만 Logical Optimizer가 Distinct를 추가하여 쿼리를 재 작성하였다. 이 쿼리변환을 Distinct Placement(DP) 라고 한다. DP는 주의해야 될 점이 있다. 인라인뷰 S를 해체(MERGE)하고 Distinct를 추가한 인라인뷰를 새로 만든다. 따라서 인라인뷰 S NO_MERGE 힌트를 사용한다면 결코 DP가 발생하지 않는다. 이 글에서 소개된 첫 번째 SQL NO_MERGE 힌트가 사용됨으로써 DP가 발생되지 않은 것이다.

DP는 약간의 비효율이 있다. 즉 필요 없는 전체 Distinct 작업이 수행된다. 실행계획을 보면 HASH UNIQUE가 두 번 존재하는데, 마지막 전체 Distinct(id 1)는 필요 없다.  SQL을 아래처럼 재 작성 하였기 때문에 불필요한 HASH UNIQUE가 추가된 것이다.

SELECT  DISTINCT              --> 필요 없는 DISTINCT 
        C.CHANNEL_ID CHANNEL_ID,
        C.CHANNEL_DESC CHANNEL_DESC,
        VW_DTP_2F839831.ITEM_2 PROD_ID,
        VW_DTP_2F839831.ITEM_3 PROMO_ID
   FROM (SELECT DISTINCT
                
S.CHANNEL_ID ITEM_1,
                 S.PROD_ID ITEM_2,
                 S.PROMO_ID ITEM_3
            FROM TLO.SALES_T S
           WHERE S.PROD_ID <= 50
             AND S.PROD_ID >= 13
             AND 50 >= 13) VW_DTP_2F839831,
        TLO.CHANNELS C
  WHERE C.CHANNEL_ID = VW_DTP_2F839831.ITEM_1
;

 

따라서 아직까지는 사람이 튜닝하는 것을 따라올 수 없다.

힌트는 PLACE_DISTINCT/NO_PLACE_DISTINCT를 사용할 수 있으며 _optimizer_distinct_placement 파라미터로 기능을 컨트롤 할 수 있다. 이 파리미터의 Default값은 True이다. DP Cost Based Query Transformation에 속한다. Search Type Iteration이 존재하기 때문이다. 10053 Trace의 내용을 보면 더 확실히 알 수 있다.

 

****************************************
Cost-Based Group-By/Distinct Placement
****************************************
GBP/DP: Checking validity of GBP/DP for query block SEL$8FA4BC11 (#1)
GBP: Checking validity of group-by placement for query block SEL$8FA4BC11 (#1)
GBP: Bypassed: Query has invalid constructs.
DP: Checking validity of distinct placement for query block SEL$8FA4BC11 (#1)

DP: Using search type: linear
DP: Considering distinct placement on query block SEL$8FA4BC11 (#1)
DP: Starting iteration 1, state space = (1) : (0)
DP: Original query
******* UNPARSED QUERY IS *******
SELECT /*+ QB_NAME ("INLINE") QB_NAME ("MAIN") */ DISTINCT "C"."CHANNEL_ID" "CHANNEL_ID","C"."CHANNEL_DESC" "CHANNEL_DESC","S"."PROD_ID" "PROD_ID","S"."PROMO_ID" "PROMO_ID" FROM "TLO"."CHANNELS" "C","TLO"."SALES_T" "S" WHERE "C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15
FPD: Considering simple filter push in query block SEL$8FA4BC11 (#1)
"C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15
try to generate transitive predicate from check constraints for query block SEL$8FA4BC11 (#1)
finally: "C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15 AND 13<=15

FPD:   transitive predicates are generated in query block SEL$8FA4BC11 (#1)
"C"."CHANNEL_ID"="S"."CHANNEL_ID" AND "S"."PROD_ID">=13 AND "S"."PROD_ID"<=15 AND 13<=15
DP: Costing query block.
CBQT: Looking for cost annotations for query block SEL$8FA4BC11, key = SEL$8FA4BC11_00000000_0
CBQT: Could not find stored cost annotations.
kkoqbc: optimizing query block SEL$8FA4BC11 (#1)

...생략
kkoqbc: finish optimizing query block SEL$8FA4BC11 (#1)
CBQT: Saved costed qb# 1 (SEL$8FA4BC11), key = SEL$8FA4BC11_00000000_0
DP: Updated best state, Cost = 1237.16

먼저 DP가 실행될 수 있는지 Validity Checking을 한다. DP를 실행하는데 문제가 없다면 Iteration 1 에서 변환되지 않은
SQL(Original query)을 보여주고 Cost를 구한다그결과 변환되지 않은 쿼리의 Cost1237.16이다. 이제 변환된 SQL COST
구할 차례이다
.
  

DP: Starting iteration 2, state space = (1) : (1)
DP: Using DP transformation in this iteration.
Registered qb: SEL$2F839831 0x11c3c2dc (QUERY BLOCK TABLES CHANGED SEL$8FA4BC11)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$2F839831 nbfros=2 flg=0
    fro(0): flg=0 objn=75859 hint_alias="C"@"MAIN"
    fro(1): flg=5 objn=0 hint_alias="VW_DTP_2F839831"@"SEL$2F839831"

Registered qb: SEL$DC663686 0x11c3b800 (SPLIT/MERGE QUERY BLOCKS SEL$2F839831)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$DC663686 nbfros=1 flg=0
    fro(0): flg=0 objn=76170 hint_alias="S"@"INLINE"

Registered qb: SEL$7323A7B6 0x11c3c2dc (VIEW ADDED SEL$2F839831)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$7323A7B6 nbfros=2 flg=0
    fro(0): flg=0 objn=75859 hint_alias="C"@"MAIN"
    fro(1): flg=1 objn=0 hint_alias="VW_DTP_2F839831"@"SEL$2F839831"

Registered qb: SEL$10E34D75 0x11c3c2dc (DISTINCT PLACEMENT SEL$8FA4BC11; SEL$8FA4BC11; "S"@"INLINE")
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$10E34D75 nbfros=2 flg=0
    fro(0): flg=0 objn=75859 hint_alias="C"@"MAIN"
    fro(1): flg=1 objn=0 hint_alias="VW_DTP_2F839831"@"SEL$2F839831"

Iteration 2에는 DP가 적용된 SQL Cost를 구한다. 여기서 DP가 수행되는 절차를 QUERY BLOCK SIGNATURE에서 볼 수 있다. 먼저 VIEW MERGE가 발생된다.(MERGE QUERY BLOCKS 부분 참조) 그 후 SALES 테이블이 포함된 뷰를 메인쿼리에 추가한다.(VIEW ADDED 부분 참조). 마지막으로 추가된 인라인뷰에 Distinct를 추가한다. (DISTINCT PLACEMENT 부분 참조)

 

DP: Transformed query
******* UNPARSED QUERY IS *******
SELECT /*+ QB_NAME ("INLINE") QB_NAME ("MAIN") */ DISTINCT "C"."CHANNEL_ID" "CHANNEL_ID","C"."CHANNEL_DESC" "CHANNEL_DESC","VW_DTP_2F839831"."ITEM_2" "PROD_ID","VW_DTP_2F839831"."ITEM_3" "PROMO_ID" FROM  (SELECT DISTINCT "S"."CHANNEL_ID" "ITEM_1","S"."PROD_ID" "ITEM_2","S"."PROMO_ID" "ITEM_3" FROM "TLO"."SALES_T" "S" WHERE "S"."PROD_ID"<=15 AND "S"."PROD_ID">=13) "VW_DTP_2F839831","TLO"."CHANNELS" "C" WHERE "C"."CHANNEL_ID"="VW_DTP_2F839831"."ITEM_1"
FPD: Considering simple filter push in query block SEL$10E34D75 (#1)
"C"."CHANNEL_ID"="VW_DTP_2F839831"."ITEM_1"
try to generate transitive predicate from check constraints for query block SEL$10E34D75 (#1)
finally: "C"."CHANNEL_ID"="VW_DTP_2F839831"."ITEM_1"

...생략
kkoqbc: finish optimizing query block SEL$10E34D75 (#1)
CBQT: Saved costed qb# 2 (SEL$DC663686), key = SEL$DC663686_00001000_2
CBQT: Saved costed qb# 1 (SEL$10E34D75), key = SEL$10E34D75_00000008_0
DP: Updated best state, Cost = 1236.23
DP: Doing DP on the preserved QB.

이제 쿼리변환이 끝났으므로 변경된 SQL을 보여주고 Costing을 시작한다. DP가 적용된 SQL Cost 1236.23임으로 원본 쿼리의 Cost에 비해 저렴하다. 따라서 DP가 선택된다.(Doing DP 부분 참조)

 

이로써 졸저 The Logical Optimizer의 416페이지 미해결 과제에서 약속한 것을 지켰다. DP의 예제가 발견되면 블로그와 책에 반영하기로 약속 했었다. 출력을 해서 책의 416페이지에 끼워넣기 바란다. 2011년에 DP를 발견했지만 여러가지 문제로 반영하지 못하다가 이제서야 올리게 되었다. 사과드린다.

Posted by extremedb
,

집계함수 내부에 Distinct를 사용할 수 있다는 것은 많은 사람들이 알고 있다. 하지만 실제로 그렇게 사용했을 때 내부적으로 무슨 일이 일어나는지 아는 사람은 드물다. 한걸음 더 나아가서 COUUNT(COL) 대신에 COUNT(Distinct COL)를 사용했다면 분명히 추가적인 부하가 존재할 것인데, 그 부하를 어떻게 해결할 것인가를 아는 사람은 거의 없을 것이다. 만약 그렇다면 SQL을 실행할 때 마다 성능이 느려질 것이고 문제를 해결할 수 없을 것이다. 여러분들에게는 그런 일이 발생하지 않는다. 이미 이 글을 읽고 있기 때문이다.

이 글은 위에서 언급된 두 가지 문제를 다룬다. 즉 내부적으로 어떤 변화가 발생하는지 알아보고, 추가적인 부하를 어떻게 없앨 수 있는지도 연구해보자. 

SQL 변경에 따른 내부적인 변화를 알아보는 가장 좋은 방법은 비교하는 것이다. 다시 말해, COUUNT(COL)로 실행했을 때의 일량과 COUNT(Distinct COL)로 사용했을 때의 일량을 비교해 보는 것이다. 따라서 우리는 SQL 두 개를 실행한 다음 각각의 작업량(실행통계)을 비교할 것이다.

환경: 오라클 11.2.0.1

CREATE TABLE SALE_T AS SELECT * FROM SALES;                                             
                                                                                        
SELECT /*+ NO_USE_HASH_AGGREGATION */                                                   
        S.PROD_ID                                                                       
       ,COUNT(S.CHANNEL_ID)                                                             
       ,SUM(S.AMOUNT_SOLD)                                                              
       ,SUM(S.QUANTITY_SOLD)                                                            
  FROM SALE_T S                                                                         
 GROUP BY S.PROD_ID;                                                                    
                                                                                        
-----------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-----------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |     72 |00:00:01.12 |    4440 |          |
|   1 |  SORT GROUP BY     |        |      1 |     72 |00:00:01.12 |    4440 | 6144  (0)|
|   2 |   TABLE ACCESS FULL| SALE_T |      1 |    918K|00:00:00.32 |    4440 |          |
-----------------------------------------------------------------------------------------

위의 SQL이 실행되는데 시간이 1.12초 걸렸고 PGA 6144 Byte를 소모하였다. 그런데 아래처럼 COUNT DISTINCT를 추가를 추가한다면 어떻게 될까?

 SELECT /*+ NO_QUERY_TRANSFORMATION */                                                  
        S.PROD_ID                                                                       
       ,COUNT(DISTINCT S.CHANNEL_ID)                                                    
       ,SUM(S.AMOUNT_SOLD)                                                              
       ,SUM(S.QUANTITY_SOLD)                                                            
  FROM SALE_T S                                                                         
 GROUP BY S.PROD_ID;                                                                    
                                                                                        
-----------------------------------------------------------------------------------------
| Id  | Operation          | Name   | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-----------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |        |      1 |     72 |00:00:02.20 |    4440 |          |
|   1 |  SORT GROUP BY     |        |      1 |     72 |00:00:02.20 |    4440 |14336  (0)|
|   2 |   TABLE ACCESS FULL| SALE_T |      1 |    918K|00:00:00.33 |    4440 |          |
-----------------------------------------------------------------------------------------
 

작업량이 증가된 이유
Distinct 만 추가했을 뿐인데 시간이 약 두 배나 걸리고 PGA도 약 두 배로 사용하였다. 그 이유는 Operation에는 나오지 않지만 내부적으로 SORT UNIQUE가 실행되기 때문이다. PROD_ID별로 SORT GROUP BY를 했음에도 CHANNEL_ID 별로 SORT UNIQUE를 다시 실행해야 한다. 92만 건의 데이터를 CHANNEL_ID 별로 SORT한 후에 중복을 제거하는 작업이 Distinct에 의해서 추가된 것이다. 그렇기 때문에 FULL TABLE SCAN의 수행시간은 거의 같지만 SORT GROUP BY의 수행시간이 0.8초에서 1.87초로 늘어나고 PGA사용량도 두 배가 된 것이다.
 

비효율을 제거하는 방법
첫 번째 의문점인 집계함수에 Distinct가 추가되면 어떤 일이 발생하는지 알아냈다. 그렇다면 두 번째 문제인 비효율(추가적인 Sort와 중복제거)을 없애는 방법은 무엇일까? SQL을 아래처럼 튜닝 할 수 있을 것이다. 

SELECT /*+ NO_USE_HASH_AGGREGATION */                                                     
        PROD_ID,                                                                          
        COUNT(S.CHANNEL_ID),                                                              
        SUM(S.AMOUNT_SOLD),                                                               
        SUM(S.QUANTITY_SOLD)                                                              
 FROM  (SELECT /*+ NO_USE_HASH_AGGREGATION */                                             
                 S.CHANNEL_ID ,                                                           
                 S.PROD_ID ,                                                              
                 SUM(S.AMOUNT_SOLD) AMOUNT_SOLD,                                          
                 SUM(S.QUANTITY_SOLD) QUANTITY_SOLD                                       
           FROM SALE_T S                                                                  
          GROUP BY PROD_ID, CHANNEL_ID) S                                                 
GROUP BY S.PROD_ID ;                                                                      
                                                                                          
-------------------------------------------------------------------------------------------
| Id  | Operation            | Name   | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
-------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT     |        |      1 |     72 |00:00:01.39 |    4440 |          |
|   1 |  SORT GROUP BY NOSORT|        |      1 |     72 |00:00:01.39 |    4440 |          |
|   2 |   VIEW               |        |      1 |    228 |00:00:01.39 |    4440 |          |
|   3 |    SORT GROUP BY     |        |      1 |    228 |00:00:01.39 |    4440 |18432  (0)|
|   4 |     TABLE ACCESS FULL| SALE_T |      1 |    918K|00:00:00.33 |    4440 |          |
------------------------------------------------------------------------------------------- 

비록 PGA 사용량은 약간 늘어났지만 수행시간은 DISTINCT가 없는 SQL과 비슷해졌다. 먼저 PROD_ID, CHANNEL_IDGROUP BY 되었기 때문에 인라인뷰 외부에서는 Distinct를 할 필요가 없다. 다른 말로 표현하면 먼저 GROUP BY했기 때문에 PROD_ID 별로는 CHANNEL_ID UNIQUE 하다. 따라서 인라인뷰 외부에서는 Distinct가 필요 없게 된 것이다.

더 좋은 것은 실행계획의 Id 1을 보면 SORT GROUP BY NOSORT가 나온다. NOSORT가 나온 이유는 인라인뷰가 이미 PROD_ID SORT 되어있기 때문에 더 이상의 SORT는 필요 없기 때문이다. 따라서 추가적인 Group By의 부하는 거의 없다. 이렇게 튜닝하면 Distinct에 의한 SORT UNIQUE의 부하가 대부분 사라진다.

옵티마이저가 사람을 대신한다
집계함수에 Distinct를 사용한다면 무조건 위의 SQL처럼 튜닝 해야 하는가? 그건 아니다. 오라클 11.2를 사용한다면 Logical Optimizer SQL을 자동으로 변경시켜 준다. 아래의 튜닝 되지 않은 SQL을 실행시켜보자.
 

 SELECT /*+ NO_USE_HASH_AGGREGATION(@"SEL$5771D262")  */                                    
        S.PROD_ID                                                                           
       ,COUNT(DISTINCT S.CHANNEL_ID)                                                        
       ,SUM(S.AMOUNT_SOLD)                                                                  
       ,SUM(S.QUANTITY_SOLD)                                                                
  FROM SALE_T S                                                                             
 GROUP BY S.PROD_ID   

---------------------------------------------------------------------------------------------
| Id  | Operation            | Name     | Starts | A-Rows |   A-Time   | Buffers | Used-Mem |
---------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT     |          |      1 |     72 |00:00:01.39 |    4440 |          |
|   1 |  SORT GROUP BY NOSORT|          |      1 |     72 |00:00:01.39 |    4440 |          |
|   2 |   VIEW               | VW_DAG_0 |      1 |    228 |00:00:01.39 |    4440 |          |
|   3 |    SORT GROUP BY     |          |      1 |    228 |00:00:01.39 |    4440 |18432  (0)|
|   4 |     TABLE ACCESS FULL| SALE_T   |      1 |    918K|00:00:00.32 |    4440 |          |
---------------------------------------------------------------------------------------------
                                                                                            
Outline Data                                                                                
-------------                                                                               
  /*+                                                                                       
      BEGIN_OUTLINE_DATA                                                                    
      …생략                                                                                
      TRANSFORM_DISTINCT_AGG(@"SEL$1")                                                      
      …생략                                                                                
      END_OUTLINE_DATA                                                                      
  */                                                                                        
  

오라클이 내부적으로 TRANSFORM_DISTINCT_AGG  힌트를 사용하였고 SQL을 자동으로 변경하였다. 실행계획도 튜닝된 SQL과 같다. 11.2 버전부터는 집계함수내부에 Distinct가 존재하면 Logical Optimizer SQL을 변경시킴으로써 성능이 향상되는 것이다. 이 기능을 Distinct To Aggregation이라고 부른다. 

아래는 10053 Trace 파일의 내용이다. 내용이 많지만 개념은 간단하다. 쿼리변환 전의 SQL을 보여주고 쿼리변환 후의 SQL을 보여준다. 그리고 두 개의 SQL 사이에는 쿼리블럭 SEL$1Distinct To Aggregation 기능에 의해서 두 개로 찢어지는 과정(SPLIT QUERY BLOCK)을 보여준다.

 

DAGG_TRANSFORM: transforming query block SEL$1 (#0)
qbcp (before transform):******* UNPARSED QUERY IS *******
SELECT "S"."PROD_ID" "PROD_ID",COUNT(DISTINCT "S"."CHANNEL_ID") "COUNT(DISTINCTS.CHANNEL_ID)",SUM("S"."AMOUNT_SOLD") "SUM(S.AMOUNT_SOLD)",SUM("S"."QUANTITY_SOLD") "SUM(S.QUANTITY_SOLD)" FROM "TLO"."SALE_T" "S" GROUP BY "S"."PROD_ID"
pgactx->ctxqbc (before transform):******* UNPARSED QUERY IS *******
SELECT "S"."PROD_ID" "PROD_ID",COUNT(DISTINCT "S"."CHANNEL_ID") "COUNT(DISTINCTS.CHANNEL_ID)",SUM("S"."AMOUNT_SOLD") "SUM(S.AMOUNT_SOLD)",SUM("S"."QUANTITY_SOLD") "SUM(S.QUANTITY_SOLD)" FROM "TLO"."SALE_T" "S" GROUP BY "S"."PROD_ID"
Registered qb: SEL$5771D262 0xea51918 (SPLIT QUERY BLOCK FOR DISTINCT AGG OPTIM SEL$1; SEL$1)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$5771D262 nbfros=1 flg=0
    fro(0): flg=0 objn=76169 hint_alias="S"@"SEL$1"

Registered qb: SEL$C33C846D 0xde78e84 (MAP QUERY BLOCK SEL$5771D262)
---------------------
QUERY BLOCK SIGNATURE
---------------------
  signature (): qb_name=SEL$C33C846D nbfros=1 flg=0
    fro(0): flg=5 objn=0 hint_alias="VW_DAG_0"@"SEL$C33C846D"

qbcp (after transform):******* UNPARSED QUERY IS *******
SELECT "VW_DAG_0"."ITEM_2" "PROD_ID",COUNT("VW_DAG_0"."ITEM_1") "COUNT(DISTINCTS.CHANNEL_ID)",SUM("VW_DAG_0"."ITEM_4") "SUM(S.AMOUNT_SOLD)",SUM("VW_DAG_0"."ITEM_3") "SUM(S.QUANTITY_SOLD)" FROM  (SELECT /*+ NO_USE_HASH_AGGREGATION */ "S"."CHANNEL_ID" "ITEM_1","S"."PROD_ID" "ITEM_2",SUM("S"."QUANTITY_SOLD") "ITEM_3",SUM("S"."AMOUNT_SOLD") "ITEM_4" FROM "TLO"."SALE_T" "S" GROUP BY "S"."CHANNEL_ID","S"."PROD_ID") "VW_DAG_0" GROUP BY "VW_DAG_0"."ITEM_2" 

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               
Distinct To Aggregation 쿼리변환은Heuristic Query Transformation에 속한다. _optimizer_distinct_agg_transform 파라미터로 이 기능을 제어할 수 있고 Defaulttrue이다. 힌트로는 TRANSFORM_DISTINCT_AGG / NO_TRANSFORM_DISTINCT_AGG 를 사용할 수 있다.

이제 우리는 집계함수에 Distinct가 추가되면 SORT UNIQUE의 부하로 성능이 느려짐을 안다. Distinct 대신에 Group By를 사용하여 그 부하를 대부분 없애는 방법도 알게 되었다. 하지만 이제는 이런 일들을 옵티마이저가 대신하게 되었다. 이런 기능들이 계속 추가된다면 언젠가는 튜너라는 직업이 사라지 않을까? 만약 튜너가 없어진다면, 그 후에 옵티마이저를 연구하는 사람까지 사라질 것이다. 왜냐하면 옵티마이저를 연구하는 사람은 튜너를 위해 존재하기 때문이다.


PS
다들 잘 지내시죠? 개인 사정으로 지난 2년간 뵙지 못했습니다. 5월달에 글을 한 두개 더 올릴 생각 입니다. 기대해 주세요. 5월 중순 부터는 바빠서 글쓰기가 힘들 것 같습니다. 

그럼 건강하세요.

Posted by extremedb
,