이전 포스트에서 concurrent한 트랜잭션으로 인한 read phenomena, 그리고 그것들을 제어할 수 있는 Isolation level에 대해서 알아보았다. https://kyumcoding.tistory.com/88
이번 포스트에서는 데이터베이스에서 isolation level에 따른 concuerrncy control을 실제로 어떻게 구현하는가에 대해서 알아보겠다. 크게 Lock을 활용한 isolation, Snapshot을 이용한 isolation, MVCC에 대해서 살펴볼 예정이다. 이번 포스트도 쉬운코드님 콘텐츠를 참고하였다. (쉬운코드님 짱)
https://www.youtube.com/watch?v=0PScmeO3Fig&list=PLcXyemr8ZeoREWGhhZi5FZs6cvymjIBVe&index=19
https://www.youtube.com/watch?v=wiVvVanI3p4&list=PLcXyemr8ZeoREWGhhZi5FZs6cvymjIBVe&index=19
https://www.youtube.com/watch?v=-kJ3fxqFmqA&list=PLcXyemr8ZeoREWGhhZi5FZs6cvymjIBVe&index=20
Isolation with Lock
스타벅스에서 공중화장실을 이용하기 위해 화장실 키를 사용하는 방법과 유사하다. lock(화장실 열쇠)을 획득한 트랜잭션만이 특정 권한을 얻을 수 있고, lock을 얻지 못한 다른 트랜잭션들을 대기해야 한다. lock에는 크게 두 가지 종류가 있다
Exclusive lock (배타적 잠금)
write lock (쓰기 잠금)이라고도 불리며, 한 트랜잭션이 데이터를 쓰고자 할 때 얻는 lock이다. (물론 이론적으로 Exclusive lock을 걸어두고 읽기만 하는 것도 가능) (이때 lock의 범위는 하나의 레코드가 될 수도 있고, 테이블 전체가 될 수 있다. 자세한건 데이터베이스마다 다름) exclusive lock을 걸게 되면 다른 트랜잭션은 해당 데이터에 대해서 읽지도, 쓰지도 못한다. 따라서 타 트랜잭션은 exclusive lock뿐만 아니라 바로 뒤에 나올 shared lock 또한 얻을 수 없게 된다. 말 그대로 배타적(exclusive)인 권한을 얻는 것이다.
Shared lock (공유 잠금)
Read lock (읽기 잠금)이라고도 불리며, 한 트랜잭션이 데이터를 읽고자 할 때 얻는 lock이다. 공유(shared)라는 용어처럼 Shared lock이 걸린 데이터에 대해 타 트랜잭션 또한 Shared lock을 걸 수 있다. 읽기 잠금이 걸렸다고 하더라도, 다른 트랜잭션에서도 해당 데이터를 읽을 수 있다. 다만, Shared lock이 걸린 데이터에 대해서 타 트랜잭션이 쓰기 작업을 할 수는 없다(== exclusive lock을 걸 수는 없음)
하지만 lock을 사용한다고 해서 반드시 스케쥴 간의 serialzability가 보장되는 것은 아니다. 아래처럼 lock을 사용해도 nonserializable한 스케쥴이 만들어질 수도 있다.
2PL Protocol
Serializability를 보장하기 위해서 등장한 것이 2PL Protocol(two-phase protocol)이다. 말그대로 locking이 2개의 단계로 걸리고 풀린다는 의미인데, lock을 취득만하고 반환하지는 않는 expanding phase(growing phase)와 lock을 반환만 하고 취득하지는 않는 shrinking phase(contracting phase) 이렇게 2단계가 차례로 있어야 한다. 좀 더 추상적으로 말하자면 하나의 트랜잭션에서 모든 locking operation이 최초의 unlock operation보다 먼저 수행되도록 하는 것이다.
하지만 2pl protocol이 완전무결하지는 않은데, 경우에 따라 트랜잭션끼리의 데드락이 발생할 수도 있다
이를 보완하기 위해 아래와 같은 여러 형식의 2pl protocol이 등장하였다.
Conservative 2PL
처음에 모든 Lock을 취득한 뒤에 트랜잭션을 진행하다. 데드락이 발생하지는 않지만, 모든 lock을 우선 취득해야 시작할 수 있기 때문에 실용적이지 않다 (모든 lock을 얻기 어려운 환경도 있고, 애초에 모든 lock을 획득하면 처리량이 낮아질 수밖에 없음)
Strict 2PL (S2PL)
Strict Schedule을 보장하는 2PL이다. Strict Schedule은 이전 포스트에서 살펴봤던 것처럼 트랜잭션의 recoverablilty, 롤백해도 온전히 이전 상태로 돌아갈 수 있음을 보장한다는 스케쥴이다. Strict 2PL은 데이터를 변경하는 오퍼레이션, 즉 write 오퍼레이션과 그로 인한 write-lock이 발생하였을 경우 그 오퍼레이션을 포함하는 트랜잭션이 커밋 혹은 롤백되기 전까지 다른 트랜잭션이 해당 데이터에 대해서 read와 write를 시도하지 못하게 하는 방식이다. 세부적인 구현은 write-lock을 커밋 혹은 롤백 시점에 lock을 반환하게 만드는 것이다.
Strong Strict 2PL (SS2PL or rigorious 2PL)
Strict 2PL이 write 작업에 대해서만 트랜잭션의 커밋 혹은 롤백 시점에 lock을 반환하게 했다면, SS2PL은 read 작업에까지 해당 규칙을 확대 적용하였다. Shared Lock와 Exclusive Lock 모두 트랜잭션이 커밋 혹은 롤백 시점에 Unlock 한다. Strict Schedule을 보장하기 때문에 Recoverability가 보장되며, S2PL보다 구현이 쉽다는 장점이 있다. 하지만 read와 write 모두에 다른 트랜잭션의 오퍼레이션을 막기 때문에 처리량이 더 낮아진다는 단점이 존재한다.
MVCC (Multi Version Concurrency Control)
2pl은 read-read를 제외하고 read-write, write-read, write-write 오퍼레이션을 block하기 때문에 전체 처리량이 좋지 않다는 한계점이 존재한다. 이를 극복하기 위해 MVCC (multiversion concurrency control)라는 개념이 등장했는데, 이는 write-write는 여전히 block하지만 그외의 read-write, write-read에 대해서는 허용하여 처리량을 높이는 방법이다.
가장 기본적인 아이디어는 데이터를 쓸 때 원데이터베이스에 바로 쓰는 것이 아니라 어떤 복사본 같은 것을 만들고 거기에서 오퍼레이션을 수행한 후에 나중에 커밋할 때 한 번에 반영하는 것. 이때 주의할 것은 lock의 장점도 분명 존재하기 때문에 MVCC를 구현할 때 lock 매커니즘을 완전히 제거하는 것은 아니라는 것이다. 특히 write-write에 대해서는 락을 기반으로 오퍼레이션들을 블락한다.
Snapshot isolation
Snapshot isolation은 MVCC의 일종인데, 이를 알아두면 이후에 편한 것들이 있어 먼저 간략하게 설명한다.
트랜잭션을 처리할 때 특정 시점에서의 스냅샷(데이터베이스의 특정 상태를 사진을 찍는 거랑 비슷, 복사본 같은 느낌)을 만들고, 그 스냅샷 안에서 트랜잭션을 진행한다. 이후에 커밋할 때 트랜잭션이 반영된 스냅샷과 커밋을 시도하는 시점의 원 데이터베이스를 비교하여 데이터의 일관성이 지켜지는지 검사하고, 이상이 없으면 반영한다.
MVCC
여튼 다시 MVCC로 돌아와서 MVCC는 read와 write 연산의 block 현상을 해결하고, lock보다 더 많은 처리량이 가능한 이유는 MVCC에서 ① 데이터의 변화(write)를 snapshot에서 처리 후 이후 커밋 때 변경하는 것과 ② 기본적으로 특정 시점을 기준으로 커밋된 데이터만 읽기 때문이다. 어떤 값을 write 하기 위해 lock을 걸더라도 우선 연산은 원래 데이터베이스가 아닌 다른 곳에서 진행되며, 다른 트랜잭션에서는 특정 시점의 커밋된 데이터를 읽어오기 때문에 block이 발생하지 않는다.
②에서 말하는 특정 시점은 isolation level에 따라서 달라진다.
- Read Uncommitted: MVCC는 committed된 데이터를 읽기 때문에 read uncommitted에서는 MVCC가 보통 적용되지 않는다
- Read Committed: Read 하는 시점을 기준으로 원 데이터베이스에 있는 데이터 (== 커밋된 데이터)를 읽어온다. 이로 인해 non-repeatable read나 phantom read 현상이 발생할 수 있다.
- Repeatable Read: 트랜잭션(t1) 시작 시간 기준(RDBMS마다 조금씩 다를 수 있음), 그 전에 commit된 데이터를 읽어온다. 다른 트랜잭션(t2)에서 write를 하거나 commit을 하더라도, t1 트랜잭션에서는 계속해서 이전과 같은 값을 읽어올 수 있다.
- 단, postgres에서는 reapeatable read에서 같은 데이터에 대해 어떤 트랜잭션(t1)이 락을 걸고, 다른 트랜잭션(t2)가 t1의 락에 의해 대기하고 있을때, t1에서 데이터를 update하여 커밋하게 되면 t2는 롤백하게 된다. 이렇게 함으로써 t1에서 update한 내용을 t2의 업데이트가 덮어씌우는 lost update 현상을 방지할 수 있다.
- 이처럼 먼저 커밋된 내용이 반영되고, 뒤에 시작된 트랜잭션이 롤백하는 것을 First-committer-win이라고 한다.
- postgres에서 SELECT ...FOR UPDATE와 SELECT ...FOR SHARED 구문을 사용하여 read에 대해서도 각각 exclusive lock, shared lock을 걸 수 있는데, 이를 통해 한 트랜잭션이 어떤 데이터에 대해서 읽고, 나아가 쓰는 것까지 lock을 걸고 실행할 수 있다. FOR UPDATE를 예시로 들자면, 다른 트랜잭션은 해당 데이터에 대한 먼저 트랜잭션이 작업을 끝낼 때까지 Read도 할 수 없으며, 만약 먼저 트랜잭션이 데이터를 업데이트 한 이후에 commit을 하게 되면 postgres에서는 뒤에 트랜잭션은 롤백을 시키기 때문에 write skew 문제도 방지할 수 있다.
- MySQL에서는 repeatable read에서 postgres처럼 롤백 시키는 기능을 제공하지 않기 때문에, locking read(SELECT ...FOR UPDATE와 SELECT ...FOR SHARED 구문으로 읽는 것에도 lock을 거는 것)를 사용한다. Locking read는 위에서 언급했듯 읽는 것도 lock을 걸어주기 때문에 update까지 이어서 한다면 격리된 트랜잭션 연산이 가능하다. 또한 MySQL의 locking read는 최근에 커밋된 데이터(트랜잭션을 시작한 시점의 데이터 X)를 읽기 때문에, 다른 트랜잭션이 먼저 값을 업데이트하여 커밋했다고 하더라도 해당 업데이트를 반영하여 트랜잭션을 진행할 수 있다. 위 과정을 통해 lost update, write skew 문제를 방지한다.
- 단, postgres에서는 reapeatable read에서 같은 데이터에 대해 어떤 트랜잭션(t1)이 락을 걸고, 다른 트랜잭션(t2)가 t1의 락에 의해 대기하고 있을때, t1에서 데이터를 update하여 커밋하게 되면 t2는 롤백하게 된다. 이렇게 함으로써 t1에서 update한 내용을 t2의 업데이트가 덮어씌우는 lost update 현상을 방지할 수 있다.
- Serializable
- MySQL: 트랜잭션의 모든 SELECT 문이 암묵적으로 SELECT ...FOR SHARE 처럼 동작. (MVCC보다는 lock 매커니즘으로 구현) FOR UPDATE 보다는 처리량이 높을 수 있으나, 데드락의 가능성이 있기 때문에 주의 필요
- PostgreSQL: SSI(Serializable Snapshot Isolation)로 구현. (구체적인 내용은 모르겠으나 스냅샷으로 트랜잭션 처리하고 커밋할 때 둘의 호환성을 비교하는 그런거인듯)
'Database' 카테고리의 다른 글
DB Concurrency Control - Read phenomena, Isolation level (0) | 2023.09.28 |
---|---|
DB Concurrency Control - Schedule, Serializability, Recoverability (0) | 2023.09.28 |
[Postgresql, Redshift] 데이터 현황 확인하기 (0) | 2023.06.27 |
[SQL] COUNT(*), COUNT(1), COUNT(expression)의 이해 (0) | 2023.06.18 |
외부에서 MySQL DB 접속하기 (0) | 2023.02.14 |