본문 바로가기
Q&A2026년 4월 12일1분 읽기

PostgreSQL UPDATE가 트래픽 몰릴 때만 lock wait timeout으로 멈춥니다

개
개발자준호
조회 560댓글 4

운영 중인 서비스에 조회수 카운터가 있는데, 평소엔 문제없다가 피크 트래픽 때만 이런 로그가 찍히고 트랜잭션이 늘어집니다.

ERROR: canceling statement due to lock timeout
CONTEXT: while updating tuple (42,17) in relation "counters"

쿼리는 아주 단순합니다.

UPDATE counters SET value = value + 1 WHERE key = 'page_view_home';

SELECT FOR UPDATE는 안 쓰고 있고, 단건 UPDATE 하나뿐인데 왜 lock이 쌓이는지 궁금합니다. 같은 행을 여러 워커가 동시에 업데이트하는 구조라는 건 맞습니다.

환경은 PostgreSQL 16, 커넥션 풀은 pgbouncer, 워커는 20개 정도입니다.

댓글 4

김김영삼6개월 전

"단건 UPDATE니까 안전하다"는 오해가 핵심입니다. row-level lock은 UPDATE 문장이 트랜잭션 커밋될 때까지 유지되므로, 같은 행을 동시에 건드리면 뒤에 온 트랜잭션은 전부 대기 큐에 쌓입니다. 피크 시 워커 20개가 같은 row를 업데이트하면 직렬화되는 거예요.


해결 방법 3가지 (현실성 순서)
① 카운터만 Redis로 빼고 배치 sync

가장 검증된 방법입니다. Redis INCR은 atomic하고 RDB에 lock을 안 만듭니다. 1분마다 배치로 Postgres에 누적치를 flush.


-- 배치 쪽
UPDATE counters SET value = value + $delta WHERE key = $key;

② advisory lock으로 직렬화

Redis 도입이 부담이면, PostgreSQL에서 제공하는 advisory lock을 쓰세요. row lock보다 가볍고 hash로 키만 잡으면 됩니다.


SELECT pg_advisory_xact_lock(hashtext('page_view_home'));
UPDATE counters SET value = value + 1 WHERE key = 'page_view_home';

③ 샤드된 카운터

트래픽이 아주 높다면 같은 key를 N개 행으로 쪼갭니다. 읽을 땐 SUM.


-- 쓰기
UPDATE counters SET value = value + 1
WHERE key = 'page_view_home' AND shard = floor(random()*10);
-- 읽기
SELECT sum(value) FROM counters WHERE key = 'page_view_home';

또 하나, pgbouncer transaction 모드에서는 세션 단위 SET LOCAL lock_timeout 동작에 주의해야 합니다. 쿼리 단위로 명시하는 게 안전합니다.

서서버지기영호6개월 전

저는 카운터 성격의 워크로드는 그냥 RDB에서 빼는 게 맞다고 봅니다. 조회수 같은 수치는 1~2개 유실돼도 비즈니스적으로 큰 문제가 안 되니까 Redis INCR → 주기적 sync가 제일 깔끔해요. 정합성이 중요한 회계 카운터면 advisory lock + 샤드 카운터 조합을 추천합니다.

개개발자준호6개월 전

advisory lock으로 긴급 조치했고 Redis 이관 작업을 스프린트에 올렸습니다. 감사합니다.


그런데 deadlock이 실제로 생기는지 어떻게 모니터링하세요? log_lock_waits 정도만 켜놓으면 되는지요?

김김영삼개발자준호6개월 전

최소 세트는 이 정도를 권장합니다.


# postgresql.conf
log_lock_waits = on
deadlock_timeout = 1s # 이 시간 넘게 대기하면 로그 + deadlock 탐지
log_min_duration_statement = 500ms

실시간으로 확인하려면 pg_stat_activity에서 wait_event_type = 'Lock' 세션을 잡고 pg_blocking_pids()로 블로킹 체인을 추적합니다.


SELECT pid, state, wait_event_type, wait_event, query,
pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

그리고 Grafana에 pg_stat_database.deadlocks 카운터를 대시보드로 붙여놓으면 추세 관찰이 편합니다.

Ctrl+Enter로 등록