본문 바로가기
엑셈 경쟁력/DB 인사이드

DB 인사이드 | PostgreSQL HOT - 1. Page와 관리

by EXEM 2023. 2. 22.

시작하며

PostgreSQL HOT Series에서는 HOT(Heap Only Tuple) Update의 동작과정을 이해하며, 궁극적으로 성능 최적화 및 모니터링 방안을 확립하기 위한 내용을 다룰 예정입니다.

이를 위해, 우선 본 문서에서는 다음과 같은 Page 및 관련 개념들에 대한 설명을 진행하겠습니다. 해당 개념들은 모두 유기적으로 연결되어 있으며, Series전반에 걸쳐 자주 언급되므로 정확한 이해를 필요로 합니다.

  • Page Layout
  • HOT
  • Single-page Vacuuming
  • Fillfactor

 

1. Page Layout

📢 페이지란 디스크 상의 표현으로 블록이라 부르며 0부터 순차적으로 번호가 지정되는데 이 번호를 블록 번호라고 합니다.

PostgreSQL의 모든 테이블과 인덱스는 고정 크기(일반적으로 8KB)의 페이지 배열로 이뤄져 있습니다. 각각의 페이지는 크게 Header와 Data 영역으로 나뉘며, 이 중 Data 영역은 Slotted Page Layout 구조를 사용하여 데이터를 저장합니다.

📢 Slotted Page Layout란 대부분의 DBMS에서 사용하는 페이지 저장 구조입니다. 이는 Tuple을 하나 저장할 때마다 Slot을 늘린 후 Tuple을 저장하는 방식으로 Slot에는 대응하는 Tuple의 시작 주소를 저장합니다. Slot들이 저장되는 Slot Array는 Data 영역의 시작 부분에 위치하며 실제 Data(Tuple)는 Data 영역의 끝에서부터 저장됩니다.

 

PostgreSQL의 각 페이지는 아래와 같이 총 다섯 개의 세부공간으로 분류 가능합니다. 각각에 대해 설명하겠습니다.

  1. Header 영역
    1. PageHeaderData
  2. Data 영역
    1. ItemId (Line Pointer)
    2. Free Space
    3. Item (Tuple)
    4. Special Space

 

1-1. PageHeaderData

먼저 Header 영역인 PageHeaderData가 있습니다. 페이지의 시작 부분(가장 낮은 주소)에 할당되며 길이는 24 Bytes로 고정되어 있습니다. 해당 영역에는 페이지에 대한 일반적인 정보(Checksum 및 페이지의 다른 부분의 크기 등)를 포함합니다.

Field Type Length Description
pd_lsn PageXLogRecPtr 8 bytes LSN : 이 페이지에 대한 마지막 변경에 대한 WAL 레코드의 마지막 바이트 다음 바이트
pd_checksum uint16 2 bytes 페이지 Checksum (활성화된 경우)
pd_flags uint16 2 bytes Flag bits
pd_lower LocationIndex 2 bytes FreeSpace의 시작에 대한 offset (페이지에서 허용되는 최대 위치)
pd_upper LocationIndex 2 bytes FreeSpace 끝까지의 offset (Insert되는 새로운 Tuple의 시작 위치)
pd_special LocationIndex 2 bytes Special 공간의 시작에 대한 offset
pd_pagesize_version uint16 2 bytes 페이지 크기 및 레이아웃 버전 번호 정보
pd_prune_xid TransactionId 4 bytes 페이지에서 제거되지 않은 가장 오래된 XMAX, 없는 경우 0

📢 PageHeaderData 구조는 src/include/storage/bufpage.h 에 정의되어 있습니다.

2-1. ItemId

Line Pointer라고도 불리는 ItemId는 Data 영역의 처음이자, Page Header 바로 뒤에 위치합니다. 각각의 ItemId에는 실제 Item을 가리키는 정보를 저장하고 있으며 이러한 ItemId들은 배열의 형태([1]→[2]→…)로 관리, 새로운 Item이 추가될 때마다 해당 Item을 가리키는 ItemId를 배열에 추가합니다. 이때, 배열의 번호는 페이지 번호와 함께 아래 설명할 페이지 목차(Index)로서의 역할도 수행합니다.

📢 ItemId는 배열의 각 요소를 의미하지만 배열 전체를 칭하기도 합니다.

개별 ItemId가 갖는 정보는 (offset, length)로 구성된 4 Bytes 값인데, 이는 해당 ItemId가 가리키는 Item의 물리 위치(주소)를 의미합니다.

하지만 이러한 직접주소의 사용은 데이터의 변경 발생(물리적인 위치 변경) 시 참조하는 인덱스의 값을 갱신해야 하며, 페이지 단편화를 해결하는데 적합하지 않기때문에 PostgreSQL은 직접주소 외에 간접주소를 따로 관리합니다.

간접주소란, (Page Number, Index of ItemId)로 이루어진 값으로, 일반적으로 Item을 식별하기 위한 포인터 CTID라고 부릅니다. 앞으로 (n,m)으로 표기되는 CTID는 n번째 Page의 m번째 ItemId를 가리키는 간접주소로 이해하면 되며, 이 ItemId가 갖는 (offset, length)를 이용해 실제 Item 특정할 수 있다는 사실을 이해하면 됩니다.

📢 페이지 내에서 Item의 위치가 이동되면 ItemId가 가리키는 Item의 현재 offset 값을 수정할 뿐, ItemId의 위치가 변경되진 않으므로 CTID는 동일하게 유지됩니다. 이로 인해 CTID값을 참조하는 인덱스 와의 참조를 끊지 않고도 Item에 대한 접근이 가능하며, 페이지의 단편화를 막을 수 있습니다.

2-2. FreeSpace

페이지에는 ItemId와 Item 사이에 여유 공간(아무것도 할당되지 않은 공간)이 남아있을 수 있습니다.

이처럼 마지막 ItemId의 끝과 최신 Item의 시작 사이의 빈 공간을 FreeSpace라고 부릅니다. 새로운 ItemId(Line Pointer)는 이 영역의 시작 부분부터 할당되고, 새로운 Item(Tuple)은 이 영역의 끝부분부터 할당됩니다. PostgreSQL은 해당 영역에 아무것도 추가할 수 없을 때 페이지가 가득 찬 것으로 판단하고, 공간 확보를 위해 파일 끝에 빈 페이지를 추가합니다.

2-3. Item

Item 영역은 메타 정보를 포함한 실제 Data를 저장하는 영역입니다. 이때 Data란 테이블 페이지의 경우 Tuple, 인덱스 페이지의 경우 Index Entry를 의미합니다.

Item 영역은 Special Space 바로 앞에 위치하며, FreeSpace의 끝 부분부터 순서대로 쌓입니다.

📢 PostgreSQL의 테이블의 경우 다중 버전 관리를 통한 동시성 제어로, 하나의 동일한 Tuple에 대하여 여러 버전이 있을 수 있습니다. 때문에, Item을 “Tuple”이 아닌 “Tuple의 버전”으로 이해해야 합니다.

테이블 페이지의 경우 ”Tuple의 각 버전”을 지칭하는 Item들이 Tuple Layout 구조로 저장됩니다. 해당 구조는 페이지와 마찬가지로 크게 Header와 Data 영역으로 분류되며, 고정 크기의 Header영역 뒤에 선택적으로 Null bitmap, Object id 및 User data 등의 속성이 있습니다.

이 중 테이블 페이지 Header 영역의 경우 아래와 같은 정보를 통해 Tuple의 다중 버전 관리가 가능합니다.

HeapTupleHeaderData

Field Type Length Description
t_xmin TransactionId 4 bytes 이 Tuple을 삽입한 트랜잭션의 txid, Tuple의 버전 구별에 사용되는 값
t_xmax TransactionId 4 bytes 이 Tuple을 삭제하거나 업데이트한 트랜잭션의 txid로, Tuple의 버전 구별에 사용되는 값(삭제되거나 업데이트되지 않은 경우에는 0)
t_cid CommandId 4 bytes 0부터 시작하는 값으로, 현재 트랜잭션 내에서 이 Command가 실행되기 전에 실행된 SQL Command의 수를 의미하는 Command ID(cid)
t_xvac TransactionId 4 bytes Tuple의 버전을 이동하는 Vacuum 작업의 xid
t_ctid ItemPointerData 6 bytes 이 Tuple 또는 최신 Tuple 버전의 현재 Tuple ID로, 동일한 Tuple에 대하여 다음 Update된 버전에 대한 LP를 알 수 있음(Tuple이 Update된다면 새로운 버전의 Tuple을 가리키고 그렇지않다면 t_ctid가 자신을 가리킴)
t_infomask2 uint16 2 bytes 속성의 수 + 다양한 플래그 비트
t_infomask uint16 2 bytes 다양한 플래그 비트로, 해당 버전의 Tuple의 속성을 정의하는 정보를 제공
t_hoff uint8 1 byte 사용자 데이터에 대한 offset
📢 HeapTupleHeaderData 구조는 src/include/access/htup_details.h  에 정의되어 있습니다.

Null bitmap

고정 Field인 HeapTupleHeaderData 다음에 위치하며, Null bitmap은 NULL 값을 포함할 수 있는 Column을 표시하는 비트의 배열입니다.

단, Header의 t_infomask에서 Tuple에 Null이 없음을 표시하면 Null bitmap은 저장되지 않습니다. (선택적)

2-4. Special Space

Special Space은 페이지의 끝 부분에 위치하며 가장 높은 주소를 사용합니다. 해당 공간은 일부 인덱스에서 추가 정보를 저장하는 데 사용하는 영역이므로, 해당하지 않는 테이블 및 일부 인덱스 페이지의 경우 이 공간의 크기가 0입니다.

인덱스 페이지에서 Special Space는 인덱스 유형에 따라 내용이 다릅니다. 하나의 인덱스도 다른 타입의 페이지를 가질 수 있는데, 예를 들어 B-Tree 인덱스는 특수구조의 메타데이터 페이지와 일반 페이지를 가질 수 있습니다. (메타데이터에는 페이지의 왼쪽과 오른쪽 Siblings에 대한 링크 및 인덱스 구조와 같은 데이터를 저장합니다.)

📢 지금부터 시리즈 전반에 걸쳐서 테이블에 대한 내용을 다루므로 Page Layout의 ItemID는 LP(Line Pointer), Item은 Tuple로 명칭 하여 설명하겠습니다.

 

2. HOT(Heap-only Tuple)

PostgreSQL 8.3버전 부터 등장한 HOT(Heap-Only Tuple)은 “인덱스에는 존재하지 않고 테이블에만 존재하는 튜플”이라고 공식문서는 그 개념을 설명합니다.

하지만 그 설명과는 달리 HOT 및 HOT Update는 인덱스가 전혀 없는 테이블, 혹은 존재하더라도 인덱스에 포함되지 않는 Column을 Update 하는 경우에만 (기존 Update 방식과 다르게) 동작하는 일종의 Update 최적화 기법이라 표현할 수 있습니다.

제약

HOT Update 기능은 위에서 언급한 내용을 포함하여 다음 두 조건을 만족할 때 동작합니다.

  • Update 수행 시 테이블의 인덱스가 참조하는 Column이 Update 대상이 아니어야 한다. (즉, 인덱스 페이지에 영향을 미치지 않아야 한다.)
  • Update 이전 버전과 동일한 페이지에 새로운 버전의 Tuple을 저장할 수 있는 공간이 충분하여 Update 전/후 Tuple이 하나의 테이블 페이지에 존재해야 한다.
📢 페이지에 충분한 공간이 있는지 확인할 때, Fillfactor라는 파라미터가 사용되며 이 파라미터의 설정에 따라 HOT Update로 수행되는 비율이 달라질 수 있습니다.

HOT Chain

이러한 HOT Update가 동작할 경우, Update로 생성된 새로운 버전의 Tuple을 가리키는 LP 정보를 변경 전 Tuple의 t_ctid에 저장합니다. 이처럼 HOT Update를 통해 생성된 Tuple들은 LP → Tuple → LP … 의 형태로 서로 연결되어 있으며 이를 HOT Chain이라 부릅니다. HOT Chain을 이용하면 최신 버전의 Tuple을 식별할 수 있습니다.

📢 ctid와 t_ctid는 모두 (pageno, lp) 쌍으로 구성되어 있습니다. 모두 Tuple을 식별하기 위한 주소(포인터)로 사용됩니다.
- ctid : 현재 버전의 Tuple을 식별하는 값. 해당 Tuple이 저장되어 있는 Page 번호와 LP의 조합으로 구성되며 이 값을 사용하여 원하는 Tuple을 찾을 수 있습니다. (이 값은 인덱스 엔트리에도 저장되어 있으며, 인덱스 스캔 시에도 사용합니다.)
- t_ctid : 동일한 Row에 대한 다음 Update로 생성된 버전의 Tuple을 식별하는 값. 이 값을 통해 다음 Update 버전을 찾을 수 있고, 현재 버전이 최신인지 여부도 확인할 수 있습니다. (HOT Chain 구성 시 사용되는 중요한 값입니다.)
Tuple이 Update 되었다면 새로운 버전의 Tuple ctid값을 이전 버전 Tuple의 t_ctid에 저장하며, 만약 이 Tuple이 최신 버전이라면 t_ctid값은 자신의 ctid값과 동일합니다.

  • 위 그림에서 “LP 1”이 가리키는 Tuple v1의 ctid는 (0,1)이고, t_ctid는 (0,2)로 실제 “LP 2”를 가리키고 있으며, “LP 2”가 가리키는 Tuple v2의 t_ctid는 (0,3)으로 “LP 3”을 가리킵니다.
  • 이때, “LP 3”이 가리키는 Tuple v3의 ctid(0,3)와 t_ctid(0,3)는 동일한 값을 가지므로, 해당 Tuple은 HOT Chain의 끝 - 즉 최신 버전의 Tuple임을 알 수 있습니다.
  • HOT Chain에서 가장 먼저 접근되는 LP (위 그림에서 LP 1)를 HOT Chain의 Head라고 부르는데, Head는 인덱스에서 직접 참조되는 유일한 LP이며 그 값이 변경되지 않는다는 특징을 갖습니다.
    (다시 말해, HOT Chain의 Head는 다른 LP와 달리 가리키는 Tuple이 Dead Tuple이 되어 제거되더라도 HOT Chain에서 해제될 수 없습니다.)
  • HOT Chain의 Head는 변경되지 않는다는 특징 때문에 HOT Update 시 인덱스에 대한 수정이 불필요합니다.
    (인덱스 스캔을 하는 경우, 인덱스 → HOT Chain의 Head부터 순차적으로 HOT Chain을 따라가며 Tuple을 찾을 수 있습니다.)
  • 하나의 Tuple에 대해서 HOT Update가 여러 번 발생하면 HOT Chain은 길어질 수 있습니다.
  • 이렇게 길어진 HOT Chain은 아래 설명할 Single-page Vacuuming을 통해 Dead Tuple을 제거하여 그 길이를 줄일 수 있습니다.

HOT Update의 장점

  • HOT Update가 발생하더라도 인덱스에 대한 수정이 불필요합니다.
    이는 인덱스와 연결된 HOT Chain의 Head(LP)가 동일하게 유지된다는 특징 때문인데, 원래의 인덱스 항목을 그대로 사용할 수 있으므로 일반 Update에 비해 적은 오버헤드를 발생시킵니다.
  • Vacuum의 필요성을 줄여줍니다.
    HOT Update 시 생성된 Tuple은 페이지 밖에서 참조되지 않으므로, 4.Single-page Vacuuming 수행만으로도 Dead Tuple을 제거하고 LP를 정리하여 페이지를 재구성할 수 있습니다. (적은 비용으로 빠르게 페이지를 정리할 수 있습니다.)

 

3. Fillfactor

Fillfactor란 테이블 페이지를 채우는 비율을 나타내는 옵션으로, 10에서 100 사이의 백분율(기본값은 100)로 설정할 수 있습니다. Fillfactor 값이 100보다 작으면, Insert 작업 시 표시된 비율까지 테이블 페이지를 채우며 나머지 공간은 예약 공간으로 비워둡니다.

이렇게 비워둔 공간은 Update 수행 시 사용되는데, 해당 공간이 존재한다는 것은 변경 전/후 Tuple이 동일한 페이지에 위치할 가능성이 있음을 의미하기도 합니다. 만약 동일 페이지에 Update전/후 값이 함께 존재한다면, Access 해야 하는 페이지 수를 최소화하는 효과를 기대할 수 있습니다.

이러한 이유로 Update 수행이 많은 테이블일수록 Fillfactor를 작게 설정하는 것이 유리한데, 이 값이 100(기본값)인 경우와 100 미만으로 설정한 경우 어떤 차이가 있는지 알아보겠습니다.

Fillfactor = 100 (%)

Fillfactor의 기본값은 100(%)입니다. 해당 설정값은 가능한 많은 데이터를 하나의 페이지에 저장(입력)하는 것을 목표로 하며, 데이터 입력으로 페이지가 가득 차면 새로운 페이지를 만들어 데이터를 입력 및 수정합니다.

이러한 설정은 Insert 작업 관점에서 보면 매우 효율적이지만, Update가 발생할 경우 변경된 데이터는 항상 이전 데이터와 다른 페이지에 존재할 수밖에 없습니다. 즉 Fillfactor=100(%)은 데이터 입력 후 변경이 없거나 매우 드문 경우에 적합한 설정값입니다.

10 (%) ≤ Fillfactor < 100 (%)

Fillfactor를 100(%) 보다 작은 값(예를 들어, 80)으로 설정하면 해당 비율까지만 페이지를 채운 후 더 이상 Insert를 허용하지 않습니다. 즉 설정한 비율에 도달하면 새로운 페이지를 생성하여 데이터를 저장하며, 남은 공간은 오로지 Update를 위한 공간으로 비워둡니다.

얼핏, 이러한 설정이 공간적인 측면에서 비효율적이라고 생각할 수 있지만, 실제로는 Update가 빈번하게 발생하는 경우라면 HOT Update기능을 활용한 Update 성능 최적화뿐만 아니라 공간 관리의 이점도 챙길 수 있습니다.

📢 HOT Update를 사용할 수 있는 상황은 위에 작성된 2. HOT(Heap-only Tuple)의 제약사항 부분을 참고하시기 바랍니다.

 

4. Single-page Vacuuming

PostgreSQL은 페이지를 읽거나 업데이트하는 동안 페이지 내 공간이 부족하다고 판단하면, 새로운 Tuple을 위한 공간을 만들기 위해 페이지 정리를 시도합니다. 이러한 작업을 Single-page Vacuuming이라 부르며, 특히 HOT Update의 효율성을 이해하기 위한 중요한 개념입니다.

Single-page Vacuuming은 일반적인 Vacuum Process와 별반 다르지 않습니다. 다만 그 대상이 테이블 페이지로 국한되기 때문에 FSM(Free Space Map)이나 인덱스에 대한 갱신작업은 본 작업에서 제외된다는 차이점이 존재합니다. 또한 추가적인 WAL(Write Ahead Log)을 생성하지 않기 때문에 일반적인 Vacuum 작업보다 훨씬 적은 비용으로 빠르게 처리가 가능합니다.

Single-page Vacuuming 작업은 일반적인 Vacuum작업과 마찬가지로 Dead Tuple을 삭제할 수 있지만, 삭제된 Tuple을 가리키는 Line Pointer 정보는 인덱스에서 여전히 참조할 수 있습니다. (하나의 페이지에 대해서만 정리가 이루어지므로 인덱스와의 연결이 바로 해제되지 않습니다.)

📢 Single-page Vacuuming 은 In-page Vacuuming 또는 Single-page Cleanup이라는 용어를 사용하기도 합니다.

발생조건

  • Update 수행했을 때, 새로운 버전의 Tuple을 할당할 만큼의 충분한 공간을 이전 버전의 Tuple이 저장된 페이지에서 찾지 못한다면, 이러한 상황이 페이지 헤더에 기억되고 다음에 Single-page Vacuuming을 수행한다.
  • 페이지 공간이 Fillfactor 설정값 이상으로 채워지면, 바로 Single-page Vacuuming을 수행한다.

Single-page Vacuuming이란, 이처럼 Fillfactor와 관련된 특정 조건 만족 시, 시시각각 페이지 단위의 Vacuum을 수행하므로 Dead Tuple로 인해 불필요하게 테이블 사이즈가 커지는 것을 예방할 수 있으며 테이블 단위 Vacuum의 부하를 줄일 수 있습니다.

 

마치며

본 문서에서는 앞으로 추가될 HOT Series 관련 글을 이해하기 위한 필수 개념들을 설명하였습니다. Page Layout, HOT Update, Fillfactor, Single-page Vacuuming은 그 자체로도 중요하지만, 이후 이어지는 글을 이해하기 위해선 반드시 짚고 넘어가야 할 내용임을 기억하기 바랍니다.

 

 

 

기획 및 글 | DB기술기획팀

이미지 제작 | 디자인그룹 이민석

 

 

댓글