Mysql 트랜잭션 격레성 레벨 (Isolation Level)

Created
Mar 5, 2024
Created by
Tags
Mysql
Property
 

Transaction

  • 데이터의 정합성을 보장하기 위한 기능 (partial update 를 방지하는 기능)
  • DBMS 커낵션의 수는 한정되어 있으므로, 트랜잭션 당 커낵션을 점유하는 시간을 최소화 해야 한다. → 트랜잭션 내에서 트랜잭션과 무관한 연산 및 network I/O 는 피해야 한다.
 

Isolation Level

  • 여러 트랜잭션 간의 데이터 생성/변경/삭제를 어떻게 공유하고 차단할 것인지 결정한다.
  • 격리성 레벨은 아래 4가지로 나뉜다. (아래로 갈수록 격리 정도는 높아지고 동시성은 떨어짐)
    • Read Uncommitted
    • Read Committed
    • Repeatable Read
    • Serializable
  • Read Uncommitted 레벨은 격리 정도가 너무 낮고, Serializable 레벨은 동시성이 너무 낮아서 잘 사용하지 않는다.
 
아래 표는 격리성 레벨 별로 발생하는 부정합(anomaly) 를 나타낸다.
Dirty Read
Non-repeatable Read
Phantom Read
Read Uncommitted
O
O
O
Read Committed
X
O
O
Repeatable Read
X
X
X in InnoDB
Serializable
X
X
X

부정합 (anomaly)

name
description
Dirty Read
커밋되지 않은 데이터가 다른 트랜잭션에 보이는 현상
Non-repeatable Read
1개 트랜잭션이 SELECT 를 2번 실행했는데, 레코드 내 필드가 서로 다른 현상
Phantom Read
1개 트랜잭션이 SELECT 를 2번 실행했는데, 첫번째 쿼리에 없던 레코드가 두번째 쿼리에서 나타나는 현상
 
격리성 레벨에 따라 일관된 읽기 정도가 다른 것은 MVCC 와 연관이 있다.
 

MVCC(Multi-Versioning Concurrency Control)

  • Multi-Versioning 이란 하나의 레코드를 여러 버전으로 관리하는 것이다.
  • InnoDB 는 언두(undo) 로그를 통해 Multi-Versioning 이 가능하다.
 
어떤 트랜잭션에서 아래 레코드를 대상으로 UPDATE employee SET first_name = 'Hodu' WHERE emp_no = 'A1234'; 구문을 수행할 때
+------------+-----------+--------+ | first_name | last_name | emp_no | +------------+-----------+--------+ | DaEun | Kim | A1234 | +------------+-----------+--------+
  1. 언두(undo) 영역에 기존 레코드 버전 (first_name = 'DaEun') 을 로깅한다.
  1. 버퍼 풀에는 트랜잭션의 커밋/롤백 여부과 상관없이 무조건 first_name = 'Hodu' 을 반영한다.
  1. 트랜잭션이 커밋되면 undo 로그를 삭제한다.
  1. 트랜잭션이 롤백되면 undo 영역에 로깅했던 이전 레코드를 버퍼 풀에 반영하고 undo 로그를 삭제한다.
 
트랜잭션 A가 열려 있는 상태에서 트랜잭션 B가 데이터를 조회할 때
  • 격리성 레벨이 Read Uncommitted 인 경우, 버퍼 풀에서 데이터를 조회하므로 트랜잭션 A가 커밋하지 않은 데이터가 트랜잭션 B에서 보인다.
  • 격리성 레벨이 Read Committed, Repeatable Read 인 경우에는 undo 영역에서 데이터를 조회하므로 트랜잭션 A 가 커밋하지 않은 데이터는 보이지 않는다.
 
트랜잭션마다 undo 영역에 로그를 쓰기 때문에 undo 영역에는 하나의 레코드가 여러 버전으로 저장될 수 있고, 격리성 레벨이 Read Committed 인지, Repeatable Read 인지에 따라 undo 영역에서 어느 버전의 레코드를 조회할 지 달라진다.
 

Consistent Non-locking Read

Read Committed, Repeatable Read 레벨에서 각 트랜잭션은 SELECT 쿼리를 통해 레코드를 조회할 때 기본적으로 undo 로그를 통해 multi-versioning 되는 레코드를 읽기 때문에
  • SELECT를 실행할 때는 락을 획득하거나 획득하기 위해 기다릴 필요가 없다. (락을 획득하려면 SELECT ~ LOCK IN SHARE MODE 와 같은 쿼리를 수행한다.)
  • 다른 트랜잭션에서 UPDATE 를 수행한다고 해도 일관된 레코드를 조회하는 데 영향을 받지 않는다. (Read Committed 에서는 영향 받을 수 있음)
이를 consistent non-locking read 라고 한다.
 
 

Read Uncommitted

  • 가장 낮은 수준의 격리 단계로, 사실 상 트랜잭션이 서로 격리되지 않는 단계를 말한다.
  • 다른 트랜잭션이 커밋하지 않은 데이터를 볼 수 있다. → insert/update 시도한 트랜잭션이 롤백할 경우 데이터가 생겼다 사라지게 되므로 혼란 초래.
 

Read Committed

  • undo 로그를 통해 다른 트랜잭션이 커밋한 데이터만 볼 수 있기 때문에 Dirty Read 가 발생하지 않는다.
  • 트랜잭션 내에서 레코드를 조회할 때 매번 새로운 undo 로그를 조회한다. → Non-Repeatable Read 가 발생한다.
mysql> SELECT * FROM employee WHERE emp_no = 'A1234'; +------------+-----------+--------+ | first_name | last_name | emp_no | +------------+-----------+--------+ | DaEun | Kim | A1234 | +------------+-----------+--------+ 1 row in set (0.00 sec)
Transaction 1
Transaction 2
1
mysql> start transaction;
2
mysql> start transaction;
3
mysql> UPDATE employee SET first_name = 'Hodu' WHERE emp_no = 'A1234'; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0
4
mysql> SELECT * FROM employee WHERE emp_no = 'A1234'; +------------+-----------+--------+ | first_name | last_name | emp_no | +------------+-----------+--------+ | DaEun | Kim | A1234 | +------------+-----------+--------+
5
mysql> commit; Query OK, 0 rows affected (0.03 sec)
6
mysql> SELECT * FROM employee WHERE emp_no = 'A1234'; +------------+-----------+--------+ | first_name | last_name | emp_no | +------------+-----------+--------+ | Hodu | Kim | A1234 | → 4번에서 수행한 SELECT 결과와 다름 (Non-Repeatable Read 발생) +------------+-----------+--------+ 1 row in set (0.00 sec)
 
  • 외래키 제약 조건을 검사하거나 duplicate-key 검사를 할 때를 제외하고 UPDATE / DELETE / SELECT FOR UPDATE 수행 시 gap lock 을 생성하지 않으므로, 다른 트랙잭션이 gap 에 INSERT 하는 것을 허용한다. → gap 대상으로 phantom read 가 발생한다.
mysql> select * from test_gap_lock; +-------------------------+---------+ | cn_without_unique_index | cn_name | +-------------------------+---------+ | 2 | name222 | | 3 | name3 | | 4 | name4 | +-------------------------+---------+ 3 rows in set (0.01 sec) mysql> show index from test_gap_lock; +---------------+------------+--------------------------------+--------------+-------------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ | Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression | +---------------+------------+--------------------------------+--------------+-------------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ | test_gap_lock | 1 | idx_to_cn_without_unique_index | 1 | cn_without_unique_index | A | 2 | NULL | NULL | | BTREE | | | YES | NULL | +---------------+------------+--------------------------------+--------------+-------------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+ 1 row in set (0.01 sec)
Transaction 1
Transaction 2
1
mysql> start transaction;
2
mysql> select * from test_gap_lock where cn_without_unique_index between 2 and 4 for update; +-------------------------+---------+ | cn_without_unique_index | cn_name | +-------------------------+---------+ | 2 | name2 | | 3 | name3 | | 4 | name4 | +-------------------------+---------+ 3 rows in set (0.00 sec)
3
mysql> select THREAD_ID, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA from performance_schema.data_locks; +-----------+---------------+--------------------------------+-----------+---------------+-------------+-------------------+ | THREAD_ID | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA | +-----------+---------------+--------------------------------+-----------+---------------+-------------+-------------------+ | 91 | test_gap_lock | NULL | TABLE | IX | GRANTED | NULL | | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X,REC_NOT_GAP | GRANTED | 2, 0x000000000206 | → gap lock 생성하지 않음 | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X,REC_NOT_GAP | GRANTED | 3, 0x000000000207 | | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X,REC_NOT_GAP | GRANTED | 4, 0x000000000208 | | 91 | test_gap_lock | GEN_CLUST_INDEX | RECORD | X,REC_NOT_GAP | GRANTED | 0x000000000207 | | 91 | test_gap_lock | GEN_CLUST_INDEX | RECORD | X,REC_NOT_GAP | GRANTED | 0x000000000208 | | 91 | test_gap_lock | GEN_CLUST_INDEX | RECORD | X,REC_NOT_GAP | GRANTED | 0x000000000206 | +-----------+---------------+--------------------------------+-----------+---------------+-------------+-------------------+ 7 rows in set (0.01 sec)
4
mysql> start transaction;
5
mysql> insert into test_gap_lock(cn_without_unique_index, cn_name) values(3, 'name33'); Query OK, 1 row affected (0.01 sec)
6
mysql> commit; Query OK, 0 rows affected (0.02 sec)
7
mysql> select * from test_gap_lock where cn_without_unique_index between 2 and 4 for update; +-------------------------+---------+ | cn_without_unique_index | cn_name | +-------------------------+---------+ | 2 | name2 | | 3 | name3 | | 3 | name33 | → 2번에서 수행한 SELECT 결과에 없던 레코드 조회 (Phantom Read 발생) | 4 | name4 | +-------------------------+---------+ 4 rows in set (0.00 sec)
 
 

Repeatable Read

  • undo 로그를 통해 다른 트랜잭션이 커밋한 데이터만 볼 수 있기 때문에 Dirty Read 가 발생하지 않는다.
  • 어떤 트랜잭션 내에서 레코드를 조회할 때, 해당 트랜잭션이 만들어지기 전에 가장 마지막에 생성된 undo 로그만을 참조하기 때문에 Non-Repeatable Read 가 발생하지 않는다.
Transaction 1
Transaction 2
1
mysql> start transaction; Query OK, 0 rows affected (0.01 sec)
2
mysql> start transaction; Query OK, 0 rows affected (0.01 sec)
3
mysql> UPDATE employee SET first_name = 'Hodu' WHERE emp_no = 'A1234'; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0
4
mysql> select * from employee where emp_no = 'A1234'; → Transaction 2 시작 전에 기록된 undo 로그 참조 +------------+-----------+--------+ | first_name | last_name | emp_no | +------------+-----------+--------+ | DaEun | Kim | A1234 | +------------+-----------+--------+ 1 row in set (0.00 sec)
5
mysql> commit; Query OK, 0 rows affected (0.01 sec)
6
mysql> select * from employee where emp_no = 'A1234'; → Transaction 2 시작 전에 기록된 undo 로그 참조 +------------+-----------+--------+ | first_name | last_name | emp_no | +------------+-----------+--------+ | DaEun | Kim | A1234 | → 4번에서 수행한 SELECT 결과와 동일 (Repeatable Read 보장) +------------+-----------+--------+ 1 row in set (0.01 sec)
 
  • UPDATE / DELETE / SELECT FOR UPDATE 수행 시 gap lock 을 생성하므로, 다른 트랙잭션이 gap 에 INSERT/ UPDATE / DELETE 하는 것을 허용하지 않는다. → phantom read 가 발생하지 않는다.
Transaction 1
Transaction 2
1
mysql> start transaction; Query OK, 0 rows affected (0.00 sec)
2
mysql> select * from test_gap_lock where cn_without_unique_index between 2 and 4 for update; +-------------------------+---------+ | cn_without_unique_index | cn_name | +-------------------------+---------+ | 2 | name2 | | 3 | name3 | | 4 | name4 | +-------------------------+---------+ 3 rows in set (0.01 sec)
3
mysql> select THREAD_ID, OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA from performance_schema.data_locks; +-----------+---------------+--------------------------------+-----------+---------------+-------------+------------------------+ | THREAD_ID | OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA | +-----------+---------------+--------------------------------+-----------+---------------+-------------+------------------------+ | 91 | test_gap_lock | NULL | TABLE | IX | GRANTED | NULL | | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X | GRANTED | supremum pseudo-record | → gap lock 생성 | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X | GRANTED | 2, 0x000000000206 | | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X | GRANTED | 3, 0x000000000207 | | 91 | test_gap_lock | idx_to_cn_without_unique_index | RECORD | X | GRANTED | 4, 0x000000000208 | | 91 | test_gap_lock | GEN_CLUST_INDEX | RECORD | X,REC_NOT_GAP | GRANTED | 0x000000000207 | | 91 | test_gap_lock | GEN_CLUST_INDEX | RECORD | X,REC_NOT_GAP | GRANTED | 0x000000000208 | | 91 | test_gap_lock | GEN_CLUST_INDEX | RECORD | X,REC_NOT_GAP | GRANTED | 0x000000000206 | +-----------+---------------+--------------------------------+-----------+---------------+-------------+------------------------+ 8 rows in set (0.01 sec)
4
mysql> start transaction; Query OK, 0 rows affected (0.00 sec)
5
mysql> insert into test_gap_lock(cn_without_unique_index, cn_name) value(3, 'name33'); → Transaction 2 에서 생성한 gap lock 에 의해 대기
6
mysql> select * from test_gap_lock where cn_without_unique_index between 2 and 4 for update; +-------------------------+---------+ | cn_without_unique_index | cn_name | +-------------------------+---------+ | 2 | name2 | | 3 | name3 | → 2번에서 수행한 SELECT 결과와 동일 (Phantom Read 발생하지 않음) | 4 | name4 | +-------------------------+---------+ 3 rows in set (0.01 sec)
 
[요약]
Non-Repeatable Read
Phantom Read
Read Committed
매번 조회하는 undo 로그가 다르므로 발생함
gap lock 을 획득하지 않으므로 발생함
Repeatable Read
트랜잭션이 시작되기 전에 가장 최근에 생성된 undo 로그만 참조하므로 발생하지 않음
gap lock 을 획득하므로 발생하지 않음
 
 

MVCC 측면에서 트랜잭션을 빨리 종료해야 하는 이유

 
  • 트랜잭션이 커밋되면 언두 로그를 조회하는 다른 트랜잭션이 종료될 때 까지 기다리다가, 언두 로그를 삭제한다.
  • 트랜잭션이 롤백되면 언두 로그에 있는 버전을 다시 버퍼 풀과 디스크에 반영하고 언두 로그를 곧바로 삭제한다.
 
트랜잭션이 커밋/롤백되어야 언두 로그가 삭제되는데, 종료되지 않고 계속 남아있는 트랜잭션이 있다면 그 수에 비례하게 언두 로그가 삭제되지 않고 남아있게 된다.
→ 종료 되기까지 시간이 오래 걸리는 트랜잭션이 많아질수록 undo 영역의 용량이 커지게 되고 최대 허용 용량을 넘게 되면 문제가 된다. 따라서 트랜잭션 begin ~ commit/rollback 시간을 최대한 짧게 해야 한다.