동시성 이슈를 제어하는 프로젝트를 진행하던 중 JPA의 Pessimistic lock 을 활용해서 연결된 MySQL DB의 X-lock을 통해 동시성 문제를 해결하려고 하였다.
콘서트 예약 환경에서 어떤 공연(show
)의 특정 좌석(seat
)에 대한 예약이 존재하는지 확인하고, 존재하지 않는다면 예약 데이터를 생성하는 것이 주 목적이었다.
동시성 제어를 위해 의도한 흐름은 다음과 같았다.
1. 특정 조건(show_id, seat_id)의 예약 record
가 존재하는 지 확인한다. (select for update
)
2. 존재하지 않는다면 해당 조건으로 record
를 생성한다. (비즈니스 로직 실행)
3. 존재한다면 비즈니스 로직을 실행하지 않고 트랜잭션을 종료한다.
위의 의도대로 동시성 문제가 해결되기를 바랬지만 테스트 진행 시 DeadLock
문제가 발생했고, 원인을 찾는데 한참 시간을 소모했다.
결국, DeadLock
이 발생한 핵심 원인은 MySQL의 Gap-lock
때문에 발생함을 알게 되었다. Gap-lock
이 궁금해서 이것저것 찾아보다가 Lock
매커니즘에 대해 많은 궁금증을 가지게 되었고, 해당 내용을 공유하고자 한다.
해당 포스팅에서는 InnoDB
의 Locking
방법론과 해당 방식으로 인해 발생할 수 있는 문제 상황에 대해 설명하려고 한다.
InnoDB Lock
에 대한 세부적인 내용은 아래 MySQL 공식 document에서 찾아볼 수 있고, 이번 포스팅에서 설명하는 내용 역시 해당 출처를 기반으로 작성되었다.
가장 흔하게 거론되고 알고 있는 Lock의 종류는 S-lock
과 X-lock
두 개일 것이다. 하지만, InnoDB는 격리수준을 구현하기 위해 더 많은 종류의 Lock
을 제공하며, 해당 부분의 작동 방식에 대해서 조금 설명하려고 한다.
특정 Row
에 대한 트랜잭션의 접근을 제어하는 lock 방법으로 위에서 언급한 두 가지 방식이 있다.
실제 해당 방식의 lock은 특정 row에 대해서 걸리게 된다.
S-lock(Shared Lock)
: 해당 lock을 소유하고 있는 트랜잭션이 특정 row를 읽을 수 있도록 한다.
X-lock(eXclusive Lock)
: 해당 lock을 소유하고 있는 트랜잭션이 특정 row에 대해 update, delete 할 수 있도록 한다.
간단하게 생각하면 InnoDB
는 트랜잭션 내부 프로세스 중 특정 row
를 읽는(select
) 쿼리가 존재하는 경우 s-lock
을 취득하고 나서 쿼리를 수행하게 된다는 뜻이다. (수정에 대해서 x-lock
도 마찬가지이다)
S-lock
의 경우 이름에서 알 수 있듯, 읽기를 위한 공유 lock이다. 따라서 만약 트랜잭션 T1이 특정 row에 대한 S-lock
을 가지고 있더라도 다른 트랜잭션 T2가 S-lock
을 취득할 수 있다.
반면 T2가 이번에 X-lock
을 취득하려고 하는 경우 T1의 S-lock
이 해제된 후에 취득할 수 있다.
만약 T1이 특정 row에 대한 X-lock
을 가질 경우 T2는 해당 row에 대한 X,S lock
그 어떤 것도 취득할 수 없다.
궁금증 1: 모든 select 쿼리는 무조건
S-lock
을 얻길 시도한다?
일반적으로 DB의 쿼리는 write
보다는 read
가 많이 일어난다. 그렇다면 특정 row에 대한 update
쿼리를 가진 트랜잭션 T1이 수행되어 X-lock
을 얻은 경우 concurrent 하게 진행되는 모든 select
쿼리는(해당 row에 대한) T1이 종료되기를 기다려야 할까?
때에 따라 굉장히 긴 트랜잭션 내부에 update
쿼리가 존재한다면 수많은 단일 select
쿼리만 가진 트랜잭션들이 계속해서 기다리는 현상이 대부분의 서비스에 부적합하다고 생각했다.
MySQL Client를 두 개 생성해서 살펴보자.
Session-1
mysql> CREATE TABLE tb_rowlevellock(
-> id INT NOT NULL,
-> name VARCHAR(100) DEFAULT NULL,
-> PRIMARY KEY(id)
-> );
mysql> INSERT INTO tb_rowlevellock
-> VALUES(1,'RID')
간단한 테스트를 위한 table
을 만들고 테스트 데이터 1개를 넣어주었다.
위에서 언급한 상황과 같이 트랜잭션을 시작해서 id=1
의 데이터에 update
쿼리를 발생하고 lock
상태를 확인해보자.
Session-1
mysql> START TRANSACTION;
mysql> UPDATE tb_rowlevellock
-> SET name = 'RID_CHANGED'
-> WHERE id =1;
Session-2
mysql> select * from performance_schema.data_locks;
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-----------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| ENGINE | ENGINE_LOCK_ID | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-----------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| INNODB | 2331056873040:1094:2331046777368 | 1654 | 61 | 37 | new_schema | tb_rowlevellock | NULL | NULL | NULL | 2331046777368 | TABLE | IX | GRANTED | NULL |
| INNODB | 2331056873040:32:4:2:2331045468184 | 1654 | 61 | 37 | new_schema | tb_rowlevellock | NULL | NULL | PRIMARY | 2331045468184 | RECORD | X,REC_NOT_GAP | GRANTED | 1 |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-----------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
2 rows in set (0.00 sec)
결과 테이블의 2번째 행을 보면 해당 record
에 대해 X-lock
이 걸려있음을 확인하였다. (부가적인 나머지 정보들은 아래에 추가로 설명하겠다!)
이제 Session-2에서 해당 row에 대한 select
쿼리를 발생해보자. 위에서 설명한 대로라면 Session-1이 가지고 있는 X-lock
때문에 첫 번째 트랜잭션이 종료되기 전까지 조회가 발생하면 안된다!
Session-2
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM tb_rowlevellock where id=1;
+----+-------------+
| id | name |
+----+-------------+
| 1 | RID |
+----+-------------+
하지만 select
쿼리는 바로 날아가고 결과를 가져온다! id=1
번 record
에 X-lock
이 걸려있지만, Session-2의 트랜잭션은 Session-1의 트랜잭션을 기다리지 않고 바로 데이터를 가져왔다는 뜻이다.
따라서 내가 위에서 걱정했던 수많은 read
트랜잭션이 하나의 write
트랜잭션을 기다리게 되는 문제는 발생하지 않는다. 이유가 무엇일까?
Lock은 트랜잭션 격리 수준을 구현하기 위한 방법 중 하나이다.
InnoDB의 트랜잭션 격리 수준은 4가지가 존재한다. 각 격리수준을 구현하기 위한 도구 중 하나로써 여러 종류의 lock을 사용하는 것임을 이해하고 있어야 한다.
트랜잭션 격리 수준에 대한 내용을 설명하는 글은 굉장히 쉽게 찾아볼 수 있으니 따로 찾아보면 좋을 것 같다!
InnoDB가 제공하는 Default 격리 수준은 Repeatable read
이고, 해당 격리 수준에서는 동일 트랜잭션 내부 조회 결과가 항상 동일함을 보장한다!
따라서, Session-1의 트랜잭션이 커밋되기 이전 TABLE의 SNAPSHOP
에서 데이터를 가져오므로 굳이 기다릴 필요가 없는 것이다. 실제로 Session-2의 조회 쿼리 결과를 보면 RID_CHANGED
가 아닌 변경 전의 RID
가 조회됨을 알 수 있다.
한 마디로 정리하면 다음과 같다.
궁금증 1: 모든 select 쿼리는 무조건 S-lock을 얻길 시도한다?
결과 : InnoDB는 Repeatable read 격리 수준에서 동일 트랜잭션 내부 조회 결과가 항상 동일함을 보장하기 위한 방법으로 s-lock을 사용하지 않고 versioning을 통한 SNAPSHOT 방식을 활용한다.
위에서 설명한 두 개의 Lock
은 특정 row에 대해서 동작하는 row-level Lock
이었다. InnoDB에서는 특정 table
에 대한 lock
도 제공하며 이를 Intention Lock이라고 한다.
아까 Lock
획득 여부를 확인하는 쿼리에서 IX라는 Lock 형태를 본 적이 있을텐데 이것이 Intention Lock
의 한 종류이다.
Intention Lock
에도 두 가지 종류가 있으며 아래와 같다.
IS(Intention Shared Lock)
: 트랜잭션 내부에서 특정 row에 대한 S-lock
을 획득하려고 하는 것을 암시. IX(Intention eXclusive Lock)
: 트랜잭션 내부에서 특정 row에 대한 X-lock
을 획득하려고 하는 것을 암시. 이름(Intention)에서 알 수 있듯이 해당 트랜잭션 내부에서 얻고자 하는 Lock
의 종류가 무엇인지 미리 알려주는 역할이라고 보면 될 것 같다.
Select for update
쿼리를 발생시키는 경우 해당 row에 대한 X-lock
을 얻기 전에 반드시 해당 row가 포함된 table에 대해 IX 락을 얻도록 동작한다.
궁금증 2: Intention Lock이 대체 왜 존재할까?
사실 해당 질문에 대한 답을 찾기가 생각보다 어려웠다. 아래 두 가지 사실에 근거해서 나름 혼자서 이유를 찾아보려고 했고 그 과정에서 얻은 내용을 공유하고자 한다.
까먹으면 안되는 것들
Lock
은 기존에 진행되고 있는 트랜잭션의 Lock
과 conflict
가 발생하게 되면 트랜잭션을 시작하지 않는다. Lock
은 트랜잭션 격리 수준을 구현하기 위한 방법론 중 하나이다. 위 두 사실을 통해 고민할 수 있는 부분은 그래서 Intention Lock
이 어떤 Lock
과 conflict
가 나도록 설계되어 있으며, 해당 방식이 트랜잭션 격리 수준을 구현하는데 어떤 역할을 하는가? 이다.
먼저, 지금까지 배운 4개의 Lock
이 Conflict
가 나타나는 조건은 아래와 같다.
구분 | X | IX | S | IS |
---|---|---|---|---|
X | Conflict | Conflict | Conflict | Conflict |
IX | Conflict | Compatible | Conflict | Compatible |
S | Conflict | Conflict | Compatible | Compatible |
IS | Conflict | Compatible | Compatible | Compatible |
위의 표를 보고 사실 나는 더 혼란스러웠다. IX-lock
은 해당 트랜잭션 내부에서 X-lock
이 발생한다는 뜻이고, X와 IX는 Conflict
이므로 결국 Conflict
가 발생할텐데 왜 IX와 IX는 서로 Compatible
인지 도저히 이해할 수가 없었다.
일단 먼저 X-lock
을 가질 수 있는 두 개의 트랜잭션을 실행해서 lock
상태를 확인해보았다.
테스트를 위한 Table
을 생성해두고 아래와 같이 2개의 record
를 저장한 상태이다.
//Session-1
SELECT * FROM tb_intention_test;
+----+-------+
| id | name |
+----+-------+
| 1 | minsu |
| 2 | RID |
2 rows in set (0.00 sec)
이제 X-lock
을 얻으려고 하는 두 트랜잭션을 실행해보자.
//Session-1
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM tb_intention_test WHERE id = 1 FOR UPDATE;
+----+-------+
| id | name |
+----+-------+
| 1 | minsu |
+----+-------+
1 row in set (0.00 sec)
//Session-2
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM tb_intention_test WHERE id = 2 FOR UPDATE;
+----+------+
| id | name |
+----+------+
| 2 | RID |
+----+------+
1 row in set (0.00 sec)
위의 상태에서 두 Session
모두 트랜잭션을 commit
하지 않고 lock
상태를 확인해보았다.
mysql> SELECT * FROM performance_schema.data_locks;
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| ENGINE | ENGINE_LOCK_ID | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| INNODB | 2981988472072:1095:2981994106648 | 2589 | 50 | 9 | new_schema | tb_intention_test | NULL | NULL | NULL | 2981994106648 | TABLE | IX | GRANTED | NULL |
| INNODB | 2981988472072:33:4:3:2981977984024 | 2589 | 50 | 9 | new_schema | tb_intention_test | NULL | NULL | PRIMARY | 2981977984024 | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| INNODB | 2981988471296:1095:2981994105880 | 2588 | 48 | 21 | new_schema | tb_intention_test | NULL | NULL | NULL | 2981994105880 | TABLE | IX | GRANTED | NULL |
| INNODB | 2981988471296:33:4:2:2981977980952 | 2588 | 48 | 21 | new_schema | tb_intention_test | NULL | NULL | PRIMARY | 2981977980952 | RECORD | X,REC_NOT_GAP | GRANTED | 1 |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
4 rows in set (0.00 sec)
두 트랜잭션 모두 해당 테이블에 대해서 IX-lock
을 얻은(GRANTED
) 상태이다. 게다가 서로 다른 row에 대해 접근하므로 각각의 row에 대한 X-lock
역시 모두 GRANTED
되었다.
이제는 두 트랜잭션이 동일한 row에 대해 접근하는 상황에 대해 테스트를 진행해보자.
//Session-2
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM tb_intention_test WHERE id=1 FOR UPDATE;
Session-2도 동일하게 1번 id의 row에 select for update
쿼리를 날렸다.
예상대로 당연히 X-lock
을 얻지 못하게 되므로 조회 결과는 나오지 않는다.
그렇다면 IX-lock
은 어떻게 되었을까?
mysql> SELECT * FROM performance_schema.data_locks;
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| ENGINE | ENGINE_LOCK_ID | ENGINE_TRANSACTION_ID | THREAD_ID | EVENT_ID | OBJECT_SCHEMA | OBJECT_NAME | PARTITION_NAME | SUBPARTITION_NAME | INDEX_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
| INNODB | 2981988472072:1095:2981994106648 | 2590 | 50 | 13 | new_schema | tb_intention_test | NULL | NULL | NULL | 2981994106648 | TABLE | IX | GRANTED | NULL |
| INNODB | 2981988472072:33:4:2:2981977984024 | 2590 | 50 | 13 | new_schema | tb_intention_test | NULL | NULL | PRIMARY | 2981977984024 | RECORD | X,REC_NOT_GAP | WAITING | 1 |
| INNODB | 2981988471296:1095:2981994105880 | 2588 | 48 | 21 | new_schema | tb_intention_test | NULL | NULL | NULL | 2981994105880 | TABLE | IX | GRANTED | NULL |
| INNODB | 2981988471296:33:4:2:2981977980952 | 2588 | 48 | 21 | new_schema | tb_intention_test | NULL | NULL | PRIMARY | 2981977980952 | RECORD | X,REC_NOT_GAP | GRANTED | 1 |
+--------+------------------------------------+-----------------------+-----------+----------+---------------+-------------------+----------------+-------------------+------------+-----------------------+-----------+---------------+-------------+-----------+
4 rows in set (0.00 sec)
확인해보면 Table
에 대한 IX-lock
은 GRANTED
되어있고, 해당 id=1
에 대한 X-lock
만 WAITING
상태에 있는 것을 확인할 수 있다.
위 테스트를 통해 알 수 있는 사실을 정리하면 아래와 같다.
IX는 X와 conflict
가 난다고 표에 그려져 있었지만, 실제 특정 트랜잭션이 table 내부 row에 대해X-lock
을 얻은 상태라도 table
에 대한 IX-lock
을 얻을 수 있다.
-> IX-lock
이 서로 compatible
한 것을 확인했다.
같은 table
내부 동일한 row에 대해 두 트랜잭션이 X-lock
을 취득하려고 하는 경우 두 트랜잭션 모두 IX-lock
을 shared
상태로 얻을 수 있다.
위에서 언급한 표의 경우 굉장히 많은 내용이 숨겨져 있기 때문에 혼란스러운 부분이 많다. 따라서 단지 표의 내용을 보고 X-lock과 IX-lock이 Conflict난다! 라고 얘기하기 보다 위와 같이 상황을 예시를 들어 실제 GRANTED된 lock을 확인하는게 더 좋은 방법인 것 같다.
이제 제일 궁금했던 부분에 대한 답을 내려보자.
대체 Intention Lock은 왜 필요할까?
지금까지 실험을 하면서 lock 정보를 가져왔던 쿼리 요청을 한 번 살펴보자.
SELECT * FROM performance_schema.data_locks;
사실 해당 database에 존재하는 모든 lock
의 상태는 table
의 형태로 존재한다.
그리고 S-lock
과 X-lock
이 실제로 GRANTED
되기 전에 "lock-table에 내가 Conflict가 날 수 있는 lock이 존재해?"를 직접 확인하는 과정이 필요하다.
이번 포스팅에서 테스트를 위해 사용했던 예시의 경우 기껏해야 lock-table
에 5개 이내의 record가 존재할 것이다.
하지만 트랜잭션이 길어지고, 내부에서 접근하는 row가 많아지고, concurrent하게 진행되는 트랜잭션이 많아진다면 lock-table
내부에 많은 양의 record가 쌓이게 된다.
그렇다면 다른 새로운 트랜잭션을 시작할 때 굉장히 많은 데이터 속에서 Conflict가 발생할 수 있는 lock을 매번 조회해야 한다.
Intention Lock은 필요없는 상황에서 해당 조회를 하지 않도록 도와주는 것이다!
아래 예시를 살펴보자.
Intention Lock
이 존재하지 않는다면 T2는 반드시 시작 전에 table-2의 id=1의 row에 대한 lock이 존재하는지를 확인하기 위해 lock-table
을 뒤져야 한다.
하지만 Intention-Lock
이 존재하는 경우는 아래와 같은 시나리오를 통해 불필요하게 lock-table
을 조회하지 않아도 된다.
Intention Lock
을 걸었는지 확인한다. Intention Lock
을 걸지 않았으므로 내부에 그 어떤 record도 lock을 GRANTED
하지 않았음이 보장된다.row-level
의 lock을 직접 확인하지 않아도 된다! 결국
Intention Lock
은 계층적으로 존재하는 table-row의 관계에서 불필요하게 lock을 확인하는 과정을 없애기 위해 존재하는 Lock이다!
위의 이유 말고 부가적인 다른 이유가 존재할 수도 있을 것 같다. 하지만 이 정도까지만 이해하더라도 충분히 InnoDB의 Lock 프로세스를 이해하는데 많은 도움이 되는 것 같다.
Lock에 대한 내용을 파보다 보니 DB가 트랜잭션을 처리하는 과정이 조금 더 깊이 이해된다.
사실 이번 공부를 통해 정말 재밌었던 부분은 Gap-lock에 대해 파보는 부분이었는데, 포스팅이 길어질 것 같아 다음 포스팅에서 설명하려고 한다.