# Postgres 큐를 건강하게 유지하기

> Clean Markdown view of GeekNews topic #28644. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=28644](https://news.hada.io/topic?id=28644)
- GeekNews Markdown: [https://news.hada.io/topic/28644.md](https://news.hada.io/topic/28644.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-04-18T08:00:07+09:00
- Updated: 2026-04-18T08:00:07+09:00
- Original source: [planetscale.com](https://planetscale.com/blog/keeping-a-postgres-queue-healthy)
- Points: 5
- Comments: 1

## Topic Body

- Postgres에서 **job queue**를 운영할 때 발생하는 고질적 문제인 **dead tuple 누적**과 이에 따른 **테이블 bloat**, 성능 저하의 원인과 해결 방법 정리  
- 큐 테이블은 대부분의 행이 **삽입-읽기-삭제**로 짧게 순환되므로 크기는 일정하지만 **누적 처리량이 매우 큼**  
- Postgres의 **MVCC** 구조상 삭제된 행은 즉시 제거되지 않고 **dead tuple**로 남아 정리되어야 하며, **autovacuum**이 이를 담당함  
- 장기 실행 트랜잭션이나 겹쳐 실행되는 분석 쿼리가 **MVCC horizon**을 고정시키면 autovacuum이 dead tuple을 정리하지 못해 큐 성능이 저하됨  
- PlanetScale의 **Traffic Control**(Insights 확장) 기능이 **쿼리 클래스별 리소스 제한**으로 이 문제를 해결하는 실용적 수단으로 제시됨  
  
---  
  
### 큐 워크로드의 특성  
  
- 큐 테이블의 고유한 특징은 대부분의 행이 **일시적(transient)** 이라는 점 — 삽입되고, 한 번 읽히고, 삭제됨  
- 테이블 크기는 거의 일정하지만 **누적 처리량은 막대함**  
- Postgres에 job queue를 두는 주요 이점은 **job 상태와 다른 DB 로직을 동일 트랜잭션으로 동기화** 가능  
  - job이 실패하면 전체 트랜잭션이 롤백  
  - 외부 큐 서비스를 쓰면 애플리케이션의 트랜잭션 상태와 동기 맞추는 작업이 복잡해짐  
  
### 예시 큐 테이블과 워커 동작  
  
- 본문에서 제시한 기본 스키마  
  
```sql  
CREATE TABLE jobs (  
  id BIGSERIAL PRIMARY KEY,  
  run_at TIMESTAMPTZ DEFAULT now(),  
  status TEXT DEFAULT 'pending',  
  payload JSONB  
);  
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';  
```  
  
- 워커는 트랜잭션을 열고 **가장 오래된 pending job**을 `FOR UPDATE SKIP LOCKED`로 잠가 중복 처리 방지  
- 작업 성공 시 `DELETE` 후 `COMMIT`, 실패 시 롤백되어 해당 행은 다시 다른 워커에게 보이게 됨  
- 이 트랜잭션은 **가능한 한 짧게 유지해야 함** — 오래 열려 있을수록 vacuum을 막기 때문 (본문 예시는 서브밀리초 워커 기준)  
  
### 성능 자체가 문제가 아님  
  
- Postgres는 **대규모 job queue**를 처리할 수 있음이 이미 문서화되어 있으며, 능력 자체는 문제되지 않음  
- 진짜 문제는 **같은 DB에서 경쟁하는 다른 워크로드들과의 공존**  
- 큐 테이블의 건강은 자체 설정뿐 아니라 **같은 Postgres 인스턴스에서 돌아가는 모든 트랜잭션의 동작**에 좌우됨  
- 본문은 프라이머리에서의 **경쟁 쿼리 트래픽**에 초점 (복제본·replication slot 영향은 별도)  
  
### 진짜 문제: dead tuple 정리  
  
- Postgres는 **MVCC**로 동일 행의 여러 버전을 유지함 — 삭제된 행은 즉시 제거되지 않고 **“삭제 표시” 후 새 트랜잭션에 비가시 상태**로 남음  
- 이렇게 남은 행이 **dead tuple**이며 **vacuum 연산**으로 정리됨  
- dead tuple은 `SELECT` 결과에 나오지 않지만 여전히 **비용을 발생**시킴  
  - **Sequential scan**: 실행기가 heap page에서 dead tuple을 읽고 가시성 확인 후 버림  
  - **Index scan**(큐에서 `ORDER BY run_at LIMIT 1`로 사용하는 방식): B-tree 인덱스가 dead tuple 참조를 누적해, 더 이상 보이지 않는 행을 가리키는 항목까지 훑게 됨  
- 각 dead index entry는 **추가 I/O를 유발**, 애플리케이션에서는 보이지 않지만 dead tuple 수에 따라 비용이 크게 증가  
- 정리 주기는 `autovacuum_naptime`(기본 1분), 실행 여부는 `autovacuum_vacuum_threshold`와 `autovacuum_vacuum_scale_factor`에 따라 결정됨  
  
### dead tuple 내부 메커니즘  
  
- 행 메타데이터 3가지가 핵심  
  - **`ctid`**: heap 내 튜플의 물리적 위치 `(page, offset)`  
  - **`xmin`**: 이 행을 삽입한 트랜잭션 ID (XID)  
  - **`xmax`**: 이 행을 삭제/잠근 트랜잭션 ID, `0`이면 삭제 표시 없음  
- pending 3건 조회 시에도 실행기는 이전에 삭제된 6건의 dead tuple을 모두 스캔한 후 **3건만 반환**하는 상황이 발생 가능  
- 인덱스 역시 leaf entry가 dead인 heap tuple을 가리키는 경우, **scan 과정에서 낭비되는 작업**이 누적됨  
- DB가 **dead tuple 생성 속도보다 정리 속도가 느려지면 실패 궤도로 진입**  
- 잘 튜닝된 Postgres 클러스터는 초당 수만 건의 큐 처리량 감당 가능  
  
### autovacuum이 무력해지는 상황  
  
- autovacuum이 dead tuple 정리에 실패하는 주요 원인  
  - 특정 **테이블 lock**이 cleanup 차단  
  - 부적절한 **autovacuum 설정**  
  - 가장 흔하게는 **활성 트랜잭션이 dead tuple 회수를 막는 경우**  
- Postgres는 **활성 트랜잭션에 아직 보일 수 있는 dead tuple은 vacuum하지 않음**  
  - 가장 오래된 활성 트랜잭션이 cutoff를 설정 → **MVCC horizon**  
  - 해당 트랜잭션이 끝날 때까지, 그 스냅샷 이후의 모든 dead tuple이 유지됨  
- 2분짜리 단일 트랜잭션 하나가 **2분 동안 horizon을 고정**  
- 동일한 실패 패턴이 **겹치는 중간 길이 쿼리들**로도 발생  
  - 예: 40초짜리 분석 쿼리 3개를 20초 간격으로 엇갈려 실행하면, 개별 쿼리는 타임아웃되지 않지만 항상 하나는 활성 상태여서 **horizon이 전진하지 못함**  
- “Just use Postgres” 철학으로 여러 워크로드를 한 DB에 두면, 빠른 job 처리 자체가 아니라 **겹치는 느린 쿼리 때문에 dead tuple 정리가 밀리는 것**이 문제  
  
### 기존 도구와 한계  
  
- autovacuum 튜닝 옵션: `autovacuum_vacuum_cost_delay`, `autovacuum_vacuum_cost_limit`  
- 장기 실행 쿼리 제한용 타임아웃  
  - **`statement_timeout`** (Postgres 7.3): 지정 시간을 넘는 개별 SQL 문을 종료  
  - **`idle_in_transaction_session_timeout`** (9.6): 트랜잭션 내부에서 유휴 상태가 지속되는 세션 종료  
  - **`transaction_timeout`** (17.0): 활성·비활성 트랜잭션이 지정 시간 초과 시 종료  
- 이 타임아웃들은 **단일 쿼리 실행 시간만 겨냥**, 동시성·실행 비용은 제한하지 못해 **MVCC horizon을 지속적으로 고정하는 워크로드** 차단에는 부적합  
- 필요한 것은 **트래픽 클래스별 구분**, 고우선 워크로드는 그대로 두고 저우선 워크로드의 리소스 사용률만 조절하는 수단  
  
### Database Traffic Control™  
  
- PlanetScale이 개발한 **Insights 확장**의 일부로 **PlanetScale Postgres 전용** 기능  
- 개별 쿼리 성능과 리소스 사용량을 세밀하게 제어할 때 사용  
- **Resource Budget**으로 대상 쿼리에 리소스 한도 부여 → 초과 시 차단 가능  
- 해결 전략은 **겹치는 느린 쿼리의 동시 실행 수와 빈도를 제한**, autovacuum이 적절한 속도로 dead tuple을 정리할 여유 확보  
- 차단된 쿼리는 영구 거부가 아니라 **재시도**되도록 애플리케이션의 retry 로직이 필수  
- 전체 작업량은 유지하면서 **수행 속도를 평탄화**하는 접근  
  
### 데모 구성과 배경  
  
- 이 글의 착안점은 2015년 Brandur Leach의 블로그 "Postgres Job Queues & Failure By MVCC"  
  - Postgres 기반 job queue의 치명적 실패 모드를 기록  
  - 닫히지 않은 트랜잭션이 MVCC horizon을 고정해 cleanup을 막는 현상을 실증하는 테스트 벤치 포함  
- 원 테스트 벤치는 `brandur/que-degradation-test`로 공개되어 있음  
  
### 문제 재현 (Postgres 18 기준)  
  
- 원 테스트는 Ruby + Que gem v0.x + Postgres 9.4 기반  
- 저자는 SQL 레벨 동작만 격리 검증하기 위해 **TypeScript + Bun**으로 재작성  
- Que와 동일한 **recursive CTE 패턴**, 동일 schema, producer rate, work duration, worker 수, long-runner 패턴 유지  
- **PlanetScale PS-5 클러스터**에서 실행 (월 $5부터 시작)  
- 결과: **가시적이지만 관리 가능한 수준의 성능 저하**  
  - 원 테스트는 15분 내 DB를 death spiral로 몰아넣었으나, PS-5에서는 15분간 워커 큐를 0 근처로 유지  
  - 그러나 dead tuple이 **선형적으로 증가**해, 시간이 더 지나면 동일 문제가 재발할 조짐  
  - B-tree 인덱스 정리 개선(버전 churn에 대한 bottom-up deletion, scan 기반 dead index tuple 제거 등) 덕에 **완화되었지만 제거된 것은 아님**  
  
### 개선 시도: SKIP LOCKED + 배치 처리  
  
- 2015년에 없던 2가지 현대적 개선  
  1. **`FOR UPDATE SKIP LOCKED`** — recursive CTE 전체를 **단일 `SELECT`** 로 대체, 다른 워커가 잠근 행은 건너뜀  
  2. **Batch processing**(트랜잭션당 10 jobs) — 한 번의 lock acquisition으로 10개 처리, 인덱스 스캔 비용을 **분산**  
- 조건 동일: worker 8, producer 50 jobs/sec, work 10ms, long-runner 45초 후 시작  
- 주요 결과  
  
| 지표 | original (recursive CTE) | enhanced (SKIP LOCKED + batch) |  
|---|---|---|  
| Baseline lock time | 2–3ms | 1.3–3.0ms |  
| End lock time (typical) | 10–34ms | 9–29ms |  
| Worst spike | 84.5ms (dead tuple 33k) | 180ms (dead tuple 24k) |  
| Queue depth | 0–100 (oscillating) | 0 (대부분) |  
| Dead tuples at end | 42,400 | 42,450 |  
| Throughput | ~89/s | ~50/s |  
  
- **degradation 곡선은 거의 동일** — 두 방식 모두 같은 B-tree 인덱스를 스캔하고 같은 dead tuple을 만나기 때문  
- throughput 차이는 lock 전략이 아닌 **테스트 설계** 영향 (CTE 워커는 producer보다 빠르게 job을 잡아감, batch 워커는 큐를 비우고 backoff sleep)  
- 결론: 10년 전 15분 만에 DB를 죽이던 큐 설계가 이제는 더 오래 버티지만, **근본 문제는 남아 있음** — 500 jobs/sec로 올리면 문제는 더 빨리 재현  
  
### Traffic Control로 해결  
  
- Resource Budget이 제공하는 제어 수단  
  - **Server share & burst limit**: 서버 리소스 비율과 소비 속도  
  - **Per-query limit**: 서버 사용량 기준 초 단위로 쿼리 실행 가능 시간  
  - **Maximum concurrent workers**: 가용 워커 프로세스 대비 비율  
- 쿼리 대상 지정은 주로 **SQLCommenter 태그의 메타데이터**로 수행 (예: `action=analytics`)  
- `idle_in_transaction_session_timeout`으로 잡히는 long-runner 대신, 더 현실적인 시나리오인 **활성 작업 중인 겹치는 분석 쿼리들**로 degradation을 유발 (세션 타임아웃으로는 못 잡는 케이스)  
- `action=analytics` 쿼리의 **Maximum concurrent workers**를 1 워커(`max_worker_processes`의 25%)로 제한 → 분석 쿼리 1개만 동시 실행  
- 15분 창 안에서 death spiral을 유도하기 위해 producer를 **800 jobs/sec**로 증가  
- EC2에서 동일 PlanetScale DB 대상으로 "enhanced" 워크로드 2회 실행  
  - 800 jobs/sec  
  - 120초짜리 분석 쿼리 3개 동시 실행, 계속 겹치도록 엇갈려 배치  
  - 15분 지속  
- 결과 비교  
  
| 지표 | Traffic Control 비활성 | Traffic Control 활성 |  
|---|---|---|  
| Queue backlog | 155,000 jobs | 0 jobs |  
| Lock time | 300ms+ | 2ms |  
| Dead tuples at end | 383,000 | 0–23,000 (cycling) |  
| Analytics queries | 3 concurrent, overlapping | 1 at a time, 2 retrying |  
| VACUUM effectiveness | Blocked (horizon 고정) | Normal (쿼리 사이 정리 창 확보) |  
| Outcome | Death spiral | Completely stable |  
  
- Traffic Control은 **특정 워크로드의 동시성을 직접 제한**, autovacuum 튜닝·타임아웃으로는 불가능했던 제어를 제공  
- 분석 리포트는 용량이 허용하는 범위에서 계속 실행되어 15분 동안 **15건 완료**, 큐는 내내 건강한 상태 유지  
  
### 정리  
  
- Postgres 기반 큐의 **MVCC dead tuple 문제**는 2015년의 유물이 아님  
- 현대 Postgres는 **B-tree 개선과 `SKIP LOCKED`** 로 상당한 여유를 제공하지만 **근본 메커니즘은 동일**  
  - VACUUM이 dead tuple을 정리하지 못하면 누적됨  
  - 장기 실행·겹치는 트랜잭션이 MVCC horizon을 고정하면 VACUUM이 정리하지 못함  
- “Just use Postgres”로 큐·분석·앱 로직을 한 DB에 두는 환경에서 이는 **이론적 위험이 아닌 평상시의 운영 조건**  
- 위험한 형태는 극적인 크래시가 아니라 **조용히 열화되는 균형 상태** — lock time이 서서히 늘고, job이 느려지며, 알람은 울리지 않음  
- Postgres의 타임아웃 도구는 **워크로드 클래스 구분이나 동시성 제한이 불가능함**  
- 큐를 다른 워크로드와 같이 돌린다면, 가장 효과적인 조치는 **VACUUM이 따라갈 수 있도록 보장하는 것**이며, Traffic Control이 이를 단순화함

## Comments



### Comment 55738

- Author: neo
- Created: 2026-04-18T08:04:42+09:00
- Points: 1

###### [Hacker News 의견들](https://news.ycombinator.com/item?id=47731838)   
* Postgres는 여전히 vacuum horizon 문제를 가지고 있음. 오래 실행되는 쿼리가 빠르게 변하는 테이블의 vacuum을 막는 현상임. 이 문제는 2015년부터 이미 잘 알려져 있었음. 기본 Postgres에는 이를 해결할 좋은 도구가 없지만, 글쓴이 회사의 커스텀 버전에는 이를 해결하는 기능이 있음. 결론적으로, OLAP 스타일의 긴 작업과 큐 스타일의 빠른 작업을 같은 Postgres 인스턴스에서 섞는 것은 여전히 현명하지 않음. 요구사항에 따라 0MQ나 RMQ 같은 메시지 큐를 사용하는 것이 더 쉬운 해결책일 수 있음  
  * 데이터베이스가 캐시나 버퍼 풀을 세밀하게 제어하지 않는 한, 이런 서로 다른 부하를 섞는 것은 항상 좋지 않은 선택임. 분석용 테이블이 전체 캐시를 더럽히는 것을 막을 방법이 없기 때문임  
  
* 글은 괜찮았지만 몇 가지 지적할 점이 있음.  
  1) MVCC horizon 설명이 서로 모순되어 보임. 트랜잭션이 서로 다른 시점에 시작되면 스냅샷이 달라지고, 첫 번째 트랜잭션이 끝나면 vacuum이 진행될 수 있어야 함  
  2) `SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED;` 쿼리의 성능 문제는 실제로 존재하지만, 단조 증가하는 컬럼을 추가하고 인덱스를 걸면 완화할 수 있음. 이렇게 하면 죽은 튜플을 고려하지 않아도 되고, 단지 공간만 낭비할 뿐 읽기 성능은 덜 저하됨. 다만 동시 쓰기 상황에서 단조 증가를 보장하는 방법은 애플리케이션 설계에 달려 있음  
  3) 요약하자면 “Postgres에서 매우 긴 트랜잭션과 매우 높은 트랜잭션 빈도를 함께 사용하지 말라”는 교훈임  
  * 인덱스를 추가해도 Postgres는 여전히 죽은 튜플을 완전히 정리할 때까지 유지해야 함. 약간의 속도 향상은 있겠지만 결국 디스크가 가득 차고, 다른 테이블의 vacuum도 방해받을 수 있음  
  
* 글쓴이임. 질문 있으면 언제든 물어보길 바람  
  * 블로그의 큐 구현이 작업 중인 동안 트랜잭션을 유지하는 것으로 보임. 가능한 짧게 유지하라는 조언은 이해하지만, 상태 컬럼을 “processing”으로 업데이트해서 긴 트랜잭션 자체를 피할 수는 없는지 궁금함  
  * 큐 테이블의 fillfactor를 100보다 작게 설정하고 테스트해봤는지 궁금함. HOT 업데이트를 사용하면 상태 변경 시 인덱스 항목을 새로 만들지 않고 죽은 공간을 재사용할 수 있어서 문제 발생 시점을 늦출 수 있을 것 같음  
  * 이 해결책이 pg_squeeze를 사용하는 것과 비교해 어떤 차이가 있는지 궁금함. 우리도 큐 시스템에서 같은 문제를 겪고 pg_squeeze를 테스트 중인데 꽤 잘 작동하는 것 같음  
  
* 광고처럼 보이지만, 기술적인 해결 방식에 대한 설명이 조금이라도 있었으면 좋겠음  
  * 트래픽 제어 방식에 대한 별도의 블로그 글이 있음: https://planetscale.com/blog/behind-the-scenes-how-traffic-c...  
  * InnoDB의 동시성 제어 방식과 비슷하게 들림. InnoDB는 쿼리가 수행할 수 있는 최대 작업량을 제한하는 토큰 기반 접근을 사용함: https://dev.mysql.com/doc/refman/8.4/en/innodb-performance-t...  
  
* Postgres는 정말 많은 일을 할 수 있음. 사람들이 Kafka나 SQS를 선택하지만, 사실 Graphile Worker로도 충분히 처리할 수 있는 작업이 많음  
  * “모든 것을 Postgres로 처리하자”는 철학은 소규모나 중간 규모에서는 단순함을 유지하기에 좋지만, 규모가 커지면 SQL 데이터베이스는 가능한 한 적은 일을 맡기는 게 좋음. 대부분의 시스템에서 병목이 되는 부분이기 때문임  
  * SQS는 매우 단순하고 AWS 환경에서는 쉽게 통합됨. Kafka는 훨씬 복잡하지만 필요한 기능이 있다면 훌륭한 선택임. 다만 운영 부담이 크고, 이력서용으로 도입된 프로젝트도 많음. 반면 SQS는 실용적인 도구로서 사용됨. 하지만 AWS를 벗어나려 한다면 의존성이 문제가 될 수 있음  
  
* Postgres에서는 행을 업데이트할 때 문제가 훨씬 심각해짐. 삽입과 삭제만 사용하면 꽤 오래 버틸 수 있음  
  * Postgres에서 UPDATE는 사실상 INSERT와 DELETE의 조합임
