콘텐츠로 이동

(KO) PostgreSQL 인덱스 생성 — CREATE INDEX, 빌드 과정, CONCURRENTLY

목차

**인덱스(index)**는 베이스 릴레이션 위에 겹쳐 놓는 중복·정렬 접근 경로다. 하나 이상의 속성(과 행 위치 정보)을 별도 구조에 복사해 점 조회와 범위 조회를 선형 이하 시간에 처리한다. Database System Concepts(Silberschatz 외, 7판, 14장 “Indexing”)는 이 주제를 두 계열로 나눈다. 순서 인덱스(ordered index)(§14.2), 그 실현인 B+-트리(§14.3–14.4), 그리고 해시 인덱스다. “인덱스 구조는 특정 *탐색 키(search key)*와 연결되며, 한 파일에 서로 다른 탐색 키에 대한 인덱스가 여럿 존재할 수 있다”고 정리한다. SQL 표면은 단순하다. §4.6이 문법 전체를 한 줄로 준다.

“We create an index with the create index command, which takes the form create index <index-name> on <relation-name> (<attribute-list>);” (DSC §4.6)

그 한 줄 뒤에 이 문서의 핵심 질문이 있다. 엔진은 비어 있는 인덱스 구조를 기존 모든 행의 항목으로 어떻게 채우는가? 교과서는 두 가지 답을 제시하며, 이 둘은 PostgreSQL의 두 코드 경로에 정확히 대응한다.

1. 튜플 단위 삽입. 빌드를 N번의 일반 인덱스 삽입으로 취급한다. 힙을 스캔하고 살아있는 행마다 구조의 insert(key, tid)를 호출한다. 정확하고 단순하지만 비효율적이다. 삽입마다 루트에서 트리를 내려가고, 내부 노드를 순서 없이 더럽히며, 임의 페이지 접근을 O(N log N)회 수행한다. 무작위 분할 이후 리프가 대략 절반만 채워진다.

2. 벌크 로딩(bulk loading). DSC §14.4.1과 “bulk loader” 논의(§14.x)는 실무 기법을 설명한다. 모든 (key, tid) 쌍을 한 번 정렬한 뒤, 정렬 순서대로 리프 페이지에 추가하며 각 리프를 높은 채움 인수로 가득 채우고 내부 레벨을 아래에서 위로 구성한다. 교과서의 평가는 명확하다.

“Most relational database products have special ‘bulk loader’ utilities to insert a large number of records efficiently … sorting the entries and inserting them in sorted order … results in a much more efficient index construction.” (DSC, bulk-loading discussion)

벌크 로딩은 O(N log N) 랜덤 I/O를 정렬 한 번과 순차 쓰기로 대체하고, 빽빽한 트리를 만들어 캐시 효율과 페이지 수를 개선한다. PostgreSQL B-트리의 ambuild(btbuild_bt_load)는 정확히 이 벌크 로더다. 힙을 스캔하고, tuplesort를 거쳐 채워진 리프를 왼쪽부터 순서대로 쓴다.

두 번째 이론 축은 빌드 자체의 동시성이다. 단순한 빌드는 스캔·정렬·쓰기 전 과정 동안 테이블을 동결해야 한다. 대형 테이블이면 DML이 수 분 혹은 수 시간 차단된다. 온라인/비블로킹 인덱스 구성 연구(Lehman & Yao 1981, “Efficient Locking for Concurrent Operations on B-Trees”와 온라인 스키마 변경 연구의 계보)는 묻는다. 테이블이 삽입·갱신·삭제를 계속 받으면서 인덱스를 만들 수 있는가? 어려움은 움직이는 목표 때문이다. 스캔 도중 다른 트랜잭션이 미처 보지 못한 행을 추가하고, 이미 인덱싱한 행을 삭제한다. 온라인 빌드는 (a) 일관된 기준선을 잡고, (b) 동시 쓰기 트랜잭션이 새 인덱스 유지를 빌드 완료 전에 시작하도록 보장하고, (c) 기준선과 쓰기 트랜잭션 참여 시점 사이의 간격을 메워야 한다. PostgreSQL의 CREATE INDEX CONCURRENTLY는 이를 위한 구체적 프로토콜이며, 이 문서의 대부분이 그 추적이다.

세 번째 아이디어는 더 작다. 인덱스는 파생 상태다. 인덱스의 어떤 항목도 권위 있는 데이터가 아니다. 힙에서 언제든 재계산할 수 있다. REINDEX가 안전한 이유(구조를 버리고 재빌드)가 여기 있다. CREATE INDEX CONCURRENTLY가 도중에 충돌했을 때 인덱스가 invalid 상태에 머무를 뿐 손상이 일어나지 않는 이유도 같다. 플래너는 그 인덱스를 무시하고, REINDEX나 DROP이 뒤처리를 한다.

네 번째 아이디어는 소스를 보기 전에 확정해 둘 만하다. 인덱스가 채워진 상태인덱스를 신뢰할 수 있는 상태의 차이다. 고전적 단일 패스 빌드는 둘을 동일시한다. 빌드가 끝나는 순간 인덱스는 모든 행을 담고 있으며 읽혀도 된다. 온라인 빌드는 이 두 상태를 분리해야 한다. 인덱스가 물리적으로 존재하고 동시 쓰기 트랜잭션이 유지까지 하고 있어도, 플래너는 테이블의 어떤 일관된 뷰에 대해서도 완전하지 않으므로 사용을 거부해야 하는 구간이 생긴다. 교과서의 인덱스 논의는 “채워짐 = 신뢰 가능”을 전제한다. 온라인 DDL 연구는 정확히 이 동일시를 안전하게 깨고 다시 복원하는 방법을 탐구한다. PostgreSQL은 이 끊어진 동일시를 두 개의 별도 플래그로 인코딩한다. indisready는 “유지 중”을, indisvalid는 “읽어도 됨”을 뜻하며, 둘 사이에 의도적인 간격이 있다. 전체 CONCURRENTLY 프로토콜은 이 간격을 신중하게 순서화하는 작업이다.

교과서는 모델을 제시한다. 순서 구조, 벌크 로딩, 온라인 구성이다. 이 섹션은 프로덕션 엔진들이 수렴하는 엔지니어링 관행을 정리한다. 교과서가 암묵적으로 남겨 둔 패턴들이다. ## PostgreSQL의 구현의 구체적 선택은 이 공유 공간 안의 한 지점이다.

  1. SQL 명령과 카탈로그 메커니즘을 분리한다. 파서는 사용자가 요청한 인덱스를 기술하는 구문 노드를 생성한다. 한 계층이 이를 검증·결정하고(접근 방법이 존재하는가? 연산자 클래스가 컬럼 타입과 호환되는가? 이름은 무엇인가?), 하위 계층이 카탈로그 행과 저장소를 실체화한다. PostgreSQL은 DefineIndex(명령)와 index_create(카탈로그)로 분리한다. 대부분의 엔진에서 볼 수 있는 “DDL 실행기”와 “카탈로그 관리자”의 분리와 같다.

  2. 빌드는 접근 방법 다형성을 가진다. B-트리, 해시, GiST, GIN, BRIN, SP-GiST 모두 빌드 방식이 다르지만, 조율 과정(사용자 ID 설정, 진행 상황 보고, AM 빌더 호출, 통계 기록)은 동일하다. 엔진은 이를 하나의 빌드 드라이버로 추출하고 AM별 콜백으로 디스패치한다. PostgreSQL의 index_buildindexRelation->rd_indam->ambuild(...)를 호출한다.

  3. 생존 상태는 불리언 하나가 아닌 세 단계다. “완료” 불리언은 온라인 빌드가 거치는 중간 상태를 표현할 수 없다. 관행적 분해는 세 가지다. live(카탈로그 행이 존재하며 DROP/정리 결정에 반영되어야 함), ready(새로운 DML이 이 인덱스에 삽입해야 함), valid(쿼리가 읽어도 됨). PostgreSQL은 이를 pg_index.indislive, indisready, indisvalid로 저장한다.

  4. 잠금 강도가 블로킹 대 온라인의 정책 손잡이다. 블로킹 빌드는 쓰기를 배제하되 읽기는 허용하는 잠금을 잡는다. 온라인 빌드는 더 약한 잠금으로 쓰기를 허용하고, 그 대가로 다중 패스 조정을 수행한다. PostgreSQL은 일반 CREATE INDEXShareLock(쓰기 차단), CONCURRENTLYShareUpdateExclusiveLock(쓰기 허용)을 사용한다.

  5. 온라인 빌드는 스냅샷을 기준으로 조정한다. 빌드 도중 테이블이 변하므로, 엔진은 참조 스냅샷을 고정하고 그 스냅샷보다 오래된 상태를 볼 수 있는 모든 트랜잭션을 기다린 뒤 인덱스를 신뢰한다. 이 “오래된 스냅샷 대기” 단계가 CONCURRENTLY의 숨겨진 비용이며, 오래 실행되는 트랜잭션 앞에서 멈출 수 있는 이유다.

  6. 재빌드는 빌드에 약간의 변형을 가한 것이다. 리인덱싱은 동일한 빌드 기계를 기존 인덱스에 돌리는 것으로, 제약 재검사를 선택적으로 건너뛸 수 있고(이전 정의를 신뢰) HOT 체인 처리도 조정된다(깨진 체인이 인덱스보다 먼저 존재하므로). PostgreSQL은 공유 index_buildisreindex 플래그를 전달한다.

  7. 빌드는 테이블 소유자 권한으로 샌드박스 안에서 실행된다. 인덱스 컬럼은 사용자 정의 함수를 포함하는 임의 표현식일 수 있고, 부분 인덱스 조건도 사용자 코드를 호출할 수 있다. 이 코드를 호출 측 슈퍼유저(pg_dump가 인덱스를 복원하는 경우 등)로 실행하면 권한 상승 취약점이 된다. 관행은 보안 제한 작업(security-restricted operation) 아래에서 테이블 소유자 ID로 전환하고, 빌드 기간 동안 search_path를 잠그는 것이다. PostgreSQL은 index_buildvalidate_index 모두에서 SetUserIdAndSecContext(..., SECURITY_RESTRICTED_OPERATION)RestrictSearchPath()를 호출하고 이후 원래 컨텍스트를 복원한다.

  8. 진행 상황은 관찰 가능하다. 피드백 없이 몇 분씩 실행되는 빌드는 운영 관점에서 불친절하다. 엔진은 빌드 진행 상황(단계, 스캔한 블록 수, 처리한 튜플 수)을 노출한다. PostgreSQL은 모든 단계에 pgstat_progress_update_* 호출을 배치하고(PROGRESS_CREATEIDX_PHASE_BUILD, _VALIDATE_IDXSCAN, _WAIT_1/2/3), 이를 pg_stat_progress_create_index로 노출한다.

flowchart TD
  subgraph cmd["명령 계층 — indexcmds.c"]
    DI["DefineIndex<br/>테이블 잠금, AM + opclass 결정,<br/>ComputeIndexAttrs, 이름 확정"]
  end
  subgraph cat["카탈로그 계층 — index.c"]
    IC["index_create<br/>pg_class / pg_attribute / pg_index 행,<br/>의존성, 제약"]
    IB["index_build<br/>userid + 진행 보고, 디스패치"]
    AM["rd_indam->ambuild<br/>(btbuild, hashbuild, ...)<br/>힙 스캔 + 벌크 적재"]
    IUS["index_update_stats<br/>reltuples / relpages 기록"]
  end
  DI --> IC
  IC -->|"SKIP_BUILD 아닌 경우"| IB
  IB --> AM
  AM --> IUS
  DI -.->|"CONCURRENTLY: 다중 트랜잭션 프로토콜"| CC["index_concurrently_build<br/>+ validate_index + 대기"]

PostgreSQL은 두 계층 분리를 정확히 구현한다. DefineIndex(commands/indexcmds.c)가 명령 드라이버, index_create(catalog/index.c)가 카탈로그 메커니즘, index_build가 AM 다형적 빌드 드라이버다. 나머지 — validate_index, index_concurrently_* 계열, reindex_index — 는 이 세 함수를 중심으로 돌아간다.

DefineIndex는 먼저 잠금과 빌드 모드를 결정한다. 임시 릴레이션은 강제로 비동시적으로 처리된다(다른 백엔드가 볼 수 없으므로 강한 잠금이 무해하고, 비동시적 드롭이 더 가볍다). 잠금 강도는 그 결과로 정해진다.

// DefineIndex — src/backend/commands/indexcmds.c
lockmode = concurrent ? ShareUpdateExclusiveLock : ShareLock;
rel = table_open(tableId, lockmode);

ShareLockRowExclusiveLock(INSERT/UPDATE/DELETE가 잡는 잠금)과 충돌한다. 일반 빌드는 쓰기는 차단하되 읽기는 허용한다. ShareUpdateExclusiveLock은 자기 자신 및 더 강한 잠금과만 충돌하므로, CONCURRENTLY는 DML이 내내 진행되도록 허용한다. 다음으로 DefineIndex접근 방법을 결정하고 루틴 vtable을 가져온다. postgres-index-am.md에서 다루는 IndexAmRoutine이다. 그리고 AM이 구문이 요청하는 기능을 실제로 지원하는지 확인한다.

// DefineIndex — src/backend/commands/indexcmds.c
accessMethodForm = (Form_pg_am) GETSTRUCT(tuple);
accessMethodId = accessMethodForm->oid;
amRoutine = GetIndexAmRoutine(accessMethodForm->amhandler);
// ...
if (stmt->unique && !stmt->iswithoutoverlaps && !amRoutine->amcanunique)
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("access method \"%s\" does not support unique indexes",
accessMethodName)));

이어서 빈 IndexInfo를 구성하고, ComputeIndexAttrs로 연산자 클래스와 콜레이션을 병렬 배열로 결정한다. PRIMARY KEY라면 추가 검사를 수행한다. IndexInfo는 빌드가 소비할 인덱스의 인메모리 기술서다. 키 컬럼, 표현식, 조건, 유일성, 동시 빌드를 구분하는 ii_Concurrent / ii_ReadyForInserts 플래그가 여기 있다. 이 모두가 결정되면 flags 워드를 조립하고 index_create를 호출한다.

// DefineIndex — src/backend/commands/indexcmds.c
flags = constr_flags = 0;
if (stmt->isconstraint)
flags |= INDEX_CREATE_ADD_CONSTRAINT;
if (skip_build || concurrent || partitioned)
flags |= INDEX_CREATE_SKIP_BUILD; // build deferred or never
if (concurrent)
flags |= INDEX_CREATE_CONCURRENT;
// ...
indexRelationId =
index_create(rel, indexRelationName, indexRelationId, parentIndexId,
parentConstraintId, stmt->oldNumber, indexInfo,
indexColNames, accessMethodId, tablespaceId,
collationIds, opclassIds, opclassOptions, coloptions,
NULL, reloptions, flags, constr_flags,
allowSystemTableMods, !check_rights, &createdConstraintId);

플래그 로직의 핵심: INDEX_CREATE_SKIP_BUILDskip_build || concurrent || partitioned일 때 설정된다. 파티션 인덱스는 자체 저장소가 없고, 동시 빌드는 별도 트랜잭션 규율 아래 나중에 빌드한다. 따라서 일반 CREATE INDEX만이 index_create가 인라인으로 빌드하도록 허용한다. 파티션 재귀(자식 인덱스 부착 또는 빌드)는 DefineIndex에서 처리되며 postgres-ddl-execution.md로 미룬다.

index_create는 장부 작업이다. pg_class를 열고, 부모 힙에서 인덱스의 네임스페이스·지속성·공유 여부를 파생하고, 파라미터를 검증하고(키 컬럼 최소 하나; 사용자 인덱스는 allow_system_table_mods 없이 시스템 카탈로그에 생성 불가), 인덱스의 TupleDescriptor를 구성하고, pg_class 행을 삽입하고, InitializeAttributeOids + AppendAttributeTuplespg_attribute를 채우고, UpdateIndexRelation으로 핵심 pg_index 행을 작성하고, 의존성을 기록하고, 선택적으로 제약을 생성하고, SKIP_BUILD가 없으면 index_build를 호출한다.

// index_create — src/backend/catalog/index.c (condensed, build tail)
if ((flags & INDEX_CREATE_SKIP_BUILD) != 0)
{
// caller (or concurrent path) will build later; just init metapage
// ... index_register / ambuildempty handling ...
}
else
{
index_build(heapRelation, indexRelation, indexInfo, false, true);
}

뒤쪽 두 boolisreindex(여기서는 false — 신규 인덱스)와 parallel(true — AM과 플래너가 동의하면 병렬 워커 허용)이다.

index_create 안에서 가장 중요한 카탈로그 쓰기는 UpdateIndexRelation이 생성하는 pg_index 행이다. 이 행이 시스템 나머지에 인덱스를 정의한다. 어떤 힙 컬럼을 담는지(indkey), 어떤 연산자 클래스(indclass)와 콜레이션(indcollation) 아래에서인지, 컬럼별 옵션(indoption, 예: DESC / NULLS FIRST)은 무엇인지, 표현식 컬럼과 부분 인덱스 조건을 위한 직렬화된 pg_node_tree 블롭이 담긴다. 생성 시점에 세 생존 비트도 여기 찍힌다.

// UpdateIndexRelation — src/backend/catalog/index.c (column assembly, condensed)
indkey = buildint2vector(NULL, indexInfo->ii_NumIndexAttrs);
for (i = 0; i < indexInfo->ii_NumIndexAttrs; i++)
indkey->values[i] = indexInfo->ii_IndexAttrNumbers[i];
indcollation = buildoidvector(collationOids, indexInfo->ii_NumIndexKeyAttrs);
indclass = buildoidvector(opclassOids, indexInfo->ii_NumIndexKeyAttrs);
indoption = buildint2vector(coloptions, indexInfo->ii_NumIndexKeyAttrs);
// ... indexprs / indpred serialized via nodeToString(make_ands_explicit(...)) ...
// values[Anum_pg_index_indisvalid] = BoolGetDatum(isvalid); // f for CONCURRENTLY
// values[Anum_pg_index_indisready] = BoolGetDatum(isready); // f for CONCURRENTLY
// values[Anum_pg_index_indislive] = BoolGetDatum(true);

일반 CREATE INDEX에서 index_createisvalid = isready = true를 전달한다. 동시 빌드에서는 둘 다 false를 전달한다. 이것이 아래 프로토콜이 의존하는 not-ready/not-valid 초기 상태다. 인덱스 표현식과 조건은 직렬화된 노드 트리로 저장된다. 부분 인덱스(WHERE)와 표현식 인덱스(ON (lower(name)))가 완전히 이 두 pg_index 컬럼 안에 존재하는 이유가 여기 있다. 플래너가 SQL을 다시 파싱하지 않고도 부분 인덱스 적용 가능성을 재구성할 수 있다(postgres-node-trees.md 참조).

빌드 드라이버: index_build와 ambuild

섹션 제목: “빌드 드라이버: index_build와 ambuild”

index_build는 AM 다형성이 살아있는 곳이다. 진행 보고를 설정하고, 테이블 소유자 ID로 전환해 SECURITY_RESTRICTED_OPERATION 아래에 들어간 뒤(인덱스 표현식이 사용자 함수를 호출할 수 있으므로), 실제 작업을 하는 호출 한 번을 실행한다.

// index_build — src/backend/catalog/index.c
Assert(PointerIsValid(indexRelation->rd_indam->ambuild));
// ...
stats = indexRelation->rd_indam->ambuild(heapRelation, indexRelation,
indexInfo);
Assert(PointerIsValid(stats));

그 호출 전에 index_build는 앞서 설명한 샌드박스에 진입한다. 테이블 소유자로 전환하고, 보안 제한 작업을 잠그고, 악의적 인덱스 표현식이 허용되지 않는 함수를 찾지 못하도록 search_path를 제한한다.

// index_build — src/backend/catalog/index.c
GetUserIdAndSecContext(&save_userid, &save_sec_context);
SetUserIdAndSecContext(heapRelation->rd_rel->relowner,
save_sec_context | SECURITY_RESTRICTED_OPERATION);
save_nestlevel = NewGUCNestLevel();
RestrictSearchPath();

ambuild는 AM별 콜백(btbuild, hashbuild, gistbuild, …)이다. B-트리는 table_index_build_scan으로 힙을 스캔하고, 살아있는 모든 (key, tid)를 tuplesort에 넣은 뒤 채워진 리프를 벌크 적재한다. 교과서의 벌크 로더 그 자체다. 반환하는 IndexBuildResult는 힙과 인덱스 튜플 수를 담고 있으며, index_build가 이를 두 pg_class 행에 기록한다.

// index_build — src/backend/catalog/index.c
index_update_stats(heapRelation, true, stats->heap_tuples);
index_update_stats(indexRelation, false, stats->index_tuples);
CommandCounterIncrement();
if (indexInfo->ii_ExclusionOps != NULL)
IndexCheckExclusion(heapRelation, indexRelation, indexInfo);

index_build깨진 HOT 체인 미묘함도 처리한다. 스캔 중에 인덱스 키가 다른 멤버를 가진 HOT 체인을 만났다면, 더 오래된 버전을 볼 수 있는 모든 트랜잭션이 사라지기 전까지 인덱스를 신뢰할 수 없다. 그래서 pg_index.indcheckxmin을 설정한다. 이 처리는 비동시·비리인덱스 빌드에서만 수행된다. 소스의 주석이 이유를 명시한다. 동시 빌드 경로는 필요 없다(해당 트랜잭션들이 사라지기 전에는 indisvalid를 설정하지 않으므로). 리인덱스는 하면 안 된다(체인이 인덱스보다 먼저 존재하고, pg_index 자체를 리인덱싱하는 도중에 튜플을 건드리면 치명적이다).

// index_build — src/backend/catalog/index.c
if (indexInfo->ii_BrokenHotChain &&
!isreindex &&
!indexInfo->ii_Concurrent)
{
// mark indcheckxmin = true on the pg_index row
}

이 섹션은 세 생존 비트 — indislive, indisready, indisvalid — 가 네 연산에서 어떻게 전환되는지 추적한다. 일반 빌드, 동시 빌드, 검증, 리인덱스가 그것이다. 비트는 오직 index_set_state_flags만이 전환하며, 이 함수의 상태 기계가 이후 모든 것의 뼈대다.

index_set_state_flags — 생존 상태 기계

섹션 제목: “index_set_state_flags — 생존 상태 기계”

빌드 단계 간 모든 전환은 index_set_state_flagspg_index의 단일 행을 갱신하는 것이다. 액션과 검증된 사전 조건이 전체 생명 주기를 인코딩한다.

// index_set_state_flags — src/backend/catalog/index.c
switch (action)
{
case INDEX_CREATE_SET_READY: /* CREATE INDEX CONCURRENTLY phase 2->3 */
Assert(indexForm->indislive);
Assert(!indexForm->indisready);
Assert(!indexForm->indisvalid);
indexForm->indisready = true;
break;
case INDEX_CREATE_SET_VALID: /* CREATE INDEX CONCURRENTLY final */
Assert(indexForm->indislive);
Assert(indexForm->indisready);
Assert(!indexForm->indisvalid);
indexForm->indisvalid = true;
break;
case INDEX_DROP_CLEAR_VALID: /* DROP INDEX CONCURRENTLY */
indexForm->indisvalid = false;
indexForm->indisclustered = false;
indexForm->indisreplident = false;
break;
case INDEX_DROP_SET_DEAD: /* DROP INDEX CONCURRENTLY, final */
Assert(!indexForm->indisvalid);
indexForm->indisready = false;
indexForm->indislive = false;
break;
}

신규 CREATE INDEX CONCURRENTLY(live=t, ready=f, valid=f) → SET_READY → (live, ready, valid=f) → SET_VALID → (live, ready, valid) 경로를 밟는다. 일반 CREATE INDEX는 행을 처음부터 (live, ready, valid)=t로 삽입하며 이 함수를 호출하지 않는다. 비대칭이 핵심이다. indisready를 먼저 설정하고 indisvalid는 대기 후에 설정하는 것이 온라인 프로토콜의 심장이다.

flowchart LR
  A["행 삽입<br/>not-ready, not-valid"] -->|"WaitForLockers + build"| B["index_concurrently_build<br/>SET_READY"]
  B -->|"commit; WaitForLockers"| C["참조 스냅샷 +<br/>validate_index"]
  C -->|"스냅샷 해제; WaitForOlderSnapshots"| D["SET_VALID<br/>쿼리 사용 가능"]
  A2["일반 CREATE INDEX<br/>행 삽입 시 ready+valid"] --> Z["인라인 빌드<br/>(ShareLock 유지)"]

비동시 경로는 짧다. index_create가 인라인으로 빌드하고, DefineIndex로 돌아와서는 릴레이션을 닫고 진행 보고를 끝내기만 하면 된다.

// DefineIndex — src/backend/commands/indexcmds.c (non-concurrent tail)
if (!concurrent)
{
/* Close the heap and we're done, in the non-concurrent case */
table_close(rel, NoLock);
if (!OidIsValid(parentIndexId))
pgstat_progress_end_command();
else
pgstat_progress_incr_param(PROGRESS_CREATEIDX_PARTITIONS_DONE, 1);
return address;
}

table_open에서 잡은 ShareLock은 주변 트랜잭션이 커밋할 때까지 유지된다. 스캔·정렬·쓰기 전 과정 동안 쓰기 트랜잭션이 차단된다. 이것이 단순성의 대가이며, CONCURRENTLY가 존재하는 이유다.

CREATE INDEX CONCURRENTLY — 다중 트랜잭션 프로토콜

섹션 제목: “CREATE INDEX CONCURRENTLY — 다중 트랜잭션 프로토콜”

if (!concurrent) 얼리 리턴 이후가 동시 프로토콜이다. 이 프로토콜은 하나의 명령 안에서 여러 트랜잭션에 걸쳐 진행한다. DefineIndex가 커밋을 넘어서 메모리에 거의 아무것도 유지하지 않는 이유가 여기 있다. 릴레이션 OID와 잠금 태그만 살아남는다. 첫 번째 이동은 세션 수준 잠금(단계별 커밋을 넘겨 살아남는)으로 승격한 뒤, not-ready/not-valid 인덱스를 가시화하는 카탈로그 행을 만든 트랜잭션을 커밋하는 것이다.

// DefineIndex — src/backend/commands/indexcmds.c (enter concurrent path)
heaprelid = rel->rd_lockInfo.lockRelId;
SET_LOCKTAG_RELATION(heaplocktag, heaprelid.dbId, heaprelid.relId);
table_close(rel, NoLock);
LockRelationIdForSession(&heaprelid, ShareUpdateExclusiveLock);
PopActiveSnapshot();
CommitTransactionCommand();
StartTransactionCommand();
/* Tell concurrent index builds to ignore us, if index qualifies */
if (safe_index)
set_indexsafe_procflags();

이 블록 위의 주석이 불변 조건을 정확히 기술한다. 빌드 전에 카탈로그 항목을 가시화하면 “다른 트랜잭션이 호환되지 않는 HOT 갱신을 하지 못하게 막는다. 새 인덱스는 not indisready, not indisvalid로 표시되어 누구도 삽입하거나 쿼리에 사용하려 하지 않는다.” 다음은 2단계다. 새 인덱스가 자신의 relcache 목록에 없는 상태로 테이블을 열었을 수 있는 모든 트랜잭션을 대기하고, 신선한 스냅샷으로 빌드를 수행한다.

// DefineIndex — src/backend/commands/indexcmds.c (phase 2)
WaitForLockers(heaplocktag, ShareLock, true);
/* Set ActiveSnapshot since functions in the indexes may need it */
PushActiveSnapshot(GetTransactionSnapshot());
/* Perform concurrent build of index */
index_concurrently_build(tableId, indexRelationId);
PopActiveSnapshot();
CommitTransactionCommand();
StartTransactionCommand();

WaitForLockers는 ProcArray를 폴링하는 대신 실제 잠금 획득을 사용한다. 힙 잠금 태그에 ShareLock을 요청하고 승인될 때까지 블록한다. 소스는 이것이 의도적이라고 설명한다. 우리가 기다리는 트랜잭션 중 하나가 우리 테이블을 잠그려고 자신도 블록되어 있다면 교착 탐지기가 발동할 수 있다. index_concurrently_buildIndexInfo를 재구성하고(커밋에서 소실됐으므로), index_build를 실행하고, 마지막으로 인덱스를 ready 상태로 승격한다.

// index_concurrently_build — src/backend/catalog/index.c
indexInfo = BuildIndexInfo(indexRelation);
Assert(!indexInfo->ii_ReadyForInserts);
indexInfo->ii_Concurrent = true;
indexInfo->ii_BrokenHotChain = false;
index_build(heapRel, indexRelation, indexInfo, false, true);
// ... close rels, keep locks ...
index_set_state_flags(indexRelationId, INDEX_CREATE_SET_READY);

이 커밋 이후 indisready가 전역으로 가시화된다. 새 트랜잭션이 인덱스에 삽입하기 시작한다. 그러나 인덱스에는 빌드 스냅샷 바로 직전에 삭제된 행이나 직후에 삽입된 행이 아직 없다. 3단계가 이 간격을 메운다. 다시 대기하고, 참조 스냅샷을 잡고, 검증을 수행한다.

// DefineIndex — src/backend/commands/indexcmds.c (phase 3)
WaitForLockers(heaplocktag, ShareLock, true);
snapshot = RegisterSnapshot(GetTransactionSnapshot());
PushActiveSnapshot(snapshot);
/* Scan the index and the heap, insert any missing index entries. */
validate_index(tableId, indexRelationId, snapshot);
limitXmin = snapshot->xmin;
PopActiveSnapshot();
UnregisterSnapshot(snapshot);

소스는 여기의 핵심 위험을 명시한다. “우리 참조 스냅샷이 커밋으로 보는 트랜잭션을 진행 중으로 보는 스냅샷이 아직 존재할 수 있다.” 그런 트랜잭션이 행을 삭제했다면, 빌드는 그 행을 인덱싱하지 않는다. 그러나 더 오래된 스냅샷은 그 행이 여전히 기대한다. 그래서 참조 스냅샷을 해제하고(다른 동시 빌드가 대기할 때 교착을 막기 위해), 커밋하고, 최종 트랜잭션을 시작하고, limitXmin보다 오래된 스냅샷을 가진 모든 트랜잭션을 기다린다.

// DefineIndex — src/backend/commands/indexcmds.c (final wait + set valid)
CommitTransactionCommand();
StartTransactionCommand();
if (safe_index)
set_indexsafe_procflags();
Assert(MyProc->xmin == InvalidTransactionId); /* advertising no xmin */
WaitForOlderSnapshots(limitXmin, true);
PushActiveSnapshot(GetTransactionSnapshot());
index_set_state_flags(indexRelationId, INDEX_CREATE_SET_VALID);
PopActiveSnapshot();
CacheInvalidateRelcacheByRelid(heaprelid.relId);
UnlockRelationIdForSession(&heaprelid, ShareUpdateExclusiveLock);

WaitForOlderSnapshotsCONCURRENTLY가 수 시간 멈출 수 있는 단계다. GetCurrentVirtualXIDs(limitXmin, ...)를 호출해 참조 스냅샷보다 먼저 xmin을 가진 모든 백엔드를 열거하고, 각각에 VirtualXactLock을 기다린다. 필터에 주목할 필요가 있다. autovacuum, 이미 vacuum 중인 프로세스, 다른 안전한 동시 빌드(set_indexsafe_procflags가 설정하는 PROC_IN_SAFE_IC 플래그)는 제외한다. 두 CONCURRENTLY 명령이 서로 교착하지 않는 이유다.

// WaitForOlderSnapshots — src/backend/commands/indexcmds.c
old_snapshots = GetCurrentVirtualXIDs(limitXmin, true, false,
PROC_IS_AUTOVACUUM | PROC_IN_VACUUM
| PROC_IN_SAFE_IC,
&n_old_snapshots);
// ... for each still-present old snapshot: VirtualXactLock(...) ...

마지막 CacheInvalidateRelcacheByRelid부모 테이블에 대한 것으로, 캐시된 플랜을 재계획하도록 기존 세션을 강제해 새 인덱스를 인식하게 한다.

validate_index는 조정 엔진이다. 참조 스냅샷에 가시적인 모든 힙 튜플 중 인덱스에 아직 없는 것을 찾아 삽입해야 한다. 이미 빌드가 채운 압도적 다수를 재삽입하지 않으면서. 함수 위 긴 주석이 설명하는 영리한 방법은 인덱스 스캔을 하지 않는 것이다. ambulkdelete 콜백을 사용해 TID를 수집하고(실제로 삭제는 안 함), 정렬한 뒤, 정렬된 TID 목록을 힙 스캔과 머지 조인한다.

// validate_index — src/backend/catalog/index.c
state.tuplesort = tuplesort_begin_datum(INT8OID, Int8LessOperator,
InvalidOid, false,
maintenance_work_mem,
NULL, TUPLESORT_NONE);
state.htups = state.itups = state.tups_inserted = 0;
/* gather every TID currently in the index via a no-op bulkdelete */
(void) index_bulk_delete(&ivinfo, NULL, validate_index_callback, &state);
tuplesort_performsort(state.tuplesort);
/* "merge join" the sorted index TIDs against a heap scan */
table_index_validate_scan(heapRelation, indexRelation, indexInfo,
snapshot, &state);

콜백은 각 아이템 포인터를 int8로 인코딩해(값 전달 정렬이 원시 ItemPointer 정렬보다 훨씬 빠르다) 개수를 센다.

// validate_index_callback — src/backend/catalog/index.c
static bool
validate_index_callback(ItemPointer itemptr, void *opaque)
{
ValidateIndexState *state = (ValidateIndexState *) opaque;
int64 encoded = itemptr_encode(itemptr);
tuplesort_putdatum(state->tuplesort, Int64GetDatum(encoded), false);
state->itups += 1;
return false; /* never delete */
}

table_index_validate_scan(테이블 AM 훅)은 힙을 물리적 순서로 걸으며, 가시적 튜플 중 정렬된 인덱스 TID 목록에 없는 것마다 index_insert를 호출한다. 유니크 인덱스를 이 방식으로 빌드하는 것은 미묘하다. 주석은 AM이 “유일성 오류를 선언하기 전에 삽입 대상 튜플의 생존 여부를 재확인해야 한다”고 명시한다. 같은 논리 행의 진행 중인 갱신과 경쟁할 수 있기 때문이다.

reindex_index는 인덱스 하나를 제자리에서 재빌드한다. 인덱스에 AccessExclusiveLock, 힙에 ShareLock을 잡고, 술어 잠금을 힙으로 이전하고, 신선한 IndexInfo를 구성하고, 새 relfilenumber를 할당해(커밋 시 이전 물리 파일이 폐기되도록) isreindex = true로 공유 index_build를 호출한다.

// reindex_index — src/backend/catalog/index.c
TransferPredicateLocksToHeapRelation(iRel);
indexInfo = BuildIndexInfo(iRel);
// ... optionally clear ii_Unique / ii_ExclusionOps if skip_constraint_checks ...
SetReindexProcessing(heapId, indexId); /* hide target from planner */
RelationSetNewRelfilenumber(iRel, persistence); /* new storage */
index_build(heapRelation, iRel, indexInfo, true, true);
ResetReindexProcessing();

SetReindexProcessing은 인덱스 OID를 백엔드 로컬 상태에 등록해, 빌드 자체가 재빌드 중인 바로 그 인덱스를 사용하려 하지 않도록 막는다(시스템 카탈로그 자체 인덱스를 리인덱싱할 때 중요하다). 빌드 이후 reindex_index는 기회주의적으로 이전에 invalid/not-ready/dead 상태였던 인덱스도 복구한다. 실패한 CREATE INDEX CONCURRENTLYDROP INDEX CONCURRENTLY의 잔재가 여기에 해당한다. 이것이 막힌 CONCURRENTLY의 문서화된 복구 방법으로 “REINDEX”가 권장되는 이유다.

REINDEX CONCURRENTLY(ReindexRelationConcurrently 경로)는 온라인 대안이다. 제자리 재빌드 대신, index_concurrently_create_copy로 섀도 인덱스를 만들고, CREATE INDEX CONCURRENTLY와 동일하게 빌드·검증하고, index_concurrently_swap으로 이름·의존성·제약을 새 인덱스로 원자적으로 교체하며 이전 인덱스를 invalid로 표시한다. 이 모든 과정이 ShareUpdateExclusiveLock 아래 수행되므로 DML은 차단되지 않는다. 스왑의 세부 메커니즘은 postgres-ddl-execution.md의 영역이다. 여기서의 요점은 동일한 빌드/검증/대기 기본 요소들을 재사용한다는 것이다.

작동 추적 — 세 명령, 하나의 기계

섹션 제목: “작동 추적 — 세 명령, 하나의 기계”

세 명령을 처음부터 끝까지 밟아 동일한 기본 요소를 어떻게 재사용하는지 살펴본다.

CREATE INDEX idx ON t (a); — 트랜잭션 하나. DefineIndextShareLock을 잡고, btreeint4_ops 연산자 클래스를 결정하고, IndexInfo를 계산하고, flags = 0으로 index_create를 호출한다(SKIP_BUILD 없음). index_create는 이미 valid+readypg_index 행을 쓴 뒤, index_build(t, idx, info, isreindex=false, parallel=true)를 호출한다. 이는 btbuild로 디스패치된다. 힙 스캔, tuplesort, 리프 채움 순으로 진행하고, index_update_stats가 튜플 수를 기록한다. 트랜잭션이 커밋되면 인덱스를 사용할 수 있다. 쓰기 트랜잭션은 내내 차단됐다.

CREATE INDEX CONCURRENTLY idx ON t (a); — 트랜잭션 . T1: ShareUpdateExclusiveLock을 잡고, SKIP_BUILD | CONCURRENTindex_createnot-ready/not-valid 행을 쓰고, 세션 잠금으로 승격하고, 커밋한다. T2: WaitForLockers, index_concurrently_build(스냅샷에 대한 힙 스캔 + btbuild, 그리고 SET_READY), 커밋한다. T3: WaitForLockers, 참조 스냅샷 등록, validate_index(인덱스 TID 수집, 정렬, 힙과 머지 조인, 간격 삽입), 커밋한다. T4: WaitForOlderSnapshots(limitXmin), SET_VALID, CacheInvalidateRelcacheByRelid, 세션 잠금 해제. DML은 내내 진행됐다. 비용은 두 번의 힙 스캔과 최대 세 번의 대기였다.

REINDEX INDEX idx;reindex_indexidxAccessExclusiveLock을 잡고, 카탈로그에서 BuildIndexInfo를 하고, RelationSetNewRelfilenumber(새 저장소, 커밋 시 이전 파일 폐기)를 한 뒤, 동일한 index_build(heap, idx, info, isreindex=true, parallel=true)btbuild를 실행한다. 호출자가 이전 정의를 신뢰하면 제약 재검사를 건너뛰고, isreindex가 true이므로 깨진 HOT 체인 처리도 건너뛴다. 트랜잭션 하나이고 인덱스가 배타적으로 잠기지만, 재빌드가 섀도 스왑이 아닌 제자리에서 이루어진다.

공통점: btbuild(ambuild 콜백)가 세 경우 모두 실행된다. 잠금과 트랜잭션 구조만 다를 뿐이다. 빌드가 AM 다형적이고 프로토콜이 그 위에 전적으로 있다는 설계의 성과다.

세 흐름을 나란히 읽는 유용한 방법은 커밋 경계를 넘어 무엇이 살아남는가를 보는 것이다. 일반 빌드는 명령 도중 커밋하지 않으므로 모든 상태 — IndexInfo, 열린 릴레이션들, tuplesort — 가 하나의 메모리 컨텍스트에 살고 트랜잭션 종료 시 사라진다. 동시 빌드는 DefineIndex 안에서 세 번 커밋하므로(T4는 유틸리티 프레임워크가 커밋) 경계를 넘어 거의 아무것도 살아남지 않는다. OID와 세션 잠금만 유지된다. 그래서 index_concurrently_buildvalidate_indexIndexInfo를 인수로 받지 않고 카탈로그에서 직접 BuildIndexInfo한다. validate_index의 주석은 호출자 측 사본이 “이전 트랜잭션에서 빌드됐기 때문에 이미 사라졌다”고 명시한다. REINDEX는 일반 빌드처럼 단일 트랜잭션이지만, 새 relfilenumber를 먼저 교체함으로써 충돌 안전성을 공짜로 얻는다. 중단되면 이전 저장소가 여전히 참조되고 새 파일은 정리 대상으로 남는다. 잠금 방식 셋, 트랜잭션 형태 셋, 빌더 하나.

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

섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”
심볼파일
DefineIndexsrc/backend/commands/indexcmds.c542
lockmode = concurrent ? ShareUpdateExclusiveLock : ShareLocksrc/backend/commands/indexcmds.c681
GetIndexAmRoutine (AM vtable 조회)src/backend/commands/indexcmds.c864
ComputeIndexAttrs (호출 지점)src/backend/commands/indexcmds.c938
index_create (DefineIndex 호출 지점)src/backend/commands/indexcmds.c1247
LockRelationIdForSession (동시 진입)src/backend/commands/indexcmds.c1644
WaitForLockers (2단계)src/backend/commands/indexcmds.c1687
index_concurrently_build (호출 지점)src/backend/commands/indexcmds.c1711
WaitForLockers (3단계)src/backend/commands/indexcmds.c1734
validate_index (호출 지점)src/backend/commands/indexcmds.c1757
WaitForOlderSnapshots (호출 지점)src/backend/commands/indexcmds.c1797
index_set_state_flags(SET_VALID) (호출 지점)src/backend/commands/indexcmds.c1808
WaitForOlderSnapshots (정의)src/backend/commands/indexcmds.c435
set_indexsafe_procflagssrc/backend/commands/indexcmds.c4615
index_createsrc/backend/catalog/index.c726
index_concurrently_buildsrc/backend/catalog/index.c1485
index_concurrently_swapsrc/backend/catalog/index.c1552
index_buildsrc/backend/catalog/index.c3002
ambuild 디스패치 (rd_indam->ambuild)src/backend/catalog/index.c3078
index_update_stats (호출 지점들)src/backend/catalog/index.c3155
validate_indexsrc/backend/catalog/index.c3350
validate_index_callbacksrc/backend/catalog/index.c3483
index_set_state_flagssrc/backend/catalog/index.c3503
reindex_indexsrc/backend/catalog/index.c3608
ReindexRelationConcurrentlysrc/backend/commands/indexcmds.c3570

소스 트리 /data/hgryoo/references/postgres, REL_18_STABLE, 커밋 273fe94852b3a7e34fd171e8abdf1481beb302fa를 기준으로 검증했다.

  • REL_18에서 심볼 존재 확인. DefineIndex, WaitForOlderSnapshots, set_indexsafe_procflagscommands/indexcmds.c에 있고, index_create, index_build, index_concurrently_build, index_concurrently_swap, validate_index, validate_index_callback, index_set_state_flags, reindex_indexcatalog/index.c에 있음을 함수 정의 grep으로 확인했다. 줄 번호는 위 표에 있다.
  • 잠금 강도 리터럴 검증. lockmode = concurrent ? ShareUpdateExclusiveLock : ShareLock이 인용된 줄에 그대로 있다. reindex_index는 인덱스를 AccessExclusiveLock으로, 힙을 ShareLock으로 연다.
  • 세 생존 비트 indislive / indisready / indisvalid와 네 개의 IndexStateFlagsAction 케이스(INDEX_CREATE_SET_READY, INDEX_CREATE_SET_VALID, INDEX_DROP_CLEAR_VALID, INDEX_DROP_SET_DEAD)가 index_set_state_flags에 그대로 있다.
  • DefineIndex를 처음부터 끝까지 읽어 단계 순서 확인. 카탈로그 커밋 → WaitForLockersindex_concurrently_build(SET_READY) → 커밋 → WaitForLockers → 참조 스냅샷 → validate_index → 스냅샷 해제 → 커밋 → WaitForOlderSnapshotsindex_set_state_flags(SET_VALID) 순서다. PROGRESS_CREATEIDX_PHASE_WAIT_1 / _2 / _3 진행 표시가 정확히 이 대기들을 구분한다.
  • PROC_IN_SAFE_IC 필터 WaitForOlderSnapshots 내부 GetCurrentVirtualXIDs에서 확인했다. set_indexsafe_procflags가 설정하므로 두 안전한 동시 빌드는 서로 무시한다. REL_18에서 유효한 메커니즘이다(제거된 B_DATACHECKSUMSWORKER_* 워커 상태와 무관).
  • index_build 시그니처 index_build(Relation, Relation, IndexInfo *, bool isreindex, bool parallel) 확인. 깨진 HOT 체인 처리 ii_BrokenHotChain && !isreindex && !ii_Concurrent가 인용된 대로 존재한다.
  • 주의 사항 — 파티션 재귀와 제약 생성index_create / DefineIndex 안에서 요약됐고 전체 인용은 아니다. 자세한 내용은 postgres-ddl-execution.md 참조. B-트리 전용 벌크 로더(btbuild_bt_load)와 테이블 AM 스캔(table_index_build_scan)은 이름을 명시했으나 내부는 각각 postgres-nbtree.mdpostgres-table-am.md에 속한다.

PostgreSQL 너머 — 비교 설계와 연구 프론티어

섹션 제목: “PostgreSQL 너머 — 비교 설계와 연구 프론티어”

PostgreSQL의 인덱스 생성은 넓은 설계 공간의 한 모서리에 있다. 대안을 명시하면 그 선택의 비용과 이점이 선명해진다.

다른 엔진의 블로킹/온라인 트레이드오프. 대부분의 프로덕션 엔진은 온라인 빌드를 제공하지만, 잠금과 조정 전략이 다르다. MySQL/InnoDB의 온라인 DDL(5.6 이후)은 동시 DML을 인메모리 행 로그에 기록하고 마지막에 짧은 배타 잠금 아래 재생한다. PostgreSQL의 스냅샷-재검증 방식과 대비되는 로그-재생 설계다. Oracle의 CREATE INDEX ... ONLINE은 트리거가 채우는 저널 테이블을 사용하며 마찬가지로 끝에 재생한다. SQL Server의 WITH (ONLINE = ON)은 빌드 중 버전화된 행 저장소에서 새 인덱스를 유지한다. 공통 형태는 동시 변경을 캡처하고, 대량 빌드하고, 조정한다이다. PostgreSQL은 변경 로그 대신 두 번째 전체 패스(validate_index)와 스냅샷 대기로 조정한다는 점에서 이례적이다. 소스 자체가 이를 “무차별 전략(brute-force strategy)“이라 부르며 새 튜플을 특별 영역에 저장하는 방안을 검토했지만 “더 많은 잠금 문제”를 이유로 기각했다고 밝힌다. 실용적 결과: PostgreSQL의 CONCURRENTLY는 힙 스캔을 두 번 수행하고 오래 실행되는 단일 트랜잭션 앞에서 무한정 멈출 수 있다(WaitForOlderSnapshots가 완료되지 않으므로). 로그-재생 설계는 tail latency가 가장 오래된 스냅샷이 아닌 백로그에 의해 제한된다.

로그 대신 두 번 패스하는 이유. 답은 MVCC에 있다. PostgreSQL은 힙에 이전 행 버전을 보관하므로(별도의 undo/롤백 세그먼트가 없다), 스냅샷이 “그때 어떤 행이 존재했는가”의 일관되고 저렴한 기술이다. 빌드는 스냅샷 S1에 가시적인 모든 것을 인덱싱하고, 새 쓰기 트랜잭션(indisready 이후)이 자신의 인덱스 항목을 직접 유지하고, validate_index가 나중의 참조 스냅샷 S2에 가시적이지만 아직 없는 행들의 간격을 채운다. 변경 로그를 직렬화할 필요가 없다. 힙의 버전 체인 자체가 이미 그 로그이기 때문이다. postgres-vacuum.mdpostgres-mvcc-snapshots.md가 활용하는 동일한 MVCC 속성이다.

벌크 로딩과 쓰기 최적화 구조. PostgreSQL의 ambuild 벌크 로더는 밀집하고 읽기 최적화된 B-트리를 만든다. 쓰기 중심 워크로드의 연구 프론티어는 LSM-트리(O’Neil 외 1996; Database System Concepts §14.8에서 LSM 트리와 버퍼 트리를 쓰기 최적화 인덱스로 다룬다)다. LSM 엔진은 같은 의미의 일회성 CREATE INDEX 벌크 로드를 수행하지 않는다. 정렬된 런을 쌓고 압축하는 방식으로 인덱스 빌드가 지속적인 압축 기계에 상각된다. PostgreSQL에 LSM 코어는 없다. 가장 가까운 쓰기 최적화는 BRIN(거의 공짜로 만들어지는 작고 손실 있는 요약 인덱스)과 GIN의 팬딩 목록이다. 이 비교는 PostgreSQL에서 CREATE INDEX가 별도의 비싼 이벤트인 반면 LSM 저장소에서는 비이벤트인 이유를 설명한다.

동시 B-트리의 기반. CONCURRENTLY 빌드 도중 테이블이 계속 변경될 수 있는 것은 기저 구조가 동시 삽입을 안전하게 지원하기 때문이다. Lehman & Yao(1981) right-link B-link-트리 알고리즘이다. PostgreSQL nbtree가 구현하는 이 알고리즘이 없다면, “우리가 빌드하고 검증하는 동안 쓰기 트랜잭션이 계속 삽입한다”는 전제가 무너진다. 인덱스 생성 프로토콜은 AM이 제공하는 동시성 보장의 이용자이지 제공자가 아니다(postgres-nbtree.md 참조).

실패 의미론이 기능이다. 충돌한 CREATE INDEX CONCURRENTLYindisvalid = false인 인덱스를 남긴다. 플래너는 무시하고 카탈로그에는 존재한다. 이것은 “인덱스는 파생 상태다” 원칙의 의도된 결과다. 최종 플래그 전환 전까지 아무도 그것을 신뢰하지 않으므로 반쯤 완성된 인공물이 무해하고, REINDEX나 DROP INDEX가 뒤처리를 한다. 단일 트랜잭션으로 빌드하는 엔진(일반 CREATE INDEX)은 all-or-nothing 롤백을 공짜로 얻는다. 온라인 프로토콜은 그 원자성을 비블로킹 동작과 교환하고, 대신 유효성 플래그로 안전성을 복원한다.

열린 연구 방향. 최근 연구는 B-트리 노드를 학습 모델로 대체하는 학습 인덱스(Kraska 외 2018)와, 명시적 DDL 이벤트가 아닌 쿼리의 부작용으로 인덱스를 지연 구성하는 증분/적응 인덱싱(database cracking — Idreos 외 2007)을 탐구한다. 둘 다 PostgreSQL의 모델을 뒤집는다. 별도의 스냅샷 경계 빌드 명령 대신 쿼리 스트림에서 인덱스가 점진적으로 실체화된다. 둘 다 PostgreSQL 코어에 없지만, DefineIndex에 내재한 가정을 선명하게 한다. 인덱스 구성이 일회성·운영자 발행·시점 고정 이벤트라는 가정이다.

  • 코드 (소스 오브 트루스): /data/hgryoo/references/postgres, REL_18_STABLE @ 273fe94852b3a7e34fd171e8abdf1481beb302fa.
    • src/backend/commands/indexcmds.cDefineIndex, WaitForOlderSnapshots, ReindexRelationConcurrently, set_indexsafe_procflags, AM/opclass 결정.
    • src/backend/catalog/index.cindex_create, index_build, index_concurrently_build, index_concurrently_swap, validate_index, validate_index_callback, index_set_state_flags, reindex_index.
    • src/include/catalog/index.hIndexStateFlagsAction, INDEX_CREATE_* 플래그.
    • src/backend/access/heap/README.HOT — 깨진 HOT 체인 / indcheckxmin 근거.
  • 이론 앵커: Silberschatz, Korth & Sudarshan, Database System Concepts, 7판 — 14장 “Indexing”(§14.2 Ordered Indices, §14.3–14.4 B+-Tree, bulk-loading 논의, §14.8 쓰기 최적화/LSM 인덱스), §4.6 “Index Definition in SQL”. knowledge/research/dbms-general/database-system-concepts.md에 캡처됨.
  • 동시성 계보: Lehman & Yao 1981, “Efficient Locking for Concurrent Operations on B-Trees”(CONCURRENTLY 도중 동시 쓰기를 가능하게 하는 nbtree 알고리즘). 참고 문헌 목록은 .omc/plans/postgres-paper-bibliography.md.
  • 교차 참조 (형제 문서, 중복 없음): postgres-ddl-execution.md(유틸리티 디스패치, 파티션 재귀, REINDEX CONCURRENTLY 스왑 메커니즘), postgres-nbtree.md(btbuild, _bt_load 벌크 로더, B-link-트리 동시성), postgres-index-am.md (IndexAmRoutine vtable, ambuild/aminsert 콜백), postgres-table-am.md(table_index_build_scan, table_index_validate_scan), postgres-mvcc-snapshots.mdpostgres-procarray.md(스냅샷, GetCurrentVirtualXIDs, VirtualXactLock), postgres-vacuum.md(index_bulk_delete), postgres-lock-manager.md(WaitForLockers, 세션 잠금), postgres-buffer-manager.mdpostgres-smgr-md.md(벌크 로더가 쓰는 물리 페이지), postgres-system-catalogs.md (pg_index / pg_class / pg_attribute 행 구조).