[MySQL튜닝]힌트-해시 조인
MySQL은 8 버젼부터 해시 조인을 지원하고 있습니다.
해시 조인을 유도할 수 있는 옵티마이져 힌트는 아래의 MySQL 레퍼런스를 통해 확인할 수 있습니다.
https://dev.mysql.com/doc/refman/8.0/en/optimizer-hints.html
현재(2023년 1월) 기준으로 위의 레퍼런스를 살펴보면, 해시 조인과 관련된 힌트로, HASH_JOIN과 NO_HASH_JOIN이 있는 것을 알 수 있습니다. 하지만, Description을 보면 해당 힌트는 MySQL 8.0.18 에서만 사용할 수 있는 힌트입니다.
저는 현재 8.0.22 를 사용하고 있습니다. 이처럼 8.0.18 이후 버젼에서는 해시 조인을 컨트를하려면 BNL이나 NO_BNL이란 힌트를 사용해야 합니다.
위 레퍼런스에는, BNL 힌트의 설명으로 아래와 같이 정리되어 있습니다.
- Prior to MySQL 8.0.20: affects Block Nested-Loop join processing;
- MySQL 8.0.18 and later: also affects hash join optimization;
- MySQL 8.0.20 and later: affects hash join optimization only
BNL은 원래 Block Nested-Loop 를 뜻합니다. NL(Nested-Loops) 조인을 처리할 때, 조인 버퍼를 사용해 NL 조인 성능을 향상시키는 기능입니다. MySQL 8 이전 버젼은 해시 조인을 지원하지 않았기 때문에, BNL 기능으로 해시 조인을 어느 정도 대체했던거 같습니다.
어찌되었든, 8.0.2 부터는 BNL 힌트가 해시 조인 최적화에 영향을 준다니 혼란스럽습니다. 아마도 8.0.2부터는 본연의 BNL(Block Nested-Loops) 기능 자체는 없어진거 같습니다. 그냥 BNL 힌트를 제거하고 HASH_JOIN 힌트를 살려줬으면 혼란이 없지 않았을까 생각이 듭니다만, 제가 모르는 다양한 이유가 있겠죠.
그러면, BNL 힌트를 사용해 해시 조인을 한번 테스트해볼까 합니다. 생각보다? 아니, 생각만큼 힌트가 잘 안먹습니다.
먼저, 저희가 테스트할 테이블인 M_CUS와 T_ORD_BIG에는 어떤 인덱스가 있는지 먼저 살펴봅니다.
인덱스는 조인의 내부적인 처리 방법(NL, HASH, Sort Merge)에 영향을 끼치는 중요 요소입니다.
MySQL에서 인덱스는 'show index from 테이블명' 명령어를 통해 쉽게 확인할 수 있습니다.
현재는 아래와 같이 인덱스가 구성되어 있습니다.
- M_CUS: PRIMARY(CUS_ID)
- T_ORD_BIG: PRIMARY(ORD_SEQ), T_ORD_BIG_X1(ORD_DT)
- 테스트 데이터는 다음 글에서 구성할 수 있습니다.: https://sweetquant.tistory.com/346
위와 같이 인덱스가 구성된 상태에서 아래와 같이 SQL을 실행해 봅니다. 실행 계획을 보면, M_CUS와 T_ORD_BIG을 NL 방식으로 조인하고 있습니다. 조인 조건으로 사용된 CUS_ID 컬럼의 인덱스는 M_CUS에만 있으므로, M_CUS를 후행 집합으로 NL 처리하고 있습니다.
EXPLAIN ANALYZE
SELECT T1.CUS_ID
,MAX(T1.CUS_NM) CUS_NM
,SUM(T2.ORD_AMT) ORD_AMT
,COUNT(*) ORD_CNT
FROM MYTUNDB.M_CUS T1
INNER JOIN MYTUNDB.T_ORD_BIG T2
ON (T1.CUS_ID = T2.CUS_ID)
WHERE T2.ORD_DT >= STR_TO_DATE('20170201','%Y%m%d')
AND T2.ORD_DT < STR_TO_DATE('20170301','%Y%m%d')
GROUP BY T1.CUS_ID
-> Table scan on <temporary> (actual time=0.003..0.015 rows=72 loops=1)
-> Aggregate using temporary table (actual time=187.347..187.376 rows=72 loops=1)
-> Nested loop inner join (cost=31569.86 rows=39462) (actual time=1.043..126.440 rows=19800 loops=1)
-> Index range scan on T2 using T_ORD_BIG_X1, with index condition: ((t2.ORD_DT >= <cache>(str_to_date('20170201','%Y%m%d'))) and (t2.ORD_DT < <cache>(str_to_date('20170301','%Y%m%d')))) (cost=17758.16 rows=39462) (actual time=0.973..91.609 rows=19800 loops=1)
-> Single-row index lookup on T1 using PRIMARY (CUS_ID=t2.CUS_ID) (cost=0.25 rows=1) (actual time=0.001..0.001 rows=1 loops=19800)
이제, NL이 아닌, 해시 조인으로 위 SQL이 처리되도록 하기 위해 아래와 같이 BNL 힌트를 적용합니다.
실행계획을 확인해보면, 힌트가 무시되고 NL 조인이 여전히 사용중인 것을 알 수 있습니다.
힌트를 잘 못 준건 아닐까란 생각에, /*+ BNL(T1) */ 이나, /*+ BNL(T1, T2) */, /*+ BNL(T2, T1) */ 등 다양하게 시도해봤으나 해시 조인이 발생하지는 않습니다.
아마도 MySQL의 옵티마이져가 생각했을때, 현재 SQL과 테이블, 인덱스 구성에서는 해시 조인이 비효율적이라고 판단한 것이겠죠.
EXPLAIN ANALYZE
SELECT /*+ BNL(T2) */
T1.CUS_ID
,MAX(T1.CUS_NM) CUS_NM
,SUM(T2.ORD_AMT) ORD_AMT
,COUNT(*) ORD_CNT
FROM MYTUNDB.M_CUS T1
INNER JOIN MYTUNDB.T_ORD_BIG T2
ON (T1.CUS_ID = T2.CUS_ID)
WHERE T2.ORD_DT >= STR_TO_DATE('20170201','%Y%m%d')
AND T2.ORD_DT < STR_TO_DATE('20170301','%Y%m%d')
GROUP BY T1.CUS_ID;
-> Table scan on <temporary> (actual time=0.002..0.014 rows=72 loops=1)
-> Aggregate using temporary table (actual time=120.311..120.340 rows=72 loops=1)
-> Nested loop inner join (cost=31569.86 rows=39462) (actual time=0.240..80.108 rows=19800 loops=1)
-> Index range scan on T2 using T_ORD_BIG_X1, with index condition: ((t2.ORD_DT >= <cache>(str_to_date('20170201','%Y%m%d'))) and (t2.ORD_DT < <cache>(str_to_date('20170301','%Y%m%d')))) (cost=17758.16 rows=39462) (actual time=0.223..58.354 rows=19800 loops=1)
-> Single-row index lookup on T1 using PRIMARY (CUS_ID=t2.CUS_ID) (cost=0.25 rows=1) (actual time=0.000..0.001 rows=1 loops=19800)
자, 그러면, 해시 조인을 발생시키기 위햇 JOIN_ORDER 힌트를 적용해보면 어떨까요?
T1를 먼저 접근하도록 JOIN_ORDER(T1, T2)를 적용해봅입니다.
이 경우, T2(T_ORD_BIG)쪽에는 CUS_ID에 대한 인덱스가 없으므로 해시 조인이 발생할 가능성이 높습니다. (FULL SCAN 방식으로 NL을 처리할 수는 없으니까요.)
BNL 힌트 없이, JOIN_ORDER 만으로 조인 순서를 변경하자 해시 조인이 사용된 것을 알 수 있습니다.
EXPLAIN ANALYZE
SELECT /*+ JOIN_ORDER(T1, T2) */
T1.CUS_ID
,MAX(T1.CUS_NM) CUS_NM
,SUM(T2.ORD_AMT) ORD_AMT
,COUNT(*) ORD_CNT
FROM MYTUNDB.M_CUS T1
INNER JOIN MYTUNDB.T_ORD_BIG T2
ON (T1.CUS_ID = T2.CUS_ID)
WHERE T2.ORD_DT >= STR_TO_DATE('20170201','%Y%m%d')
AND T2.ORD_DT < STR_TO_DATE('20170301','%Y%m%d')
GROUP BY T1.CUS_ID;
-> Table scan on <temporary> (actual time=0.002..0.014 rows=72 loops=1)
-> Aggregate using temporary table (actual time=106.843..106.871 rows=72 loops=1)
-> Inner hash join (t2.CUS_ID = t1.CUS_ID) (cost=1598243.65 rows=355158) (actual time=0.386..67.943 rows=19800 loops=1)
-> Index range scan on T2 using T_ORD_BIG_X1, with index condition: ((t2.ORD_DT >= <cache>(str_to_date('20170201','%Y%m%d'))) and (t2.ORD_DT < <cache>(str_to_date('20170301','%Y%m%d')))) (cost=13816.34 rows=39462) (actual time=0.250..55.218 rows=19800 loops=1)
-> Hash
-> Table scan on T1 (cost=9.25 rows=90) (actual time=0.058..0.081 rows=90 loops=1)
이번에는 조인 순서를 T2->T1으로 변경하고 BNL 힌트까지 추가해봅니다. 혹시나 JOIN_ORDER와 BNL을 같이 써야만 해시 조인이 사용되는지 의심되서 입니다.
하지만, 다시 NL 조인이 사용되는 것을 볼 수 있습니다. 아무래도 M_CUS의 PRIMARY 인덱스 사용의 우선 순위가 높은거 아닌가 싶습니다.
EXPLAIN ANALYZE
SELECT /*+ JOIN_ORDER(T2, T1) BNL(T2, T1) */
T1.CUS_ID
,MAX(T1.CUS_NM) CUS_NM
,SUM(T2.ORD_AMT) ORD_AMT
,COUNT(*) ORD_CNT
FROM MYTUNDB.M_CUS T1
INNER JOIN MYTUNDB.T_ORD_BIG T2
ON (T1.CUS_ID = T2.CUS_ID)
WHERE T2.ORD_DT >= STR_TO_DATE('20170201','%Y%m%d')
AND T2.ORD_DT < STR_TO_DATE('20170301','%Y%m%d')
GROUP BY T1.CUS_ID;
-> Table scan on <temporary> (actual time=0.002..0.011 rows=72 loops=1)
-> Aggregate using temporary table (actual time=93.163..93.184 rows=72 loops=1)
-> Nested loop inner join (cost=31569.86 rows=39462) (actual time=0.236..62.395 rows=19800 loops=1)
-> Index range scan on T2 using T_ORD_BIG_X1, with index condition: ((t2.ORD_DT >= <cache>(str_to_date('20170201','%Y%m%d'))) and (t2.ORD_DT < <cache>(str_to_date('20170301','%Y%m%d')))) (cost=17758.16 rows=39462) (actual time=0.219..44.639 rows=19800 loops=1)
-> Single-row index lookup on T1 using PRIMARY (CUS_ID=t2.CUS_ID) (cost=0.25 rows=1) (actual time=0.000..0.000 rows=1 loops=19800)
끝으로, 실제 성능과 상관없이, 죽었다 깨어나도 T2->T1 순서로 조인 순서를 유지하면서, 해시 조인을 만들어 보고 싶다면, 조인 조건의 강제 변화를 고려할 수 있습니다.
아래와 같이 T1.CUS_ID를 TRIM 처리해서 인덱스를 사용못하도록 합니다. 그리고 JOIN_ORDER 힌트만 사용해서 T2 먼저 접근하도록 합니다. BNL 힌트를 주지 않았지만, 해시 조인이 발생한 것을 알 수 있습니다.
(아래와 같이 조인 조건절을 일부러 변형해 해시 조인을 유도하는 것은 절대 좋은 방법이 아니죠.)
EXPLAIN ANALYZE
SELECT /*+ JOIN_ORDER(T2, T1) */
T1.CUS_ID
,MAX(T1.CUS_NM) CUS_NM
,SUM(T2.ORD_AMT) ORD_AMT
,COUNT(*) ORD_CNT
FROM MYTUNDB.M_CUS T1
INNER JOIN MYTUNDB.T_ORD_BIG T2
ON (TRIM(T1.CUS_ID) = T2.CUS_ID) -- > T1.CUS_ID를 일부러 TRIM 처리
WHERE T2.ORD_DT >= STR_TO_DATE('20170201','%Y%m%d')
AND T2.ORD_DT < STR_TO_DATE('20170301','%Y%m%d')
GROUP BY T1.CUS_ID;
-> Table scan on <temporary> (actual time=0.002..0.024 rows=72 loops=1)
-> Aggregate using temporary table (actual time=280.777..280.815 rows=72 loops=1)
-> Inner hash join (trim(t1.CUS_ID) = t2.CUS_ID) (cost=372923.03 rows=3551580) (actual time=141.649..239.391 rows=19800 loops=1)
-> Table scan on T1 (cost=0.00 rows=90) (actual time=0.078..0.119 rows=90 loops=1)
-> Hash
-> Index range scan on T2 using T_ORD_BIG_X1, with index condition: ((t2.ORD_DT >= <cache>(str_to_date('20170201','%Y%m%d'))) and (t2.ORD_DT < <cache>(str_to_date('20170301','%Y%m%d')))) (cost=17758.16 rows=39462) (actual time=0.243..48.248 rows=19800 loops=1)
지금까지 살펴본 바로는, 해시 조인을 사용하는 힌트인 BNL이 그렇게 잘 작동하는거 같지는 않습니다. 조인을 처리할 수 있는 막강한 PRIMARY 인덱스가 있다면, 아마도 해시 조인보다 NL 조인을 선호하는 것은 아닌가 생각이 듭니다.
BNL 힌트는 잘 작동하지 않지만, NO_BNL 힌트는 또 잘 먹는거 같드라고요.
아래와 같이 해시 조인이 자동으로 작동했던, JOIN_ORDER(T1, T2)를 적용한 후에 NO_BNL까지 힌트를 걸어주면 NL 방식으로 조인이 처리되어 매우 느린 것을 알 수 있습니다.
EXPLAIN ANALYZE
SELECT /*+ JOIN_ORDER(T1, T2) NO_BNL(T2) */
T1.CUS_ID
,MAX(T1.CUS_NM) CUS_NM
,SUM(T2.ORD_AMT) ORD_AMT
,COUNT(*) ORD_CNT
FROM MYTUNDB.M_CUS T1
INNER JOIN MYTUNDB.T_ORD_BIG T2
ON (T1.CUS_ID = T2.CUS_ID)
WHERE T2.ORD_DT >= STR_TO_DATE('20170201','%Y%m%d')
AND T2.ORD_DT < STR_TO_DATE('20170301','%Y%m%d')
GROUP BY T1.CUS_ID;
-> Group aggregate: max(t1.CUS_NM), sum(t2.ORD_AMT), count(0) (actual time=187.159..6557.740 rows=72 loops=1)
-> Nested loop inner join (cost=1598243.65 rows=355158) (actual time=16.554..6530.982 rows=19800 loops=1)
-> Index scan on T1 using PRIMARY (cost=9.25 rows=90) (actual time=0.060..0.328 rows=90 loops=1)
-> Filter: (t2.CUS_ID = t1.CUS_ID) (cost=13816.34 rows=3946) (actual time=24.775..72.503 rows=220 loops=90)
-> Index range scan on T2 using T_ORD_BIG_X1, with index condition: ((t2.ORD_DT >= <cache>(str_to_date('20170201','%Y%m%d'))) and (t2.ORD_DT < <cache>(str_to_date('20170301','%Y%m%d')))) (cost=13816.34 rows=39462) (actual time=0.290..61.939 rows=19800 loops=90)
본 글에서는 BNL을 이용해 해시 조인이 발생한 케이스는 나오지 않았으나, 인덱스의 구성, 쿼리 조건의 변경에 따라 BNL 힌트가 작동이 될 수도 있다고 생각합니다. 어쨋든, 8.0.2 부터는 BNL 힌트를 사용해 해시 조인을 유도할 수 있다는 점과, 힌트를 준다고 해도, 무조건 해시 조인이 발생하지 않는다는 점을 알고 있으면 될거 같습니다.
오늘은 여기까지입니다. 감사합니다.