(KO) PostgreSQL CatCache & SysCache — 백엔드별 카탈로그 튜플 캐시, 네거티브 항목, sinval 무효화 루프
목차
이론적 배경
섹션 제목: “이론적 배경”데이터베이스 엔진은 CPU 시간의 적지 않은 비율을 자신의 데이터 사전에 쓴다. 데이터 사전이란 테이블, 컬럼, 타입, 함수, 연산자 등 모든 스키마 객체를 기술하는 테이블들의 집합이다. PostgreSQL에서는 이것이 바로 시스템 카탈로그다 — pg_class, pg_attribute, pg_proc, pg_type, 그리고 수십 개의 다른 테이블들이 이에 해당한다. 쿼리 플랜의 거의 모든 노드는 카탈로그를 최소 한 번 이상 조회한다. 파서는 릴레이션 이름이 존재하는지 확인하고, 타입 리졸버는 타입 OID와 입력 함수를 가져오며, 플래너는 열 통계를 위해 pg_statistic을 읽고, 실행기는 연산자 OID를 함수 포인터로 변환한다. 캐시가 없다면 이 조회들 하나하나가 버퍼 매니저를 거쳐 디스크로 향하게 된다. 수백 개의 카탈로그 행을 건드리는 멀티 조인 플랜에서는 감당하기 어려운 오버헤드다.
이 문제를 해결하는 일반적인 개념이 데이터 사전 캐시 (또는 카탈로그 캐시)다. 최근에 접근한 카탈로그 튜플을 주 메모리에 보관해, 동일한 조회를 밀리초가 아닌 마이크로초 단위로 처리한다. Database System Concepts(Silberschatz, ch. 25 §“Buffer Management”)는 카탈로그 캐싱을 버퍼 풀과 나란히 표준 기법으로 소개한다. Database Internals(Petrov, ch. 6)도 메타데이터 조회가 모든 쿼리의 임계 경로에 있으며 최대한 저렴하게 만들어야 한다고 지적한다.
카탈로그 캐시의 설계 공간은 두 축으로 정의된다.
-
범위: 프로세스별 vs. 공유. 캐시를 백엔드 고유 힙에 둘 것인가(세션당 사본 하나), 공유 메모리에 둘 것인가(모든 백엔드가 공유), 아니면 둘 다 쓸 것인가? 프로세스별 캐시는 구조가 단순하고 읽기에 잠금이 필요 없다. 반면 여러 동시 백엔드에서 동일한 항목이 중복된다. 공유 캐시는 메모리를 절약하지만 접근마다 동시성 제어가 필요하다.
-
무효화: 즉시 vs. 지연. DDL 문이 카탈로그 행을 변경하면 다른 곳에 캐싱된 사본은 낡은 것이 된다. 즉시 무효화(브로드캐스트 후 대기)는 캐시 일관성을 즉각 유지하지만 모든 백엔드를 DDL 트랜잭션에 묶는다. 지연 무효화(다음 사용 시 표시 후 정리)는 DDL과 독자를 분리하지만, 각 백엔드가 캐싱된 값을 반환하기 전에 무효화 여부를 확인해야 한다.
PostgreSQL은 공유 무효화 메시지 큐 (sinval)를 통한 지연 무효화를 택하고 각 백엔드에 프로세스 전용 캐시를 둔다. 이 선택은 아키텍처 전반을 관통하는 “공유 메모리 기계, 프로세스는 그 거주자” 철학과 맥을 같이 한다(postgres-architecture-overview.md 축 6 참고). 일반적인 읽기에는 캐시별 잠금이 불필요하며, 무효화 메커니즘도 새로운 일관성 경로를 추가하는 대신 기존 sinval 인프라에 깔끔하게 통합된다.
이론적으로 중요한 개념이 하나 더 있다. 네거티브 캐시 항목이다. 이는 주어진 키에 대한 조회가 아무 튜플도 반환하지 않았다는 사실을 명시적으로 기록한다. 단순한 캐시에서는 “미스”가 곧 카탈로그 접근으로 이어진다. 그런데 수백 개의 백엔드가 동시에 존재하지 않는 행을 반복적으로 조회하는 상황 — 선택적 카탈로그 항목의 존재 여부를 확인할 때 흔하다 — 에서는 캐시가 전혀 도움이 되지 않는다. 네거티브 항목은 이 미스를 이후 호출에서 캐시 히트로 전환한다. 존재하는 튜플을 나타내는 양성 항목만큼이나 없음을 확정적으로 표현한다는 점이다. 무효화는 양성 항목과 동일하게 처리한다. 새 튜플이 삽입되면 네거티브 항목이 dead로 표시된다.
DBMS 공통 설계
섹션 제목: “DBMS 공통 설계”Oracle, SQL Server, MySQL InnoDB, DB2 등 시스템 카탈로그를 가진 거의 모든 프로덕션 데이터베이스 엔진은 교과서 모델과 구체적 구현 사이에서 공통된 패턴으로 수렴한다.
카탈로그 신원을 키로 하는 해시 테이블
섹션 제목: “카탈로그 신원을 키로 하는 해시 테이블”캐시는 해시 테이블이다. 키는 카탈로그 신원이다 — 어느 릴레이션을 조회하는지와 행을 특정하는 키 값들(OID, 이름, 또는 복합 키)의 조합이다. 값은 캐싱된 튜플이다. 네거티브 항목의 경우 센티널이다. 해시는 전체 카탈로그를 메모리에 올리지 않고도 평균 O(1) 조회 시간을 보장한다.
refcount 기반 튜플 생존 기간
섹션 제목: “refcount 기반 튜플 생존 기간”캐싱된 튜플은 쿼리 종료 시 해제되지 않는다. 참조가 없고 무효화까지 됐을 때만 해제된다. 패턴은 이렇다. 호출자가 조회 시 참조 카운트를 증가시키고, 반환 시 감소시킨다. 캐시는 refcount가 0으로 떨어지고 항목이 dead로 표시됐을 때만 튜플을 해제한다. 덕분에 호출자는 복사 없이, 그리고 튜플이 발 밑에서 사라질 걱정 없이 캐시 내부로 직접 가리키는 포인터를 쓸 수 있다.
양성·음성 항목을 동일하게 관리
섹션 제목: “양성·음성 항목을 동일하게 관리”네거티브 캐싱을 구현하는 대부분의 엔진은 양성·음성 항목을 동일한 구조체로 표현하고 negative 플래그만 다르게 둔다. 이 균형 덕에 무효화, 교체, refcount 로직이 두 종류에 동일하게 적용된다. 호출자에 대한 반환값만 다르다. 양성이면 튜플 포인터, 음성이면 NULL이다.
공유 큐를 통한 브로드캐스트 무효화
섹션 제목: “공유 큐를 통한 브로드캐스트 무효화”트랜잭션이 카탈로그 행 변경을 커밋하면 다른 모든 백엔드에 그들의 캐시 사본이 낡았음을 알려야 한다. 일반적인 메커니즘은 공유 메시지 큐다. 각 백엔드는 다음 “안전 지점” — 보통 명령 시작 또는 대기 지점 — 에서 큐를 읽는다. 메시지에는 영향받은 키의 해시 값 정도만 담긴다. 이것으로 각 백엔드가 해당 캐시 항목을 dead로 표시할 수 있다. 다른 호출자가 refcount를 쥐고 있다면 즉시 해제되지 않는다. refcount가 0이 될 때 해제된다.
두 레이어 분리: 범용 캐시 엔진과 이름 지정 파사드
섹션 제목: “두 레이어 분리: 범용 캐시 엔진과 이름 지정 파사드”대부분의 프로덕션 엔진에서 카탈로그 캐시는 두 수준을 갖는다. 하나는 해시 버킷, refcount, 무효화를 아는 범용 튜플 캐시 레이어로 특정 카탈로그에 대해서는 아무것도 모른다. 다른 하나는 사람이 읽기 좋은 카탈로그 이름을 정수 ID에 매핑하고 편의 함수를 노출하는 파사드 레이어다 — lookup(cache_id, datum) 대신 get_type_by_oid(oid). 파사드 레이어가 나머지 엔진이 호출하는 쪽이고, 범용 레이어는 파사드 레이어가 사용하는 쪽이다. 이 분리 덕에 범용 레이어는 독립적으로 테스트·추론할 수 있고, 파사드는 캐시 내부를 건드리지 않고 API를 발전시킬 수 있다.
이론 ↔ PostgreSQL 매핑
섹션 제목: “이론 ↔ PostgreSQL 매핑”| 이론 개념 | PostgreSQL 이름 |
|---|---|
| 데이터 사전 캐시 | CatCache (범용) + SysCache[] 배열 (이름 지정) |
| 해시 버킷 배열 | CatCache.cc_bucket[] — dlist_head 배열 |
| 캐싱된 튜플 (양성) | negative = false인 CatCTup |
| 네거티브 캐시 항목 | negative = true인 CatCTup |
| 부분 키 다중 행 결과 | CatCList |
| 참조 카운트 | CatCTup.refcount / CatCList.refcount |
| 무효화 메시지 | (cache_id, hash_value)를 담은 sinval 메시지 |
| 이름 지정 파사드 | syscache.c + MAKE_SYSCACHE에서 파생된 열거형으로 인덱싱된 SysCache[] |
| 편의 래퍼 | lsyscache.c — get_attname, get_func_name, … |
PostgreSQL의 접근법
섹션 제목: “PostgreSQL의 접근법”레이어 아키텍처
섹션 제목: “레이어 아키텍처”PostgreSQL의 카탈로그 캐시는 각각 별도 소스 파일에 담긴 세 레이어로 구성된다.
lsyscache.c ← 편의 API (get_attname, get_func_name, …) │syscache.c ← 이름 지정 ID 파사드 (SearchSysCache1/2/3/4, cacheinfo[]) │catcache.c ← 범용 해시 테이블 엔진 (CatCache, CatCTup, CatCList)catcache.c는 자신이 어느 카탈로그를 캐싱하는지 전혀 모른다. 릴레이션 OID, 인덱스 OID, 키 개수, 키 컬럼 속성 번호를 받아 해시 테이블을 만들 뿐이다. syscache.c는 pg_*.h 헤더의 MAKE_SYSCACHE 선언에서 생성된 cacheinfo[] 테이블을 사용해 SysCache[] 배열을 채우고 SearchSysCache1/2/3/4 패밀리를 노출한다. lsyscache.c는 syscache.c를 공통 쿼리 패턴당 하나의 함수로 감싼다. 덕분에 호출자는 HeapTuple을 직접 다루는 대신 get_func_name(funcid)를 쓸 수 있다.
캐시 선언 방식: MAKE_SYSCACHE
섹션 제목: “캐시 선언 방식: MAKE_SYSCACHE”각 카탈로그 헤더는 MAKE_SYSCACHE로 캐시를 선언한다.
// MAKE_SYSCACHE — src/include/catalog/pg_proc.h (line 143)MAKE_SYSCACHE(PROCOID, pg_proc_oid_index, 128);
// MAKE_SYSCACHE — src/include/catalog/pg_type.h (line 268)MAKE_SYSCACHE(TYPEOID, pg_type_oid_index, 64);
// MAKE_SYSCACHE — src/include/catalog/pg_class.h (line 162)MAKE_SYSCACHE(RELOID, pg_class_oid_index, 128);MAKE_SYSCACHE(RELNAMENSP, pg_class_relname_nsp_index, 128);매크로는 심볼릭 캐시 ID(예: PROCOID), 기반이 되는 유니크 인덱스 OID 표현식, 초기 버킷 수(항상 2의 거듭제곱)를 등록한다. syscache.c의 cacheinfo[] 배열은 #include "catalog/syscache_info.h" 인클루드로 채워지는데, 이 선언들이 (reloid, indoid, nkeys, key[], nbuckets)를 담은 struct cachedesc 항목으로 확장된다. InitCatalogCache는 그 배열을 순회하며 각 항목에 InitCatCache를 호출해 SysCache[] 배열을 구성한다.
CatCache 구조체
섹션 제목: “CatCache 구조체”// struct catcache — src/include/utils/catcache.htypedef struct catcache{ int id; /* 캐시 식별자 (예: PROCOID) */ int cc_nbuckets; /* 해시 버킷 수 (2의 거듭제곱) */ TupleDesc cc_tupdesc; /* 첫 사용 시 relcache에서 복사 */ dlist_head *cc_bucket; /* 버킷별 이중 연결 리스트 */ CCHashFN cc_hashfunc[CATCACHE_MAXKEYS]; /* 키별 해시 함수 */ CCFastEqualFN cc_fastequal[CATCACHE_MAXKEYS]; /* 키별 동등 비교 함수 */ int cc_keyno[CATCACHE_MAXKEYS]; /* 속성 번호 */ int cc_nkeys; /* 1..CATCACHE_MAXKEYS */ int cc_ntup; /* 현재 튜플 수 */ int cc_nlist; /* 현재 리스트 수 */ dlist_head *cc_lbucket; /* 리스트 해시 버킷 (필요 시 할당) */ Oid cc_reloid; /* 캐싱 대상 릴레이션 */ Oid cc_indexoid; /* 지원 유니크 인덱스 */ bool cc_relisshared; /* 데이터베이스 간 공유 여부? */ ScanKeyData cc_skey[CATCACHE_MAXKEYS]; /* 사전 계산된 스캔 키 */ /* ... CATCACHE_STATS 필드 생략 ... */} CatCache;cc_tupdesc는 첫 사용 전까지 NULL이다. CatalogCacheInitializeCache가 릴레이션을 열어 채운다. 이 지연 초기화는 relcache가 준비되기 전에 카탈로그 릴레이션이 열리는 것을 막는다. 부트스트랩 과정에서 특히 중요하다.
CatCTup: 캐싱된 튜플
섹션 제목: “CatCTup: 캐싱된 튜플”// struct catctup — src/include/utils/catcache.htypedef struct catctup{ int ct_magic; /* CT_MAGIC = 0x57261502 */ uint32 hash_value; /* 키의 사전 계산된 해시 */ Datum keys[CATCACHE_MAXKEYS]; /* 추출된 키 값 */ dlist_node cache_elem; /* 버킷별 리스트 링크 (LRU 순서) */ int refcount; /* 활성 참조 수 */ bool dead; /* 제거 대상으로 표시됨 */ bool negative; /* 네거티브 캐시 항목? */ HeapTupleData tuple; /* 튜플 관리 헤더 */ struct catclist *c_list; /* 소유 CatCList, 없으면 NULL */ CatCache *my_cache; /* 소유 CatCache */ /* 정렬된 튜플 데이터가 이어짐 (음성 항목 제외) */} CatCTup;CatCTup과 그 튜플 데이터는 CacheMemoryContext에서 하나의 palloc으로 할당된다. 튜플 바이트가 구조체 바로 뒤에 이어진다는 뜻이다. 음성 항목에는 튜플 데이터가 없다. keys[]는 검색에 사용된 값을 담으며(별도 palloc), 항목 제거 시 해제된다.
설계상 중요한 점이 있다. 버킷별 dlist_head 리스트는 LRU 순서로 유지된다는 점이다. SearchCatCacheInternal은 히트 시 dlist_move_head로 항목을 버킷 리스트의 맨 앞으로 이동한다. 포인터 두 번 교체로 끝나는 저렴한 연산이며, 사실상 버킷 내 근사 LRU를 구현한다.
CatCList: 부분 키 다중 행 결과
섹션 제목: “CatCList: 부분 키 다중 행 결과”어떤 릴레이션의 모든 pg_attribute 행처럼 부분 키를 공유하는 튜플들이 모두 필요한 경우, SearchCatCacheList가 CatCList를 만든다.
// struct catclist — src/include/utils/catcache.htypedef struct catclist{ int cl_magic; /* CL_MAGIC = 0x52765103 */ uint32 hash_value; /* 부분 키의 해시 */ dlist_node cache_elem; /* 캐시별 리스트 버킷 링크 */ Datum keys[CATCACHE_MAXKEYS]; /* 부분 키 값 */ int refcount; bool dead; bool ordered; /* 인덱스 순서대로 멤버가 정렬됐는가? */ short nkeys; /* 지정한 키 컬럼 수 */ int n_members; CatCache *my_cache; CatCTup *members[FLEXIBLE_ARRAY_MEMBER]; /* 멤버 튜플들 */} CatCList;CatCList 항목은 자신이 포함하는 개별 CatCTup 항목들을 가리킨다. 그 CatCTup들은 포인트 조회를 위한 버킷별 해시 리스트에도 동시에 존재한다. CatCTup은 최대 하나의 CatCList에만 속할 수 있다. 리스트 해시 버킷(cc_lbucket)은 첫 리스트 검색 시 지연 할당되며, fill factor가 2를 초과하면 두 배로 늘어난다.
빠른 경로: SearchCatCacheInternal
섹션 제목: “빠른 경로: SearchCatCacheInternal”캐시를 통한 핫 패스는 카탈로그 접근을 전혀 일으키지 않는다.
// SearchCatCacheInternal — src/backend/utils/cache/catcache.c (line 1402)static inline HeapTupleSearchCatCacheInternal(CatCache *cache, int nkeys, Datum v1, Datum v2, Datum v3, Datum v4){ uint32 hashValue; Index hashIndex; dlist_iter iter; CatCTup *ct;
ConditionalCatalogCacheInitializeCache(cache); /* 일회성 초기화 */
hashValue = CatalogCacheComputeHashValue(cache, nkeys, v1, v2, v3, v4); hashIndex = HASH_INDEX(hashValue, cache->cc_nbuckets); /* 비트마스크 */
dlist_foreach(iter, &cache->cc_bucket[hashIndex]) { ct = dlist_container(CatCTup, cache_elem, iter.cur); if (ct->dead) continue; if (ct->hash_value != hashValue) continue; /* 조기 탈출 */ if (!CatalogCacheCompareTuple(...)) continue;
dlist_move_head(...); /* LRU 앞으로 이동 */
if (!ct->negative) { ct->refcount++; ResourceOwnerRememberCatCacheRef(...); return &ct->tuple; /* ← 캐시 HIT, 양성 */ } return NULL; /* ← 캐시 HIT, 음성 */ } return SearchCatCacheMiss(...); /* ← 캐시 MISS */}이 함수는 pg_attribute_always_inline이며 SearchCatCache1/2/3/4로 특화된다. 고정된 nkeys로 컴파일러가 해시 계산 루프를 펼칠 수 있다. 해시는 키별 해시를 고정 비트 회전(키 2·3·4 각각 8·16·24비트)으로 XOR해 구성한다. 해시와 동등 비교 함수는 일반적인 키 타입(OID, int4, int2, name, text)마다 GetCCHashEqFuncs에 하드코딩된 빠른 경로 구현체를 쓴다. 임계 경로에서 fmgr 오버헤드를 피하기 위해서다.
그림 1 — 캐시 조회 결정 트리
flowchart TD
A[SearchSysCache1/2/3/4] --> B[ConditionalCatalogCacheInitializeCache]
B --> C{cc_tupdesc == NULL?}
C -- 예 --> D[CatalogCacheInitializeCache<br/>릴레이션 열기, TupleDesc 복사]
C -- 아니오 --> E[ComputeHashValue + HASH_INDEX]
D --> E
E --> F[cc_bucket dlist 스캔]
F --> G{dead 또는 해시 불일치?}
G -- 건너뜀 --> F
G -- 키 일치? --> H{negative?}
H -- 아니오 --> I[refcount 증가\n&ct->tuple 반환]
H -- 예 --> J[NULL 반환\n음성 캐시 HIT]
F --> K{소진?}
K -- 예 --> L[SearchCatCacheMiss]
L --> M[카탈로그 인덱스에 systable_beginscan]
M --> N{발견?}
N -- 예 --> O[CatalogCacheCreateEntry\n양성 항목]
N -- 아니오 --> P[CatalogCacheCreateEntry\n음성 항목]
O --> I
P --> J
그림 1 — 두 갈래 결정 트리. 양성 항목 히트는 튜플을 직접 반환하고, 음성 항목 히트는 카탈로그 스캔 없이 NULL을 반환하며, 미스는 SearchCatCacheMiss를 호출해 systable_beginscan으로 카탈로그를 연다.
미스 경로: SearchCatCacheMiss
섹션 제목: “미스 경로: SearchCatCacheMiss”캐시 미스 시 SearchCatCacheMiss는 table_open(AccessShareLock)으로 카탈로그 릴레이션을 열고 systable_beginscan으로 인덱스 스캔을 수행한다. IndexScanOK는 인덱스 스캔이 실제로 안전한지 확인한다. pg_am에는 항상 false를 반환하고, criticalRelcachesBuilt 전까지 pg_index에도 false를 반환해 부트스트랩 재귀를 방지한다.
// SearchCatCacheMiss — src/backend/utils/cache/catcache.c (line 1510) relation = table_open(cache->cc_reloid, AccessShareLock); do { scandesc = systable_beginscan(relation, cache->cc_indexoid, IndexScanOK(cache), NULL, nkeys, cur_skey); ct = NULL; stale = false; while (HeapTupleIsValid(ntp = systable_getnext(scandesc))) { ct = CatalogCacheCreateEntry(cache, ntp, NULL, hashValue, hashIndex); if (ct == NULL) { stale = true; break; } /* toast 재시도 */ ct->refcount++; break; /* 유니크 인덱스: 최대 하나의 일치 */ } systable_endscan(scandesc); } while (stale); table_close(relation, AccessShareLock);
if (ct == NULL) /* 발견 안 됨 → 음성 항목 */ ct = CatalogCacheCreateEntry(cache, NULL, arguments, hashValue, hashIndex);stale 재시도 루프는 미묘한 경쟁 조건을 처리한다. 튜플에 out-of-line TOAST 필드가 있으면 CatalogCacheCreateEntry가 toast_flatten_tuple을 호출하는데, 이 과정에서 AcceptInvalidationMessages가 실행돼 진행 중인 항목을 dead로 표시할 수 있다. 그 경우 CatalogCacheCreateEntry가 NULL을 반환하고 스캔이 재시작된다. 이것이 CatCInProgress 스택 메커니즘이다. 항목 생성 중에 CatCInProgress 노드가 catcache_in_progress_stack에 푸시되고, 무효화가 도착하면 node->dead = true로 설정된다. CatalogCacheCreateEntry는 새 항목을 삽입하기 전에 이를 확인한다.
CatalogCacheCreateEntry: 튜플 저장
섹션 제목: “CatalogCacheCreateEntry: 튜플 저장”// CatalogCacheCreateEntry — src/backend/utils/cache/catcache.c (line 2144) ct = (CatCTup *) palloc(sizeof(CatCTup) + MAXIMUM_ALIGNOF + dtp->t_len); ct->tuple.t_data = (HeapTupleHeader) MAXALIGN(((char *) ct) + sizeof(CatCTup)); memcpy((char *) ct->tuple.t_data, (const char *) dtp->t_data, dtp->t_len); /* 키 추출 — 양성 항목에서는 튜플을 직접 가리킴 */ for (i = 0; i < cache->cc_nkeys; i++) ct->keys[i] = heap_getattr(&ct->tuple, cache->cc_keyno[i], ...);핵심 설계 포인트가 있다. CatCTup과 튜플 바이트가 CacheMemoryContext의 하나의 할당 덩어리라는 점이다. 키 Datum 값은 튜플 데이터를 직접 가리킨다. 고정 길이 by-value 타입은 인라인으로 복사되고, 가변 길이 타입은 힙 튜플 안을 가리킨다. 음성 항목에는 튜플이 없으며 키는 별도 palloc된 사본이다.
syscache.c: 이름 지정 ID 파사드
섹션 제목: “syscache.c: 이름 지정 ID 파사드”syscache.c는 catcache.c를 정수 캐시 ID로 감싼다.
// InitCatalogCache — src/backend/utils/cache/syscache.c (line 110)void InitCatalogCache(void){ for (cacheId = 0; cacheId < SysCacheSize; cacheId++) { SysCache[cacheId] = InitCatCache(cacheId, cacheinfo[cacheId].reloid, cacheinfo[cacheId].indoid, cacheinfo[cacheId].nkeys, cacheinfo[cacheId].key, cacheinfo[cacheId].nbuckets); } /* RelationHasSysCache / RelationSupportsSysCache를 위한 정렬된 OID 배열 구성 */ qsort(SysCacheRelationOid, SysCacheRelationOidSize, sizeof(Oid), oid_compare); /* ... 중복 제거 ... */ CacheInitialized = true;}공개 API는 SearchSysCache1/2/3/4 패밀리이며 SearchCatCache1/2/3/4에 직접 위임한다.
// SearchSysCache1 — src/backend/utils/cache/syscache.c (line 221)HeapTuple SearchSysCache1(int cacheId, Datum key1){ Assert(cacheId >= 0 && cacheId < SysCacheSize && PointerIsValid(SysCache[cacheId])); Assert(SysCache[cacheId]->cc_nkeys == 1); return SearchCatCache1(SysCache[cacheId], key1);}편의 매크로 SearchSysCacheCopy1, SearchSysCacheExists1, GetSysCacheOid1, GetSysCacheHashValue1은 syscache.h에 미사용 키 인수를 0으로 패딩하는 인라인 래퍼로 정의된다.
SearchSysCacheLocked1(line 287)은 수정 전 잠금이 필요한 inplace 업데이트 테이블(pg_class, pg_database, pg_authid 등)을 위한 PG18 시대 추가 함수다. SearchSysCache1 → LockTuple → 재조회를 TID가 안정될 때까지 반복하므로, 호출자는 튜플과 튜플 잠금을 하나의 원자적 단계로 얻는다.
SysCacheGetAttr와 SysCacheGetAttrNotNull
섹션 제목: “SysCacheGetAttr와 SysCacheGetAttrNotNull”// SysCacheGetAttr — src/backend/utils/cache/syscache.c (line 600)Datum SysCacheGetAttr(int cacheId, HeapTuple tup, AttrNumber attributeNumber, bool *isNull){ /* 캐시의 TupleDesc가 튜플과 일치하는지 Assert */ Assert(...); return heap_getattr(tup, attributeNumber, SysCache[cacheId]->cc_tupdesc, isNull);}캐싱된 튜플은 트랜잭션 컨텍스트가 아닌 CacheMemoryContext에 살아 있다. 반환된 Datum 포인터는 항목이 무효화되지 않는 한 백엔드 생존 기간 동안 유효하다. 다음 카탈로그 접근 이후에도 값을 보유해야 하는 호출자는 datumCopy로 복사해야 한다.
lsyscache.c: 편의 파사드
섹션 제목: “lsyscache.c: 편의 파사드”lsyscache.c는 ~150개의 타입화된 래퍼 함수를 제공해 호출자가 HeapTuple을 직접 다루지 않아도 된다. 패턴은 균일하다.
// get_func_name — src/backend/utils/cache/lsyscache.c (line 1786)char *get_func_name(Oid funcid){ HeapTuple tp; char *result;
tp = SearchSysCache1(PROCOID, ObjectIdGetDatum(funcid)); if (!HeapTupleIsValid(tp)) return NULL; result = pstrdup(NameStr(((Form_pg_proc) GETSTRUCT(tp))->proname)); ReleaseSysCache(tp); return result;}흐름은 이렇다. OID로 조회하고, GETSTRUCT로 타입화된 구조체를 추출하고, 캐시 튜플 안의 필드를 호출자의 메모리 컨텍스트로 복사한 후, 캐시 참조를 해제하고 반환한다. pstrdup이 필수적인 이유가 있다. 캐싱된 NameStr 포인터는 CacheMemoryContext 안을 가리키는데, 이후 카탈로그 접근이 무효화를 유발할 수 있기 때문이다. 현재 메모리 컨텍스트로 복사해야 값이 살아남는다.
그림 2 — 전형적인 카탈로그 조회의 레이어별 호출 흐름
sequenceDiagram
participant E as 실행기 / 플래너
participant L as lsyscache.c
participant S as syscache.c
participant C as catcache.c
participant K as 카탈로그 (힙)
E->>L: get_func_name(funcid)
L->>S: SearchSysCache1(PROCOID, funcid)
S->>C: SearchCatCache1(SysCache[PROCOID], funcid)
C->>C: ComputeHashValue + 버킷 스캔
alt 캐시 HIT
C-->>S: &ct->tuple (refcount++)
S-->>L: HeapTuple
L->>S: ReleaseSysCache(tp)
S->>C: ReleaseCatCache (refcount--)
L-->>E: pstrdup(proname)
else 캐시 MISS
C->>K: table_open + systable_beginscan
K-->>C: 인덱스 스캔에서 HeapTuple
C->>C: CatalogCacheCreateEntry (CacheMemoryContext에 palloc)
C-->>S: &ct->tuple (refcount++)
S-->>L: HeapTuple
L->>S: ReleaseSysCache(tp)
S->>C: ReleaseCatCache (refcount--)
L-->>E: pstrdup(proname)
end
그림 2 — 히트와 미스 경로 모두 refcount를 감소시키는 ReleaseSysCache 호출로 끝난다. 호출자는 캐시 포인터가 아닌 이름의 pstrdup 사본을 받는다.
무효화: CatCacheInvalidate와 sinval 루프
섹션 제목: “무효화: CatCacheInvalidate와 sinval 루프”트랜잭션이 카탈로그 변경을 커밋하면 inval.c가 영향받는 각 캐시 항목마다 (cacheId, hashValue)를 담은 sinval 메시지를 큐에 넣는다. 다음 안전 지점 — AcceptInvalidationMessages, 트랜잭션 시작, CHECK_FOR_INTERRUPTS 등에서 호출됨 — 에 각 백엔드가 큐를 처리하고 SysCacheInvalidate → CatCacheInvalidate를 호출한다.
// CatCacheInvalidate — src/backend/utils/cache/catcache.c (line 636)void CatCacheInvalidate(CatCache *cache, uint32 hashValue){ /* 1. 이 캐시의 모든 CatCList를 무효화 (선택적 처리가 너무 어려움) */ for (int i = 0; i < cache->cc_nlbuckets; i++) dlist_foreach_modify(iter, &cache->cc_lbucket[i]) { CatCList *cl = ...; if (cl->refcount > 0) cl->dead = true; else CatCacheRemoveCList(cache, cl); }
/* 2. 특정 해시 버킷에서 일치하는 튜플 무효화 */ hashIndex = HASH_INDEX(hashValue, cache->cc_nbuckets); dlist_foreach_modify(iter, &cache->cc_bucket[hashIndex]) { CatCTup *ct = ...; if (hashValue == ct->hash_value) { if (ct->refcount > 0 || (ct->c_list && ct->c_list->refcount > 0)) ct->dead = true; /* 사용 중: 표시 후 나중에 해제 */ else CatCacheRemoveCTup(cache, ct); /* 미사용: 즉시 해제 */ } }
/* 3. 진행 중인 빌드 항목 dead 표시 */ for (CatCInProgress *e = catcache_in_progress_stack; e; e = e->next) if (e->cache == cache && (e->list || e->hash_value == hashValue)) e->dead = true;}세 가지 점을 주목해야 한다. 첫째, 영향받은 캐시의 모든 CatCList 항목이 키에 관계없이 무조건 무효화된다. 어느 부분 키 리스트가 영향받는지 파악하기 너무 어렵기 때문이다. 둘째, refcount > 0인 튜플은 단지 dead로 표시될 뿐이다. ReleaseCatCache가 refcount를 0으로 떨어뜨릴 때까지 해제되지 않는다. 셋째, 일치는 튜플 TID가 아닌 hash_value 기준이다. VACUUM FULL 후 정확성을 보장하기 위해서다. VACUUM FULL은 TID를 바꾸므로 TID 기반 일치는 신뢰할 수 없다.
그림 3 — CatCTup 항목의 무효화 상태 머신
stateDiagram-v2
[*] --> 활성 : CatalogCacheCreateEntry
활성 --> 활성 : SearchCatCacheInternal 히트\nrefcount++
활성 --> 활성 : ReleaseCatCache\nrefcount--
활성 --> 사망_참조중 : CatCacheInvalidate\nrefcount > 0
활성 --> 해제됨 : CatCacheInvalidate\nrefcount == 0
사망_참조중 --> 해제됨 : ReleaseCatCache\nrefcount 0으로 감소
해제됨 --> [*] : CatCacheRemoveCTup pfree
그림 3 — 항목은 사용 중에 무효화되면 활성에서 사망_참조중으로 전이하며, 마지막 호출자가 해제한 후에야 메모리가 돌아간다.
콜백: RegisterSyscacheCallback
섹션 제목: “콜백: RegisterSyscacheCallback”relcache나 타입 캐시처럼 자체 파생 상태를 유지하는 서브시스템은 RegisterSyscacheCallback으로 콜백을 등록한다. CatCacheInvalidate가 항목을 dead로 표시할 때 CallSyscacheCallbacks가 영향받은 캐시 ID에 등록된 각 콜백을 발화한다. relcache는 이 메커니즘으로 RelationData 항목을 무효화하고, 타입 캐시는 파생 타입 정보를 버린다. 단일 sinval 이벤트가 인메모리 카탈로그 계층의 모든 레이어에 전파되는 경로가 바로 이것이다.
해시 버킷 증가: RehashCatCache
섹션 제목: “해시 버킷 증가: RehashCatCache”버킷 수는 cacheinfo[]의 초기값으로 시작해, 부하율이 휴리스틱 임계치를 초과하면 두 배로 늘어난다. RehashCatCache는 새 버킷 배열을 할당하고, 기존 항목을 모두 순회하며 HASH_INDEX(ct->hash_value, newnbuckets)로 재배치한 후 이전 배열을 해제한다. 블로킹이 전혀 없다. 리해시를 유발한 백엔드 안에서 동기적으로 처리된다. 마찬가지로 RehashCatCacheLists가 리스트 버킷 배열을 두 배로 늘린다.
소스 탐방
섹션 제목: “소스 탐방”catcache.c — 범용 엔진
섹션 제목: “catcache.c — 범용 엔진”InitCatCache—CacheMemoryContext에CatCache구조체 할당,cc_reloid,cc_indexoid,cc_nkeys,cc_keyno,cc_nbuckets설정. 릴레이션을 열지는 않음.CatalogCacheInitializeCache— 카탈로그 릴레이션을 처음(한 번) 열어TupleDesc복사,GetCCHashEqFuncs로 키별 해시·동등 비교 함수 해결,cc_skey[]채움.SearchCatCacheInternal— 인라인 핫 패스: 해시 → 버킷 스캔 → LRU 승격 → refcount.SearchCatCache1/2/3/4에서 디스패치됨.SearchCatCacheMiss—pg_noinline콜드 패스: 인덱스에systable_beginscan, 발견 시 양성 항목 또는 미발견 시 음성 항목으로CatalogCacheCreateEntry.CatalogCacheCreateEntry—CatCTup+ 튜플 바이트 단일 palloc,CatCInProgress스태일 감지와 함께 TOAST 평탄화 처리.SearchCatCacheList— 부분 키 다중 행 조회를CatCList로 빌드,in_progress_ent로 빌드 중 무효화 도착 시 재시도.CatCacheInvalidate— 해시 값 기준 항목 표시/해제, 모든 리스트 무효화, 진행 중 항목 표시.ReleaseCatCache/ReleaseCatCacheList— refcount 감소, dead이고 refcount==0이면 해제.RehashCatCache/RehashCatCacheLists— 버킷 배열 두 배로 늘리기, 항목 재배치.PrepareToInvalidateCacheTuple—inval.c가 튜플 삽입/수정/삭제 시 호출해 큐에 넣을 해시 값 계산.
syscache.c — 이름 지정 ID 파사드
섹션 제목: “syscache.c — 이름 지정 ID 파사드”InitCatalogCache—cacheinfo[]로SysCache[]빌드, 정렬된SysCacheRelationOid[]및SysCacheSupportingRelOid[]배열 채움.SearchSysCache1/2/3/4—SearchCatCache1/2/3/4를 호출하는 얇은 래퍼.SearchSysCacheLocked1—LOCKTAG_TUPLE을InplaceUpdateTupleLock으로 획득 후 조회, TID가 안정될 때까지 반복.SysCacheGetAttr/SysCacheGetAttrNotNull— 캐시의cc_tupdesc로heap_getattr호출.SysCacheInvalidate—SysCache[]인덱싱 후CatCacheInvalidate호출,CallSyscacheCallbacks발화.RelationHasSysCache/RelationSupportsSysCache—SysCacheRelationOid[]/SysCacheSupportingRelOid[]의 이진 탐색.
lsyscache.c — 편의 파사드
섹션 제목: “lsyscache.c — 편의 파사드”대표적인 래퍼들:
get_attname(relid, attnum)—ATTNUM캐시,attname의pstrdup반환.get_opname(opno)—OPEROID캐시,oprname의pstrdup반환.get_func_name(funcid)—PROCOID캐시,proname의pstrdup반환.get_rel_name(relid)—RELOID캐시,relname의pstrdup반환.get_namespace_name(nspid)—NAMESPACEOID캐시,nspname의pstrdup반환.
위치 힌트 (커밋 273fe94, 2026-06-05)
섹션 제목: “위치 힌트 (커밋 273fe94, 2026-06-05)”| 심볼 | 파일 | 줄 |
|---|---|---|
struct catcache | src/include/utils/catcache.h | 44 |
struct catctup | src/include/utils/catcache.h | 88 |
struct catclist | src/include/utils/catcache.h | 159 |
struct catcacheheader | src/include/utils/catcache.h | 184 |
InitCatCache | src/backend/utils/cache/catcache.c | 889 |
CatalogCacheInitializeCache | src/backend/utils/cache/catcache.c | 1126 |
SearchCatCacheInternal | src/backend/utils/cache/catcache.c | 1402 |
SearchCatCacheMiss | src/backend/utils/cache/catcache.c | 1510 |
CatalogCacheCreateEntry | src/backend/utils/cache/catcache.c | 2144 |
SearchCatCacheList | src/backend/utils/cache/catcache.c | 1730 |
CatCacheInvalidate | src/backend/utils/cache/catcache.c | 636 |
ReleaseCatCache | src/backend/utils/cache/catcache.c | 1658 |
ReleaseCatCacheList | src/backend/utils/cache/catcache.c | 2104 |
PrepareToInvalidateCacheTuple | src/backend/utils/cache/catcache.c | 2387 |
InitCatalogCache | src/backend/utils/cache/syscache.c | 110 |
SearchSysCache1 | src/backend/utils/cache/syscache.c | 221 |
SearchSysCacheLocked1 | src/backend/utils/cache/syscache.c | 287 |
SysCacheGetAttr | src/backend/utils/cache/syscache.c | 600 |
SysCacheInvalidate | src/backend/utils/cache/syscache.c | 698 |
RelationHasSysCache | src/backend/utils/cache/syscache.c | 745 |
get_func_name | src/backend/utils/cache/lsyscache.c | 1786 |
get_attname | src/backend/utils/cache/lsyscache.c | 957 |
get_rel_name | src/backend/utils/cache/lsyscache.c | 2106 |
get_namespace_name | src/backend/utils/cache/lsyscache.c | 3544 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”검증된 사실
섹션 제목: “검증된 사실”-
CatCTup과 튜플 바이트는CacheMemoryContext에서 하나의 할당 덩어리다.CatalogCacheCreateEntry(catcache.c:2216)에서 검증.palloc(sizeof(CatCTup) + MAXIMUM_ALIGNOF + dtp->t_len)으로 할당하고ct->tuple.t_data를MAXALIGN(((char *) ct) + sizeof(CatCTup))으로 설정한다. 튜플 바이트가 구조체 헤더 바로 뒤 캐시라인 정렬 주소에 위치한다는 뜻이다. -
음성 항목은 별도 palloc된 키를 가지며, 양성 항목의 키는 튜플을 직접 가리킨다. 검증됨. 양성 항목(ntp != NULL)에서
ct->keys[i] = heap_getattr(...)은 인라인 튜플을 가리킨다. 음성 항목에서CatCacheCopyKeys가 별도 datum을 할당한다.CatCacheRemoveCTup은 음성 항목에 대해서만CatCacheFreeKeys를 호출한다. -
CatCList무효화는 전체 적용된다 — 어떤 무효화 이벤트에서도 캐시의 모든 리스트가 무효화된다.CatCacheInvalidate(catcache.c:636)에서 검증. 함수가 모든cc_nlbuckets개의 리스트 버킷을 조건 없이 순회한다. 코드 주석에도 “어떤 검색이 여전히 올바른지 파악하기 너무 어려우니 전부 날린다”라고 명시돼 있다. -
cc_lbucket(리스트 해시 버킷)은 첫SearchCatCacheList호출 시 지연 할당된다. 검증됨.InitCatCache가cp->cc_lbucket = NULL로 설정한다(catcache.c:949).SearchCatCacheList는if (cache->cc_lbucket == NULL)조건 하에 첫 호출 시 초기 16버킷 배열을 할당한다. -
CatCInProgress스택은 프로세스 로컬이다. 검증됨.static CatCInProgress *catcache_in_progress_stack = NULL로 선언(catcache.c:61). 공유 메모리 컴포넌트가 없다. 이 스택이 보호하는 경쟁 조건은 프로세스 내부에서만 발생한다.toast_flatten_tuple중에 무효화가 도착하는 상황이 그것이다. -
SearchSysCacheLocked1은 TID가 안정될 때까지 반복한다. syscache.c:287–376에서 검증.SearchSysCache1 → LockTuple → 재조회 → TID 비교를 루프로 수행한다.README.tuplock의 §“Locking to write inplace-updated tables”에서 설명하는 메커니즘이다. PG16/17 inplace 업데이트 강화의 일환으로 추가됐다. -
MAKE_SYSCACHE선언은 각pg_*.h카탈로그 헤더에 위치한다. grep으로 검증.MAKE_SYSCACHE(PROCOID, ...)— pg_proc.h:143,MAKE_SYSCACHE(TYPEOID, ...)— pg_type.h:268,MAKE_SYSCACHE(RELOID, ...)— pg_class.h:162에 각각 존재한다.syscache_info.h가 포함돼cacheinfo[]를 채운다.
미해결 질문
섹션 제목: “미해결 질문”-
커밋 273fe94 기준
SysCacheSize의 정확한 값. 캐시 ID의 정확한 수는 생성된catalog/syscache_ids.h에 있는데, 작업 트리의src/include/catalog/아래 예상 경로에서 해당 파일을 찾지 못했다. 조사 경로:grep -r SysCacheSize src/include/로 생성된 헤더 또는 열거형 정의를 찾는다. -
RegisterSyscacheCallback용량 한도.inval.c는 등록된 콜백을 고정 배열에 저장한다. 용량 한도와 초과 시 동작(assert vs. 오류 vs. 확장)이 검증되지 않았다. 조사 경로:inval.c의CallSyscacheCallbacks와 배열 선언을 읽는다. -
debug_discard_cachesGUC 동작.ResetCatalogCachesExt(true)는debug_discard_caches가 설정됐을 때 호출된다. 정확한 GUC 이름과 PG18 릴리스 빌드에서 접근 가능한지(USE_ASSERT_CHECKING필요?) 여부가 검증되지 않았다.
PostgreSQL 너머 — 비교 설계와 연구 프런티어
섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프런티어”-
Oracle 로우 캐시 (공유). Oracle의 데이터 사전 캐시는 모든 세션이 공유하는 공유 풀(SGA)에 위치한다. 세션별 중복을 막지만 접근마다 래치가 필요하다. 트레이드오프는 명확하다. 고동시성 워크로드에서 Oracle은 래치 비용을 감수할 만큼 공유가 이익이 된다. PostgreSQL의 프로세스별 모델은 읽기 잠금을 완전히 없애는 대신 세션 간 항목을 중복 보관한다. 높은 동시성 DDL-읽기 워크로드에서 두 접근법을 비교 측정하면 트레이드오프를 정량화할 수 있을 것이다.
-
MySQL InnoDB 테이블 정의 캐시 (
table_definition_cache). 스레드 간 공유되는TABLE_SHARE객체의 공유 LRU 캐시다. 개별 카탈로그 튜플보다 전체 테이블 정의가 공유 단위다. -
CockroachDB 리스 기반 디스크립터 캐시. CockroachDB는 범위 리스가 적용된 스키마 디스크립터를 사용한다. 노드는 시간 제한 리스로 스키마 버전을 보유하고 리스가 만료될 때까지 로컬 사본으로 요청을 처리한다. 이는 즉시 무효화에 더 가까운 접근이며, sinval 큐 대신 리스 프로토콜이 그 역할을 한다. PostgreSQL의 수요 기반 지연 무효화와 대조된다.
-
연구: 세밀한 리스트 무효화. PostgreSQL의
CatCacheInvalidate는 어떤 카탈로그 변경에서도 모든CatCList항목을 무효화한다. 어떤 부분 키가 영향받는지와 관계없이 그렇다. 보수적이고 때로 과도하다. 더 세밀한 접근 — 부분 키를 해시해 일치하는 리스트만 무효화하는 — 은 대규모 카탈로그에서의 DDL 워크로드 아래 불필요한 리스트 재빌드를 줄일 것이다. PostgreSQL에 특화된 정량적 연구는 알려지지 않았다. -
연구: 공유 카탈로그 캐시. PostgreSQL에서는 오랫동안 백엔드별 메모리 사용량과 카탈로그 스캔 시작 오버헤드를 줄이기 위한 공유 카탈로그 캐시 제안이 있었다. 커넥션 풀러와 단명 세션에서 특히 중요하다. 구현 난점은 새로운 전역 래치 없이 공유 구조체에서 refcount와 무효화를 관리하는 것이다. pgsql-hackers 논의(~2019–2022)에서 일부 작업이 등장했으며 주제는 여전히 열려 있다.
소스 파일 (커밋 273fe94, REL_18_STABLE)
섹션 제목: “소스 파일 (커밋 273fe94, REL_18_STABLE)”src/backend/utils/cache/catcache.c— 범용 카탈로그 캐시 엔진src/backend/utils/cache/syscache.c— 이름 지정 ID 파사드 및SysCache[]배열src/backend/utils/cache/lsyscache.c— 타입화된 편의 래퍼src/include/utils/catcache.h—CatCache,CatCTup,CatCList구조체 정의src/include/utils/syscache.h—SearchSysCache*프로토타입 및 매크로 래퍼src/include/utils/lsyscache.h—get_attname,get_func_name, … 프로토타입src/include/catalog/pg_class.h,pg_proc.h,pg_type.h,pg_attribute.h,pg_namespace.h—MAKE_SYSCACHE선언
교과서 참고
섹션 제목: “교과서 참고”- Database Internals (Petrov, 2019) — ch. 6 §“Metadata Management”: 카탈로그 캐시를 임계 경로 최적화로 소개.
- Database System Concepts (Silberschatz, 7e) — ch. 25 §“Buffer Management”: 데이터 사전 캐시와 버퍼 풀의 나란한 소개.
이 KB의 관련 문서
섹션 제목: “이 KB의 관련 문서”knowledge/ko/code-analysis/postgres/postgres-cache-invalidation.md— sinval 메시지 큐와inval.c전체 분석.knowledge/ko/code-analysis/postgres/postgres-relcache.md— catcache의 상위 소비자인 relcache,RegisterSyscacheCallback사용.knowledge/ko/code-analysis/postgres/postgres-system-catalogs.md— 카탈로그 레이아웃,MAKE_SYSCACHE매크로,.bki부트스트랩.knowledge/ko/code-analysis/postgres/postgres-memory-contexts.md—CacheMemoryContext생존 기간과 할당 패턴.