Firebird 5.0.1 옵티마이저 개선 사항
(c) D.Simonov, IBSurgeon, 21-Aug-2024
최근 Firebird 5.0 DBMS의 포인트 릴리스인 Firebird 5.0.1이 출시되었습니다. 오류 수정 외에도 이 글에서 다룰 새로운 실험적 옵티마이저 기능이 추가되었습니다.
세미 조인에서 서브쿼리를 ANY/SOME/IN/EXISTS로 변환
세미 조인(semi-join)은 두 관계를 조인하되 전체 조인을 수행하지 않고 한 관계의 행만 반환하는 연산입니다. 다른 조인 연산자와 달리 세미 조인 수행 여부를 지정하는 명시적 구문은 없습니다. 그러나 ANY/SOME/IN/EXISTS에서 서브쿼리를 사용하여 세미 조인을 수행할 수 있습니다.
전통적으로 Firebird는 ANY/SOME/IN 조건자의 서브쿼리를 EXISTS 조건자의 상관 서브쿼리로 변환하고, 외부 쿼리의 각 레코드에 대해 EXISTS 내부의 서브쿼리를 실행합니다. EXISTS 조건자 내부에서 서브쿼리를 실행할 때는 FIRST ROWS 전략이 사용되며, 첫 번째 레코드가 반환된 직후 실행이 중지됩니다.
Firebird 5.0.1부터 ANY/SOME/IN/EXISTS 조건자의 서브쿼리를 세미 조인으로 변환할 수 있습니다. 이 기능은 기본적으로 비활성화되어 있으며, firebird.conf 또는 database.conf 파일에서 SubQueryConversion 구성 매개변수를 true로 설정하여 활성화할 수 있습니다.
이 기능은 실험적이므로 기본적으로 비활성화되어 있습니다. 이 기능을 활성화하고 ANY/SOME/IN/EXISTS 조건자에 서브쿼리가 있는 쿼리를 테스트해 볼 수 있으며, 성능이 더 좋다면 활성화된 상태로 두고, 그렇지 않으면 SubQueryConversion 매개변수를 기본값(false)으로 되돌리면 됩니다.SubQueryConversion 구성 매개변수의 기본값은 향후 변경될 수 있으며, 매개변수가 완전히 제거될 수도 있습니다. 이는 새로운 방식이 대부분의 경우 더 최적임이 입증된 후에 발생할 것입니다. |
ANY/SOME/IN/EXISTS를 서브쿼리에 직접, 즉 상관 서브쿼리로 수행하는 것과 달리 세미 조인으로 수행하면 최적화 여지가 더 많아집니다. 세미 조인은 다양한 Hash Join (semi) 또는 Nested Loop Join (semi) 알고리즘으로 수행할 수 있는 반면, 상관 서브쿼리는 항상 외부 쿼리의 각 레코드에 대해 수행됩니다.
firebird.conf 파일에서 SubQueryConversion 매개변수를 true로 설정하여 이 기능을 활성화해 보겠습니다. 이제 몇 가지 실험을 해보겠습니다.
다음 쿼리를 실행해 보겠습니다:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND H.CODE_SEX = 2
AND H.CODE_HORSE IN (
SELECT COVER.CODE_FATHER
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND EXTRACT(YEAR FROM COVER.BYDATE) = 2023
)
Select Expression
-> Aggregate
-> Filter
-> Hash Join (semi)
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap And
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
-> Bitmap
-> Index "FK_HORSE_SEX" Range Scan (full match)
-> Record Buffer (record length: 41)
-> Filter
-> Table "COVER" Access By ID
-> Bitmap And
-> Bitmap
-> Index "IDX_COVER_BYYEAR" Range Scan (full match)
-> Bitmap
-> Index "FK_COVER_DEPARTURE" Range Scan (full match)
COUNT
=====================
297
Current memory = 552356752
Delta memory = 352
Max memory = 552567920
Elapsed time = 0.045 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 43984
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 1516| | | |
HORSE | | 37069| | | |
--------------------------------+---------+---------+---------+---------+---------+
실행 계획에서 새로운 조인 방법인 Hash Join (semi)을 볼 수 있습니다. IN의 서브쿼리 결과가 버퍼링되었으며, 이는 계획에서 Record Buffer (record length: 41)로 표시됩니다. 즉, 이 경우 IN의 서브쿼리는 한 번 실행되고 그 결과가 해시 테이블 메모리에 저장된 다음, 외부 쿼리는 이 해시 테이블에서 단순히 검색만 수행합니다.
비교를 위해 서브쿼리-세미 조인 변환을 비활성화한 상태로 동일한 쿼리를 실행해 보겠습니다.
Sub-query
-> Filter
-> Filter
-> Table "COVER" Access By ID
-> Bitmap And
-> Bitmap
-> Index "FK_COVER_FATHER" Range Scan (full match)
-> Bitmap
-> Index "IDX_COVER_BYYEAR" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap And
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
-> Bitmap
-> Index "FK_HORSE_SEX" Range Scan (full match)
COUNT
=====================
297
Current memory = 552046496
Delta memory = 352
Max memory = 552135600
Elapsed time = 0.395 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 186891
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 297| | | |
HORSE | | 37069| | | |
--------------------------------+---------+---------+---------+---------+---------+
실행 계획은 서브쿼리가 기본 쿼리의 각 레코드에 대해 실행되지만 추가 인덱스 FK_COVER_FATHER를 사용함을 보여줍니다. 이는 실행 통계에서도 확인할 수 있습니다: Fetches 수가 4배 더 많고, 실행 시간도 거의 4배 더 나쁩니다.
| 독자는 이렇게 질문할 수 있습니다: 해시 세미 조인이 COVER 테이블의 인덱스 읽기를 5배 더 많이 보여주는데 왜 그 외에는 더 나은가요? 사실 통계의 인덱스 읽기는 인덱스를 사용하여 읽은 레코드 수를 보여주며, 레코드를 전혀 검색하지 않는 인덱스 접근의 총 횟수는 보여주지 않습니다. 그러나 이러한 접근도 무료는 아닙니다. |
무슨 일이 일어났을까요? 서브쿼리 변환을 더 잘 이해하기 위해 가상의 세미 조인 연산자 “SEMI JOIN"을 도입해 보겠습니다. 앞서 말했듯이 이 유형의 조인은 SQL 언어로 표현되지 않습니다. IN 연산자가 있는 우리의 쿼리는 다음과 같이 작성할 수 있는 동등한 형태로 변환되었습니다:
SELECT
COUNT(*)
FROM
HORSE H
SEMI JOIN (
SELECT COVER.CODE_FATHER
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND EXTRACT(YEAR FROM COVER.BYDATE) = 2023
) TMP ON TMP.CODE_FATHER = H.CODE_HORSE
WHERE H.CODE_DEPARTURE = 1
AND H.CODE_SEX = 2
이제 더 명확해졌습니다. EXISTS를 사용하는 서브쿼리에서도 동일한 일이 발생합니다. 다른 예를 살펴보겠습니다:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_DEPARTURE = 1
AND COVER.CODE_FATHER = H.CODE_FATHER
AND COVER.CODE_MOTHER = H.CODE_MOTHER
)
현재 이러한 EXISTS를 IN으로 작성하는 것은 불가능합니다. 세미 조인으로 변환하지 않고 어떻게 구현되는지 살펴보겠습니다.
Sub-query
-> Filter
-> Table "COVER" Access By ID
-> Bitmap And
-> Bitmap
-> Index "FK_COVER_MOTHER" Range Scan (full match)
-> Bitmap
-> Index "FK_COVER_FATHER" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
COUNT
=====================
91908
Current memory = 552240400
Delta memory = 352
Max memory = 554680016
Elapsed time = 19.083 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 935679
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 91908| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
매우 느립니다. 이제 SubQueryConversion = true로 설정하고 쿼리를 다시 실행해 보겠습니다.
Select Expression
-> Aggregate
-> Filter
-> Hash Join (semi)
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
-> Record Buffer (record length: 49)
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "FK_COVER_DEPARTURE" Range Scan (full match)
COUNT
=====================
91908
Current memory = 552102000
Delta memory = 352
Max memory = 561520736
Elapsed time = 0.208 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 248009
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 140254| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
쿼리가 100배 더 빨라졌습니다! 가상의 SEMI JOIN 연산자를 사용하여 다시 작성하면 쿼리는 다음과 같습니다:
SELECT
COUNT(*)
FROM
HORSE H
SEMI JOIN (
SELECT
COVER.CODE_FATHER,
COVER.CODE_MOTHER
FROM COVER
) TMP ON TMP.CODE_FATHER = H.CODE_FATHER AND TMP.CODE_MOTHER = H.CODE_MOTHER
WHERE H.CODE_DEPARTURE = 1
IN/EXISTS의 모든 상관 서브쿼리가 세미 조인으로 변환될 수 있나요? 아니요, 전부는 아닙니다. 예를 들어 서브쿼리에 FETCH/FIRST/SKIP/ROWS 필터가 포함된 경우 서브쿼리는 세미 조인으로 변환될 수 없으며 상관 서브쿼리로 실행됩니다. 다음은 그러한 쿼리의 예입니다:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_FATHER = H.CODE_HORSE
OFFSET 0 ROWS
)
여기서 OFFSET 0 ROWS 구문은 쿼리의 의미를 변경하지 않으며 실행 결과는 그것 없이 실행한 것과 동일합니다. 이 쿼리의 플랜과 통계를 살펴보겠습니다.
Sub-query
-> Skip N Records
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "FK_COVER_FATHER" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
COUNT
=====================
10971
Current memory = 551912944
Delta memory = 288
Max memory = 552002112
Elapsed time = 0.201 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 408988
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 10971| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
보시다시피 세미 조인으로의 변환이 발생하지 않았습니다. 이제 OFFSET 0 ROWS를 제거하고 다시 통계를 확인해 보겠습니다.
Select Expression
-> Aggregate
-> Filter
-> Hash Join (semi)
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
-> Record Buffer (record length: 33)
-> Table "COVER" Full Scan
COUNT
=====================
10971
Current memory = 552112128
Delta memory = 288
Max memory = 585044592
Elapsed time = 0.405 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 854841
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | 722465| | | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
여기서 세미 조인으로의 변환이 발생했으며, 실행 시간이 더 나빠진 것을 확인할 수 있습니다. 그 이유는 현재 옵티마이저가 Hash Join (semi)와 인덱스를 사용하는 Nested Loop Join (semi) 조인 알고리즘 간의 비용 추정을 하지 않기 때문입니다. 따라서 규칙은 다음과 같습니다: 조인 조건에 오직 동등 비교만 포함된 경우 Hash Join (semi) 알고리즘이 선택되고, 그렇지 않으면 IN/EXISTS 서브쿼리가 일반적인 방식으로 실행됩니다.
이제 세미 조인 변환을 비활성화하고 실행 통계를 확인해 보겠습니다.
Sub-query
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "FK_COVER_FATHER" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
COUNT
=====================
10971
Current memory = 551912752
Delta memory = 288
Max memory = 552001920
Elapsed time = 0.193 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 408988
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 10971| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
보시다시피 Fetches는 서브쿼리에 OFFSET 0 ROWS 절이 포함된 경우와 정확히 동일하며, 실행 시간은 오차 범위 내에서 차이가 있습니다. 이는 OFFSET 0 ROWS 절을 세미 조인 변환을 비활성화하는 힌트로 사용할 수 있음을 의미합니다.
이제 서브쿼리에서 동등 비교와 IS NOT DISTINCT FROM 이외의 상관 조건이 사용되는 경우를 살펴보겠습니다.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.BYDATE > H.BIRTHDAY
)
Sub-query
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "COVER_IDX_BYDATE" Range Scan (lower bound: 1/1)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
위에서 말씀드린 대로, 세미 조인으로의 변환이 발생하지 않았으며, 서브쿼리는 메인 쿼리의 각 레코드에 대해 실행됩니다.
실험을 계속해서, 동등 조건과 동등 조건 외의 조건 하나를 사용하는 쿼리를 작성해 보겠습니다.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_FATHER = H.CODE_FATHER
AND COVER.BYDATE > H.BIRTHDAY
)
Select Expression
-> Aggregate
-> Nested Loop Join (semi)
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
-> Filter
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "COVER_IDX_BYDATE" Range Scan (lower bound: 1/1)
여기서 실행 계획에서 Nested Loop Join (semi) 조인 방식의 첫 사용을 볼 수 있지만, 안타깝게도 이 계획은 좋지 않습니다. FK_COVER_FATHER 인덱스가 사용되지 않기 때문입니다. 이러한 쿼리로는 어떤 결과도 얻을 수 없습니다. 이 문제는 OFFSET 0 ROWS 힌트를 사용하여 해결할 수 있습니다.
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_DEPARTURE = 1
AND EXISTS (
SELECT *
FROM COVER
WHERE COVER.CODE_FATHER = H.CODE_FATHER
AND COVER.BYDATE > H.BIRTHDAY
OFFSET 0 ROWS
)
Sub-query
-> Skip N Records
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "FK_COVER_FATHER" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "FK_HORSE_DEPARTURE" Range Scan (full match)
COUNT
=====================
72199
Current memory = 554017824
Delta memory = 320
Max memory = 554284480
Elapsed time = 45.548 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 84145713
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 75894621| | | |
HORSE | | 96021| | | |
--------------------------------+---------+---------+---------+---------+---------+
최상의 실행 시간은 아니지만, 이 경우에는 적어도 결과를 얻을 수 있었습니다.
따라서 서브쿼리를 ANY/SOME/IN/EXISTS에서 세미 조인으로 변환하면 경우에 따라 쿼리 실행을 크게 가속화할 수 있지만, 현재 이 기능은 아직 완벽하지 않아 기본적으로 비활성화되어 있습니다. Firebird 6.0에서는 이 기능에 대한 비용 추정을 추가하고 다른 여러 단점을 수정하려고 합니다. 또한 Firebird 6.0에서는 ALL/NOT IN/NOT EXISTS 서브쿼리를 안티 조인으로 변환하는 기능을 추가할 계획입니다.
IN/EXISTS에서 서브쿼리 실행에 대한 검토를 마무리하면서, 다음과 같은 형태의 쿼리가 있다면
SELECT ...
FROM T1
WHERE IN (SELECT field FROM T2 ...)
또는
SELECT ...
FROM T1
WHERE EXISTS (SELECT ... FROM T2 WHERE T1. = T2.field)
이러한 쿼리는 거의 항상 다음과 같이 실행하는 것이 더 효율적입니다.
SELECT ...
FROM
T1
JOIN (SELECT DISTINCT field FROM T2) tmp ON tmp.field = T1.
명확한 예를 들어 보겠습니다:
SELECT
COUNT(*)
FROM
HORSE H
WHERE H.CODE_HORSE IN (
SELECT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
)
Hash Join (semi)를 사용한 실행 계획 및 통계
Select Expression
-> Aggregate
-> Filter
-> Hash Join (semi)
-> Table "HORSE" as "H" Full Scan
-> Record Buffer (record length: 41)
-> Filter
-> Table "COVER" Access By ID
-> Bitmap
-> Index "IDX_COVER_BYYEAR" Range Scan (full match)
COUNT
=====================
1616
Current memory = 554176768
Delta memory = 288
Max memory = 555531328
Elapsed time = 0.229 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 569683
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 6695| | | |
HORSE | 525875| | | | |
--------------------------------+---------+---------+---------+---------+---------+
상당히 빠르지만, HORSE 테이블이 전체 스캔됩니다.
클래식 서브쿼리 실행 방식의 실행 계획 및 통계
Sub-query
-> Filter
-> Filter
-> Table "COVER" Access By ID
-> Bitmap And
-> Bitmap
-> Index "FK_COVER_FATHER" Range Scan (full match)
-> Bitmap
-> Index "IDX_COVER_BYYEAR" Range Scan (full match)
Select Expression
-> Aggregate
-> Filter
-> Table "HORSE" as "H" Full Scan
COUNT
=====================
1616
Current memory = 553472512
Delta memory = 288
Max memory = 553966592
Elapsed time = 6.862 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 2462726
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 1616| | | |
HORSE | 525875| | | | |
--------------------------------+---------+---------+---------+---------+---------+
매우 느립니다. HORSE 테이블은 전체 스캔되고, 서브쿼리는 HORSE 테이블의 각 레코드에 대해 여러 번 실행됩니다.
이제 DISTINCT를 사용한 빠른 옵션입니다.
SELECT
COUNT(*)
FROM
HORSE H
JOIN (
SELECT
DISTINCT
CODE_FATHER
FROM COVER
WHERE EXTRACT(YEAR FROM COVER.BYDATE) = 2022
) TMP ON TMP.CODE_FATHER = H.CODE_HORSE
Select Expression
-> Aggregate
-> Nested Loop Join (inner)
-> Unique Sort (record length: 44, key length: 12)
-> Filter
-> Table "COVER" as "TMP COVER" Access By ID
-> Bitmap
-> Index "IDX_COVER_BYYEAR" Range Scan (full match)
-> Filter
-> Table "HORSE" as "H" Access By ID
-> Bitmap
-> Index "PK_HORSE" Unique Scan
COUNT
=====================
1616
Current memory = 554349728
Delta memory = 320
Max memory = 555531328
Elapsed time = 0.011 sec
Buffers = 32768
Reads = 0
Writes = 0
Fetches = 14954
Per table statistics:
--------------------------------+---------+---------+---------+---------+---------+
Table name | Natural | Index | Insert | Update | Delete |
--------------------------------+---------+---------+---------+---------+---------+
COVER | | 6695| | | |
HORSE | | 1616| | | |
--------------------------------+---------+---------+---------+---------+---------+
불필요한 읽기가 없어 쿼리가 매우 빠르게 실행됩니다. 따라서 결론은 - 항상 IN/EXISTS/ANY/SOME에 있는 서브쿼리의 실행 계획을 확인하고, 쿼리 작성의 대안적인 변형들을 검토하라는 것입니다.