Skip to content
Published on

PostgreSQL 17 파티셔닝 전략과 병렬 쿼리 최적화 완벽 가이드

Authors
  • Name
    Twitter
PostgreSQL 17 파티셔닝 전략과 병렬 쿼리 최적화 완벽 가이드

왜 PostgreSQL 17에서 파티셔닝과 병렬 쿼리가 중요한가

대규모 운영 환경에서 단일 테이블의 행 수가 수억 건을 넘어가면 인덱스 크기, vacuum 시간, 쿼리 응답 속도 모두 급격히 나빠진다. PostgreSQL 17은 선언적 파티셔닝(Declarative Partitioning)과 병렬 쿼리(Parallel Query)를 한 단계 끌어올렸다. 파티셔닝된 테이블에서 identity column과 exclusion constraint를 직접 지원하고, ALTER TABLE ... MERGE PARTITIONSSPLIT PARTITION 구문으로 파티션 경계를 동적으로 변경할 수 있게 되었다. 병렬 쿼리 쪽에서는 FULL OUTER JOIN과 집계 함수에 대한 병렬 처리가 확대되었고, GIN 인덱스의 Parallel Create Index, 상관 서브쿼리의 병렬화 등이 추가되었다.

이 글에서는 파티셔닝 유형별 설계 전략, 파티션 프루닝 원리, 병렬 쿼리 실행 계획 분석, 대규모 테이블 마이그레이션 절차, 그리고 실제 운영에서 만나는 트러블슈팅까지 코드와 함께 다룬다.

파티셔닝 유형 개요

PostgreSQL은 세 가지 기본 파티셔닝 전략과 이를 조합한 복합 파티셔닝을 지원한다.

Range 파티셔닝

시간 기반 데이터나 연속 값 범위에 적합하다. 주문 테이블을 월별로 나누는 것이 대표적이다.

-- Range 파티셔닝: 월별 주문 테이블
CREATE TABLE orders (
    order_id    BIGSERIAL,
    customer_id BIGINT NOT NULL,
    order_date  DATE NOT NULL,
    total_amount NUMERIC(12,2),
    status      VARCHAR(20) DEFAULT 'pending'
) PARTITION BY RANGE (order_date);

-- 월별 파티션 생성
CREATE TABLE orders_2026_01 PARTITION OF orders
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE orders_2026_02 PARTITION OF orders
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE orders_2026_03 PARTITION OF orders
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- 기본 파티션 (범위에 해당하지 않는 데이터 수용)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 각 파티션에 로컬 인덱스 자동 생성
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status, order_date);

List 파티셔닝

이산형 카테고리 값(지역, 상태 코드 등)으로 분리할 때 사용한다.

-- List 파티셔닝: 지역별 사용자 테이블
CREATE TABLE users (
    user_id     BIGSERIAL,
    username    VARCHAR(100) NOT NULL,
    email       VARCHAR(255) NOT NULL,
    region      VARCHAR(10) NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT now()
) PARTITION BY LIST (region);

CREATE TABLE users_kr PARTITION OF users
    FOR VALUES IN ('KR');

CREATE TABLE users_us PARTITION OF users
    FOR VALUES IN ('US');

CREATE TABLE users_eu PARTITION OF users
    FOR VALUES IN ('DE', 'FR', 'GB', 'IT', 'ES');

CREATE TABLE users_apac PARTITION OF users
    FOR VALUES IN ('JP', 'SG', 'AU', 'IN');

CREATE TABLE users_others PARTITION OF users DEFAULT;

Hash 파티셔닝

특정 컬럼의 해시 값으로 균등하게 분배한다. 데이터에 자연스러운 범위나 카테고리가 없을 때 유용하다.

-- Hash 파티셔닝: 세션 테이블을 4개 파티션으로 균등 분배
CREATE TABLE sessions (
    session_id  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     BIGINT NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMPTZ DEFAULT now(),
    expires_at  TIMESTAMPTZ
) PARTITION BY HASH (session_id);

CREATE TABLE sessions_p0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

복합 파티셔닝 (Sub-Partitioning)

Range와 List를 조합하여 2단계 파티셔닝을 구성할 수 있다.

-- 복합 파티셔닝: 연도별 Range -> 지역별 List
CREATE TABLE events (
    event_id    BIGSERIAL,
    event_type  VARCHAR(50),
    region      VARCHAR(10),
    event_date  DATE NOT NULL,
    payload     JSONB
) PARTITION BY RANGE (event_date);

CREATE TABLE events_2026 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')
    PARTITION BY LIST (region);

CREATE TABLE events_2026_kr PARTITION OF events_2026
    FOR VALUES IN ('KR');

CREATE TABLE events_2026_us PARTITION OF events_2026
    FOR VALUES IN ('US');

CREATE TABLE events_2026_eu PARTITION OF events_2026
    FOR VALUES IN ('DE', 'FR', 'GB');

Range vs List vs Hash 파티셔닝 선택 기준 비교

항목RangeListHash
적합한 데이터시계열, 연속 범위이산 카테고리균등 분배 필요
파티션 키 예시order_date, created_atregion, statususer_id, session_id
파티션 프루닝범위 조건에서 효과적등호 조건에서 효과적해시 키 등호에서만 동작
데이터 편향 위험특정 기간 집중 가능특정 값 집중 가능낮음 (균등 분배)
신규 파티션 추가쉬움 (미래 범위 추가)쉬움 (새 값 추가)불가 (재해시 필요)
파티션 제거DROP으로 즉시 삭제DROP으로 즉시 삭제개별 삭제 불가
보관/아카이빙우수 (오래된 파티션 분리)보통어려움
복합 파티셔닝지원지원2단계 불가
PostgreSQL 17 개선MERGE/SPLIT 지원MERGE/SPLIT 지원제한적

선택 기준 정리: 시계열 로그나 주문 데이터라면 Range, 지역이나 상태 코드 기반이면 List, 균등 분배가 가장 중요하고 프루닝이 덜 중요하면 Hash를 선택한다. 실무에서는 Range + List 복합 파티셔닝이 가장 흔하다.

파티션 프루닝 원리와 최적화

파티션 프루닝(Partition Pruning)은 쿼리의 WHERE 절을 분석하여 불필요한 파티션을 실행 계획에서 제외하는 메커니즘이다. PostgreSQL 17에서는 컴파일 타임 프루닝과 런타임 프루닝이 모두 동작한다.

컴파일 타임 프루닝

쿼리 계획 단계에서 상수 조건을 평가하여 파티션을 제외한다.

-- 컴파일 타임 프루닝 확인
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE order_date >= '2026-03-01' AND order_date < '2026-04-01';

/*
결과:
  Append
    -> Seq Scan on orders_2026_03
          Filter: ((order_date >= '2026-03-01') AND (order_date < '2026-04-01'))
-- orders_2026_01, orders_2026_02는 프루닝되어 스캔하지 않음
*/

런타임 프루닝

파라미터화된 쿼리나 서브쿼리 결과에 따라 실행 시점에 프루닝이 동작한다.

-- 런타임 프루닝: Prepared Statement에서 동작
PREPARE get_orders(date, date) AS
SELECT * FROM orders WHERE order_date >= $1 AND order_date < $2;

EXPLAIN ANALYZE EXECUTE get_orders('2026-02-01', '2026-03-01');
-- 실행 시점에 orders_2026_02만 스캔

프루닝이 동작하지 않는 경우

다음 상황에서는 프루닝이 작동하지 않으므로 주의해야 한다.

  • 함수 적용: WHERE EXTRACT(MONTH FROM order_date) = 3 형태로 파티션 키에 함수를 적용하면 프루닝 불가
  • 타입 불일치: 파티션 키가 DATE인데 TIMESTAMP 값으로 비교하면 암시적 캐스팅으로 프루닝 실패 가능
  • OR 조건의 비파티션 키 혼합: WHERE order_date = '2026-03-01' OR customer_id = 100처럼 파티션 키와 비파티션 키를 OR로 연결하면 모든 파티션 스캔
  • enable_partition_pruning = off: 이 GUC 파라미터가 꺼져 있으면 프루닝 비활성화
-- 프루닝 동작 확인: enable_partition_pruning 설정
SHOW enable_partition_pruning;  -- 반드시 'on' 확인

-- 안티패턴: 파티션 키에 함수 적용 (프루닝 불가)
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE EXTRACT(YEAR FROM order_date) = 2026
  AND EXTRACT(MONTH FROM order_date) = 3;
-- 모든 파티션을 스캔하게 됨

-- 올바른 패턴: 범위 조건 사용 (프루닝 동작)
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE order_date >= '2026-03-01' AND order_date < '2026-04-01';
-- orders_2026_03만 스캔

병렬 쿼리 실행 계획 분석

PostgreSQL 17은 병렬 쿼리 실행을 더욱 적극적으로 활용한다. 파티셔닝된 테이블에서 각 파티션을 병렬 워커가 동시에 스캔할 수 있으며, FULL OUTER JOIN과 집계 연산에 대한 병렬 처리도 확장되었다.

핵심 병렬 쿼리 파라미터

파라미터기본값설명
max_parallel_workers8인스턴스 전체 병렬 워커 최대 수
max_parallel_workers_per_gather2Gather 노드당 최대 병렬 워커 수
min_parallel_table_scan_size8MB병렬 Seq Scan 최소 테이블 크기
min_parallel_index_scan_size512kB병렬 Index Scan 최소 인덱스 크기
parallel_tuple_cost0.1병렬 워커에서 튜플 전달 비용
parallel_setup_cost1000병렬 워커 시작 비용
max_worker_processes8전체 백그라운드 워커 프로세스 제한
work_mem4MB워커당 개별 적용되는 작업 메모리

병렬 실행 계획 확인

-- 병렬 쿼리 파라미터 설정 (세션 레벨)
SET max_parallel_workers_per_gather = 4;
SET work_mem = '256MB';

-- 대규모 파티셔닝 테이블 병렬 스캔
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2026-04-01'
GROUP BY customer_id
ORDER BY total_spent DESC
LIMIT 100;

/*
실행 계획 예시:
  Limit  (cost=... rows=100)
    -> Sort  (cost=... rows=...)
          Sort Key: (sum(total_amount)) DESC
          -> Finalize GroupAggregate  (cost=... rows=...)
                Group Key: customer_id
                -> Gather Merge  (cost=... rows=...)
                      Workers Planned: 4
                      Workers Launched: 4
                      -> Partial GroupAggregate  (cost=... rows=...)
                            Group Key: customer_id
                            -> Parallel Append  (cost=... rows=...)
                                  -> Parallel Seq Scan on orders_2026_01
                                        Filter: (...)
                                  -> Parallel Seq Scan on orders_2026_02
                                        Filter: (...)
                                  -> Parallel Seq Scan on orders_2026_03
                                        Filter: (...)
  Planning Time: 2.1 ms
  Execution Time: 1,245 ms
*/

핵심 포인트:

  • Workers PlannedWorkers Launched가 일치하는지 확인한다. 불일치하면 max_worker_processes 또는 시스템 리소스가 부족한 것이다.
  • Parallel Append는 여러 파티션을 병렬 워커들이 나누어 스캔한다는 뜻이다.
  • Partial GroupAggregateFinalize GroupAggregate로 집계가 2단계(워커 부분 집계 + 리더 최종 집계)로 나뉜다.

Parallel Hash Join 분석

-- 병렬 Hash Join: 대규모 조인 최적화
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.order_id, o.order_date, c.username, o.total_amount
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-03-01' AND o.order_date < '2026-04-01'
  AND o.total_amount > 10000;

/*
  Gather  (cost=... rows=...)
    Workers Planned: 4
    Workers Launched: 4
    -> Parallel Hash Join  (cost=... rows=...)
          Hash Cond: (o.customer_id = c.customer_id)
          -> Parallel Seq Scan on orders_2026_03 o
                Filter: (total_amount > 10000)
          -> Parallel Hash  (cost=... rows=...)
                Buckets: 65536  Batches: 1  Memory Usage: 12MB
                -> Seq Scan on customers c
*/

Parallel Hash Join에서는 모든 워커가 공유 해시 테이블을 함께 구축하므로 단일 스레드 Hash Join보다 빠르게 빌드된다. work_mem 설정에 따라 in-memory 또는 multi-batch로 동작하는데, 워커 수가 N이면 최대 (N+1) x work_mem의 메모리가 사용된다는 점에 유의한다.

PostgreSQL 17의 파티셔닝 신규 기능

MERGE PARTITIONS

여러 파티션을 하나로 병합할 수 있다. 오래된 데이터를 분기별이나 연도별로 통합할 때 유용하다.

-- PostgreSQL 17: 월별 파티션을 분기별로 병합
ALTER TABLE orders
    MERGE PARTITIONS (orders_2026_01, orders_2026_02, orders_2026_03)
    INTO orders_2026_q1;

-- 병합 후 새 파티션 확인
SELECT
    parent.relname AS parent_table,
    child.relname  AS partition_name,
    pg_get_expr(child.relpartbound, child.oid) AS partition_bound
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child  ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;

SPLIT PARTITION

하나의 파티션을 여러 개로 분리한다. 데이터가 너무 커진 파티션을 세분화할 때 사용한다.

-- PostgreSQL 17: 분기 파티션을 다시 월별로 분할
ALTER TABLE orders
    SPLIT PARTITION orders_2026_q1 INTO (
        PARTITION orders_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'),
        PARTITION orders_2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'),
        PARTITION orders_2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
    );

대규모 테이블 파티셔닝 마이그레이션 절차

기존 단일 테이블(수억 건)을 파티셔닝 테이블로 전환하는 것은 운영 환경에서 가장 까다로운 작업 중 하나다. 다운타임을 최소화하는 실전 절차를 정리한다.

방법 1: pg_partman을 활용한 온라인 마이그레이션

-- 1단계: 확장 설치
CREATE EXTENSION IF NOT EXISTS pg_partman;

-- 2단계: 새로운 파티셔닝 테이블 생성
CREATE TABLE orders_partitioned (LIKE orders INCLUDING ALL)
    PARTITION BY RANGE (order_date);

-- 3단계: pg_partman으로 파티션 자동 생성
SELECT partman.create_parent(
    p_parent_table := 'public.orders_partitioned',
    p_control := 'order_date',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3
);

-- 4단계: 데이터 마이그레이션 (배치 처리)
-- 한 번에 전체를 INSERT하면 WAL이 폭증하므로 배치로 나눈다
DO $$
DECLARE
    batch_start DATE := '2020-01-01';
    batch_end   DATE;
BEGIN
    WHILE batch_start < '2026-04-01' LOOP
        batch_end := batch_start + INTERVAL '1 month';
        INSERT INTO orders_partitioned
        SELECT * FROM orders
        WHERE order_date >= batch_start AND order_date < batch_end;
        RAISE NOTICE 'Migrated: % to %', batch_start, batch_end;
        batch_start := batch_end;
        PERFORM pg_sleep(0.5);  -- WAL 부하 분산
    END LOOP;
END $$;

-- 5단계: 테이블 교체 (짧은 잠금)
BEGIN;
ALTER TABLE orders RENAME TO orders_old;
ALTER TABLE orders_partitioned RENAME TO orders;
COMMIT;

-- 6단계: 검증 후 이전 테이블 제거
-- SELECT count(*) FROM orders;
-- SELECT count(*) FROM orders_old;
-- DROP TABLE orders_old;

방법 2: 논리적 복제(Logical Replication) 활용

다운타임이 허용되지 않는 환경에서는 논리적 복제를 활용한다.

  1. 새 파티셔닝 테이블을 별도 스키마에 생성
  2. 논리적 복제 구독(Subscription)으로 실시간 데이터 동기화
  3. 차이 확인 후 짧은 점검 시간 동안 애플리케이션 전환
  4. 구독 해제 및 이전 테이블 정리

운영 시 주의사항

인덱스 관리

파티셔닝 테이블에서 인덱스는 각 파티션에 로컬로 생성된다. 부모 테이블에 인덱스를 생성하면 기존 및 미래 파티션에 자동으로 적용된다. 글로벌 고유 인덱스는 파티션 키를 포함해야 한다.

-- 파티셔닝 테이블의 고유 제약조건: 파티션 키 포함 필수
-- 아래는 오류 발생 (파티션 키 미포함)
-- ALTER TABLE orders ADD CONSTRAINT pk_orders PRIMARY KEY (order_id);

-- 올바른 방법: 파티션 키 포함
ALTER TABLE orders ADD CONSTRAINT pk_orders
    PRIMARY KEY (order_id, order_date);

Vacuum 전략

파티셔닝 테이블에서 VACUUM은 각 파티션에 개별적으로 실행된다. PostgreSQL 17에서는 VACUUM 명령의 병렬 처리가 가능하다.

-- 특정 파티션만 vacuum
VACUUM (VERBOSE, ANALYZE) orders_2026_03;

-- 전체 파티셔닝 테이블 vacuum (각 파티션 순차)
VACUUM (VERBOSE, ANALYZE) orders;

-- autovacuum 튜닝: 파티션별 설정
ALTER TABLE orders_2026_03 SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_analyze_scale_factor = 0.005,
    autovacuum_vacuum_cost_delay = 2
);

제약조건과 트리거

  • CHECK 제약조건은 각 파티션에 독립적으로 정의 가능하다
  • FOREIGN KEY는 파티셔닝 테이블을 참조하는 것은 가능하지만, 파티셔닝 테이블에서 다른 테이블로 FK를 거는 것도 PostgreSQL 12 이후 지원한다
  • BEFORE ROW 트리거는 각 파티션에 정의해야 한다

트러블슈팅: 실패 사례와 복구

사례 1: 병렬 워커가 0개로 실행되는 문제

증상: EXPLAIN ANALYZE에서 Workers Planned: 4인데 Workers Launched: 0

원인과 해결:

-- 현재 설정 확인
SHOW max_worker_processes;          -- 기본 8
SHOW max_parallel_workers;          -- 기본 8
SHOW max_parallel_workers_per_gather;  -- 기본 2

-- 동시 실행 중인 워커 수 확인
SELECT count(*) FROM pg_stat_activity
WHERE backend_type = 'parallel worker';

-- 해결: max_worker_processes 증가 (재시작 필요)
-- postgresql.conf
-- max_worker_processes = 16
-- max_parallel_workers = 12
-- max_parallel_workers_per_gather = 4

전체 워커 풀이 다른 쿼리에 의해 이미 소진된 상태에서 새 쿼리가 실행되면 워커를 0개로 받을 수 있다. 피크 시간대에 병렬 쿼리가 집중되지 않도록 커넥션 풀 설정과 함께 조율해야 한다.

사례 2: 파티션 프루닝 미동작으로 전체 스캔

증상: 특정 월의 데이터만 조회하는데 모든 파티션을 스캔

원인: 파티션 키에 함수를 적용하거나 타입 불일치

-- 문제 쿼리: 타입 불일치
-- 파티션 키가 DATE인데 TIMESTAMP로 비교
SELECT * FROM orders
WHERE order_date = '2026-03-15 00:00:00'::timestamp;

-- 해결: 정확한 타입으로 비교
SELECT * FROM orders
WHERE order_date = '2026-03-15'::date;

-- 프루닝 동작 검증
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE order_date = '2026-03-15'::date;
-- Append -> Seq Scan on orders_2026_03 (프루닝 정상)

사례 3: 파티션 추가 시 LOCK 대기

증상: CREATE TABLE ... PARTITION OF 실행 시 오랫동안 대기

원인: 부모 테이블에 대한 ACCESS EXCLUSIVE LOCK이 필요하며, 동시 트랜잭션이 해당 테이블을 사용 중

해결:

-- lock_timeout 설정으로 빠른 실패
SET lock_timeout = '5s';

-- 비활성 트랜잭션 확인
SELECT pid, state, query_start, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
   OR state = 'idle in transaction';

-- 유지보수 윈도우에서 실행하거나, CONCURRENTLY 옵션 활용
-- (파티션 추가 자체는 CONCURRENTLY 미지원이므로 인덱스만)
CREATE INDEX CONCURRENTLY idx_new_part ON orders_2026_04 (customer_id);

사례 4: work_mem 폭발로 OOM

증상: 병렬 쿼리 실행 중 PostgreSQL 프로세스가 OOM Killer에 의해 종료

원인: work_mem이 워커 수만큼 곱해짐. work_mem = 1GB, 워커 4개면 리더 포함 5 x 1GB = 5GB 사용 가능

-- 안전한 work_mem 계산
-- 총 메모리: 64GB, 최대 동시 연결: 200, 병렬 워커 최대: 4
-- work_mem = 64GB * 0.25 / 200 / 5 = 약 16MB
SET work_mem = '16MB';

-- 특정 쿼리에서만 임시로 높이기
SET LOCAL work_mem = '256MB';
SELECT ... ;  -- 대규모 집계 쿼리
RESET work_mem;

성능 최적화 체크리스트

실전 운영을 위한 체크리스트를 정리한다.

파티셔닝 설계:

  • 파티션 수를 100개 이하로 유지한다. 수천 개의 파티션은 플래너 오버헤드를 발생시킨다.
  • DEFAULT 파티션을 반드시 생성하여 범위 밖 데이터의 INSERT 실패를 방지한다.
  • 파티션 키를 PRIMARY KEY와 UNIQUE 제약조건에 포함시킨다.

병렬 쿼리 튜닝:

  • min_parallel_table_scan_sizemin_parallel_index_scan_size를 워크로드에 맞게 조정한다.
  • parallel_tuple_costparallel_setup_cost를 낮추면 플래너가 병렬 계획을 더 적극적으로 선택한다.
  • jit = on과 병렬 쿼리를 함께 사용하면 OLAP 워크로드에서 추가 성능 향상을 기대할 수 있다.

모니터링 항목:

  • pg_stat_user_tables로 각 파티션의 n_live_tup, n_dead_tup, last_autovacuum 확인
  • pg_stat_activity에서 병렬 워커 사용 현황 모니터링
  • EXPLAIN (ANALYZE, BUFFERS)로 파티션 프루닝과 병렬 실행 검증

참고자료

  1. PostgreSQL 공식 문서 - Table Partitioning
  2. PostgreSQL 공식 문서 - How Parallel Query Works
  3. PostgreSQL 17 릴리스 노트 - 파티셔닝 및 병렬 쿼리 개선
  4. Crunchy Data - Postgres Parallel Query Troubleshooting
  5. Mydbops - PostgreSQL 17 Partitioning Best Practices: MERGE/SPLIT Commands
  6. Microsoft Tech Community - Postgres 17 Query Performance Improvements
  7. AWS Blog - Improve query performance with parallel queries in PostgreSQL
  8. pgMustard - Increasing max_parallel_workers_per_gather