InnoDB 락에는 여러가지 유형이 있다. 락의 종류와 발생하는 케이스를 MySQL 8.4 버전을 기준으로 알아보자.
잠금 대상(Locking Scope)에 따른 분류
잠금 대상 | 설명 | 예시 |
---|---|---|
행 수준(Row-Level Lock) | 개별 행을 대상으로 설정되는 잠금. 주로 특정 레코드의 읽기/쓰기를 보호. | 공유(S) 락: 특정 레코드를 읽는 동안 다른 트랜잭션이 수정하지 못하도록 보호. 배타(X) 락: 특정 레코드를 수정하는 동안 다른 트랜잭션이 읽거나 수정하지 못하도록 보호. 갭 락(Gap Lock): 두 인덱스 레코드 사이의 갭을 보호하여 다른 트랜잭션이 삽입하지 못하도록 막음. |
테이블 수준(Table-Level Lock) | 테이블 전체를 대상으로 설정되는 잠금. 주로 테이블 구조적 변경 또는 읽기/쓰기 작업을 보호. | 의도 락(IS, IX): 개별 행에 대한 잠금을 설정하기 전에 테이블에 설정하여 충돌 방지. AUTO-INC 락: AUTO_INCREMENT 값을 보장하기 위해 테이블 전체에 설정. |
잠금의 용도 및 목적(Locking Purpose)에 따른 분류
잠금 용도 | 설명 | 예시 |
---|---|---|
읽기 보호(Read Protection) | 트랜잭션이 레코드를 읽는 동안 다른 트랜잭션이 해당 레코드를 수정하는 것을 방지. | 공유(S) 락: 읽기 작업 중 다른 트랜잭션이 수정하지 못하도록 보호. |
쓰기 보호(Write Protection) | 트랜잭션이 레코드를 수정하는 동안 다른 트랜잭션이 해당 레코드를 읽거나 수정하지 못하도록 보호. | 배타(X) 락: 수정 중일 때 다른 트랜잭션이 읽거나 수정하지 못하도록 보호. |
삽입 제어(Insert Control) | 레코드 삽입 시 삽입하려는 레코드 간의 충돌을 방지하고 동시성을 관리. | 갭 락(Gap Lock): 레코드 간의 갭에 새로운 레코드가 삽입되는 것을 방지. 삽입 의도 잠금(Insert Intention Lock): 여러 트랜잭션이 동시에 삽입 시 서로 차단되지 않도록 관리. |
InnoDB는 row-level 수준의 락을 구현하며, 두 가지 유형의 락이 있다.
트랜잭션 T1이 행 r에 대해 공유(S) 락을 보유하고 있을 때, 트랜잭션 T2가 r에 대해 락을 요청하면, T2가 S 락을 요청할 경우 즉시 허용된다. 결과적으로 T1과 T2는 모두 r에 대해 S 락을 보유하게 된다. 그러나 T2가 X 락을 요청할 경우 즉시 허용되지 않는다.
만약 T1이 배타(X) 락을 보유하고 있다면, T2는 r에 대한 락을 즉시 획득하지 못하며, T1이 락을 해제할 때까지 대기해야 한다.
공유(S) 락은 중복해서 허용되지만, 배타(X) 락은 중복될 수 없을까??
공유(S) 락이 중복해서 허용되는 이유는 공유 락이 읽기 작업만 허용하기 때문이다. 여러 트랜잭션이 동시에 동일한 데이터를 읽을 수 있지만, 데이터를 읽는 동안 다른 트랜잭션이 그 데이터를 수정할 필요가 없다. 즉, 읽기 작업은 데이터의 무결성을 손상시키지 않으므로 동시에 여러 트랜잭션이 안전하게 읽기 작업을 수행할 수 있다.
반면에, 배타(X) 락은 쓰기 작업을 허용하기 때문에 중복될 수 없다. 데이터에 대한 쓰기 작업은 그 데이터를 수정하고 잠재적으로 무결성에 영향을 미칠 수 있으므로, 한 번에 하나의 트랜잭션만 배타 락을 보유해야 한다. 만약 여러 트랜잭션이 동시에 배타 락을 보유한다면, 각 트랜잭션이 동시에 데이터를 수정하려고 할 것이며, 이는 데이터의 일관성 및 무결성을 손상시킬 수 있다.
InnoDB는 row lock 과 table lock이 공존할 수 있는 다중 락킹을 지원한다. 예를 들어, LOCK TABLES ... WRITE 명령어는 테이블에 배타(X) 락을 설정한다. 이를 실용적으로 만들기 위해 의도 락을 사용하며, 테이블 수준에서 트랜잭션이 나중에 특정 행에 대해 어떤 락(공유 또는 배타)을 설정할 것임을 나타낸다.
Intention Locks은 트랜잭션이 개별 행에 대한 공유(S) 또는 배타(X) 락을 설정할 의도가 있음을 테이블 수준에서 미리 알리기 위한 것이다. 이로 인해, 테이블 전체에 락을 설정하려는 트랜잭션과 충돌을 감지할 수 있다.
예를 들어, SELECT ... FOR SHARE는 IS 락을, SELECT ... FOR UPDATE는 IX 락을 설정한다.
X | IX | S | IS | |
---|---|---|---|---|
X | 충돌 | 충돌 | 충돌 | 충돌 |
IX | 충돌 | 호환 | 충돌 | 호환 |
S | 충돌 | 충돌 | 호환 | 호환 |
IS | 충돌 | 호환 | 호환 | 호환 |
의도 락은 전체 테이블 요청(LOCK TABLES ... WRITE 등)을 제외하고는 다른 트랜잭션을 차단하지 않는다. 주요 목적은 누군가가 테이블의 행을 잠그거나 잠그려 한다는 사실을 표시하는 것이다.
Intention Locks 은 왜 테이블에 먼저 걸어야 할까?
트랜잭션이 개별 행에 배타 락을 걸기 전에 테이블에 의도 배타 락(IX)을 설정하면, 다른 트랜잭션이 테이블 전체에 대해 공유(S) 락이나 배타(X) 락을 걸려고 할 때 충돌을 감지할 수 있다. 이를 통해 다른 트랜잭션이 테이블 전체에 대한 락을 요청하는 상황에서 미리 경고할 수 있다.
만약 행에 락을 바로 걸 수 있다면 어떻게 될까?
한 트랜잭션이 행에 락을 걸고 나중에 다른 트랜잭션이 테이블 전체에 락을 걸려고 할 때, 테이블 락을 걸지 못할 수 있다. 왜냐하면, 특정 행에 이미 락이 걸려 있기 때문에 테이블 락과의 충돌이 발생할 수 있다.
개별 행에 바로 락을 걸 경우, 테이블 전체에 락이 필요할 때마다 테이블의 각 행을 검사해야 할 것이다. 이는 트랜잭션이 많아질수록 락 충돌 탐지 과정이 복잡해지고 성능이 저하될 수 있다.
만약 여러 트랜잭션이 테이블의 다른 행에 각각 배타 락을 걸고, 동시에 테이블 전체에 락을 걸려고 한다면, 교착 상태(deadlock)가 발생할 가능성이 높아진다. 이는 트랜잭션 간의 복잡한 락 대기와 충돌을 초래할 수 있다.
AUTO_INCREMENT 속성을 갖는 테이블에 데이터를 삽입할 때 설정되는 테이블 수준의 잠금이다. 이 잠금은 자동 증가되는 값이 중복되거나 충돌하지 않도록 보장하는 역할을 한다.
테이블 잠금이라면, 동시성이 문제가 있을 것 같은데?
AUTO-INC Lock이 테이블 수준의 잠금이기 때문에, 기본적으로는 여러 트랜잭션이 동시에 AUTO_INCREMENT 값을 삽입하려고 할 때, 잠금으로 인해 동시성이 떨어질 수 있다. 이는 각 트랜잭션이 순차적으로 AUTO_INCREMENT 값을 할당받아야 하기 때문에 발생하는 문제이다.
MySQL에서는 이 문제를 해결하기 위해 AUTO-INC Lock 모드를 조정할 수 있는 설정을 제공한다.
이 설정은 innodb_autoinc_lock_mode 시스템 변수를 통해 조정할 수 있으며, 이 변수는 세 가지 모드를 제공한다.
1. 모드 0 (전통적 모드): 모든 삽입 작업이 테이블 수준에서 잠금을 사용하여 순차적인 AUTO_INCREMENT 값을 보장합니다. 가장 안전하지만 동시성 성능이 가장 낮다.
2. 모드 1 (간격 모드): 트랜잭션이 완료되지 않았더라도 삽입할 때 AUTO_INCREMENT 값을 미리 할당하고, 각 트랜잭션이 다른 트랜잭션과 독립적으로 삽입을 수행할 수 있다. 동시성은 높아지지만 AUTO_INCREMENT 값의 연속성은 보장되지 않을 수 있다.
3. 모드 2 (비예측적 모드): 잠금을 최소화하여 가장 높은 동시성을 제공합니다. 각 트랜잭션이 AUTO_INCREMENT 값을 독립적으로 할당받아 삽입한다. 연속적인 값은 보장되지 않으며, 일부 값이 건너뛰어질 수 있다. 가장 높은 동시성 성능을 제공하지만 AUTO_INCREMENT 값의 예측 가능성은 줄어든다.
아래 명령어로 모드를 확인할 수 있다.
SHOW VARIABLES LIKE 'innodb_autoinc_lock_mode';
Record Lock은 인덱스 레코드에 대한 락이다. 예를 들어, SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE는 t.c1 = 10인 행을 다른 트랜잭션이 수정하거나 삭제하지 못하도록 한다.
Record Lock은 테이블에 인덱스가 없어도 InnoDB가 숨겨진 클러스터 인덱스를 생성하여 처리한다.
Gap Lock은 인덱스 레코드 사이 또는 첫 번째 또는 마지막 인덱스 레코드 앞뒤의 갭에 대한 락이다. 예를 들어, SELECT c1 FROM t WHERE c1 BETWEEN 10 AND 20 FOR UPDATE는 다른 트랜잭션이 t.c1에 15를 삽입하지 못하게 한다.
Gap Lock은 동시성 제어와 성능 간의 절충점을 나타내며, 일부 트랜잭션 격리 수준에서만 사용된다. 유니크 인덱스를 사용하여 특정 행을 검색하는 경우 갭 락이 필요하지 않다.
Next-Key Lock은 Record Lock과 Gap Lock을 결합한 것이다. Next-Key Lock은 인덱스 레코드뿐만 아니라 그 이전 갭도 잠근다.
이 구간은 REPEATABLE READ 트랜잭션 격리 수준에서 사용된다. 이 락은 팬텀 행이 발생하는 것을 방지한다.
InnoDB의 Next-Key Lock 메커니즘의 예외적인 케이스이다. 일반적인 Next-Key Lock은 인덱스 레코드와 그 앞의 갭을 잠가서, 트랜잭션 중에 다른 트랜잭션이 해당 레코드 앞에 있는 공간에 데이터를 삽입하지 못하게 한다. 하지만 Supremum 잠금은 인덱스 페이지에서 마지막 레코드 이후의 가상의 공간(인덱스 끝 부분)을 잠근다.
이 Supremum 잠금은 실제로 존재하지 않는 가상의 “Supremum” 레코드에 적용되는 것으로, 가장 마지막 인덱스 레코드 뒤에 데이터를 삽입하는 것을 방지하는 역할을 한다. Supremum 잠금은 새로운 레코드가 인덱스 페이지의 마지막에 추가되는 것을 막아, 트랜잭션 간의 충돌을 방지하는 데 사용된다.
기본적으로 InnoDB는 REPEATABLE-READ 트랜잭션 격리 수준에서 동작한다. 이 경우 InnoDB는 검색 및 인덱스 스캔에 대해 넥스트 키 락을 사용하여 팬텀 레코드를 방지한다.
“Supremum” 레코드가 스캔 대상에 포함되었기 때문이다.
Insert Intention Lock은 삽입 작업 전에 설정되는 Gap Lock으로, 키가 겹치지 않는다면 대기하지 않고 실행될 수 있도록하는 잠금이다. Insert Intention Lock이 없다면, 하나의 트랜잭션이 완료될 때까지 다른 트랜잭션은 삽입할 수 없게 되어 동시성이 크게 떨어진다. 이 락은 갭 락이 있는 상황에서도 삽입을 동시에 처리할 수 있도록 해준다.
mysql> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
mysql> INSERT INTO child (id) values (90),(102);
mysql> START TRANSACTION;
mysql> SELECT * FROM child WHERE id > 100 FOR UPDATE;
-- 클라이언트 B
START TRANSACTION;
INSERT INTO child (id) VALUES (95);
-- 클라이언트 C
START TRANSACTION;
INSERT INTO child (id) VALUES (97);
클라이언트 B가 id = 95, 클라이언트 C가 id = 97로 삽입을 시도한다.