0. 트랜잭션 ACID란
Atomicity
- 하나의 트랜잭션이 정의 되면 그 트랜잭션 전체가 모두 반영(commit)되거나 모두 미반영(rollback)
- 트랜잭션을 사용하는 이유라고 할 수 있다. Atomicity가 없다면 트랜잭션 도중 문제가 생길 경우 application level에서 실행 도중 생긴 문제를 파악하고 그 이전사항까지 되돌리는 코드를 각자가 구현해야 한다.
Consistency
- 데이터베이스 ddl등에서 정의한 데이터의 제약 조건을 보장한다.
- 제약조건, 트리거등을 통해 만들어진 조건을 트랜잭션이 위반하면 Rollback한다.
Isolation
- 각각의 트랜잭션이 다른 트랜잭션에 독립적이라는 의미이다 즉 트랜잭션 실행 도중 다른 트랜잭션이 어떤 일을 수행한지에 관계없이 하나의 트랜잭션 내부에서 영향을 받아서는 안된다는 것이다.
- Concurrency Control의 목표가 Isolation을 만족하는 것이며 이때 직력성이나 회복성을 보장하려고 한다.
- 하지만, 완벽한 Isolation을 추구할 경우 트랜잭션 throughput이 감소할 수 있으므로 비지니스 성격에 맞게 개발자가 isolation level을 설정할 수 있게 즉, 일부러 Isolation을 일부 포기하도록 DBMS마다 다르게 설정할 수 있다.
Durability
- 하나의 트랜잭션이 Commit을 하면 Commit 된 데이터는 영구적으로 저장되는것이 보장되어야 한다.
💡 이 글에서는 ACID중 Isolation에 대한 내용을 설명할 것이다.
1. Serializability
1) Serializability란?
- 여러 트랜잭션이 동시에 실행되면 동시성 문제가 생길 수 있다. 이 문제를 가장 간단하게 해결 할 수 있는 방법은 모든 트랜잭션을 serial하게 즉 동시에 실행되지 못하도록 만드는 것이다.
- 하지만 이렇게 트랜잭션을 실행하면 성능면에서 얻는 이점도 많이 포기해야 하기 때문에 어떻게 하면 동시에 트랜잭션을 실행하면서도 각각이 순차적으로 실행되는것과 같은 결과를 낼 수 있을 지를 고민하였고 순차적 실행과 같은 트랜잭션 실행 결과를 보장하는 것을 Serializability를 만족시킨다고 한다.
2) Serial Schedule
-
서로 다른 두 트랜잭션이 동시에 실행된다고 할 때 다음과 같이 여러 case로 실행 될 수 있으며 이를 각각 Schedule이라고 한다.
-
아래 그림에서 r, w, c는 각각 read, write, commit을 나타내고 숫자와 색깔은 서로 다른 트랜잭션임을 의미한다. k와 h는 대상 데이터를 의미한다.
- case1, case2는 serial schedule이다. serial schedule은 순차적으로 하나의 트랜잭션이 실행되고 commit되고 나서 다른 트랜잭션이 실행 될 수 있다. 따라서 항상 결과가 의도한 대로 실행될 수 있다는 장점이 있다.
serial schedule의 문제점
- serial schedule은 순차적으로 동작해야하기 때문에 multi thread로 동시에 동작할 수 없다는 단점이 있다. DBMS는 읽고 쓰는 과정에서 i/o 작업을 자주 수행하는데 이때 대기해야만 한다면 큰 성능적인 문제가 발생할 것이다.
non-serial schedule
- 따라서 DBMS는 non-serial schedule로 동작한다.
- case3, 4는 non serial schedule로 트랜잭션 1, 2가 commit되지 않았음에도 다른 트랜잭션이 동시에 실행된다.
- 이 경우 성능은 증가 하지만 실행 순서에 따라 실행결과를 예측할 수 없다는 큰 문제가 있다.
- 이렇게 실행 순서에 따라 실행결과가 달라지는 이유는 Conflict을 어떻게 처리하는지에 따라 달라진다.
case4의 Lost Update
- 4가지의 case중 문제가 있는 case는 case4이다. 이 경우 H에 쓰기를 트랜잭션 1에서 할 때 lost update가 발생하고 case1, 2, 3과 다른 결과가 나오게 된다.
- Case4만 문제가 있는 이유는 이 트랜잭션만 Conflict를 다르게 처리하고 있기 때문이다.
3)Conflict
- 트랜잭션의 Conflict란 1)서로 다른 트랜잭션이 2)같은 데이터에 접근하면서 3)적어도 하나가 write를 하고 있는 경우에 발생한다
- Conflict에 대해서 다른 순서로 연산을 수행하면 다른 결과가 나오게 된다.
- case4에서는 H에 대해서 R, W를 하므로 Conflict가 H자원에 대해서 발생한다.
- Case3의 Conflict는 총 3경우로 r1(H) → w2(H), r2(H) → w1(H), w2(H) → w1(H)이다.
- 여기에서 Conflict equivalent라는 개념이 등장하는데, 이는 Conflict가 발생하는 자원에 대한 operation이 모두 같은 순서로 처리하는 경우를 말한다.
- Conflict equivalent가 보장되면 같은 결과가 나오는 Schedule이다.
4) Conflict serializable
- 적어도 하나의 serial schedule과 conflict equivalent하면 Conflict serializable이며 이 경우 순차 실행과 같은 결과를 내게 된다.
- case3는 case2와 Conflict equivalent하다, 하지만 case4는 case1, 2모두와 Conflict equivalent하지 않다. Conflict serializable가 만족 되지 않고 Lost Update가 발생한다.
5) 실제 DBMS는 이렇게 모든 Conflict를 Conflict Serializable한지 찾지는 않는다.
- 실제 DBMS에서 Conflict Equivalent를 체크하는 것은 아니고 Conflict serializable하도록 schedule을 보장하는 프로토콜을 적용한다.
- 즉, 모든 Schedule의Conflict Equivalent를 검증하는 것이 아니라, MVCC, 2PL이라는 기능들을 활용하여 Serializability를 보장한다.
- 다만, Serializability를 엄격하게 보장할 경우 성능이 저하될 수 있으므로 DBMS마다 사용자가 격리수준을 설정하게 하여 Serializability를 어느정도 포기하고 이상현상을 받아들여 성능을 향상시킨다.
- DBMS마다 Serializability를 보장하는 방식은 조금씩 다르다.
2. Recoverability
1) Recoverability란?
- Schedule이 Rollback을 통해 트랜잭션이 실행되기 전 상태로 완전히 돌아갈 수 있음을 말한다.
- Serializability는 DBMS가 타협하여 격리수준을 낮추지만 Recoverability는 DBMS가 어떤경우에도 보장해야 하는 특성이다.
2) Recoverable Schedule
- 두 트랜잭션이 하나의 데이터에 대해서 write하고 있을 때 먼저 write한 트랜잭션이 먼저 커밋해야만함.
- 이렇게 하지 않고 뒤에 write하는 트랜잭션이 먼저 write할 경우 앞의 트랜잭션이 rollback하는 것이 불가능해짐.
3) Cascadeless Schedule
- Recoverable Schedule의 경우 하나의 트랜잭션이 롤백되면 다른 트랜잭션들도 연쇄적으로 롤백될 수 있다.
- 이것을 막기 위해서는 Recoverable Schedule에 추가적으로 어떤 데이터에대해서 여러 트랜잭션이 쓸때 먼저 write한 트랜잭션이 commit한 이후에 다음 트랜잭션이 그 데이터를 읽을 수 있다는 조건이 붙어야한다.
- 앞선 트랜잭션이 commit하기 전에 다음 트랜잭션이 그 데이터의 값을 읽었다면 앞선 트랜잭션이 롤백 할 경우 다음 트랜잭션도 롤백된 데이터를 읽고 처리한것이므로 연쇄적으로 롤백해야한다.
4) Strict Schedule
- 먼저 write한 트랜잭션이 commit한 이후에 다음 트랜잭션이 그 데이터를 읽고 쓸 수 있다는 조건이 있는 경우.
- Cascadeless Schedule의 경우 롤백 과정중 하나의 트랜잭션이 다른 트랜잭션을 유실 시킬 수 있다는 문제가 있다.
5) 개인적으로 의문이 들었던 점
- 일반적으로 수정 시 write-lock을 취득해야하고 트랜잭션이 종료 되는 시점에 이를 반환하는 경우가 많기 때문에 Strict Schedule은 어렵지 않게 보장될 것 같다.
- 또한 MVCC가 적용되는 환경에서는 commit 순서와 상관없이 Recoverable Schedule이 잘 보장 될 것으로 보인다.
- 아마 여기서의 설명은 이러한 것을 배제했을 때의 설명으로 보인다.
3. 트랜잭션 이상현상
💡 기본적으로 트랜잭션은 서로가 실행되는 것에 전혀 영향 받지 않고 독립적으로 실행되는 맞다는 전제하에서 다음 이상현상들을 살펴 보자.
1) Dirty Read
- 아직 다른 트랜잭션에서 commit하지 않은 데이터를 읽을 수 있는 문제.
- Dirty Read의 경우 심각한 문제가 발생할 수 있는데 rollback 한 트랜잭션에서 변경한 값을 다른 트랜잭션에서 읽고 처리한후 commit을 먼저하게 되면 이는 rollback 한 데이터를 읽고 commit까지 한 것이기 때문에 정합성에 큰 문제가 발생한다.
2) Non-Repetable Read
- 한 트랜잭션 안에서 같은 데이터를 읽는데 다른 값으로 읽혀지는 문제.
3) Phantom Read
- 한 트랜잭션 안에서 데이터를 읽을 때 없던 데이터가 추가되어 읽히는 문제.
- 직접적으로 조회 되는 것이 아니라 count등으로 간접적으로 읽히는 것도 해당됨.
💡 여기까지가 일반적으로 설명되는 데이터베이스 격리수준의 차이를 말할 때 설명되는 이상현상이지만 뒤에 나오는 이상 현상도 분명 존재하고 데이터베이스 격리수준이나 DBMS에 따라서 발생할 수 있는 이상현상임. 다음 논문에서는 기존에 ANSI/ISO standard SQL 92 에서 앞에 3가지 이상현상만을 설명하는 것에서 비판하고 다른 이상현상에 대해서도 꼭 다뤄야 함을 이야기 하고 있음.
- A Critique of ANSI SQL Isolation Levels
https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/tr-95-51.pdf
4) Dirty Write
- 커밋이 안된 데이터를 write함.
- 먼저 write한 트랜잭션이 rollback 할 경우 뒤에 트랜잭션이 write하고 commit까지 해도 commit 반영이 안될 수 있음.
- roll back 시 정상적인 rollback, commit이 되는 것이 매우 중요하기 때문에 모든 DBMS의 모든 격리수준에서 이를 막음.
5) Lost Update
- 두 트랜잭션이 한 데이터에 대해서 write를 하고 commit할 때 앞선 commit도 분명 반영은 되지만 뒤에오는 트랜잭션은 앞에 트랜잭션이 데이터를 바꾸었다는걸 모르고 write로 덮어씌우기 때문에 실질적으로 앞에 트랜잭션의 write가 미반영된것이나 마찬가지인 현상이 발생함.
- 예를들어 돈이 계좌에 10000원이 있고 두사람이 이계좌에 동시에 입금 했을 때 앞선 트랜잭션이 11000원을 write하고 commit 했을 때 뒤에 오는 트랜잭션은 11000원으로 데이터가 바뀐지 모르고 11000원을 write하고 commit하기 때문에 앞에 1000원 입금은 누락됨.
6) Read Skew
- skew라는 말은 데이터의 정합성이 맞지않는 상황을 말함. 즉 한 트랜잭션 내에서 읽기 연산을 했더니 데이터 정합 조건에 맞지않는 데이터들이 읽어지는것을 말함.
- 예를 들어 x=50 ,y = 50일 때 x와 y의 합은 100이어야한다는 조건이 있다고 하자. 두 트랜잭션중 하나가 x=50임을 먼저 읽고 다른 트랜잭션은 x에서 40을 빼고 y에 40을 더하는 연산을 하고 commit 했다. 이 트랜잭션에서 x = 10, y = 90으로 read과정에서 데이터 정합성이 맞아떨어진다.
- 하지만 처음에 x=50임을 먼저 확인했던 다른 트랜잭션에서 y만 읽는다면 y=90으로 나오므로 x=50, y=90이라는 정합성이 맞지않는 결과를 가지게 된다. 이를 Read Skew라고 한다.
7) Write Skew
- 정합성이 맞지 않게 쓰게 된다는 의미이다.
- x = 50, y = 50 이고 x + y ≥0이 보장되어야 할 때,
- a 트랜잭션은 x의 값에서 80을 빼고, b 트랜잭션은 y의 값에서 90을 뺴려고 한다.
- 각각 a, b트랜잭션이 먼저 x, y를 읽고 빼는 행동이 x + y ≥0 를 보장하고 write를 준비한다.
- a가 먼저 x = -30으로 만들고 그 다음 b가 y = -40으로 만든다.
- 이러면 결국 x = -30, y = -40이 되어 정합성이 맞지 않는 쓰기가 되게 된다.
💡 보통 Repetable Read, Read Commited로 격리 수준을 설정하기 때문에 Lost Update, Write Skew, Read Skew 이상 현상이 생겨 해결할 일이 많다.
4. 트랜잭션 격리수준과 이상현상
- 설명순서대로 격리수준이 높은 즉, 성능은 낮지만 데이터 정합성이 잘 보장되는 격리 수준이다.
- 다음과 같이 같은 격리수준에서 DBMS마다 구현에 따라 이상현상은 다를 수 있다.
- 각각의 격리수준을 어떻게 구현했는지는 다음 내용에서 설명한다.
1)Read Uncommited
- 가장 낮은 격리 수준으로 거의 Dirty Write를 제외한 모든 이상현상이 생길 수 있다.
- commit 되지않은 데이터를 읽을 수 있다.
- 일부 SNS서비스에서 데이터 정합성이 중요하지않고 성능이 중요한경우 활용하기도 하지만 웹서비스에서 일반적으로 잘 사용되지 않는다.
2)Read Commited
- commit된 데이터만 읽을 수 있다. 하지만 일반적으로 repetable read를 보장하지는 않는다.
3)Repetable Read
- 한 트랜잭션 내에서 repetable read를 보장한다. 하지만 PhantomRead 는 발생할 수 있다.(물론 위에 사진에서 나와있는 것럼 대부분의 DBMS에서 PhantomRead가 발생하지 않는다.)
- 하지만 lost update, write skew등의 쓰기 문제는 발생할 수 있다.
- Pessimistic 락을 적절히 활용하거나 application level에서 데이터를 버저닝해서 optimistic lock 의 효과를 주면 Lost update등의 이상 현상을 상황에 맞게 해결할 수 있다.
- 앞에 나온 그림처럼 DBMS마다 이상현상이 다르게 발생한다.
4)Serializable
- 모든 트랜잭션 이상현상으로 부터 자유로우며 serial schedule만을 실행한다.
- 성능이 느리다. 실제에서 결제등의 계산이 중요한 로직에서 활용될 수 있다.
5. Lock과 2PL(Two Phase Locking)
1) DBMS에서 Lock이란
- DBMS에서 lock이란 특정 데이터를 수정하기 위해 필요한 키라고 생각하면 된다. 이를 통해 Concurrency Control을 할 수 있다.
- 실제로는 record lock, gap lock등 여러 형태로 DBMS 마다 다양한 lock이 있지만 여기서는 설명을 단순화기 위하여 record에 Shared Lock, Exclusive Lock등의 Pessimistic Lock이 걸리는 것을 lock이라고 정의하겠다.
Pessimistic Lock
- 충돌이 발생할 것을 미리 가정하고 아예 접근을 막도록 하는 lock
- 충돌이 자주 발생할 것으로 예상될 경우 유리하다. 충돌이 적게 발생하면 Optimistic Lock에 비해서 불필요한 성능저하가 발생할 수 있다.
- 개발자가 처리할 부분이 거의 없다. select for update, select for share 등의 구분을 활용하면 lock을 얻을 수 있다.
- Shared Lock , Exclusive Lock이 있다.
Optimistic Lock
- 충돌이 발생하지 않는다고 가정하고 일단 트랜잭션을 진행시키고 트랜잭션 커밋 시점에 충돌이 확인되면(주로 버저닝을 통해 확인) 롤백함.
- 롤백에 따른 처리를 개발자가 세심하게 해주어야한다.(예를들어 다시 3번 트랜잭션을 진행한다든지).
- 충돌이 잘 발생하지 않는다고 예상될 경우 유리하다.
- pessimistic lock의 경우 read-write가 동시 실행 될 수없지만 Optimistic lock의 경우 동시 실행될 수 있기 때문에 성능이 좋음.
Shared Lock vs Exclusive Lock
- 특정 데이터에 대해서 접근 할 때 SharedLock만 필요한경우 동시에 여러 트랜잭션이 접근할 수 있다.
- 하지만 Exclusive Lock 이 필요한 데이터는 트랜잭션이 단 하나만 접근할 수 있다.
- 보통 s-lock 은 읽기에 x-lock은 쓰기에 사용하는 경우가 많다. 이를 통해 예시를 들면 어떤 데이터에 읽는데에 s-lock이 필요하다면 여러 트랜잭션이 s-lock을 얻어서 읽기만 한다면 동시에 같이 데이터를 읽을 수 있을 것이다. 하지만 쓰기를 원하는 트랜잭션이 있다면 x-lock을 취득해야므로 나머지 모든 s-lock을 취득한 트랜잭션들이 lock을 반환해야만 x-lock을 얻어서 쓰기를 할 수 있다. 그리고 쓰는 동안 어떠한 lock도 다른 트랜잭션이 얻을 수 없으므로 모두 대기해야한다.
- select for update(Exclusive Lock), select for share (Shared Lock)
2) 2PL(Two Phase Locking)
- Pessimistic Lock을 활용하여 Serializability를 보장하기 위해 지켜야하는 규칙이다.
- 트랜잭션의 읽기에는 s-lock을 획득하고 쓰기에는 x-lock을 획득해야한다고 가정한 상황에서
- 트랜잭션의 모든 Lock을 획득하는 operation이 unlock operation보다 먼저 수행되어야함. 즉, one Phase는 lock을 얻고, the other Phase는 lock을 unlock하는 operation만 실행됨.
(1)Conservative 2PL
- 맨앞에서 모든 lock을 미리 얻고 트랜잭션을 시작함.
- deadlock방지효과
- 실질적으로 잘 사용되지는 않음.
(2) Strict 2PL
- strict Schedule을 보장하는 효과가 있음.
- Recoverability를 가장 높은수준으로 보장.
- write-lock을 commit/rollback할 때 반환.
(3) Strong Strict 2PL
- read, write-lock모두 commit/rollback할 때 반환.
(4) 2PL문제점
- lock은 동시성을 저하시키기 때문에 성능이 저하됨
- read-write가 서로를 block하는것을 해결해보자
- MVCC가 등장함.
6. MVCC(Multi-Version Concurrency Control)
- 2PL에서 read-write가 동시에 되지않음을 해결하기 위해 만들어진 것으로 여러 버전의 commit 된 snapshot을 활용하여 Concurrency Control을 한다.
- 현대 DBMS에서는 MVCC와 Lock을 적절히 섞어서 Serializability를 만족시킨다.
1) MVCC의 기본적인 특징
- write시에는 베타락을 획득해야한다.(race condition을 방지하기 위한 목적이다.) read에는 lock이 걸리지 않기 때문에 read-write가 동시에 가능하다.
- lock은 commit 시점에 제거한다.(2PL특성 일부가질 수 있음.)
- 각 트랜잭션은 각각 snapshot을 가진다. write 한 값은 snapshot에 적어 두었다가 commit 할 때 적용한다.commit 시점에 snapshot 버전과 상관없이 commit을 허용하는 db도 있고(mysql), rollback 하는 db(postgresql)도 있다.
- commit 된 데이터만을 읽는다. 즉, 보통 read Uncommited에서는 MVCC가 적용되지 않을 것이다.
2) PostgreSql
- read commited, repetable read 의 차이는 snap shot에 저장해 놓은 데이터를 commit 시점에 반영할 때 발생한다.
- repetable read에서 PostgreSql기준으로 commit시 버전을 체크하여 데이터가 내가 읽었던 시점의 바로 앞시점이 아닐 경우 rollback한다. read commited는 그냥 반영하기 때문에 lost update의 문제가 발생할 수 있다.
3)Mysql
- Mysql 의 경우에는 repetable read 라고 해도 commit 시점에 rollback해주는 개념이 없기 때문에 바로 앞 버전이 아니더라도 commit이 되고 lost update가 발생할 수 있다.
- read commited의 경우 다른 트랜잭션에서 도중에 commit 한 데이터를 읽을 수도 있다.
4) Repetable read에서 Lost update, Write skew해결
Write Skew
- 다음 두 트랜잭션은 x=10, y=10 으로 시작한다.
- 따라서 serial schedule인 경우 원하는 결과가 x = 20, y=30 혹은 x=30, y=20이 맞다
- 하지만 Repetable read(버전을 체크해서 롤백해도 Lost Update는 방지 되지만 여러 데이터에 write를 한번씩만 하는 경우에는 write skew는 방지 못함.)임에도 불구하고 x=20, y=20이라는 이상한 결과가 나왔음을 볼 수 있다.
- 이 경우를 해결하기 위해서는
- 트랜잭션 격리 수준을 Serializable로 올리거나
- 모든 데이터 Read시 x-lock( or s-lcok을 취득해도 되지만 이 경우 데드락이 잘 발생함.)을 취득하는 방법이 있다.
- Mysql의 경우 Read시 x-lock을 취득하면 자기가 최신 데이터를 읽고 쓰는 것이 보장 되므로 그냥 공통 커밋된 데이터 저장소에서 최신 데이터를 읽고 거기에 바로 쓰기 때문에 버저닝에 따른 rollback이 생길일이 사라진다.
- PostgreSql에서는 x-lock을 걸더라도 버전이 다르면 롤백한다.
5) Serializable
MYSQL
- MVCC + read s-lock으로 처리한다.
- 위에서 Repetable Read + x-lock과 장단점이 있는데
- s-lock을 걸면
- 장점은 rw를 어느정도 동시에 할 수 있기 때문에 r비율이 높으면 성능이 향상된다.
- 단점은 쓰기가 많을 경우 데드락이 잘 걸린다.
- x-lock이 걸리면
- 읽기가 많을 경우 rw동시에 못해서 성능이 떨어질 수 있지만
- 쓰기가 많더라도 데드락이 덜 걸린다.
💡 따라서 쓰기가 많을 경우 Mysql 에서 Repetable Read + x-lock이 유리하고 읽기가 많을 경우 Serializable 로 격리 수준을 올리는 것이 유리하다.
PostgreSql
- Serializable Snapshot Isolation을 통해 해결.
7.결론: 실무에서 어떻게 격리수준을 맞출지
- 일반 적으로 웹 어플리케이션의 DBMS 격리수준은 Read Commited, Repetable Read로 맞추어져 있다.
- 따라서 먼저 활용할 격리 수준에서 발생할 수 있는 이상현상을 찾아보고 (책이나 메뉴얼)
- 꼭 처리해야하는 이상현상이 있다고 판단되면
- 격리수준 올리는 것 고려해보고 이것이 부적합하다고 판단되면
- 비관적 락 vs 낙관적 락 중 비지니스 룰에 적합한 것을 선택
- 분산락도 고려해본다.