Intention Lock 은 대체 왜 필요할까?

RID·2024년 7월 16일
0

MySql Lock

목록 보기
1/1

배경


동시성 이슈를 제어하는 프로젝트를 진행하던 중 JPA의 Pessimistic lock 을 활용해서 연결된 MySQL DB X-lock을 통해 동시성 문제를 해결하려고 하였다.

콘서트 예약 환경에서 어떤 공연(show)의 특정 좌석(seat)에 대한 예약이 존재하는지 확인하고, 존재하지 않는다면 예약 데이터를 생성하는 것이 주 목적이었다.

동시성 제어를 위해 의도한 흐름은 다음과 같았다.
1. 특정 조건(show_id, seat_id)의 예약 record가 존재하는 지 확인한다. (select for update)
2. 존재하지 않는다면 해당 조건으로 record를 생성한다. (비즈니스 로직 실행)
3. 존재한다면 비즈니스 로직을 실행하지 않고 트랜잭션을 종료한다.

위의 의도대로 동시성 문제가 해결되기를 바랬지만 테스트 진행 시 DeadLock 문제가 발생했고, 원인을 찾는데 한참 시간을 소모했다.

결국, DeadLock이 발생한 핵심 원인은 MySQLGap-lock 때문에 발생함을 알게 되었다. Gap-lock이 궁금해서 이것저것 찾아보다가 Lock 매커니즘에 대해 많은 궁금증을 가지게 되었고, 해당 내용을 공유하고자 한다.

MySQL의 Locking


해당 포스팅에서는 InnoDBLocking 방법론과 해당 방식으로 인해 발생할 수 있는 문제 상황에 대해 설명하려고 한다.

InnoDB Lock에 대한 세부적인 내용은 아래 MySQL 공식 document에서 찾아볼 수 있고, 이번 포스팅에서 설명하는 내용 역시 해당 출처를 기반으로 작성되었다.

https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html

가장 흔하게 거론되고 알고 있는 Lock의 종류는 S-lockX-lock 두 개일 것이다. 하지만, InnoDB는 격리수준을 구현하기 위해 더 많은 종류의 Lock을 제공하며, 해당 부분의 작동 방식에 대해서 조금 설명하려고 한다.

Row-level 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

S-lock의 경우 이름에서 알 수 있듯, 읽기를 위한 공유 lock이다. 따라서 만약 트랜잭션 T1이 특정 row에 대한 S-lock을 가지고 있더라도 다른 트랜잭션 T2S-lock을 취득할 수 있다.

반면 T2가 이번에 X-lock을 취득하려고 하는 경우 T1S-lock이 해제된 후에 취득할 수 있다.

X-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=1recordX-lock이 걸려있지만, Session-2의 트랜잭션은 Session-1의 트랜잭션을 기다리지 않고 바로 데이터를 가져왔다는 뜻이다.

따라서 내가 위에서 걱정했던 수많은 read 트랜잭션이 하나의 write 트랜잭션을 기다리게 되는 문제는 발생하지 않는다. 이유가 무엇일까?

Lock은 트랜잭션 격리 수준을 구현하기 위한 방법 중 하나이다.

InnoDB의 트랜잭션 격리 수준4가지가 존재한다. 각 격리수준을 구현하기 위한 도구 중 하나로써 여러 종류의 lock을 사용하는 것임을 이해하고 있어야 한다.

트랜잭션 격리 수준에 대한 내용을 설명하는 글은 굉장히 쉽게 찾아볼 수 있으니 따로 찾아보면 좋을 것 같다!

[MySQL] 트랜잭션의 격리 수준(Isolation Level)에 대해 쉽고 완벽하게 이해하기

InnoDB가 제공하는 Default 격리 수준은 Repeatable read이고, 해당 격리 수준에서는 동일 트랜잭션 내부 조회 결과가 항상 동일함을 보장한다!

따라서, Session-1의 트랜잭션이 커밋되기 이전 TABLE의 SNAPSHOP에서 데이터를 가져오므로 굳이 기다릴 필요가 없는 것이다. 실제로 Session-2의 조회 쿼리 결과를 보면 RID_CHANGED가 아닌 변경 전의 RID가 조회됨을 알 수 있다.

한 마디로 정리하면 다음과 같다.

궁금증 1: 모든 select 쿼리는 무조건 S-lock을 얻길 시도한다?
결과 : InnoDB는 Repeatable read 격리 수준에서 동일 트랜잭션 내부 조회 결과가 항상 동일함을 보장하기 위한 방법으로 s-lock을 사용하지 않고 versioning을 통한 SNAPSHOT 방식을 활용한다.

Table-level Lock


위에서 설명한 두 개의 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이 대체 왜 존재할까?

사실 해당 질문에 대한 답을 찾기가 생각보다 어려웠다. 아래 두 가지 사실에 근거해서 나름 혼자서 이유를 찾아보려고 했고 그 과정에서 얻은 내용을 공유하고자 한다.

까먹으면 안되는 것들

  1. 특정 트랜잭션이 수행되기 위해 필요로 하는 모든 형태의 Lock은 기존에 진행되고 있는 트랜잭션의 Lockconflict가 발생하게 되면 트랜잭션을 시작하지 않는다.
  2. Lock은 트랜잭션 격리 수준을 구현하기 위한 방법론 중 하나이다.

위 두 사실을 통해 고민할 수 있는 부분은 그래서 Intention Lock이 어떤 Lockconflict가 나도록 설계되어 있으며, 해당 방식이 트랜잭션 격리 수준을 구현하는데 어떤 역할을 하는가? 이다.

먼저, 지금까지 배운 4개의 LockConflict가 나타나는 조건은 아래와 같다.

구분XIXSIS
XConflictConflictConflictConflict
IXConflictCompatibleConflictCompatible
SConflictConflictCompatibleCompatible
ISConflictCompatibleCompatibleCompatible

위의 표를 보고 사실 나는 더 혼란스러웠다. 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-lockGRANTED 되어있고, 해당 id=1에 대한 X-lockWAITING 상태에 있는 것을 확인할 수 있다.

위 테스트를 통해 알 수 있는 사실을 정리하면 아래와 같다.

  • IX는 X와 conflict가 난다고 표에 그려져 있었지만, 실제 특정 트랜잭션이 table 내부 row에 대해X-lock을 얻은 상태라도 table에 대한 IX-lock얻을 수 있다.
    -> IX-lock이 서로 compatible 한 것을 확인했다.

  • 같은 table 내부 동일한 row에 대해 두 트랜잭션이 X-lock을 취득하려고 하는 경우 두 트랜잭션 모두 IX-lockshared 상태로 얻을 수 있다.

위에서 언급한 표의 경우 굉장히 많은 내용이 숨겨져 있기 때문에 혼란스러운 부분이 많다. 따라서 단지 표의 내용을 보고 X-lock과 IX-lock이 Conflict난다! 라고 얘기하기 보다 위와 같이 상황을 예시를 들어 실제 GRANTED된 lock을 확인하는게 더 좋은 방법인 것 같다.

이제 제일 궁금했던 부분에 대한 답을 내려보자.

대체 Intention Lock은 왜 필요할까?

지금까지 실험을 하면서 lock 정보를 가져왔던 쿼리 요청을 한 번 살펴보자.

SELECT * FROM performance_schema.data_locks;

사실 해당 database에 존재하는 모든 lock의 상태는 table의 형태로 존재한다.
그리고 S-lockX-lock이 실제로 GRANTED 되기 전에 "lock-table에 내가 Conflict가 날 수 있는 lock이 존재해?"를 직접 확인하는 과정이 필요하다.

이번 포스팅에서 테스트를 위해 사용했던 예시의 경우 기껏해야 lock-table에 5개 이내의 record가 존재할 것이다.

하지만 트랜잭션이 길어지고, 내부에서 접근하는 row가 많아지고, concurrent하게 진행되는 트랜잭션이 많아진다면 lock-table 내부에 많은 양의 record가 쌓이게 된다.

그렇다면 다른 새로운 트랜잭션을 시작할 때 굉장히 많은 데이터 속에서 Conflict가 발생할 수 있는 lock을 매번 조회해야 한다.

Intention Lock은 필요없는 상황에서 해당 조회를 하지 않도록 도와주는 것이다!
아래 예시를 살펴보자.

  • 트랜잭션 T1은 table-1에 존재하는 id=2에 x-lock GRANTED.
  • 트랜잭션 T2는 table-2에 존재하는 id=1의 record에 접근하고 싶어함.

Intention Lock이 존재하지 않는다면 T2는 반드시 시작 전에 table-2의 id=1의 row에 대한 lock이 존재하는지를 확인하기 위해 lock-table을 뒤져야 한다.

하지만 Intention-Lock이 존재하는 경우는 아래와 같은 시나리오를 통해 불필요하게 lock-table을 조회하지 않아도 된다.

  • 트랜잭션 T1은 table-1에 존재하는 id=2에 x-lock GRANTED.
  • 트랜잭션 T2는 table-2에 존재하는 id=1의 record에 접근하고 싶어함(X-lock형태로).
  • 트랜잭션 T2는 table-2에 IX-lock을 걸고 다른 트랜잭션이 table-2에 대해서 Intention Lock을 걸었는지 확인한다.
  • 그 어떤 트랜잭션도 table-2에 대해 Intention Lock을 걸지 않았으므로 내부에 그 어떤 record도 lock을 GRANTED하지 않았음이 보장된다.
  • 굳이 row-level의 lock을 직접 확인하지 않아도 된다!

결국 Intention Lock은 계층적으로 존재하는 table-row의 관계에서 불필요하게 lock을 확인하는 과정을 없애기 위해 존재하는 Lock이다!

위의 이유 말고 부가적인 다른 이유가 존재할 수도 있을 것 같다. 하지만 이 정도까지만 이해하더라도 충분히 InnoDB의 Lock 프로세스를 이해하는데 많은 도움이 되는 것 같다.

정리


Lock에 대한 내용을 파보다 보니 DB가 트랜잭션을 처리하는 과정이 조금 더 깊이 이해된다.
사실 이번 공부를 통해 정말 재밌었던 부분은 Gap-lock에 대해 파보는 부분이었는데, 포스팅이 길어질 것 같아 다음 포스팅에서 설명하려고 한다.

0개의 댓글