최근 어느 개발자분이 “PostgreSQL에서 시퀀스를 건너뛰는(jump) 현상은 언제 발생하나요?” 라는 아주 중요한 질문을 했다. 시퀀스를 건너뛰는 현상은 또 다른 말로 시퀀스 갭이라고도 하는데, DB 서버를 운영하다보면 흔히 겪게되는 현상 중 하나이다. 하지만 시퀀스 사이에 갭이 존재한다고 해서 그 모든 갭들이 시퀀스 갭이 되는 것은 아니다. 레코드가 삭제되어도 갭이 발생할 수 있고, 업데이트가 되어도 갭이 발생할 수 있다. 시퀀스 갭은 오롯이 시퀀스 본연의 메커니즘에 의해 발생한 갭만을 의미한다.
시퀀스 갭(Sequence Gap)이란?
사실 시퀀스 갭은 수학적으로 등차수열 조건을 만족하지 않는 연속성을 의미한다. 예를 들어 1, 2, 3, 4, 5와 같은 연속성, 1, 3, 5, 7, 9와 같은 연속성, 1, 4, 7, 10, 13과 같은 연속성들은 모두 시퀀스이자 등차수열 조건을 만족하는 연속성이다. 반면에 1, 4, 8, 15, 20과 같은 연속성, 2, 7, 10, 11, 24와 같은 연속성, 3, 4, 5, 6, 8과 같은 연속성들은 모두 시퀀스이자 등차수열 조건을 만족하지 않는 연속성이다. 이 때 등차수열 조건을 만족하지 않는 연속성이 보일 때 갭이 발생했다고 표현하는 것이고, 비공식적인 용어로 시퀀스 갭(Sequence Gap) 또는 시퀀스 홀(Sequence Hole)이라고 부르는 것이다.
(시퀀스 갭은 현상일 뿐이지 공식적인 용어는 아니다.)
만일 아래와 같은 옵션을 가지고 생성했다면,
CREATE SEQUENCE test_seq START WITH 1 INCREMENT BY 2;
시퀀스는 다음과 같이 크게 3가지의 요소를 가지고 동작하게 된다.
- seqstart = 1 (첫째항)
- seqincrement = 2 (공차)
- next += incby 연산으로 매번 2씩 증가
위 시퀀스는 시작 값이 1이고, 공차가 2이기 때문에 1, 3, 5, 7, 9… 와 같은 등차수열 조건을 만족하는 연속성을 가지게 된다.
시퀀스 메커니즘
PostgreSQL은 내부적으로 아래 3개의 저장소에 시퀀스의 상태 정보를 저장한다.
- 메모리 캐시
- 디스크 저장소
- WAL
PostgreSQL 서버에서 nextval() 함수가 호출되면 일반적인 캐시 메커니즘처럼 가장 먼저 세션당(커넥션당) 캐시에서 시퀀스를 Hit하며, Miss일 경우 디스크 저장소에서 시퀀스를 읽어온다. 메모리 캐시는 CACHE 설정과 관계없이 항상 사용되고, CACHE 1 일 경우에는 캐시가 매번 소진되어서 디스크 접근이 발생하고, CACHE가 2 이상일 경우에는 캐시된 값들을 순차적으로 하나씩 사용한다.
WAL에 로깅된 시퀀스는 DB 서버가 정상적인 상태에서는 사용되지 않지만 크래시 복구를 위해 사용되며, 매번 WAL에 기록하는 대신 SEQ_LOG_VALS 단위(상수값 32로 고정되어 있다)로 미리 로깅해서 Disk I/O를 줄인다. 디스크 저장소의 log_cnt 필드가 미리 로깅된 잔여 개수(32)를 추적하는데, 이 값은 32부터 시작해서 nextval() 함수를 호출할 때마다 하나씩 감소한다. log_cnt > 0 이면 시퀀스 잔여 개수가 남았기 때문에 WAL 로깅을 생략하고 log_cnt가 0이 되거나 신규 세션에서 접근할 때만 WAL에 기록한다.
DB 서버에서 크래시가 발생한 경우에는 WAL 레코드에서 시퀀스를 복구하되, 미리 로깅된 값들 중 사용하지 않은 시퀀스는 건너뛰게 된다. 이것은 클라이언트 연결이 종료되어 CACHE(n>1)가 초기화된 경우에도 동일하다.
디버깅용 코드
이제 위에서 설명한 시퀀스 메커니즘에 따라 시퀀스를 추적하기 위해 PostgreSQL 서버 코드(src/backend/commands/sequence.c)에 디버깅을 위한 코드를 추가한다. 물론 코드를 추가하지 않아도 시퀀스의 상태 정보를 조회할 수 있지만, 기본 상태 정보는 last_value, log_cnt, is_called 3가지 필드만 지원하기 때문에 좀 더 상세한 추적을 위해 추가하는 것이다.
1. 메모리 캐시
if (elm->last != elm->cached) /* some numbers were cached */
{
Assert(elm->last_valid);
Assert(elm->increment != 0);
elm->last += elm->increment;
/********************
added by bumhwaklee
*********************/
elog(NOTICE, "[SEQ DEBUG] CACHED - OID:%u, elm->last:%lld, elm->cached:%lld", elm->relid, (long long)elm->last, (long long)elm->cached);
relation_close(seqrel, NoLock);
last_used_seq = elm;
return elm->last;
}
2. 디스크 저장소
/* lock page' buffer and read tuple */
seq = read_seq_tuple(seqrel, &buf, &seqdatatuple);
page = BufferGetPage(buf);
elm->increment = incby;
last = next = result = seq->last_value;
fetch = cache;
log = seq->log_cnt;
/********************
added by bumhwaklee
*********************/
elog(NOTICE, "[SEQ DEBUG] DISK - OID:%u, disk_last_value:%lld, is_called:%s, log_cnt:%lld", elm->relid, (long long)seq->last_value, seq->is_called ? "true" : "false", (long long)seq->log_cnt);
3. 반환 값
/* save info in local cache */
elm->last = result; /* last returned number */
elm->cached = last; /* last fetched number */
elm->last_valid = true;
/********************
added by bumhwaklee
*********************/
elog(NOTICE, "[SEQ DEBUG] FINAL - OID:%u, returned_value:%lld, elm->last:%lld, elm->cached:%lld", elm->relid, (long long)result, (long long)elm->last, (long long)elm->cached);
4. WAL 로깅
/* set values that will be saved in xlog */
seq->last_value = next;
seq->is_called = true;
seq->log_cnt = 0;
/********************
added by bumhwaklee
*********************/
elog(NOTICE, "[SEQ DEBUG] WAL - OID:%u, wal_last_value:%lld, actual_returned:%lld, fetched_count:%lld", elm->relid, (long long)next, (long long)result, (long long)(cache - fetch));
시퀀스 테스트
아래 표는 서비스 운영 상황에 따라 시퀀스 상태 정보가 어떻게 변경되는지를 추적한 결과 표이다. 이 표에서 중요한 부분은 시퀀스 설정별(CACHE 1과 CACHE 10)로 시점에 따라 반환 값이 다른 부분이다. 이 부분을 눈여겨 보도록 하자.
| Client | Sequence (CACHE 1) | Sequence (CACHE 10) |
|---|---|---|
| ============================ | ======= 클라이언트 DB 연결 ======== | ============================ |
| SELECT nextval(‘seq_cache_1’); | – wal_last_value: 33 – log_cnt: 0 – elm->cached: 1 – returned_value: 1 → 현재 WAL에 33(returned_value + SEQ_LOG_VALS(=32))을 기록하고, 캐시된 값은 즉시 소진된다. | – wal_last_value: 42 – log_cnt: 0 – elm->cached: 10 – returned_value: 1 -> 현재 WAL에 42(returned_value + (cache-1) + SEQ_LOG_VALS)를 기록하고, 메모리 캐시에 9개를 캐싱한다. (1개는 즉시 반환되기 때문에 실제로는 9개만 캐시) |
| 1을 반환 | 1을 반환 | |
| SELECT nextval(‘seq_cache_1’); | – log_cnt: 32 – elm->cached: 2 – returned_value: 2 -> log_cnt 필드로 잔여 개수를 추적하며, 0이 되면 WAL에 기록한다. 시퀀스는 2를 반환한다. | – elm->last: 2 → 메모리 캐시에서 시퀀스 2를 반환한다. |
| 2를 반환 | 2를 반환 | |
| SELECT nextval(‘seq_cache_1’); | – log_cnt: 31 – elm->cached: 3 – returned_value: 3 -> 시퀀스 3을 반환한다. | – elm->last: 3 → 메모리 캐시에서 시퀀스 3을 반환한다. |
| 3을 반환 | 3을 반환 | |
| ============================ | === 클라이언트 DB 연결 종료 후 재연결 === | ============================ |
| SELECT nextval(‘seq_cache_1’); | – log_cnt: 30 – elm->cached: 4 – returned_value: 4 -> 시퀀스 4를 반환한다. | – log_cnt: 32 – elm->cached: 20 – returned_value: 11 → CACHE 10에서 3개까지 시퀀스를 사용했지만 실제 디스크에는 10이 할당되었기 때문에 신규 세션에서는 seq->last_value 기준으로 시작한다. 즉 손실된 값 4~10을 건너뛰게 된다. |
| 4를 반환 | 11을 반환 | |
| SELECT nextval(‘seq_cache_1’); | – log_cnt: 29 – elm->cached: 5 – returned_value: 5 → 시퀀스 5를 반환한다. | – elm->last: 12 → 메모리 캐시에서 시퀀스 12를 반환한다. |
| 5를 반환 | 12를 반환 | |
| ============================ | ===== DB 서버 강제 종료 & 재시작 ===== | ============================ |
| SELECT nextval(‘seq_cache_1’); | – wal_last_value: 66 – log_cnt: 0 – elm->cached: 34 – returned_value: 34 → 현재 WAL은 returned_value(34)와 SEQ_LOG_VALS(32)를 합산하여 66으로 기록되며, 시퀀스는 WAL에서 disk_last_value가 복구되어 34(33+1)를 반환한다. | – wal_last_value: 84 – log_cnt: 0 – elm->cached: 52 – returned_value: 43 → 현재 WAL은 returned_value + (cache-1) + SEQ_LOG_VALS = 43 + 9 + 32 = 84로 기록되며, 시퀀스는 WAL이 복구되어 43(42+1)을 반환한다. DB 서버가 강제 종료되기 전 WAL 로깅에서 wal_last_value가 42로 기록되었기 때문에 43부터 52까지 10개를 캐싱한다. 따라서 elm->cached는 52이다. |
| 34를 반환 | 43을 반환 | |
| SELECT nextval(‘seq_cache_1’); | – log_cnt: 32 – elm->cached: 35 – returned_value: 35 → log_cnt는 상수값 32로 초기화되며, 시퀀스는 35를 반환한다. | elm->last: 44 → 메모리 캐시에서 시퀀스 44를 반환한다. |
| 35를 반환 | 44를 반환 | |
| ============================ | ========= DB 서버 재시작 ========= | ============================ |
| SELECT nextval(‘seq_cache_1’); | – wal_last_value: 68 – log_cnt: 31 – elm->cached: 36 – returned_value: 36 → WAL은 returned_value(36)과 SEQ_LOG_VALS(32)를 합산하여 68로 기록되며, 시퀀스는 36을 반환한다. (log_cnt > 0 이지만 Page LSN 강제 로깅에 의해 WAL 기록) | – wal_last_value: 94 – log_cnt: 0 – elm_cached: 62 – returned_value: 53 → 현재 WAL은 wal_last_value는 53 + (cache-1) + SEQ_LOG_VALS = 53 + 9 + 32 = 94로 기록되며, 시퀀스는 디스크에 저장된 last_value를 기준으로 53(52+1)을 반환한다. 53부터 62까지 10개 값을 새로 캐시하므로 elm->cached는 62이다. |
| 36을 반환 | 53을 반환 | |
| SELECT nextval(‘seq_cache_1’); | – log_cnt: 32 – elm->cached: 37 – returned_value: 37 → log_cnt는 상수값 32로 초기화되며, 37을 반환한다. | elm->last: 54 → 메모리 캐시에서 시퀀스 54를 반환한다. |
| 37을 반환 | 54를 반환 |
테스트 결과
시퀀스의 CACHE를 기본값 1로 설정하여 사용하는 경우 시퀀스 갭은 DB 서버가 불완전 재시작하는 경우 발생한다. CACHE를 2 이상으로 설정하여 사용하는 경우 시퀀스 갭은 클라이언트의 연결이 종료되는 경우, DB 서버가 불완전 재시작하는 경우 발생한다.
시퀀스의 메모리 캐시 활용은 Disk I/O를 줄일 수 있는 이점도 존재하지만, 시퀀스 갭의 크기가 커지는 단점도 존재한다. 때문에 갭을 최소화 하고자 한다면 CACHE를 가능한 작게 설정해야 한다.
트랜잭션과의 연관성
일반적으로 시퀀스는 nextval() 함수를 호출할 때 서비스 테이블과 함께 트랜잭션에 묶여서 사용된다. 그렇기 때문에 트랜잭션 롤백 시에도 시퀀스 갭이 발생할 수 있다.
트랜잭션 롤백으로 인해 시퀀스 갭이 발생하는 경우는 다음과 같다.
psql> CREATE TABLE test(id BIGSERIAL, name VARCHAR(10));
psql> INSERT INTO test(name) VALUES('test');
psql> SELECT * FROM test;
id | name
----+------
1 | test
psql> BEGIN;
psql> INSERT INTO test(name) VALUES('test');
psql> INSERT INTO test(name) VALUES('test');
psql> INSERT INTO test(name) VALUES('test');
psql> INSERT INTO test(name) VALUES('test');
psql> INSERT INTO test(name) VALUES('test');
psql> ROLLBACK --> 롤백 수행
psql> INSERT INTO test(name) VALUES('test');
psql> SELECT * FROM test;
id | name
----+------
1 | test
7 | test --> 6개의 갭이 발생한다.
DB 커넥션이 끊어져서 활성 트랜잭션이 종료되어도 트랜잭션이 롤백되기 때문에 시퀀스 갭이 발생하며, DB 서버가 크래시되지 않아도, DB 커넥션이 끊어지지 않아도 트랜잭션이 롤백되면 시퀀스 갭이 항상 발생한다.
결론
개발자의 질문 내용으로 돌아가서,
일반적으로 서비스에서 사용하는 시퀀스의 CACHE 값은 1이기 때문에 커넥션당 캐시 손실로 인한 시퀀스 갭은 거의 발생하지 않는다. 시퀀스 자체 메커니즘으로 인한 갭 발생보다는 트랜잭션 롤백으로 인한 갭(사용자 테이블에 시퀀스가 생성된 필드) 발생 빈도가 더 높다.
앞선 내용들을 토대로 시퀀스의 CACHE가 기본 값(=1)일 때 운영 시점에 따른 시퀀스 갭 발생 여부와 원인은 다음과 같이 정리할 수 있다.
| 시점 | 시퀀스 갭 발생 여부 | 원인 | 빈도 |
|---|---|---|---|
| Kubernetes Rolling Update | O | 트랜잭션 롤백 | 높음 |
| Auto Scaling으로 파드 증감 | O | 트랜잭션 롤백 | 높음 |
| 시스템 유지보수 및 재시작 | O | 트랜잭션 롤백 | 중간 |
| Aurora Writer/Reader 전환 | O | 트랜잭션 롤백 | 중간 |
| RDS 크래시 | O | – WAL 시퀀스 복구 – 트랜잭션 롤백 | 낮음 |
시퀀스 갭이 발생하면 안되는 명확한 이유가 있다면 트랜잭션의 안정성을 높이거나, PostgreSQL의 시퀀스를 사용하지 않고 갭 리스 시퀀스를 구현하는 방법을 모색해야할 것이다.

댓글 남기기