(KO) PostgreSQL 시퀀스 — 릴레이션 기반 생성기, 캐싱, 그리고 WAL
목차:
- 이론적 배경
- DBMS 공통 설계
- PostgreSQL의 접근 방식
- 소스 코드 워크스루
- 소스 검증 (2026-06-05 기준)
- PostgreSQL 너머 — 비교 설계 및 연구 전선
- 출처
이론적 배경
섹션 제목: “이론적 배경”**시퀀스(sequence)**는 관계형 데이터베이스가 겉으로는 단순해 보이는 문제를 풀기 위해 존재한다. 동시에 도착하는 여러 호출자에게 겹치지 않는 숫자 스트림 — 1, 2, 3, … — 을 나눠 주는 것이다. Database System Concepts(Silberschatz 7e)는 시퀀스를 SQL 챕터의 “대리 기본 키 생성에 쓰이는 메커니즘”으로 정의하며, SERIAL/bigserial 유사 타입과 SQL 표준 GENERATED … AS IDENTITY 컬럼의 엔진이 바로 시퀀스라고 설명한다. 시퀀스가 제공하는 계약은 좁지만 엄밀하다.
- 유일성(Uniqueness). 성공한
nextval호출 두 건이 동일한 값을 반환하는 일은 절대 없다. 기본 키 생성에서 애플리케이션이 실제로 의존하는 보장은 이 하나뿐이다. - 단조성(Monotonicity). 값은 증가 방향(기본값은 오름차순)으로 전진한다. 단일 백엔드 세션 내에서 보이는 값은 엄밀하게 증가한다.
- 명시적으로 갭-프리(gap-free)가 아님. 시퀀스는 발급 값이 연속적이라고 보장하지 않는다. 갭은 세 가지 독립적인 원인에서 발생한다. 값을 소비한 트랜잭션이 롤백된 경우, 세션 종료 시 폐기된 캐시 값, 그리고 충돌로 손실된 미리 로그된 값이다. 시퀀스를 갭-프리 행 카운터로 취급하는 것은 가장 흔한 애플리케이션 수준 오용이다. 갭-프리 카운터는 모든 할당자를 직렬화하고 커밋까지 점유를 유지해야 하므로 동시성과 근본적으로 양립할 수 없다. 설계는 이 비용을 의도적으로 거부한다.
모든 시퀀스 구현을 관통하는 긴장은 (2)+(3) 대 성능이다. 올바른 전역 카운터 하나를 잠금으로 보호하면 설계는 단순해지지만 느려진다. 모든 nextval이 잠금 하나를 두고 경합하고, 내구성이 필요하면 디스크 쓰기 한 번을 강제하기 때문이다. 실제 시스템은 초당 수백만 건의 시퀀스 값을 발급하므로, 카운터는 단일 경합 셀이어서는 안 된다. 탈출구는 **배치(batching)**다. 할당자가 단일 잠금 획득과 단일 내구성 이벤트 아래 값의 블록 전체를 예약한 뒤, 그 블록에서 개별 값을 저렴하게 제공한다. 배치의 대가가 정확히 (3)의 갭이다. 예약되었지만 발급되지 않아 손실된 블록은 영구적인 구멍이 된다. 설계는 그 대가를 치르기로 선택하며, SQL 표준도 이 선택을 인정한다.
두 번째 긴장은 트랜잭션성이다. 순진한 독해로는 생성기가 단순한 가변 카운터이므로 갱신도 트랜잭션처럼 처리되어야 한다고 볼 수 있다. 트랜잭션을 롤백하면 번호를 돌려받는 식이다. 그러나 키 생성기에는 정반대 동작이 맞다. nextval이 트랜잭션에 묶인다면, 두 동시 트랜잭션이 각각 nextval을 호출해 “다음은 5”라고 보고, 패배자의 롤백이 5를 재사용 가능 상태로 되돌리는 순간 동시성 하에서 유일성이 깨진다. 따라서 생성기의 현재 위치는 비-트랜잭션적이고 즉시 가시적인 의미론으로 전진해야 한다. 증가는 호출 트랜잭션의 운명과 무관하게 즉시 내구성 있게 모든 세션에 가시화된다. 이것이 시퀀스에 관한 가장 깊은 개념적 포인트다. 시퀀스는 트랜잭션 스토리지 엔진 내부에 살면서도, 핵심 연산에 대해서는 트랜잭션 롤백을 의도적으로 거부하고, 정의 변경(이름 변경, 재파라미터화)에 대해서는 수용한다.
세 번째 축은 내구성 세분성이다. 순수하게 in-memory인 카운터는 충돌 시 위치를 잃고 값을 재발급한다. 이는 재시작에 걸친 유일성에 치명적이다. 증가마다 fsync하는 카운터는 올바르지만 사용하기에 너무 느리다. 해결책은 미래 내구성 예약이다. 현재 위치보다 앞선 위치를 로그에 기록해 두면, 충돌 후 복구가 로그된 앞선 위치부터 재개되므로 유일한 손실은 실제 발급 위치와 로그 위치 사이의 갭뿐이다. 재발급은 절대 발생하지 않는다. 이것이 PostgreSQL이 구현하는 WAL log_cnt 배치이다. in-memory 캐시 배치가 잠금을 배치 처리하듯, log_cnt 배치는 선행 기록 로그(write-ahead log)를 배치 처리한다.
DBMS 공통 설계
섹션 제목: “DBMS 공통 설계”엔진들 전반에 걸쳐 시퀀스/identity 생성을 위한 반복적인 설계 관례가 존재한다. 공통 패턴을 먼저 정리하면 PostgreSQL의 구체적 선택이 알려진 설계 공간 안의 좌표로 읽힌다.
카운터는 어디에 사는가?
섹션 제목: “카운터는 어디에 사는가?”두 가지 큰 계열이 있다.
- 카탈로그/메타데이터-행 카운터. 현재 위치가 시스템 카탈로그(또는 전용 단일 행 테이블)의 열이다. 전진이 카탈로그 갱신이다. 단순하지만, 모든 전진이 카탈로그 버퍼와 카탈로그 캐시 무효화를 두고 경합한다.
- 독자적 스토리지를 가진 전용 시퀀스 객체. 시퀀스가 고유 페이지를 가진 일급 스키마 객체로, 고유 잠금 아래 전진하고 고유 로그 레코드 유형 아래 기록된다. 이것이 고동시성 선택이며, PostgreSQL, Oracle, DB2 모두 이 방식을 택한다.
2단계 캐시
섹션 제목: “2단계 캐시”거의 모든 고속 생성기는 두 가지 배치 수준을 갖는다.
- 내구성 있는/공유 예약 — 단일 잠금 + 단일 로그 이벤트 아래 영속 카운터에서 값 블록을 예약한다. Oracle은 크기를
CACHE n이라 부르고, PostgreSQL도CACHE라 하되 세션별로 적용한다(아래 참조). 이와 별도로SEQ_LOG_VALS만큼 WAL에 미리 로그한다. - 세션-로컬 캐시 — 각 백엔드가 서브-블록을 사적 메모리로 가져와 공유 상태 접근 없이
nextval을 제공한다.
세션-로컬 캐시 덕분에 nextval은 일반적인 경우 사실상 공짜다. 동시에 이것이 세션 간 갭과 세션 전반에 걸친 비순서 값의 직접적인 원인이다. 세션 A가 [1..10]을, 세션 B가 [11..20]을 캐싱하므로, 행이 물리적으로 삽입되는 순서는 운반하는 값과 임의로 뒤섞일 수 있다.
충돌 복구와 “미리 로그”
섹션 제목: “충돌 복구와 “미리 로그””내구성 있는 카운터는 재발급 없이 충돌에서 살아남아야 한다. 공유되는 방법은 발급 위치보다 앞선 위치를 영속 상태로 기록해 재생(replay)이 발급된 모든 것을 지나 재개되게 하는 것이다. 복구 규칙은 “redo 시 카운터를 최소한 로그된 값까지 빠르게 전진”이다. PostgreSQL의 log_cnt가 정확히 이것이다. 로그 레코드 이후 on-disk last_value는 log_cnt만큼 더 가져간 이후의 값이므로, 재생은 로그-앞선 위치를 복원하고, 라이브 시스템은 미발급 꼬리를 단순히 건너뛴다.
트랜잭션적 vs. 비-트랜잭션적 분리
섹션 제목: “트랜잭션적 vs. 비-트랜잭션적 분리”보편적으로 채택된 해결책은 시퀀스 상태를 분리하는 것이다.
- 위치 전진 (
nextval/setval) — 비-트랜잭션적. 즉시 전역 가시화되고 내구성 있으며, 절대 롤백되지 않는다. - 정의 (create/alter/drop, 재파라미터화, reset) — 트랜잭션적. 여느 DDL처럼 MVCC와 롤백을 따른다.
엔진은 두 번째 절반을 시퀀스 스토리지 버전 관리로 구현한다. ALTER/RESTART가 커밋 시에만 가시화되는 새 물리 파일/세그먼트를 쓰므로, 롤백은 기존 스토리지를 그대로 남긴다. 이것이 정확히 PostgreSQL의 RelationSetNewRelfilenumber 재작성 방식이다.
flowchart TB
subgraph Defn["정의 상태 — 트랜잭션적 (MVCC, 롤백 가능)"]
PGS["pg_sequence 카탈로그 행<br/>start, increment, min, max,<br/>cache, cycle, typid"]
REL["시퀀스 릴레이션 신원<br/>relfilenumber (ALTER/RESTART 시 새 파일)"]
end
subgraph Pos["위치 상태 — 비-트랜잭션적 (즉시, 내구성)"]
TUP["block 0의 힙 튜플 하나<br/>last_value, log_cnt, is_called"]
WAL["XLOG_SEQ_LOG 레코드<br/>미래 last_value를 미리 로그"]
end
APP["애플리케이션: nextval / currval / setval"] --> Pos
DDL["CREATE / ALTER / RESTART / DROP"] --> Defn
Pos -. "파라미터를 읽음" .-> PGS
상위의 “identity” 계층
섹션 제목: “상위의 “identity” 계층”SERIAL과 GENERATED AS IDENTITY는 별개의 메커니즘이 아니다. 시퀀스에 OWNED BY 의존성과 컬럼 기본값을 추가한 것이다. 내부 생성기는 동일한 nextval이다. PostgreSQL은 process_owned_by로 소유 링크를 구현하며, 테이블을 삭제하면 시퀀스도 삭제되도록 pg_depend 행을 기록한다.
PostgreSQL의 접근 방식
섹션 제목: “PostgreSQL의 접근 방식”PostgreSQL의 핵심 결정은 시퀀스가 릴레이션이라는 것이다. relkind = RELKIND_SEQUENCE이며, 특권적인 in-memory 객체가 아니다. 시퀀스는 pg_class 행, relfilenode, 버퍼-관리 데이터 파일을 가지며, 힙 테이블과 동일한 버퍼 매니저, WAL, smgr 기계를 거친다. 시퀀스를 시퀀스로 만드는 것은 (a) 데이터 파일이 block 0에 특수 영역 매직 번호를 가진 힙 튜플 정확히 하나를 보유하고, (b) 변형 연산이 일반 실행기 대신 sequence.c를 거친다는 점뿐이다. “모든 것은 릴레이션”이라는 선택 덕분에 시퀀스는 충돌 안전성, 복제, pg_upgrade를 공짜로 상속받는다.
두 구조체, 두 저장소
섹션 제목: “두 구조체, 두 저장소”시퀀스 상태는 트랜잭션적/비-트랜잭션적 분리를 반영해 두 저장소에 나뉜다.
// FormData_pg_sequence — src/include/catalog/pg_sequence.h// 불변 파라미터, 트랜잭션적, pg_sequence 카탈로그에 저장typedef struct FormData_pg_sequence{ Oid seqrelid; /* 소유 시퀀스 릴레이션 OID */ Oid seqtypid; /* smallint | integer | bigint */ int64 seqstart; /* START WITH */ int64 seqincrement; /* INCREMENT BY */ int64 seqmax; /* MAXVALUE */ int64 seqmin; /* MINVALUE */ int64 seqcache; /* CACHE (백엔드별 배치 크기) */ bool seqcycle; /* CYCLE? */} FormData_pg_sequence;// FormData_pg_sequence_data — src/include/commands/sequence.h// 가변 위치, 비-트랜잭션적, block 0의 단일 튜플typedef struct FormData_pg_sequence_data{ int64 last_value; /* on-disk 상태에 마지막으로 전달된 값 */ int64 log_cnt; /* 미리 로그된 남은 인출 횟수 */ bool is_called; /* nextval이 last_value를 지나 전진한 적 있는가? */} FormData_pg_sequence_data;분리는 의도적이다. 파라미터는 정상 운영 중 변하지 않으므로, 버퍼 잠금 없이 해시 조회로 동작하는 SEQRELID syscache에서 가져온다(postgres-catcache-syscache.md 참조). 위치는 모든 캐시 재충전 시 변하므로, 카탈로그 기계와 독립적으로 ex-lock, 변형, WAL-로그할 수 있는 릴레이션 고유 페이지에 저장된다.
백엔드별 SeqTable 캐시
섹션 제목: “백엔드별 SeqTable 캐시”핫-경로 최적화는 프로세스-로컬 해시 테이블 seqhashtab이다. 접촉한 각 시퀀스의 relid를 SeqTableData에 매핑한다.
// SeqTableData — src/backend/commands/sequence.ctypedef struct SeqTableData{ Oid relid; /* 이 시퀀스의 pg_class OID (해시 키) */ RelFileNumber filenumber; /* 마지막으로 확인한 이 시퀀스의 relfilenumber */ LocalTransactionId lxid; /* 마지막 시퀀스 연산을 수행한 xact */ bool last_valid; /* 유효한 "last" 값이 있는가? */ int64 last; /* nextval이 마지막으로 반환한 값 */ int64 cached; /* nextval을 위해 이미 캐시된 마지막 값 */ /* last != cached이면 모든 캐시 값을 소진하지 않은 상태 */ int64 increment; /* 시퀀스 increment 필드의 복사본 */} SeqTableData;불변식 last != cached는 “이 백엔드에 미발급 캐시 값이 남아 있다”를 의미한다. 이 조건이 참이면 nextval은 단순히 last에 increment를 더해 반환한다. 버퍼 없음, 잠금 없음, WAL 없음, 카탈로그 조회 없음. lxid 필드는 트랜잭션별 한 번 잠금을 구현한다. 릴레이션 잠금이 최상위 트랜잭션의 리소스 오너 아래 획득되므로, 한 xact 내에서 nextval을 몇 번 호출해도 잠금은 최대 한 번만 획득된다. 시퀀스의 relfilenumber가 SeqTableData에 캐시된 값과 달라지면, 즉 ALTER/RESTART 커밋의 신호이면, elm->cached = elm->last로 설정해 캐시된-미발급 윈도우를 폐기한다.
flowchart TB
START["nextval_internal(relid)"] --> INIT["init_sequence:<br/>SeqTable 항목 찾기/생성,<br/>xact별 한 번 lock_and_open_sequence"]
INIT --> CACHED{"elm->last != elm->cached ?<br/>(미발급 캐시 값 존재)"}
CACHED -- yes --> FAST["elm->last += increment;<br/>반환 — 버퍼/WAL 없음"]
CACHED -- no --> PARAMS["SearchSysCache SEQRELID:<br/>incby/max/min/cache/cycle 로드"]
PARAMS --> READ["read_seq_tuple:<br/>block 0 ex-lock, last_value/log_cnt 읽기"]
READ --> DECIDE{"log_cnt < fetch OR<br/>page LSN <= redo ptr ?"}
DECIDE -- yes --> FORCE["WAL 강제: fetch = log = cache + SEQ_LOG_VALS;<br/>logit = true"]
DECIDE -- no --> NOLOG["캐시만 사용, 새 WAL 없음"]
FORCE --> LOOP["루프: increment 적용,<br/>max/min 검사, cycle, rescnt 계산"]
NOLOG --> LOOP
LOOP --> CRIT["START_CRIT_SECTION;<br/>MarkBufferDirty"]
CRIT --> WALW{"logit && RelationNeedsWAL ?"}
WALW -- yes --> EMIT["'앞으로 log회 더 인출 가능' 튜플 기록;<br/>XLogInsert XLOG_SEQ_LOG; PageSetLSN"]
WALW -- no --> SKIP["WAL 생략"]
EMIT --> FINAL["최종 in-buffer 튜플 설치:<br/>last_value=last, log_cnt=log"]
SKIP --> FINAL
FINAL --> RET["END_CRIT_SECTION; 잠금 해제; result 반환"]
nextval이 비-트랜잭션적인 이유와 이를 강제하는 보호 장치
섹션 제목: “nextval이 비-트랜잭션적인 이유와 이를 강제하는 보호 장치”nextval_internal은 버퍼 변경을 트랜잭션의 undo 경로에 등록하지 않는다. 증가는 버퍼가 dirty로 표시되고 WAL(있는 경우)이 커밋 시 플러시되는 순간 내구성을 갖는다. 결과적으로 안전하지 않거나 무의미한 컨텍스트는 거부해야 한다. PreventCommandIfParallelMode("nextval()") 호출(병렬 워커는 백엔드-로컬 캐시를 공유할 수 없음)과 비-임시 시퀀스에 대한 PreventCommandIfReadOnly("nextval()") 호출(읽기 전용 xact는 공유 상태를 내구성 있게 전진시킬 수 없음)이 이를 담당한다. 반면 같은 파일의 ResetSequence와 AlterSequence는 트랜잭션적이다. RelationSetNewRelfilenumber를 호출해 완전히 새로운 스토리지 파일을 생성하므로, 중단된 ALTER는 새 파일을 게시하지 않고 기존 위치가 유지된다.
소스 코드 워크스루
섹션 제목: “소스 코드 워크스루”코드는 다섯 클러스터로 명확히 나뉜다. 접근 메서드 심(shim) (access/sequence/sequence.c), 생성/변경 (DefineSequence, AlterSequence, ResetSequence, init_params), nextval 핫 경로 (init_sequence, read_seq_tuple, nextval_internal), 읽기/설정 헬퍼 (currval, lastval, do_setval, pg_* 함수들), WAL/redo (seq_redo, seq_mask, seq_desc). commands/sequence.c 전체를 호출 흐름 순서로 살펴본다.
시퀀스 접근 메서드 — relkind 단언
섹션 제목: “시퀀스 접근 메서드 — relkind 단언”“시퀀스 AM”은 의도적으로 간단하다. 힙이나 nbtree AM과 달리 IndexAmRoutine/TableAmRoutine 벡터가 없다. relation_open/relation_close를 감싸고 relkind를 단언하는 래퍼 두 개뿐이다. 시퀀스는 페이지에서 힙 튜플 형식을 재사용하므로 튜플별 AM이 필요없다.
// sequence_open — src/backend/access/sequence/sequence.cRelationsequence_open(Oid relationId, LOCKMODE lockmode){ Relation r;
r = relation_open(relationId, lockmode); validate_relation_kind(r); /* RELKIND_SEQUENCE가 아니면 ereport */ return r;}validate_relation_kind는 relkind가 RELKIND_SEQUENCE가 아니면 ERRCODE_WRONG_OBJECT_TYPE을 발생시킨다. nextval('not_a_seq')가 거부되는 방식이 이것이다.
시퀀스 생성 — DefineSequence
섹션 제목: “시퀀스 생성 — DefineSequence”DefineSequence는 세 열(last_value, log_cnt, is_called)로 구성된 일반 테이블을 만들고, relkind RELKIND_SEQUENCE로 DefineRelation을 호출해 생성한 뒤, 초기 단일 행 튜플을 기록하고 파라미터 행을 pg_sequence에 삽입한다.
// DefineSequence — src/backend/commands/sequence.cinit_params(pstate, seq->options, seq->for_identity, true, &seqform, &seqdataform, &need_seq_rewrite, &owned_by);/* ... int8/bool 세 열을 가진 CreateStmt 구성 ... */address = DefineRelation(stmt, RELKIND_SEQUENCE, seq->ownerId, NULL, NULL);seqoid = address.objectId;rel = sequence_open(seqoid, AccessExclusiveLock);tuple = heap_form_tuple(tupDesc, value, null);fill_seq_with_data(rel, tuple); /* block 0 초기화 *//* ... 이후 pg_sequence에 파라미터를 CatalogTupleInsert ... */init_params는 WITH 옵션 목록을 seqform/seqdataform으로 변환하는 긴 검증기다. 타입별 기본값(INT8/INT4/INT2 min/max), 교차 검사(seqmin < seqmax, START 범위 내), 그리고 세대-영향 파라미터가 변경될 때 log_cnt를 0으로 재설정하는 것이 핵심이다. 파라미터 변경 후 오래된 pre-log 윈도우가 남지 않도록 하는 장치다.
block 0 초기화 — fill_seq_with_data
섹션 제목: “block 0 초기화 — fill_seq_with_data”fill_seq_with_data는 릴레이션의 첫 페이지를 초기화한다. 버퍼를 확장하고, 특수 영역에 sequence_magic(0x1717)을 설정하고, 튜플의 xmin을 FrozenTransactionId로 강제 설정한다(시퀀스는 VACUUM을 받지 않으므로 튜플이 영구적으로 가시 상태여야 함). 그리고 전체 페이지를 REGBUF_WILL_INIT으로 WAL-로그한다.
// fill_seq_fork_with_data — src/backend/commands/sequence.cPageInit(page, BufferGetPageSize(buf), sizeof(sequence_magic));sm = (sequence_magic *) PageGetSpecialPointer(page);sm->magic = SEQ_MAGIC;/* 시퀀스는 VACUUM을 받지 않으므로 튜플을 영구 가시 상태로 강제 설정 */HeapTupleHeaderSetXmin(tuple->t_data, FrozenTransactionId);HeapTupleHeaderSetXminFrozen(tuple->t_data);/* ... */START_CRIT_SECTION();MarkBufferDirty(buf);offnum = PageAddItem(page, (Item) tuple->t_data, tuple->t_len, InvalidOffsetNumber, false, false);if (RelationNeedsWAL(rel) || forkNum == INIT_FORKNUM){ xl_seq_rec xlrec; XLogBeginInsert(); XLogRegisterBuffer(0, buf, REGBUF_WILL_INIT); xlrec.locator = rel->rd_locator; XLogRegisterData(&xlrec, sizeof(xl_seq_rec)); XLogRegisterData(tuple->t_data, tuple->t_len); recptr = XLogInsert(RM_SEQ_ID, XLOG_SEQ_LOG); PageSetLSN(page, recptr);}END_CRIT_SECTION();래퍼 fill_seq_with_data는 unlogged 시퀀스도 처리한다. INIT_FORKNUM 포크에 동일한 내용을 쓰고 플러시하므로, 충돌 후 unlogged 시퀀스는 찢긴 데이터 대신 init 이미지로 재설정된다.
트랜잭션별 잠금 — init_sequence와 lock_and_open_sequence
섹션 제목: “트랜잭션별 잠금 — init_sequence와 lock_and_open_sequence”모든 진입점은 init_sequence를 거쳐 SeqTable 항목을 찾거나 생성하고 릴레이션을 연다. 릴레이션 잠금은 캐시된 lxid를 확인해 트랜잭션당 최대 한 번만 획득된다.
// lock_and_open_sequence — src/backend/commands/sequence.cLocalTransactionId thislxid = MyProc->vxid.lxid;if (seq->lxid != thislxid){ ResourceOwner currentOwner = CurrentResourceOwner; CurrentResourceOwner = TopTransactionResourceOwner; /* xact 종료까지 잠금 유지 */ LockRelationOid(seq->relid, RowExclusiveLock); CurrentResourceOwner = currentOwner; seq->lxid = thislxid; /* 잠금 보유 기록 */}return sequence_open(seq->relid, NoLock);init_sequence는 트랜잭션적 교체를 감지한다. 릴레이션의 relfilenode가 캐시된 filenumber와 다르면, ALTER/RESTART 커밋이 발생한 것이므로 캐시된-미발급 윈도우를 elm->cached = elm->last로 폐기한다(last/currval 상태는 유지).
// init_sequence — src/backend/commands/sequence.cseqrel = lock_and_open_sequence(elm);if (seqrel->rd_rel->relfilenode != elm->filenumber){ elm->filenumber = seqrel->rd_rel->relfilenode; elm->cached = elm->last; /* 미리 인출된 캐시 윈도우 폐기 */}단일 튜플 읽기 — read_seq_tuple
섹션 제목: “단일 튜플 읽기 — read_seq_tuple”read_seq_tuple은 block 0을 ex-lock하고, 매직 번호를 검증하며, 버퍼 안의 포인터를 반환한다. 과거 SELECT FOR UPDATE 버그가 남긴 오래된 xmax도 기회주의적으로 제거하며, 이 수정은 unlogged 힌트로 처리한다.
// read_seq_tuple — src/backend/commands/sequence.c*buf = ReadBuffer(rel, 0);LockBuffer(*buf, BUFFER_LOCK_EXCLUSIVE);page = BufferGetPage(*buf);sm = (sequence_magic *) PageGetSpecialPointer(page);if (sm->magic != SEQ_MAGIC) elog(ERROR, "bad magic number in sequence \"%s\": %08X", ..., sm->magic);lp = PageGetItemId(page, FirstOffsetNumber);seqdatatuple->t_data = (HeapTupleHeader) PageGetItem(page, lp);seqdatatuple->t_len = ItemIdGetLength(lp);/* 과거 SELECT FOR UPDATE 버그가 남긴 non-frozen xmax 정리 */if (HeapTupleHeaderGetRawXmax(seqdatatuple->t_data) != InvalidTransactionId){ HeapTupleHeaderSetXmax(seqdatatuple->t_data, InvalidTransactionId); seqdatatuple->t_data->t_infomask |= HEAP_XMAX_INVALID; MarkBufferDirtyHint(*buf, true);}핫 경로 — nextval_internal
섹션 제목: “핫 경로 — nextval_internal”nextval_internal이 모듈의 핵심이다. 먼저 캐시 fast path:
// nextval_internal — src/backend/commands/sequence.c (fast path)if (elm->last != elm->cached) /* 캐시된 번호가 남아 있음 */{ Assert(elm->last_valid); Assert(elm->increment != 0); elm->last += elm->increment; sequence_close(seqrel, NoLock); last_used_seq = elm; return elm->last; /* 버퍼 없음, WAL 없음, 카탈로그 없음 */}캐시 미스 시 SEQRELID에서 파라미터를 로드하고, 튜플을 읽고, 이번 재충전에 WAL 로그가 필요한지 판단한다. 두 가지 조건이 로그를 강제한다. 로컬 수요가 미리 로그된 예산을 초과하는 경우(log < fetch), 또는 페이지의 마지막 업데이트가 현재 체크포인트 redo 포인터보다 앞선 경우다. 후자가 없으면 체크포인트부터의 redo가 이미 발급된 값을 지나 시퀀스를 전진시키지 못한다.
// nextval_internal — src/backend/commands/sequence.c (로그 결정)log = seq->log_cnt;if (log < fetch || !seq->is_called){ fetch = log = fetch + SEQ_LOG_VALS; /* 32개 추가 확보, 미리 로그 */ logit = true;}else{ XLogRecPtr redoptr = GetRedoRecPtr(); if (PageGetLSN(page) <= redoptr) /* 체크포인트 이후 첫 nextval */ { fetch = log = fetch + SEQ_LOG_VALS; logit = true; }}할당 루프는 MAXVALUE/MINVALUE와 CYCLE을 적용하며 최대 fetch회 increment를 적용한다. 첫 번째 발급 값을 함수 결과로, 마지막 발급 값을 새 캐시 고수위로 기록한다.
// nextval_internal — src/backend/commands/sequence.c (할당 루프, 오름차순 분기)if ((maxv >= 0 && next > maxv - incby) || (maxv < 0 && next + incby > maxv)){ if (rescnt > 0) break; /* 호출자 충족; 중단 */ if (!cycle) ereport(ERROR, (errcode(ERRCODE_SEQUENCE_GENERATOR_LIMIT_EXCEEDED), errmsg("nextval: reached maximum value of sequence \"%s\" (%lld)", ...))); next = minv; /* CYCLE은 MINVALUE로 순환 */}else next += incby;fetch--;if (rescnt < cache) { log--; rescnt++; last = next; if (rescnt == 1) result = next; }마지막으로 WAL/버퍼 프로토콜. 버퍼는 값이 설치되기 전에 dirty로 표시된다(버퍼가 ex-lock되어 있으므로 안전). WAL 레코드는 log회 더 인출한 이후의 튜플을 담고, 이후 in-buffer 튜플이 남은 log_cnt를 가진 실제 상태로 설정된다.
// nextval_internal — src/backend/commands/sequence.c (WAL + 설치)START_CRIT_SECTION();MarkBufferDirty(buf);if (logit && RelationNeedsWAL(seqrel)){ XLogBeginInsert(); XLogRegisterBuffer(0, buf, REGBUF_WILL_INIT); seq->last_value = next; /* 미리 로그된 값 */ seq->is_called = true; seq->log_cnt = 0; xlrec.locator = seqrel->rd_locator; XLogRegisterData(&xlrec, sizeof(xl_seq_rec)); XLogRegisterData(seqdatatuple.t_data, seqdatatuple.t_len); recptr = XLogInsert(RM_SEQ_ID, XLOG_SEQ_LOG); PageSetLSN(page, recptr);}/* 이제 실제 최종 in-buffer 상태, 로그된 값보다 뒤일 수 있음 */seq->last_value = last;seq->is_called = true;seq->log_cnt = log; /* 이만큼 더 인출이 미리 로그됨 */END_CRIT_SECTION();log_cnt 배치의 핵심이 이것이다. WAL 쓰기 한 번 후 on-disk last_value는 SEQ_LOG_VALS만큼 앞서고, 다음 ~32회 재충전은 추가 WAL 레코드 없이 버퍼의 log_cnt를 감소시킨다. 충돌은 미발급 꼬리(갭)를 잃을 뿐, 재발급은 없다. logit인 경우 크리티컬 섹션 전에 GetTopTransactionId()를 호출해 최상위 xact에 실제 XID를 강제 부여하므로, 커밋 시 WAL 플러시와 동기식-복제 대기가 트리거된다.
currval, lastval, setval
섹션 제목: “currval, lastval, setval”currval_oid는 elm->last에서 세션의 마지막 nextval 결과를 직접 반환한다(last_valid가 false이면 오류). lastval은 파일-정적 last_used_seq 포인터를 경유해 이 세션이 접촉한 임의의 시퀀스의 마지막 값을 반환한다(시퀀스가 여전히 존재하는지 재확인함). do_setval은 nextval의 단순한 형제다. 대상을 min/max 범위로 검증하고, 로컬 캐시를 폐기하며(elm->cached = elm->last), log_cnt = 0으로 새 튜플을 항상 WAL-로그한다.
// do_setval — src/backend/commands/sequence.cif ((next < minv) || (next > maxv)) ereport(ERROR, (errcode(ERRCODE_NUMERIC_VALUE_OUT_OF_RANGE), ...));if (iscalled) { elm->last = next; elm->last_valid = true; }elm->cached = elm->last; /* 미래 캐시 번호 폐기 */START_CRIT_SECTION();seq->last_value = next;seq->is_called = iscalled;seq->log_cnt = 0;MarkBufferDirty(buf);/* ... RelationNeedsWAL이면 무조건 XLOG_SEQ_LOG ... */두 인수 및 세 인수 SQL 형식(setval_oid, setval3_oid)은 호출자가 is_called를 지울 수 있는지에서만 다르다. 세 인수 형식은 pg_dump가 시퀀스의 정확한 is_called 상태를 복원할 수 있도록 존재한다.
WAL redo와 스탠바이 마스킹
섹션 제목: “WAL redo와 스탠바이 마스킹”seq_redo는 XLOG_SEQ_LOG를 처리할 때 block 0을 처음부터 재초기화한다. 동일한 레코드 유형이 라이브 업데이트와 핫 스탠바이 동시 페이지 읽기 모두에 쓰이므로, redo는 palloc된 워크스페이스에 새 페이지를 만들고 memcpy로 원자적으로 삽입한다. 찢긴 중간 상태가 절대 가시화되지 않는다.
// seq_redo — src/backend/commands/sequence.cbuffer = XLogInitBufferForRedo(record, 0);page = (Page) BufferGetPage(buffer);localpage = (Page) palloc(BufferGetPageSize(buffer));PageInit(localpage, BufferGetPageSize(buffer), sizeof(sequence_magic));sm = (sequence_magic *) PageGetSpecialPointer(localpage);sm->magic = SEQ_MAGIC;item = (char *) xlrec + sizeof(xl_seq_rec);itemsz = XLogRecGetDataLen(record) - sizeof(xl_seq_rec);PageAddItem(localpage, (Item) item, itemsz, FirstOffsetNumber, false, false);PageSetLSN(localpage, lsn);memcpy(page, localpage, BufferGetPageSize(buffer)); /* 원자적 교체 */MarkBufferDirty(buffer);리소스 매니저는 rmgrlist.h에 PG_RMGR(RM_SEQ_ID, "Sequence", seq_redo, seq_desc, seq_identify, NULL, NULL, seq_mask, NULL)로 등록된다. seq_mask(wal_consistency_checking 사용)는 스탠바이와 프라이머리 페이지를 비교하기 전에 페이지 LSN/체크섬과 미사용 공간을 마스킹한다. seq_desc는 pg_waldump를 위해 relfilelocator를 출력한다.
위치 힌트 (2026-06-05 기준, REL_18 273fe94)
섹션 제목: “위치 힌트 (2026-06-05 기준, REL_18 273fe94)”| 심볼 | 파일 | 줄 |
|---|---|---|
SEQ_LOG_VALS (=32) | src/backend/commands/sequence.c | 58 |
SEQ_MAGIC (=0x1717) | src/backend/commands/sequence.c | 63 |
sequence_magic | src/backend/commands/sequence.c | 65 |
SeqTableData | src/backend/commands/sequence.c | 76 |
seqhashtab (static) | src/backend/commands/sequence.c | 91 |
last_used_seq (static) | src/backend/commands/sequence.c | 97 |
DefineSequence | src/backend/commands/sequence.c | 121 |
ResetSequence | src/backend/commands/sequence.c | 262 |
fill_seq_with_data | src/backend/commands/sequence.c | 338 |
fill_seq_fork_with_data | src/backend/commands/sequence.c | 359 |
AlterSequence | src/backend/commands/sequence.c | 437 |
SequenceChangePersistence | src/backend/commands/sequence.c | 541 |
DeleteSequenceTuple | src/backend/commands/sequence.c | 570 |
nextval_oid | src/backend/commands/sequence.c | 615 |
nextval_internal | src/backend/commands/sequence.c | 623 |
currval_oid | src/backend/commands/sequence.c | 866 |
lastval | src/backend/commands/sequence.c | 897 |
do_setval | src/backend/commands/sequence.c | 945 |
setval_oid | src/backend/commands/sequence.c | 1049 |
lock_and_open_sequence | src/backend/commands/sequence.c | 1085 |
create_seq_hashtable | src/backend/commands/sequence.c | 1113 |
init_sequence | src/backend/commands/sequence.c | 1129 |
read_seq_tuple | src/backend/commands/sequence.c | 1190 |
init_params | src/backend/commands/sequence.c | 1257 |
process_owned_by | src/backend/commands/sequence.c | 1593 |
sequence_options | src/backend/commands/sequence.c | 1707 |
pg_get_sequence_data | src/backend/commands/sequence.c | 1787 |
pg_sequence_last_value | src/backend/commands/sequence.c | 1847 |
seq_redo | src/backend/commands/sequence.c | 1892 |
ResetSequenceCaches | src/backend/commands/sequence.c | 1945 |
seq_mask | src/backend/commands/sequence.c | 1960 |
sequence_open | src/backend/access/sequence/sequence.c | 37 |
sequence_close | src/backend/access/sequence/sequence.c | 58 |
validate_relation_kind | src/backend/access/sequence/sequence.c | 70 |
seq_desc / seq_identify | src/backend/access/rmgrdesc/seqdesc.c | 21 / 34 |
FormData_pg_sequence | src/include/catalog/pg_sequence.h | 23 |
FormData_pg_sequence_data / SEQ_COL_* | src/include/commands/sequence.h | 25 / 38 |
XLOG_SEQ_LOG / xl_seq_rec | src/include/commands/sequence.h | 46 / 48 |
PG_RMGR(RM_SEQ_ID, "Sequence", …) | src/include/access/rmgrlist.h | 43 |
소스 검증 (2026-06-05 기준)
섹션 제목: “소스 검증 (2026-06-05 기준)”/data/hgryoo/references/postgres의 REL_18_STABLE 워크트리(커밋 273fe94)를 기준으로 수행한 검증:
SEQ_LOG_VALS는 32이고SEQ_MAGIC은0x1717이다.sequence.c:58과:63에서 확인. pre-log 배치 크기는 GUC가 아닌 컴파일-타임 상수다.FormData_pg_sequence_data는 정확히 세 필드{last_value, log_cnt, is_called}를 갖는다(commands/sequence.h:25). 릴레이션의 세 열은SEQ_COL_LASTVAL/LOG/CALLED= 1/2/3으로 각각 매핑된다(commands/sequence.h:38).DefineSequence의 열 구성 루프가last_value,log_cnt,is_called순서로 방출함을 확인.- 파라미터는
pg_sequence에 저장된다.seqrelid, seqtypid, seqstart, seqincrement, seqmax, seqmin, seqcache, seqcycle필드(catalog/pg_sequence.h:23).MAKE_SYSCACHE(SEQRELID, pg_sequence_seqrelid_index, 32)로 등록되어 있으며,nextval_internal,do_setval,ResetSequence,sequence_options의SearchSysCache1(SEQRELID, …)호출을 확인. - 캐시 fast path는 잠금/WAL을 전혀 취하지 않는다.
nextval_internal라인 668–676 재독:elm->last != elm->cached이면sequence_close(seqrel, NoLock)후 반환하며,read_seq_tuple도XLogInsert도 없다. 확인. - 체크포인트 강제-로그 조건은
redoptr = GetRedoRecPtr()을 사용해PageGetLSN(page) <= redoptr이다(nextval_internal~721–728). “체크포인트 이후 첫 nextval” 설명 주석 블록 확인. - 로그된 튜플은 미래 상태다. WAL 분기에서
seq->last_value = next(미리 로그된 값)가XLogInsert전에 설정되고, 이후seq->last_value = last; seq->log_cnt = log로 덮어쓰인다. 두 대입이XLogInsert호출 전후에 걸쳐 있음을 확인. redo가 발급된 것보다 앞선 값을 복원하는 메커니즘이다. nextval/setval보호 장치.PreventCommandIfParallelMode와PreventCommandIfReadOnly(후자는!rd_islocaltemp로 조건 처리)가nextval_internal과do_setval모두에 나타난다. 확인.- ALTER/RESET는 relfilenumber 교체로 트랜잭션성을 확보한다.
ResetSequence와AlterSequence(need_seq_rewrite인 경우)가RelationSetNewRelfilenumber를 호출한 후fill_seq_with_data를 호출한다.:309와:503에서 확인. seq_redo재초기화-후-memcpy.XLogInitBufferForRedo,palloc로컬 페이지,PageAddItem,memcpy(page, localpage, …)확인. 주석이 핫-스탠바이 동시 읽기를 이유로 명시한다.- RM 등록.
rmgrlist.h:43이RM_SEQ_ID“Sequence”를seq_redo,seq_desc,seq_identify,seq_mask로 등록한다(decode/undo 훅은 NULL). PG19 전용 rmgr 변경이 참조되지 않음을 확인. - 시퀀스 AM은 relkind 단언뿐이다.
access/sequence/sequence.c는 79줄이다:sequence_open,sequence_close,validate_relation_kind. AM 루틴 벡터가 없음을 확인. - pre-log 크리티컬 섹션 XID 강제.
GetTopTransactionId()가nextval_internal안에서 호출되고,AlterSequence/SequenceChangePersistence의 두 재작성 분기(:497,:559)에서도 각각RelationSetNewRelfilenumber직전에 호출된다. 재작성 경로와 pre-log 경로 모두 실제 최상위-트랜잭션 XID를 강제해 커밋이 WAL 플러시를 구동함을 확인.
PostgreSQL 너머 — 비교 설계 및 연구 전선
섹션 제목: “PostgreSQL 너머 — 비교 설계 및 연구 전선”-
Oracle의
CACHE/NOCACHE와NOORDER/ORDER옵션. Oracle은 시퀀스의 고수위 표시(high-water mark)를SEQ$딕셔너리 테이블에 저장하고 SGA(공유 메모리, 세션별이 아님)에 값 블록을 캐싱한다.ORDER절은 전역 잠금으로 인스턴스 전반의 할당을 직렬화한다. PostgreSQL의CACHE는 백엔드별이어서(Oracle의 공유 캐시보다 세션 간 갭이 더 예리함) 세션 간 교차 인스턴스 왕복 비용을 치르지 않는다.nextval_internal의SEQ_LOG_VALSpre-log 방식과 Oracle의 SGA 캐시 + 딕셔너리 업데이트를 나란히 비교하면 “미리 로그”가 “미리 캐시”에 비해 무엇을 제공하는지 명확해진다. -
분산 시스템 프리미티브로서의 시퀀스 범위 할당. 샤딩 또는 멀티-마스터 시스템에서 단일 단조 카운터는 전역 직렬화 지점이다. 표준 탈출구는 범위 할당이다. 코디네이터가 각 노드에 불연속 블록(
[1000..1999])을 할당하면 노드들이 로컬에서 서빙한다. 이는 PostgreSQL의 백엔드별SeqTable윈도우를 한 단계 위로 올린 것과 구조적으로 동일하다. 백엔드가 “노드”이고, WAL pre-log가 “코디네이터 핸드오프”다. Snowflake 스타일 ID 생성기는 한 발 더 나아가(timestamp, node_id, per-node_counter)를 인코딩해 엄격한 전역 단조성을 포기하는 대신 중앙 조율이 필요없다. PostgreSQL 설계는 보수적인 쪽에 위치한다. 하나의 권위 있는 내구성 카운터를 두 번(캐시 + WAL) 배치 처리해, 단일-노드 단순성과 정확한 충돌-복구 의미론을 위해 분산 확장성을 포기한다. -
실제 비용으로서의 잠금 매니저 경합. A Scalable Lock Manager for Multicores(
dbms-papers/scalable-lock-manager.md)는nextval마다RowExclusiveLock하나라도 다중 코어에서 왜 치명적인지를 보여준다. 잠금 테이블의 파티션 래치가 버퍼보다 먼저 병목이 된다. PostgreSQL은 두 가지 방법으로 이를 회피한다.lxid검사로 릴레이션 잠금을 트랜잭션당 한 번만 획득하고,elm->last != elm->cached인 경우 캐시 fast path가 잠금을 전혀 취하지 않는다. 이 논문의 투기적 잠금-상속(speculative-lock-inheritance) 아이디어는 두 방법 모두 패배하는 드문 워크로드(오토커밋 상태에서 핫 시퀀스에 수백만 건의 단일-값nextval이 각자 별도 트랜잭션으로 들어오는 경우)의 연구 전선이다. -
MVCC-프리 위치 상태. Database Internals(Petrov, ch. 5 “Transaction Processing and Recovery”)는 생성기가 MVCC를 거부해야 하는 이유를 제시한다. 할당을 롤백하면 동시성 하에서 유일성이 깨진다. frozen-xmin 단일-행 튜플(
HeapTupleHeaderSetXminFrozen)은 “이 행은 절대 버전 관리되지 않고, 절대 VACUUM을 받지 않으며, 항상 가시적”이라는 PostgreSQL의 구체적 인코딩이다. 이는 otherwise-uniform MVCC 패브릭에 의도적으로 뚫린 구멍이다. -
GENERATED AS IDENTITY대SERIAL과 SQL 표준. SQL:2003 identity-column 기능은SERIAL이 비공식적으로 하던 일을 표준화했다. PostgreSQL은 동일한nextval기계 위에 두 가지를 모두 구현하며, 차이는 카탈로그 의존성뿐이다.process_owned_by가 identity에는DEPENDENCY_INTERNAL로, serial에는DEPENDENCY_AUTO로pg_depend행을 기록한다. 이는 시퀀스를 독립적으로 삭제할 수 있는지를 결정한다. 생성기 계약(유일성, 단조성, 갭-프리 아님)은 동일하며, 소유권 생명주기만 다르다. 스토리지 메커니즘(릴레이션 기반 카운터)이 두 가지 SQL 표면 문법 아래 재사용되는 깔끔한 예다. -
시퀀스 전진의 논리 복제. 역사적으로 퍼블리셔의
nextval은 논리 구독자에게 복제되지 않아 장애 조치 시 위치가 손실됐다.XLOG_SEQ_LOG레코드는 미리 로그된last_value를 복제하는 데 필요한 모든 것을 담고 있다.seq_desc의 relfilelocator 위에 시퀀스-디코딩 출력-플러그인 경로를 구축하는 것은 활성 영역이다(postgres-logical-decoding.md 참조). pre-log 의미론이 여기서 중요하다. 미리 로그된 값을 재생하는 논리 구독자는 동일한 “미발급 꼬리 건너뜀” 갭 동작을 상속하며, 이는 유일성에는 올바르지만 연속 값을 기대하는 운영자에게는 놀라울 수 있다.
인-트리 소스 파일 (REL_18_STABLE, 커밋 273fe94)
섹션 제목: “인-트리 소스 파일 (REL_18_STABLE, 커밋 273fe94)”src/backend/commands/sequence.c— 전체 모듈:DefineSequence,AlterSequence,ResetSequence,SequenceChangePersistence,init_params,fill_seq_with_data/fill_seq_fork_with_data,SeqTableData캐시,init_sequence/lock_and_open_sequence,read_seq_tuple,nextval_internal,currval_oid/lastval,do_setval/setval_oid,process_owned_by, WALseq_redo/seq_mask. 위치 힌트 테이블이 각 심볼을 고정한다.src/backend/access/sequence/sequence.c— 79줄짜리 “시퀀스 AM”:sequence_open,sequence_close,validate_relation_kind.relation_open위의 relkind 단언이 전부다.src/backend/access/rmgrdesc/seqdesc.c—pg_waldump가XLOG_SEQ_LOG를 디코딩하기 위한seq_desc/seq_identify.src/include/commands/sequence.h—FormData_pg_sequence_data{last_value, log_cnt, is_called},SEQ_COL_*열 번호,XLOG_SEQ_LOG,xl_seq_recWAL 헤더 구조체.src/include/catalog/pg_sequence.h—FormData_pg_sequence(불변 파라미터:seqstart, seqincrement, seqmax, seqmin, seqcache, seqcycle, seqtypid).src/include/access/rmgrlist.h—PG_RMGR(RM_SEQ_ID, "Sequence", seq_redo, seq_desc, seq_identify, NULL, NULL, seq_mask, NULL)등록.
논문 및 교재 챕터
섹션 제목: “논문 및 교재 챕터”- Database System Concepts (Silberschatz, Korth, Sudarshan, 7e), SQL 챕터 — “유일 키 값 생성에 쓰이는 시퀀스”,
GENERATED AS IDENTITY, 대리 키 (knowledge/research/dbms-general/database-system-concepts.md). - Database Internals (Petrov 2019), ch. 5 “Transaction Processing and Recovery” — 생성기가 MVCC를 거부하는 이유, WAL log-ahead 프레이밍 (
knowledge/research/dbms-general/database-internals.md). - A Scalable Lock Manager for Multicores — 잠금 테이블 파티션-래치 경합; 트랜잭션별 한 번 잠금과 lock-free 캐시 fast path의 동기 (
knowledge/research/dbms-papers/scalable-lock-manager.md). - Optimistic Concurrency Control (Kung & Robinson 1981) — “검증 후 전진, 절대 롤백하지 않음”이 공유 카운터에 올바른 규율인 이유에 대한 배경 (
knowledge/research/dbms-papers/occ.md).
형제 문서 (교차 참조 — 메커니즘은 해당 문서 소유, 여기서 중복하지 않음)
섹션 제목: “형제 문서 (교차 참조 — 메커니즘은 해당 문서 소유, 여기서 중복하지 않음)”postgres-heap-am.md— 단일-행 시퀀스 튜플이 재사용하는 힙 튜플 형식과 페이지 아이템 레이아웃. 이 문서는 튜플을 불투명하게 취급한다.postgres-xlog-wal.md—XLogBeginInsert/XLogRegisterBuffer/XLogInsert,REGBUF_WILL_INIT, redo 버퍼 초기화(XLogInitBufferForRedo),RM_SEQ_ID를seq_redo로 라우팅하는 리소스-매니저 디스패치.postgres-catcache-syscache.md—nextval_internal/do_setval/ResetSequence가 버퍼 잠금 없이 시퀀스 파라미터를 로드하는 데 사용하는SEQRELIDsyscache.postgres-smgr-md.md/postgres-buffer-manager.md—ReadBuffer(rel, 0)과 unlogged 시퀀스의 INIT_FORKNUM 처리 아래의 스토리지-매니저 및 버퍼 계층.postgres-ddl-execution.md—DefineSequence/AlterSequence/ResetSequence가 활용하는 DDL 기계인DefineRelation과RelationSetNewRelfilenumber.postgres-architecture-overview.md— 스토리지 엔진 축에서 릴레이션 기반 시퀀스가 퇴화한 단일-행 테이블로 위치하는 맥락.