포스트

Postgres 커넥션 하나의 비용 - 풀을 키우면 대기가 사라지는 게 아니라 볼 수 없는 곳으로 옮겨간다

엔지니어링 요약

Problem

커넥션 풀은 당연히 쓰는 것이 됐지만 무엇을 얼마나 아끼는지는 재 본 적이 없었다. 그리고 '풀을 늘렸는데 p99가 나빠진다'는 이야기는 들었어도, 어느 지점에서 그렇게 되는지와 그때 대기가 어디로 가는지는 설명하지 못했다.

Decision

커넥션 여는 비용, 유휴 커넥션의 서버 메모리, 풀 크기 1~150 스윕, 준비된 문장 네 가지를 200만 행 테이블에서 쟀다. 스윕은 두 번 돌렸다. 한 번은 서버가 버퍼에서 바로 답하는 점 조회로, 한 번은 서버가 실제로 일하는 범위 집계로. 총 지연뿐 아니라 풀에서 커넥션을 얻기까지의 시간을 따로 기록해 대기가 어디에 있는지 보이게 했다.

Result

커넥션을 여는 데 5.48ms, 열린 커넥션에서 질의는 0.23ms였다. 유휴 커넥션 하나가 서버 메모리 1.5MB를 선형으로 차지했다. 처리량은 점 조회에서 풀 10(3,250 rps), 서버 부하 질의에서 풀 5(1,801 rps)에 정점을 찍고 150에서 각각 15%·39% 낮아졌다. 정점을 넘긴 구간에서 획득 대기 p99는 89.3ms에서 38.8ms로 줄었는데 총 p50은 60.2ms에서 69.8ms로 늘었다. 애플리케이션이 볼 수 있는 대기가 줄고 볼 수 없는 대기가 늘어난 것이다.

커넥션과 세션에서 “풀이 작으면 애플리케이션 쪽에서 대기하고, 크면 DB 안에서 보이지 않게 대기한다”고 썼다. 그 글은 문서를 근거로 썼고 마지막에 네 가지를 재 보자고 적어 뒀다. 이 글이 그 측정이다.

저장소는 data-ops-lab이고 재실행은 한 줄이다.

1
./.venv/bin/python experiments/connection/run.py --seconds 12 --clients 200

커넥션 하나를 여는 값

 중앙값p95
커넥션 열기5.48 ms9.11 ms
이미 열린 커넥션에서 질의0.23 ms-
질의마다 새 커넥션7.32 ms-

여는 값이 질의의 약 24배다. 질의마다 열면 32배다.

여기서 풀이 무엇을 아끼는지가 분명해진다. 질의가 빨라지는 것이 아니라 핸드셰이크가 사라지는 것이다. 질의가 원래 100ms 걸린다면 풀이 있든 없든 100ms이고, 절약분 5.48ms는 5%다. 반대로 이 실험처럼 질의가 0.23ms면 절약분이 전체의 96%다. 풀의 이득은 질의가 쌀수록 크다.

유휴 커넥션은 공짜가 아니다

아무것도 하지 않는 커넥션을 늘려 가며 컨테이너 메모리를 읽었다.

유휴 커넥션컨테이너 메모리증가커넥션당
0447.1 MB--
10461.9 MB14.8 MB1,515 KB
25484.1 MB37.0 MB1,515 KB
50521.1 MB74.0 MB1,515 KB
100595.4 MB148.3 MB1,519 KB

약 1.5MB, 완전히 선형이다. PostgreSQL이 커넥션마다 백엔드 프로세스를 fork하기 때문이고, 유휴여도 그 프로세스는 그대로 있다.

실무 산술로 옮기면 무서워진다. 애플리케이션 인스턴스 10대가 각각 풀 150을 잡으면 커넥션 1,500개이고, 일을 시작하기 전에 2.2GB다. 그리고 max_connections를 넘으면 그때부터는 메모리 문제가 아니라 접속 거부다.

풀 스윕: 처리량은 정점을 찍고 내려온다

클라이언트 200개 고정, 풀 크기만 바꿔 12초씩.

점 조회 — 서버가 병목이 아닌 경우

풀rpsp50p99획득 p99
1768.6219.3647.2644.8
21,885.991.7269.0266.4
52,738.063.2207.5204.4
103,250.760.292.389.3
202,849.267.3180.4163.6
502,930.766.897.075.0
1002,799.968.9109.155.3
1502,751.769.8122.238.8

서버 부하 질의 — 서버가 병목인 경우

풀rpsp50p99획득 p99
1669.6278.4535.9533.1
21,298.7147.5259.8257.3
51,801.5102.6240.0232.8
101,635.1117.1179.1174.0
201,610.8118.9176.0163.4
501,474.5128.2205.0168.5
1001,236.7161.1409.5167.6
1501,102.2185.1375.4116.5

두 곡선의 모양이 같고 정점의 위치만 다르다. 점 조회는 풀 10, 서버 부하 질의는 풀 5다. 150에서 각각 15%, 39% 낮다.

질의가 무거울수록 최적 풀이 작다. 서버가 동시에 처리할 수 있는 양이 정해져 있으므로, 건당 비용이 크면 그 양에 도달하는 커넥션 수가 적다.

핵심: 대기는 사라지지 않고 옮겨간다

점 조회에서 풀 10 → 150 구간을 보면 두 값이 반대로 움직인다.

1
2
획득 p99   89.3 ms  →  38.8 ms   (애플리케이션이 보는 대기: 줄었다)
총    p50  60.2 ms  →  69.8 ms   (사용자가 겪는 시간: 늘었다)

풀을 키웠으니 커넥션을 기다리는 시간은 당연히 준다. hikaricp_connections_pending 같은 지표는 좋아진다. 그런데 총 지연은 늘었다. 줄어든 대기가 다른 곳으로 갔기 때문이다. 커넥션을 받은 요청들이 이번엔 서버 안에서 CPU와 잠금을 두고 줄을 선다.

여기가 이 실험에서 가장 실무적인 부분이다. 풀을 키우는 조치는 대시보드를 개선하면서 사용자 경험을 악화시킬 수 있다. 풀 대기 지표만 보고 있으면 그 사실이 보이지 않는다.

그리고 p99가 처리량보다 먼저 나빠진다. 서버 부하 스윕에서 풀 100의 p99는 409.5ms로 풀 10의 179.1ms보다 2.3배인데, 처리량은 24% 떨어졌을 뿐이다. 평균 처리량만 보면 “조금 느려졌네” 수준이고 꼬리는 두 배가 넘게 나빠져 있다.

준비된 문장

 중앙값p99
서버 측 준비 없음0.254 ms0.47 ms
5회 후 준비0.199 ms0.33 ms

0.25ms짜리 질의에서 중앙값 22%다. 절대값은 작지만 초당 수천 건이면 누적된다. 다만 PgBouncer의 transaction 모드를 쓰면 서버 측 준비된 문장을 못 쓰므로, 이 이득은 풀러 구성과 맞물려 있다(커넥션과 세션).

실무로 옮기면

  1. 풀 크기의 출발점은 L = λ × W다. 초당 100건, 질의 20ms면 2다. 큐잉 이론에서 정리한 그 식이고, 이 실험의 정점(5~10)이 그 근처에 있다.
  2. 풀 대기 지표와 총 지연을 함께 본다. 하나만 보면 옮겨간 대기를 놓친다.
  3. 정점을 찾는 실험을 한 번은 한다. 계산으로 시작하고 스윕으로 확인한다. 정점은 질의 무게와 서버 CPU에 달려 있어 남의 숫자를 가져올 수 없다.
  4. 유휴 커넥션 수 × 1.5MB를 용량 계획에 넣는다. 인스턴스 수를 곱하는 것을 잊지 않는다.
  5. p99를 기준으로 고른다. 처리량이 정점인 지점과 p99가 최선인 지점이 다를 수 있다. 이 실험의 점 조회에서는 둘 다 풀 10이었지만, 서버 부하 질의에서는 처리량 정점이 5이고 p99 최선은 20(176.0ms)이었다.

ParityPay 2편에서 잠금 보유 17ms 중 DB가 실제로 일한 시간이 2.31ms였다는 것을 보고 고칠 곳을 찾았다. 여기서도 같은 방식이다. 총 지연을 “기다린 시간”과 “일한 시간”으로 나누기 전까지는 풀을 키울지 줄일지 알 수 없다.

한계

  • PostgreSQL에 CPU 2개다. 정점의 위치는 그 상한의 함수이고, 큰 서버에서는 오른쪽으로 간다. 옮겨갈 수 있는 것은 숫자가 아니라 곡선의 모양이다.
  • TLS가 없다. 켜면 5.48ms에 왕복이 최소 하나 더 붙는다.
  • 클라이언트가 파이썬 스레드다. 3,000 rps 구간에서는 측정된 지연 일부가 클라이언트 자신의 스케줄링이다. 풀 크기 사이의 비교는 클라이언트가 동일하므로 유효하다.
  • 메모리는 docker stats 값이다. 공유 버퍼와 페이지 캐시 귀속이 섞여 있어 커넥션당 값은 증분이지 정확한 개별 메모리가 아니다.
  • 스윕마다 질의가 한 종류다. 섞인 워크로드는 두 곡선 사이 어딘가에 있다.
  • PgBouncer를 재지 않았다. 외부 풀러가 정점을 옮기는지는 별도 측정이다.

정리

  • 풀이 아끼는 것은 핸드셰이크다. 5.48ms 대 0.23ms이고, 질의가 쌀수록 이득이 크다.
  • 유휴 커넥션 하나가 1.5MB를 선형으로 쓴다. PostgreSQL은 커넥션마다 프로세스를 만든다.
  • 처리량은 정점을 찍고 내려온다. 질의가 무거울수록 최적 풀이 작다(점 조회 10, 서버 부하 5).
  • 정점을 넘기면 획득 대기는 줄고 총 지연은 는다. 대기가 보이는 곳에서 안 보이는 곳으로 옮겨간다.
  • p99가 처리량보다 먼저 나빠진다. 평균만 보면 꼬리의 악화를 놓친다.
  • 준비된 문장은 중앙값 22%를 줄이지만 풀러 구성이 그것을 무효로 만들 수 있다.

참고

데이터베이스 내부와 트랜잭션 장애 대응과 관측
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다