Executor?
Query Processing 과정에서 Optimizer가 생성한 실행 계획에 따라 데이터 가공에 필요한 알고리즘을 실행 시키는 레이어를 Executor라고 부릅니다. 실행 계획은 Plan Node로 이루어진 Plan Tree라는 형태로 저장되는데, 각 Plan Node에는 실행 계획의 각 단계에서 실행되어야 하는 알고리즘에 대한 함수 포인터가 있습니다. Executor는 실행 계획을 순회하여 사용자가 요구한 결과물을 위해 데이터를 가공합니다.
이번 포스팅에서는 Executor가 데이터 가공을 하는 전반적인 과정을 소개하고 Executor의 동작에 영향을 미치는 메모리 설정 파라미터를 소개하도록 하겠습니다.
Executor 프레임워크 개요
Executor가 Query를 처리하는 프레임워크는 pquery.c 파일에 정의된 ProcessQuery 함수에서 수행됩니다. 해당 함수의 수행 과정은 아래와 같이 간략하게 정리할 수 있습니다.
- 실행 계획 수행에 필요한 초기 정보를 담은 메타데이터 생성 (CreateQueryDesc 호출)
- 실행 계획 수행 중 사용될 리소스와 표현식에 대한 정보 초기화 (ExecutorStart 호출)
- 실행 계획 수행 (ExecutorRun 호출)
- 실행 계획 수행 완료 후 해야 하는 마무리 작업 수행 (ExecutorFinish 호출)
- 실행 계획 수행에 사용한 상태 정보 초기화 및 리소스 해제 (ExecutorEnd 호출)
- Query Metadata 해제 (FreeQueryDesc 호출)
각 단계 별로 어떤 작업을 하는지 자세히 살펴보겠습니다.
Query Metadata 생성 (CreateQueryDesc 호출)
실행 계획 수행에 필요한 초기 정보를 QueryDesc 라는 객체를 초기화하고 담아주게 됩니다. QueryDesc 객체에는 Optimizer가 생성한 Plan Tree와 Query에서 참조해야 하는 스냅샷 정보, 그리고 바인드 변수 정보 등이 있습니다.
C
/* —————-
* query descriptor:
*
* a QueryDesc encapsulates everything that the executor
* needs to execute the query.
*
* For the convenience of SQL-language functions, we also support QueryDescs
* containing utility statements; these must not be passed to the executor
* however.
* ———————
*/
typedef struct QueryDesc
{
/* These fields are provided by CreateQueryDesc */
CmdType operation; /* CMD_SELECT, CMD_UPDATE, etc. */
PlannedStmt *plannedstmt; /* planner’s output (could be utility, too) */
const char *sourceText; /* source text of the query */
Snapshot snapshot; /* snapshot to use for query */
Snapshot crosscheck_snapshot; /* crosscheck for RI update/delete */
DestReceiver *dest; /* the destination for tuple output */
ParamListInfo params; /* param values being passed in */
QueryEnvironment *queryEnv; /* query environment passed in */
int instrument_options; /* OR of InstrumentOption flags */
/* These fields are set by ExecutorStart */
TupleDesc tupDesc; /* descriptor for result tuples */
EState *estate; /* executor’s query-wide state */
PlanState *planstate; /* tree of per-plan-node state */
/* This field is set by ExecutorRun */
bool already_executed; /* true if previously executed */
/* This is always set NULL by the core system, but plugins can change it */
struct Instrumentation *totaltime; /* total time spent in ExecutorRun */
} QueryDesc;
Executor 상태정보 초기화 (ExecutorStart 호출)
QueryDesc를 생성한 후 Plan Tree에 있는 Plan Node를 순회하며 PlanState라는 구조체로 이루어진 이진 트리를 만듭니다. PlanState는 각 Plan Node에 해당되는 Operation 알고리즘을 수행하면서 기록해야 하는 상태 정보를 저장하는 공간으로 사용됩니다. Query 처리 전반에 있어 기록해야 하는 상태 정보는 EState 구조체에 저장합니다.
State Tree를 만들면서 각 Operation에서 수행해야 하는 초기화 작업을 진행합니다. State Tree를 만드는 과정은 Plan Tree의 Root Node부터 시작하여 재귀적으로 자식 노드에 대한 초기화 함수를 호출하며 수행됩니다. 아래 execProcnode.c 파일의 주석으로 설명되어 있는 글을 읽으면 이해하기가 쉽습니다.
C
/*
* EXAMPLE
* Suppose we want the age of the manager of the shoe department and
* the number of employees in that department. So we have the query:
*
* select DEPT.no_emps, EMP.age
* from DEPT, EMP
* where EMP.name = DEPT.mgr and
* DEPT.name = “shoe”
*
* Suppose the planner gives us the following plan:
*
* Nest Loop (DEPT.mgr = EMP.name)
* /
* /
* Seq Scan Seq Scan
* DEPT EMP
* (name = “shoe”)
*
* ExecutorStart() is called first.
* It calls InitPlan() which calls ExecInitNode() on
* the root of the plan — the nest loop node.
*
* * ExecInitNode() notices that it is looking at a nest loop and
* as the code below demonstrates, it calls ExecInitNestLoop().
* Eventually this calls ExecInitNode() on the right and left subplans
* and so forth until the entire plan is initialized. The result
* of ExecInitNode() is a plan state tree built with the same structure
* as the underlying plan tree.
*/
즉 위의 설명을 간추리자면 ExecutorStart 함수가 호출이 되었을 때 Plan Tree의 Root Node를 Input으로 ExecInitNode 함수가 호출되어 Operation 별로 사용할 리소스에 대한 초기화를 한 후 재귀적으로 다시 자식 노드에 ExecInitNode를 호출하는 방식으로 State Tree를 완성합니다. Sequential Scan을 예를 들면 ExecInitNode 함수 호출 시 ExecInitSeqScan함수가 호출이 되는데 해당 함수에서는 읽어야 하는 테이블 파일을 열고 읽어올 Tuple에 대한 버퍼를 할당하는 등에 대한 초기화 작업을 수행합니다.
실행 계획 수행 (ExecutorRun 호출)
ExecutorRun 함수에서는 ExecutorStart에서 만들어 놓은 State Tree의 Root Node에 접근하여 노드 안에 저장되어 있는 ExecProcNode라는 이름으로 되어 있는 함수 포인터 변수를 실행시킵니다.
ExecProcNode는 각 Operation에서 수행해야 하는 알고리즘을 담은 함수이고 PlanState를 초기화 할 때 Operation 마다 정의되어 있는 함수로 설정해놓았습니다. ExecProcNode에는 각 Operation이 필요로 하는 Input 데이터를 받기 위해 OuterPlanState의 ExecProcNode를 재귀호출하는 로직이 들어가 있습니다. 만약 Operation이 State Tree의 Leaf Node라면 재귀호출을 하지 않고, Subplan이 있는 Operation이라면 재귀호출을 합니다.
간단한 예제로 아래 IT 부서에 속한 부서원을 사원번호로 정렬하는 Query의 플랜을 들어보겠습니다.
postgres=$ select EMP.id, EMP.name from EMP where EMP.dept_name = ‘IT’ order by EMP.id;
QUERY PLAN
——————————————————————————————
Sort (cost=18.52..18.53 rows=3 width=48)
Sort Key: id
-> Seq Scan on emp (cost=0.00..18.50 rows=3 width=48)
Filter: (dept_name = ‘IT’::bpchar)
(4 rows)
상기 실행 계획이 ExecutorRun에서 수행되는 방식은 다음과 같습니다.
- ExecutorRun은 State Tree의 Root Node인 Sort만 접근하여 ExecProcNode 함수를 호출합니다.
- 이 때 Sort에 대응되는 PlanState에는 ExecSort 함수가 ExecProcNode에 설정되어 있어 ExecSort 함수가 실행됩니다.
- Sort의 경우 Input 데이터를 모두 받아와야지만 정렬을 할 수 있기 때문에 모든 데이터를 Subplan으로부터 받기 전까지 계속해서 OuterPlanState의 ExecProcNode를 호출합니다.
- Seq Scan의 ExecProcNode에 설정되어 있는 ExecSeqScan 함수가 실행됩니다.
- Sequential Scan은 State Tree에서 Leaf Node에만 존재할 수 있기 때문에 ExecSeqScan 함수는 OuterPlanState의 ExecProcNode 함수 호출 없이 스캔 결과만을 반환합니다.
- Seq Scan이 모든 데이터를 스캔할 때까지 ExecSort 함수는 ExecSeqScan 함수를 호출하고, 모든 데이터가 전달되면 실제 정렬을 시작합니다.
이런 과정을 통해 State Tree를 순환하여 데이터 가공을 하게 되고 ExecutorRun 함수에서는 Root Node에만 접근하여 결과값을 받아올 수 있게 됩니다.
실행 계획 수행 후 마무리 작업 (ExecutorFinish 호출)
ExecutorRun 함수를 통해 모든 결과값을 클라이언트 애플리케이션에 전달하고 나면 이제 런타임에 할당한 자원을 정리해야 합니다. 하지만 그 전에 ExecutorFinish 함수를 호출하여 아래와 같은 마무리 작업을 먼저 진행합니다.
- AFTER 트리거에 대한 처리
- 보조 DML에 대한 후속 처리
AFTER 트리거에 대한 처리에 대해서는 명백하기 때문에 추가 설명은 생략하겠습니다. 보조 DML은 생소할 수 있어 설명을 추가합니다. 아래의 WITH 절을 예제로 들어보겠습니다.
create table T(C1 int);
create table S(C1 int);
insert into T select generate_series(1,1000);
with MOVED_ROWS as (
delete from T
returning *
)
insert into S select * from MOVED_ROWS limit 10;
위 예제를 보면 테이블 T에 있는 모든 Row를 지우는 동시에 지운 Row 중 10개만 테이블 S에 삽입하도록 되어 있습니다. 해당 DML을 실행 계획으로 보면 아래와 같이 나옵니다.
postgres=$ explain analyze with moved_rows as (
delete from t
returning *
)
insert into s select * from moved_rows limit 10;
QUERY PLAN
————————————————————————————————————————–
Insert on s (cost=13812.00..13812.30 rows=0 width=0) (actual time=0.247..0.250 rows=0 loops=1)
CTE moved_rows
-> Delete on t (cost=0.00..13812.00 rows=894400 width=6) (actual time=0.112..137.846 rows=1000 loops=1)
-> Seq Scan on t (cost=0.00..13812.00 rows=894400 width=6) (actual time=0.080..132.965 rows=1000 loops=1)
-> Limit (cost=0.00..0.20 rows=10 width=4) (actual time=0.120..0.191 rows=10 loops=1)
-> CTE Scan on moved_rows (cost=0.00..17888.00 rows=894400 width=4) (actual time=0.117..0.184 rows=10 loops=1)
Planning Time: 0.281 ms
Execution Time: 138.191 ms
(8 rows)
ExecutorRun 동작을 떠올리며 실행계획을 살펴보면 테이블 S에 대한 Insert 동작이 수행되며 Insert를 할 데이터를 받아오기 위해 Limit과 CTE(Common Table Expression) Scan을 수행합니다. CTE Scan은 테이블 T에 대한 Delete를 호출하게 되고, Row를 하나씩 삭제하며 삭제한 버전의 Row를 CTE Scan에게 전달합니다. 이렇게 Row-by-Row로 처리를 하다가 Limit에서 10개 Row를 다 받으면 더이상 OuterPlanNode의 ExecProcNode 호출을 멈추게 됩니다. 이렇게 되면 테이블 T에 대한 Delete는 10개 Row에 대해서만 수행되고 Query가 마무리 되기 때문에 후속처리가 필요하게 됩니다.
따라서 ExecutorFinish에서는 이렇게 후속처리가 필요한 DML에 대해 마무리해주는 동작을 해줍니다. 실행 계획이 생성될 때 Limit 노드에 의해 테이블 T에 대한 Delete 동작이 마무리되지 않은 채 중단됨을 인지하고 있기 때문에 Auxiliary DML 리스트를 실행 계획에 만들어놓고 ExecutorFinish에서 마무리 할 수 있도록 합니다.
실행 계획 수행 후 리소스 해제 (ExecutorEnd 호출)
이 단계에 오면 Query 수행에 사용된 자원을 해제해주는 일을 합니다. 간략하게 동작 방식을 소개하자면 ExecutorEnd 함수를 호출하여 리소스를 해제해주는데 ExecutorRun과 마찬가지로 State Tree의 Root Node부터 ExecEndNode 함수를 재귀적으로 호출하여 각 State Tree 노드에 대한 정리를 진행하는 방식으로 동작합니다.
그리고 마지막으로 QueryDesc 객체를 해제하며 Query 수행이 마무리 됩니다.
Executor 관련 메모리 파라미터
이 포스팅을 마무리하기 전에 Executor 런타임에 영향을 주는 메모리 관련 파라미터에 대해 알아보겠습니다.
work_mem (Default: 4MB)
Executor 런타임에 수행되는 State Tree의 Operation 노드는 각자의 동작 방식에 따라 OuterPlanState로부터 받아온 튜플을 메모리에 버퍼링 하는 것과 하지 않는 것으로 나뉩니다. 예를 들어 정렬을 하기 위한 Sort 노드에서는 전체 데이터에 대한 정렬을 위해서는 모든 튜플을 버퍼링 해야 하지만, Seq Scan과 같은 노드는 버퍼링 할 필요가 없습니다.
만약 튜플을 버퍼링하는 Operation이 메모리 상황을 고려하지 않고 버퍼링을 하면 가용 메모리가 부족해 Out of Memory가 발생하게 될 것입니다. PostgreSQL에서는 이렇게 튜플을 버퍼링하는 Operation이 사용할 수 있는 메모리 양을 work_mem 파라미터 제한해두었습니다. Hash Join이나 Sort같은 Operation은 메모리 사용량을 기록하며 OuterPlanState로부터 받은 튜플을 각자의 자료구조(해시 테이블과 같은)에 저장하다가, work_mem을 초과해야 하는 상황까지 오게 되면 Disk Spill을 발생시켜 임시 파일을 이용한 알고리즘으로 전환합니다.
Default 값인 4MB는 예를 들어 GB 단위의 테이블을 정렬하는 Query를 수행하기엔 터무니 없이 작은 값이라 느껴질 수 있지만, 여러 세션이 동시에 버퍼링 노드가 있는 Query를 수행하는 경우를 생각해본다면 보수적이더라도 OOM을 방지하기 위해 적절한 값임을 알 수 있게 됩니다. 따라서 work_mem을 제어할 때는 DB로 들어오는 워크로드가 어떤 유형을 띄는지 잘 아는 상태에서 제어를 해야 하며, 대용량 분석 Query는 가능하다면 동시 접속 세션이 적은 시간에 work_mem을 늘려 수행하는 것이 바람직할 수 있을 것 같습니다.
max_stack_depth (Default: 2MB)
ExecutorRun 동작 설명 중 State Tree에 대한 ExecProcNode 함수를 재귀적 호출로 실행한다는 것을 보고 혹자는 콜 스택이 깊어지는 것을 우려했을 겁니다. PostgreSQL은 콜 스택이 깊어져 Stack Overflow가 발생하는 것을 방지하기 위해 코드 곳곳에 스택 깊이를 체크하는 로직을 추가해두었습니다. 그리고 만약 콜 스택이 max_stack_depth 파라미터 보다 크다는 것이 감지되었을 때 백엔드 프로세스는 Query 수행을 중단하고 아래와 같이 에러 메시지를 반환합니다.
ERROR: stack depth limit exceeded
HINT: Increase the configuration parameter “max_stack_depth” (currently 2048kB), after ensuring the platform’s stack depth limit is adequate.
그렇다면 이런 에러가 발생하지 않도록 max_stack_depth를 OS 커널의 최대 스택 크기 만큼 키우는 것이 바람직해 보이나요? 그러다가는 Stack Overflow가 발생해 시스템 장애가 발생할 수 있습니다! 그 이유는 PostgreSQL이 콜 스택 깊이 체크 로직을 “곳곳에” 추가해 두긴 했지만, 체크 로직 사이에 콜 스택이 깊어지게 되면 Stack Overflow를 감지할 수 없게 됩니다. 따라서 PostgreSQL 공식 문서에도 나와있지만, 중간에 콜 스택이 max_stack_depth를 넘길 수 있는 것을 인지하여 OS 커널의 최대 스택 크기 보다 최소 몇 MB는 여유를 두고 설정하는 것이 바람직 합니다.
지금까지 PostgreSQL의 쿼리 엔진_ Executor에 관해 알아보았습니다
‘PostgreSQL의 Concurrency Control’를 바로 이어서 확인해보세요!