concurrency control 3 : transaction isolation level

이혁진·2022년 12월 27일
0

0. reference

https://youtu.be/bLLarZTrebU
https://velog.io/@youngerjesus/트랜잭션Transaction
https://velog.io/@guswns3371/데이터베이스-트랜잭션-격리수준
https://medium.com/@lsy.study/study-데이터-중심-애플리케이션-설계-7-트랜잭션-10b4b4c08efe
https://junghyun100.github.io/Isolation-level/
https://velog.io/@jduck1024/동시성-제어
https://velog.io/@flobeeee/TIL-the-transaction-isolation-level
https://jaeseongdev.github.io/development/2021/06/16/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-%ED%8A%B9%EC%A7%95-(ACID)/
https://lion-king.tistory.com/entry/SpringJPATransaction-Write-skew-Phantom-1

1. isolation level

트랜잭션의 성질 중 격리(isolation)은 여러 트랜잭션이 동시에 실행되더라도 마치 독립된 것처럼 실행된다는 것이다. 이러한 트랜잭션의 격리 정도가 너무 심하면(serial) dbms의 트랜잭션 처리 속도가 늦어지게 된다. 반면 너무 격리가 약하면 트랜잭션이 실행되는 도중 여러 이상 현상이 일어날 수 있다. 이에, 격리 정도를 dbms 사용자가 조절할 수 있도록 만든 것이 isolation level이며, 그 단계는 얼마나 많은 이상 현상을 허용할 지에 따라 결정된다.

이상 현상이 모두 발생하지 않을 수 있게 만들 수 있으나, 그러면 제약 사항이 많아져 동시 처리 가능한 트랜잭션 수가 줄어들어 DB의 전체 처리량이 하락하게 된다.

이거랑 관련한 트랜잭션 성질은 일관성과 고립성이다. 아래는 트랜잭션 ACID를 정리하고 DBMS의 기능들이 어떤 ACID 성질을 달성하는지를 나타낸 그림이다.

특히 주목할 점은 consistency이다. 위에 말했던 것을 이것으로 설명하면, isolaton level이 높아질 수록(concurrency가 낮아질 수록) 일관성이 높아지고, 낮아질 수록 일관성이 낮아지므로, 적절한 타협점을 찾아야 하겠다. 아래는 consistency와 concerrency의 관계를 나타낸 그림이다.

밑에 글 정리하다보니 consistency가 잘 이해되지 않아서 찾아보니까 아래가 가장 맞는 것 같다.

트랜잭션이 성공적으로 수행된 후에도 데이터베이스가 일관성 있는 상태를 유지해야 한다. 여기서 말하는 일관성이란, 크게 2가지 의미를 가진다.

  • 첫 번째로, 트랜잭션이 커밋되면 데이터베이스에 적용한 제약조건(CHECK(x>0...), PRIMARY KEY, UNIQUE, NOT NULL 등)을 위반하지 않는다는 보장을 의미한다.
    ex) 사용자가 번호를 저장하려고 하는데, 이 번호에 UNIQUE 제약이 설정되어 있으면 중복되니 사용자 번호를 저장할 수 없어야 한다.

  • 두 번째로, 트랜잭션의 작업이 애플리케이션에서 의도하고자 한 작동이 정상적으로 일어난다는 보장을 의미한다.
    ex) 재고가 떨어졌을 때, 더 이상 판매를 할 수 없도록 제한해야 한다.

[2023.05.04 추가]
데이터 일관성이란 여러 곳에 존재하는 데이터가 같은 값을 가진다는 것이다. 이는 이해하기 크게 어렵지 않다. 다만 트랜잭션의 일관성으로 가면 의미가 잘 이해가 되지 않는다. 트랜잭션 일관성이란 트랜잭션의 시작과 끝이 일관(의미 그대로)된 상태를 유지하는 것을 말한다. 가령 lost update 문제에서는

r1 r2 w2 w1

w2의 변화가 커밋되고, 그 다음 t1에서 읽으면 그 변화가 반영된다. 하지만, t1의 끝 시점에는 변화가 반영되었지만 시작 시점에는 반영되지 않았기에, 비일관적이라는 것이다.(끝 시점만 일관적)

그렇다고 w2의 시점에서 t1이 수행되는 메모리에도 써주는 것은 무리가 아니겠는가. 따라서 그냥 비일관성이 발생한 t1 자체를 롤백하고 다시 수행시킬 수가 있다. 아니면 애초에 디스크에 커밋된 값을 읽어서 일관된 상태로 시작하게 할 수도 있다.(t2의 커밋 값 혹은 이전 트랜잭션의 커밋 값이든..) 아무튼 요지는 시작과 끝이 모두 일관적이어야 한다는 것이고, 이는 최신 값을 수신하는 것을 포함한다.

단순히 최신 값을 수신하는 것만으로 일관성을 이해하면, non-repeatable read는 설명할 수 없다

r1 w2 r1

최신 값을 수신했지만 끝 시점의 r1은 시작 시점의 r1과 다를 수 있다. w2의 변화는 끝 시점의 r1에만 반영되었기에(t1의 작업 메모리에 넣어주면 될 것 같기도 하고) 비일관적이라 할 수 있다.

"시작과 끝이 일관된 상태를 유지합니다." 라는 문장이 잘 이해가 안된 이유가 이것 때문이다. 생각해보니 오히려 매번 최신 변화가 반영되어서가 아니라, 시작 상태에까지 변화가 반영되지 않아서 발생한 문제였고, 일관성이 왜 '일관'이라는 단어를 썼는지 조금 더 잘 이해할 수 있었다.

근데 또 생각해보면 분할된 트랜잭션 작업 메모리가 근본적인 비일관성의 원인이니, 간단히 변화를 저 트랜잭션 메모리에 넣어주면 되지 않을까? 싶다. 근데 또

r1 w1 w2 r1

이러면 안될 거 같긴 하다.

2. 이상 현상(standard SQL 92)

2.1. dirty read

dirty read는 commit 되지 않은 변화를 읽었을 때 발생한다. commit되지 않은 변화를 읽고 commit한 상황에서, 만약 읽힌 트랜잭션이 rollback된다면 db의 무결성이 깨지게 된다.(잘못된, abort된 데이터를 기반해서 처리했으므로) 예를 들면

t1 : x에 y를 더한다.
t2 : y에 10을 더한다.
x = 30, y = 20

r1(x)=>30
				r2(y)=>20
                w2(y=30)
r1(y)=>30
w1(x=60)
commit			
				abort:rollback(y=20)

원래대로라면 t2가 롤백되었으니, x=50, y=20이 되어야 한다. 하지만 결과를 보면 x=60, y=20인 것을 확인할 수 있다.

2.2. nonrepeatable read

nonrepeatable read란 같은 데이터의 값이 달라지는 현상을 말한다. isolation에 따르면 어떤 데이터를 읽는 상황에서 다른 트랜잭션이 해당 데이터를 수정했다고 해보자. 그 다음에 데이터를 다시 읽는다면 데이터가 바뀌었을 것이다. isolation이 이루어진다면, 다른 트랜잭션이 있더라도, 똑같은 데이터를 두 번 읽었을 때 변화가 있으면 안되는 것이다. 예시를 보자.

t1 : x를 두 번 읽는다.
t2 : x에 50을 저장한다.
x=20

r1(x)=>20
				w2(x=50)
                commit
r1(x)=>50             
commit

직렬 스케줄이었다면 x를 두 번 읽더라도 모두 20이 나올 것이다. 반면, 이 스케줄에서는 다른 트랜잭션의 변화가 반영되어 isolation이 잘 이루어지지 않은 것을 알 수 있다.

2.3. phantom read

phantom read란 없던 데이터가 생기는 것을 말한다. 조건을 걸어 데이터를 읽는 경우, 다른 트랜잭션에서 해당 조건에 맞는 데이터를 만든다면, 다음번에 읽을 때에는 다른 데이터가 읽힐 수 있다. 무슨 소리냐면

t1 : v=10인 데이터를 두 번 읽는다.
t2 : y.v를 10으로 바꾼다.
x = (..., v=10)
y = (..., v=20)

r1(v=10)=>x
					w2(y.v=10)
                    commit
r1(v=10)=>x,y
commit

첫 번째 read에서 데이터가 x만 있었지만, t2의 개입으로 인해서 두 번째 read에서는 y도 역시 검색되었다.

2.4. isolation level(standard SQL 92)

위의 이상 현상 중 얼마나 많은 것을 허용할 지에 따라서 개발자는 isolation level을 선택할 수 있다.

<isolation level과 허용하는 현상>
read uncommitted : dirty-, nonrepeat-, phantom-
read committed : nonrepeat-, phantom-
repeatable read : phantom-
serializable : X

생각해보면, read committed는 커밋된 것을 읽는다는 뜻으로, dirty read의 정의와 상반되는 것을 확인할 수 있다.

3. 이상 현상(A Critique of ANSI SQL Isolation Levels)

기존 SQL 표준에서 isolation level은 다음의 문제점이 있었다.

첫째, 세 가지 이상 현상의 정의가 모호했다. 예시로써 표현했을 뿐, 그 현상의 일반화된 정의는 알 수가 없었다.
둘째, 세 가지 이상 현상 이외에도 다른 이상 현상이 존재한다.
셋째, 상업적인 DBMS의 방법을 반영해서 isolation level을 구분하지 않았다.

이에, 마이크로소프트? 리서치에서 SQL 표준을 비판하는 논문을 쓴 모양이다. 기존 것에 더해 새로이 정의된 이상 현상은 다음이 있다.

확장된 dirty read, dirty write, non-repeatable read, 확장된 phantom read, lost update, read skew, write skew이 그것이다.

얼핏 보면 달라보이지만, 기존 현상을 확장(한 데이터 뿐만 아니라 연관 데이터까지 포함)하거나 더 일반화된 현상을 만든 것이다.

3.1. dirty write

dirty write는 commit되지 않은 데이터에 write 작업을 수행하는 것을 말한다. commit되지 않은 데이터에 write를 한 뒤, 다른 트랜잭션에서 abort, rollback 했을 때, write작업이 사라지게 된다. 만약 사라지게 하고 싶지 않다면, 롤백할 수 있는데, 이 경우에도 기존 데이터의 롤백이 반영되지 않는다. 무슨 말이냐면

t1 : x에 10을 더한다.
t2 : x에 40을 쓴다.
x = 0

r1(x)=>0
w1(x=10)
                w2(x=40)
                
abort:rollback(x=0)
				
                commit??
                

commit을 abort 후에 한다고 해보자. x가 0으로 롤백했는데, 이는 w2(x=40)의 변화를 무시하게 된다. 그래서 w2(x=40)을 롤백하면 다음과 같다.

r1(x)=>0
w1(x=10)
                w2(x=40)
                
abort:rollback(x=0)

				abort:rollback(x=10)??

롤백하면, x가 10이 되는데, 이 경우에는 t1의 롤백을 무시하게 된다. 만약 t1의 abort 전에 t2가 commit하는 경우에도, x=0으로 롤백되어 commit된 변화가 덮어쓰여지게 된다.

이처럼 dirty write에서는 정상적인 recovery가 불가능해진다. recovery의 중요성 때문에 dirty read와 더불어 가장 기본적인 격리 수준에서 방지된다.

3.2. 확장된 dirty read

dirty read는 commit 되지 않은 변화를 읽었을 경우, 읽은 대상이 abort 되었을 때, 읽은 내용이 실제와 다르게 되는 현상이었다. 근데 꼭 abort 되지 않더라도 실제와 다르게 될 수 있다. commit 되지 않은 변화를 읽고, commit 되지 않은 트랜잭션이 마저 변화를 수행한다면 읽었던 데이터는 잘못된 데이터가 되는 것이다. 무슨 소리냐면

t1 : x에 10을 두 번 더한다.
t2 : x를 읽는다.
x = 20

r1(x)=>20
w1(x=30)
				read(x)=>30
                commit
r1(x)=>30
w1(x=40)
commit/abort:rollback(x=20)

t2에서 commit되지 않은 변화를 읽은 뒤에, x에 변화가 추가적으로 일어난다. 즉, 잘못된 데이터를 읽은 것이다. 만약 정상적인 스케줄이었다면, x는 40(commit)이거나 x는 20(abort)가 읽힐 것이다.(t1->t2일 때) 기존의 dirty read에 해당하는 abort의 경우 뿐만 아니라 commit의 경우도 dirty read에 포함시키는 것이 새로운 정의이다.

그리고 주의해야 할 점이 있는데, 생각해보면 "commit되지 않은 데이터"라는 말이 이상할 수도 있다. 왜냐하면 commit은 과거에 이미 어떤 트랜잭션에서 일어났을 수도 있는 일이고, commit 안된 데이터는 거의 없기 때문이다. 이것을 그대로 해석하면 안되고, 정확히는 "다른 트랜잭션에서 제어 중인 데이터"라고 보는 것이 맞는 듯 하다. 뭔소리냐면, recoverability 글에서 "피의존자의 commit/abort 후에만 read/write를 한다"라는 것이 있다. 이것이 위배되었을 때 리커버리가 정상적으로 작동하지 않게 되고, dirty read/write가 발생한다고 보면 되겠다.

3.3. read skew

read skew란 inconsistent한 읽기를 말한다. nonrepeatable read처럼 데이터의 일관성이 깨지는 것은 맞는데, 좀 더 일반적인 현상이다. 하나의 데이터 뿐만 아니라 연관이 있는 여러 데이터의 일관성이 깨지는 것을 포함한다.

t1 : x가 y에 40을 이체
t2 : x와 y를 읽는다
x = 110, y = 50

					read2(x)=>50 // not updated
read1(x)=>10
write1(x=70)
read1(y)=>50
write1(y=90)
commit
					read2(y)=>90 // updated
                    

보면, 처음에 읽은 데이터는 t1이 반영되지 않았고, 후에 읽은 것은 t1이 반영되었다. 즉, 일관성이 깨지게 된 것이다. 무슨 말이냐면, 원래라면 이체를 하더라도 x계좌와 y계좌의 합은 동일해야 하는데, 이 상황에서는 합이 바뀌게 됐다는 것이다.

특히 이 경우는 read committed 정도의 격리에 해당한다. write1(y=90) 혹은 연관된 write1(x=70)라는 변화의 커밋 후에 read2(y)=>90가 이루어지기 때문이다. read committed 임에도 불구하고 read skew가 발생할 수 있음을 확인할 수 있는 특별한 사례이다.

3.4. write skew

write skew란 inconsistent한 쓰기를 말한다. 제약 조건 같은 것이 있으면, 모든 읽기 작업에 동일하게 적용되어야 하는데, 스케줄에 따라서 제약이 적용되지 않는 일부 write가 발생할 수 있다. 무슨 말이냐면

t1 : x에서 80을 인출한다.
t2 : y에서 110을 인출한다.
x = 30, y = 90 (constraint:x+y>=0)

r1(x)=>30
r1(y)=>90
				r2(x)=>30
				r2(y)=>90
w1(x=-50)
commit
// ok : x는 -50이고 y는 90이니까
				
				w2(y=-20)
				commit
				// ok : x는 30이고 y는 -20이니까
                

t1은 괜찮은데, t2가 문제다. 두 계좌의 합이 0보다 크거나 같아야 한다는 제약이 적용되지 않았다. 원래 커밋 시점에서 x는 -50이고 y는 -20이라 abort되어야 하기 때문이다. 아무튼 이렇게 제약이 비일관되게 적용되는 것이 write skew이다.

이거도 read committed에서 괜찮다. w1(x=-50)의 변화를 w2(y=-20)이 덮어쓰고 있지만(x와 y는 연관됨) w1의 커밋 후에 발생하기 때문이다. 그런데도 역시 write skew가 발생했음을 확인할 수 있다.

쓰기 스큐: 트랜잭션이 무언가를 읽고, 읽은 값을 기반으로 어떤 결정을 내린 후 그 결정을 데이터 베이스에 쓴다.
그러나 쓰기를 실행하는 시점에는 읽은시점에 결정을 내렸던 상태와 달라 참이 아니다. 직렬성 격리만 이런 현상을 막을 수 있다.

인용한 글인데, 이거를 잘 생각해보면 이해가 될 것 같다. 또 read skew랑 write skew랑 비슷한 것 같기도 하다.(결정이 쓰기냐 읽기냐 차이?)

3.5. lost update

lost update는 끼어든 다른 트랜잭션의 변화를 덮어쓰는 현상이다. 기존 트랜잭션이 수행되는 동안에, 다른 트랜잭션이 들어와 같은 데이터를 갱신했다고 해보자. 다른 트랜잭션이 갱신을 커밋한 다음에 기존 트랜잭션이 이어서 write 작업을 수행했다면, 끼어든 트랜잭션의 작업은 사라지게 된다. 무슨 말이냐면

t1 : x에 30을 더한다.
t2 : x에서 10을 뺀다.
x = 60

r1(x)=>60
				r2(x)=>60
            	w2(x=50)
                commit
w1(x=90)
commit

원래대로라면 30을 더한 값인 90에서 10을 빼서 80이어야 하지만, 빼기 작업이 사라져서 90으로 commit이 된다. 요것도 커밋 후에 덮어쓰니까 read committed에 해당된다.

3.6. 확장된 phantom read

기존 팬텀 리드는 같은 조건 두 번 검색했을 때 없던 데이터가 새로이 생기는 현상이었다. 이를 보다 일반화하여 정의한 팬텀 리드는 "꼭 같은 조건이 아니더라도 연관된 데이터를 읽는 경우"까지 포함한다. 뭔 소리냐면

t1 : v>10인 데이터와 cnt를 읽는다.
t2 : v=20을 추가하고 cnt를 1 증가.

x(..., v=7)
cnt = 0

r1(v>10)=>null
					w2(y(..., v=20))
                    r2(cnt)=>0
                    w2(cnt=1)
					commit    
r1(cnt)=>1 //??
commit

변경의 커밋 후 읽으므로 read committed이지만, t1의 결과가 이상하다. 분명 조건에 맞는 것이 없는데 cnt는 1이다. 이처럼 새로 데이터가 검색되는 것 뿐만 아니라 그것으로 인한 연관된 데이터의 변화도 phantom read로 해야 한댄다.

3.7. isolation level

새로운 현상을 포함해 isolation level을 설명하면 다음과 같다. 해당 레벨 별 제어 방식은 후에 다룰 예정이다.

<isolation level이 '막는' 현상>
read uncommitted(0) : X
read committed(1) : + dirty r/w
repeatable read(2) : + r skew(nonrepeat r) + lost update
serializable(3) : + w skew + phantom read
                 

4. snapshot isolaton concept

스냅샷 아이솔레이션은 후에 더 자세히 다루고, 작동 원리만 대강 정리한다. 스냅샷 아이솔레이션은 트랜잭션마다 데이터들의 버전을 따로 가지게 하고, 그것을 기반으로 이상현상이 발생했는지 확인, abort하는 것이라 한다.

아래는 다른 글에서 가져온 글이다.

동시성 제어의 목표는, 동시에 실행되는 트랜잭션 수를 최대화하면서도 입력, 수정, 삭제, 검색 시 데이터 무결성이 유지되도록 하는 것이다. 하지만, 일반적인 Locking 메커니즘에서 읽기 작업과 쓰기 작업이 서로 방해를 일으키기 때문에 종종 동시성에 문제가 생기곤 한다. 또한, 데이터 일관성에 문제가 생기는 경우도 있어 이를 해결하려면 Lock을 더 오랫동안 유지하거나 테이블 레벨 Lock을 사용해야 하므로 동시성을 더 심각하게 떨어뜨리는 결과를 낳는다.

이러한 문제를 해결하기 위해 Oracle은 버전 3부터 다중버전 동시성 제어(Multiversion Concurrency Control, MVCC) 메커니즘을 사용해 왔다. 이는 스냅샷 격리성 수준이라고도 하며 동시성과 일관성을 동시에 높이려는 노력의 일환이다.

데이터를 변경할 때마다 그 변경사항을 Undo 영역에 저장해둔다.
데이터를 읽다가 쿼리(또는 트랜잭션) 시작 시점 이후에 변경된 값을 발견하면, Undo 영역에 저장된 정보를 이용해 쿼리(또는 트랜잭션) 시작 시점의 일관성 있는 버전을 생성하고 그것을 읽는다.
쿼리 도중 배타적 Lock이 걸린 레코드를 만나더라도 대기하지 않기 때문에 동시성 측면에서 매우 유리하며 사용자에게 제공되는 데이터의 기준 시점이 쿼리(또는 트랜잭션) 시작 시점으로 고정되기 때문에 일관성 측면에도 유리하다. 하지만, Undo 블록 I/O, CR Copy 생성, CR 블록 캐싱같은 부가적인 작업 때문에 생기는 오버헤드도 무시할 수 없다.

정리하면, 기존의 방식은 lock으로 인해서 동시성이 떨어지기에 snapshot isolation level에서는 locking이 아니라 데이터를 여러 버전으로 관리하게 함으로써 보다 동시성을 높였다는 것이다. 다만 그러한 버전별 데이터를 따로 저장하는 등의 오버헤드가 단점이 될 수 있겠다.

예시를 통해서 어떻게 작동하는지 살펴보자.

t1 : x가 y에 40을 이체한다.
t2 : y에 100을 입금한다.
x=200, y=100

그냥 작동하면 가능한 스케줄 하나를 생각해보자.

r1(x)=>200
w1(x=160)
				r2(y)=>100
                w2(y)=>200
                commit
r1(y)=>100
w1(y=140)
commit

lost update가 발생했다. snapshot isolation level에서 사용되는 mvcc 메커니즘을 적용해보자. 정확하지는 않다. u는 undo이다.

r1(x)=>200	u1:{x=200,y=100}	db:{x=200,y=100}
w1(x=160)	u1:{x=160,y=100}	db:{x=200,y=100}

r2(y)=>100  u2:{y=100}	db:{x=200,y=100}
w2(y)=>200  u2:{y=200}	db:{x=200,y=100}
commit		u2:{y=200}	db:{x=200,y=200} // no w-w conflict, commit OK

r1(y)=>100	u1:{x=160,y=100}	db:{x=200,y=200}
w1(y=140)	u1:{x=160,y=140}	db:{x=200,y=200}
abort		u1:{x=160,y=140}	db:{x=200,y=200} // w-w conflict, abort!

일단 결과는 lost update를 막았다는 것이다. t1이 롤백되었지만 t2의 입금은 잘 적용되었다. 그러한 이유는 트랜잭션이 변화를 db에 바로 저장하지 않고 undo 영역에서 버전별로 관리하기 때문이다. (트랜잭션 시작 시 만든 undo에서 읽고 쓴 다음에 commit시 db에 저장)

위에서 t2의 commit만 적용된 이유는 mvcc에서 w-w conflict인 두 트랜잭션에 대해서, 먼저 commit한 트랜잭션의 변화만 반영하고, 후의 것은 abort 시키기 때문이다.

profile
한양대학교 정보시스템학과 22학번 이혁진 입니다

0개의 댓글