MVCC가 무엇인지, PostgreSQL이 이를 어떻게 구현하는지, 그리고 그 방식이 왜 다른 주요 DBMS에 비해 불리한지 살펴본다.
URL: https://www.cs.cmu.edu/~pavlo/blog/2023/04/the-part-of-postgresql-we-hate-the-most.html
데이터베이스에는 선택지가 정말 많습니다(2023년 4월 기준 897개). 시스템이 이렇게 많은데 무엇을 골라야 할지 알기 어렵죠! 그런데 흥미로운 현상이 하나 있습니다. 인터넷이 집단적으로 신규 애플리케이션의 ‘기본 선택지’를 정해버리는 현상입니다. 2000년대에는 구글, 페이스북 같은 떠오르는 기술 스타들이 사용한다는 이유로 통념적으로 MySQL이 선택됐습니다. 그러다 2010년대에는 비내구성 쓰기가 “webscale”하다고 여겨지면서 MongoDB가 그 자리를 차지했죠. 그리고 지난 5년 동안 PostgreSQL은 인터넷의 총애를 받는 DBMS가 되었습니다. 그럴 만한 이유도 충분합니다! PostgreSQL은 믿을 수 있고, 기능이 풍부하며, 확장 가능하고, 대부분의 운영성 워크로드에 잘 맞습니다.
하지만 OtterTune에서는 PostgreSQL을 사랑하는 만큼, 어떤 부분은 그다지 훌륭하지도 않습니다. 그래서 모두가 좋아하는 코끼리 테마 DBMS의 대단함을 찬양하는 흔한 블로그 글을 또 쓰는 대신, “하나의 큰 문제”를 이야기하려 합니다. 바로 PostgreSQL이 다중 버전 동시성 제어(MVCC)를 구현하는 방식입니다. 카네기멜론대학교에서의 우리 연구와 Amazon RDS에서 PostgreSQL 인스턴스를 최적화해온 경험을 통해, PostgreSQL의 MVCC 구현은 MySQL, Oracle, Microsoft SQL Server를 포함한 널리 사용되는 다른 관계형 DBMS들 사이에서 최악이라는 결론을 얻었습니다. 그리고 네, Amazon의 PostgreSQL Aurora도 여전히 이 문제를 안고 있습니다.
이 글에서는 MVCC가 무엇인지, PostgreSQL이 이를 어떻게 구현하는지, 그리고 왜 끔찍한지 파헤쳐 보겠습니다. OtterTune의 목표는 데이터베이스에 대해 걱정해야 할 일을 줄여 드리는 것이며, 그래서 우리는 이 문제를 어떻게 다룰지 오랫동안 고민해왔습니다. 다음 주 후속 글에서는 RDS와 Aurora 데이터베이스에서 PostgreSQL의 MVCC 문제를 자동으로 관리하기 위한 OtterTune의 솔루션을 다룰 예정입니다.
DBMS에서 MVCC의 목표는 가능할 때 서로 간섭하지 않으면서 여러 쿼리가 동시에 데이터베이스를 읽고 쓸 수 있도록 하는 것입니다. MVCC의 기본 아이디어는 DBMS가 기존 행을 절대로 덮어쓰지 않는다는 점입니다. 대신 각 (논리적) 행에 대해 DBMS는 여러 (물리적) 버전을 유지합니다. 애플리케이션이 쿼리를 실행하면 DBMS는 어떤 버전 순서(예: 생성 타임스탬프)에 따라 요청을 만족시키기 위해 어떤 버전을 가져올지 결정합니다. 이 접근의 장점은 어떤 쿼리가 행을 업데이트하고 있더라도, 다른 쿼리들이 오래된 버전을 읽어도 차단되지 않는다는 점입니다. 쿼리들은 DBMS가 해당 쿼리의 트랜잭션을 시작했을 당시의 데이터베이스 스냅샷을 관찰합니다(스냅샷 격리). 이 방식은 작성자가 동일 항목을 수정하는 동안 독자가 데이터에 접근하지 못하도록 막는 명시적 레코드 락의 필요성을 없애줍니다.
우리가 알기로, 데이비드 리드(David Reed)의 1978년 MIT 박사학위 논문 “Concurrency Control in Distributed Database Systems”이 MVCC를 처음으로 기술한 출판물입니다. MVCC를 상용 DBMS로 처음 구현한 것은 1980년대의 InterBase였습니다. 그 이후로, 트랜잭션을 지원하는 지난 20년간의 거의 모든 신규 DBMS는 MVCC를 구현합니다.
MVCC를 지원하는 DBMS를 만들 때 시스템 엔지니어는 여러 설계 결정을 내려야 합니다. 높은 수준에서 보면 다음 세 가지로 귀결됩니다.
이 결정들은 상호 배타적이지 않습니다. PostgreSQL의 경우, 1980년대에 첫 번째 질문을 처리하기 위해 내린 결정이 다른 두 질문에서도 문제가 되도록 만들었고, 그 여파가 오늘날까지 이어지고 있습니다.
이 글에서는 영화 정보가 담긴 다음 예시 테이블을 사용하겠습니다. 각 행은 영화 이름, 개봉 연도, 감독, 그리고 기본 키로 쓰이는 고유 ID를 포함하며, 영화 이름과 감독에 대한 보조 인덱스가 있습니다. 다음은 이 테이블을 생성하는 DDL입니다.
sqlCREATE TABLE movies ( id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name VARCHAR(256) NOT NULL, year SMALLINT NOT NULL, director VARCHAR(128) ); CREATE INDEX idx_name ON movies (name); CREATE INDEX idx_director ON movies (director);
이 테이블에는 기본 인덱스(movies_pkey)와 두 개의 보조 B+Tree 인덱스(idx_name, idx_director)가 있습니다.
스톤브레이커(Stonebraker)의 1987년 시스템 설계 문서에서 논의되었듯이, PostgreSQL은 처음부터 다중 버전 지원을 염두에 두고 설계되었습니다. PostgreSQL의 MVCC 핵심 아이디어는 겉보기엔 단순합니다. 쿼리가 테이블의 기존 행을 업데이트할 때, DBMS는 해당 행을 복사한 뒤 원본을 덮어쓰는 대신 새 버전에 변경을 적용합니다. 우리는 이 방식을 append-only(추가 전용) 버전 저장 방식이라고 부릅니다. 하지만 아래에서 설명하듯, 이 방식은 시스템의 다른 부분에 여러 비자명한 함의를 낳습니다.
PostgreSQL은 테이블 내의 모든 행 버전을 동일한 스토리지 공간에 저장합니다. 기존 튜플(tuple)을 업데이트하기 위해 DBMS는 먼저 테이블에서 새 행 버전을 위한 빈 슬롯을 확보합니다. 그런 다음 현재 버전의 행 내용을 새 버전으로 복사하고, 새로 할당된 버전 슬롯의 행에 수정 사항을 적용합니다. 아래 예시에서는 애플리케이션이 movies 데이터베이스에서 “Shaolin and Wu Tang”의 개봉 연도를 1985에서 1983으로 바꾸는 업데이트 쿼리를 실행할 때 이 과정을 볼 수 있습니다.
UPDATE 쿼리가 테이블의 튜플을 변경하면, PostgreSQL은 원본 튜플 버전을 복사한 다음 새 버전에 변경을 적용합니다. 이 예시에서는 Table Page #1에 더 이상 공간이 없어서, PostgreSQL이 Table Page #2에 새 버전을 생성합니다.
이제 동일한 논리적 행을 나타내는 두 개의 물리적 버전이 생겼으므로, DBMS는 향후 이를 찾을 수 있도록 버전들의 계보(lineage)를 기록해야 합니다. MVCC DBMS들은 단일 연결 리스트(singly linked list)로 버전 체인(version chain) 을 만들어 이를 해결합니다. 버전 체인은 스토리지와 유지보수 오버헤드를 줄이기 위해 한 방향으로만 이어집니다. 따라서 DBMS는 어떤 순서를 사용할지 결정해야 합니다: 최신→과거(N2O: newest-to-oldest) 또는 과거→최신(O2N: oldest-to-newest). N2O에서는 각 튜플 버전이 이전 버전을 가리키며, 체인의 헤드는 항상 최신 버전입니다. O2N에서는 각 튜플 버전이 다음(더 새로운) 버전을 가리키고, 헤드는 가장 오래된 튜플 버전입니다. O2N 접근은 튜플이 수정될 때마다 인덱스가 더 새로운 버전을 가리키도록 DBMS가 인덱스를 업데이트할 필요를 없애줍니다. 하지만 쿼리 처리 중 최신 버전을 찾는 데 시간이 더 오래 걸 수 있으며, 긴 버전 체인을 따라가야 할 수도 있습니다. Oracle, MySQL을 포함한 대부분의 DBMS는 N2O를 구현합니다. 하지만 PostgreSQL은 (SQL Server의 Microsoft In-Memory OLTP 엔진을 제외하면) 유일하게 O2N을 사용합니다.
다음 이슈는 PostgreSQL이 이 버전 포인터에 무엇을 기록하느냐입니다. PostgreSQL에서 각 행 헤더는 다음 버전(또는 최신 버전이라면 자기 자신의 튜플 id)을 담는 튜플 id 필드(t_tcid)를 포함합니다. 따라서 다음 예시처럼, 쿼리가 어떤 행의 최신 버전을 요청할 때 DBMS는 인덱스를 따라가 가장 오래된 버전에 도착한 후, 필요한 버전을 찾을 때까지 포인터를 따라갑니다.
SELECT 쿼리는 인덱스를 탐색해 요청된 영화 이름의 튜플을 찾습니다. 인덱스 엔트리는 튜플의 가장 오래된 버전을 가리키므로, PostgreSQL은 원본 버전에 내장된 버전 체인을 따라 새 버전을 찾습니다.
PostgreSQL 개발자들은 일찍부터 이 MVCC 방식에 두 가지 문제가 있음을 깨달았습니다. 첫째, 업데이트 때마다 튜플 전체를 새로 복사하는 것은 비용이 큽니다. 둘째, 최신 버전(대부분의 쿼리가 원하는 것)을 찾기 위해 전체 버전 체인을 매번 순회하는 것은 낭비입니다. 물론 오래된 버전을 정리하는 문제도 있는데, 그 부분은 아래에서 다룹니다.
전체 버전 체인을 순회하는 것을 피하기 위해, PostgreSQL은 행의 물리적 버전마다 테이블 인덱스에 엔트리를 추가합니다. 즉, 하나의 논리적 행에 물리적 버전이 5개 있다면, 인덱스에는 그 튜플에 대한 엔트리가 (최대) 5개 존재하게 됩니다! 아래 예시에서는 idx_name 인덱스가 “Shaolin and Wu Tang”의 각 버전에 대한 엔트리를 서로 다른 페이지로 가지고 있는 것을 볼 수 있습니다. 이로써 긴 버전 체인을 순회하지 않고도 최신 버전에 직접 접근할 수 있습니다.
이 예시에서 인덱스는 “Shaolin and Wu Tang” 튜플에 대해(버전마다 하나씩) 여러 엔트리를 포함합니다. 이제 PostgreSQL은 인덱스로 최신 버전을 찾은 다음 곧바로 Table Page #2에서 이를 가져오며, Table Page #1에서 시작하는 버전 체인을 따라갈 필요가 없습니다.
PostgreSQL은 인덱스 엔트리를 여러 개 설치하고 관련 버전을 여러 페이지에 걸쳐 저장해야 하는 상황을 줄이기 위해, 디스크 I/O를 줄이는 목적으로 기존 버전과 동일한 디스크 페이지(블록)에 새 복사본을 생성하려고 합니다. 이 최적화는 HOT(heap-only tuple) 업데이트라고 합니다. 업데이트가 테이블 인덱스에서 참조하는 어떤 컬럼도 수정하지 않고, 새 버전이 이전 버전과 같은 데이터 페이지에 저장될 수 있는(그 페이지에 공간이 있는) 경우 DBMS는 HOT 방식을 사용합니다. 이때 예시에서는 업데이트 후에도 인덱스가 여전히 오래된 버전을 가리키며, 쿼리는 버전 체인을 따라 최신 버전을 가져옵니다. 정상 동작 중 PostgreSQL은 버전 체인을 가지치기(prune)하기 위해 오래된 버전을 제거하면서 이 과정을 추가로 최적화합니다.
PostgreSQL은 애플리케이션이 행을 업데이트할 때마다 복사본을 만든다는 점을 확인했습니다. 다음 질문은 시스템이 오래된 버전(“dead tuple”이라 부름)을 어떻게 제거하느냐입니다. 1980년대의 초기 PostgreSQL은 dead tuple을 제거하지 않았습니다. 모든 오래된 버전을 유지하면 애플리케이션이 “시간 여행(time-travel)” 쿼리를 실행해 특정 시점의 데이터베이스를 조회할 수 있다는 아이디어였죠(예: 지난주 말 기준 상태에 대해 SELECT 실행). 하지만 dead tuple을 절대 제거하지 않으면, 애플리케이션이 튜플을 삭제하더라도 테이블 크기는 절대 줄지 않습니다. 또한 자주 업데이트되는 튜플은 버전 체인이 길어져 쿼리가 느려질 수 있습니다. 다만 PostgreSQL은 인덱스 엔트리를 추가해 체인을 순회하지 않고도 올바른 버전으로 빠르게 점프하게 하므로 그 문제를 완화합니다. 하지만 이제 인덱스가 더 커져 느려지고, 추가 메모리 압박도 생깁니다. 이제 이런 문제들이 왜 서로 얽혀 있는지 이해하실 수 있을 것입니다.
이 문제들을 극복하기 위해 PostgreSQL은 테이블에서 dead tuple을 청소하는 vacuum 절차를 사용합니다. vacuum은 마지막 실행 이후 수정된 테이블 페이지를 순차 스캔하여 만료된 버전을 찾습니다. DBMS는 어떤 버전이 활성 트랜잭션에 의해 보이지 않는 경우 그 버전을 “만료(expired)”로 간주합니다. 즉, 어떤 현재 트랜잭션도 그 버전에 접근하고 있지 않고, 미래의 트랜잭션은 최신 “live” 버전을 사용할 것입니다. 따라서 만료 버전을 제거하고 그 공간을 재사용할 수 있도록 회수하는 것은 안전합니다.
PostgreSQL은 설정값에 따라 일정 간격으로 이 vacuum 절차를 자동으로 실행합니다(autovacuum). 모든 테이블의 vacuum 빈도에 영향을 주는 전역 설정 외에도, PostgreSQL은 특정 테이블에 대해 autovacuum을 테이블 단위로 조정할 수 있게 해 줍니다. 또한 사용자는 VACUUM SQL 명령으로 수동으로 vacuum을 실행해 데이터베이스 성능을 최적화할 수도 있습니다.
솔직히 말하겠습니다. 오늘날 누군가가 새 MVCC DBMS를 만든다면, PostgreSQL이 하는 방식(예: append-only 스토리지 + autovacuum)으로 구현하면 안 됩니다. 우리의 2018년 VLDB 논문(일명 “MVCC에 관한 역사상 최고의 논문”)에서 우리는 PostgreSQL처럼 MVCC를 구현한 다른 DBMS를 찾지 못했습니다. 이 설계는 1980년대의 유물이며, 1990년대 이후 로그 구조 시스템 패턴이 널리 퍼지기 이전의 사고방식을 반영합니다.
PostgreSQL의 MVCC에서 발생하는 네 가지 문제를 이야기해봅시다. 그리고 Oracle이나 MySQL 같은 다른 MVCC DBMS들이 왜 이런 문제를 피하는지도 함께 살펴보겠습니다.
append-only MVCC 저장 방식에서는 쿼리가 튜플을 업데이트할 때 DBMS가 그 모든 컬럼을 새 버전에 복사합니다. 이 복사는 단 하나의 컬럼만 업데이트하든, 전부를 업데이트하든 상관없이 발생합니다. 상상하실 수 있듯, append-only MVCC는 막대한 데이터 중복과 스토리지 요구량 증가를 초래합니다. 즉 PostgreSQL은 다른 DBMS보다 같은 데이터베이스를 저장하는 데 더 많은 메모리와 디스크를 필요로 하며, 이는 더 느린 쿼리와 더 높은 클라우드 비용으로 이어집니다. MySQL과 Oracle은 새 버전을 위해 튜플 전체를 복사하는 대신, 새 버전과 현재 버전 사이의 компакт한 델타(delta)를 저장합니다(깃의 diff처럼 생각하면 됩니다). 델타를 사용하면, 예를 들어 1000개 컬럼을 가진 테이블에서 쿼리가 튜플의 컬럼 하나만 업데이트할 경우 DBMS는 그 한 컬럼의 변경만 담긴 델타 레코드만 저장합니다. 반면 PostgreSQL은 변경된 한 컬럼과 변경되지 않은 나머지 999개 컬럼을 포함한 새 버전을 생성합니다. PostgreSQL이 TOAST 속성을 다르게 처리한다는 점은 여기서는 무시하겠습니다.
PostgreSQL의 버전 저장 구현을 현대화하려는 시도도 있었습니다. EnterpriseDB가 2013년에 append-only 스토리지 엔진을 델타 버전 방식으로 대체하기 위한 zheap 프로젝트를 시작했습니다. 하지만 마지막 공식 업데이트는 2021년이었고, 우리가 아는 한 이 노력은 흐지부지된 것으로 보입니다.
PostgreSQL에서 만료된 버전(즉, dead tuple)도 델타 버전보다 더 많은 공간을 차지합니다. PostgreSQL의 autovacuum이 결국 이러한 dead tuple을 제거하긴 하지만, 쓰기 위주의 워크로드에서는 vacuum이 따라잡는 속도보다 dead tuple이 쌓이는 속도가 더 빨라 데이터베이스가 지속적으로 커질 수 있습니다. 시스템은 dead tuple과 live tuple이 페이지 내에 섞여 있기 때문에, 쿼리 실행 시 dead tuple도 메모리에 올려야 합니다. 제어되지 않은 팽창(bloat)은 테이블 스캔 시 DBMS가 불필요하게 더 많은 IOPS를 발생시키고 더 많은 메모리를 소비하게 만들어 성능을 떨어뜨립니다. 또한 dead tuple로 인해 옵티마이저 통계가 부정확해지면 좋지 않은 쿼리 플랜으로 이어질 수 있습니다.
예를 들어 movies 테이블에 live tuple 1천만 개와 dead tuple 4천만 개가 있어 테이블의 80%가 쓸모없는 데이터라고 해봅시다. 또한 테이블에 우리가 보여준 것보다 훨씬 많은 컬럼이 있고 평균 튜플 크기가 1KB라고 가정합시다. 이 경우 live tuple은 10GB, dead tuple은 약 40GB를 차지하며, 테이블 총 크기는 50GB입니다. 이 테이블에 대해 쿼리가 전체 테이블 스캔을 수행하면, PostgreSQL은 대부분이 쓸모없더라도 디스크에서 50GB 전체를 읽어 메모리에 올려야 합니다. Postgres는 순차 스캔이 버퍼 풀 캐시를 오염시키는 것을 피하기 위한 보호 메커니즘이 있긴 하지만, I/O 비용 자체를 막아주지는 못합니다.
또한 PostgreSQL의 autovacuum이 규칙적으로 실행되고 워크로드를 따라잡을 수 있도록(항상 쉬운 일은 아닙니다. 아래에서 설명) 설정하더라도, autovacuum은 스토리지 공간을 회수하지는 못합니다. autovacuum은 dead tuple을 제거하고 각 페이지 내에서 live tuple을 재배치할 뿐, 디스크에서 빈 페이지를 회수하지 않습니다.
DBMS가 어떤 튜플도 남지 않은 마지막 페이지를 잘라내(truncate)더라도, 다른 페이지들은 디스크에 남아 있습니다. 위 예시에서 PostgreSQL이 movies 테이블에서 40GB의 dead tuple을 제거하더라도, 운영체제(또는 RDS라면 Amazon)로부터 할당받은 50GB의 공간은 그대로 유지합니다. 이런 미사용 공간을 회수하여 반환하려면 VACUUM FULL 또는 pg_repack 확장을 사용해 전체 테이블을 낭비 공간 없이 새 공간으로 다시 작성해야 합니다. 두 작업 모두 운영(production) 데이터베이스에서 성능 영향을 고려하지 않고 쉽게 실행할 수 있는 작업이 아닙니다. 자원 소모가 크고 시간이 오래 걸리며 쿼리 성능을 박살낼 수 있습니다. 다음 그림은 VACUUM과 VACUUM FULL이 어떻게 동작하는지 보여줍니다.
PostgreSQL의 일반 VACUUM 작업에서는 DBMS가 각 테이블 페이지에서 dead tuple만 제거하고, 페이지 끝에 live tuple이 모이도록 재정렬합니다. VACUUM FULL에서는 PostgreSQL이 각 페이지에서 dead tuple을 제거한 뒤 남은 live tuple을 새 페이지(Table Page #3)로 모아 압축(compact)하고, 불필요한 페이지(Table Pages #1 / #2)를 삭제합니다.
튜플 한 번의 업데이트만으로도 PostgreSQL은 해당 테이블의 모든 인덱스를 업데이트해야 합니다. 이는 PostgreSQL이 기본 인덱스와 보조 인덱스 모두에 버전의 정확한 물리적 위치를 저장하기 때문입니다. DBMS가 새 버전을 이전 버전과 같은 페이지에 저장하는(HOT 업데이트) 경우가 아니라면, 시스템은 업데이트마다 이 작업을 수행합니다.
앞서 본 UPDATE 예시로 돌아가면, PostgreSQL은 원본 버전을 새 페이지로 복사하여 새 버전을 생성합니다. 그리고 테이블의 기본 키 인덱스(movies_pkey)와 두 개의 보조 인덱스(idx_director, idx_name)에 새 버전을 가리키는 엔트리도 삽입합니다.
HOT이 아닌 업데이트에서의 PostgreSQL 인덱스 유지보수 작업 예시. DBMS는 Table Page #2에 튜플의 새 버전을 만들고, 그 버전을 가리키는 새 엔트리를 모든 테이블 인덱스에 삽입합니다.
업데이트마다 모든 인덱스를 수정해야 한다는 점은 여러 성능 상의 함의를 갖습니다. 당연히 업데이트 쿼리가 느려집니다. 시스템은 각 인덱스를 탐색하고 새 엔트리를 삽입하기 위해 추가 I/O를 발생시킵니다. 인덱스 접근은 인덱스 자체와 DBMS 내부 자료구조(예: 버퍼 풀의 페이지 테이블) 모두에서 락/래치 경합을 유발합니다. 또한 PostgreSQL은 쿼리가 결코 사용하지 않을 인덱스에 대해서도 이 유지보수 작업을 수행합니다(참고로 OtterTune은 데이터베이스의 미사용 인덱스를 자동으로 찾아줍니다). 이런 추가 읽기/쓰기는 Amazon Aurora처럼 IOPS 기준으로 과금하는 DBMS에서 특히 문제가 됩니다.
앞서 설명했듯 PostgreSQL은 새 버전이 기존 버전과 같은 페이지에 위치하는 HOT 쓰기를 할 수 있으면 매번 인덱스를 업데이트하지 않으려 합니다. OtterTune 고객들의 PostgreSQL 데이터베이스를 분석해보면 평균적으로 업데이트의 약 46%가 HOT 최적화를 사용합니다. 인상적인 숫자이긴 하지만, 여전히 50%가 넘는 업데이트가 이 페널티를 치른다는 뜻입니다.
사용자들이 PostgreSQL의 MVCC 구현에서 이 부분 때문에 고생하는 사례는 많습니다. 그중 가장 유명한 증거는 Uber가 2016년에 왜 Postgres에서 MySQL로 전환했는지를 설명한 블로그 글입니다. 다수의 보조 인덱스를 가진 테이블에서 쓰기 위주의 워크로드가 심각한 성능 문제를 겪었습니다.
Oracle과 MySQL은 MVCC 구현에서 이런 문제가 없습니다. 그들의 보조 인덱스는 새 버전의 물리 주소를 저장하지 않기 때문입니다. 대신 논리적 식별자(예: 튜플 id, 기본 키)를 저장하고, DBMS가 이를 사용해 현재 버전의 물리 주소를 조회합니다. 이 방식은 DBMS가 논리 식별자를 해석(resolve)해야 하므로 보조 인덱스 읽기가 더 느릴 수 있지만, 이들 DBMS는 오버헤드를 줄이는 다른 MVCC 상의 이점을 갖습니다.
PostgreSQL의 성능은 autovacuum이 오래된 데이터를 제거하고 공간을 회수하는 데 얼마나 효과적인지에 크게 의존합니다(그래서 OtterTune은 데이터베이스를 처음 연결하면 즉시 autovacuum의 건강 상태를 확인합니다). RDS, Aurora, Aurora Serverless 중 무엇을 사용하든 상관없이 PostgreSQL의 모든 변형은 동일한 autovacuum 이슈를 갖고 있습니다. 하지만 PostgreSQL의 autovacuum이 가능한 최선으로 동작하도록 보장하는 것은 복잡성 때문에 어렵습니다. PostgreSQL의 기본 autovacuum 튜닝 설정은 모든 테이블, 특히 대형 테이블에 이상적이지 않습니다. 예를 들어, autovacuum이 시작되기 전에 테이블의 몇 퍼센트가 업데이트되어야 하는지를 제어하는 설정(autovacuum_vacuum_scale_factor)의 기본값은 20%입니다. 즉 테이블에 1억 개의 튜플이 있다면, DBMS는 최소 2천만 개의 튜플이 업데이트되기 전에는 autovacuum을 트리거하지 않습니다. 그 결과 PostgreSQL은 오랜 시간 동안 테이블에 많은 dead tuple을 불필요하게 남겨둘 수 있고(따라서 I/O 및 메모리 비용을 부담), 그 기간이 길어질 수 있습니다.
PostgreSQL autovacuum의 또 다른 문제는 장시간 실행되는 트랜잭션에 의해 막힐 수 있다는 점입니다. 그러면 더 많은 dead tuple과 오래된 통계가 누적됩니다. 만료된 버전을 적시에 정리하지 못하면 수많은 성능 문제가 생기고, 이는 autovacuum을 막는 더 많은 장시간 트랜잭션을 만들기도 합니다. 악순환이죠. 결국 사람의 개입이 필요해 장시간 트랜잭션을 수동으로 종료해야 합니다. 아래 그래프는 OtterTune 고객 데이터베이스에서 2주 동안의 dead tuple 수를 보여줍니다.
PostgreSQL Amazon RDS 데이터베이스에서 시간에 따른 dead tuple 수.
그래프의 톱니 모양(sawtooth) 패턴은 autovacuum이 대략 하루에 한 번 큰 정리를 수행함을 보여줍니다. 예를 들어 2월 14일에 DBMS는 320만 개의 dead tuple을 정리했습니다. 이 그래프는 사실 건강하지 않은 PostgreSQL 데이터베이스의 예입니다. dead tuple 수가 증가 추세인 것이 분명하며, autovacuum이 워크로드를 따라잡지 못하고 있음을 보여줍니다.
OtterTune에서는 고객 데이터베이스에서 이 문제를 자주 봅니다. 한 PostgreSQL RDS 인스턴스에서는 대량 삽입 이후의 오래된 통계 때문에 장시간 실행되는 쿼리가 발생했습니다. 이 쿼리가 autovacuum이 통계를 갱신하는 것을 막았고, 그 결과 더 많은 장시간 쿼리가 발생했습니다. OtterTune의 자동화된 헬스 체크가 문제를 식별했지만, 관리자는 여전히 쿼리를 수동으로 죽이고 대량 삽입 후 ANALYZE를 실행해야 했습니다. 좋은 소식은 그 장시간 쿼리의 실행 시간이 52분에서 34초로 줄었다는 점입니다.
DBMS를 만들 때는 항상 어려운 설계 결정을 내려야 합니다. 그리고 그 결정들은 워크로드에 따라 어떤 DBMS든 성능이 다르게 나오게 만듭니다. Uber의 특정 쓰기 집약적 워크로드에서는 MVCC 때문에 생기는 PostgreSQL의 인덱스 쓰기 증폭이 MySQL로 옮긴 이유였습니다. 하지만 우리의 불평이 “PostgreSQL을 절대 쓰지 말라”는 뜻으로 오해되지는 않았으면 합니다. MVCC 구현은 잘못된 방식이지만, PostgreSQL은 여전히 우리가 가장 좋아하는 DBMS입니다. 무언가를 사랑한다는 것은 그 결함과 함께할 의지가 있다는 뜻이니까요(댄 새비지(Dan Savage)의 “The Price of Admission” 참고).
그럼 PostgreSQL의 이런 특성을 어떻게 우회할 수 있을까요? 글쎄요, 엄청난 시간과 노력을 들여 직접 튜닝하는 방법도 있습니다. 행운을 빕니다.
다음 글에서 우리가 무엇을 할 수 있는지 더 다루겠습니다.