콘텐츠로 이동

(KO) PostgreSQL Autovacuum — 런처, 워커, 그리고 안티-랩어라운드 스케줄링

목차:

Autovacuum은 PostgreSQL MVCC(다중 버전 동시성 제어) 가비지 컬렉터 위에 얹힌 **정책 계층(policy layer)**이다. MVCC는 행(row)을 제자리에서 덮어쓰지 않음으로써 읽기-블록-없음(read-without-blocking)을 실현한다. UPDATEDELETE는 이전 튜플 버전을 힙 페이지에 남겨 두어, 변경 이전 스냅샷을 가진 트랜잭션이 그것을 계속 볼 수 있게 하고, 새 버전은 뒤에 추가(append)된다. Database System Concepts(Silberschatz, 7e, §18.7 “Multiversion Schemes”)는 이 결과를 명확히 서술한다. 다중 버전 방식은 “어떤 시점에서 오래된 데이터 항목 버전을 삭제해야” 하며, “그 삭제는 해당 버전을 읽을 수 있는 트랜잭션이 더 이상 활성 상태가 아닐 때만 가능하다”는 것이다. 그 삭제 작업이 바로 vacuum 연산이다. 이 문서가 답하는 질문은 vacuum이 죽은 튜플을 어떻게 회수하느냐가 아니라(그것은 postgres-vacuum.md의 주제이다), 누가 어느 테이블을, 어느 데이터베이스에서, 얼마나 강하게 실행할지를 결정하느냐, 즉 스케줄링 문제이다.

스케줄링 문제 안에는 순수 가비지 컬렉션 이론이 드러내지 않는 경성 데드라인(hard deadline)이 숨어 있다. 그것이 **트랜잭션 ID 랩어라운드(transaction-id wraparound)**이다. PostgreSQL은 모든 행 버전에 32비트 xmin/xmax를 찍고, 모듈러(circular) 공간에서 트랜잭션 ID를 비교해 가시성을 판단한다. “오래된”이란 “링에서 약 20억 ID 뒤에 있다”는 뜻이다. 어떤 테이블에 삽입 트랜잭션이 한 번도 freeze되지 않은 살아 있는 행이 존재한다면, 약 20억 번의 트랜잭션이 추가로 실행된 뒤 그 ID는 “먼 과거”에서 “먼 미래”로 뒤집혀 버리고, 그 행은 소리 없이 비가시 상태가 된다. 이는 탐지 불가능한 데이터 손실이다. Database Internals(Petrov, ch. 5)는 vacuum을 버전 공간을 제한하는 유지보수 작업으로 정의하며, PostgreSQL의 구체적인 제한 수단은 freeze이다. 오래된 튜플의 xmin을 frozen 마커로 재작성해 해당 행이 조건 없이 가시 상태가 되도록 하고 원래 ID를 재사용할 수 있게 한다. freeze는 vacuum 안에서만 일어난다. 결국 vacuum은 서로 무관한 두 가지 일을 한다. 공간 회수와 랩어라운드 방지이다. 스케줄러는 두 가지 모두를 처리해야 하며, 후자는 최적화 목표가 아니라 정확성 데드라인이다.

자동 vacuum 스케줄러를 설계할 때 나타나는 세 가지 긴장 관계가 있으며, 이것이 PostgreSQL이 조정하는 핵심 파라미터들이다.

  1. 테이블이 vacuum을 받을 만큼 “더럽다”는 기준은 무엇인가? 죽은 튜플이 세 개뿐인 테이블을 vacuum하면 I/O를 낭비하고, 고빈도 변경 테이블을 90% 블로트까지 방치하면 디스크와 스캔 속도를 낭비한다. 표준 답은 테이블 크기에 비례한 임계값이다. 죽은 튜플이 base + scale × live_tuples를 초과하면 vacuum을 실행한다. 상수 base는 작은 테이블 보호(10행짜리 테이블을 반복 처리하지 않는다), 크기 비례 항은 큰 테이블 대응(1억 행 테이블은 절대 죽은 튜플 수가 더 많아도 vacuum이 효율적)이다.

  2. 하나의 머신에서 여러 테이블과 데이터베이스를 어떻게 공정하게 처리하는가? 항상 가장 더러운 테이블을 고르는 단순 스케줄러는 작은 데이터베이스를 굶기고, 데이터베이스를 라운드-로빈으로 순회하면 긴급성을 무시한다. PostgreSQL은 결정을 분리한다. 공정성을 위해 데이터베이스 간 라운드-로빈, 긴급성을 위해 데이터베이스 내 임계값 기반 선택, 그리고 랩어라운드 위험이 둘 다를 덮어쓴다.

  3. 얼마나 강하게 밀어붙일 것인가? Vacuum은 I/O가 많이 드는 작업이며 포그라운드 쿼리와 경쟁한다. 고전적 제어 방식은 **비용 기반 지연(cost-based delay)**이다. Vacuum이 접촉한 페이지마다 “비용”을 누적하고 한도를 넘으면 스스로 sleep해 처리량을 조절한다. 여러 vacuum이 동시에 실행될 때는 집계 처리율이 제한되어야 하므로, 예산을 동시 워커 수로 나누는 분산 레이트-리미터 방식을 쓴다.

Autovacuum 서브시스템은 이 세 가지 답을 구현한 것이다. vacuum 메커니즘 바깥에 의도적으로 위치하며, vacuum은 언제든 손으로 실행할 수 있고(VACUUM 명령), 스로틀링 코드는 수동 실행과 자동 실행 모두에 공통으로 작동한다. Autovacuum은 아무도 그 명령을 타이핑할 필요가 없도록 해주는 데몬이다.

교과서는 모델을 제시한다(다중 버전 삭제, 랩어라운드 한도, 임계값 스케줄링, 레이트-제한 유지보수). 이 절은 MVCC 또는 지연 정리 스토리지 계층 위에 자동 유지보수 데몬을 붙인 프로덕션 엔진들에서 반복적으로 나타나는 엔지니어링 관례를 열거한다. 대상 엔진은 PostgreSQL, Oracle(자동 세그먼트/공간 어드바이저 및 SMON undo 정리), SQL Server(ghost record cleanup + auto-stats), MySQL/InnoDB(purge 스레드), CUBRID(전용 vacuum 워커)이다. 다음 절의 PostgreSQL 구체적 선택은 이 공유 공간 안의 다이얼 조합으로 읽힌다.

장수하는 스케줄러 + 단명하는 실행자

섹션 제목: “장수하는 스케줄러 + 단명하는 실행자”

거의 모든 엔진이 결정 프로세스와 작업 프로세스를 분리한다. 항상 켜져 있는 단일 스케줄러(데몬, 코디네이터 스레드)가 전역 그림을 보유한다. 어떤 오브젝트가 낡았고 어떤 데드라인이 다가오는지를 파악한 뒤, 작업 단위 하나를 처리하고 종료(또는 풀 반환)하는 실행자 풀로 작업을 분산한다. 이 분리 구조는 전역 상태를 한 곳에 두어 “누가 어느 테이블을 vacuum 중인가”에 대한 N-방향 조율을 없애고, 실행자를 교체 가능하게 만든다. vacuum 도중 충돌하거나 kill된 실행자는 스케줄러의 장부를 온전히 남기며, 다음 디스패치가 해당 테이블을 단순히 다시 선택한다. PostgreSQL의 런처/워커 쌍이 정확히 이 형태이며, InnoDB의 purge coordinator + purge workers도 한 프로세스 안의 같은 아이디어이다.

파라미터로 크기를 제한하는 풀

섹션 제목: “파라미터로 크기를 제한하는 풀”

유지보수 작업이 머신 전체를 점유하지 않도록 실행자 풀은 설정 파라미터(max workers)로 상한이 정해진다. 스케줄러는 작은 고정 크기 공유 구조체(빈 목록과 실행 중 목록)에서 여유/사용 중 실행자를 추적하므로, “워커를 하나 더 디스패치할 수 있나?”는 상수 시간 확인이다. 상한은 상위 한도이지, 목표치가 아니다. 더러운 테이블이 없으면 풀은 대기 상태로 남는다.

고정 타임테이블이 아닌 통계 기반 임계값

섹션 제목: “고정 타임테이블이 아닌 통계 기반 임계값”

“매 시간 모든 테이블을 vacuum”하는 대신, 스케줄러는 DML의 부산물로 실행 중 시스템이 유지하는 누적 활동 통계를 참조한다. 죽은 튜플 수, 삽입 수, 마지막 유지보수 이후 변경 수 같은 지표이다. 아무도 쓰지 않는 테이블은 절대 스케줄되지 않고, 핫 테이블은 자주 스케줄된다. 임계값은 테이블 크기와 해당 카운터들에 대한 수식이며, 오브젝트별 오버라이드로 전역 설정을 건드리지 않고 특수 테이블을 튜닝할 수 있다.

소프트 정책을 덮어쓰는 경성 데드라인

섹션 제목: “소프트 정책을 덮어쓰는 경성 데드라인”

“충분히 더러운가” 소프트 정책 위에 정확성 데드라인(랩어라운드, undo 공간 고갈, 로그 공간 압박 — 엔진마다 다름)을 위한 강제 경로가 겹쳐진다. 오브젝트가 경성 한도를 넘으면 스케줄러는 아무리 깨끗해 보여도, 운영자가 일상적 유지보수를 비활성화했더라도, 반드시 처리해야 한다. 강제 경로는 보통 스케줄러가 어느 오브젝트를 먼저 선택하는지(가장 위험한 것 먼저)와 실행자가 인터럽트될 수 있는지(보통 불가능하다)도 바꾼다.

여러 실행자가 동시에 실행될 때 집계 유지보수 I/O를 제한하기 위해, 레이트 리밋은 활성 실행자 수로 나뉘는 공유 수치이다. 각 실행자는 주기적으로 공유 메모리에서 현재 제수(divisor)를 읽고 자신의 개인 한도를 재계산한다. 실행자가 추가되거나 종료될 때 나머지가 중앙 랑데부(rendezvous) 없이 자동으로 재균형을 맞춘다. 이 분할이 교과서의 단일 레이트-리미터를 분산 형태로 구현한 것이다.

임시 유지보수 요청을 위한 사이드 큐

섹션 제목: “임시 유지보수 요청을 위한 사이드 큐”

통계 기반 스케줄 외에, 엔진의 다른 부분이 특정 유지보수 작업(“지금 이 인덱스 범위를 요약하라”)을 필요로 할 때가 있다. 관례는 스케줄러 공유 메모리 안의 작은 고정 크기 **작업 항목 큐(work-item queue)**이다. 어느 백엔드든 거기에 게시(post)할 수 있고, 실행자들이 기회가 될 때 비운다. 이로써 일회성 요청이 별도 전용 프로세스를 만들지 않고 기존 실행자 풀에 편승한다.

이론 / 관례PostgreSQL 엔터티
장수하는 스케줄러AutoVacLauncherMain (B_AUTOVAC_LAUNCHER 프로세스)
단명하는 실행자AutoVacWorkerMain (디스패치마다 하나의 B_AUTOVAC_WORKER)
제한된 실행자 풀autovacuum_worker_slots 빈 목록 av_freeWorkers in AutoVacuumShmem
”디스패치 가능?” 확인av_worker_available (여유 슬롯 vs. 예약 슬롯)
데이터베이스 간 공정성rebuild_database_list가 구성하는 DatabaseList 라운드-로빈
데이터베이스 내 긴급성relation_needs_vacanalyze 임계식
활동 통계PgStat_StatTabEntry (dead_tuples, ins_since_vacuum, mod_since_analyze)
임계값 수식base + scale × reltuples, vac_max_thresh로 상한 적용
경성 데드라인relfrozenxid/relminmxid vs. recentXid - freeze_max_ageforce_vacuum
가장 위험한 것 먼저for_xid_wrapdo_start_worker가 가장 오래된 datfrozenxid 선택
공유 분할 스로틀av_nworkersForBalance + AutoVacuumUpdateCostLimit
임시 요청 큐av_workItems[NUM_WORKITEMS] + AutoVacuumRequestWork

다음 절에서 av_nworkersForBalance를 만났을 때, 독자는 이미 그것이 무엇인지 알고 있을 것이다. 분산 레이트-리미터의 제수이다.

PostgreSQL은 스케줄러 전체를 단일 파일 src/backend/postmaster/autovacuum.c(REL_18 기준 약 3,475줄)에 구현하고, 작은 공개 헤더 src/include/postmaster/autovacuum.h를 둔다. 아키텍처는 하나의 공유 메모리 구조체와 postmaster의 포크 메커니즘으로 이어진 2계층 프로세스 모델이다. 공유 상태, 런처의 스케줄링 루프, 워커의 테이블별 판정, 비용 균형 프로토콜, 강제 안티-랩어라운드 경로, 사이드 작업 항목 큐를 순서대로 살펴본다.

두 프로세스, 하나의 공유 구조체

섹션 제목: “두 프로세스, 하나의 공유 구조체”

런처는 데이터베이스에 접속하지 않으며 어떤 vacuum도 직접 실행하지 않는다. 런처는 어느 데이터베이스가 다음에 워커를 받을지 결정하고 postmaster에 포크를 요청하는 영속 스케줄러이다. 워커는 단명한다. 포크된 워커는 정확히 하나의 데이터베이스에 붙어 “적절한 양의 작업”을 수행하고 종료한다. 두 계층이 공유하는 메모리는 오직 AutoVacuumShmem, 즉 시작 시 크기가 정해지는 단일 구조체(슬롯별 WorkerInfoData 배열이 뒤따름)뿐이다.

// AutoVacuumShmemStruct — src/backend/postmaster/autovacuum.c
typedef struct
{
sig_atomic_t av_signal[AutoVacNumSignals];
pid_t av_launcherpid;
dclist_head av_freeWorkers; /* WorkerInfo free list */
dlist_head av_runningWorkers; /* WorkerInfo non-free queue */
WorkerInfo av_startingWorker; /* one being started; cleared by the worker */
AutoVacuumWorkItem av_workItems[NUM_WORKITEMS]; /* NUM_WORKITEMS == 256 */
pg_atomic_uint32 av_nworkersForBalance; /* cost-balance divisor */
} AutoVacuumShmemStruct;

이 구조체는 거의 전부 하나의 LWLock인 AutovacuumLock으로 보호된다. 예외는 의도적이다. av_signal은 원격 프로세스가 락 없이 설정하는 sig_atomic_t 배열이어서(백엔드가 “재균형 필요” 신호를 저렴하게 보낼 수 있다), av_nworkersForBalance는 워커가 핫 경로에서 락 없이 읽는 pg_atomic_uint32이다. 그 외 모든 것—워커 빈 목록, 실행 중 목록, 시작 중 워커 포인터, 작업 항목 배열—은 AutovacuumLock 아래에서 변경된다.

워커의 위치 정보는 WorkerInfoData 슬롯 하나에 기록되며, 슬롯의 수는 정확히 autovacuum_worker_slots개로, 고정 구조체 뒤 평면 배열로 할당된다.

// WorkerInfoData — src/backend/postmaster/autovacuum.c
typedef struct WorkerInfoData
{
dlist_node wi_links; /* entry into free list or running list */
Oid wi_dboid; /* database this worker works on */
Oid wi_tableoid; /* table currently being vacuumed, if any */
PGPROC *wi_proc; /* PGPROC of the running worker, NULL if not started */
TimestampTz wi_launchtime;
pg_atomic_flag wi_dobalance;/* include this worker in balance calc? */
bool wi_sharedrel;
} WorkerInfoData;

동일한 슬롯이 단일 wi_links 노드로 두 목록에 연결된다. 유휴 상태에는 av_freeWorkers, 워커가 점유하면 av_runningWorkers에 놓인다. wi_tableoidwi_sharedrel은 워커가 외부에 공개하는 두 필드이며(AutovacuumScheduleLock으로 보호), 같은 데이터베이스 내 다른 워커들이 중복 작업을 피하는 데 사용된다.

공유 메모리는 서버 시작 시 크기가 잡히고 배치된다.

// AutoVacuumShmemInit — src/backend/postmaster/autovacuum.c
AutoVacuumShmem = (AutoVacuumShmemStruct *)
ShmemInitStruct("AutoVacuum Data", AutoVacuumShmemSize(), &found);
// ... condensed ...
worker = (WorkerInfo) ((char *) AutoVacuumShmem +
MAXALIGN(sizeof(AutoVacuumShmemStruct)));
for (i = 0; i < autovacuum_worker_slots; i++)
{
dclist_push_head(&AutoVacuumShmem->av_freeWorkers, &worker[i].wi_links);
pg_atomic_init_flag(&worker[i].wi_dobalance);
}
pg_atomic_init_u32(&AutoVacuumShmem->av_nworkersForBalance, 0);

여기에는 PG17→PG18 변화가 반영되어 있다. 풀의 크기는 autovacuum_worker_slots(시작 시 예약되는 물리적 슬롯 수, 공유 메모리가 늘어나지 않으므로 클러스터 수명 동안 고정)로 결정되고, autovacuum_max_workers는 autovacuum이 실제로 사용할 슬롯 수의 런타임 GUC 상한이다. 둘 사이의 차이가 예비분이며, av_worker_available이 이를 강제한다.

// av_worker_available — src/backend/postmaster/autovacuum.c
free_slots = dclist_count(&AutoVacuumShmem->av_freeWorkers);
reserved_slots = autovacuum_worker_slots - autovacuum_max_workers;
reserved_slots = Max(0, reserved_slots);
return free_slots > reserved_slots;

이 구조 덕분에 운영자가 재시작 없이 autovacuum_max_workersautovacuum_worker_slots 상한까지 올릴 수 있다. 이는 이전 버전에서 자주 지적된 불편이었다.

전체 토폴로지:

flowchart TB
    PM["postmaster<br/>(forks every process)"]
    LA["autovacuum launcher<br/>AutoVacLauncherMain<br/>perpetual scheduler, no DB"]
    subgraph SHM["AutoVacuumShmem (shared memory, AutovacuumLock)"]
      FREE["av_freeWorkers<br/>(free WorkerInfo slots)"]
      RUN["av_runningWorkers<br/>(busy WorkerInfo slots)"]
      START["av_startingWorker<br/>(handoff pointer)"]
      WI["av_workItems[256]<br/>(ad-hoc requests)"]
      NB["av_nworkersForBalance<br/>(atomic divisor)"]
    end
    W1["worker (db A)<br/>AutoVacWorkerMain"]
    W2["worker (db B)<br/>AutoVacWorkerMain"]
    BK["any backend<br/>(BRIN summarize)"]

    PM -->|fork| LA
    LA -->|"do_start_worker:<br/>pick db, fill startingWorker,<br/>signal PMSIGNAL_START_AUTOVAC_WORKER"| PM
    PM -->|fork| W1
    PM -->|fork| W2
    LA --- SHM
    W1 --- SHM
    W2 --- SHM
    BK -->|"AutoVacuumRequestWork"| WI
    W1 -->|"SIGUSR2 'I'm up / I finished'"| LA

그림 1 — 2계층 프로세스 모델. 런처는 데이터베이스를 직접 건드리지 않는다. 대상 데이터베이스를 선택하고, WorkerInfoav_startingWorker에 파킹한 뒤, postmaster에 실제 워커 포크를 요청하는 신호를 보낸다. 포크된 워커는 파킹된 슬롯을 점유하고, 실행 중 목록으로 이동시키고, SIGUSR2로 런처에 신호를 돌려보낸다. 모든 조율은 AutovacuumLock 아래 AutoVacuumShmem에서 이루어진다. 모든 백엔드는 av_workItems에 일회성 요청을 게시할 수 있다.

표준 보조 프로세스 초기화(시그널 핸들러, InitProcess, PostgresMain에서 단순화된 sigsetjmp 에러 복구 블록) 이후, 런처는 데이터베이스 목록을 한 번 구성하고 sleep-후-maybe-launch 루프에 진입한다.

// AutoVacLauncherMain — src/backend/postmaster/autovacuum.c (condensed)
rebuild_database_list(InvalidOid);
while (!ShutdownRequestPending)
{
struct timeval nap;
launcher_determine_sleep(av_worker_available(), false, &nap);
(void) WaitLatch(MyLatch, WL_LATCH_SET | WL_TIMEOUT | WL_EXIT_ON_PM_DEATH,
(nap.tv_sec * 1000L) + (nap.tv_usec / 1000L),
WAIT_EVENT_AUTOVACUUM_MAIN);
ResetLatch(MyLatch);
ProcessAutoVacLauncherInterrupts();
/* ... SIGUSR2 처리: 재균형, 또는 포크 실패 후 재시도 ... */
current_time = GetCurrentTimestamp();
LWLockAcquire(AutovacuumLock, LW_SHARED);
can_launch = av_worker_available();
/* ... av_startingWorker가 여전히 대기 중이고 타임아웃 전이면 can_launch = false ... */
LWLockRelease(AutovacuumLock);
if (!can_launch)
continue;
if (dlist_is_empty(&DatabaseList))
launch_worker(current_time); /* 부트스트랩: 아직 아무것도 스케줄 안 됨 */
else
{
avl_dbase *avdb = dlist_tail_element(avl_dbase, adl_node, &DatabaseList);
if (TimestampDifferenceExceeds(avdb->adl_next_worker, current_time, 0))
launch_worker(current_time); /* 기한이 된 데이터베이스 */
}
}

이 루프를 올바르게 만드는 불변식(invariant)이 두 가지 있다. 첫째, 한 번에 하나의 워커만 “시작 중” 상태일 수 있다. av_startingWorker가 NULL이 아니면 런처는 다른 워커를 디스패치하지 않는다. 포크된 워커가 아직 슬롯을 점유하지 않은 상황에서 중복 예약이 일어나는 것을 막기 위해서이다. 시작 중인 워커가 Min(autovacuum_naptime, 60) 초 이상 걸리면 런처는 슬롯을 회수하고 경고를 로그에 남긴다. 슬롯을 점유하기 전에 죽은 포크 워커가 파이프라인을 막지 않도록 하기 위한 안전망이다. 둘째, 런처는 다음 기한이 된 데이터베이스까지 sleep한다. 고정 틱이 아니라 목록에서 계산된 시간까지이다.

rebuild_database_list가 데이터베이스 간 공정성 메커니즘이다. pgstats 항목이 있는 데이터베이스마다 avl_dbase 항목 하나씩으로 이중 연결 목록을 구성하며, 가장 나중에 기한이 되는 데이터베이스가 head, 가장 먼저 기한이 되는 것이 tail에 오도록 정렬한다. 이 때문에 위 루프가 dlist_tail_element를 읽어 다음 대상을 얻는다. “next worker” 타임스탬프는 하나의 autovacuum_naptime 인터벌 안에 고르게 분산된다.

// rebuild_database_list — src/backend/postmaster/autovacuum.c (condensed)
millis_increment = 1000.0 * autovacuum_naptime / nelems;
if (millis_increment <= MIN_AUTOVAC_SLEEPTIME) /* MIN == 100.0 ms */
millis_increment = MIN_AUTOVAC_SLEEPTIME * 1.1;
current_time = GetCurrentTimestamp();
for (i = 0; i < nelems; i++)
{
db = &(dbary[i]);
current_time = TimestampTzPlusMilliseconds(current_time, millis_increment);
db->adl_next_worker = current_time;
dlist_push_head(&DatabaseList, &db->adl_node); /* later goes nearer head */
}

이 함수는 실질적으로 정교하다. 먼저 데이터베이스들에 점수를 매기고(새 데이터베이스 = 0, 기존 목록 순서, 그 다음 get_database_list() 나머지), 임시 해시에 넣고, 점수로 배열을 정렬한 뒤, 재구성 시에도 naptime 윈도우 내 데이터베이스 순서가 유지되도록 목록을 재구성한다. 결과적으로 N개 데이터베이스와 60초 naptime이 있을 때, 각 데이터베이스는 대략 60초마다 한 번씩 고르게 분산되어 워커를 받으며, 순서가 안정적이어서 어느 데이터베이스도 계속 앞자리를 차지하지 못한다. 워커가 실제로 한 데이터베이스를 위해 시작되면, launch_worker는 그 데이터베이스의 next_worker를 naptime 하나만큼 밀고 head로 옮겨 사실상 큐 맨 뒤로 보낸다.

flowchart LR
    subgraph DL["DatabaseList — ordered by adl_next_worker"]
      direction LR
      H["head:<br/>db due furthest out"]
      M["...staggered every<br/>naptime/N ms..."]
      T["tail:<br/>db due soonest"]
    end
    SLEEP["launcher_determine_sleep<br/>sleeps until tail's<br/>adl_next_worker"]
    PICK["pick tail database"]
    LW["launch_worker:<br/>next_worker += naptime,<br/>move to head"]

    T --> SLEEP
    SLEEP --> PICK
    PICK --> LW
    LW -->|"this db now at head<br/>(due furthest out)"| H

그림 2 — 데이터베이스 라운드-로빈. 목록은 adl_next_worker 순으로 정렬되어 있어 기한이 가장 가까운 데이터베이스는 항상 tail에 있다. 런처는 정확히 그 데이터베이스의 기한까지 sleep하고, 워커를 디스패치한 뒤, 그 데이터베이스의 다음 슬롯을 naptime만큼 미래로 밀고 head로 이동시킨다. 하나의 autovacuum_naptime 윈도우 안에 모든 데이터베이스가 고르게 한 번씩 방문된다. 안티-랩어라운드는 이 순서를 우회하는 예외이다. 아래를 참조하라.

포크된 워커(AutoVacWorkerMain)는 파킹된 WorkerInfo를 점유하고, av_runningWorkers로 이동시키고, 배정된 데이터베이스에 접속한 뒤 do_autovacuum을 호출한다. 이 함수는 pg_class를 두 번 스캔한다(메인 테이블과 materialized view를 먼저, TOAST 테이블은 나중에 처리한다. TOAST 테이블은 부모의 reloptions를 상속하기 때문이다). 각 릴레이션마다 정책의 핵심인 relation_needs_vacanalyze를 호출해 세 개의 boolean을 결정한다. vacuum 여부, analyze 여부, 랩어라운드 강제 여부이다.

임계값 수식은 교과서의 공식이다. 파라미터는 테이블의 reloptions에서 가져오고, 없으면 전역 GUC를 사용한다.

// relation_needs_vacanalyze — src/backend/postmaster/autovacuum.c (condensed)
vac_scale_factor = (relopts && relopts->vacuum_scale_factor >= 0)
? relopts->vacuum_scale_factor : autovacuum_vac_scale;
vac_base_thresh = (relopts && relopts->vacuum_threshold >= 0)
? relopts->vacuum_threshold : autovacuum_vac_thresh;
/* PG18: 계산된 vacuum 임계값의 상위 한도; -1이면 비활성화 */
vac_max_thresh = (relopts && relopts->vacuum_max_threshold >= -1)
? relopts->vacuum_max_threshold : autovacuum_vac_max_thresh;
// ... ins와 analyze 파라미터도 같은 방식으로 결정 ...
vactuples = tabentry->dead_tuples;
instuples = tabentry->ins_since_vacuum;
anltuples = tabentry->mod_since_analyze;
vacthresh = (float4) vac_base_thresh + vac_scale_factor * reltuples;
if (vac_max_thresh >= 0 && vacthresh > (float4) vac_max_thresh)
vacthresh = (float4) vac_max_thresh;
vacinsthresh = (float4) vac_ins_base_thresh +
vac_ins_scale_factor * reltuples * pcnt_unfrozen;
anlthresh = (float4) anl_base_thresh + anl_scale_factor * reltuples;
*dovacuum = force_vacuum || (vactuples > vacthresh) ||
(vac_ins_base_thresh >= 0 && instuples > vacinsthresh);
*doanalyze = (anltuples > anlthresh);

판정에 투입되는 세 숫자는 모두 누적 통계에서 가져온다. dead_tuples(블로트 지표, 고전적 vacuum 임계값을 구동), ins_since_vacuum(삽입 지표, PG13+에서 삽입 전용 테이블도 결국 freeze되도록 vacuum을 받게 한다), mod_since_analyze(플래너 통계 신선도 지표, analyze를 구동). 삽입 경로에는 주목할 만한 PG18 개선이 있다. 삽입 임계값이 pcnt_unfrozen, 즉 아직 all-frozen 상태가 아닌 페이지의 비율(relallfrozen/relpages에서 도출)로 스케일된다는 점이다. 오래된 페이지가 이미 freeze된 삽입 전용 테이블은 여전히 활성인 영역의 삽입만으로 판정된다. 이미 정착된 테이블을 불필요하게 다시 vacuum하지 않는 것이다.

flowchart TD
    START["relation_needs_vacanalyze(rel)"]
    XID{"relfrozenxid older than<br/>recentXid - freeze_max_age?"}
    MXID{"relminmxid older than<br/>recentMulti - mxid_freeze_max_age?"}
    FORCE["force_vacuum = true<br/>wraparound = true"]
    ENABLED{"autovacuum enabled<br/>for this table?"}
    SKIP["dovacuum = false<br/>doanalyze = false<br/>(unless forced)"]
    STATS{"pgstats entry exists<br/>and autovacuum active?"}
    THRESH["dovacuum = forced OR dead>vacthresh<br/>OR ins>vacinsthresh<br/>doanalyze = mod>anlthresh"]
    ONLYFORCE["dovacuum = force_vacuum<br/>doanalyze = false"]

    START --> XID
    XID -->|yes| FORCE
    XID -->|no| MXID
    MXID -->|yes| FORCE
    MXID -->|no| ENABLED
    FORCE --> STATS
    ENABLED -->|"no, and not forced"| SKIP
    ENABLED -->|"yes, or forced"| STATS
    STATS -->|yes| THRESH
    STATS -->|no| ONLYFORCE

그림 3 — 테이블별 결정 트리. 랩어라운드 확인이 가장 먼저 실행되며 force_vacuum을 설정한다. 강제 대상 테이블은 운영자가 autovacuum을 비활성화했더라도 vacuum된다(!av_enabled && !force_vacuum 조기 반환은 강제가 아닐 때만 실행된다). 강제 경로가 결정된 뒤에야 소프트 임계값 수식이 실행되며, 이는 살아 있는 통계가 있는 테이블에 한해서이다.

do_autovacuum이 처리할 OID 목록을 확보하면, 각 테이블을 건드리기 전에 다른 워커와의 중복을 피해야 한다. AutovacuumScheduleLock을 잡은 상태에서 실행 중인 워커를 확인하고 자신의 점유를 공표한다.

// do_autovacuum — src/backend/postmaster/autovacuum.c (condensed claim)
LWLockAcquire(AutovacuumScheduleLock, LW_EXCLUSIVE);
LWLockAcquire(AutovacuumLock, LW_SHARED);
dlist_foreach(iter, &AutoVacuumShmem->av_runningWorkers)
{
WorkerInfo worker = dlist_container(WorkerInfoData, wi_links, iter.cur);
if (worker == MyWorkerInfo) continue;
if (!worker->wi_sharedrel && worker->wi_dboid != MyDatabaseId) continue;
if (worker->wi_tableoid == relid) { skipit = true; break; }
}
LWLockRelease(AutovacuumLock);
if (skipit) { LWLockRelease(AutovacuumScheduleLock); continue; }
/* 스케줄 락을 해제하기 전에 점유 선언 */
MyWorkerInfo->wi_tableoid = relid;
MyWorkerInfo->wi_sharedrel = isshared;
LWLockRelease(AutovacuumScheduleLock);
tab = table_recheck_autovac(relid, table_toast_map, pg_class_desc,
effective_multixact_freeze_max_age);
if (tab == NULL) { /* 다른 워커가 먼저 처리; 점유 해제 */ continue; }

점유-후-재확인(publish-then-recheck) 패턴은 작은 레이스 윈도우 아래 “점유 및 검증”의 표준 방식이다. 워커는 스케줄 락을 잡은 상태에서 wi_tableoid를 공표하고, 그런 다음 통계를 다시 읽는다(table_recheck_autovac). 첫 번째 스캔과 지금 사이에 다른 워커가 이미 그 테이블을 vacuum했을 수 있기 때문이다. 재확인 결과 작업이 불필요하다면 점유를 해제하고 다음으로 넘어간다. 공유 카탈로그(relisshared)는 모든 데이터베이스의 워커에게 보이므로, 충돌 확인은 wi_sharedrel을 보고 데이터베이스 경계를 가로질러 수행된다.

워커가 처리하는 각 테이블은 공유 비용 균형 체계에서 워커의 위치도 갱신한다. Vacuum은 페이지당 비용을 누적하고 vacuum_cost_limit을 초과하면 sleep하며 스스로 스로틀한다. 여러 워커가 활성 상태일 때는 집계 I/O 비율이 단일 워커 목표치 근처에 머물도록 그 한도를 나눈다. 제수는 공유 메모리에 있으며, 균형을 맞추는 워커 집합이 바뀔 때마다 재계산된다.

// autovac_recalculate_workers_for_balance — autovacuum.c (condensed)
dlist_foreach(iter, &AutoVacuumShmem->av_runningWorkers)
{
WorkerInfo worker = dlist_container(WorkerInfoData, wi_links, iter.cur);
if (worker->wi_proc == NULL ||
pg_atomic_unlocked_test_flag(&worker->wi_dobalance))
continue;
nworkers_for_balance++;
}
pg_atomic_write_u32(&AutoVacuumShmem->av_nworkersForBalance, nworkers_for_balance);
// AutoVacuumUpdateCostLimit — autovacuum.c (condensed)
if (av_storage_param_cost_limit > 0)
vacuum_cost_limit = av_storage_param_cost_limit; /* 테이블별 오버라이드: 균형 제외 */
else
{
vacuum_cost_limit = (autovacuum_vac_cost_limit > 0)
? autovacuum_vac_cost_limit : VacuumCostLimit;
if (pg_atomic_unlocked_test_flag(&MyWorkerInfo->wi_dobalance))
return; /* 이 워커는 균형에서 제외 */
nworkers_for_balance = pg_atomic_read_u32(&AutoVacuumShmem->av_nworkersForBalance);
vacuum_cost_limit = Max(vacuum_cost_limit / nworkers_for_balance, 1);
}

wi_dobalance 플래그가 옵트아웃 수단이다. 비용 관련 reloptions(자체 vacuum_cost_delay/vacuum_cost_limit)가 있는 테이블은 공유 예산에 포함되지 않는다. 운영자가 그 테이블에 특정 비율을 요청했으므로, 해당 비율로 실행되고 제수에서도 제외된다. 다른 모든 워커는 VacuumUpdateCosts 호출 시 정기적으로 av_nworkersForBalance를 (원자적으로, 락 없이) 읽고 전역 한도를 그것으로 나눈다. 워커가 테이블을 시작하거나 마칠 때 AutoVacRebalance를 신호로 보내고, 런처가 락 아래서 제수를 재계산하면, 실행 중인 워커들이 다음 확인 때 새 값을 가져간다. 분산 레이트-리미터가 중앙 랑데부 없이 재균형을 맞추는 방식이다.

랩어라운드 데드라인은 모든 계층을 관통한다. 어느 데이터베이스를 런처가 선택하는지, 어느 테이블을 워커가 강제 처리하는지, 비활성화된 autovacuum을 건너뛸 수 있는지를 모두 바꾼다.

데이터베이스 수준에서 do_start_worker는 강제 한도를 계산하고 모든 데이터베이스(통계가 없는 것까지 pg_database에서 직접 스캔)를 순회하며, 가장 위험한 것을 우선한다.

// do_start_worker — src/backend/postmaster/autovacuum.c (condensed)
recentXid = ReadNextTransactionId();
xidForceLimit = recentXid - autovacuum_freeze_max_age;
if (xidForceLimit < FirstNormalTransactionId)
xidForceLimit -= FirstNormalTransactionId;
recentMulti = ReadNextMultiXactId();
multiForceLimit = recentMulti - MultiXactMemberFreezeThreshold();
foreach(cell, dblist)
{
avw_dbase *tmp = lfirst(cell);
if (TransactionIdPrecedes(tmp->adw_frozenxid, xidForceLimit))
{
if (avdb == NULL || TransactionIdPrecedes(tmp->adw_frozenxid, avdb->adw_frozenxid))
avdb = tmp;
for_xid_wrap = true; /* 이 DB는 위험; 이후 위험하지 않은 DB 무시 */
continue;
}
else if (for_xid_wrap) continue;
else if (MultiXactIdPrecedes(tmp->adw_minmulti, multiForceLimit)) { /* multixact 위험 */ }
// ... 그 외에는 "가장 최근에 autovacuum된 게 가장 오래된" 선택 ...
}

XID 랩어라운드 위험에 처한 데이터베이스가 발견되는 순간, for_xid_wrap이 true로 래치되어 이후 위험하지 않은 모든 데이터베이스는 스캔에서 무시된다. 위험한 데이터베이스들 중 가장 오래된 datfrozenxid를 가진 것이 선택된다. XID 랩어라운드가 MultiXact 랩어라운드를 앞서고, 둘 다 일반적인 “가장 오래전에 autovacuum된” 선택보다 앞선다. 이것이 가장 위험한 것 먼저 오버라이드이다. 며칠 동안 건드리지 않은 데이터베이스라도 frozen-xid 수평선이 한계에 가장 가깝다면 바쁜 데이터베이스보다 먼저 선택된다.

테이블 수준에서는 relation_needs_vacanalyze 안에서 같은 비교가 실행되어 force_vacuum을 설정하며, 소프트 경로에 없는 세 가지 결과를 낳는다.

// relation_needs_vacanalyze — force path (condensed)
xidForceLimit = recentXid - freeze_max_age;
relfrozenxid = classForm->relfrozenxid;
force_vacuum = (TransactionIdIsNormal(relfrozenxid) &&
TransactionIdPrecedes(relfrozenxid, xidForceLimit));
/* ... else check relminmxid against the multixact force limit ... */
*wraparound = force_vacuum;
if (!av_enabled && !force_vacuum) /* 비활성화된 테이블: 강제가 아닌 경우에만 건너뜀 */
{
*doanalyze = false; *dovacuum = false; return;
}

첫째, 강제 vacuum은 av_enabled가 false일 때(테이블 또는 클러스터의 autovacuum이 꺼진 경우)도 실행된다. 랩어라운드 방지는 선택 사항이 아니다. 둘째, 워커가 해당 테이블을 처리하는 동안 do_autovacuum 루프 안의 설정 재로드 확인은 autovacuum이 방금 비활성화되었음을 감지해도 의도적으로 종료하지 않는다. 소스 내 주석은 “this might be a for-wraparound emergency worker”라고 명시한다. 셋째, 강제(안티-랩어라운드) vacuum은 취소하기가 더 어렵다. 일반 autovacuum 충돌 요청에 따라 잠금 관리자가 보내는 신호를 무시한다. DDL 문이 데이터 손실을 막는 유일한 수단을 반복적으로 취소하도록 내버려 두는 것은 위험하기 때문이다. 취소 동작 자체는 잠금 관리자와 vacuum.c에 있으며, postgres-xid-wraparound-freeze.md에서 다룬다.

마지막으로, 어느 백엔드든 특정 유지보수 작업을 요청할 수 있게 해주는 작은 고정 크기 큐이다. REL_18 코어에서 유일한 생산자는 BRIN 인덱스 요약(AVW_BRINSummarizeRange)으로, brin_summarize_*이 게시한다.

// AutoVacuumRequestWork — src/backend/postmaster/autovacuum.c (condensed)
LWLockAcquire(AutovacuumLock, LW_EXCLUSIVE);
for (i = 0; i < NUM_WORKITEMS; i++) /* NUM_WORKITEMS == 256 */
{
AutoVacuumWorkItem *workitem = &AutoVacuumShmem->av_workItems[i];
if (workitem->avw_used) continue;
workitem->avw_used = true;
workitem->avw_active = false;
workitem->avw_type = type;
workitem->avw_database = MyDatabaseId;
workitem->avw_relation = relationId;
workitem->avw_blockNumber = blkno;
result = true;
break;
}
LWLockRelease(AutovacuumLock);
return result;

큐는 256슬롯의 평면 배열이다. 큐가 가득 차면 요청은 소리 없이 버려진다(false 반환). 워커는 테이블 목록을 마친 뒤 자신의 데이터베이스에 속한 항목을 perform_work_item으로 처리하며, 실행 중인 항목마다 avw_active를 표시해 두 번째 워커가 중복 처리하지 않게 한다. 기존 워커 풀에 임시 유지보수를 편승시키는 엔진의 관례이다. 전용 프로세스가 없고, 워커가 나가면서 확인하는 우편함만 있다.

모든 심볼은 별도 표시가 없으면 src/backend/postmaster/autovacuum.c에 있다. 공개 인터페이스는 src/include/postmaster/autovacuum.h이다.

  • AutoVacuumShmemStruct (struct) — 단일 공유 구조체. 시그널 배열, 런처 pid, 워커 빈/실행 중 목록, 시작 중 워커 핸드오프 포인터, 작업 항목 배열, 균형 제수.
  • WorkerInfoData / WorkerInfo — 슬롯 하나. wi_links로 빈 목록 또는 실행 중 목록에 연결. 충돌 방지를 위해 wi_tableoid/wi_sharedrel 공표.
  • avl_dbase (struct) — 런처 측 데이터베이스 목록 항목(adl_datid, adl_next_worker, adl_score).
  • avw_dbase (struct) — 워커 측 데이터베이스 디스크립터. 랩어라운드 선택을 위한 adw_frozenxid/adw_minmulti.
  • AutoVacuumWorkItem (struct) + NUM_WORKITEMS (== 256) — 임시 요청 큐 원소와 배열 크기.
  • AutoVacuumShmemSize / AutoVacuumShmemInit — 세그먼트 크기 계산 및 초기화. 빈 목록에 autovacuum_worker_slots개 슬롯을 씨드.
  • autovac_init — postmaster 시간 정상 확인(track_counts가 꺼져 있으면 경고).
  • AutoVacLauncherMain — 스케줄러 진입점 및 메인 루프.
  • ProcessAutoVacLauncherInterrupts — SIGHUP(재로드 + 목록 재구성), 셧다운, 배리어 처리.
  • AutoVacLauncherShutdown — 정상 종료. av_launcherpid 초기화.
  • launcher_determine_sleep — 다음 기한 데이터베이스까지 nap 시간 계산. [MIN_AUTOVAC_SLEEPTIME, MAX_AUTOVAC_SLEEPTIME]으로 클램프.
  • rebuild_database_list — 라운드-로빈 목록 구성. autovacuum_naptime 안에 고르게 분산. adl_next_worker 순 정렬.
  • get_database_listpg_database 순차 스캔(런처의 유일한 트랜잭션).
  • db_comparatoradl_score 기준 qsort 비교자.
  • do_start_worker — 대상 데이터베이스 선택(랩어라운드 우선, 그 다음 가장 오래전에 autovacuum된 것). av_startingWorkerWorkerInfo 파킹. postmaster에 신호.
  • launch_workerdo_start_worker를 호출한 뒤 선택된 데이터베이스의 adl_next_worker를 naptime만큼 전진시키는 래퍼.
  • av_worker_available — 여유 슬롯 vs. 예약 슬롯(worker_slots - max_workers).
  • avl_sigusr2_handler / AutoVacWorkerFailed — 워커 기동/완료 및 포크 실패 신호.
  • AutoVacWorkerMain — 워커 진입점. 파킹된 슬롯 점유, 데이터베이스 접속, do_autovacuum 호출.
  • FreeWorkerInfoon_shmem_exit 콜백. 슬롯을 빈 목록에 반환하고 런처를 깨움.
  • do_autovacuum — 두 번의 pg_class 스캔, 고아 임시 테이블 정리, 테이블별 점유/재확인/vacuum 루프, 작업 항목 처리.
  • extract_autovac_optspg_class reloptions 튜플에서 AutoVacOpts 추출.
  • relation_needs_vacanalyze — 임계값 + freeze age 판정. dovacuum/doanalyze/wraparound 출력.
  • recheck_relation_needs_vacanalyze / table_recheck_autovac — 점유 후 신선한 통계로 재평가. autovac_table 작업 디스크립터 또는 NULL 반환.
  • autovacuum_do_vac_analyzeautovac_table을 공유 vacuum() 진입점에 전달.
  • perform_work_item / autovac_report_workitem — 큐에 든 AutoVacuumWorkItem 처리 및 보고.
  • VacuumUpdateCosts — 이 워커(또는 수동 VACUUM)의 vacuum_cost_delay/vacuum_cost_limit 재계산. vacuum 설정 시 및 재로드 후 호출.
  • AutoVacuumUpdateCostLimitav_nworkersForBalance로 전역 한도 나누기(wi_dobalance로 옵트아웃 또는 테이블별 비용 reloption 우선).
  • autovac_recalculate_workers_for_balance — 균형 워커 수 재계산 및 av_nworkersForBalance 갱신.
  • AutoVacuumingActive — 데몬이 설정상 켜져 있는가?
  • AutoVacuumRequestWorkAutoVacuumWorkItem 게시(큐 가득 차면 false 반환).
  • check_autovacuum_work_mem / check_av_worker_gucs — GUC 확인 훅.

위치 힌트 (2026-06-05, REL_18 273fe94 기준)

섹션 제목: “위치 힌트 (2026-06-05, REL_18 273fe94 기준)”
심볼파일
avl_dbase (struct)postmaster/autovacuum.c171
avw_dbase (struct)postmaster/autovacuum.c180
WorkerInfoData (struct)postmaster/autovacuum.c231
AutoVacuumWorkItem (struct)postmaster/autovacuum.c263
NUM_WORKITEMS (== 256)postmaster/autovacuum.c273
MIN_AUTOVAC_SLEEPTIME (100.0 ms)postmaster/autovacuum.c139
MAX_AUTOVAC_SLEEPTIME (300 s)postmaster/autovacuum.c140
AutoVacuumShmemStruct (struct)postmaster/autovacuum.c293
AutoVacLauncherMainpostmaster/autovacuum.c368
ProcessAutoVacLauncherInterruptspostmaster/autovacuum.c747
AutoVacLauncherShutdownpostmaster/autovacuum.c792
launcher_determine_sleeppostmaster/autovacuum.c809
rebuild_database_listpostmaster/autovacuum.c893
db_comparatorpostmaster/autovacuum.c1072
do_start_workerpostmaster/autovacuum.c1090
launch_workerpostmaster/autovacuum.c1302
AutoVacWorkerFailedpostmaster/autovacuum.c1354
avl_sigusr2_handlerpostmaster/autovacuum.c1361
AutoVacWorkerMainpostmaster/autovacuum.c1376
FreeWorkerInfopostmaster/autovacuum.c1606
VacuumUpdateCostspostmaster/autovacuum.c1654
AutoVacuumUpdateCostLimitpostmaster/autovacuum.c1723
autovac_recalculate_workers_for_balancepostmaster/autovacuum.c1769
get_database_listpostmaster/autovacuum.c1809
do_autovacuumpostmaster/autovacuum.c1885
perform_work_itempostmaster/autovacuum.c2605
extract_autovac_optspostmaster/autovacuum.c2719
table_recheck_autovacpostmaster/autovacuum.c2749
recheck_relation_needs_vacanalyzepostmaster/autovacuum.c2900
relation_needs_vacanalyzepostmaster/autovacuum.c2967
autovacuum_do_vac_analyzepostmaster/autovacuum.c3173
autovac_report_workitempostmaster/autovacuum.c3248
AutoVacuumingActivepostmaster/autovacuum.c3288
AutoVacuumRequestWorkpostmaster/autovacuum.c3300
autovac_initpostmaster/autovacuum.c3342
AutoVacuumShmemSizepostmaster/autovacuum.c3359
AutoVacuumShmemInitpostmaster/autovacuum.c3378
av_worker_availablepostmaster/autovacuum.c3449
AutoVacuumWorkItemType (enum)include/postmaster/autovacuum.h23
  • 워커 풀은 시작 시 autovacuum_worker_slots로 크기가 정해지고, 런타임에 autovacuum_max_workers로 상한이 적용된다. AutoVacuumShmemInit(빈 목록이 autovacuum_worker_slots개 항목으로 씨드됨)과 av_worker_available(autovacuum_worker_slots에서 autovacuum_max_workers를 빼서 예비분을 계산)에서 2026-06-05에 검증됨. 이 2파라미터 분리가 PG17→PG18 변화의 핵심이다. 운영자가 재시작 없이 autovacuum_max_workers를 올릴 수 있게 되었다. 공유 메모리는 늘어나지 않으므로 autovacuum_worker_slots는 변경 불가능한 상한이다.

  • 런처는 한 번에 워커 하나만 디스패치하며 그 워커가 슬롯을 점유할 때까지 기다린다. AutoVacLauncherMain에서 검증됨. av_startingWorker가 NULL이 아니면 런처는 can_launch = false로 설정하며, Min(autovacuum_naptime, 60) 초 후에야 슬롯을 회수하고 “autovacuum worker took too long to start; canceled” 로그를 남긴다. 핸드셰이크 순서는 런처가 슬롯 파킹 → postmaster에 신호 → 포크된 워커가 AutoVacWorkerMain에서 슬롯 점유 및 av_startingWorker 초기화 → SIGUSR2로 런처에 신호이다.

  • vacuum 임계값은 base + scale × reltuples이며, 선택적으로 최대값으로 클램프된다. relation_needs_vacanalyze에서 검증됨. vacthresh = vac_base_thresh + vac_scale_factor * reltuples, 이후 if (vac_max_thresh >= 0 && vacthresh > vac_max_thresh) vacthresh = vac_max_thresh. vacuum_max_threshold 상한은 PG18 추가 기능이다(기본값 100,000,000). 매우 큰 테이블의 임계값이 무한정 늘어나지 않도록 하며, -1은 클램프를 비활성화한다. 세 가지 구동 카운터는 PgStat_StatTabEntrydead_tuples, ins_since_vacuum, mod_since_analyze이다.

  • 안티-랩어라운드 vacuum은 autovacuum이 비활성화된 테이블에서도 실행된다. relation_needs_vacanalyze에서 검증됨. 조기 반환 가드가 if (!av_enabled && !force_vacuum)이므로, 강제 대상 테이블은 절대 건너뛰어지지 않는다. do_autovacuum의 테이블별 루프에서도 독립적으로 확인됨. 설정 재로드 핸들러가 새로 비활성화된 autovacuum을 감지해도 명시적으로 종료하지 않으며, 소스 내 주석이 “might be a for-wraparound emergency worker”라고 적고 있다.

  • XID 랩어라운드가 MultiXact 랩어라운드를 앞서고, 둘 다 데이터베이스 선택 시 일반적인 가장-오래전-vacuum 선택보다 앞선다. do_start_worker에서 검증됨. 루프는 첫 번째 XID 위험 데이터베이스에서 for_xid_wrap을 래치하고 이후 위험하지 않은 모든 데이터베이스를 continue로 건너뛴다. XID 위험이 없을 때만 MultiXactIdPrecedes 분기가 실행된다. 둘 다 없을 때만 last_autovac_time 비교가 선택을 결정한다. 위험한 데이터베이스들 중에서는 가장 오래된 adw_frozenxid(또는 adw_minmulti)가 이긴다.

  • 워커별 비용 한도는 전역 한도를 av_nworkersForBalance로 나눈 값이다. AutoVacuumUpdateCostLimit에서 검증됨. vacuum_cost_limit = Max(vacuum_cost_limit / nworkers_for_balance, 1). 제수는 autovac_recalculate_workers_for_balance에서 wi_dobalance 플래그가 설정된 실행 중 워커를 세어 계산되며, 워커의 핫 경로에서 원자적으로(pg_atomic_read_u32) 락 없이 읽힌다. 테이블별 비용 reloptions가 있는 워커는 wi_dobalance를 해제하며 제수 계산과 나누기 모두에서 제외된다.

  • 임시 작업 항목 큐는 256슬롯의 평면 배열이며, 큐가 가득 차면 요청을 소리 없이 버린다. AutoVacuumRequestWork(NUM_WORKITEMS == 256; 빈 슬롯이 없으면 false 반환)와 AutoVacuumShmemStructav_workItems[NUM_WORKITEMS] 필드에서 검증됨. REL_18 코어에서 유일한 생산자는 AVW_BRINSummarizeRange이다. autovacuum.hAutoVacuumWorkItemType 열거형은 정확히 그 멤버 하나만 가진다.

  • 런처는 pg_database를 읽기 위한 단 하나의 트랜잭션만 실행한다. get_database_list의 헤더 주석(“this is the only function in which the autovacuum launcher uses a transaction”)과 AutoVacLauncherMainInitPostgres(NULL, InvalidOid, ...)를 호출함으로써 검증됨. 특정 데이터베이스에 붙지 않는다는 뜻이다.

  1. rebuild_database_list의 초기 해시 크기가 리터럴 20이며, 소스에 /* magic number here FIXME */로 표시되어 있다. 수천 개의 데이터베이스가 있는 클러스터에서 이것이 실제로 문제가 되는지(해시가 단순히 20 이상으로 커진다), 아니면 단순히 외관상의 문제인지는 미검증이다. 조사 경로: 데이터베이스 10,000개 이상인 클러스터에서 rebuild_database_list 비용을 측정하고, dynahash 리사이즈가 나타나는지 확인. git blame으로 이 FIXME에 대한 이전 논의 추적.

  2. 포크 실패 재시도에 상한이 없다. AutoVacLauncherMainAutoVacForkFailed 처리는 1초 sleep 후 postmaster에 무한 재신호를 보내며, 소스 내 XXX가 재시도 한도가 의미 있는지 의문을 제기한다. OS 프로세스 테이블 고갈 같은 지속적인 포크 실패 상황에서 런처는 이 경로를 계속 순환할 수 있다. 이것이 무해한지 실제 가용성 문제인지는 미검증이다. 조사 경로: OS 프로세스 테이블을 상한으로 제한해 재현하고 런처 로그 양과 CPU를 관찰.

PostgreSQL 너머 — 비교 설계 및 연구 전선

섹션 제목: “PostgreSQL 너머 — 비교 설계 및 연구 전선”
  • InnoDB purge coordinator + purge threads (MySQL, innodb_purge_threads) — InnoDB의 delete-marked 레코드와 오래된 undo-log 버전의 지연 정리는 단일 프로세스 안에서 purge coordinator가 purge worker 스레드에 작업을 분산하는 방식으로 구동된다. 이는 PostgreSQL의 런처/워커 포크 모델을 하나의 프로세스 안에서 구현한 유사체이다. 비교 시 고려할 점은 프로세스 격리(PG: 충돌한 워커가 스케줄러를 오염시킬 수 없다)와 스레드 풀 지연(InnoDB: 작업 단위당 포크 비용이 없다)이다.

  • Oracle SMON + 자동 유지보수 작업 — Oracle의 undo 세그먼트 정리와 자동 옵티마이저 통계 수집은 SMON과 autotask 스케줄러 윈도우에 걸쳐 분리되어 있다. PostgreSQL의 지속적인 통계 임계값 디스패치와 Oracle의 유지보수 윈도우(시간대별 예산) 사용의 대비가 흥미로운 비교점이다. 캘린더 정책과 부하 반응적 정책의 차이이다.

  • CUBRID 전용 vacuum 워커 — CUBRID도 vacuum 마스터/코디네이터와 vacuum 워커를 분리하지만, 테이블별 dead-tuple 통계가 아니라 로그(MVCC 버전 정리가 트랜잭션 로그를 따른다)에서 구동한다. 양쪽을 나란히 놓으면 PostgreSQL이 pg_class + 누적 통계 폴링 방식으로 무엇을 교환하는지, CUBRID의 로그 기반 회수 가능 버전 탐색과 무엇이 다른지 명확해진다. knowledge/code-analysis/cubrid/cubrid-vacuum.md의 CUBRID vacuum 분석 참조.

  • 64비트 XID 제안 — PostgreSQL 커뮤니티에서 오랫동안 진행 중인 트랜잭션 ID 64비트 확장 노력이다. 실현되면 autovacuum 복잡성의 절반을 강제하는 랩어라운드 데드라인이 제거된다. 강제 경로, 가장 위험한-것-먼저 데이터베이스 선택, 취소 불가능한 비상 vacuum이 모두 해당된다. 설계 논의를 추적하면, freeze 데드라인이 정확성 제약이 아닌 공간 관리 최적화로 바뀔 경우 do_start_workerrelation_needs_vacanalyze의 어느 부분이 단순해지는지 파악할 수 있다.

  • 인접 PostgreSQL 문서 — 이 스케줄러가 호출하는 메커니즘은 postgres-vacuum.md(힙 정리, 인덱스 정리, 비용-지연 회계 자체)에 있다. freeze 의미론과 랩어라운드 수학은 postgres-xid-wraparound-freeze.md에, 포크 메커니즘과 PMSIGNAL_START_AUTOVAC_WORKER 핸드셰이크는 postgres-postmaster.md에 있다. 이 스케줄러가 읽는 통계(PgStat_StatTabEntry)는 누적 통계 시스템에서 생성된다(postgres-overview-monitoring-stats.md).

소비된 원자료: 없음. 이 문서는 REL_18 소스 트리에서 직접 합성되었다. sources:는 비어 있다.

교재:

  • Database System Concepts (Silberschatz, Korth, Sudarshan, 7판), §18.7 “Multiversion Schemes” — 어떤 트랜잭션도 그것을 읽을 수 없게 되면 오래된 버전을 삭제해야 한다는 요건. autovacuum이 자동화하는 것이 바로 그 삭제 스케줄링이다. knowledge/research/dbms-general/database-system-concepts.md에 수록.
  • Database Internals (Alex Petrov, 2019), ch. 5 — 버전 공간 한정으로서의 MVCC와 버전 유지보수. freeze는 PostgreSQL의 구체적인 버전-공간 한도이다. knowledge/research/dbms-general/database-internals.md에 수록.

소스 코드 (REL_18_STABLE, commit 273fe94, 2026-06-05 기준):

  • src/backend/postmaster/autovacuum.c — 서브시스템 전체: 런처, 워커, 스케줄링, 임계값, 비용 균형, 작업 항목 큐, 공유 메모리.
  • src/include/postmaster/autovacuum.h — 공개 인터페이스(AutoVacuumWorkItemType, GUC extern, 런처/워커 진입점, shmem 함수).

인접 큐레이션 문서 (상호 참조, 중복 없음):

  • knowledge/code-analysis/postgres/postgres-vacuum.md — 이 스케줄러가 호출하는 vacuum 메커니즘.
  • knowledge/code-analysis/postgres/postgres-xid-wraparound-freeze.md — freeze 의미론과 랩어라운드 데드라인 수학.
  • knowledge/code-analysis/postgres/postgres-postmaster.md — 포크 모델과 워커 시작 신호.