티스토리 뷰

반응형

목차 

- 트랜잭션 격리
- 스냅샷 격리
- 직렬성
  - 진짜 순서대로 실행하기 
  - 2PL
  - 직렬성 스냅숏 격리
     - 오래된 MVCC 읽기 감지하기
     - 과거의 읽기에 영향을 미치는 쓰기 감지하기
- 참고문헌 

 

트랜잭션 격리

이제까지 트랜잭션 격리와 그 수준에 대해서 충분히 이야기한 듯 하다. 하지만 격리는 어떻게 구현되는 걸까? read committed, repeatable read는 다른 트랜잭션이 변화를 주어도 본 트랜잭션에 영향이 없다. read committed인 경우 상대 트랜잭션이 커밋한 순간 값이 업데이트 되며 repeatable read는 본인의 트랜잭션이 커밋 또는 롤백되기 전까지 다른 트랜잭션이 변경하거나 커밋한 사항을 확인할 수 없다. 트랜잭션은 undo log에 위치한 데이터를 스냅샷으로 가지고 그것을 일관성 있게 읽는다. 이것을 스냅샷 격리라고 한다.

스냅샷 격리

스냅샷 격리는 데이터베이스가 트랜잭션 간 서로의 영역을 침범하지 않고, 일관성을 유지하게 끔 해주는 개념이다. 트랜잭션은 본인이 시작하기 전에 DB의 내용을 읽는다. 이때 DB 레코드를 읽는 것이 아니라 스냅샷 이라는 부분을 읽는다. 

스냅샷 격리란 트랜잭션은 자기가 트랜잭션을 시작한 스냅샷을 읽고 그 스냅샷을 통해 데이터를 질의하거나 변경하고 커밋/롤백 하는 개념이다. 트랜잭션은 본인이 시작한 시점에서 고유한 트랜잭션 아이디를 가지며, 그에 대응 하는 스냅샷을 가진다. 그리고 스냅샷을 기준으로 변경을 수행하고, 언두로그를 활용하여 본인의 변경을 기록한다. 기본적으로 데이터베이스는 READ UNCOMMITTED에서 발생할 수 있는 더티쓰기를 방지하기위해 다른 연산이 해당 레코드에 접근하지 못하게 할 수 있다. 하지만 스냅샷 격리의 핵심은, 한 트랜잭션이 읽는데 다른 트랜잭션들이 쓰는 것을 방해하지 않는 다는 것이고, 반대로 한 트랜잭션이 쓰는데 다른 트랜잭션이 읽는것을 방해하지 않는 다는 것이다.

각 트랜잭션이 트랜잭션 고유 아이디를 버전으로 활용하며, 각자의 스냅샷을 가지고 버전을 컨트롤 한다는 것을 MVCC(다중 버전 동시성 제어) 라고 한다.

직렬성

앞서 우리는 각자 트랜잭션들이 공통된 자원을 가지고 경쟁하는 조건을 가질 때, 또는 공통된 조건을 통해 스큐가 발생할 때, 또는 팬텀리드가 발생할 때 등을 살펴보았다. 이들을 해결할 수 있는 것은 모든 트랜잭션들을 직렬적으로 실행하는 것이다. 직렬성은 다음 세가지 방식으로 구현할 수 있다

- 진짜 모든 것을 순서대로 실행한다.
- 2PL을 활용한다.
- 직셜성 스냅숏 격리를 활용한다. ~ 낙관적동시성제어

세개를 살펴보겠다.

진짜 순서대로 실행하기

정말로 순서대로 실행은 여러개를 동시에 실행하는 것보다 당연히 속도가 느릴 것이다. 하지만, 언젠가부터 엔지니어들은 또는 데이터베이스 설계자 들은 어떠한 부분에서 그게 더 효율적이라고 생각하였다. 그리고 그러한 데이터베이스를 구현해 내었는데, 그 중 하나가 레디스이다. 안타깝게도 그 외의 것은 VoltDB, Datomic 인데, 그다지 유명하지 않다.

VoltDB 설명 이미지

VoltDB는 모든 쿼리가 스토어드 프로시져로 동작한다고 한다. 스토어드 프로시져는 데이터베이스에서 실행하는 로직이다. 일반적으로 앱에서 함수를 정의하여 트랜잭션을 열고 select 이후 if 그리고 update 로직을 실행하며 영속성 장치에 쿼리를 날리고 네트워크 홉을 타는 반면에 스토어드 프로시져는 그걸 극단적으로 데이터베이스가 수행하게 끔 하는 방식이다. 따라서 네트워크 홉이 적으며 극단적으로 빠르다. 그에비해 단점은 매우 많아, 단점이 모든 장점을 덮는다.

- 우선 DBMS 프로덕트마다 프로시저 언어가 다르다. 그리고 생태계가 부족하다.
- 데이터베이스에서 함수를 작성하여 수행하는데, 버전관리가 매우 힘들다. 테스트하기도 까다롭다. 
- 데이터베이스에서 코드를 잘 못 작성하는것은, 어플리케이션에서 코드를 잘 못 작성하는 것보다 더 최악의 상황이 나올 수 있다.

레디스의 대표 스토어드 프로시저는 루아스크립트이다. 하지만 잘 쓰이는 것을 보지 못할것이다. 대부분 비즈니스 로직은 코드로 관리되고 다른 로직들과 race 가 되지 않도록 적절한 레벨에서 관리가 되어야한다. 

2PL을 사용하기

2PL은 락을 두 단계로 나누어 사용하는 방식이다. 이 방식은 락을 획득하고, 다른 트랜잭션이 그 것을 읽거나 쓰지 못하게 한다음, 본인의 트랜잭션을 커밋/롤백 하여 락을 해제한다. 락을 획득, 해제 하는 단계로 이루어져있다. 락을 획득하는 단계는 여러개의 락을 획득할 수 있는데 S-Lock, X-Lock 둘 중 하나로 나뉜다. 

S-Lock은 Shared Lock으로 특징은 다음과 같다.

1. 다른 잠금(*-Lock)이 해당 레코드에 대해 잠금을 획득할 수 있다.
2. S-Lock 작업이 커밋 또는 롤백 되기 전까지, 다른 트랜잭션 또는 비 트랜잭션이 해당 레코드를 업데이트 하기위해 대기해야한다.
3. S-Lock 작업이 쓰기 작업을 수행할 때 X-Lock으로 변경된다. 이 경우 다른 트랜잭션이 해당 레코드에 대해 잠금(*-Lock)을 획득할 수 없다.

X-Lock은 Exclusive Lock으로 특징은 다음과 같다.

1. 다른 락(*-Lock)이 해당 레코드에 대해 잠금을 획득할 수 없다.
2. X-Lock 작업이 커밋 또는 롤백 되기 전까지, 다른 트랜잭션 또는 비 트랜잭션이 해당 레코드를 업데이트 하기위해 대기해야한다.

레코드에 락을 걸면, 갱신에 대해 직렬성을 보장할 수 있다.

단, 팬텀 리드에 대해서는 index-range Lock 또는 next-key Lock을 수행해야한다. 인덱스가 값 기반으로 정렬 하기 때문에 그 인덱스 구간을 잠구면 팬텀리드에 대해서도 직렬성 격리가 된다.

직렬성 스냅숏 격리(SSI) 

** 직렬성 스냅숏 격리는 MySQL 에서는 없고, PostgreSQL 에 있어 낯설 수 있다.

직렬성 스냅숏 격리란 성능적으로 직렬성 격리보단 완화하면서 스냅숏 격리보단 손해보면서 직렬성을 제공한다. 앞에서 살펴본 직렬성 격리(2PL)은 직렬성을 보장해야하는 여러 트랜잭션이 잠금을 획득할 때 마다 대기해야한다. 그래서 잠금을 유지하는 시간이 길수록 성능에 제약이 있다. 

직렬성 스냅숏 격리는 데이터베이스에 쓰는 시점에 본인의 스냅숏이 업데이트 되어야할 필드가 이미 업데이트가 되어있다면 실패로 파악한다. 즉, 잠금을 획득하였다고 판단하는 시점은 트랜잭션이 스냅숏을 얻는 시점이며 충돌이 일어났다고 판단하는 시점은 커밋 시점이 된다. 만약 충돌이 일어났다고 감지하면 해당 트랜잭션은 어보트된다. 

장점은 경쟁이 낮다고 판단되는 경우 성능적으로 이득이 있으며, 경쟁이 심한 경우에 어보트 된다. 어보트 시 재시도를 시도하는데 이로부터 발생하는 부가적인 트랜잭션 부하가 발생하여 성능을 저하 시킬 수 있다. 

직렬성 스냅숏 격리는 스냅숏을 격리를 기반으로 한다. 트랜잭션에서 실행되는 모든 읽기는 데이터베이스의 일관된 스냅숏을 보게된다. 이 부분이 낙관적 동시성 제어와 SSI의 다른점이다. SSI는 스냅숏 격리 상에서 쓰기 작업시 충돌을 파악하고 어보트 시킬 트랜잭션을 결정할 알고리즘을 추가한다.

오래된 MVCC 읽기 감지하기

스냅숏 격리는 다중 버전 동시성 제어(MVCC)을 기반으로 동작한다. 트랜잭션이 일관된 스냅숏에서 읽으면 스냅숏 생성 시점에 다른 트랜잭션이 썼지만 아직 커밋되지 않은 데이터는 무시한다.

만약 한 트랜잭션이 읽은 뒤 다른 트랜잭션이 해당 데이터를 커밋하였다고 하자. 뒤에 커밋할 트랜잭션은 본인이 읽은 스냅숏이 다른 트랜잭션에 의해 커밋되었으며, 본인이 읽은 스냅숏과 달라졌음을 파악해야한다. SSI를 지원하는 데이터베이스는 해당 트랜잭션이 쓰는 시점에 MVCC 가시성 규칙에 따라 다른 트랜잭션이 쓴 것을 무시한 경우를 추적한다. 그래서 무시한 것, 즉 후자 트랜잭션이 전제로한 스냅숏과 다른 트랜잭션이 커밋한 것이 겹치는 부분이 있다면 해당 트랜잭션은 어보트 된다.

T1: BEGIN---READ(XY)-----WRITE(X) ------------------------------------- COMMIT  // 전자 트랜잭션
                                                                            * (트랜잭션은 이 값이 더이상 최신이 아님을 알수있다.)
T2: ------------------------BEGIN ----- READ(X old,Y) ------ WRITE(X old) -------- COMMIT (abort!)  //후자트랜잭션

> T1 <--rw,rw --> T2 (둘 중 하나는 abort, 후자가 abort된다.)

커밋될 때 까지 데이터베이스는 어보트하지 않고 기다린다. 후자 트랜잭션이 아무런 쓰기를 하지 않거나, 전자 트랜잭션으로 고려되었던 트랜잭션이 어보트 될 수 있기 때문이다. 

과거의 읽기에 영향을 미치는 쓰기 감지하기

T1: BEGIN --- READ(XY) -------  WRITE(X) -------------------- COMMIT  // 전자 트랜잭션
                                                            (T2에게 알린다)  (T1에게 알린다)
T2: BEGIN ----READ(XY)---------------------- WRITE(Y) ---------------------- COMMIT  (abort!) // 후자 트랜잭션

또 다른 경우는 데이터를 읽은 후 다른 트랜잭션에서 그 데이터를 변경할 때다. 일반적으로 2PL의 경우 읽은 시점에서, 다른 트랜잭션이 읽는 것을 하지만 SSI는 차단하지 않는다. 단 커밋되는 경우, 해당 트랜잭션이 쓰는 데이터에 영향을 받는 다른 트랜잭션이 있는지 인덱스에서 확인 해야한다.

이 과정에서 LOCK이 사용되지만, 다른 트랜잭션의 동작을 방해하진 않고 다른 트랜잭션에게 읽고 있는 데이터가 오래 된 것이라고 알려줄 뿐이다. 물론 어떤것이 먼저 커밋 되는지 모르므로, 서로가 서로에게 알려준다. 그리고 나중에 커밋되는 트랜잭션은 어보트 된다.

 

마술같지만 이런 작업만으로도 (물론 내부적으로 더 어려운 이론이 있지만) SSI는 직렬성 격리(프로시저나 2PL)를 완벽히 대체한다. 즉, 직렬성을 유지한다는 점이다. 만약 postgreSQL 을 쓰게된다면 serializable을 쓰는것도 고려해봐야겠다. 

 

 

 

 

 

 

 

참고문헌

- 데이터중심 애플리케이션 설계

- SSI : https://velog.io/@jaquan1227/PSQL-%EC%97%90%EC%84%9C-Serializable-%EA%B2%A9%EB%A6%AC%EC%88%98%EC%A4%80%EC%9D%84-%EC%93%B0%EA%B8%B0-%EB%AC%B4%EC%84%9C%EC%9A%B0%EC%8B%A0%EA%B0%80%EC%9A%94

반응형

'데이터베이스' 카테고리의 다른 글

트랜잭션과 동시성(락)(2)  (0) 2025.11.11
트랜잭션과 동시성(락) (1)  (0) 2025.10.26
인덱스  (0) 2022.01.18
트랜잭션과 ACID 성질  (0) 2022.01.16
인강 1) 관계 대수와 SQL  (0) 2021.06.29