Zettelkasten

동시성 카운터 업데이트는 Fanout 패턴으로 row lock 경합을 분산한다

·수정 2026.05.23·수정 1

요약

  • 단일 행 카운터를 N개 슬롯으로 쪼개서 동시 쓰기를 가능하게 만드는 패턴
  • 페이지뷰·좋아요처럼 같은 행에 UPDATE가 몰리는 경우 row-level lock이 병목이 되는데, fanout으로 lock 경합 면적을 줄인다

본문

문제: 단일 행은 lock 한 줄

DB는 UPDATE 시 행(row) 단위로 lock을 건다. count = count + 1은 read-modify-write라서 lost update를 막으려면 lock이 필수다.

/home 카운트가 1개 행이면:

요청 1000개 → 모두 같은 행 (url='/home') 노림
              ↓
       한 명만 lock 획득, 나머지 999개 대기
              ↓
       순차 처리 (직렬화)

처리량 한계 = 1개 트랜잭션 처리 속도.

해결: 1000개를 N개로 쪼개서 기록

같은 /home이라도 (url='/home', fanout=0) ~ (url='/home', fanout=99)PK/UNIQUE 인덱스 상 서로 다른 행 → lock이 따로 걸린다.

요청 1000개 → 랜덤하게 100개 슬롯에 분배
              ↓
       슬롯당 평균 10개씩 경쟁
              ↓
       100개 슬롯 동시 lock → 병렬 처리

이론상 처리량 N배.

구현

테이블:

CREATE TABLE pageviews_counts (
  url varchar(255) PRIMARY KEY,
  fanout smallint NOT NULL,
  count int
);
CREATE UNIQUE INDEX pageviews_slots ON pageviews (url, fanout);

UPSERT (PostgreSQL):

INSERT INTO pageviews_counts (url, fanout, count)
VALUES ('/home', FLOOR(RANDOM() * 100), 1)
ON CONFLICT (url, fanout) DO UPDATE
  SET count = pageviews_counts.count + excluded.count;

조회 시: SELECT SUM(count) FROM pageviews_counts WHERE url = '/home'

왜 fanout 슬롯끼리는 안 잠기나 — Next-Key Lock 예외

의심이 들 수 있다: InnoDB의 FOR UPDATE는 기본적으로 레코드 + 앞의 간격을 함께 잠그는 Next-Key Lock인데, 그러면 fanout=4를 잠글 때 fanout=3, 5도 같이 잠겨야 하는 거 아닌가?

답: 유니크 인덱스 + 정확 매칭 + 존재하는 행의 3박자가 맞으면 Gap Lock이 떨어져 나가고 Record Lock만 남는다. Fanout 패턴은 정확히 이 예외에 의존한다.

-- (url, fanout) UNIQUE INDEX, 양쪽 다 = 매칭, 행 존재
UPDATE pageviews_counts SET count = count + 1
WHERE url = '/home' AND fanout = 4

→ Record Lock만. fanout=3, 5는 다른 트랜잭션이 동시 UPDATE 가능.

반대로 다음은 fanout을 무의미하게 만든다:

-- 범위 조회 → Next-Key Lock 활성
SELECT * FROM pageviews_counts
WHERE url = '/home' AND fanout > 3
FOR UPDATE;

→ fanout=4, 5, 6... 전부 + 사이 간격까지 잠긴다. Fanout 패턴을 쓸 땐 항상 단일 슬롯 정확 매칭으로 접근해야 한다.

조건 잠금
UNIQUE INDEX + = 매칭 + 행 존재 Record Lock만
범위 조회 (>, <, BETWEEN) Next-Key Lock
행이 없는 슬롯에 INSERT Insert Intention + Gap Lock (잠시)
READ COMMITTED 격리 수준 항상 Record Lock만

행이 없는 슬롯에 처음 INSERT 할 때만 잠깐 Gap Lock이 걸리므로, 안전하게 가려면 슬롯을 0~N까지 미리 다 만들어두는 것이 깨끗하다.

SKIP LOCKED로 추가 최적화

랜덤이 운 나쁘게 잠긴 슬롯을 골라도 대기하지 않게:

SELECT fanout
FROM pageviews_counts
WHERE url = '/home'
LIMIT 1
FOR UPDATE SKIP LOCKED;

이미 잠긴 슬롯을 건너뛰고 안 잠긴 슬롯을 잡는다 → 대기 시간 0.

Trade-off

측면 효과
쓰기 처리량 ↑ (lock 경합 N배 감소)
읽기 매번 SUM 필요 → 비용 ↑
저장 행 수 N배
정합성 최종값 동일, 다만 실시간 조회 시 SUM 부담

보통 백그라운드 잡으로 주기적 집계해서 메인 테이블에 합산값 저장 → 읽기 비용 상쇄.

초당 수만 건이면 DB fanout보다 Redis 같은 인메모리 카운터가 적합하다.

핵심 통찰

Fanout은 "lock을 없애는" 게 아니라 "lock 충돌 면적을 줄이는" 트릭이다. 정합성은 DB가 lock으로 보장하고, 우리는 그 lock의 입자를 더 잘게 쪼개는 것뿐.

참고