모르지 않다는 것은 아는것과 다르다.

Database

PostgreSQL 실행계획 분석하기 5편 (Aggregates)

채마스 2023. 11. 25. 22:50

개요

  • 실무에서 자주 등장하는 Aggregate 오퍼레이션의 종류는 크게 세가지 이다.
    • Aggregate
    • HashAggregate
    • GroupAggregate
  • Aggregate는 SUM(), AVG(), COUNT(), MAX(), MIN()와 같은 집계 함수를 처리할 때 주로 등장한다.
  • HashAggregate와 GroupAggregate는 Group By를 할 때에 주로 등장한다.
  • 이번 글에서는 위 세가지 Aggregate 오퍼레이션에 대해서 정리해 보려고 한다.

필수 사전 지식

테이블 생성

  • 먼저, 테스트를 위해 아래와 같이 테이블을 만든다.
CREATE TABLE aggregates_test_table (
    id SERIAL PRIMARY KEY,
    category INT,
    value INT
);

 

Aggregate

  • Aggregate는 PostgreSQL에서 SUM(), AVG(), COUNT(), MAX(), MIN()과 같은 집계함수를 실행할 때 주로 사용된다.

데이터 세팅

INSERT INTO aggregates_test_table (category, value)
SELECT (random() * 100000)::int AS category, (random() * 100000)::int AS value
FROM generate_series(1, 100000) s;
  • 이제 실행계획을 살펴보자.

 

  • 위와 같이 Aggregate 오퍼레이션을 통해서 aggregate_test_table을 Seq Scan으로 Full Scan해서 max값을 찾고 있다.
  • 만약, 인덱스가 있다면 max(), min() 같은 집계함수는 성능 개선이 가능하다.

 

  • 집계함수 중에서 AVG()와 SUM()같이 데이터를 다 읽어야 하는 경우에는 인덱스를 활용할 수 없지만, MAX(), MIN()과 같은 특정 데이터만 찾는 경우에는 인덱스를 활용할 수 있다.
  • 성능 또한 25ms에서 0.081ms로 빨라진 것을 확인할 수 있다.

 

HashAggregate

  • HashAggregate는 PostgreSQL에서 group by와 같은 그룹화 연산을 수행할 때 사용되는 방법 중 하나이다.
  • HashAggregate는 Group By에 사용되는 칼럼들로 메모리상에 해시테이블을 만들고, 해당 해시테이블을 바탕으로 SUM, COUNT, AVG와 같은 집계 함수를 처리하는 방식이다.
  • 해시 테이블의 크기는 GROUP BY 절에서 사용되는 칼럼의 카디널리티(고윳값의 수)와 데이터의 양에 의해 결정된다.
  • 카디널리티가 낮아야 중복된 값이 많다는 것이기 때문에 HashAggregate에 효율적이다.
  • 메모리 사용량이 설정된 work_mem 한계를 초과하면, 성능 저하가 발생할 수 있다.
  • HashAggregate는 미리 정렬되어있을 필요가 없고, 너무 크지 않다면 대용량 처리에 유리하다. (너무 크다면 메모리 자원이 한정적이기 때문에 분리하다.)
  • 정렬이 필요없기 때문에 정렬에 소요되는 시간이 절약될 수 있지만, 대용량의 데이터가 카디널리티까지 높아서 해시테이블이 커진다면, HashAggregate는 비효율적일 수 있다.
  • 또한, 정렬된 데이터가 필요 없기 때문에 인덱스가 없는 경우 주로 선택된다.
  • 이제 데이터를 넣고 실행계획을 살펴보자

데이터 세팅

INSERT INTO aggregates_test_table (category, value)
SELECT (random() * 100)::int AS category, (random() * 1000)::int AS value
FROM generate_series(1, 100000) s;
  • 10만건의 데이터를 생성했다. 여기서 category는 100개의 랜덤 한 값으로 생성했다.
  • 따라서, 데이터는 많고 category 칼럼에 카디널리티는 낮은 데이터셋으로 생성했다.
  • 또한, 인덱스도 따로 설정하지 않았다. 인덱스를 설정했다고 해도 카디널리티가 낮기 때문에 인덱스를 타지 않을 것이다.

  • 위 실행계획을 보면 HashAggregate가 선택되었고, category 칼럼을 Group Key로 사용한 것을 확인할 수 있다.

 

GroupAggregate

  • GroupAggregate는 HashAggregate과 마찬가지로 그룹화 연산을 수행하는 또다른 방법이다.
  • GroupAggregate는 GROUP BY에 사용되는 칼럼들로 이미 정렬된 데이터를 기반으로 하여 그룹화를 수행하고, 해당 그룹화된 데이터를 바탕으로 SUM, COUNT, AVG와 같은 집계 함수를 처리하는 방식이다
  • 그렇기 때문에 GroupAggregate는 이미 정렬된 데이터셋이나 적절한 인덱스가 있는 경우에 선택될 때 가장 효과적으로 동작한다.
  • GroupAggregate는 대량의 데이터를 버퍼링하지 않기 때문에, 메모리에 올리기 너무 큰 데이터의 경우에 HashAggregate 대신 GroupAggregate가 선택되기도 한다.
  • 다시 말해서, GroupAggregate는 현재 처리 중인 그룹에 대한 데이터만 메모리에 유지하기 때문에, 전체적인 메모리 사용량이 HashAggregate에 비해 적을 수 있다.

데이터 세팅

truncate aggregates_test_table;

INSERT INTO aggregates_test_table (category, value)
SELECT (random() * 10000000)::int AS category, (random() * 1000)::int AS value
FROM generate_series(1, 1000000) s;

create index idx_aggregates_test_table_category
on aggregates_test_table(category);

  • 위와 같이, 100만 건의 데이터에 대해서 category 칼럼에 대한 카디널리티가 높게 데이터셋으로 생성했다. (카디널리티 값은 -1에 가까울수록 높디.)
  • 카디널리티를 높게 설정한 이유는 인덱스를 태우기 위함이다.
  • 또한, category에 인덱스를 걸어주었다.

  • Group이라고 표시된 것이 GroupAggregate를 말한다.
  • 인덱스가 있고 데이터의 크기가 100만 건으로 상당히 크기 때문에 옵티마이저는 HashAggregate 대신 GroupAggregate를 선택했다.
  • 인덱스가 있다고 하더라고 데이터의 크기가 크지 않다면 HashAggregate가 선택

 

데이터 크기에 따른 옵티마이저의 Aggregate 선택

truncate aggregates_test_table;

INSERT INTO aggregates_test_table (category, value)
SELECT (random() * 1000000000)::int AS category, (random() * 1000)::int AS value
FROM generate_series(1, 138990) s;

analyze aggregates_test_table;

explain (analyze , buffers )
select category
from aggregates_test_table
group by category;

  • Memory Usage: 10289kB -> 최대 10MB 정도의 메모리를 사용했다는 것을 의미한다.
  • Disk Usage: 1560kB -> 1560kB의 디스크 사용량은 연산 중 일부 데이터가 메모리 용량을 초과하여 디스크에 임시 저장되었음을 나타낸다.
  • Buffers: shared hit=752, temp read=108 written=272
    • shared hit=752 -> 공유 버퍼 캐시에서 752번 데이터를 읽었음 의미한다.
    • temp read=108 written=272 -> 임시 디스크 공간에서 데이터를 108번 읽고 272번 썼다는 것을 나타낸다.
  • 메모리 용량을 초과하여 디스크에 임시 저장되었다는 것은 옵티마이저가 HashAggregate 대신 GroupAggregate을 사용하려고 할 수 있다.
  • 데이터 수를 조금만 올려보자.
truncate aggregates_test_table;

INSERT INTO aggregates_test_table (category, value)
SELECT (random() * 1000000000)::int AS category, (random() * 1000)::int AS value
FROM generate_series(1, 139999) s;

analyze aggregates_test_table;

explain (analyze , buffers )
select category
from aggregates_test_table
group by categ

  • Heap Fetches: 139999 -> 실제 테이블(Heap)에서 139999번의 행을 가져왔다는 것을 의미한다.
  • shared hit=140358 -> 공유 버퍼 캐시에서 140358번 데이터를 읽어왔음을 의미한다.
  • 위 테스트로 보아, 옵티마이저가 테이터 크기가 어느 임계치를 넘어가면 GroupAggregate를 선택하는 것을 확인할 수 있다.
  • 내가 테스트해보면서 느낀 건, PostgreSQL의 옵티마이저는 메모리에 큰 부하가 없는 경우, HashAggregate를 자주 선택하는 것 같다. (내 예측이다.)
    • 또한, 인덱스가 없는 경우, 메모리에 부하가 커도 GroupAggregate를 선택하는 경우는 거의 보지 못했다.

 

References