지난 글에서 tid라는 것에 대해 간단하게 소개를 했다. tid는 튜플(레코드)의 물리적인 위치라고 알고있는데, 값이 종종 변경되는 것을 보며 어째서 변경이 되는 것인지 궁금하여 찾아보게 됐다.
찾아본 결과 MVCC와 VACUUM이라는 기능 때문이라는 것을 알게되며 알게된 점을 기록하게 됐다.
MVCC
MVCC란 Multi Version Concurrency Control의 줄임말로 여러 버전을 동시에 관리한다는 의미이다.
예를 들어 다음의 쿼리를 보자
begin;
update employee e set e.name = 'john';
select * from employee e where e.name = 'john';
rollback;
위 쿼리에 select 를 하는 시점에는 분명 조회가 됐지만, 트랜잭션이 종료되면 조회가 되지 않을 것이다. 또한 다른 트랜잭션에서 john이라는 이름으로 employee 테이블에서 조회하는 것도 되지 않을 것이다. (격리 수준에 따라 다르지만, 디폴트 격리수준에 따르면)
이러한 것이 가능한 이유는 한 레코드가 여러 버전을 갖고 있기 때문에 가능하다. 내가 익숙한 mysql에서는 언두로그라는 곳에 다른 버전을 기록해두는데, postgresql에서는 언두로그의 개념과는 다르게 동작한다.
postgresql에서 모든 테이블에는 xmin, xmax 라는 컬럼이 숨겨져 있는데, xmin 은 해당 레코드에 insert 또는 update 쿼리가 수행된 마지막 트랜잭션 아이디, xmax에는 해당 레코드에 delete 또는 update쿼리가 수행된 마지막 트랜잭션 아이디를 의미한다.
예를 들어 다음의 쿼리가 수행되면 해당 레코드는 xmin, xmax가 서로 다른 버전이 아래 사진처럼 존재하게 된다.
INSERT INTO account (balance) VALUES ('500$'); -- 이때 트랜잭션 아이디는 40
UPDATE account SET balance = '600$' WHERE balacne = '500$'; -- 이때는 41
DELETE FROM account WHERE balance = '600$'; -- 이때는 42
따라서 업데이트 쿼리가 수행된 순간 balance가 500$ 인 레코드는 2개의 버전을 갖게 되는 것이다.
표로 보면 아래와 같다. 이 상황에서 트랜잭션 아이디가 42번 이상인 조회에서 이 레코드에 대한 조회를 할 때 마치 없는 레코드인 것처럼 보이게 되는 것이다.
xmin | xmax | balance
-------+-------+---------
40 | 41 | 500$
41 | 42 | 600$
Postgresql은 이런식으로 MVCC를 지원하는데, 이 때 버전이 지나서 더이상 관리할 필요가 없는 버전의 레코드를 Dead Tuple이라고 한다. 문제는 이 Dead Tuple 을 정리해줘야 하기 때문에 생기는데, 이를 위해 나온 개념이 Vacuum이다. 마치 자바의 gc와 동일한 개념이라고 보면 된다.
Vacuum
Dead Tuple을 정리하기 위해서 나타난 기능인 Vacuum은 Dead Tuple을 정리하는 용도 외에도 Transaction ID Wraparound 라는 문제를 해결하기 위해서도 꼭 필요하다. Transaction ID Wraparound 는 우선 Dead Tuple을 정리하는 과정을 보며 직접 살펴보도록 하자.
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
user_name VARCHAR(100),
balance DECIMAL(10,2)
);
INSERT INTO accounts (user_name, balance) VALUES
('Alice', 100.00),
('Bob', 200.00),
('Charlie', 300.00);
alter table accounts set (autovacuum_enabled = off);
일단 기반이 되는 쿼리는 위와 같다. 이 때의 이 테이블의 크기를 조회하면 아래와 이미지와 같이 24kb 로 보이는 것을 확인할 수 있다.
SELECT pg_size_pretty(pg_total_relation_size('accounts'));
자 이제 이런 쿼리를 실행해보자 아래와 같은 이미지가 보인다. 3개의 튜플이 존재하고 데드 튜플의 수는 0개인 것을 확인할 수 있다.
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
다음으로 실제 데드 튜플이 생성이 되는지 아래의 쿼리로 테스트 해보자.
UPDATE accounts SET balance = balance + 50 WHERE user_name = 'Alice'; -- Alice의 잔액 업데이트
DELETE FROM accounts WHERE user_name = 'Bob'; -- Bob의 행 삭제
한개의 행을 삭제하고, 한개의 행을 업데이트 했다.
보는바와 같이 실제 죽은 튜플의 개수가 2개 증가했다. (삭제, 수정 쿼리가 실행되기 전의 튜플은 죽은 튜플이 됨)
그리고 다시 테이블의 크기를 확인해보자
SELECT pg_size_pretty(pg_total_relation_size('accounts'));
심지어 한개의 데이터는 삭제했는데 테이블이 디스크에서 차지하고 있는 영역이 늘어났다.
실제 디스크에서 이 데드 튜플을 정리하는 작업은 'vacuum full 테이블' 이라는 쿼리로 수행을 해야 하는데, 이 쿼리는 매우매우 굉장히 조심해서 사용해야 한다. (엄청 오래걸리고 테이블에 베타 락이 걸림) (이게 너무 성능이 오래 걸려서 pg_reck이라는 오픈 소스를 이용한다고도 하는데 나는 안써봤다.)
그래서 Postgresql은 일반 vacuum 만을 자동으로 사용하는 데,vacuum 이벤트가 발생하는 기본 값은 다음과 같다.
1. 데드 튜플의 개수가 50개 이상
2. 데드 튜플의 비율이 전체 튜플의 20퍼 이상
아래와 같은 쿼리로 직접 확인해봐도 좋다. (이 값은 튜닝을 해도 괜찮아 보이긴 하는데, 기본 값인 이유가 있지 않을까!?)
SHOW autovacuum_vacuum_scale_factor;
SHOW autovacuum_vacuum_threshold;
실제 디스크 정리를 하는 것도 아닌 vacuum은 그렇다면 무엇을 정리하는 것일까?
이 내용을 이해하기 위해서는 FSM(Free Space Map) 이라는 것과 페이지라는 것을 알고 있어야 한다.
페이지는 간단하다.
아래와 같은 쿼리를 수행할 때 데이터는 데이터가 저장되어있는 페이지(1 페이지당 8kb)들을 스캔하며 결과를 반환한다. 이 때 데이터가 저장되어 있는 공간을 페이지라고 한다.
select * from accounts;
FSM은 자유 공간 지도라는 이름에서부터 유추 가능하듯이 위에서 말한 페이지 내에 어떤 페이지가 비어있는 페이지인지에 대한 정보를 갖고 있는 지도이다. 이 FSM을 통해 많은 양의 데이터를 가져오더라도 특정 페이지는 비어있기 때문에 스캔하지 않아 성능을 더 좋게 해줄 수 있는 셈이다.
Vacuum이 발생하면 바로 이 FSM에 Dead Tuple이 어떤 페이지에 위치했는지 정보를 전달하여 해당 페이지는 더 이상 읽지 않게 한다. 이게 바로 Vacuum이다.
실제 아래의 쿼리를 수행한 결과
vacuum accounts;
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
데드 튜플의 개수가 줄어있는 것을 확인 가능하다.
full vacuum을 하고 나면 디스크가 잘 정리되어 있는 것을 확인 가능하다.
vacuum full accounts;
SELECT pg_size_pretty(pg_total_relation_size('accounts'));
Transaction ID Wraparound
다음으로 앞서 이야기 했던 Transaction ID Wraparound 문제가 무엇이고 vacuum이 이를 어떻게 해결하는지 살펴보자.
기본적으로 Postgresql은 트랜잭션 아이디를 저장하는데 4바이트를 이용한다 이는 약 42억(2^32 - 1) 까지의 트랜잭션 id를 이용하는데, 이를 넘어서면 tx id가 0부터 시작이 된다. 이 과정에서 Transaction ID Wraparound(이름이 너무 기니 txWr이라는 별칭을 주겠음) 라는 문제가 발생한다. 왜냐하면 tx id가 0부터 시작하면 xmin이 42억으로 설정된 데이터는 읽히지 않게 된다.
이런 문제를 vacuum freeze 라는 것으로 해결이 가능한데, 트랜잭션 id가 0으로 설정됐을 때 다른 레코드의 xmin, xmax 값을 frozenXid 라는 값을 할당해 항상 읽힐 수 있도록 한다.
아래와 같은 쿼리로 freeze가 발생하는 임계 tx id를 확인 가능하다. (기본값은 2억)
show autovacuum_freeze_max_age;
ctid는 물리적인 위치라고 했는데, 왜 변경되리까? 라는 의문을 갖고 찾아보기 시작했다. 이 과정에서 vacuum이라는 것을 알게 됐고, Dead Tuple 이라는 것 때문에 데이터가 삽입또는 삭제 되면 과거의 튜플(Dead Tuple)이 원래 갖고 있던 ctid를 갖고 현재 버전의 튜플이 다음 페이지에 이어서 저장 되는 것을 알게 됐다. full vacuum이 일어나면 원래 맨 처음 할당됐던 ctid로 돌아가는 것도 확인했다.
아래 쿼리를 실행해보면 실제 그런지 확인이 가능하다.
UPDATE accounts SET balance = balance + 50 WHERE user_name = 'Alice';
select user_name , ctid from accounts a;
UPDATE accounts SET balance = balance + 50 WHERE user_name = 'Alice';
select user_name , ctid from accounts a;
UPDATE accounts SET balance = balance + 50 WHERE user_name = 'Alice';
select user_name , ctid from accounts a;
vacuum full accounts;
select user_name , ctid from accounts a ;
이번에 쿼리 플랜을 보며 꽤나 많은 내용을 알게 됐다. 튜닝에 대한 정보는 링크 이분이 되게 자세히 설명을 해주셨는데, 참고하면 정말 도움이 될 것 같다.