SAVEPOINT는 안전장치처럼 생겼어요. 트랜잭션 중간에 표시를 남기고, 일부만 되돌릴 수 있게 해 주는 기능이니까요. 그런데 이 안전장치를 한 트랜잭션에서 64번 넘게 쓰는 순간, 그 트랜잭션이 아니라 클러스터 전체가 절벽에서 떨어집니다.
PlanetScale이 8월 11일에 공개한 분석이 이 경로를 실측으로 보여줬습니다. 동일한 INSERT/SELECT/DELETE 워크로드가 평소 약 7,200 TPS(지연 약 2ms)로 돌다가, 어느 한 트랜잭션이 subtransaction 64개를 넘긴 순간 약 160 TPS(지연 약 90ms)로 떨어졌습니다. 45배 하락입니다. 문제의 트랜잭션이 끝나자 처리량은 원래대로 돌아왔습니다.
이 글은 해당 분석을 따라가면서, 왜 남의 트랜잭션 하나가 내 쿼리를 느리게 만드는지 경로를 짚고, 마지막에 원문에는 없는 우리 클러스터 점검용 진단 쿼리를 붙입니다.
subtransaction은 어디서 생기나
subtransaction은 최상위 트랜잭션 안에 중첩된 트랜잭션입니다. 각 subtransaction은 자기만의 transaction ID(subxid)를 받습니다. 만드는 경로는 두 가지입니다.
명시적 경로는 SAVEPOINT입니다.
BEGIN;
SAVEPOINT s1; -- subtransaction 1
INSERT INTO orders ...;
SAVEPOINT s2; -- subtransaction 2
UPDATE stock ...;
COMMIT;
암묵적 경로가 더 위험합니다. PL/pgSQL에서 EXCEPTION 절이 있는 블록은 진입할 때마다 subtransaction을 하나 만듭니다. “혹시 실패하면 이 블록만 되돌린다"를 구현하는 방법이 내부적으로 SAVEPOINT와 같기 때문입니다.
-- 이 루프는 반복마다 subtransaction을 하나씩 만든다
FOR i IN 1..70 LOOP
BEGIN
INSERT INTO audit_log VALUES (...);
EXCEPTION WHEN unique_violation THEN
NULL; -- 무시하고 계속
END;
END LOOP;
-- 70개 생성, 64개 한계 초과
ORM도 조용히 만듭니다. Django의 transaction.atomic()을 중첩하면 안쪽은 SAVEPOINT가 되고, JPA/Hibernate에서 REQUIRES_NEW가 아닌 중첩 트랜잭션 전파, Rails의 중첩 transaction 블록(requires_new: true)도 같습니다. 애플리케이션 코드에는 SAVEPOINT라는 글자가 한 번도 안 나오는데 DB에는 수십 개씩 쌓이는 상황이 흔합니다.
64개 한계와 pg_subtrans
각 backend의 PGPROC 구조체에는 subtransaction ID를 담는 캐시가 있고, 크기가 PGPROC_MAX_CACHED_SUBXIDS, 즉 64개입니다. 이 안에 다 들어가는 동안에는 다른 세션이 MVCC visibility를 판단할 때 공유 메모리만 보면 됩니다. 빠릅니다.
65개째부터 이 캐시는 overflow 상태로 표시됩니다. 이제 다른 세션들은 어떤 subxid가 어느 최상위 트랜잭션에 속하는지 공유 메모리에서 알 수 없고, 디스크 기반 구조인 pg_subtrans를 조회해야 합니다. pg_subtrans는 SLRU(simple least-recently-used) 캐시를 통해 접근하는데, 이 조회는 lightweight lock을 잡고 필요하면 디스크 I/O까지 발생시킵니다.
flowchart TD
A[트랜잭션이
subxid 65개째 생성] --> B[PGPROC 캐시
overflow 표시]
B --> C[모든 세션의
snapshot 판정 변경]
C --> D[XidInMVCCSnapshot이
pg_subtrans 조회]
D --> E[SLRU lock 경합
+ 디스크 I/O]
E --> F[클러스터 전체
TPS 하락]
핵심은 비용을 치르는 쪽이 subtransaction을 만든 트랜잭션이 아니라는 점입니다. overflow가 켜져 있는 동안 다른 모든 세션이 자기 snapshot으로 튜플 가시성을 판단할 때마다(XidInMVCCSnapshot) pg_subtrans SLRU lock을 두고 경합합니다. PlanetScale 실측에서 병목으로 지목된 지점이 정확히 이 lock입니다.
overflow 상태는 문제의 트랜잭션이 끝날 때까지 유지됩니다. 그리고 얼마나 오래 가는지는 그 트랜잭션이 아니라, 클러스터에서 가장 오래 돌고 있는 트랜잭션의 수명에 좌우됩니다. long-running transaction이 있으면 그만큼 고통이 길어집니다.
hot standby까지 번지는 이유
이 문제의 두 번째 얼굴이 더 고약합니다. overflow가 발생한 시점에 WAL로 기록되는 RUNNING_XACTS 레코드에 “subxid overflowed” 표시가 함께 남습니다. 활성 subtransaction 목록을 다 담지 못했다는 뜻입니다.
새로 배포한 standby가 이 오염된 RUNNING_XACTS를 읽으면, 일관된 snapshot을 만들 수 없다고 판단하고 hot standby 활성화를 보류합니다. WAL은 계속 재생하는데 읽기 쿼리는 받지 않는 상태, 접속하면 이런 메시지만 돌아옵니다.
FATAL: the database system is not yet accepting connections
DETAIL: Recovery snapshot is not yet ready for hot standby.
pg_subtrans를 primary에서 넘겨받으면 되지 않느냐는 생각이 들지만, pg_subtrans 변경은 WAL에 기록되지 않고, standby는 기동 시 활성 페이지를 0으로 초기화하기 때문에 로컬 데이터도 신뢰할 수 없습니다. standby가 이 상태를 벗어나는 조건은 overflow 되지 않은 새 RUNNING_XACTS 레코드가 도착하거나, primary의 shutdown checkpoint를 재생하거나, 누락 가능성이 있는 트랜잭션들이 전부 끝나는 것입니다.
운영 관점에서 시나리오를 조합하면 이렇게 됩니다. 부하가 튀어서 읽기 용량을 늘리려고 replica를 새로 붙였는데, 하필 그 부하를 만들던 워크로드가 subtransaction overflow를 일으키는 중이라면, 새 replica는 WAL만 재생하며 접속을 거부합니다. 용량을 더하려고 만든 노드가 용량이 되어 주지 않는 역설입니다.
지금 확인할 것들
여기부터는 원문 내용에 우리가 운영에서 바로 실행할 쿼리를 더해 정리합니다.
먼저 지금 이 순간 subtransaction을 쌓고 있는 backend가 있는지 확인합니다. PostgreSQL 14부터 pg_stat_get_backend_subxact()로 backend별 subxact 개수와 overflow 여부를 볼 수 있습니다.
SELECT pg_stat_get_backend_pid(id) AS pid, s.subxact_count, s.subxact_overflowed
FROM pg_stat_get_backend_idset() AS id
JOIN LATERAL pg_stat_get_backend_subxact(id) AS s ON true
WHERE s.subxact_count > 0
ORDER BY s.subxact_count DESC;
subxact_overflowed = true인 행이 하나라도 보이면 그 순간 클러스터 전체가 pg_subtrans 조회 경로로 동작 중이라는 뜻입니다. pg_stat_activity와 조인하면 어떤 애플리케이션, 어떤 쿼리인지까지 특정됩니다.
과거에 겪었는지 의심된다면 SLRU 통계를 봅니다.
SELECT name, blks_hit, blks_read, blks_written, stats_reset
FROM pg_stat_slru
WHERE name = 'subtransaction';
평상시 이 카운터는 거의 움직이지 않습니다. blks_hit가 급증한 구간이 있었다면 overflow가 있었다는 강한 정황입니다. 그래프 도구에 이 두 컬럼을 올려 두면 사후 추적이 쉬워집니다.
가장 오래된 트랜잭션 나이도 함께 감시합니다. overflow의 지속 시간을 결정하는 값입니다.
SELECT max(now() - xact_start) AS oldest_tx
FROM pg_stat_activity
WHERE xact_start IS NOT NULL;
예방책은 소박합니다.
| 대상 | 조치 |
|---|---|
| PL/pgSQL 루프 안 EXCEPTION 블록 | 루프 밖으로 빼거나, 예외를 안 쓰는 로직(ON CONFLICT 등)으로 전환 |
| ORM 중첩 트랜잭션 | 중첩 atomic()/transaction 블록이 정말 필요한지 검토 |
| long-running transaction | transaction_timeout(17+), idle_in_transaction_session_timeout 설정 |
| 상시 감시 | pg_stat_get_backend_subxact() + pg_stat_slru 지표화 |
코어 쪽 근본 해법으로는 PGPROC_MAX_CACHED_SUBXIDS를 늘려 재컴파일하는 단기 우회(공유 메모리 비용 증가)와, snapshot을 CSN(Commit Sequence Number) 기반으로 바꾸는 장기 방향이 거론됩니다. CSN 논의는 10년 넘게 이어지고 있지만 아직 본가에 병합되지 않았습니다.
64라는 숫자를 기억해 두면 좋겠어요. SAVEPOINT와 EXCEPTION 블록은 공짜가 아니고, 비용 청구서는 옆 세션으로 날아갑니다. 저는 이번 주에 저희 클러스터에도 위 진단 쿼리 두 개를 모니터링에 추가해 두려고 합니다.
참고 자료
- The Dangers of Postgres Subtransactions - PlanetScale, 2026-08-11
- PostgreSQL 문서: SAVEPOINT
- PostgreSQL 문서: pg_stat_slru