본문으로 건너뛰기

"PostgreSQL" 태그로 연결된 43개 게시물개의 게시물이 있습니다.

모든 태그 보기

PostgreSQL 18.5 결번

· 약 8분

버전 번호가 하나 비어 있으면 신경이 쓰여요. 8월 13일에 나온 PostgreSQL 마이너 릴리스는 18.6인데, 그 앞은 18.5가 아니라 18.4입니다. 제 경우엔 패키지 목록을 보다가 "내가 하나 놓쳤나" 싶어 한참을 되짚었습니다. 결론부터 적으면 놓친 것은 없습니다. 18.5는 만들어졌지만 세상에 나오지 않았습니다.

공식이 말한 것은 한 줄뿐입니다

사유를 언급한 곳은 두 군데인데, 표현이 미묘하게 다릅니다.

18.6 릴리스 노트에는 이렇게 적혀 있습니다.

18.5 was never released, due to a regression discovered post-wrap.

릴리스 공지는 좀 더 건조합니다.

This release skips PostgreSQL 18 versions from PostgreSQL 18.4 to 18.6. 18.5 was not shipped due to a regression.

공지는 "shipped"라고만 했고, 릴리스 노트는 post-wrap이라는 단어를 하나 더 얹었습니다. 이 단어가 사실상 전부를 설명합니다. 무엇이 wrap이고, 그 이후라는 게 왜 중요한지 알면 18.5가 사라진 경로가 그대로 보입니다.

마이너 릴리스는 이런 순서로 만들어집니다

PostgreSQL의 Release process 위키에 절차가 공개돼 있습니다. 요약하면 committer가 버전을 찍고, 서버에서 tarball을 말고, packager들이 먼저 써 보고, 마지막에 태그를 미는 순서입니다.

2단계가 wrap입니다. pgsql 계정으로 borka.postgresql.org에 들어가 mk-release-bundle을 실행하면 tarball과 체크섬이 staging 디렉토리에 만들어집니다. 이 단계를 특정 서버에서만 하는 이유는 재현성 때문입니다. bison, flex, docbook 버전이 다르면 결과물이 달라지니 빌드 환경을 한 곳으로 고정해 둔 것입니다.

주목할 곳은 3단계와 4단계 사이입니다. 위키가 태그를 두고 분명하게 적어 뒀습니다.

Pushing a tag is more or less irreversible, so don't do this until packager preliminary testing is over.

태그를 밀면 되돌리기 어렵습니다. 그래서 packager들이 최소 24시간 먼저 만져 보고, 이상이 없어야 태그가 나갑니다. 18.5는 그 구간을 통과하지 못했습니다.

취소는 되는데 번호는 돌아오지 않습니다

같은 위키에 사고 처리 절차도 한 줄로 적혀 있습니다.

In event of disaster, fix code as needed then repeat the wrap process, with or without a version number bump as seems appropriate.

문제가 생기면 코드를 고쳐 wrap을 다시 하되, 번호를 올릴지 말지는 그때 적절히 판단하라는 뜻입니다. 규정이 아니라 재량입니다. 18.5 건에서는 올리는 쪽을 택했습니다.

왜 그랬을까요. version_stamp.pl이 이미 Stamp 18.5. 커밋을 남겼고 그 번호로 tarball이 만들어졌기 때문입니다. staging에 있던 물건이라 일반 사용자에게 내려간 것은 아니지만, packager들 손에는 18.5라는 이름표가 붙은 파일이 이미 가 있었습니다. 같은 번호로 내용이 다른 tarball을 한 번 더 돌리는 것보다 번호를 하나 올리는 편이 안전합니다. 태그를 밀기 전이라 릴리스 자체는 멈출 수 있었지만, 번호는 그 시점에 이미 쓰인 것입니다.

8월 9일까지는 18.5였습니다

타임라인도 이 해석과 맞습니다. Jonathan Katz(조너선 카츠)가 pgsql-hackers에 올린 8월 13일 릴리스 공지 초안은 2026년 8월 9일자인데, 거기 적힌 버전은 아직 18.5입니다.

마이너 릴리스는 관례상 목요일에 공개하고 tarball은 그 주 초에 맙니다. 8월 13일이 목요일이니 wrap은 10일 전후, 공지 초안은 그 직전에 돌린 셈입니다. 초안이 나가고 며칠 사이에 번호가 하나 올라갔습니다.

결번은 여섯 개뿐입니다

그럼 이런 일이 자주 있었을까요. 세어 봤습니다. bucardo가 모든 버전의 릴리스 노트를 한 페이지에 모아 두는데, 여기서 never released를 전수 검색하면 여섯 건이 나옵니다.

결번직전 릴리스대체 릴리스
7.4.207.4.19 (2008-01-07)7.4.21 (2008-06-12)
8.0.168.0.15 (2008-01-07)8.0.17 (2008-06-12)
8.1.128.1.11 (2008-01-07)8.1.13 (2008-06-12)
8.2.88.2.7 (2008-03-17)8.2.9 (2008-06-12)
8.3.28.3.1 (2008-03-17)8.3.3 (2008-06-12)
18.518.4 (2026-05-14)18.6 (2026-08-13)

여섯 개지만 사건은 두 건입니다. 위의 다섯은 대체 릴리스 날짜가 전부 2008년 6월 12일로 같습니다. 한 번의 사고로 당시 지원하던 브랜치가 통째로 결번된 것입니다.

18.5는 혼자입니다. 같은 날 나온 17.11, 16.15, 15.19, 14.24는 번호가 정상입니다. 회귀가 18 브랜치에만 있었다는 뜻입니다.

2008년에는 무슨 일이 있었나

8.3.2 릴리스 노트를 열면 Release Date 필드가 never released입니다. 그런데 본문은 멀쩡합니다. "This release contains a variety of fixes from 8.3.1"로 시작하고, Migration to Version 8.3.2 절도 붙어 있고, Changes 목록에 마흔 건 가까운 수정이 그대로 나열돼 있습니다. Windows에서 UTF-8 인코딩일 때 나던 크래시, %r 매크로의 archive 절단 지점 오산, GIN의 "too many LWLocks taken" 실패, SIGTERM으로 backend를 개별 종료했을 때 shared memory가 오염되던 문제가 보입니다.

그중 %r 항목은 지금 봐도 아찔합니다. warm standby 스크립트가 그 값을 믿고 WAL segment 파일을 버리면 데이터를 잃을 수 있었습니다. 이런 수정을 담은 릴리스가 문턱까지 갔다가 멈췄습니다.

노트에 사유는 적혀 있지 않습니다. 대신 대체 버전 노트의 첫 문장이 단서를 흘립니다.

버전첫 문장
8.3.3one serious and one minor bug fix over 8.3.2
8.2.9one serious and one minor bug fix over 8.2.8
7.4.21one serious bug fix over 7.4.20

세상에 나온 적 없는 버전을 기준점으로 삼아 "그 대비 몇 건"이라고 적었습니다. 결번본이 실재했다는 자백입니다. 그리고 여기서 말하는 "one serious"가 무엇인지는 다섯 브랜치 공통 항목으로 노트에 남아 있습니다.

Make pg_get_ruledef() parenthesize negative constants (Tom Lane)

Before this fix, a negative constant in a view or rule might be dumped as, say, -42::integer, which is subtly incorrect: it should be (-42)::integer due to operator precedence rules. Usually this would make little difference, but it could interact with another recent patch to cause PostgreSQL to reject what had been a valid SELECT DISTINCT view query. Since this could result in pg_dump output failing to reload, it is being treated as a high-priority fix.

view나 rule 안에 들어 있는 음수 상수를 괄호 없이 덤프하던 문제입니다. 연산자 우선순위상 (-42)::integer여야 하는데 -42::integer로 나왔습니다. 그 자체로는 대개 차이가 없지만, 당시 함께 들어간 다른 패치와 맞물리면 멀쩡하던 SELECT DISTINCT view 쿼리를 PostgreSQL이 거부했습니다.

여기서 심각도가 뛴 지점은 마지막 문장입니다. pg_dump 출력이 다시 적재되지 않을 수 있다는 결론이었습니다. 백업 파일이 복원되지 않는 문제는 다른 어떤 버그와도 무게가 다릅니다. 준비돼 있던 다섯 개 tarball을 버리고 이 수정을 얹어 다시 낸 이유로 충분합니다.

두 사건이 남긴 기록의 차이

구조는 같습니다. wrap이 끝난 뒤 회귀가 잡혔고, 고쳐서 번호를 올려 다시 냈습니다. 달라진 것은 그 사실을 어떻게 적어 두느냐입니다.

18.6 노트는 결번 사유를 한 줄로 명시했습니다. 짧지만 왜 번호가 비었는지가 문서 안에 적혀 있습니다. 2008년 노트들은 never released라는 사실만 남기고 사유를 적지 않았습니다. 지금 그 이유를 재구성할 수 있는 것은 대체본이 "over 8.3.2"라는 표현을 쓴 덕분입니다. 의도한 기록이라기보다 흔적에 가깝습니다.

공통점도 있습니다. 여섯 개 전부 릴리스 노트 페이지가 지금도 살아 있습니다. 나오지 않은 버전의 문서를 지우지 않는 관행이 적어도 2008년부터 이어지고 있습니다. 덕분에 18년 전 사건을 1차 출처로 따라갈 수 있었습니다.

검색하면 섞여 나오는 이야기 하나

18.5 결번을 검색하면 "standby가 구버전 마이너의 WAL을 재생하다 self-deadlock에 빠진다"는 내용이 사유처럼 딸려 나옵니다. 별개 사건입니다.

credativ가 정리한 그 버그MultiXactOffsetSLRU deadlock이고, 2026년 5월 14일에 나온 14.23, 15.18, 16.14에서 유입됐습니다. 17과 18은 영향이 없습니다. 증상은 standby의 startup 프로세스가 pg_stat_activity에서 LWLock/MultiXactOffsetSLRU 대기로 멈추는 형태입니다.

같은 8월 13일 릴리스에서 함께 고쳐졌을 뿐, 18.5를 취소시킨 원인이 아닙니다. 두 이야기를 붙여 쓰면 틀린 글이 됩니다.

지금 확인할 것

운영 관점에서 할 일은 많지 않습니다.

  1. 18.4에서 18.6으로 바로 올립니다. 중간에 빠뜨린 버전은 없습니다
  2. 자동화 스크립트가 마이너 번호를 순차 증가로 가정하고 있다면 확인합니다. 18.4 + 1 = 18.5를 기대하는 코드는 여기서 멈춥니다
  3. 사내 문서나 지원 버전 표에 18.5를 적어 둔 곳이 있으면 지웁니다

3번은 사소해 보이지만 실제로 혼란을 만듭니다. "18.5 적용 예정"이라고 써 둔 계획서가 남아 있으면 나중에 읽는 사람이 없는 버전을 찾게 됩니다.

남은 질문

18.5를 취소시킨 회귀가 정확히 무엇이었는지는 공개되지 않았습니다. 공식 문구는 "a regression"이 전부입니다. 2008년 사건에서도 회귀를 유발한 "another recent patch"가 무엇인지는 노트에 적혀 있지 않습니다.

저는 이 공백이 문제라고 보지는 않아요. 세상에 나가지 않은 코드의 버그를 상세히 적는 것은 그 자체로 이상한 일이니까요. 다만 번호가 하나 비면 사람은 반드시 이유를 찾습니다. 18.6 노트가 한 줄이라도 남겨 둔 덕분에 저 같은 사람이 패키지 목록 앞에서 오래 헤매지 않았습니다. 기록은 그 정도만 해 줘도 충분히 일을 합니다.

참고

PG 병렬 집계 공유 해시

· 약 7분

병렬 쿼리를 켰는데 빨라지지 않는 경험, 집계 쿼리에서 특히 잦습니다. 이유가 구조에 있고, 그 구조를 바꿔 보려는 실험 결과가 나왔어요.

pgEdge의 Andrei Lepikhov(안드레이 레피호프)가 Do Global Hash Tables Strike Back in PostgreSQL?에서 공유 해시 테이블 기반 병렬 집계를 직접 구현하고 측정했습니다. 결론이 흥미롭습니다. 되기는 되는데, 값이 너무 비쌉니다.

지금 구조: 나눠서 세고, 하나가 합친다

PostgreSQL의 병렬 집계는 두 단계입니다. 각 워커가 자기가 읽은 행을 Partial HashAggregate로 부분 집계합니다. 그 결과를 Gather가 모으고, 리더 프로세스 하나가 Finalize HashAggregate로 합칩니다.

그룹 수가 적으면 이 구조가 잘 돕니다. 워커 넷이 각각 100개 그룹의 부분 합계를 만들면, 리더는 400개 행만 합치면 됩니다.

그룹 수가 많으면 이야기가 달라집니다. 그룹이 500만 개면 각 워커의 부분 집계 결과도 수백만 행이고, 리더가 그걸 다 받아 다시 해시 테이블을 만들어야 합니다. 저자가 든 예시에서 스캔은 252ms인데 Finalize 단계가 전체 25초 중 18초를 썼습니다. 병렬을 끄면 13초였으니, 병렬 계획이 직렬보다 거의 두 배 느렸습니다.

직접 재 봤다

PostgreSQL 18에서 확인했습니다. 워커를 8개까지 쓸 수 있게 두고, 500만 행 테이블 셋을 만들어 그룹 수만 달리했습니다.

-- 그룹 100개
create table agg_few as select (i % 100) as g, i as v
from generate_series(1,5000000) i;

-- 그룹 500만개 (모든 행이 각자 그룹)
create table agg_many (g bigint, v bigint);
insert into agg_many select i, i from generate_series(1,5000000) i;

그룹 100개짜리는 예상대로 병렬 계획이 나왔습니다.

Finalize GroupAggregate (actual time=851.515..855.136 rows=100.00)
-> Gather Merge (actual time=851.509..855.094 rows=400.00)
Workers Planned: 3
Workers Launched: 3
-> Sort (actual time=837.048..837.052 rows=100.00 loops=4)

Gather Merge가 400행을 올리고 Finalize가 4ms 안에 끝납니다. 워커 넷이 각각 100개 그룹을 만들었으니 정확히 400행입니다.

그룹 500만개에서는 계획 자체가 달라졌습니다.

HashAggregate (actual time=3860.148..8242.441 rows=5000000.00)
Group Key: g
Batches: 17 Memory Usage: 135313kB Disk Usage: 161920kB
-> Seq Scan on agg_many (actual time=0.023..569.741 rows=5000000.00)
Execution Time: 8488.126 ms

병렬이 아예 없습니다. planner가 워커를 하나도 쓰지 않기로 결정했습니다. 비용 모형이 이미 "이 경우 병렬 집계는 손해"라고 판단한 것입니다. 저자가 지적한 문제를 planner가 회피하는 방식으로 대응하고 있다는 뜻입니다.

그래서 억지로 병렬을 켜 봤습니다. parallel_setup_costparallel_tuple_cost를 0으로 낮췄습니다.

HashAggregate (actual time=2438.858..6180.438 rows=5000000.00)
Batches: 17 Memory Usage: 135313kB Disk Usage: 161936kB
-> Gather (actual time=0.263..300.906 rows=5000000.00)
Workers Planned: 3
Workers Launched: 3

여기가 이 실험에서 가장 선명한 부분입니다. Gather가 500만 행을 300ms에 올립니다. 읽기는 병렬로 잘 됐습니다. 그런데 그 위의 HashAggregate가 6,180ms를 씁니다. 스캔은 병렬인데 집계는 단일 프로세스입니다.

주목할 것은 Partial HashAggregate가 아예 붙지 않았다는 점입니다. 그룹이 너무 많아 부분 집계가 아무 이득이 없다고 보고, 워커는 스캔만 하고 집계 전부를 리더에게 넘겼습니다. 저자가 지적한 "단일 프로세스 병목"이 계획에 그대로 나타납니다.

디스크 spill도 눈에 걸립니다. work_mem을 64MB로 뒀는데 17개 batch로 나뉘고 161MB를 디스크에 썼습니다. 리더 하나가 500만 그룹의 해시 테이블을 메모리에 못 담아서입니다. 워커들이 나눠 담았다면 각자 125만 그룹이니 상황이 달랐을 것입니다.

프로토타입: 공유 해시 테이블

저자가 만든 것은 DSM(Dynamic Shared Memory)에 해시 테이블 하나를 두고, 모든 워커가 lock을 잡고 그 테이블을 갱신하는 방식입니다. 부분 집계와 Finalize 단계가 통째로 사라집니다.

구현 규모가 이 실험의 성격을 보여 줍니다. nodeAgg.c 밖에 약 800줄, planner에 약 600줄(비용 모형과 적용 여부 판단), executor 코어에 일곱 줄이 들어갔습니다. nodeAgg.c에서 가져와 고친 static 함수가 여덟 개입니다.

새로 만들어야 했던 것들이 있습니다. Parallel Hash Join의 해시 테이블 설계를 재사용할 수 없어서 전용 해시 테이블을 만들었습니다. numeric이나 text처럼 가변 타입의 상태를 다루려고 DSM 안팎으로 복사하는 계층을 별도로 짰습니다. 메모리 한도 계산은 로컬에 모았다가 일괄 반영하는 방식으로 처리했고, 집계 함수마다 공유 방식이 안전한지 표시하는 aggsharedsafe 플래그를 새로 넣었습니다.

기존 기계장치도 꽤 썼습니다. spill 처리에 SharedTuplestore, by-value 상태에 32KB 단위 DSA 할당자, 단계 동기화와 임시 파일, 그리고 대기 이벤트 다섯 개와 LWLock tranche 두 개입니다.

균등 분포에서는 통한다

측정은 GCP VM에서 했습니다. 48코어를 주로 쓰고 16코어, 14코어도 함께 봤습니다. 전부 메모리 안에서 돌았고 디스크 spill은 없었습니다.

그룹이 고르게 분포하고 워커가 8개일 때 결과입니다. by-value 집계는 약 4.8배, numeric 같은 by-reference 집계는 약 2.0배 빨라졌습니다. 같은 조건에서 두 배 차이가 나는 이유가 DSM 안팎으로 상태를 복사하는 비용입니다.

집계 함수 개수를 늘려 보면 곡선이 나옵니다. 그룹당 집계 2개면 4.64배, 12개면 5.80배로 정점을 찍고, 32개가 되면 4.40배로 내려갑니다. lock을 한 번 잡고 여러 집계를 갱신하니 개수가 늘면 lock 비용이 분산되고, 너무 늘면 critical section이 길어져 다시 나빠집니다.

skew에서 무너진다

문제는 여기입니다. 한 그룹이 전체를 얼마나 차지하는지에 따라 결과가 이렇게 변합니다.

최대 그룹 비중속도 향상
2%4.49배
5~10%거의 동등
95%0.04배

95%에서 0.04배는 25배 느려진다는 뜻입니다. 이유가 명확합니다. 모든 워커가 같은 그룹의 상태를 갱신하려고 같은 lock을 두고 줄을 섭니다. 병렬이 아니라 순차가 되고, 여기에 lock 획득 비용이 얹힙니다.

현실 데이터에서 흔한 80대 20 분포에서는 by-value 집계가 대략 동등, by-reference는 통상적인 워커 수에서 이득이 거의 없었습니다. 실무 데이터가 대개 고르지 않다는 점을 생각하면 이 결과가 결정적입니다.

세 가지 장애물

저자는 lock 경합 자체보다 더 근본적인 문제 셋을 지목했습니다.

첫째, LWLock의 입도입니다. "LWLock은 이 크기의 critical section에 맞는 primitive가 아니다"라고 적었습니다. count(*)조차 나노초 단위 증가 연산을 하려고 마이크로초 단위 LWLockAcquire()를 부릅니다. 보호하려는 작업보다 보호 장치가 비쌉니다.

둘째, JIT 컴파일이 불가능해집니다. 지금의 비공유 경로는 ExecBuildAggTrans()가 만든 마이크로프로그램 하나를 JIT으로 함수 하나로 컴파일합니다. 공유 경로에서는 lock을 잡기 전에 인자를 계산하고 lock 안에서 transition 함수를 호출해야 하니, 이 통합 컴파일이 성립하지 않습니다. 병렬로 얻은 이득을 컴파일 손실로 반납하는 구조입니다.

셋째, lock 안에서 임의 코드가 돕니다. enum_cmp_internal() 같은 함수는 카탈로그를 조회하고, 그 과정에서 table_open()과 buffer 읽기가 일어날 수 있습니다. LWLock을 잡은 상태에서 다른 lock을 잡는 상황이 만들어집니다.

프로세스 모델이 청구서를 보낸다

저자의 결론 문장이 셉니다. "프로세스 모델이 공유 모델로 가는 길의 주된 장애물이다."

스레드 기반 엔진과 비교하면 차이가 구체적입니다. DSM 안에서는 직접 포인터를 쓸 수 없어서 palloc, repalloc, MemoryContext를 쓸 수 없습니다. by-reference 상태는 복사해 넣고 복사해 나와야 합니다. TOAST를 제자리에서 압축 해제할 수 없습니다. 집계 함수 구현이 표준적인 가변 상태 패턴을 쓸 수 없습니다.

정리하면 이렇습니다. "PostgreSQL은 스레드를 가진 엔진보다 공유 가변 상태에 훨씬 많은 값을 치르고, 돌려받는 것은 같다."

pgrust가 Volcano 모델의 청구서를 뜯어 보여 준 이야기와 같은 종류의 결론입니다. 30년 전에 정한 실행 모델이 지금 어떤 최적화를 막고 있는지 확인하는 작업입니다. Momjian이 짚은 코어의 빈칸들에도 같은 성격의 항목이 여럿 있었습니다.

그럼 어디에 쓸까

저자는 포기하지 않고 적용 범위를 좁히자고 제안합니다.

고정 폭 by-value 상태로 한정합니다. count, sum(integer), sum(float)이 여기 들어갑니다. 이런 상태는 원자적 병합 연산 하나로 처리할 수 있으니 lock 없이 갱신할 수 있습니다. 위 세 장애물 중 첫째와 둘째가 상당히 완화됩니다.

그리고 MCV 통계로 skew를 미리 감지해 병리적인 경우를 피합니다. planner가 pg_stats의 most common values를 보고 "이 그룹 키는 한 값이 지배적이다"라고 판단하면 공유 방식을 고르지 않는 식입니다. 통계가 이미 있는 정보라서 추가 비용이 낮습니다.

더 유망한 쪽으로 두 곳을 꼽았습니다. SetOp 연산자는 현재 병렬화가 아예 없습니다. 그리고 단순 SELECT DISTINCT입니다. 둘 다 by-reference 상태의 복잡성이 걸리지 않는 영역입니다.

남는 생각

이 글의 값은 4.8배라는 숫자가 아니라 그 숫자가 왜 못 남는지에 있습니다. 균등 분포 벤치마크만 보면 도입할 이유가 충분해 보이는데, skew 하나로 25배 역행이 나옵니다. 벤치마크 조건을 고르는 일이 결과 자체보다 중요하다는 사례입니다.

제 실측에서 planner가 500만 그룹에 병렬을 아예 안 붙인 것도 같은 맥락으로 읽힙니다. 비용 모형이 이미 이 지점을 알고 회피하고 있습니다. 새 실행 방식을 넣는다면 planner가 그것을 언제 고를지 판단하는 부분이 구현의 절반이 됩니다. 저자가 planner에 600줄을 쓴 이유겠죠.

참고

pg_shmemviz 공유 메모리

· 약 6분

일주일 전에 pg_walviz로 WAL segment 내부를 들여다봤는데, 같은 저자가 이번엔 공유 메모리를 열었어요. Bertrand Drouvot(베르트랑 드루보)이 2026년 8월 20일 pg_shmemviz v0.1.0-beta.1을 공개했습니다.

두 도구의 성격이 닮았습니다. 이미 있는 뷰로도 요약은 볼 수 있는데, 정작 "그게 메모리 어디에 어떤 모양으로 놓여 있나"는 안 보인다는 문제를 같은 방식으로 풉니다. 스냅샷을 떠서 브라우저에서 바이트 단위로 걸어 다니게 하는 방식입니다.

pg_shmem_allocations는 어디까지 보여 주나

먼저 기존 뷰의 한계를 실측으로 확인했습니다. PostgreSQL 18 컨테이너를 기본 설정으로 띄우고 pg_shmem_allocations를 조회했습니다.

$ docker run -d --name shmemtest -e POSTGRES_PASSWORD=pw postgres:18
$ docker exec shmemtest psql -U postgres -Atc "show shared_buffers"
128MB
select name, off, size, allocated_size
from pg_shmem_allocations
order by size desc limit 8;
name | off | size | allocated_size
----------------------+-----------+-----------+----------------
Buffer Blocks | 6785664 | 134221824 | 134221824
<anonymous> | | 4747776 | 4747776
XLOG Ctl | 60928 | 4208200 | 4208256
AioHandleIOV | 150482816 | 2850816 | 2850816
| 154760064 | 2280576 | 2280576
AioHandle | 148879232 | 1603584 | 1603584
AioHandleData | 153333632 | 1425408 | 1425408
Buffer Descriptors | 5737088 | 1048576 | 1048576

전체는 75개 항목, 합계 150MB였습니다. 여기서 볼 수 있는 것과 볼 수 없는 것이 꽤 선명하게 갈립니다.

보이는 것은 이름, 시작 오프셋, 요청 크기, 실제 할당 크기입니다. XLOG Ctl이 4,208,200바이트를 요청했는데 4,208,256바이트가 할당된 것을 보면 56바이트가 정렬 때문에 붙었다는 사실까지는 읽힙니다. PostgreSQL 18에서 비동기 I/O가 들어오면서 AioHandle, AioHandleIOV, AioHandleData 세 항목이 합쳐 5.8MB를 차지하는 것도 확인됩니다. 비동기 I/O 글에서 다룬 io_uring 구조가 메모리에서 이 정도 자리를 쓴다는 뜻입니다.

안 보이는 것이 문제입니다. 위 출력에서 이름 칸이 빈 행이 하나 있는데, 이건 아직 아무에게도 배정되지 않은 여유 공간입니다. <anonymous>는 이름 없이 잡힌 익명 할당이라 오프셋조차 안 나옵니다. 그리고 XLOG Ctl 안에 XLogCtlData 구조체의 어떤 필드가 몇 번째 바이트에 앉아 있는지, 필드 사이에 컴파일러가 끼워 넣은 padding이 몇 바이트인지는 이 뷰의 관심사가 아닙니다.

정리하면 이렇습니다.

알고 싶은 것pg_shmem_allocationspg_shmemviz
할당 이름과 크기지원지원
요청 크기 대 실제 크기 차이지원지원
할당 사이 빈 구간의 물리적 위치부분 (이름 없는 행)지원
C 구조체의 필드별 오프셋과 값지원
필드 사이 컴파일러 padding지원
포인터가 가리키는 대상 영역지원
페이지의 NUMA 노드 배치pg_shmem_allocations_numa지원 (시각화)
두 시점 사이 차이 비교수동지원

실행 중인 서버에 붙지 않고 구조체를 읽는 방법

여기가 이 도구에서 가장 흥미로운 부분입니다. 공유 메모리의 바이트 배열을 읽는 것 자체는 어렵지 않습니다. 어려운 건 그 바이트가 무슨 구조체의 어느 필드인지 알아내는 일입니다. 그 정보는 소스 코드에만 있고 실행 중인 서버의 메모리에는 없습니다.

pg_shmemviz는 이 문제를 postgres 실행 파일의 DWARF 디버그 정보로 해결합니다. 컴파일된 바이너리에는 각 구조체의 필드 이름, 타입, 오프셋이 DWARF 형식으로 들어 있습니다. macOS에서는 LLDB, 그 외 환경에서는 GDB를 써서 이 메타데이터를 읽습니다. 중요한 건 디버거를 실행 중인 서버에 attach하지 않는다는 점입니다. 실행 파일의 메타데이터만 읽습니다.

복사 단계에는 lock을 걸지 않습니다. 덕분에 서버를 멈추지 않지만, 대가가 있습니다. 스냅샷 안의 필드들이 서로 다른 순간의 값일 수 있습니다. 저자도 이 점을 문서에 명시했습니다. 그러니 "이 두 카운터의 차이가 정확히 몇인가"를 따지는 용도로는 맞지 않고, 구조와 배치를 파악하는 용도입니다.

설치와 사용

extension과 CLI 두 부분으로 되어 있습니다.

cd ~/pg_shmemviz
make PG_CONFIG=/path/to/postgres-install/bin/pg_config
make PG_CONFIG=/path/to/postgres-install/bin/pg_config install

psql -d postgres -c 'CREATE EXTENSION pg_shmemviz'

스냅샷을 뜨는 명령입니다.

~/pg_shmemviz/bin/pg_shmemviz capture \
--pg-config /path/to/postgres-install/bin/pg_config \
--dbname postgres \
/path/to/new-snapshot

뜬 스냅샷을 브라우저로 봅니다. 기본값은 127.0.0.1:8765이고 로컬 브라우저가 열립니다.

~/pg_shmemviz/bin/pg_shmemviz serve /path/to/new-snapshot

원격 서버에서 뜬 스냅샷을 SSH 포트 포워딩으로 볼 때는 브라우저 자동 실행을 끕니다.

~/pg_shmemviz/bin/pg_shmemviz serve --no-open --port 8765 /path/to/new-snapshot

화면이 보여 주는 것

뷰가 여러 개인데 서로 연동됩니다. 한쪽에서 할당을 고르면 다른 쪽이 같은 지점을 따라갑니다.

공유 메모리 맵은 main segment와 DSM, DSA 영역을 통틀어 이름 있는 할당, padding, 미사용 구간을 늘어놓습니다. 위 실측에서 이름 칸이 비어 있던 2.2MB가 어디에 어떤 이웃과 붙어 있는지가 여기서 드러납니다.

Structure Fields 패널이 이 도구의 핵심입니다. 중첩된 C 구조체를 펼쳐 필드별 오프셋과 값, 컴파일러가 끼운 padding, 배열의 stride padding을 보여 줍니다. 경계를 알 수 있는 포인터는 가리키는 영역을 참조 구간으로 표시합니다. 통계, WAL, 프로세스 배열, SLRU, dynahash, DSM registry 같은 PostgreSQL 특유의 구조에는 전용 해석이 들어가 있습니다.

Physical Bytes 뷰는 주소, 오프셋, 값, 어느 구조체 필드에 속하는지, 어느 NUMA 노드에 놓였는지를 함께 보여 주는 바이트 창입니다. Buffer Cache 뷰는 선택 사항인데, buffer별 식별자와 database, relation, fork, block 번호, 그리고 그 buffer를 pin하고 있는 backend까지 나옵니다. pg_buffercache로 보던 내용을 물리적 배치 위에 겹쳐 놓은 셈입니다.

NUMA 노드가 여러 개인 장비에서는 페이지 배치를 그림으로 봅니다. pg_shmem_allocations_numa 뷰가 숫자로 알려 주던 것을 눈으로 확인하는 용도입니다.

스냅샷 두 개를 비교한다

이 기능이 실무 관점에서 가장 쓸모 있어 보입니다. 서로 다른 시점의 스냅샷 두 개를 나란히 열어 할당 단위, 필드 단위, 바이트 단위로 차이를 봅니다.

~/pg_shmemviz/bin/pg_shmemviz serve \
/path/to/before-snapshot \
/path/to/after-snapshot

설정 하나를 바꿨을 때 공유 메모리가 실제로 어떻게 달라지는지 확인하는 데 쓸 수 있습니다. 위 실측에서 PostgreSQL 18의 AIO 관련 세 할당이 5.8MB를 차지했는데, io_method를 바꾸기 전후로 스냅샷을 떠서 비교하면 그 5.8MB의 내부 구성이 어떻게 변하는지가 필드 단위로 보일 것입니다.

운영 인스턴스에서는 쓰지 않는다

저자가 문서 앞쪽에 강하게 못 박은 부분이라 그대로 옮깁니다.

Do not run pg_shmemviz on a production PostgreSQL instance.

이유가 몇 겹입니다. 스냅샷은 공유 메모리 전체를 복사한 파일이라 크고, 그 안에 실제 데이터가 그대로 들어갑니다. buffer에 올라온 테이블 내용이 파일로 떠지는 셈이니 민감 정보가 그대로 흘러나갑니다. viewer에는 인증도, 권한 검사도, TLS도 없습니다. loopback 밖으로 내보내면 안 됩니다.

구조체 해석에는 캡처한 서버가 쓰던 것과 정확히 같은 postgres 실행 파일이 필요합니다. 빌드가 다르면 필드 오프셋이 어긋나 엉뚱한 값을 읽습니다. 검증은 PostgreSQL 20devel 기준으로 되어 있고, 어느 버전까지 되는지는 저장소 README를 봐야 합니다. 제가 위에서 실측한 PostgreSQL 18 컨테이너 이미지에는 디버그 정보가 없으니, 실제로 붙여 보려면 디버그 심볼을 켜서 직접 빌드한 인스턴스가 필요합니다.

어디에 쓰면 좋을까

용도가 좁습니다. 운영 진단 도구가 아니고, extension이나 코어 패치를 개발하면서 "내가 잡은 공유 메모리 구조가 실제로 어떻게 배치됐나"를 확인하는 도구입니다. 그리고 학습 자료로서의 가치가 따로 있습니다. XLogCtlDataPGPROC 배열이 메모리에서 어떤 모양인지 소스만 읽어서 상상하던 것을 눈으로 확인하는 경험은 소스 독해 속도를 꽤 올려 줍니다.

PostgreSQL 내부를 3D 도시로 걸어 본 글에서 시각화 도구가 학습에 어떤 도움이 되는지 이야기했는데, pg_shmemviz는 그보다 훨씬 실무 쪽에 가깝습니다. 비유가 아니라 실제 주소와 실제 바이트를 보여 주니까요.

pg_walviz와 pg_shmemviz가 한 주 간격으로 나왔습니다. 저는 이 흐름이 반갑습니다. PostgreSQL 내부는 소스를 읽을 수 있는 사람에게만 열려 있었는데, 그 문턱을 낮추는 도구가 늘고 있어요.

참고

PostgreSQL UUID v7 PK

· 약 5분

primary key를 UUID로 쓰기로 하면 대개 v4를 씁니다. 그런데 그 선택이 index를 얼마나 부풀리는지 숫자로 본 적은 없었어요. pgEdge의 Shaun Thomas(숀 토머스)가 The Time Traveler's Primary Key에서 그 숫자를 냈고, PostgreSQL 18 컨테이너로 직접 재현해 봤습니다.

v4가 index를 흩어 놓는 방식

B-tree는 정렬된 구조입니다. 새 키가 들어오면 그 값이 들어갈 자리를 찾아 해당 leaf page에 씁니다.

시퀀스로 만든 키는 항상 오른쪽 끝으로 갑니다. 마지막 page를 채우고, 차면 새 page를 붙입니다. page 하나가 거의 꽉 찬 상태로 남습니다.

UUID v4는 값이 완전히 무작위입니다. 원문 표현대로 "생성된 각 값이 맨 첫 행보다 앞에 정렬될 확률과 맨 마지막 행보다 뒤에 정렬될 확률이 같습니다". 그래서 삽입이 index 전체에 흩어집니다. 이미 꽉 찬 page 가운데에 값이 들어오면 page split이 일어나고, split된 두 page는 각각 절반씩만 채워집니다. 같은 개수의 키를 담는 데 page가 두 배로 필요해집니다.

UUID v7은 상위 48비트에 밀리초 단위 Unix 타임스탬프를 최상위 비트부터 담습니다. version 정보 뒤에 12비트를 더해 밀리초 이하 정밀도까지 표현합니다. 남는 약 62비트가 무작위입니다. 결과적으로 나중에 만든 값이 앞서 만든 값보다 크게 정렬됩니다. 시퀀스와 같은 삽입 패턴이 됩니다.

직접 재 봤다

PostgreSQL 18에 uuidv7()uuidv4()가 내장되어 있습니다. 기본 설정 컨테이너로 세 가지 테이블에 100만 행씩 넣었습니다.

create table t_bigint (id bigserial primary key, payload text);
create table t_v4 (id uuid primary key, payload text);
create table t_v7 (id uuid primary key, payload text);

insert into t_bigint (payload) select 'x' from generate_series(1,1000000);
insert into t_v4 (id,payload) select uuidv4(),'x' from generate_series(1,1000000);
insert into t_v7 (id,payload) select uuidv7(),'x' from generate_series(1,1000000);

크기를 뽑았습니다.

relname | heap | idx
----------+-------+-------
t_bigint | 42 MB | 21 MB
t_v4 | 50 MB | 37 MB
t_v7 | 50 MB | 30 MB

원문이 낸 수치가 v4 index 38MB, v7 30MB였는데 거의 그대로 나왔습니다. index만 놓고 보면 v7이 v4보다 19% 작습니다.

이유를 직접 확인하려면 pgstattuplepgstatindex()를 봅니다.

select 'v4' as k, avg_leaf_density, leaf_fragmentation from pgstatindex('t_v4_pkey')
union all
select 'v7', avg_leaf_density, leaf_fragmentation from pgstatindex('t_v7_pkey')
union all
select 'bigint', avg_leaf_density, leaf_fragmentation from pgstatindex('t_bigint_pkey');
k | avg_leaf_density | leaf_fragmentation
--------+------------------+--------------------
v4 | 72.61 | 49.86
v7 | 89.98 | 0
bigint | 90.01 | 0

여기가 이 실험에서 가장 선명한 부분입니다. v4의 leaf 밀도가 72.61%이고 단편화가 49.86%입니다. v7은 밀도 89.98%에 단편화 0입니다. bigserial의 90.01%와 사실상 같습니다. v7의 삽입 패턴이 시퀀스와 구분되지 않는다는 뜻입니다.

원문 수치가 71.53%와 49.89%, 89.98%와 0.00%였으니 소수점 자리까지 맞았습니다.

heap은 줄지 않는다

표에서 놓치기 쉬운 칸이 heap입니다. t_bigint가 42MB, UUID 두 개는 50MB로 같습니다. UUID는 128비트, bigint는 64비트니까 행마다 8바이트가 더 붙습니다. v7으로 바꿔도 이 차이는 그대로입니다.

정리하면 v7이 개선하는 것은 index의 공간 효율과 삽입 시 page split이고, 값 자체의 크기는 아닙니다. bigint와 UUID 사이의 선택은 여전히 별개 문제입니다. 원문도 이 부분을 분명히 합니다. 데이터가 단일 노드에 있고, 하나의 primary 인스턴스가 키를 발급하고, 키가 외부로 돌아다니지 않는다면 BIGINT GENERATED ALWAYS AS IDENTITY가 최선이라고 적었습니다. 위 실측에서도 bigint index가 21MB로 v7의 70% 수준입니다.

타임스탬프가 공개된다

v7의 대가는 성능이 아니라 정보입니다. 상위 비트가 생성 시각이라서 값에서 그대로 읽힙니다. PostgreSQL 18은 그 추출 함수를 내장으로 제공합니다.

select id, uuid_extract_timestamp(id) from t_v7 limit 2;
01a03337-728f-7bf2-bdf8-69d062bfa627|2026-08-24 10:01:06.959+00
01a03337-7290-7767-bc60-69fb35dd6c00|2026-08-24 10:01:06.96+00

버전도 확인됩니다.

select uuid_extract_version(id) from t_v7 limit 1; -- 7

이게 뜻하는 바가 셋입니다. 키를 받은 쪽이 그 레코드의 생성 시각을 밀리초 단위로 압니다. 키 두 개를 비교하면 그 사이 생성 간격을 알 수 있어서 생성 속도가 측정됩니다. 그리고 값이 시간순으로 인접하니 이웃 키를 추측해 보는 것이 무작위 값보다 훨씬 그럴듯해집니다.

주문 번호나 사용자 ID를 URL에 노출하는 구조라면 이 세 가지가 실제 문제가 됩니다. 가입 시각, 하루 주문 건수, 경쟁사 성장 속도가 키에서 읽힙니다. 원문도 생성 시각과 삽입 속도가 비공개여야 하는 경우에는 v4가 여전히 적합하다고 적었습니다.

오른쪽 끝이 경합 지점이 된다

또 하나 짚어 둘 만한 대가가 있습니다. v7의 삽입이 index 오른쪽 끝으로 몰린다는 것은 곧 그 page가 모든 writer의 경합 지점이 된다는 뜻입니다.

샤딩된 환경이나 병렬 쓰기가 많은 구성에서는 이게 실제 병목이 될 수 있습니다. v4는 흩어져서 비효율적인 대신, 쓰기를 고르게 분산합니다. 단일 노드에서 순차 삽입이 주된 패턴이라면 v7이 유리하고, writer가 많고 오른쪽 끝 경합이 이미 보이는 환경이라면 계산이 달라집니다.

어떻게 고를까

세 선택지의 성격을 정리하면 이렇습니다.

index 효율분산 생성생성 시각 비공개크기
bigserial / identity가장 좋음 (21MB)어려움유지8바이트
UUID v4나쁨 (37MB, 단편화 50%)지원유지16바이트
UUID v7좋음 (30MB, 단편화 0)지원노출16바이트

이미 UUID v4를 쓰고 있고 분산 생성이 필요해서 그렇게 한 것이라면, v7으로 옮기는 건 대체로 이득입니다. index가 작아지고 page split이 사라지는 대신 잃는 것은 생성 시각의 비공개성뿐입니다. 그 시각이 민감하지 않다면 바꿀 이유가 충분합니다.

반대로 UUID를 쓰는 이유가 "ID를 추측할 수 없게 하려고"였다면 v7은 그 목적과 충돌합니다. v7의 무작위 비트가 62비트라 값 자체를 맞히기는 여전히 어렵지만, 시간순 인접성 때문에 열거 시도의 탐색 공간이 좁아집니다.

새로 만드는 테이블이고 단일 노드라면 bigint가 답입니다. 위 실측에서 index가 v7의 70%, heap이 84%였고 그게 전부 실제 I/O입니다. UUID를 고르는 이유는 성능이 아니라 분산 생성이나 외부 노출이니까요.

참고

PgBouncer와 Patroni 시간대

· 약 5분

로그의 타임스탬프 절반은 맞고 절반은 UTC라는 신고가 들어오면 어디부터 볼까요. Percona Community에 올라온 사례의 답이 흥미로웠습니다. 원인이 PostgreSQL 안이 아니었습니다.

배경은 Oracle에서 Patroni로 관리하는 PostgreSQL 18 클러스터로 마이그레이션한 직후입니다. 개발자들이 "타임스탬프가 어떤 건 맞고 어떤 건 UTC로 나온다"고 알렸습니다.

계층이 넷이었다

문제를 이해하려면 timezone 값이 어디에 저장되는지 짚어야 합니다. 이 환경에서는 네 곳이었습니다.

계층처음문제 상황최종
VM (OS)UTCUTCUTC
PostgreSQLUTC (기본값)Africa/LagosAfrica/Lagos
Patroni 설정없음재시작 후 UTCAfrica/Lagos
PgBouncerUTC (캐시)UTC (낡은 캐시)갱신됨

세 번째 행과 네 번째 행이 사건의 두 단계입니다.

1단계: ALTER SYSTEM이 살아남지 못한다

처음 시도한 방법이 이것이었습니다.

ALTER SYSTEM SET TIMEZONE = 'Africa/Lagos';
SELECT pg_reload_conf();

이 명령이 어디에 쓰이는지 컨테이너로 확인했습니다.

$ psql -Atc "show timezone"
Etc/UTC

$ psql -c "alter system set timezone = 'Asia/Seoul'"
$ psql -Atc "select pg_reload_conf()"
t

$ cat $PGDATA/postgresql.auto.conf
# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
timezone = 'Asia/Seoul'

새 세션에서는 바로 반영됩니다.

$ psql -Atc "show timezone; select now();"
Asia/Seoul
2026-08-24 19:02:52.265584+09

여기까지는 정상입니다. 문제는 값이 postgresql.auto.conf에 있다는 것입니다. Patroni는 클러스터 설정을 분산 설정 저장소에서 관리하고, 노드를 재시작할 때 자기가 가진 설정으로 구성 파일을 다시 씁니다. ALTER SYSTEM이 남긴 값은 그 과정에서 사라집니다. Patroni 노드가 재시작되자 timezone이 UTC로 되돌아갔습니다.

Patroni 환경에서 올바른 방법은 Patroni 쪽에 넣는 것입니다.

patronictl edit-config

여기서 설정한 값은 노드 재시작에도 유지됩니다. Patroni를 쓰는 클러스터에서 ALTER SYSTEM을 쓰지 않는다는 원칙이 이 사례의 첫 교훈입니다.

2단계: PgBouncer가 기억하고 있었다

Patroni 설정을 Africa/Lagos로 바로잡은 뒤에도 일부 세션이 UTC로 나왔습니다. 여기가 진짜 원인입니다.

PgBouncer는 세션 파라미터를 캐시합니다. 클라이언트가 연결할 때 넘기는 startup parameter를 기억해 두고, 풀에서 서버 연결을 꺼내 줄 때 그 값을 맞춰 줍니다. 그런데 Patroni 설정 변경으로 데이터베이스의 timezone이 바뀐 경우에는 이 내부 캐시를 제대로 무효화하지 않습니다. ALTER DATABASE로 바꿨을 때와 경로가 다릅니다.

그래서 낡은 UTC 기준으로 만들어진 세션들이 풀에서 계속 재활용됐습니다. 새로 만들어진 연결은 Africa/Lagos, 재활용된 연결은 UTC입니다. 로그에 두 값이 섞인 이유입니다.

startup parameter가 서버 설정을 이긴다

캐시가 왜 이렇게 강한지 궁금해서 직접 확인했습니다. 서버 설정은 Asia/Seoul인 상태에서, 클라이언트가 연결 시점에 timezone을 지정해 봤습니다.

$ psql -Atc "show timezone"
Asia/Seoul

$ PGOPTIONS="-c timezone=UTC" psql -Atc "show timezone"
UTC

같은 서버, 같은 순간인데 값이 다릅니다. 클라이언트가 연결할 때 넘긴 값이 서버의 설정을 덮어씁니다. 이게 정상 동작입니다. 세션 단위 설정이 서버 기본값보다 우선하니까요.

PgBouncer의 캐시가 문제가 되는 지점이 여기입니다. 서버 설정을 고쳐도 pooler가 예전 값을 세션에 계속 넣어 주면, 서버 쪽 변경은 그 세션에 닿지 않습니다. show timezone으로 확인해도 클라이언트가 보는 값은 pooler가 정한 값입니다.

어떻게 고쳤나

PgBouncer 파드를 재시작해 캐시를 비웠습니다. 이후 새 연결은 모두 Africa/Lagos를 반영했습니다.

원문이 정리한 교훈 셋입니다. Patroni에서 timezone을 바꾼 뒤에는 PgBouncer를 재시작하거나 재연결시킵니다. ALTER SYSTEM SET TIMEZONE이 아니라 patronictl edit-config를 씁니다. 그리고 Kubernetes에서는 PgBouncer를 독립 파드가 아니라 sidecar로 배치합니다.

세 번째 항목에 이 사례의 구조적 원인이 들어 있습니다. PgBouncer가 독립 파드로 떠 있었기 때문에 Patroni 노드 재시작과 PgBouncer 재시작이 서로 무관했습니다. sidecar였다면 파드 재시작이 둘을 함께 갈아 줬을 것입니다. 연결 풀이 DB 인스턴스보다 오래 사는 배치에서는 이런 종류의 불일치가 반복됩니다.

진단할 때 볼 지점

같은 증상을 만나면 확인 순서를 이렇게 잡을 만합니다.

-- 지금 이 세션이 보는 값과 그 출처
select name, setting, source, sourcefile
from pg_settings where name = 'TimeZone';

-- 접속 중인 세션별로 실제 적용된 값
select pid, application_name, backend_start
from pg_stat_activity where backend_type = 'client backend';

pg_settingssource 컬럼이 핵심입니다. 값이 configuration file이면 서버 설정에서, client면 클라이언트가 startup parameter로 넘긴 값입니다. client로 나오면서 값이 기대와 다르면 pooler를 봐야 합니다.

pooler 쪽에서는 SHOW SERVERSSHOW POOLS로 서버 연결이 언제 만들어졌는지 확인합니다. 설정을 바꾼 시각보다 오래된 연결이 남아 있으면 그게 문제의 세션입니다. RECONNECT 명령이나 파드 재시작으로 정리합니다.

남는 생각

이 사례가 좋은 이유는 원인이 PostgreSQL 밖에 있었다는 점입니다. show timezone을 아무리 확인해도, 그 값을 정한 주체가 pooler라면 서버 설정을 보는 것으로는 안 풀립니다.

Oracle에서 넘어온 직후라는 배경도 한몫했다고 봅니다. Oracle에는 연결 풀을 이런 식으로 앞에 두는 관례가 덜하고, 세션 파라미터가 계층별로 덮어써지는 구조에 익숙하지 않으면 의심 대상에 pooler가 안 들어옵니다. 저도 이 글을 읽기 전까지 PgBouncer가 startup parameter를 그렇게 오래 붙들고 있는 줄은 몰랐어요.

참고

Aurora pgvector 이진 양자화

· 약 5분

벡터 검색에서 제일 자주 부딪히는 벽이 "index가 메모리에 안 들어간다"입니다. AWS가 그 벽을 이진 양자화로 넘은 사례를 냈습니다. 1억 벡터 index가 367GB에서 약 38GB가 됐습니다.

부호만 남긴다

이진 양자화의 방식은 이름 그대로입니다. 각 차원의 float32 값을 0을 기준으로 잘라 1비트로 만듭니다. 양수면 1, 음수면 0입니다.

768차원 벡터라면 원래 768 × 4바이트 = 3,072바이트입니다. 양자화하면 768비트, 즉 96바이트입니다. 32분의 1입니다.

정보를 크게 버리는 방식인데 벡터 검색에서 통하는 이유가 있습니다. 고차원 임베딩에서는 각 차원의 정확한 크기보다 부호 패턴이 방향을 상당히 결정합니다. 768개 차원의 부호가 얼마나 겹치는지만 봐도 두 벡터가 비슷한지 대략 알 수 있습니다. 이 비교가 Hamming 거리입니다.

직접 재 봤다

pgvector 0.8.6이 들어간 PostgreSQL 18 컨테이너로 확인했습니다. 768차원 벡터 3만 개를 넣고 두 종류 index를 만들었습니다.

create table docs (id bigserial primary key, embedding vector(768));
insert into docs (embedding)
select (select array_agg(random()-0.5) from generate_series(1,768))::vector
from generate_series(1,30000);

일반 HNSW와 이진 양자화 HNSW입니다.

create index idx_full on docs
using hnsw (embedding vector_cosine_ops)
with (m=16, ef_construction=64);

create index idx_bq on docs
using hnsw ((binary_quantize(embedding)::bit(768)) bit_hamming_ops)
with (m=16, ef_construction=64);

bit_hamming_ops가 bit 타입에 Hamming 거리를 적용하는 연산자 클래스입니다. 크기를 비교했습니다.

indexrelname | size
--------------+--------
docs_pkey | 672 kB
idx_bq | 10 MB
idx_full | 104 MB

10.4배 차이입니다. 벡터 자체는 32배 줄었는데 index는 10배 남짓입니다. 이 격차가 이 글에서 짚고 싶은 부분입니다.

32배와 10배 사이

차이의 이유는 HNSW index에 든 것이 벡터만이 아니라는 데 있습니다. HNSW는 계층 그래프입니다. 각 노드가 이웃으로 향하는 링크를 들고 있고, 그 개수를 m 파라미터가 정합니다. 위 실험에서는 m=16이었습니다.

링크는 양자화 대상이 아닙니다. 벡터를 96바이트로 줄여도 링크는 그대로입니다. 그래서 압축률이 벡터 크기 비율보다 낮게 나옵니다.

원문 수치로 확인해 봐도 같은 방향입니다. 367GB에서 38GB는 약 9.7배입니다. AWS가 "32배 압축"이라고 표현한 것은 벡터 데이터 자체의 비율이고, index 전체는 10배 안쪽입니다. 용량 계획을 세울 때 32배로 잡으면 어긋납니다.

원문이 준 벡터당 크기도 이 관점에서 읽힙니다. 1536차원에서 벡터당 약 680바이트, 768차원에서 약 400바이트입니다. 768비트는 96바이트니까 나머지 300바이트 정도가 그래프 구조입니다.

재순위화가 없으면 반쪽이다

이진 양자화만으로 검색을 끝내면 정확도가 떨어집니다. 부호만 봤으니 당연합니다. 그래서 두 단계로 나눕니다. 양자화된 index로 후보를 넉넉히 뽑고, 그 후보들만 원본 벡터로 다시 정렬합니다.

원문이 제시한 쿼리 모양입니다.

SELECT * FROM (
SELECT id, embedding <=> '[query_vector]'::vector AS exact_distance
FROM your_table
ORDER BY binary_quantize(embedding)::bit(768) <~>
binary_quantize('[query_vector]'::vector)::bit(768)
LIMIT 200
) candidates
ORDER BY exact_distance
LIMIT 10;

연산자 두 개가 각각 다른 일을 합니다. <~>가 bit 타입의 Hamming 거리로 후보 200개를 고릅니다. <=>가 원본 vector의 코사인 거리로 그 200개를 다시 정렬합니다. 최종 10개는 원본 정밀도로 판단된 결과입니다.

이 구조에서 후보 개수가 정확도와 지연의 조절 손잡이입니다. 200개를 뽑으면 100개보다 정확하고 느립니다.

수치는 어디까지 좋아졌나

원문이 낸 측정치 중 눈에 띄는 것들입니다.

OpenAI 임베딩 500만 개(1536차원)를 r8g.large에서 돌린 결과입니다. 이진 양자화 HNSW가 recall 0.951에 138 QPS, p99 지연 18.9ms였습니다. 원본 정밀도 HNSW는 78 QPS에 p99 1,356ms였습니다. 지연 차이가 70배 넘게 벌어집니다.

LAION 1억 개(768차원)를 r8g.4xlarge에서 cold cache로 돌린 쪽이 더 극적입니다. 이진 양자화가 recall 0.931에 13.5 QPS, 원본 정밀도가 3.4 QPS입니다. 원문은 원본 정밀도 index가 메모리에 들어가지 않았다고 적었습니다. 이 한 줄이 사실 이 기법의 존재 이유입니다. 압축 자체가 목적이 아니라, index를 buffer cache 안에 들여놓는 것이 목적입니다.

index 빌드 시간도 1.1시간 대 16.1시간으로 갈렸습니다.

빌드 설정은 이렇게 잡았습니다.

maintenance_work_mem = 96GB
max_parallel_maintenance_workers = 48

쿼리 쪽 설정입니다.

SET hnsw.ef_search = 800;
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 1400;

hnsw.iterative_scan이 여기서 역할이 있습니다. ef_search의 후보 한계인 1,000개를 넘겨 재순위화하려면 이 설정이 필요합니다.

요구 사항은 Aurora PostgreSQL 16.8 이상 또는 17.x이고 pgvector 0.8.0 이상입니다.

제 실측의 한계

위에서 크기는 재 봤지만 recall은 재지 않았습니다. 이유를 밝혀 두는 게 맞습니다.

제가 넣은 벡터는 random()-0.5로 만든 균등 난수입니다. 실제 임베딩과 분포가 다릅니다. 임베딩은 의미가 비슷한 것끼리 방향이 모이는 구조인데, 균등 난수는 모든 벡터가 서로 비슷하게 멀리 있습니다. 이런 데이터에서 recall을 재면 이진 양자화의 정보 손실이 실제와 다르게 나옵니다.

index 크기는 데이터 분포와 무관한 구조적 값이라 그대로 쓸 수 있고, 정확도는 실제 임베딩으로 재야 의미가 있습니다. 그 부분은 원문 수치를 인용하는 쪽이 정직합니다.

언제 쓸까

기준이 비교적 선명합니다. index가 메모리에 들어가느냐입니다.

들어가는 규모라면 이진 양자화의 이득이 작습니다. 원본 정밀도 HNSW가 이미 충분히 빠르고, 재순위화 단계가 붙지 않아 쿼리도 단순합니다.

들어가지 않는 규모라면 계산이 완전히 달라집니다. index를 디스크에서 읽는 순간 지연이 자리수 단위로 뛰기 때문에, recall을 0.95 근처로 유지하면서 index를 10분의 1로 줄이는 거래가 압도적으로 유리해집니다. 위 LAION 사례에서 QPS가 4배 차이 난 것이 그 지점입니다.

AlloyDB가 AI 에이전트에 문을 연 이야기를 다룰 때도 느낀 건데, 클라우드 3사가 벡터 쪽에서 경쟁하는 방향이 새 엔진이 아니라 PostgreSQL 위에 얹는 최적화로 모이고 있어요. 이번 것도 pgvector의 기존 기능을 조합한 결과입니다. binary_quantizebit_hamming_ops는 pgvector 0.8.0부터 있던 것이고, AWS가 한 일은 그것을 1억 벡터 규모에서 검증하고 설정값을 찾은 것입니다.

참고

pg_walviz WAL 시각화

· 약 3분

WAL을 공부할 때 제일 답답한 건 실체가 안 보인다는 점이었어요. record가 페이지 경계에서 쪼개진다, 세그먼트 첫 페이지에는 long header가 붙는다, 이런 문장을 문서로는 읽는데 실제 바이트가 어떻게 놓이는지는 상상에 맡겨야 했거든요.

Bertrand Drouvot(베르트랑 드루보)가 8월 13일 공개한 pg_walviz가 정확히 그 지점을 채웁니다. WAL 세그먼트 파일 하나를 읽어 브라우저에서 시각화하는 읽기 전용 도구입니다. 현재 버전은 v0.1.0-beta.1이고 저장소는 GitHub에 있습니다.

무엇을 보여주나

화면은 서로 동기화되는 네 개의 뷰로 구성됩니다.

내용
세그먼트 개요히트맵. resource manager별로 record가 세그먼트 어디에 몰려 있는지
record 조각 목록선택한 페이지에 걸친 record들, 페이지 경계에서 쪼개진 조각 포함
record 검사기선택한 record의 헤더, 물리 배치, full-page image 정보
물리 바이트색으로 구분된 원본 바이트열

히트맵에서 눈에 띄는 영역을 클릭하면 그 페이지의 record 목록이 뜨고, record를 고르면 헤더 필드와 실제 바이트가 같이 하이라이트됩니다. 페이지 번호, record 번호, 파일 오프셋, LSN을 직접 입력해서 이동할 수도 있습니다.

pg_waldump와 겹치는 도구가 아니냐는 생각이 들 수 있는데, 역할이 다릅니다. pg_waldump는 record의 논리적 내용을 사람이 읽을 텍스트로 풀어 줍니다. 어떤 rmgr가 어떤 연산을 기록했는지 보기에 좋습니다. pg_walviz는 record가 세그먼트 안에 물리적으로 어떻게 저장되는지를 보여줍니다. 페이지 헤더, record 조각, continuation record, 정렬 padding, 블록 참조가 바이트 위에 그대로 표시됩니다. 실제로 pg_walviz는 내부적으로 pg_waldump를 사용하므로 두 도구는 상하 관계에 가깝습니다.

실행 방법

PostgreSQL 서버도, 데이터 디렉터리도 필요 없습니다. 세그먼트 파일 하나와 그 파일을 만든 서버 버전에 맞는 pg_waldump 바이너리만 있으면 됩니다.

~/pg_walviz/bin/pg_walviz \
--pg-waldump /usr/pgsql-18/bin/pg_waldump \
/archive/000000010000000000000042

실행하면 로컬 웹서버가 뜨고 브라우저가 열립니다. 원격 서버에서 실행할 때는 브라우저 자동 실행을 끄고 포트를 지정합니다.

~/pg_walviz/bin/pg_walviz --no-open --port 8765 \
--pg-waldump /usr/pgsql-18/bin/pg_waldump \
/archive/000000010000000000000042

주의사항이 둘 있습니다. 현재 쓰기가 진행 중인 세그먼트는 열지 말라는 것, 그리고 WAL에는 실데이터가 들어 있으므로 로컬 밖으로 노출하지 말라는 것입니다. 특히 두 번째는 archive에서 세그먼트를 복사해 분석용 장비에서 여는 습관과 묶어 기억해 둘 만합니다. WAL은 INSERT된 행의 내용을 그대로 담고 있어서, 세그먼트 파일 하나가 곧 데이터 유출 경로가 됩니다.

어디에 쓸 만한가

첫째는 학습입니다. WAL record 헤더의 xl_prev가 무엇인지, full-page image가 왜 checkpoint 직후에 몰리는지 같은 주제는 글로 읽는 것보다 히트맵에서 직접 확인하는 쪽이 빠릅니다. checkpoint 직후 세그먼트를 열어 보면 FPI가 차지하는 공간이 시각적으로 드러나고, full_page_writes가 WAL 볼륨에 미치는 영향이 감으로 잡힙니다.

둘째는 장애 분석의 보조 도구입니다. 예전에 standby가 record with incorrect prev-link를 반복하며 멈춘 사건을 분석할 때는 pg_waldump 출력과 오프셋 계산을 손으로 맞춰 가며 recycled 세그먼트의 잔재를 추론했습니다. 그때 이 도구가 있었다면 문제 지점의 바이트를 바로 눈으로 확인했을 겁니다. 세그먼트 경계의 long page header(40바이트)와 첫 record의 위치 같은 것들이 화면에 그대로 보이니까요.

PostgreSQL 내부를 3D 도시로 만든 PGSimCity가 buffer와 프로세스를 보여주는 조감도였다면, pg_walviz는 WAL이라는 한 지점을 현미경으로 파는 도구입니다. 아직 beta라 큰 세그먼트에서 로딩이 느리고 단일 파일 검사가 권장되는 수준이지만, 방향이 좋습니다.

archive에 쌓여 있는 세그먼트 하나 골라서 열어 보세요. 문서 열 페이지보다 히트맵 한 화면이 WAL 구조를 빨리 가르쳐 줄 거예요.

참고 자료

PostgreSQL 18.6 보안

· 약 5분

이번 분기 마이너 릴리스는 조용히 지나갈 수 없는 규모예요. 8월 13일 공지로 PostgreSQL 18.6, 17.11, 16.15, 15.19, 14.24가 한꺼번에 나왔는데, 보안 취약점 수정이 28건입니다. 통상 마이너 릴리스의 보안 수정이 손에 꼽는 수준인 걸 생각하면 이례적인 숫자입니다.

버전 번호부터 눈에 걸립니다. 18 계열의 직전 버전은 18.4인데 이번이 18.6입니다. 18.5는 릴리스 준비 중 회귀(regression)가 발견되어 배포 없이 결번 처리됐습니다. 18.4에서 바로 18.6으로 올라가면 되고, 중간에 놓친 버전은 없습니다.

CVSS 8점대만 12건

28건 전체 중 CVSS 8.0 이상이 12건입니다. 성격별로 묶으면 그림이 보입니다.

계열대표 CVECVSS내용
클라이언트 도구CVE-2026-64648.1psql COPY FROM STDIN, 데이터 줄을 psql 명령으로 처리
클라이언트 도구CVE-2026-184088.8psql \unrestrict, 조작된 덤프 원본에서 임의 코드 실행
클라이언트 도구CVE-2026-193858.8pg_dump heap buffer overflow
heap overflowCVE-2026-146648.8regexp 처리, 임의 코드 실행 가능
heap overflowCVE-2026-146698.8to_char()
heap overflowCVE-2026-146768.8pg_stat_statements
type confusionCVE-2026-146718.8refint plan cache
type confusionCVE-2026-162388.8pg_restore_attribute_stats()
type confusionCVE-2026-162398.8cursor CLOSE + DECLARE
type confusionCVE-2026-146808.8"internal" 인자 처리
기타CVE-2026-146628.8tsvector/tsquery integer wraparound
기타CVE-2026-157428.8fuzzystrmatch, 임의 주소 기록

이 중 DBA가 특히 무겁게 볼 것은 psql 계열입니다. 나머지는 대체로 "DB에 로그인한 공격자가 권한을 넘어서는" 유형이라 접속 통제가 1차 방어선이 되지만, psql 취약점은 방향이 반대입니다. 신뢰할 수 없는 덤프나 SQL 파일을 psql로 읽어들이는 쪽이 피해자가 됩니다.

CVE-2026-6464는 COPY FROM STDIN 처리 중 초기 실패가 발생하면 뒤따르는 데이터 줄을 psql 명령으로 해석하는 문제입니다. 누군가 건네준 덤프 파일을 복원하는 일상 작업이 곧 공격 경로입니다. CVE-2026-18408도 같은 결로, pg_dump 출력에 포함되는 \unrestrict 처리를 악용하면 덤프를 만든 쪽(superuser 권한으로 조작된 서버)이 복원하는 쪽 클라이언트에서 임의 코드를 실행할 수 있습니다. 외부에서 받은 덤프를 복원할 일이 있는 조직이라면 클라이언트 패키지 업데이트를 서버만큼 서둘러야 합니다.

낮은 점수 중에도 운영에 직접 닿는 항목이 있습니다. CVE-2026-14663은 pgcrypto에서 비활성화된 cipher로 암복호화를 요청하면 조용히 평문으로 처리하던 문제입니다. 점수는 6.5지만 "암호화됐다고 믿었는데 평문이었다"는 유형이라 감사 관점에서는 확인해 볼 가치가 있습니다. CVE-2026-14672는 SCRAM 인증에서 scram_iterations 응답 차이로 계정 존재 여부가 노출되는 문제입니다.

업데이트만으로 끝나지 않는 세 가지

이번 릴리스의 함정은 바이너리 교체 후에도 남는 숙제입니다. 릴리스 노트가 세 가지 후속 조치를 명시합니다.

첫째, parallel GIN index build를 쓴 적이 있다면 reltuples 오염을 확인합니다. PostgreSQL 14, 15, 16, 18에서 parallel worker가 초기화되지 않은 row count를 보고해 pg_class.reltuples가 Infinity나 NaN이 되는 버그가 있었습니다. 이 상태가 되면 autovacuum과 autoanalyze가 해당 테이블을 건너뛰고, 자연적으로는 복구되지 않습니다. GIN 인덱스를 가진 테이블을 이 쿼리로 확인합니다.

SELECT DISTINCT t.oid::regclass, t.reltuples
FROM pg_class t
JOIN pg_index i ON t.oid = i.indrelid
JOIN pg_class ic ON i.indexrelid = ic.oid
WHERE t.relhasindex AND ic.relam = 2742; -- 2742 = gin

reltuples가 Infinity, NaN, 혹은 말이 안 되는 값이면 해당 테이블에 ANALYZE를 실행합니다. vacuum이 안 도는 테이블은 wraparound 위험까지 이어지니, GIN을 쓰는 클러스터라면 이 확인을 빼먹지 않는 게 좋습니다.

둘째, btree_gist 인덱스의 REINDEX입니다. float4/float8 컬럼에서 NaN 처리, bit/bit varying 컬럼에서 정렬이 잘못되던 문제가 고쳐졌습니다. 해당 타입 컬럼에 btree_gist 인덱스가 있다면 REINDEX가 필요합니다.

셋째, 큰 ltree 값의 인덱스도 REINDEX 대상입니다. 약 14,653개 이상의 label을 가진 ltree 값에서 비교 연산이 틀려 B-tree 인덱스가 손상될 수 있었습니다. 그 정도 깊이의 ltree를 쓰는 곳은 드물겠지만, 해당된다면 REINDEX 대상입니다.

이 밖에 버그 수정 중에는 standby가 구버전 마이너의 WAL을 재생하다 멈추는 deadlock 수정(14~16), DEFAULT partition이 pruning에서 잘못 제외되던 문제, REINDEX CONCURRENTLY와 deferred unique constraint 조합 오류 같은 굵직한 것들이 포함됐습니다. 전체 목록은 릴리스 노트에 있습니다.

19 Beta 3와 14 EOL

같은 날 PostgreSQL 19 Beta 3도 나왔습니다. 눈에 띄는 변화는 GROUP BY ALL 문법이 revert 된 것입니다. Beta 1에 들어왔던 편의 문법인데 최종 릴리스 전에 빠졌습니다. 그 외 FOR PORTION OF(temporal 문법) 수정 다수, logical replication의 sequence 동기화 race condition 수정 등이 반영됐습니다. 19 신기능을 검증 중이라면 Beta 3 기준으로 다시 확인하는 게 안전합니다.

그리고 PostgreSQL 14의 지원 종료가 2026년 11월 12일로 예고됐습니다. 이번 14.24를 포함해 앞으로 한 번의 릴리스만 남았습니다. 아직 14로 운영 중인 클러스터는 마이그레이션 일정을 지금 세워야 합니다.

지금 해야 할 일

우선순위로 정리하면 이렇습니다.

  1. 서버 바이너리 업데이트 (마이너 업데이트라 dump/reload 불필요, 재시작만)
  2. 클라이언트 패키지(psql, pg_dump)도 같이 업데이트 - 이번 릴리스에서는 클라이언트가 서버만큼 급합니다
  3. GIN 인덱스 테이블의 reltuples 확인, 이상 시 ANALYZE
  4. btree_gist(float/bit 컬럼), 깊은 ltree 인덱스 REINDEX
  5. PostgreSQL 14 사용 중이면 업그레이드 계획 수립

28건이라는 숫자에 놀랐지만, 뒤집어 보면 fuzzing과 코드 감사가 코어에 촘촘히 들어가고 있다는 뜻이기도 합니다. 저는 클라이언트 도구가 공격면이 되는 흐름이 이번 릴리스의 진짜 교훈이라고 봐요. 서버는 잘 잠가 두는데 psql은 아무 파일이나 여는 습관, 이번 기회에 같이 고치면 좋겠습니다.

참고 자료

subtransaction 64개 한계

· 약 5분

SAVEPOINT는 안전장치처럼 생겼어요. 트랜잭션 중간에 표시를 남기고, 일부만 되돌릴 수 있게 해 주는 기능이니까요. 그런데 이 안전장치를 한 트랜잭션에서 64번 넘게 쓰는 순간, 그 트랜잭션이 아니라 클러스터 전체가 절벽에서 떨어집니다.

PlanetScale이 8월 11일에 공개한 분석이 이 경로를 실측으로 보여줬습니다. 동일한 INSERT/SELECT/DELETE 워크로드가 평소 약 7,200 TPS(지연 약 2ms)로 돌다가, 어느 한 트랜잭션이 subtransaction 64개를 넘긴 순간 약 160 TPS(지연 약 90ms)로 떨어졌습니다. 45배 하락입니다. 문제의 트랜잭션이 끝나자 처리량은 원래대로 돌아왔습니다.

이 글은 해당 분석을 따라가면서, 왜 남의 트랜잭션 하나가 내 쿼리를 느리게 만드는지 경로를 짚고, 마지막에 원문에는 없는 우리 클러스터 점검용 진단 쿼리를 붙입니다.

subtransaction은 어디서 생기나

subtransaction은 최상위 트랜잭션 안에 중첩된 트랜잭션입니다. 각 subtransaction은 자기만의 transaction ID(subxid)를 받습니다. 만드는 경로는 두 가지입니다.

명시적 경로는 SAVEPOINT입니다.

BEGIN;
SAVEPOINT s1; -- subtransaction 1
INSERT INTO orders ...;
SAVEPOINT s2; -- subtransaction 2
UPDATE stock ...;
COMMIT;

암묵적 경로가 더 위험합니다. PL/pgSQL에서 EXCEPTION 절이 있는 블록은 진입할 때마다 subtransaction을 하나 만듭니다. "혹시 실패하면 이 블록만 되돌린다"를 구현하는 방법이 내부적으로 SAVEPOINT와 같기 때문입니다.

-- 이 루프는 반복마다 subtransaction을 하나씩 만든다
FOR i IN 1..70 LOOP
BEGIN
INSERT INTO audit_log VALUES (...);
EXCEPTION WHEN unique_violation THEN
NULL; -- 무시하고 계속
END;
END LOOP;
-- 70개 생성, 64개 한계 초과

ORM도 조용히 만듭니다. Django의 transaction.atomic()을 중첩하면 안쪽은 SAVEPOINT가 되고, JPA/Hibernate에서 REQUIRES_NEW가 아닌 중첩 트랜잭션 전파, Rails의 중첩 transaction 블록(requires_new: true)도 같습니다. 애플리케이션 코드에는 SAVEPOINT라는 글자가 한 번도 안 나오는데 DB에는 수십 개씩 쌓이는 상황이 흔합니다.

64개 한계와 pg_subtrans

각 backend의 PGPROC 구조체에는 subtransaction ID를 담는 캐시가 있고, 크기가 PGPROC_MAX_CACHED_SUBXIDS, 즉 64개입니다. 이 안에 다 들어가는 동안에는 다른 세션이 MVCC visibility를 판단할 때 공유 메모리만 보면 됩니다. 빠릅니다.

65개째부터 이 캐시는 overflow 상태로 표시됩니다. 이제 다른 세션들은 어떤 subxid가 어느 최상위 트랜잭션에 속하는지 공유 메모리에서 알 수 없고, 디스크 기반 구조인 pg_subtrans를 조회해야 합니다. pg_subtrans는 SLRU(simple least-recently-used) 캐시를 통해 접근하는데, 이 조회는 lightweight lock을 잡고 필요하면 디스크 I/O까지 발생시킵니다.

핵심은 비용을 치르는 쪽이 subtransaction을 만든 트랜잭션이 아니라는 점입니다. overflow가 켜져 있는 동안 다른 모든 세션이 자기 snapshot으로 튜플 가시성을 판단할 때마다(XidInMVCCSnapshot) pg_subtrans SLRU lock을 두고 경합합니다. PlanetScale 실측에서 병목으로 지목된 지점이 정확히 이 lock입니다.

overflow 상태는 문제의 트랜잭션이 끝날 때까지 유지됩니다. 그리고 얼마나 오래 가는지는 그 트랜잭션이 아니라, 클러스터에서 가장 오래 돌고 있는 트랜잭션의 수명에 좌우됩니다. long-running transaction이 있으면 그만큼 고통이 길어집니다.

hot standby까지 번지는 이유

이 문제의 두 번째 얼굴이 더 고약합니다. overflow가 발생한 시점에 WAL로 기록되는 RUNNING_XACTS 레코드에 "subxid overflowed" 표시가 함께 남습니다. 활성 subtransaction 목록을 다 담지 못했다는 뜻입니다.

새로 배포한 standby가 이 오염된 RUNNING_XACTS를 읽으면, 일관된 snapshot을 만들 수 없다고 판단하고 hot standby 활성화를 보류합니다. WAL은 계속 재생하는데 읽기 쿼리는 받지 않는 상태, 접속하면 이런 메시지만 돌아옵니다.

FATAL: the database system is not yet accepting connections
DETAIL: Recovery snapshot is not yet ready for hot standby.

pg_subtrans를 primary에서 넘겨받으면 되지 않느냐는 생각이 들지만, pg_subtrans 변경은 WAL에 기록되지 않고, standby는 기동 시 활성 페이지를 0으로 초기화하기 때문에 로컬 데이터도 신뢰할 수 없습니다. standby가 이 상태를 벗어나는 조건은 overflow 되지 않은 새 RUNNING_XACTS 레코드가 도착하거나, primary의 shutdown checkpoint를 재생하거나, 누락 가능성이 있는 트랜잭션들이 전부 끝나는 것입니다.

운영 관점에서 시나리오를 조합하면 이렇게 됩니다. 부하가 튀어서 읽기 용량을 늘리려고 replica를 새로 붙였는데, 하필 그 부하를 만들던 워크로드가 subtransaction overflow를 일으키는 중이라면, 새 replica는 WAL만 재생하며 접속을 거부합니다. 용량을 더하려고 만든 노드가 용량이 되어 주지 않는 역설입니다.

지금 확인할 것들

여기부터는 원문 내용에 우리가 운영에서 바로 실행할 쿼리를 더해 정리합니다.

먼저 지금 이 순간 subtransaction을 쌓고 있는 backend가 있는지 확인합니다. PostgreSQL 14부터 pg_stat_get_backend_subxact()로 backend별 subxact 개수와 overflow 여부를 볼 수 있습니다.

SELECT pg_stat_get_backend_pid(id) AS pid, s.subxact_count, s.subxact_overflowed
FROM pg_stat_get_backend_idset() AS id
JOIN LATERAL pg_stat_get_backend_subxact(id) AS s ON true
WHERE s.subxact_count > 0
ORDER BY s.subxact_count DESC;

subxact_overflowed = true인 행이 하나라도 보이면 그 순간 클러스터 전체가 pg_subtrans 조회 경로로 동작 중이라는 뜻입니다. pg_stat_activity와 조인하면 어떤 애플리케이션, 어떤 쿼리인지까지 특정됩니다.

과거에 겪었는지 의심된다면 SLRU 통계를 봅니다.

SELECT name, blks_hit, blks_read, blks_written, stats_reset
FROM pg_stat_slru
WHERE name = 'subtransaction';

평상시 이 카운터는 거의 움직이지 않습니다. blks_hit가 급증한 구간이 있었다면 overflow가 있었다는 강한 정황입니다. 그래프 도구에 이 두 컬럼을 올려 두면 사후 추적이 쉬워집니다.

가장 오래된 트랜잭션 나이도 함께 감시합니다. overflow의 지속 시간을 결정하는 값입니다.

SELECT max(now() - xact_start) AS oldest_tx
FROM pg_stat_activity
WHERE xact_start IS NOT NULL;

예방책은 소박합니다.

대상조치
PL/pgSQL 루프 안 EXCEPTION 블록루프 밖으로 빼거나, 예외를 안 쓰는 로직(ON CONFLICT 등)으로 전환
ORM 중첩 트랜잭션중첩 atomic()/transaction 블록이 정말 필요한지 검토
long-running transactiontransaction_timeout(17+), idle_in_transaction_session_timeout 설정
상시 감시pg_stat_get_backend_subxact() + pg_stat_slru 지표화

코어 쪽 근본 해법으로는 PGPROC_MAX_CACHED_SUBXIDS를 늘려 재컴파일하는 단기 우회(공유 메모리 비용 증가)와, snapshot을 CSN(Commit Sequence Number) 기반으로 바꾸는 장기 방향이 거론됩니다. CSN 논의는 10년 넘게 이어지고 있지만 아직 본가에 병합되지 않았습니다.

64라는 숫자를 기억해 두면 좋겠어요. SAVEPOINT와 EXCEPTION 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.

참고 자료

cascading 복제 버그

· 약 4분

"requested starting point ... is ahead of the WAL flush position of this server". 이 오류를 cascading standby에서 본 적이 있다면, 그건 설정 실수가 아니었을 수도 있어요. 2013년 PostgreSQL 9.3부터 잠복해 있던 버그였습니다.

CloudNativePG 창립자 Gabriele Bartolini(가브리엘레 바르톨리니)가 8월 3일 EDB 블로그에 공개한 글이 이 버그의 발견부터 수정까지를 담고 있습니다. 발견 경위가 흥미롭습니다. 코어 해커의 코드 감사가 아니라, Kubernetes 위에서 분산 토폴로지를 선언적으로 돌리다가 드러났습니다.

증상: 재시도 없는 무한 대기

구성은 cascading replication입니다. primary에서 standby A가 받고, standby B는 A에게서 받는 구조로, 다중 리전 토폴로지에서 흔한 형태입니다.

standby B가 어떤 이유로든 streaming이 끊겨 archive recovery로 폴백했다가, WAL을 따라잡고 다시 upstream(A)으로 streaming 접속을 시도하는 순간이 문제의 무대입니다. 특정 조건에서 A가 접속을 이렇게 거부합니다.

FATAL: requested starting point 0/A000000 is ahead of
the WAL flush position of this server 0/9000000

그리고 B는 재시도 메커니즘 없이 이 상태에 머뭅니다. 사람이 개입할 때까지 replication이 서 있는 것입니다.

메커니즘: segment 단위 recovery와 record 단위 flush의 어긋남

원인은 StartReplication()에 2013년 추가된 timeline switch 로직입니다. 이 로직은 "요청한 시작 LSN이 내 WAL flush 위치보다 앞서면 거부한다"는 방어 검사를 합니다. 검사 자체는 합리적입니다. 아직 나에게 없는 WAL을 달라는 요청이니까요.

문제는 archive recovery의 진행 단위입니다. streaming은 record 단위로 흐르지만, archive recovery는 segment 파일 단위로 처리합니다. B가 archive에서 segment 하나를 다 소화하면, 다음 읽기 위치는 그다음 segment의 시작(예: 0/A000000)이 됩니다. 그런데 upstream A의 flush 위치는 record 단위로 진행 중이라 그 경계에 못 미쳐 있을 수 있습니다(예: 0/9000000 근처). B의 요청 위치가 A의 flush 위치를 기계적으로 앞서게 되는 것입니다.

타이밍이 정확히 맞아야 하는 race라서 13년 동안 드물게, 재현 불가능하게만 나타났습니다. 마주친 운영자는 대개 standby를 재기동하거나 재생성했을 것이고, 문제는 "가끔 이상해지는 replication"이라는 민담으로만 남았을 겁니다.

수정: 접속 전에 물어보고, 가까우면 기다린다

수정은 walreceiver 쪽에 들어갔습니다. START_REPLICATION을 보내기 전에 IDENTIFY_SYSTEM으로 upstream의 flush 위치를 먼저 확인하고, 내 요청 위치와의 차이가 WAL segment 하나 이내면 거부당할 요청을 던지는 대신 재시도합니다(wal_receiver_timeout 한도 안에서). upstream이 곧 그 지점까지 flush할 것이 확실한 상황이니, 잠깐 기다리면 자연히 풀리는 것입니다.

이 수정은 8월 13일 나온 마이너 릴리스에 포함됐습니다. EDB 글은 작성 시점 기준 18.5를 예고했지만, 18.5가 회귀로 결번되면서 실제로는 18.6, 17.11, 16.15, 15.19, 14.24에 실렸습니다. cascading 구성을 운영 중이라면 이번 마이너 업데이트를 챙길 이유가 하나 더 있는 셈입니다.

13년 만에 잡힌 이유

이 이야기에서 버그 자체보다 오래 남는 것은 발견의 조건입니다.

CloudNativePG는 primary, cascading standby, archive(오브젝트 스토리지), 리전 간 복제를 하나의 선언으로 묶어 돌립니다. 같은 토폴로지가 수천 클러스터에서 반복 생성되고 파괴되니, 확률이 낮은 race도 통계적으로 반드시 걸립니다. 게다가 구성이 코드로 고정되어 있어 "그때 그 상황"을 그대로 다시 만들 수 있습니다. 실제로 이 버그는 Docker/Kind 기반 학습 환경인 cnpg-playground에서도 재현됩니다.

수동 운영의 세계에서 이 버그는 재현 불가능한 유령이었습니다. 선언적 운영의 세계에서는 재현 가능한 테스트 케이스가 됐습니다. "Kubernetes에서 데이터베이스를 돌려도 되는가"라는 오래된 논쟁에 대한 답변으로, 오퍼레이터 진영이 내놓을 수 있는 가장 실속 있는 증거가 아닐까 싶습니다. 돌려도 되는가를 넘어, 돌렸더니 코어의 13년 묵은 버그가 잡혔다는 것이니까요.

pgBackRest 종료 이후 CNPG 쪽 흐름을 계속 지켜보고 있는데, 이 사건은 그 생태계의 성숙을 보여주는 좋은 지표로 기억해 둘 만해요.

참고 자료