LLM WikiAccess-protected knowledge portal
← 스터디 홈
5편 · 약 20분

잠금과 장애: metadata lock, gap lock, deadlock, long transaction

잠금 문제가 장애로 이어지는 이유

MySQL 운영 장애의 상당수는 쿼리 오류나 하드웨어 문제가 아니라 잠금(lock)에서 시작된다. 배포 중에 ALTER TABLE이 멈춰 이후 SELECT까지 줄을 서고, 특정 트랜잭션이 commit을 잊은 채 열려 있어 다른 세션 전체가 blocked 상태에 빠지거나, 두 트랜잭션이 서로를 기다리다 deadlock으로 롤백되는 상황이 모두 여기에 해당한다.

잠금 문제가 다루기 어려운 이유는 원인이 하나가 아니기 때문이다. metadata lock은 DDL과 DML 사이에서 발생하고, gap lock과 next-key lock은 SELECT FOR UPDATE 같은 읽기 쿼리에서도 나타난다. deadlock은 단순히 두 트랜잭션이 동시에 같은 행을 수정할 때만 생기는 것이 아니라, 더 복잡한 접근 순서와 격리 수준이 얽혀 발생한다.

이번 장은 각 잠금 유형의 동작 원리와, 그것이 실제로 어떤 운영 문제를 만드는지에 초점을 둔다.


InnoDB 잠금의 계층 구조

MySQL Server Layer Metadata Lock (MDL) — DDL 보호, 트랜잭션 종료까지 유지 InnoDB Storage Engine Layer Intention Lock (IS/IX) Row Lock (S/X) Table-level Lock (LOCK TABLES) Record Lock 인덱스 레코드 자체 Gap Lock 레코드 사이 또는 Next-Key Lock Record + Gap 조합 Insert Intention 삽입 의도 신호 격리 수준과 잠금의 관계 REPEATABLE READ(기본): Next-Key Lock 사용 → phantom read 방지, gap lock 범위 넓음 READ COMMITTED: Record Lock 위주, gap lock 최소화 → deadlock 발생 줄어들 수 있음
InnoDB 잠금 유형 계층도

Metadata Lock: DDL이 DML을 막는 구조

Metadata Lock(MDL)은 MySQL Server Layer에서 관리하는 테이블 구조 보호 잠금이다. 트랜잭션이 테이블을 사용하는 순간 MySQL은 해당 테이블에 shared MDL을 자동으로 획득하고, 트랜잭션이 commit 또는 rollback될 때까지 유지한다. DDL은 exclusive MDL을 필요로 하므로, 오래된 트랜잭션이 shared MDL을 들고 있으면 DDL이 기다린다.

문제는 여기서 생긴다. DDL이 exclusive MDL을 요청하면 pending 상태로 대기열에 들어가고, 그 뒤에 들어오는 새 SELECT나 DML도 pending DDL 뒤에서 기다리게 된다. 결과적으로 오래된 세션 하나 때문에 테이블 전체가 멈춘 것처럼 보이는 현상이 발생한다.

-- MDL 대기 세션 확인 (Performance Schema 활성화 필요)
SELECT
    r.OBJECT_SCHEMA,
    r.OBJECT_NAME,
    r.LOCK_TYPE,
    r.LOCK_STATUS,
    t.PROCESSLIST_ID,
    t.PROCESSLIST_TIME,
    t.PROCESSLIST_INFO
FROM performance_schema.metadata_locks r
JOIN performance_schema.threads t
  ON r.OWNER_THREAD_ID = t.THREAD_ID
WHERE r.LOCK_STATUS = 'PENDING'
   OR r.LOCK_TYPE = 'EXCLUSIVE'
ORDER BY t.PROCESSLIST_TIME DESC;

-- 오래 열린 트랜잭션 찾기
SELECT
    trx_id,
    trx_state,
    trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_seconds,
    trx_mysql_thread_id
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 30
ORDER BY trx_seconds DESC;

운영 판단은 간단하다. DDL 실행 전에 반드시 오래된 트랜잭션과 열린 세션을 확인하고, 필요하면 kill한다. KILL 세션ID는 롤백을 포함하므로 실행 전에 해당 세션이 무엇을 하고 있는지 확인해야 한다.


Gap Lock과 Next-Key Lock: 팬텀 읽기 방지의 비용

InnoDB REPEATABLE READ에서 기본 잠금 전략은 Next-Key Lock이다. Next-Key Lock은 인덱스 레코드에 대한 record lock과, 해당 레코드 직전 gap에 대한 gap lock을 결합한 것이다. 팬텀 읽기(같은 범위 쿼리가 두 번 실행됐을 때 중간에 다른 트랜잭션이 행을 삽입해 결과가 달라지는 현상)를 방지하는 것이 목적이다.

Gap lock만 단독으로 보면, 특정 인덱스 값 사이의 공간을 잠가 다른 트랜잭션이 그 공간에 새 행을 삽입하지 못하게 한다. Gap lock은 같은 gap에 여러 트랜잭션이 동시에 가질 수 있다는 점에서 record lock과 다르다. 충돌하는 것은 gap에 삽입하려는 insert intention lock이다.

-- 예시: id 10과 20 사이에 gap lock이 걸리는 경우
-- T1이 REPEATABLE READ에서
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;

-- T2는 id=15 삽입을 시도하면 T1의 gap lock과 충돌해 기다린다
INSERT INTO orders (id, status) VALUES (15, 'new');
-- Lock wait timeout exceeded

Gap lock이 문제가 되는 흔한 시나리오는 두 가지다.

범위 조건 + 삽입 충돌: WHERE status = 'pending' 처럼 인덱스 범위 조건으로 SELECT FOR UPDATE를 하면 해당 범위 전체에 next-key lock이 걸린다. 동시에 다른 세션이 그 범위에 해당하는 행을 삽입하려 하면 대기하거나 deadlock이 발생한다.

존재하지 않는 행 조회: WHERE id = 999처럼 존재하지 않는 레코드를 SELECT FOR UPDATE로 조회하면 해당 gap에 gap lock이 걸린다. 다른 세션도 같은 시도를 하면 두 세션 모두 gap lock을 들고, 각자 insert intention lock을 기다리다 deadlock이 생길 수 있다.

격리 수준을 READ COMMITTED로 낮추면 gap lock이 거의 제거되고 record lock만 사용하므로 이런 충돌이 크게 줄어든다. 단, phantom read는 허용하게 되고, binary log format은 ROW여야 한다.


Deadlock: 탐지, 분석, 대응

Deadlock은 두 트랜잭션이 서로 상대방이 들고 있는 잠금을 기다리는 상황이다. MySQL InnoDB는 deadlock을 자동으로 탐지해 한 트랜잭션을 선택해 롤백한다. 기준은 주로 undo log 크기가 작은 쪽, 즉 덜 진행된 트랜잭션이다.

-- 마지막 deadlock 상세 정보 (LATEST DETECTED DEADLOCK 섹션)
SHOW ENGINE INNODB STATUS\G

출력에서 중요하게 봐야 할 항목은 다음과 같다.

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 12345678, ACTIVE 0 sec starting index read
...
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 45 n bits 72 index PRIMARY
...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index PRIMARY ...

*** (2) TRANSACTION:
...
*** (2) HOLDS THE LOCK(S):
...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
...

*** WE ROLL BACK TRANSACTION (2)

분석 절차는 세 단계다.

  1. 어떤 트랜잭션이 어떤 lock을 들고 있는가 (HOLDS THE LOCK(S))
  2. 무엇을 기다리는가 (WAITING FOR THIS LOCK TO BE GRANTED)
  3. 두 트랜잭션의 접근 순서가 교차하는 지점이 어디인가

Deadlock의 가장 흔한 패턴은 두 트랜잭션이 같은 테이블의 행들을 다른 순서로 접근하는 것이다. 테이블 A → 테이블 B 순서로 잠그는 트랜잭션과 B → A 순서로 잠그는 트랜잭션이 있으면 반드시 교착이 생길 수 있다. 접근 순서를 통일하는 것이 근본적인 해결책이다.

-- Deadlock 모니터링: 발생 횟수 추적
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

-- innodb_deadlock_detect: 기본 ON (탐지 즉시 롤백)
-- 끄면 innodb_lock_wait_timeout까지 기다린 뒤 타임아웃
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

Long Transaction: 모든 잠금 문제의 증폭기

Long transaction은 오래 열려 있는 트랜잭션을 말한다. 그 자체가 장애를 만드는 것은 아니지만, 다른 문제를 증폭시킨다.

  • MDL 충돌 증폭: 오래된 트랜잭션이 shared MDL을 들고 있으면 DDL이 기다리고, DDL 대기가 뒤의 모든 DML을 막는다.
  • Undo log 증가: 열린 트랜잭션이 오래될수록 그 시작 시점 이후의 변경 버전이 모두 undo에 남아야 한다. 대규모 batch 트랜잭션은 undo tablespace를 수십 GB까지 키울 수 있다.
  • Replica lag 연쇄: primary에서 long transaction이 commit되면 그 트랜잭션의 모든 변경이 한꺼번에 binlog에 기록되고, replica는 이것을 순차적으로 적용한다.
  • Deadlock 확률 증가: 트랜잭션이 잠금을 들고 있는 시간이 길수록 다른 트랜잭션과 충돌할 확률이 높아진다.
-- Long transaction 탐지 쿼리
SELECT
    t.trx_id,
    t.trx_state,
    t.trx_started,
    TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS running_sec,
    t.trx_rows_locked,
    t.trx_rows_modified,
    p.USER,
    p.HOST,
    LEFT(p.INFO, 120) AS query_info
FROM information_schema.innodb_trx t
JOIN information_schema.PROCESSLIST p
  ON t.trx_mysql_thread_id = p.ID
WHERE TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) > 60
ORDER BY running_sec DESC;

애플리케이션 레벨에서 long transaction을 줄이는 방법은 다음과 같다.

  • DB 작업 밖에서 오래 걸리는 연산(API 호출, 파일 처리 등)을 하고 트랜잭션은 짧게 유지
  • 큰 batch 작업은 청크 단위로 나눠 커밋
  • autocommit = 1 환경에서 불필요한 명시적 트랜잭션 열지 않기
  • connection pool에서 idle connection이 자동으로 열린 트랜잭션을 들고 있는지 모니터링

잠금 문제 진단 체계

잠금 관련 장애가 발생했을 때 빠르게 확인할 체크리스트를 정리하면 다음과 같다.

-- 1. 현재 대기 중인 lock
SELECT * FROM sys.innodb_lock_waits;

-- 2. Lock 상세 정보
SELECT
    r.trx_id AS waiting_trx,
    r.trx_mysql_thread_id AS waiting_thread,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx,
    b.trx_mysql_thread_id AS blocking_thread,
    b.trx_query AS blocking_query
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;

-- 3. 전체 잠금 상태 스냅샷
SHOW ENGINE INNODB STATUS\G

-- 4. 메타데이터 잠금
SELECT * FROM performance_schema.metadata_locks
WHERE LOCK_STATUS = 'PENDING';

잠금 장애 대응에서 KILL은 마지막 수단이 아니라 판단 후에 쓰는 도구다. 어떤 세션이 blocking하고 있는지, 그 세션이 수행 중인 작업이 무엇인지, kill하면 롤백이 발생하는지 확인한 뒤 실행한다. 특히 long batch 트랜잭션을 kill하면 롤백 시간이 트랜잭션 실행 시간보다 길 수 있다.

-- 세션 상세 확인
SHOW FULL PROCESSLIST;
SELECT * FROM information_schema.PROCESSLIST WHERE ID = 12345;

-- 세션 종료 (롤백 포함)
KILL 12345;
-- 쿼리만 종료 (트랜잭션은 유지)
KILL QUERY 12345;

References

  • MySQL 8.4 Reference Manual, "InnoDB Locking": https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html
  • MySQL 9.7 Reference Manual, "Metadata Locking": https://dev.mysql.com/doc/refman/9.7/en/metadata-locking.html
  • MySQL 9.7 Reference Manual, "Deadlocks in InnoDB": https://dev.mysql.com/doc/en/innodb-deadlocks.html
  • MySQL 8.4 Reference Manual, "Locks Set by Different SQL Statements in InnoDB": https://dev.mysql.com/doc/refman/8.4/en/innodb-locks-set.html
  • MySQL Blog Archive, "InnoDB Data Locking – Part 3 Deadlocks": https://dev.mysql.com/blog-archive/innodb-data-locking-part-3-deadlocks/
  • Percona Blog, "InnoDB Gap Locks": https://www.percona.com/blog/innodbs-gap-locks/
  • OneUptime Blog, "How to Understand Next-Key Locks in MySQL InnoDB": https://oneuptime.com/blog/post/2026-03-31-mysql-innodb-next-key-locks/view
  • OneUptime Blog, "How to Analyze Deadlocks with SHOW ENGINE INNODB STATUS": https://oneuptime.com/blog/post/2026-03-31-mysql-analyze-deadlocks-show-engine-innodb-status/view