(KO) PostgreSQL MultiXact — 하나의 튜플에 여러 잠금 보유자와 갱신자
목차
- 이론적 배경
- DBMS 공통 설계 패턴
- PostgreSQL의 구현
- 소스 코드 안내
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계와 연구 프론티어
- 출처
이론적 배경
섹션 제목: “이론적 배경”행 수준 잠금(row-level locking)은 관계형 엔진에서 가장 오래된 기능 중 하나다. 교과서적 설명은 단순하다. 갱신하려는 트랜잭션은 행에 배타 잠금을 취득하고, 읽으려는 트랜잭션은 공유 잠금을 취득하며, 잠금 관리자가 호환성 행렬(compatibility matrix)로 충돌을 중재한다. Database System Concepts(Silberschatz 7판, 18장 “Concurrency Control”)는 이 모델의 두 기본 모드 — **공유(S)**와 배타(X) — 를 정의한다. 공유 잠금은 여러 트랜잭션이 동시에 보유할 수 있고, 배타 잠금은 다른 모든 잠금과 충돌한다. 2PL(이단계 잠금)은 이 행렬 위에 직렬화 가능성(serializability)을 구축한다.
핵심 설계 질문은 교과서가 가볍게 넘기는 부분에 있다. 잠금 상태를 어디에 보관하는가?
순수 잠금 관리자 방식은 모든 잠금을 공유 메모리 해시 테이블에 저장한다.
활성 트랜잭션 수가 잠금 수의 상한선인 이 방식은 크래시가 나면 잠금 상태가 소멸한다.
반면 MVCC 엔진은 다른 접근을 원한다. 잠금을 휘발성 메모리가 아닌 행 자체에 내구성 있게 기록해서, 나중에 도착한 읽기 트랜잭션이 잠금 테이블을 조회하지 않고도 행이 잠겼는지, 누구에 의해, 어떤 강도로 잠겼는지 파악할 수 있게 하는 것이다.
PostgreSQL은 이 원칙을 따른다. 갱신 또는 배타 잠금은 갱신자의 XID를 튜플의 xmax에 기록하고, MultiXact 서브시스템은 이 내구성 있는 온-행(on-row) 표현을 동시에 여러 트랜잭션이 관여하는 경우로 확장한다.
튜플 잠금의 충돌 행렬을 살펴보자. PostgreSQL은 네 가지 LockTupleMode 강도를 제공한다.
약한 것부터 강한 순서로 나열하면 KeyShare, Share, NoKeyExclusive, Exclusive다.
KeyShare와 Share 두 공유 모드는 서로 호환된다. SELECT ... FOR KEY SHARE는 참조하는 행에 외래 키 검사가 걸 때 사용하며, 많은 자식 행 삽입이 같은 부모 행에 동시에 키-공유 잠금을 보유할 수 있다.
그러면 단일 부모 행이 동시에 열 개의 트랜잭션에 의해 “잠긴” 상태가 된다.
xmax 필드는 XID 하나만 담는다. 표현 문제는 피할 수 없다. N개의 동시 잠금 보유자를 단 하나의 XID 슬롯에 어떻게 기록하는가?
문헌이 제시하는 답은 **간접 참조(indirection)**다. 행에는 식별자를 저장하고, 그 식별자가 실제 목록을 담은 별도 구조를 가리키게 한다. 파일시스템의 디렉터리 엔트리가 파일 내용을 직접 담지 않고 아이노드(inode)를 가리키는 것과 같은 발상이다. 비용은 추가 조회와 가비지 컬렉션 문제(별도 구조를 언제 회수할 수 있는가)이고, 이점은 고정 폭 슬롯이 무한히 증가하는 집합을 표현할 수 있다는 것이다. MultiXact 서브시스템은 트랜잭션 저장소의 수명과 크래시 내구성 제약에 특화된 PostgreSQL의 구체적 구현이다. **MultiXactId(MXID)**가 식별자이고, members SLRU가 별도 구조이며, VACUUM 기반 freezing이 가비지 컬렉터다.
MultiXact가 단순 잠금 보유자 목록 이상인 이유인 두 번째 이론적 뉘앙스가 있다.
잠금과 갱신은 시간적으로 상호 배제되지 않는다. 트랜잭션 A가 행에 FOR KEY SHARE를 취득한 뒤, 트랜잭션 B가 해당 행을 노-키 갱신(no-key update)할 수 있다. 노-키 갱신은 키-공유와 충돌하지 않기 때문이다.
이제 행은 동시에 한 트랜잭션이 잠그고 다른 트랜잭션이 갱신한 상태다. 갱신자의 XID는 xmax에 들어가야 하고(다음 독자가 갱신 체인을 따라가야 하므로), 잠금 보유자의 XID도 보존돼야 한다(외래 키 잠금을 조용히 삭제해선 안 되므로). 하나의 xmax 슬롯에 의미적으로 다른 두 클레임이 존재한다.
따라서 별도 구조는 멤버마다 누구인지뿐 아니라 어떤 종류의 클레임인지도 기록해야 한다. 잠금(그 강도)인지 갱신(키 변경 여부)인지를 구분하는 값이 이 모듈의 핵심인 여섯 가지 MultiXactStatus 열거 값이다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”행 잠금을 내구성 있게 만들지 여부, 그리고 어떻게 만드는지는 엔진마다 크게 다르다.
잠금 테이블 전용 설계 (DB2, SQL Server, 전통적 2PL). 잠금 관리자는 공유 메모리 해시 테이블이고, 행 잠금은 잠금 보유 기간에만 존재하는 임시 항목이다. 크래시가 나면 소멸하고, 행에 아무런 흔적도 남기지 않는다. 따라서 동시 잠금 보유자 수에 표현 한계가 없고(테이블이 항목을 더 가질 뿐) 가비지 컬렉션 문제도 없다(잠금 해제 시 항목이 해제된다). 대신 **잠금 에스컬레이션(lock escalation)**이 비용이다. 행 잠금이 너무 많이 쌓이면 페이지 또는 테이블 수준으로 에스컬레이션해서 동시성을 메모리 한계와 맞바꾼다. MultiXactId 같은 개념이 없다. 잠금 상태가 행에 맞아야 할 이유가 없기 때문이다.
온-행 단일 잠금 보유자 설계. 여러 MVCC 엔진은 가장 최근 기록자의 트랜잭션 ID를 행별 슬롯에 기록한다(Oracle의 관심 트랜잭션 목록, InnoDB의 DB_TRX_ID). Oracle의 **ITL(Interested Transaction List)**이 MultiXact과 가장 가까운 친척이다. 데이터 블록 헤더에 소규모 ITL 슬롯 배열이 있고, 행을 잠그거나 수정하는 트랜잭션이 슬롯 하나를 차지한다. 여러 동시 잠금 보유자는 블록 내 여러 ITL 슬롯으로 수용하며, 배열은 INITRANS / MAXTRANS로 증가할 수 있다(블록 공간을 소비하면서).
핵심 차이는 위치다. Oracle의 잠금 보유자 목록은 데이터 블록 헤더에 있다(블록당 하나, 그 블록의 모든 행이 공유). PostgreSQL의 목록은 전역 members SLRU에 있다(MXID당 하나, 어떤 테이블의 어떤 행이든 참조 가능). Oracle 설계는 목록을 데이터와 가까이 두어 캐시 효율이 좋고 블록 크기로 경계가 생긴다. PostgreSQL 설계는 목록 크기를 페이지 공간과 분리해(멀티는 힙 페이지 공간을 소비하지 않고도 많은 멤버를 가질 수 있다) SLRU 조회 비용과 별도 순환 도메인, 그리고 VACUUM 시 FreezeMultiXactId 가비지 컬렉션 의무를 지불한다.
PostgreSQL의 위치. PostgreSQL의 행 잠금은 온-행이면서 간접 참조다. 단일 갱신자 또는 단일 배타 잠금 보유자라는 흔한 경우에는 MultiXact가 전혀 필요 없다. 튜플의 xmax가 직접 XID를 담고 HEAP_XMAX_IS_MULTI 비트는 꺼진다.
행에 진짜로 여러 이해관계자가 생기거나, 잠금 보유자와 갱신자가 공존해야 할 때에야 힙이 MultiXactId를 할당하고 xmax에 쓰며 멀티 비트를 세운다. 이 설계는 압도적으로 흔한 단일 잠금 보유자 경로를 SLRU 트래픽에서 해방시키고, 비싼 기계(SLRU 페이지, 멤버 배열, 별도 순환 카운터)를 실제로 동시성이 필요한 소수 튜플로만 국한시킨다.
flowchart TD
A["heap_lock_tuple / heap_update<br/>튜플 클레임 시도"] --> B{"xmax가 이미<br/>설정되어 유효한가?"}
B -- "아니오" --> C["xmax에 직접 XID 기록<br/>HEAP_XMAX_IS_MULTI 해제"]
B -- "예, 직접 XID" --> D{"새 클레임이 기존<br/>클레임과 호환되는가?"}
D -- "비호환" --> E["기존 XID 대기 후 재시도"]
D -- "호환" --> F["MultiXactIdCreate(oldXID, newXID)<br/>2멤버 멀티 할당"]
B -- "예, 이미 멀티" --> G["MultiXactIdExpand(multi, newXID)<br/>죽은 멤버 제거 후 추가"]
F --> H["xmax에 MXID 기록<br/>HEAP_XMAX_IS_MULTI 설정"]
G --> H
H --> I["멤버는 pg_multixact SLRU에 보관<br/>VACUUM이 튜플을 동결할 때까지"]
위 다이어그램은 heapam.c가 소유하는 정책 계층이다. MXID 할당, 멤버 배열 SLRU 페이지 패킹, 읽기, 회수는 multixact.c가 소유하는 기계 계층이며, 이 문서의 주제다.
PostgreSQL의 구현
섹션 제목: “PostgreSQL의 구현”MultiXactId란 무엇이고 무엇이 아닌가
섹션 제목: “MultiXactId란 무엇이고 무엇이 아닌가”multixact.c 파일 헤더는 역사를 솔직하게 기술한다.
// multixact.c — file header comment * The pg_multixact manager is a pg_xact-like manager that stores an array of * MultiXactMember for each MultiXactId. It is a fundamental part of the * shared-row-lock implementation. Each MultiXactMember is comprised of a * TransactionId and a set of flag bits. The name is a bit historical: * originally, a MultiXactId consisted of more than one TransactionId (except * in rare corner cases), hence "multi". Nowadays, however, it's perfectly * legitimate to have MultiXactIds that only include a single Xid.MultiXactId는 “잠금을 공유하는 트랜잭션 집합”이 아니다. 저장된 불변 MultiXactMember 레코드 배열의 이름이다. 단일 멤버 멀티도 합법적이고 흔하다. 베어 xmax가 표현할 수 없는 더 풍부한 멤버별 상태가 필요할 때 생긴다. 플래그 비트, 즉 멤버 status는 multixact.c에 불투명하다. 모듈은 배열을 저장하고 꺼낼 뿐이며, heapam.c가 상태를 잠금 모드로 해석한다.
MXID는 32비트 값으로 XID와 같은 숫자 범위에 있지만 별도의 주소 공간을 사용하며 자체 카운터와 순환을 가진다. 헤더가 낮은 값을 예약한다.
// multixact.h — reserved MXID values#define InvalidMultiXactId ((MultiXactId) 0)#define FirstMultiXactId ((MultiXactId) 1)#define MaxMultiXactId ((MultiXactId) 0xFFFFFFFF)
#define MultiXactIdIsValid(multi) ((multi) != InvalidMultiXactId)#define MaxMultiXactOffset ((MultiXactOffset) 0xFFFFFFFF)여섯 가지 멤버 상태
섹션 제목: “여섯 가지 멤버 상태”멤버 상태는 잠금 강도와 이 멤버가 튜플을 갱신했는지를 단일 열거형으로 인코딩한다. 숫자 순서가 의미를 가진다.
// multixact.h — MultiXactStatustypedef enum{ MultiXactStatusForKeyShare = 0x00, MultiXactStatusForShare = 0x01, MultiXactStatusForNoKeyUpdate = 0x02, MultiXactStatusForUpdate = 0x03, /* an update that doesn't touch "key" columns */ MultiXactStatusNoKeyUpdate = 0x04, /* other updates, and delete */ MultiXactStatusUpdate = 0x05,} MultiXactStatus;
#define MaxMultiXactStatus MultiXactStatusUpdate
/* does a status value correspond to a tuple update? */#define ISUPDATE_from_mxstatus(status) \ ((status) > MultiXactStatusForUpdate)앞의 네 개는 잠금 전용 상태(SELECT ... FOR KEY SHARE / SHARE / NO KEY UPDATE / UPDATE)이고, 마지막 두 개는 실제 갱신 또는 삭제를 수행한 멤버를 표시한다. ISUPDATE_from_mxstatus 매크로는 이 순서에 의존한다. ForUpdate(0x03)보다 엄격히 큰 값이면 갱신자다. 이 단일 판별자는 모듈 전반에서 부하가 큰 역할을 한다. MultiXactIdExpand가 죽은 멤버를 유지할지 결정하는 방식이고, FreezeMultiXactId가 잠금 보유자(실행이 끝나면 삭제 가능)와 커밋된 갱신자(새 xmax로 보존 필요)를 구분하는 방식이다. 생성 시 확인되는 핵심 불변식은 멀티에 갱신 멤버가 최대 하나라는 것이다.
heapam.c는 SQL 수준 LockTupleMode를 MultiXactStatus로 정적 테이블로 매핑한다.
// heapam.c — tupleLockExtraInfo (lock mode -> hwlock + member statuses)tupleLockExtraInfo[MaxLockTupleMode + 1] ={ { AccessShareLock, MultiXactStatusForKeyShare, -1 }, /* KeyShare */ { RowShareLock, MultiXactStatusForShare, -1 }, /* Share */ { ExclusiveLock, MultiXactStatusForNoKeyUpdate, MultiXactStatusNoKeyUpdate }, /* NoKeyExclusive */ { AccessExclusiveLock, MultiXactStatusForUpdate, MultiXactStatusUpdate }, /* Exclusive */};각 행은 인메모리 튜플 잠금에 사용하는 무거운 LOCKMODE, 클레임이 잠금일 때 기록할 멤버 상태, 그리고 갱신일 때 기록할 멤버 상태를 제공한다. -1 항목은 키-공유와 공유 잠금이 갱신 멤버가 될 수 없음을 나타낸다.
두 개의 SLRU: offsets와 members
섹션 제목: “두 개의 SLRU: offsets와 members”표현의 핵심은 두 개의 병렬 SLRU 영역이다. “offsets” SLRU는 MXID를 인덱스로 하는 평탄 배열이다. 각 4바이트 슬롯이 해당 멀티의 멤버 배열이 “members” SLRU에서 시작하는 오프셋을 담는다. “members” SLRU는 가변 길이 (xid, status) 배열 자체를 담는다. 헤더가 이 두 영역 분리를 설명한다.
// multixact.c — file header comment * We use two SLRU areas, one for storing the offsets at which the data * starts for each MultiXactId in the other one. This trick allows us to * store variable length arrays of TransactionIds.offsets 레이아웃은 각 항목이 고정 폭이라 단순하다.
// MultiXactIdToOffsetPage / Entry — multixact.c#define MULTIXACT_OFFSETS_PER_PAGE (BLCKSZ / sizeof(MultiXactOffset))
static inline int64MultiXactIdToOffsetPage(MultiXactId multi){ return multi / MULTIXACT_OFFSETS_PER_PAGE;}
static inline intMultiXactIdToOffsetEntry(MultiXactId multi){ return multi % MULTIXACT_OFFSETS_PER_PAGE;}members 레이아웃은 더 복잡하다. 각 멤버에는 TransactionId(4바이트)와 상태를 위한 플래그 바이트가 필요하다. 정렬 낭비를 피하기 위해 멤버를 네 개씩 그룹으로 패킹한다. 플래그 바이트 4개 + XID 4개로 이루어진 20바이트 5-워드 그룹이다.
// multixact.c — members layout comment + group constants * we store four bytes of flags, and then the * corresponding 4 Xids. Each such 5-word (20-byte) set we call a "group", and * are stored as a whole in pages. Thus, with 8kB BLCKSZ, we keep 409 groups * per page.
#define MULTIXACT_FLAGBYTES_PER_GROUP 4#define MULTIXACT_MEMBERS_PER_MEMBERGROUP \ (MULTIXACT_FLAGBYTES_PER_GROUP * MXACT_MEMBER_FLAGS_PER_BYTE)#define MULTIXACT_MEMBERGROUP_SIZE \ (sizeof(TransactionId) * MULTIXACT_MEMBERS_PER_MEMBERGROUP + MULTIXACT_FLAGBYTES_PER_GROUP)#define MULTIXACT_MEMBERGROUPS_PER_PAGE (BLCKSZ / MULTIXACT_MEMBERGROUP_SIZE)#define MULTIXACT_MEMBERS_PER_PAGE \ (MULTIXACT_MEMBERGROUPS_PER_PAGE * MULTIXACT_MEMBERS_PER_MEMBERGROUP)멤버 오프셋을 물리 바이트 위치로 변환하려면 해당 페이지에서 그룹을 찾고, 플래그 4바이트를 건너뛰고, 그룹 내 XID를 인덱싱한다. MXOffsetToMemberPage, MXOffsetToFlagsOffset, MXOffsetToMemberOffset이 이 계산을 수행한다. 여기서 “오프셋”은 전역 32비트 주소 공간의 멤버 인덱스(MultiXactOffset 타입)로, MXID 공간과는 별개다. 이것이 멤버에 독자적인 순환 문제가 있는 이유다.
flowchart LR X["tuple.xmax = MXID 4711<br/>HEAP_XMAX_IS_MULTI 설정"] --> O["offsets SLRU<br/>slot[4711] = 멤버 오프셋 9020"] O --> M["members SLRU<br/>오프셋 9020: (xid=812, ForKeyShare)<br/>오프셋 9021: (xid=915, NoKeyUpdate)"] O2["offsets SLRU<br/>slot[4712] = 멤버 오프셋 9022"] -.->|"길이 = 9022 - 9020 = 2"| M X --> O2
멀티 4711의 멤버 배열 길이는 명시적으로 저장되지 않는다. offsets SLRU에서 slot[4711]과 slot[4712]의 차이로 계산된다. 이 때문에 RecordNewMultiXact는 현재 멀티의 오프셋과 다음 슬롯을 모두 설정해야 하고, GetMultiXactIdMembers는 “최신 멀티에 아직 후속이 없는” 코너 케이스를 신중하게 처리한다.
백엔드별 호라이즌이 SLRU를 보호한다
섹션 제목: “백엔드별 호라이즌이 SLRU를 보호한다”members/offsets SLRU는 VACUUM이 지속적으로 잘라내기(truncation)를 수행하기 때문에, 오래된 멀티를 읽으려는 백엔드는 먼저 호라이즌을 게시해야 한다. 각 백엔드는 두 개의 공유 메모리 슬롯을 가진다. OldestMemberMXactId[k]는 이 백엔드의 트랜잭션이 멤버로 참여할 수 있는 가장 오래된 멀티이고, OldestVisibleMXactId[k]는 검사할 수 있는 가장 오래된 멀티다. 공유 잠금을 취득하기 전에 전자를, 멤버 배열을 읽기 전에 후자를 설정하는 것이 SLRU 데이터가 읽는 도중에 잘려나가지 않도록 보장한다. 이 두 슬롯의 전역 최솟값이 VACUUM이 존중하는 OldestMulti 기준선이다. 이는 procarray의 xmin 호라이즌과 직접 대응되는 개념이다(postgres-procarray.md 참조).
소스 코드 안내
섹션 제목: “소스 코드 안내”멀티 생성: MultiXactIdCreate와 MultiXactIdExpand
섹션 제목: “멀티 생성: MultiXactIdCreate와 MultiXactIdExpand”힙이 호출하는 두 진입점은 정책 다이어그램의 두 상황에 대응한다. MultiXactIdCreate는 두 단일 XID를 2멤버 멀티로 바꾼다. 베어-xmax 튜플이 두 번째 호환 이해관계자를 얻을 때 사용된다.
// MultiXactIdCreate — multixact.cMultiXactIdMultiXactIdCreate(TransactionId xid1, MultiXactStatus status1, TransactionId xid2, MultiXactStatus status2){ MultiXactId newMulti; MultiXactMember members[2];
Assert(!TransactionIdEquals(xid1, xid2) || (status1 != status2)); /* MultiXactIdSetOldestMember() must have been called already. */ Assert(MultiXactIdIsValid(*MyOldestMemberMXactIdSlot()));
members[0].xid = xid1; members[0].status = status1; members[1].xid = xid2; members[1].status = status2;
newMulti = MultiXactIdCreateFromMembers(2, members); return newMulti;}첫 번째 Assert는 두 클레임이 달라야 한다는 규칙을 강제한다. 두 번째 Assert는 크래시 안전성 계약이다. MultiXactIdSetOldestMember()가 이 백엔드의 호라이즌을 먼저 게시해야 멀티에 편입될 수 있으므로, VACUUM이 이 백엔드가 참여하려는 범위를 잘라내지 않는다.
MultiXactIdExpand는 “이미 멀티” 분기를 처리한다. 기존 멀티를 변경하지 않는다. 대신 이전 멤버 배열을 읽고 필터링한 뒤 새 멤버를 추가해 새로운 MXID를 할당한다.
// MultiXactIdExpand — multixact.c (condensed)MultiXactIdMultiXactIdExpand(MultiXactId multi, TransactionId xid, MultiXactStatus status){ nmembers = GetMultiXactIdMembers(multi, &members, false, false); if (nmembers < 0) { /* all members gone; just make a singleton */ member.xid = xid; member.status = status; return MultiXactIdCreateFromMembers(1, &member); }
/* already a member with the same status? return multi unchanged */ for (i = 0; i < nmembers; i++) if (TransactionIdEquals(members[i].xid, xid) && members[i].status == status) return multi;
/* keep running members, and committed updaters; drop the rest */ for (i = 0, j = 0; i < nmembers; i++) { if (TransactionIdIsInProgress(members[i].xid) || (ISUPDATE_from_mxstatus(members[i].status) && TransactionIdDidCommit(members[i].xid))) { newMembers[j].xid = members[i].xid; newMembers[j++].status = members[i].status; } } newMembers[j].xid = xid; newMembers[j++].status = status; return MultiXactIdCreateFromMembers(j, newMembers);}소스 주석은 불변성이 왜 필수인지 명시한다. 멀티를 제자리에서 변경하면 그 멀티가 끝나기를 기다리는 트랜잭션과 경쟁이 발생한다. 항상 새 MXID를 발급함으로써, 이전 MXID를 포착한 대기자는 절대 증가하지 않는 안정적인 멤버 집합을 본다. 죽은 잠금 보유자를 걸러내는 루프는 최적화가 아니라 정확성 요구 사항이다. 멤버 XID가 MXID를 살아있게 유지할 수 있는 기간에 경계를 두는 것이 동결(freezing)의 전제 조건이기 때문이다.
MXID 할당: MultiXactIdCreateFromMembers와 GetNewMultiXactId
섹션 제목: “MXID 할당: MultiXactIdCreateFromMembers와 GetNewMultiXactId”MultiXactIdCreateFromMembers는 백엔드 로컬 캐시를 먼저 확인하고(백엔드가 읽는 멀티 대부분은 자신이 생성한 것이다), 단일 갱신자 불변식을 강제한 뒤, 멀티를 할당하고 내구성 있게 기록한다.
// MultiXactIdCreateFromMembers — multixact.c (condensed)multi = mXactCacheGetBySet(nmembers, members);if (MultiXactIdIsValid(multi)) return multi; /* re-use cached identical multi */
/* Verify that there is a single update Xid among the given members. */for (i = 0; i < nmembers; i++) if (ISUPDATE_from_mxstatus(members[i].status)) { if (has_update) elog(ERROR, "new multixact has more than one updating member: %s", ...); has_update = true; }
multi = GetNewMultiXactId(nmembers, &offset); /* enters crit section */
/* WAL the create, then write the SLRU entries */xlrec.mid = multi; xlrec.moff = offset; xlrec.nmembers = nmembers;XLogBeginInsert();XLogRegisterData(&xlrec, SizeOfMultiXactCreate);XLogRegisterData(members, nmembers * sizeof(MultiXactMember));(void) XLogInsert(RM_MULTIXACT_ID, XLOG_MULTIXACT_CREATE_ID);RecordNewMultiXact(multi, offset, nmembers, members);END_CRIT_SECTION();mXactCachePut(multi, nmembers, members);GetNewMultiXactId는 MXID 카운터와 멤버 오프셋 카운터를 모두 MultiXactGenLock 아래에서 전진시키고, 두 순환 가드를 실행한다. 파일 확장(실패할 수 있음)은 카운터를 증가시키는 크리티컬 섹션 이전에 수행된다. GetNewTransactionId와 동일한 패턴이다.
// GetNewMultiXactId — multixact.c (condensed)LWLockAcquire(MultiXactGenLock, LW_EXCLUSIVE);if (MultiXactState->nextMXact < FirstMultiXactId) MultiXactState->nextMXact = FirstMultiXactId;result = MultiXactState->nextMXact;
/* MXID wraparound guard: vac/warn/stop limits, like GetNewTransactionId */if (!MultiXactIdPrecedes(result, MultiXactState->multiVacLimit)){ ... if past multiStopLimit: ereport(ERROR, "... to avoid wraparound data loss"); ... if past multiWarnLimit: ereport(WARNING, "... must be vacuumed before ..."); ... SendPostmasterSignal(PMSIGNAL_START_AUTOVAC_LAUNCHER);}
/* Reserve offsets-file room for the *next* MXID's start offset */ExtendMultiXactOffset(result + 1);
nextOffset = MultiXactState->nextOffset;if (nextOffset == 0) { *offset = 1; nmembers++; } /* never hand out offset 0 */else *offset = nextOffset;
/* MEMBERS-space wraparound guard (a *separate* 32-bit domain) */if (MultiXactState->oldestOffsetKnown && MultiXactOffsetWouldWrap(MultiXactState->offsetStopLimit, nextOffset, nmembers)) ereport(ERROR, (errmsg("multixact \"members\" limit exceeded"), ...));
ExtendMultiXactMember(nextOffset, nmembers);
START_CRIT_SECTION();(MultiXactState->nextMXact)++;MultiXactState->nextOffset += nmembers;LWLockRelease(MultiXactGenLock);두 개의 순환 검사가 두 주소 공간의 핵심이다. 첫 번째(multiVacLimit / multiStopLimit)는 MXID 카운터를 보호하고, 두 번째(offsetStopLimit via MultiXactOffsetWouldWrap)는 멤버 오프셋 카운터를 보호한다. 많은 대형 멀티가 있는 워크로드는 MXID 공간보다 멤버 공간이 먼저 고갈될 수 있다. 오프셋 0이 예약되므로 nextOffset == 0이면 *offset = 1 건너뛰기가 발생한다.
멀티 읽기: GetMultiXactIdMembers
섹션 제목: “멀티 읽기: GetMultiXactIdMembers”읽기는 역순이다. 캐시 조회, 가시 호라이즌 게시, MXID를 알려진 범위에서 검증, 그런 다음 시작 오프셋과 다음 멀티의 시작 오프셋을 읽어 배열 길이를 계산한다.
// GetMultiXactIdMembers — multixact.c (condensed)length = mXactCacheGetById(multi, members);if (length >= 0) return length; /* cache hit */
MultiXactIdSetOldestVisible(); /* pin the truncation horizon */
/* lock-only multis older than our visible horizon cannot be running */if (isLockOnly && MultiXactIdPrecedes(multi, *MyOldestVisibleMXactIdSlot())) { *members = NULL; return -1; }
LWLockAcquire(MultiXactGenLock, LW_SHARED);oldestMXact = MultiXactState->oldestMultiXactId;nextMXact = MultiXactState->nextMXact;nextOffset = MultiXactState->nextOffset;LWLockRelease(MultiXactGenLock);
if (MultiXactIdPrecedes(multi, oldestMXact)) ereport(ERROR, "MultiXactId %u does no longer exist -- apparent wraparound");if (!MultiXactIdPrecedes(multi, nextMXact)) ereport(ERROR, "MultiXactId %u has not been created yet -- apparent wraparound");
/* read offsets[multi] and offsets[multi+1]; length is the difference */slotno = SimpleLruReadPage(MultiXactOffsetCtl, pageno, true, multi);offset = ((MultiXactOffset *) ...page_buffer[slotno])[entryno];...if (nextMXact == multi + 1) length = nextOffset - offset; /* corner case 1 */else length = nextMXOffset - offset;두 개의 ereport(ERROR, ...) 호출은 MXID 순환의 런타임 감지기다. oldestMultiXactId보다 오래된 멀티는 “더 이상 존재하지 않고”, nextMXact보다 오래되지 않은 멀티는 “아직 생성되지 않았다”고 본다. 두 경우 모두 카운터가 라이브 범위를 앞질렀음을 나타낸다. GetNewMultiXactId의 순환 가드가 막으려는 바로 그 재앙이다.
멤버 공간 순환 계산: MultiXactOffsetWouldWrap
섹션 제목: “멤버 공간 순환 계산: MultiXactOffsetWouldWrap”멤버 오프셋은 0xFFFFFFFF에서 순환하는 32비트 카운터이므로 “X가 경계에서 충분히 멀리 있는가?”는 단순 비교로 판단할 수 없다. 덧셈 자체가 순환할 수 있기 때문이다. 헬퍼는 부호 차이 논리와 예약된 오프셋 0 건너뛰기를 사용한다.
// MultiXactOffsetWouldWrap — multixact.cstatic boolMultiXactOffsetWouldWrap(MultiXactOffset boundary, MultiXactOffset start, uint32 distance){ MultiXactOffset finish;
finish = start + distance; if (finish < start) finish++; /* skip the reserved offset 0 on overflow */
if (start < boundary) return finish >= boundary || finish < start; else return finish >= boundary && finish < start;}멤버 동결 임계값은 MULTIXACT_MEMBER_SAFE_THRESHOLD(오프셋 공간의 절반)와 MULTIXACT_MEMBER_DANGER_THRESHOLD(4분의 3)로 autovacuum을 MXID 나이와 독립적으로 더 공격적으로 만든다.
// MultiXactMemberFreezeThreshold — multixact.c (condensed)if (!ReadMultiXactCounts(&multixacts, &members)) return 0; /* unknown utilization: assume the worst */if (members <= MULTIXACT_MEMBER_SAFE_THRESHOLD) return autovacuum_multixact_freeze_max_age; /* plenty of room */
fraction = (double) (members - MULTIXACT_MEMBER_SAFE_THRESHOLD) / (MULTIXACT_MEMBER_DANGER_THRESHOLD - MULTIXACT_MEMBER_SAFE_THRESHOLD);victim_multixacts = multixacts * fraction;result = multixacts - victim_multixacts;return Min(result, autovacuum_multixact_freeze_max_age);멤버 공간 부족이 autovacuum_multixact_freeze_max_age MXID 기준에서 전혀 오래되지 않은 테이블에도 공격적 동결을 강제할 수 있는 이유다. 두 도메인은 독립적인 압력을 가지고, autovacuum은 더 빡빡한 쪽에 반응한다.
순환 한계 설정: SetMultiXactIdLimit
섹션 제목: “순환 한계 설정: SetMultiXactIdLimit”vacuum/warn/stop/wrap 한계는 모든 데이터베이스의 가장 오래된 datminmxid에서 파생된다. SetTransactionIdLimit을 거울처럼 따른다.
// SetMultiXactIdLimit — multixact.c (condensed)multiWrapLimit = oldest_datminmxid + (MaxMultiXactId >> 1); /* "half the space" */multiStopLimit = multiWrapLimit - 3000000; /* refuse new MXIDs */multiWarnLimit = multiWrapLimit - 40000000; /* loud warnings */multiVacLimit = oldest_datminmxid + autovacuum_multixact_freeze_max_age;
LWLockAcquire(MultiXactGenLock, LW_EXCLUSIVE);MultiXactState->oldestMultiXactId = oldest_datminmxid;MultiXactState->multiVacLimit = multiVacLimit;MultiXactState->multiWarnLimit = multiWarnLimit;MultiXactState->multiStopLimit = multiStopLimit;MultiXactState->multiWrapLimit = multiWrapLimit;LWLockRelease(MultiXactGenLock);
/* Members have their *own* limits, computed separately */needs_offset_vacuum = SetOffsetVacuumLimit(is_startup);소스의 “half the space” 주석은 의도적인 근사치다. 멀티는 XID와 다른 방식으로 순환한다. 실제 부담이 되는 한계는 멀티 부하가 높은 워크로드에서 먼저 발동하는 SetOffsetVacuumLimit이다.
멀티 동결: FreezeMultiXactId (heapam.c)
섹션 제목: “멀티 동결: FreezeMultiXactId (heapam.c)”VACUUM은 오래된 MXID를 튜플에 영구히 남겨둘 수 없다. 결국 MXID나 멤버가 동결 기준선 아래로 떨어지면 제거해야 한다. 힙의 FreezeMultiXactId는 멀티 가비지 컬렉터다. 반환값은 FRM_* 플래그 중 하나로 네 가지 처분 결과를 나타낸다.
// heapam.c — FRM_* freeze dispositions#define FRM_NOOP 0x0001 /* keep the multi as-is */#define FRM_INVALIDATE_XMAX 0x0002 /* drop xmax entirely */#define FRM_RETURN_IS_XID 0x0004 /* replace multi with a single XID */#define FRM_RETURN_IS_MULTI 0x0008 /* replace with a new, smaller multi */결정 트리를 정리하면 이렇다.
// FreezeMultiXactId — heapam.c (condensed)if (!MultiXactIdIsValid(multi) || HEAP_LOCKED_UPGRADED(t_infomask)){ *flags |= FRM_INVALIDATE_XMAX; return InvalidTransactionId; }
if (MultiXactIdPrecedes(multi, cutoffs->relminmxid)) ereport(ERROR, "found multixact %u from before relminmxid %u", ...);
if (MultiXactIdPrecedes(multi, cutoffs->OldestMxact)){ /* this old multi cannot have running members; verify, then resolve */ if (MultiXactIdIsRunning(multi, HEAP_XMAX_IS_LOCKED_ONLY(t_infomask))) ereport(ERROR, "multixact %u ... found to be still running", ...); if (HEAP_XMAX_IS_LOCKED_ONLY(t_infomask)) { *flags |= FRM_INVALIDATE_XMAX; return InvalidTransactionId; } /* lockers only */ update_xact = MultiXactIdGetUpdateXid(multi, t_infomask); ... if updater aborted: FRM_INVALIDATE_XMAX ... else: *flags |= FRM_RETURN_IS_XID; return update_xact;}
/* multi is >= OldestMxact: maybe keep it. Walk members. */nmembers = GetMultiXactIdMembers(multi, &members, false, HEAP_XMAX_IS_LOCKED_ONLY(...));need_replace = false;for (i = 0; i < nmembers; i++) if (TransactionIdPrecedes(members[i].xid, cutoffs->FreezeLimit)) { need_replace = true; break; }if (!need_replace) need_replace = MultiXactIdPrecedes(multi, cutoffs->MultiXactCutoff);if (!need_replace) { *flags |= FRM_NOOP; return multi; } /* keep it */
/* second pass: keep only running lockers + the live updater, build new multi */구조는 잠금/갱신 구분을 구체적으로 드러낸다. OldestMxact보다 오래된 잠금 전용 멀티는 단순히 삭제된다(FRM_INVALIDATE_XMAX). 보유자들이 사라졌으니 잠금은 의미가 없다. 커밋된 갱신자를 포함한 멀티는 그 갱신자의 XID를 새 xmax로 유지해야 한다(FRM_RETURN_IS_XID). 갱신 체인이 여전히 의존하기 때문이다. 일부 라이브 멤버가 있지만 동결 기준선 아래인 멤버도 있는 멀티는 더 작게 재구성된다(FRM_RETURN_IS_MULTI). 실행 중인 잠금 보유자와 라이브 갱신자만 유지한다. relminmxid 이전에서 온 멀티에 대한 ereport(ERROR, ...)는 동결이 순환보다 앞서 있음을 증명하는 손상 감지기다. 이 기준선들의 XID 측면(relfrozenxid, OldestXmin, FreezeLimit)은 postgres-xid-wraparound-freeze.md에서 다룬다.
잘라내기: TruncateMultiXact와 두 SLRU 처리
섹션 제목: “잘라내기: TruncateMultiXact와 두 SLRU 처리”전역 가장 오래된 멀티가 전진하면 두 SLRU를 모두 잘라낸다. members SLRU는 페이지 번호만으로 잘라낼 수 없다(전체 범위에 걸쳐 거의 꽉 찰 수 있다). 따라서 삭제할 멤버 범위는 offsets SLRU에서 도출된다. find_multixact_start(newOldestMulti)가 멤버 오프셋 경계를 제공하고, PerformMembersTruncation이 그 아래 전체 세그먼트를 삭제하며, PerformOffsetsTruncation이 offsets SLRU를 페이지 단위로 잘라낸다. MultiXactOffsetPagePrecedes와 MultiXactMemberPagePrecedes 콜백이 SimpleLruTruncate에 각 순환 주소 공간에서 “오래된”이 무엇인지 알려준다. 잘라내기는 WAL로 기록되어(XLOG_MULTIXACT_TRUNCATE_ID) 스탠바이가 multixact_redo로 재생한다.
flowchart TD V["VACUUM이 datminmxid 전진<br/>vac_truncate_clog이 TruncateMultiXact 호출"] --> S["find_multixact_start(newOldestMulti)<br/>offsets SLRU 읽기 → newOldestOffset"] S --> M["PerformMembersTruncation<br/>newOldestOffset 아래 멤버 세그먼트 SlruDeleteSegment"] S --> O["PerformOffsetsTruncation<br/>SimpleLruTruncate(offsets, newOldest-1 페이지)"] M --> W["WriteMTruncateXlogRec<br/>XLOG_MULTIXACT_TRUNCATE_ID"] O --> W W --> R["스탠바이: multixact_redo로 잘라내기 재생"]
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 라인 |
|---|---|---|
MultiXactStatus (enum) | src/include/access/multixact.h | 37 |
ISUPDATE_from_mxstatus | src/include/access/multixact.h | 52 |
MultiXactMember (struct) | src/include/access/multixact.h | 56 |
FirstMultiXactId / MaxMultiXactId | src/include/access/multixact.h | 25 |
MULTIXACT_OFFSETS_PER_PAGE | src/backend/access/transam/multixact.c | 110 |
MultiXactIdToOffsetPage | src/backend/access/transam/multixact.c | 113 |
MULTIXACT_MEMBERGROUP_SIZE | src/backend/access/transam/multixact.c | 152 |
MXOffsetToMemberOffset | src/backend/access/transam/multixact.c | 206 |
MULTIXACT_MEMBER_SAFE_THRESHOLD | src/backend/access/transam/multixact.c | 216 |
MultiXactStateData (struct) | src/backend/access/transam/multixact.c | 242 |
MultiXactIdCreate | src/backend/access/transam/multixact.c | 478 |
MultiXactIdExpand | src/backend/access/transam/multixact.c | 531 |
MultiXactIdIsRunning | src/backend/access/transam/multixact.c | 643 |
MultiXactIdSetOldestMember | src/backend/access/transam/multixact.c | 717 |
MultiXactIdSetOldestVisible | src/backend/access/transam/multixact.c | 774 |
MultiXactIdCreateFromMembers | src/backend/access/transam/multixact.c | 859 |
RecordNewMultiXact | src/backend/access/transam/multixact.c | 960 |
GetNewMultiXactId | src/backend/access/transam/multixact.c | 1201 |
GetMultiXactIdMembers | src/backend/access/transam/multixact.c | 1470 |
mxstatus_to_string | src/backend/access/transam/multixact.c | 1904 |
SetMultiXactIdLimit | src/backend/access/transam/multixact.c | 2530 |
ExtendMultiXactOffset | src/backend/access/transam/multixact.c | 2721 |
ExtendMultiXactMember | src/backend/access/transam/multixact.c | 2753 |
MultiXactOffsetWouldWrap | src/backend/access/transam/multixact.c | 3012 |
find_multixact_start | src/backend/access/transam/multixact.c | 3060 |
MultiXactMemberFreezeThreshold | src/backend/access/transam/multixact.c | 3150 |
PerformMembersTruncation | src/backend/access/transam/multixact.c | 3219 |
TruncateMultiXact | src/backend/access/transam/multixact.c | 3274 |
MultiXactOffsetPagePrecedes | src/backend/access/transam/multixact.c | 3466 |
MultiXactIdPrecedes | src/backend/access/transam/multixact.c | 3506 |
multixact_redo | src/backend/access/transam/multixact.c | 3583 |
tupleLockExtraInfo | src/backend/access/heap/heapam.c | 132 |
get_mxact_status_for_lock | src/backend/access/heap/heapam.c | 4527 |
FreezeMultiXactId | src/backend/access/heap/heapam.c | 6713 |
MultiXactIdGetUpdateXid | src/backend/access/heap/heapam.c | 7536 |
HEAP_XMAX_IS_MULTI | src/include/access/htup_details.h | 209 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”- 두 개의 SLRU, offsets→members 간접 참조. 파일 헤더 주석과
MultiXactIdToOffsetPage/MXOffsetToMemberPage매크로 패밀리에서 확인. offsets SLRU는 MXID로 인덱싱된 고정 폭MultiXactOffset[]이고, members SLRU는 그 오프셋으로 위치를 찾는 가변 길이 배열이다. 멤버 배열 길이는 연속 오프셋 차이로 계산되며 저장되지 않는다.GetMultiXactIdMembers의 “코너 케이스 1” 처리에서 확인. - 5-워드(20바이트) 멤버 그룹, 8 kB 페이지당 409개. 4 플래그 바이트 + 4 XID 패킹은 members 레이아웃 주석과
MULTIXACT_MEMBERGROUP_SIZE/MULTIXACT_MEMBERGROUPS_PER_PAGE매크로에 있다.BLCKSZ=8192로8192 / 20 = 409그룹, 페이지당 12바이트 낭비는 주석과 일치한다. - 여섯 가지 멤버 상태, 순서가 의미를 가짐.
MultiXactStatus는 정확히 여섯 값(ForKeyShare=0 …Update=5)을 가지고,ISUPDATE_from_mxstatus는status > MultiXactStatusForUpdate(0x03)로 판별한다.multixact.h에서 확인. - 멀티는 불변; expand는 새 MXID 생성.
MultiXactIdExpand는 모든 비-trivial 결과에서MultiXactIdCreateFromMembers를 호출하고 전달된multi에 다시 쓰지 않는다. 소스 주석이 대기자와의 경쟁을 설명한다. 확인됨. - 단일 갱신자 불변식.
MultiXactIdCreateFromMembers는ISUPDATE_from_mxstatus를 만족하는 멤버가 둘이면elog(ERROR, "new multixact has more than one updating member")를 발생시킨다. 확인됨. - 두 개의 독립된 순환 도메인.
GetNewMultiXactId는MultiXactGenLock아래에서nextMXact와nextOffset을 모두 전진시키고, 두 개의 별개 가드 블록을 실행한다. MXID 가드(multiVacLimit/multiStopLimit)와 멤버 가드(offsetStopLimitviaMultiXactOffsetWouldWrap). 확인됨. - 오프셋 0은 예약됨.
GetNewMultiXactId는if (nextOffset == 0) { *offset = 1; nmembers++; }를 수행하고,GetMultiXactIdMembers는 읽은 제로 멤버를 무시한다. 두 함수에서 확인됨. - 잠금 모드→상태 매핑은 heapam에 있음.
tupleLockExtraInfo는 네 개의LockTupleMode강도를(hwlock, lockstatus, updstatus)로 매핑하며, 두 공유 모드의 updstatus는-1이다.get_mxact_status_for_lock이 접근자다. 확인됨. FreezeMultiXactId는 네 가지 FRM_ 처분을 반환.*FRM_NOOP,FRM_INVALIDATE_XMAX,FRM_RETURN_IS_XID,FRM_RETURN_IS_MULTI는 heapam.c:6660–6663에 정의되며, 결정 트리는 잠금 전용(무효화)과 커밋된 갱신자(XID 반환)를 구분한다. 확인됨.- 잘라내기는 offsets SLRU에서 멤버 범위를 도출.
TruncateMultiXact→find_multixact_start→PerformMembersTruncation(세그먼트 삭제) +PerformOffsetsTruncation(SimpleLruTruncate), 모두WriteMTruncateXlogRec(XLOG_MULTIXACT_TRUNCATE_ID)으로 WAL 기록됨. 확인됨.
미해결 질문
섹션 제목: “미해결 질문”- 백엔드 로컬
MXactCache주석에는 명시적인FIXME가 있다. 트랜잭션별 수명이 “이제 멀티가 갱신 XID를 포함할 수 있으므로 분명히 틀렸다”는 내용이다. 다른 멤버가 지속되는 항목의 관련성이 캐시가 유지될 수 있다. 알려진 허용된 불완전함으로, 관찰 가능한 정확성 영향이 있는 버그는 아니다. 캐시는 빠른 경로로만 사용되고, 미스는 SLRU로 떨어진다. SetMultiXactIdLimit의 “half the space” 순환 한계 주석은 인정된 근사치다. 멀티 부하가 높은 워크로드에서 실제로 먼저 발동하는 제약은SetOffsetVacuumLimit의 멤버 공간 한계다. MXID 한계가 아니다. 극단적인 멤버 압력 하에서 두 한계 집합 간의 정확한 상호작용은SetOffsetVacuumLimit와MultiXactMemberFreezeThreshold를 나란히 읽어야 이해된다.
PostgreSQL 너머 — 비교 설계와 연구 프론티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”-
Oracle의 ITL vs. members SLRU. ITL은 MultiXact의 주류 가장 가까운 친척이다. 둘 다 행에 관심 있는 트랜잭션 집합을 기록하고, 잠금 보유자와 갱신자가 공존하도록 한다. 교훈적인 차이는 위치다. Oracle은 데이터 블록 헤더에 목록을 유지한다(블록당 하나,
INITRANS/MAXTRANS로 크기 설정). 동시 잠금 보유자는 유한한 블록 내 슬롯을 두고 경쟁하며 ITL이 소진되면ORA-00060류 대기가 발생한다. PostgreSQL은 목록을 전역 members SLRU에 유지한다. 목록 크기를 힙 페이지 공간에서 분리하는 대신 SLRU 조회, 별도 순환 도메인, VACUUM 시FreezeMultiXactId가비지 컬렉션 의무를 지불한다. ITL 슬롯 경합 대 멤버 오프셋 소진의 비교가 핵심 트레이드오프를 틀짓는다. 지역성과 제한된 공간(Oracle) 대 무제한 행별 이해관계자와 상각된 SLRU 트래픽(PostgreSQL). -
잠금 테이블 전용 엔진(DB2, SQL Server)과 잠금 에스컬레이션. 전통적인 2PL 엔진에서 행 잠금은 온-행 흔적이 없는 임시 공유 메모리 해시 항목이다. 동시 잠금 보유자 수에 표현 한계가 없고 동결 문제도 없다. 다만 축적된 행 잠금이 페이지/테이블 단위로 에스컬레이션을 유발해, 동시성과 제한된 메모리를 맞바꾼다. PostgreSQL의 MultiXact는 이중 설계다. 에스컬레이션이 없는 대신(내구성 있는 온-행 MXID에 메모리 압력 절벽이 없다) 순환과 잘라내기 비용을 인메모리 잠금 테이블이 피하는 방식으로 지불한다. 이 비교는 “잠금 상태가 어디에 사는가”가 이론적 배경이 열었던 핵심 설계 축임을 명확히 보여준다. PostgreSQL 자체의 인메모리 무거운 잠금 테이블에 대해서는
postgres-lock-manager.md를 참조하라. 튜플 잠금 경로는 내구성 있는 MXID 외에 이를 일시적으로 사용한다. -
Hekaton / 인메모리 MVCC와 내구성 있는 행 잠금의 부재. Microsoft의 Hekaton(Larson 외, “High-Performance Concurrency Control Mechanisms for Main-Memory Databases,” VLDB 2011)은 버전에 직접 스탬프된 시작/종료 타임스탬프를 통한 낙관적 버전 체인 검증으로 쓰기-쓰기 및 잠금 충돌을 해결한다. 충돌은 검증 시점에 감지되지 내구성 있게 기록되지 않으므로 온-행 잠금 보유자 집합이 아예 없다. MultiXact는 설계 공간의 반대 극단을 차지한다. 비관적이고, 내구성 있고, 누가 무엇을 보유하는가를 온-행 인코딩한다. 크래시 후 도착한 독자가 튜플 단독으로 잠금 상태를 재구성해야 하는 PostgreSQL의 디스크 지향, 크래시 복구 힙에서 정당화된다. 여섯 값
MultiXactStatus를 Hekaton의 타임스탬프 전용 버전과 대비하면, 잠금 자체(데이터가 아니라)의 내구성이 무엇을 비용으로 치르는지 선명하게 드러난다. -
이중 카운터 순환 문제는 연구 산물이기도 하다. 멤버 오프셋 공간은 MXID 도메인 위에 겹쳐진 두 번째 32비트 순환 도메인이다. 역사적으로(9.3 시대의 SLRU 버그가
SetOffsetVacuumLimit와MultiXactMemberFreezeThreshold를 도입하게 한 사건) FK 부하가 높은 워크로드에서 먼저 물어뜯는 쪽이었다.autovacuum_multixact_freeze_max_age가 멤버 비율 백-프레셔 곡선과 어떻게 상호작용하는지, 그리고 64비트 멤버 오프셋(postgres-xid-wraparound-freeze.md에서 추적 중인 64비트 XID 제안과 유사)이 두 번째 도메인 전체를 없앨 수 있는지 집중적으로 연구하는 것이 자연스러운 연구 프론티어다. -
멤버별 상태를 잠금/갱신 너머로 일반화.
MultiXactMember플래그 바이트는 현재 정확히 여섯 가지 상태를 인코딩한다. SLRU 레이아웃은 더 풍부한 멤버별 메타데이터(예: 술어 잠금 의도 구분, 서브트랜잭션 ID 포함)를 금지하지 않는다. 불변 멀티 원칙은 생성 시점에 순수하게 추가적이다. 멤버 어휘가 성장해야 하는지 — 별도의 술어 잠금 기계(postgres-ssi-predicate-locking.md)로 더 풍부한 의도를 밀어넣는 것과 비교해 — 는 현재 단일 갱신자 불변식이 좁게 범위를 유지하는 열린 설계 질문이다.
트리 내 소스 파일 (REL_18_STABLE, 커밋 273fe94)
섹션 제목: “트리 내 소스 파일 (REL_18_STABLE, 커밋 273fe94)”src/backend/access/transam/multixact.c— 서브시스템 본체: offsets/members SLRU 레이아웃 매크로(MultiXactIdToOffsetPage,MXOffsetToMemberPage, 5-워드 멤버 그룹 상수), 생성 경로(MultiXactIdCreate,MultiXactIdExpand,MultiXactIdCreateFromMembers,GetNewMultiXactId,RecordNewMultiXact), 읽기 경로(GetMultiXactIdMembers), 백엔드별 호라이즌(MultiXactIdSetOldestMember/SetOldestVisible), 이중 순환 가드(MultiXactOffsetWouldWrap,SetMultiXactIdLimit,SetOffsetVacuumLimit,MultiXactMemberFreezeThreshold), 잘라내기(TruncateMultiXact,find_multixact_start,PerformMembersTruncation,PerformOffsetsTruncation), WAL 재생(multixact_redo).src/include/access/multixact.h—MultiXactStatus(여섯 가지 멤버 상태),ISUPDATE_from_mxstatus,MultiXactMember, 예약된 MXID 값(InvalidMultiXactId,FirstMultiXactId,MaxMultiXactId,MaxMultiXactOffset).src/backend/access/heap/heapam.c— 잠금 모드 어휘와 동결 컬렉터를 소유하는 정책 계층:tupleLockExtraInfo(LockTupleMode → 멤버 상태),get_mxact_status_for_lock,MultiXactIdGetUpdateXid, 네 가지FRM_*처분을 가진FreezeMultiXactId.src/include/access/htup_details.h—HEAP_XMAX_IS_MULTI와xmax를 직접 XID로 읽을지 MXID로 읽을지 결정하는HEAP_XMAX_IS_LOCKED_ONLY/HEAP_LOCKED_UPGRADEDinfomask 판별자.src/backend/access/heap/README.tuplock— 멀티가 왜 필요한지 동기를 부여하는 튜플 잠금 설계 서술(호환 공유 잠금, 잠금 보유자-갱신자 공존, infomask 인코딩).
논문 및 교과서 챕터
섹션 제목: “논문 및 교과서 챕터”- Database System Concepts(Silberschatz, Korth, Sudarshan, 7판), 18장 “Concurrency Control” — 공유/배타 잠금 모드, 호환성 행렬, 2PL. 이론적 배경이 구축하는 교과서 틀.
- Database Internals(Petrov 2019), 5장 “Transaction Processing and Recovery” — 잠금 관리자 구조와 잠금 상태가 어디에 사는가. 공통 설계 패턴 섹션이 돌아서는 축.
- Larson, P.-Å. 외 (2011). “High-Performance Concurrency Control Mechanisms for Main-Memory Databases.” VLDB 4(5):298-309. PostgreSQL 너머 섹션의 Hekaton 낙관적 MVCC 대비(버전의 타임스탬프 대 내구성 있는 온-행 잠금 보유자 집합).
- Oracle Database Concepts — ITL,
INITRANS/MAXTRANS, 블록 수준 행 잠금 저장. members SLRU 대 블록 헤더 트레이드오프의 비교 닻.
형제 문서 (교차 참조 — 기계는 해당 문서 소유, 여기서 중복하지 않음)
섹션 제목: “형제 문서 (교차 참조 — 기계는 해당 문서 소유, 여기서 중복하지 않음)”postgres-slru.md— offsets와 members SLRU의 인스턴스인SimpleLruReadPage/SimpleLruTruncate/SlruDeleteSegment버퍼 기계. 이 문서는 SLRU를 주어진 것으로 처리한다.postgres-lock-manager.md—tupleLockExtraInfo의 첫 번째 열에 공급하는 인메모리 무거운 잠금 테이블과LOCKMODE어휘(AccessShareLock…AccessExclusiveLock). 튜플 잠금 경로는 내구성 있는 MXID 외에 이를 일시적으로 취득한다.postgres-xid-wraparound-freeze.md— 동일한 VACUUM 패스 내에서 MXID 측면(relminmxid,OldestMxact,MultiXactCutoff)과 나란히 실행되는 XID 동결 측면(relfrozenxid,OldestXmin,FreezeLimit,vac_update_datfrozenxid).postgres-heap-am.md/postgres-mvcc-snapshots.md— MXID가 할당될 때와 이후 독자가 해석하는 방법을 결정하는heap_lock_tuple,heap_update,xmax/infomask 가시성 규칙.postgres-procarray.md— SLRU가 발 아래에서 잘리지 않도록 유지하는OldestMemberMXactId/OldestVisibleMXactId백엔드별 호라이즌의 xmin-호라이즌 유사체.postgres-clog-commit-ts.md— offsets/members 분리가 소개될 때 참조되는, 페이지 계산과 잘라내기 패턴이 multixact와 평행한 다른 두 영역 SLRU.