콘텐츠로 이동

(KO) PostgreSQL 병렬 쿼리 — 단일 프로세스에서 병렬 조인·집계·유지보수까지

이 문서는 버전 진화(version-evolution) 문서다. PostgreSQL 주요 릴리스 전반에 걸쳐 쿼리 내 병렬 처리(intra-query parallelism) 서브시스템이 어떻게 형성됐는지를 추적하며, 현재 REL_18 상태에서 마무리한다. 메커니즘 자체 — DSM 플랜, shm_mq 튜플 큐, 워커 상태 복원, gather_readnext — 는 재유도하지 않는다. 오늘날 병렬 실행이 어떻게 동작하는지는 현재 상태 모듈 문서 postgres-parallel-query.md를 참고하라. 이 문서가 답하는 질문은 따로 있다. 각 릴리스에서 무엇이 바뀌었고, 왜 바뀌었는가.

목차

  • 이 서브시스템이 진화해야 했던 이유 — 단일 프로세스의 한계와 병렬 처리를 가능하게 한 조건.
  • 타임라인 — 9.6부터 REL_18까지의 시대, 왼쪽에서 오른쪽으로.
  • Pre-9.6 — 단일 프로세스 실행기 — 출발점과 먼저 갖춰져야 했던 인프라.
  • 9.6 — 병렬 순차 스캔과 Gather — 첫 병렬 노드, partial path 개념, 읽기 전용 계약.
  • 10 — 병렬 조인·집계·GatherMerge — Gather 아래 서브트리를 병렬화할 가치가 있도록 풍부하게; 순서 보존 병합.
  • 11 — 병렬 CREATE INDEX와 parallel-aware Append — 병렬 처리가 DDL과 파티션 스캔 레이어로 진입.
  • 12 — 플래너·비용 개선 — 병렬 안전성 분류 정교화와 partial path 생성 개선.
  • 13 — 병렬 VACUUM — 병렬 처리가 유지보수 영역에 도달.
  • 14–18 — 점진적 강화 — 리더 참여 조정, 더 많은 병렬 안전 경로, 노드 수준 확장.
  • REL_18 현재 상태 — 통합 설계와 PG19 전망 메모.
  • 출처 — 릴리스 노트, 모듈 문서, 핵심 소스 파일.

이 서브시스템이 진화해야 했던 이유 (원래의 한계)

섹션 제목: “이 서브시스템이 진화해야 했던 이유 (원래의 한계)”

PostgreSQL은 초기 약 20년 동안 모든 쿼리를 단일 백엔드 프로세스 안에서 실행했다. 클라이언트 연결 하나가 OS 프로세스 하나(postmaster가 fork)에 매핑됐고, 그 프로세스 혼자서 플랜 트리 전체를 위에서 아래로 실행했다. postgres-executor.md에서 구조적으로 설명하는 모델이 바로 이것이다. 실행기는 PlanState 노드의 demand-pull 트리이고, ExecProcNode는 단일 프로세스 안에서 그 트리를 타고 올라가며 한 번에 튜플 하나를 끌어올린다.

이 설계는 의도적이고 합리적인 선택이었다. 단일 프로세스 실행기는 추론이 단순하다. 스냅샷, 트랜잭션 상태, 잠금 집합, 메모리 컨텍스트 계층, 프로세스 간 조정 — 모두 딱 하나씩만 존재하므로 잘못될 여지가 적다. OLTP — 짧은 트랜잭션이 동시에 많이 들어오는 패턴 — 에서도 이 모델은 거의 최적에 가깝다. 서버 자체가 연결 단위로 이미 병렬이므로, 짧은 쿼리 하나는 여러 코어를 조금씩 쓰는 것보다 코어 하나를 독점하는 편이 낫다.

이 한계가 드러나는 지점은 다른 워크로드였다. 단일 큰 테이블이나 조인 위의 단일 대형 쿼리가 그것이다. 32개의 유휴 코어를 가진 머신에서 100GB 테이블에 SELECT count(*) FROM big WHERE ...를 실행한다고 생각해 보자. 단일 프로세스 실행기는 나머지 31개 코어가 노는 동안 코어 하나로 힙 전체를 스캔한다. 분석 쿼리와 리포팅 쿼리 — 대형 순차 스캔, 대형 해시 조인, 넓은 집계 — 는 단일 스레드에서 CPU 및 스캔 바운드였고, 하드웨어가 아무리 많아도 PostgreSQL은 그에 코어 하나 이상을 투입할 수 없었다.

이를 고치려면 세 가지 조건이 동시에 갖춰져야 했다. 세 조건이 한꺼번에 마련되지 않았다는 사실이, 이 진화가 한 릴리스에 완료되지 않고 여러 릴리스에 걸쳐 진행된 이유다.

  1. 트랜잭션에 귀속된 추가 프로세스에서 코드를 실행하는 방법. PostgreSQL은 스레드가 아닌 프로세스를 사용하므로, “코어를 더 쓴다”는 말은 “백엔드를 더 fork한다”는 뜻이다. 그 백엔드는 독자적인 트랜잭션을 시작하는 게 아니라 원래 트랜잭션의 가시성을 공유해야 한다. 백그라운드 워커(background worker, 9.3 도입, 9.4에서 일반화)와 동적 공유 메모리(DSM)(9.4)가 이를 가능하게 한 기본 기제다. DSM 없이는 플랜과 결과 채널을 전달할 사후 공유 영역 자체가 없었다.

  2. 프로세스 간 가시성이 일치하도록 상태를 전달하는 방법. 워커는 리더와 정확히 같은 것을 봐야 한다. 동일 스냅샷, 동일 진행 중 트랜잭션의 XID와 combo CID, 동일 GUC 설정, 동일하게 로드된 라이브러리와 사용자 신원. 이 직렬화 기계 — src/backend/access/transam/parallel.cParallelContext — 는 그 자체로 상당한 서브시스템이며, postgres-parallel-query.md에 문서화돼 있다.

  3. 언제 병렬 처리가 안전하고 더 저렴한지 아는 플래너. 부수 효과가 있는 VOLATILE 함수, 쓰기 쿼리, 워커 간에 분할할 수 없는 노드 — 이런 것들은 병렬 처리를 잘못되게 하거나 불법으로 만든다. 옵티마이저는 모든 함수와 플랜 노드의 병렬 안전성(parallel-safety) 분류가 필요했고, 워커 기동 비용과 큐를 통한 튜플 전달 비용을 아는 비용 모델이 있어야만 병렬 플랜을 선택할 수 있었다. 이 분류 작업은 릴리스마다 계속 확장됐고, 그 때문에 후기 버전이 초기 버전보다 더 많은 노드 타입을 병렬화하는 것이다.

아래 이야기의 흐름은 이 세 역량이 성숙해 가는 과정이다. 배관 공사와 단일 병렬 노드(9.6), 병렬화할 만한 풍부한 서브트리(10), DDL과 파티션 인식 병렬 처리(11), 유지보수(13), 그리고 플래너가 병렬로 실행하려 하고 실행할 수 있는 작업의 범위를 넓혀 나간 수년간의 점진적 확장.


timeline
    title PostgreSQL 쿼리 내 병렬 처리 — 진화
    section Pre-9.6 (≤2016)
        단일 프로세스 실행기 : 백엔드 하나가 전체 플랜 실행
                            : DSM (9.4) + bgworkers (9.3/9.4) 기반 인프라 마련
    section 9.6 (2016)
        첫 번째 병렬 노드 : 병렬 Seq Scan
                         : Gather 노드 + 워커별 튜플 큐
                         : Partial path, 읽기 전용 병렬 모드
    section 10 (2017)
        풍부한 병렬 서브트리 : 병렬 해시 / 머지 / 중첩 루프 조인
                            : Partial + Finalize 집계
                            : GatherMerge로 정렬 순서 보존
                            : 병렬 비트맵 힙 스캔, 병렬 인덱스 스캔
    section 11 (2018)
        DDL과 파티션의 병렬 처리 : 병렬 CREATE INDEX (btree)
                                  : 파티션 위 parallel-aware Append
                                  : Parallel Hash (공유 해시 테이블)
    section 12 (2019)
        플래너 개선 : 병렬 안전성 분류 정교화
                   : Partial path 생성 개선
    section 13 (2020)
        유지보수의 병렬 처리 : 병렬 VACUUM (인덱스 병렬 처리)
                             : 병렬 CREATE INDEX 비용 조정
    section 14-18 (2021-2025)
        점진적 강화 : 리더 참여 조정
                   : 더 많은 병렬 안전 함수 및 경로
                   : 노드 수준 확장, REL_18 현재 상태

Pre-9.6 — 단일 프로세스 실행기 (출발점)

섹션 제목: “Pre-9.6 — 단일 프로세스 실행기 (출발점)”

9.6 이전에는 쿼리 내 병렬 처리가 전혀 없었다. 이후 시대마다 이 출발점과의 차이를 이야기하는 만큼, 출발점을 정확히 짚어 두는 것이 중요하다.

실행이 어떻게 이뤄졌나. postmaster는 연결마다 백엔드를 하나씩 fork했다. 그 백엔드가 쿼리를 파싱·플래닝·실행했다. 실행은 postgres-executor.md에 여전히 설명된 demand-pull PlanState 트리다. ExecutePlan이 반복적으로 최상위 노드에 ExecProcNode를 호출하고, 노드는 재귀적으로 자식에게 튜플을 끌어올린다. 10억 행 테이블 위의 Seq Scan은 그 행들을 코어 하나에서 한 번에 하나씩 반환했고, 그 동안 나머지 코어는 모두 유휴 상태였다.

왜 단순히 스레드를 쓸 수 없었나. PostgreSQL의 프로세스-per-백엔드 모델은 공유 주소 공간이 없으므로 나이브한 병렬 처리가 불가능했다. 두 가지 설계 사실이 이를 막았다.

  • 공유 메모리는 postmaster 기동 시 고정됐다. 주 공유 메모리 세그먼트(버퍼, 잠금, ProcArray)는 한 번 크기가 정해지면 늘어나지 않았다. 실행 중인 백엔드가 새로운 공유 영역을 만들어 헬퍼 프로세스에 전달하고 쿼리 종료 시 해제하는 일반적 메커니즘이 없었다.
  • 트랜잭션 상태는 프로세스 로컬 메모리에 있었다. 활성 스냅샷, 현재 XID, combo CID, 리소스 오너 트리 — 모두 프로세스별 데이터였다. 새로 fork된 헬퍼 프로세스는 다른 가시성을 계산해 결과를 오염시킬 것이었다.

기반 배관 공사 (9.3–9.4). 첫 번째 문제의 해결책은 병렬 쿼리 이전에 독립 인프라로 도착했다.

  • 백그라운드 워커 (9.3 도입, 9.4에서 일반화): postmaster가 관리하되 특정 백엔드의 생존 기간에 귀속된 추가 프로세스를 등록·기동하는 방법.
  • 동적 공유 메모리 / DSM (9.4): 백엔드가 기동 후에 공유 세그먼트를 만들고, 협력 프로세스가 이에 접근하며, 완료 시 해제할 수 있다. 모든 병렬 플랜이 올라타는 기반이다.
  • 공유 메모리 메시지 큐(shm_mq)목차(table-of-contents, shm_toc) 레이아웃 헬퍼: DSM 위의 단일 읽기/단일 쓰기 바이트 파이프, 그리고 프로세스들이 알려진 키로 하나의 세그먼트 안에서 하위 구조를 찾도록 하는 키-디렉터리.

이 모두는 아직 병렬 쿼리가 아니었다. 활주로였다. 9.6이 그 위를 날기 시작했다.

교차 참조. 이 시대가 확립한 구조 모델 — PlanState 트리, ExecProcNode, 단일 등록 스냅샷 — 은 postgres-executor.md에서 기준선으로 설명한다. 병렬 쿼리는 이를 대체하지 않는다. 프로세스 간에 복제한다. 각 워커는 본질적으로 동일한 트리의 자체 복사본을 실행한다.


9.6 — 병렬 순차 스캔과 Gather (첫 번째 병렬 노드)

섹션 제목: “9.6 — 병렬 순차 스캔과 Gather (첫 번째 병렬 노드)”

PostgreSQL 9.6은 처음으로 쿼리 내 병렬 처리를 도입했다. 이 릴리스는 postgres-parallel-query.md의 모든 것이 탄생한 기원이다. Gather 노드, DSM 직렬화 플랜, 워커별 튜플 큐, 읽기 전용 병렬 모드 계약이 모두 여기서 시작됐다.

세 가지 새로운 개념이 함께 도입됐다. 오늘날에도 핵심 하중을 지탱하는 것들이다.

  1. Gather 노드. partial plan 위에 놓이는 새 실행기 노드다. 실행 시 N개의 백그라운드 워커를 기동하고, 각 워커는 아래 서브트리의 복사본을 실행하며, 워커들의 출력 튜플을 단일 스트림으로 모아 부모에게 전달한다. Gather는 아래의 “여러 프로세스”와 위의 “하나의 프로세스” 사이의 경계다. 현재 구현은 src/backend/executor/nodeGather.c에 있다.

  2. Partial path와 병렬 인식 서브트리. 플래너는 partial path 개념을 얻었다. N개의 워커가 동시에 실행할 때 중복 없이 집합적으로 전체 결과를 생성하는 경로다. 최초의 표준 예시는 병렬 Seq Scan이다. 각 워커가 힙의 서로 겹치지 않는 블록 범위를 가져가므로(스캔 디스크립터의 공유 상태로 조율), 모든 워커 출력의 합집합이 하나의 전체 스캔과 같다. 플래너는 이를 지금의 src/backend/optimizer/path/allpaths.c에 있는 partial path 기계로 처리한다.

  3. DSM 기반 플랜과 튜플 큐. 첫 실행에서 Gather는 플랜을 DSM 세그먼트로 직렬화하고, postmaster가 워커를 fork하며, 각 워커는 세그먼트에 접근해 리더의 트랜잭션 상태를 복원하여 가시성을 맞추고, 자신의 실행기 복사본을 실행하고, MinimalTuple을 워커별 shm_mq 튜플 큐에 쓴다. 리더는 그 큐들을 드레인한다. postgres-parallel-query.md에 자세히 설명된 ExecInitParallelPlan / ParallelWorkerMain / gather_readnext 흐름이 바로 이것이다.

동기는 앞 절의 한계에서 나왔다. 대형 스캔이 코어 하나에 묶여 있었다. 병렬 순차 스캔은 가장 단순한 성과다. 힙 블록은 독립적이므로, 블록 범위를 워커 간에 나누는 데 “다음 블록” 카운터 공유 외에 다른 데이터 교환이 필요 없다. 새 배관 공사 전체를 단 대 단으로 테스트하면서도 조율을 최소화할 수 있는 첫 번째 표적으로 딱 맞았다.

flowchart TB
    subgraph before["9.6 이전 — 단일 프로세스"]
        A1["Aggregate count(*)"] --> A2["Seq Scan big"]
        note1["백엔드 하나가\n모든 블록을\n코어 하나에서 스캔"]
    end

    subgraph after["9.6 — Gather + 병렬 Seq Scan"]
        B1["Aggregate count(*)\n(리더, finalize)"] --> B2["Gather\n리더가 튜플 수집"]
        B2 --> B3["Partial Aggregate\n워커 1"]
        B2 --> B4["Partial Aggregate\n워커 2"]
        B2 --> B5["Partial Aggregate\n리더"]
        B3 --> B6["병렬 Seq Scan\n분리된 블록 범위"]
        B4 --> B7["병렬 Seq Scan\n분리된 블록 범위"]
        B5 --> B8["병렬 Seq Scan\n분리된 블록 범위"]
    end

핵심 형태 변화: Gather 노드가 트리에 삽입되고, 그 아래 서브트리가 병렬 인식(여러 동시 복사본이 스캔 공유)이 된다. Gather 위는 이전과 동일하게 단일 프로세스다. 9.6에서도 count(*)는 이미 Gather 아래의 partial 집계와 위의 final 집계를 필요로 했다. 다만 9.6의 집계 지원은 제한적이었고, 풍부한 partial/finalize 분리는 PG10에서 완성됐다.

9.6은 병렬 모드를 엄격한 읽기 전용으로 만들었다. EnterParallelMode를 호출해 리더와 워커 모두 새로운 XID 할당이나 쓰기를 금지한다. 쓰기를 수행하거나, 병렬 안전하지 않은 함수를 호출하거나, 플래너가 추론할 수 없는 구조를 사용하는 쿼리는 그냥 직렬로 실행된다. 이 보수적 계약 — 증명 가능하게 안전한 것만 병렬화한다 — 은 이후 진화가 가장자리를 서서히 완화하더라도(더 많은 함수와 노드 타입이 안전하다고 증명됨) 핵심 규칙은 절대 버리지 않는 설계 불변량이다. postgres-parallel-query.md의 병렬 모드 논의를 참고하라.

여기서 도입된 메커니즘 — ExecInitParallelPlan, shm_toc 레이아웃, 워커 상태 복원, gather_readnext — 은 postgres-parallel-query.md에서 현재 설계로 문서화돼 있다. 9.6이 그 탄생지이고, 이후 시대들은 이 배관을 대체하는 대신 새로운 소비자를 추가한다.


10 — 병렬 조인·집계·GatherMerge (가치 있는 서브트리)

섹션 제목: “10 — 병렬 조인·집계·GatherMerge (가치 있는 서브트리)”

9.6은 스캔을 병렬화할 수 있었지만, Gather 아래 서브트리는 빈약했다. 병렬 스캔, 필터, 제한적인 partial 집계 정도였다. 조인이나 실질적인 그룹 집계가 필요한 쿼리는 직렬로 돌아갔다. PostgreSQL 10은 partial 서브트리를 실제 워크로드에서 병렬화할 가치가 있을 만큼 풍부하게 만든 릴리스다.

  1. 병렬 조인 — 세 가지 전략 모두. 10은 플래너가 조인을 Gather 아래에 배치해 각 워커가 외부 측의 자기 몫을 조인하도록 가르쳤다.

    • 병렬 해시 조인: 10에서 각 워커는 (공유) 내부 스캔에서 자신만의 개인 해시 테이블 복사본을 만들고, 외부 행의 자기 슬라이스로 탐색한다. (공유 해시 테이블 — 모든 워커가 협력해 하나를 만드는 방식 — 은 PG11 추가 사항이다. 다음 절 참조.)
    • 병렬 머지 조인병렬 중첩 루프 조인: 외부 측은 partial path(워커마다 슬라이스); 내부 측은 외부 행당 / 각 워커 안에서 평소처럼 재실행 / 병합된다. 조인 전략 메커니즘 자체는 postgres-join-nodes.md에 문서화돼 있다. 10이 추가한 것은 플래너가 partial 조인 경로를 생성하려는 의지와 외부 입력에 대한 실행기의 병렬 인식이다.
  2. Partial + Finalize 집계. 이것이 핵심이다. 10은 집계를 두 협력 노드로 분리했다.

    • Partial Aggregate (Gather 아래, 워커별): 각 워커가 자기 슬라이스의 partial 집계 상태를 계산한다. 예컨대 avg를 위한 (count, sum) 진행치.
    • Finalize Aggregate (Gather 위, 리더): 각 집계의 결합 함수(aggcombinefn)로 워커별 partial 상태를 최종 결과로 합치고, final 함수로 사용자가 볼 값을 내보낸다. 이로써 모든 병렬화 가능 집계는 결합 함수(combinefn not set for aggregate function 오류는 이게 없을 때 발생)를 선언해야 했다. 이 partial/finalize 분리 덕분에 SUM, COUNT, AVG, MIN/MAX 등이 워커 간에 실행될 수 있다. 집계 실행 내부는 postgres-agg-sort-nodes.md에 있다.
  3. GatherMerge — 순서 보존 수집. 일반 Gather는 튜플을 임의 도착 순서로 수집한다 (데이터가 있는 워커 큐를 라운드 로빈). 그러면 워커들이 만든 정렬 순서가 사라지므로, ORDER BY가 있는 쿼리는 이득을 얻지 못했다. Gather 위에서 재정렬해야 했기 때문이다. 10은 GatherMerge (src/backend/executor/nodeGatherMerge.c)를 추가했다. 각 워커가 이미 정렬된 슬라이스를 생성하면, GatherMerge는 워커별 스트림을 이진 힙으로 병합해 단일 정렬 출력을 만든다 — 병합 단계의 병렬 유사체. 이로써 병렬 ORDER BY, 정렬 출력을 위쪽으로 전달하는 병렬 머지 조인, 정렬 입력이 필요한 병렬 그룹 집계가 가능해졌다.

  4. 더 많은 병렬 스캔 타입. 10은 병렬 비트맵 힙 스캔병렬 인덱스 스캔을 추가해 병렬 서브트리의 리프가 더 이상 일반 순차 스캔에 국한되지 않게 됐다.

9.6 이후 병목은 “병렬로 스캔할 수 있는가”에서 “분석 쿼리에서 CPU가 집중되는 부분 — 조인과 집계 — 이 여전히 직렬”로 옮겨갔다. 직렬 해시 조인을 먹이는 병렬 스캔은 이득 대부분을 낭비한다. 조인이 CPU를 쓰는 곳이기 때문이다. 조인과 집계를 partial 인식으로 만든 것이 병렬 쿼리를 개념 증명에서 실제 리포팅 쿼리를 가속하는 수준으로 끌어올렸다. GatherMerge는 동반 수정이었다. 없으면 모든 정렬 병렬 플랜이 리더에서 전체 재정렬을 치르며 이득을 날렸다.

flowchart TB
    subgraph nine6["9.6 — 빈약한 서브트리, 직렬 조인"]
        C1["Hash Join\n(직렬, 리더 전담)"] --> C2["Gather"]
        C1 --> C3["Hash 내부\n(직렬)"]
        C2 --> C4["병렬 Seq Scan\n외부"]
    end

    subgraph ten["10 — Gather 아래 조인 + 집계"]
        D1["Finalize Aggregate\n(리더, combinefn)"] --> D2["GatherMerge\n정렬 스트림 병합"]
        D2 --> D3["Partial Aggregate\n워커"]
        D3 --> D4["병렬 Hash Join\n워커"]
        D4 --> D5["병렬 Seq Scan\n외부 슬라이스"]
        D4 --> D6["Hash 내부\n워커별 개인 복사"]
    end

변화의 핵심: 9.6에서는 조인이 Gather 에 놓여 직렬이었다. 10에서는 조인-and-partial 집계 전체 스택이 Gather 아래에 놓이고, 저렴한 finalize 단계만 리더에게 남으며, GatherMerge가 올라오는 과정에서 순서를 보존한다.

이 partial 조인 및 partial 집계 경로를 생성하는 플래너 측 기계 — partial path 생성, 병렬 비용 항목 — 는 postgres-planner-overview.md에 요약된 옵티마이저 동작이다. 실행기 측 GatherMerge 힙 병합과 partial/finalize 노드 실행은 postgres-parallel-query.mdpostgres-executor.md의 현재 설계에 포함돼 있다.


11 — 병렬 CREATE INDEX와 parallel-aware Append (병렬 처리가 SELECT를 벗어나다)

섹션 제목: “11 — 병렬 CREATE INDEX와 parallel-aware Append (병렬 처리가 SELECT를 벗어나다)”

10까지 병렬 처리는 전적으로 읽기 쿼리 실행 안에 있었다. PG11은 두 방향으로 병렬 처리를 순수 SELECT 밖으로 끌어냈다. DDL(인덱스 빌드)과 파티션 레이어(워커에게 파티션 전체를 할당)가 그것이다. 병렬 해시 조인도 개인에서 공유 해시 테이블로 업그레이드됐다.

  1. 병렬 CREATE INDEX (btree). btree 빌드는 한 가지 작업이 지배한다. 모든 행의 인덱스 키를 정렬하는 것이다. PG11은 그 정렬을 병렬화했다. 리더와 워커가 힙을 협력해서 스캔하고 병렬 외부 정렬(Tuplesort가 병렬 모드를 갖게 됨)에 공급한다. 워커들은 정렬된 런을 생성하고, 최종 병합이 btree를 만든다. 드라이버는 src/backend/access/nbtree/nbtsort.c에 있다(_bt_parallel_* 헬퍼, 리더와 워커 간에 공유하는 BTBuildState). 쓰기 측 작업에 병렬 처리가 처음 적용된 경우다. CREATE INDEX는 힙의 안정적인 스냅샷을 읽고 새 인덱스의 유일한 작성자가 빌드 자체이므로 합법이다. 정렬 메커니즘은 postgres-tuplesort.md에, 빌드 흐름은 postgres-index-creation.md에 있다.

  2. Parallel-aware Append. 11 이전에는 파티션 테이블(또는 UNION ALL)에 대한 Append가 각 워커 안에서 자식 서브플랜을 차례로 실행했다. PG11은 Appendparallel-aware (src/backend/executor/nodeAppend.c, ParallelAppendState)로 만들었다. 자식들을 공유 메모리에 두고 워커들이 서브플랜을 가져가는 방식이다. 워커마다 다른 파티션을 동시에 처리한다. 큰 non-partial 자식은 먼저 스케줄링되고(하나의 워커만 가져갈 수 있음), partial 자식은 여러 워커가 공유할 수 있다. 이것이 각 스캔을 개별로 병렬화하는 것보다 파티션을 워커 간에 실행함으로써 이득을 얻는 파티션 쿼리 병렬화의 핵심이다.

  3. Parallel Hash (공유 해시 테이블). PG10의 병렬 해시 조인은 각 워커가 내부 해시 테이블을 개인 복사본으로 만들었다 — 중복 작업과 중복 메모리. PG11은 Parallel Hash를 추가했다. 워커들이 협력해 DSM 안에 하나의 공유 해시 테이블을 만들고, 빌드 작업을 서로 나누며, 단일 공유 테이블을 모두가 탐색한다. 워커별 메모리 폭발을 없애고 병렬 해시 조인이 대형 내부 릴레이션으로 확장될 수 있게 됐다. 해시 조인 내부는 postgres-join-nodes.md에 있다.

두 가지 독립적인 압력이 있었다.

  • 인덱스 빌드는 데이터 적재와 pg_restore 중 직렬 장벽이었다. 대용량 테이블을 벌크 로드한 뒤 CREATE INDEX 단계가 코어 하나에서 수십 분에서 수 시간을 소모했다. 정렬을 병렬화하면 지배적인 비용을 정확히 공략하고, 새 인덱스는 커밋까지 개인 상태이므로 동시성 위험도 없다.
  • 파티셔닝이 1급 기능으로 자리 잡았다 (선언적 파티셔닝은 10에서 도입). 많은 파티션에 걸친 쿼리가 흔해졌지만, 직렬 Append는 플래너가 병렬 처리를 선택했어도 한 번에 파티션 하나만 스캔했다. Parallel-aware Append가 파티션 차원 자체를 병렬 처리의 원천으로 삼게 했다.

공유 해시 업그레이드는 PG10 병렬 해시 조인의 자연스러운 성숙이다. 조인이 병렬로 실행되기 시작하자, 낭비되는 워커별 해시 테이블이 다음 분명한 비효율이었다.

flowchart TB
    subgraph before["10 — 직렬 Append, 개인 해시"]
        E1["Append\n(직렬: 자식 순서대로)"] --> E2["part_1 스캔"]
        E1 --> E3["part_2 스캔"]
        E1 --> E4["part_3 스캔"]
        E5["CREATE INDEX\n(직렬 정렬, 코어 하나)"]
    end

    subgraph after["11 — 병렬 Append, 공유 해시, 병렬 인덱스 빌드"]
        F1["Gather"] --> F2["Parallel Append\n워커가 서브플랜 가져감"]
        F2 --> F3["워커 A -> part_1"]
        F2 --> F4["워커 B -> part_2"]
        F2 --> F5["워커 C -> part_3"]
        G1["CREATE INDEX\n병렬 Tuplesort"] --> G2["워커 정렬 런"]
        G1 --> G3["워커 정렬 런"]
        G1 --> G4["리더 병합 -> btree"]
    end

읽기 측 변화: Append가 자식에 대한 직렬 루프에서 워커들이 가져가는 서브플랜 작업 큐가 된다. 쓰기 측 변화: 인덱스 빌드에 처음으로 병렬 정렬이 붙는다.

현재 parallel-aware Append 실행 (ParallelAppendState, 서브플랜 가져가기)은 postgres-executor.md의 실행기 설계와 postgres-parallel-query.md의 병렬 배관에 포함돼 있다. 병렬 인덱스 빌드 흐름은 postgres-index-creation.mdpostgres-tuplesort.md에서 다룬다.


12 — 플래너·비용 개선 (새 노드 없음, 더 날카로운 플래너)

섹션 제목: “12 — 플래너·비용 개선 (새 노드 없음, 더 날카로운 플래너)”

PG12는 주목할 만한 새 병렬 노드를 추가하지 않았다. 그러나 이 시점의 병렬 처리 제약이 “실행기가 할 수 있는가”가 아니라 “플래너가 올바르게 선택하는가, 작업이 안전하다고 분류됐는가”로 옮겨간 만큼 의미 있는 시대다. 12는 모든 릴리스에 걸쳐 지속되는 플래너 측 투자의 대표적 사례다.

  • 병렬 안전성 분류 정교화. 표현식, 함수, 서브플랜의 병렬 안전 / 제한 / 비안전 분류가 계속 다듬어져 잘못된 결과 위험 없이 더 많은 플랜이 병렬 처리에 적합해졌다. (세 단계 분류는 주어진 서브트리 위에 Gather를 놓을 수 있는지 판단하는 관문이다.)
  • Partial path 생성 및 비용 산정 개선. 플래너가 더 많은 곳에서 partial path를 더 기꺼이, 더 정확하게 생성하게 됐고, 병렬 비용 항목(워커 기동, 튜플 큐 전달)이 개선돼 선택하는 병렬 수준이 현실에 더 잘 맞게 됐다. 비용 프레임워크는 postgres-cost-model.md에 문서화돼 있다.
  • 주변 기능의 병렬 친화성 향상. 파티션 프루닝과 partitionwise 조인 같은 기능이 성숙하면서 병렬 Append 및 병렬 조인과의 상호작용이 개선됐다. 새로운 실행기 노드 없이도 파티션 분석 쿼리가 더 나은 플랜을 얻었다.

PG12쯤에는 실행기가 스캔, 조인, 집계, 정렬(GatherMerge 경유), Append, 인덱스 빌드를 병렬화할 수 있었다. 남은 이득은 새 메커니즘보다 플래너가 기존 메커니즘을 더 많은 상황에 적용하고 병렬 수준을 잘 선택하는 데 있었다. 실행기가 병렬로 실행할 수 있는 노드도 플래너가 내보내지 않거나 비용을 잘못 매기면 소용없다.

그릴 노드 형태 변화가 없다. 변화는 플래너의 결정 표면에 있다 — 더 많은 서브트리가 Gather에 적합해지고, 병렬 수준이 더 정확하게 선택된다. 다이어그램이 바뀌지는 않지만, 앞선 시대의 병렬 다이어그램이 실제로 사용되는 빈도가 바뀌는 시대다.

병렬 안전성 분류와 partial path 비용 산정은 postgres-planner-overview.mdpostgres-cost-model.md의 옵티마이저 동작에 포함돼 있다.


13 — 병렬 VACUUM (병렬 처리가 유지보수에 도달하다)

섹션 제목: “13 — 병렬 VACUUM (병렬 처리가 유지보수에 도달하다)”

PG13은 병렬 처리를 유지보수 영역으로 확장했다. 읽기 쿼리(9.6–10)와 DDL 인덱스 빌드(11)에 이은 세 번째 주요 영역이다. VACUUM이 테이블의 인덱스를 병렬로 처리할 수 있게 됐다.

여러 인덱스를 가진 테이블의 VACUUM은 인덱스 vacuum 단계에 많은 시간을 쓴다. 각 인덱스를 스캔해 죽은 힙 튜플을 가리키는 포인터를 제거하는 작업이다. 직렬로는 인덱스를 차례차례 처리한다. PG13의 병렬 vacuum (src/backend/commands/vacuumparallel.c)은 백그라운드 워커를 기동해 각 인덱스를 워커에게 할당하므로, 여러 인덱스가 동시에 vacuumed(및 정리)된다. 리더는 힙 스캔을 수행하고 조율한다. 병렬 처리는 인덱스 단위로 이뤄진다 — 각 인덱스가 독립적인 구조이므로 여기가 당혹스러울 정도로 병렬적인 작업이 있는 곳이다.

핵심 설계 포인트, 요약 (메커니즘 자체는 postgres-vacuum.md “병렬 vacuum”에 문서화돼 있다):

  • 병렬 처리는 인덱스 단위다. 한 인덱스 내 블록 단위가 아니다. 인덱스가 워커를 쓸 만큼 충분히 커야(min_parallel_index_scan_size) 하고, 해당 액세스 메서드가 병렬 vacuum을 지원해야 한다.
  • 인덱스 vacuum과 인덱스 정리 단계에 적용된다. 힙 스캔은 해당 없다. 워커들이 소비하는 죽은 TID 목록은 리더의 힙 패스로 구축돼 DSM으로 공유된다.
  • 수동 VACUUM (PARALLEL n)과 일반 VACUUM에 사용 가능하다. autovacuum은 기본적으로 사용하지 않는다. autovacuum 워커가 여러 프로세스로 증식하는 것을 피하기 위해서다.

많은 인덱스를 가진 대형 테이블의 유지보수 창은 실제 운영 고통이었다. 야간 VACUUM이 박스에 유휴 코어가 있는데도 거의 단일 스레드로 몇 시간을 달렸다. 인덱스는 독립적인 구조이므로 동시에 vacuuming하는 것은 깔끔하고 조율이 적은 이득이다. 구조적으로는 parallel Append (독립 자식)과 병렬 스캔(독립 블록)과 같은 통찰이다. 작업이 이미 분리된 축을 찾아 분리된 조각을 워커에게 할당한다. 결정적으로, 병렬 vacuum은 9.6이 쿼리 실행을 위해 만든 src/backend/access/transam/parallel.c같은 ParallelContext 배관을 재사용한다. 이 시대는 새로운 인프라가 아닌 기존 인프라의 새 소비자다.

flowchart TB
    subgraph before["<=12 — 직렬 인덱스 vacuum"]
        H1["VACUUM 리더"] --> H2["힙 스캔 -> 죽은 TID"]
        H2 --> H3["인덱스 1 vacuum"]
        H3 --> H4["인덱스 2 vacuum"]
        H4 --> H5["인덱스 3 vacuum"]
    end

    subgraph after["13 — 병렬 인덱스 vacuum"]
        I1["VACUUM 리더"] --> I2["힙 스캔 -> 죽은 TID (DSM 공유)"]
        I2 --> I3["워커 -> 인덱스 1 vacuum"]
        I2 --> I4["워커 -> 인덱스 2 vacuum"]
        I2 --> I5["리더 -> 인덱스 3 vacuum"]
    end

변화: 인덱스별 vacuum 단계의 직렬 체인이 팬아웃으로 바뀐다. 각 인덱스는 워커(또는 리더)가 가져가는 독립적인 작업 단위가 되며, 모두가 하나의 공유 죽은 TID 목록을 소비한다.

현재 병렬 vacuum 설계 — 적격성 규칙, DSM 공유 죽은 TID 목록, 리더/워커 분할 — 는 postgres-vacuum.md에 문서화돼 있다. 그 아래 ParallelContextpostgres-parallel-query.md에 설명된 것과 동일하다.


14–18 — 점진적 강화 (더 많은 안전 경로, 더 나은 조정)

섹션 제목: “14–18 — 점진적 강화 (더 많은 안전 경로, 더 나은 조정)”

PG14부터 진화는 “새로운 병렬 영역 추가”에서 기존 것의 강화와 확장으로 전환된다. 이 구간에는 단일 극적인 노드가 없다. 대신 릴리스마다 거친 모서리를 다듬는다. 묶어서 다루는 것이 그 성격에 솔직하다 — 이것들은 새 아키텍처가 있는 시대가 아닌, 델타들이다.

  1. 리더 참여 조정. 리더 프로세스도 partial 플랜을 직접 실행할 수 있다 (parallel_leader_participation). 그런데 일부 플랜 — 특히 특정 병렬 해시와 GatherMerge 형태 — 에서는 바쁜 리더가 워커 큐 드레인을 막아 처리량을 떨어뜨린다. 이후 릴리스들이 리더가 언제 참여하는지와 플래너가 리더 참여를 비용으로 산정하는 방식을 다듬어, 더 많은 플랜 형태에서 기본 동작이 개선됐다. 리더 참여 메커니즘은 postgres-parallel-query.md의 현재 설계에 포함된다.

  2. 더 많은 병렬 안전 함수와 구조. 병렬 안전으로 표시된 내장 함수와 SQL 구조의 집합이 계속 늘었다. 더 많은 쿼리 형태(추가 집계/윈도우 상황, 더 많은 서브쿼리 형태, 더 많은 partitionwise 기계)가 병렬 처리에 적합해졌다. 각 변경이 이전에 직렬 실행을 강제했던 서브트리 위에 Gather가 놓일 수 있게 한다.

  3. 노드 수준 확장과 개선된 partial path. 더 많은 플랜 노드 타입이 partial path 변형을 얻거나 병렬 비용 산정이 개선됐다. partitionwise 조인/집계와 병렬 Append의 상호작용이 계속 개선돼 파티션 분석 쿼리가 꾸준히 더 나은 병렬 플랜을 얻었다.

  4. 병렬 쿼리가 간접적으로 이득을 얻는 인프라. REL_18은 통합 비동기 I/O 서브시스템을 가져온다 (postgres-aio.md 참조). 병렬 스캔은 AIO 자체가 “병렬 쿼리”는 아니더라도, 워커별 스캔 아래의 향상된 I/O 동시성으로 이득을 얻는 워크로드 중 하나다.

PG14쯤에는 병렬 쿼리의 아키텍처가 본질적으로 완성됐다. Gather / GatherMerge, ParallelContext 배관, partial path, 병렬 조인/집계/Append, 병렬 인덱스 빌드, 병렬 vacuum. 남은 이득은 새 병렬 메커니즘 발명보다 커버리지와 조정 — 플래너가 더 많은 경우에 올바르게 병렬 처리를 선택하고, 선택한 플랜이 효율적으로 실행되도록 — 에서 나왔다. 이것이 예상되는 성숙 곡선이다. 어려운 구조 작업이 초기 릴리스(9.6–13)에 집중되고, 이후 길고 점진적인 다듬기가 이어진다.

적용할 새 노드 형태 다이어그램이 없다. 변화는 양적이다(더 많은 서브트리가 적합, 더 잘 조정된 실행). “이후” 그림은 앞선 시대 다이어그램 모두의 합집합이며, 이제 더 많은 쿼리에서 도달 가능하고 더 잘 실행된다 — 정확히 아래 REL_18 그림이다.


REL_18 (커밋 273fe94, PG 18.x)에서 쿼리 내 병렬 처리는 하나의 일관된 기반 위에 구축된 성숙한 다중 영역 서브시스템이다. 현재 설계 — 이 문서가 의도적으로 재유도하지 않는 — 는 postgres-parallel-query.md에 문서화돼 있다. 이 흐름이 어디에 도달했는지 요약한다.

  • 경계 노드. 모든 병렬 플랜은 Gather(임의 순서) 또는 GatherMerge(정렬) 노드가 삽입된 단일 프로세스 트리다. 그 아래 서브트리는 병렬 인식이고, 위는 단일 프로세스 실행이다. 구현: src/backend/executor/nodeGather.c, src/backend/executor/nodeGatherMerge.c.
  • 배관. 첫 실행 시 ExecInitParallelPlan (src/backend/executor/execParallel.c)이 플랜을 shm_toc로 레이아웃한 DSM 세그먼트로 직렬화하고, PlanState 트리를 걸으며 크기를 정하고, 워커별 shm_mq 튜플 큐를 배치한다. src/backend/access/transam/parallel.c의 **ParallelContext**가 워커를 fork하고 리더 상태(스냅샷, XID, combo CID, GUC, 라이브러리, 사용자 ID)를 복원해 가시성을 맞춘다. ParallelWorkerMain이 각 워커의 실행기 복사본을 실행하고, 리더의 gather_readnext가 큐를 드레인한다.
  • 병렬화 가능한 것들. 순차 / 인덱스 / 비트맵 힙 스캔; 해시 / 머지 / 중첩 루프 조인 (공유 Parallel Hash 포함); partial+finalize 집계; GatherMerge를 통한 정렬; 파티션과 UNION ALL에 대한 parallel-aware Append; 병렬 btree CREATE INDEX; 병렬 인덱스 VACUUM. 9.6의 읽기 전용 계약은 여전히 유지된다. 병렬 모드는 쓰기를 금지하고, 증명 가능하게 병렬 안전한 작업만 적합하다.
  • 각 부분의 출처. Gather + 병렬 seq scan(9.6); 조인, partial 집계, GatherMerge(10); 병렬 CREATE INDEX, parallel-aware Append, 공유 Parallel Hash(11); 플래너/비용 개선(12); 병렬 VACUUM(13); 리더 참여 및 커버리지 조정(14–18).

타임라인이 드러내는 가장 중요한 구조적 사실은 하나다. 9.6–13이 메커니즘을 만들었고, 그 이후는 같은 ParallelContext 기반 위에서 확장하고 조정한다. 새로운 영역(DDL, 유지보수)은 9.6 배관을 재발명하는 게 아니라 소비자가 되는 방식으로 추가됐다.

PG19 전망 메모. PostgreSQL 개발은 병렬 커버리지를 계속 확장하고 있다 — 더 많은 노드 타입과 유틸리티 작업이 병렬 안전 경로를 얻고, 병렬 처리와 리더 참여가 언제 효과를 내는지에 대한 조정이 계속된다. PG19 항목들은 막 출시된 다음 단계로 보아야 하며, REL_18 동작으로 취급하면 안 된다. 위의 REL_18 설계가 현재 기록 상태다.


릴리스 노트 (기능 귀속 확인에 사용):

  • PostgreSQL 9.6 릴리스 노트 — 병렬 순차 스캔, 병렬 조인(초기), Gather 노드, 병렬 집계(초기), 백그라운드 워커 및 DSM 인프라.
  • PostgreSQL 10 릴리스 노트 — 병렬 머지 조인, 병렬 비트맵 힙 스캔, 병렬 인덱스 스캔, 개선된 병렬 집계(partial/finalize), GatherMerge.
  • PostgreSQL 11 릴리스 노트 — 병렬 CREATE INDEX (btree), parallel-aware Append, 공유 해시 테이블 병렬 해시 조인.
  • PostgreSQL 12 릴리스 노트 — 병렬 쿼리 플래너 및 비용 개선; 파티셔닝 상호작용.
  • PostgreSQL 13 릴리스 노트 — 병렬 VACUUM.
  • PostgreSQL 14–18 릴리스 노트 — 점진적 병렬 안전성 및 리더 참여 개선; REL_18 (커밋 273fe94) 현재 상태.

현재 상태 모듈 문서 (메커니즘이 여기 있음 — 인용, 중복 없음):

핵심 소스 파일 (REL_18, 커밋 273fe94 기준으로 확인):

  • src/backend/executor/execParallel.cExecInitParallelPlan, DSM/shm_toc 레이아웃, 워커 설정.
  • src/backend/executor/nodeGather.c — Gather 노드, gather_readnext.
  • src/backend/executor/nodeGatherMerge.c — 순서 보존 병합.
  • src/backend/executor/nodeAppend.cParallelAppendState, parallel-aware Append (PG11).
  • src/backend/access/transam/parallel.cParallelContext, 워커 fork 및 상태 복원; 병렬 vacuum과 병렬 인덱스 빌드도 재사용.
  • src/backend/access/nbtree/nbtsort.c_bt_parallel_*, 병렬 btree 빌드 (PG11).
  • src/backend/commands/vacuumparallel.c — 병렬 vacuum (PG13).
  • src/backend/optimizer/path/allpaths.c — partial path 생성.