1. MVCC(Multi-Version Concurrency Control)
- MVCC는 MySQL의 InnoDB 스토리지 엔진에서 사용하는 동시 성 제어 메커니즘입니다.
- 여러 트랜잭션이 동시에 데이터베이스에 접근할 때, 각 트랜잭션이 데이터의 특정 버전(스냅샷)을 보게 함으로써 동시성과 격리성을 모두 제공합니다.
- MVCC의 핵심 아이디어는 데이터를 읽는 트랜잭션이 데이터를 수정하는 트랜잭션을 차단하지 않고, 반대로 수정하는 트랜잭션도 읽는 트랜잭션을 차단하지 않는 것입니다.
- 여시서 MVCC의 멀티 버전이라 함은 하나의 레코드에 대해 여러 개의 버전이 동시에 관리된다는 의미입니다.
- MVCC의 가장 큰 목적은 Lock(잠금)을 사용하지 않고 일관된 읽기를 제공하는 것입니다.
1.1 MVCC의 주요 특징
- 데이터의 여러 버전을 동시에 유지합니다.
- 각 트랜잭션은 트랜잭션 시작 시점의 일관된 데이터베이스 스냅샷을 볼 수 있습니다.
- 락(lock)을 최소화하여 동시성을 향상시킵니다.
- 읽기 작업(SELECT)이 쓰기 작업(INSERT, UPDATE, DELETE)을 차단하지 않습니다.
- InnoDB에서는 Undo 로그를 활용하여 MVCC를 구현합니다.
1.2 잠금 없는 일관된 읽기
- InnoDB 스토리지 엔진은 MVCC 기술을 활용하여 읽기 작업 시 잠금을 사용하지 않습니다.
- SERIALIZABLE 격리 수준을 제외한 모든 격리 수준(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ)에서 일반 SELECT 쿼리는 다른 트랜잭션이 레코드에 걸어둔 잠금과 관계없이 즉시 읽기가 가능합니다.
- 다른 트랜잭션이 레코드를 변경하고 아직 커밋하지 않아 잠금 상태인 레코드도, InnoDB는 언두 로그를 활용하여 변경 이전의 데이터 버전을 읽을 수 있습니다.
- 그러나 이로 인해 장기 실행 트랜잭션이 존재할 경우, 해당 트랜잭션의 일관된 읽기를 위해 언두 로그를 삭제하지 못하고 계속 유지해야 하므로 서버 성능이 저하될 수 있습니다.
- 따라서 성능 최적화를 위해 트랜잭션은 가능한 빨리 커밋 또는 롤백하여 종료하는 것이 좋습니다.
2. MVCC의 동작 원리
- 지금부터는 MVCC의 동작 원리를 살펴보겠습니다.
2.1 트랜잭션 ID 활용
- InnoDB는 각 트랜잭션에 고유한 트랜잭션 ID(TRX_ID)를 부여합니다.
- 트랜잭션 ID는 트랜잭션이 시작될 때마다 단조 증가하는 값으로 할당됩니다.
- 데이터 행(row)이 변경될 때마다 해당 행에 현재 트랜잭션의 ID가 기록됩니다.
2.2 행 버전 관리
- InnoDB는 테이블의 각 행에 대해 시스템 컬럼을 내부적으로 관리합니다
- 이 컬럼들은 InnoDB 내부에서만 사용되며, 사용자가 직접 조회할 수는 없습니다.
DB_TRX_ID
: 해당 행을 마지막으로 수정한 트랜잭션의 ID를 저장합니다.DB_ROLL_PTR
- Undo 로그 레코드를 가리키는 포인터입니다.
- InnoDB는 DB_ROLL_PTR 포인터를 따라 Undo 로그 체인으로 이동합니다.
- 이 포인터는 해당 레코드의 이전 버전을 가리킵니다.
2.3 가시성 결정 메커니즘
- 트랜잭션이 데이터를 읽을 때, InnoDB는 각 행의 DB_TRX_ID를 확인하여 해당 행의 버전이 해당 트랜잭션에 '가시적'인지 결정합니다.
- 읽기 트랜잭션이 시작할 때, 시스템은 '읽기 뷰(Read View)'라는 구조를 생성합니다.
- 읽기 뷰는 특정 시점의 데이터베이스 상태를 나타내는 스냅샷으로, 가시성(visibility) 판단에 쓰입니다.
- 자세한 내용은 아래에서 설명합니다.
2.3.1 읽기 뷰(Read View)
- 읽기 뷰는 MVCC (Multi-Version Concurrency Control) 를 사용하는 MySQL(InnoDB)에서 트랜잭션 격리 수준을 구현하기 위해 사용됩니다.
- 읽기 뷰는 특정 시점의 데이터베이스 상태를 나타내는 스냅샷으로, 가시성(visibility) 판단에 쓰입니다.
- 수정 불가(immutable) 한 데이터 구조입니다.
읽기 뷰의 구조
- 읽기 뷰 구조체는 다음 정보를 포함합니다.
- creator_trx_id
- 읽기 뷰를 생성한 트랜잭션의 ID입니다.
- trx_ids
- 읽기 뷰 생성 시점에 아직 커밋되지 않고 활성 상태였던 모든 트랜잭션 ID의 목록입니다.
- up_limit_id
- 가장 오래된 활성 트랜잭션 ID입니다.
- 활성 트랜잭션 목록(trx_ids)에서 가장 작은 트랜잭션 ID입니다.
- 이 값보다 작은 트랜잭션 ID를 가진 레코드는 모두 이미 커밋되었다고 간주하여 가시적입니다.
- low_limit_id
- 읽기 뷰 생성 시점까지 시스템에서 할당한 마지막 트랜잭션 ID에 1을 더한 값입니다.
- 즉, 이 값은 다음에 할당될 트랜잭션 ID입니다.
- 이 값 이상의 ID를 가진 트랜잭션은 읽기 뷰 생성 이후에 시작된 것으로 간주하여 비가시적입니다.
격리 수준별 읽기 뷰 생성 시점
- Read Committed 격리 수준에서는 매 쿼리가 실행될 때 마다 ReadView 를 생성하지만, Repeatable Read 수준에서는 트랜잭션이 실행할 때 한번만 ReadView 를 생성합니다.
예시
- 현재 상황
- 트랜잭션 ID 100, 101, 102, 103이 모두 커밋됨
- 트랜잭션 ID 104, 105는 현재 활성 상태 (커밋되지 않음)
- 트랜잭션 ID 106이 방금 시작되어 읽기 뷰를 생성합니다.
- 읽기 뷰 구성
- creator_trx_id: 106 (읽기 뷰를 생성한 트랜잭션 ID)
- trx_ids: [104, 105] (현재 활성 상태인 트랜잭션 목록)
- up_limit_id = 104(활성 트랜잭션 목록에서 가장 작은 트랜잭션 ID)
- low_limit_id = 107 (다음에 할당될 트랜잭션 ID)
2.3.2 행 가시성 결정 규칙
- InnoDB가 특정 레코드가 현재 트랜잭션에 "보여야 하는지" 결정할 때는 다음과 같은 알고리즘을 사용합니다.
- 먼저 레코드의 DB_TRX_ID와 읽기 뷰를 사용하여 레코드의 가시성을 결정합니다.
DB_TRX_ID < up_limit_id
인 경우 -> 가시적- 이 레코드를 마지막으로 수정한 트랜잭션의 ID가 up_limit_id보다 작습니다.
- up_limit_id은 읽기 뷰 생성 시점에 만들어진 활성 트랜잭션 목록에서 가장 작은 트랜잭션 ID입니다.
- 해당 트랜잭션이 현재 읽기 뷰가 생성되기 전에 이미 커밋되었음을 의미합니다.
- 따라서 이 레코드는 현재 트랜잭션에 "가시적"이며, 현재 버전을 읽을 수 있습니다.
DB_TRX_ID >= low_limit_id
인 경우 -> 비가시적- low_limit_id는 읽기 뷰가 생성된 시점에서 시스템에서 할당된 마지막 트랜잭션 ID에 1을 더한 값입니다.
- 이 조건의 의미는 해당 레코드가 읽기 뷰가 생성된 이후에 시작된 트랜잭션에 의해 수정되었음을 의미합니다.
- 따라서 해당 레코드는 현재 트랜잭션에 "비가시적"이며, Undo 로그에서 이전 버전을 찾아야 합니다.
- InnoDB는 DB_ROLL_PTR 포인터를 따라 Undo 로그 체인으로 이동합니다. 이 포인터는 해당 레코드의 이전 버전을 가리킵니다.
- 이후 과정은 가시성 판단을 위해 반복됩니다.
up_limit_id <= DB_TRX_ID < low_limit_id
인 경우 -> 조건부 가시성- 이는 더 복잡한 케이스로, 해당 트랜잭션이 현재 읽기 뷰 생성 시점에 활성화되어 있었을 수도, 이미 커밋되었을 수도 있습니다.
- DB_TRX_ID가 trx_ids 목록에 있는 경우
- 해당 트랜잭션은 읽기 뷰 생성 시점에 활성 상태였고 아직 커밋되지 않았을 수 있습니다.
- 따라서 이 레코드는 "비가시적"이며, Undo 로그에서 이전 버전을 찾아야 합니다.
- DB_TRX_ID가 trx_ids 목록에 없는 경우
- 해당 트랜잭션은 읽기 뷰 생성 시점 전에 이미 커밋되었습니다.
- 따라서 이 레코드는 "가시적"이며, 현재 버전을 읽을 수 있습니다.
2.3.3 MVCC 가시성 판단 및 레코드 탐색 예시
- 초기 상황:
- 트랜잭션 ID 50: 이미 커밋됨
- 트랜잭션 ID 51: 활성 상태 (아직 커밋되지 않음)
- 트랜잭션 ID 52: 방금 시작되어 읽기 뷰 생성
- 다음에 할당될 트랜잭션 ID: 53
- 트랜잭션 52의 읽기 뷰 구성:
- creator_trx_id: 52
- trx_ids: [51]
- up_limit_id: 51
- low_limit_id: 53
- 데이터 상황:
- 사용자 테이블에 "user_id=1"인 행이 있고, 이름 필드가 여러 트랜잭션에 의해 수정되었다고 가정합니다.
현재 DB의 행 데이터:
- DB_TRX_ID: 54 (가장 최근에 수정한 트랜잭션 ID)
- 이름: "Charlie"
- DB_ROLL_PTR: → Undo 로그 #1
Undo 로그 체인:
Undo 로그 #1:
- DB_TRX_ID: 51
- 이름: "Bob"
- DB_ROLL_PTR: → Undo 로그 #2
Undo 로그 #2:
- DB_TRX_ID: 50
- 이름: "Alice"
- DB_ROLL_PTR: → Undo 로그 #3
Undo 로그 #3:
- DB_TRX_ID: 45
- 이름: "John"
- DB_ROLL_PTR: null (더 이상 이전 버전 없음)
이제 트랜잭션 52가 "SELECT 이름 FROM 사용자 WHERE user_id=1"을 실행할 때:
- 현재 행 데이터 확인:
- DB_TRX_ID = 54
- 가시성 판단:
54 >= 53
(low_limit_id)이므로 비가시적 - 결론: 읽기 뷰 생성 후 변경됨, DB_ROLL_PTR을 따라 이전 버전으로 이동
- Undo 로그 #1로 이동:
- DB_TRX_ID = 51
- 가시성 판단:
51 >= 51 && 51 < 53
이고, 51은 trx_ids에 있음 - 결론: 아직 커밋되지 않은 트랜잭션의 변경, 비가시적, 다시 DB_ROLL_PTR 따라감
- Undo 로그 #2로 이동:
- DB_TRX_ID = 50
- 가시성 판단:
50 < 51
(up_limit_id) - 결론: 가시적! 이미 커밋된 트랜잭션의 변경
- 최종 결과:
- 트랜잭션 52는 "Alice"라는 이름을 읽음
- 더 이상 Undo 로그 체인을 탐색할 필요 없음
3. Undo 로그와 MVCC
3.1 Undo 로그의 역할
- Undo 로그는 데이터 변경 이전의 상태를 저장하는 로그입니다.
- MVCC에서 두 가지 핵심 기능을 제공합니다:
- 트랜잭션 롤백: 트랜잭션이 실패하거나 명시적 롤백 시 데이터를 이전 상태로 복원합니다.
- 일관된 읽기: 다른 트랜잭션에서 변경한 데이터에 대해 이전 버전을 제공합니다.
- 트랜잭션 롤백: 트랜잭션이 실패하거나 명시적 롤백 시 데이터를 이전 상태로 복원합니다.
3.2 Undo 로그 레코드의 연결 구조
- 하나의 행이 여러 번 수정될 때, Undo 로그 레코드는 링크드 리스트 형태로 연결됩니다.
- 각 Undo 로그 레코드는 이전 버전의 Undo 로그 레코드를 가리키는 포인터를 포함합니다.
- 이 연결 구조를 통해 InnoDB는 필요한 경우 데이터의 모든 이전 버전에 접근할 수 있습니다.
4. 트랜잭션 격리 수준과 MVCC
4.1 READ UNCOMMITTED
- 가장 낮은 격리 수준으로, MVCC를 사용하지 않습니다.
- 트랜잭션은 다른 트랜잭션이 커밋하지 않은 변경사항(더티 리드)도 볼 수 있습니다.
- 성능은 가장 좋지만, 데이터 일관성은 보장되지 않습니다.
4.2 READ COMMITTED
- 각 쿼리 실행 시마다 새로운 읽기 뷰(Read View)를 생성합니다.
- 커밋된 데이터만 볼 수 있어 더티 리드는 방지됩니다.
- 하나의 트랜잭션 내에서도 다른 트랜잭션이 커밋한 변경사항이 보일 수 있어 반복 읽기 불일치(non-repeatable read) 문제가 발생할 수 있습니다.
4.3 REPEATABLE READ
- MySQL InnoDB의 기본 격리 수준입니다.
- 트랜잭션 시작 시 읽기 뷰를 생성하고, 트랜잭션 종료까지 이 읽기 뷰를 유지합니다.
- 트랜잭션 내에서 일관된 데이터 스냅샷을 제공하여 반복 읽기 불일치를 방지합니다.
- 팬텀 리드(phantom read)는 특정 상황에서 발생할 수 있지만, InnoDB는 갭 락(gap lock)과 넥스트 키 락(next-key lock)을 사용하여 이를 대부분 방지합니다.