Published on

MySQL HOT row 락 폭주 해결 - Redis+큐 설계

Authors

서버가 잘 버티다가도 특정 순간에 UPDATE ... WHERE id = ? 같은 단일 행 업데이트가 폭주하면, MySQL은 결국 HOT row(핫 로우) 를 중심으로 row lock 경합이 커지고 지연이 연쇄적으로 전파됩니다. 이 글은 단순히 인덱스를 더 붙이거나 격리수준을 바꾸는 수준을 넘어, Redis + 큐(Queue) + 워커(Worker) 로 쓰기 병목을 분산·완충해 락 폭주를 구조적으로 제거하는 방법을 다룹니다.

핵심 아이디어는 간단합니다.

  • MySQL이 직접 동시 쓰기를 맞지 않도록, 먼저 Redis에서 집계/중복제거/버퍼링
  • 큐에 작업을 적재하고 워커가 키 단위로 직렬 처리
  • DB에는 최종 상태만 반영(또는 배치로 반영)해 lock hold time을 최소화

관련 장애가 복제 지연으로 번지는 경우도 많습니다. 레플리카 지연 튜닝은 별도 글인 MySQL Replication Lag 폭증 원인·튜닝 7단계도 함께 참고하면 전체 병목 흐름을 이해하는 데 도움이 됩니다.

HOT row 락 폭주가 왜 생기나

전형적인 패턴

  • 포인트/마일리지 적립: user_balance 한 행을 계속 UPDATE
  • 재고 차감: inventory 특정 SKU 한 행에 주문이 집중
  • 좋아요/조회수 카운터: post_stats 한 행을 계속 증가
  • 좌석/쿠폰 선착순: coupon_stock 한 행을 여러 트랜잭션이 경쟁

MySQL(InnoDB)에서 같은 레코드를 동시에 갱신하려 하면, 결국 한 트랜잭션만 락을 잡고 나머지는 대기합니다. 대기 시간이 길어지면 다음이 연쇄적으로 터집니다.

  • 커넥션 풀 고갈
  • 평균 응답시간 증가, 타임아웃 증가
  • 롤백 증가(특히 데드락/락 대기 타임아웃)
  • binlog/replication 지연 증가(쓰기 지연이 누적)
  • 애플리케이션 재시도 폭주로 더 악화

단순 인덱스 튜닝으로 해결이 안 되는 이유

핫 로우는 “찾는 비용”이 아니라 “같은 행을 동시에 쓰는 경쟁”이 문제입니다. 인덱스를 아무리 최적화해도, 동일 레코드에 대한 UPDATE 는 직렬화될 수밖에 없습니다.

따라서 해결 방향은 둘 중 하나입니다.

  1. 동시 쓰기를 허용하지 않는 구조로 바꾸기(직렬화/큐)
  2. 쓰기 자체를 줄이기(집계 후 반영, 배치, 최종상태만 반영)

증상 확인: 락 경합을 빠르게 가시화하기

performance_schema 로 대기 이벤트 확인

SELECT
  event_name,
  count_star,
  sum_timer_wait/1000000000000 AS total_wait_sec
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE event_name LIKE 'wait/lock/innodb/%'
ORDER BY sum_timer_wait DESC
LIMIT 10;

현재 누가 누구를 막는지(블로킹 체인)

SELECT
  r.trx_id waiting_trx_id,
  r.trx_mysql_thread_id waiting_thread,
  b.trx_id blocking_trx_id,
  b.trx_mysql_thread_id blocking_thread,
  TIMESTAMPDIFF(SECOND, r.trx_started, NOW()) waiting_sec,
  rl.lock_table,
  rl.lock_index,
  rl.lock_type
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
JOIN information_schema.innodb_locks rl ON rl.lock_id = w.requested_lock_id
ORDER BY waiting_sec DESC;

애플리케이션 로그에서 확인할 것

  • 특정 API만 p99/p999가 급증하는지
  • DB 에러가 Lock wait timeout exceeded 또는 데드락인지
  • 재시도 로직이 있는지(있다면 폭주를 키움)

해결 전략 개요: Redis로 흡수하고, 큐로 직렬화

핫 로우 문제를 “DB 성능 문제”가 아니라 “쓰기 경로 설계 문제”로 보면 답이 보입니다.

목표

  • MySQL에 들어가는 동시 업데이트 수를 제한
  • 동일 키(예: user_id, sku_id, post_id)에 대한 업데이트를 순서 있게 처리
  • DB 트랜잭션을 짧게 유지(락 보유 시간 최소화)

권장 아키텍처

  1. API 서버는 요청을 즉시 DB에 쓰지 않고 Redis에 기록
  2. 작업을 큐에 넣음(메시지 브로커: Redis Streams, Kafka, RabbitMQ, SQS 등)
  3. 워커가 큐를 소비하면서 키 단위로 직렬 처리
  4. DB에는 최종 반영(가능하면 UPSERT 또는 누적 반영)

아웃박스(Outbox) 패턴을 함께 쓰면 “큐 적재와 DB 커밋의 원자성”을 더 강하게 만들 수 있습니다. 이벤트 유실/중복 방지는 MSA 트랜잭션 아웃박스 패턴으로 중복·유실 막기도 같이 읽어보면 좋습니다.

패턴 1: 카운터/누적값은 Redis에서 먼저 합치고 DB는 배치 반영

언제 쓰나

  • 조회수, 좋아요, 다운로드 수 같은 “정확히 실시간일 필요는 없지만 결국 맞아야 하는 값”
  • 초당 수백~수천 번 동일 키가 업데이트되는 경우

Redis에 누적

# 예: post:123 조회수 1 증가
INCRBY post:view:123 1
# TTL로 메모리 회수(선택)
EXPIRE post:view:123 3600

워커가 주기적으로 DB에 반영(플러시)

-- 예: 10초마다 워커가 누적치를 읽어 DB에 더함
UPDATE post_stats
SET view_count = view_count + ?
WHERE post_id = ?;

구현 팁

  • Redis에서 값을 읽고 0으로 만드는 동작은 원자적으로 처리해야 합니다.
  • Redis GETDEL(버전에 따라 지원) 또는 Lua 스크립트를 사용합니다.
-- key의 값을 읽고 삭제
local v = redis.call('GET', KEYS[1])
if v then
  redis.call('DEL', KEYS[1])
  return v
end
return '0'

이 방식의 장점은 DB에 UPDATE 가 “매 요청”이 아니라 “주기적 배치”로 바뀌어, 락 경합이 사실상 사라진다는 점입니다.

정확도/일관성 트레이드오프

  • Redis에 누적된 값이 DB에 반영되기 전까지는 DB 조회가 최신이 아닙니다.
  • 해결책: 읽기 경로에서 DB 값 + Redis 누적값 을 합산해서 반환하거나, 대시보드/통계만 “지연 허용”으로 설계합니다.

패턴 2: 재고/잔액처럼 강한 제약이 있으면 “큐로 직렬화 + DB는 단일 트랜잭션”

재고 차감, 잔액 차감은 “나중에 합치기”가 위험합니다. 대신 동일 키에 대한 쓰기를 큐에서 직렬화합니다.

핵심: 키 파티셔닝

  • 메시지 키를 sku_id 또는 user_id 로 설정
  • 같은 키는 항상 같은 파티션/컨슈머로 가게 하여 순서 보장

Kafka를 예로 들면 message key = sku_id 로 설정하고 파티션 수를 늘리면, SKU 단위로는 직렬 처리되면서 SKU 간에는 병렬 처리가 됩니다.

워커의 DB 업데이트는 “조건부 업데이트”로 짧게

-- 재고 차감: 남은 재고가 충분할 때만 감소
UPDATE inventory
SET stock = stock - ?
WHERE sku_id = ?
  AND stock >= ?;

애플리케이션에서는 affected rows가 1인지 확인합니다.

  • 1이면 성공
  • 0이면 재고 부족

이 쿼리는 락을 잡더라도 단일 행, 짧은 시간에 끝나며, 무엇보다 “동일 SKU에 대한 동시성”이 큐에서 이미 정리되어 DB에 폭주하지 않습니다.

Redis는 어디에 쓰나

  • API 서버에서 “요청을 즉시 수락”하고 큐에 넣기 전/후 상태를 캐시
  • 중복 요청 방지(멱등 키)
  • 워커가 처리 중인 키에 대한 소프트 락(선택)

멱등성: Redis로 중복 처리/재시도 폭주를 막기

락 폭주 상황에서 재시도는 불을 붓습니다. 큐 기반 구조에서는 멱등성이 필수입니다.

요청 멱등 키 예시

  • order_id
  • request_id(클라이언트가 생성)
  • user_id + timestamp bucket + nonce

Redis SET NX 로 “처리한 적 있음”을 기록합니다.

# 24시간 동안 같은 request_id는 한 번만 처리
SET idemp:req:9f3a... 1 NX EX 86400
  • 성공이면 큐에 적재
  • 실패면(이미 존재) 이전 결과를 조회하거나 “이미 처리됨” 응답

큐 선택지: Redis Streams로 빠르게 시작하기

운영 환경에 Kafka가 없다면 Redis Streams는 도입 장벽이 낮습니다.

생산자(Producer)

XADD stock-events * sku_id 123 qty 1 request_id 9f3a...

소비자 그룹(Consumer Group)

XGROUP CREATE stock-events stock-workers $ MKSTREAM
XREADGROUP GROUP stock-workers worker-1 COUNT 10 BLOCK 2000 STREAMS stock-events >

처리 성공 후 ACK

XACK stock-events stock-workers 171234567890-0

주의할 점

  • 워커 장애 시 PEL(Pending Entries List) 재처리를 설계해야 합니다.
  • 장기 미처리 메시지의 재할당은 XAUTOCLAIM 등을 고려합니다.

워커에서의 동시성 제어: “키 단위 직렬화” 구현

큐가 파티셔닝을 보장하지 않는 경우, 워커 내부에서 키별로 직렬화가 필요합니다.

간단한 방법: Redis 분산 락(소프트 락)

  • 동일 sku_id 를 동시에 처리하려는 워커가 있으면 한쪽이 대기/재시도
SET lock:sku:123 worker-1 NX PX 3000

락을 얻은 워커만 DB 업데이트를 수행하고, 끝나면 락을 해제합니다.

DEL lock:sku:123

락 해제 안전성

락 값에 토큰을 넣고 Lua로 “내 락만 해제”를 보장하는 패턴을 권장합니다.

if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

DB 스키마/쿼리 측면 최적화: 락 시간을 더 줄이는 체크리스트

Redis+큐로 구조를 바꿔도, DB 쿼리가 비효율적이면 워커 처리량이 떨어집니다.

1) 트랜잭션을 짧게

  • 트랜잭션 내부에서 외부 API 호출 금지
  • 같은 트랜잭션에서 불필요한 SELECT 최소화

2) 필요한 인덱스는 반드시

  • WHERE sku_id = ?sku_id 인덱스가 없으면 레코드 탐색이 길어지고 락 유지 시간이 늘어납니다.

3) SELECT ... FOR UPDATE 남용 금지

가능하면 “읽고-판단-쓰기” 대신 조건부 업데이트 한 방으로 처리합니다.

  • 나쁜 예: SELECT ... FOR UPDATE 로 재고 확인 후 UPDATE
  • 좋은 예: UPDATE ... WHERE stock >= ?

4) 격리수준은 문제를 숨기지 못한다

격리수준을 낮춰도 동일 행 업데이트 경합 자체는 사라지지 않습니다. 오히려 이상한 읽기 문제가 생길 수 있습니다.

운영 관점: 모니터링과 튜닝 포인트

반드시 봐야 할 지표

  • 큐 적재량(레이트), 컨슈머 처리량
  • 큐 지연(생성 시각 대비 처리 시각)
  • Redis 메모리/eviction, keyspace hit rate
  • MySQL Threads_running, Innodb_row_lock_time, Innodb_row_lock_waits
  • API p95/p99, 타임아웃 비율

백프레셔(Backpressure)

큐 지연이 임계치를 넘으면 “계속 받기”는 장애를 키웁니다.

  • API에서 429 또는 “잠시 후 재시도”를 응답
  • 특정 키에 대한 요청을 제한(예: 선착순 쿠폰)

재시도/백오프 설계는 외부 API뿐 아니라 내부 큐에도 동일하게 중요합니다. 백오프 설계 감각은 Azure OpenAI 429/503 재시도·백오프 설계 가이드에서 다룬 원칙을 그대로 적용할 수 있습니다.

예시 설계: 선착순 쿠폰(재고 1만장)에서 락 폭주 제거

기존(문제) 흐름

  • API 요청마다 MySQL에서 coupon_stock 단일 행을 UPDATE
  • 이벤트 시작 1초에 수만 요청이 몰리며 row lock 대기

개선 흐름

  1. API는 Redis에서 멱등 체크 후 큐에 이벤트 적재
  2. 워커는 coupon_id 기준으로 직렬 처리
  3. DB에는 조건부 업데이트로 차감
  4. 성공 시 발급 테이블에 insert

워커 트랜잭션 예시

START TRANSACTION;

UPDATE coupon_stock
SET remaining = remaining - 1
WHERE coupon_id = ?
  AND remaining > 0;

-- affected rows가 1일 때만 발급 기록
INSERT INTO coupon_issued(coupon_id, user_id, issued_at)
VALUES(?, ?, NOW());

COMMIT;
  • UPDATE 가 실패(0 rows)면 재고 소진으로 처리하고 ROLLBACK
  • 발급 중복 방지를 위해 coupon_id + user_id 유니크 키를 두고, 중복이면 멱등 처리

마이그레이션 전략: 한 번에 갈아엎지 않기

  1. 가장 문제 심한 HOT row 대상 1개를 선정(예: 조회수, 좋아요, 재고 중 하나)
  2. Redis 누적 또는 큐 직렬화 중 적합한 패턴을 선택
  3. “그림자 모드”로 Redis 누적만 먼저 적용하고 DB 값과 비교
  4. 트래픽의 일부만 새 경로로 라우팅(카나리)
  5. 지표 안정화 후 점진 확대

결론

MySQL HOT row 락 폭주는 대개 “DB가 느리다”가 아니라 “동시 쓰기 모델이 DB의 직렬화를 강요한다”에서 시작합니다. Redis와 큐를 사이에 두면 다음을 얻습니다.

  • MySQL에 들어가는 동시 업데이트를 구조적으로 줄임
  • 키 단위 직렬화로 경합 제거
  • 멱등성/백프레셔로 재시도 폭주 차단
  • 최종적으로 p99 지연과 타임아웃을 안정화

정확히 실시간이 필요 없는 값은 Redis에서 합치고, 강한 제약이 필요한 값은 큐로 직렬화하는 것이 실전에서 가장 재현성 높은 해법입니다.