Split View: 커넥션 풀 크기, 크게 잡으면 손해인 이유 — 대기열을 어디에 세울 것인가
커넥션 풀 크기, 크게 잡으면 손해인 이유 — 대기열을 어디에 세울 것인가
- 들어가며 — 풀 크기를 늘렸는데 왜 더 느려졌나
- 커넥션은 왜 비싼 자원인가
- 풀을 키울수록 느려지는 원리 — 대기열은 데이터베이스 밖에 세워라
- 자주 인용되는 공식의 근거와 한계
- 인스턴스 수 곱하기 풀 크기 — 마이크로서비스의 전형적 사고
- PgBouncer의 세 가지 모드와 트랜잭션 모드에서 못 쓰는 것
- 커넥션 누수 진단과 서버리스의 특수성
- 마치며 — 공식이 아니라 측정, 그리고 대기열의 위치
들어가며 — 풀 크기를 늘렸는데 왜 더 느려졌나
부하 테스트에서 응답 시간이 튀고 로그에 커넥션 획득 타임아웃이 찍힙니다. 자연스러운 대응은 풀 크기를 늘리는 것입니다. 20에서 50으로, 그래도 안 되면 100으로.
그런데 처리량은 그대로거나 오히려 떨어지고, p99 지연은 더 나빠집니다. 데이터베이스 CPU 사용률은 100퍼센트에 붙어 있는데 초당 처리 건수는 늘지 않습니다.
이 현상은 튜닝 실패가 아니라 예정된 결과입니다. 커넥션 풀은 성능을 늘리는 장치가 아니라 동시성을 제한하는 장치이기 때문입니다. 풀을 키운다는 것은 제한을 푸는 것이고, 제한을 풀면 대기열이 풀에서 데이터베이스 안으로 옮겨 갑니다. 대기열의 위치가 바뀔 뿐 일의 총량은 그대로인데, 데이터베이스 안의 대기열은 훨씬 비쌉니다.
이 글은 그 이유를 설명하고, 크기를 정하는 실제 절차와 자주 인용되는 공식의 한계, 그리고 PgBouncer와 서버리스 환경에서 달라지는 것들을 정리합니다.
커넥션은 왜 비싼 자원인가
PostgreSQL은 연결마다 운영체제 프로세스를 하나씩 만듭니다. 스레드가 아니라 프로세스입니다. 이 설계는 안정성 면에서 장점이 있지만 비용도 분명합니다.
-- 커넥션 하나가 실제로 프로세스 하나인지 확인
SELECT pid, backend_type, application_name, state
FROM pg_stat_activity
WHERE backend_type = 'client backend'
LIMIT 5;
# 같은 pid가 OS 프로세스 목록에 그대로 있다
ps -o pid,rss,command -p 24188
# PID RSS COMMAND
# 24188 11284 postgres: api shop 10.0.3.21(52144) idle
비용은 세 층위로 나뉩니다.
첫째, 생성 비용입니다. 새 연결마다 fork, 인증, 카탈로그 캐시 초기화가 일어납니다. 로컬에서도 수 밀리초, 네트워크와 TLS 핸드셰이크를 포함하면 수십 밀리초입니다. 풀을 쓰는 첫 번째 이유가 이것입니다.
둘째, 메모리입니다. 유휴 백엔드도 카탈로그 캐시와 실행 계획 캐시 때문에 수 메가바이트를 차지하고, 오래 살아 있으면서 다양한 쿼리를 처리한 백엔드는 훨씬 커집니다. 여기에 work_mem이 곱해집니다. work_mem은 커넥션당이 아니라 쿼리 안의 정렬이나 해시 연산 하나당 할당되므로, 커넥션 200개에 work_mem 64MB이고 쿼리마다 정렬이 두 번이면 이론상 25GB까지 갈 수 있습니다.
셋째, 커넥션 수에 비례하는 내부 비용입니다. 스냅샷을 만들 때 실행 중인 트랜잭션 목록을 순회해야 하고, 잠금 관리 자료구조도 커집니다. PostgreSQL 14에서 스냅샷 획득 경로가 크게 개선되어 유휴 커넥션의 부담은 많이 줄었지만, 활성 커넥션 수에 따른 비용은 여전히 남아 있습니다. 여기에 운영체제의 컨텍스트 스위칭이 더해집니다. 실행 가능한 프로세스가 코어 수를 크게 넘어서면 CPU는 일하는 대신 전환에 시간을 씁니다.
# 컨텍스트 스위칭이 급증하는지 관찰
vmstat 1 5
# procs -----------memory---------- ---system-- ------cpu-----
# r b swpd free buff cache in cs us sy id wa st
# 68 0 0 2104928 88420 9821004 42118 318442 71 26 3 0 0
r 열이 코어 수를 크게 넘고 cs가 수십만이라면 데이터베이스가 일이 아니라 스케줄링을 하고 있는 상태입니다.
풀을 키울수록 느려지는 원리 — 대기열은 데이터베이스 밖에 세워라
원리는 대기열 이론 하나로 설명됩니다. 리틀의 법칙에 따르면 평균 동시 처리 건수는 처리량과 평균 응답 시간의 곱입니다.
동시 처리 건수(L) = 처리량(X) × 응답 시간(W)
데이터베이스의 물리적 처리 능력은 코어 수와 디스크 대역폭으로 정해져 있습니다. 이 한계에 도달한 뒤에 동시 요청을 늘리면 처리량 X는 더 이상 늘지 않고, 대신 응답 시간 W가 비례해서 늘어납니다. 즉 같은 일을 하면서 모든 요청의 지연만 커집니다.
여기에 더해 동시성이 높아지면 실제로는 처리량이 감소하기 시작합니다.
- 컨텍스트 스위칭이 유효 CPU 시간을 잠식합니다.
- 같은 행과 인덱스 페이지를 두고 잠금 경합이 늘어납니다. 경합은 동시성의 제곱에 가깝게 증가합니다.
- 버퍼 캐시를 두고 경쟁하면서 캐시 적중률이 떨어집니다.
- 데드락과 직렬화 실패가 늘어 재시도 부하가 붙습니다.
그래서 대기열은 어딘가에 반드시 생기고, 문제는 위치입니다. 풀에서 기다리면 아무 자원도 소모하지 않고 순서대로 처리되며, 대기 시간이 지표로 노출됩니다. 데이터베이스 안에서 기다리면 프로세스와 메모리와 잠금을 쥔 채로 기다리고, 서로를 느리게 만듭니다.
핵심 문장 하나로 요약하면 이렇습니다. 풀 크기는 데이터베이스가 동시에 잘 처리할 수 있는 건수여야 하고, 애플리케이션이 보내고 싶은 건수여서는 안 됩니다.
실제 관찰 방법은 간단합니다. 풀 크기를 고정하고 부하를 올리면서 두 지표를 함께 봅니다.
-- 데이터베이스가 실제로 동시에 무엇을 하고 있는가
SELECT state, wait_event_type, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
state | wait_event_type | count
--------+-----------------+-------
active | LWLock | 41
active | | 8
active | Lock | 22
idle | Client | 64
active인데 LWLock이나 Lock을 기다리는 백엔드가 실제로 일하는 백엔드보다 훨씬 많다면, 풀이 이미 너무 큽니다. 여기서 풀을 더 키우면 이 비율이 더 나빠집니다.
자주 인용되는 공식의 근거와 한계
가장 널리 인용되는 식은 이것입니다.
풀 크기 = (코어 수 × 2) + 유효 디스크 축(spindle) 수
근거는 명확합니다. 코어 수만큼은 실제로 계산을 하고, 그 두 배를 잡는 것은 디스크나 네트워크 대기 때문에 코어가 놀 때 다른 요청이 채우도록 하는 여유분입니다. 마지막 항은 병렬 디스크 입출력 능력을 반영합니다. 8코어 서버라면 대략 20 근처가 나옵니다.
이 식이 주는 메시지는 숫자 자체가 아니라 자릿수입니다. 정답은 수백이 아니라 수십이라는 것. 8코어 데이터베이스에 풀 200개를 붙이고 있다면 그 설정은 거의 확실히 틀렸습니다.
다만 공식을 그대로 쓸 수 없는 조건이 몇 가지 있습니다.
첫째, 애플리케이션과 데이터베이스 사이의 왕복 지연입니다. 같은 가용 영역이면 0.2ms 수준이지만 영역을 넘으면 1ms를 넘습니다. 트랜잭션 하나에 문장이 열 개면 왕복만 10ms가 되고, 그 시간 동안 커넥션은 점유되어 있지만 데이터베이스는 놀고 있습니다. 이런 워크로드는 공식보다 큰 풀이 필요합니다. 정확히는 풀을 키울 것이 아니라 왕복 수를 줄여야 합니다.
둘째, 트랜잭션 안에서 애플리케이션이 하는 일입니다. 외부 API 호출이나 파일 처리가 트랜잭션 중간에 들어가면 커넥션 점유 시간이 폭증합니다. 이 경우 필요한 풀 크기는 공식과 무관해지고, 해법도 풀 크기 조정이 아닙니다.
셋째, 워크로드 혼재입니다. OLTP와 분석 쿼리가 한 풀을 쓰면 무거운 쿼리 몇 개가 풀 전체를 막습니다. 이때는 크기 조정이 아니라 풀 분리가 답입니다.
# 용도별로 풀을 나눈다
pools:
web: { size: 12, timeout: 3s } # 사용자 응답 경로
batch: { size: 4, timeout: 60s } # 야간 배치
readonly: { size: 8, timeout: 10s } # 레플리카 조회
그래서 실제 절차는 공식으로 시작해서 측정으로 끝내야 합니다.
- 공식으로 초기값을 잡습니다. 8코어라면 20 근처입니다.
- 목표 부하를 걸고 풀 대기 시간의 p99와 전체 처리량을 기록합니다.
- 풀 크기를 절반으로 줄여 봅니다. 처리량이 유지되면 원래 값이 너무 컸다는 뜻입니다.
- 처리량이 늘지 않는 지점을 찾을 때까지 늘려 봅니다. 처리량이 평평해지기 시작한 값보다 조금 작은 곳이 적정입니다.
- 그 값에서 풀 대기 시간이 여전히 크다면 풀이 아니라 쿼리를 고쳐야 합니다.
5번이 특히 중요합니다. 커넥션 대기가 길다는 것은 커넥션 점유 시간이 길다는 뜻이고, 그것은 대개 느린 쿼리이거나 긴 트랜잭션입니다. 풀 크기는 그 증상을 잠시 가릴 뿐입니다.
인스턴스 수 곱하기 풀 크기 — 마이크로서비스의 전형적 사고
단일 애플리케이션에서는 잘 맞춘 설정이 서비스가 늘면서 조용히 무너집니다.
서비스 A: 인스턴스 12개 × 풀 20 = 240
서비스 B: 인스턴스 8개 × 풀 15 = 120
서비스 C: 인스턴스 6개 × 풀 10 = 60
배치 워커: 4개 × 풀 5 = 20
합계 = 440
max_connections = 200
각 서비스의 설정은 모두 합리적으로 보입니다. 그런데 합이 최대 커넥션의 두 배를 넘습니다. 평상시에는 모든 풀이 가득 차지 않아 문제가 드러나지 않다가, 트래픽이 몰리거나 롤링 배포로 인스턴스가 일시적으로 두 배가 되는 순간 터집니다.
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
더 나쁜 것은 이 오류가 헬스 체크와 모니터링 에이전트까지 막는다는 점입니다. 장애 상황에서 관측 수단이 함께 사라집니다.
예산으로 관리해야 합니다. 계산에서 빠뜨리기 쉬운 항목들이 있습니다.
SHOW max_connections; -- 200
SHOW superuser_reserved_connections; -- 3
SHOW max_wal_senders; -- 10 (레플리카, max_connections와 별도)
-- 지금 누가 얼마나 쓰고 있는가
SELECT application_name,
count(*) AS total,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle') AS idle,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_tx
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1
ORDER BY 2 DESC;
application_name | total | active | idle | idle_in_tx
------------------+-------+--------+------+------------
order-service | 88 | 6 | 79 | 3
user-service | 42 | 3 | 39 | 0
datadog-agent | 6 | 1 | 5 | 0
flyway | 2 | 0 | 2 | 0
이 출력이 전형적인 상태입니다. 88개를 쥐고 있지만 실제로 일하는 것은 6개입니다. 나머지 79개는 메모리와 프로세스 슬롯만 차지합니다.
예산 배분 원칙은 이렇습니다.
- 사용자 경로 서비스에 먼저 배분하고, 배치와 관리 도구는 최소로 잡습니다.
- 롤링 배포 중 인스턴스가 일시적으로 늘어나는 것을 고려해 여유를 둡니다. 최대 인스턴스 수 기준으로 계산해야 합니다.
- 마이그레이션 도구, 모니터링 에이전트, 관리자 접속용으로 몇 개를 남겨 둡니다.
- 서비스별 데이터베이스 사용자를 분리하고 사용자 단위로 상한을 겁니다.
-- 사용자 단위 커넥션 상한. 한 서비스가 전체를 잠식하는 것을 구조적으로 막는다.
ALTER ROLE batch_worker CONNECTION LIMIT 10;
ALTER ROLE order_service CONNECTION LIMIT 60;
풀 크기를 줄여도 서비스 수가 계속 늘어난다면 그때가 커넥션 풀러를 도입할 시점입니다.
PgBouncer의 세 가지 모드와 트랜잭션 모드에서 못 쓰는 것
PgBouncer는 애플리케이션과 PostgreSQL 사이에 서서 다수의 클라이언트 연결을 소수의 서버 연결에 다중화합니다. 모드가 세 가지이고, 차이는 서버 커넥션을 언제 회수하는가입니다.
| 모드 | 서버 커넥션 반환 시점 | 다중화 효율 | 사용할 수 없는 기능 |
|---|---|---|---|
| session | 클라이언트 연결 종료 | 낮음 | 없음. 연결 생성 비용만 절약된다 |
| transaction | 트랜잭션 종료 | 높음 | 세션 변수, 세션 수준 advisory lock, LISTEN과 NOTIFY, WITH HOLD 커서, 임시 테이블 |
| statement | 문장 종료 | 가장 높음 | 여러 문장으로 이루어진 트랜잭션 자체 |
실무에서 의미 있는 선택지는 사실상 transaction 모드입니다. 클라이언트 1000개를 서버 커넥션 20개로 받아 낼 수 있습니다.
[databases]
shop = host=10.0.1.10 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 60
max_prepared_statements = 200
그 대가로 포기해야 하는 것들이 명확합니다. 트랜잭션이 끝나는 순간 다른 클라이언트가 그 서버 커넥션을 쓰기 때문에, 트랜잭션 경계를 넘어 유지되는 세션 상태는 모두 깨집니다.
첫째, 세션 변수입니다. SET search_path 나 SET timezone 을 트랜잭션 밖에서 실행하면 다음 요청에 남아 있지 않습니다. 멀티테넌시를 search_path로 구현했다면 트랜잭션 모드에서 조용히 다른 테넌트의 스키마를 읽을 수 있습니다.
-- 트랜잭션 모드에서 안전한 방식
BEGIN;
SET LOCAL search_path = tenant_42, public;
SELECT ...;
COMMIT;
SET LOCAL은 트랜잭션 종료 시 자동으로 되돌아가므로 안전합니다. 행 수준 보안에 쓰는 SET LOCAL app.current_user_id 같은 패턴도 마찬가지입니다.
둘째, advisory lock입니다. pg_advisory_lock은 세션 단위라서 트랜잭션이 끝나도 해제되지 않고, 그 커넥션이 다른 클라이언트에게 넘어가면 영영 풀리지 않습니다.
-- 위험: 세션 단위 잠금
SELECT pg_advisory_lock(12345);
-- 안전: 트랜잭션 종료 시 자동 해제
SELECT pg_advisory_xact_lock(12345);
셋째, prepared statement입니다. 오랫동안 트랜잭션 모드의 가장 큰 제약이었고, JDBC나 asyncpg처럼 기본으로 준비된 문장을 쓰는 드라이버에서 오류가 났습니다. PgBouncer 1.21부터 max_prepared_statements 설정으로 프로토콜 수준 prepared statement를 지원하므로, 최신 버전이라면 이 제약은 사라졌습니다. 오래된 버전을 쓰고 있다면 드라이버 쪽에서 비활성화해야 합니다.
# JDBC
prepareThreshold=0
# asyncpg
statement_cache_size=0
넷째, LISTEN과 NOTIFY입니다. 구독은 세션에 묶여 있으므로 트랜잭션 모드에서는 동작하지 않습니다. 알림을 쓰려면 그 용도만 session 모드 풀을 따로 두거나 PgBouncer를 우회해야 합니다.
한 가지 더. PgBouncer를 넣었다고 해서 애플리케이션 쪽 풀을 없애면 안 됩니다. 두 층을 함께 쓰되, 애플리케이션 풀은 작게 잡고 PgBouncer가 다중화를 담당하게 하는 것이 일반적인 구성입니다.
커넥션 누수 진단과 서버리스의 특수성
커넥션 누수는 두 가지 형태로 나타납니다.
첫째, 반환되지 않은 커넥션입니다. 예외 경로에서 close를 빠뜨린 코드가 원인이고, 풀 사용량이 시간에 따라 단조 증가하는 그래프로 드러납니다.
둘째, 더 위험한 idle in transaction입니다. 트랜잭션을 열어 둔 채 애플리케이션이 다른 일을 하는 상태입니다. 커넥션을 점유하는 것에 그치지 않고 그 트랜잭션의 스냅샷 때문에 VACUUM이 죽은 튜플을 정리하지 못합니다. 테이블이 부풀고 전체 성능이 서서히 나빠집니다.
-- 오래된 idle in transaction 찾기
SELECT pid,
application_name,
state,
now() - xact_start AS tx_age,
now() - state_change AS idle_age,
left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - state_change > interval '30 seconds'
ORDER BY xact_start;
pid | application_name | state | tx_age | idle_age | last_query
-------+------------------+---------------------+--------------+--------------+-------------------------------
24193 | order-service | idle in transaction | 00:18:44.221 | 00:18:42.008 | SELECT * FROM orders WHERE ...
18분째 열려 있습니다. last_query가 원인 코드를 가리키므로 여기서 바로 추적할 수 있습니다. 그리고 안전망을 걸어 둡니다.
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
ALTER SYSTEM SET idle_session_timeout = '10min'; -- PostgreSQL 14 이상
SELECT pg_reload_conf();
idle_session_timeout은 커넥션 풀러 뒤에서는 주의해서 써야 합니다. 풀이 유지하려는 유휴 커넥션을 서버가 끊으면 풀이 예상치 못한 끊김을 만나게 되므로, 풀의 유휴 검증 주기보다 길게 잡아야 합니다.
가장 오래된 트랜잭션이 VACUUM을 얼마나 막고 있는지도 함께 봐야 합니다.
SELECT max(age(backend_xmin)) AS oldest_xmin_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL;
서버리스 환경은 여기에 구조적인 문제가 하나 더 있습니다. 함수 인스턴스는 요청마다 만들어지고 사라지므로, 각 인스턴스가 자기 풀을 유지하는 모델이 성립하지 않습니다. 동시 실행 인스턴스가 500개라면 커넥션 요청도 500개가 됩니다. 게다가 인스턴스가 종료될 때 연결을 정리할 보장이 없어 서버 쪽에 유령 커넥션이 남습니다.
대응은 세 가지입니다.
- 외부 풀러를 필수로 둡니다. PgBouncer, RDS Proxy 같은 관리형 프록시가 다중화를 담당합니다. 서버리스에서 이것은 선택 사항이 아닙니다.
- 함수 안에서는 풀 크기를 1로 잡습니다. 인스턴스 하나가 동시에 처리하는 요청이 하나라면 커넥션도 하나면 충분합니다.
- 콜드 스타트 밖에서 연결을 만들고 인스턴스 재사용 시 유지하도록 핸들러 바깥 스코프에 클라이언트를 둡니다.
HTTP 기반 드라이버를 쓰는 선택지도 있습니다. TCP 연결과 세션을 유지하지 않고 요청마다 HTTP로 쿼리를 보내는 방식으로, 커넥션 관리 문제 자체를 없앱니다. 대신 트랜잭션과 세션 기능에 제약이 생깁니다.
마치며 — 공식이 아니라 측정, 그리고 대기열의 위치
기억할 것은 세 가지입니다.
첫째, 커넥션 풀의 목적은 동시성을 늘리는 것이 아니라 제한하는 것입니다. 데이터베이스가 동시에 잘 처리할 수 있는 건수를 넘겨 보내면 처리량은 늘지 않고 지연만 커집니다. 대기열은 어차피 생기므로, 아무 자원도 쥐지 않은 채 기다릴 수 있는 풀 쪽에 세우는 편이 항상 낫습니다.
둘째, 코어 수 기반 공식은 시작점이지 정답이 아닙니다. 그 식이 알려 주는 진짜 정보는 정답의 자릿수가 수백이 아니라 수십이라는 사실입니다. 실제 값은 목표 부하에서 풀 크기를 바꿔 가며 처리량과 대기 시간을 측정해서 정해야 합니다. 그리고 대기 시간이 길다면 대개 풀이 작은 것이 아니라 트랜잭션이 긴 것입니다.
셋째, 인스턴스 수와 풀 크기의 곱을 항상 계산하십시오. 각 서비스의 설정이 모두 합리적이어도 총합이 max_connections를 넘으면 배포 중에 장애가 납니다. 사용자 단위 CONNECTION LIMIT으로 상한을 걸고, 서비스가 계속 늘어난다면 PgBouncer의 transaction 모드를 도입하되 세션 상태에 의존하는 코드를 먼저 정리하십시오.
Why a Big Connection Pool Costs You — Deciding Where to Put the Queue
- Introduction — I Raised the Pool Size, So Why Did It Get Slower
- Why Is a Connection an Expensive Resource
- Why It Gets Slower as the Pool Grows — Put the Queue Outside the Database
- The Basis and the Limits of the Frequently Quoted Formula
- Instance Count Times Pool Size — The Classic Microservices Accident
- The Three PgBouncer Modes and What You Cannot Use in Transaction Mode
- Diagnosing Connection Leaks and What Is Special About Serverless
- Closing — Measurement Rather Than Formulas, and the Location of the Queue
Introduction — I Raised the Pool Size, So Why Did It Get Slower
Response times spike in a load test and connection acquisition timeouts appear in the log. The natural response is to raise the pool size. From 20 to 50, and when that does not work, to 100.
Yet throughput stays flat or even drops, and p99 latency gets worse. Database CPU utilization is pinned at 100 percent, but requests per second do not increase.
This phenomenon is not a tuning failure but a predetermined outcome. That is because a connection pool is not a device for increasing performance but a device for limiting concurrency. Enlarging the pool means loosening the limit, and loosening the limit moves the queue from the pool into the database. Only the location of the queue changes and the total amount of work stays the same, but a queue inside the database is far more expensive.
This article explains why, and lays out the actual procedure for choosing a size, the limits of the frequently quoted formula, and what changes with PgBouncer and in serverless environments.
Why Is a Connection an Expensive Resource
PostgreSQL creates one operating system process per connection. Not a thread, a process. This design has advantages in terms of stability, but the costs are equally clear.
-- Confirm that one connection really is one process
SELECT pid, backend_type, application_name, state
FROM pg_stat_activity
WHERE backend_type = 'client backend'
LIMIT 5;
# The same pid is right there in the OS process list
ps -o pid,rss,command -p 24188
# PID RSS COMMAND
# 24188 11284 postgres: api shop 10.0.3.21(52144) idle
The cost splits into three layers.
First, creation cost. Every new connection involves a fork, authentication, and catalog cache initialization. That is several milliseconds even locally, and tens of milliseconds once you include the network and the TLS handshake. This is the first reason to use a pool.
Second, memory. Even an idle backend occupies several megabytes for its catalog cache and plan cache, and a backend that has lived a long time serving a variety of queries grows much larger. On top of that, work_mem multiplies. work_mem is allocated not per connection but per sort or hash operation inside a query, so with 200 connections, a work_mem of 64MB, and two sorts per query, you could in theory reach 25GB.
Third, internal costs proportional to the connection count. Building a snapshot requires walking the list of running transactions, and the lock management data structures grow too. The snapshot acquisition path was improved substantially in PostgreSQL 14 so the burden of idle connections dropped a lot, but the cost that scales with the number of active connections still remains. On top of this comes operating system context switching. Once the number of runnable processes greatly exceeds the core count, the CPU spends its time switching instead of working.
# Watch whether context switching is spiking
vmstat 1 5
# procs -----------memory---------- ---system-- ------cpu-----
# r b swpd free buff cache in cs us sy id wa st
# 68 0 0 2104928 88420 9821004 42118 318442 71 26 3 0 0
If the r column greatly exceeds the core count and cs is in the hundreds of thousands, the database is doing scheduling rather than work.
Why It Gets Slower as the Pool Grows — Put the Queue Outside the Database
The principle is explained by a single piece of queueing theory. By the law of Little, the average number of items in the system is throughput times average response time.
concurrent items (L) = throughput (X) × response time (W)
The physical processing capacity of a database is fixed by core count and disk bandwidth. Once you reach that limit and keep increasing concurrent requests, throughput X no longer grows and response time W grows proportionally instead. In other words, the same work gets done while the latency of every request goes up.
On top of that, as concurrency rises, throughput actually begins to decrease.
- Context switching eats into effective CPU time.
- Lock contention over the same rows and index pages increases. Contention grows close to the square of concurrency.
- Competing over the buffer cache lowers the cache hit ratio.
- Deadlocks and serialization failures increase, adding retry load.
So a queue necessarily forms somewhere, and the question is where. Waiting in the pool consumes no resources, is processed in order, and exposes wait time as a metric. Waiting inside the database means waiting while holding processes, memory, and locks, and making each other slower.
Summed up in one sentence: pool size must be the number of items the database can handle well concurrently, not the number the application wants to send.
The actual way to observe this is simple. Fix the pool size, raise the load, and watch two metrics together.
-- What is the database actually doing concurrently
SELECT state, wait_event_type, count(*)
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;
state | wait_event_type | count
--------+-----------------+-------
active | LWLock | 41
active | | 8
active | Lock | 22
idle | Client | 64
If backends that are active but waiting on LWLock or Lock far outnumber the backends actually doing work, the pool is already too large. Enlarging the pool further only makes this ratio worse.
The Basis and the Limits of the Frequently Quoted Formula
The most widely quoted expression is this one.
pool size = (core count × 2) + effective number of disk spindles
The basis is clear. The core count is what actually performs computation, and doubling it gives headroom so that another request can fill in when a core idles waiting on disk or network. The last term reflects parallel disk I/O capability. For an 8-core server this lands somewhere around 20.
The message this expression carries is not the number itself but the order of magnitude. The answer is in the tens, not the hundreds. If you have a pool of 200 attached to an 8-core database, that setting is almost certainly wrong.
That said, there are a few conditions under which you cannot use the formula as-is.
First, the round-trip latency between the application and the database. Within the same availability zone it is around 0.2ms, but across zones it exceeds 1ms. With ten statements in one transaction the round trips alone become 10ms, and during that time the connection is occupied while the database sits idle. This kind of workload needs a bigger pool than the formula suggests. More precisely, rather than enlarging the pool, you should reduce the number of round trips.
Second, what the application does inside the transaction. If an external API call or file processing lands in the middle of a transaction, connection occupancy time explodes. In that case the required pool size becomes unrelated to the formula, and the remedy is not pool sizing either.
Third, mixed workloads. If OLTP and analytic queries share one pool, a handful of heavy queries block the entire pool. Here the answer is not resizing but splitting the pool.
# Split pools by purpose
pools:
web: { size: 12, timeout: 3s } # user response path
batch: { size: 4, timeout: 60s } # nightly batch
readonly: { size: 8, timeout: 10s } # replica queries
So the real procedure has to start with the formula and end with measurement.
- Take an initial value from the formula. For 8 cores that is around 20.
- Apply the target load and record the p99 of pool wait time and overall throughput.
- Try halving the pool size. If throughput holds, the original value was too large.
- Increase it until you find the point where throughput stops rising. Slightly below the value where throughput starts to flatten is the right size.
- If pool wait time is still large at that value, you have to fix the queries, not the pool.
Step 5 is especially important. Long connection waits mean long connection occupancy, and that usually means a slow query or a long transaction. Pool size only hides that symptom for a while.
Instance Count Times Pool Size — The Classic Microservices Accident
A setting that was tuned well for a single application quietly collapses as the number of services grows.
Service A: 12 instances × pool 20 = 240
Service B: 8 instances × pool 15 = 120
Service C: 6 instances × pool 10 = 60
Batch workers: 4 × pool 5 = 20
total = 440
max_connections = 200
Every service setting looks reasonable on its own. Yet the sum exceeds twice the maximum connections. In normal times not every pool is full so the problem stays hidden, and then it blows up the moment traffic surges or a rolling deployment temporarily doubles the instance count.
FATAL: sorry, too many clients already
FATAL: remaining connection slots are reserved for non-replication superuser connections
Worse still, this error also blocks health checks and monitoring agents. Your means of observation disappears together with the service during an incident.
You have to manage this as a budget. There are items that are easy to leave out of the calculation.
SHOW max_connections; -- 200
SHOW superuser_reserved_connections; -- 3
SHOW max_wal_senders; -- 10 (replicas, separate from max_connections)
-- Who is using how many right now
SELECT application_name,
count(*) AS total,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle') AS idle,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_tx
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY 1
ORDER BY 2 DESC;
application_name | total | active | idle | idle_in_tx
------------------+-------+--------+------+------------
order-service | 88 | 6 | 79 | 3
user-service | 42 | 3 | 39 | 0
datadog-agent | 6 | 1 | 5 | 0
flyway | 2 | 0 | 2 | 0
This output is the typical state. It holds 88 connections, but only 6 are actually working. The remaining 79 occupy nothing but memory and process slots.
The principles for allocating the budget are these.
- Allocate to user-path services first, and give batch and admin tools the minimum.
- Leave headroom for instances temporarily increasing during a rolling deployment. The calculation must be based on the maximum instance count.
- Reserve a few for migration tools, monitoring agents, and administrator access.
- Separate database users per service and cap them per user.
-- Per-user connection cap. Structurally prevents one service from consuming everything.
ALTER ROLE batch_worker CONNECTION LIMIT 10;
ALTER ROLE order_service CONNECTION LIMIT 60;
If the number of services keeps growing even after you shrink pool sizes, that is the point to introduce a connection pooler.
The Three PgBouncer Modes and What You Cannot Use in Transaction Mode
PgBouncer stands between the application and PostgreSQL and multiplexes many client connections onto a few server connections. There are three modes, and the difference is when a server connection is reclaimed.
| Mode | When the server connection returns | Multiplexing efficiency | Features you cannot use |
|---|---|---|---|
| session | Client connection closes | Low | None. Only connection creation cost is saved |
| transaction | Transaction ends | High | Session variables, session-level advisory locks, LISTEN and NOTIFY, WITH HOLD cursors, temporary tables |
| statement | Statement ends | Highest | Multi-statement transactions themselves |
In practice the only meaningful option is transaction mode. It lets you take 1000 clients on 20 server connections.
[databases]
shop = host=10.0.1.10 port=5432 dbname=shop
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 20
reserve_pool_size = 5
reserve_pool_timeout = 3
server_idle_timeout = 60
max_prepared_statements = 200
What you give up in exchange is clear. The moment a transaction ends another client uses that server connection, so all session state that persists across transaction boundaries breaks.
First, session variables. Run SET search_path or SET timezone outside a transaction and it will not survive into the next request. If you implemented multi-tenancy with search_path, transaction mode can quietly read the schema of a different tenant.
-- The safe way in transaction mode
BEGIN;
SET LOCAL search_path = tenant_42, public;
SELECT ...;
COMMIT;
SET LOCAL reverts automatically at the end of the transaction, so it is safe. The same goes for patterns like SET LOCAL app.current_user_id used with row-level security.
Second, advisory locks. pg_advisory_lock is session-scoped, so it is not released when the transaction ends, and if that connection is handed to another client it never gets released at all.
-- Dangerous: session-scoped lock
SELECT pg_advisory_lock(12345);
-- Safe: released automatically at the end of the transaction
SELECT pg_advisory_xact_lock(12345);
Third, prepared statements. This was the biggest limitation of transaction mode for a long time, and drivers that use prepared statements by default, such as JDBC or asyncpg, would error out. From PgBouncer 1.21 the max_prepared_statements setting supports protocol-level prepared statements, so on a recent version this limitation is gone. If you are on an older version, you have to disable it on the driver side.
# JDBC
prepareThreshold=0
# asyncpg
statement_cache_size=0
Fourth, LISTEN and NOTIFY. A subscription is bound to the session, so it does not work in transaction mode. To use notifications you need a separate session mode pool just for that purpose, or you have to bypass PgBouncer.
One more thing. Adding PgBouncer is not a reason to remove the application-side pool. The usual configuration is to use both layers, keeping the application pool small and letting PgBouncer handle the multiplexing.
Diagnosing Connection Leaks and What Is Special About Serverless
Connection leaks appear in two forms.
First, connections that are never returned. The cause is code that misses a close on an exception path, and it shows up as a graph where pool usage rises monotonically over time.
Second, the more dangerous idle in transaction. This is the state where the application leaves a transaction open while doing something else. It does not stop at occupying a connection: because of the snapshot of that transaction, VACUUM cannot clean up dead tuples. Tables bloat and overall performance slowly degrades.
-- Find old idle in transaction sessions
SELECT pid,
application_name,
state,
now() - xact_start AS tx_age,
now() - state_change AS idle_age,
left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - state_change > interval '30 seconds'
ORDER BY xact_start;
pid | application_name | state | tx_age | idle_age | last_query
-------+------------------+---------------------+--------------+--------------+-------------------------------
24193 | order-service | idle in transaction | 00:18:44.221 | 00:18:42.008 | SELECT * FROM orders WHERE ...
It has been open for 18 minutes. Since last_query points at the offending code, you can trace it right from here. And then you put a safety net in place.
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
ALTER SYSTEM SET idle_session_timeout = '10min'; -- PostgreSQL 14 and later
SELECT pg_reload_conf();
idle_session_timeout has to be used carefully behind a connection pooler. If the server cuts off idle connections the pool is trying to keep, the pool runs into unexpected disconnections, so it must be set longer than the idle validation interval of the pool.
You should also watch how much the oldest transaction is blocking VACUUM.
SELECT max(age(backend_xmin)) AS oldest_xmin_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL;
Serverless environments have one more structural problem on top of this. Function instances are created and destroyed per request, so the model where each instance maintains its own pool does not hold. With 500 concurrently executing instances you get 500 connection requests. On top of that, there is no guarantee that connections get cleaned up when an instance terminates, so ghost connections pile up on the server side.
There are three responses.
- Make an external pooler mandatory. A managed proxy such as PgBouncer or RDS Proxy takes over multiplexing. In serverless this is not optional.
- Set the pool size inside the function to 1. If one instance handles one request at a time, one connection is enough.
- Create the connection outside the cold start and keep the client in the scope outside the handler so it survives instance reuse.
There is also the option of using an HTTP-based driver. Rather than maintaining a TCP connection and a session, it sends queries over HTTP per request, eliminating the connection management problem itself. In exchange you get constraints on transactions and session features.
Closing — Measurement Rather Than Formulas, and the Location of the Queue
There are three things to remember.
First, the purpose of a connection pool is to limit concurrency, not to increase it. Send more than the number of items the database can handle well concurrently and throughput does not rise while latency does. A queue forms either way, so it is always better to put it on the pool side where waiting holds no resources.
Second, the core-count formula is a starting point, not the answer. The real information it conveys is that the order of magnitude of the answer is tens, not hundreds. The actual value must be decided by measuring throughput and wait time while varying the pool size at your target load. And if wait times are long, it is usually not that the pool is small but that the transactions are long.
Third, always compute instance count times pool size. Even if every service setting is reasonable, once the total exceeds max_connections you get an outage during a deployment. Cap it with a per-user CONNECTION LIMIT, and if services keep multiplying, introduce the transaction mode of PgBouncer — but clean up the code that depends on session state first.