Skip to content

Split View: Database Query Optimizer 완전 가이드 2025: Volcano, Cascades, Cost-Based, Cardinality Estimation — PostgreSQL/MySQL 실전 분석

|

Database Query Optimizer 완전 가이드 2025: Volcano, Cascades, Cost-Based, Cardinality Estimation — PostgreSQL/MySQL 실전 분석

들어가며: SQL 한 줄의 마법

같은 쿼리, 1000배 차이

다음 쿼리를 보자:

SELECT u.name, o.total
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'KR' AND o.created_at > '2025-01-01';

이 한 줄짜리 SQL은 최소 수십 가지 방법으로 실행될 수 있다:

  1. users를 먼저 스캔 → filter → orders와 nested loop join
  2. orders를 먼저 스캔 → filter → users와 hash join
  3. 양쪽 인덱스 사용 → merge join
  4. country 인덱스 + date 인덱스 조합...

각 방식의 성능 차이는 수백~수천 배일 수 있다. 같은 쿼리, 같은 데이터, 완전히 다른 실행 시간.

그 선택을 하는 주인공이 query optimizer다. 당신이 SQL을 입력하면, 옵티마이저는:

  1. 쿼리를 관계 대수로 변환
  2. 가능한 실행 계획들을 생성
  3. 각 계획의 비용을 추정
  4. 가장 싼 것을 선택

이 글은 이 전 과정을 해부한다. Volcano에서 Cascades까지, cost-based optimization의 원리부터 현대 DB의 실전 구현까지.

왜 이걸 알아야 하는가?

  • 느린 쿼리 디버깅: EXPLAIN 결과를 정확히 해석하려면 옵티마이저를 알아야 한다.
  • 인덱스 설계: 옵티마이저가 어떻게 선택하는지 알면 인덱스가 왜 "안 쓰이는지" 안다.
  • 통계 관리: 왜 ANALYZE가 필요한지, 통계가 어떻게 성능을 좌우하는지.
  • DB 간 차이: PostgreSQL, MySQL, Oracle의 옵티마이저는 서로 크게 다르다. 이식할 때 중요.
  • 최신 NewSQL: TiDB, CockroachDB의 옵티마이저는 어떻게 다른가?

1. 옵티마이저의 세 가지 단계

1단계: Parsing

SQL 텍스트를 AST (Abstract Syntax Tree) 로 변환한다.

SELECT * FROM t WHERE id = 5
Select
├── Projection: *
├── From: t
└── Where: Equal(id, 5)

parsing 단계에선 주로 문법 검사만 한다. 이후 catalog 검증(테이블/컬럼 존재 여부)도 한다.

2단계: Rewriting / Logical Optimization

AST를 관계 대수(Relational Algebra) 로 변환하고, 논리적 변환을 적용한다:

  • Predicate pushdown: WHERE 조건을 최대한 안쪽으로.
  • Projection pushdown: 필요한 컬럼만 읽기.
  • Constant folding: 1 + 23으로.
  • Subquery unnesting: 서브쿼리를 조인으로 변환.
  • View expansion: 뷰를 원래 쿼리로 확장.

이 단계는 의미를 바꾸지 않는 변환들이다.

3단계: Physical Optimization / Cost-Based

가능한 물리적 실행 계획들을 생성하고, 비용을 추정해 가장 싼 것을 고른다. 이 단계가 옵티마이저의 진짜 핵심이다.

  • 어떤 access method 쓸까? (full scan, index scan, index only scan)
  • 어떤 join 알고리즘? (nested loop, hash join, merge join)
  • 어떤 join 순서?
  • 어디서 정렬할까?

2. 관계 대수와 논리적 최적화

관계 대수 기본

관계 대수는 집합 이론에 기반한 쿼리 언어의 수학적 모델이다:

  • σ (selection): WHERE (행 필터링).
  • π (projection): SELECT 컬럼 (열 선택).
  • ⋈ (join): 테이블 결합.
  • ∪ (union), ∩ (intersection), - (difference).
  • γ (group): GROUP BY.

SQL:

SELECT name FROM users WHERE age > 30

관계 대수:

π_name(σ_age>30(users))

대수적 동등 변환

관계 대수는 변환 규칙이 있다. 예를 들어:

  • Selection 분배: σ_p∧q(R) = σ_p(σ_q(R))
  • Selection push through join: σ_p(R ⋈ S) = σ_p(R) ⋈ S (p가 R의 컬럼만 사용할 때)
  • Projection push through selection: π_A(σ_p(R)) = π_A(σ_p(π_A∪필요컬럼(R)))
  • Join 교환: R ⋈ S = S ⋈ R
  • Join 결합: (R ⋈ S) ⋈ T = R ⋈ (S ⋈ T)

이 규칙들을 적용하면 같은 결과를 내는 다른 쿼리가 된다.

Predicate Pushdown 예시

SELECT u.name, o.total
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'KR';

초기 계획:

π_{name,total}(σ_{country='KR'}(users ⋈ orders))

users와 orders를 먼저 조인하고 나서 country 필터링 → 비효율.

Predicate pushdown 적용:

π_{name,total}(σ_{country='KR'}(users) ⋈ orders)

users에서 KR만 먼저 필터링하고 orders와 조인 → 훨씬 효율.

현대 옵티마이저는 이런 변환을 수백 가지 자동 적용한다.

Projection Pushdown

SELECT name FROM users JOIN orders ON users.id = orders.user_id;

초기 계획: users와 orders의 모든 컬럼을 가져와서 조인 후 name만 뽑기.

최적화: 조인 전에 users에서 (id, name), orders에서 user_id만 가져오기. 네트워크와 메모리 대폭 절약.

Subquery Unnesting

SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE country = 'KR');

이 쿼리는 두 가지 방식으로 실행될 수 있다:

  1. Correlated: orders의 각 행마다 서브쿼리 실행 → N번의 users 스캔 → 매우 느림.
  2. Unnested: 조인으로 변환.
-- 옵티마이저가 이렇게 변환
SELECT o.* FROM orders o JOIN users u ON o.user_id = u.id
WHERE u.country = 'KR';

좋은 옵티마이저는 이런 변환을 자동으로 한다. 나쁜 옵티마이저(또는 오래된 것)는 correlated execution을 그대로 실행해서 극도로 느린 결과가 나온다.


3. 물리 연산자: 실행의 기본 단위

Access Method: 테이블 읽기

Sequential Scan (Full Table Scan):

[Page 1][Page 2][Page 3]...
   ↓       ↓       ↓
모든 행을 순차적으로 읽음
  • 큰 테이블 + 조건 없음: 빠름 (순차 I/O).
  • 작은 결과 + 조건 있음: 느림 (모든 행을 버려야 함).

Index Scan:

B-Tree
    (조건에 맞는 키 찾기)
Heap → 해당 행 읽기
  • 작은 결과: 매우 빠름.
  • 큰 결과 (선택도 높음): random I/O 많아서 느림.
  • 보통 전체의 10% 이상을 반환하면 full scan이 더 빠르다.

Index Only Scan:

B-Tree
    (인덱스만으로 답변)
Heap 접근 없음

필요한 모든 컬럼이 인덱스에 있으면 heap을 건드리지 않는다. 가장 빠른 접근 방식.

Bitmap Index Scan:

Index 1 → bitmap 1
Index 2 → bitmap 2
AND/OR 결합
Heap scan (정렬된 순서)

여러 인덱스의 결과를 결합. 순차 접근 패턴으로 random I/O를 줄인다.

Join Algorithms

Nested Loop Join (NLJ):

for r in R:
    for s in S:
        if r.key == s.key:
            output(r, s)
  • 복잡도: O(|R| × |S|).
  • 인덱스가 있으면: O(|R| × log|S|).
  • 작은 outer + 인덱스 있는 inner에 최적.

Hash Join:

# Build phase
hash_table = {}
for r in R:
    hash_table[r.key] = r

# Probe phase
for s in S:
    if s.key in hash_table:
        output(hash_table[s.key], s)
  • 복잡도: O(|R| + |S|).
  • 메모리 필요.
  • 큰 테이블 + 인덱스 없음에 최적.
  • Parallel 친화적.

Sort-Merge Join:

sort(R, by=key)
sort(S, by=key)

i, j = 0, 0
while i < len(R) and j < len(S):
    if R[i].key == S[j].key:
        output(R[i], S[j])
        # 중복 처리
    elif R[i].key < S[j].key:
        i += 1
    else:
        j += 1
  • 복잡도: O(|R| log|R| + |S| log|S|).
  • 이미 정렬되어 있으면: O(|R| + |S|).
  • ORDER BY나 인덱스로 이미 정렬된 경우에 최적.
  • Range join 지원.

선택의 기준

어떤 조인을 고를까? 옵티마이저는 비용 함수로 비교한다:

  • NLJ cost: |R| × cost_per_probe
  • Hash join cost: |R| × hash_cost + |S| × probe_cost + build_memory
  • Merge join cost: sort(R) + sort(S) + scan(R) + scan(S)

여기서 핵심은 |R|, |S|를 모른다는 것이다. 옵티마이저는 이를 추정해야 한다.


4. Cost-Based Optimization: 비용의 수학

비용 함수

각 물리 연산자는 비용 공식을 가진다:

Sequential Scan:

cost = seq_page_cost × num_pages + cpu_tuple_cost × num_tuples

Index Scan:

cost = random_page_cost × (index_pages + heap_pages) 
     + cpu_index_tuple_cost × num_matched 
     + cpu_tuple_cost × num_matched

파라미터의 의미

PostgreSQL의 기본값:

seq_page_cost = 1.0          # 순차 디스크 페이지 I/O
random_page_cost = 4.0       # 랜덤 디스크 페이지 I/O
cpu_tuple_cost = 0.01        # 행 처리 CPU 비용
cpu_index_tuple_cost = 0.005 # 인덱스 엔트리 처리
cpu_operator_cost = 0.0025   # 연산자 실행 (=, <, ...)

이 값들은 상대적이다. seq_page_cost = 1.0은 단지 기준일 뿐이다.

SSD 시대의 조정

HDD 시대엔 random I/O가 sequential I/O보다 훨씬 느렸다 (~100배). 그래서 random_page_cost = 4.0으로 설정되어 있다. 하지만 SSD에선 차이가 작다. NVMe는 거의 같다.

# SSD 환경에선
random_page_cost = 1.1

이 하나의 변경만으로도 옵티마이저가 인덱스를 더 많이 사용하게 된다.

effective_cache_size

effective_cache_size = 4GB

이 파라미터는 "OS page cache + PostgreSQL shared buffers에 몇 MB가 캐시될 수 있는가"를 알려준다. 옵티마이저는 이 값을 참고해 인덱스 스캔의 예상 I/O를 조정한다. 크면 인덱스 선호, 작으면 full scan 선호.

Join Cost 예시

SELECT * FROM A JOIN B ON A.id = B.a_id

A: 10,000행, B: 100,000행, B에 a_id 인덱스 있음.

NLJ (A outer):

10,000 × (index_lookup_cost + matched_rows_cost)
10,000 × (4 + 0.01 × 10)  (A 행마다 평균 10 매치)
40,000 + 1000 = 41,000

Hash Join (B as build):

build: 100,000 × cpu_tuple_cost = 1,000
probe: 10,000 × (hash_cost + matched_cost)10,000 × 0.02 = 200
total: 1,200

Hash가 훨씬 싸다. 옵티마이저는 hash join을 선택한다.

주의: 비용은 상대적 비교용이지 절대 시간이 아니다. "cost=1,000"이 "1초"가 아니다. 같은 쿼리 내에서 대안들의 순위를 매기는 용도다.


5. Cardinality Estimation: 옵티마이저의 생명

"얼마나 많은 행이 나올까?"

옵티마이저의 모든 비용 계산은 **각 연산자의 결과 행 수(cardinality)**에 의존한다. 추정이 틀리면 전체 계획이 틀린다.

SELECT * FROM users WHERE country = 'KR' AND age > 30;

질문:

  • 전체 users 수: 100만
  • country='KR'의 선택도: 20% → 20만
  • age > 30의 선택도: 40% → 40만
  • 둘 다 만족: ?

선택도 독립 가정: 20% × 40% = 8% → 8만 행 추정.

이 추정이 실제(예: 5만 행)와 비슷하면 좋은 계획. 완전히 틀리면(예: 50만 행) 재앙.

통계: 옵티마이저의 감각

통계가 정확해야 추정도 정확하다. PostgreSQL의 pg_statistic에 저장되는 정보:

  1. n_distinct: 컬럼의 고유값 수.
  2. null_frac: NULL 비율.
  3. most_common_vals (MCV): 가장 자주 나오는 값들.
  4. most_common_freqs: MCV들의 빈도.
  5. histogram_bounds: 값 분포 히스토그램 (균등 버킷).
  6. correlation: 물리 순서와 논리 순서의 상관관계.

히스토그램

컬럼 age의 히스토그램 (100개 버킷)
[0-5][5-12][12-20][20-25][25-28]...[70-120]
 5%   5%     5%     5%      5%       5%

각 버킷은 전체의 균등 비율(예: 1%)을 담는다. 버킷 범위가 좁을수록 값이 밀집된 구간.

쿼리 WHERE age > 30:

  • 30 이상 버킷이 몇 개인가?
  • 각 버킷은 전체의 1%.
  • 추정: 버킷수 × 1% × 총행수.

MCV (Most Common Values)

값이 매우 편중된 경우 (예: status 컬럼의 90%가 'active'):

  • MCV: ['active', 'inactive', 'pending']
  • MCV freqs: [0.9, 0.08, 0.02]

WHERE status = 'active' → 추정: 0.9 × 총행수.

MCV에 없는 값은 히스토그램으로 추정.

선택도 계산의 함정

함정 1: 독립 가정의 오류

WHERE city = 'Seoul' AND country = 'Korea'

옵티마이저: P(city='Seoul') × P(country='Korea') = 0.1 × 0.2 = 0.02

실제: P(city='Seoul' AND country='Korea') = 0.1 (Seoul은 Korea에만 있음)

5배 차이. 잘못된 계획으로 이어진다.

해결: 확장 통계(Extended Statistics). PostgreSQL 10+에서:

CREATE STATISTICS ON (city, country) FROM addresses;
ANALYZE addresses;

이제 옵티마이저가 두 컬럼의 상관관계를 안다.

함정 2: LIKE 패턴

WHERE name LIKE 'A%'

선택도 추정이 어렵다. 옵티마이저는 MCV와 히스토그램으로 시도하지만 종종 틀린다.

함정 3: 조인 결과 추정

SELECT * FROM A JOIN B ON A.key = B.key

결과 행 수 추정:

|RS||R| × |S| / max(n_distinct(A.key), n_distinct(B.key))

이 공식은 "균등 분포" 가정에 기반한다. 실제론 치우쳐 있으면 오차가 크다.

ANALYZE의 중요성

통계는 ANALYZE 명령으로 업데이트된다:

ANALYZE users;  -- 특정 테이블
ANALYZE;        -- 전체

PostgreSQL은 autovacuum이 주기적으로 ANALYZE를 실행한다. 그러나:

  • 대량 INSERT/UPDATE 후엔 수동 ANALYZE 권장.
  • 통계가 오래되면 옵티마이저가 잘못된 계획을 고른다.
  • 새 테이블에 ANALYZE 없이 대량 조회하면 끔찍한 계획이 나올 수 있다.

6. Volcano Optimizer

Goetz Graefe의 혁신

1993년 Goetz Graefe가 발표한 Volcano Optimizer는 현대 query optimizer의 시작점이다. 핵심 아이디어:

"변환 규칙의 집합으로 모든 가능한 계획을 생성하고, 가장 싼 것을 고른다."

Operator Tree

쿼리는 logical operator의 트리로 표현된다:

Join
├── Scan(A)
└── Scan(B)

옵티마이저의 목표는 이를 physical operator의 트리로 변환하면서 비용을 최소화하는 것:

HashJoin
├── SeqScan(A)
└── IndexScan(B)

Transformation Rules

Volcano는 변환 규칙들의 집합으로 탐색한다:

  • Logical → Logical: Join commutativity, associativity.
  • Logical → Physical: Join → HashJoin, Join → NestedLoopJoin 등.
  • Enforcer: 정렬이 필요하면 Sort 추가.

Top-down 탐색

Volcano는 탐색을 top-down으로 진행한다:

  1. 루트 연산자부터 시작.
  2. 각 연산자에 가능한 물리 구현을 나열.
  3. 자식들도 재귀적으로 최적화.
  4. 모든 하위 트리의 비용을 계산 후 최적 선택.

Memoization

같은 하위 문제를 여러 번 만나면 재계산하면 비싸다. Volcano는 memoization(계산 결과 캐싱)을 사용한다. 같은 하위 식은 한 번만 최적화.

문제점

Volcano는 훌륭하지만 몇 가지 한계가 있었다:

  1. Exhaustive search: 모든 계획을 생성하려 해서 조인 수가 많으면 폭발.
  2. Top-down의 비효율: 상위에서 결정이 하위로 전파되며 여러 번 재방문.

이를 개선한 것이 Cascades다.


7. Cascades Optimizer

Graefe의 차기작

Cascades (1995)도 Goetz Graefe의 작품이다. SQL Server, CockroachDB, Apache Calcite, Greenplum의 옵티마이저가 모두 Cascades 기반.

주요 개선

  1. Unified logical/physical transformations: 규칙을 통일된 방식으로 표현.
  2. Memoization 구조 개선: "memo" 데이터 구조로 중복 최적화 방지.
  3. Property propagation: 정렬, 분포 같은 속성을 전파.
  4. Branch-and-bound pruning: 명백히 나쁜 계획은 일찍 포기.

Memo 구조

Memo는 동등한 식들의 그룹 집합이다:

Group 1: σ_p(R)
Group 2: σ_p(R)S, (RS with σ_p pushed)
Group 3: AB, BA (commutativity)

각 group은 동일한 결과를 내는 여러 표현 방식을 담는다. 옵티마이저는 group 수준에서 비용 비교.

Property

Physical Properties는 계획이 가지는 특성이다:

  • Ordering: 결과가 정렬되어 있는가?
  • Distribution: 분산 DB에서 데이터가 어떻게 파티션되어 있나?
  • Uniqueness: 결과에 중복이 없는가?

부모 연산자가 특정 property를 요구하면(예: Merge Join이 정렬된 입력 요구), 자식이 그걸 제공하거나 enforcer를 추가한다(Sort).

Pruning

탐색이 폭발하는 걸 막기 위해 pruning:

  • 이미 찾은 최적 비용보다 높은 하위 문제는 스킵.
  • Branch-and-bound 알고리즘.

Cascades 기반 옵티마이저들

  • SQL Server: 자체 Cascades 기반 구현.
  • PostgreSQL: Cascades 아님 (dynamic programming 기반).
  • CockroachDB: Cascades 기반.
  • Apache Calcite: Cascades 기반, Hive/Flink/Beam에서 사용.
  • Presto/Trino: Calcite 영감 + 자체 구현.

8. PostgreSQL의 옵티마이저

구조

PostgreSQL은 Volcano/Cascades보다 조금 다른 접근을 쓴다:

  • RewritingLogical plan
  • Plan generation: Dynamic programming + Genetic algorithm.
  • Cost estimation: 이산 비용 함수.
  • Plan selection: 가장 싼 것.

Dynamic Programming Join Ordering

조인 순서 결정에 Dynamic Programming (DP) 를 쓴다. 각 부분집합의 최적 조인을 구하고 조합한다.

  • 복잡도: O(3^n) 정도.
  • n ≤ 12: DP로 정확한 최적.
  • n > 12: geqo_threshold 초과 → Genetic Algorithm으로 전환.

Genetic Algorithm (GEQO)

많은 테이블의 조인(예: 15개)에서는 DP가 너무 느리므로:

  1. 무작위 조인 순서 생성.
  2. "적합도" (예상 비용) 평가.
  3. 좋은 것을 교차/변이.
  4. 여러 세대 진화.
  5. 가장 좋은 것 선택.

정확한 최적은 아니지만 합리적인 해를 빠르게 찾는다. geqo = on (기본).

Generic vs Custom Plan

PostgreSQL은 prepared statement에 대해 특별한 동작을 한다:

  • Custom plan: 매 실행마다 파라미터 기반 최적화.
  • Generic plan: 한 번만 최적화, 이후 재사용.

PostgreSQL은 처음 몇 번은 custom, 이후 generic이 더 싸면 generic으로 전환. 이로 인해 "처음엔 빠르다가 갑자기 느려지는" 현상이 발생할 수 있다.

EXPLAIN

옵티마이저의 결정을 보려면 EXPLAIN:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE) 
SELECT u.name, o.total 
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'KR';
Hash Join  (cost=10.00..250.00 rows=500 width=64) (actual time=0.1..5.0 rows=487 loops=1)
  Hash Cond: (o.user_id = u.id)
  Buffers: shared hit=234
  ->  Seq Scan on orders o  (cost=0.00..150.00 rows=10000 width=16)
  ->  Hash  (cost=8.00..8.00 rows=100 width=56)
        ->  Index Scan using idx_users_country on users u
              Index Cond: (country = 'KR')

해석:

  • cost: 추정 비용 (시작_비용..전체_비용).
  • rows: 추정 행 수.
  • actual: 실제 실행 시 측정값.
  • loops: 이 노드가 실행된 횟수.

추정 rows vs actual rows 차이가 크면 통계 문제. ANALYZE 필요.


9. MySQL의 옵티마이저

역사

MySQL의 옵티마이저는 오랫동안 heuristics 기반이었다:

  • 항상 테이블을 고정 순서로 조인 (왼쪽 깊은 트리).
  • 인덱스 사용 여부를 규칙으로 결정.

5.6 이후 cost-based optimizer를 도입하고 지속적으로 개선 중이다.

Histograms

MySQL 8.0부터 히스토그램 지원:

ANALYZE TABLE users UPDATE HISTOGRAM ON country WITH 100 BUCKETS;

PostgreSQL보다 늦게 추가되었지만 효과적이다.

Optimizer Hints

MySQL은 옵티마이저에게 힌트를 줄 수 있다:

SELECT /*+ INDEX(users idx_country) */ *
FROM users WHERE country = 'KR';

이는 "country 인덱스 써"라는 지시다. PostgreSQL은 힌트를 공식 지원하지 않는다 (철학적 차이).

ICP (Index Condition Pushdown)

MySQL의 유용한 기능. WHERE 조건 중 일부를 인덱스 수준에서 평가:

SELECT * FROM users WHERE country = 'KR' AND age > 30;

인덱스 (country, age)가 있으면:

  1. 일반적: country='KR' 인덱스 매치 → 행 fetch → age 검사.
  2. ICP: country='KR' 인덱스 매치 → age 조건을 인덱스 수준에서 체크 → 통과한 것만 fetch.

random I/O를 크게 줄인다.

Join Buffer (Block Nested Loop)

인덱스가 없을 때 MySQL의 기본 조인은 Block Nested Loop:

join_buffer_size 만큼 outer의 여러 행을 메모리에 모음
inner table을 한 번 스캔하면서 각 outer 블록과 매치
I/O 줄임

Hash join은 MySQL 8.0.18부터 추가되었다. 이전엔 block NLJ가 유일한 대안이었다.


10. 옵티마이저의 한계와 실전 기법

한계 1: 통계가 없거나 오래됨

증상: EXPLAIN의 rows 추정이 실제와 심하게 다름.

해결:

ANALYZE my_table;  -- PostgreSQL
ANALYZE TABLE my_table;  -- MySQL

자동 ANALYZE가 작동하는지 확인. 큰 업데이트 후엔 수동 실행.

한계 2: 조인 수 폭발

20개 테이블 조인은 옵티마이저를 질리게 한다. 시간이 기하급수적으로 늘어난다.

해결:

  • View로 분해: 자주 쓰이는 조합을 뷰로.
  • Subquery로 분리: 부분적으로 먼저 계산.
  • PostgreSQL: join_collapse_limit, from_collapse_limit 조정.
  • MySQL: optimizer_search_depth 조정.

한계 3: 상관 컬럼

WHERE city = 'Seoul' AND country = 'Korea'

독립 가정이 틀려 추정 오류. Extended statistics(PostgreSQL) 또는 histograms + correlation 정보 필요.

한계 4: Skewed data

특정 값이 극도로 많은 경우:

status 컬럼: 'active' 99%, 'deleted' 1%

"all-or-nothing" 쿼리에서 문제:

SELECT * FROM t WHERE status = 'deleted';
  • 통계 없이: "행 수의 50%" 추정 → full scan.
  • MCV로: "1%" 추정 → 인덱스 사용.

MCV가 정확하게 잡혀야 한다.

한계 5: 파라미터 의존 쿼리

PREPARE p AS SELECT * FROM t WHERE x = $1 AND y > $2;

$1 = 'rare_value'$1 = 'common_value'에 맞는 최적 계획이 다를 수 있다. Generic plan은 두 경우 모두에 최적일 수 없다.

해결:

  • Custom plan 강제: PostgreSQL의 plan_cache_mode = force_custom_plan.
  • 동적 SQL: 파라미터화 포기.

실전 기법: Query Rewriting

옵티마이저가 못 푸는 문제를 SQL 재작성으로 해결:

원본 (느림):

SELECT * FROM orders
WHERE EXISTS (
  SELECT 1 FROM users
  WHERE users.id = orders.user_id AND users.country = 'KR'
);

재작성 (빠름):

SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.country = 'KR';

현대 옵티마이저는 대부분 자동 변환하지만, 항상은 아니다.

실전 기법: Materialized View

자주 실행되는 비싼 쿼리를 미리 계산해서 저장:

CREATE MATERIALIZED VIEW user_order_summary AS
SELECT u.id, u.name, COUNT(o.id) as order_count, SUM(o.total) as total_spent
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

REFRESH MATERIALIZED VIEW user_order_summary;

옵티마이저는 (PostgreSQL 기본으론) 자동 활용하지 않지만, 애플리케이션에서 직접 사용 가능.

실전 기법: Hint or Plan Pinning

마지막 수단: 특정 계획 강제.

Oracle:

SELECT /*+ INDEX(t idx_t_name) */ * FROM t WHERE name = 'foo';

PostgreSQL (pg_hint_plan 확장):

/*+ IndexScan(t idx_t_name) */
SELECT * FROM t WHERE name = 'foo';

MySQL:

SELECT * FROM t USE INDEX (idx_t_name) WHERE name = 'foo';

힌트는 유혹적이지만 위험하다. 데이터 분포가 바뀌면 더 이상 최적이 아닐 수 있다. 최후의 수단으로.


11. 분산 DB의 옵티마이저

추가 고려사항

분산 DB(CockroachDB, TiDB, Spanner)의 옵티마이저는 추가로 고려할 것들:

  • 데이터 위치(locality): 어떤 노드에 어떤 데이터가 있는가.
  • 네트워크 비용: 조인을 어디서 할까?
  • 병렬성: 여러 노드에서 동시 실행.
  • Exchange 연산자: 노드 간 데이터 이동.

Distributed Join Strategies

Broadcast Join: 작은 테이블을 모든 노드에 복제.

Small table → broadcast → 각 노드에서 local join

Shuffle Join (Hash Redistribution): 조인 키 해시로 노드에 재분배.

Both tables → 조인 키 해시 → 같은 키가 같은 노드로

Collocated Join: 두 테이블이 이미 같은 키로 파티션되어 있으면 로컬에서 바로.

옵티마이저는 이들의 비용을 비교해 선택한다.

TiDB의 옵티마이저

TiDB는 Cost-Based + Rule-Based 하이브리드:

  • Rule-based: 항상 적용되는 변환 (predicate pushdown 등).
  • Cost-based: 계획 선택에 cardinality + 비용 모델.
  • Statistics: 히스토그램 + Count-Min Sketch for skew 처리.

분산 환경의 추가 복잡도를 극복하기 위해 많은 연구가 진행 중이다.


퀴즈로 복습하기

Q1. 옵티마이저가 "좋은 계획"을 고르려면 가장 중요한 입력은 무엇이며, 왜인가?

A. 통계(statistics), 특히 정확한 cardinality 추정이다. 옵티마이저의 모든 비용 계산은 각 단계에서 나오는 예상 행 수에 기반한다. 추정이 틀리면:

  1. 선택한 access method가 부적절 (큰 결과에 인덱스 스캔, 작은 결과에 full scan).
  2. 조인 알고리즘 잘못 선택 (nested loop이 최악).
  3. 조인 순서 잘못 → 중간 결과 폭발.

통계가 없거나 오래되면 이런 일이 반드시 일어난다. 그래서 데이터베이스 튜닝의 첫 번째 단계는 항상 "ANALYZE를 실행했는가?" 를 확인하는 것이다. 대량 INSERT/UPDATE 후, 새 인덱스 추가 후엔 수동 ANALYZE가 필수다.

Q2. PostgreSQL에서 random_page_cost를 4.0에서 1.1로 낮추면 어떤 일이 벌어지는가?

A. 옵티마이저가 인덱스 스캔을 더 선호하게 된다. 이유:

  • Index Scan은 heap에 random I/O를 발생시킨다.
  • 기본값 4.0은 random I/O가 sequential의 4배 느리다는 가정 — HDD 시대 유산.
  • 현대 SSD/NVMe에선 차이가 거의 없다 (1.1이 더 현실적).

이 변경 후 변화:

  1. 더 많은 쿼리가 인덱스 스캔으로 전환.
  2. 선택도가 낮은 (적은 행 반환) 조건에서 full scan 회피.
  3. 일반적으로 전체 성능 향상, 특히 random I/O가 저렴한 환경에서.

주의: 이 파라미터는 옵티마이저의 추정을 바꿀 뿐, 실제 성능을 직접 바꾸진 않는다. 환경에 맞지 않으면 잘못된 계획으로 이어질 수 있다. SSD에선 1.1~1.5, HDD에선 4.0 유지, 네트워크 스토리지는 그 중간이 적절하다.

Q3. "선택도 독립 가정(independent selectivity assumption)"의 문제와 PostgreSQL의 해결책은?

A. 문제: 옵티마이저는 P(A and B) = P(A) × P(B)로 가정한다. 두 조건이 독립이면 맞지만, 실제론 상관관계가 있는 경우가 많다:

WHERE city = 'Seoul' AND country = 'Korea'
  • 추정: 10% × 20% = 2%
  • 실제: 10% (Seoul은 Korea에만)
  • 5배 과소 추정 → 잘못된 계획.

PostgreSQL 해결책 (10+): Extended Statistics

CREATE STATISTICS stat_addr (dependencies) ON city, country FROM addresses;
CREATE STATISTICS stat_addr_ndist (ndistinct) ON city, country FROM addresses;
ANALYZE addresses;

세 가지 타입:

  1. dependencies: 함수적 의존성 감지 (city → country).
  2. ndistinct: 여러 컬럼의 고유 조합 수.
  3. MCV (multivariate): 여러 컬럼의 가장 흔한 값 조합.

이 정보로 옵티마이저는 상관관계를 고려할 수 있다. 매우 효과적이지만 추가 통계 유지 비용이 있으므로 문제가 되는 쿼리에 선택적으로 적용.

Q4. Volcano와 Cascades 옵티마이저의 차이는?

A.

Volcano (1993):

  • Top-down 탐색.
  • Exhaustive한 계획 생성.
  • Memoization 있음.
  • 변환 규칙 기반.
  • 한계: 많은 조인에서 탐색 공간 폭발, property 처리 부족.

Cascades (1995):

  • Volcano의 개선판 (같은 저자).
  • Memo 데이터 구조로 동등한 식들을 그룹화.
  • Physical properties 전파 (ordering, distribution).
  • Branch-and-bound pruning.
  • Enforcer: 필요한 속성을 제공하기 위해 Sort, Exchange 추가.

비유: Volcano가 "모든 계획을 만들고 골라보자"라면, Cascades는 "그룹 단위로 비교하고, 필요한 속성만 요구하며, 명백히 나쁜 건 일찍 버리자".

사용처: SQL Server, CockroachDB, Apache Calcite (Cascades 기반). PostgreSQL은 둘 다 아닌 자체 DP 기반. Volcano는 연구/강의에 주로 등장하고, Cascades가 실전의 표준이 되었다.

Q5. EXPLAIN ANALYZE에서 "estimated rows"와 "actual rows"가 크게 다를 때의 진단 순서는?

A. 이는 옵티마이저가 잘못된 추정을 했다는 신호다. 진단 순서:

1. ANALYZE 실행 여부 확인

SELECT last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname='mytable';

너무 오래됐으면 수동 실행: ANALYZE mytable;

2. 통계 품질 확인

SELECT attname, n_distinct, most_common_vals, histogram_bounds
FROM pg_stats WHERE tablename = 'mytable';
  • n_distinct가 이상하면 샘플 크기 부족 → ALTER TABLE ... SET STATISTICS 1000.
  • MCV가 비어 있으면 데이터가 균등 분포가 아닐 수 있음.

3. 상관 컬럼 의심 여러 WHERE 조건이 있는데 모두 함께 나타나는 경우가 있다면 extended statistics:

CREATE STATISTICS s ON col1, col2 FROM mytable;

4. 서브쿼리/함수 호출 WHERE col = get_default() 같은 함수 호출은 선택도를 추정하지 못한다.

5. 조인 결과 오추정 중간 조인 결과가 이상하면 조인 참여 컬럼의 n_distinct가 부정확할 가능성.

6. 힌트/재작성 위 단계가 모두 실패하면 pg_hint_plan 확장으로 특정 계획 강제, 또는 쿼리 재작성 (예: CTE 사용, 서브쿼리 인라인).

핵심은 "통계 → 데이터 특성 → 쿼리 구조" 순서로 접근하는 것이다. 대부분의 문제는 ANALYZE 한 번으로 해결된다.


마치며: 옵티마이저는 협력자다

핵심 정리

  1. 옵티마이저는 통계에 의존한다. ANALYZE를 소홀히 하지 마라.
  2. Cost 기반이다. 비용 파라미터는 환경에 맞춰 튜닝.
  3. Cardinality estimation이 생명이다. 상관 컬럼엔 extended statistics.
  4. EXPLAIN을 읽어라. estimated vs actual의 차이가 진단의 시작.
  5. 조인 순서는 옵티마이저의 어려운 과제. 테이블 수가 많으면 힌트나 재작성 고려.
  6. Cascades가 현대의 표준이지만 PostgreSQL은 자체 DP.
  7. 힌트는 최후의 수단. 통계와 구조부터 고쳐라.

옵티마이저와의 협력

옵티마이저를 이 아니라 협력자로 생각하자:

  • 통계를 주어라: ANALYZE, extended statistics.
  • 명확한 쿼리를 써라: 옵티마이저가 이해하기 쉬운 SQL.
  • 인덱스를 제공하라: 필요한 access path 확보.
  • 측정하라: EXPLAIN ANALYZE로 현실을 확인.
  • 신뢰하라, 그러나 검증하라: 대부분의 경우 옵티마이저가 맞다.

마지막 교훈

옵티마이저는 수십 년간의 연구와 수만 명의 엔지니어가 쏟아부은 결과다. 그것을 이기려 들지 말자. 대신 옵티마이저가 올바른 결정을 내리게 도와주자. 정확한 통계, 적절한 인덱스, 명료한 쿼리. 이 셋이 모이면 SQL은 마법처럼 빠르게 돈다.

다음에 느린 쿼리를 만나면, 먼저 물어보자:

  • "통계는 최신인가?"
  • "옵티마이저가 본 cardinality가 실제와 맞는가?"
  • "내가 제공한 인덱스와 통계로 최적화할 수 있는 구조인가?"

답이 "그렇다"라면, 옵티마이저는 거의 항상 올바른 답을 준다.


참고 자료

Database Query Optimizer Complete Guide 2025: Volcano, Cascades, Cost-Based, Cardinality Estimation — PostgreSQL/MySQL In-Depth Analysis

Introduction: The Magic of One SQL Line

Same Query, 1000x Difference

Consider the following query:

SELECT u.name, o.total
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'KR' AND o.created_at > '2025-01-01';

This one-line SQL can be executed in at least dozens of ways:

  1. Scan users first → filter → nested loop join with orders
  2. Scan orders first → filter → hash join with users
  3. Use indexes on both sides → merge join
  4. Combine country index + date index...

The performance difference between these methods can be hundreds to thousands of times. Same query, same data, completely different execution times.

The one making that choice is the query optimizer. When you submit SQL, the optimizer:

  1. Converts the query to relational algebra
  2. Generates possible execution plans
  3. Estimates the cost of each plan
  4. Picks the cheapest one

This article dissects the entire process. From Volcano to Cascades, from the principles of cost-based optimization to real-world implementations in modern databases.

Why Should You Know This?

  • Debugging slow queries: To read EXPLAIN output accurately, you need to understand the optimizer.
  • Index design: Knowing how the optimizer picks helps you understand why an index "isn't being used."
  • Statistics management: Why ANALYZE matters, how statistics govern performance.
  • Cross-DB differences: The optimizers in PostgreSQL, MySQL, and Oracle differ significantly. Important when porting.
  • Modern NewSQL: How are the optimizers in TiDB and CockroachDB different?

1. The Three Stages of an Optimizer

Stage 1: Parsing

Converts SQL text into an AST (Abstract Syntax Tree).

SELECT * FROM t WHERE id = 5
Select
├── Projection: *
├── From: t
└── Where: Equal(id, 5)

The parsing stage mainly checks syntax. It also performs catalog validation afterwards (does the table/column exist).

Stage 2: Rewriting / Logical Optimization

Converts the AST into relational algebra and applies logical transformations:

  • Predicate pushdown: Push WHERE conditions as far inside as possible.
  • Projection pushdown: Read only the necessary columns.
  • Constant folding: Turn 1 + 2 into 3.
  • Subquery unnesting: Convert subqueries into joins.
  • View expansion: Expand views back into their original queries.

These transformations do not change semantics.

Stage 3: Physical Optimization / Cost-Based

Generates possible physical execution plans, estimates their cost, and picks the cheapest. This stage is the real heart of the optimizer.

  • Which access method to use? (full scan, index scan, index only scan)
  • Which join algorithm? (nested loop, hash join, merge join)
  • Which join order?
  • Where to sort?

2. Relational Algebra and Logical Optimization

Relational Algebra Basics

Relational algebra is the mathematical model of query languages based on set theory:

  • σ (selection): WHERE (row filtering).
  • π (projection): SELECT columns (column selection).
  • ⋈ (join): Table combination.
  • ∪ (union), ∩ (intersection), - (difference).
  • γ (group): GROUP BY.

SQL:

SELECT name FROM users WHERE age > 30

Relational algebra:

π_name(σ_age>30(users))

Algebraic Equivalence Transformations

Relational algebra has transformation rules. For example:

  • Selection distribution: σ_p∧q(R) = σ_p(σ_q(R))
  • Selection push through join: σ_p(R ⋈ S) = σ_p(R) ⋈ S (when p only uses R's columns)
  • Projection push through selection: π_A(σ_p(R)) = π_A(σ_p(π_A∪required(R)))
  • Join commutativity: R ⋈ S = S ⋈ R
  • Join associativity: (R ⋈ S) ⋈ T = R ⋈ (S ⋈ T)

Applying these rules produces a different query with the same result.

Predicate Pushdown Example

SELECT u.name, o.total
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'KR';

Initial plan:

π_{name,total}(σ_{country='KR'}(users ⋈ orders))

Join users and orders first, then filter by country → inefficient.

With predicate pushdown:

π_{name,total}(σ_{country='KR'}(users) ⋈ orders)

Filter users for KR first, then join with orders → much more efficient.

Modern optimizers apply hundreds of such transformations automatically.

Projection Pushdown

SELECT name FROM users JOIN orders ON users.id = orders.user_id;

Initial plan: fetch all columns of users and orders, join, then extract only name.

Optimized: before the join, fetch only (id, name) from users and user_id from orders. Dramatic savings in network and memory.

Subquery Unnesting

SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE country = 'KR');

This query can be executed two ways:

  1. Correlated: Run the subquery for each orders row → N users scans → very slow.
  2. Unnested: Convert to a join.
-- The optimizer rewrites it like this
SELECT o.* FROM orders o JOIN users u ON o.user_id = u.id
WHERE u.country = 'KR';

A good optimizer performs this transformation automatically. A poor (or older) optimizer executes correlated execution as-is, producing extremely slow results.


3. Physical Operators: The Basic Units of Execution

Access Method: Reading Tables

Sequential Scan (Full Table Scan):

[Page 1][Page 2][Page 3]...
   ↓       ↓       ↓
Read all rows sequentially
  • Large table + no condition: fast (sequential I/O).
  • Small result + conditions: slow (must discard all rows).

Index Scan:

B-Tree
   ↓ (find keys matching the condition)
Heap → read the corresponding rows
  • Small result: very fast.
  • Large result (high selectivity): slow due to heavy random I/O.
  • Generally if more than 10% of the total is returned, full scan wins.

Index Only Scan:

B-Tree
   ↓ (answer from index alone)
No heap access

If all needed columns are in the index, the heap is never touched. The fastest access method.

Bitmap Index Scan:

Index 1 → bitmap 1
Index 2 → bitmap 2
AND/OR combination
Heap scan (in sorted order)

Combine results from multiple indexes. Converts to sequential access pattern to reduce random I/O.

Join Algorithms

Nested Loop Join (NLJ):

for r in R:
    for s in S:
        if r.key == s.key:
            output(r, s)
  • Complexity: O(|R| × |S|).
  • With an index: O(|R| × log|S|).
  • Best for small outer + indexed inner.

Hash Join:

# Build phase
hash_table = {}
for r in R:
    hash_table[r.key] = r

# Probe phase
for s in S:
    if s.key in hash_table:
        output(hash_table[s.key], s)
  • Complexity: O(|R| + |S|).
  • Requires memory.
  • Best for large tables + no index.
  • Parallelism-friendly.

Sort-Merge Join:

sort(R, by=key)
sort(S, by=key)

i, j = 0, 0
while i < len(R) and j < len(S):
    if R[i].key == S[j].key:
        output(R[i], S[j])
        # Handle duplicates
    elif R[i].key < S[j].key:
        i += 1
    else:
        j += 1
  • Complexity: O(|R| log|R| + |S| log|S|).
  • If already sorted: O(|R| + |S|).
  • Best when ORDER BY or an index already sorts the input.
  • Supports range joins.

Selection Criteria

Which join to pick? The optimizer compares via a cost function:

  • NLJ cost: |R| × cost_per_probe
  • Hash join cost: |R| × hash_cost + |S| × probe_cost + build_memory
  • Merge join cost: sort(R) + sort(S) + scan(R) + scan(S)

The key point here is that the optimizer does not know |R| and |S|. It must estimate them.


4. Cost-Based Optimization: The Mathematics of Cost

Cost Functions

Each physical operator has its own cost formula:

Sequential Scan:

cost = seq_page_cost × num_pages + cpu_tuple_cost × num_tuples

Index Scan:

cost = random_page_cost × (index_pages + heap_pages) 
     + cpu_index_tuple_cost × num_matched 
     + cpu_tuple_cost × num_matched

Parameter Meanings

PostgreSQL defaults:

seq_page_cost = 1.0          # Sequential disk page I/O
random_page_cost = 4.0       # Random disk page I/O
cpu_tuple_cost = 0.01        # Row processing CPU cost
cpu_index_tuple_cost = 0.005 # Index entry processing
cpu_operator_cost = 0.0025   # Operator execution (=, <, ...)

These values are relative. seq_page_cost = 1.0 is just a reference.

Adjustments for the SSD Era

In the HDD era, random I/O was much slower than sequential I/O (about 100x). That's why random_page_cost = 4.0 was set. But on SSDs the gap is small. On NVMe it's nearly identical.

# In SSD environments
random_page_cost = 1.1

This single change alone leads the optimizer to use indexes more often.

effective_cache_size

effective_cache_size = 4GB

This parameter tells the optimizer "how many MB can be cached in OS page cache + PostgreSQL shared buffers." The optimizer uses this to adjust expected I/O for index scans. Larger → prefers indexes, smaller → prefers full scan.

Join Cost Example

SELECT * FROM A JOIN B ON A.id = B.a_id

A: 10,000 rows, B: 100,000 rows, B has an a_id index.

NLJ (A outer):

10,000 × (index_lookup_cost + matched_rows_cost)
≈ 10,000 × (4 + 0.01 × 10)  (10 matches per A row on average)
≈ 40,000 + 1000 = 41,000

Hash Join (B as build):

build: 100,000 × cpu_tuple_cost = 1,000
probe: 10,000 × (hash_cost + matched_cost) ≈ 10,000 × 0.02 = 200
total: 1,200

Hash is much cheaper. The optimizer picks hash join.

Note: Cost is for relative comparison, not absolute time. "cost=1,000" is not "1 second." It's used to rank alternatives within the same query.


5. Cardinality Estimation: The Lifeblood of the Optimizer

"How Many Rows Will This Produce?"

All cost calculations depend on the result row count (cardinality) of each operator. If the estimate is wrong, the entire plan is wrong.

SELECT * FROM users WHERE country = 'KR' AND age > 30;

Questions:

  • Total users: 1 million
  • Selectivity of country='KR': 20% → 200k
  • Selectivity of age > 30: 40% → 400k
  • Both satisfied: ?

Independence assumption for selectivity: 20% × 40% = 8% → estimated 80k rows.

If this estimate is close to reality (say 50k), the plan is good. If it's completely off (say 500k), disaster.

Statistics: The Optimizer's Sense Organs

Accurate statistics lead to accurate estimates. Information stored in PostgreSQL's pg_statistic:

  1. n_distinct: number of distinct values in a column.
  2. null_frac: NULL ratio.
  3. most_common_vals (MCV): the most frequently occurring values.
  4. most_common_freqs: frequencies of the MCVs.
  5. histogram_bounds: value distribution histogram (equal-frequency buckets).
  6. correlation: correlation between physical and logical order.

Histograms

Histogram for column age (100 buckets)
[0-5][5-12][12-20][20-25][25-28]...[70-120]
 5%   5%     5%     5%      5%       5%

Each bucket holds an equal fraction (e.g., 1%) of the total. Narrower bucket ranges indicate denser value regions.

Query WHERE age > 30:

  • How many buckets are at or above 30?
  • Each bucket is 1% of the total.
  • Estimate: bucket_count × 1% × total_rows.

MCV (Most Common Values)

When values are heavily skewed (e.g., 90% of status is 'active'):

  • MCV: ['active', 'inactive', 'pending']
  • MCV freqs: [0.9, 0.08, 0.02]

WHERE status = 'active' → estimate: 0.9 × total_rows.

Values not in MCV are estimated via the histogram.

Pitfalls in Selectivity Computation

Pitfall 1: The Independence Assumption Fallacy

WHERE city = 'Seoul' AND country = 'Korea'

Optimizer: P(city='Seoul') × P(country='Korea') = 0.1 × 0.2 = 0.02

Reality: P(city='Seoul' AND country='Korea') = 0.1 (Seoul only exists in Korea)

5x off. Leads to a bad plan.

Solution: Extended Statistics. In PostgreSQL 10+:

CREATE STATISTICS ON (city, country) FROM addresses;
ANALYZE addresses;

Now the optimizer knows the correlation between the two columns.

Pitfall 2: LIKE Patterns

WHERE name LIKE 'A%'

Selectivity is hard to estimate. The optimizer tries with MCVs and histograms, but often misses.

Pitfall 3: Estimating Join Results

SELECT * FROM A JOIN B ON A.key = B.key

Result row count estimate:

|R ⋈ S| ≈ |R| × |S| / max(n_distinct(A.key), n_distinct(B.key))

This formula is based on an "even distribution" assumption. In reality, skew causes large errors.

The Importance of ANALYZE

Statistics are updated with the ANALYZE command:

ANALYZE users;  -- Specific table
ANALYZE;        -- Everything

PostgreSQL's autovacuum runs ANALYZE periodically. However:

  • After bulk INSERT/UPDATE, a manual ANALYZE is recommended.
  • Stale statistics cause the optimizer to pick bad plans.
  • Querying new tables at scale without ANALYZE may yield horrible plans.

6. Volcano Optimizer

Goetz Graefe's Innovation

The Volcano Optimizer, published by Goetz Graefe in 1993, is the starting point of modern query optimizers. The core idea:

"Generate every possible plan using a set of transformation rules, and pick the cheapest."

Operator Tree

A query is represented as a tree of logical operators:

Join
├── Scan(A)
└── Scan(B)

The optimizer's goal is to convert this into a tree of physical operators while minimizing cost:

HashJoin
├── SeqScan(A)
└── IndexScan(B)

Transformation Rules

Volcano explores with sets of transformation rules:

  • Logical → Logical: Join commutativity, associativity.
  • Logical → Physical: Join → HashJoin, Join → NestedLoopJoin, etc.
  • Enforcer: Add a Sort if ordering is required.

Volcano performs a top-down search:

  1. Start from the root operator.
  2. Enumerate possible physical implementations for each operator.
  3. Recursively optimize children.
  4. After computing costs of all subtrees, pick the optimum.

Memoization

Re-computing the same subproblem many times is expensive. Volcano uses memoization (caching computed results). The same subexpression is optimized only once.

Drawbacks

Volcano is excellent but had several limitations:

  1. Exhaustive search: Tries to generate every plan, exploding with many joins.
  2. Top-down inefficiency: Decisions at the top propagate down and cause repeated revisits.

The improvement over this was Cascades.


7. Cascades Optimizer

Graefe's Next Work

Cascades (1995) is also Goetz Graefe's work. The optimizers of SQL Server, CockroachDB, Apache Calcite, and Greenplum are all Cascades-based.

Key Improvements

  1. Unified logical/physical transformations: Rules expressed in a unified way.
  2. Better memoization structure: The "memo" data structure prevents redundant optimization.
  3. Property propagation: Propagates properties like ordering and distribution.
  4. Branch-and-bound pruning: Obviously bad plans are abandoned early.

The Memo Structure

The memo is a set of groups of equivalent expressions:

Group 1: σ_p(R)
Group 2: σ_p(R) ⋈ S, (R ⋈ S with σ_p pushed)
Group 3: A ⋈ B, B ⋈ A (commutativity)

Each group holds multiple expressions that yield the same result. The optimizer compares costs at the group level.

Properties

Physical Properties are characteristics of a plan:

  • Ordering: Is the result sorted?
  • Distribution: In a distributed DB, how is the data partitioned?
  • Uniqueness: Are there no duplicates in the result?

When a parent operator requires a certain property (e.g., Merge Join requires sorted input), the child either provides it or an enforcer is added (Sort).

Pruning

To prevent the search from exploding, pruning:

  • Skip subproblems whose cost already exceeds the best found.
  • Branch-and-bound algorithm.

Cascades-Based Optimizers

  • SQL Server: Its own Cascades-based implementation.
  • PostgreSQL: Not Cascades (dynamic-programming-based).
  • CockroachDB: Cascades-based.
  • Apache Calcite: Cascades-based, used in Hive/Flink/Beam.
  • Presto/Trino: Calcite-inspired + custom implementation.

8. PostgreSQL's Optimizer

Structure

PostgreSQL takes a slightly different approach from Volcano/Cascades:

  • RewritingLogical plan
  • Plan generation: Dynamic programming + Genetic algorithm.
  • Cost estimation: Discrete cost functions.
  • Plan selection: Cheapest wins.

Dynamic Programming Join Ordering

Join ordering uses Dynamic Programming (DP). It computes the optimal join of each subset and combines them.

  • Complexity: around O(3^n).
  • n ≤ 12: DP gives the exact optimum.
  • n > 12: exceeds geqo_threshold → switch to Genetic Algorithm.

Genetic Algorithm (GEQO)

For joins across many tables (e.g., 15), DP is too slow, so:

  1. Generate random join orders.
  2. Evaluate "fitness" (estimated cost).
  3. Cross over and mutate good ones.
  4. Evolve over generations.
  5. Pick the best.

Not exactly optimal, but finds a reasonable solution quickly. geqo = on (default).

Generic vs Custom Plan

PostgreSQL behaves specially for prepared statements:

  • Custom plan: Optimized per execution based on parameters.
  • Generic plan: Optimized once, then reused.

PostgreSQL uses custom plans for the first few executions, then switches to generic if generic is cheaper. This can cause the phenomenon where "it's fast at first, then suddenly slow."

EXPLAIN

To see the optimizer's decisions, use EXPLAIN:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE) 
SELECT u.name, o.total 
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.country = 'KR';
Hash Join  (cost=10.00..250.00 rows=500 width=64) (actual time=0.1..5.0 rows=487 loops=1)
  Hash Cond: (o.user_id = u.id)
  Buffers: shared hit=234
  ->  Seq Scan on orders o  (cost=0.00..150.00 rows=10000 width=16)
  ->  Hash  (cost=8.00..8.00 rows=100 width=56)
        ->  Index Scan using idx_users_country on users u
              Index Cond: (country = 'KR')

Interpretation:

  • cost: estimated cost (startup_cost..total_cost).
  • rows: estimated row count.
  • actual: measurement during actual execution.
  • loops: how many times this node ran.

A big gap between estimated rows and actual rows indicates a statistics problem. ANALYZE needed.


9. MySQL's Optimizer

History

MySQL's optimizer was heuristics-based for a long time:

  • Always joins tables in fixed order (left-deep tree).
  • Decides index usage via rules.

Since 5.6 it has introduced a cost-based optimizer and continues to improve.

Histograms

Histograms are supported from MySQL 8.0:

ANALYZE TABLE users UPDATE HISTOGRAM ON country WITH 100 BUCKETS;

Added later than PostgreSQL but effective.

Optimizer Hints

MySQL allows giving hints to the optimizer:

SELECT /*+ INDEX(users idx_country) */ *
FROM users WHERE country = 'KR';

This tells it "use the country index." PostgreSQL doesn't officially support hints (philosophical difference).

ICP (Index Condition Pushdown)

A useful MySQL feature. Evaluates some WHERE conditions at the index level:

SELECT * FROM users WHERE country = 'KR' AND age > 30;

With an index (country, age):

  1. Normal: match country='KR' in the index → fetch rows → check age.
  2. ICP: match country='KR' in the index → check age condition at the index level → fetch only those that pass.

Greatly reduces random I/O.

Join Buffer (Block Nested Loop)

When no index exists, MySQL's default join is Block Nested Loop:

Gather several outer rows into memory up to join_buffer_size
Scan the inner table once, matching each outer block
Reduced I/O

Hash join was added in MySQL 8.0.18. Before that, block NLJ was the only alternative.


10. Optimizer Limitations and Real-World Techniques

Limitation 1: Missing or Stale Statistics

Symptom: The rows estimate in EXPLAIN is badly off from reality.

Solution:

ANALYZE my_table;  -- PostgreSQL
ANALYZE TABLE my_table;  -- MySQL

Verify automatic ANALYZE works. After big updates, run it manually.

Limitation 2: Join Explosion

Joining 20 tables exhausts the optimizer. Time grows exponentially.

Solutions:

  • Decompose via views: Frequently used combinations as views.
  • Split via subqueries: Compute parts first.
  • PostgreSQL: Tune join_collapse_limit, from_collapse_limit.
  • MySQL: Tune optimizer_search_depth.

Limitation 3: Correlated Columns

WHERE city = 'Seoul' AND country = 'Korea'

Independence assumption fails, causing estimation errors. Use extended statistics (PostgreSQL) or histograms with correlation information.

Limitation 4: Skewed Data

When a particular value is extremely frequent:

status column: 'active' 99%, 'deleted' 1%

Problematic in "all-or-nothing" queries:

SELECT * FROM t WHERE status = 'deleted';
  • Without stats: estimate "50% of rows" → full scan.
  • With MCV: estimate "1%" → use index.

MCV needs to be captured accurately.

Limitation 5: Parameter-Dependent Queries

PREPARE p AS SELECT * FROM t WHERE x = $1 AND y > $2;

The optimal plan may differ for $1 = 'rare_value' vs $1 = 'common_value'. A generic plan cannot be optimal for both cases.

Solutions:

  • Force custom plan: PostgreSQL's plan_cache_mode = force_custom_plan.
  • Dynamic SQL: Give up parameterization.

Practical Technique: Query Rewriting

For problems the optimizer can't solve, rewrite SQL:

Original (slow):

SELECT * FROM orders
WHERE EXISTS (
  SELECT 1 FROM users
  WHERE users.id = orders.user_id AND users.country = 'KR'
);

Rewritten (fast):

SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE u.country = 'KR';

Modern optimizers do most such rewrites automatically, but not always.

Practical Technique: Materialized Views

Precompute and store expensive, frequently run queries:

CREATE MATERIALIZED VIEW user_order_summary AS
SELECT u.id, u.name, COUNT(o.id) as order_count, SUM(o.total) as total_spent
FROM users u LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

REFRESH MATERIALIZED VIEW user_order_summary;

The optimizer (by default in PostgreSQL) doesn't exploit these automatically, but the application can use them directly.

Practical Technique: Hints or Plan Pinning

The last resort: force a specific plan.

Oracle:

SELECT /*+ INDEX(t idx_t_name) */ * FROM t WHERE name = 'foo';

PostgreSQL (pg_hint_plan extension):

/*+ IndexScan(t idx_t_name) */
SELECT * FROM t WHERE name = 'foo';

MySQL:

SELECT * FROM t USE INDEX (idx_t_name) WHERE name = 'foo';

Hints are tempting but risky. If data distribution changes, they may no longer be optimal. Use only as a last resort.


11. Optimizers in Distributed Databases

Additional Considerations

Optimizers in distributed DBs (CockroachDB, TiDB, Spanner) have additional concerns:

  • Data locality: Which node has which data.
  • Network cost: Where to perform the join?
  • Parallelism: Execute concurrently on multiple nodes.
  • Exchange operator: Data movement between nodes.

Distributed Join Strategies

Broadcast Join: Replicate a small table to all nodes.

Small table → broadcast → local join at each node

Shuffle Join (Hash Redistribution): Redistribute to nodes by hashing the join key.

Both tables → hash on join key → same keys to the same node

Collocated Join: If both tables are already partitioned on the same key, join locally.

The optimizer compares their costs and chooses.

TiDB's Optimizer

TiDB is a Cost-Based + Rule-Based hybrid:

  • Rule-based: always-applied transforms (predicate pushdown, etc.).
  • Cost-based: cardinality + cost model for plan selection.
  • Statistics: histograms + Count-Min Sketch for skew handling.

Significant research continues to overcome the extra complexity of distributed environments.


Quiz for Review

Q1. What is the most important input for an optimizer to pick a "good plan," and why?

A. Statistics, particularly accurate cardinality estimation. All cost calculations in the optimizer are based on the expected row count from each stage. If estimates are wrong:

  1. The chosen access method becomes inappropriate (index scan on a big result, full scan on a small one).
  2. Wrong join algorithm (nested loop is the worst case).
  3. Wrong join order → intermediate result explosion.

If statistics are missing or stale, these will happen without fail. That's why the first step of database tuning is always to check "Did you run ANALYZE?". After bulk INSERT/UPDATE or adding a new index, a manual ANALYZE is a must.

Q2. What happens in PostgreSQL when you lower random_page_cost from 4.0 to 1.1?

A. The optimizer becomes more likely to prefer index scans. Why:

  • Index Scan causes random I/O against the heap.
  • The default of 4.0 assumes random I/O is 4x slower than sequential — a legacy of the HDD era.
  • On modern SSDs/NVMe, the gap is almost nonexistent (1.1 is more realistic).

After this change:

  1. More queries switch to index scans.
  2. Conditions with low selectivity (few rows) avoid full scan.
  3. Overall performance typically improves, especially where random I/O is cheap.

Caveat: This parameter only changes the optimizer's estimates, not actual performance directly. If it doesn't match the environment, it may lead to bad plans. Use 1.1 to 1.5 on SSDs, keep 4.0 on HDDs, somewhere in between for network storage.

Q3. What is the problem with the "independent selectivity assumption" and PostgreSQL's solution?

A. Problem: The optimizer assumes P(A and B) = P(A) × P(B). This holds when conditions are independent, but in practice, they are often correlated:

WHERE city = 'Seoul' AND country = 'Korea'
  • Estimate: 10% × 20% = 2%
  • Reality: 10% (Seoul only in Korea)
  • 5x underestimate → wrong plan.

PostgreSQL solution (10+): Extended Statistics

CREATE STATISTICS stat_addr (dependencies) ON city, country FROM addresses;
CREATE STATISTICS stat_addr_ndist (ndistinct) ON city, country FROM addresses;
ANALYZE addresses;

Three types:

  1. dependencies: Detect functional dependencies (city → country).
  2. ndistinct: Number of distinct combinations across columns.
  3. MCV (multivariate): Most common value combinations across columns.

With this info, the optimizer can account for correlation. Very effective, but it has additional statistics-maintenance cost, so apply selectively to problematic queries.

Q4. What is the difference between the Volcano and Cascades optimizers?

A.

Volcano (1993):

  • Top-down search.
  • Exhaustive plan generation.
  • Has memoization.
  • Based on transformation rules.
  • Limits: Search space explosion for many joins, limited property handling.

Cascades (1995):

  • Improvement over Volcano (same author).
  • Memo data structure groups equivalent expressions.
  • Physical property propagation (ordering, distribution).
  • Branch-and-bound pruning.
  • Enforcers: Add Sort, Exchange to provide required properties.

Analogy: If Volcano is "generate all plans and pick one," Cascades is "compare at the group level, demand only required properties, and discard obviously bad ones early."

Usage: SQL Server, CockroachDB, Apache Calcite (Cascades-based). PostgreSQL is neither — it's its own DP-based design. Volcano mostly appears in research and lectures, while Cascades has become the industry standard.

Q5. What is the diagnostic order when "estimated rows" and "actual rows" in EXPLAIN ANALYZE differ significantly?

A. This is a sign that the optimizer made a bad estimate. Diagnostic order:

1. Check whether ANALYZE ran

SELECT last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname='mytable';

If too stale, run it manually: ANALYZE mytable;

2. Check statistics quality

SELECT attname, n_distinct, most_common_vals, histogram_bounds
FROM pg_stats WHERE tablename = 'mytable';
  • If n_distinct is odd, the sample may be too small → ALTER TABLE ... SET STATISTICS 1000.
  • If MCV is empty, the data might not be evenly distributed.

3. Suspect correlated columns If you have multiple WHERE conditions that always appear together, use extended statistics:

CREATE STATISTICS s ON col1, col2 FROM mytable;

4. Subqueries / function calls WHERE col = get_default() and similar function calls make selectivity unestimable.

5. Bad join-result estimates If an intermediate join result looks wrong, the n_distinct of join-participating columns may be inaccurate.

6. Hints / rewrite If all the above fail, force a specific plan with the pg_hint_plan extension, or rewrite the query (e.g., use a CTE, inline a subquery).

The key is to approach it in the order "statistics → data characteristics → query structure." Most problems are resolved with a single ANALYZE.


Closing: The Optimizer Is a Partner

Key Points

  1. The optimizer depends on statistics. Don't skimp on ANALYZE.
  2. It's cost-based. Tune cost parameters to your environment.
  3. Cardinality estimation is life. For correlated columns, use extended statistics.
  4. Read EXPLAIN. The difference between estimated and actual is the start of diagnosis.
  5. Join order is the optimizer's hard problem. With many tables, consider hints or rewrites.
  6. Cascades is the modern standard, but PostgreSQL has its own DP.
  7. Hints are the last resort. Fix statistics and structure first.

Cooperating with the Optimizer

Think of the optimizer not as an enemy but as a partner:

  • Give it statistics: ANALYZE, extended statistics.
  • Write clear queries: SQL the optimizer can understand easily.
  • Provide indexes: Secure the necessary access paths.
  • Measure: Check reality with EXPLAIN ANALYZE.
  • Trust, but verify: Most of the time, the optimizer is right.

Final Lesson

The optimizer is the product of decades of research and work by tens of thousands of engineers. Don't try to beat it. Instead, help the optimizer make the right decisions. Accurate statistics, appropriate indexes, clear queries. When those three come together, SQL runs magically fast.

Next time you meet a slow query, first ask:

  • "Are the statistics fresh?"
  • "Does the cardinality the optimizer sees match reality?"
  • "Is the structure amenable to optimization with the indexes and statistics I provided?"

If the answers are "yes," the optimizer will almost always give you the right answer.


References