PostgreSQL Docker image를 사용하여 개발하던 도중 이상현상을 발견했다.Serial
(auto increment integer) 필드 값이 내가 넣은 레코드 수보다 분명히 더 큰 값으로 설정되고 있었다.
처음에는 잘못봤겠거니.. 했는데 반복적으로 일어나는 현상이었다.
원인 파악을 위해 sequence 를 살펴봤다.
문제 상황
1. 비정상적인 종료 시 log_cnt만큼 last_value가 올라간다.

- db가 비정상 종료되었다가 재실행 되면
last_value
가log_cnt
만큼 업데이트 된다는 것을 확인했다.
new_keep=# insert into users(email, nickname, login_type) values('dsaaaaaaaaadsaa', 'nickname', 1);
INSERT 0 1
new_keep=# select * from users_user_id_seq;
last_value | log_cnt | is_called
------------+---------+-----------
170 | 0 | t
(1 row)
new_keep=# insert into users(email, nickname, login_type) values('dsaaaaaaaaaddsaa', 'nickname', 1);
INSERT 0 1
new_keep=# select * from users_user_id_seq;
last_value | log_cnt | is_called
------------+---------+-----------
171 | 32 | t
(1 row)
2. Insert를 실패해도 sequence 값이 올라간다.
insert를 실험하다 또 다른 사실을 발견했다. insert의 성공/실패 유무와 관계없이 sequence 값은 증가하게 된다.

- unique 조건 위배

- not null 조건 위배
unique, not null 제약조건에 위배되어 insert가 실패하는 상황이다.
당연히 insert에 실패하면 sequence도 증가하지 않을것이라고 생각했는데, 예상과 달랐다.
원인
알고 보니 이건 PostgreSQL의 정상 작동 방식이었다.
그리고 발생 원인이 정말 정직하게 PostgreSQL 문서에 적혀있었다.
Sequence는 gapless 할당을 보장할 수 없다.
https://www.postgresql.org/docs/16/sql-createsequence.html
v16 사용중이므로 16 문서를 참고하였다.
Because nextval and setval calls are never rolled back, sequence objects cannot be used if “gapless” assignment of sequence numbers is needed. It is possible to build gapless assignment by using exclusive locking of a table containing a counter; but this solution is much more expensive than sequence objects, especially if many transactions need sequence numbers concurrently.
- “gapless” assignment를 보장해야 할 때는 sequence object를 사용할 수 없다.
- counter를 포함한 테이블을 lock 해서 gapless (순서 사이에 빈칸 없음)을 보장할 수 있지만 굉장히 고비용, 비효율적
즉 nextval()
호출은 절대 롤백되지 않아서 sequence 객체는 연속적인(gapless) 번호 할당을 보장할 수 없다는 것이다.
대안인 테이블 lock이 있긴 하지만 성능이 많이 떨어진다.
Session이 종료되면 Cache를 잃는다.
Unexpected results might be obtained if a *cache* setting greater than one is used for a sequence object that will be used concurrently by multiple sessions. Each session will allocate and cache successive sequence values during one access to the sequence object and increase the sequence object's last_value accordingly. Then, the next *cache*-1 uses of nextval within that session simply return the preallocated values without touching the sequence object. So, any numbers allocated but not used within a session will be lost when that session ends, resulting in “holes” in the sequence.
- 각 세션은 sequence에 접근할 때 여러 값을 미리 할당받아 캐싱한다.
- 세션 종료 시 사용하지 않은 캐시들은 모두 소멸된다.
둘을 종합해서 보면 sequence는 연속성을 보장할 수 없고, 세션 종료 시 캐싱된 sequnce 값이 소멸된다.
그럼 어떤 경우에 Cache를 잃을까?
Graceful Stop에 실패했을 때가 cache를 잃을 때라고 생각하고 가설을 검증하기 위해 테스트를 해봤다.
1. 정상 종료 케이스
- 종료 후 재실행해도
last_value
는 종료 전과 동일하다.
docker compose down

docker compose stop

ctrl+C (SIGINT - graceful stop)

2. 비정상 종료 케이스
last_value
가log_cnt
만큼 증가한다.
docker compose kill


docker kill
커맨드는 SIGKILL을 보내므로 (Forces running containers to stop by sending a SIGKILL signal. - docker 공식 문서) gracefully stopping이 되지 않는다.
ctrl+C를 누르고 종료되고 있는 와중에 ctrl+C를 또 누르는 경우
- 맨 처음 이 현상을 발견하게 된 계기로 성격 급한 한국인의 면모를 보여준다.
- ctrl+C는 graceful stop하지만 한 번 더 누르면 graceful 하지 못한 stop이 된다.

log_cnt는 뭘까?
여기서 주목할 점은 last_value
가 log_cnt
만큼 증가했다는 것이다. 그럼 log_cnt
는 무엇일까?
PostgreSQL에서 sequence 테이블을 조회하면 볼 수 있는 열이다:
new_keep=# select * from users_user_id_seq;
last_value | log_cnt | is_called
------------+---------+-----------
171 | 32 | t
(1 row)
log_cnt는 현재 세션에서 캐시한 시퀀스 값의 수를 나타낸다. 이 값들은 WAL(Write-Ahead Logging)에 로깅된 값들이다.
PostgreSQL 소스 코드를 살펴보면 log_cnt의 정확한 역할을 알 수 있다.
postgres/src/backend/commands/sequence.c
의 int64 nextval_internal(Oid relid, bool check_permissions)
함수 일부이다.
postgres/src/backend/commands/sequence.c at 5db3bf7391d77ae86bc9b5f580141e022803b744 · postgres/postgres
Mirror of the official PostgreSQL GIT repository. Note that this is just a *mirror* - we don't work with pull requests on github. To contribute, please see https://wiki.postgresql.org/wiki/Subm...
github.com
/* Decide whether we should emit a WAL log record. */
if (log < fetch || !seq->is_called)
{
/* forced log to satisfy local demand for values */
fetch = log = fetch + SEQ_LOG_VALS;
logit = true;
}
여기서 log는 시퀀스 튜플의 log_cnt
값이다. (이전에 log = seq->log_cnt;
로 초기화된다.)
필요한 값보다 적을 경우 SEQ_LOG_VALS
(32)만큼 추가로 할당한다.
이후 트랜잭션이 종료될 때 다음과 같이 최종 상태를 업데이트한다.
/* Now update sequence tuple to the intended final state */
seq->last_value = last; /* last fetched number */
seq->is_called = true;
seq->log_cnt = log; /* how much is logged */
이를 통해 log_cnt는 실제로 WAL(Write ahead logging)에 로깅된 시퀀스 값의 수를 추적하며, 이 값들이 캐시되어 있다가 비정상 종료 시 손실된다는 것을 알 수 있다. 소스 코드의 주석 /* how much is logged */
에서도 이 값이 로깅된 값의 수를 나타냄을 명시하고 있다.
'개발' 카테고리의 다른 글
[Docker] 이미지 크기 줄이다 chown한테 맞은 이야기 (2) | 2025.03.30 |
---|---|
[TypeORM] Entity 관계 설정에 eager, lazy 옵션을 명시해야 할까? (0) | 2025.03.16 |
Notion API로 원하는 날짜에 일정 생성하기 (2) | 2024.10.09 |
[Passport.js] passport 에서 로그인과 callback route 를 나눠야 할까? (1) | 2023.11.04 |
[NestJS] 파일 업로드 구현하다 FileInterceptor 를 커스텀 한 사람이 있다? (1) | 2023.10.08 |
PostgreSQL Docker image를 사용하여 개발하던 도중 이상현상을 발견했다.Serial
(auto increment integer) 필드 값이 내가 넣은 레코드 수보다 분명히 더 큰 값으로 설정되고 있었다.
처음에는 잘못봤겠거니.. 했는데 반복적으로 일어나는 현상이었다.
원인 파악을 위해 sequence 를 살펴봤다.
문제 상황
1. 비정상적인 종료 시 log_cnt만큼 last_value가 올라간다.

- db가 비정상 종료되었다가 재실행 되면
last_value
가log_cnt
만큼 업데이트 된다는 것을 확인했다.
new_keep=# insert into users(email, nickname, login_type) values('dsaaaaaaaaadsaa', 'nickname', 1);
INSERT 0 1
new_keep=# select * from users_user_id_seq;
last_value | log_cnt | is_called
------------+---------+-----------
170 | 0 | t
(1 row)
new_keep=# insert into users(email, nickname, login_type) values('dsaaaaaaaaaddsaa', 'nickname', 1);
INSERT 0 1
new_keep=# select * from users_user_id_seq;
last_value | log_cnt | is_called
------------+---------+-----------
171 | 32 | t
(1 row)
2. Insert를 실패해도 sequence 값이 올라간다.
insert를 실험하다 또 다른 사실을 발견했다. insert의 성공/실패 유무와 관계없이 sequence 값은 증가하게 된다.

- unique 조건 위배

- not null 조건 위배
unique, not null 제약조건에 위배되어 insert가 실패하는 상황이다.
당연히 insert에 실패하면 sequence도 증가하지 않을것이라고 생각했는데, 예상과 달랐다.
원인
알고 보니 이건 PostgreSQL의 정상 작동 방식이었다.
그리고 발생 원인이 정말 정직하게 PostgreSQL 문서에 적혀있었다.
Sequence는 gapless 할당을 보장할 수 없다.
https://www.postgresql.org/docs/16/sql-createsequence.html
v16 사용중이므로 16 문서를 참고하였다.
Because nextval and setval calls are never rolled back, sequence objects cannot be used if “gapless” assignment of sequence numbers is needed. It is possible to build gapless assignment by using exclusive locking of a table containing a counter; but this solution is much more expensive than sequence objects, especially if many transactions need sequence numbers concurrently.
- “gapless” assignment를 보장해야 할 때는 sequence object를 사용할 수 없다.
- counter를 포함한 테이블을 lock 해서 gapless (순서 사이에 빈칸 없음)을 보장할 수 있지만 굉장히 고비용, 비효율적
즉 nextval()
호출은 절대 롤백되지 않아서 sequence 객체는 연속적인(gapless) 번호 할당을 보장할 수 없다는 것이다.
대안인 테이블 lock이 있긴 하지만 성능이 많이 떨어진다.
Session이 종료되면 Cache를 잃는다.
Unexpected results might be obtained if a *cache* setting greater than one is used for a sequence object that will be used concurrently by multiple sessions. Each session will allocate and cache successive sequence values during one access to the sequence object and increase the sequence object's last_value accordingly. Then, the next *cache*-1 uses of nextval within that session simply return the preallocated values without touching the sequence object. So, any numbers allocated but not used within a session will be lost when that session ends, resulting in “holes” in the sequence.
- 각 세션은 sequence에 접근할 때 여러 값을 미리 할당받아 캐싱한다.
- 세션 종료 시 사용하지 않은 캐시들은 모두 소멸된다.
둘을 종합해서 보면 sequence는 연속성을 보장할 수 없고, 세션 종료 시 캐싱된 sequnce 값이 소멸된다.
그럼 어떤 경우에 Cache를 잃을까?
Graceful Stop에 실패했을 때가 cache를 잃을 때라고 생각하고 가설을 검증하기 위해 테스트를 해봤다.
1. 정상 종료 케이스
- 종료 후 재실행해도
last_value
는 종료 전과 동일하다.
docker compose down

docker compose stop

ctrl+C (SIGINT - graceful stop)

2. 비정상 종료 케이스
last_value
가log_cnt
만큼 증가한다.
docker compose kill


docker kill
커맨드는 SIGKILL을 보내므로 (Forces running containers to stop by sending a SIGKILL signal. - docker 공식 문서) gracefully stopping이 되지 않는다.
ctrl+C를 누르고 종료되고 있는 와중에 ctrl+C를 또 누르는 경우
- 맨 처음 이 현상을 발견하게 된 계기로 성격 급한 한국인의 면모를 보여준다.
- ctrl+C는 graceful stop하지만 한 번 더 누르면 graceful 하지 못한 stop이 된다.

log_cnt는 뭘까?
여기서 주목할 점은 last_value
가 log_cnt
만큼 증가했다는 것이다. 그럼 log_cnt
는 무엇일까?
PostgreSQL에서 sequence 테이블을 조회하면 볼 수 있는 열이다:
new_keep=# select * from users_user_id_seq;
last_value | log_cnt | is_called
------------+---------+-----------
171 | 32 | t
(1 row)
log_cnt는 현재 세션에서 캐시한 시퀀스 값의 수를 나타낸다. 이 값들은 WAL(Write-Ahead Logging)에 로깅된 값들이다.
PostgreSQL 소스 코드를 살펴보면 log_cnt의 정확한 역할을 알 수 있다.
postgres/src/backend/commands/sequence.c
의 int64 nextval_internal(Oid relid, bool check_permissions)
함수 일부이다.
postgres/src/backend/commands/sequence.c at 5db3bf7391d77ae86bc9b5f580141e022803b744 · postgres/postgres
Mirror of the official PostgreSQL GIT repository. Note that this is just a *mirror* - we don't work with pull requests on github. To contribute, please see https://wiki.postgresql.org/wiki/Subm...
github.com
/* Decide whether we should emit a WAL log record. */
if (log < fetch || !seq->is_called)
{
/* forced log to satisfy local demand for values */
fetch = log = fetch + SEQ_LOG_VALS;
logit = true;
}
여기서 log는 시퀀스 튜플의 log_cnt
값이다. (이전에 log = seq->log_cnt;
로 초기화된다.)
필요한 값보다 적을 경우 SEQ_LOG_VALS
(32)만큼 추가로 할당한다.
이후 트랜잭션이 종료될 때 다음과 같이 최종 상태를 업데이트한다.
/* Now update sequence tuple to the intended final state */
seq->last_value = last; /* last fetched number */
seq->is_called = true;
seq->log_cnt = log; /* how much is logged */
이를 통해 log_cnt는 실제로 WAL(Write ahead logging)에 로깅된 시퀀스 값의 수를 추적하며, 이 값들이 캐시되어 있다가 비정상 종료 시 손실된다는 것을 알 수 있다. 소스 코드의 주석 /* how much is logged */
에서도 이 값이 로깅된 값의 수를 나타냄을 명시하고 있다.
'개발' 카테고리의 다른 글
[Docker] 이미지 크기 줄이다 chown한테 맞은 이야기 (2) | 2025.03.30 |
---|---|
[TypeORM] Entity 관계 설정에 eager, lazy 옵션을 명시해야 할까? (0) | 2025.03.16 |
Notion API로 원하는 날짜에 일정 생성하기 (2) | 2024.10.09 |
[Passport.js] passport 에서 로그인과 callback route 를 나눠야 할까? (1) | 2023.11.04 |
[NestJS] 파일 업로드 구현하다 FileInterceptor 를 커스텀 한 사람이 있다? (1) | 2023.10.08 |