Computer Science 모아보기 👉🏻 https://github.com/seoul-developer/CS
💋 MVCC란?
✔️ 등장 배경
락을 사용한 동시성 제어의 경우에, read-read 락끼리는 2개 이상의 트랜잭션일 경우에도 동시에 처리가 가능하다. 하지만, write 락을 얻은 트랜잭션이 1개라도 존재한다면 다른 트랜잭션들은 모두 read 락 조차 획득할 수 없게 된다.
이 경우 처리량이 조금 아쉬운데, 이 점을 극복하기 위해서 MVCC가 개발된다.
MVCC는 isolation을 위해서 락을 덜 사용하는 방식이다.
✔️ 개념
MVCC와 락 모두 데이터베이스에서 트랜잭션 격리를 보장하기 위한 구현 방법 중 하나다.
MVCC는 write-write 락의 경우에는 한 쪽 트랜잭션이 block되지만, 그 외에 read의 경우에는 다른 트랜잭션을 block하지 않도록 만들어졌다. ⇒ 처리량 증가
MVCC는 read lock을 사용하지 않고 구현된다.
write lock도 사용하지 않고 구현할 수 있지만, 오늘날 대부분의 RDBMS는 MVCC 구현 시 write lock을 사용한다.
⇒ write lock만 사용하는 것으로 설명하겠음.
✔️ 특징
MVCC는 커밋된 데이터만을 읽는다. ⇒ dirty read 없음.
⇒ Tx1은 Tx2가 변경한 x=50
이 아니라, 기존에 커밋되어 있던 x=10
을 읽어온다.
또한 RDBMS는 내부적으로 커밋 후에 unlock을 하도록 해서, recoverability를 보장하도록 한다.
- MVCC는 데이터를 읽을 때,
특정 시점
을 기준으로가장 최근에 commit된 데이터
를 읽는다. (특정 시점은 격리 수준에 따라 달라짐) - recoverability를 위해 commit 후 lock을 반환한다.
- 데이터 변화(write) 이력을 관리한다. ⇒
추가적 저장 공간
필요 (메모리 효율이 안좋음) - read-write는 서로 block하지 않는다. ⇒
처리량
이 좋음
💋 MVCC를 사용한 트랜잭션 격리 수준별 동작 방법
✔️ read uncommitted
MVCC는 commit된 데이터만을 읽기 때문에, 이 레벨에서는 MVCC가 보통 적용되지 않는다. 어차피 개차반인 격리 수준으로 거의 사용하지 않기 때문에 그냥 스쳐 지나가고 다음 레벨부터 열심히 공부하자.
- MySQL
- read uncommitted 격리수준을 지원은 해주는데, MVCC를 사용하지는 않는다.
- 다른 트랜잭션이 변경 중인 데이터에 대한
직접적인 읽기
가 가능하다. - PostgreSQL
- READ UNCOMMITTED 격리 수준을 지원하지 않으며, 최소한 READ COMMITTED 격리 수준으로 동작한다.
- read uncommitted 레벨이 존재하긴 하는데, 이름만 이렇게 생기고 사실은 read committed 레벨처럼 동작한다.
✔️ read committed
- MySQL
- 각 트랜잭션은
커밋된 데이터만
을 읽을 수 있다. - MVCC를 사용해 트랜잭션이 시작된 시점의 스냅샷을 사용하여 읽는다.
- PostgreSQL
- MySQL과 마찬가지로, MVCC를 사용해 트랜잭션이 시작된 시점의 스냅샷을 사용하여 읽는다.
✔️ repeatable read
repeatable read 레벨에서는 Tx 시작 시간 기준
으로, 그 당시에 커밋된 데이터를 읽는다.
여기서 Tx 시작 시간
에 대한 구체적인 기준은 RDBMS의 구현마다 다를 수 있다.\
어떤 RDMBS는 트랜잭션 시작 시간을 기준으로 하기도 하고, 다른 서비스에서는 트랜잭션에서 최초의 read/write가 발생한 시간을 기준으로 하기도 하기 때문에 주의해서 살펴봐야 한다.
특정 시점(이 경우 Tx 시작 시간)을 기준으로 가장 최근에 커밋된 데이터를 읽는 것은 MySQL에서는 consistent read라고 한다.
- MySQL
- 트랜잭션이 시작할 때의 커밋된 데이터 스냅샷을 찍어놓고 여기서 데이터를 읽는다.
- 이 스냅샷은 트랜잭션이 실행되는 동안 다른 트랜잭션이 커밋되더라도 변경되지 않습니다.
- PostgreSQL
- MySQL과 동일하다.
✔️ serializable
- MySQL
- MVCC를 사용하지 않고, 레코드 레벨에서의 lock으로 동작한다.
- PostgreSQL
- SSI(Serializable Snapshot Isolation) 기법이 적용된 MVCC로 동작한다.
- 범위 잠금 및 버전 관리를 통해 Phantom Read와 같은 문제를 방지한다.
💋 postgreSQL의 Lost Update
✔️ 모든 트랜잭션이 READ_COMMITTED인 경우 Lost Update 발생
Tx1: x가 y에 40을 이체한다.
Tx2: x에 30을 입금한다.
⇒ 결과적으로 x=40, y=50
이 되어야 한다.
하지만, 실행하고 보니 결과가 x=80, y=50
으로, 원하던 결과인 x=40, y=50
과 다른 이상한 결과가 나와버렸다.
Tx2에서 Tx1 작업 중간에 x를 읽어와서 작업한 후 update 해버렸기 때문에
이런 상황을 lost update라고 한다.
✔️ Try: 한 트랜잭션의 격리 수준만을 REPEATABLE_READ로 올린다. ⇒ 실패
PostgreSQL의 경우에, 같은 데이터에 먼저 업데이트한 트랜잭션이 commit되면, 나중에 읽은 트랜잭션은 rollback된다. ⇒ first updater win
위의 경우 Tx1만 성공하게 되어, 결과적으로 데이터베이스에는 x=10, y=50
으로 남아있다.
Tx2는 실패했지만, DB 상태는 정상적이고 재시도할 경우에 성공할 수 있다.
그렇다면 한 쪽만 REPEATABLE_READ
이어도 될까?
Tx1, Tx2의 시작 순서를 바꿔서 Tx1이 나중에 실행되도록 해보자
이 경우에도 Tx2가 반영한 내용이 사라지는 Lost Update가 발생했다.
⇒ Tx1의 격리 수준이 READ_COMMITTED
로는 충분하지 않다!
✔️ Try: 모든 트랜잭션의 격리수준을 REPEATABLE_READ으로 올린다. ⇒ 해결
PostgreSQL의 경우에, 같은 데이터에 먼저 업데이트한 트랜잭션이 commit되면, 나중에 읽은 트랜잭션은 rollback된다. (그냥 postgreSQL의 동작 방식이다)
⇒ first updater win
현재 트랜잭션 외에 다른 트랜잭션의 격리 수준도 함께 고려해야 한다는 점,,,
최종적으로는 Tx2만 성공하여 결과는 x=80, y=10
으로 남을 것이다.
이 경우에 Tx1은 실패했기 때문에 결과가 사라진 것은 없으며, 이후에 Tx1을 필요하다면 다시 실행하면 된다.
한 트랜잭션이 실패했기 때문에 이 결과가 왜 유효한지 의문을 가질 수 있는데, 트랜잭션의 일부가 실행되지만 않는다면 이후에 로깅을 잘 해놓는 등 다른 작업을 해놓는다면 실패했던 작업을 다시 실행하는 식으로 데이터를 충분히 복구할 수 있기 때문에 이 경우에는 lost update를 해결했다고 본다.
⇒ postgreSQL의 MVCC는 Lost update을 REPEATABLE_READ
만으로 해결할 수 있다.
💋 MySQL의 Lost Update
✔️ 모든 트랜잭션이 REPEATABLE_READ여도 Lost Update 발생
✔️ Try: Locking Read를 사용 ⇒ 성공
개발자가 select문 뒤에 for update를 작성하면, read를 하면서 write lock을 획득하도록 할 수 있다.
Tx2에서 x에 대해 locking read를 하는 동안, Tx1은 x를 읽을 수 없다.
이후에 Tx2가 x에 대해 락을 해제해 Tx1이 락을 가져갔을 때, 처음 락 획득을 시도하다가 실패한 시점의 x가 아니라, 가장 최근에 커밋된 데이터를 읽는다. (이건 그냥 MySQL이 이렇게 구현되어 있다.)
결과적으로, 이번에는 모든 트랜잭션이 성공하면서 어쨌든 유효한 결과가 된다.
⇒ MySQL에서 Lost Update를 방지하려면, REPEATABLE_READ
만으로는 부족하고 locking read를 함께 써줘야 한다.
근데 여기서 드는 의문,,, Locking read에서도 write 락을 쓰고 write 작업에서도 write 락을 쓰면 그냥 모두가 write 락을 써서 완전 serialized schedule 아닌가..?
FOR UPDATE
를 사용하면 해당 행에 대한 배타적인 락(exclusive lock)을 획득하므로, 해당 행에 대한 읽기 및 쓰기 작업을 다른 트랜잭션에서 수행할 수 없게 되기 때문에 처리량이 줄어 성능에 안좋을 수 있다.
💋 Write Skew
✔️ write skew란?
두 트랜잭션이 동일한 데이터를 읽지만 다른 데이터를 업데이트하는 경우에 발생하는 현상이다.
아래 경우를 살펴보자. x=10, y=10
에서 시작하고 결과는 각 트랜잭션의 실행 순서에 따라 달라지지만 아무튼 하나는 20, 하나는 30이 되어야 한다.
이번에는 각 트랜잭션이 동일한 데이터를 읽지만, 서로 다른 데이터에 쓰기 작업을 하고 있다. 쓰기 작업에 대해서만 배타 락을 걸고 있어서 x=20, y=20
의 유효하지 않은 결과가 나오게 된다.
위와 같은 현상을 Write Skew라고 하는데, mysql, postgreSQL에서 모두 일어날 수 있는 현상이다.
✔️ Try: MySQL에서 FOR UPDATE를 사용해서 읽는다. ⇒ 성공
MySQL의 repeatable read는 앞에서 설명했듯 이후에 락 획득에 실패했다가 기다려서 이후에 다시 획득하게 되었을 때, 처음 락 획득을 시도하다가 실패한 시점의 데이터가 아니라, 락을 획득한 현재 가장 최근에 커밋된 데이터
를 읽는다. (이건 그냥 MySQL이 이렇게 구현되어 있다.)
따라서 두 트랜잭션 모두 커밋되고, 유효한 결과를 얻을 수 있다.
✔️ Try: PostgreSQL에서 FOR UPDATE를 사용해서 읽는다. ⇒ 성공
postgreSQL도 비슷하게 FOR UPDATE를 사용한 select가 가능하다. 다만 동작 방식이 조금 다르다.
mysql과 동일하게 진행되다가, Tx2가 잠시 락을 얻기 위해 기다렸다가 Tx1에서 반환한 락을 얻은 시점에서 달라진다.
postgreSQL은 repeatable read에서 같은 데이터인 x에 대해서 먼저 update한 Tx1이 커밋되면 나중에 락을 획득한 Tx2는 그냥 롤백된다.
그래서 이 경우에는 Tx1은 커밋되고, Tx2는 롤백된다.
그래도 여전히 결과는 유효하다. (나중에 Tx2를 재시도 하면 되니깐!)
✔️ Try: serializable로 격리 수준을 높인다.
이 경우에도 문제를 해결할 수 있다.
- MySQL
- repeatable read와 유사하게 동작
- Tx의 모든 평범한 select문을 암묵적으로
SELECT … FOR SHARE
처럼 동작한다. (read 락으로 동작한다 ⇒ 성능 이슈 때문에 share lock을 사용하는 듯!) - PostgreSQL
- SSI(Serializable Snapshot Isolation)으로 구현되어 있다.
- first committer winner 방식으로 동작
💋 참고자료
- https://www.youtube.com/watch?v=wiVvVanI3p4&list=PLcXyemr8ZeoREWGhhZi5FZs6cvymjIBVe&index=21
- https://www.youtube.com/watch?v=-kJ3fxqFmqA&list=PLcXyemr8ZeoREWGhhZi5FZs6cvymjIBVe&index=20
도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!