부제: Cardinality Feed Back의 개념과 사용예제
이번 글은 난이도가 높으므로 익숙하지 않은 사람은 Cardinality Feedback의 개념 정도만 이해하기 바란다. 물론 이 블로그를 꾸준히 구독한 독자라면 어려움 없이 볼 수 있다.
시스템이 운영 중에 있을 때 가장 곤욕스러운 경우 중 하나는 SQL의 실행계획이 갑자기 바뀌어 성능이 나빠지는 것이다. SQL과 인덱스 그리고 통계정보가 모두 바뀌지 않아도 실행계획은 바뀔 수 있다. 예를 들면 Oracle11g의 기능인 Cardinality Feedback을 사용함으로 해서 얼마든지 실행계획이 바뀔 수 있는 것이다. 이번 시간에는 실행계획이 변경되는 원인 중 하나인 Cardinality Feedback 의 개념과 작동방식에 대해 알아보고 이것이 언제 문제가 되는지 분석해 보자. 이번에 소개할 예제는 종합적이다. Cardinality Feedback + Cost Based Query Transformation + Bloom Filter가 결합된 것이다. 이를 놓친다면 이들이 어떻게 결합되는지 알 수 없을 뿐만 아니라 성능이 악화된 원인을 파악할 수 없다.
예측, 실행, 비교, 그리고 전달
소 잃고 외양간 고친다는 말이 있다. 이미 늦었다는 이야기 이지만 좋은 말로 바꾸면 실수를 다시 하지 않겠다는 의지이다. cardinality feedback(이후 CF)도 이와 비슷한 개념이다. 예를 들어 col1 = ‘1’ 이라는 조건으로 filter되면 백만 건이 return된다고 옵티마이져가 예측해서 full table scan을 했다. 하지만 예측과 달리 실행결과가 100건이 나왔다면? 해당 SQL을 다시 실행할 때는 full table scan보다는 index scan이 유리할 것이다. 그런데 같은 SQL을 두 번째 실행할 때 "실제로는 백만 건이 아니라 100건 뿐이야"라는 정보를 옵티마이져에게 알려주는 전달자가 필요하다. 그 전달자가 바로 CF이다. CF가 없으면 결과가 100건 임에도 SQL을 실행 할 때마다 full table scan을 반복할 것이다. 결국 CF는 악성 실행계획을 올바로 수정하는 것이 목적이며 매우 유용한 기능임을 알 수 있다. CF의 단점은 최초에 한번은 full table scan이 필요하다는 것이다. 왜냐하면 실행해서 결과가 나와야만 실제 분포도(건수)를 알 수 있기 때문이다.
CF는 어떻게 실행되나?
CF는 같은 SQL을 두 번 이상 실행했을 때 적용된다. 그 이유는 아래의 CF 적용순서를 보면 알 수 있다.
1. 최초의 실행계획을 작성할 때(Hard Parsing 시에) 예측 분포도가 계산된다.
2. SQL이 실행된다. 한번은 실행 해봐야 예측 분포도와 실제 분포도를 비교할 수 있다.
3. 예측 분포도와 실제 분포도의 값이 차이가 크다면 실제 분포도를 저장한다.
4. 두 번째 실행될 때 CF에 의해 힌트의 형태로 옵티마이져에게 전달되어 실제 분포도가 적용된다. 이때 분포도뿐만 아니라 실행계획이 바뀔 수 있다. 두 번째 이후로 실행될 때는 CF가 계속 적용된다.
CF를 발생시켜보자
실행환경 :Oracle 11.2.0.1
ALTER SYSTEM FLUSH SHARED_POOL;
ALTER SESSION SET "_OPTIMIZER_USE_FEEDBACK" = TRUE; -- CF를 활성화 한다. default로 true이다.
SELECT /*+ GATHER_PLAN_STATISTICS LEADING(c) */
c.cust_id, c.cust_first_name, c.cust_last_name,
s.prod_cnt, s.channel_cnt, s.tot_amt
FROM customers c,
(SELECT s.cust_id,
COUNT (DISTINCT s.prod_id) AS prod_cnt,
COUNT (DISTINCT s.channel_id) AS channel_cnt,
SUM (s.amount_sold) AS tot_amt
FROM sales s
GROUP BY s.cust_id) s
WHERE c.cust_year_of_birth = 1987
AND s.cust_id = c.cust_id ;
----------------------------------------------------------------------------------------------------------------
| Id | Operation | Name | E-Rows | A-Rows | A-Time | Buffers | Used-Mem |
----------------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | 23 |00:00:00.15 | 5075 | |
|* 1 | HASH JOIN | | 162 | 23 |00:00:00.15 | 5075 | 1215K (0)|
| 2 | JOIN FILTER CREATE | :BF0000 | 162 | 151 |00:00:00.01 | 148 | |
| 3 | TABLE ACCESS BY INDEX ROWID | CUSTOMERS | 162 | 151 |00:00:00.01 | 148 | |
| 4 | BITMAP CONVERSION TO ROWIDS| | | 151 |00:00:00.01 | 2 | |
|* 5 | BITMAP INDEX SINGLE VALUE | CUSTOMERS_YOB_BIX | | 1 |00:00:00.01 | 2 | |
| 6 | VIEW | | 7059 | 55 |00:00:00.15 | 4927 | |
| 7 | SORT GROUP BY | | 7059 | 55 |00:00:00.15 | 4927 |88064 (0)|
| 8 | JOIN FILTER USE | :BF0000 | 918K| 7979 |00:00:00.12 | 4927 | |
| 9 | PARTITION RANGE ALL | | 918K| 7979 |00:00:00.11 | 4927 | |
|* 10 | TABLE ACCESS FULL | SALES | 918K| 7979 |00:00:00.09 | 4927 | |
----------------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
1 - access("S"."CUST_ID"="C"."CUST_ID")
5 - access("C"."CUST_YEAR_OF_BIRTH"=1987)
10 - filter(SYS_OP_BLOOM_FILTER(:BF0000,"S"."CUST_ID")) --> Bloom Filter 적용
SQL 실행결과 sales 테이블의 예측 분포도는 918K건이며 실제 분포도는 Bloom Filter가 적용되어 7979건이다. 그리고 group by된 operation(ID 7번)의 예측 분포도는 7059건이며 실제 분포도는 55건이다. 예측과 실제의 분포도 차이는 두 경우 모두 100배 이상이다. 따라서 CF가 적용될 것이다. 이와는 반대로 customers 테이블의 예측 분포도와 실제 분포도는 162와 152로 크게 다르지 않으므로 CF가 적용되지 않을 것이다. 이제 위의 SQL을 재 실행한다면 CF가 적용되어 실제 분포도가 적용될 것이다.
--> CF를 발생시키기 위해 위의 SQL 다시 실행
----------------------------------------------------------------------------------------------------------------
| Id | Operation | Name | E-Rows | A-Rows | A-Time | Buffers | Used-Mem |
----------------------------------------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | | 23 |00:00:05.61 | 5075 | |
| 1 | SORT GROUP BY | | 55 | 23 |00:00:05.61 | 5075 |75776 (0)|
|* 2 | HASH JOIN | | 270 | 3230 |00:00:05.60 | 5075 | 1201K (0)|
| 3 | TABLE ACCESS BY INDEX ROWID | CUSTOMERS | 162 | 151 |00:00:00.01 | 148 | |
| 4 | BITMAP CONVERSION TO ROWIDS| | | 151 |00:00:00.01 | 2 | |
|* 5 | BITMAP INDEX SINGLE VALUE | CUSTOMERS_YOB_BIX | | 1 |00:00:00.01 | 2 | |
| 6 | PARTITION RANGE ALL | | 7979 | 918K|00:00:02.82 | 4927 | |
| 7 | TABLE ACCESS FULL | SALES | 7979 | 918K|00:00:00.98 | 4927 | |
----------------------------------------------------------------------------------------------------------------
Predicate Information (identified by operation id):
---------------------------------------------------
2 - access("S"."CUST_ID"="C"."CUST_ID")
5 - access("C"."CUST_YEAR_OF_BIRTH"=1987)
Note
-----
- cardinality feedback used for this statement --> CF가 발생되었음을 나타냄.
두 번째 실행 할 때 CF가 적용되어 예측 분포도가 7979로 바뀌었고 group by 분포도는 55건으로 바뀌었다. 이에 따라 실행계획도 바뀌었다. 즉 CF에 의해서 쿼리변환(Complex View Merging)이 발생된 것이다. 그리고 note에 CF가 적용되었다고 친절히 설명된다.
이제 더 자세한 분석을 위하여 10053 Trace의 내용을 보자. 두 번째 실행된 SQL의 10053 Trace에 따르면 쿼리변환전의 SQL은 다음과 같다.
SELECT /*+ LEADING (C) */
c.cust_id, c.cust_first_name, c.cust_last_name,
s.prod_cnt, s.channel_cnt, s.tot_amt tot_amt
FROM tlo.customers c,
(SELECT /*+ OPT_ESTIMATE (GROUP_BY ROWS=55.000000 ) OPT_ESTIMATE (TABLE S ROWS=7979.000000 ) */
s.cust_id cust_id, COUNT (DISTINCT s.prod_id) prod_cnt,
COUNT (DISTINCT s.channel_id) channel_cnt, SUM (s.amount_sold) tot_amt
FROM tlo.sales s
GROUP BY s.cust_id) s
WHERE c.cust_year_of_birth = 1987
AND s.cust_id = c.cust_id ;
CF에 의해서 OPT_ESTIMATE 힌트가 적용되었다. 실제 건수로 적용하는 것이므로 일견 문제가 없어 보인다. 하지만 쿼리변환과정(Complex View Merging)을 거치면 문제가 생긴다. 10053 Trace에서 나타난 쿼리변환 후의 SQL은 다음과 같다.
SELECT /*+ OPT_ESTIMATE (GROUP_BY ROWS=55.000000 ) LEADING (C) OPT_ESTIMATE (TABLE S ROWS=7979.000000 ) */
c.cust_id cust_id, c.cust_first_name cust_first_name,
c.cust_last_name cust_last_name, COUNT (DISTINCT s.prod_id) prod_cnt,
COUNT (DISTINCT s.channel_id) channel_cnt, SUM (s.amount_sold) tot_amt
FROM tlo.customers c, tlo.sales s
WHERE c.cust_year_of_birth = 1987
AND s.cust_id = c.cust_id
GROUP BY s.cust_id, c.ROWID, c.cust_last_name, c.cust_first_name, c.cust_id ;
CF의 문제점은?
위의 SQL은 두 가지 문제점이 있다. 두 문제 모두 쿼리변환에 의해 발생된다. 첫 번째 문제는 Bloom Filter와 관련된 것이다. CF의 영향으로 원본 SQL에 존재했던 Group By 뷰(Complex View)가 사라졌다. 뷰가 없어짐으로써 Bloom Filter가 적용되지 않는다. Filter가 사라졌음에도 불구하고 Filter가 존재했던 Cardinality 7979를 적용해 버렸다. 이에 따라 CF를 적용했음에도 7979건과 실제건수인 91만 8천 건과는 엄청난 차이가 나고 말았다. 즉 Bloom Filter가 사라질 때는 CF를 적용하면 안 된다는 이야기이다. 비유하자면 Filter가 없는데도 불구하고 Filter가 존재할 때의 건수를 적용시킨 것이다.
두 번째 문제는 쿼리변환 후 힌트의 상속과 관련된다. 쿼리변환전의 CF의 의한 힌트를 보면 Group By된 뷰의 건수는 55건이다. 그런데 이 힌트는 오직 sales 테이블에 대한 것이다. 그런데 쿼리변환후의 힌트를 보면 그대로 55건이 적용되어 되어버렸다. Group by가 외부로 빠져 나옴으로 해서 GROUP_BY ROWS는 전체건수와 마찬가지가 되어버렸다. sales 테이블의 Group By건수는 55건이 맞다. 하지만 쿼리변환 때문에 조인 후에 Group By 하게 된다면 cardinality를 다시 계산해야 한다. 조인이 없는 테이블의 Group By건수와 조인후의 Group By건수가 어떻게 같을 수 있나?
두 가지의 문제점은 Cost를 계산할 때 그대로 적용되어 버린다. 10053 trace를 보자.
Access path analysis for SALES
***************************************
SINGLE TABLE ACCESS PATH
Single Table Cardinality Estimation for SALES[S]
Table: SALES Alias: S
Card: Original: 918843.000000 >> Single Tab Card adjusted from:918843.000000 to:7979.000000
Rounded: 7979 Computed: 7979.00 Non Adjusted: 918843.00
Access Path: TableScan
Cost: 1328.68 Resp: 1328.68 Degree: 0
Cost_io: 1321.00 Cost_cpu: 155262306
Resp_io: 1321.00 Resp_cpu: 155262306
Bloom Filter가 없음에도 불구하고 Sales 테이블의 건수(Cardinality)가 7979로 적용되어 버렸다. 이제 Group By가 적용된 건수를 보자.
GROUP BY cardinality: 270.000000, TABLE cardinality: 270.000000
>> Query Blk Card adjusted from 270.000000 to: 55.000000
SORT ressource Sort statistics
Sort width: 583 Area size: 510976 Max Area size: 102340608
Degree: 1
Blocks to Sort: 3 Row size: 69 Total Rows: 270
Initial runs: 1 Merge passes: 0 IO Cost / pass: 0
Total IO sort cost: 0 Total CPU sort cost: 20302068
Total Temp space used: 0
Group By Cardinality와 관련된 Trace 내용이다. 여기서도 잘못된 Group By건수인 55를 적용시키고 있다. 조인 후에 Group By할 때는 Cardinality를 다시 계산해야 옳다. 이래서는 제대로 된 Cost가 나올 수 없다. 여기에 밝혀진 문제점은 SQL 하나에서 나온 것이므로 실전에서는 두 가지 문제뿐만 아니라 더 많을 것이다. 물론 옵티마이져가 모든 경우에 완벽할 수는 없다.
해결책
CF 문제의 해결방법을 생각해보자. 갑자기 실행계획이 바뀌어 성능문제가 발생했을 때 dbms_xplan.display_cursor의 note나 10053 Trace의 실행계획 부분을 보면 CF가 적용되었는지 아닌지 알 수 있다. 만약 CF가 적용되었다면 일단 의심해보아야 한다. 아래는 10053 trace의 실행계획 부분이다.
-----------------------------------------------------------+------------------------
| Id | Operation | Name | Rows | Bytes | Cost |
-----------------------------------------------------------+------------------------
| 0 | SELECT STATEMENT | | | | 1368 |
| 1 | SORT GROUP BY | | 55 | 2915 | 1368 |
| 2 | HASH JOIN | | 270 | 14K | 1367 |
| 3 | TABLE ACCESS BY INDEX ROWID | CUSTOMERS | 162 | 5832 | 38 |
| 4 | BITMAP CONVERSION TO ROWIDS | | | | |
| 5 | BITMAP INDEX SINGLE VALUE | CUSTOMERS_YOB_BIX| | | |
| 6 | PARTITION RANGE ALL | | 7979 | 132K | 1329 |
| 7 | TABLE ACCESS FULL | SALES | 7979 | 132K | 1329 |
-----------------------------------------------------------+------------------------
Predicate Information:
----------------------
2 - access("S"."CUST_ID"="C"."CUST_ID")
5 - access("C"."CUST_YEAR_OF_BIRTH"=1987)
Content of other_xml column
===========================
nodeid/pflags: 7 17nodeid/pflags: 6 17 cardinality_feedback: yes --> CF가 적용됨
...이후 생략
만약 CF가 문제가 된다면 해당 SQL을 시작하기 전에 세션단위로 _optimizer_use_feedback = false를 적용하거나 opt_param 힌트를 사용하면 된다. 이렇게 하면 CF가 방지되어 쿼리변환의 원인이 제거된다. 따라서 Bloom Filter도 보존할 수 있다. 또 다른 방법은 인라인뷰에 no_merge 힌트를 적용하여 쿼리변환을 방지하면 문제는 해결된다. 이 두 가지 방법은 결국 쿼리변환을 방지하는 것이다.
결론
CF란 건수를 예측하고, 실행해서 실제건수와 예측건수를 비교하여 차이가 많다면 다음 번에 실행할 때 옵티마이져에게 실제건수를 전달해주는 역할을 한다. CF의 개념을 정리 했으므로 이제 큰 그림을 그려보자. 위의 예제에서 성능이 악화된 직접적인 이유는 Bloom Filter가 사라졌기 때문이다. 하지만 그렇게 된 이유는 쿼리변환 때문이며 쿼리변환의 이유는 CF 때문이다. 직접적인 원인을 찾았다고 해도 포기해선 안 된다. 꼬리에 꼬리를 무는 원인이 있을 수 있기 때문이다. 이를 도식화 하면 다음과 같다.
옵티마이져의 설계관점에서 개선해야 될 사항을 논의 해보자. 옵티마이져가 CBQT를 고려할 때는 두 가지의 경우로 판단한다. 쿼리변환을 적용하기 전(Iteration 1)의 Cost와 적용 후(Iteration 2)의 Cost를 비교해야 되기 때문이다. 쿼리변환전의 Cost를 구할 때는 CF를 적용시키고 반대로 쿼리변환 후에는 CF를 적용하지 않는 것이 더 좋은 Cost를 구할 수 있다. 왜냐하면 비록 답이 같다고 하더라도 형태가 전혀 다른 SQL에 대해 CF를 적용시킬 이유는 없기 때문이다. 물론 이렇게 해도 여전히 문제가 될 수는 있다. 하지만 문제의 발생확률은 많이 줄어들지 않겠는가?
'Oracle > Optimizer' 카테고리의 다른 글
COUNT(Distinct 컬럼)의 성능 (9) | 2013.05.06 |
---|---|
공지 - Cardinality Feed Back이 위험할 때 (1) | 2010.10.22 |
union과 union all의 숨겨진 차이점 (6) | 2010.10.18 |
메트릭스나 터미네이터는 먼 미래의 이야기 인가? (2) | 2010.04.28 |
해결사 되기 (15) | 2010.02.04 |