커넥션이 하나 더 열려 backend가 fork될 때, 그 backend만을 위해 새로 확보되는 메모리는 무엇입니까?
shared_buffers를 살아있는 커넥션 수로 나눈 몫이 배정됩니다.
shared_buffers는 커넥션과 무관하게 서버 시작 시 한 덩어리로 잡히고 모든 프로세스가 같은 페이지 캐시를 함께 씁니다. 커넥션별로 잘라 쓴다면 같은 테이블 페이지가 커넥션 수만큼 중복 적재될 것입니다.
정답work_mem과 catalog cache처럼 그 프로세스 안에서만 쓰이는 private 메모리입니다.
backend는 부모에게서 shared memory 매핑을 물려받아 시작하지만, 정렬 버퍼나 카탈로그 캐시처럼 자기 쿼리를 처리하는 데 필요한 공간은 따로 확보합니다. 커넥션 1,000개가 각각 수 MB를 들고 있으면 그 합만으로 수 GB가 되는 이유가 여기입니다.
proc array가 한 칸 늘어나면서 shared memory 전체 크기가 커집니다.
proc array는 max_connections를 보고 서버 시작 시점에 미리 다 잡아둡니다. 그래서 max_connections를 바꾸려면 재시작이 필요하고, 커넥션이 붙고 떨어지는 동안 배열 크기 자체는 변하지 않습니다.
없습니다. fork된 자식은 부모의 메모리를 그대로 물려받아 계속 공유합니다.
fork 직후에는 copy-on-write로 같은 물리 페이지를 보지만, 쓰기가 일어나는 순간 그 페이지는 각자의 것으로 갈라집니다. 끝까지 공유가 유지되는 쪽은 명시적으로 attach한 shared memory입니다.
해설
backend가 쓰는 메모리는 두 갈래입니다. shared memory는 서버 시작 시 한 번 잡혀 모든 프로세스가 attach하는 공용 영역이고, private 메모리는 fork된 각 backend가 자기 쿼리를 처리하려고 따로 쓰는 영역입니다. 이 구분이 커넥션 비용의 실체입니다. 커넥션을 늘려도 shared_buffers는 그대로지만 private 메모리는 커넥션 수에 비례해 늘어납니다. pooler가 하는 일이 바로 이 비례 관계를 끊는 것입니다. 클라이언트 연결 수천 개를 backend 수십 개로 접어서, 늘어나는 쪽이 프로세스가 아니라 대기 큐가 되게 만듭니다.
-- 애플리케이션 서버를 20대로 늘리면서 각 서버의 커넥션 풀 상한을 40으로 두었습니다.-- 쿼리도 데이터도 그대로인데 모든 종류의 쿼리가 함께 느려졌습니다.SELECT state,count(*)FROM pg_stat_activity
WHERE backend_type ='client backend'GROUPBY state;-- state | count-- ---------------------+--------- active | 23-- idle | 742-- idle in transaction | 6
실제로 일하는 backend는 23개뿐입니다. 놀고 있는 742개가 성능을 갉아먹는 경로는 무엇입니까?
정답스냅샷을 뜰 때마다 살아있는 backend 전부가 담긴 proc array를 훑으므로, 일하지 않는 backend도 그 비용에 그대로 기여합니다.
스냅샷 생성은 proc array를 선형으로 훑어 지금 진행 중인 트랜잭션 명단을 만드는 일입니다. 이 배열의 길이는 살아있는 backend 수이지 일하는 backend 수가 아닙니다. 스냅샷은 문장마다 새로 뜨므로 이 비용은 쿼리 수만큼 반복되고, 같은 배열에 걸리는 락 경합도 함께 커집니다.
idle backend가 shared_buffers를 붙잡고 있어 active 쿼리가 쓸 캐시가 줄어듭니다.
버퍼는 커넥션이 아니라 페이지 단위로 관리되고, 쿼리를 돌리지 않는 backend는 어떤 버퍼도 고정해두지 않습니다. 캐시가 부족해진 상황이었다면 hit 비율이 떨어지고 read가 늘었을 텐데 그 방향의 증상이 아닙니다.
postmaster가 모든 쿼리를 backend에 중계하므로 커넥션이 늘면 postmaster에서 병목이 생깁니다.
postmaster는 커넥션을 받아 fork할 때만 관여하고, 그 뒤로는 backend가 클라이언트와 직접 메시지를 주고받습니다. 이미 붙어 있는 742개는 postmaster를 전혀 건드리지 않습니다.
트랜잭션을 열어둔 것은 idle in transaction 6개이고, 그냥 idle은 트랜잭션 밖이라 xmin을 들고 있지 않습니다. 게다가 회수가 밀리면 bloat로 나타나지 모든 쿼리의 즉각적인 지연 상승으로 먼저 나타나지 않습니다.
해설
커넥션 수가 성능에 미치는 영향은 동시 실행량과 별개입니다. Postgres는 커넥션마다 프로세스를 두고, 살아있는 모든 프로세스의 상태를 proc array라는 공유 배열에 모아둡니다. 트랜잭션이 시작될 때 뜨는 스냅샷은 이 배열 전체를 훑어 진행 중인 xid 명단을 만드는 작업이라, 배열이 길어지면 아무 일도 하지 않는 커넥션이 모든 쿼리의 앞단에 비용을 붙입니다. 여기에 커넥션당 private 메모리까지 더해지면, 커넥션 수를 늘려도 처리량이 더는 늘지 않고 오히려 꺾이는 구간이 생깁니다.
이미 맺어진 커넥션에서는 postmaster를 다시 거치지 않고 같은 backend가 다음 쿼리를 곧장 받습니다. 절약되는 것은 연결 하나를 세우는 데 드는 고정 비용이지 쿼리를 처리하는 비용이 아닙니다.
아무것도 건너뛰지 않습니다. pooler는 동시에 도는 backend 수를 제한할 뿐입니다.
동시 실행을 제한하는 것도 pooler의 핵심 효과가 맞지만, 연결을 재사용한다는 사실 자체가 fork와 인증 비용을 매 요청에서 걷어냅니다. 짧은 요청을 많이 처리하는 서비스에서는 이쪽이 먼저 체감됩니다.
해설
pooler의 이득은 두 갈래이고 성격이 다릅니다. 하나는 연결 재사용입니다. fork와 인증, 세션 초기화는 커넥션 하나당 한 번만 내면 되는 비용이라, 요청마다 연결을 열고 닫는 애플리케이션에서 곧바로 사라집니다. 다른 하나는 동시 backend 수 제한입니다. proc array 스캔과 private 메모리, 락 경합처럼 살아있는 프로세스 수에 비례하는 비용을 눌러줍니다. 반면 parser부터 executor까지는 어느 쪽으로도 줄어들지 않습니다. 쿼리 자체가 느린 문제를 pooler로 풀려고 하면 여기서 막힙니다.
checkpoint마다 디스크 쓰기가 몰리는 것을 보고 background writer를 더 공격적으로 돌리기로 했습니다. 이 설정으로 실제로 얻게 되는 효과는 무엇입니까?
checkpoint가 flush할 dirty page가 줄어 checkpoint 구간의 쓰기 스파이크가 사라집니다.
미리 쓴 만큼 checkpoint가 할 일이 줄기는 하지만 부수 효과입니다. 같은 페이지가 checkpoint 전에 또 더러워지면 두 번 쓰게 되어 총 write는 오히려 늘 수 있습니다. 스파이크 자체를 다루는 손잡이는 checkpoint_completion_target 쪽입니다.
정답쿼리를 처리하던 backend가 빈 버퍼를 못 찾아 직접 디스크에 쓰는 상황이 줄어듭니다.
background writer의 본래 목적은 clean buffer를 미리 확보해두는 것입니다. 버퍼 풀이 전부 dirty면 새 페이지를 올리려는 backend가 쫓아낼 페이지를 자기 쿼리 도중에 직접 write해야 하고, 그 지연이 사용자 응답 시간에 그대로 실립니다.
WAL이 디스크로 flush되는 주기가 짧아져 커밋 지연이 줄어듭니다.
WAL flush는 walwriter와 커밋하는 backend가 맡고, background writer는 데이터 파일 쪽 dirty buffer만 다룹니다. 둘 다 더러워진 것을 내려보내지만 대상이 완전히 다릅니다.
dirty buffer를 내리는 일 전체를 넘겨받아 checkpointer가 할 일이 없어집니다.
checkpointer는 단순히 페이지를 쓰는 프로세스가 아니라, 어느 시점까지의 변경이 디스크에 반영되었는지를 WAL에 checkpoint 레코드로 못 박는 역할을 합니다. 복구를 어디서부터 시작할지 정하는 일이라 대체할 수 없습니다.
해설
background writer를 checkpoint 부담을 나눠 지는 프로세스로 설명하는 경우가 있는데, 공식 문서가 말하는 주 목적은 clean buffer 확보입니다. 노리는 것은 사용자 쿼리를 처리하던 backend가 버퍼를 비우느라 디스크 쓰기를 기다리는 일을 없애는 것이고, checkpoint가 쓸 페이지가 줄어드는 것은 그 결과로 따라오는 부수 효과에 가깝습니다. 이 구분이 중요한 이유는 진단 방향이 달라지기 때문입니다. checkpoint 구간에서만 지연이 튄다면 checkpoint 쪽 파라미터를 봐야 하고, 특정 구간과 무관하게 쓰기가 많은 쿼리의 응답이 들쭉날쭉하다면 clean buffer가 모자란 것을 의심해야 합니다.
물리 메모리 128GB의 전용 DB 서버입니다. 데이터는 약 300GB로 메모리에 다 올라가지 않고, 워크로드는 읽기가 대부분입니다. shared_buffers를 32GB(25%)에서 76GB(60%)로 올리고 재시작했더니 캐시를 더 준 쪽인데도 p99가 눈에 띄게 나빠졌습니다.
이 결과를 가장 잘 설명하는 것은 무엇입니까?
shared_buffers가 커지면 그 안을 뒤지는 탐색 비용이 버퍼 개수에 비례해 늘어납니다.
버퍼 조회는 buffer tag를 키로 하는 해시 테이블로 이루어져 버퍼 개수에 선형으로 끌려가지 않습니다. 크기 자체가 조회를 느리게 만드는 구조가 아닙니다.
shared_buffers를 늘린 만큼 각 backend의 work_mem이 줄어 정렬이 디스크로 넘어갑니다.
work_mem은 shared_buffers와 독립된 파라미터라 서로의 값을 깎지 않습니다. 둘을 합쳐 물리 메모리를 넘기는 일은 생길 수 있지만 그건 설정한 사람이 만드는 상황이지 자동으로 조정되는 관계가 아닙니다.
checkpoint가 훑어야 할 버퍼가 늘어 체크포인트마다 서버가 멈춥니다.
버퍼가 늘면 checkpoint가 스캔할 대상이 늘어나는 것은 맞지만, 그 영향은 checkpoint 구간에 몰린 쓰기 부하로 나타납니다. 읽기가 대부분인 워크로드에서 p99 전반이 나빠진 것을 설명하기 어렵습니다.
정답OS 페이지 캐시가 얇아져, shared_buffers에서 밀린 페이지를 다시 읽을 때 곧장 디스크까지 내려갑니다.
Postgres는 자기 버퍼에서 못 찾으면 OS에 read()를 겁니다. 이전에는 그 요청의 상당수를 OS 페이지 캐시가 받아줬는데, 76GB를 Postgres가 가져가면 남는 캐시가 얇아져 같은 read가 실제 디스크 I/O가 됩니다. 데이터가 메모리보다 크면 이 두 번째 캐시의 적중률이 꼬리 지연을 좌우합니다.
해설
Postgres는 자기 버퍼 풀과 OS 페이지 캐시라는 두 겹의 캐시 위에서 동작합니다. shared_buffers를 키우면 첫 겹은 두꺼워지지만 두 번째 겹은 그만큼 얇아집니다. 데이터가 메모리에 다 들어가는 규모라면 첫 겹만으로 끝나므로 키울수록 이득이지만, 데이 터가 메모리보다 크면 어느 쪽에서도 못 찾는 요청이 남고 그 요청은 디스크까지 갑니다. 두 겹에 같은 페이지가 중복으로 올라가는 낭비까지 겹치면 전체 적중률은 오히려 떨어질 수 있습니다. 공식 문서가 40%를 넘겨 잡는 것이 더 작게 잡는 것보다 나을 가능성은 낮다고 못 박은 배경이 이것입니다. 값을 정하기 전에 데이터 크기와 메모리 크기의 관계를 먼저 봐야 합니다.
Aggregate (cost=19222.51..19222.52 rows=1 width=8) (actual time=31.204..31.206 rows=1 loops=1)
Buffers: shared hit=210 read=8136
-> Seq Scan on demo (cost=0.00..19197.51 rows=9995 width=0) (actual time=0.086..30.112 rows=9999)
Filter: (id < 10000)
Buffers: shared hit=210 read=8136
-- 이 쿼리가 도는 동안 pg_stat_activity의 wait_event를 반복 표집했지만
-- IO / DataFileRead 대기는 거의 관측되지 않았습니다.
8,136개 페이지를 read했는데 디스크 대기는 잡히지 않았습니다. 두 관측은 서로 모순입니까?
정답모순이 아닙니다. read는 OS에 요청했다는 뜻이고, OS 페이지 캐시가 답을 주면 디스크까지 가지 않습니다.
Postgres 가 긋는 경계는 shared_buffers 바깥입니다. 그 바깥에 OS 페이지 캐시가 한 겹 더 있어서, read로 집계된 요청이 실제로 디스크에 닿았는지는 Postgres 통계만으로는 알 수 없습니다. 8,136개 페이지가 약 65MB이고 31ms에 끝났다는 점도 메모리에서 왔다는 쪽을 가리킵니다.
모순입니다. read는 디스크에서 읽어온 블록 수이므로 그만큼의 I/O 대기가 반드시 잡혀야 합니다.
이 해석이 가장 흔한 오독입니다. Postgres는 디스크에 닿았는지가 아니라 자기 버퍼에서 찾았는지를 셉니다. read를 디스크 I/O로 곧장 번역하면 캐시가 잘 도는 서버를 I/O 병목으로 오진하게 됩니다.
모순이 아닙니다. BUFFERS가 보여주는 값은 실제 접근이 아니라 planner가 추정한 페이지 수입니다.
hit과 read는 실행 중에 실제로 센 값이라 ANALYZE 없이는 나오지도 않습니다. 추정치는 같은 줄의 cost와 rows 쪽이고, 그래서 estimated와 actual을 나란히 놓고 비교할 수 있는 것입니다.
버퍼가 모자라 dirty page를 쫓아내야 했다면 쓰기 대기로 나타났겠지만, read 자체는 버퍼가 넉넉해도 발생합니다. 처음 읽는 페이지는 정의상 버퍼에 없기 때문입니다.
해설
hit과 read는 executor가 storage 경계를 넘은 횟수이지 디스크에 닿은 횟수가 아닙니다. 경계는 shared_buffers 바깥에 그 어져 있고 그 너머에 OS 페이지 캐시가 한 겹 더 있습니다. 그래서 read가 크게 잡혀도 지연은 작을 수 있고, 반대로 서버를 막 재시작해 두 캐시가 모두 비어 있으면 같은 read 수치에 훨씬 큰 지연이 붙습니다. 같은 쿼리가 어떤 날은 빠르고 어떤 날은 느린 현상 대부분이 이 두 겹의 상태 차이입니다. read 수치만 보고 shared_buffers를 키우는 진단으로 넘어가기 전에, 그 read가 실제로 기다림을 만들고 있는지를 대기 이벤트나 track_io_timing으로 확인해야 합니다.
슬롯 2번의 t_ctid가 자기 자신이 아니라 (0,4)를 가리키고 있습니다. 이 값이 뜻하는 것은 무엇입니까?
이 튜플이 4번 슬롯으로 물리적으로 옮겨졌고 2번 슬롯은 비었습니다.
옛 튜플은 자리에 그대로 남습니다. 2번 슬롯의 lp_flags가 1(LP_NORMAL)이고 lp_off도 8128로 유효한 것이 그 증거입니다. 자리를 옮긴 것이 아니라 버전이 하나 더 생긴 것입니다.
4번 슬롯이 이 튜플의 이전 버전이고 인덱스는 4번을 가리킵니다.
t_ctid는 더 새로운 버전 쪽을 가리키는 앞방향 포인터입니다. 4번 슬롯의 t_xmin이 743으로 2번의 742보다 나중이고 t_xmax가 0이라 아직 살아 있으니, 4번이 이전 버전일 수 없습니다. HOT update에서 인덱스는 옛 위치인 2번을 계속 가리킵니다.
정답이 튜플의 다음 버전이 같은 페이지의 4번 슬롯에 있습니다.
t_ctid는 평소 자기 위치를 가리키다가, UPDATE로 새 버전이 생기면 그 위치로 갱신됩니다. 한 row의 여러 버전이 이 필드로 이어진 연결 리스트가 되고, 인덱스는 옛 위치만 알아도 이 사슬을 따라 최신 버전에 도달합니다.
이 페이지에서 4번 슬롯부터 빈 공간이 시작됩니다.
페이지의 빈 공간 경계는 개별 튜플의 t_ctid가 아니라 페이지 헤더의 pd_lower와 pd_upper가 정합니다. 튜플 헤더는 페이지 전체의 여유 공간을 알지 못합니다.
해설
UPDATE는 기존 튜플을 제자리에서 고치지 않고 새 튜플을 추가합니 다. 옛 튜플에는 t_xmax에 UPDATE 트랜잭션의 xid가 찍히고 t_ctid가 새 버전의 위치로 갱신됩니다. 위 출력에서 슬롯 2의 t_xmax와 슬롯 4의 t_xmin이 똑같이 743인 것은 한 트랜잭션이 한쪽을 죽이고 한쪽을 만들었다는 뜻입니다. t_infomask2의 16386과 32770은 각각 HEAP_HOT_UPDATED(0x4000)와 HEAP_ONLY_TUPLE(0x8000)에 컬럼 수 2가 더해진 값이라, 이번 UPDATE가 HOT으로 처리되어 인덱스 엔트리가 추가되지 않았음까지 읽어낼 수 있습니다.
MAXALIGN을 적용한 크기가 정확히 1,024바이트인 튜플 하나를 이 페이지에 INSERT할 수 있습니까?
정답없습니다. line pointer 4바이트가 함께 필요해 1,028바이트가 있어야 합니다.
튜플 하나가 들어가려면 데이터 영역에 튜플 본체가, 슬롯 디렉터리에 그것을 가리킬 ItemId 4바이트가 동시에 자리를 잡아야 합니다. 여유는 pd_lower와 pd_upper 사이의 1,024바이트뿐이라 4바이트가 모자랍니다.
있습니다. 여유 공간이 정확히 1,024바이트라 딱 맞습니다.
튜플 크기만 세고 슬롯 디렉터리를 빠뜨린 계산입니다. 페이지는 양쪽에서 채워지므로 INSERT 한 건은 언제나 두 방향을 함께 소비합니다.
있습니다. special space가 비어 있어 8,192바이트 지점까지 쓸 수 있습니다.
힙 페이지의 special space 크기가 0인 것은 맞지만, pd_upper 아래쪽은 이미 기존 튜플들이 채우고 있습니다. 실제 여유는 pd_special까지의 거리가 아니라 pd_lower와 pd_upper 사이입니다.
판단할 수 없습니다. 이 테이블의 FILLFACTOR를 알아야 합니다.
FILLFACTOR는 이 페이지를 INSERT 대상으로 고를지 말지를 정하는 기준이라, 낮게 잡혀 있으면 오히려 더 일찍 다른 페이지로 넘어갑니다. 물리적으로 들어가느냐는 pd_lower와 pd_upper만으로 답이 나옵니다.
해설
페이지의 여유 공간은 pd_upper에서 pd_lower를 뺀 값입니다. 여기서는 1,120에서 96을 뺀 1,024바이트입니다. 그런데 INSERT 한 건은 이 공간을 양쪽에서 갉아먹습니다. 튜플 본체는 pd_upper를 앞으로 당기고, 그 튜플을 가리킬 ItemId 4바이트는 pd_lower를 뒤로 밉니다. 필요한 것은 1,028바이트이고 4바이트가 모자라 이 페이지는 후보에서 탈락합니다. PostgreSQL은 FSM을 뒤져 다른 페이지를 찾고, 없으면 파일 끝에 새 페이지를 붙입니다. 한 가지 덧붙이면, LP_UNUSED 상태의 빈 슬롯이 남아 있을 때는 그 ItemId를 재사용해 4바이트를 아낄 여지가 있습니다. 다만 페이지를 고를 때 쓰는 PageGetHeapFreeSpace는 재사용 가능한 슬롯이 있는지 따지지 않고 언제나 ItemId 4바이트를 미리 빼고 여유를 계산하므로, 이 페이지는 어느 쪽이든 후보에서 빠집니다.
CREATETABLE members (
id bigintPRIMARYKEY,
email text,statustext,
last_login timestamptz,
memo text);CREATEUNIQUEINDEXON members (lower(email));CREATEINDEXON members (id)WHEREstatus='active';
이 테이블의 페이지에는 새 튜플이 들어갈 여유가 충분합니다. 다음 UPDATE 중 HOT update로 처리될 수 있는 것은 무엇입니까?
UPDATE members SET status = 'inactive' WHERE id = 7;
status는 부분 인덱스의 WHERE predicate에 등장합니다. 값이 바뀌면 그 row가 인덱스에 포함되는지 여부 자체가 달라지므로 인덱스를 갱신해야 하고 HOT 조건이 깨집니다. 인덱스 키 목록만 훑으면 놓치는 지점입니다.
정답UPDATE members SET last_login = now() WHERE id = 7;
last_login은 인덱스 키에도, 표현식에도, 부분 인덱스의 predicate에도 등장하지 않습니다. 갱신된 컬럼이 어떤 인덱스와도 무관하고 같은 페이지에 자리가 있으므로, 새 인덱스 엔트리 없이 HOT chain으로 연결됩니다.
UPDATE members SET email = 'new@x.com' WHERE id = 7;
email 자체는 인덱스 키가 아니지만 lower(email) 표현식 인덱스가 이 컬럼을 참조합니다. 표현식이 계산에 쓰는 컬럼도 인덱스에 등장하는 컬럼으로 취급됩니다.
UPDATE members SET memo = NULL, id = id + 1000 WHERE id = 7;
id는 PRIMARY KEY라 인덱스 키 컬럼입니다. 한 UPDATE가 여러 컬럼을 건드릴 때는 그중 하나만 인덱스에 걸려도 전체가 HOT에서 제외됩니다.
해설
HOT update의 첫 번째 조건은 갱신된 컬럼이 그 테이블의 어떤 인덱스 정의에도 등장하지 않는 것인데, 여기서 등장한다는 말의 범위가 넓습니다. 인덱스 키 컬럼은 물론이고 표현식 인덱스가 계산에 쓰는 컬럼, 부분 인덱스의 WHERE predicate에 나오는 컬럼까지 전부 포함됩니다. BRIN 같은 summarizing 인덱스만 이 검사에서 빠집니다. 두 번째 조건인 같은 페이지의 여유 공간은 문제에서 충족된다고 주었으니 판정은 컬럼 조건만으로 끝납니다. 운영에서 HOT 비율이 기대보다 낮게 나온다면, 무심코 만들어둔 부분 인덱스나 표현식 인덱스가 조건을 조용히 깨고 있는 경우가 많습니다.
INSERTINTO docs
SELECT1, string_agg(md5(g::text),'')FROM generate_series(1,5000) g;SELECT pg_column_size(content)AS column_size,
pg_column_compression(content)AS compression
FROM docs WHERE id =1;-- column_size | compression-- -------------+--------------- 160000 |SELECT pg_relation_size('docs')AS main_size;-- main_size-- ------------- 8192
content 컬럼의 값에 실제로 무슨 일이 일어났습니까?
TOAST가 동작하지 않았습니다. column_size가 원본 크기 그대로입니다.
pg_column_size는 외부 저장된 값에 대해 TOAST 테이블에 실제로 들어간 크기를 돌려줍니다. 압축이 안 걸리면 그 크기가 원본과 같아지므로, 이 숫자만으로는 in-line인지 외부인지 구분되지 않습니다.
pglz로 압축되어 본 테이블 안에 in-line으로 저장되었습니다.
압축이 걸렸다면 pg_column_compression이 pglz를 돌려줍니다. md5 해시를 이어붙인 문자열은 반복 패턴이 없어 pglz가 거의 줄이지 못하고, 압축해도 이득이 없으면 압축본을 쓰지 않습니다.
값이 여러 chunk로 쪼개져 본 테이블의 여러 페이지에 나뉘어 저장되었습니다.
chunk로 쪼개지는 것은 맞지만 장소가 틀렸습니다. chunk는 본 테이블이 아니라 전용 TOAST 테이블에 저장됩니다. 본 테이블의 한 튜플이 여러 페이지에 걸치는 일은 없습니다.
정답압축이 통하지 않아 TOAST 테이블로 외부 저장되었고, 본 테이블에는 포인터만 남았습니다.
compression이 비어 있으니 압축은 걸리지 않았고, 본 테이블이 페이지 한 장 크기 그대로인 것은 160KB가 그 안에 없다는 뜻입니다. 두 지표를 합치면 값이 옆 테이블로 빠졌다는 결론만 남습니다.
해설
튜플 크기가 임계값(약 2KB)을 넘으면 PostgreSQL은 먼저 압축을 시도하고, 압축 후에도 임계값 아래로 내려가지 않으면 값을 떼어내 pg_toast.pg_toast_<oid> 테이블로 옮긴 뒤 본 테이블에는 18바이트짜리 포인터만 남깁니다. 판정하려면 세 지표를 함께 읽어야 합니다. pg_column_compression이 비어 있으면 압축은 없었고, pg_relation_size가 페이지 한 장이면 값이 본 테이블에 없으며, pg_column_size는 어느 경우든 실제 저장 크기를 돌려주므로 단독으로는 근거가 되지 못합니다. 외부 저장된 컬럼은 읽을 때마다 TOAST 테이블 조회가 따라붙으므로, 이런 컬럼이 있는 테이블에 SELECT * 를 습관적으로 쓰면 필요 없는 I/O를 매번 지불하게 됩니다.
-- update가 잦은 테이블의 HOT 비율을 올리려고 적용ALTERTABLE members SET(fillfactor=80);
VACUUM members;
며칠이 지 나도 기존 데이터가 들어 있는 페이지들의 밀도는 그대로이고 HOT update 비율도 거의 오르지 않았습니다. 원인은 무엇입니까?
정답fillfactor는 새로 쓰이는 페이지에만 적용되고, VACUUM은 기존 페이지를 다시 채우지 않습니다.
ALTER TABLE은 storage parameter만 바꿀 뿐 이미 놓인 튜플을 건드리지 않습니다. 일반 VACUUM은 페이지 안에서 남은 튜플을 밀어 모으기는 하지만, 살아 있는 튜플을 다른 페이지로 옮겨 밀도를 낮추지는 않습니다. 기존 페이지에 여유를 만들려면 테이블을 통째로 다시 쓰는 VACUUM FULL, CLUSTER, pg_repack이 필요합니다.
ANALYZE는 플래너용 통계를 수집할 뿐 페이지 배치와 무관합니다. 어떤 통계 갱신도 이미 디스크에 놓인 튜플의 위치를 바꾸지 않습니다.
fillfactor를 80보다 더 낮춰야 효과가 나타납니다.
값이 아니라 적용 시점이 문제입니다. 기존 페이지가 다시 쓰이지 않는 한 70으로 낮춰도 결과는 같습니다.
해설
ALTER TABLE로 바꾼 fillfactor는 그 이후 새로 할당되거나 새로 채워지는 페이지에만 효력이 있습니다. 이미 가득 찬 페이지는 그대로 남고, 그 페이지의 row를 UPDATE하면 새 버전이 들어갈 자리가 없어 다른 페이지로 나가면서 HOT 조건이 깨집니다. 일반 VACUUM은 dead tuple의 line pointer를 LP_UNUSED로 되돌리고 남은 튜플을 페이지 안에서 밀어 모아 빈틈을 없애기는 합니다. 다만 그렇게 되찾는 것은 죽은 튜플이 쓰던 공간뿐이고, 살아 있는 튜플을 다른 페이지로 옮기지는 못하므로 라이브 튜플로 꽉 찬 페이지의 밀도는 그대로입니다. 기존 페이지까지 새 밀도로 다시 쓰려면 테이블 전체를 다시 쓰는 작업이 필요하고, 운영 중이라면 락을 오래 잡지 않는 pg_repack이 현실적인 선택입니다.
-- PostgreSQL 18, 64비트 리눅스 기본 빌드 (MAXALIGN 8, 블록 크기 8KB)-- fillfactor는 기본값 100이고, TOAST가 걸릴 만큼 큰 값은 없습니다.CREATETABLE readings (
sensor_id int,-- 고정 길이 4바이트
taken_at timestamptz,-- 고정 길이 8바이트
amount doubleprecision,-- 고정 길이 8바이트
tag text,-- 모든 row에서 3글자 ASCII
memo text-- 모든 row에서 NULL);
이 테이블의 row 하나가 페이지의 튜플 영역에서 차지하는 바이트 수와, 빈 페이지 한 장에 최대 몇 개가 들어가는지를 바르게 짝지은 것은 무엇입니까?
튜플 영역에서 48바이트를 차지하고 빈 페이지에 157개가 들어갑니다.
컬럼 사이 정렬 패딩을 빼고 4 + 8 + 8 + 4를 그대로 더한 값입니다. sensor_id가 끝나는 28바이트 지점에서 taken_at의 8바이트 경계까지 4바이트를 비워야 하므로 실제로는 52바이트입니다. 공교롭게도 48바이트는 8바이트 컬럼 두 개를 앞으로 당겨 선언했을 때 진짜로 나오는 크기이고, 그때는 페이지당 157개가 맞습니다. 같은 데이터인데 선언 순서만으로 136개와 157개가 갈립니다.
튜플 영역에서 52바이트를 차지하고 빈 페이지에 145개가 들어갑니다.
52바이트는 튜플 자체의 길이이지만 페이지에 놓일 때 시작 지점이 MAXALIGN 경계여야 해서 뒤에 4바이트가 더 붙습니다. 즉 튜플 하나가 실제로 먹는 자리는 56바이트이고, 이 4바이트를 빠뜨리면 페이지당 개수가 136개가 아니라 145개로 부풀어 오릅니다.
정답튜플 영역에서 56바이트를 차지하고 빈 페이지에 136개가 들어갑니다.
t_hoff 24에서 시작해 sensor_id 4바이트, 8바이트 경계를 맞추는 패딩 4바이트, taken_at 8바이트, amount 8바이트, 1바이트 헤더가 붙은 짧은 varlena인 tag 4바이트를 더하면 52바이트입니다. NULL인 memo는 자리를 차지하지 않습니다. 페이지 에 놓일 때 MAXALIGN으로 56이 되고, 8,168바이트를 ItemId 4바이트를 더한 60으로 나누면 136개입니다.
튜플 영역에서 64바이트를 차지하고 빈 페이지에 120개가 들어갑니다.
NULL이 하나 있으니 null bitmap이 붙어 t_hoff가 32로 올라간다고 본 계산입니다. bitmap은 컬럼 하나당 1비트라 5컬럼이면 1바이트뿐이고, 23 + 1 = 24는 이미 8의 배수라 t_hoff는 24 그대로입니다. bitmap이 2바이트가 되는 9컬럼부터 비로소 32로 올라갑니다.
해설
튜플 크기는 세 층의 정렬을 차례로 통과해야 나옵니다. 첫째, 사용자 데이터의 시작 지점인 t_hoff입니다. HeapTupleHeaderData의 struct 크기는 23바이트지만 t_hoff는 MAXALIGN의 배수여야 하므로 24부터 데이터가 시작합니다. NULL이 있으면 헤더 뒤에 null bitmap이 끼어드는데, 컬럼당 1비트라 8컬럼까지는 1바이트로 끝나고 23 + 1 = 24가 그대로 8의 배수라 t_hoff는 여전히 24입니다. 8컬럼 이하 테이블에서 bitmap은 정렬 패딩 자리를 얻어 쓰는 셈이라 공짜입니다. 둘째, 컬럼마다 자기 타입의 경계에서 시작합니다. sensor_id는 24부터 27까지 쓰고, 다음 taken_at은 8의 배수인 32부터 시작해야 하므로 28부터 31까지 4바이트가 패딩으로 버려집니다. 이어서 taken_at이 32부터 39, amount가 40부터 47, tag가 48부터 51이라 튜플 길이는 52바이트입니다. 셋째, 튜플이 페이지에 놓일 때 전체 크기가 다시 MAXALIGN되어 56바이트를 소비합니다. 페이지에서 쓸 수 있는 자리는 8,192에서 페이지 헤더 24를 뺀 8,168바이트이고 INSERT 한 건은 튜플 56에 ItemId 4를 더한 60바이트를 먹으므로 136개가 한계입니다. 실무에서 중요한 것은 두 번째 층입니다. 컬럼을 넓은 것부터 좁은 것 순으로 선언하면 이 테이블은 48바이트로 줄어 같은 페이지에 157개가 들어갑니다. 데이터는 한 글자도 안 바뀌었는데 선언 순서만으로 저장 공간이 15% 넘게 차이 납니다.
이 snapshot을 들고 있는 트랜잭션에게, xid 750이 INSERT한 튜플은 보입니까?
보입니다. 750이 xmin(748)과 xmax(752) 사이에 있습니다.
그 구간에 있다는 사실만으로는 아무것도 결정되지 않습니다. 구간 안의 xid는 xip 목록에 있으면 진행 중, 없으면 이미 종료된 것으로 갈립니다. 749나 751이었다면 이 답이 맞습니다.
정답안 보입니다. 750이 xip 목록에 있어 snapshot을 찍은 순간 아직 진행 중이었습니다.
xip 목록은 snapshot 생성 시점에 끝나지 않은 트랜잭션의 명단입니다. INSERT가 확정되지 않은 상태 이므로 이 튜플은 존재 자체가 보이지 않습니다. 750이 그 뒤에 커밋하더라도 이 snapshot을 쓰는 동안에는 판정이 바뀌지 않습니다.
보입니다. 750은 xmax(752)보다 작으므로 이미 커밋된 과거입니다.
xmax보다 작다는 것은 snapshot을 찍을 때 이미 시작은 했다는 뜻일 뿐입니다. 시작한 것과 끝난 것은 다릅니다.
CLOG를 읽어봐야 알 수 있습니다.
CLOG는 종료된 트랜잭션이 커밋인지 롤백인지 가릴 때 필요합니다. 750은 xip 목록에 있어 아직 종료되지 않았으므로, CLOG를 볼 것도 없이 안 보인다고 판정됩니다.
해설
xmin:xmax:xip_list 형식은 xid 번호선을 세 구간으로 나눕니다. 748 미만은 모두 종료된 과거이고, 752 이상은 snapshot을 찍은 시점에 아직 완료되지 않은 구간입니다(시작하지 않았거나 진행 중입니다). 그 사이는 xip 목록으로 갈립니다. 목록에 있으면 진행 중이라 안 보이고, 목록에 없으면 종료된 것이므로 CLOG에서 커밋 여부를 확인한 뒤 판정합니다. 여기서 749와 751은 구간 안에 있지만 목록에 없으므로 이미 끝난 트랜잭션이고, 커밋되었다면 그들이 만든 튜플은 보입니다. 구간 안에 있다는 것과 보인다는 것을 같은 말로 읽으면 이후 판정이 전부 어긋납니다.
Session A는 Repeatable Read로 트랜잭션을 열어 snapshot 806:812:806을 확보했습니다. 그 뒤 Session B가 xid 812로 balance를 7000으로 UPDATE하고 커밋했습니다. 지금 Session A가 SELECT balance FROM accounts WHERE id = 1을 실행하면 무엇이 나옵니까?
7000. Session B가 커밋을 마쳤으므로 최신 값이 보입니다.
커밋 여부는 판정의 한 축일 뿐이고 다른 축이 snapshot입니다. 812는 Session A가 snapshot을 찍은 시점에 아직 완료되지 않은 트랜잭션이라, 그 뒤에 커밋을 했더라도 이 snapshot에서는 없는 것으로 취급됩니다.
두 행이 모두 나옵니다. 페이지에 튜플이 두 개 있습니다.
물리적으로 두 개가 있는 것은 맞지만 가시성 판정이 그중 하나만 통과시킵니다. 여러 버전이 동시에 존재하면서도 결과 집합에는 하나만 나오게 하는 것이 MVCC가 하는 일입니다.
에러가 납니다. 다른 트랜잭션이 같은 row를 이미 커밋했습니다.
읽기만으로는 충돌이 나지 않습니다. Repeatable Read에서 에러가 발생하는 것은 이 트랜잭션이 같은 row를 쓰려고 할 때입니다.
정답5000. 구 버전이 보입니다.
구 버전의 t_xmin은 799라 snapshot의 xmin(806)보다 작은 확정된 과거이고, t_xmax는 812라 snapshot의 xmax(812)와 같아 아직 완료되지 않은 트랜잭션으로 분류됩니다. 만든 트랜잭션은 보이고 죽인 트랜잭션은 보이지 않으므로 이 튜플은 살아 있는 것으로 판정됩니다.
해설
판정은 두 단계로 나뉩니다. 먼저 t_xmin이 이 snapshot에서 보이는가, 그다음 t_xmax가 유효한 삭제인가. 구 버전은 t_xmin 799가 snapshot의 xmin(806) 아래라 1단계를 통과하고, t_xmax 812가 snapshot의 xmax(812) 이상이라 아직 확정되지 않은 삭제로 판정되어 살아남습니다. 신 버전은 t_xmin 812가 같은 이유로 미완료 구간에 속해 1단계에서 탈락합니다. Session B의 커밋은 실제로 끝났지만 Session A의 snapshot은 그 사건보다 먼저 찍혔고, 그 사진을 트랜잭션 끝까지 그대로 들고 가는 것이 Repeatable Read입니다.
-- Session A, 앞의 트랜잭션을 그대로 유지한 채 이어서 실행UPDATE accounts SET balance = balance +500WHERE id =1;
Session A가 같은 트랜잭션 안에서 이어서 이 UPDATE를 실행합니다. 무슨 일이 일어납니까?
정답직렬화 실패 에러가 나고 트랜잭션이 중단됩니다.
Repeatable Read의 UPDATE는 트랜잭션 시작 시점에 커밋되어 있던 버전만 대상으로 찾습니다. 그 버전은 이미 Session B가 갱신하고 커밋한 뒤라, PostgreSQL은 최신 버전을 따라가 덮어쓰는 대신 could not serialize access due to concurrent update 에러로 트랜잭션을 롤백합니다.
5000에 500을 더한 5500이 저장됩니다.
그렇게 되면 Session B가 커밋한 7000이 흔적 없이 사라집니다. lost update를 막기 위해 Repeatable Read는 이 상황을 성공이 아니라 에러로 처리합니다.
7000에 500을 더한 7500이 저장됩니다.
Read Committed의 동작입니다. 그쪽은 UPDATE 직전에 snapshot을 다시 찍어 최신 버전 위에서 계산합니다. snapshot을 다시 찍지 않는 Repeatable Read는 같은 결말에 도달할 수 없습니다.
Session B의 트랜잭션이 끝날 때까지 대기하다가 진행됩니다.
Session B는 이미 커밋했으므로 기다릴 대상이 없습니다. 대기는 상대가 아직 진행 중일 때의 이야기이고, 여기서는 기다림 없이 즉시 판정이 납니다.
해설
읽기는 snapshot이 보여주는 버전을 그대로 쓰면 되지만 쓰기는 그럴 수 없습니다. 자기 snapshot이 보여준 버전을 그대로 갱신하면 다른 트랜잭션이 커밋한 변경이 덮여 사라지기 때문입니다. Repeatable Read는 이 모순을 감추지 않고 에러로 드러냅니다. 그래서 이 격리 수준을 쓰는 애플리케이션은 직렬화 실패를 잡아 트랜잭션을 통째로 재시도하는 경로를 반드시 갖춰야 합니다. 재시도 없이 격리 수준만 올리면 이상 현상 대신 실패율을 얻게 됩니다. Read Committed는 매 문장마다 snapshot을 다시 찍어 이 에러를 피하는 대신, 한 트랜잭션 안에서 같은 SELECT가 다른 값을 돌려주는 non-repeatable read를 허용합니다.
대량 INSERT 직후, 아무것도 변경하지 않고 SELECT count(*)만 반복해서 돌렸습니다. 그런데 첫 실행에서 상당한 양의 페이지가 dirty로 표시되고 이후 checkpoint에서 디스크 쓰기가 발생했습니다. 원인은 무엇입니까?
SELECT가 통계를 갱신하느라 시스템 카탈로그에 write가 발생했습니다.
플래너용 통계는 ANALYZE와 autovacuum이 갱신합니다. SELECT는 실행 계획을 세울 때 그 통계를 읽기만 합니다.
count(*)가 중간 집계 결과를 디스크에 쓰는 과정에서 발생했습니다.
임시 파일은 work_mem을 넘기는 정렬이나 해시에서 생기고, 그 경우에도 shared_buffers의 페이지를 dirty로 만들지는 않습니다. 단순 집계는 임시 파일 자체를 만들지 않습니다.
정답가시성 판정 결과를 튜플 헤더의 hint bit에 캐싱하면서 페이지가 변경되었습니다.
첫 조회는 각 튜플의 xmin이 커밋되었는지 CLOG를 뒤져야 합니다. 그 결과를 t_infomask에 HEAP_XMIN_COMMITTED 같은 비트로 적어두면 다음 조회부터 CLOG를 건너뛸 수 있고, 이 기록이 페이지를 dirty로 만듭니다.
Seq Scan이 읽은 페이지를 shared_buffers에 올리는 것 자체가 dirty 표시입니다.
읽어서 올리기만 한 버퍼는 clean 상태입니다. dirty는 버퍼의 내용이 디스크와 달라졌다는 뜻이라, 페이지를 실제로 수정해야만 붙습니다.
해설
가시성 판정의 진실 소스는 CLOG(pg_xact)입니다. 트랜잭션당 2비트로 커밋 여부를 기록하는데, 튜플마다 이걸 조회하면 비쌉니다. 그래서 PostgreSQL은 한 번 확인한 결과를 튜플 헤더 t_infomask의 hint bit에 캐시해둡니다. 읽기만 하는 쿼리가 페이지를 수정하는 이 동작 때문에, 대량 INSERT 직후의 첫 조회는 읽기 쿼리인데도 쓰기 부하를 만듭니다. 같은 쿼리를 두 번째 돌릴 때 빨라지는 이유의 상당 부분이 여기에 있습니다. 벤치마크에서 첫 회차 수치를 그대로 쓰면 이 일회성 비용을 조회 성능으로 잘못 읽게 됩니다.
CLOG는 freeze가 진행되면서 오래된 구간이 잘려나가므로 무한히 자라지 않습니다. 문제는 저장 공간이 아니라 번호 공간의 소비 속도입니다.
정답읽기만 하는 트랜잭션에도 xid가 강제로 할당되어 xid 소비 속도가 올라갑니다.
이 함수는 xid가 없는 트랜잭션에 xid를 그 자리에서 발급합니다. 1초에 하나면 하루 약 8만 6천 개, 한 달이면 약 260만 개가 데이터 변경 없이 소비됩니다. 값만 확인할 목적이라면 발급하지 않는 pg_current_xact_id_if_assigned()를 써야 합니다.
부작용은 없습니다. 읽기 전용 쿼리는 트랜잭션 ID를 소비하지 않습니다.
일반적으로는 맞지만 이 함수가 예외입니다. read-only 트 랜잭션은 원래 xid를 받지 않는데, 이 함수는 돌려줄 값을 만들기 위해 xid를 강제로 할당합니다.
매 호출이 snapshot을 새로 찍어 oldest xmin이 계속 앞당겨지고 VACUUM이 막힙니다.
방향이 반대입니다. 짧게 끝나는 트랜잭션은 oldest xmin을 붙잡지 않습니다. VACUUM을 막는 것은 오래 열려 있는 트랜잭션입니다.
해설
pg_current_xact_id()는 현재 트랜잭션의 xid를 돌려주는데, 아직 xid가 없으면 그 자리에서 발급합니다. PostgreSQL이 read-only 트랜잭션에 xid를 주지 않는 이유가 32비트뿐인 번호 공간을 아끼기 위해서인데, 이 호출이 그 절약을 무력화합니다. 한 달에 260만 개면 21억 개인 번호 공간을 당장 위협하지는 않습니다. 문제는 age(relfrozenxid)가 데이터 변경과 무관하게 계속 자라 anti-wraparound VACUUM이 그만큼 일찍 발동한다는 점이고, 쓰기가 거의 없는 인스턴스일수록 이 인위적인 소비가 차지하는 비중이 커집니다. 확인만 할 목적이라면 xid가 이미 할당된 경우에만 값을 돌려주고 아니면 NULL을 주는 pg_current_xact_id_if_assigned()가 정확한 도구입니다. 관측 도구가 관측 대상의 상태를 바꿔놓는 사례라, 모니터링 쿼리를 상시로 걸 때는 이런 부작용을 먼저 확인하는 편이 안전합니다.
HEAP_XMIN_FROZEN이 켜진 튜플은 xmin을 snapshot과 대조하는 단계 자체를 수행하지 않고 커밋된 과거로 확정되므로, t_xmin이 412로 남아 있어도 판정이 흔들리지 않습니다. 반면 lp=2는 412와 현재 xid의 거리가 약 26억이라 2^31을 넘어 미래로 뒤집히고, 아직 확정되지 않은 INSERT로 분류되어 탈락합니다.
둘 다 보입니다. t_xmin 412는 현재 xid 2600000000보다 작은 수이므로 과거입니다.
순서를 정하는 것은 숫자의 대소가 아니라 modulo 2^32 비교입니다. 어떤 xid에서 보든 과거로 읽히는 범위는 2^31개(약 21억 4700만)뿐이고, 거리가 그보다 멀어지면 작은 쪽이 미래가 됩니다. 412는 약 26억 떨어져 있어 이미 그 경계를 넘었습니다.
둘 다 안 보입니다. freeze는 t_xmin 값을 그대로 두므로 비교 결과가 달라지지 않습니다.
값을 보존하는 것은 맞지만 판정까지 같아지지는 않습니다. 가시성 검사는 t_infomask의 freeze 비트를 먼저 확인하고, 켜져 있으면 t_xmin을 꺼내 비교하는 경로로 내려가지 않습니다. 값이 남는 것은 사후 확인용이고 판정을 결정하는 것은 비트입니다.
lp=2만 보입니다. lp=1은 t_xmin이 FrozenTransactionId(2)가 아니라 412라서 freeze가 미완입니다.
PostgreSQL 9.4 이전의 방식입니다. 그 시절에는 t_xmin을 2로 덮어썼지만, 지금은 원래 xid를 보존한 채 t_infomask에 비트만 세팅하는 방식으로 바뀌었습니다. t_xmin이 412로 남아 있다는 것은 freeze가 덜 된 증거가 아니라 현재 구현이 정상 동작한 결과입니다.
해설
freeze는 튜플을 옮기거나 값을 다시 쓰는 작업이 아니라 헤더에 표식 하나를 남기는 작업입니다. VACUUM이 충분히 오래된 튜플을 만나면 t_infomask에 HEAP_XMIN_FROZEN을 세팅하고, 가시성 검사는 이 비트가 켜져 있으면 snapshot과의 xmin 비교를 아예 수행하지 않고 커밋된 과거로 확정합니다. 그래서 lp=1은 t_xmin이 412로 남아 있어도 그 값이 판정에 쓰이지 않습니다. 9.4 이전에는 t_xmin을 FrozenTransactionId(2)로 덮어쓰는 방식이었고, 지금은 원래 xid를 보존해 이 튜플을 누가 넣었는지 나중에 확인할 수 있게 합니다. 값이 남아 있다는 것과 그 값으로 비교한다는 것은 별개입니다. 표식이 없는 lp=2가 겪는 일이 wraparound입니다. xid 순서는 modulo 2^32 비교라 어떤 xid에서 보든 과거로 읽히는 범위는 2^31개(약 21억 4700만)뿐이고, 그보다 멀어진 xid는 미래로 뒤집힙니다. 412는 현재 xid에서 약 26억 떨어져 있어 미래에 해당하고, 미래 트랜잭션이 만든 튜플은 아직 확정되지 않은 INSERT로 취급되어 Step 1을 통과하지 못합니다. 디스크에는 데이터가 그대로 있는데 SELECT 결과에서만 사라지는 형태의 유실이고, freeze는 오래된 튜플을 이 비교의 대상에서 빼내 그 사고를 원천적으로 막습니다.
ORDER BY created_at DESC LIMIT 10이 정렬 노드 없이 인덱스만으로 끝날 수 있는 구조적 근거는 무엇입니까?
루트와 브랜치에 각 리프의 최솟값이 요약돼 있어 상위 10개를 바로 골라낼 수 있습니다.
내부 노드의 키는 어느 자식으로 내려갈지 정하는 이정표일 뿐 값을 골라내는 요약이 아닙니다. 실제 값과 ctid는 리프에만 있으므로 상위 10개를 알려면 결국 리프까지 내려가야 합니다.
힙 튜플이 인덱스 키 순서대로 디스크에 놓여 있기 때문입니다.
힙의 물리적 순서는 인덱스와 무관하고 INSERT 순서와 빈 자리 재사용으로 정해집니다. CLUSTER로 한 번 맞춰줄 수는 있지만 이후 갱신으로 다시 흐트러지고, 인덱스 정렬은 그것과 별개로 항상 유지됩니다.
정답같은 레벨의 리프가 좌우로 이어져 있어 그 체인을 걸어가는 것 자체가 정렬 순서입니다.
리프 페이지는 sibling 포인터로 연결된 리스트를 이룹니다. 진입 지점을 한 번 찾으면 새 탐색 없이 옆 리프로 넘어가며 순서대로 읽어나갈 수 있고, 그 순서가 곧 인덱스 키 순서입니다. range 조회와 ORDER BY가 같은 구조에 기대는 이유입니다.
플래너가 이전 실행의 정렬 결과를 캐시해두기 때문입니다.
Postgres는 정렬 결과를 실행 사이에 캐시하지 않습니다. prepared statement로 plan을 재사용하더라도 데이터는 매번 다시 읽고 다시 정렬합니다.
해설
B-tree의 리프가 좌우로 연결된 리스트라는 사실 하나가 range 조회와 정렬을 동시에 설명합니다. equality 조회는 루트에서 리프까지 한 번 내려가면 끝나지만, BETWEEN이나 ORDER BY LIMIT은 시작점을 찾은 뒤 옆으로 걸어가야 합니다. 이때 매번 루트부터 다시 내려간다면 range가 길어질수록 비용이 폭증하겠지만, sibling 포인터 덕분에 다음 리프로 곧장 넘어갑니다. DESC 정렬도 같은 체인을 반대 방향으로 읽으면 되므로 별도 구조가 필요 없습니다.
세 컬럼 조건이 모두 리프에서 읽을 구간의 시작과 끝을 좁히는 데 쓰이는 쿼리는 어느 것입니까?
정답WHERE tenant_id = 7 AND status = 'paid' AND created_at >= '2026-07-01';
equality가 앞에 두 개 오고 range가 마지막에 옵니다. tenant_id와 status가 한 값으로 고정되면 그 조합 안에서 created_at이 정렬되어 있으므로, 세 조건이 그대로 스캔의 시작 지점과 끝 지점으로 번역됩니다.
WHERE tenant_id = 7 AND created_at >= '2026-07-01';
tenant_id는 진입점이 되지만 그 구간 안은 status로 먼저 정렬되어 있어 created_at이 연속된 범위를 이루지 않습니다. tenant_id = 7 구간을 전부 훑으면서 created_at은 걸러내는 조건으로만 쓰입니다.
WHERE tenant_id = 7 AND status >= 'p' AND created_at >= '2026-07-01';
range가 두 번째 컬럼에서 시작하는 순간 그 뒤 컬럼의 정렬은 구간 안에서 유지되지 않습니다. status >= 'p'가 여러 status 값을 걸치므로 created_at은 경계에 기여하지 못합니다.
WHERE status = 'paid' AND created_at >= '2026-07-01';
선두 컬럼 조건이 없으면 어디로 진입할지가 정해지지 않습니다. PG 18의 skip scan이 tenant_id의 distinct 값을 뛰어다니며 도울 수는 있지만, tenant 수가 많으면 매 값마다 트리를 다시 내려가는 비용이 커져 플래너가 고르지 않습니다.
해설
복합 인덱스의 선두 컬럼 규칙은 정렬 방식에서 그대로 따라나옵니다. (tenant_id, status, created_at)은 tenant_id로 먼저 줄을 세우고, 같은 tenant_id 안에서 status로, 같은 status 안에서만 created_at으로 정렬합니다. 어떤 컬럼이 구간의 경계를 좁히려면 그 앞의 컬럼들이 모두 하나의 값으로 고정되어 있어야 합니다. 그래서 설계 규칙이 equality를 앞에 몰고 range는 마지막에 하나만 두는 형태가 됩니다. range가 중간에 끼면 그 뒤 컬럼은 인덱스 안에 있어도 읽을 양을 줄여주지 못하고, 이미 읽은 엔트리를 걸러내는 데만 쓰입니다.
Index Only Scan using orders_customer_id_idx on orders
(actual time=0.089..214.577 rows=48213 loops=1)
Index Cond: (customer_id = 4210)
Heap Fetches: 47106
Buffers: shared hit=1204 read=45938
이름은 Index Only Scan인데 Heap Fetches가 47,106입니다. 무슨 일이 일어나고 있습니까?
SELECT 목록에 인덱스에 없는 컬럼이 섞여 있어 나머지를 힙에서 채워오고 있습니다.
그런 쿼리는 계획 자체가 Index Only Scan으로 잡히지 않고 Index Scan이 됩니다. Index Only Scan이라는 이름이 붙었다는 것은 필요한 컬럼이 인덱스 안에서 모두 해결된다는 뜻입니다.
정답그 ctid들이 가리키는 힙 페이지의 all-visible 비트가 꺼져 있어 가시성을 힙에서 확인하고 있습니다.
인덱스 엔트리에는 xmin과 xmax가 없어서 그 튜플이 내 snapshot에 보이는지를 인덱스만으로는 판정할 수 없습니다. Visibility Map 비트가 서 있는 페이지에 대해서만 힙을 건너뛸 수 있고, 꺼져 있으면 결국 힙 페이지를 읽어 확인합니다. 대량 갱신 직후처럼 VACUUM이 아직 지나가지 않은 구간에서 전형적으로 나타납니다.
인덱스에 죽은 엔트리가 쌓여 있고, 그것이 유효한지 확인하려고 힙을 읽습니다.
인덱스에 죽은 엔트리가 많으면 스캔할 엔트리 수와 buffers가 늘지만 Heap Fetches라는 별도 카운터로 잡히지는 않습니다. 이 카운터는 가시성 확인을 위해 힙에 간 횟수만 셉니다.
통계가 낡아 플래너가 행 수를 잘못 잡았고, 실행 중에 그것을 보정하고 있습니다.
추정이 빗나갔다면 애초에 다른 계획을 골랐어야 했다는 이야기이지, 실행 중에 힙을 더 읽는 동작으로 이어지지 않습니다. executor는 통계를 보고 움직이지 않습니다.
해설
Index-Only Scan은 힙을 안 읽겠다는 약속이 아니라 읽지 않아도 되면 건너뛰겠다는 최적화입니다. 판단 근거가 Visibility Map이고, 이 비트를 세우는 주체는 VACUUM입니다. 그래서 대량 INSERT나 UPDATE 직후에는 이름만 Index Only Scan이고 실제로는 튜플마다 힙을 읽는 상태가 됩니다. read=45938이라는 숫자가 그 증거입니다. 인덱스 리프 몇 장으로 끝날 스캔이 힙 페이지 수만 장을 읽고 있습니다. 대상 테이블에 VACUUM을 돌리고 나서 같은 계획의 Heap Fetches가 0으로 떨어지는지 보면 진단이 확정됩니다.
C 로케일 데이터베이스이거나 text_pattern_ops로 만든 인덱스라면 같은 쿼리가 Index Scan으로 갑니다. LIKE 자체가 막혀 있는 것이 아니라 문자열을 비교하는 방식이 걸림돌입니다.
추정 행 수가 98건으로 작아 플래너가 인덱스를 쓸 이유를 찾지 못했습니다.
98건이면 오히려 인덱스가 압도적으로 유리하다고 판단할 규모입니다. 쓸 수 있는데 안 쓴 것이 아니라 후보로 올라오지도 못한 상황입니다.
정답기본 opclass가 로케일 규칙으로 비교하므로 prefix를 범위 조건으로 번역할 수 없습니다.
'kim%'을 인덱스로 풀려면 email >= 'kim' AND email < 'kin' 같은 범위 조건으로 바꿀 수 있어야 합니다. 로케일 비교에서는 이 변환이 실제 정렬 순서와 일치한다고 보장되지 않아 플래너가 시도하지 않습니다. text_pattern_ops 인덱스를 하나 더 만들면 바이트 순서로 정렬되어 변환이 성립합니다.
email 컬럼에 NULL이 많아 인덱스의 선택도가 떨어졌습니다.
B-tree는 NULL도 인덱싱하고, LIKE 조건은 NULL 행을 애초에 결과에서 제외합니다. NULL 비율은 이 계획이 갈리는 지점이 아닙니다.
해설
B-tree가 잘하는 일은 정렬된 순서 위에서 연속된 구간을 뽑는 것 하나입니다. LIKE 'kim%'는 그 구간으로 번역될 수 있어 인덱스를 쓸 수 있고, LIKE '%kim'은 정렬 어디에도 연속 구간이 없어 쓸 수 없습니다. 여기에 한 겹이 더 있습니다. 번역이 성립하려면 인덱스의 정렬 순서가 바이트 순서와 같아야 합니다. 로케일 비교는 대소문자나 부호를 무시하는 등 바이트 순서와 다르게 줄을 세우므로, 같은 prefix를 가진 문자열이 인덱스에서 붙어 있다고 보장할 수 없습니다. text_pattern_ops는 이 보장을 돌려주는 대신 ORDER BY email이 기대하는 로케일 정렬을 포기하게 만들므로, 둘 다 필요하면 인덱스를 두 개 두게 됩니다.
VACUUM으로 Heap Fetches를 0으로 되돌린 뒤 SELECT 목록에 total_amount를 추가했더니 계획이 Index Scan으로 바뀌었습니다. INCLUDE (total_amount)로 인덱스를 다시 만들 때 실제로 얻는 것은 무엇입니까?
total_amount가 정렬 키에 더해져 ORDER BY customer_id, total_amount까지 함께 해결됩니다.
INCLUDE 컬럼은 리프에 payload로만 얹히고 정렬에는 관여하지 않습니다. 정렬까지 인덱스로 얻으려면 키 컬럼으로 넣어야 하고, 그러면 내부 노드에도 그 값이 실려 트리가 두꺼워집니다.
정답리프에만 얹히는 컬럼이라 Index Only Scan은 되살아나지만, total_amount 조건은 읽을 구간을 좁혀주지 못합니다.
INCLUDE는 검색 키가 아니라 결과를 돌려주기 위한 payload입니다. customer_id로 잡은 구간을 전부 읽으면서 total_amount는 걸러내는 데만 쓰입니다. 대신 내부 노드에는 실리지 않아 fanout과 트리 깊이는 그대로 유지됩니다.
담기는 정보가 정리되어 인덱스 크기가 줄어듭니다.
리프 엔트리마다 total_amount 값이 하나씩 더 붙으므로 인덱스는 반드시 커집니다. 힙 접근을 줄이는 대가로 인덱스 크기와 갱신 비용을 내주는 거래입니다.
UNIQUE를 함께 걸면 customer_id와 total_amount 조합의 유일성이 보장됩니다.
유일성 판정은 키 컬럼만 봅니다. INCLUDE 컬럼은 제약에 관여하지 않으므로 customer_id 하나의 유일성만 강제되고, total_amount는 그저 함께 실려 다닐 뿐입니다.
해설
INCLUDE는 인덱스를 검색용 자료구조에서 결과 전달용 저장소로 조금 확장하는 장치입니다. 키 컬럼은 정렬과 탐색에 쓰이면서 내부 노드에도 복사되지만, INCLUDE 컬럼은 리프에만 붙어 Index-Only Scan을 성립시키는 역할만 합니다. 그래서 자주 함께 조회되지만 조건으로는 쓰이지 않는 컬럼이 INCLUDE의 자리입니다. 반대로 그 컬럼으로 범위를 좁히거나 정렬하고 싶다면 키 컬럼이어야 합니다. 어느 쪽이든 인덱스가 커지고 쓰기 비용이 늘어나므로, 힙 접근이 실제로 병목인지를 Heap Fetches와 buffers로 먼저 확인한 뒤에 결정하는 순서가 맞습니다.
CREATEINDEX idx_orders_pending ON orders (created_at)WHEREstatus='pending';EXPLAINSELECT*FROM orders
WHEREstatusIN('pending','paid')AND created_at >now()-interval'1 hour';-- Seq Scan on orders-- Filter: ((status = ANY ('{pending,paid}'::text[]))-- AND (created_at > (now() - '01:00:00'::interval)))
created_at은 이 인덱스의 유일한 키 컬럼입니다. 그런데도 인덱스를 타지 않는 이유는 무엇입니까?
partial index는 IN 형태의 조건과 함께 쓸 수 없습니다.
IN이 문제가 아닙니다. status IN ('pending')처럼 인덱스의 조건을 함의하는 형태였다면 그대로 후보에 올랐을 것입니다.
created_at이 선두 컬럼이 아니어서 진입 지점을 정할 수 없습니다.
이 인덱스의 키 컬럼은 created_at 하나뿐이라 그 자체가 선두 컬럼입니다. 선두 컬럼 규칙은 여기서 걸리는 지점이 아닙니다.
now()는 실행마다 값이 달라지는 함수라 인덱스 조건으로 쓸 수 없습니다.
now()는 트랜잭션 안에서 같은 값을 돌려주는 stable 함수라 실행 시점에 상수로 확정되어 인덱스 조건이 됩니다. volatile 함수였다면 이 지적이 맞습니다.
정답쿼리 조건이 인덱스의 predicate를 함의하지 않기 때문입니다.
이 인덱스에는 status가 'pending'인 행만 들어 있습니다. 쿼리는 'paid'인 행도 요구하므로 인덱스를 끝까지 훑어도 답의 일부가 빠집니다. 플래너는 쿼리 조건이 인덱스 predicate를 함의한다고 증명할 수 있을 때만 partial index를 후보로 올립니다.
해설
partial index는 인덱스 크기와 갱신 비용을 조건에 맞는 행만큼으로 줄여주는 대신, 쓸 수 있는 쿼리를 좁힙니다. 판단 기준은 단순합니다. 쿼리의 조건이 인덱스의 predicate를 논리적으로 함의해야 합니다. status = 'pending'은 함의하고, status IN ('pending', 'paid')는 함의하지 않습니다. 후자를 인덱스로 처리하려면 pending 구간은 partial index로, paid 구간은 다른 경로로 읽어 합쳐야 하는데 플래너는 그런 조합을 만들지 않습니다. 같은 함정이 expression index에도 있습니다. LOWER(email)로 만든 인덱스는 쿼리도 LOWER(email)로 써야 걸리고, email로 쓰면 표현식이 달라 후보에 오르지 않습니다.
tags jsonb 컬럼에 B-tree 인덱스를 만들어도 tags @> '["urgent"]' 조건이 인덱스를 타지 못하는 구조적 이유는 무엇입니까?
jsonb는 가변 길이 타입이라 B-tree 키로 넣을 수 없습니다.
jsonb에도 정렬 순서가 정의되어 있어 B-tree 인덱스를 만들 수 있고, tags = '["urgent","p1"]' 같은 완전 일치 조회는 실제로 그 인덱스를 탑니다. 길이가 아니라 질문의 모양이 걸림돌입니다.
정답B-tree는 행 하나를 키 하나로 저장하므로 값 안에 든 원소를 따로 찾을 자리가 없습니다.
리프에는 tags 값 전체가 통째로 한 자리를 차지하고 정렬 순서도 그 전체 값 기준입니다. 그래서 'urgent'를 담은 행들이 인덱스에서 붙어 있지 않고 사방에 흩어집니다. GIN은 같은 행을 원소마다 하나씩 키로 쪼개 넣어 이 문제를 풉니다.
@> 연산자가 B-tree opclass에 등록되어 있지 않을 뿐, 등록만 하면 됩니다.
등록할 수 없는 이유가 곧 구조입니다. B-tree opclass가 요구하는 것은 두 값의 크고 작음을 정하는 비교 함수인데, 포함 관계는 그런 순서로 환원되지 않습니다.
jsonb 값이 TOAST로 떨어져 나가 인덱스에서 원본을 읽을 수 없습니다.
인덱스는 TOAST 이전의 값을 받아 저장하므로 TOAST 여부는 인덱싱 가능성과 무관합니다. 다만 키가 페이지의 3분의 1을 넘으면 B-tree가 거부하는 별개의 제약은 있습니다.
해설
B-tree의 전제는 한 행이 하나의 키로 결정된다는 것입니다. 이 전제가 깨지는 질문이 두 종류 있습니다. 하나는 한 행이 여러 조각으로 쪼개져야 하는 경우입니다. 문서 안의 단어, 배열의 원소, JSON의 경로가 전부 여기에 해당하고 GIN이 담당합니다. 다른 하나는 값끼리 크고 작음으로 줄을 세울 수 없는 경우입니다. 도형이 겹치는지, 시 간 범위가 겹치는지 같은 질문이고 GiST가 담당합니다. 어떤 인덱스를 고를지는 데이터 타입이 아니라 던지는 질문의 모양에서 결정됩니다.
-- 문서 테이블 docs의 body(tsvector)에 GIN 인덱스가 걸려 있습니다.-- 평소 전문 검색은 20ms 안쪽인데 하루에 몇 번씩 400ms를 넘깁니다.-- 지연이 튀는 시각에 CPU도 디스크도 한가합니다.SELECT n_pending_pages, n_pending_tuples
FROM gin_metapage_info(get_raw_page('docs_body_gin',0));-- n_pending_pages | n_pending_tuples-- -----------------+-------------------- 512 | 38104
검색 지연이 간헐적으로 튀는 원인은 무엇입니까?
정답아직 병합되지 않은 pending list를 검색이 선형으로 함께 훑고 있습니다.
fastupdate가 켜져 있으면 새 엔트리는 정렬되지 않은 pending list에 먼저 쌓입니다. 검색은 답을 놓치지 않으려고 엔트리 트리와 pending list를 모두 봐야 하는데, pending list는 정렬돼 있지 않아 처음부터 끝까지 읽습니다. 리스트가 부풀어 있는 구간에서만 지연이 튀는 이유입니다.
인덱스가 bloat되어 엔트리 트리의 깊이가 깊어졌습니다.
트리가 깊어져도 한두 단계 늘어나는 정도라 20배 차이를 만들지 못합니다. 게다가 깊이는 상시 조건이라 지연이 늘 높게 유지되지 특정 시점에만 튀지 않습니다.
autovacuum이 그 시각에 같은 테이블을 잠그고 있습니다.
VACUUM은 검색을 막는 락을 잡지 않습니다. 오히려 VACUUM이 지나가면서 pending list가 메인 구조로 병합되므로, 이 상황에서는 지연을 없애는 편에 섭니다.
GIN은 정렬을 지원하지 않아 결과를 매번 다시 정렬해야 합니다.
정렬이 필요한 쿼리라면 그 비용은 매 실행에 똑같이 붙습니다. 어떤 때는 20ms이고 어떤 때는 400ms라는 관측을 설명하지 못합니다.
해설
GIN은 쓰기가 비쌉니 다. 문서 하나를 넣을 때 그 안의 단어 수백 개 각각의 posting list를 건드려야 하기 때문입니다. fastupdate는 이 비용을 뒤로 미루는 장치라, 새 엔트리를 정렬되지 않은 pending list에 모아두었다가 나중에 한꺼번에 병합합니다. 병합은 VACUUM이나 autoanalyze가 지나갈 때, gin_clean_pending_list()를 직접 부를 때, 리스트가 gin_pending_list_limit(기본 4MB)을 넘을 때 일어납니다. 그래서 쓰기가 몰리는 구간에 리스트가 부풀고 검색이 그만큼 느려집니다. 쓰기 폭주가 상시라면 해당 테이블의 autovacuum 주기를 줄이거나, 검색 지연의 일관성이 더 중요하면 fastupdate를 끄고 쓰기 쪽에서 비용을 내는 선택이 있습니다.
CREATEINDEX users_name_trgm ON users USING gin (name gin_trgm_ops);-- (A) 인덱스를 탑니다SELECT*FROM users WHERE name LIKE'%kim%';-- (B) 200만 행을 전부 읽고 정렬합니다SELECT name FROM users ORDERBY name <->'kim'LIMIT10;
같은 컬럼, 같은 확장인데 (B)만 인덱스를 못 씁니다. (B)를 인덱스로 풀려면 무엇이 필요합니까?
gin_trgm_ops 인덱스를 REINDEX해서 정렬 가능한 상태로 되돌립니다.
REINDEX는 같은 구조를 다시 만들 뿐입니다. GIN은 어떤 상태에서도 거리순 스캔을 제공하지 않으므로 재구축으로는 달라지지 않습니다.
name에 B-tree 인덱스를 추가해 정렬을 그쪽으로 넘깁니다.
B-tree가 아는 순서는 사전순이지 'kim'과의 거리순이 아닙니다. ORDER BY 식이 인덱스의 정렬 기준과 다르면 그 인덱스는 정렬에 쓰이지 않습니다.
pg_trgm.similarity_threshold를 낮춰 후보 행을 줄입니다.
그 설정은 % 연산자가 유사하다고 판정하는 기준선이라 <-> 정렬에는 관여하지 않습니다. 후보를 줄인다 해도 정렬 자체를 인덱스가 대신해주지는 않습니다.
정답같은 컬럼에 gist_trgm_ops로 GiST 인덱스를 하나 더 만듭니다.
거리순 상위 N개는 트리를 거리 기준으로 가지치기하며 내려갈 수 있어야 풀립니다. GiST는 내부 노드가 자식들의 trigram 집합을 감싸는 요약을 들고 있어 그 가지치기가 가능하지만, GIN은 키에 달린 ctid 묶음을 모아 비트맵을 만드는 구조라 순서를 만들어내지 못합니다.
해설
pg_trgm은 GIN과 GiST 양쪽에 opclass를 제공해서 둘 중 무엇을 골라야 하는지가 헷갈리는 대표 지점입니다. 기준은 질문이 들어있냐 없냐인지, 얼마나 가까운지인지입니다. LIKE '%kim%'은 포함 여부만 묻고 결과에 순서가 필요 없으므로 역색인에서 ctid를 긁어모아 비트맵을 만드는 GIN이 빠릅니다. ORDER BY name <-> 'kim' LIMIT 10은 거리라는 순서를 요구하므로, 요약을 들고 있는 노드부터 거리를 재며 가지를 쳐낼 수 있는 GiST가 필요합니다. 두 쿼리를 다 써야 한다면 인덱스를 두 개 두게 되고, 그때는 쓰기 비용이 두 배로 드는 것을 감수할 만한지가 판단 기준이 됩니다.
-- 주문 테이블 3억 행. created_at 기준 최근 구간을 자주 조회합니다.-- B-tree 인덱스가 6GB를 넘어 BRIN으로 교체했더니 쿼리가 오히려 느려졌습니다.SELECT attname, correlation FROM pg_stats
WHERE tablename ='orders'AND attname IN('created_at','updated_at');-- attname | correlation-- ------------+--------------- created_at | 0.31-- updated_at | 0.04
BRIN이 제 몫을 못 하는 이유로 맞는 것은 무엇입니까?
pages_per_range를 128에서 1024로 키우면 요약이 촘촘해져 해결됩니다.
방향이 반대입니다. 값을 키우면 한 묶음이 더 넓어져 요약은 더 뭉뚱그려지고 정밀도는 나빠집니다. 게다가 겹침의 원인이 물리 순서라 어느 쪽으로 조정해도 근본은 그대로입니다.
BRIN 인덱스를 만든 뒤 ANALYZE를 하지 않아 요약이 비어 있습니다.
BRIN 요약은 인덱스를 만들 때 채워지고, 이후 새로 추가된 블록 범위는 brin_summarize_new_values()나 autosummarize 옵션이 채웁니다. ANALYZE가 만드는 것은 플래너용 통계이지 인덱스 내용이 아닙니다.
정답블록 범위마다 min과 max 구간이 서로 크게 겹쳐 건너뛸 묶음이 남지 않습니다.
correlation 0.31은 같은 시간대의 행이 파일 전체에 흩어져 있다는 뜻입니다. 그러면 어느 묶음의 요약도 조회 범위와 겹쳐서 결국 전부 읽게 되고, 정밀도가 낮은 만큼 B-tree보다 나쁜 결과가 나옵니다. 시간 기준 파티셔닝이나 CLUSTER로 물리 순서를 맞추면 살아나고, 그럴 수 없으면 B-tree로 돌아가는 것이 맞습니다.
updated_at의 correlation이 0.04로 낮아 created_at BRIN까지 함께 무력해졌습니다.
BRIN 요약은 인덱싱한 컬럼 하나만 봅니다. 다른 컬럼의 분포는 이 인덱스가 묶음을 배제하는 능력에 영향을 주지 않습니다.
해설
BRIN은 인덱스가 작아지는 대신 정밀도를 포기하는 거래입니다. 그 거래가 성립하려면 디스크 저장 순서와 값의 순서가 거의 일치해야 합니다. 일치하면 각 묶음의 min과 max 구간이 서로 겹치지 않아 조건에 걸린 몇 개만 읽고 나머지를 통째로 버릴 수 있습니다. 어긋나면 모든 묶음의 구간이 전체 범위를 덮어버려 어느 것도 배제하지 못하고, 결국 순차 스캔에 요약 읽기 비용만 얹은 꼴이 됩니다. 판단 지표가 pg_stats.correlation이고 절댓값 0.9 이상이 실질 하한선입니다. append-only 로그는 이 조건을 자연스럽게 만족하지만, 갱신이 잦은 OLTP 테이블은 시간이 지나며 이 값이 떨어지므로 주기적으로 다시 봐야 합니다.
CREATEINDEX events_tags_gin ON events USING gin (tags jsonb_path_ops);-- 이 조건은 인덱스를 탑니다EXPLAINSELECT*FROM events WHERE tags @>'["urgent"]';-- Bitmap Heap Scan on events-- Recheck Cond: (tags @> '["urgent"]'::jsonb)-- -> Bitmap Index Scan on events_tags_gin-- 이 조건만 Seq Scan입니다EXPLAINSELECT*FROM events WHERE tags ? 'urgent';-- Seq Scan on events-- Filter: (tags ? 'urgent'::text)
같은 인덱스인데 두 번째 조건만 후보에 오르지 못합니다. 이유는 무엇입니까?
tags가 객체가 아니라 배열이라 ? 연산자를 쓸 수 없습니다.
? 연산자는 객체의 키뿐 아니라 배열의 문자열 원소도 확인합니다. 조건 자체는 유효하고 Filter로 정상 평가되어 결과도 나옵니다. 인덱스만 못 쓰고 있는 상황입니다.
결과 행이 많아 플래너가 인덱스보다 Seq Scan이 싸다고 판단했습니다.
그런 판단이 내려질 때는 Bitmap Heap Scan과 Seq Scan의 cost를 견주게 되고, 인덱스가 후보로 등장한 흔적이 계획에 남습니다. 여기서는 아예 후보에 오르지 못했습니다.
? 가 드라이버의 파라미터 자리표시자와 충돌해 조건이 상수로 바뀌었습니다.
드라이버 수준의 이스케이프 문제는 실제로 존재하지만, 그 경우 쿼리가 에러를 내거나 다른 조건으로 실행됩니다. 여기서는 Filter에 원래 조건이 그대로 남아 있습니다.
정답jsonb_path_ops가 키 존재 연산자를 지원하지 않기 때문입니다.
jsonb_path_ops는 경로와 값을 함께 해싱해 담기 때문에 키 이름만 따로 찾아낼 수 없습니다. 지원하는 연산자는 @>, @?, @@ 셋뿐입니다. ? 계열까지 쓰려면 기본 jsonb_ops로 만들어야 하고, 대신 인덱스가 커지고 흔한 키가 섞인 조건에서 후보를 넓게 잡습니다.
해설
인덱스가 있는데도 계획에 등장조차 하지 않는다면 cost 비교 이전에 자격 문제입니다. 어떤 연산자를 인덱스로 처리할 수 있는지는 opclass가 정합니다. jsonb에는 두 가지가 있습니다. 기본 jsonb_ops는 키와 값을 모두 별도 엔트리로 넣어 포함 관계와 키 유무를 다 지원하고, jsonb_path_ops는 경로와 값을 묶어 해싱해서 포함 관계 계열만 지원하는 대신 인덱스가 작습니다. 어느 쪽을 고를지는 앞으로 던질 조건에 ? 계열이 들어가는지로 갈립니다. 이미 만들어둔 뒤에 조건이 늘어나면 이 문제가 나타나므로, Seq Scan이 나왔을 때 opclass부터 확인하는 습관이 진단 시간을 줄여줍니다.
세션 저장 테이블입니다. 컬럼은 token text(평균 180바이트), payload jsonb, expires_at timestamptz이고, token은 애플리케이션이 난수로 만들며 DB 제약은 걸려 있지 않습니다. 조회는 token 완전 일치 하나뿐이고 만료 정리는 expires_at 기준 배치로 따로 돕니다. 행 수는 300만 선에서 크게 늘지 않습니다.
token 인덱스를 B-tree에서 Hash로 바꾸는 판단에 대한 설명으로 맞는 것은 무엇입니까?
부적절합니다. Hash는 crash 이후 복구되지 않아 운영에 쓸 수 없습니다.
PG 10에서 WAL 로깅이 들어가면서 crash-safe해졌고 복제 대상에도 정상적으로 전달됩니다. 그 이전 시절의 인상이 오래 남아 있을 뿐 지금은 사실이 아닙니다.
정답적절합니다. 키가 크고 equality만 쓰므로 해시값만 담는 Hash가 인덱스를 크게 줄입니다.
B-tree는 180바이트 키를 리프에 그대로 담지만 Hash는 4바이트 해시값만 담습니다. 300만 행이면 이 차이만으로 수백 MB가 갈립니다. 행 수가 안정적이라 버킷 확장이 잦지 않다는 점, 정렬이나 범위 조회가 없다는 점도 이 선택에 유리합니다. 다만 Hash로는 UNIQUE 제약을 걸 수 없으므로 유일성은 애플리케이션이 계속 책임져야 합니다.
부적절합니다. text 타입에는 Hash 인덱스를 만들 수 없습니다.
text에도 hash opclass가 있어 인덱스를 만들 수 있습니다. Hash가 못 하는 것은 타입이 아니라 연산입니다. 부등호 비교, 정렬, prefix 조회가 전부 안 됩니다.
적절합니다. Hash는 조회가 상수 시간이라 힙을 다시 읽을 필요도 없습니다.
Hash 인덱스는 실제 컬럼 값을 저장하지 않고 해시값만 담아서, 충돌 가능성 때문에 힙에서 조건을 반드시 다시 확인합니다. 스캔이 항상 lossy라는 것이 이 인덱스의 성질입니다.
해설
Hash가 B-tree를 이기는 구간은 좁고 분명합니다. 키가 커서 저장 비용 차이가 크고, 조회가 equality 하나뿐이고, 유일성 제약이 필요 없고, 행 수가 급격히 늘지 않는 경우입니다. 네 조건 중 하나만 어긋나도 B-tree가 낫습니다. 키가 작으면 아끼는 공간이 얼마 안 되고, 범위나 정렬이 하나라도 있으면 인덱스를 하나 더 만들어야 하며, UNIQUE가 필요하면 Hash로는 애초에 불가능하고, 행 수가 빠르게 늘면 버킷을 늘리는 작업이 사용자 INSERT와 같은 흐름에서 일어나 쓰기 지연을 키웁니다. 한번 커진 Hash 인덱스는 REINDEX 말고는 줄일 방법이 없다는 점도 함께 봐야 합니다.
status(고유값 3개)와 amount(값이 거의 다 다름)에 ANALYZE가 남기는 통계는 어떻게 갈립니까?
두 컬럼 모두 MCV와 histogram이 함께 만들어지고, 플래너가 조건에 따라 둘 중 하나를 골라 씁니다.
histogram은 MCV에 뽑힌 값을 빼고 계산합니다. MCV가 컬럼 전체를 덮어버리면 남는 값이 없어 histogram은 아예 만들어지지 않습니다. 둘은 선택지가 아니라 서로의 빈자리를 메우는 관계입니다.
정답status는 MCV가 세 값을 다 담아 histogram이 비고, amount는 반대로 histogram이 분포를 맡습니다.
MCV는 특정 값이 자주 나올 때 위력을 발휘하므로 카디널리티가 낮은 범주형이 주 무대입니다. 반대로 값이 거의 다 다른 컬럼은 어느 값도 자주 나오지 않아 MCV가 비고, 경계선 101개가 만든 버킷 100개가 분포를 대신합니다.
status는 고유값이 적어 n_distinct만 저장되고, MCV는 고유값이 많은 컬럼에만 만들어집니다.
방향이 반대입니다. 고유값이 적을수록 각 값의 빈도가 높아 MCV에 담을 가치가 커지고, 고유값이 거의 다 다르면 MCV에 걸릴 값 자체가 없습니다.
amount는 숫자 타입이라 최솟값과 최댓값만 저장하고 그 사이는 균등분포로 가정합니다.
그렇게 하면 값이 한쪽에 몰린 분포에서 추정이 크게 어긋납니다. histogram은 버킷마다 담긴 행 수를 같게 맞추므로, 값이 몰린 구간일수록 경계선이 촘촘해져 분포의 모양을 따라갑니다.
해설
플래너가 selectivity를 구하는 경로는 두 갈래이고 컬럼의 성격에 따라 어느 쪽이 일하는지가 갈립니다. 등치 조건의 값이 MCV에 있으면 저장된 빈도를 그대로 씁니다. MCV 밖이면 MCV가 덮지 못한 나머지 비율을 MCV에 없는 고유값 개수로 나눠 추정합니다. 범위 조건은 histogram 경계선 배열에서 그 값이 어디쯤인지를 보간해 비율을 계산합니다. 그래서 범주형 컬럼의 등치 조건은 MCV가, 연속형 컬럼의 범위 조건은 histogram이 받쳐줍니다. 어느 쪽도 없는 경우가 문제가 되는데, 표현식으로 감싼 컬럼이 대표적입니다.
-- orders 10만 행. status는 completed 85%, pending 13%, refunded 2%.-- status에 인덱스가 걸려 있습니다.EXPLAINSELECT*FROM orders WHEREstatus='completed';-- Seq Scan on orders (cost=0.00..1972.00 rows=85023 width=23)-- Filter: (status = 'completed'::text)EXPLAINSELECT*FROM orders WHEREstatus='refunded';-- Bitmap Heap Scan on orders (cost=23.64..770.39 rows=1980 width=23)-- Recheck Cond: (status = 'refunded'::text)-- -> Bitmap Index Scan on orders_status_idx (cost=0.00..23.15 rows=1980 width=0)
같은 컬럼에 같은 인덱스인데 조건 값에 따라 계획이 갈립니다. 이 선택을 지배하는 것은 무엇입니까?
completed는 MCV에 있고 refunded는 MCV 밖이라, MCV에 없는 값만 인덱스로 처리됩니다.
세 값 모두 MCV에 들어 있습니다. MCV 안팎은 빈도를 어떤 방식으로 구하느냐를 가를 뿐이고, 계획을 가르는 것은 그렇게 구해낸 행 수입니다.
Bitmap Index Scan은 결과가 적을 때만 쓸 수 있는 노드라 completed에는 적용할 수 없습니다.
Bitmap 스캔은 오히려 결과가 많고 힙 여기저기 흩어져 있을 때 쓰라고 만든 노드입니다. 행이 많아도 실행 자체는 되고, 다만 그 경우 cost가 Seq Scan을 넘어서 선택되지 않을 뿐입니다.
정답MCV에 기록된 그 값의 빈도입니다. 읽을 행이 테이블의 큰 비중이면 인덱스에서 힙으로 오가는 왕복이 순차 읽기보다 비싸집니다.
85,023과 1,980이라는 추정치가 그대로 cost 계산에 들어갑니다. 전체의 85%를 뽑는다면 어차피 거의 모든 페이지를 만지게 되므로 인덱스를 거치는 만큼이 순수한 추가 비용입니다. 2%라면 대부분의 페이지를 건드리지 않아도 되어 인덱스가 압도합니다. 13%인 pending도 이 계산에서는 여전히 인덱스 쪽이 이깁니다.
통계가 낡아 completed 쪽 추정만 틀어졌습니다.
추정 85,023은 85%라는 실제 분포와 맞아떨어집니다. 통 계가 낡았다면 두 조건 모두에서 추정이 어긋났을 것입니다.
해설
인덱스가 있으면 항상 인덱스를 타는 것이 아니라, 읽어야 할 행의 비중이 어느 선을 넘으면 순차 읽기가 이깁니다. 그 선을 계산하는 재료가 통계입니다. 그래서 같은 SQL의 파라미터만 바꿔도 계획이 뒤집힐 수 있고, 이는 버그가 아니라 설계된 동작입니다. 실무에서 이 성질이 문제가 되는 지점은 prepared statement입니다. 파라미터 값이 정해지지 않은 채 만들어진 일반 계획은 특정 값의 빈도를 반영하지 못하므로, 값의 분포가 심하게 치우친 컬럼에서는 어느 쪽 값에도 최적이 아닌 계획이 굳어질 수 있습니다.
-- addresses 8만 행. pg_stats에는 country의 'KR'이 0.3765,
-- city의 'Seoul'이 0.1266으로 잡혀 있습니다.
Seq Scan on addresses (cost=0.00..1700.00 rows=3813 width=17)
(actual time=0.003..3.240 rows=10000.00 loops=1)
Filter: ((country = 'KR'::text) AND (city = 'Seoul'::text))
Rows Removed by Filter: 70000
Buffers: shared hit=500
추정 3,813에 실제 10,000입니다. 플래너가 3,813이라는 숫자를 만들어낸 경로는 무엇입니까?
정답두 조건의 선택도를 서로 독립이라 보고 곱했습니다. 0.3765 × 0.1266에 8만 행을 곱한 값입니다.
0.3765 × 0.1266 = 0.04767이고 여기에 80,000을 곱하면 3,813입니다. 실제로는 Seoul이 KR에만 존재해서 두 번째 조건이 첫 번째 조건을 하나도 더 걸러내지 못하고, city = 'Seoul'인 10,000행이 그대로 남습니다.
MCV 슬롯 100개를 넘는 값이 있어 나머지 값들을 균등하게 나눠 잡았습니다.
country는 세 값, city는 여덟 값이라 둘 다 MCV에 여유 있게 들어갑니다. 슬롯이 모자라 생기는 오차가 아닙니다.
표본이 8만 행 중 3만 행뿐이라 그 비율만큼 추정이 줄었습니다.
표본에서 구하는 것은 비율이고, 추정 행 수는 그 비율에 전체 행 수를 곱해 만듭니다. 표본 크기가 결과 행 수를 직접 깎지 않습니다.
city 조건에 쓸 통계가 없어 등치 조건의 기본 선택도 0.5%가 적용됐습니다.
0.5%를 8만 행에 곱하면 400행이라 3,813과 맞지 않습니다. 그 기본값은 통계가 아예 없을 때 쓰이는 값이고, 여기서는 두 컬럼 모두 MCV가 있습니다.
해설
플래너는 여러 조건이 붙으면 각각의 선택도를 구해 곱합니다. 이 곱셈은 두 조건이 서로 무관하다는 가정 위에 서 있고, 그 가정이 깨지는 순간 추정이 어긋납니다. 도시와 나라, 우편번호와 시도, 제품 코드와 카테고리처럼 한쪽이 다른 쪽을 결정하는 관계가 실무에 흔합니다. 이런 컬럼 쌍에서는 조건을 하나 더 붙여도 실제로는 걸러지는 행이 거의 없는데, 플래너는 매번 곱하므로 조건을 붙일수록 추정이 실제보다 작아집니다. 어긋나는 방향이 항상 과소 추정이라는 점이 특히 위험합니다. 작게 잡힌 행 수가 위쪽 노드의 알고리즘 선택을 좌우하기 때문입니다.
이 오차를 CREATE STATISTICS로 고치기로 했습니다. 이 조치가 노드 하나를 넘어 중요한 이유는 무엇입니까?
확장 통계를 만들면 두 컬럼에 대한 복합 인덱스가 함께 생성되어 스캔 자체가 빨라집니다.
CREATE STATISTICS는 통계만 만들고 인덱스를 만들지 않습니다. 계획을 고르는 근거를 고칠 뿐 데이터를 읽는 경로를 직접 바꾸지 않습니다.
Seq Scan의 cost가 1,700에서 내려가 다른 계획이 선택됩니다.
Seq Scan의 cost는 페이지 수와 행 수로 정해지므로 추정이 고쳐져도 1,700 그대로입니다. 실제로 통계를 붙인 뒤에도 이 쿼리는 여전히 Seq Scan입니다. 달라지는 것은 이 노드를 재료로 쓰는 위쪽의 결정입니다.
Rows Removed by Filter가 줄어 필터를 평가하는 비용이 절약됩니다.
추정이 바뀌어도 실제로 읽고 버리는 행 수는 그대로입니다. 통계는 무엇을 어떻게 읽을지 정하는 데 쓰이지 이미 정해진 스캔의 실행량을 바꾸지 않습니다.
정답행 수 추정이 위쪽 노드의 입력이 되어, 그 숫자로 고른 Nested Loop가 2.6배 반복됩니다.
이 노드가 조인의 바깥이면 플래너는 안쪽을 3,813번 돌리는 비용으로 Nested Loop를 저울질합니다. 실제로 10,000행이 들어오면 안쪽 조회가 2.6배 돌아가고, 조인이 두세 단 겹치면 이 배수가 곱해집니다. 단일 노드에서 3,813 대 10,000은 대수롭지 않아 보이지만 위로 올라갈수록 벌어집니다.
해설
추정 오차를 판단할 때 그 노드에서 시간이 얼마나 더 걸리는지가 아니라 그 숫자가 위쪽에서 무엇을 결정하는지를 봐야 합니다. dependencies 통계는 city 값을 알면 country가 정해진다는 함수 종속을 표본에서 찾아내, 두 조건의 선택도를 곱하지 않도록 막아줍니다. 강하게 연관된 컬럼 쌍에서는 거의 항상 효과가 있습니다. 다만 확장 통계도 만들어두면 자동으로 갱신되는 것이 아니라 ANALYZE가 다시 지나가야 채워지고, 컬럼 조합마다 따로 만들어야 하므로 조합이 많으면 관리 대상이 늘어납니다.
-- users는 1만 행이고 email에는 UNIQUE 제약이 걸려 있습니다.
Seq Scan on users (cost=0.00..224.00 rows=50 width=28)
Filter: (lower(email) = 'admin@example.com'::text)
email이 UNIQUE라 많아야 한 행일 텐데 추정이 50행입니다. 이 숫자는 어디서 나왔습니까?
MCV 슬롯 100개에 이 값이 없어 나머지 값들로 균등하게 나눈 결과입니다.
그 계산은 컬럼 통계가 있을 때 쓰는 경로입니다. 여기서는 표현식에 대한 통계가 아예 없어 그 계산까지 가지도 못합니다.
정답lower(email)의 분포가 통계에 없어 등치 조건의 기본 선택도 0.5%가 적용됐습니다.
pg_statistic에는 email 원본 컬럼의 분포만 있습니다. 플래너는 lower를 거친 값이 어떤 분포인지 알 방법이 없어 타입별 기본 상수로 후퇴하고, 1만 행의 0.5%인 50이 나옵니다. lower(email)에 표현식 인덱스를 만들면 ANALYZE가 그 표현식 값의 통계를 따로 수집하면서 추정이 1로 내려갑니다.
UNIQUE가 email에만 걸려 있고 lower(email)에는 없어, 플래너가 중복 가능성을 50배로 잡았습니다.
유일성을 모른다는 지적은 맞지만 그 결과로 배수를 곱하는 것은 아닙니다. 분포 정보가 아예 없어 통계를 쓰지 않는 기본 값으로 후퇴한 것이고, 50은 그 기본값에서 바로 나온 숫자입니다.
표본으로 뽑은 3만 행 안에서 이 값이 50번 나왔습니다.
users는 1만 행이라 표본이 3만 행일 수 없습니다. 애초에 표본에서 실제로 센 값이었다면 그만큼 정확했을 것입니다. 추정 50은 데이터를 본 적 없는 상수에서 나왔습니다.
해설
통계는 컬럼 단위로 수집되므로 컬럼을 함수로 감싸는 순간 참조할 것이 사라집니다. 그러면 플래너는 연산자마다 정해둔 기본 선택도로 후퇴하고, 등치 조건의 기본값은 0.5%입니다. 이 값은 데이터를 전혀 반영하지 않으므로 어떤 방향으로든 크게 틀릴 수 있습니다. 해법은 그 표현식 자체에 인덱스를 만드는 것입니다. 표현식 인덱스가 있으면 ANALYZE가 그 표현식을 평가한 값으로 별도의 통계를 수집합니다. 인덱스를 실제로 타지 않더라도 통계만으로 추정이 제자리를 찾는 경우가 있고, 같은 이유로 통계를 얻으려고 표현식 인덱스를 만드는 것이 정당한 선택이 되기도 합니다.
-- 같은 서버, 같은 데이터, 같은 계획입니다.
-- 낮에 손으로 돌리면 1.2초, 새벽 배치 안에서는 24초가 걸립니다.
-- 낮
Seq Scan on orders (cost=0.00..246860.00 rows=49948 width=23)
(actual time=0.021..1180.334 rows=49948.00 loops=1)
Filter: (amount > '500'::numeric)
Buffers: shared hit=90360
-- 새벽 배치
Seq Scan on orders (cost=0.00..246860.00 rows=49948 width=23)
(actual time=0.314..23904.701 rows=49948.00 loops=1)
Filter: (amount > '500'::numeric)
Buffers: shared hit=412 read=89948
계획도 cost도 추정 행 수도 같은데 실행 시간만 20배 차이가 납니다. 어디에서 갈렸습니까?
새벽에 통계가 갱신되면서 같은 계획이라도 다른 경로를 타게 됐습니다.
계획이 글자 그대로 같고 cost와 추정 행 수까지 동일합니다. 통계가 바뀌었다면 그중 하나는 달라졌을 것입니다.
새벽 배치는 트랜잭션 안에서 돌아 MVCC 가시성 판정 비용이 추가로 붙습니다.
가시성 판정은 두 경우 모두 튜플마다 일어납니다. 트랜잭션을 명시적으로 열었는지 여부가 그 비용을 바꾸지 않습니다.
정답버퍼 적중입니다. 낮에는 90,360페이지가 전부 메모리에 있었고 새벽에는 그중 89,948페이지를 다시 읽어와야 했습니다.
cost 모델은 페이지가 메모리에 있는지 없는지를 아예 다루지 않습니다. 읽어야 할 페이지 수가 같으면 캐시 상태와 무관하게 같은 cost가 나옵니다. 그래서 계획이 같은데 시간만 다른 상황은 대부분 hit과 read의 비율에서 갈립니다. 새벽 배치가 앞서 다른 대량 스캔을 돌려 shared_buffers를 밀어낸 것이 흔한 원인입니다.
cost가 246,860으로 이미 높으니 24초 쪽이 정상이고 낮의 1.2초가 예외적으로 빨랐던 것입니다.
cost는 seq_page_cost를 1로 놓은 상대값이라 밀리초로 번역되지 않습니다. 숫자의 크기만 보고 어느 쪽이 정상인지 말할 수 없고, 애초에 다른 쿼리의 cost와 견주는 것도 의미가 없습니다.
해설
cost가 낮은데 실제로 느리다면 계획을 더 들여다보기 전에 Buffers의 read 값을 먼저 봐야 합니다. cost 모델이 캐시 상태를 모른다는 사실이 이 진단의 근거입니다. 플래너는 페이지를 몇 장 읽어야 하는지까지만 계산하고, 그 페이지가 메모리에 있는지 디스크에서 와야 하는지는 계산에 넣지 않습니다. 그래서 같은 계획이 캐시가 더운 상태와 찬 상태에서 전혀 다른 시간을 보입니다. 이 경우 손댈 곳은 계획이 아니라 shared_buffers 크기, 배치 순서, 또는 대량 스캔이 캐시를 밀어내지 않도록 작업을 분리하는 쪽입니다.
outer 조건을 u.id = 42에서 u.id < 10000으로 바꾸자 계획이 Nested Loop에서 Hash Join으로 넘어갔습니다. 두 알고리즘의 cost는 입력 크기에 어떻게 다르게 반응합니까?
둘 다 양쪽 행 수의 곱에 비례하지만 Hash Join 쪽 상수가 더 작습니다.
Hash Join은 build에서 작은 쪽을 한 번, probe에서 큰 쪽을 한 번 훑고 끝납니다. 상수의 크기가 아니라 곱셈이냐 덧셈이냐가 두 알고리즘을 가르는 지점입니다.
Hash Join은 outer 크기와 무관하게 항상 같은 비용이 듭니다.
probe 단계에서 큰 쪽 전체를 훑으므로 그쪽 행 수에 비례해 늘어납니다. 무관한 것이 아니라 선형으로 늘어난다는 것이 요점입니다.
정답Nested Loop는 outer 행 수만큼 곱해지고, Hash Join은 양쪽을 한 번씩 훑는 덧셈입니다.
outer가 1행이면 안쪽 인덱스를 한 번만 타므로 Nested Loop가 압도적으로 쌉니다. outer가 1만 행이면 같은 조회가 1만 번 반복되지만, Hash Join은 해시 테이블을 한 번 짓고 큰 쪽을 한 번 훑는 것으로 끝납니다. 어느 지점에서 뒤집힐지는 양쪽 크기와 안쪽 조회 단가가 정합니다.
Nested Loop는 outer가 커지면 안쪽 인덱스가 캐시에서 밀려나 느려집니다.
캐시 효과가 실제로 있을 수는 있지만 플래너는 캐시 상태를 cost에 넣지 않습니다. 계획이 바뀐 것은 추정 비용이 뒤집혔기 때문이지 실제 캐시 거동 때문이 아닙니다.
해설
세 알고리즘의 성격은 cost 공식의 모양에 다 들어 있습니다. Nested Loop는 outer 행 수와 안쪽 조회 비용의 곱, Hash Join은 build와 probe의 합, Merge Join은 양쪽 정렬 비용에 병합 비용을 더한 값입니다. 곱셈인 Nested Loop는 outer가 작을 때 무적이고 커질수록 급격히 나빠집니다. 덧셈인 Hash Join은 양쪽이 아무리 커도 선형으로만 늘어나는 대신 등가 조건에서만 쓸 수 있고 메모리를 요구합니다. 어떤 알고리즘이 좋고 나쁜 것이 아니라, 어느 구간에 있느냐로 승자가 바뀝니다.
Nested Loop (cost=0.71..16.87 rows=1 width=44)
(actual time=0.043..41203.882 rows=78412.00 loops=1)
-> Index Scan using addresses_country_city_idx on addresses a
(cost=0.42..8.44 rows=1 width=20)
(actual time=0.019..38.204 rows=78412.00 loops=1)
Index Cond: ((country = 'KR'::text) AND (city = 'Seoul'::text))
-> Index Scan using orders_addr_id_idx on orders o
(cost=0.29..8.31 rows=1 width=24)
(actual time=0.000..0.000 rows=1.00 loops=78412)
Index Cond: (addr_id = a.id)
Execution Time: 41209.334 ms
41초가 걸렸습니다. 가장 먼저 손댈 곳은 어디입니까?
안쪽 Index Scan이 병목이므로 orders에 더 나은 인덱스를 만들어야 합니다.
안쪽 한 번의 actual time은 0.000ms입니다. 이보다 빠른 인덱스를 만들 여지가 없습니다. 문제는 한 번의 비용이 아니라 그것을 몇 번 반복하느냐입니다.
정답outer 추정이 1행인데 실제 78,412행입니다. 그 숫자부터 통계로 바로잡아야 합니다.
안쪽 노드의 loops가 78,412입니다. 한 번은 0.000ms로 끝나도 그만큼 반복하면 40초가 됩니다. 플래너는 outer가 1행이라 믿었기에 이 반복을 1회분으로만 계산했고, 그래서 Nested Loop가 가장 싸 보였습니다. country와 city처럼 서로 종속된 조건이라면 CREATE STATISTICS가, 통계가 낡았다면 ANALYZE가 먼저입니다.
enable_nestloop를 off로 두어 Hash Join을 강제합니다.
증상은 사라지지만 원인은 남습니다. 잘못된 행 수 추정은 이 쿼리 말고 다른 계획에서도 계속 나쁜 선택을 만들고, 전역으로 끄면 Nested Loop가 정말 유리한 쿼리까지 함께 망가집니다. 원인을 확인하는 동안 임시로 쓰는 수단입니다.
work_mem을 올려 Nested Loop가 안쪽 결과를 재사용하게 합니다.
Nested Loop는 세 알고리즘 중 유일하게 work_mem과 무관합니다. 안쪽 결과를 기억해 반복 조회를 건너뛰는 것은 Memoize 노드의 일이고, 이 계획에는 그 노드가 없습니다.
해설
Nested Loop가 위험해지는 지점은 알고리즘 자체가 아니라 outer 크 기를 잘못 추정했을 때입니다. 추정이 1행이면 안쪽 조회 비용이 1회분으로만 잡혀 어떤 대안보다 싸 보이고, 실제로 수만 행이 들어오면 그 조회가 수만 번 돌아갑니다. 같은 오차가 Hash Join에서는 훨씬 덜 아픕니다. 덧셈 구조라 입력이 커진 만큼만 선형으로 늘어나기 때문입니다. 그래서 계획을 읽을 때 안쪽 노드의 loops와 바깥 노드의 추정 대 실측을 함께 보는 습관이 중요합니다. 한 번의 시간이 0에 가까워도 loops가 크면 그 곱이 전체를 지배합니다.
양쪽이 모두 큰 조인에서 Nested Loop로 가면 안쪽 조회가 바깥 행 수만큼 반복됩니다. Hash Join이 덧셈으로 끝낼 일을 곱셈으로 되돌리는 방향입니다.
Buckets가 65,536뿐이라 해시 충돌이 잦습니다. 버킷 수를 직접 늘립니다.
버킷 수는 사용자가 정하는 값이 아니라 주어진 메모리 예산 안에서 자동으로 결정됩니다. 그리고 여기서 디스크를 오가게 만든 것은 충돌이 아니라 예산 초과입니다.
temp의 read와 written이 같으니 정상 동작입니다. 손댈 곳이 없습니다.
쓴 만큼 읽는 것은 batch 처리의 당연한 결과이지 건강하다는 신호가 아닙니다. 이 왕복 자체가 없앨 수 있는 비용입니다.
정답이 세션의 메모리 한도를 올려 Batches를 1로 내립니다. 대가는 그 값이 곱해진다는 점입니다.
Batches 32는 해시 테이블을 32조각으로 쪼개 양쪽을 디스크에 내렸다가 다시 읽었다는 뜻이고, temp의 read와 written이 그 왕복입니다. 한 조각에 다 담기면 이 왕복이 통째로 사라집니다. 다만 해시 노드의 한도는 work_mem × hash_mem_multiplier이고 노드마다 따로 잡히므로, 전역으로 올리면 동시 커넥션 수까지 곱해진 값이 최악의 상한이 됩니다.
해설
Hash Join에서 Batches가 2 이상이면 그 자리에 없앨 수 있는 디스크 왕복이 있다는 신호입니다. 확인은 쉽습니다. 세션에서만 한도를 올려 다시 찍어보고 Batches가 1로 떨어지는지, temp 지표가 사라지는지 보면 됩니다. 어려운 쪽은 그 다음입니다. work_mem은 세션 하 나에 하나씩 주어지는 값이 아니라 정렬과 해시 노드마다 따로 잡히는 값이라, 노드가 셋인 쿼리를 커넥션 100개가 동시에 돌리면 상한이 그만큼 곱해집니다. 그래서 전역으로 올리기보다 문제가 확인된 쿼리나 배치 세션에만 걸어주는 편이 안전합니다.
Merge Join은 셋 중 가장 드물게 보이지만 특정 상황에서는 Hash Join을 이깁니다. 어떤 상황입니까?
정답양쪽이 조인 키로 이미 정렬돼 있고 결과에도 그 정렬이 필요한 경우입니다.
정렬을 인덱스가 공급하면 Merge Join의 cost에서 Sort 두 개가 통째로 빠집니다. 게다가 출력이 이미 정렬된 상태로 나오므로 위쪽의 ORDER BY도 공짜가 됩니다. Hash Join은 출력 순서를 보장하지 않아 같은 쿼리에서 Sort 노드가 하나 더 붙습니다.
조인 키에 중복이 많아 해시 버킷이 한쪽에 몰리는 경우입니다.
그 상황에서 Hash Join이 나빠지는 것은 맞지만, 중복이 많으면 Merge Join도 같은 값 구간을 되감아 반복해야 해서 함께 나빠집니다. 스큐는 어느 한쪽의 강점이 아닙니다.
조인 조건이 등가가 아니라 범위 비교인 경우입니다.
Merge Join도 등가 조건에만 쓸 수 있습니다. 정확히는 B-tree 연산자 패밀리에 등록된 등가 연산자여야 병합 조건이 됩니다. 범위 조인은 Merge와 Hash 둘 다 못 쓰고 Nested Loop로 떨어집니다.
메모리가 부족해 해시 테이블이 한도 안에 들어가지 않는 경우입니다.
메모리가 부족하면 Merge Join의 Sort도 외부 정렬로 디스크를 씁니다. 두 알고리즘이 함께 나빠지는 조건이라 이것만으로 승부가 갈리지 않습니다.
해설
Merge Join의 cost는 정렬 둘에 병합 하나입니다. 정렬 비용이 살아 있으면 Hash Join의 덧셈에 밀리지만, 그 비용이 0이 되는 순간 경쟁력이 생깁니다. 0이 되는 경로는 두 가지입니다. 양쪽 조인 키에 B-tree 인덱스가 있어 인덱스 스캔이 정렬된 순서를 그대로 내보내는 경우, 그리고 아래쪽 노드가 이미 정렬을 만들어 올려보내는 경우입니다. 여기에 결과가 정렬된 상태로 필요한 쿼리라면 이득이 한 번 더 붙습니다. Hash Join으로 갔다면 마지막에 Sort를 얹어야 하는데 Merge Join은 그대로 내보내면 됩니다.
-- ip_ranges는 5만 행, access_log는 800만 행입니다.-- enable_nestloop를 꺼도 계획이 바뀌지 않습니다.EXPLAINSELECT l.ip, r.org
FROM access_log l
JOIN ip_ranges r ON l.ip_num BETWEEN r.lo AND r.hi;-- Nested Loop (cost=0.00..28412030.00 rows=4000000 width=40)-- Join Filter: ((l.ip_num >= r.lo) AND (l.ip_num <= r.hi))
양쪽 다 큰데 플래너가 Nested Loop만 내놓습니다. 다른 두 알고리즘이 후보에 오르지 못하는 이유는 무엇입니까?
양쪽에 인덱스가 없어 Merge Join이 정렬을 얻지 못했습니다.
정렬이 없으면 Sort 노드를 붙여서라도 Merge Join을 후보에 올릴 수는 있습니다. 여기서 막힌 것은 정렬 유무가 아니라 조건의 종류입니다.
정답Hash Join과 Merge Join은 등가 조건에만 쓸 수 있습니다.
해시는 같은 값이 같은 버킷으로 간다는 성질에 기대므로 크고 작음을 물을 수 없습니다. Merge도 B-tree 연산자 패밀리의 등가 연산자에만 붙습니다. 범위 조인은 두 알고리즘의 전제 자체를 벗어나기 때문에 Nested Loop만 남고, enable_nestloop를 꺼도 대안이 없어 계획이 그대로입니다.
결과 행이 400만으로 추정되어 해시 테이블이 메모리 한도를 넘어서기 때문입니다.
한도를 넘으면 batch로 쪼개서라도 Hash Join을 씁니다. 후보에 오르지도 못한 것은 비용을 견주기 이전 단계의 문제입니다.
access_log가 800만 행이라 build 쪽으로 쓰기에 너무 큽니다.
build 쪽은 둘 중 작은 쪽을 고르므로 5만 행짜리 ip_ranges가 쓰였을 것입니다. 크기는 이 상황에서 걸림돌이 아닙니다.
해설
조인 알고리즘의 선 택은 비용 비교 이전에 자격 심사를 통과해야 합니다. Hash Join과 Merge Join은 등가 조건 전용이고, 범위나 비등가 조건은 Nested Loop만 처리할 수 있습니다. IP 대역 매칭, 유효 기간 매칭, 구간별 요율표 조인이 실무에서 이 함정에 자주 걸립니다. 해법은 조건을 등가로 바꿔줄 무언가를 만드는 것입니다. 범위를 고정 크기 버킷으로 쪼개 버킷 키를 등가 조인에 쓰거나, range 타입과 GiST 인덱스를 써서 안쪽 조회 단가를 낮추는 방향입니다. 안쪽이 인덱스로 싸지면 Nested Loop 자체는 나쁜 선택이 아닙니다.
테이블 11개를 명시적 JOIN으로 이어붙인 리포트 쿼리입니다. 어느 날부터 3초짜리가 40초가 됐습니다. 통계는 최신이고 각 노드의 추정과 실측도 크게 어긋나지 않습니다. 다만 조인 순서가 사람이 보기에 납득하기 어려운 모양입니다.
여기서 먼저 확인할 설정은 무엇입니까?
work_mem입니다. 조인 순서는 중간 결과가 메모리에 들어가는지로 결정됩니다.
work_mem은 각 노드가 쓸 수 있는 메모리 상한이지 조인 순서를 정하는 기준이 아닙니다. 순서는 중간 결과의 추정 행 수와 그로부터 나오는 cost로 정해집니다.
default_statistics_target입니다. 조인 순서는 MCV 개수를 보고 결정됩니다.
통계 정밀도가 추정 행 수를 통해 간접적으로 영향을 주기는 하지만, 지문은 추정과 실측이 크게 어긋나지 않는다고 못 박고 있습니다. 추정이 맞는데도 순서가 이상하다면 다른 축을 봐야 합니다.
정답join_collapse_limit과 geqo_threshold입니다. 테이블 수에 따라 탐색 범위와 탐색 방식이 달라집니다.
테이블 수가 geqo_threshold(기본 12) 미만이면 동적 프로그래밍으로 조합을 전부 견주지만, 넘어서면 유전자 알고리즘이 근사해를 찾아 최적을 놓칠 수 있습니다. 그리고 명시적 JOIN 트리를 몇 개까지 펼쳐 탐색 대상에 넣을지는 join_collapse_limit(기본 8)이 정하므로, 11개를 이어붙였어도 일부는 쓴 순서에 묶인 채로 남습니다.
random_page_cost입니다. 이 값이 낮으면 플래너가 조인 순서를 뒤집습니다.
이 상수는 인덱스 경로와 순차 경로 중 무엇을 고를지에 영향을 주지, 어느 테이블을 먼저 붙일지를 직접 정하지 않습니다. 스캔 방식이 바뀌면 순서가 따라 바뀔 수는 있지만 지문의 증상은 순서 쪽에 있습니다.
해설
조인 순서는 알고리즘 선택과 별개의 축이고, 테이블이 늘수록 조합이 폭발하므로 플래너는 탐색을 스스로 제한합니다. 그래서 테이블 수가 어느 선을 넘는 순간 계획의 성격이 달라질 수 있습니다. 12개를 넘으면 전수 비교에서 근사 탐색으로 바뀌고, 명시적 JOIN을 쓴 쿼리는 8개를 넘는 부분이 작성 순서에 묶입니다. 사람이 보기에 이상한 순서가 나왔다면 이 두 한계선부터 확인하는 것이 순서입니다. 한계를 올리면 탐색 시간이 늘어나므로 무한정 키울 수는 없고, 대안으로 쿼리를 CTE로 쪼개 조인 블록을 나누는 방법이 있습니다.
Workers Launched는 2인데 안쪽 노드의 loops는 3입니다. 나머지 하나는 어디서 왔습니까?
Gather 노드 자체가 한 번의 실행으로 함께 세어집니다.
loops는 그 노드가 몇 번 실행됐는지를 세는 값이라 자기 자신을 아래쪽 카운터에 더하지 않습니다. Gather는 위쪽 노드가 한 번 호출하므로 loops가 1로 찍혀 있습니다.
정답parallel_leader_participation이 기본 on이라 leader도 자기 몫을 스캔합니다.
Workers Planned와 Launched는 leader를 제외한 숫자입니다. leader도 Gather 아래 plan을 직접 실행하므로 실제 실행 흐름은 항상 worker 수보다 하나 많습니다. 1천만 행을 셋이 나눠 3,333,333.33씩 맡은 것이 그 결과입니다.
worker 하나가 중간에 죽어 재시작되면서 한 번 더 세어졌습니다.
worker가 죽으면 쿼리가 에러로 끝나거나 남은 흐름이 그 몫을 나눠 갖습니다. loops가 늘어나는 형태로 나타나지 않습니다.
Partial HashAggregate가 그룹 개수만큼 여러 번 반복 실행됩니다.
이 노드는 자기 몫의 입력을 한 번 훑으면서 그룹별 부분 집계를 만듭니다. 그룹 개수는 출력 행 수에 나타나지 loops에 나타나지 않습니다.
해설
병렬 계획을 읽을 때 실행 흐름 수를 worker 수와 헷갈리면 처리량 계산이 어긋납니다. leader는 결과를 모으기만 하는 관리자가 아니라 자기 몫을 함께 처리하는 참여자입니다. Workers Planned가 2면 셋이 나눠 읽고, 4면 다섯이 나눠 읽습니다. 계산할 때는 loops를 보는 것이 가장 확실합니다. leader가 참여하지 않기를 원한다면 parallel_leader_participation을 끌 수 있는데, leader가 위쪽 노드 처리에 바빠 병목이 되는 경우에나 의미가 있고 보통은 그대로 두는 편이 낫습니다.
-- logs는 4GB입니다. 이 SELECT만 따로 돌리면 Parallel Seq Scan이 붙습니다.EXPLAININSERTINTO logs_summary (level, cnt)SELECTlevel,COUNT(*)FROM logs GROUPBYlevel;-- Insert on logs_summary-- -> HashAggregate-- -> Seq Scan on logs
같은 SELECT인데 INSERT 안에 넣자 병렬이 사라졌습니다. 이유는 무엇입니까?
정답데이터를 쓰는 쿼리에는 병렬 plan이 만들어지지 않습니다. SELECT 부분만 따로 병렬로 도는 일도 없습니다.
쓰기나 행 잠금이 포함되면 그 쿼리 전체가 병렬 후보에서 빠집니다. 최상위에 있든 CTE 안에 있든 마찬가지입니다. 병렬 INSERT는 개발 중에 커밋됐다가 되돌려진 뒤 아직 들어오지 않았습니다. 예외는 새 테이블을 만들며 채우는 CREATE TABLE AS나 CREATE MATERIALIZED VIEW 계열로, 이때는 아래 SELECT가 병렬로 돕니다.
대상 테이블 logs_summary가 작아 min_parallel_table_scan_size에 못 미칩니다.
그 기준은 병렬로 훑을 대상인 logs에 적용되지 결과를 쓰는 테이블에 적용되지 않습니다. logs는 4GB라 기준을 넉넉히 넘습니다.
INSERT가 행 락을 잡아 worker끼리 서로를 기다리게 되므로 플래너가 피합니다.
결론은 비슷해 보이지만 경로가 다릅니다. 플래너는 실행 중의 락 경합을 예측해 회피하는 것이 아니라, 쓰기가 섞여 있으면 애초에 병렬 plan을 후보로 만들지 않습니다.
GROUP BY 결과가 3행뿐이라 병렬 오버헤드가 더 크다고 판단했습니다.
출력이 3행이어도 훑어야 할 입력이 4GB면 병렬이 유리하다고 계산됩니다. 같은 SELECT를 따로 돌리면 병렬이 붙는다는 사실이 그 증거 입니다.
해설
병렬이 켜지는 조건은 세 가지가 AND로 붙습니다. 테이블이 하한 크기를 넘고, 병렬 오버헤드를 감수할 만큼 cost가 크고, 쿼리의 모든 요소가 병렬 안전해야 합니다. 실무에서 가장 자주 걸리는 것은 세 번째이고 그중에서도 쓰기와 행 잠금입니다. SELECT ... FOR UPDATE, DECLARE CURSOR로 만든 커서, PARALLEL UNSAFE 함수가 같은 이유로 병렬을 끕니다. 특히 사용자 정의 함수는 명시하지 않으면 기본이 unsafe이므로, 집계 쿼리에 자체 함수를 하나 끼워넣은 순간 병렬이 통째로 사라지는 일이 생깁니다.
-- 새벽 배치 시간대에만 같은 쿼리가 3배 느립니다. 계획은 낮과 동일합니다.
Gather (actual rows=3.00 loops=1)
Workers Planned: 4
Workers Launched: 0
-> Partial HashAggregate (actual rows=3.00 loops=1)
-> Parallel Seq Scan on logs
(actual rows=10000000.00 loops=1)
Workers Planned는 4인데 하나도 뜨지 않았습니다. 어디를 확인해야 합니까?
통계가 갱신되면서 플래너가 병렬을 포기했습니다.
플래너가 포기했다면 Gather 노드 자체가 계획에 없었을 것입 니다. 계획에는 남아 있는데 실행 시점에만 0이라는 것은 계획과 실행이 갈렸다는 뜻입니다.
parallel_leader_participation이 off로 바뀌어 leader가 빠졌습니다.
off인데 worker도 0이라면 실행할 흐름이 남지 않습니다. 지금은 leader가 혼자 1천만 행을 다 읽고 있으므로 이 설정은 오히려 on입니다.
테이블이 커져 min_parallel_table_scan_size 판정이 뒤집혔습니다.
그 기준은 넘어야 병렬 후보가 되는 하한선이라 테이블이 커지면 더 확실히 통과합니다. 그리고 이 판정은 계획 단계의 일이라 Workers Launched를 좌우하지 않습니다.
정답서버 전체가 공유하는 병렬 worker 풀입니다. 같은 시간대의 다른 쿼리들이 이미 다 쓰고 있으면 요청이 거절됩니다.
worker 요청은 서버 전체가 공유하는 풀에서 나갑니다. 풀이 비어 있지 않으면 plan은 그대로인 채 Launched만 줄고, 0이면 leader 혼자 Parallel Seq Scan을 전부 처리합니다. loops가 1이고 그 하나가 1천만 행을 다 읽은 것이 그 증거입니다. max_parallel_workers와 max_worker_processes, 그리고 그 시간대의 동시 쿼리 수를 함께 봐야 합니다.
해설
Workers Planned는 계획이고 Workers Launched는 실행 결과입니다. 둘이 다르면 원인은 플래너가 아니라 실행 시점의 자원 상황에 있습니다. worker는 커넥션마다 따로 잡히는 자원이 아니라 서버 하나가 max_parallel_workers만큼만 들고 있는 공용 자원 이라, 병렬 쿼리 여럿이 동시에 몰리면 뒤에 온 쿼리부터 요청이 거절됩니다. max_parallel_workers_per_gather를 올려 쿼리 하나의 병렬도를 키우는 조정이 이 상황을 악화시킬 수 있다는 점도 함께 봐야 합니다. 한 쿼리가 더 많이 가져갈수록 다른 쿼리에 남는 몫이 줄어듭니다.
-- logs는 created_at 기준 월 단위 RANGE 파티션 36개입니다.EXPLAINSELECTCOUNT(*)FROM logs
WHEREdate(created_at)='2026-04-15';-- Aggregate-- -> Append-- -> Seq Scan on logs_2023_05 logs_1-- -> Seq Scan on logs_2023_06 logs_2-- ... (36개가 모두 남음)
파티션 키로 조건을 걸었는데도 36개가 전부 스캔됩니다. 원인과 해법은 무엇입니까?
파티션 키에 인덱스가 없어 pruning이 동작하지 않습니다.
pruning은 인덱스가 아니라 파티션 경계 정의를 보고 판단하므로 인덱스가 없어도 걸립니다. 인덱스는 살아남은 파티션 안에서 행을 찾는 다음 단계에서 쓰입니다.
파티션이 36개나 되어 플래너가 pruning 탐색을 포기했습니다.
파티션이 많으면 planning 시간이 늘기는 하지만 개수 때문에 pruning을 포기하지는 않습니다. 하나도 잘려나가지 않았다는 것은 판단 자체가 성립하지 못했다는 뜻입니다.
정답date()로 감싼 값은 파티션 키와 다른 값입니다. 함수를 걷어내고 범위 조건으로 바꿔야 합니다.
파티션 경계는 created_at 원본 값으로 정의돼 있어서, 각 파티션이 조건과 겹치는지 판단하려면 조건도 그 컬럼에 직접 걸려 있어야 합니다. created_at >= '2026-04-15' AND created_at < '2026-04-16'으로 바꾸면 4월 파티션 하나만 남습니다. 인덱스를 표현식으로 감싸면 못 타는 것과 같은 구조의 문제입니다.
COUNT(*)는 모든 행을 세어야 하므로 pruning 대상이 아닙니다.
세는 대상은 조건을 통과한 행이지 테이블 전체가 아닙니다. 같은 COUNT(*)라도 조건을 원본 컬럼으로 바꾸면 파티션 하나만 읽고 끝납니다.
해설
pruning이 걸렸는지 확인하는 방법은 Append 아래 자식 노드 수를 세는 것입니다. 잘려나간 파티션은 EXPLAIN 출력에 흔적조차 남지 않기 때문입니다. 걸리지 않는 원인은 몇 가지로 수렴합니다. 파티션 키를 함수나 CAST로 감싼 경우, 파티션 키 타입과 다른 타입의 리터럴을 비교해 세션 타임존에 의존하는 변환이 끼는 경우, 그리고 prepared statement가 값과 무관한 일반 계획으로 굳은 경우입니다. 세 번째는 실행 시점 pruning이 어느 정도 보완하지만 완전하지는 않아서, 의심스러우면 plan_cache_mode를 세션에서 바꿔 차이를 확인해볼 수 있습니다.
5억 행짜리 이벤트 테이블입니다. autovacuum이 따라오지 못해 bloat가 쌓이고, 90일이 지난 데이터를 지우는 DELETE 배치가 매일 몇 시간씩 돕니다. 월 단위 RANGE 파티셔닝 도입을 검토하고 있습니다.
이 도입으로 얻는 것과 얻지 못하는 것을 바르게 짝지은 것은 무엇입니까?
정답오래된 데이터를 DROP TABLE로 즉시 회수할 수 있게 되지만, autovacuum 총 부담이 줄지는 않습니다.
대량 DELETE는 dead tuple을 만들고 그것을 VACUUM이 다시 치워야 하는 반면, 파티션을 통째로 DROP하면 파일이 사라지므로 회수할 대상 자체가 생기지 않습니다. 반면 살아 있는 데이터에 대한 autovacuum은 파티션마다 따로 돌아야 해서 총 부담은 오히려 조금 늘어납니다. 이 도입의 실질은 회수 부담을 줄이는 것이 아니라 회수라는 작업을 없애는 것입니다.
autovacuum이 파티션 단위로 병렬 실행되어 총 부담이 파티션 수만큼 줄어듭니다.
파티션마다 따로 도는 것은 맞지만 일이 나뉘는 것이지 사라지는 것이 아닙니다. 오히려 테이블이 늘어난 만큼 autovacuum이 관리할 대상과 갱신할 통계도 함께 늘어납니다.
글로벌 UNIQUE 제약이 파티션별로 나뉘어 삽입 성능이 좋아집니다.
유일성 제약을 파티션 키 없이는 걸 수 없다는 것은 이득이 아니라 제약입니다. id 단독 유일성이 필요했다면 그것을 잃는 쪽이고, 애플리케이션이 대신 보장해야 합니다.
파티션 키에 인덱스가 자동으로 생겨 범위 조회가 빨라집니다.
파티션 키에 인덱스가 자동으로 붙지는 않습니다. pruning은 어떤 파티션을 읽을지만 정하고, 그 파티션 안에서 행을 찾는 일은 여전히 인덱스의 몫입니다.
해설
파티셔닝을 도입할 때 기대와 실제가 가장 크게 갈리는 지점이 보관 정책과 VACUUM입 니다. 오래된 데이터를 지우는 일이 DELETE에서 DROP TABLE로 바뀌는 것은 성능 개선이 아니라 작업의 성격이 바뀌는 것입니다. 몇 시간짜리 배치가 순간에 끝나고, 그 뒤에 따라오던 VACUUM과 인덱스 정리도 함께 사라집니다. 반면 살아 있는 데이터를 관리하는 비용은 줄지 않고, 파티션 수만큼 관리 대상이 늘고, planning 비용도 늘고, 유일성 제약과 파티션 키를 바꾸는 UPDATE에는 새로운 제약이 붙습니다. 그래서 판단 기준은 테이블이 큰가가 아니라 조건이 파티션 키로 자주 좁혀지는가와 보관 정책이 파티션 경계와 맞는가입니다.
파티션 12개짜리 테이블 둘을 같은 파티션 키로 조인하는 리포트가 느립니다. enable_partitionwise_join을 켜면 무엇이 달라집니까?
각 파티션을 서로 다른 worker가 맡아 조인이 병렬로 돌아갑니다.
그것은 Parallel Append가 하는 일이고 이 설정과는 별개의 축입니다. partitionwise join은 조인을 파티션 쌍 단위로 쪼개는 최적화이고, 그 결과를 병렬로 돌릴지는 따로 결정됩니다.
정답대응되는 파티션끼리만 조인해 큰 조인 하나가 작은 조인 12개로 쪼개집니다.
같 은 경계로 나뉜 두 테이블이면 서로 다른 파티션의 행끼리는 조인 조건을 만족할 수 없습니다. 그래서 작은 조인 12개로 쪼개면 각 해시 테이블이 메모리 한도 안에 들어가 batch 분할이 사라지는 경우가 많습니다. 대신 플래너가 견줘야 할 조합이 늘어나므로 파티션이 수백 개인 환경에서는 켜기 전에 planning 시간을 재봐야 합니다.
두 테이블의 파티션 경계가 달라도 플래너가 자동으로 맞춰줍니다.
경계가 서로 대응해야 이 최적화가 성립합니다. 경계가 다르면 어느 파티션이 어느 파티션과만 맞는지 보장할 수 없어 적용되지 않습니다.
기본으로 켜져 있으므로 따로 설정할 필요가 없습니다.
partitionwise join과 partitionwise aggregate는 둘 다 기본 off입니다. planning 비용이 늘어나는 것이 이유이고, 파티션이 많을수록 그 비용이 커집니다.
해설
partitionwise 최적화는 파티션 경계라는 정보를 조인과 집계에까지 활용하는 장치입니다. 같은 키로 같은 경계에 나뉜 두 테이블이라면 조인은 파티션 쌍 안에서만 일어날 수 있으므로, 큰 조인 하나를 작은 조인 여럿으로 바꿀 수 있습니다. 여기서 오는 이득은 두 갈래입니다. 각 조인의 입력이 작아져 메모리 안에서 끝나고, 파티션마다 독립적이라 병렬화도 쉬워집니다. 대가는 플래너가 다뤄야 할 경우의 수입니다. 기본값이 off인 이유가 여기 있고, 그래서 파티션 수가 적당하고 조인이 실제로 병목인 환경에서만 켜는 것이 맞습니다.
dead tuple은 전부 사라졌는데 파일 크기는 UPDATE 직후와 똑같은 13MB입니다. VACUUM이 실패한 것입니까?
실패했습니다. dead tuple 수거를 막는 long-running 트랜잭션이 있습니다.
그런 상황이면 dead_tuple_count가 0으로 떨어지지 않습니다. 이 출력은 오히려 수거가 완료되었다는 증거입니다.
통계가 갱신되지 않았을 뿐입니다. ANALYZE를 돌리면 크기가 줄어듭니다.
pg_relation_size는 통계가 아니라 실제 파일 크기를 읽습니다. ANALYZE는 플래너용 통계만 갱신하며 파일에 손대지 않습니다.
인덱스 정리 단계가 남아 있어 다음 VACUUM에서 줄어듭니다.
인덱스 정리는 이번 VACUUM 안에서 이미 끝났고, 인덱스 파일과 힙 파일의 크기는 별개입니다. 몇 번을 더 돌려도 일반 VACUUM은 힙 파일 중간을 잘라내지 않습니다.
정답정상입니다. 일반 VACUUM은 공간을 재사용 가능으로 표시할 뿐 OS에 반환하지 않습니다.
dead tuple의 line pointer를 LP_UNUSED로 되돌리고 FSM에 여유 공간을 기록하는 것까지가 일반 VACUUM의 일입니다. 이후 INSERT가 그 자리부터 채우므로 테이블이 더 커지지는 않지만, 파일 길이 자체는 줄지 않습니다.
해설
일반 VACUUM이 하는 일은 dead tuple이 쓰던 자리를 비워 재사용 가능하게 만드는 것까지입니다. line pointer가 LP_UNUSED로 돌아가고 FSM에 여유 공간이 기록되면 이후 INSERT와 UPDATE가 그 자리를 먼저 채우므로 테이블은 더 커지지 않습니다. 다만 파일 길이는 그대로입니다. 예외가 하나 있는데, 파일 끝쪽 페이지가 통째로 비면 VACUUM이 그만큼을 잘라냅니다. 실제 테이블에서 dead tuple은 파일 전체에 흩어져 있어 이 조건이 거의 성립하지 않습니다. 파일 크기를 실제로 줄이려면 살아 있는 튜플만 새 파일에 옮겨 쓰는 VACUUM FULL이나 pg_repack이 필요하고, 그 대가로 테이블 크기만큼의 임시 공간과 락을 내주게 됩니다.
-- Session A는 직렬화 실패를 받고 ROLLBACK한 뒤 재시도하려고-- 트랜잭션을 다시 열어 accounts를 SELECT했습니다. 그런데 애플리케이션이-- 다음 단계에서 멈춰 그 트랜잭션이 몇 시간째 그대로 남아 있습니다.SELECT pid, state, backend_xid, backend_xmin,now()- xact_start AS open_for
FROM pg_stat_activity WHERE backend_xmin ISNOTNULL;-- pid | state | backend_xid | backend_xmin | open_for-- -------+---------------------+-------------+--------------+------------ 8412 | idle in transaction | | 743 | 03:12:45
이 세션 하나가 데이터베이스에 만드는 영향은 무엇입니까?
정답이 트랜잭션의 xmin이 수거 기준선을 붙잡아, 이후 죽은 튜플이 어느 테이블에서도 회수되지 않습니다.
VACUUM은 활성 중인 모든 snapshot에서 보이지 않는 튜플만 수거합니다. 열려 있는 트랜잭션 하나가 옛 snapshot을 붙들고 있으면 기준선이 그 시점에 못 박히고, 그 뒤에 죽은 튜플은 전부 대상에서 빠집니다. 정확히는 같은 데이터베이스에 접속한 백엔드끼리 공유하는 기준선이라, 이 세션이 건드리지도 않은 테이블까지 함께 영향을 받습니다.
Session A가 실제로 읽은 accounts 테이블에서만 VACUUM이 회수에 실패합니다.
수거 기준선은 테이블별 값이 아닙니다. 같은 데이터베이스에 접속한 모든 백엔드의 xmin 중 가장 오래된 값 하나로 정해지므로, 어느 테이블을 읽었는지와 무관하게 모든 테이블의 기준이 함께 밀립니다.
읽기만 한 트랜잭션이라 xid를 배정받지 않았으므로 수거 기준선과 무관합니다.
xid가 없어도 snapshot을 들고 있으면 backend_xmin이 남습니다. 수거 기준선은 배정된 xid가 아니라 각 백엔드가 광고하는 xmin으로 정해지므로, 출력의 backend_xid가 비어 있는 것은 반박 근거가 되지 못합니다.
잠금이 걸린 row에만 영향이 있고 VACUUM 동작과는 무관합니다.
락과 가 시성은 별개의 축입니다. 이 세션은 락을 하나도 잡고 있지 않아도 xmin horizon만으로 VACUUM을 막을 수 있습니다.
해설
열린 트랜잭션 하나가 만드는 피해는 그 트랜잭션이 만진 데이터에 한정되지 않습니다. VACUUM이 튜플을 수거하려면 현재 활성 중인 어떤 snapshot에서도 보이지 않는다는 것이 확인되어야 하고, 그 기준선은 같은 데이터베이스에서 실행 중인 모든 트랜잭션의 xmin 중 가장 오래된 값입니다. 다른 데이터베이스의 세션은 공유 카탈로그에만 영향을 주지만, 한 인스턴스에 데이터베이스를 하나만 두는 흔한 구성에서는 사실상 전역입니다. 몇 시간 열려 있는 세션이 하나만 있어도 그 시점 이후의 dead tuple이 전부 살아남아 n_dead_tup은 계속 오르고, autovacuum은 열심히 돌면서도 아무것도 회수하지 못합니다. 같은 이유로 relfrozenxid도 전진하지 못해 오래 방치되면 wraparound 압력까지 커집니다. idle_in_transaction_session_timeout으로 이런 세션을 자동으로 끊는 것이 표준적인 방어이고, 오래된 replication slot이 xmin을 고정하고 있을 때도 증상이 똑같이 나타납니다. 한 가지 구분할 것은, 에러를 받고 idle in transaction (aborted)로 남은 세션은 여기에 해당하지 않는다는 점입니다. 문장이 실패하는 순간 abort가 기록되면서 그 백엔드는 xid와 xmin을 이미 내려놓았고 락도 풀었기 때문에, 접속 슬롯만 붙들고 있을 뿐 VACUUM을 막지는 않습니다.
-- PostgreSQL 18, 관련 파라미터는 모두 기본값-- autovacuum_vacuum_threshold = 50-- autovacuum_vacuum_scale_factor = 0.2-- autovacuum_vacuum_max_threshold = 100000000SELECT reltuples FROM pg_class WHERE relname ='events';-- reltuples-- --------------- 1000000000
10억 행짜리 events 테이블에서 autovacuum은 dead tuple이 몇 개를 넘겼을 때 발동합니까?
약 2억 개. 50 + 0.2 × 10억입니다.
PG 17까지의 답입니다. PG 18에서 추가된 상한이 이 계산 결과를 잘라내므로 실제 발동 시점은 절반으로 당겨집니다.
정답약 1억 개. 계산값 2억이 max_threshold에서 잘립니다.
기본 공식으로는 50 + 0.2 × 10억 = 2억 50이지만, PG 18의 autovacuum_vacuum_max_threshold가 상한이라 1억에서 잘립니다. 이 상한이 없던 시절에는 2억 개가 쌓일 때까지 기다렸습니다.
50개. threshold가 절대 기준이고 scale_factor는 보조 조건입니다.
두 값은 선택 관계가 아니라 덧셈입니다. threshold는 행이 아주 적은 테이블에서 scale_factor 항이 0에 가까워지는 것을 막는 하한 역할을 합니다.
판단할 수 없습니다. reltuples는 추정치라 임계값이 확정되지 않습니다.
reltuples가 추정치인 것은 맞지만 autovacuum은 그 추정치를 그대로 공식에 넣습니다. 임계값은 매번 계산되어 확정되며, 통계가 낡으면 기준이 어긋날 뿐 계산이 불가능해지지는 않습니다.
해설
autovacuum의 발동 기준은 autovacuum_vacuum_threshold에 autovacuum_vacuum_scale_factor 곱하기 reltuples를 더한 값입니다. 기본값으로 풀면 전체 행의 20%에 50행을 더한 만큼이라, 테이블이 클수록 기준이 느슨해집니다. 10억 행이면 2억 개가 죽을 때까지 기다린다는 뜻이고, 그쯤이면 Seq Scan 성능은 이미 크게 떨어져 있습니다. PG 18에서 추가된 autovacuum_vacuum_max_threshold(기본 1억)가 계산값을 잘라 이 문제를 완화하지만 1억도 여전히 큰 숫자입니다. 대용량 테이블은 테이블별로 기준을 따로 잡는 것이 실무 표준이라, autovacuum_vacuum_scale_factor를 0.01로, threshold를 1000으로 내리면 1,000만 개 남짓에서 발동해 기준이 열 배가량 빡빡해집니다.
orders 테이블
현재 파일 크기 500 GB
bloat 비율 60% (살아 있는 데이터 200 GB)
남은 디스크 여유 150 GB
서비스 가동 24시간, 중단 불가
이 상황에서 취할 수 있는 조치로 가장 타당한 것은 무엇입니까?
VACUUM FULL을 트래픽이 적은 새벽 시간대에 실행합니다.
시간대를 옮겨 락 문제를 완화해도 디스크 문제는 그대로 남습니다. 작업 중에는 원본 500GB와 사본 200GB가 함께 존재해야 하는데 여유가 150GB라 시작조차 하지 못합니다.
정답autovacuum 기준을 먼저 조이고, 디스크를 확보한 뒤 pg_repack으로 회수합니다.
VACUUM FULL과 pg_repack은 둘 다 살아 있는 튜플을 새 파일에 복사하므로 사본 200GB가 들어갈 자리가 먼저 있어야 합니다. 여유가 150GB뿐이면 어느 쪽도 시작할 수 없어 공간 확보가 선행 조건이고, 그동안 bloat가 더 자라지 않게 막아두는 것이 순서상 먼저입니다.
pg_repack을 바로 실행합니다. 온라인 도구라 지금 상태에서도 안전합니다.
pg_repack이 온라인인 것은 락 측면의 이야기이고, 디스크 측면에서는 VACUUM FULL과 같습니다. 새 테이블에 복사한 뒤 swap하는 방식이라 사본이 들어갈 공간이 똑같이 필요합니다.
일반 VACUUM을 공격적으로 돌려 파일 크기를 200GB로 되돌립니다.
일반 VACUUM은 공간을 재사용 가능으로 표시할 뿐 파일을 잘라내지 않습니다. 아무리 자주 돌려도 이미 부푼 500GB가 줄지는 않습니다. 다만 더 부풀지 않게 막는 효과는 확실해서, 다른 조치의 앞 단계로는 의미가 있습니다.
해설
세 도구의 차이를 락으로만 외워두면 이 상황에서 막힙니다. 락 측면에서는 일반 VACUUM이 가장 가볍고, pg_repack은 마지막 swap 순간에만 배타 락을 잡아 온라인으로 쓸 수 있으며, VACUUM FULL은 작업 내내 모든 접근을 막습니다. 그런데 디스크 측면에서는 pg_repack과 VACUUM FULL이 같은 편입니다. 둘 다 살아 있는 튜플을 새 파일에 복사한 뒤 옛 파일을 지우므 로, 작업 중 한때 원본과 사본이 함께 존재합니다. 여기서는 순간 최대 700GB가 필요한데 쓸 수 있는 것은 650GB라 시작 자체가 막힙니다. 인덱스도 함께 다시 만들어지므로 실제로는 이보다 더 필요합니다. 그래서 순서가 중요합니다. 먼저 autovacuum 기준을 조여 증가를 멈추고, 공간을 확보하고, 그다음에 서비스 영향이 가장 작은 도구로 회수합니다.
cost 한도에 도달했을 때 쉬는 시간을 늘리는 파라미터라 방향이 반대입니다. 게다가 위 실행은 수동 VACUUM이어서 적용되는 값은 vacuum_cost_delay이고, 기본값 0에서는 애초에 쉬지 않으므로 이 설정을 건드려도 622초는 그대로입니다.
정답조회에 쓰이지 않는 인덱스를 찾아 DROP합니다.
2단계 비용은 인덱스 개수에 정비례합니다. 8개 중 2개를 걷어내면 순회 자체가 2회 줄고, 그 인덱스에 dead 엔트리를 만들던 UPDATE 비용과 WAL도 함께 사라집니다.
INDEX_CLEANUP off를 기본 운영 방식으로 삼습니다.
그 회차의 2단계는 사라지지만 인덱스의 dead 엔트리가 그대로 쌓여 다음 회차 비용으로 넘어갈 뿐입니다. 인덱스 bloat가 커지면 Index Scan이 느려지고, 결국 훨씬 비싼 정리를 한 번에 치르게 됩니다. 긴급 상황에서 한 번 쓰는 옵션이지 상시 설정이 아닙니다.
정답maintenance_work_mem을 늘려 dead tuple TID가 한 패스에 담기게 합니다.
수집한 TID가 메모리에 다 들어가지 않으면 VACUUM은 구간을 나눠 여러 패스로 돌고, 패스마다 인덱스 8개를 전부 다시 순회합니다. 요약의 2패스가 그 흔적입니다. 한 패스로 끝나면 인덱스 순회 횟수가 절반이 됩니다.
해설
VACUUM의 2단계는 인덱스 개수와 패스 수의 곱만큼 반복됩니다. 그래서 줄이는 길도 두 갈래입니다. 인덱스 개수를 줄이거나 패스 수를 줄이는 것입니다. 패스가 여러 번으로 갈리는 이유는 1단계에서 모은 dead tuple TID가 maintenance_work_mem에 다 들어가지 않기 때문이고, 한도를 넘으면 그때까지 모은 것으로 2단계와 3단계를 수행한 뒤 남은 구간을 다시 스캔합니다. PG 17부터 TID 저장 구조가 radix tree 기반으로 바뀌어 같은 메모리로 훨씬 많은 TID를 담게 되었고 옛 1GB 상한도 사라졌습니다. 더 근본적인 해법은 dead tuple을 덜 만드는 쪽입니다. HOT update가 성립하면 인덱스에 dead 엔트리 자체가 생기지 않아 2단계가 치울 것이 줄어듭니다.
이미 vacuum_failsafe_age를 넘겨 인덱스 정리와 cost 스로틀링을 포기한 상태입니다.
vacuum_failsafe_age는 16억이고 관측값은 1억 6,430만이라 자릿수가 하나 다릅니다. 이 선에 닿으려면 14억 3,570만 트랜잭션이 더 흘러야 하고, 그때까지는 cost 기반 스로틀링도 인덱스 정리도 평소대로 적용됩니다.
aggressive 전환선은 2억이라 다음 VACUUM도 all-visible 페이지를 건너뜁니다.
2억은 anti-wraparound VACUUM이 강제되는 선이고, aggressive로 바뀌는 선은 vacuum_freeze_table_age인 1.5억입니다. 두 선을 하나로 합쳐 외우면 1.5억과 2억 사이 구간이 통째로 사라집니다.
정답다음에 도는 VACUUM은 aggressive이고, 3,570만 트랜잭션 뒤에는 anti-wraparound가 강제 발동합니다.
age 164,300,000은 vacuum_freeze_table_age(1.5억)를 1,430만 넘긴 값이라 다음 VACUUM은 all-frozen이 아닌 페이지를 전부 훑습니다. autovacuum_freeze_max_age(2억)까지는 3,570만이 남았습니다.
autovacuum_enabled가 off라 2억을 넘겨도 이 테이블에는 VACUUM이 돌지 않습니다.
테이블 단위 autovacuum_enabled는 dead tuple 기준으로 도는 일상 autovacuum만 막습니다. autovacuum_freeze_max_age에 도달한 테이블에는 이 설정과 무관하게 wraparound 방지 VACUUM이 실행되며 취소할 수도 없 습니다.
해설
네 임계값은 age(relfrozenxid) 하나를 놓고 순서대로 걸리는 관문입니다. 관측값 164,300,000을 그 위에 얹으면 위치가 바로 나옵니다. 5천만은 이미 한참 지났고, 1.5억을 1,430만 넘겼으며, 2억까지 3,570만이 남았고, 16억은 아직 자릿수가 다릅니다. 그래서 지금은 aggressive 구간입니다. 일반 VACUUM은 VM에서 all-visible로 표시된 페이지를 건너뛰지만, 이 구간에서는 all-frozen이 아닌 페이지를 전부 스캔하며 오래된 xid를 걷어냅니다. 여기서 실무 판단이 갈립니다. 이 테이블은 autovacuum_enabled가 off라 dead tuple이 아무리 쌓여도 autovacuum이 찾아오지 않고, 그래서 age가 여기까지 밀린 것입니다. 하지만 2억은 그 설정이 통하지 않는 선이라, 3,570만 트랜잭션 뒤 anti-wraparound VACUUM이 시각을 가리지 않고 발동하며 취소도 되지 않습니다. 그때 이 VACUUM은 16억에 한참 못 미치므로 failsafe가 아니라 평소의 cost 스로틀링을 그대로 받으며 도는데, 12GB짜리 테이블이면 트래픽이 몰리는 시간에 몇 시간을 끌 수 있습니다. 그래서 손이 닿을 때 한산한 시간대에 VACUUM을 직접 걸어두는 편이 낫습니다. 한 번 aggressive로 돌고 나면 relfrozenxid가 전진해 age가 많아야 vacuum_freeze_min_age 수준인 5천만으로 내려가고, 0이 되지는 않습니다. 이 값이 다음 관문까지의 여유를 정하므로, age가 0으로 리셋된다고 가정하고 주기를 잡으면 계산이 어긋납니다.
SELECT ... FOR UPDATE로 10만 행을 한 번에 잠갔습니다. 이때 서버에서 벌어지는 일로 맞는 것은 무엇입니까?
lock table에 10만 개 엔트리가 만들어지고, 상한을 넘으면 테이블 락으로 승격됩니다.
그것은 다른 여러 RDBMS의 방식이고 PostgreSQL은 다릅니다. 행마다 별도 엔트리를 만들지 않으므로 승격이라는 개념 자체가 row-level lock에는 없습니다.
정답각 튜플의 xmax에 잠근 트랜잭션의 xid가 기록되고, 별도의 락 메모리는 늘지 않습니다.
PostgreSQL은 행 락을 튜플 헤더의 xmax와 infomask 비트로 표현합니다. 락 상태를 확인하려면 힙 페이지를 읽어야 하지만 그 행을 수정하려면 어차피 페이지를 읽어야 하므로 추가 비용이 거의 없습니다. 100만 행을 잠가도 락 테이블이 넘치지 않는 이유입니다.
다른 세션은 그 행들을 SELECT할 수도 없게 됩니다.
행 락은 읽기를 막지 않습니다. 다른 세션의 일반 SELECT는 자기 snapshot에서 보이는 버전을 그대로 읽습니다. 막히는 것은 UPDATE, DELETE, 그리고 충돌하는 SELECT ... FOR 계열뿐입니다.
잠긴 행들은 VACUUM이 회수하지 못하도록 표시됩니다.
회수 여부를 정하는 것은 락이 아니라 그 튜플이 어느 트랜잭션에게도 보이지 않는가입니다. 락은 동시 수정을 조율할 뿐 그 판정에 직접 관여하지 않습니다.
해설
PostgreSQL의 행 락에는 별도의 저장소가 없습니다. UPDATE나 DELETE가 튜플의 xmax에 자기 xid를 적는 행위 자체가 잠갔다는 표시이고, 다른 트랜잭션은 그 값을 보고 대기 여부를 정합니다. 이 설계의 이점은 잠근 행 수와 무관하게 메모리가 늘지 않는다는 것입니다. 대량 갱신이 락 자원 때문에 실패하는 일이 없습니다. 여러 트랜잭션이 같은 행에 공유 락을 걸어야 할 때는 xid 하나로 표현할 수 없으므로 여러 xid를 묶은 MultiXactId를 쓰고, infomask 비트로 그 사실을 표시합니다.
-- 배포 중 ALTER TABLE 한 줄을 실행했더니 users를 읽는 모든 요청이 멈췄습니다.SELECT pid, state, wait_event_type, wait_event,now()- xact_start AS open_for,LEFT(query,26)AS query
FROM pg_stat_activity WHERE state <>'idle';-- pid | state | wait_event_type | wait_event | open_for | query-- -------+--------+-----------------+------------+----------+------------------------------ 4102 | active | | | 00:41:12 | SELECT ... FROM users ...-- 5511 | active | Lock | relation | 00:00:38 | ALTER TABLE users ADD COL-- 5610 | active | Lock | relation | 00:00:31 | SELECT id FROM users WHERE-- 5644 | active | Lock | relation | 00:00:12 | SELECT id FROM users WHERE
ALTER는 아직 시작도 못 했는데 그 뒤에 들어온 SELECT까지 멈췄습니다. 왜 그렇습니까?
정답락 큐가 FIFO라서 대기 중인 AccessExclusive를 뒤에 온 요청이 앞지를 수 없습니다.
새로 온 SELECT의 AccessShare는 이미 실행 중인 41분짜리 SELECT와 완전히 호환됩니다. 그런데 그 사이에 AccessExclusive가 줄을 서 있고 큐는 순서를 지키므로, 호환되는 상대가 앞에 있어도 넘어갈 수 없습니다. 한 줄짜리 ALTER가 서비스 전체를 멈추는 사고가 이 구조에서 나옵니다.
ALTER가 이미 AccessExclusive를 획득해 모든 읽기를 막고 있습니다.
획득했다면 wait_event가 Lock으로 남아 있지 않습니다. 5511은 아직 기다리는 중이고, 실제로 락을 쥔 것은 41분째 열려 있는 4102입니다.
ALTER TABLE이 테이블을 다시 쓰기 시작해 그동안 데이터를 읽을 수 없습니다.
컬럼을 추가하는 ALTER는 기본값이 없거나 상수 기본값이면 테이블을 다시 쓰지 않습니다. 그리고 세 세션 모두 재작성이 아니라 락을 기다리는 상태입니다.
SELECT끼리도 AccessShare를 서로 배타적으로 잡기 때문입니다.
AccessShare는 자기들끼리 완전히 호환됩니다. 8단계 행렬에서 AccessShare 줄에 X가 있는 곳은 AccessExclusive 한 칸뿐입니다.
해설
ALTER TABLE이 위험한 이유는 락이 세서가 아니라 락을 기다리는 동안에도 뒤를 막기 때문입니다. 앞에 긴 트랜잭션 하나만 있으면 그 뒤로 오는 모든 요청이 함께 멈춥니다. 그래서 DDL을 돌리기 전에 lock_timeout을 3초에서 5초 정도로 짧게 걸어두는 것이 표준 관행입니다. 그 안에 락을 못 잡으면 에러로 빠져나오므로 뒤에 줄이 길게 늘어서기 전에 상황이 정리되고, 긴 트랜잭션을 먼저 정리한 뒤 다시 시도하면 됩니다. 같은 이유로 idle_in_transaction_session_timeout을 걸어 열어둔 채 방치된 트랜잭션을 자동으로 끊는 설정도 함께 씁니다.
-- accounts에는 id=1, balance=1000 한 행이 있습니다.-- Session A -- Session BBEGIN;BEGIN;UPDATE accounts -- 아래 문장은 A가 끝날 때까지 대기합니다SET balance = balance -100UPDATE accounts
WHERE id =1;SET balance = balance +500WHERE id =1;COMMIT;
두 세션이 모두 Read Committed일 때 B가 풀린 뒤 balance는 얼마가 되고, 둘 다 Repeatable Read였다면 무엇이 달라집니까?
두 경우 모두 1400입니다. 격리 수준은 읽기에만 영향을 줍니다.
격리 수준은 충돌이 생긴 뒤 어떻게 진행할지도 정합니다. Read Committed는 최신 버전으로 갈아타고 Repeatable Read는 포기합니다.
Read Committed면 1500이 되고, Repeatable Read면 1400이 됩니다.
1500은 B가 원래 값 1000 위에 계산했다는 뜻이라 A의 변경이 통째로 사라집니다. 그런 결과가 나오려면 대기 자체가 없어야 하는데 같은 행이라 반드시 대기합니다.
두 경우 모두 B가 abort됩니다. 같은 행을 동시에 수정했기 때문입니다.
Read Committed는 대기한 뒤 진행하는 것이 정상 동작입니다. 이 재평가 덕분에 한 문장 안에서 벌어지는 lost update가 막힙니다.
정답Read Committed면 1400이 되고, Repeatable Read면 B가 직렬화 에러로 abort됩니다.
Read Committed의 UPDATE는 대기에서 풀린 뒤 자기가 처음 읽은 버전이 아니라 최신 커밋 버전을 다시 읽고 WHERE를 다시 평가합니다. 900 위에 500을 더해 1400입니다. Repeatable Read는 트랜잭션 시작 시점의 snapshot을 고수하므로, 그 버전이 이미 갱신된 것을 발견하는 순간 could not serialize access due to concurrent update로 끝냅니다.
해설
같은 충돌에서 두 격리 수준이 정반대로 반응합니다. Read Committed는 현실에 맞춰 갈아타고, Repeatable Read는 자기가 본 세상과 어긋나면 포기합니다. 어느 쪽이 옳은가는 무엇을 쓰는지에 달려 있습니다. balance = balance - 100처럼 계산이 데이터베이스 안에서 끝나면 Read Committed의 재평가로 충분합니다. 반대로 값을 애플리케이션으로 가져와 계산한 뒤 그 결과를 상수로 쓰는 코드라면, 재평가가 일어나도 이미 낡은 값으로 만든 결과를 쓰게 되므로 SELECT ... FOR UPDATE로 먼저 잠그거나 Repeatable Read로 올려 재시도하는 쪽을 택해야 합니다.
예약 테이블에 창고별 예약이 행 단위로 쌓입니다. 규칙은 한 창고의 예약 합계가 보유 수량을 넘지 않는 것입니다. 예약 처리는 그 창고의 합계를 SELECT해서 여유를 확인한 뒤, 여유가 있으면 자기 예약 행을 INSERT합니다. Repeatable Read로 돌리고 있는데 동시 요청이 몰리는 시간대에만 가끔 합계가 보유 수량을 넘습니다.
이 현상의 이름과, Repeatable Read가 막지 못하는 이유는 무엇입니까?
정답write skew입니다. 서로 다른 행을 쓰므로 행 락이 충돌하지 않고, 상대의 미커밋 변경도 보이지 않습니다.
각 트랜잭션은 자기가 읽은 합계 위에서 판단하고 자기 행만 씁니다. 읽은 것과 쓴 것이 어긋나 있어 어느 쪽도 충돌로 감지되지 않습니다. Repeatable Read는 자기가 읽은 행이 그 사이에 바뀌었을 때만 에러를 내므로, 읽지 않은 새 행이 끼어드는 이 패턴은 원리적으로 걸러내지 못합니다.
표준은 허용하지만 PostgreSQL의 Repeatable Read는 트랜잭션 시작 시점의 snapshot을 끝까지 유지해 phantom read까지 막습니다. 여기서 새는 것은 읽기의 일관성이 아니라 읽은 값을 근거로 한 쓰기입니다.
lost update입니다. 두 예약이 같은 값을 읽고 서로 덮어썼습니다.
lost update는 같은 행을 덮어쓸 때 생기고, 그 경우라면 Repeatable Read가 직렬화 에러로 잡아냅니다. 여기서는 서로 다른 행을 INSERT하므로 덮어쓴 것이 없습니다.
dirty read입니다. 상대의 미커밋 INSERT가 합계에 섞여 들어갔습니다.
방향이 반대입니다. 미커밋 변경이 보였다면 여유가 없다고 판단해 오히려 막혔을 것입니다. 보이지 않았기 때문에 둘 다 통과한 것입니다.
해설
read-modify-write 패턴에서 읽는 대상과 쓰는 대상이 다르면 snapshot isolation의 사각지대에 들어갑니다. 규칙은 여러 행에 걸쳐 성립하는데 검사는 각자 따로 하고, 쓰기는 서로 다른 행에 하기 때문입니다. 해결책은 세 가지입니다. Serializable로 올리고 40001 에러에 재시도를 붙이거나, 검사 대상을 하나의 행으로 만들어 그 행에 SELECT ... FOR UPDATE를 걸거나, 규칙 자체를 데이터베이스 제약으로 표현하는 것입니다. 세 번째가 가능하면 가장 튼튼하지만 합계 상한 같은 규칙은 제약으로 옮기기 어려워서, 실무에서는 두 번째가 가장 자주 쓰입니다.
Serializable로 바꾼 뒤 실행 중에는 아무도 대기하지 않는데 커밋에서 실패하는 트랜잭션이 생깁니다. 이 동작을 설명하는 것은 무엇입니까?
커밋 시점에 뒤늦게 행 락을 잡으려다 실패한 것입니다.
Serializable에서도 쓰기에 대한 행 락은 실행 시점에 잡습니다. 커밋에서 나는 실패는 락 획득 실패가 아니라 의존 구조 판정의 결과입니다.
SIRead lock이 너무 많이 쌓여 메모리가 부족해진 것입니다.
행 단위 SIRead lock이 많아지면 페이지나 테이블 단위로 묶여 관리되지 메모리 부족 에러로 이어지지 않습니다. 다만 그렇게 묶이면 감시 범위가 넓어져 굳이 취소하지 않아도 될 트랜잭션까지 취소될 수는 있습니다.
정답읽은 범위를 기록하는 SIRead lock은 대기를 만들지 않고, 위험한 의존 구조가 드러나는 커밋 시점에 한쪽이 취소됩니다.
SIRead lock은 무엇을 읽었는지 표시만 남기는 장치라 다른 트랜잭션을 멈추지 않습니다. 나중에 그 데이터를 누군가 수정하면 읽기와 쓰기 사이의 의존 관계가 기록되고, 그 관계가 한 트랜잭션으로 들어왔다가 다시 나가는 모양을 이루면 직렬 실행으로 설명할 수 없는 조합이라 판정해 취소합니다.
Serializable은 트랜잭션을 실제로 하나씩 순서대로 실행하므로 뒤늦게 순서가 밀린 것입니다.
직렬로 실행하는 것이 아니라 동시에 실행하되 그 결과가 어떤 직렬 순서로 설명되는지를 검사합니다. 설명되지 않는 조합만 취소하므로 대부분의 트랜잭션은 그대로 통과합니다.
해설
SSI가 다른 격리 수준과 크게 다른 점은 비용을 실행 중이 아니라 커밋 시점에 청구한다는 것입니다. 읽기는 아무도 막지 않고 대기도 만들지 않으므로 처리량이 크게 떨어지지 않지만, 대신 어떤 트랜잭션은 다 해놓고 마지막에 취소됩니다. 판정은 안전한 쪽으로 기울어 있어서, 실제로는 문제가 없었을 조합도 취소될 수 있습니다. 놓치는 경우는 없으므로 커밋에 성공한 것들만 놓고 보면 결과는 반드시 어떤 직렬 실행으로 설명됩니다. 그래서 Serializable을 쓰기로 했다면 40001을 잡아 트랜잭션 전체를 처 음부터 다시 실행하는 재시도 wrapper가 선택이 아니라 전제 조건입니다.
-- 계좌 이체 로직입니다. 하루에 몇 건씩 deadlock detected가 찍힙니다.BEGIN;UPDATE accounts SET balance = balance - :amt WHERE id = :from_id;UPDATE accounts SET balance = balance + :amt WHERE id = :to_id;COMMIT;
이 코드를 어떻게 고치면 deadlock이 애초에 만들어지지 않습니까?
격리 수준을 Serializable로 올려 SSI가 미리 막게 합니다.
SSI는 읽기와 쓰기 사이의 의존 구조를 보는 장치라 락 대기 순환과는 다른 문제를 다룹니다. Serializable에서도 잠그는 순서가 엇갈리면 deadlock은 그대로 발생합니다.
정답두 UPDATE를 항상 id가 작은 쪽부터 실행하도록 순서를 고정합니다.
deadlock은 두 세션이 서로가 쥔 행을 기다릴 때 생깁니다. 모든 트랜잭션이 같은 순서로 잠그면 먼저 온 쪽이 두 행을 다 잡고 뒤에 온 쪽은 첫 행에서 멈추므로 대기 그래프에 순환이 만들어지지 않습니다. 이체는 다루는 행이 둘뿐이라 이 원칙을 적용하기 쉬운 편입니다.
deadlock_timeout을 늘려 한쪽이 먼저 끝나기를 기다립니다.
순환이 닫혀 있으면 아무리 기다려도 저절로 풀리지 않습니다. 이 값을 늘리면 감지가 늦어져 두 세션이 더 오래 묶여 있게 될 뿐입니다.
두 UPDATE를 각각 별도 트랜잭션으로 쪼갭니다.
deadlock은 사라지지만 이체의 원자성이 깨집니다. 첫 트랜잭션만 커밋되고 두 번째가 실패하면 어느 계좌에도 없는 돈이 생깁니다.
해설
deadlock은 버그가 아니라 동시성이 있는 시스 템의 정상적인 가능성이고, PostgreSQL의 감지 기능은 그것을 처리하는 정상 메커니즘입니다. 그래도 만들지 않을 수 있으면 만들지 않는 편이 낫고, 가장 단순한 원칙이 잠그는 순서를 전역에서 하나로 고정하는 것입니다. 문제는 비즈니스 로직이 복잡해지면 어떤 행을 어떤 순서로 잠그게 될지 코드만 보고 알기 어려워진다는 점입니다. 그래서 현실적인 대응은 순서 고정과 재시도를 함께 두는 것입니다. 간헐적으로 나는 deadlock은 재시도로 흡수하고, 초당 수십 건씩 난다면 트랜잭션 범위와 잠금 순서를 다시 설계합니다.
COMMIT 응답을 받은 직후 전원이 나갔습니다. 변경된 페이지가 아직 data file에 쓰이지 않았는데도 데이터가 살아남는 이유는 무엇입니까?
COMMIT 시점에 변경된 페이지들이 모두 data file로 flush되기 때문입니다.
그 방식이라면 트랜잭션마다 여기저기 흩어진 페이지를 랜덤 쓰기로 내려야 해서 감당하기 어렵습니다. PostgreSQL이 COMMIT에서 반드시 내리는 것은 데이터 페이지가 아니라 WAL입니다.
정답변경을 기술하는 WAL record가 페이지보다 먼저 디스크에 확정되고, 재시작 시 그것을 다시 적용하기 때문입니다.
이것이 write-ahead 규칙입니다. data file이 낡은 상태여도 WAL만 남아 있으면 재시작할 때 그 내용을 순서대로 다시 적용해 crash 직전 상태를 복원할 수 있습니다. WAL은 한 줄로 이어 붙이는 순차 쓰기라, 흩어진 페이지를 매 트랜잭션마다 내리는 것보다 훨씬 쌉니다.
shared_buffers가 공유 메모리에 있어 프로세스가 죽어도 내용이 보존되기 때문입니다.
공유 메모리는 프로세스 사이에서 공유될 뿐 전원이 나가면 함께 사라집니다. 재시작 후 남아 있는 것은 디스크에 있던 것뿐입니다.
checkpointer가 1초마다 dirty page를 내리므로 손실 구간이 짧기 때문입니다.
checkpoint는 기본 5분 간격이거나 WAL이 일정량 쌓였을 때 돕니다. 그리고 짧은 손실 구간을 남기는 것과 하나도 잃지 않는 것은 전혀 다른 보장입니다.
해설
WAL의 규칙은 한 줄입니다. 데이터 페이지의 변경이 디스크에 쓰이기 전에 그 변경을 기술하는 record가 먼저 디스크에 써져야 합니다. 이 순서만 지키면 data file은 얼마든지 뒤처져 있어도 됩니다. 뒤처진 만큼은 WAL에 남아 있고, 재시작할 때 그 구간을 다시 적용하면 되기 때문입니다. 여기서 얻는 실질적 이득은 쓰기 패턴의 변화입니다. 흩어진 페이지를 매번 랜덤으로 내리는 대신, 이어붙이기만 하는 파일 하나를 순차로 쓰고 COMMIT에서 그것만 확정합니다.
LOG: checkpoint starting: wal
LOG: checkpoint complete: wrote 61204 buffers (46.7%); ...
write=22.184 s, sync=0.031 s, total=22.402 s;
distance=1046873 kB, estimate=1046873 kB
LOG: checkpoints are occurring too frequently (26 seconds apart)
HINT: Consider increasing the configuration parameter "max_wal_size".
checkpoint_timeout은 기본값 5분 그대로입니다. 그런데 26초마다 checkpoint가 돕니다. 어떻게 이런 일이 가능합니까?
정답max_wal_size에 먼저 도달했기 때문입니다. 두 조건 중 먼저 닿는 쪽이 checkpoint를 트리거합니다.
checkpoint는 시간과 WAL 양 두 조건 중 먼저 도달하는 쪽으로 트리거됩니다. 쓰기가 많으면 5분을 기다리기 전에 WAL이 상한에 닿아 계속 checkpoint가 돌게 됩니다. 첫 줄의 checkpoint starting 뒤에 붙은 wal이 그 사실을 그대로 말해주고, distance가 약 1GB라는 것도 기본 max_wal_size에 닿았다는 신호입니다.
checkpoint_timeout은 최대 간격이고 실제 주기는 서버가 부하에 맞춰 자동 조절합니다.
이 값은 시간 기준 트리거 하나일 뿐 부하에 따라 알아서 줄어드는 값이 아닙니다. 간격을 줄인 것은 다른 트리거 조건입니다.
누군가 CHECKPOINT 문을 반복 실행하고 있습니다.
그 경우 checkpoint starting 뒤에 붙는 이유가 wal이 아니라 force로 찍힙니다. 로그가 이미 어느 조건으로 시작됐는지 알려주고 있습니다.
checkpoint_completion_target이 0.9라서 실제 간격이 그만큼 짧아집니다.
이 값은 flush 작업을 얼마나 넓게 펼칠지 정하는 비율이고 간격 자체를 바꾸지 않습니다. 5분 간격에 0.9면 앞의 4.5분에 걸쳐 나눠 쓴다는 뜻입니다.
해설
checkpoint는 시간과 WAL 양이라는 두 개의 방아쇠를 가집니다. 어느 쪽이 먼저 당겨지는지는 워크로드가 정합니다. 읽기 위주라면 시간이 먼저 오고, 쓰기가 많으면 WAL이 먼저 찹니다. 로그의 checkpoint starting 줄에 time인지 wal인지가 그대로 찍히므로 진단은 어렵지 않고, 그래서 log_checkpoints를 켜두는 것이 기본입니다. 26초 간격은 여기에 더해 write=22초와 함께 읽어야 합니다. 쓰는 데 22초가 걸리는 작업이 26초마다 돌아온다면 checkpointer가 거의 쉬지 않고 일하고 있다는 뜻입니다.
데이터 변경량은 지난주와 비슷한데 하루 WAL 생성량이 3배가 됐습니다. pg_waldump --stats로 세그먼트를 훑어보니 Full Page Image가 전체 WAL 바이트의 84%를 차지합니다.
checkpoint가 잦아진 것이 WAL 생성량을 3배로 키운 경로는 무엇입니까?
정답checkpoint 직후 첫 변경이라는 판정이 계속 초기화되어, 차분으로 끝났을 변경이 매번 페이지 전체로 실립니다.
Full Page Write는 checkpoint 이후 그 페이지가 처음 변경될 때 한 번만 붙고, 같은 구간의 두 번째부터는 수십 바이트짜리 차분만 남습니다. 간격이 26초로 줄면 자주 만지는 페이지마다 첫 변경이 반복되어, 원래 차분이었을 것들이 8KB짜리 이미지로 바뀝니다. FPI 비중 84%가 그 결과입니다.
checkpoint가 dirty page를 flush하는 작업 자체가 WAL에 기록됩니다.
data file에 페이지를 쓰는 일은 WAL을 남기지 않습니다. WAL은 변경을 기술하는 기록이지 그 변경을 반영하는 작업의 기록이 아닙니다.
max_wal_size가 작아 세그먼트 파일이 재활용되지 못해 총량이 늘어납니다.
재활용은 이미 만들어둔 파일을 다시 쓰는 것이라 생성되는 record의 양과 무관합니다. max_wal_size는 checkpoint를 언제 트리거할지를 정할 뿐이고, WAL을 늘린 것은 그 결과로 잦 아진 checkpoint입니다.
WAL 압축이 자동으로 꺼져 record가 커졌습니다.
wal_compression은 설정으로만 바뀌고 스스로 꺼지지 않습니다. 그리고 압축은 FPI 크기를 줄이는 장치라 켜져 있어도 FPI가 생기는 것 자체는 막지 못합니다.
해설
Full Page Write는 반쪽만 쓰인 페이지에서 복구하기 위한 장치입니다. 디스크는 8KB를 한 번에 쓰지 않으므로 쓰는 도중 전원이 나가면 앞뒤가 섞인 페이지가 남을 수 있고, 그 위에 차분을 적용하면 결과가 어긋납니다. 그래서 checkpoint 이후 첫 변경에는 페이지 전체를 함께 기록합니다. 비용은 WAL 볼륨이고, 그 비용의 크기를 정하는 것이 checkpoint 간격입니다. 간격이 짧을수록 첫 변경이 자주 돌아와 같은 워크로드가 훨씬 많은 WAL을 만듭니다. 대응은 두 갈래입니다. max_wal_size를 늘려 간격을 되돌리고, wal_compression을 켜 FPI 한 장의 크기를 줄이는 것입니다.
crash recovery 도중 서버가 또 죽어도 다시 시작하면 결국 올바른 상태에 도달합니다. 이 멱등성을 만드는 것은 무엇입니까?
정답LSN 비교로 이미 반영된 변경을 건너뛰는 것, 그리고 Full Page Image입니다.
redo는 REDO point부터 WAL 끝까지 무조건 다시 훑지만, 페이지마다 마지막 변경 위치가 pd_lsn에 적혀 있어 이미 반영된 record는 건너뜁니다. 여기에 반쪽만 쓰인 페이지를 통째로 되살리는 FPI가 더해져야 완성됩니다. 깨진 페이지에 차분을 적용하면 결과가 비결정적이 되어 몇 번을 반복해도 같아진다는 보장이 무너지기 때문입니다.
recovery가 어디까지 진행됐는지를 pg_control에 매 record마다 기록하기 때문입니다.
pg_control에 기록되는 것은 checkpoint 정보이고 record마다 갱신되지 않습니다. 그렇게 했다면 WAL을 읽는 속도만큼 쓰기도 함께 해야 해서 recovery가 훨씬 느려집니다.
recovery 중에는 WAL을 새로 쓰지 않으므로 반복해도 상태가 변하지 않습니다.
recovery는 페이지를 실제로 수정하므로 상태는 변합니다. 반복해도 안전한 이유는 변화가 없어서가 아니라 이미 적용된 것을 다시 적용하지 않기 때문입니다.
redo가 트랜잭션 단위로 원자적으로 적용되기 때문입니다.
redo는 트랜잭션이 아니라 WAL record 단위로 페이지에 적용됩니다. 커밋되지 않은 트랜잭션의 변경도 일단 적용되고, 그것이 보이지 않아야 한다는 판정은 가시성 규칙이 따로 처리합니다.
해설
crash recovery가 안전한 이유는 두 장치가 겹쳐 있기 때문입니다. 하나는 페이지가 자기 마지막 변경 위치를 기억한다는 것이고, 다른 하나는 깨진 페이지를 통째로 되돌릴 수 있다는 것입니다. 앞의 것만 있으면 정상적인 페이지에 대해서는 중복 적용을 피할 수 있지만 torn page 앞에서 무너지고, 뒤의 것만 있으면 매번 처음부터 전부 다시 적용해야 합니다. 둘이 함께 있으므로 recovery는 몇 번을 다시 시작해도 같은 결과에 도달합니다. 이 성질이 있어야 recovery 도중의 장애를 별도로 처리하지 않아도 됩니다.
로그 적재 전용 세션에만 synchronous_commit을 off로 두자는 제안이 나왔습니다. 팀 안에서 이 설정이 정확히 무엇을 포기하는 것인지에 대해 의견이 갈립니다.
synchronous_commit을 off로 둘 때 실제로 포기하는 것은 무엇입니까?
WAL을 아예 쓰지 않게 되어 crash recovery 자체가 불가능해집니다.
WAL은 그대로 기록되고 재시작 시 replay도 그대로 일어납니다. 달라지는 것은 COMMIT이 그 기록의 디스크 확정을 기다리느냐 하나뿐입니다.
트랜잭션이 반쪽만 적용된 채로 남을 수 있어 일관성이 깨집니다.
이것이 가장 흔한 오해입니다. 커밋 record가 디스크에 없으면 그 트랜잭션은 통째로 없던 일이 되고, 있으면 그 앞의 변경들도 함께 남습니다. 반쪽짜리 상태는 만들어지지 않습니다.
정답crash가 났을 때 마지막 flush 이후에 커밋된 트랜잭션이 통째로 사라질 수 있다는 점입니다.
COMMIT이 WAL buffer에만 쓰고 바로 응답하므로 아직 디스크에 내려가지 않은 구간은 crash에서 사라집니다. walwriter가 최대 wal_writer_delay의 세 배 간격으로 flush하므로 기본 설정에서 최악은 600ms 남짓입니다. 정상 종료에서는 shutdown checkpoint가 남은 것을 모두 내리므로 유실이 없고, immediate 모드로 죽이면 crash와 같아집니다.
다른 세션이 그 변경을 보지 못하게 됩니다.
가시성은 메모리상의 커밋 상태로 판정되므로 같은 서버 안의 다른 세션은 즉시 봅니다. 이 설정이 바꾸는 것은 디스크 확정 시점 하나입니다.
해설
synchronous_commit은 durability를 켜고 끄는 스위치가 아니라 얼마나 최근까지 보장할지를 정하는 손잡이입니다. off로 두면 최근 수백 밀리초를 잃을 수 있지만 일관성은 그대로입니다. 이 구분이 중요한 이유는 세션 단위로 조절할 수 있기 때문입니다. 같은 서버에서 결제 트랜잭션은 on으로 두고 로그 적재나 통계 카운터 갱신 세션만 off로 내리면, 잃어도 되는 것만 골라 지연을 줄일 수 있습니다. 전역으로 바꾸는 설정이 아니라 워크로드별로 나누는 설정으로 다루는 것이 맞습니다.
쓰기가 많은 시스템에서 checkpoint_timeout을 5분에서 15분으로, max_wal_size를 1GB에서 16GB로 함께 올렸습니다.
이 변경이 만드는 결과를 바르게 짝지은 것은 무엇입니까?
WAL 생성량과 재시작 시간이 함께 줄어듭니다.
두 값은 같은 방향으로 움직이지 않습니다. 재시작 시간을 줄이려면 REDO point를 자주 갱신해야 하고, 그러려면 checkpoint를 자주 돌려야 하며, 그러면 FPI가 늘어 WAL이 커집니다.
정답WAL 생성량과 checkpoint I/O는 줄지만, crash 이후 재시작에 걸리는 시간은 늘어납니다.
간격이 길어지면 같은 페이지의 첫 변경 판정이 덜 초기화되어 FPI가 줄고, dirty page를 몰아 쓰는 횟수도 줄고, 그것을 펼칠 시간 구간도 넓어집니다. 대가는 REDO point가 뒤로 밀린다는 점입니다. crash가 나면 그 지점부터 WAL 끝까지 다시 적용해야 하므로 재시작이 그만큼 오래 걸립니다.
checkpoint당 flush할 dirty page 수가 줄어 한 번의 I/O 부담이 가벼워집니다.
간격이 길어지면 그동안 쌓인 dirty page는 오히려 많아집니다. 부담이 나아지는 것은 한 번의 양이 줄어서가 아니라 횟수가 줄고 나눠 쓸 시간이 길어지기 때문입니다.
pg_wal 디렉터리 크기는 그대로이고 checkpoint 빈도만 줄어듭니다.
max_wal_size를 16배로 올렸으므로 checkpoint 사이에 쌓아둘 수 있는 WAL이 늘고 pg_wal이 차지하는 디스크도 그만큼 커집니다. 이 설정을 바꾸기 전에 디스크 여유를 함께 확인해야 하는 이유입니다.
해설
checkpoint 튜닝의 본질은 정상 운영 중의 비용과 사고 이후의 복구 시간을 저울에 올리는 일입니다. 간격을 넓히면 FPI가 줄어 WAL이 가벼워지고 I/O가 고르게 펴지지만, REDO point가 멀어져 재시작이 길어집니다. 좁히면 반대가 됩니다. 그래서 기준은 이 서비스가 재시작에 몇 분을 허용하는가입니다. 그 답이 정해지면 checkpoint 간격의 상한이 정해지고, 그 안에서 log_checkpoints의 write 시간과 distance를 보며 조정하면 됩니다. max_wal_size는 함께 올려야 하는 짝입니다. 한쪽만 올리면 다른 쪽이 먼저 방아쇠를 당겨 의도한 간격이 나오지 않습니다.
major version 업그레이드 다운타임을 줄이려고 새 버전 서버로 데이터를 옮기려 합니다. Streaming Replication으로는 안 되고 Logical Replication이어야 하는 이유는 무엇입니까?
Streaming은 테이블 단위로 고를 수 없어 전체를 옮겨야 하기 때문입니다.
전체를 옮기는 것이 목적이라면 그 자체는 걸림돌이 아닙니다. 막히는 지점은 복제 범위가 아니라 받는 쪽이 물리적으로 동일해야 한다는 제약입니다.
Streaming standby는 읽기만 되므로 전환 후 쓰기를 받을 수 없습니다.
승격하면 쓰기를 받을 수 있으므로 이것만으로는 이유가 되지 않습니다. HA 구성이 실제로 그 방식으로 동작합니다.
정답Streaming은 WAL 바이트를 그대로 적용하므로 양쪽이 같은 major version이어야 합니다.
standby는 primary의 물리적 복제본이라 데이터 파일이 바이트 단위로 같아집니다. 버전이 다르면 이 전제가 성립하지 않습니다. Logical Replication은 WAL을 행 단위 변경으로 디코딩해 보내므로 받는 쪽이 독립된 데이터베이스이고, 다른 major version이어도 적용할 수 있습니다.
Streaming은 wal_level이 replica라 업그레이드에 필요한 정보를 남기지 않습니다.
wal_level은 어떤 방식을 쓸 수 있는지를 정하는 설정이고 logical로 올리면 두 방식을 함께 쓸 수 있습니다. 버전 차이를 막는 것은 이 설정이 아닙니다.
해설
두 복제 방식의 차이는 무엇을 보내느냐 하나에서 전부 파생됩니다. 바이트를 그대로 보내면 받는 쪽은 물리적 복제본이 되어야 하고, 그래서 버전이 같아야 하고, 테이블을 골라 받을 수 없고, 쓰기도 못 합니다. 디코딩해서 행 단위로 보내면 받는 쪽은 그 변경을 자기 방식으로 적용하는 독립된 데이터베이스가 되므로, 버전이 달라도 되고 테이블을 고를 수 있고 자체 인덱스나 추가 컬럼도 가질 수 있습니다. 대신 디코딩 비용이 붙고, 행을 식별할 수단이 필요해지고, 스키마 변경은 따라오지 않습니다.
-- primary의 pg_wal이 400GB까지 불어나 디스크 경보가 울렸습니다.-- max_wal_size는 4GB입니다.SELECT slot_name, slot_type, active, wal_status,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))AS retained
FROM pg_replication_slots;-- slot_name | slot_type | active | wal_status | retained-- -----------+-----------+--------+------------+------------- standby1 | physical | t | reserved | 12 MB-- analytics | logical | f | extended | 391 GB
max_wal_size가 4GB인데 pg_wal이 400GB입니다. 무슨 일이 벌어지고 있습니까?
정답비활성 논리 슬롯이 391GB 구간의 WAL을 붙잡고 있어 checkpoint가 그것을 지우지 못합니다.
슬롯은 이 소비자가 어디까지 읽었는지를 LSN으로 기록하고, primary는 그 지점 이후의 WAL을 지우지 않습니다. 구독자가 사라져도 슬롯이 남아 있으면 보존은 계속됩니다. max_wal_size는 checkpoint를 언제 트리거할지를 정하는 값이지 pg_wal의 크기 상한이 아니라서 이 상황을 막아주지 못합니다.
max_wal_size 설정이 아직 적용되지 않은 것이므로 재시작이 필요합니다.
max_wal_size는 재시작 없이 적용되는 값이고 애초에 pg_wal 총량의 상한이 아닙니다. 값이 제대로 적용되었더라도 슬롯이 붙잡은 세그먼트는 지워지지 않습니다.
checkpoint가 돌지 않아 오래된 세그먼트가 재활용되지 못했습니다.
checkpoint가 멈췄다면 physical 슬롯 쪽 retained도 함께 커졌을 것입니다. 12MB로 정상이라는 것은 checkpoint가 돌고 있고 정리도 되고 있다는 뜻입니다.
논리 슬롯은 디코딩을 위해 WAL을 두 벌 보관하므로 크기가 두 배가 됩니다.
디코딩은 기존 WAL을 읽어 변환하는 작업이라 사본을 따로 두지 않습니다. 391GB는 두 배로 불어난 것이 아니라 소비되지 않은 채 쌓인 결과입니다.
해설
슬롯은 복제를 안전하게 만드는 장치이면서 동시에 가장 흔한 장애 원인입니다. 소비자가 잠시 끊겼다 돌아와도 필요한 WAL이 남아 있게 해주지만, 소비자가 영영 돌아오지 않으면 그 보존이 끝나지 않습니다. 대응은 두 갈래입니다. max_slot_wal_keep_size로 보존 상한을 걸어 한도를 넘으면 슬롯이 무효화되게 하고, 쓰지 않는 슬롯은 지우는 것입니다. 논리 슬롯은 여기에 하나가 더 붙습니다. catalog_xmin을 붙잡고 있으면 시스템 카탈로그의 dead tuple도 회수되지 못하므로, 디스크뿐 아니라 bloat 쪽으로도 영향이 번집니다.
-- read replica에서 분석 쿼리가 자주 죽습니다.
ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
-- 같은 standby에서
-- confl_snapshot | confl_bufferpin | confl_lock
-- ----------------+-----------------+------------
-- 1842 | 37 | 0
이 상황에서 hot_standby_feedback을 켜기로 했습니다. 무엇을 얻고 무엇을 내주게 됩니까?
standby가 WAL 적용을 미루게 되어 충돌이 사라지고 primary는 영향을 받지 않습니다.
적용을 미루는 것은 max_standby_streaming_delay가 하는 일이고 그 대가는 lag입니다. feedback은 반대로 primary 쪽 동작을 바꾸는 설정이라 영향이 primary에 갑니다.
정답standby 쿼리의 xmin을 primary가 알게 되어 충돌이 줄지만, 그만큼 primary의 회수가 밀립니다.
confl_snapshot이 압도적이라는 것은 primary에서 회수된 행 버전을 standby 쿼리가 아직 보려 한다는 뜻입니다. feedback을 켜면 primary의 VACUUM이 그 xmin 이후 것을 남겨두므로 충돌이 사라집니다. 대신 standby에서 오래 도는 쿼리가 primary의 회수 기준선을 붙잡게 되어, 열어둔 트랜잭션이 VACUUM을 막는 것과 같은 상태를 원격으로 만들어냅니다.
충돌이 나면 쿼리를 죽이는 대신 자동으로 재시도하게 됩니다.
재시도는 애플리케이션의 몫입니다. 이 설정은 충돌이 애초에 덜 생기게 만드는 쪽이지 충돌이 난 뒤의 처리를 바꾸지 않습니다.
buffer pin 충돌까지 함께 사라집니다.
feedback이 다루는 것은 어느 행 버전을 남길지이지 페이지를 누가 붙잡고 있는지가 아닙니다. confl_bufferpin 쪽은 여전히 max_standby_streaming_delay의 영역으로 남습니다.
해설
hot standby에서 쿼리가 취소되는 이유는 redo와 조회가 같은 데이터를 놓고 부딪히기 때문입니다. 조율 수단은 세 가지이고 대가가 각각 다릅니다. max_standby_streaming_delay를 늘리면 redo가 기다려주지만 lag이 쌓이고, hot_standby_feedback을 켜면 충돌이 줄지만 primary의 bloat가 늘고, 아무것도 하지 않으면 쿼리가 취소됩니다. 어느 쪽이 맞는지는 이 standby의 용도가 정합니다. 짧은 조회를 받는 read replica라면 기본값으로 충분한 경우가 많고, 몇 분씩 도는 분석 쿼리를 받는 용도라면 feedback을 켜되 primary의 dead tuple 추이를 함께 봐야 합니다.
-- events 테이블을 publication에 추가한 직후부터 이 에러가 납니다.UPDATE events SET processed =trueWHERE id =91204;-- ERROR: cannot update table "events" because it does not have-- a replica identity and publishes updates
이 에러가 알려주는 것으로 맞는 것은 무엇입니까?
subscriber가 어떤 행을 바꿀지 못 찾아 apply worker가 멈춘 것입니다.
에러가 UPDATE를 던진 쪽에서 즉시 돌아오고 있습니다. 변경이 subscriber까지 가지도 못한 상황입니다.
PK는 있지만 REPLICA IDENTITY를 FULL로 바꾸지 않아서입니다.
PK가 있으면 기본값 DEFAULT로 충분합니다. FULL은 PK가 없거나 모든 컬럼의 이전 값이 필요할 때 쓰는 설정이고, 대신 전송량이 크게 늘어납니다.
publication에 넣기 전에 초기 동기화를 마치지 않아서입니다.
초기 동기화는 기존 데이터를 복사하는 단계이고, 이 검사는 그것과 무관하게 매 UPDATE마다 일어납니다. 동기화가 끝나도 같은 에러가 계속됩니다.
정답publisher에서 UPDATE 문 자체가 거부됩니다. 복제가 밀리는 것이 아니라 원본 쓰기가 막힌 상태입니다.
PostgreSQL은 UPDATE와 DELETE를 실행하는 시점에 이 테이블이 어떤 행을 바꿨는지 구독자에게 알릴 수단이 있는지 확인합니다. REPLICA IDENTITY가 DEFAULT인데 PK가 없으면 알릴 방법이 없으므로 문장을 거부합니다. 그래서 증상이 복제 지연이 아니라 애플리케이션 쓰기 실패로 나타납니다.
해설
이 함정이 위험한 이유는 증상이 복제 쪽이 아니라 서비스 쪽에 나타나기 때문입니다. publication에 테이블을 하나 추가했을 뿐인데 잘 돌던 UPDATE가 실패하기 시작합니다. 대응은 PK를 만들거나, 유일한 인덱스가 있다면 REPLICA IDENTITY USING INDEX로 지정하거나, 둘 다 없으면 FULL로 두는 것입니다. FULL은 모든 컬럼의 이전 값을 WAL에 실어 보내므로 전송량과 subscriber의 탐색 비용이 함께 커지고, 인덱스가 없으면 행을 찾느 라 전체 스캔을 하게 되므로 최후의 선택으로 봐야 합니다. 세팅의 첫 단계가 대상 테이블의 PK 유무 점검인 이유입니다.
정답standby의 redo 처리 속도입니다. WAL은 디스크까지 도착했는데 적용만 밀려 있습니다.
sent와 write, write와 flush의 차이는 각각 96KB와 16KB로 사실상 붙어 있습니다. 네트워크도 디스크 쓰기도 따라오고 있다는 뜻입니다. 남은 구간은 startup process가 WAL을 읽어 페이지에 적용하는 단계 하나뿐입니다. redo는 단일 프로세스라 primary의 여러 backend가 만든 변경을 혼자 감당해야 하고, standby에서 도는 긴 쿼리와 충돌해 대기 중일 수도 있습니다.
네트워크 대역폭입니다. 3.8GB가 아직 전송되지 못했습니다.
전송은 sent와 write 사이 구간이고 그 차이가 96KB뿐입니다. 전송이 막혔다면 이 값이 먼저 커졌을 것입니다.
standby의 디스크 쓰기 성능입니다. flush가 따라가지 못하고 있습니다.
write와 flush의 차이가 16KB로 거의 붙어 있습니다. 받은 것을 디스크에 내리는 일은 병목이 아닙니다.
primary의 WAL 생성량입니다. 슬롯을 늘려 분산해야 합니다.
슬롯을 늘려도 같은 WAL을 같은 standby가 적용해야 합니다. 슬롯은 어디까지 보존할지를 추적하는 장치이지 부하를 나누는 장치가 아닙니다.
해설
pg_stat_replication이 LSN을 네 단계로 나눠 보여주는 이유가 여기에 있습니다. 어느 구간에서 벌어지는지를 보면 병목의 위치가 바로 나옵니다. sent와 write 사이가 벌어지면 네트워크, write와 flush 사이면 standby의 디스크, flush와 replay 사이면 redo 처리입니다. 마지막 구간이 특히 자주 문제가 되는 이유는 redo가 단일 프로세스이기 때문입니다. primary에서는 수십 개의 backend가 병렬로 만들어낸 변경을 standby에서는 하나가 순서대로 적용합니다. 여기에 hot standby 충돌로 대기까지 걸리면 격차가 빠르게 벌어지므로, 시간 기반 컬럼인 replay_lag과 함께 추이로 봐야 합니다.
logical replication으로 새 버전 서버를 동기화해두고 전환을 준비하는 중입니다. 이 기간에 스키마를 바꿔야 한다면 무엇을 주의해야 합니까?
DDL도 WAL에 기록되므로 자동으로 함께 적용됩니다.
WAL에 기록되는 것과 논리 복제로 전달되는 것은 다릅니다. output plugin이 만들어내는 것은 행 변경 스트림이고 스키마 변경은 그 안에 담기지 않습니다.
정답DDL은 복제되지 않으므로 양쪽에 직접 적용해야 하고, 순서를 잘못 잡으면 복제가 깨집니다.
복제되는 것은 행 단위 변경뿐입니다. publisher에만 컬럼을 추가하고 subscriber에 반영하지 않으면, 그 컬럼이 들어간 변경이 도착하는 순간 apply worker가 멈춥니다. 그래서 컬럼 추가는 subscriber에 먼저, 컬럼 삭제는 publisher에 먼저 적용하는 순서를 지킵니다.
publication을 다시 만들면 스키마까지 재동기화됩니다.
publication을 다시 만들면 초기 동기화가 다시 돌 수는 있지만 그것은 데이터를 복사하는 단계이고, subscriber의 테이블 정의를 바꿔주지는 않습니다.
스키마가 달라지면 apply worker가 자동으로 매핑을 조정합니다.
컬럼 이름과 타입이 맞아야 적용됩니다. 맞지 않으면 조정하는 것이 아니라 에러를 내고 멈추며, 그 지점부터 lag이 계속 쌓입니다.
해설
logical replication을 쓸 때 가장 자주 발이 걸리는 지점이 스키마입니다. 데이터는 알아서 따라오는데 정의는 따라오지 않으므로, 배포 절차가 데이터베이스 두 곳을 함께 다루는 형태로 바뀌어야 합니다. 순서 규칙은 어느 쪽이 먼저 몰라도 되는가로 정하면 기억하기 쉽습니다. 컬럼을 더할 때는 받는 쪽이 먼저 알고 있어야 하고, 컬럼을 뺄 때는 보내는 쪽이 먼저 그만 보내야 합니다. 같은 이유로 복제는 백업을 대신하지 못한다는 점도 함께 봐야 합니다. 실수로 지운 데이터는 그 삭제까지 그대로 복제되므로, 시점 복구가 필요하면 WAL 아카이빙과 base backup을 따로 갖춰야 합니다.
SELECT substr(query,1,44)AS q, calls,round(total_exec_time::numeric)AS total_ms,round(mean_exec_time::numeric,2)AS mean_ms
FROM pg_stat_statements ORDERBY total_exec_time DESCLIMIT3;-- q | calls | total_ms | mean_ms-- ---------------------------------------------+--------+----------+------------ UPDATE user_sessions SET last_active_at = $1 | 941200 | 282360 | 0.30-- SELECT * FROM reports WHERE period = $1 | 14 | 201600 | 14400.00-- SELECT u.*, p.* FROM users u JOIN posts p | 62800 | 97340 | 1.55
서버 전체의 여유를 만드는 것이 목표일 때, 어느 줄부터 손대야 합니까?
정답총 시간이 가장 큰 쪽부터 봅니다. 한 번이 0.3ms여도 94만 번이면 비중이 가장 큽니다.
회수할 수 있는 시간의 총량이 기준입니다. 0.3ms를 절반으로 줄이면 141초가 돌아오고, 호출 자체를 줄이면 그만큼 더 돌아옵니다. 평균이 낮다는 이유로 목록에서 지나치기 쉬운 것이 바로 이런 쿼리입니다.
평균이 가장 큰 쪽부터 봅니다. 14.4초짜리 쿼리가 명백한 문제입니다.
한 번이 오래 걸린다는 것과 서버 시간을 많이 쓴다는 것은 다릅니다. 14번만 도는 배치라면 고쳐도 전체 부하는 거의 그대로입니다. 다만 그 14번이 사용자 요청이라면 체감 측면에서 우선순위가 달라지므로 호출 맥락을 함께 봐야 합니다.
호출 수가 가장 많은 쪽부터 봅니다. 94만 번은 그 자체로 비정상입니다.
호출이 많다는 사실만으로는 판단할 수 없습니다. 세션 갱신처럼 원래 자주 일어나는 작업일 수 있습니다. 호출 수는 총 시간과 함께 볼 때 의미가 생깁니다.
쿼리 텍스트가 가장 복잡한 쪽부터 봅니다. 조인이 들어간 세 번째 줄입니다.
복잡도와 비용은 비례하지 않습니다. 인덱스가 잘 걸린 조인은 단순한 전체 스캔보다 훨씬 쌉니다. 눈으로 보이는 복잡함이 아니라 측정된 숫자 로 골라야 합니다.
해설
pg_stat_statements를 처음 열었을 때 total_exec_time으로 정렬하는 이유는 이 목록의 목적이 어디에 시간이 쌓여 있는지를 보는 것이기 때문입니다. 평균만 보면 자주 도는 작은 쿼리가 통째로 안 보이고, 호출 수만 보면 원래 자주 도는 정상 작업이 상위를 채웁니다. 총 시간으로 후보를 좁힌 다음 같은 줄의 calls와 mean을 함께 읽으면 어느 쪽을 손볼지가 갈립니다. mean이 크면 쿼리 하나를 고치고, calls가 크면 호출 패턴을 고칩니다. 앞의 것은 실행 계획의 문제이고 뒤의 것은 애플리케이션의 문제라 손대는 곳이 다릅니다.
결제 로직 대부분이 PL/pgSQL 함수 안에 들어 있습니다. 애플리케이션 로그에서는 결제 API가 평균 800ms인데, pg_stat_statements 상위 목록에는 SELECT process_payment($1, $2) 한 줄만 보이고 그 함수 안의 SQL은 하나도 보이지 않습니다.
함수 내부의 SQL이 목록에 보이지 않는 이유는 무엇입니까?
PL/pgSQL 안의 SQL은 prepared statement로 실행되어 정규화 대상이 아닙니다.
정규화는 파라미터를 자리표시자로 바꾸는 작업이라 prepared statement여도 그대로 적용됩니다. 여기서 걸린 것은 정규화가 아니라 추적 범위입니다.
pg_stat_statements.max를 넘어서 오래된 항목이 밀려났습니다.
그 경우라면 일부는 남았을 것이고 함수 호출 줄도 함께 밀려날 수 있습니다. 내부 SQL이 하나도 안 보인다면 밀려난 것이 아니라 애초에 수집되지 않은 것입니다.
정답기본 설정이 최상위 문장만 추적하기 때문입니다. track을 all로 바꾸면 내부 SQL도 잡힙니다.
pg_stat_statements.track의 기본값은 top이라 클라이언트가 직접 보낸 문장만 셉니다. 함수 안에서 도는 SQL은 그 함수 호출 한 줄에 시간이 뭉쳐서 잡히고 개별 항목으로 남지 않습니다. all로 바꾸면 보이지만 항목 수가 크게 늘어나므로 max 설정과 함께 봐야 합니다.
함수 안의 SQL은 WAL을 남기지 않아 통계에 잡히지 않습니다.
WAL 기록 여부와 실행 통계 수집은 무관합니다. 조회 쿼리는 WAL을 남기지 않지만 pg_stat_statements에는 정상적으로 잡힙니다.
해설
관측 도구를 쓸 때는 그 도구가 무엇을 보지 않는지를 함께 알아야 합니다. pg_stat_statements의 기본 추적 범위는 클라이언트가 보낸 최상위 문장이라, 로직이 함수나 프로시저 안에 들어 있으면 안쪽이 통째로 가려집니다. 800ms가 어디서 쓰였는지 알 수 없는 상태가 되는 것입니다. track을 all로 올리면 보이지만 항목 수와 수집 오버헤드가 함께 늘어나므로, 문제를 파악하는 동안만 올렸다가 되돌리는 방식이 무난합니다. 함수 내부까지 계획 단위로 봐야 한다면 auto_explain을 세션에 걸어 그 함수를 한 번 호출해보는 방법도 있습니다.
-- 1초 간격으로 다섯 번 표집한 결과를 합친 것입니다.SELECT wait_event_type, wait_event,count(*)FROM pg_stat_activity WHERE state ='active'GROUPBY1,2ORDERBY3DESC;-- wait_event_type | wait_event | count-- -----------------+---------------+--------- | | 61-- IO | DataFileRead | 9-- Lock | transactionid | 2
활성 세션 대부분이 wait_event 없이 잡힙니다. 이것은 무엇을 뜻합니까?
표집이 잘못된 것입니다. 활성 세션은 항상 어떤 wait_event를 가집니다.
실행 중인 세션이 아무것도 기다리지 않는 것은 정상이고 오히려 바람직한 상태입니다. 값이 비어 있다는 것 자체가 하나의 정보입니다.
정답기다리는 것 없이 CPU에서 실행 중이라는 뜻입니다. 병목이 락이나 디스크에 있지 않습니다.
이 상태가 반복 표집에서 계속 잡히면 쿼리 자체가 하는 일이 많다는 신호입니다. 다음 단계는 pg_stat_statements로 시간을 많이 쓰는 쿼리를 고르고 그 실행 계획을 보는 것이지, 락을 풀거나 디스크를 바꾸는 것이 아닙니다. IO 9건과 Lock 2건은 이 규모에서 배경 잡음에 가깝습니다.
그 세션들이 응답을 클라이언트로 보내는 중이라 대기로 잡히지 않습니다.
클라이언트를 기다리는 상태는 ClientWrite나 ClientRead 같은 별도의 wait_event로 나타납니다. 비어 있는 것과는 다릅니다.
통계 수집이 꺼져 있어 wait_event가 채워지지 않았습니다.
같은 표집에서 IO와 Lock 항목은 정상적으로 채워져 있습니다. 수집이 꺼졌다면 그것들도 함께 비어 있어야 합니다.
해설
wait_event는 한 순간의 사 진이라 한 번만 찍어서는 쓸모가 적고, 짧은 간격으로 여러 번 찍어 분포를 봐야 의미가 생깁니다. 그 분포에서 무엇이 많은지가 다음 행동을 정합니다. Lock이 많으면 누가 막고 있는지를 찾고, IO가 많으면 캐시와 bloat와 checkpoint 시점을 보고, 비어 있는 것이 많으면 도구를 바꿔 쿼리 자체를 봅니다. 마지막 경우가 특히 헷갈리기 쉽습니다. 아무 대기도 없다는 관측이 곧 정상이라는 뜻은 아니고, 일하는 양 자체가 많다는 뜻일 수 있습니다. 이때는 서버 설정을 만지는 방향이 아니라 계획과 인덱스를 보는 방향으로 가야 합니다.
느린 쿼리를 잡으려고 EXPLAIN ANALYZE를 손으로 돌려봤는데 언제 돌려도 빠릅니다. auto_explain이 이 상황에서 유효한 이유는 무엇입니까?
auto_explain이 쿼리를 자동으로 재실행해 평균 계획을 만들어줍니다.
재실행하지 않습니다. 실제로 실행된 그 한 번을 계측해서 기록할 뿐이고, 그래서 운영 환경에 붙일 수 있는 것입니다.
EXPLAIN ANALYZE보다 오버헤드가 없어 운영에서 마음껏 켤 수 있습니다.
log_analyze를 켜면 노드마다 계측이 붙어 오버헤드가 생깁니다. 그래서 임계값을 1초처럼 보수적으 로 잡고 시작하고, 필요하면 log_timing만 꺼서 시간 측정 비용을 덜어냅니다.
느린 쿼리를 자동으로 취소해 서비스 영향을 줄여줍니다.
취소는 statement_timeout의 일입니다. auto_explain은 기록만 하고 실행에 개입하지 않습니다.
정답문제가 난 그 순간의 계획을 서버가 대신 기록해주기 때문입니다.
같은 SQL이라도 바인딩된 값의 빈도, 통계가 갱신된 시점, 그때의 동시성에 따라 다른 계획으로 풀립니다. 손으로 다시 돌리면 그 조건이 재현되지 않아 늘 빠릅니다. auto_explain은 지정한 시간을 넘긴 실행에 대해 그때의 계획을 로그에 남기므로, 재현하지 못한 순간을 사후에 확인할 수 있습니다.
해설
운영에서 느린 쿼리를 못 잡는 가장 흔한 이유는 재현이 안 된다는 것입니다. 파라미터 값에 따라 계획이 갈리고, 통계가 갱신되면 또 갈리고, 동시성이 높을 때만 대기가 붙습니다. 그래서 사람이 나중에 돌려보는 방식으로는 그 순간을 잡을 수 없습니다. auto_explain은 이 문제를 관측 시점을 옮겨서 풉니다. 서버가 실행하는 그때 계측해서 남기므로 나중에 로그만 보면 됩니다. 대가는 계측 오버헤드와 로그 볼륨이라, 임계값을 크게 잡고 시작해 병목을 하나씩 걷어내며 낮추는 순서가 안전합니다.
SELECT substr(query,1,40)AS q, calls,round(mean_exec_time::numeric,1)AS mean_ms,
shared_blks_hit, shared_blks_read
FROM pg_stat_statements WHERE queryid =8813029471;-- q | calls | mean_ms | shared_blks_hit | shared_blks_read-- -----------------------------------------+-------+---------+-----------------+-------------------- SELECT * FROM orders WHERE user_id = $1 | 31400 | 47.2 | 204100 | 38620400
user_id로 조회하는 쿼리가 호출마다 평균 1,200페이지 넘게 읽어오고 있습니다. 가장 먼저 확인할 것은 무엇입니까?
정답user_id에 인덱스가 있는지, 있다면 그 인덱스를 실제로 타는지입니다.
호출당 1,200장이 넘고 그중 대부분이 shared_buffers 밖에서 온다는 것은, 소수의 행을 찾으려고 테이블을 넓게 훑고 있다는 뜻입니다. 인덱스가 없거나, 있어도 통계 문제로 선택되지 않았거나, user_id 분포가 치우쳐 특정 값에서만 넓게 읽는 경우가 후보입니다. EXPLAIN (ANALYZE, BUFFERS)로 계획과 buffers를 함께 보면 셋 중 어느 쪽인지 갈립니다.
shared_buffers를 늘려 read를 hit으로 바꾸는 것입니다.
캐시를 키우면 같은 페이지를 다시 읽을 때는 이득이지만, 애초에 읽지 않아도 될 페이지를 읽는 문제는 그대로 남습니다. 읽는 양 자체를 줄이는 쪽을 먼저 보고, 그러고도 read가 크면 그때 캐시를 논의합니다.
호출 수가 31,400회이므로 애플리케이션의 N+1 패턴을 먼저 고치는 것입니다.
호출 수를 줄이는 것도 유효한 방향이지만, 한 번에 1,200페이지를 읽는다는 사실은 그대로 남습니다. 단가가 비정상인 상황에서는 횟수보다 단가를 먼저 봅니다.
이 쿼리를 prepared statement로 바꿔 계획 수립 비용을 줄이는 것입니다.
계획 수립은 보통 밀리초 미만이고 47ms의 대부분은 페이지를 읽는 데 쓰였습니다. planning이 병목이었다면 total_plan_time 쪽에 흔적이 남았을 것입니다.
해설
pg_stat_statements의 블록 카운터는 그 쿼리가 무엇을 하고 있는지를 간접적으로 보여줍니다. hit과 read의 절대값보다 호출당 값이 중요합니다. 조건에 맞는 행이 몇 건인데 페이지를 수천 장 읽고 있다면 접근 경로가 잘못된 것이고, 인덱스 누락이 첫 번째 후보입니다. 인덱스가 이미 있는데도 이 수치가 나온다면 그다음 후보는 두 가지입니다. 통계가 낡아 플래너가 그 인덱스를 안 고르는 경우, 그리고 테이블이 bloat되어 살아 있는 행 하나를 위해 죽은 행이 가득한 페이지들을 함께 읽는 경우입니다. 셋을 가르는 방법은 같습니다. 그 쿼리 하나를 EXPLAIN (ANALYZE, BUFFERS)로 찍어 계획과 실제 읽은 블록을 나란히 보는 것입니다.
그 기간 동안 쓰이지 않았다는 사실만 알려줄 뿐입니다. 관측 구간이 짧으면 아직 오지 않은 워크로드를 통째로 놓칩니다.
됩니다. 크기가 큰 인덱스부터 지우면 쓰기 성능이 곧바로 좋아집니다.
쓰기 비용이 줄어드는 것은 맞지만 그것은 이 인덱스가 정말 쓰이지 않을 때의 이야기입니다. 잘못 지우면 어느 날 그 쿼리가 순차 스캔으로 떨어집니다.
정답안 됩니다. 통계가 어제 리셋되어 관측 구간이 하루뿐입니다.
idx_scan은 통계가 리셋된 이후의 누적입니다. 하루치로는 주간 배치나 월말 정산에서만 쓰이는 인덱스를 구분할 수 없고, settled_at이라는 이름 자체가 정산 주기를 연상시킵니다. 최소 한두 주, 월간 작업이 있는 시스템이라면 한 달 이상의 기록을 놓고 판단해야 합니다.
안 됩니다. 유일성 제약을 뒷받침하는 인덱스일 수 있습니다.
고려할 만한 지적이지만 제약을 뒷받침하는 인덱스는 제약을 먼저 지우지 않으면 삭제 자체가 거부되므로 실수로 사라지지 않습니다. 여기서 더 큰 위험은 관측 기간입니다.