[오라클 성능 고도화 Ⅰ] I/O 효율화 원리

129612 단어 오라클오라클

블록 단위 I/O

오라클의 모든 I/O 는 블록 단위
∴ 하나의 컬럼 읽을 때도 레코드가 속한 블록 전체를 읽음

위 사항은 매우 중요하다.

레코드를 순차적으로 읽을 때 무거운 디스크 I/O 수반에서는 비효율이 없다.

레코드를 읽기 위해 블록을 통째로 액세스하면 메모리에서 읽더라도 비효율이 존재할 수 있다.

이때 전자를 Sequential 액세스, 후자를 Random 액세스 라고 한다.

블록 단위의 원리로 다음 두 쿼리의 처리양은 같다.

1) select ename from emp where sal >= 2000;

2) select * from emp where sal >= 2000;

블록 단위는 I/O 버퍼 캐시와 데이터파일 I/O 모두 적용된다.

  • 메모리 버퍼 캐시에서 블록을 읽고 쓸 때
  • 데이터파일에 저장된 데이터 블록을 직접 읽거나 쓸 때
  • 데이터파일에서 DB 버퍼 캐시로 블록을 적재할 때 : Single Block Read 또는 Multiblock Read 방식 사용
  • 버퍼 캐시의 변경된 블록을 데이터파일에 저장할 때 : Dirty 버퍼에 기록, 성능 향상을 위해 한 번에 여러 블록 처리

허용되는 블록 크기는 2K, 4K, 8K, 16K 32K 이다.
데이터베이스를 생성할 때 표준 블록 크기를 지정하며, 다른 크기의 블록을 동시 사용하려면 각각 별도로 테이블스페이스와 버퍼 Pool 을 구성해주어야 한다.

Sequential vs. Random 액세스

Sequential 액세스 는 레코드간 논리적 또는 물리적인 순서를 따라 차례대로 읽어나가는 방식이다.

인덱스 리프 블록의 모든 레코드는 포인터를 따라 논리적으로 연결되어 있고 이 포인터를 따라 스캔하는 것이다.
위 그림에서 ⑤번이 여기 해당한다.

Sequential 액세스 성능 향상을 위해 오라클 내부적으로 Multiblock I/O, 인덱스 Prefetch 같은 기능을 사용한다.

Random 액세스는 레코드간 논리적, 물리적인 순서를 따르지 않고, 한 건을 읽기 위해 한 블록씩 접근하는 방식을 말한다. ①, ②, ③, ④, ⑥이 여기 해당한다.

①, ②, ③ 액세스는 인덱스 깊이에 따라 1~3 블록 정도 읽어, 대개 성능에 영향을 미치지 않는다.

④, ⑥번은 액세스가 성능 저하를 일으킨다.

NL 조인에서 Inner 테이블 액세스는 전자 까지도 영향을 미칠 수 있다.

Random 액세스 성능 향상을 위해 오라클 내부적으로 버퍼 Pinning, 테이블 Prefetch 같은 기능을 사용한다.

총 읽은 레코드에서 결과 집합으로 선택되는 비중을 선택도라고 한다. Sequential 액세스 효율은 선택도에 의해 결정된다.
여기서 I/O 튜닝의 핵심 원리 두 가지를 발견할 수 있다.

  • Sequential 액세스의 선택도를 높인다.
  • Random 액세스 발생량을 줄인다.

Sequential 액세스 선택도 높이기

먼저 테스트용 테이블을 만든다.

CREATE TABLE T
AS
SELECT * FROM ALL_OBJECTS
ORDER BY DBMS_RANDOM.VALUE;

테이블이 생성되었습니다.

SELECT COUNT(*) FROM T;

COUNT(*)
--------
   49906

T 테이블엔 총 49,906 레코드가 저장되어 있다.

select count(*) from t
where owner like 'SYS%'

Rows  Rows Source Operation
----- -------------------------------------------------------
    0 STATEMENT
    1  SORT AGGREGATE (cr=691 pr=0 pw=0 time=13037 us)
24613    TABLE ACCESS FULL T (cr=691 pr=0 pw=0 time=98473 us)

위 쿼리는 24,613개 레코드를 선택하려고 49,906개 레코드를 스캔했으므로 선택도는 49%이다. Full Scan 선택도가 이 정도면 나쁘진 않다. 읽은 블록 수는 691개 였다.

select count(*) from t
where owner like 'SYS%'
and   object_name = 'ALL_OBJECTS'

Rows  Rows Source Operation
----- ------------------------------------------------------
    0 STATEMENT
    1  SORT AGGREGATE (cr=691 pr=0 pw=0 time=7191 us)
    1    TABLE ACCESS FULL T (cr=691 pr=0 pw=0 time=7150 us)

위 쿼리는 1개 레코드 선택을 위해 49,906개 레코드를 스캔하였으므로 선택도는 0.002%다. 매우 비효율이다.

여기서도 읽은 블록 수는 691개다. 이처럼 스캔하며 읽은 레코드 중 대부분 필터링되고 일부만 선택된다면 아래처럼 인덱스를 이용하는게 효과적이다.

create index t_idx on t(owner, object_name);

select /*+ index(t t_idx) */ count(*) from t
where  owner like 'SYS%'
and    object_name = 'ALL_OBJECTS'

Rows  Rows Source Operation
----- ------------------------------------------------------
    0 STATEMENT
    1  SORT AGGREGATE (cr=76 pr=0 pw=0 time=7009 us)
    1    TABLE ACCESS FULL T (cr=76 pr=0 pw=0 time=6972 us) (Object ID 55337)

위 쿼리 결과는 모두 인덱스만 스캔하여 결과를 내었다.
76개 블록을 읽고 1개 레코드를 얻었다. 1개 레코드를 얻으려고 실제 스캔한 레코드 수를 세려면 아래 쿼리를 이용할 수 있다.

select /*+ index(t t_idx) */ count(*) from t
where owner like 'SYS%'
and ((owner = 'SYS' and object_name >= 'ALL_OBJECTS') 
or (owner > 'SYS'));

COUNT(*)
--------
   14587

1 / 14587 x 100 = 0.007% 선택도이다. 테이블뿐 아니라 인덱스를 Sequential 액세스 스캔할 때도 비효율이 있는 것을 알았다.

인덱스는 테이블과 달리 정렬되어 있어 일정 범위 읽다 멈출 수 있는 점만 다르다. 인덱스 스캔의 효율으 조건절에 사용된 컬럼과 연산자 형태, 인덱스 구성에 의해 영향을 받는다.

인덱스 컬럼 순서를 변경하고 같은 쿼리를 수행해보자.

drop index t_idx;
create index t_idx on t(object_name, owner);

select /*+ index(t t_idx) */ count(*) from t
where owner like 'SYS%'
and   object_name = 'ALL_OBJECTS'

Rows  Rows Source Operation
----- ------------------------------------------------------
    0 STATEMENT
    1  SORT AGGREGATE (cr=2 pr=0 pw=0 time=44 us)
    1    TABLE ACCESS FULL T (cr=2 pr=0 pw=0 time=23 us) (Object ID 55338)

두 번의 CR 블록 읽기가 발생했다. 인덱스 루트 블록과 하나의 리프 블록만 읽었기 때문이다.

한 건을 얻으려고 스캔한 건수도 한 건(정확히는 one-plus 스캔까지 두 건)일 것이다.
선택도가 100%이므로 가장 효율적인 방식으로 Sequential 액세스를 수행했다.

Random 액세스 발생량 줄이기

인덱스에 속하지 않는 컬럼을 참조하도록 쿼리를 변경함으로써 테이블 액세스가 발생하도록 할 것이다.

drop index t_idx;

create index t_idx on t (owner);

selet object_id from t
where owner = 'SYS'
and   object_name = 'ALL_OBJECTS'

Rows  Rows Source Operation
----- ------------------------------------------------------
    0 STATEMENT
    1  TABLE ACCESS BY INDEX ROWID T (cr=739 pr=0 pw=0 time=38822 us)
22934    INDEX RANGE SCAN T_IDX (cr=51 pr=0 pw=0 time=115672 us) (Object ID 55339)

위 수행은 688개의 블록(739-51)을 Random 액세스 했다.
내부적으로 블록을 22,934번 방문했지만 Random 액세스 횟수가 688번에 머무는 것은 버퍼 Pinning 효과인데 클러스팅 팩터가 좋을수록 버퍼 Pinning에 의한 블록 I/O 감소 효과는 더 커진다.

최종 한 건을 선택하기 위해 너무 많은 Random 액세스가 발생했다.
object_name 을 필터링하기 위해 많이 방문한 것인데 인덱스 액세스 단계에서 필터링할 수 있도록 object_name 을 추가해 본다.

drop index t_idx;
create index t_idx on t(owner, object_name);

select object_id from t
where owner = 'SYS'
and   object_name = 'ALL_OBJECTS'

Rows  Rows Source Operation
----- ------------------------------------------------------
    0 STATEMENT
    1  TABLE ACCESS BY INDEX ROWID T (cr=4 pr=0 pw=0 time=67 us)
    1    INDEX RANGE SCAN T_IDX (cr=3 pr=0 pw=0 time=51 us) (Object ID 55340)

인덱스로부터 1건을 출력하여 테이블을 1번만 방문하였다.
실제 발생한 Random 액세스도 1(4-3)번이다. 같은 쿼리지만 인덱스 구성에 따라 대폭 감소했다.

Memory vs. Disk I/O

I/O 효율화 튜닝의 중요성

디스크 경유는 물리적으로 액세스 암을 통해 헤드를 움직어 읽고 쓰기가 느리다.

반면, 메모리 입출력은 전기적 신호라서 디스크와 비교할 수 없을 정도로 빠르다. 그래서 모든 DBMS는 버퍼 캐시를 경유해 I/O를 수행한다.

메모리로 블록을 찾는 것이 디스크 상의 파일을 읽는 것보다 평균적으로 10,000배 이상 나은 성능을 보인다.

메모리는 유한한 자원이기 때문에 효율적으로 사용해야 한다.

그래서 자주 액세스하는 블록들이 캐시에 오래남도록 LRU 알고리즘을 사용한다.

버퍼 캐시 히트율(Buffer Cache Hit Ratio)

버퍼 캐시 효율을 측정하는 지표로 버퍼 캐시 히트율이 있다.
전체 읽은 블록ㅇ서 얼만큼 메모리 버퍼 캐시에서 찾았는지 나타내는 것이다. 구하는 공식은 다음과 같다.

BCHR = ( 캐시에서 곧바로 찾은 블록 수 / 총 읽은 블록 수 ) X 100
     = ( ( 논리적 블록 읽기 - 물리적 블록읽기 ) / 논리적 블록 읽기 ) X 100
     = ( 1 - ( 물리적 블록읽기 ) / ( 논리적 블록읽기 ) ) X 100
     
  • 논리적 블록읽기 = 총 읽은 블록 수
  • 캐시에서 바로 찾은 블록 수 = 논리적 블록읽기 - 물리적 블록읽기

Direct Path Read 방식을 제외하면 모든 블록 읽기는 버퍼 캐시로 이루어진다.

애플리케이션 성격에 따라 차이가 있지만 BCHR는, 온라인 트랜잭션을 주로 처리하는 시스템이라면 99% 달성을 목표해야 한다. 주로 시스템 전체적인 관점에서 바라보지만, 개별 SQL 측면에서 구해볼 수 있는데 이 비율은 낮은 것이니 쿼리 성능을 떨어뜨리는 주된 원인이다.

Call     Count  CPU Time  Elapsed Time   Disk      Query Current  Rows
------ ------- --------- ------------ ------ ---------- ------- -----
Parse        1     0.000         0.001      0          0       0     0
Execute      1     0.010         0.006      0          0       0     0
Fetch        2   138.680      1746.630 601458    1351677       0     1
------ ------- --------- ------------- ------ ---------- ------- -----
Total        4   138.690      1746.637 601458    1351677       0     1

위 디스크 읽기가 물리적 블록읽기에 해당한다. 논리적 블록읽기는 Query와 Current 항목을 더해서 구하며, Direct Path Read 방식으로 읽은 블록이 없다면 이 두 값을 더한 것이 총 읽은 블록 수 가 된다.

총 1,351,677개 블록 중 601,458개가 디스크에서 적재 후에 읽었으니 위 샘플의 BCHR 는 56%다.

BCHR = (1 - (Disk / (Query + Current))) X 100
     = (1 - (601,458 / (1,351,677 + 0))) X 100
     = 55.5%
     

그래서 논리적 블록 요청을 줄이고, 물리적으로 디스크에서 읽어야 할 블록 수를 줄이는 것이 I/O 효율화 튜닝의 핵심 원리다.

예외적으로 작은 테이블에 자주 액세스하면 모든 블록이 메모리에서 찾아 BCHR은 높지만 래치를 얻는 과정으로 인해 의외로 큰 비용을 수반한다.

특히, 래치 경합과 버퍼 Lock 경합까지 발생하면 디스크 I/O 이상으로 커질 수 있다. 따라서 100%의 BCHR이라도 블록 수의 절대량이 많다면 반드시 SQL 튜닝을 실시해야한다.

대량의 데이터를 기준으로 NL 조인 방식으로 작은 테이블을 반복적으로 Lookup 하는 경우가 대표적이다.

네트워크, 파일시스템 캐시가 I/O 효율에 미치는 영향

네트워크 속도도 I/O 선응에 영향을 미친다.

대용량의 데이터를 읽고 쓰는데 다양한 네트워크 기술이 사용됨에 따라 네트워크 속도도 SQL 성능에 영향을 미친다.

RAC 같은 클러스터링 환경은 인스턴스 간에 캐시된 블록을 공유하여 메모리 I/O 에도 성능 영향을 미친다.

같은 양의 디스크 I/O 발생은 대기시간이 크게 차이 나는 것은 디스크 경합으로 인한 것일 수 있지만 OS 에서 지원하는 파일시스템 버퍼 캐시와 SAN 캐시로 인한 것일 수 있다.

즉, 네트워크든 파일 시스템이든 I/O 성능에 확실히 영향을 미치며 근본적인 해결책은 논리적인 블록 요청을 최소화하며 튜닝하는 것이다.

Single Block vs. Multiblock I/O

Call     Count  CPU Time  Elapsed Time   Disk  Query  Current  Rows
------ ------- --------- -------------  ------ ------ ------- -----
Parse        1     0.00           0.00       0      0       0     0
Execute      1     0.00           0.00       0      0       0     0
Fetch        2     0.26           0.26      64     69       0     1
------ ------- --------- --------------  ------ ------ ------- -----
Total        4     0.26           0.26      64     69       0     1

위 통계를 보면 버퍼 캐시에서 69개 블록을 읽어 그 중 64개가 디스크에서 읽었다.
히트율은 7.24%다. 하지만 디스크에서 읽은 블록 수가 64개라고 I/O Call 까지 64번 발생했다는 것은 아니다.
64번일 수도 그보다 작을 수도 있다.

읽고자 하는 블록을 버퍼 캐시에서 찾이 못했을 때, I/O Call 로 버퍼 캐시에 적재하는 방식에 크게 두 가지가 있다.

  • Single Block I/O
    • 한번의 I/O Call에 하나의 데이터 블록만 읽어도 메모리에 적재
    • 인덱스로 액세스할 때, 기본적으로 인덱스와 테이블 블록 모두 이 방식을 사용
  • Multiblock I/O
    • I/O Call이 필요한 시점에 인접한 블록들을 같이 읽어 메모리에 적재
    • 블록 사이즈가 얼마건 간에 OS 단에서 보통 1MB 단위로 I/O 를 수행
    • 1MB 크기므로 Full Scan처럼 물리적으로 저장된 순서에 따라 읽을 때 범위 내에 인적한 블록을 같이 읽을 때 유리

Multiblock I/O 단위는 db_file_multiblock_read_count 파라미터에 의해 결정된다. 이 파라미터가 16이면 한 번에 최대 16개 블록까지만 적재가 가능하다.

대개 OS 레벨에서 I/O 단위가 1MB 이므로 db_block_size 가 8,192 일 때 최대 설정할 수 있는 값은 128이 된다.

디스크 I/O는 비용이 크므로 한 블록 보단 여러 블록을 읽는게 좋을텐데, 인덱스 스캔은 왜 한 블록씩 읽을 까?

인덱스 리프 블록 끼리 이중 연결 리스트 구조로 연결되어 있는데, 물리적으로는 같이 적재하여 올려도, 논리적 순서로는 다를 수 있다. 그럼 실제 사용되지 못한 채 버퍼 상에 밀리는 경우가 있는데, 한 블록을 캐싱하기 위해 다른 블록을 밀어내는 현상 때문에 오히려 효율이 떨어진다.

따라서 인덱스 스캔은 Single Block I/O 방식으로 읽는게 효율적이다.

Index Range Scan 뿐 아니라 Index Full Scan 시에도 논리적인 순서에 따라 Single Block I/O 방식으로 읽는다.
인덱스의 논리적 순서를 무시하고 물리적 순서로 읽는 방식도 있는데 이를 Index Fast Full Scan이라 한다. 이때 Table Full Scan 과 마찬가지로 Multiblock I/O 방식으로 사용하며, 한 번에 읽을 수 있는 최대 블록 수는 db_file_multiblock_read_count 파라미터에 의해 결정된다.

서버 프로세스는 디스크의 블록을 읽을 시점마다 I/O 서브시스템에서 I/O 요청을 하고 대기 상태에 빠지는데, 대표적으로 두 가지가 있다.

  • db file sequential read 대기 이벤트 : Single Block I/O 방식으로 I/O를 요청할 때 발생
  • db file scattered read 대기 이벤트 : Multiblock I/O 방식으로 요청할 때 발생

대량의 데이터를 읽을 때 Multiblock I/O 방식이 Single Block I/O 보다 성능상 유리한 것은 I/O Call 횟수를 줄여주기 때문이다.

직접 테스트 해보자.

create table t
as
select * from all_objects;

alter table t add
constraint t_pk primary key(object_id);

위와 같이 생성했다. 만들자마다 쿼리를 수행하면 대부분 디스크 I/O 를 통해 읽을 것이다.

select /*+ index(t) */ count(*)
from t where object_id > 0

Call     Count  CPU Time  Elapsed Time   Disk  Query  Current  Rows
------ ------- --------- -------------  ------ ------ ------- -----
Parse        1     0.00           0.00       0      0       0     0
Execute      1     0.00           0.00       0      0       0     0
Fetch        2     0.26           0.25      64     65       0     1
------ ------- --------- --------------  ------ ------ ------- -----
Total        4     0.26           0.25      64     65       0     1

Rows  Rows Source Operation
----- ------------------------------------------------------
    1  SORT AGGREGATE (cr=65 r=64 w=0 time=256400 us)
31192    INDEX RANGE SCAN T_PK (cr=65 r=64 w=0 time=134613 us)

Elapsed times include waiting on following events:
 Event waited on              Time   Max. Wait   Total Waited
 --------------------------- Waited ---------- --------------
 SQL*Net message to client        2       0.00           0.00
 db file sequential read         64       0.00           0.00
 SQL*Net message from client      2       0.05           0.05

위 결과는 논리적으로 65개 블록을 읽을 때 64개의 디스크 블록을 읽었다.
이벤트 현황은 db file sequential read 대기 이벤트가 64번 발생했다. 즉, 64개 인덱스 블록을 Disk에서 읽으며 64번 I/O Call이 발생했다.

이번엔 Mutloblock I/O 방식으로 읽는 경우를 살펴보자. 그전에 db_block_size 와 Multiblock I/O 단위를 확인한다.

show parameter db_block_size

NAME                          TYPE              VALUE
----------------------------- ----------------- ----------------
db_block_size                 integer           8192

show parameter db_file_multiblock_read_count

NAME                          TYPE              VALUE
----------------------------- ----------------- ----------------
db_file_multiblock_read_count integer           16

db_block_size 는 8,192이고 Multiblock I/O 단위는 16이다.
앞서와 같은 양의 인덱스 블록을 Multiblock I/O 방식으로 읽도록 유도하기 위해 인덱스를 index fast scan 방식으로 읽게 한다.

index_ffs 힌트를 사용하면 된다. Multiblock I/O 단위가 16이므로 데이터파일에서 똑같이 64개 블록을 읽었을 때 4(=64/16)번의 I/O Call 이 발생할 것으로 예상된다.

디스크 I/O 를 위해 테이블을과 인덱스를 Drop 했다가 다시 생성해야 한다.

select /*+ index_ffs(t) */ count(*)
from t where object_id > 0

Call     Count  CPU Time  Elapsed Time   Disk  Query  Current  Rows
------ ------- --------- -------------  ------ ------ ------- -----
Parse        1     0.00           0.00       0      0       0     0
Execute      1     0.00           0.00       0      0       0     0
Fetch        2     0.26           0.26      64     69       0     1
------ ------- --------- --------------  ------ ------ ------- -----
Total        4     0.26           0.26      64     69       0     1

Rows  Rows Source Operation
----- ------------------------------------------------------
    1  SORT AGGREGATE (cr=69 r=64 w=0 time=267453 us)
31192    INDEX RANGE SCAN T_PK (cr=69 r=64 w=0 time=143781 us)

Elapsed times include waiting on following events:
 Event waited on              Time   Max. Wait   Total Waited
 --------------------------- Waited ---------- --------------
 SQL*Net message to client        2       0.00           0.00
 db file sequential read          9       0.00           0.00
 SQL*Net message from client      2       0.35           0.36

동일하게 64개 블록을 읽었지만, I/O Call이 9번에 그쳤다.
Single Block I/O 보다 줄었지만 예상한 4번 보다는 두 배 많은 수치다.
64/9 = 7.11 이므로 평균 7~8개 읽은 셈이다.

OS에서 I/O 단위가 65,536(=8,192X8) 바이트인지 트레이스 파일로 확인해보자.

EXEC #1:c=0,e=58,p=0,cr=0,cu=0,mis=0,dep=0,og=0,tim=1207457668427947
WAIT #1: nam='SQL*Net message to client' ela=7 pl=1413697536 p2=1 p3=0
WAIT #1: name='db file scattered read' ela=73 pl=9 p2=80997 p3=4
WAIT #1: name='db file scattered read' ela=73 pl=9 p2=81001 p3=8
WAIT #1: name='db file scattered read' ela=72 pl=9 p2=81010 p3=7
WAIT #1: name='db file scattered read' ela=77 pl=9 p2=81017 p3=8
...

EXEC #1:c=262961,e=267473,p=64,cr=69,cu=0,mis=0,r=1,dep=0,og=4,tim=1207457668695515
WAIT #1: nam='SQL*Net message to client' ela=4073 pl=1413697536 p2=1 p3=0
FETCH #1: c=0,e=4,p=0,cr=0,cu=0,mis=0,r=0,dep=0,og=0,tim=1207457668699756
...

db file scattered read 대기 이벤트가 실제 9번 발생한 것을 볼 수 있고, 세 번째 파라미터를 보면 처음만 빼고 매번 7 또는 8번 읽었다.
테이블스페이스에 할당된 익스텐트 크기를 확인해보면 이유를 알 수 있다.

select extent_id, block_id, bytes, blocks
from dba_extents
where owner = USER
and   segment_name = 'T_PK'
and   tablespace_name = 'USERS'
order by extent_id;

EXTENT_ID  BLOCK_ID   BYTES  BLOCKS
--------- --------- ------- -------
        0     80993   65536       8
        1     81001   65536       8
        2     81009   65536       8
        3     81017   65536       8
        4     81025   65536       8
        5     81033   65536       8
        6     81041   65536       8
        7     81049   65536       8
        8     81057   65536       8

모든 익스텐트가 8개 블록으로 구성돼 있는 것이 원인이었다.
Multiblock I/O 방식은 익스텐트 범위를 넘지 못하니 모든 익스텐트에 20개 블록이 있고 db_file_multiblock_read_count 가 8이면, 익스텐트마다 8, 8, 4개씩 세 번 걸쳐 읽는다.

예상보다 더 많이 발생했지만 Single Block I/O 보다 훨씬 적은 양이 발생했다.

참고로 위 테스트는 9i이며, 10g 부터는 Index Range Scan 또는 Index Full Scan 일 때도 Multiblock I/O 방식으로 읽는 경우가 있는데, 위처럼 테이블 액세스 없이 인덱스만 읽고 처리할 때가 그렇다.
인덱스를 스캔하며 테이블을 Random 액세스할 때는 9i 이전과 동일하게 테이블, 인덱스 블록 모두 Single Block I/O 방식으로 읽는다.

Single block I/O 방식으로 읽은 블록들은 LRU 리스트 상 MRU 쪽으로 연결되어 한번 적재되면 버퍼 캐시에 비교적 오래 머무는 반면, Multiblock I/O 방식으로 읽은 블록들은 LRU 리스트에서 LRU 쪽에 연결되어 적재되고 얼마안가 밀려난다.

따라서 대량의 데이터를 Full Scan 했다고 해서 사용빈도가 높은 블록들이 버퍼 캐시에서 모두 밀려날 우려는 하지 않아도 된다.

Prefetch

본 절의 Prefetch 는 테이블 Prefetch와 인덱스 Prefetch를 자칭한다.

오라클을 포함한 모든 DBMS는 디스크 블록을 읽을 때 곧이어 읽을 가능성이 높은 블록일 미리 읽어오는 Prefetch 기능을 제공한다. 앞 절의 Multiblock I/O 도 Prefetch 기능 중 하나라고 할 수 있다.
테이블 Prefetch, 인덱스 Prefetch와 다른 점은 한 익스텐트에 속한 인접한 블록들을 Prefetch 한다는 점이다.

Prefetch는 한번에 여러 개 Single Block I/O를 동시 수행하는 것을 말한다. 지금 설명하는 테이블 Prefetch 와 인덱스 Prefetch는 인접하지 않은 블록, 즉 서로 다른 익스텐트에 위치한 블록을 배치 방식으로 미리 적재하는 것을 말한다. I/O Call 을 병렬 방식으로 동시에 여러 개 수행하는 것이므로 읽어야 할 블록들이 서로 다른 디스크 드라이브에 위치한다면 Prefetch에 의한 성능 향상은 배가 된다.

이 기능은 곧 읽을 가능성 높은 블록을 미리 적재할 때만 성능이 향상되며 그렇지 않으면 버퍼 캐시 효율만 나빠진다. Prefetch 된 블록들을 모니터링하는 기능은 CKPT 프로세스가 맡는다.

아래 쿼리를 참고하자.

select name, value from v$sysstat
where  name in ( 'physical reads cache prefetch',
                 'prefetched blocks aged out before use' );
                 
NAME                                  VALUE
------------------------------------- ------
physical reads cache prefetch          51094
prefetched blocks aged out before use     38

앞으로 읽을 블록을 미리 적재하는 기능이니 시스템 전반의 디스크 경합을 줄이기 보다, I/O를 위한 시스템 Call 을 줄이고 개별 쿼리의 수행 속도를 향상시키는데 도움을 준다.

서브시스템에 I/O Call 을 발생시키고 잠시 대기 상태에 빠지는데 어차피 쉬는데, 읽을 가능성 높은 블록을 미리 적재하면 대기 이벤트 횟수를 줄일 수 있다.

Prefetch 는 db file parallel read 대기 이벤트로 측정된다.
아래는 Prefetch 가 작동하는 상황을 10046 이벤트 트레이스로 모니터링한 것이다.

WAIT #2: nam='db file paralled read' ela= 1166 files=1 blocks=5 requests=5
obj#=79955 tim=245422093812
WAIT #2: nam='db file paralled read' ela= 14151 files=1 blocks=2 requests=2
obj#=79955 tim=245429134612
...

인덱스 Prefetch

오라클 7.2부터 나온 기능이다.

브랜치 블록에서 앞으로 읽게 될 리프 블록 주기를 미리 얻을 수 있으니 I/O Call이 필요한 시점에 미리 캐싱해두는 것이 가능하다.

위와 같이 ① -> ② -> ③ -> ④ 순으로 Index Range Scan을 진행한다고 하자. 2번 브랜치 블록을 읽고 5번 리프 블록을 읽는 과정에 5번 블록이 버퍼 캐시에 없으면 물리적 디스크 I/O가 필요하다. 이때 6, 7번 을 같이 적재하여 디스크 I/O로 인한 대기 가능성을 줄일 수 있다.

인덱스 Prefetch 기능이 효과적일 수 있는 상황은 Index Full Scan이 일어날 때다. 부분범위처리 방식으로 중간에 멈추지만 않으면 모든 인덱스 리프 블록을 읽게 되기 때문이다. 근데 Index Full Scan 시 Prefetch 방식으로 I/O 하려면 리프 블록 위쪽의 브랜치 블록들을 추가로 읽어야 하기 때문에 Prefetch 하지 않을 때보다 I/O 약간 더 발생한다.

아래는 인덱스 Prefetch 를 제어하는 파라미터다.

  • _index_prefetch_factor : 기본 값은 100이며, 이 값을 더 작게 설정할수록 옵티마이저는 인덱스 Prefetch를 더 선호하게 된다.
  • _db_file_noncontig_mblock_read_count : 한 번에 최대 몇 개 블록을 Prefetch 할지를 지정한다. 1로 지정되면 Prefetch 기능이 정지된다.

테이블 Prefetch

'테이블 Lookup Prefetch' 또는 '데이터 블록 Prefetch' 라고도 한다.

인덱스를 경유해 테이블 레코드를 액세스하는 도중 디스크에서 캐시로 블록을 적재해야 하는 상황이 발생할 수 있는데, 그때 다른 테이블 블록까지 미리 적재해 두는 기능이다.

리프 블록의 인덱스 레코드는 논리적 순서에 따라 읽는데, 도중에 디스크 I/O 가 필요해지면 현재 읽던 리프 블록 내에 앞으로 액세스할 데이터 블록 목록을 미리 취합할 수 있다.

위는 인덱스를 스캔하며 데이터 블록을 Random 액세스한다.
진행 순서는 ① -> ② -> ③ -> ④ -> ⑤ -> ⑥ -> ⑦ 순이다.

5번 인덱스 리프를 읽고 12번 테이블 블록을 읽으려는 시점에 12번 블록이 버퍼 캐시에 없으면 물리적 디스크 I/O가 필요한데, 이때 13, 15, 18번 블록을 같이 적재하면 5, 6, 7번 액세스 시에 디스크 I/O로 인한 대기를 하지 않아도 된다.

버퍼 Pinning 은 Random 액세스에 의한 논리적 블록 요청 횟수를 감소시키고, 테이블 Prefetch 는 디스크 I/O에 의한 대기 횟수를 감소시킨다.

이 기능은 인덱스 클러스팅 팩터가 나쁠 때 특히 효과를 발휘한다.
클러스터링 팩터가 나쁘면 논리적|디스크 I/O도 많이 발생하기 때문이다.

아래는 테이블 Prefetch 를 제어하는 파라미터이다.

  • _table_lookup_prefetch_size : 기본 값은 40
  • _table_lookup_prefetch_thresh : 기본 값은 2
  • _multi_join_key_table_lookup : 기본 값은 TRUE

Direct Path I/O

블록을 읽을 때, 버퍼 캐시에 찾아보고, 없으면 디스크에서 읽는다.
DBWR 프로세스가 주기적으로 변경된 블록들을 데이터파일에 기록한다.

시스템 전반 I/O 성능 향상을 위해 버퍼 캐시를 이용하지만 개별 프로세스는 대용량 데이터를 건건이 버퍼 캐시를 경유하면 성능이 나빠질 수 있다.

재사용 가능성 없는 임시 세그먼트 블록들을 읽고 쓸 때도 버펴캐시를 경유하지 않는 것이 유리하다. 오라클은 이럴 때 버퍼 캐시를 경유하지 않고 곧바로 데이터 블록을 읽고 쓸 수 있는 Direct Path I/O 기능을 제공한다.
아래는 Direct Path I/O가 작동하는 경우다.

  • Temp 세그먼트 블록들을 읽고 쓸 때
  • 병렬 쿼리로 Full Scan 수행할 때
  • nocache 옵션을 지정한 LOB 컬럼을 읽을 때
  • direct 옵션을 지정하고 export 를 수행할 때
  • parallel DML 을 수행할 때
  • Direct Path Insert 를 수행할 때

Direct Path Read/Write Temp

데이터 정렬에는 PGA 메모리에 할당되는 Sort Area 를 이용한다.
정렬할 데이터가 많아 Sort Area 가 부족해지면 Temp 테이블스페이스를 이용하는데, Sort Area에 정렬된 데이터를 Temp 테이블스페이스에 쓰고 이를 읽을 때 Direct Path I/O 방식을 사용한다.

이 과정에 I/O Call이 완료될 때까지 대기가 발생하는데, direct path write tempdirect path read temp 이벤트로 측정된다.

create table t as select * from all_objects;

alter session set workarea_size_policy = manual;

alter session set sort_area_size = 524288;

select *
from (
  select a.*, rownum no
  from (select * from t order by object_name) a
)
where no <= 10

Call     Count  CPU Time  Elapsed Time   Disk  Query  Current  Rows
------ ------- --------- -------------  ------ ------ ------- -----
Parse        1     0.00           0.00       0      0       0     0
Execute      1     0.00           0.00       0      0       0     0
Fetch        2     0.34           0.74     698    691      14    10
------ ------- --------- --------------  ------ ------ ------- -----
Total        4     0.26           0.74     698    691      14    10

Rows  Rows Source Operation
----- ------------------------------------------------------
   10  VIEW (cr=691 pr=698 pw=698 time=281516 us)
49867    COUNT (cr=691 pr=698 pw=698 time=1727596 us)
49867      VIEW (cr=691 pr=698 pw=698 time=1328642 us)
49867        SORT ORDER BY (cr=691 pr=698 pw=698 time=879823 us)
49867          TABLE ACCESS FULL T (cr=691 pr=0 pw=0 time=249381 us)

Elapsed times include waiting on following events:
 Event waited on              Time   Max. Wait   Total Waited
 --------------------------- Waited ---------- --------------
 SQL*Net message to client        2       0.00           0.00
 direct path write temp         384       0.01           0.02
 direct path read temp          486       0.03           0.36
 SQL*Net message from client      2       0.05           0.05 

Direct Path Read

병렬 쿼리로 Full Scan 수행할 때도 Direct Path Read 방식을 사용한다.
병렬도(DOP)를 2로 주고 수행한다고 2배 빨라지는 것이 아닌 그 이상의 빠른 수행속도를 보여주는 이유가 여기에 있다.
따라서 대용량 데이터를 읽을 때는 Full Scan과 병렬 옵션을 적절히 사용하여 시스템 리소스를 적게 사용하도록 하는 것이 좋다.

Direct Path Read 과정에서 읽기 Call 이 완료될 때까지 대기가 발생하는데, direct path read 이벤트로 측정된다.

버퍼캐시에만 기록된 변경사항이 데이터파일에 기록되지 않은 상태로 데이터 파일을 직접 읽으면 정합성 문제가 생기므로 병렬 Direct Path Read를 수행하려면 메모리와 디스크간 동기화를 먼저 수행하여 Dirty 버퍼를 해소해야 한다.

예전엔 병렬 쿼리 수행 시 체크포인트를 통해 버퍼 캐시 전체를 데이터파일에 기록했지만 10gR2 부터 병렬 쿼리와 관련된 세그먼트만 동기화를 수행한다.


Adaptive Direct Path Reads

병렬 쿼리가 아니어도 오라클 8.1.5 이후부터 Hidden 파라미터 _serial_direct_read 를 true로 변경하여 Direct Path Read 방식으로 읽도록 할 수 있다.

게다가 11g 부터는 이 파라미터가 false 인 상태에서도 Serial Direct PAth Read 방식이 작동할 수 있으며, 이미 캐싱돼 있는 블록 개수, 디스크에 기록해야 할 Dirty 블록 개수 등에 따라 결정한다.


Direct Path Write

Direct Path Write 는 병렬로 DML 을 수행하거나 Direct Path Insert 방식으로 데이터를 insert 할 때 사용된다. 이 과정에서 I/O Call 이 발생할 때마다 direct path wrtie 이벤트가 나타난다.

아래는 Direct Path Insert 방식으로 데이터를 입력하는 방법이다.

  • insert ... select 문장에 /*+ append */ 힌트 사용
  • 병렬 모드로 insert
  • direct 옵션을 지정하고 SQL*Loader로 데이터를 로드
  • CTAS(create table - as select) 문장을 수행

일반적인 insert 시에는 Freelist를 통해 데이터를 삽입할 블록을 할당받는다. Freelist 를 조회하면서 Random 액세스 방식으로 버퍼 캐시에서 해당 블록을 찾고, 없으면 데이터파일에서 읽어 캐시에 적재 후 데이터를 삽입하므로 대량의 데이터를 insert 할 때 느리다.

Direct Path Insert 시엔 Freelist를 참조하지 않고 테이블 세그먼트 또는 각 파티션 세그먼트의 HWM 바깥 영역에서 데이터를 순차적으로 입력한다. Freelist로부터 블록을 할당받는 작업이 생략될 뿐아니라 insert 할 블록을 버퍼 캐시에 적재하지않고 데이터 파일에 직접 insert 하므로 일반적인 insert 보다 훨씬 빠르다. HWN 바깥 영역에 데이터를 입력하므로 Undo 발생량도 최소화된다.
게다가 Redo 로그까지 최소화하도록 옵션을 줄 수 있어 더 빠른 insert 가 가능하다. 이 기능을 활성화하려면 테이블 속성을 nologging 으로 바꿔주면 된다.

alter table t NOLOGGING;

아래처럼 코딩하는 개발자가 있을 수 있는데 이는 무관한 기능이다.

insert into t NOLOGGING select * from test;

Direct Path Insert 가 아닌 일반 insert 문을 로깅하지 않도록 하는 방법은 없으며, 이 방식으로 데이터를 입력하면 Exclusive 모드 테이블 Lock 이 걸린다는 사실을 주의해야 한다.

alter session enable parallel dml;
delete /*+ parallel(b 4) */ from big_table b;	--> Exclusive 모드 TM Lock !!

성능을 매우 빨라지겠지만 Exclusive 모드 테이블 Lock을 사용하면 해당 테이블에서 다른 트랜잭션이 DML 을 수행하지 못하도록 만든다. 따라서 트랜잭션이 빈번한 주말에 이 옵션을 사용하는 것은 절대 금물이다.

RAC 캐시 퓨전

디비 동시 사용자가 많을 때 부하를 분산할 목적으로 여러 전략이 사용된다.

  1. 데이터베이스 서버 간 복제
    • 여러 대 디비 서버를 두고 각 서버의 트랜잭션 데이터를 상호 복제하는 방식
    • 실시간 동기화가 필요할 때는 복제 과정에서 발생하는 부하 때문에 실제 부하 분산 효과를 얻기 힘듦
  2. 업무별 수직 분할
    • 업무영역별로 디비를 따로 두고 각 테이블을 관리하며 다른 영역의 데이터는 분산 쿼리를 이용해 조회하는 방식
    • 분산 쿼리로 자주 액세스되는 공통 영역의 범위에 따라 성패가 좌우
  3. 데이터 구분에 따른 수평 분
    • 스키마는 같지만 데이터 구분에 따른 디비를 따로 가져가는 방식
    • 분할된 데이터 간 의존성이 낮을 때 성공적인 모델
    • 서버 간 데이터 이동이 발생할 때 처리 방법에 대한 모델 관점에서의 방안 필요

최근 데이터베이스를 다시 하나로 통합하고 이를 액세스하는 인스턴스를 여러 개 두고 공유 디스크 방식의 데이터베이스 클러스터링 기법이 도입되기 시작했다.

그 중 RAC 모델은 공유 디스크 방식을 기반으로 인스턴스 간에 버퍼 캐시까지 공유하는 캐시 퓨전 기술로 발전했다.

오라클 RAC는 캐시 퓨전 기술로 고가용성, 확장성, 부하 분산 등 성공을 거뒀으며 특히, 데이터를 하나의 데이터베이스에 통합 모델로 관리하여 높은 정합성을 유지할 수 있다는 것이 가장 큰 장점이다.

단점으로는 튜닝이 되지 않아 많은 블록 I/O 를 일으키는 애플리케이션에서 RAC를 도입하면 부하 분산은커녕 단일 인스턴스 환경보다 더 심각한 성능저하가 일어난다.

여러 인스턴스에 놓인 프로세스끼리 한 데이터를 동시에 읽고 쓰려는 경합이 심하게 발생하는데, 그런 동시에 액세스를 직렬화하려면 추가적인 동기화 메커니즘이 필요하고, 이 때문에 새로운 성능 이슈가 생긴다.
따라서 RAC 모델 특성상 발생하는 성능 문제들을 해결하려면 캐시 퓨전 프로세싱 원리를 이해해야 한다.

RAC 는 글로벌 캐시 개념을 사용한다. 즉, 클러스터링 된 모든 인스턴스 노드의 버퍼 캐시를 하나의 버퍼 캐시로 간주한다. 그래서 데이터 블록이 로컬 캐시에 없어도 다른 노드에 캐싱돼 있으면 디스크 I/O 를 일으키지 않고 가져와 읽고 쓸 수 있다.

모든 데이터 블록에 대한 마스터 노드가 정해져 있고, 그 노드를 통해 글로벌 캐시에 캐싱된 블록의 상태와 Lock 정보를 관리한다. 마스터 노드는 각 블록 주소의 해시 값에 의해 인스턴스가 기동되는 시점에 동적으로 정해진다.

캐시 퓨전 원리는 읽고자 하는 블록이 로컬 캐시에 없을 때 마스터 노드에 전송 요청을 하고, 마스터 노드는 해당 블록을 캐싱하고 있는 노드에 메시지를 보내 그 블록을 요청했던 노드에 전송하도록 지시하는 방식이다.
만약 어느 노드에도 캐싱돼있지 않으면 직접 디스크에서 읽도록 권한을 부여한다.


Current 블록은 디스크로부터 읽혀진 후 사용자의 갱신사항이 반영된 최종 상태의 원본 블록을 말하며, CR 블록은 Current 블록에 대한 복사본이다. CR 블록은 여러 버전이 존재할 수 있지만 Current 블록은 오직 한 개뿐이다.


RAC 환경에서 Current 블록은 Shared 모드 Current(SCur) 와 Exclusive 모드 Current(XCur)로 나뉜다.

SCur 상태 블록은 동시에 여러 노드를 캐싱할 수 있지만
XCur 상태 블록은 단 하나의 노드만 존재할 수 있다.
이는 RAC 성능 문제의 이해에 중요한 사실이다.

자주 읽는 데이터 블록을 각 노드가 SCur 모드로 캐싱하고 있을 때가 가장 효율적인 상태다. 하지만 그 중 한 노드가 XCur 모드로 업그레이드 요청하는 순간 다른 노드에 캐싱된 SCur 블록들이 모두 Null 모드로 다운그레이드 된다. 즉, 더 이상 쓸 수 없는 블록이 되는 것이다.

캐시 퓨전 원리는 RAC 노드간 버퍼 캐시를 공유하며 블록을 서로 주고받는 전송 매커니즘은 아래 5가지로 자세히 설명할 수 있다.

  • 전송 없는 읽기 : Read with No Transfer
  • 읽기/읽기 전송 : Read to Read Transfer
  • 읽기/쓰기 전송 : Read to Wrtie Transfer
  • 쓰기/쓰기 전송 : Write to Write Transfer
  • 쓰기/읽기 전송 : Write to Read Transfer

전송 없는 읽기 : Read with No Transfer

A 노드에서 K 블록을 읽으려 하는데, 어떤 노드에도 캐싱돼 있지 않은 상태다.
현재 K 블록의 SCN은 123이라 하자.

  1. K 블록을 읽으려하는 A 노드는 그 블록의 리소스 마스터인 B 노드에게 전송 요청을 보낸다. 이때 gc cr request 이벤트에 대기한다.
  2. B 노드는 현재 어느 노드에도 캐싱돼 있지 않음을 확인하고, A 노드에게 데이터파일에서 직접 블록을 SCur 모드로 읽도록 권한 부여
  3. A 노드는 디스크에서 블록을 읽어 로컬 캐시에 캐싱

읽기/읽기 전송 : Read to Read Transfer

A 노드만 K 블록을 SCur 모드로 캐싱된 상태에서 C 노드가 같은 K 블록을 SCur 모드로 읽으려 한다.

  1. C 노드는 리소스 마스터인 B 노드에게 K 블록에 대한 전송요청을 보낸다. 이때, gc cr request 이벤트를 대기한다.
  2. B 노드는 현재 K 블록을 A 노드가 캐싱하고 있음을 확인하고, C 노드에 블록을 전송해 주도록 A 노드에게 지시한다.
  3. A 노드는 C 노드에게 블록을 전송해 준다.
  4. C 노드는 블록을 성공적으로 전송받아 SCur 모드로 캐싱하게 되었음을 알리려고 마스터 노드인 B 에게 메시지를 보낸다.

읽기/쓰기 전송 : Read to Write Transfer

A, C 노드 모두 K 블록을 SCur 모드로 캐싱한다.
이제 C 노드가 K 블록을 XCur 모드로 업그레이드하고 해당 블록을 갱신하려 한다.

  1. 마스터 노드인 B 에게 K 블록을 XCur 모드로 업그레이드한다고 요청
  2. B 노드는 현재 K 블록을 A 노드도 캐싱하고 있음을 확인하고 Null 모드로 다운그레이드 하도록 지시
  3. A 노드는 C 노드에게 Null 모드로 다운그레디으 했음을 알림
  4. C 노드는 K 블록을 XCur 모드로 업그레이드하고 그 결과를 마스터 노드인 B에게 알림. 이때 A 노드에 캐싱된 블록이 Null 모드로 다운그레이드된 사실도 함께 알림.

이제 C 노드가 XCur 모드로 K 블록을 얻고 변경을 가하므로 블록 SCN은 증가하게 된다. 123에서 154로 증가했다고 하자.

쓰기/쓰기 전송 : Write to Write Transfer

현재 A 노드는 K 블록을 Null 모드로 갖고 있고, C 노드는 XCur 모드로 갖고 있다. C 노드가 갖고 있는 Current 버전의 SCN 은 154로 증가되었고, 데이터파일에 있는 블록 SCN 은 아직 123이므로 Dirty 버퍼 상태다.
이때, A 노드가 K 블록을 XCur 모드로 읽으려 한다. 물론 블록을 갱신하려는 것이다.

  1. 마스터 노드인 B에게 K 블록을 XCur 모드로 요청
  2. B 노드는 현재 K 블록을 C 노드가 XCur 모드로 캐싱하고 있음을 확인하고 A 노드에게 보내 주도록 지시
  3. C 노드는 A 노드에게 블록을 전송하고 자신이 갖고 있던 블록은 Null 모드로 다운그레이드 한다. C 노드가 갖고 있던 XCur 블록은 아직 커밋되지 않아 로우 Lock 이 걸린 상태일 수 있다.
  4. A 노드는 K 블록을 XCur 모드로 캐싱하게 됐음을 B 노드에게 알려줌.

다른 인스턴스가 갱신 중인 블록을 읽고자 할 때 로우 Lock이 해제될 때까지 기다리지 않고 블록을 주고 받는 다는 사실이 중요하다. 게다가, 예전 OPS에선 쓰기/쓰기 전송 상황에서 C 노드는 일단 블록을 디스크에 기록한다. 그럼 A 노드는 이를 디스크에서 읽는 방식을 사용했고 이처럼 디스크를 거쳐 블록을 주고받는 과정을 핑이라 불렀다.
RAC에선 디스크 동기화 없이 로우 Lock이 설정된 채로 버퍼 캐시 간 블록 전송이 가능해진 것이다.

A 노드가 XCur 모드로 K 블록을 얻고 변경을 가하므로 블록 SCN은 증가한다.
154에서 168로 증가했다고 하자.

쓰기/읽기 전송 : Write to Read Transfer

현재 노드는 K 블록을 XCur 모드로 갖고 있고, C 노드는 Null 모드로 갖고 있다. A 노드가 갖고 있는 Current 버전의 SCN은 168로 증가됐고, 데이터파일에 있는 블록 SCN 은 여전히 123이므로 Dirty 버퍼 상태다. 이때 C 노드가 K 블록을 SCur 모드로 읽으려 한다.

  1. 마스터 노드인 B 에게 K 블록을 SCur 모드로 요청
  2. B 노드는 현재 K 블록을 A 노드가 XCur 모드로 캐싱하고 있음을 확인하고, C 노드에게 보내 주도록 지시
  3. A 노드는 C 노드에게 블록을 전송하고 자신이 갖고 있던 블록은 SCur 모드로 다운그레이드 한다.
  4. C 노드는 K 블록을 SCur 모드로 캐싱하게 됐음을 B 노드에게 알린다. 이때, A 노드에게 캐싱돼 있던 블록이 SCur 모드로 다운그레이드된 사실도 함께 알린다.

3, 4번 과정이 복잡한데, K 블록의 커밋 여부에 따라 다르다.
아직 커밋되지 않았다면 Current 블록을 전송하지 않고 계속 CR Copy 를 만들어 전송한다. C 노드는 읽기 작업을 원하니 굳이 Current 블록을 보낼 필요가 없다. 현재 A 노드에서 갱신이 진행 중이니 Current 블록을 보내면 언젠가 다시 가져와야 하는 부담 때문에 그런 것이다.

K 블록이 커밋된 상태라도 위 설명과 같이 Current 블록을 보내지 않는다. 처음엔 CR Copy 만 전송하다가 일정 횟수 이상 요청이 반복되면 그때 Current 블록을 보낸다. Current 블록을 보내주려면 자신의 XCur 모드를 SCur 모드로 다운그레이드 해야 하는데, 곧이어 갱신이 다시 발생하면 XCur 모드로 또 다시 업그레이드 해야하는 상황이 나온다. 그때는 SCur 블록을 가져간 다른 노드도 모두 Null 모드로 다운그레이드 해야 하므로 이런 일이 자주 발생하면 RAC 부하가 증가한다. 이런 부하 발생 가능성을 최소화하려고 일정 횟수만큼 CR Copy 만을 보내주는 방식을 사용하는 것이다.

CR Copy 를 보내주는 횟수는 _fairness_threshold 파라미터에 의해 결정되며, 기본 값은 4로 설정돼 있다.
커밋된 XCur 블록을 보유하고 있는 노드는 블록 전송 요청을 받을 때마다 CR Copy를 만들어 전송하고 fairness_count 값을 1씩 증가 시킨다. fairness_count 값이 4에 도달하면 Redo 로그 버퍼를 비우고 XCur 를 SCur 로 다운그레이드 한다. 이제 SCur 상태이므로 이후에 읽기 요청을 보낸 노드는 곧바로 SCur 모드로 블록을 전송받게 된다.

주로 읽기 작업 위주로 수행하면 _fairness_threshold 파라미터를 낮게 설정하는 것이 성능 향상에 도움이 될 수 있다. 0으로 설정한다면 CR Copy 전송 없이 곧바로 SCur 모드로 다운그레이드하고 Current 블록을 전송하게 된다. XCur 모드를 SCur 로 다운그레이드 했다 다시 XCur 로 업그레이드 할 가능성이 적다면 읽기 요청이 반복되는 블록들을 가급적 빨리 SCur 모드로 보내주는 것이 좋다.

아래 쿼리를 수행했을 때 다운그레이드 Ratio 가 높다면, 결국 Current 모드로 공유할 수 밖에 없음에도 CR을 만들어 보내준다는 의미이다.

select data_requests, fairness_down_converts
     , round(fairness_down_converts / data_requests * 100) "DOWNGRADE RATIO (%)"
from   v$sc_block_server

쓰기/읽기 캐시 퓨전 원리를 이해해야 RAC 구성 데이터를 가공하는 노드와 읽는 노드를 분리하는 것이 성능에 얼마나 좋은지 예상할 수 있다.


dynamic remastering

리소스 친화도(Resource Affinity)에 따라 마스터 노드가 동적으로 변경될 수 있다. 예로 A 노드가 ownership을 갖는 리소스를 B 노드가 반복적으로 요청한다면 어느 순간부터 그 리소스에 대한 마스터 노드가 B로 바뀔 수 있는 것이다. 자주 사용하는 리소스에 대한 상태 정보를 자신이 직접 관리하므로 RAC 성능 향상에 도움을 준다.


디스크 I/O 관련 대기 이벤트가 증가하는 만큼 RAC 관련 이벤트도 같이 증가하는 것을 볼 수 있다. 그래서 블록 읽기 요청 횟수를 줄여 인터커넥트를 통한 데이터 전송량을 감소시키는 것이 확실한 해결책이며 이는 SQL 튜닝으로 달성할 수 있다.

Result 캐시

Result 캐시는 버퍼 캐시에 위치하고 Shared Pool에 위치하지만 시스템 I/O 발생량을 최소화하는데 도움되는 기능이다.

DB 버퍼 캐시는 자주 사용될 블록을 캐싱하므로 작은 테이블을 메모리 버퍼 캐시에서 읽더라도 반복 액세스가 많이 일어나면 좋은 성능을 기대하기 어렵다. 버퍼 캐시 히트율이 낮을 수 밖에 없는 대용량 데이터 쿼리면 더더욱 I/O 효율화를 위한 튜닝이 곤란한데, 집계 테이블을 따로 설계하거나 Materialized View 를 생성하는 것 외에 별다른 I/O 효율화 방안이 없는 경우가 있다.

이에 오라클은 한번 수행한 쿼리 또는 PL/SQL 함수의 결과값을 Result 캐시에 저장해두는 기능을 11g 부터 제공하기 시작했다.

  1. DML이 거의 발생하지 않는 테이블을 참조하면서
  2. 반복 수행 요청이 많은 쿼리에 이 기능을 사용하여

I/O 발생량을 현격히 감소시킬 수 있다.

Result 캐시 메모리는 다음 두 가지 캐시 영역으로 구성된다.

  • SQL Query Result 캐시 : SQL 쿼리 결과를 저장
  • PL/SQL 함수 Result 캐시 : PL/SQL 함수 결과값을 저장

Result 캐시를 위해 추가된 파라미터를 살펴보자.

구분기본값설명
result_cache_modemanualResult 캐시 등록 방식을 결정
● manual : result_cache 힌트를 명시한 SQL만 등록
● force : no_result_cache 힌트를 명시하지 않은 모든 SQL을 등록
result_cache_max_sizeN/ASGA 내에서 result_cache가 사용할 메모리 총량을 바이트로 지정. 0으로 지정 시 이 기능이 작동하지 않음
result_cache_max_result5하나의 SQL 결과집합이 전체 캐시 영역에서 차지할 수 있는 최대 크기를 %로 지정
result_cache_remote_expiration0remote 객체의 결과를 얼마 동안 보관할지를 분 단위로 지정.
remote 객체는 result 캐시에 저장하지 않도록 하려면 0으로 설정.

result_cache_max_size 를 DB 관리자가 직접 지정하지 않으면, 아래 규칙으로 자동 할당한다.

  • SGA와 PGA를 통합 관리하는 11g 방식으로 SGA 메모리를 관리하면, memory_target 으로 설정된 값의 0.25% 를 Result 캐시를 위해 사용한다.
  • sga_target 파라미터를 사용하는 10g 방식으로 SGA 메모리를 관리하면, 그 값의 0.5%를 Result 캐시를 위해 사용한다.
  • 과거처럼 shared_pool_size 를 수동으로 설정하면 그 값의 1%를 Result 캐시를 위해 사용한다.
  • 어떤 방식을 사용하든 Result 캐시가 사용할 수 있는 최대 크기는 Shared Pool 의 75% 넘지 않도록 오라클이 관리한다.

Result 캐시는 SGA의 Shared Pool 에 저장된다. SGA 영역이므로 모든 세션에서 공유할 수 있고, 인스턴스를 재기동하면 당연히 초기화 된다. 공유 영역에 위치하므로 래치가 필요한데, 11g 에선 아래 두 가지 래치가 추가되었다.

  • Result Cache : Latch
  • Result Cache : SO Latch

지금부터 이 기능의 사용법을 보자. Force 모드일 때는 no_result_cache 힌트를 사용하지 않은 모든 SQL을 대상으로 캐싱을 시도하니 Manual 모드를 중심으로 설명한다.

Manual 모드에서 쿼리 결과를 캐싱하려면 다음과 같이 result_cache 힌트를 사용하면 된다.

SELECT /*+ RESULT_CACHE */ COL, COUNT(*)
FROM   R_CACHE_TEST
WHERE  GUBUN = 7
GROUP BY COL

힌트가 지정된 쿼리를 수행할 때 오라클 서버 프로세스는 Result 캐시 메모리를 먼저 찾아보고, 캐싱돼 있으면 그것을 가져다 결과 집합을 리턴한다. 캐시에서 찾지 못 할 때만 쿼리를 수행해 결과를 리턴하고, Result 캐시에도 저장해 둔다.

Result 캐시에서 결과 집합을 찾았을 때는 실제 쿼리를 수행하지 않기 때문에 블록 I/O가 전혀 발생하지 않는다. 직접 수행한 쿼리를 보자.

SELECT /*+ RESULT_CACHE */ COL, COUNT(*)
FROM   R_CACHE_TEST
WHERE  GUBUN = 7
GROUP BY COL

Call     Count  CPU Time  Elapsed Time   Disk  Query  Current  Rows
------ ------- --------- -------------  ------ ------ ------- -----
Parse        1     0.00           0.02       0      0       0     0
Execute      1     0.00           0.00       0      0       0     0
Fetch        4     1.00           4.98    9446 104332       0    24
------ ------- --------- --------------  ------ ------ ------- -----
Total        6     1.00           5.00    9446    691       0    24

Rows    Rows Source Operation
------- ------------------------------------------------------
      0  STATEMENT
     24    RESULT CACHE? (cr=104332 pr=9446 ...)
     24      HASH GROUP BY (cr=104332 pr=9446 ...)
1000000        TABLE ACCESS BY INDEX ROWID SRC_TEST (cr=104332 pr=9446 ...)
1000000          INDEX RANGE SCAN SRC_TEST_X01 (cr=4351 pr=4351 pw=4351 time=14635 ...)

처음 쿼리를 실행하자 104,332개의 블록 I/O가 발생했다. Result 캐시에 등록된 쿼리 목록과 사용 현황은 v$result_cache_objects 를 통해서 확인해 볼 수 있다. 아래는 이 뷰를 통해 방금 수행한 쿼리의 캐싱 여부를 조회한 것이다.

TYPE       STATUS     NAME                                    NAMESPACE  SCAN COUNT
---------- --------- ---------------------------------------- ---------- -----------
Dependency Published ORAKING.R_CACHE_TEST                                 0
Result     Published SELECT /*+ RESULT_CACHE */ COL, COUNT(*) ... SQL     0

위 결과를 보고 Result 캐시에 등록됐음을 알 수 있다. 이제 쿼리를 다시 수행해 보자.

SELECT /*+ RESULT_CACHE */ COL, COUNT(*)
FROM   R_CACHE_TEST
WHERE  GUBUN = 7
GROUP BY COL

Call     Count  CPU Time  Elapsed Time   Disk  Query  Current  Rows
------ ------- --------- -------------  ------ ------ ------- -----
Parse        1     0.00              0       0      0       0     0
Execute      1     0.00              0       0      0       0     0
Fetch        4     0.00              0       0      0       0    24
------ ------- --------- --------------  ------ ------ ------- -----
Total        6     0.00              0       0      0       0    24

Rows Rows Source Operation
---- ------------------------------------------------------
   0  STATEMENT
  24    RESULT CACHE? (cr=0 pr=0 ...)
   0      HASH GROUP BY (cr=0 pr=0 ...)
   0        TABLE ACCESS BY INDEX ROWID SRC_TEST (cr=0 pr=0 ...)
   0          INDEX RANGE SCAN SRC_TEST_X01 (cr=0 pr=0 pw=0 time=0 ...)

캐시의 결과를 사용하여 위와 같이 블록 I/O가 0으로 나타나는 것을 볼 수 있다. 이처럼 블록 I/O 발생 횟수를 줄여주기 때문에 같은 SQL 을 반복 수행할 때 성능을 향상시켜 준다.

단, 아래 경우엔 쿼리 결과집합을 캐싱하지 못한다.

  • Dictional 오브젝트를 참조할 때
  • Temproray : 테이블을 참조할 때
  • 시퀀스로부터 CURRVAL, NEXTVAL, Presudo 컬럼을 호출할 때
  • 쿼리에서 아래 SQL 함수를 사용할 때
    • CURRENT_DATE
    • CURRENT_TIMESTAMP
    • LOCAL_TIMESTAMP
    • SYS_CONTEXT(with non-constant variables)
    • SYS_GUID
    • SYSDATE
    • SYSTIMESTAMP
    • USERENV(with non-constant variables)

애플리케이션에선 대부분 바인드 변수를 사용하는데, 이때는 각 바인드 변수 값에 따라 개별 캐싱이 이루어진다. 아래는 같은 바인드 변수에 다른 값을 입력하면서 SQL를 두 번 실행한 후 r$result_target_cachedo 뷰를 조회하면 다음과 같다.

NAME                                                          SCAN COUNT
------------------------------------------------------------- -----------
select /*+ result_cache */ * from r_cache_test where key = :x           1
select /*+ result_cache */ * from r_cache_test where key = :x           1

만약 바인드 변수 값의 종류가 다양하고 그 값들이 골고루 입력된다면 Result 캐시 영역이 특정 SQL로 가득 채워지는 일이 발생할지도 모른다. 하나의 쿼리 결과 집합을 캐싱하려면 다른 캐시 엔트리를 밀어내야 하므로 캐시에 등록되었다가 밀려나는 일이 빈번하게 발생할수록 캐시의 효율성은 떨어진다. 따라서 변수 값의 종류가 다양하고 수행 빈도가 높은 쿼리를 Result 캐시에 등록하는 것은 삼가애햐 한다. 함수 Result 캐시 기능도 마찬가지다.

그나마 위와 같은 상황에서 사용 빈도가 높은 캐시 엔트리들을 보호하고 Result 캐시의 효율성을 높일 목적으로 LRU 알고리즘을 사용해 관리한다.

오라클은 캐싱된 쿼리가 참조하는 테비을에 변경이 발생하면 해당 캐시 엔트리를 무효화시킴으로써 쿼리 결과에 대한 정합성을 보장한다. 만약 쿼리에서 두 테이블을 참조한다면, 둘 중 하나에 DML이 발생하는 순간 캐싱된 결과 집합에 영향을 미치지 않더라도 예외없이 캐시를 무효화시켜 버린다는 사실을 기억할 필요가 있다. 예로 아래 쿼리 결과가 캐싱되었다고 하자.

select /*+ result_cache */ * from emp where ename = 'SCOTT'

이때, 아래처럼 위 결과 집합과 무관한 레코드를 삽입하더라도 위에서 캐싱된 결과 집합은 무효화된다.

insert into emp (empno, ename, ...) values (7935, 'EDWARD', ...);
commit; --> 이때 무효화됨

파티션 테이블에 DML이 발생할 때도 변경이 발생한 파티션과 무관한 파티션을 참조하는 쿼리 결과 집합까지 무효화시킨다. 즉, 2월 파티션에 DML이 발생하면 1월 파티션을 참조하는 쿼리의 캐시까지 무효화된다.

함수 Result 캐시도 함수에서 참조하는 테이블에 변경이 발생하면 무효화된다. 아래는 함수 결과를 캐싱하도록 하는 예제다.

create or replace function get_team_name (p_team_cd number)
return varchar2
RESULT_CACHE RELIES_ON (r_cache_function)
is
  l_team_name r_cache_function.team_name%type;
begin
  select team_name into l_team_name
  from   r_cache_function
  where  team_cd = p_team_cd;
  return l_team_name;
end;

result_cache 옵션을 사용하면 되고, relies_on 절에 지정된 테이블에 변경이 발생할 때마다 캐싱된 함수 결과 값이 무효화된다. relies_on 절에 지정하지 않은 테이블에 DML이 발생하면 함수가 무효화되지 않아 잘못된 결과를 리턴할 수 있으므로 주의가 필요하다.

따라서 DML이 자주 발생하는 테이블을 참조하는 쿼리나 함수를 캐싱하도록 하는 것은 시스템 부하를 오히려 가증시킬 수 있다. DML이 발생할 때마다 캐시를 관리하는 비용이 추가되고 그 과정에서 래치 경합도 많이 발생하기 때문이다. 그리고 result_cache 힌트를 사용한 쿼리를 수행할 때마다 Result 캐시를 탐색하는 비용이 추가로 발생하는데, 히트율이 높다면 상관없지만 DML이 자주 발생해 히트율이 낮아진다면 쿼리 수행 비용을 더 높게 만드는 요인으로 작용한다.

여러 개 쿼리 블록을 서로 연결해 최종 결과 집합을 완성하는 복잡한 형태의 쿼리에서, 특정 쿼리 블록만 캐싱할 수 있다면 Result 캐시의 활용성은 매우 높아질 수 있다. 다행히 오라클은 이 기능도 제공한다.

몇 가지 패턴으로 나뉘어 보자.

먼저, 아래처럼 인라인 뷰에만 result_cache 힌트를 사용해보자.

select *
from   r_cache_test t1,
     ( SELECT /*+ RESULT_CACHE */ ID FROM R_CACHE_TEST2
       WHERE ID = 1 ) t2
where  t1.id = t2.id

인라인 뷰 쿼리만 독립적으로 캐싱된다.

WITH 구문도 살펴보자.

with wv_test
as ( SELECT /* RESULT_CACHE MATERIALIZE */ SUM(ID) RNUM, ID
     FROM R_CACHE_TEST
     GROUP BY ID )
select *
from   r_cache_test t1,
       wv_test t2
where  t1.id = t2.rnum

마찬가지로 독립적으로 캐싱이 가능하다.

union all 을 이용해 2개 커리의 합집합을 구하는 경우를 보자.

select sum(val)
from ( select sum(c) val
       from   ext_stat_test
       union all
       SELECT /*+ RESULT_CACHE */ SUM(ID+SUM_DATA)
       FROM   R_CACHE_TEST
     ) 

위 쿼리에서 대문자로 작성한 쿼리 부분만 별도로 캐싱된다.

DML이 자주 발생하는 테이블을 참조하는 쿼리는 Result 캐시 대상으로 부적합하다 했는데, 쿼리가 union all 형태라면 DML 발생 여부에 따라 각 집합별로 캐싱 여부를 선택해 줄 수 있다.

만약 전체 결과를 캐싱하고 싶다면 바깥 select 절에 힌트를 사용하면 된다.
아래처럼 where 절에 사용된 서브쿼리만 캐싱하는 기능은 제공되지 않는다.

select *
from   r_cache_test
where  id = ( SELECT /*+ RESULT_CACHE */ ID
              FROM R_CACHE_TEST2
              WHERE ID = 1 )

Result 캐시는 DW 뿐 아니라 OLTP 환경에서도 잘 활용하면 반복적인 I/O 요청횟수를 줄이는 데 기여할 수 있다.
이 기능이 효과적이려면 기본적으로 쿼리 빈도가 높아야 하며, 아래와 같은 상황에 효과가 배가 될 것이다.

  • 작은 결과 집합을 얻으려고 대용량 데이터를 읽어야 할 때
  • 읽기 전용의 작은 테이블을 반복적으로 읽어야 할 때
  • 읽기 전용 코드 테이블을 읽어 코드명칭을 반환하는 함수

아래의 경우엔 Result 캐시 기능 사용을 자제해야 한다.

  • 쿼리가 참조하는 테이블에 DML이 자주 발생
  • 함수 또는 바인드 변수를 가진 쿼리에서 입력 값의 종류가 많고, 그 값들이 골고루 입력될 때

이 기능들은 SGA에 결과 집합을 저장하는 '서버 측 Result 캐시' 기능이며 클라이언트 메모리에 결과 집합을 저장하는 '클라이언트 측 Result 캐시' 기능도 제공되므로 따로 기능을 숙지하면 좋다.

I/O 효율화 원리

하드웨어적인 방법으로 I/O 성능을 향상시킬 수도 있다.
RAW 디바이스, 비동기 I/O 를 사용하거나 스트라이핑 방식을 달리하거나, 고대역폭 인터커넥트를 사용하는 등의 방법이 있다.

하지만 이런 내용은 애플리케이션 측면의 노력만큼 효과가 크지 않다. SQL 튜닝을 통한 I/O 발생 횟수를 줄이는 것이 더 확실항 해결 방법이다.

애플리케이션 측면의 I/O 효율화 원리는 다음으로 요약할 수 있다.

  • 최소 블록만 읽도록 쿼리 작성
  • 최적의 옵티마이징 팩터 제공
  • 필요하면, 옵티마이저 힌트를 사용해 최적의 액세스 경로로 유도

최소 블록만 읽도록 쿼리 작성

SQL 명령을 던지는 사용자 스스로 최소 일량을 요구하는 형태로 논리적인 집합을 정의하고, 효율적인 처리가 가능하도록 쿼리를 작성하는 것이 중요하다.

아래는 웹 게시판 구현할 때 흔히 볼 수 있는 쿼리다.

SELECT *
FROM  (
       SELECT ROWNUM NO, 등록일자, 번호, 제목,
              회원명, 게시판유형명, 질문유형명, 아이콘, 댓글개수
       FROM (
              SELECT A.등록일자, A.번호, A.제목, B.회원명, C.게시판유형명, D.질문유형명,
                     FUNC_ICON(D.질문유형코드) 아이콘, ( SELECT ... ) 댓글개수
              FROM   게시판 A, 회원 B, 게시판유형 C, 질문유형 D
              WHERE  A.게시판유형 = :TYPE
              AND    B.회원번호 = A.작성자번호
              AND    C.게시판유형 = A.게시판유형
              AND    D.질문유형 = A.질문유형
              ORDER BY A.등록일자 DESC, A.질문유형, A.번호
       )
       WHERE ROWNUM <= 30
)
WHERE NO BETWEEN 21 AND 30

위는 성능면에서 불합리한 요소들이 있다.

  1. 화면 출력 대상이 아닌 게시물에 대한 아이콘도 찾으며 댓글개수를 세는 스칼라 서브쿼리를 수행
  2. 회원, 게시판유형, 질문유형 테이블과 조인하는 부분, 출력 대상 집합을 확정 짓고 난 후에 조인해도 되는 테이블들이다.

아래처럼 쿼리를 바꾼다면 처리할 일량을 획기적으로 줄일 수 있다.

SELECT /*+ ORDERED USE_NL(B) USE NL(C) USE NL(D) */
       A.등록일자, B.번호, A.제목, B.회원명, C.게시판유형명, D.질문유형명
     , FUNC_ICON(D.질문유형코드) 아이콘, ( SELECT ... ) 댓글개수 
     -- 최종 결과 집합 10건에 대해서만 함수 호출 및 스칼라 서브 쿼리 수행
FROM (
       SELECT A.*, ROWNUM NO
       FROM (
              SELECT 등록일자, 번호, 제목, 작성자번호
                   , 게시판유형, 질문유형
              FROM   게시판
              WHERE  게시판유형 = :TYPE
              AND    작성자번호 IS NOT NULL
              AND    게시판유형 IS NOT NULL
              AND    질문유형 IS NOT NULL
              ORDER BY 등록일자 DESC, 질문유형, 번호
            ) A
       WHERE ROWNUM <= 30
      ) A, 회원 B, 게시판유형 C, 질문유형 D
WHERE A.NO BETWEEN 21 AND 30
-- 최종 결과 집합 10건에 대해서만 NL 조인 수행
AND   B.회원번호 = A.작성자번호
AND   C.게시판유형 = A.게시판유형
AND   D.질문유형 = A.질문유형

한가지 사례를 더 보자.

아래는 '일별종목거래' 테이블 이용해 종목별로 전입, 주간, 전월, 연중 거래 현황을 집계하는 쿼리다. '일별종목거래' 테이블의 PK는 종목코드와 거래일자 컬럼으로 구성된다.

select a.종목코드
     , a.거래량 전일_거래량, a.거래대금 전일_거래대금
     , a.상한가 전일_상한가, a.하한가 전일_하한가
     , b.거래량 주간_거래량, b.거래대금 주간_거래대금
     , c.거래량 전월_총거래량, c.거래대금 전월_총거래대금
     , d.시가총액 전월말_시가총액
     , e.상한가 연중_상한가, e.하한가 연중_하한가
from (select 종목코드, 거래량, 거래대금, 상한가, 하한가
      from   일별종목거래
      where  거래일자 = to_char(sysdate-1, 'yyyymmdd') ) a
   , (select 종목코드, sum(거래량) 거래량, sum(거래대금) 거래대금
      from   일별종목거래
      where  거래일자 between to_char(sysdate-7, 'yyyymmdd')
                     and     to_char(sysdate-1, 'yyyymmdd')
      group by 종목코드) b
   , (select 종목코드, sum(거래량) 거래량, sum(거래대금) 거래대금
      from   일별종목거래
      where  거래일자 like to_char(add_months(sysdate, -1), 'yyyymm') || '%'
      group by 종목코드) c
   , (slect 종목코드, 상장주식수 * 종가 시가총액
      from  일별종목거래
      where 거래일자 = to_char(last_day(add_months(sysdate, -1)), 'yyyymmdd') ) d
   , (select 종목코드, max(거래량) 거래량, max(거래대금) 거래대금
           , max(종가) 상한가, min(종가) 하한가
      from   일별종목거래
      where  거래일자 between to_char(add_months(sysdate, -12), 'yyyymmdd')
                     and     to_char(sysdate-1, 'yyyymmdd')
      group by 종목코드) e
where b.종목코드(+) = a.종목코드
and   c.종목코드(+) = a.종목코드
and   d.종목코드(+) = a.종목코드
and   e.종목코드(+) = a.종목코드

위 쿼리는 전일, 주간, 전월, 연중 거래, 시가총액으로 총 5번 조인한다. 이는 불필요하게 반복하여 읽게되는 부분들이 있다.

쿼리를 다음과 같이 작성하면 1년치 데이터 한번만 읽고도 전일, 주간, 전월, 전월 말일 거래 현황 모두 구할 수 있다.

select 종목코드
     , sum(case when 거래일자 = to_char(sysdate-1, 'yyyymmdd')
                then 거래량 end) 전일_거래량
     , sum(case when 거래일자 = to_char(sysdate-1, 'yyyymmdd')
                then 거래대금 end) 전일_거래대금
     , max(case when 거래일자 = to_char(sysdate-1, 'yyyymmdd')
                then 종가 end) 전일_상한가
     , min(case when 거래일자 = to_char(sysdate-1, 'yyyymmdd')
                then 종가 end) 전일_하한가
     , sum(case when 거래일자 between to_char(sysdate-7, 'yyyymmdd')
                             and     to_char(sysdate-1, 'yyyymmdd')
                then 거래량 end) 주간_거래량,
     , sum(case when 거래일자 between to_char(sysdate-7, 'yyyymmdd')
                             and     to_char(sysdate-1, 'yyyymmdd')
                then 거래대금 end) 주간_거래대금
     , sum(case when 거래일자 like to_char(add_months(sysdate, -1), 'yyyymm') || '%'
                then 거래량 end) 전월_거래량
     , sum(case when 거래일자 like to_char(add_months(sysdate, -1), 'yyyymm') || '%'
                then 거래대금 end) 전월_거래대금
     , sum(case when 거래일자 =
                     to_char(last_day(add_months(sysdate, -1)), 'yyyymmdd')
                then 상장주식수 * 종가 end) 전월말_시가총액
     , max(거래량) 연중_최대거래량
     , max(거래대금) 연중_최대거래대금
     , max(종가) 연중_상한가
     , min(종가) 연중_하한가
from 일별종목거래
where 거래일자 between to_char(add_months(sysdate, -12), 'yyyymmdd')
              and     to_char(sysdate-1, 'yyyymmdd')
group by 종목코드
having sum(case when 거래일자 = to_char(sysdate-1, 'yyyymmdd') then 거래량 end) > 0

최적의 옵티마이징 팩터 제공

앞의 두 사례는 논리적인 집합 재구성으로 최소화하였다.
하지만 오라클이 이를 처리하는데 있어 사용자 의도대로 블록 액세스를 최소화하며 효율적인 쿼리 프로세싱을 할 수 있도록 하려면 최적의 옵티마이징 팩터를 제공해 주어야 한다.

전략적인 인덱스 구성

전략적인 인덱스 구성은 옵티마이저를 돕는 가장 기본적인 옵티마이징 팩터다.

DBMS가 제공하는 다양한 기능 활용

1억 건 중 100만 건을 출력할 때, 인덱스만 제공하고 1분만에 결과가 나오길 바라는 것은 옵티마이저에게 무리한 요구이다.

파티션, 클러스터, IOT, MV, FBI, 분석 함수 등 DBMS가 제공하는 기능들을 적극활용하면 옵티마이저에게 강력한 무기가 된다.

옵티마이저 모드 설정

강력한 무기를 갖춰도 전략과 목표가 뚜렷하지 않으면 효과적이지 않을 수 있다.

옵티마이저에게 명령을 내릴 때 전체 레코드를 다 읽을 지, 아니면 10건만 읽고 멈출지 목적을 분명히 밝혀야 한다.
예를 살펴보자.

create table t
as
select * from all_objects
order by dbms_random.value;

테이블이 생성되었습니다.

create index t_idx on t(owner, created);

인덱스가 생성되었습니다.

begin
  dbms_stats.gather_table_stats
  ( ownname		--> USER
  , tabname		--> 'T'
  , estimate_percent	--> 100
  , block_sample	-->true
  , method_opt		--> 'for all columns size auto'
  );
end;
/

처리가 정상적으로 완료되었습니다.

set autotrace traceonly exp

alter session set optimizer_mode = 'ALL_ROWS';

세션이 변경되었습니다.

select * from t
where owner = 'SYS'
order by created ;

--------------------------------------------------------------------------
| ID | Operation           | Name | Rows | Bytes | TempSpc | Cost (%CPU) |
--------------------------------------------------------------------------
|  0 | SELECT STATEMENT    |      | 1921 | 174K  |         | 202 (2)     |
|  1 |  SORT ORDER BY      |      | 1921 | 174K  | 488K    | 202 (2)     |
|* 2 |   TABLE ACCESS FULL | T    | 1921 | 174K  |         | 158 (2)     |
--------------------------------------------------------------------------

사용자가 옵티마이저 모드를 all_rows 로 지정했다. 정렬된 결과 집합을 전체를 Fetch 할 것이므로 거기에 따라 최적화 수행하도록 목적을 밝힌 것이다.
따라서 옵티마이저는 owner, created 순으로 정렬된 인덱스가 있더라도 그것을 사용하지 않고 테이블을 Full Scan 한 후에 정렬하는 방식을 택했다.
다량의 데이터를 인덱스로 경유해 Random 액세스하는 부하를 최소화하고자 함이다.

옵티마이저 모드를 first_rows 로 바꾸고 다시 수행해보자.

alter session set optimizer_mode = 'FIRST_ROWS' ;

세션이 변경되었습니다.

select * from t
where owner = 'SYS'
order by created ;

| ID | Operation                         | Name  | Rows | Bytes | Cost (%CPU) |
-------------------------------------------------------------------------------
|  0 | SELECT STATEMENT                  |       | 1921 | 174K  | 1870 (2)    |
|  1 |  TABLE ACCESS BY INDEX ROWID      | T     | 1921 | 174K  | 1870 (2)    |
|* 2 |   INDEX RANGE SCAN                | T_IDX | 1921 |       |    8 (2)    |
-------------------------------------------------------------------------------

같은 쿼리를 수행했는데, 옵티마이저 모드를 바꾸자 인덱스를 이용하는 방식이 바뀌었다.

first_rows 모드는 전체 결과 집합에서 처음 일부 레코드만 Fetch 하다가 멈출 것임을 시사하는 것이다.
따라서 옵티마이저는 Random 액세스가 많지 않을 것으로 믿고 정렬 작업을 따로 수행하지 않아도 된다.

만약 이 상태에서 쿼리 결과를 끝까지 Fetch 한다면 Full Scan으로 처리할 때보다 더 느려지고 시스템 리소스도 낭비하는 결과를 초래한다. 하지만 옵티마이저에게는 잘못이 없으며 옵티마이저 모드를 잘못 설정한 사용자 실수다.

옵티마이저 모드 외에도 옵티마이저 행동에 영향을 미치는 파라미터들이 몇 가지 있다.
대개 기본 값으로 나눠도 상관없지만 몇몇 파라미터는 애플리케이션 특성에 따라 조정이 필요하다.

통계정보의 중요성

마지막으로 dbms_stats_gather_table_stats 프로시저를 이용해 T 테이블에 대한 오브젝트 통계를 수집하는 것을 바로 앞 단계에서 보았다. 오브젝트 통계 수접 전에 시스템 통계도 미리 수집돼 있어야 한다.

아래는 _dbms_stats_gather_system_stats` 프로시저를 이용해 시스템 통계를 수집했을 때 구해지는 통계 항목들이다.

  • CPU 속도
  • 평균적인 Single Block 읽기 속도
  • 평균적인 Multiblock 읽기 속도
  • 평균적인 Multiblock I/O 개수

정리해보자면,

  1. 옵티마이저 모드를 포함해 적절한 초기화 파라미터를 설정해 주고, 적절한 통계 정보를 수집하는 것이 중요하다.
  2. 전략적인 인덱스 구성이 필수적으로 뒷받침되어야 한다.
  3. 기타 다양한 DBMS 기능들을 적극 활용해 옵티마이저가 최적의 선택을 할 수 있도록 수단을 제공해 주어야 한다.

필요하다면, 옵티마이저 힌트를 사용해 최적의 인덱스 경로로 유도

옵티마이저가 생각만큼 최적의 실행계획을 수립하지 못하는 경우가 종종 있다. 그럴 때는 어쩔 수 없이 힌트를 사용해야 한다.

애플리케이션 특성에 따라 힌트 사용을 최소화하기보다 적극적으로 사용할 때도 있다.
통계 정보나 실행 환경에 따라 실행계획이 동적으로 바뀌었을 때 매우 심각한 결과를 초래하는 시스템이 있기 때문이다.

중요한 몇몇 테이블의 통계정보를 고정시키는 방법도 있지만 그것만으로는 안심할 수 없다.

아래는 옵티마이저 힌트를 이용해 실행계획을 제어하는 방법을 제시한다.

select /*+ leading(d) use_nl(e) index(d dept_loc_idx) */ *
from   emp e, dept d
where  e.deptno = d.deptno
and    d.loc = 'CHICAGO'

또한, 옵티마이저 힌트를 쓰더라도 최적의 실행계획인지 확인해야 한다.

CBO 기술이 고도로 발전하지만 여러 이유로 옵티마이저 힌트의 사용은 불가피하다.
따라서 데이터베이스 애플리케이션 개발자면 인덱스, 조인, 옵티마이저 기본 원리를 이해해야 한다.

이는 2에서 다룬다.

좋은 웹페이지 즐겨찾기