(KO) PostgreSQL 선언적 파티셔닝 — 경계, 튜플 라우팅, 프루닝
목차
- 이론적 배경
- DBMS 공통 설계 관례
- PostgreSQL의 접근 방식
- 소스 워크스루
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 전선
- 출처
이론적 배경
섹션 제목: “이론적 배경”**파티셔닝(partitioning)**은 논리 릴레이션 하나를 행의 값에 대한 결정론적 함수에 따라 여러 물리 저장 단위로 분배하는 기법이다. Database System Concepts(Silberschatz, 7판, 21장 “병렬·분산 저장소”, §21.2 “데이터 파티셔닝”)는 파티셔닝을 병렬 처리와 관리 용이성 모두의 출발점으로 규정한다. “릴레이션을 여러 노드에 분산하는 가장 기본적인 방법은 릴레이션을 파티션하는 것”이며, 지정된 파티셔닝 속성에 대한 파티셔닝 함수로 각 튜플을 파티션에 배정한다.
교재는 상용 엔진이 수렴하는 세 가지 파티셔닝 전략을 명명하며, PostgreSQL은 이 세 가지를 그대로 구현한다(§21.2).
- 범위 파티셔닝(range partitioning). 파티셔닝 속성의 연속된 순서 범위로 튜플을 배정한다. 파티션 i는
[v_i, v_{i+1})의 값을 보유한다. “범위 쿼리에 적합하다”고 교재는 평가하는데,attr BETWEEN a AND b조건이[a, b]와 겹치는 파티션만 건드리기 때문이다. 단점은 **스큐(skew)**다. 분할 기준이 잘못 선택되거나 타임스탬프처럼 단조 증가하는 키를 쓰면 최근 행이 한 파티션에 집중된다. - 리스트 파티셔닝(list partitioning). 파티션마다 허용할 값의 집합을 명시한다(
FOR VALUES IN (...)). 국가 코드, 상태 열거값처럼 키가 범주형이고 자연스러운 순서 범위로 묶이지 않을 때 유용하다. - 해시 파티셔닝(hash partitioning).
h(attr) mod n으로 튜플을 배정한다. 교재의 평가는 해시 파티셔닝이 “데이터를 균등하게 분포시키는 경향이 있다”는 것이다. 범위 파티셔닝의 스큐 문제를 해소하지만, 범위 쿼리 지역성을 잃는다. 인접한 값이 관계없는 버킷에 해시될 수 있어BETWEEN조건이 모든 파티션을 건드릴 수 있다(§21.3 “스큐 처리”).
스큐를 다루는 §21.3 논의가 세 전략이 공존하는 이유다. 범위는 쿼리 지역성을 주지만 스큐 위험이 있고, 해시는 균형을 주지만 지역성을 잃으며, 리스트는 그 사이에서 DBA에게 명시적 통제권을 준다. PostgreSQL은 세 가지를 모두 PARTITION BY {RANGE|LIST|HASH} 선언으로 제공하고 중첩도 허용한다. 예를 들어 범위 파티션 아래에 해시 서브파티션을 붙일 수 있어, DBA가 레벨마다 트레이드오프를 조합할 수 있다.
파티션된 시스템의 런타임을 지배하는 두 연산은 모두 “값에 맞는 파티션 찾기” 문제로 귀결된다.
- 튜플 라우팅(tuple routing, 쓰기 경로). 새 행이 어느 파티션에 속하는지 결정한다. 삽입된 행마다 한 번씩 실행되므로 비용이 낮아야 한다.
- 파티션 프루닝(partition pruning, 읽기 경로). 쿼리 조건이 주어졌을 때 완전히 건너뛸 수 있는 파티션을 결정한다. 교재는 이를 쿼리 측면에서 파티셔닝의 핵심 이점으로 규정한다. 10억 행 테이블 전체를 스캔하는 대신 조건 값 집합과 겹치는 소수 파티션만 스캔할 수 있다. 프루닝이 파티셔닝을 단순한 관리 편의 기능이 아닌 성능 기능으로 만드는 요소다.
선언적 파티셔닝의 간결함은 두 연산이 동일한 기본 연산으로 환원된다는 점에 있다. 파티션 경계를 정렬된 집합으로 저장하면, 튜플 하나를 라우팅하는 것은 튜플 값을 기준으로 한 이진 탐색이 되고, 범위 조건을 프루닝하는 것은 조건 양 끝점을 감싸는 두 번의 이진 탐색이 된다. 이 문서의 나머지 부분은 PostgreSQL이 그 정렬된 경계 구조를 한 번 구축한 뒤 두 경로 모두에서 재사용하는 방식을 추적한다.
DBMS 공통 설계 관례
섹션 제목: “DBMS 공통 설계 관례”교재는 무엇을 제시한다. 세 가지 전략과 두 가지 값 조회 연산이다. 이 절은 상용 파티셔닝 엔진이 그 연산을 빠르고 정확하게 만들기 위해 채택하는 엔지니어링 관례를 정리한다. 교재가 암묵적으로 남겨 둔 패턴들이다. 다음 절에서 소개하는 PostgreSQL의 구체적 선택은 이 공통 공간 안의 한 가지 구성으로 읽으면 된다.
경계를 정렬된 탐색 가능 구조로 정규화한다
섹션 제목: “경계를 정렬된 탐색 가능 구조로 정규화한다”사용자가 선언한 파티션 경계(FOR VALUES FROM (...) TO (...))는 파서 노드다. 선언하기는 편하지만 탐색에는 쓸 수 없다. 범용 관례는 테이블의 모든 경계를 한 번에 경계 datum 정렬 배열과, 경계 위치를 파티션 인덱스에 대응시키는 병렬 맵으로 정규화하는 것이다. 범위 경계는 정렬된 분기점 목록으로, 리스트 경계는 정렬된 허용 값 목록으로, 해시 경계는 (modulus, remainder) 테이블로 축약된다. 정규화 이후 모든 값 조회 — 튜플 라우팅이든 조건 프루닝이든 — 는 그 단일 구조에 대한 이진 탐색(범위/리스트)이나 모듈로 인덱스(해시)로 처리된다. 구축 비용은 O(n log n) 한 번, 사용 비용은 O(log n)이다.
경계 구조를 릴레이션에 캐시하고 DDL 시 무효화한다
섹션 제목: “경계 구조를 릴레이션에 캐시하고 DDL 시 무효화한다”경계 구조는 구축 비용이 크고 DDL(ATTACH / DETACH / CREATE ... PARTITION OF) 때만 변경된다. 관례는 인메모리 릴레이션 디스크립터에 붙여 두고, 캐시 무효화 시 지연 재구축하는 것이다. 쿼리마다 재구축하지 않는다. 파티셔닝 고유의 미묘한 점이 있다. 세션마다 서로 다른 스냅샷을 보유하기 때문에, 한 트랜잭션이 연결된(attached) 파티션으로 보는 것을 다른 트랜잭션은 아직 분리된(detached) 상태로 볼 수 있다. 캐시된 디스크립터는 전역 공유가 아닌 스냅샷 한정 방식으로 관리해야 한다.
계층을 탑-다운으로 내려가며 쓰기를 라우팅한다
섹션 제목: “계층을 탑-다운으로 내려가며 쓰기를 라우팅한다”파티션은 중첩된다. 삽입된 튜플을 라우팅하는 관례는 탑-다운 하강이다. 루트 파티션 테이블에서 시작해 튜플 키에 맞는 자식 파티션을 찾고, 그 자식이 다시 파티션돼 있으면 재귀한다. 각 레벨이 서로 다른 컬럼 배치(삭제된 컬럼, 속성 순서 변경)를 가질 수 있으므로 재귀 시 튜플을 재매핑할 수도 있다. 하강은 행을 물리적으로 저장하는 유일한 종류인 리프 테이블에서 끝난다.
플랜 타임과 런타임 두 단계로 프루닝한다
섹션 제목: “플랜 타임과 런타임 두 단계로 프루닝한다”플래닝 시점에 알 수 있는 조건(WHERE created > '2024-01-01')은 플래너가 플랜을 확정하기 전에 파티션을 제거할 수 있다. 이것이 플랜 타임 프루닝이다. 그러나 제너릭 준비 플랜의 파라미터나 중첩 루프 외부 행 값처럼 실행 전까지 알 수 없는 조건은 플랜 타임에 프루닝할 수 없다. 관례는 프루닝을 재사용 가능한 스텝 프로그램으로 컴파일해 두 단계 모두에서 실행하는 것이다. 플랜 타임에는 상수만 채워 한 번 실행하고, 런타임에는 파라미터가 바인딩된 뒤 다시 실행한다. 런타임 프루닝 덕분에 하나의 제너릭 플랜이 실행마다 파티션을 건너뛸 수 있다.
연산자를 파티셔닝 경계 아래로 밀어 넣는다
섹션 제목: “연산자를 파티셔닝 경계 아래로 밀어 넣는다”두 테이블이 동일하게 파티션돼 있으면(같은 전략, 같은 경계), 두 테이블의 조인은 매칭 파티션 쌍의 조인 합집합과 같다. 이것이 **파티션와이즈 조인(partitionwise join)**이다. 마찬가지로 파티션 키를 포함하는 GROUP BY는 파티션별 집계의 합집합이다. 이것이 **파티션와이즈 집계(partitionwise aggregate)**다. 관례는 두 경계 구조를 병합해 호환성을 확인한 뒤, 큰 연산 하나를 각각 데이터의 일부를 처리하고 독립적으로 병렬화할 수 있는 N개의 작은 연산으로 재작성하는 것이다.
이론 ↔ PostgreSQL 대응표
섹션 제목: “이론 ↔ PostgreSQL 대응표”다음 절에서 PostgreSQL 심볼 이름을 만나기 전에, 그것이 어떤 종류의 것인지 먼저 알아야 한다.
| 이론 / 관례 | PostgreSQL 이름 |
|---|---|
| 테이블별 파티션 카탈로그 | PartitionDesc (PartitionDescData), Relation에 캐시 |
| 정규화된 정렬 경계 구조 | PartitionBoundInfo (PartitionBoundInfoData): datums[] + indexes[] |
| 경계 구조 구축 | partition_bounds_create → create_{hash,list,range}_bounds |
| 범위/리스트 값 조회 | partition_range_datum_bsearch / partition_list_bsearch |
| 해시 값 조회 | compute_partition_hash_value 후 % nindexes |
| 튜플 하나를 리프로 라우팅 | ExecFindPartition → get_partition_for_tuple |
| INSERT별 라우팅 상태 | PartitionTupleRouting + PartitionDispatch |
| 마지막 발견 라우팅 캐시 | PartitionDesc.last_found_* + PARTITION_CACHED_FIND_THRESHOLD |
| 프루닝 스텝 프로그램 | PartitionPruneStepOp / PartitionPruneStepCombine |
| 플랜 타임 프루닝 | prune_append_rel_partitions → get_matching_partitions |
| 런타임(초기 + 실행) 프루닝 | ExecDoInitialPruning / ExecFindMatchingSubPlans |
| 전략별 경계 매칭 | get_matching_{hash,list,range}_bounds |
| 파티션와이즈 조인 경계 병합 | partition_bounds_merge → merge_{list,range}_bounds |
파티션을 생성하는 카탈로그 DDL과 ATTACH/DETACH 검증은 postgres-ddl-execution.md가 담당한다. 플래너가 살아남은 파티션 위에 Append/MergeAppend 경로를 구축하는 방식과 파티션와이즈 경로 비용 계산은 postgres-planner-overview.md가 담당한다. 리프 스캔 노드 동작(SeqScan, IndexScan)은 postgres-scan-nodes.md가 담당한다. 이 문서는 파티셔닝 기계 본체 — 경계 구조, 튜플 라우팅, 세 계층이 공유하는 두 단계 프루닝 — 를 다룬다.
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL 선언적 파티셔닝(PARTITION BY 구문은 PG 10에 도입돼 이후 꾸준히 강화됐다)은 교재 모델을 네 가지 설계 결정으로 구체화한다.
- 테이블당 하나의 정규화된 경계 구조
PartitionBoundInfo.partition_bounds_create가 한 번 구축하고 릴캐시 엔트리의PartitionDesc안에 캐시한다. 라우팅과 프루닝 모두 이 구조를 탐색하며, 어느 쪽도 재구축하지 않는다. PartitionDispatch체인을 통한 탑-다운 튜플 라우팅.PartitionDesc에last_found캐시가 있어, 연속 행이 동일 파티션에 떨어지는 경우 — 타임시리즈 삽입의 일반적인 경우 — 이진 탐색을 건너뛴다.PartitionPruneStep노드의 컴파일된 스텝 프로그램으로서의 프루닝. 동일 프로그램이 플랜 타임(상수만)과 런타임(파라미터 바인딩 후) 모두에서 실행된다. 이것이 가장 중요한 구조적 선택이다. 두 프루닝 단계를 하나의 익스큐터 아래로 통합한다.- 경계 병합
partition_bounds_merge로 제어되는 파티션와이즈 연산자. 플래너가 조인이나 집계를Append아래로 밀기 전에 두 테이블의 경계 호환성을 증명한다.
PartitionDesc와 PartitionBoundInfo — 캐시된 경계 구조
섹션 제목: “PartitionDesc와 PartitionBoundInfo — 캐시된 경계 구조”파티션된 모든 릴레이션은 릴캐시 엔트리에 PartitionDesc를 보유한다. 파티션 OID 정렬 목록과 정규화된 경계로 구성된다.
// PartitionDescData — src/include/partitioning/partdesc.htypedef struct PartitionDescData{ int nparts; /* Number of partitions */ bool detached_exist; /* Are there any detached partitions? */ Oid *oids; /* Array of 'nparts' elements containing * partition OIDs in order of their bounds */ bool *is_leaf; /* whether each oids[] entry is a leaf */ PartitionBoundInfo boundinfo; /* collection of partition bounds */
/* Caching fields to cache lookups in get_partition_for_tuple() */ int last_found_datum_index; int last_found_part_index; int last_found_count;} PartitionDescData;boundinfo가 정규화를 담당한다. 세 전략 모두 동일한 레이아웃을 공유한다. 경계 값의 datums[] 배열과, 경계 위치를 파티션 인덱스에 대응시키는 indexes[] 배열이다. 배열의 의미만 전략마다 다르다.
// PartitionBoundInfoData — src/include/partitioning/partbounds.htypedef struct PartitionBoundInfoData{ PartitionStrategy strategy; /* hash, list or range? */ int ndatums; /* Length of the datums[] array */ Datum **datums; PartitionRangeDatumKind **kind; /* per range datum; NULL for hash/list */ Bitmapset *interleaved_parts; /* possibly-interleaved LIST partitions */ int nindexes; /* Length of the indexes[] array */ int *indexes; /* Partition indexes */ int null_index; /* the null-accepting partition; -1 if none */ int default_index; /* the default partition; -1 if none */} PartitionBoundInfoData;partbounds.h의 헤더 주석은 하나의 구조체가 세 전략을 모두 수용하는 전략별 불변 조건을 명시한다.
- LIST:
indexes[]는 datum당 항목 하나(nindexes == ndatums). 해당 정확한 값을 허용하는 파티션이다. - RANGE:
indexes[]는 고유 범위 datum당 항목 하나에 끝에 항목 하나를 추가한다(nindexes == ndatums + 1).indexes[i]는datums[i]가 상한인 파티션이며, 해당 범위에 파티션이 없으면-1이다. - HASH:
nindexes == 최대 모듈러스.indexes[r]은 나머지r을 수용하는 파티션이며, 없으면-1이다.
null_index와 default_index는 두 가지 특수 슬롯이다. NULL 키를 허용하는 파티션(LIST 전용)과 DEFAULT 파티션이다. 이 구조는 partition_bounds_create가 전략에 따라 분기해 구축한다.
// partition_bounds_create — src/backend/partitioning/partbounds.cPartitionBoundInfopartition_bounds_create(PartitionBoundSpec **boundspecs, int nparts, PartitionKey key, int **mapping){ *mapping = (int *) palloc(sizeof(int) * nparts); for (int i = 0; i < nparts; i++) (*mapping)[i] = -1;
switch (key->strategy) { case PARTITION_STRATEGY_HASH: return create_hash_bounds(boundspecs, nparts, key, mapping); case PARTITION_STRATEGY_LIST: return create_list_bounds(boundspecs, nparts, key, mapping); case PARTITION_STRATEGY_RANGE: return create_range_bounds(boundspecs, nparts, key, mapping); } Assert(false); return NULL;}각 create_*_bounds 워커는 원시 경계를 오름차순으로 qsort하고(qsort_partition_{hbound,list_value,rbound}_cmp 비교자 사용) datums[]/indexes[]를 채운다. *mapping 출력 파라미터는 각 원래 파티션 위치를 정규(정렬) 인덱스로 기록한다. 카탈로그는 파티션을 경계 순서가 아닌 OID 순서로 저장하기 때문에 호출자가 이 매핑을 필요로 한다. 해시의 경우 정렬된 모듈러스로 최대 모듈러스를 인덱스 배열 크기로 선택한다.
// create_hash_bounds — src/backend/partitioning/partbounds.c (condensed)qsort(hbounds, nparts, sizeof(PartitionHashBound), qsort_partition_hbound_cmp);/* After sorting, moduli are now stored in ascending order. */greatest_modulus = hbounds[nparts - 1].modulus;
boundinfo->nindexes = greatest_modulus;boundinfo->indexes = (int *) palloc(greatest_modulus * sizeof(int));for (i = 0; i < greatest_modulus; i++) boundinfo->indexes[i] = -1;/* ... each partition with (modulus m, remainder r) claims every * index r, r+m, r+2m, ... < greatest_modulus ... */디스크립터 자체는 RelationBuildPartitionDesc(partdesc.c)가 RelationGetPartitionDesc를 통한 첫 접근 시 지연 구축하며, omit_detached 플래그로 스냅샷 한정된다. partdesc.h의 주석은 “각 호출자에게 보이는 파티션이 해당 스냅샷에 따라 다르기 때문에 올바른 시점에 디스크립터를 캐시에서 제거하기 어렵다”고 설명한다. 그래서 분리된 파티션 가시성을 하나의 공유 디스크립터에 고정하지 않고 파라미터로 전달한다.
flowchart TB
SPEC["PartitionBoundSpec[] (parser nodes)<br/>FOR VALUES FROM/TO/IN/WITH"]
SPEC -->|partition_bounds_create| DISP{key->strategy}
DISP -->|HASH| CH["create_hash_bounds<br/>qsort by (modulus,remainder)"]
DISP -->|LIST| CL["create_list_bounds<br/>qsort accepted values"]
DISP -->|RANGE| CR["create_range_bounds<br/>qsort breakpoints, de-dup"]
CH --> BI
CL --> BI
CR --> BI
subgraph BI["PartitionBoundInfo (canonical, sorted)"]
D["datums[] (sorted boundary values)"]
IX["indexes[] (boundary pos -> partition idx)"]
SP["null_index / default_index"]
end
BI --> PD["PartitionDesc on relcache<br/>oids[] + is_leaf[] + boundinfo"]
그림 1 — 경계 정규화. 사용자의 파서 노드 경계는 partition_bounds_create가 한 번 정렬해 단일 PartitionBoundInfo로 만든다. datums[]/indexes[] 레이아웃은 세 전략 모두를 수용한다. 디스크립터는 릴캐시 엔트리에 캐시되며 쓰기(라우팅) 경로와 읽기(프루닝) 경로 모두에서 재사용된다.
세 가지 값 조회 기본 연산
섹션 제목: “세 가지 값 조회 기본 연산”datums[]가 정렬된 상태에서, 값에 맞는 파티션을 찾는 것은 리스트와 범위에서 동일한 이진 탐색이고 해시에서는 모듈로다. 리스트 탐색은 value <= datum인 가장 큰 datum을 반환한다.
// partition_list_bsearch — src/backend/partitioning/partbounds.cintpartition_list_bsearch(FmgrInfo *partsupfunc, Oid *partcollation, PartitionBoundInfo boundinfo, Datum value, bool *is_equal){ int lo = -1, hi = boundinfo->ndatums - 1, mid; while (lo < hi) { int32 cmpval; mid = (lo + hi + 1) / 2; cmpval = DatumGetInt32(FunctionCall2Coll(&partsupfunc[0], partcollation[0], boundinfo->datums[mid][0], value)); if (cmpval <= 0) { lo = mid; *is_equal = (cmpval == 0); if (*is_equal) break; } else hi = mid - 1; } return lo;}호출자는 *is_equal이 참일 때만 리스트 매칭을 유효로 처리한다. 리스트 파티셔닝은 범위 포함이 아닌 정확한 값 매칭이기 때문이다. partition_range_datum_bsearch는 구조적으로 동일하지만 partition_rbound_datum_cmp로 다중 컬럼 튜플을 다중 컬럼 범위 경계와 비교하며, 호출자는 indexes[bound_offset + 1](다음 분기점이 상한인 파티션)을 사용한다. 해시는 탐색이 필요 없다. compute_partition_hash_value가 컬럼별 해시를 결합하고, 호출자가 % nindexes를 취한다.
// compute_partition_hash_value — src/backend/partitioning/partbounds.cuint64compute_partition_hash_value(int partnatts, FmgrInfo *partsupfunc, const Oid *partcollation, const Datum *values, const bool *isnull){ uint64 rowHash = 0; Datum seed = UInt64GetDatum(HASH_PARTITION_SEED);
for (int i = 0; i < partnatts; i++) { if (!isnull[i]) /* Nulls are just ignored */ { Datum hash = FunctionCall2Coll(&partsupfunc[i], partcollation[i], values[i], seed); rowHash = hash_combine64(rowHash, DatumGetUInt64(hash)); } } return rowHash;}이 세 기본 연산이 파티셔닝의 전체 산술 핵심이다. 나머지는 언제 이 연산을 호출하고 결과로 무엇을 할지 결정하는 배관이다.
INSERT 시 튜플 라우팅 — ExecFindPartition
섹션 제목: “INSERT 시 튜플 라우팅 — ExecFindPartition”파티션된 테이블에 행이 삽입될 때, ModifyTable은 ExecFindPartition(execPartition.c)을 호출해 물리적으로 저장할 리프 ResultRelInfo를 찾는다. 라우팅 상태는 PartitionTupleRouting 구조체에 있으며, 그 partition_dispatch_info[] 배열이 파티션된 테이블마다 PartitionDispatch 하나를 보유한다(슬롯 0은 항상 루트). ExecFindPartition은 탑-다운 하강이다. 각 레벨에서 파티션 키를 추출하고 자식을 찾아, 자식이 리프면 멈추고 아니면 자식의 디스패치로 재귀한다.
// ExecFindPartition — src/backend/executor/execPartition.c (condensed)dispatch = pd[0]; /* start at the root table */while (dispatch != NULL){ int partidx = -1; rel = dispatch->reldesc; partdesc = dispatch->partdesc;
/* expression machinery in FormPartitionKeyDatum reads ecxt_scantuple */ ecxt->ecxt_scantuple = slot; FormPartitionKeyDatum(dispatch, slot, estate, values, isnull);
if (partdesc->nparts == 0 || (partidx = get_partition_for_tuple(dispatch, values, isnull)) < 0) ereport(ERROR, (errcode(ERRCODE_CHECK_VIOLATION), errmsg("no partition of relation \"%s\" found for row", RelationGetRelationName(rel)), /* ... */ ));
if (partdesc->is_leaf[partidx]) { /* reached a leaf: fetch (or lazily build) its ResultRelInfo */ if (likely(dispatch->indexes[partidx] >= 0)) rri = proute->partitions[dispatch->indexes[partidx]]; else rri = ExecInitPartitionInfo(mtstate, estate, proute, dispatch, rootResultRelInfo, partidx); dispatch = NULL; /* terminate the loop */ } else { /* sub-partitioned: descend, possibly converting the tuple layout */ dispatch = /* the child PartitionDispatch for partidx */; }}FormPartitionKeyDatum은 파티션 키 표현식을 values[]/isnull[]로 평가한다. 컴파일된 keystate ExprState를 실행하므로 ecxt_scantuple이 올바른 슬롯을 가리켜야 한다(익스큐터 표현식 기계는 postgres-expression-eval.md에서 다룬다). 지연 구축에 주목할 필요가 있다. 파티션의 ResultRelInfo와 PartitionDispatch는 튜플이 처음 라우팅될 때(ExecInitPartitionInfo / ExecInitPartitionDispatchInfo) 비로소 구축된다. 천 개 파티션 중 세 개에만 적중하는 대량 INSERT ... SELECT는 세 개 비용만 지불한다.
get_partition_for_tuple은 값 조회 기본 연산을 호출하는 곳이며, 마지막 발견 캐시로 감싸져 있다. 이 캐시가 라우팅 경로의 특징적 최적화다. 헤더 주석은 동기를 명시한다. RANGE + 타임스탬프 조합에서 “파티션 키가 현재 시각”일 때, 연속 행이 동일 파티션에 떨어진다.
// get_partition_for_tuple — src/backend/executor/execPartition.c (LIST arm)case PARTITION_STRATEGY_LIST: if (isnull[0]) { if (partition_bound_accepts_nulls(boundinfo)) return boundinfo->null_index; } else { bool equal; if (partdesc->last_found_count >= PARTITION_CACHED_FIND_THRESHOLD) { int last_datum_offset = partdesc->last_found_datum_index; Datum lastDatum = boundinfo->datums[last_datum_offset][0]; int32 cmpval; /* does the last found datum index match this datum? */ cmpval = DatumGetInt32(FunctionCall2Coll(&key->partsupfunc[0], key->partcollation[0], lastDatum, values[0])); if (cmpval == 0) return boundinfo->indexes[last_datum_offset]; /* fall-through and do a manual lookup */ } bound_offset = partition_list_bsearch(key->partsupfunc, key->partcollation, boundinfo, values[0], &equal); if (bound_offset >= 0 && equal) part_index = boundinfo->indexes[bound_offset]; } break;switch 이후 캐시 로직은 동일한 bound_offset이 반복되면 last_found_count를 증가시키고, 달라지면 1로 리셋한다. 카운트가 PARTITION_CACHED_FIND_THRESHOLD(16)에 도달해야 캐시 빠른 경로가 활성화된다. 빠른 경로는 O(log n) 이진 탐색을 기억된 datum과의 단일 비교로 대체하며, 캐시 미스 시 탐색으로 투명하게 복귀한다.
// get_partition_for_tuple — src/backend/executor/execPartition.c (cache update)if (bound_offset == partdesc->last_found_datum_index) partdesc->last_found_count++;else{ partdesc->last_found_count = 1; partdesc->last_found_part_index = part_index; partdesc->last_found_datum_index = bound_offset;}return part_index;해시는 “비용이 낮아 캐시할 필요 없다”는 이유로 캐시하지 않는다. NULL/DEFAULT 매칭은 bound_offset이 없으므로 캐시를 건드리지 않고 바로 반환된다. 임계값 16은 의도적 보호 장치다. 행마다 파티션이 교대하는 워크로드는 연속 16회에 도달하지 못하므로, 이중 확인 비용을 지불하지 않고 어차피 했을 이진 탐색을 그대로 수행한다.
flowchart TB
T["새 튜플 슬롯"] --> FK["FormPartitionKeyDatum<br/>values[] / isnull[]"]
FK --> GP["get_partition_for_tuple"]
GP --> ST{전략}
ST -->|HASH| H["compute_partition_hash_value<br/>% nindexes"]
ST -->|LIST/RANGE| C{last_found_count<br/>>= 16 ?}
C -->|예| FAST["캐시된 datum과 비교<br/>적중 -> 캐시 인덱스 반환"]
C -->|아니오| BS["partition_list_bsearch /<br/>partition_range_datum_bsearch"]
FAST -->|미스| BS
H --> IDX["파티션 인덱스"]
BS --> IDX
IDX --> LEAF{is_leaf[partidx] ?}
LEAF -->|아니오| GP
LEAF -->|예| RRI["리프 ResultRelInfo<br/>(지연 구축)"]
그림 2 — 튜플 라우팅. ExecFindPartition이 계층을 하강하며, 각 레벨에서 get_partition_for_tuple이 모듈로(해시) 또는 이진 탐색(리스트/범위)을 수행한다. 동일 파티션이 16회 연속 발생하면 마지막 발견 캐시로 단락된다. 리프에서 하강이 멈추고, 비리프 매칭은 재귀한다.
플랜 타임과 런타임 파티션 프루닝
섹션 제목: “플랜 타임과 런타임 파티션 프루닝”프루닝은 라우팅의 읽기 경로 대응이다. “이 값이 어느 파티션으로 가는가” 대신 “이 조건을 가능성 있게 만족하는 파티션은 어느 것인가”를 묻는다. 핵심 설계 선택은 프루닝이 두 단계 모두에서 실행되는 스텝 프로그램으로 컴파일된다는 점이다. 두 스텝 노드 타입은 작다.
// PartitionPruneStepOp / Combine — src/include/nodes/plannodes.htypedef struct PartitionPruneStepOp{ PartitionPruneStep step; StrategyNumber opstrategy; /* btree strategy of the clause's operator */ List *exprs; /* one expr per partition key column */ List *cmpfns; /* comparison support function OIDs */ Bitmapset *nullkeys; /* keys matched by IS NULL */} PartitionPruneStepOp;
typedef enum PartitionPruneCombineOp{ PARTPRUNE_COMBINE_UNION, PARTPRUNE_COMBINE_INTERSECT,} PartitionPruneCombineOp;
typedef struct PartitionPruneStepCombine{ PartitionPruneStep step; PartitionPruneCombineOp combineOp; List *source_stepids;} PartitionPruneStepCombine;Op 스텝은 리프 비교(partkey < $1)이고, Combine 스텝은 이전 스텝 결과를 UNION(OR용) 또는 INTERSECT(AND용)로 접는다. gen_partprune_steps가 절 트리를 탐색해 이 프로그램을 생성한다. 플랜 타임에 prune_append_rel_partitions가 상수만으로 실행한다.
// prune_append_rel_partitions — src/backend/partitioning/partprune.c (condensed)if (!enable_partition_pruning || clauses == NIL) return bms_add_range(NULL, 0, rel->nparts - 1);
gen_partprune_steps(rel, clauses, PARTTARGET_PLANNER, &gcontext);if (gcontext.contradictory) return NULL; /* WHERE 1=0 style: prune everything */pruning_steps = gcontext.steps;if (pruning_steps == NIL) return bms_add_range(NULL, 0, rel->nparts - 1); /* nothing usable */
/* ... fill PartitionPruneContext from rel->part_scheme / rel->boundinfo ... */context.planstate = NULL; /* not valid at plan time */context.exprcontext = NULL;return get_matching_partitions(&context, pruning_steps);get_matching_partitions는 스텝 프로그램의 인터프리터다. 각 스텝을 results[] 슬롯으로 실행한 뒤, 최종 스텝의 bound_offsets 비트맵을 읽어 boundinfo->indexes[]로 파티션 인덱스에 매핑하고, 결과 플래그에 따라 null/default 파티션을 추가한다.
// get_matching_partitions — src/backend/partitioning/partprune.c (condensed)foreach(lc, pruning_steps){ PartitionPruneStep *step = lfirst(lc); switch (nodeTag(step)) { case T_PartitionPruneStepOp: results[step->step_id] = perform_pruning_base_step(context, (PartitionPruneStepOp *) step); break; case T_PartitionPruneStepCombine: results[step->step_id] = perform_pruning_combine_step(context, (PartitionPruneStepCombine *) step, results); break; }}final_result = results[num_steps - 1];/* map surviving bound_offsets -> partition indexes, add null/default */perform_pruning_base_step은 스텝의 exprs에서 조회 키를 구축한다. 파티션 키 컬럼마다 Datum을 추출하고, 하나라도 NULL이면 “매칭 없음”으로 조기 종료한다. 프루닝 연산자가 strict하기 때문이다. 이후 전략별 매처로 디스패치한다. 해시 매처는 구조를 잘 보여준다. 모든 키에 값이 있으면 나머지 하나를 계산해 단일 오프셋을 반환하고, 그렇지 않으면 보수적으로 모든 오프셋을 반환한다.
// get_matching_hash_bounds — src/backend/partitioning/partprune.c (condensed)if (nvalues + bms_num_members(nullkeys) == partnatts){ for (i = 0; i < partnatts; i++) isnull[i] = bms_is_member(i, nullkeys); rowHash = compute_partition_hash_value(partnatts, partsupfunc, partcollation, values, isnull); greatest_modulus = boundinfo->nindexes; if (partindices[rowHash % greatest_modulus] >= 0) result->bound_offsets = bms_make_singleton(rowHash % greatest_modulus);}else /* not all keys known: every partition is a candidate */ result->bound_offsets = bms_add_range(NULL, 0, boundinfo->nindexes - 1);동일한 스텝 프로그램은 플랜 타임에 알 수 없는 값이 있을 때 런타임에 다시 실행된다. 제너릭 준비 플랜의 $1이나 중첩 루프 내부 측의 키가 현재 외부 행에 의존하는 경우가 그 예다. ExecDoInitialPruning(InitPlan에서 호출)은 외부 파라미터를 대상으로 초기 프루닝을 수행하고, ExecFindMatchingSubPlans는 외부 행마다 변하는 파라미터를 받아 실행 중에 재호출된다.
// ExecDoInitialPruning — src/backend/executor/execPartition.c (condensed)foreach(lc, estate->es_part_prune_infos){ PartitionPruneInfo *pruneinfo = lfirst_node(PartitionPruneInfo, lc); PartitionPruneState *prunestate; Bitmapset *validsubplans = NULL;
prunestate = CreatePartitionPruneState(estate, pruneinfo, &all_leafpart_rtis); estate->es_part_prune_states = lappend(estate->es_part_prune_states, prunestate);
if (prunestate->do_initial_prune) validsubplans = ExecFindMatchingSubPlans(prunestate, true, &validsubplan_rtis); /* ... record surviving subplan indexes for Append/MergeAppend init ... */ estate->es_part_prune_results = lappend(estate->es_part_prune_results, validsubplans);}ExecFindMatchingSubPlans는 임시 메모리 컨텍스트에서 스텝을 실행한다. 외부 튜플마다 재실행되므로 할당량을 저렴하게 버릴 수 있어야 하기 때문이다. find_matching_subplans_recurse로 파티션 계층을 재귀하며, 실행할 Append/MergeAppend 하위 플랜 인덱스 비트맵을 반환한다. 살아남은 집합이 어느 자식 플랜 노드를 초기화할지 결정한다. 프루닝된 하위 플랜은 건너뛰며, 이것이 postgres-executor.md에서 언급한 상태 서브노드 배열이 플랜의 하위 플랜 목록과 엇갈리는 익스큐터 특이점이다.
flowchart TB
CL["WHERE 절"] -->|gen_partprune_steps| PROG["스텝 프로그램:<br/>PruneStepOp + PruneStepCombine"]
PROG --> PT["플랜 타임<br/>prune_append_rel_partitions<br/>(상수만)"]
PROG --> RT["런타임<br/>ExecDoInitialPruning /<br/>ExecFindMatchingSubPlans<br/>(파라미터 바인딩 후)"]
PT -->|get_matching_partitions| GM["스텝 해석 -><br/>bound_offsets 비트맵"]
RT -->|get_matching_partitions| GM
GM --> BASE["perform_pruning_base_step<br/>get_matching_{hash,list,range}_bounds"]
GM --> COMB["perform_pruning_combine_step<br/>UNION (OR) / INTERSECT (AND)"]
BASE --> SURV["살아남은 파티션 인덱스"]
COMB --> SURV
SURV --> AP["Append / MergeAppend<br/>살아남은 자식만 스캔"]
그림 3 — 단일 스텝 프로그램 위의 두 단계 프루닝. gen_partprune_steps가 WHERE 절을 재사용 가능한 Op/Combine 스텝 프로그램으로 컴파일한다. 같은 프로그램을 get_matching_partitions가 플랜 타임(상수)과 파라미터 바인딩 후 런타임 모두에서 해석한다. 하나의 제너릭 플랜으로 실행마다 프루닝한다.
파티션와이즈 조인과 집계
섹션 제목: “파티션와이즈 조인과 집계”enable_partitionwise_join이 켜져 있고 두 조인 테이블이 호환되게 파티션돼 있으면, 플래너는 조인을 파티션 쌍별 조인의 Append로 재작성한다. 호환성은 partition_bounds_merge로 두 경계 구조를 병합해 결정한다. merge_list_bounds / merge_range_bounds로 분기한다.
// partition_bounds_merge dispatch — src/backend/partitioning/partbounds.c/* called from try_partitionwise_join() in the planner */switch (outer_binfo->strategy){ case PARTITION_STRATEGY_LIST: return merge_list_bounds(partsupfunc, partcollation, outer_rel, inner_rel, jointype, outer_parts, inner_parts); case PARTITION_STRATEGY_RANGE: return merge_range_bounds(partnatts, partsupfunc, partcollation, outer_rel, inner_rel, jointype, outer_parts, inner_parts); /* HASH partitionwise join requires identical bounds, handled separately */}병합은 조인 릴레이션을 위한 새 PartitionBoundInfo와, 병합된 파티션마다 내부·외부 소스 파티션을 매핑하는 두 목록(outer_parts/inner_parts)을 생성한다. 조인이 도입하는 비대칭성도 처리한다. 한쪽에만 있는 DEFAULT 파티션, NULL 파티션, 부분적으로 겹치는 범위, 매칭 없는 외부 파티션도 행을 내보내야 하는 외부 조인 시맨틱(process_outer_partition / merge_default_partitions가 담당). 출력은 플래너의 try_partitionwise_join이 소비하며, 이 부분은 postgres-planner-overview.md가 담당한다. 이 문서는 파티션와이즈 조인을 제어하는 경계 병합 기본 연산만 다룬다.
enable_partitionwise_aggregate는 GROUP BY에 대한 유사한 재작성을 수행한다. 그루핑 키가 파티션 키의 상위 집합이면 각 파티션의 행이 완전한 그룹을 형성한다. 집계가 Append 아래에서(파티션당 Agg 하나) 실행될 수 있어, 위에서 실행하는 것보다 파티션당 Agg가 독립적으로 병렬화된다. 두 GUC 모두 기본값이 off다. 경계 병합과 추가 경로 생성의 플래닝 비용은 파티션이 많고 파티션당 연산이 단일 연산보다 저렴할 때만 투자할 가치가 있기 때문이다.
소스 워크스루
섹션 제목: “소스 워크스루”심볼 이름에 앵커를 두고 줄 번호에 의존하지 않는다. PostgreSQL 소스는 릴리스 사이에 이동하지만 함수나 구조체 이름은 대부분의 리팩터링을 거쳐도 그대로 유지된다.
git grep -n '<symbol>' src/backend/partitioning/으로 현재 위치를 찾는다. 위치 힌트 표의 줄 번호는 커밋273fe94(REL_18_STABLE) 기준 관측값으로 빠른 힌트일 뿐이다.
경계 구조 (partbounds.c, partdesc.c)
섹션 제목: “경계 구조 (partbounds.c, partdesc.c)”partition_bounds_create— 파서의PartitionBoundSpec[]를PartitionBoundInfo로 정규화하는 전략 분기. 원래→정규(정렬)*mapping을 채운다.create_hash_bounds/create_list_bounds/create_range_bounds— 전략별 워커. 각각 원시 경계를 qsort하고datums[]/indexes[]를 채운다. 해시는indexes[]를 최대 모듈러스 크기로 설정한다.qsort_partition_hbound_cmp/qsort_partition_list_value_cmp/qsort_partition_rbound_cmp— 이진 탐색이 의존하는 불변 조건을 확립하는 정렬 비교자.partition_list_bsearch/partition_range_datum_bsearch/partition_range_bsearch/partition_hash_bsearch— 값 조회 기본 연산. 탐침<=최대 경계를 반환한다.compute_partition_hash_value—HASH_PARTITION_SEED와 함께 컬럼별partsupfunc해시에hash_combine64적용. null은 무시한다.partition_bounds_equal/partition_bounds_copy— 구조적 동등성(동일 경계 파티션와이즈 조인 감지에 사용)과 깊은 복사.RelationBuildPartitionDesc/RelationGetPartitionDesc(partdesc.c) — 캐시된PartitionDesc를 지연 구축/조회.omit_detached로 스냅샷 한정 분리 파티션 가시성을 처리한다.
튜플 라우팅 (execPartition.c)
섹션 제목: “튜플 라우팅 (execPartition.c)”ExecSetupPartitionTupleRouting— 파티션된 테이블을 대상으로 하는ModifyTable의PartitionTupleRouting할당.ExecFindPartition— 탑-다운 하강. 각 레벨에서FormPartitionKeyDatum+get_partition_for_tuple호출, 리프에서 종료.get_partition_for_tuple— 조회 기본 연산에 대한 전략 switch.PARTITION_CACHED_FIND_THRESHOLD(= 16)로 제어되는last_found캐시로 감싸져 있다.FormPartitionKeyDatum— 파티션 키keystateExprState를values[]/isnull[]로 실행.ecxt_scantuple이 설정돼야 한다.ExecInitPartitionInfo/ExecInitPartitionDispatchInfo/ExecInitRoutingInfo— 리프의ResultRelInfo와 서브파티션의PartitionDispatch를 첫 라우팅된 튜플 시 지연 구축.ExecCleanupTupleRouting— 라우팅 전용으로 열린 파티션 닫기.
두 단계 프루닝 (partprune.c, execPartition.c)
섹션 제목: “두 단계 프루닝 (partprune.c, execPartition.c)”gen_partprune_steps/gen_partprune_steps_internal—WHERE절을PartitionPruneStep프로그램으로 컴파일.contradictory설정.make_partition_pruneinfo— 플래너 측: 런타임 프루닝을 위해 플랜에 전달되는PartitionPruneInfo구축.prune_append_rel_partitions— 플랜 타임 진입점.get_matching_partitions로 상수를 사용해 스텝 실행.get_matching_partitions— 스텝 인터프리터. 각 스텝을results[]로 실행하고, 최종bound_offsets를 파티션 인덱스로 매핑하며 null/default를 추가.perform_pruning_base_step—exprs에서 조회 키 구축.get_matching_{hash,list,range}_bounds로 디스패치.perform_pruning_combine_step—PARTPRUNE_COMBINE_UNION/PARTPRUNE_COMBINE_INTERSECT로 자식 결과 접기.get_matching_hash_bounds/get_matching_list_bounds/get_matching_range_bounds— 전략별 오프셋 매칭.ExecDoInitialPruning—InitPlan에서의 익스큐터 진입.PartitionPruneState를 구축하고 외부 파라미터에 대한 초기 프루닝 실행.ExecInitPartitionExecPruning— 노드별 실행 프루닝 연결. 초기 프루닝 후 하위 플랜 맵 재시퀀싱.ExecFindMatchingSubPlans/find_matching_subplans_recurse— 임시 컨텍스트에서 스텝을 실행해 살아남은Append/MergeAppend하위 플랜 인덱스 반환. 실행 파라미터가 있으면 외부 튜플마다 재호출.
파티션와이즈 연산자 (partbounds.c)
섹션 제목: “파티션와이즈 연산자 (partbounds.c)”partition_bounds_merge—merge_list_bounds/merge_range_bounds로 분기해 조인 측 경계 호환성 증명.merge_list_bounds/merge_range_bounds— 병합된PartitionBoundInfo와 매칭된outer_parts/inner_parts목록 생성.process_outer_partition/process_inner_partition/merge_null_partitions/merge_default_partitions— 병합 중 null/default/외부 조인 비대칭성 처리.partitions_are_ordered— 파티션에 대한 순서 있는Append(MergeAppend 회피) 활성화 테스트.
주요 구조체와 상수
섹션 제목: “주요 구조체와 상수”PartitionDescData(partdesc.h) —oids[]+is_leaf[]+boundinfo+last_found_*캐시 필드.PartitionBoundInfoData(partbounds.h) —strategy,datums[],kind[],indexes[],null_index,default_index.PartitionTupleRouting/PartitionDispatch(execPartition.c) — INSERT별 라우팅 상태와 레벨별 디스패치 객체.PartitionPruneStepOp/PartitionPruneStepCombine/PartitionPruneCombineOp(plannodes.h) — 컴파일된 프루닝 프로그램.PartitionPruneContext(partprune.h) — boundinfo + 지원 함수 + 선택적planstate/exprcontext(플랜 타임에는 NULL).PARTITION_CACHED_FIND_THRESHOLD(= 16,execPartition.c),HASH_PARTITION_SEED(catalog/partition.h),PARTITION_MAX_KEYS(= 32,pg_config_manual.h).
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
partition_bounds_create | src/backend/partitioning/partbounds.c | 299 |
create_hash_bounds | src/backend/partitioning/partbounds.c | 347 |
create_list_bounds | src/backend/partitioning/partbounds.c | 462 |
create_range_bounds | src/backend/partitioning/partbounds.c | 677 |
partition_list_bsearch | src/backend/partitioning/partbounds.c | 3607 |
partition_range_bsearch | src/backend/partitioning/partbounds.c | 3653 |
partition_range_datum_bsearch | src/backend/partitioning/partbounds.c | 3695 |
partition_hash_bsearch | src/backend/partitioning/partbounds.c | 3738 |
compute_partition_hash_value | src/backend/partitioning/partbounds.c | 4722 |
RelationBuildPartitionDesc | src/backend/partitioning/partdesc.c | 134 |
RelationGetPartitionDesc | src/backend/partitioning/partdesc.c | 71 |
ExecSetupPartitionTupleRouting | src/backend/executor/execPartition.c | 218 |
ExecFindPartition | src/backend/executor/execPartition.c | 265 |
ExecInitPartitionInfo | src/backend/executor/execPartition.c | 502 |
ExecInitRoutingInfo | src/backend/executor/execPartition.c | 994 |
ExecInitPartitionDispatchInfo | src/backend/executor/execPartition.c | 1102 |
ExecCleanupTupleRouting | src/backend/executor/execPartition.c | 1241 |
FormPartitionKeyDatum | src/backend/executor/execPartition.c | 1302 |
PARTITION_CACHED_FIND_THRESHOLD | src/backend/executor/execPartition.c | 1356 |
get_partition_for_tuple | src/backend/executor/execPartition.c | 1399 |
ExecDoInitialPruning | src/backend/executor/execPartition.c | 1824 |
ExecInitPartitionExecPruning | src/backend/executor/execPartition.c | 1880 |
ExecFindMatchingSubPlans | src/backend/executor/execPartition.c | 2498 |
find_matching_subplans_recurse | src/backend/executor/execPartition.c | 2571 |
make_partition_pruneinfo | src/backend/partitioning/partprune.c | 225 |
gen_partprune_steps | src/backend/partitioning/partprune.c | 744 |
prune_append_rel_partitions | src/backend/partitioning/partprune.c | 780 |
get_matching_partitions | src/backend/partitioning/partprune.c | 847 |
get_matching_hash_bounds | src/backend/partitioning/partprune.c | 2706 |
get_matching_list_bounds | src/backend/partitioning/partprune.c | 2783 |
get_matching_range_bounds | src/backend/partitioning/partprune.c | 2994 |
perform_pruning_base_step | src/backend/partitioning/partprune.c | 3458 |
perform_pruning_combine_step | src/backend/partitioning/partprune.c | 3606 |
PartitionBoundInfoData | src/include/partitioning/partbounds.h | 79 |
PartitionDescData | src/include/partitioning/partdesc.h | 29 |
PartitionPruneStepOp | src/include/nodes/plannodes.h | 1699 |
PartitionPruneStepCombine | src/include/nodes/plannodes.h | 1721 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”위의 모든 심볼, 구조체 필드, 코드 발췌는 커밋 273fe94(PostgreSQL 18.x) REL_18_STABLE 작업 트리에서 직접 읽었다. 확인한 사항:
PartitionBoundInfoData레이아웃 —partbounds.h에서strategy,ndatums,datums,kind,interleaved_parts,nindexes,indexes,null_index,default_index확인. 전략별indexes[]불변 조건(LISTnindexes == ndatums; RANGEnindexes == ndatums + 1; HASHnindexes == 최대 모듈러스)은 해당 헤더의 주석 블록에 명시돼 있다.PartitionDescData캐시 필드 —partdesc.h에서last_found_datum_index,last_found_part_index,last_found_count확인. 주석이get_partition_for_tuple()을 소비자로 명시한다.PARTITION_CACHED_FIND_THRESHOLD—execPartition.c에서#define ... 16확인.get_partition_for_tuple의 LIST와 RANGE 양쪽 분기가 모두last_found_count >= PARTITION_CACHED_FIND_THRESHOLD로 캐시 빠른 경로를 제어한다는 것도 확인했다.- 프루닝 스텝 타입 —
plannodes.h에서PartitionPruneStepOp(opstrategy,exprs,cmpfns,nullkeys포함),PartitionPruneStepCombine(combineOp,source_stepids포함),PARTPRUNE_COMBINE_UNION/PARTPRUNE_COMBINE_INTERSECT열거형 확인. - 두 단계 진입점 —
prune_append_rel_partitions(플랜 타임,context.planstate = NULL설정)와ExecDoInitialPruning/ExecFindMatchingSubPlans(런타임) 모두 동일한pruning_steps를 넘겨get_matching_partitions를 호출한다는 것 확인. - 파티션와이즈 GUC —
costsize.c에서enable_partitionwise_join과enable_partitionwise_aggregate선언(기본값 모두false) 확인.relnode.c,allpaths.c,planner.c에서 소비. - REL_18 금지 항목 없음 — 이 문서는
XLOG2rmgr이나B_DATACHECKSUMSWORKER_*심볼을 참조하지 않는다.
주의: 위치 힌트 표의 줄 번호는 변할 수 있다. 심볼 이름이 지속적 앵커다. 병합 헬퍼(merge_{list,range}_bounds, process_{outer,inner}_partition)는 partbounds.c에 static 전방 선언과 정의로 확인됐지만, 상세한 비대칭성 처리는 플래너 측에 해당하므로 디스패치 레벨에서만 인용했다. 자세한 내용은 postgres-planner-overview.md에서 다룬다.
PostgreSQL 너머 — 비교 설계와 연구 전선
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 전선”PostgreSQL 선언적 파티셔닝은 의도적으로 보수적이다. 단일 노드 파티셔닝이다. 모든 파티션이 동일 인스턴스, 동일 스토리지, 동일 트랜잭션 관리자 아래에 있다. 교재의 파티셔닝 프레임(DSC §21.2)은 더 넓다. 교재는 파티셔닝을 노드 간 분산 데이터 배치의 기초로 제시한다. 흥미로운 비교는 PostgreSQL이 넘지 않기로 선택한 경계 축에 있다.
로컬 대 분산 파티셔닝. Oracle, SQL Server, DB2는 같은 세 전략(범위/리스트/해시)에 PostgreSQL이 수동 서브파티셔닝으로 근사하는 복합(예: 범위 후 해시) 파티셔닝을 추가해 로컬 선언적 파티셔닝을 오래전부터 제공해 왔다. 분산 엔진 — Citus(PostgreSQL 확장), CockroachDB, Spanner, Vitess, YugabyteDB — 은 동일한 h(key) mod n 아이디어를 다른 노드에 파티션을 배치하는 데 확장한다. 이는 프루닝 문제를 노드로의 라우팅 문제로 바꾸고 DSC 23장이 다루는 분산 트랜잭션 우려를 추가한다. PostgreSQL 코어는 노드 경계에서 멈춘다. postgres_fdw + 파티셔닝 조합(“외래 테이블을 통한 샤딩”)이 코어가 가장 가까이 가는 방식이며, 네이티브 분산 플래너가 아닌 동일한 PartitionBoundInfo와 FDW 푸시다운에 의존한다.
프루닝 정교함. PostgreSQL의 플랜 타임 프루닝은 절 인터프리터다. 상용 엔진은 PostgreSQL이 갖지 않거나 부분적으로만 갖는 추가 프루닝 형태를 계층화한다. 조인 프루닝(작은 차원 테이블에 대한 조인 조건으로 한 테이블의 파티션을 제거하는 Oracle의 “파티션와이즈 조인 + 파티션 프루닝”)과 해시 조인의 빌드 측에서 런타임에 구축된 비트맵 기반 동적 프루닝(Oracle의 “블룸 필터 프루닝”, SQL Server의 “비트맵 필터링”)이 있다. PostgreSQL의 런타임 프루닝(ExecFindMatchingSubPlans)은 파라미터화된 중첩 루프와 제너릭 플랜을 처리하지만, 형제 연산자에서 런타임 값 집합을 구축해 프루닝에 사용하는 것은 아직 없다. hackers 메일링 리스트에서 알려진 격차다.
적응형/학습 파티셔닝. 연구 전선(DSC §21.3의 스큐 논의 주제)은 DBA 결정이 아닌 데이터 분포에서 자동으로 파티션 경계를 선택하는 것이다. 자율 데이터베이스 연구(Pavlo 등, “Self-Driving Database Management Systems”, CIDR 2017)와 학습 구조 연구(Kraska 등, “The Case for Learned Index Structures”, SIGMOD 2018) 모두 관측된 워크로드로 파티션 수와 분기점을 선택하는 방향을 가리킨다. “학습 파티셔닝” 아이디어는 정렬된 datums[] 배열을 키에 대한 파티션을 예측하는 학습 모델로 대체한다. PostgreSQL의 datums[] + 이진 탐색은 이들이 비교하는 고전적 기준선이며, last_found 캐시는 학습 모델이 활용할 지역성을 수작업으로 근사한 것이다.
컬럼 스토어 축. Database Internals(Petrov, 1장)는 수평 파티셔닝(행 분할, PostgreSQL 선언적 파티셔닝이 하는 것)과 수직 파티셔닝(컬럼 분할, 컬럼 스토어가 하는 것)의 직교적 구분을 제시한다. 분석 엔진(ClickHouse, DuckDB, Snowflake)은 둘을 결합한다. 수평 파티션(“파트”/“마이크로 파티션”)을 각각 컬럼 방식으로 저장하고, 파티션당 min/max “존 맵”으로 PostgreSQL의 파티션당 경계보다 훨씬 세밀한 단위에서 프루닝한다. PostgreSQL의 행 스토어 파티션은 테이블 전체를 프루닝하고, 존 맵 컬럼 스토어는 파티션 내 개별 컬럼 블록을 프루닝한다. 두 기술은 결합 가능하다. PostgreSQL 힙에 존 맵 스타일 블록 프루닝을 도입하는 것(BRIN 인덱스로 구현, 별도 문서에서 다룬다)이 행 스토어 방식의 유사체다.
PostgreSQL 설계의 강점. 라우팅과 프루닝을 하나의 PartitionBoundInfo 아래로, 플랜 타임과 런타임 프루닝을 하나의 PartitionPruneStep 프로그램 아래로 통합한 것은 유달리 깔끔하다. 많은 엔진이 런타임 프루닝을 별도 코드 경로로 덧붙인다. PostgreSQL의 “한 번 컴파일, 두 단계에서 해석” 구조는 하나의 정확성 논증이 두 경우를 모두 커버한다는 의미다. 단점은 PostgreSQL 프루닝이 해석적(런타임에 순회하는 스텝 프로그램)이라는 점이다. 네이티브 코드로 컴파일하지 않는다. 최후의 속도 개선을 포기하고 유지보수성을 택한 의도적 트레이드오프이며, postgres-executor.md에서 설명하는 익스큐터의 인터프리터 우선 철학과 일치한다.
소스 트리 (REL_18_STABLE, 커밋 273fe94, 읽은 날짜 2026-06-05):
src/backend/partitioning/partbounds.c— 경계 정규화(partition_bounds_create,create_*_bounds), 값 조회 기본 연산(partition_*_bsearch,compute_partition_hash_value), 파티션와이즈 경계 병합(partition_bounds_merge,merge_*_bounds).src/backend/partitioning/partprune.c— 프루닝 스텝 컴파일(gen_partprune_steps), 인터프리터(get_matching_partitions,perform_pruning_*_step), 전략 매처(get_matching_*_bounds).src/backend/partitioning/partdesc.c—RelationBuildPartitionDesc/RelationGetPartitionDesc(캐시된, 스냅샷 한정 디스크립터).src/backend/executor/execPartition.c— 튜플 라우팅(ExecFindPartition,get_partition_for_tuple,PartitionTupleRouting/PartitionDispatch)과 런타임 프루닝(ExecDoInitialPruning,ExecFindMatchingSubPlans).src/backend/catalog/partition.c— 파티션 카탈로그 헬퍼(get_partition_parent,get_partition_ancestors,get_default_partition_oid,map_partition_varattnos).src/include/partitioning/partbounds.h,src/include/partitioning/partdesc.h,src/include/partitioning/partprune.h,src/include/nodes/plannodes.h—PartitionBoundInfoData,PartitionDescData,PartitionPruneContext,PartitionPruneStep*타입 정의.
이론:
- Database System Concepts, Silberschatz, Korth & Sudarshan, 7판, 21장 “병렬·분산 저장소” — §21.2 “데이터 파티셔닝”(범위/리스트/해시 전략, 파티셔닝 함수와 속성), §21.3 “파티셔닝의 스큐 처리”.
knowledge/research/dbms-general/database-system-concepts.md에 수록. - Database Internals, Alex Petrov, 1장 — 수평 대 수직 파티셔닝, 컬럼 스토어 데이터 레이아웃.
knowledge/research/dbms-general/database-internals.md에 수록.
관련 KB 문서 (교차 참조, 중복 없음):
postgres-ddl-execution.md—CREATE TABLE ... PARTITION BY,ATTACH/DETACH PARTITION, 경계 중복 검증(check_new_partition_bound).postgres-planner-overview.md— 살아남은 파티션 위의Append/MergeAppend경로 생성과try_partitionwise_join.postgres-scan-nodes.md— 파티션별 리프 스캔 노드 동작.postgres-executor.md—PlanState트리,ExecInitNode, 상태/플랜 트리 매핑에서의 프루닝된 하위 플랜 특이점.postgres-expression-eval.md—FormPartitionKeyDatum과partkey_datum_from_expr뒤의ExprState기계.postgres-relcache.md— 캐시된PartitionDesc를 재구축하는 릴캐시 무효화.