(KO) PostgreSQL 누적 통계 — PG15 공유 메모리 통계 서브시스템
목차
- 이론적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 안내
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
이론적 배경
섹션 제목: “이론적 배경”모든 실용적인 DBMS는 두 종류의 내부 관찰 상태를 유지한다. 둘을 혼동하는 것은 고전적인 설계 오류다. 첫 번째는 **라이브 뷰(live view)**로, 각 세션이 지금 이 순간 무엇을 하는지 — 어떤 쿼리를 실행 중인지, 어떤 대기 이벤트에 걸렸는지, VACUUM이 어느 단계까지 진행됐는지 — 를 보여 준다. 두 번째는 **누적 뷰(cumulative view)**로, 시간에 걸쳐 무엇이 일어났는지 — 이 테이블이 순차 스캔을 몇 번 처리했는지, 튜플이 몇 개 삽입됐는지, 마지막 vacuum 이후 죽은 튜플이 얼마나 쌓였는지, WAL이 몇 바이트 기록됐는지 — 를 담는다. 라이브 뷰는 1밀리초 뒤에는 의미가 없는 스냅샷이다. 누적 뷰는 단조 증가하는 카운터 집합이며, 그 값 자체가 축적 결과다. 이 문서는 누적 뷰를 다룬다. 구체적으로는 pg_stat_* 뷰 패밀리의 배후 서브시스템, 그리고 오토베큠이 어느 테이블을 다음에 처리할지 결정할 때 참조하는 카운터들의 구조를 설명한다.
Database System Concepts (Silberschatz 외, 7판)는 이 카운터들의 역할을 두 군데서 언급한다. 쿼리 최적화(16장 “Query Optimization”)에서 카탈로그 통계 — 릴레이션 카디널리티, 튜플 크기, 값 분포 — 는 비용 모델의 원재료다. 이것 없이는 옵티마이저가 장님이다. 교재는 그 플래너 통계(ANALYZE로 수집하고 pg_statistic에 저장해 선택도 추정에 쓰는 것)와 이 문서에서 다루는 활동 카운터를 명확히 구분한다. 둘은 다른 종류의 데이터다. 플래너 통계는 데이터 분포의 표본 추정치로, 가끔 갱신된다. 누적 활동 카운터는 매 튜플 접촉마다 갱신되는 정확한 이벤트 집계다. 그리고 둘 다 오토베큠에 공급된다는 공통점이 있다. 누적 dead-tuple 카운터가 임계값을 넘으면 오토베큠이 스케줄링되고, 그것이 엔진의 자기 유지 루프다(DSC 25장 §25.3, 스토리지 매니저 하우스키핑).
교재가 암묵적으로만 제기하는 더 깊은 설계 질문이 있다. 공유 카운터의 정본이 어디에 위치하는지, 누가 그것을 쓸 수 있는지의 문제다. “테이블 T에 삽입된 튜플 수” 같은 카운터는 T에 삽입하는 모든 백엔드가 증가시키고, SELECT * FROM pg_stat_user_tables를 실행하는 어느 백엔드도 읽는다. 전형적인 다중 쓰기/다중 읽기 공유 상태 문제다. 가장 단순한 해법 — 객체별 전역 가변 카운터 하나, 매 증가마다 락 — 은 확장되지 않는다. 핫 테이블 카운터의 락이 데이터 페이지보다 심한 병목이 될 것이다. The Architecture of a Database System (Hellerstein, Stonebraker & Hamilton 2007, §6 공유 구성요소 및 §2 프로세스 모델)은 지배하는 긴장을 직접 명명한다. 공유 내성 상태는 계측이 측정 대상을 교란하지 않을 만큼 (관찰자 효과) 업데이트 비용이 저렴해야 하면서도, 숫자를 신뢰할 수 있을 만큼 일관성을 갖춰야 한다. 표준적인 해법 — PostgreSQL이 채택하는 — 은 로컬 축적 후 주기적 집계다. 각 쓰기자가 개인 누계를 유지하다가 가끔 공유 합계에 반영한다. 공유 합계는 약간 stale하지만 핫 경로에서 경쟁이 없다. staleness 창은 조정 가능한 파라미터다.
교재가 충분히 강조하지 않지만 구현을 지배하는 네 번째 힘이 있다. 카운트의 트랜잭션 정합성 문제다. 나중에 중단된 트랜잭션 안에서 삽입된 행은 물리적으로 발생한 작업이다(작업이 이뤄졌고 죽은 튜플이 존재한다). 그러나 논리적으로는 라이브 행을 추가하지 않았다. 따라서 튜플 카운트를 삽입 시점에 단순 증가시킬 수 없다. 현재 (서브)트랜잭션마다 스테이징하고 커밋/중단 시점에 결산해야 하며, “시도된 작업” 카운트(중단된 작업 포함)와 “순 효과” 카운트(결과에 의존하는 live/dead-tuple 델타)를 분리해야 한다. PostgreSQL 릴레이션 통계가 비트랜잭션 PgStat_TableCounts와 서브트랜잭션별 PgStat_TableXactStatus 레코드 스택을 함께 유지하는 이유가 여기에 있다. 새로 생성된 테이블의 통계 항목 존재 자체가 트랜잭션적인 이유이기도 하다. 이를 무시한 누적 통계 시스템은 모든 롤백 후마다 유령 행을 보고할 것이다.
네 가지 설계 힘이 누적 통계 서브시스템의 형태를 결정하며, PostgreSQL 소스에서 반복적으로 나타난다.
- 쓰기 증폭 vs. 정확성. 로컬 집계를 N이벤트마다 또는 T밀리초마다 공유 메모리에 반영하는 것은 처리량을 위해 신선도를 트레이드오프하는 선택이다. PostgreSQL은 유휴 경로에서 최대 초당 한 번, 커밋 시 무조건 플러시한다.
- 객체 수명 vs. 카운터 수명. 백엔드가 카운트를 플러시하는 중이거나
pg_stat스냅샷을 읽는 중에 테이블이 DROP될 수 있다. 카운터 저장소는 모든 관찰자가 놓아 줄 때까지 객체보다 충분히 오래 살아야 한다. 참조 카운팅 문제다. - 재시작에 걸친 내구성. 누적 카운터는 재계산이 어렵다(“평생 순차 스캔 횟수”를 데이터에서 역산할 수 없다). 따라서 종료 시 디스크에 직렬화하고 재로딩하지만, 크래시 후에는 폐기한다. 크래시는 일부 진행 중 업데이트가 손실됐음을 의미하므로 합계를 더 이상 신뢰할 수 없다.
- 트랜잭션 결산. 순 효과 카운트(live/dead 튜플)는 커밋/중단을 기다려야 한다. 시도 카운트는 결과와 무관하게 누적된다. 통계 항목의 수명 자체가 트랜잭션적이다 —
CREATE에서 생성되고DROP에서 삭제되며, 둘 다 롤백으로 되돌릴 수 있고 커밋/중단 WAL 레코드로 스탠바이에서 재현된다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”이론적 힘들을 알았으니, 이 절에서는 실제 프로덕션 엔진들이 그 힘들을 해소하는 데 쓰는 엔지니어링 관례를 정리한다. PostgreSQL의 구체적 선택이 알려진 설계 공간 위의 한 점으로 읽히도록 하기 위해서다.
워커 로컬 축적 버퍼. 처리량이 높은 엔진은 거의 예외 없이 각 스레드나 프로세스에 비공개 카운터 블록을 주고, 잠금 없는 일반 산술로 증가시킨 뒤 느린 주기로 전역 집계에 병합한다. Oracle의 V$ 고정 테이블, SQL Server의 DMV, MySQL의 performance_schema 모두 스레드별 계측 버퍼를 집계 기반으로 삼는다. 병합만이 동기화 연산이므로 핫 경로의 계측 비용은 비원자 증가 하나다. PostgreSQL의 pending 항목이 바로 이 버퍼다.
공유 카운터의 키 기반 레지스트리. 공유 집계는 평평한 배열이기 어렵다. 객체(테이블, 함수) 집합이 동적이기 때문에 객체 식별자로 인덱싱된 해시 테이블이 필요하다. 레지스트리는 동시 조회, 삽입(객체가 처음 접촉될 때), 삭제(객체 DROP)를 지원해야 한다. 전역 락 하나 대신 파티션/스트라이프 해시 테이블에 파티션별 락을 쓰는 방향으로 설계를 밀게 된다. PostgreSQL은 파티션 락을 갖춘 동적 공유 메모리 해시 테이블인 dshash를 사용한다.
참조 카운트 기반 항목 수명. 카운터가 공유 레지스트리에 살면서 읽기나 업데이트와 동시에 DROP될 수 있게 되면, 항목은 해시 테이블 내 존재와 독립적인 수명이 필요하다. 표준 패턴은 참조 카운트다. 항목은 미결 사용자 수, “논리적 삭제” 묘비 플래그를 담고, 카운트가 0이 될 때만 물리적으로 해제된다. Linux의 dput() dentry 캐시, Java의 PhantomReference 기반 정리, RCU 읽기 측과 같은 패턴이다. 미묘한 점은 재사용 경쟁이다. 백엔드가 항목을 해제하기로 결정하고 실제 해제하기 전 사이에 슬롯이 같은 키를 가진 새 객체로 재사용될 수 있다(OID 순환, 복제 슬롯 인덱스 재사용). 항목의 단조 증가 세대/에포크 카운터가 늦은 해제자에게 “내가 해제하려던 것이 지금 여기 있는 것과 다르다”는 사실을 알려 물러서게 한다.
읽기자를 위한 스냅샷 격리. 여러 카운터를 스캔하는 읽기자는 테이블 A의 카운트가 t1 시점이고 테이블 B의 카운트가 t2 시점인 찢어진 읽기가 아닌 일관된 그림을 원한다. 엔진은 일관성을 위한 구체화 스냅샷 모드(모든 것을 한 번에 복사해 복사본에서 읽기)와 신선도를 위한 직접 모드를 제공한다. PostgreSQL은 none, cache, snapshot 세 가지 설정을 가진 stats_fetch_consistency GUC로 이를 노출한다.
느린 경로의 내구성. 합계는 순수 축적이므로 디스크 플러시는 지연된다. 보통 체크포인트나 종료 시 전용 백그라운드 프로세스가 담당하며, 트랜잭션 커밋 경로에서는 절대 수행하지 않는다. 크래시 후 몇 초의 통계를 잃는 것은 허용 가능하고(어차피 크래시 후에는 전부 버린다), WAL의 동기 규율과는 정반대다. 통계 내구성은 의도적으로 최선 노력 방식이다.
단일 쓰기자 빠른 경로. 두 번째 유형의 카운터는 정확히 하나의 쓰기자만 갖는다 — 체크포인터가 체크포인터 통계를 쓰고, archiver가 archiver 통계를 쓰며, 각 백엔드가 자신의 IO/WAL 집계만 쓴다. 이런 경우 엔진은 쓰기 측 락을 완전히 생략하고 seqlock 방식의 changecount 프로토콜을 쓸 수 있다. 쓰기자는 버전 카운터를 홀수→짝수 쌍으로 증가시키며(메모리 배리어와 함께) 업데이트를 감싸고, 읽기자는 홀수(쓰기 진행 중)이거나 읽기 전후로 카운트가 바뀌면 재시도한다. 이것은 고전적인 낙관적 읽기 패턴(Linux seqlock, RCU 읽기 측)으로, 성능이 중요한 방향인 쓰기를 배리어 하나로 락 프리하게 만들고 간헐적인 읽기자 재시도를 비용으로 지불한다. PostgreSQL 고정 수 통계가 정확히 이 방식을 쓴다. 가변 수 통계(객체별)는 여러 쓰기자가 있으므로 항목별 LWLock을 잡는다.
flowchart TD
subgraph backend["각 백엔드 (프로세스 로컬)"]
hot["핫 경로: pgstat_count_heap_insert 등<br/>pending 항목에 일반 증가"]
pend["pending 항목<br/>PgStat_EntryRef->pending"]
lhash["로컬 조회 해시 테이블<br/>pgStatEntryRefHash"]
hot --> pend
lhash -. refs 캐시 .-> pend
end
subgraph shmem["공유 메모리 (전 백엔드)"]
dshash["dshash 테이블<br/>key = PgStat_HashKey<br/>entry = PgStatShared_HashEntry"]
body["DSA stats 본문<br/>PgStatShared_Relation 등"]
fixed["고정 수 블록<br/>PgStat_ShmemControl<br/>archiver/bgwriter/io/wal/slru"]
dshash -- dsa_pointer body --> body
end
pend -- "pgstat_report_stat()<br/>플러시, 최대 1/초 또는 커밋 시" --> body
lhash -- "miss: dshash_find_or_insert" --> dshash
disk["pg_stat/pgstat.stat<br/>디스크 파일"]
body -. "종료 시 체크포인터 기록" .-> disk
fixed -. "종료 시 체크포인터 기록" .-> disk
disk -. "기동 프로세스 읽기<br/>(크래시 후 폐기)" .-> body
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”PostgreSQL 15 이전에는 누적 통계가 단일 전용 프로세스, 즉 stats collector에 있었다. 백엔드들은 카운터 델타를 UDP 데이터그램으로 직렬화해 루프백 소켓으로 collector에 전송했다. collector가 모든 카운터의 유일한 메모리 내 사본을 소유했고, 주기적으로 스냅샷 파일을 써서 읽기자가 통째로 슬러프해 갔다. 세 가지 고질적인 문제가 있었다. UDP 패킷이 부하 하에서 소리 없이 드롭될 수 있어 카운트가 손실됐다. 단일 collector가 처리량 상한이었다. 그리고 모든 pg_stat 읽기가 파일 전체를 역직렬화했다. PG15(커밋 5891c7a8e, Andres Freund)가 이를 이 문서에서 설명하는 공유 메모리 누적 통계 시스템으로 교체했다. pgstat.c의 헤더 주석이 아키텍처를 직접 기술한다.
// pgstat.c (file header) — src/backend/utils/activity/pgstat.c// Fixed-numbered stats are stored in plain (non-dynamic) shared memory.//// Statistics for variable-numbered objects are stored in dynamic shared// memory and can be found via a dshash hashtable. The statistics counters// are not part of the dshash entry (PgStatShared_HashEntry) directly, but// are separately allocated (PgStatShared_HashEntry->body)...//// To avoid contention on the shared hashtable, each backend has a// backend-local hashtable (pgStatEntryRefHash) in front of the shared// hashtable, containing references (PgStat_EntryRef) to shared hashtable// entries.두 저장소 클래스, 하나의 파사드. 모든 통계 종류는 정적 pgstat_kind_builtin_infos[] 테이블의 PgStat_KindInfo 행으로 기술된다. fixed_amount 플래그가 세계를 둘로 나눈다. 고정 수 종류(archiver 하나, bgwriter 하나, IO 구조체 하나, WAL 구조체 하나, SLRU 배열 하나)는 평범한 공유 메모리의 단일 PgStat_ShmemControl 구조체에 멤버로 내장된다. 가변 수 종류(릴레이션별, 함수별, 복제 슬롯별, 구독별, 백엔드별 항목)는 dshash에 산다. kind 테이블은 서브시스템 전체의 디스패치 vtable이다.
// pgstat_kind_builtin_infos[] — src/backend/utils/activity/pgstat.c[PGSTAT_KIND_RELATION] = { .name = "relation", .fixed_amount = false, .write_to_file = true, .shared_size = sizeof(PgStatShared_Relation), .shared_data_off = offsetof(PgStatShared_Relation, stats), .shared_data_len = sizeof(((PgStatShared_Relation *) 0)->stats), .pending_size = sizeof(PgStat_TableStatus), .flush_pending_cb = pgstat_relation_flush_cb, .delete_pending_cb = pgstat_relation_delete_pending_cb,},PGSTAT_KIND_RELATION은 2다. 내장 kind는 PGSTAT_KIND_BUILTIN_MIN(= PGSTAT_KIND_DATABASE, 1)부터 올라가고, 커스텀 익스텐션 kind는 PGSTAT_KIND_CUSTOM_MIN(24)부터 차지한다. shared_* 오프셋 세 값은 pgstat.c와 pgstat_shmem.c의 제네릭 코드가 kind의 구체 구조체를 몰라도 가변 크기 항목에서 올바른 서브범위를 잘라낼 수 있게 한다. pgstat_get_entry_data()는 헤더 포인터에 shared_data_off만 더하면 된다.
공유 해시 항목은 데이터가 아닌 얇은 핸들이다. dshash 항목은 고정 크기인데 stats 본문은 크기가 다양하다(릴레이션 항목이 구독 항목보다 훨씬 크다). 그래서 dshash는 PgStatShared_HashEntry — 키, 플래그, 참조 카운트, 세대, 실제 본문을 가리키는 dsa_pointer — 만 저장하고 본문은 DSA에 별도로 할당한다. 이 분리로 새 kind가 추가될 때 dshash 크기 조정도 불필요하다.
// PgStatShared_HashEntry — src/include/utils/pgstat_internal.htypedef struct PgStatShared_HashEntry{ PgStat_HashKey key; /* {kind, dboid, objid} */ bool dropped; /* logically deleted; no new refs allowed */ pg_atomic_uint32 refcount; /* entry lifetime, not dshash-entry lifetime */ pg_atomic_uint32 generation; /* bumped on reinit; detects reuse races */ dsa_pointer body; /* -> PgStatShared_Common + kind-specific stats */} PgStatShared_HashEntry;키 PgStat_HashKey는 {PgStat_Kind kind, Oid dboid, uint64 objid}다. 릴레이션 항목의 경우 dboid는 데이터베이스 OID(공유 카탈로그는 InvalidOid)이고 objid는 테이블 OID다. 본문은 항상 magic 교차 검증과 항목별 LWLock을 담은 PgStatShared_Common 헤더로 시작한다. 서로 다른 테이블에 카운트를 플러시하는 두 백엔드는 절대 경쟁하지 않고, 같은 테이블의 플러시자와 읽기자만 그 테이블 하나의 락에서 직렬화된다.
dshash 항목에 없는 필드에 주목할 가치가 있다. 실제 카운터다. 이 선택은 의도적이다. PgStatShared_Relation 본문은 수십 개의 int64 카운터와 타임스탬프로 이뤄지고, PgStatShared_Subscription 본문은 몇 개에 불과하다. 모든 kind의 데이터가 dshash 항목에 인라인이라면 항목 크기가 모든 kind 중 최댓값이어야 하고, 더 큰 kind가 추가될 때마다 dshash 크기를 늘려야 한다. kind별 정확한 shared_size로 본문을 별도 할당하면 dshash가 kind에 무관하고 항상 적절한 크기를 유지한다. 비용은 접근당 포인터 역참조(dsa_get_address(body)) 하나인데, PgStat_EntryRef가 이를 해석된 로컬 포인터로 캐시하므로 백엔드당 객체당 한 번만 지불한다.
고정 수 kind는 dshash를 전혀 쓰지 않는다. 데이터는 shared_ctl_off로 접근하는 PgStat_ShmemControl에 직접 내장되고, 쓰기 경로에 LWLock 대신 앞서 설명한 seqlock changecount를 쓴다.
// PgStatShared_BgWriter / PgStat_ShmemControl — src/include/utils/pgstat_internal.htypedef struct PgStatShared_BgWriter{ LWLock lock; /* protects reset_offset + reset_timestamp */ uint32 changecount; /* seqlock: odd = write in progress */ PgStat_BgWriterStats stats; PgStat_BgWriterStats reset_offset;} PgStatShared_BgWriter;
typedef struct PgStat_ShmemControl{ void *raw_dsa_area; dshash_table_handle hash_handle; /* the variable-numbered hash */ bool is_shutdown; pg_atomic_uint64 gc_request_count; /* GC epoch */ PgStatShared_Archiver archiver; /* fixed kinds embedded inline */ PgStatShared_BgWriter bgwriter; PgStatShared_Checkpointer checkpointer; PgStatShared_IO io; PgStatShared_SLRU slru; PgStatShared_Wal wal; void *custom_data[PGSTAT_KIND_CUSTOM_SIZE];} PgStat_ShmemControl;로컬 pending과 참조 캐시. 백엔드는 핫 경로에서 dshash를 거의 건드리지 않는다. 릴레이션이 처음 수정되면 백엔드는 PgStat_EntryRef를 확보한다. 공유 PgStatShared_HashEntry를 가리키는 포인터, 본문의 해석된 로컬 포인터(반복적인 dsa_get_address() 호출 방지), 획득 시점의 본문 세대, 프로세스 로컬 scratch 버퍼를 가리키는 pending 포인터를 묶은 로컬 핸들이다. 이 refs는 백엔드 로컬 simplehash인 pgStatEntryRefHash에 캐시된다. 핫 경로(pgstat_count_heap_insert 등)는 pending 버퍼의 정수를 증가시키기만 한다. 튜플 카운트의 트랜잭션 처리가 한 겹을 더한다. 삽입/수정/삭제는 서브트랜잭션별 PgStat_TableXactStatus에 누적되어 롤백이 가능하고, (서브)트랜잭션 종료 시에만 백엔드의 PgStat_TableStatus.counts에 합산된다.
flowchart TD
start["테이블 T에 DML<br/>pgstat_count_heap_insert(rel, n)"] --> chk{"pgstat_should_count_relation?"}
chk -- no --> done1["return (미추적)"]
chk -- yes --> ensure["ensure_tabstat_xact_level<br/>현재 중첩 레벨을 위한<br/>PgStat_TableXactStatus push"]
ensure --> bump["trans->tuples_inserted += n"]
bump --> commitq{"(서브)트랜잭션 종료?"}
commitq -- 서브트랜잭션 커밋 --> up["카운트를 부모에 합산<br/>AtEOSubXact_PgStat_Relations"]
commitq -- 최상위 커밋 --> fold["AtEOXact_PgStat_Relations<br/>trans -> counts 합산<br/>delta_live/dead_tuples 유도"]
commitq -- 중단 --> abort["truncdrop 카운터 복원<br/>삽입된 튜플은 dead 처리"]
fold --> later["다음 pgstat_report_stat()"]
later --> flush["pgstat_relation_flush_cb<br/>항목 락, 공유에 카운트 추가,<br/>database pending 항목도 갱신"]
flush --> shared["PgStatShared_Relation.stats<br/>DSA 내"]
플러시 주기. pgstat_report_stat(force)는 단일 드레인 포인트다. pending 목록이 비어 있고 고정 수 kind의 플러시 요청도 없으면 빠르게 종료한다. 그렇지 않으면, 강제가 아닌 경우 스로틀링한다. PGSTAT_MIN_INTERVAL(1000ms)보다 자주 플러시하지 않으며, 업데이트가 PGSTAT_MAX_INTERVAL(60000ms) 이상 pending 상태이면 블로킹 플러시를 강제한다. 비강제 경로에서는 항목 락을 nowait로 획득하고 플러시 불가 항목을 다음 라운드를 위해 pending 목록에 남겨 두며, 제안 유휴 타임아웃(PGSTAT_IDLE_INTERVAL, 10000ms)을 반환한다. 커밋, 백엔드 종료, pg_stat_force_next_flush()는 강제 플러시다.
수명은 참조 카운트 기반이며 DROP은 트랜잭션적이다. 객체가 DROP되면 통계 항목을 즉시 해제할 수 없다. 다른 백엔드가 참조를 잡고 있거나 스냅샷을 읽는 중일 수 있다. pgstat_init_entry()는 refcount = 1(“드롭되지 않음”을 의미하는 sentinel)로 시작하고, 참조를 획득하는 각 백엔드가 1을 더한다. DROP은 dropped = true를 설정하고 sentinel을 뺀다. 카운트가 0이 되면, 드롭 이후 언제든 마지막 백엔드가 마지막 참조를 해제할 때 본문이 dsa_free()된다. OID/인덱스 재사용으로 키가 부활할 수 있으므로 generation은 재초기화 시 증가하고, 늦은 해제자는 포착한 세대와 현재 세대를 비교한다. 그리고 드롭 결정 자체가 트랜잭션적이므로(롤백되는 DROP TABLE은 통계를 드롭해서는 안 된다), pgstat_drop_transactional()은 드롭을 큐에 넣기만 하고 AtEOXact_PgStat_DroppedStats()가 커밋 시 실행한다. 드롭은 커밋/중단 WAL 레코드에 기록되어 스탠바이와 크래시 복구가 수렴한다. 나머지는 아래에서 심볼별로 추적한다.
소스 코드 안내
섹션 제목: “소스 코드 안내”공유 메모리 부트스트랩과 어태치
섹션 제목: “공유 메모리 부트스트랩과 어태치”StatsShmemSize()는 sizeof(PgStat_ShmemControl) + 고정 256KB DSA 시드 + 커스텀 고정 kind 공간을 예약한다. StatsShmemInit()은 postmaster에서 한 번 실행된다. 예약된 공유 세그먼트 안에서 DSA를 in-place로 생성한(postmaster는 동적 공유 메모리 세그먼트를 사용할 수 없다) 뒤, DSA 크기를 초기 크기로 일시 제한해 구조가 평범한 공유 메모리에 머물게 하면서 dshash를 생성하고 핸들을 기록한다. 그 다음 kind 테이블을 순회해 각 고정 수 kind의 init_shmem_cb를 호출한다.
// StatsShmemInit() — src/backend/utils/activity/pgstat_shmem.cctl->raw_dsa_area = p;p += MAXALIGN(pgstat_dsa_init_size());dsa = dsa_create_in_place(ctl->raw_dsa_area, pgstat_dsa_init_size(), LWTRANCHE_PGSTATS_DSA, NULL);dsa_pin(dsa);/* clamp so dshash header lands in plain shared memory */dsa_set_size_limit(dsa, pgstat_dsa_init_size());dsh = dshash_create(dsa, &dsh_params, NULL);ctl->hash_handle = dshash_get_hash_table_handle(dsh);dsa_set_size_limit(dsa, -1); /* lift limit */각 백엔드는 pgstat_initialize()(BaseInit()에서 호출)에서 pgstat_attach_shmem()을 실행한다. DSA와 dshash를 in-place로 어태치하고 백엔드 수명 동안 매핑을 pin한다. gc_request_count는 1로 초기화되어 로컬 참조 나이 비교에 0이 아닌 기준선을 준다. 대응하는 pgstat_detach_shmem()은 먼저 모든 로컬 항목 참조를 해제한 뒤 분리한다. 참조를 잡은 채 프로세스가 종료되면 공유 본문이 결코 해제되지 않을 수 있다.
참조 획득: pgstat_get_entry_ref
섹션 제목: “참조 획득: pgstat_get_entry_ref”모든 통계 접근이 통과하는 funnel이다. pgstat_get_entry_ref(kind, dboid, objid, create, created_entry)는 먼저 pgstat_get_entry_ref_cached()로 백엔드 로컬 캐시를 확인한다. 이 함수는 항상 로컬 해시 테이블 슬롯을 먼저 삽입해(캐시 미스 비용이 조회 하나이고, 나중에 out-of-memory 오류가 발생해도 공유 참조 카운트를 되돌릴 필요가 없다), 로컬 히트 시 공유 상태를 전혀 건드리지 않고 즉시 반환한다. 미스 시 공유 락(dshash_find)으로 dshash를 탐색하고, create+absent 경로에서만 삽입 경로를 취한다.
// pgstat_get_entry_ref() — src/backend/utils/activity/pgstat_shmem.cshhashent = dshash_find(pgStatLocal.shared_hash, &key, false);if (create && !shhashent){ shhashent = dshash_find_or_insert(pgStatLocal.shared_hash, &key, &shfound); if (!shfound) { shheader = pgstat_init_entry(kind, shhashent); ... pgstat_acquire_entry_ref(entry_ref, shhashent, shheader); if (created_entry != NULL) *created_entry = true; return entry_ref; }}pgstat_init_entry()가 새 항목을 탄생시키는 곳이다. refcount = 1(드롭되지 않음 sentinel), generation = 0을 설정하고, kind의 shared_size만큼 DSA_ALLOC_ZERO 할당 후 0xdeadbeef magic을 찍고 항목별 LWLock을 초기화한다. pgstat_acquire_entry_ref()는 호출자의 참조(두 번째 증가)를 추가하고, dshash 파티션 락을 해제하고, 항목의 세대를 로컬 참조에 스냅샷으로 기록한다. 핵심 특성: 참조 카운트는 공유 dshash 락만 잡은 채로 증가한다. 많은 백엔드가 공유 락 아래 동시에 참조를 획득하기 때문에 원자적이어야 한다.
찾은 항목이 dropped이지만 create가 true면, pgstat_reinit_entry()가 부활시킨다. sentinel 참조 카운트를 다시 추가하고, generation을 증가시켜(낡은 구현을 가진 백엔드가 알아채도록) dropped를 지우고 데이터를 0으로 초기화한다.
로컬 캐시(pgstat_get_entry_ref_cached())의 순서는 미묘하다. 공유 메모리에 접근하기 전에 로컬 해시 테이블 슬롯을 삽입하는 이유가 두 가지다. 미스 시 두 번째 해시 테이블 조회를 피하고, 나중에 out-of-memory 오류 발생 시 공유 참조 카운트 증가를 되돌리지 않아도 된다. 새로 삽입된 슬롯은 entry_ref->shared_stats == NULL이어서 함수가 “미발견”을 반환하고 호출자가 공유 조회로 진행한다. 채워진 슬롯은 본문 magic이 온전하고 참조 카운트가 양수임을 어설션하며 히트로 반환된다. 로컬 참조는 pgStatSharedRefContext에, 캐시 해시 테이블은 pgStatEntryRefHashContext에 할당되며 둘 다 TopMemoryContext의 자식이므로 트랜잭션 경계를 넘어 살아남는다. 테이블에 한 번 접촉한 백엔드는 평생 저렴한 경로를 유지한다.
vacuum/analyze 경로는 pending 버퍼를 완전히 우회한다. pgstat_report_vacuum()과 pgstat_report_analyze()는 pgstat_get_entry_ref_locked()로 공유 항목에 LWLock 아래 직접 쓴다. 이 연산들은 드물고 이미 비싸며, 그 결과 — last_vacuum_time, dead-tuple 리셋, ins_since_vacuum = 0 — 는 플러시를 기다리지 않고 즉시 보여야 한다.
// pgstat_report_vacuum() — src/backend/utils/activity/pgstat_relation.centry_ref = pgstat_get_entry_ref_locked(PGSTAT_KIND_RELATION, dboid, tableoid, false);shtabentry = (PgStatShared_Relation *) entry_ref->shared_stats;tabentry = &shtabentry->stats;tabentry->live_tuples = livetuples;tabentry->dead_tuples = deadtuples;tabentry->ins_since_vacuum = 0;if (AmAutoVacuumWorkerProcess()) { tabentry->autovacuum_count++; ... }pgstat_unlock_entry(entry_ref);pending 버퍼와 플러시
섹션 제목: “pending 버퍼와 플러시”pgstat_prep_pending_entry()는 kind의 pending_size scratch 버퍼를 pgStatPendingContext에 지연 할당하고 ref를 전역 pgStatPending dlist에 연결한다. 릴레이션의 실제 핫 경로 카운터는 pgstat_relation.c의 pgstat_count_heap_insert() 등을 거쳐 서브트랜잭션별 PgStat_TableXactStatus로 흘러 들어간다.
// pgstat_count_heap_insert() — src/backend/utils/activity/pgstat_relation.cif (pgstat_should_count_relation(rel)){ PgStat_TableStatus *pgstat_info = rel->pgstat_info; ensure_tabstat_xact_level(pgstat_info); pgstat_info->trans->tuples_inserted += n;}최상위 커밋에서 AtEOXact_PgStat_Relations()는 트랜잭션 카운트를 비트랜잭션 counts에 합산해 오토베큠이 참조하는 live/dead-tuple 델타를 유도한다. 삽입은 live 튜플을 추가하고, 수정과 삭제는 각각 dead 튜플을 만들며, 모든 작업은 변경 이벤트로 계산된다.
// AtEOXact_PgStat_Relations() — src/backend/utils/activity/pgstat_relation.ctabstat->counts.delta_live_tuples += trans->tuples_inserted - trans->tuples_deleted;tabstat->counts.delta_dead_tuples += trans->tuples_updated + trans->tuples_deleted;tabstat->counts.changed_tuples += trans->tuples_inserted + trans->tuples_updated + trans->tuples_deleted;pgstat_report_stat()이 pending 목록을 드레인한다. 스로틀과 빠른 종료가 서브시스템 성능의 핵심이다.
// pgstat_report_stat() — src/backend/utils/activity/pgstat.c/* Don't expend a clock check if nothing to do */if (dlist_is_empty(&pgStatPending) && !pgstat_report_fixed) return 0;...nowait = !force;partial_flush = false;partial_flush |= pgstat_flush_pending_entries(nowait);if (pgstat_report_fixed) for (kind = PGSTAT_KIND_MIN; kind <= PGSTAT_KIND_MAX; kind++) if (kind_info->flush_static_cb) partial_flush |= kind_info->flush_static_cb(nowait);pgstat_flush_pending_entries()는 dlist를 순회하며 각 kind의 flush_pending_cb를 호출하고, 플러시에 성공한 경우에만 항목을 pending 목록에서 제거한다. nowait면 락 경쟁이 있는 항목은 pending 상태로 남고 함수는 have_pending = true를 반환한다. 릴레이션의 경우 pgstat_relation_flush_cb()는 counts가 전부 0이면 바로 종료하고(pg_memory_is_all_zeros — 플래너가 열었지만 스캔하지 않은 인덱스 등), 항목의 LWLock을 잡고 모든 카운터를 공유 PgStat_StatTabEntry에 더하고, live/dead 튜플을 비음수로 클램핑하고, pg_stat_database가 두 번째 패스 없이 일관성을 유지하도록 database pending 항목도 갱신한다.
// pgstat_relation_flush_cb() — src/backend/utils/activity/pgstat_relation.cif (pg_memory_is_all_zeros(&lstats->counts, sizeof(struct PgStat_TableCounts))) return true;if (!pgstat_lock_entry(entry_ref, nowait)) return false;tabentry = &shtabstats->stats;tabentry->numscans += lstats->counts.numscans;... /* all counters folded in */pgstat_unlock_entry(entry_ref);/* same counts also roll up to the database entry */dbentry = pgstat_prep_database_pending(dboid);dbentry->tuples_inserted += lstats->counts.tuples_inserted;읽기: pgstat_fetch_entry와 스냅샷 모드
섹션 제목: “읽기: pgstat_fetch_entry와 스냅샷 모드”pgstat_fetch_entry()는 pg_stat_get_* 뒤에 있는 읽기 파사드다. 동작은 stats_fetch_consistency가 결정한다. SNAPSHOT 모드에서는 먼저 pgstat_build_snapshot()을 호출한다. 전체 dshash를 한 번 순차 스캔하여 관련 항목을 로컬 pgStatLocal.snapshot.stats simplehash에 복사한다(dropped 항목과 다른 데이터베이스의 비공유 통계는 건너뛴다). 이 트랜잭션에 동결된 뷰를 제공한다. CACHE 모드는 첫 접근 시 항목을 복사해 메모이제이션하고, NONE 모드는 매번 호출자의 컨텍스트로 새로 복사한다.
// pgstat_fetch_entry() — src/backend/utils/activity/pgstat.centry_ref = pgstat_get_entry_ref(kind, dboid, objid, false, NULL);if (entry_ref == NULL || entry_ref->shared_entry->dropped) { ... return NULL; }...(void) pgstat_lock_entry_shared(entry_ref, false);memcpy(stats_data, pgstat_get_entry_data(kind, entry_ref->shared_stats), kind_info->shared_data_len);pgstat_unlock_entry(entry_ref);pgstat_build_snapshot()은 PgStat_EntryRef 없이 각 본문의 LWLock을 직접 획득하고 아래에서 복사하며, 항목의 참조 카운트가 여전히 양수임을 어설션한다. 스냅샷 스캔은 dshash seq lock에 의존해 복사 중 항목이 살아 있음을 보장한다. 고정 수 kind는 snapshot_cb로 스냅샷된다. 락 대신 seqlock 재시도 루프(pgstat_copy_changecounted_stats()) 아래 복사해 단일 쓰기자 쓰기 측을 거울처럼 반영한다.
고정 수 플러시: report-fixed 플래그
섹션 제목: “고정 수 플러시: report-fixed 플래그”가변 수 kind는 pgStatPending 목록에 있는 것 자체가 pending 작업의 신호다. 고정 수 kind는 그런 목록이 없다. 각 백엔드가 IO 통계 등을 프로세스 로컬 구조체에 축적하므로, 전역 pgstat_report_fixed 불리언으로 pgstat_report_stat()에 신호를 보낸다. 백엔드가 IO나 WAL 이벤트를 기록할 때 플래그를 설정하고, 다음 pgstat_report_stat()에서 kind 루프가 각 flush_static_cb(pgstat_io_flush_cb, pgstat_wal_flush_cb, pgstat_slru_flush_cb)를 호출해 로컬 집계를 seqlock 아래 내장 공유 구조체에 합산한다. 이 플래그 덕분에 “아무 일도 없었다”는 공통 호출이 비교 두 번으로 종료된다.
// pgstat_report_stat() fast exit — src/backend/utils/activity/pgstat.cif (dlist_is_empty(&pgStatPending) && !pgstat_report_fixed) return 0;pgstat_report_fixed는 pgstat_report_stat()만이 플러시 전체 라운드 성공 후 지울 수 있다. kind별 콜백이 이를 리셋해서는 안 된다. 락 경쟁으로 부분 플러시된 라운드가 남은 작업 신호를 잃게 된다.
DROP과 참조 카운트 기반 회수
섹션 제목: “DROP과 참조 카운트 기반 회수”pgstat_drop_entry_internal()이 회수의 핵심이다. dropped = true를 설정하고 sentinel 참조 카운트를 뺀다. 0이 되면(백엔드가 참조를 잡지 않음) 본문을 즉시 해제한다. 그렇지 않으면 묘비 항목을 dshash에 남겨 마지막 참조자가 수거하도록 한다.
// pgstat_drop_entry_internal() — src/backend/utils/activity/pgstat_shmem.cshent->dropped = true;/* release refcount marking entry as not dropped */if (pg_atomic_sub_fetch_u32(&shent->refcount, 1) == 0){ pgstat_free_entry(shent, hstat); return true;}else{ if (!hstat) dshash_release_lock(pgStatLocal.shared_hash, shent); return false; /* could not free yet */}대응하는 해제 경로는 pgstat_release_entry_ref()다. 백엔드가 로컬 참조를 놓아 줄 때(GC, 분리, 명시적 드롭 시) 실행한다. 참조 카운트를 감소시키고, 마지막 참조였다면(fetch_sub가 1을 반환) 배타적 dshash 락 아래 항목을 재탐색한다. 세대가 여전히 일치하면 해제한다. 세대가 바뀌었다면, 이 백엔드가 해제 중이던 사이에 슬롯이 새 객체를 위해 재초기화된 것이므로 락을 풀고 새 구현을 그대로 둔다.
// pgstat_release_entry_ref() — src/backend/utils/activity/pgstat_shmem.cif (pg_atomic_fetch_sub_u32(&entry_ref->shared_entry->refcount, 1) == 1){ shent = dshash_find(pgStatLocal.shared_hash, &entry_ref->shared_entry->key, true); if (pg_atomic_read_u32(&entry_ref->shared_entry->generation) == entry_ref->generation) pgstat_free_entry(shent, NULL); /* same incarnation: safe to free */ else dshash_release_lock(pgStatLocal.shared_hash, shent); /* reused: back off */}DROP이 본문을 해제하지 못한 경우(어느 백엔드가 참조를 잡고 있을 때), pgstat_drop_entry() / pgstat_drop_matching_entries()는 pgstat_request_entry_refs_gc()를 호출해 공유 gc_request_count 에포크를 증가시킨다. 다음에 pgstat_get_entry_ref()를 통과하는 각 백엔드는 pgStatSharedRefAge != gc_request_count를 알아채고 pgstat_gc_entry_refs()를 실행한다. 이제 dropped이거나 세대가 달라진 로컬 참조를 모두 해제하는데, 그것이 참조 카운트를 0으로 만들어 본문을 최종 해제하는 해제가 될 수 있다.
flowchart TD
drop["DROP TABLE 커밋<br/>pgstat_drop_entry_internal"] --> tomb["dropped = true<br/>refcount -= 1 (sentinel)"]
tomb --> z{"refcount == 0?"}
z -- yes --> free1["pgstat_free_entry<br/>dshash_delete + dsa_free"]
z -- no --> keep["묘비 유지<br/>pgstat_request_entry_refs_gc<br/>gc_request_count 증가"]
keep --> epoch["다른 백엔드:<br/>gc_request_count 변경 감지"]
epoch --> gc["pgstat_gc_entry_refs<br/>stale 로컬 참조 해제"]
gc --> rel["pgstat_release_entry_ref<br/>refcount -= 1"]
rel --> z2{"마지막 참조이고<br/>세대가 일치?"}
z2 -- yes --> free2["pgstat_free_entry"]
z2 -- no, 재사용됨 --> backoff["락 해제, 항목 유지"]
트랜잭션적 DROP과 크래시 안전성
섹션 제목: “트랜잭션적 DROP과 크래시 안전성”객체 DROP은 트랜잭션적이다. pgstat_drop_transactional()(과 생성 쌍둥이)은 create_drop_transactional_internal()을 호출해 현재 서브트랜잭션의 pending_drops 목록에 PgStat_PendingDroppedStatsItem을 push한다. AtEOXact_PgStat_DroppedStats()가 결산한다. 커밋 시에는 드롭을 실행하고, 중단 시에는 생성의 undo(방금 생성된 항목 드롭)를 실행한다. 서브트랜잭션 중단/커밋은 AtEOSubXact_PgStat_DroppedStats()가 목록을 위로 전파하거나 버린다. 동일한 항목 집합이 pgstat_get_transactional_drops()로 커밋/중단 WAL 레코드에 전달되고, pgstat_execute_transactional_drops()가 복구와 2PC 완료 중 재현한다. 프라이머리에서 DROP된 테이블의 고아 항목이 스탠바이에 쌓이지 않는 이유가 여기에 있다.
내구성
섹션 제목: “내구성”pgstat_before_server_shutdown()(체크포인터가 정상 종료 시 실행)은 최종 플러시를 강제하고 pgstat_write_statsfile()을 호출한다. dshash를 순차 스캔하며 모든 write_to_file kind와 고정 kind를 pg_stat/pgstat.stat에 PGSTAT_FILE_FORMAT_ID 태그와 함께 기록한다. 기동 시 startup 프로세스가 pgstat_restore_stats() → pgstat_read_statsfile()을 호출한다. 크래시 후에는 pgstat_discard_stats()가 파일을 지우고 모든 것을 리셋한다. 크래시 후 살아남은 누적 카운터는 신뢰할 수 없으므로 의도적으로 버린다.
statsfile 형식은 평평하고 자기 서술적인 스트림이다. 형식-ID 헤더, 그 다음 항목 레코드들 — 'F'(고정 kind), 'S'(해시 키 가변 항목), 'N'(이름 키 항목, 복제 슬롯 통계에 사용 — 런타임 objid인 슬롯 인덱스가 재시작 후 안정적이지 않으므로 슬롯 이름을 직렬화하고 로드 시 kind의 from_serialized_name 콜백으로 재해석), 'E'로 종료 — 의 순서다. write_to_file = true인 kind만 영속된다. PGSTAT_KIND_BACKEND는 명시적으로 기록하지 않는다. 백엔드별 통계는 재시작 후 의미가 없기 때문이다. 쓰기가 공유 메모리에 접근하는 마지막 단일 프로세스에서 일어나므로, pgstat_write_statsfile()은 락을 잡지 않고 그 불변성을 명시적으로 문서화한다.
무엇이 게이팅되는지, 그리고 파라미터의 위치
섹션 제목: “무엇이 게이팅되는지, 그리고 파라미터의 위치”서브시스템 전체가 pgstat_track_counts(track_counts GUC)에 조건부다. pgstat_init_relation()은 relcache 열기 시 이를 적용한다. 카운팅이 꺼져 있으면 릴레이션의 pgstat_info를 null로 하고 pgstat_enabled = false로 설정해, 핫 경로 매크로 pgstat_should_count_relation()이 통계 시스템으로의 분기 없이 단락된다. 함수 호출 타이밍은 track_functions로 따로 게이팅되고, 블록 IO 타이밍은 track_io_timing으로 게이팅된다. 이것들이 쓰기 측 파라미터다. stats_fetch_consistency는 유일한 읽기 측 파라미터로, pgstat_fetch_entry() / pgstat_snapshot_fixed() 안에서만 참조된다. 이것을 변경하면 force_stats_snapshot_clear가 설정되어 다음 읽기가 stale 스냅샷을 버린다. 분리가 중요하다. 모니터링 오버헤드를 조정하는 DBA는 track_*을 바꾸고, 리포팅 쿼리에서 뷰 간 일관성이 걱정되는 DBA는 stats_fetch_consistency를 바꾼다. 둘은 서로 간섭하지 않는다.
종합: 카운터 하나의 전체 생명주기
섹션 제목: “종합: 카운터 하나의 전체 생명주기”모든 조각을 하나로 묶기 위해 카운터 하나를 처음부터 끝까지 따라간다. 백엔드가 테이블 T를 연다. pgstat_init_relation()이 추적 가능으로 표시하지만 아무것도 할당하지 않는다. 첫 INSERT가 pgstat_assoc_relation() → pgstat_prep_relation_pending()을 호출하고, 이것이 pgstat_get_entry_ref(create = true)를 호출한다. {RELATION, mydb, T}의 dshash 항목이 찾히거나 삽입되고, 참조 카운트가 1(sentinel)에서 2(이 백엔드)로 올라가고, 로컬 PgStat_EntryRef가 캐시되며, 0으로 초기화된 PgStat_TableStatus pending 버퍼가 pgStatPending에 연결된다. 삽입되는 각 튜플은 서브트랜잭션별 레코드의 trans->tuples_inserted를 증가시킨다. 평범한 산술이며 락도 공유 메모리도 없다. COMMIT에서 AtEOXact_PgStat_Relations()가 트랜잭션 카운트를 pending 버퍼의 counts에 합산하고 live/dead 델타를 유도한다. 다음 pgstat_report_stat()(커밋 시 강제)이 pgstat_relation_flush_cb()를 호출하고, T의 항목 LWLock을 잡고 카운트를 공유 PgStat_StatTabEntry에 더하며 database pending 항목에도 롤업한다. 동시에 실행 중인 SELECT ... FROM pg_stat_user_tables가 pgstat_fetch_entry()를 실행한다. 기본 cache 모드에서는 공유 락 아래 T의 공유 본문을 한 번 복사하고 메모이제이션한다. 결국 누군가 DROP TABLE T를 실행한다. pgstat_drop_relation()이 트랜잭션적 드롭을 큐에 넣고, 그 커밋에서 AtEOXact_PgStat_DroppedStats()가 pgstat_drop_entry()를 호출해 항목을 묘비로 만들고 sentinel 참조 카운트를 제거한다. 다른 백엔드가 참조를 잡고 있지 않으면 본문이 즉시 해제된다. 그렇지 않으면 GC 에포크가 증가하고, pgstat_get_entry_ref()를 마지막으로 통과하는 백엔드가 세대 검사를 거쳐 수거한다. 이 문서의 모든 메커니즘이 그 순회에서 한 번씩 등장한다.
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
PgStatShared_HashEntry (struct) | src/include/utils/pgstat_internal.h | 65 |
PgStat_EntryRef (struct) | src/include/utils/pgstat_internal.h | 135 |
PgStat_KindInfo (struct) | src/include/utils/pgstat_internal.h | 202 |
PgStat_ShmemControl (struct) | src/include/utils/pgstat_internal.h | 466 |
PgStat_Snapshot (struct) | src/include/utils/pgstat_internal.h | 510 |
PgStat_LocalState (struct) | src/include/utils/pgstat_internal.h | 548 |
pgstat_kind_builtin_infos[] | src/backend/utils/activity/pgstat.c | 282 |
pgstat_report_stat | src/backend/utils/activity/pgstat.c | 693 |
pgstat_fetch_entry | src/backend/utils/activity/pgstat.c | 933 |
pgstat_build_snapshot | src/backend/utils/activity/pgstat.c | 1122 |
pgstat_prep_pending_entry | src/backend/utils/activity/pgstat.c | 1267 |
pgstat_flush_pending_entries | src/backend/utils/activity/pgstat.c | 1341 |
StatsShmemInit | src/backend/utils/activity/pgstat_shmem.c | 155 |
pgstat_attach_shmem | src/backend/utils/activity/pgstat_shmem.c | 244 |
pgstat_init_entry | src/backend/utils/activity/pgstat_shmem.c | 301 |
pgstat_reinit_entry | src/backend/utils/activity/pgstat_shmem.c | 340 |
pgstat_acquire_entry_ref | src/backend/utils/activity/pgstat_shmem.c | 381 |
pgstat_get_entry_ref | src/backend/utils/activity/pgstat_shmem.c | 457 |
pgstat_release_entry_ref | src/backend/utils/activity/pgstat_shmem.c | 603 |
pgstat_gc_entry_refs | src/backend/utils/activity/pgstat_shmem.c | 758 |
pgstat_drop_entry_internal | src/backend/utils/activity/pgstat_shmem.c | 888 |
pgstat_drop_entry | src/backend/utils/activity/pgstat_shmem.c | 990 |
pgstat_count_heap_insert | src/backend/utils/activity/pgstat_relation.c | 374 |
AtEOXact_PgStat_Relations | src/backend/utils/activity/pgstat_relation.c | 551 |
pgstat_relation_flush_cb | src/backend/utils/activity/pgstat_relation.c | 816 |
pgstat_report_vacuum | src/backend/utils/activity/pgstat_relation.c | 210 |
AtEOXact_PgStat_DroppedStats | src/backend/utils/activity/pgstat_xact.c | 67 |
create_drop_transactional_internal | src/backend/utils/activity/pgstat_xact.c | 335 |
pgstat_drop_transactional | src/backend/utils/activity/pgstat_xact.c | 384 |
PGSTAT_KIND_RELATION (macro) | src/include/utils/pgstat_kind.h | 28 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”REL_18_STABLE 워킹 트리 커밋 273fe94852b 기준으로 검증했다.
- 저장소 분리 확인.
pgstat.c파일 헤더(18-39행)는 고정 수 통계가 평범한 공유 메모리에, 가변 수 통계가 DSA(dshash로 접근)에 있으며 카운터가 dshash 항목과 분리(PgStatShared_HashEntry->body)됨을 명시한다.PgStat_ShmemControl구조체가archiver,bgwriter,checkpointer,io,slru,wal을 직접 내장하고 dshash용hash_handle을 담음을 확인했다. - Kind 테이블과 5개 핵심 kind.
pgstat_kind_builtin_infos[]에PGSTAT_KIND_RELATION과PGSTAT_KIND_FUNCTION(fixed_amount = false,flush_pending_cb있음),PGSTAT_KIND_IO,PGSTAT_KIND_WAL,PGSTAT_KIND_SLRU(fixed_amount = true,flush_static_cb/init_shmem_cb/snapshot_cb있음)가 인용된 줄에서 확인됐다. - 참조 카운트 sentinel과 세대 경쟁.
pgstat_init_entry()가refcount = 1/generation = 0을 설정하고,pgstat_reinit_entry()가 generation을 증가시키며,pgstat_release_entry_ref()가 해제 전generation == entry_ref->generation을 비교함을 확인했다. - 트랜잭션적 DROP의 WAL 기록.
pgstat_drop_transactional()→create_drop_transactional_internal()이pending_drops에 큐잉하고,pgstat_get_transactional_drops()/pgstat_execute_transactional_drops()가 항목을 커밋/중단 레코드와 복구에 전달함을pgstat_xact.c에서 확인했다. - REL_18 건전성 검사. 이 파일들 어디에도
XLOG2rmgr나B_DATACHECKSUMSWORKER_*백엔드 유형 참조가 없다.PGSTAT_KIND_BACKENDkind와 백엔드별 IO/WAL 플러시 플래그(PGSTAT_BACKEND_FLUSH_IO/_WAL)가 존재하며 PG18과 일치한다. - 줄 번호는 2026-06-05에 트리에서 직접 읽었다. 시간이 지나면 달라질 수 있는 힌트다. 심볼 이름이 내구성 있는 기준점이다.
- 범위 경계. 대기 이벤트, 백엔드 상태, 진행 보고도
utils/activity/아래 있지만 다른 메커니즘(라이브 뷰)으로postgres-wait-events-progress.md에서 다룬다. 오토베큠의 dead-tuple 카운터 소비는postgres-autovacuum.md가 다룬다. 이 문서는 그것들을 재파생하지 않는다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”PostgreSQL이 떠나보낸 UDP collector. PG15 이전의 stats collector는 설계 공간에서 교훈적인 지점으로 기억할 가치가 있다. 모든 카운터 델타를 루프백 UDP로 하나의 프로세스에 보내는 방식은 완벽한 쓰기 격리(오직 collector만 합계를 수정)를 주었지만 세 가지 비용이 따랐다. 공유 메모리 설계가 이 비용을 없앴다. 첫째, 부하 하에서 UDP 데이터그램이 소리 없이 드롭될 수 있어 카운터가 구조적으로 근사치였다. 둘째, 단일 프로세스가 처리량 상한이었다. 셋째, 모든 pg_stat 쿼리가 직렬화된 파일 전체를 역직렬화했다. PG15 재설계는 메시지 전달 공유 상태 모델에서 로컬 집계를 가진 공유 메모리 모델로의 교과서적 이행이다. Architecture of a Database System (Hellerstein et al. 2007) §2의 프로세스 모델 트레이드오프가 한 서브시스템의 역사에서 실현된 것이다. 교훈은 일반화된다. 공유 상태가 교환 가능한 집계(카운터처럼 덧셈만 하는)라면, 로컬 축적 + 주기적 무충돌 병합이 중앙화된 직렬화를 이긴다. 덧셈은 결합 법칙이 성립하고 병합 순서가 중요하지 않기 때문이다.
다른 엔진들의 스레드별 계측. Oracle의 V$ 고정 테이블과 SGA 통계, SQL Server의 DMV, MySQL/InnoDB의 performance_schema 모두 같은 워커 버퍼 + 집계 구조로 수렴했다. 차이는 주로 스레딩 모델이다. 주소 공간을 공유하는 스레드는 원자 연산으로 평범한 공유 구조체를 쓸 수 있지만, PostgreSQL의 프로세스 모델은 DSA와 dshash를 강제한다. MySQL의 performance_schema는 신선도/오버헤드 파라미터를 계측별로 세분화해 노출한다(개별 계측을 비활성화할 수 있다). PostgreSQL의 파라미터는 더 거칠다. track_counts, track_functions, track_io_timing이 전체 범주를 게이팅하고, stats_fetch_consistency는 읽기 측만 제어한다.
반복되는 패턴으로서의 참조 카운트 기반 회수. PostgreSQL이 통계 항목에 쓰는 참조 카운트 + 묘비 + 세대 트리오는 락 프리 자료 구조 연구의 에포크 기반 회수(EBR)와 해저드 포인터(Michael 2004; Fraser 2004)와 같은 형태다. gc_request_count 에포크는 본질적으로 거칠고 협력적인 EBR이다. 각 읽기자가 정밀한 에포크를 발행하는 대신, 드로퍼가 전역 카운터를 증가시키고 모든 백엔드가 결국 pgstat_get_entry_ref()를 통과해 알아챌 것을 신뢰한다. 진정한 EBR보다 저렴하다(접근별 에포크 발행 없음). 대신 회수 지연이 무한정일 수 있다는 비용이 있는데, stale 통계 메모리가 작고 dropped-but-still-referenced 객체 수로 bounded되므로 여기서는 수용 가능하다. generation 필드는 락 프리 문헌이 강조하는 ABA 문제 방어막이다. “이 포인터가 같은 것인가?”를 “이 구현이 같은 것인가?”로 바꿔 준다.
모니터링 읽기의 일관성 모델. stats_fetch_consistency GUC는 스트리밍/관찰성 연구 커뮤니티가 길게 다루는 질문에 대한 작고 실용적인 답이다. 모니터링 읽기가 어떤 격리를 받아야 하는가. snapshot은 단일 시점에 모든 카운터에 걸쳐 직렬화 가능에 가까운 일관성을 주지만(전체 테이블 복사 비용을 지불한다), cache는 객체별 읽기 커밋에 메모이제이션을 더한다. none은 가장 신선하지만 찢어진 읽기가 된다. 현대 시계열/관찰성 시스템(Prometheus, 스트리밍 집계 관련 연구)은 보통 none에 해당하는 최종 일관성, 시리즈별 방식을 선택한다. 크로스 시리즈 일관성이 알림에 거의 중요하지 않기 때문이다. PostgreSQL의 기본값 cache는 한 구문 안에서 몇 가지 객체를 건드리는 일반적인 pg_stat 쿼리에 맞춘 중간 지점이다.
프론티어: 집계를 이벤트 소스에 더 가깝게 밀기. 반복되는 연구/엔지니어링 주제는 집계를 이벤트 소스에 더 가깝게 이동시켜 병합 비용을 줄이는 것이다. 높은 카디널리티 경우를 위한 스케치 기반 근사 카운터(Count-Min, HyperLogLog)나 하드웨어 지원 원자 연산 등이 예다. 정확하고 낮은 카디널리티 활동 카운터의 경우 PostgreSQL의 백엔드별 정확한 집계가 올바른 선택이다. 흥미로운 미해결 질문은 가변 수 극단, 즉 수백만 개의 단명 테이블/파티션이 있어 dshash와 객체별 DSA 본문이 비용 중심이 되고 참조 카운팅 GC가 휘청거리는 워크로드다. 커스텀 누적 통계 kind(pgstat_register_kind() 익스텐션 포인트, PGSTAT_KIND_CUSTOM_MIN 이상의 예약 ID)는 익스텐션이 이 기계에 자신의 kind를 추가하게 하며, 더 조밀한 표현으로 실험할 곳이 바로 여기다.
- 소스 트리: PostgreSQL REL_18_STABLE, 커밋
273fe94852b(2026-06-05):src/backend/utils/activity/pgstat.c— 인프라: kind 테이블,pgstat_report_stat(), pending 목록, 스냅샷 빌드, statsfile I/O.src/backend/utils/activity/pgstat_shmem.c— 공유 메모리 레이어: DSA/dshash 부트스트랩,pgstat_get_entry_ref(), 참조 카운트/세대 수명, 드롭과 GC.src/backend/utils/activity/pgstat_relation.c— 릴레이션 kind: 트랜잭션 튜플 카운트, vacuum/analyze 보고, 플러시 콜백.src/backend/utils/activity/pgstat_xact.c— 트랜잭션적 생성/드롭 통합과 WAL/2PC 핸드오프.src/include/utils/pgstat_internal.h— 공유/로컬 구조체 정의와 단일 쓰기자 고정 통계용 changecount 인라인 헬퍼.src/include/pgstat.h,src/include/utils/pgstat_kind.h— 퍼블릭 타입, kind ID,stats_fetch_consistency열거형, 파일 형식 ID.
- 이론 기반:
- Database System Concepts (Silberschatz, Korth & Sudarshan, 7판) — 16장 (플래너 통계 vs. 활동 카운터), 25장 §25.3 (스토리지 매니저 하우스키핑 / 오토베큠 역할).
knowledge/research/dbms-general/database-system-concepts.md에 수록. - Architecture of a Database System (Hellerstein, Stonebraker & Hamilton 2007) — §2 프로세스 모델, §6 공유 구성요소: PG15 재설계가 구현하는 메시지 전달 vs. 공유 메모리 트레이드오프.
.omc/plans/postgres-paper-bibliography.md에 정리.
- Database System Concepts (Silberschatz, Korth & Sudarshan, 7판) — 16장 (플래너 통계 vs. 활동 카운터), 25장 §25.3 (스토리지 매니저 하우스키핑 / 오토베큠 역할).
- 락 프리 회수 맥락 (비교 절): 에포크 기반 회수와 해저드 포인터 문헌 (Michael 2004; Fraser 2004) — 참조 카운트/세대/에포크 트리오의 설계 계보를 위해 인용. PostgreSQL 소스가 아니다.
- 이 코드 분석 트리 내 교차 참조:
postgres-wait-events-progress.md—utils/activity/를 공유하는 라이브 뷰(상태, 대기 이벤트, 진행).postgres-autovacuum.md— dead-tuple / 변경 카운터의 소비자.postgres-shared-memory-ipc.md,postgres-lwlock-spinlock.md— 이 서브시스템이 구축된 DSA, dshash, LWLock 기반.postgres-overview-monitoring-stats.md— 서브카테고리 라우터.