(KO) PostgreSQL ResourceOwner — 계층적 자원 추적과 에러 안전 해제
목차
이론적 배경
섹션 제목: “이론적 배경”SQL 구문을 실행하는 데이터베이스 백엔드는 짧은 쿼리 수명 자원(query-lifespan resource)을 대거 획득한다. 버퍼 매니저가 스캔 중인 페이지를 교체하지 못하도록 버퍼를 핀(pin)하고, 접근하는 릴레이션과 튜플에 락 매니저 락을 건다. 캐시된 릴레이션 디스크립터와 카탈로그 캐시 항목의 참조 카운트를 올리고, 정렬·해시 스필용 임시 파일을 열며, 스냅샷을 등록하고, 병렬 워커를 위해 JIT 컴파일 모듈과 DSM 세그먼트를 할당한다. 이 모두는 유한한 공유 풀에 대한 청구권이며 반드시 반납해야 한다. 그리고 핵심 정확성 문제는 정상 경로가 아니라 에러 경로에 있다. SQL 쿼리는 거의 어떤 C 구문에서든 실패할 수 있다. palloc이 메모리 한계에 부딪히거나, 데이터 타입 입력 함수가 리터럴을 거부하거나, 유일 인덱스 삽입이 제약을 위반하거나, 스캔 도중 SIGINT가 도착하면 된다. 그 순간 핀과 참조를 쥐고 있던 수십 개의 중첩 C 프레임이 longjmp로 해체되고, 그 프레임들에 심어 둔 “해제” 호출은 하나도 실행되지 않는다. 자원이 프레임의 로컬 변수에만 기록되어 있다면 누수가 발생한다. 누수된 버퍼 핀은 페이지를 영구히 고정하고, 누수된 락은 다른 모든 백엔드를 블로킹하며, 누수된 릴케시 참조는 무효화를 망가뜨린다.
개념적 해답은 시스템 언어들이 RAII(Resource Acquisition Is Initialization, 자원 획득이 초기화)라는 이름으로 제시한 것과 같다. 모든 자원의 수명을 어떤 소유자 객체의 수명에 묶어, 소유자가 해체될 때 소유한 것이 자동으로 해제되게 하는 것이다. C++는 스택에 할당된 객체의 소멸자가 스택 언와인딩 중 실행되는 방식으로 이를 구현하고, Rust는 Drop으로 구현한다. C에는 소멸자도 언와인딩도 없으므로 PostgreSQL은 이 기제를 직접 만든다. 자원과 그 자원을 획득한 C 스택 프레임 사이에 힙 할당 부기 객체인 ResourceOwner를 삽입해 “이 소유자가 지금 이 핀/락/참조를 보유하고 있다”고 기록한다. 소유자는 C 스택 프레임이 아니라 트랜잭션·서브트랜잭션·포털에 묶인다. 이 경계들이 바로 PostgreSQL이 자원을 회수하길 원하는 범위이기 때문이다. 트랜잭션이 끝날 때, 커밋이든 어보트든, ResourceOwner를 순회해 아직 보유 중인 자원을 종류별 콜백으로 해제한다. 에러 경로는 그러면 단순해진다. longjmp가 어보트 핸들러에 착지하면 최상위 트랜잭션의 ResourceOwner를 해제하고, 이것이 실패한 프레임들이 남긴 모든 자원을 정리한다.
두 번째, 미묘한 이론적 요건은 순서다. 자원들은 독립적이지 않다. 핀된 버퍼는 공유 버퍼 디스크립터의 refcount로 다른 백엔드에 가시적이고, 보유한 락은 다른 백엔드가 기다리고 있는 것이다. 커밋 중인 트랜잭션이 버퍼 핀을 해제하기 전에 락을 해제한다면, 다른 백엔드가 해제된 락을 획득해 릴레이션을 열었을 때 락의 관점에서 이미 떠난 트랜잭션이 여전히 핀(그리고 가능하면 수정 중)한 페이지를 발견할 수 있다. 따라서 해제는 단계화되어야 한다. 다른 백엔드에 가시적인 것(핀)은 락보다 먼저 해제하고, 백엔드 내부 정리(카탈로그 캐시, 임시 파일)는 나중에 해야 한다. 이것은 ARIES(Mohan et al. 1992, knowledge/research/dbms-papers/aries.md에 수록)가 커밋/어보트에 부과하는 것과 같은 순서 규율이다. ResourceOwner의 3단계 해제는 단일 백엔드 내에서 그 규칙을 구현한 것이다.
표준 교재는 이 문제를 간접적으로만 다룬다. Database System Concepts(Silberschatz 7e, ch. 17–18)는 원자성과 복구의 단위로서 트랜잭션을 논하고, Database Internals(Petrov 2019, ch. 5–6)는 버퍼 관리와 락 수명을 다루지만, 어느 쪽도 자원 수명을 트랜잭션 범위에 묶는 백엔드 내 부기 객체를 명명하지 않는다. 이 객체는 구현 발명품이며, PostgreSQL의 resowner/README는 그 설계가 MemoryContext API를 모델로 했다고 명시한다. 두 기제는 의도적으로 분리되어 있지만(메모리 누수와 자원 누수는 사용 패턴이 다르다) 같은 형태를 공유한다. 범위의 트리, “현재” 포인터, 범위 종료 시 재귀적 일괄 해제가 그것이다.
DBMS 공통 설계
섹션 제목: “DBMS 공통 설계”모든 진지한 DBMS는 에러 시 누수 문제에 맞닥뜨린다. 차이는 어떤 언어 기제에 의존하느냐와 부기가 얼마나 명시적이냐에 있다.
가비지 컬렉션/관리형 엔진 (Java H2, Derby; C# 엔진)은 메모리 회수를 무료로 얻지만 비메모리 자원(락, 파일 핸들, 래치)에는 여전히 명시적 해제가 필요하다. 언어의 try/finally나 using/try-with-resources 구문을 사용해 예외 전파 중 정리 코드를 실행한다. 언어 런타임에 의한 RAII다. 비용은 규율이 분산되어 있다는 점이다. 각 프레임이 자체 정리를 기억해야 하고, finally가 빠지면 조용히 누수가 발생한다.
C++ 엔진 (현대 C++로 작성된 스토리지 레이어 대부분)은 RAII를 직접 사용한다. BufferGuard나 LockGuard 같은 스택 객체의 소멸자가 언핀/언락하며, 예외 전파 중 C++ 스택 언와인딩이 소멸자를 자동으로 실행한다. 우아하고 지역적이지만 자원 수명을 C 스택 범위에 묶는다는 한계가 있다. 여러 함수 반환을 거쳐 핀 상태를 유지하다가 트랜잭션 종료 시 해제해야 하는 버퍼는 스택 가드에 담을 수 없다. 결국 어떤 식으로든 힙 상주 소유자가 필요해진다.
C 엔진 (PostgreSQL, SQLite, 그리고 Berkeley/Ingres 계열에서 파생된 많은 엔진)에는 소멸자도 없고 PostgreSQL의 경우 진짜 예외도 없다. setjmp/longjmp만 있다. 언와인딩 중 컴파일러가 정리를 실행해 줄 수 없으므로 미결 자원의 명시적이고 중앙화된 레지스트리를 유지하고 단일 에러 핸들러가 순회해야 한다. SQLite는 단일 스레드 연결 방식과 아레나(arena) 할당 방식으로 대부분을 sqlite3 연결 객체와 준비된 구문에 범위화해 이 문제를 상당 부분 우회한다. PostgreSQL은 고도로 동시적인 다중 자원 엔진이므로 더 풍부한 것이 필요하다. 트랜잭션/서브트랜잭션/포털 중첩을 반영하는 소유자 포레스트와, 획득 프리미티브가 자동으로 참조하는 “현재 소유자” 전역 변수가 그것이다.
세 가지 설계 축이 이 구현들에서 반복된다.
-
소유자의 단위. C 프레임(C++ 가드), 구문, 트랜잭션/세이브포인트 중 어디에 묶을 것인가. PostgreSQL은 트랜잭션/서브트랜잭션/포털을 선택한다. 이 경계들이 복구 경계이고 실제로 일괄 회수를 원하는 지점이기 때문이다.
-
해제의 단계화. 2단계 락킹(사실상 모든 엔진)을 사용하는 엔진은 락보다 백엔드 가시 상태를 먼저 해제해야 한다. 대부분은 커밋/어보트 루틴에서 정리 단계의 순서 목록으로 이를 인코딩한다. PostgreSQL은 자원 종류별 단계+우선순위로 선언적으로 인코딩하고 ResourceOwner가 정렬하게 한다.
-
락의 특수 취급. 커밋하는 서브트랜잭션이나 포털이 보유한 락은 해제되어선 안 된다. 지금은 상위 트랜잭션의 소유가 됐기 때문이다. 따라서 자식 소유자 “해제”는 락의 경우 실제로 부모에게로의 이전이다. 세이브포인트를 가진 엔진들은 모두 이것이 필요하다. PostgreSQL은 ResourceOwner의 락 처리에 직접 이를 내장한다.
통합 아이디어는 자원 정리가 실행기 전체에 흩어진 수십 개 수작업 해제 호출의 정확성에 맡기기엔 너무 중요하다는 것이다. 추적을 하나의 모듈에 집중하고, 획득 시 묵시적으로 현재 소유자에 등록하며, 범위 종료를 단일 재귀 스윕으로 만드는 것이다. PostgreSQL의 resowner.c는 어떤 오픈소스 엔진에서든 이 아이디어를 가장 깔끔하게 실현한 예 중 하나다.
PostgreSQL의 접근
섹션 제목: “PostgreSQL의 접근”PostgreSQL의 ResourceOwner는 TopMemoryContext에 할당된 힙 객체다. 트랜잭션의 메모리 컨텍스트보다 오래 살아남으며 명시적으로만 해제된다. 부모 포인터와 자식 목록을 보유해 포레스트를 이룬다. 네 개의 전역 소유자가 포레스트를 고정한다.
// resowner.c — the four globally-known owners (resowner.c, GLOBAL MEMORY)ResourceOwner CurrentResourceOwner = NULL;ResourceOwner CurTransactionResourceOwner = NULL;ResourceOwner TopTransactionResourceOwner = NULL;ResourceOwner AuxProcessResourceOwner = NULL;CurrentResourceOwner는 획득 시점에 중요한 것이다. ReadBuffer가 페이지를 핀하거나 LockAcquire가 락을 잡을 때, 그 순간 CurrentResourceOwner가 가리키는 소유자에 자원을 기록한다. README는 트랜잭션 밖에서(그리고 실패한 트랜잭션 안에서) 이것이 NULL이며, 그때 쿼리 수명 자원을 획득하는 것은 불법이라고 강조한다. CurTransactionResourceOwner는 현재 (서브)트랜잭션의 소유자이고, TopTransactionResourceOwner는 최외곽 트랜잭션의 소유자(트랜잭션별 서브트리의 루트)이며, AuxProcessResourceOwner는 체크포인터·walwriter처럼 실제 트랜잭션은 없지만 버퍼를 핀하는 보조 프로세스를 위한 것이다.
포레스트의 형태는 중첩을 그대로 따른다. 서브트랜잭션의 소유자는 부모 소유자의 자식으로 생성된다.
// xact.c — StartSubTransaction creates a child owner (xact.c)s->curTransactionOwner = ResourceOwnerCreate(s->parent->curTransactionOwner, "SubTransaction");CurTransactionResourceOwner = s->curTransactionOwner;CurrentResourceOwner = s->curTransactionOwner;포털의 소유자는 현재 트랜잭션 소유자의 자식으로 생성되어, 포털이 닫힐 때 보유한 락이 트랜잭션의 책임이 된다.
// portalmem.c — CreatePortal hangs the portal owner under the transactionportal->resowner = ResourceOwnerCreate(CurTransactionResourceOwner, "Portal");각 자원 종류는 언제 어떤 순서로 해제될지와 해제·디버그 출력 콜백을 선언하는 ResourceOwnerDesc를 등록한다. 예를 들어 버퍼 핀은 버퍼 핀 우선순위를 가진 BEFORE_LOCKS 자원이다.
// bufmgr.c — the buffer-pin resource kind descriptorconst ResourceOwnerDesc buffer_pin_resowner_desc ={ .name = "buffer pin", .release_phase = RESOURCE_RELEASE_BEFORE_LOCKS, .release_priority = RELEASE_PRIO_BUFFER_PINS, .ReleaseResource = ResOwnerReleaseBufferPin, .DebugPrint = ResOwnerPrintBufferPin};세 단계와 내장 우선순위는 헤더에 고정되어 있다. 단계 열거형이 순서 규칙을 직접 인코딩한다. 핀과 외부 가시 자원은 BEFORE_LOCKS이고, 락은 자체 단계이며, 백엔드 내부 캐시(catcache, plancache, tupdesc, 스냅샷, 파일, wait-event 집합)는 AFTER_LOCKS다.
// resowner.h — the three release phases and selected built-in prioritiestypedef enum{ RESOURCE_RELEASE_BEFORE_LOCKS = 1, RESOURCE_RELEASE_LOCKS, RESOURCE_RELEASE_AFTER_LOCKS,} ResourceReleasePhase;
/* priorities of built-in BEFORE_LOCKS resources */#define RELEASE_PRIO_BUFFER_IOS 100#define RELEASE_PRIO_BUFFER_PINS 200#define RELEASE_PRIO_RELCACHE_REFS 300/* priorities of built-in AFTER_LOCKS resources */#define RELEASE_PRIO_CATCACHE_REFS 100#define RELEASE_PRIO_SNAPSHOT_REFS 500#define RELEASE_PRIO_FILES 600해제 자체는 xact.c가 TopTransactionResourceOwner에 단계별로 ResourceOwnerRelease를 세 번 호출해 구동한다. 단계화는 호출자의 책임이며, 엔진 정리(카탈로그 무효화, 릴케시 정리)가 정확히 맞는 순간에 단계 사이에 끼워진다. 에러 언와인드 경로도 같은 세 호출이되 isCommit=false다. 이것이 PG_TRY 통합의 핵심이다. 어디서든 longjmp가 발생하면 AbortTransaction에 착지하고, 이 세 호출이 어보트된 C 프레임들이 남긴 모든 자원을 해제한다.
전체 설계는 의도적으로 MemoryContext를 병렬로 한다. 범위의 트리, 획득 프리미티브가 참조하는 Current* 포인터, 범위 종료 시 재귀적 일괄 스윕이다. 차이는 ResourceOwner가 타입이 있는 외부 청구권(각각 해제 콜백 보유)을 추적하는 반면 MemoryContext는 원시 할당을 추적한다는 것이다.
flowchart TD
TT["TopTransactionResourceOwner<br/>(TopTransaction)"]
ST["서브트랜잭션 소유자<br/>(부모 xact의 자식)"]
P1["포털 소유자<br/>(CurTransactionResourceOwner의 자식)"]
P2["포털 소유자 #2"]
AUX["AuxProcessResourceOwner<br/>(별도 루트: 체크포인터, walwriter)"]
TT --> ST
TT --> P1
TT --> P2
ST --> STP["서브xact 아래 중첩 포털"]
CUR["CurrentResourceOwner<br/>(전역: 새 획득의 소유자)"]
CUR -.활성 범위를 가리킴.-> P1
subgraph claims["소유자에 기억된 자원들"]
B["버퍼 핀 (BEFORE_LOCKS)"]
L["lmgr 락 (LOCKS 단계, 손실형 캐시)"]
R["relcache / catcache 참조"]
F["임시 파일, 스냅샷 (AFTER_LOCKS)"]
A["AIO 핸들 (dlist, 크리티컬 섹션 안전)"]
end
P1 --> claims
소스 코드 가이드
섹션 제목: “소스 코드 가이드”소유자 객체와 스토리지
섹션 제목: “소유자 객체와 스토리지”ResourceOwnerData가 모듈의 핵심이다. 세 가지 스토리지 영역에 주목하라. 가장 최근 자원을 위한 32슬롯 고정 배열, 배열이 넘칠 때 쓰는 해시 테이블, 그리고 별도의 15엔트리 락 캐시다. releasing/sorted 플래그는 해제가 시작되면 더 이상 Remember/Forget을 할 수 없도록 소유자를 잠근다.
// resowner.c — ResourceOwnerData (abridged to the load-bearing fields)struct ResourceOwnerData{ ResourceOwner parent; /* NULL if no parent (toplevel owner) */ ResourceOwner firstchild; /* head of linked list of children */ ResourceOwner nextchild; /* next child of same parent */ const char *name; /* name (just for debugging) */
bool releasing; /* release has started; no more Remember */ bool sorted; /* are 'hash' and 'arr' sorted by priority? */
uint8 nlocks; /* number of owned locks */ uint8 narr; /* how many items are stored in the array */ uint32 nhash; /* how many items are stored in the hash */
ResourceElem arr[RESOWNER_ARRAY_SIZE]; /* recent resources (size 32) */
ResourceElem *hash; /* open-addressing spill table */ uint32 capacity; /* allocated length of hash[] */ uint32 grow_at; /* grow hash when reach this */
LOCALLOCK *locks[MAX_RESOWNER_LOCKS]; /* lossy lock cache (size 15) */ dlist_head aio_handles; /* AIO handles, registered in crit sections */};ResourceElem은 { Datum item; const ResourceOwnerDesc *kind; }에 불과하다. 파일 상단의 설계 주석은 배열/해시 분리를 설명한다. 일반적인 패턴은 기억-후-곧-망각(버퍼 핀 후 튜플 읽기 후 언핀)이며, 이는 작은 배열의 선형 스캔으로 저렴하게 처리된다. 수명이 길거나 수가 많은 자원은 해시로 스필된다. 생성은 TopMemoryContext에서 구조체를 영초기화하고 부모 아래에 연결한다.
// resowner.c — ResourceOwnerCreateResourceOwnerResourceOwnerCreate(ResourceOwner parent, const char *name){ ResourceOwner owner;
owner = (ResourceOwner) MemoryContextAllocZero(TopMemoryContext, sizeof(struct ResourceOwnerData)); owner->name = name;
if (parent) { owner->parent = parent; owner->nextchild = parent->firstchild; parent->firstchild = owner; } dlist_init(&owner->aio_handles); return owner;}확장-후-기억: 획득 전 예약 계약
섹션 제목: “확장-후-기억: 획득 전 예약 계약”자원 기억을 두 호출로 나눈 것은 의도적이다. ResourceOwnerEnlarge는 자원 획득 전에 공간을 보장한다. 공간 확보 실패(해시 확장을 위한 메모리 부족)는 추적되지 않은 핀을 손에 쥐기 전에 실패해야 하기 때문이다.
// resowner.c — ResourceOwnerEnlarge (hash-growth path abridged)voidResourceOwnerEnlarge(ResourceOwner owner){ if (owner->releasing) elog(ERROR, "ResourceOwnerEnlarge called after release started");
if (owner->narr < RESOWNER_ARRAY_SIZE) return; /* no work needed — array has room */
/* array full: ensure the hash has space, growing (doubling) if needed */ if (owner->narr + owner->nhash >= owner->grow_at) { uint32 newcap = (owner->capacity > 0) ? owner->capacity * 2 : RESOWNER_HASH_INIT_SIZE; ResourceElem *newhash = MemoryContextAllocZero(TopMemoryContext, newcap * sizeof(ResourceElem)); /* ... after this point we assume no failure, so scribble on owner ... */ owner->hash = newhash; owner->capacity = newcap; owner->grow_at = RESOWNER_HASH_MAX_ITEMS(newcap); /* re-hash old entries, pfree old table */ }
/* Drain the 32-slot array into the hash so the array is free again */ for (int i = 0; i < owner->narr; i++) ResourceOwnerAddToHash(owner, owner->arr[i].item, owner->arr[i].kind); owner->narr = 0;}ResourceOwnerRemember는 이후 (이제 공간이 보장된) 배열 끝에 추가한다. 호출자가 공간을 예약했고 해제가 시작되지 않았음을 assert한다.
// resowner.c — ResourceOwnerRemember appends to the fast arrayvoidResourceOwnerRemember(ResourceOwner owner, Datum value, const ResourceOwnerDesc *kind){ Assert(kind->release_phase != 0); Assert(kind->release_priority != 0); Assert(!owner->releasing); Assert(!owner->sorted);
if (owner->narr >= RESOWNER_ARRAY_SIZE) elog(ERROR, "ResourceOwnerRemember called but array was full");
owner->arr[owner->narr].item = value; owner->arr[owner->narr].kind = kind; owner->narr++;}bufmgr 핀 경로가 이 계약을 실제로 보여 준다. ResourceOwnerEnlarge(CurrentResourceOwner)를 앞에서 호출하고(ReservePrivateRefCountEntry와 함께), 실제 핀의 ResourceOwnerRemember는 핀이 확보된 후에 호출된다. README의 경고 “Enlarge와 예약한 Remember 사이에 무관한 ResourceOwnerRemember 호출이 없도록 하라”는 정확히 이 예약 슬롯 하나를 보존하는 것에 관한 것이다.
Forget: 배열 먼저, 그 다음 해시
섹션 제목: “Forget: 배열 먼저, 그 다음 해시”ResourceOwnerForget은 배열을 뒤에서 앞으로 검색한다(가장 최근 슬롯이 가장 유력한 대상). 히트 시 마지막 원소를 내려 교체하는 O(1) 비순서 제거다. 배열에서 찾지 못할 때만 해시를 탐색한다. 뒤에서 앞으로 스캔하고 마지막과 교체하는 방식이 방금-기억한-것을-망각하는 경우를 공짜로 처리하며, 여러 호출자들이 이에 의존한다.
// resowner.c — ResourceOwnerForget (array scan; hash probe abridged)voidResourceOwnerForget(ResourceOwner owner, Datum value, const ResourceOwnerDesc *kind){ if (owner->releasing) elog(ERROR, "ResourceOwnerForget called for %s after release started", kind->name); Assert(!owner->sorted);
/* Search the array first, newest-first */ for (int i = owner->narr - 1; i >= 0; i--) { if (owner->arr[i].item == value && owner->arr[i].kind == kind) { owner->arr[i] = owner->arr[owner->narr - 1]; /* swap last down */ owner->narr--; return; } } /* else probe the open-addressing hash, NULL out the slot, nhash-- */ /* ... */ elog(ERROR, "%s %p is not owned by resource owner %s", kind->name, DatumGetPointer(value), owner->name);}3단계 해제와 재귀
섹션 제목: “3단계 해제와 재귀”ResourceOwnerRelease는 단계화, 재귀, 락 특수 처리가 실제로 일어나는 ResourceOwnerReleaseInternal의 얇은 래퍼다. 두 가지 구조적 사실이 지배한다. 첫째, 자식을 먼저 재귀 처리해 포털/서브xact가 각 단계 내에서 부모보다 먼저 완전히 해제된다. 둘째, 첫 번째 호출 시 releasing을 설정하고 자원을 단계+우선순위로 정렬하며, 이후 Remember/Forget은 불가능하다.
// resowner.c — ResourceOwnerReleaseInternal (children-first recursion + sort)static voidResourceOwnerReleaseInternal(ResourceOwner owner, ResourceReleasePhase phase, bool isCommit, bool isTopLevel){ ResourceOwner child; ResourceOwner save;
/* Recurse to handle descendants before self */ for (child = owner->firstchild; child != NULL; child = child->nextchild) ResourceOwnerReleaseInternal(child, phase, isCommit, isTopLevel);
if (!owner->releasing) { Assert(phase == RESOURCE_RELEASE_BEFORE_LOCKS); owner->releasing = true; } if (!owner->sorted) { ResourceOwnerSort(owner); /* sort by reverse phase+priority */ owner->sorted = true; }
/* Make the release callbacks see the owner being released as current */ save = CurrentResourceOwner; CurrentResourceOwner = owner; /* ... per-phase work below ... */ CurrentResourceOwner = save;}단계별 본문은 순서 규칙이 집행되는 곳이다. BEFORE_LOCKS는 외부 가시 자원(AIO 핸들 드레인 포함)을 해제하고, AFTER_LOCKS는 백엔드 내부 자원을 해제한다. 둘 다 정렬된 ResourceOwnerReleaseAll을 통한다. LOCKS 단계는 특수하다.
// resowner.c — the LOCKS phase: bulk for top xact, transfer-or-release for childrenif (phase == RESOURCE_RELEASE_LOCKS){ if (isTopLevel) { /* top xact: drop ALL locks in one lmgr call at the top of recursion */ if (owner == TopTransactionResourceOwner) { ProcReleaseLocks(isCommit); ReleasePredicateLocks(isCommit, false); } } else { /* subxact/portal: hand this owner's locks to the lock manager */ LOCALLOCK **locks; int nlocks;
if (owner->nlocks > MAX_RESOWNER_LOCKS) /* cache overflowed */ locks = NULL, nlocks = 0; /* lmgr scans its own table */ else locks = owner->locks, nlocks = owner->nlocks;
if (isCommit) LockReassignCurrentOwner(locks, nlocks); /* transfer to parent */ else LockReleaseCurrentOwner(locks, nlocks); /* truly release */ }}README와 이론 모두가 요구하는 락 특수 취급이 여기 있다. 서브트랜잭션이나 포털의 커밋 시 락은 부모에게 재할당된다(트랜잭션 종료까지 자식보다 오래 살아야 한다). 어보트 시에는 진짜로 해제된다. 15엔트리 owner->locks 캐시는 손실형 빠른 경로다. 자식이 락을 15개 이하로 보유하면 캐시에서 직접 재할당/해제할 수 있다. 넘친 경우에는 NULL을 전달하고 락 매니저가 자체 로컬 락 해시 테이블을 스캔하는 폴백을 사용한다.
역방향 우선순위 정렬
섹션 제목: “역방향 우선순위 정렬”ResourceOwnerSort는 배열과 해시를 하나의 연속 구간으로 합쳐 resource_priority_cmp로 qsort한다. 이 비교 함수는 단계 후 우선순위를 역방향으로 정렬하여 ResourceOwnerReleaseAll이 끝에서부터 해제하다가 다음 단계로 넘어가면 멈출 수 있게 한다.
// resowner.c — resource_priority_cmp orders reverse so release walks from the endstatic intresource_priority_cmp(const void *a, const void *b){ const ResourceElem *ra = a; const ResourceElem *rb = b;
/* Note: reverse order */ if (ra->kind->release_phase == rb->kind->release_phase) return pg_cmp_u32(rb->kind->release_priority, ra->kind->release_priority); else if (ra->kind->release_phase > rb->kind->release_phase) return -1; else return 1;}ResourceOwnerReleaseAll은 nitems-1부터 아래로 순회하며 각 종류의 ReleaseResource(value) 콜백을 호출한다. printLeakWarnings(즉 커밋 시, 실행기가 스스로 모두 해제했어야 함)가 설정된 경우 종류의 DebugPrint로 WARNING: resource was not closed: ...를 발행한다. 어보트 시 누수는 예상된 것이고 조용하다. 이 비대칭이 README의 “커밋 시 소유자는 비어 있어야 한다; 어보트 시 우리가 이 기제에 진정으로 의존한다”다.
락 캐시, AIO 핸들, 삭제
섹션 제목: “락 캐시, AIO 핸들, 삭제”락은 배열/해시를 완전히 우회해 15슬롯 캐시에 저장되며, ResourceOwnerRememberLock으로 채워진다. 캐시는 의도적으로 손실형이다. 넘치면 추적을 중단해 정확한 회계를 저렴한 일괄 해제/재할당과 맞바꾼다.
// resowner.c — ResourceOwnerRememberLock: lossy 15-entry cachevoidResourceOwnerRememberLock(ResourceOwner owner, LOCALLOCK *locallock){ Assert(locallock != NULL);
if (owner->nlocks > MAX_RESOWNER_LOCKS) return; /* already overflowed: stop tracking */
if (owner->nlocks < MAX_RESOWNER_LOCKS) owner->locks[owner->nlocks] = locallock; else { /* overflowed (nlocks becomes MAX+1, a sentinel) */ } owner->nlocks++;}AIO 핸들은 자체 dlist를 갖는다. 크리티컬 섹션 안에서 기억될 수 있어, palloc이 가능한 일반 ResourceOwnerEnlarge를 사용할 수 없기 때문이다. 할당 없는 push/pop(ResourceOwnerRememberAioHandle/ForgetAioHandle)을 사용하며 BEFORE_LOCKS 단계에서 pgaio_io_release_resowner로 드레인된다. ResourceOwnerDelete는 소유자 객체 자체를 해제하지만, 모든 자원이 사라진 후에만 가능하다. 배열, 해시, 락 카운트가 비어 있음을 assert하고(락 카운트는 합법적으로 오버플로 센티널일 수 있다) 자식들을 재귀 삭제한다.
// resowner.c — ResourceOwnerDelete asserts emptiness, recurses, freesvoidResourceOwnerDelete(ResourceOwner owner){ Assert(owner != CurrentResourceOwner); Assert(owner->narr == 0); Assert(owner->nhash == 0); Assert(owner->nlocks == 0 || owner->nlocks == MAX_RESOWNER_LOCKS + 1);
while (owner->firstchild != NULL) ResourceOwnerDelete(owner->firstchild); /* child delinks itself */
ResourceOwnerNewParent(owner, NULL); /* delink from parent */ if (owner->hash) pfree(owner->hash); pfree(owner);}xact.c가 구동하는 방식 — PG_TRY 에러 언와인드 연결
섹션 제목: “xact.c가 구동하는 방식 — PG_TRY 에러 언와인드 연결”단계화는 호출자의 일이다. CommitTransaction은 isCommit=true로 세 번의 ResourceOwnerRelease를 발행하며, 단계 사이에 엔진 정리를 끼워 넣는다. RESOURCE_RELEASE_BEFORE_LOCKS가 실행되고, AtEOXact_Buffers/AtEOXact_RelationCache/AtEOXact_Inval이 실행된 다음, LOCKS와 AFTER_LOCKS 단계가 실행된다. 따라서 카탈로그 무효화는 락이 아직 보유된 상태에서 게시된다.
// xact.c — CommitTransaction: phased release with cleanup interleavedCurrentResourceOwner = NULL;ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_BEFORE_LOCKS, true, true);AtEOXact_Buffers(true);AtEOXact_RelationCache(true);AtEOXact_Inval(true); /* publish catalog invalidations under lock */AtEOXact_MultiXact();ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_LOCKS, true, true);ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_AFTER_LOCKS, true, true);AbortTransaction은 isCommit=false로 같은 세 단계를 실행한다. 전체 설계의 보상이 여기 있다. 실패한 C 프레임에서 longjmp가 어보트 경로로 착지하면(최상위 루프의 PG_TRY/sigsetjmp가 설정한 지점으로), 실행기 자체의 해제 호출은 실행되지 않았다. ResourceOwner만이 아직 보유 중인 핀, 락, 참조를 알고 있으며, 이 세 호출이 모두 해제한다.
// xact.c — AbortTransaction: same three phases, isCommit=false (error unwind)if (TopTransactionResourceOwner != NULL){ CallXactCallbacks(XACT_EVENT_ABORT); ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_BEFORE_LOCKS, false, true); AtEOXact_Buffers(false); AtEOXact_RelationCache(false); AtEOXact_Inval(false); ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_LOCKS, false, true); ResourceOwnerRelease(TopTransactionResourceOwner, RESOURCE_RELEASE_AFTER_LOCKS, false, true);}CleanupTransaction은 ResourceOwnerDelete(TopTransactionResourceOwner)를 호출해 (이제 비어 있는) 소유자 객체들을 해제하고 세 전역 변수를 NULL로 만든다.
flowchart TD
ERR["에러: ereport(ERROR) / elog(ERROR)<br/>실행기 C 프레임 어디서든"]
LJ["siglongjmp → PG_exception_stack<br/>(메인 루프의 PG_TRY/sigsetjmp가 설정)"]
AB["AbortTransaction()"]
P1["ResourceOwnerRelease(BEFORE_LOCKS, isCommit=false)<br/>→ 버퍼 언핀, AIO 드레인"]
EC["AtEOXact_Buffers / RelationCache / Inval"]
P2["ResourceOwnerRelease(LOCKS, false)<br/>→ ProcReleaseLocks (진짜 해제)"]
P3["ResourceOwnerRelease(AFTER_LOCKS, false)<br/>→ catcache/파일/스냅샷 해제"]
CL["CleanupTransaction → ResourceOwnerDelete<br/>소유자 트리 해제, 전역 변수 null화"]
ERR --> LJ --> AB --> P1 --> EC --> P2 --> P3 --> CL
P1 -. 자식 우선 재귀 .-> P1
위치 힌트 (2026-06-06 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-06 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
struct ResourceOwnerData | src/backend/utils/resowner/resowner.c | 112 |
RESOWNER_ARRAY_SIZE (32) | src/backend/utils/resowner/resowner.c | 73 |
MAX_RESOWNER_LOCKS (15) | src/backend/utils/resowner/resowner.c | 107 |
CurrentResourceOwner (전역) | src/backend/utils/resowner/resowner.c | 173 |
resource_priority_cmp | src/backend/utils/resowner/resowner.c | 269 |
ResourceOwnerSort | src/backend/utils/resowner/resowner.c | 292 |
ResourceOwnerReleaseAll | src/backend/utils/resowner/resowner.c | 348 |
ResourceOwnerCreate | src/backend/utils/resowner/resowner.c | 421 |
ResourceOwnerEnlarge | src/backend/utils/resowner/resowner.c | 452 |
ResourceOwnerRemember | src/backend/utils/resowner/resowner.c | 524 |
ResourceOwnerForget | src/backend/utils/resowner/resowner.c | 564 |
ResourceOwnerRelease | src/backend/utils/resowner/resowner.c | 658 |
ResourceOwnerReleaseInternal | src/backend/utils/resowner/resowner.c | 678 |
ResourceOwnerReleaseAllOfKind | src/backend/utils/resowner/resowner.c | 818 |
ResourceOwnerDelete | src/backend/utils/resowner/resowner.c | 871 |
ResourceOwnerNewParent | src/backend/utils/resowner/resowner.c | 914 |
CreateAuxProcessResourceOwner | src/backend/utils/resowner/resowner.c | 999 |
ResourceOwnerRememberLock | src/backend/utils/resowner/resowner.c | 1062 |
ResourceOwnerForgetLock | src/backend/utils/resowner/resowner.c | 1082 |
ResourceOwnerRememberAioHandle | src/backend/utils/resowner/resowner.c | 1104 |
ResourceReleasePhase 열거형 | src/include/utils/resowner.h | 52 |
ResourceOwnerDesc 구조체 | src/include/utils/resowner.h | 91 |
buffer_pin_resowner_desc | src/backend/storage/buffer/bufmgr.c | 244 |
| 서브xact 소유자 생성 | src/backend/access/transam/xact.c | 1293 |
| 포털 소유자 생성 | src/backend/utils/mmgr/portalmem.c | 205 |
| CommitTransaction 해제 | src/backend/access/transam/xact.c | 2411 |
| AbortTransaction 해제 | src/backend/access/transam/xact.c | 2967 |
| CleanupTransaction 삭제 | src/backend/access/transam/xact.c | 3027 |
소스 검증 (2026-06-06 기준)
섹션 제목: “소스 검증 (2026-06-06 기준)”검증된 사실
섹션 제목: “검증된 사실”-
ResourceOwner는
TopMemoryContext에 할당되고 명시적으로만 해제된다.ResourceOwnerCreate(MemoryContextAllocZero(TopMemoryContext, ...))와ResourceOwnerDelete(pfree(owner))에서 검증. 소유자가 어떤 트랜잭션 메모리 컨텍스트 리셋보다 오래 살아야 하는 이유다. 자원 회계가 추적하는 메모리보다 오래 살아야 하기 때문이다. -
포레스트는 트랜잭션/포털 중첩을 반영한다. 서브xact 소유자는 부모 xact 소유자의 자식이고, 포털 소유자는
CurTransactionResourceOwner의 자식이다.xact.cStartSubTransaction(ResourceOwnerCreate(s->parent->curTransactionOwner, "SubTransaction"))과portalmem.cCreatePortal(ResourceOwnerCreate(CurTransactionResourceOwner, "Portal"))에서 검증. README의 “남은 [포털] 자원들은 현재 트랜잭션의 책임이 된다”는 이 부모 연결로 구현된다. -
해제는 정확히 세 단계로 호출자가 세 번 구동하며, 각 단계 내에서 자식이 부모보다 먼저 해제된다.
ResourceOwnerReleaseInternal(자식 우선for루프 overfirstchild)과RESOURCE_RELEASE_BEFORE_LOCKS / LOCKS / AFTER_LOCKS분기 구조, 그리고CommitTransaction과AbortTransaction모두의 세ResourceOwnerRelease호출에서 검증. -
버퍼 핀은
BEFORE_LOCKS이며 락보다 먼저 해제된다.bufmgr.cbuffer_pin_resowner_desc(.release_phase = RESOURCE_RELEASE_BEFORE_LOCKS,.release_priority = RELEASE_PRIO_BUFFER_PINS)에서 검증. 핀이 다른 백엔드에 가시적이므로 다른 백엔드가 기다리는 락이 해제되기 전에 사라져야 한다는 README의 근거와 일치한다. -
서브트랜잭션/포털 커밋 시 락은 해제가 아닌 부모에게 재할당된다; 어보트 시에는 해제된다.
RESOURCE_RELEASE_LOCKS비최상위 분기:isCommit ? LockReassignCurrentOwner(...) : LockReleaseCurrentOwner(...)에서 검증. README의 “자식에서의 해제 작업은 isCommit이 true이면 락 소유권을 부모에게 이전한다”와 정확히 일치한다. -
락 캐시는 최대
MAX_RESOWNER_LOCKS(15) 엔트리를 보유하며 오버플로 시 손실형이다.ResourceOwnerRememberLock(nlocks > MAX_RESOWNER_LOCKS일 때 조기 반환; 센티널nlocks == MAX+1)과, 오버플로 시locks = NULL을 전달해 lmgr이 자체 테이블을 스캔하는 LOCKS 단계에서 검증.15값과 pg_dump 기반 근거는MAX_RESOWNER_LOCKS주석에 있다. -
빠른 스토어는 32슬롯 배열이 개방 주소 해시로 스필된다;
Enlarge는Remember전에 호출해야 한다.RESOWNER_ARRAY_SIZE 32,ResourceOwnerEnlarge(반환 전 확장/드레인),ResourceOwnerRemember(Enlarge를 건너뛰면elog(ERROR, "...array was full"))에서 검증. -
해제는 역방향 우선순위로 정렬하고 끝에서부터 순회하다 단계 경계에서 멈춘다.
resource_priority_cmp(/* Note: reverse order */)와ResourceOwnerReleaseAll에서 검증. -
커밋 시 누수된 자원에 경고; 어보트 시에는 조용하다.
ResourceOwnerReleaseAll에서printLeakWarnings(커밋 여부로 전달)가elog(WARNING, "resource was not closed: %s", ...)를 제어함으로써 검증. -
AIO 핸들은 크리티컬 섹션에서 기억될 수 있어 ResourceElem 배열 대신
dlist를 사용한다.ResourceOwnerRememberAioHandle(dlist_push_tail)과BEFORE_LOCKS드레인 루프의pgaio_io_release_resowner호출에서 검증.
미해결 질문
섹션 제목: “미해결 질문”-
15엔트리 락 캐시가 실제 OLTP에서 얼마나 자주 오버플로하는가.
MAX_RESOWNER_LOCKS주석은 9.2 시대 pg_dump 측정치(비최상위 소유자당 최대 9개 락)를 인용한다. 현대 파티셔닝 스키마에서 수백 개의 파티션별 락이 최상위 소유자 캐시를 오버플로해 커밋 시 느린 lmgr 해시 스캔을 강제하는지는 워크로드 의존적이며 여기서 측정하지 않았다. 조사 경로: 파티션 집약적 벤치마크에서ResourceOwnerRememberLock의 오버플로 분기를 계측한다. -
넓은 실행기 트리에서 배열→해시 스필 임계값(32)의 비용. 많은 버퍼를 동시에 핀하는 깊은 플랜은 32슬롯 배열을 넘어 해시로 들어가며 재해시 비용을 치르고 저렴한 선형 Forget을 잃는다.
RESOWNER_ARRAY_SIZE가 현재 실행기 핀 카운트에 여전히 잘 맞는지는 코드에서 확인되지 않았다. 조사 경로: TPC 스타일 쿼리에서narr최고 수위선을 추적한다. -
ResourceOwnerReleaseAllOfKind(스냅샷/릴케시 리셋에서 사용하는 소매 일괄 해제)가 이후의 정상 단계별 해제와 깔끔하게 상호작용하는가. 정렬 없이 임시로releasing을 설정한다.ResourceOwnerReleaseInternal의 재진입 주석은 이 상호작용이 의도적임을 시사하지만, 한 소유자에서 둘을 혼합하는 정확한 호출자 집합은 여기서 열거하지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”-
CUBRID. CUBRID에는 단일 통합 ResourceOwner 추상이 없다. 자원 정리는 트랜잭션 디스크립터(
LOG_TDES), 페이지 버퍼의 페이지 픽스 부기(pgbuf), 락 매니저의 트랜잭션별 락 항목 목록에 분산되어 있다. 버퍼 픽스는 스레드/트랜잭션별로 추적되고 요청 종료나 롤백 시 언픽스된다. 락은 트랜잭션 디스크립터에 연결되어lock_unlock_all에서 해제된다. 효과는 같다. 트랜잭션 종료와er_set/setjmp스타일 에러 처리를 통한 언와인드 시 모든 자원이 회수된다. 하지만 부기가 하나의 소유자 객체와 선언적 해제 단계를 통하지 않고 서브시스템별로 분산된다. PostgreSQL의 단일 단계+우선순위 정렬 소유자가 훨씬 균일하다. CUBRID는 그 균일성을 서브시스템 로컬 제어와 맞바꾼다. (락 매니저와 버퍼 세부 사항은knowledge/code-analysis/cubrid/아래 CUBRID 코드 분석 트리를 참고하라.) -
C++ 스토리지 엔진 (RocksDB 스타일, InnoDB). RAII 가드 객체에 의존한다. InnoDB의
mtr_t미니 트랜잭션, RocksDB의unique_ptr/범위 가드는 C++ 스택 언와인딩 중 소멸자가 자동으로 실행된다. 수명은 어휘적(C 프레임에 묶임)이다. 진정한 어휘 범위를 가진 자원에는 더 깔끔하지만, 여러 프레임을 거쳐 핀 상태를 유지하다 트랜잭션 종료 시 해제하는 것은 힙 상주 소유자 없이 표현할 수 없다. InnoDB의 미니 트랜잭션이 범위가 있는 ResourceOwner의 가장 가까운 유사체이지만, 트랜잭션 범위가 아닌 구문/작업 범위이며 락은trx_t에서 별도로 추적된다. -
관리형 런타임 엔진. Java/C# 엔진은 GC로 메모리 회수를 얻고 나머지에는
try-with-resources/using을 사용하며, 정리를 프레임에 분산한다. PostgreSQL의 중앙화된 소유자와의 절충은 고전적이다. 언어 통합 언와인딩은 인체공학적이지만 각 프레임이 자체 정리를 기억해야 하는 반면, 중앙 레지스트리는 단일 스윕을 권위 있게 만들되 모든 획득에 명시적 Remember/Forget 프로토콜이라는 비용이 따른다. -
MemoryContext 계보와의 관계. README는 ResourceOwner API가 MemoryContext를 모델로 했다고 명시한다. 이 병렬 관계가 연구 프론티어 관련 관찰이다. PostgreSQL은 힙 할당에서 검증된 범위의 트리 + 현재 포인터 + 재귀 일괄 해제 패턴이 어떤 회수 가능한 청구권에도 일반화된다는 것을 발견했다. 이들을 통합하지 않기로 한 의도적 결정(다른 사용 패턴: 할당은 타입 없고 매우 빈번하다; 자원은 타입 있고 콜백을 가지며 더 적다)은 작지만 교훈적인 설계 판단이다. 같은 추상 형태를, 서로 다른 비용 모델로 두 번 인스턴스화한 것이다.
-
진정한 동인으로서의 에러 처리. 더 깊은 지점은 ARIES의 규율 있는 커밋/어보트 순서 주장을 메아리친다. ResourceOwner는 PostgreSQL이 퍼 프레임 정리 대신
setjmp/longjmp를 선택했기 때문에 존재한다. 소멸자나 검사된 예외를 가진 언어로 만든 엔진은 이것을 발명하지 않았을 수 있다. C에서 자동 언와인딩의 부재와 프레임 범위가 아닌 트랜잭션 범위 수명의 필요가 결합되어, 중앙화된 단계 순서 소유자를 여기서 올바른 답으로 만든다. PG_TRY/sigsetjmp기계는postgres-error-handling.md를 참고하라.
트리 내 README 및 소스 파일 (REL_18_STABLE, commit 273fe94)
섹션 제목: “트리 내 README 및 소스 파일 (REL_18_STABLE, commit 273fe94)”src/backend/utils/resowner/README— 설계 문서. MemoryContext 모델링 근거, 포레스트/부모 이전 의미론, 락 특수 취급, “새 자원 타입 추가” 방법, 부모/자식 우선순위 예제와 함께하는 3단계 해제 순서.src/include/utils/resowner.h—ResourceOwner불투명 타입, 네 개의 전역 소유자,ResourceReleasePhase,RELEASE_PRIO_*내장 우선순위,ResourceOwnerDesc, 전체 공개 함수 표면(Create/Release/Delete/Enlarge/Remember/Forget/RememberLock/…).src/backend/utils/resowner/resowner.c—ResourceOwnerData, 배열/해시 스토어,Enlarge/Remember/Forget,ResourceOwnerSort+resource_priority_cmp,ResourceOwnerReleaseInternal(3단계 재귀와 락 이전), 락 캐시, AIO 핸들, 보조 프로세스 소유자.src/backend/access/transam/xact.c— 구동자.StartSubTransaction소유자 생성,CommitTransaction/AbortTransaction의 세 단계ResourceOwnerRelease호출,CleanupTransaction의ResourceOwnerDelete.src/backend/utils/mmgr/portalmem.c—CreatePortal이 포털 소유자를CurTransactionResourceOwner아래에 연결.src/backend/storage/buffer/bufmgr.c—buffer_pin_resowner_desc/buffer_io_resowner_desc와ResourceOwnerEnlarge예약-전-핀 호출 지점.src/backend/storage/lmgr/lock.c—ResourceOwnerRememberLock/ForgetLock호출 지점과 LOCKS 단계에서 사용하는LockReassignCurrentOwner/LockReleaseCurrentOwner.
논문 및 교재 챕터
섹션 제목: “논문 및 교재 챕터”- Mohan, C. et al. (1992). “ARIES: A Transaction Recovery Method…” ACM TODS 17(1):94-162. 락을 마지막으로, 외부 가시 상태가 일관성 있게 된 후에만 해제하는 규율. 3단계 해제가 구현하는 원칙.
knowledge/research/dbms-papers/aries.md에 수록. - Database System Concepts (Silberschatz, Korth, Sudarshan, 7e), ch. 17-18 — 원자성/복구의 단위로서 트랜잭션, 자원 회수 경계 (
knowledge/research/dbms-general/). - Database Internals (Petrov 2019), ch. 5-6 — 버퍼 관리와 락 수명; ResourceOwner가 추적하는 자원 청구권 (
knowledge/research/dbms-general/). - RAII / 범위 묶음 자원 관리 (Stroustrup, C++ 관용구) — PostgreSQL이 C에 소멸자와 진짜 예외가 없어 직접 구현하는 언어 런타임 대안.
이 지식 베이스 내 교차 참조
섹션 제목: “이 지식 베이스 내 교차 참조”postgres-memory-contexts.md— ResourceOwner API가 모델로 삼은 형제 할당기. 범위의 트리 + 현재 포인터 + 재귀 일괄 해제.postgres-error-handling.md— PG_TRY/sigsetjmp/ereport. 이 모듈이 정리를 수행하는 언와인딩 절반.postgres-buffer-manager.md— 버퍼 핀, 표준적인 BEFORE_LOCKS 자원,ReservePrivateRefCountEntry+ResourceOwnerEnlarge예약 프로토콜.postgres-lock-manager.md—LOCALLOCK,LockReassignCurrentOwner, 손실형 락 캐시를 뒷받침하는 로컬 락 해시.postgres-xact.md— 트랜잭션별 소유자를 생성하고 구동하는 트랜잭션/서브트랜잭션 상태 기계.postgres-portals-prepared.md— 소유자가 현재 트랜잭션 소유자의 자식인 포털.postgres-aio.md— 소유자의dlist로 추적하는 비동기 I/O 핸들.postgres-overview-base-infra.md— ResourceOwner가 기반 인프라 레이어에서 위치하는 곳.