동시성 카운터 업데이트는 Fanout 패턴으로 row lock 경합을 분산한다
요약
- 단일 행 카운터를 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의 입자를 더 잘게 쪼개는 것뿐.
참고
- Lock의 critical section은 최소한으로 작게 유지해야 한다 — Fanout도 결국 lock 충돌 면적을 줄이는 같은 원리
- InnoDB는 Next-Key Lock으로 팬텀 리드를 방지한다 — row 단위 lock이 실제로 어떻게 걸리는지의 구현 배경
- 출처: https://sqlfordevs.com/concurrent-updates-locking