트랜잭션 잘 사용하기
0. 트랜잭션
- 데이터 시스템은 다양한 문제가 발생할 가능성이 있다.
- 데이터베이스 소프트웨어나 하드웨어는 언제라도 실패할 수 있다.
- 애플리케이션은 언제라도 죽을 수 있다.
- 네트워크가 끊기면 애플리케이션과 데이터베이스의 연결이 갑자기 끊기거나 데이터베이스 노드 사이의 통신이 안 될수 있다.
- 여러 클라이언트가 동시에 데이터베이스에 쓰기를 실행해서 다른 클라이언트가 쓴 내용을 덮어쓸 수 있다.
- 클라이언트가 부분적으로만 갱신돼서 비정상적인 데이터를 읽을 수 있다.
- 클라이언트 사이의 경쟁 조건은 예측하지 못한 버그를 유발할 수 있다.
- 데이터베이스 소프트웨어나 하드웨어는 언제라도 실패할 수 있다.
- 이러한 결함이 발생하여 시스템에 치명적인 장애가 발생하지 않도록 막아야 한다. - 내결함성
- 트랜잭션은 여러 읽기와 쓰기를 논리적 단위로 묶어서 이러한 문제를 단순화하는 메커니즘이다.
- 데이터베이스에 접속하는 애플리케이션에서 프로그래밍 모델을 단순화하려는 목적으로 만들어졌다.
- 트랜잭션을 사용함으로써 애플리케이션에서 어느 정도 잠재적인 오류 시나리오와 동시성 문제를 무시할 수 있다. - 안정성 보장
1. 애매모호한 트랜잭션의 개념
- 트랜잭션의 개념은 1975년 첫 번째 SQL 데이터베이스인 IBM시스템 R 에서 소개되었고 대부분의 데이터베이스는 그 스타일을 따른다.
- NoSQL 데이터베이스의 발전과 분산 데이터베이스가 홍보되면서 시스템의 높은 성능과 고가용성을 유지하기 위해 트랜잭션은 과거의 인식보다 많이 약화되었다.
1.1. ACID의 의미
- 데이터베이스의 내결함성 메커니즘을 나타내는 정확한 용어를 확립하기 위해 만들어졌다.
- 원자성(Atomicity)
- 일관성(Consistency)
- 격리성(Isolation)
- 지속성(Durability)
- 데이터베이스의 ACID 구현은 제각각이고 어떤 데이터베이스가 ACID를 준수한다고 할 때 그 시스템에서 실제로 어떤 것을 기대할 수 있는지 분명하지 않다. (ACID는 거의 마케팅 용어가 되어버렸다.)
- BASE이라고 불리우는 ACID 표준을 따르지 않는 ACID보다 더 모호한 정의가 있다.
- 기본적으로 가용성을 제공 (Basically Available)
- 유연한 상태를 가짐 (Soft state)
- 최종적 일관성 (Eventual consistency)
1.1.1. 원자성
- 원자적
- 더 작은 부분으로 쪼갤 수 없는 무언가
- 다중 스레드 프로그래밍에서 한 스레드는 다른 스레드에서 절반만 완료된 연산을 관찰할 수 없다.
- 시스템은 연산을 실행하기 전이나 실행한 후의 상태만 있을 수 있고 중간 상태에 머물 수 없다.
- 원자성은 여러 프로세스가 동시에 같은 데이터에 접근하려고 할 때 무슨일이 생기는지 설명하지 않기 때문에 ACID의 맥락에서 보면 원자성은 동시성과 관련이 없다.
- ACID의 원자성은 클라이언트가 여러 쓰기 작업을 실행하려고 할 때 일부만 처리된 후 결함이 생기면 무슨 일이 생기는지 설명한다.
- 결함 때문에 완료될 수 없다면 어보트되고 데이터베이스는 이 트랜잭션에서 지금까지 실행한 쓰기를 무시하거나 취소해야한다.
- ACID의 원자성은 오류가 발생했을 때 트랜잭션을 어보트하고 해당 트랜잭션에서 기록한 모든 내용을 취소하는 능력이다. - 어보트 능력(abortability)
1.1.2. 일관성
- 다양한 의미로 일관성이 사용된다.
- ACID의 일관성은 항상 진실어야하는, 데이터에 관한 어떤 선언(불변식(invariant))이 있다는 것이다.
- 이 일관성은 데이터베이스가 보장하는 것이 아니라 애플리케이션이 책임이다.
- 데이터베이스는 불변식을 위반하는 잘못된 데이터를 쓰지 못하도록 막을 수 없다.
- 애플리케이션은 일관성을 달성하기 위해 데이터베이스의 원자성과 격리성 속성에 기댈 수는 있지만 데이터베이스만으로 되는 것은 아니다.
1.1.3. 격리성
- 데이터베이스에는 여러 클라이언트가 접속이 가능하고 클라이언트들이 동일한 데이터베이스 레코드에 접근하면 동시성 문제(경쟁 조건)에 맞닥뜨리게 된다.
- e.g. 카운터를 동시에 증가시키는 경우
- ACID에서 격리성은 동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미한다.
- 트랜잭션은 다른 트랜잭션을 방해할 수 없다.
- 고전적인 데이터베이스 교과서에서는 격리성 = 직렬성
- 직렬성은 각 트랜잭션이 전체 데이터베이스에서 실행되는 유일한 트랜잭션인 것처럼 동작할 수 있다는 것을 의미한다.
- 여러 트랜잭션이 동시에 실행되면 트랜잭션이 커밋됐을 때의 결과가 순차적으로(하나씩 차례로) 실행됐을 때의 결과와 동일하도록 보장한다.
- 직렬성 격리는 성능 손해를 동반하므로 현실에서는 거의 사용되지 않고 직렬성보다 보장이 약한 스냅숏 격리를 구현했다.
1.1.5. 지속성
- 데이터베이스 시스템의 목적은 데이터를 잃어버릴 염려가 없는 안전한 저장소를 제공하는 것이다.
- 지속성(durability)은 트랜잭션이 성공적으로 커밋됐다면 하드웨어 결함이 발생하거나 데이터베이스가 죽더라도 트랜잭션에서 기록한 모든 데이터는 손실되지 않는다는 보장이다.
- 디스크에 저장된 데이터 구조가 오염됐을 때 복구할 수 있게 해주는 쓰기 전 로그(write-ahead log)나 비슷한 수단을 동반한다.
- 복제 기능이 있다면 데이터가 다른 몇 개의 노드에 복사됐다는 것으로 지속성을 의미할 수 있다.
- 지속성을 보장하려면 데이터베이스는 트랜잭션이 성공적으로 커밋됐다고 보고하기 전에 쓰기나 복제가 완료될 때까지 기다려야한다.
1.2. 단일 객체 연산과 다중 객체 연산
- 원자성, 격리성의 정의는 한 번에 여러 객체를 변경할 수 있다고 가정한다.
- 다중 객체 트랜잭션은 흔히 데이터의 여러 조각이 동기화된 상태로 유지돼야 할 때 필요하다.
- 관계형 데이터베이스의 트랜잭션은 BEGIN TRANSACTION, COMMIT 사이의 모든 것은 같은 트랜잭션에 속하는 것으로 여겨진다.
- 비관계형 데이터베이스에서는 다중 put 연산을 제공하지만 반드시 트랜잭션을 보장하지는 않기 때문에 일부만 성공하고 나머지는 실패하는 부분 갱신 상태가 될 수 있다.
1.2.1. 단일 객체 쓰기
- 저장소 엔진들은 거의 보편적으로 한 노드에 존재하는 단일 객체 수준에서 원자성과 격리성을 제공하는 것을 목표로 한다.
- 원자성: 장애 복구용 로그
- 격리성: 객체 잠금
- 어떤 데이터베이스는 Compare-And-Set 같은 연산을 제공하기도 한다.
- 이러한 단일 객체 연산은 동시에 같은 객체를 쓰려고할 때 갱신 손실을 방지하지만 일반적으로 사용되는 트랜잭션과는 다르다.
- 트랜잭션은 보통 다중 객체에 대한 다중 연산을 하나의 실행 단위로 묶는 메커니즘으로 이해된다.
1.2.2. 다중 객체 트랜잭션의 필요성
- 많은 분산 데이터스토어는 다중 객체 트랜잭션 지원을 포기했다.
- 여러 파티션에 걸쳐서 구현하기 어렵다.
- 매우 높은 가용성과 성능이 필요한 곳에서는 방해가 되는 시나리오가 없다.
- 애플리케이션에 내에서 단일 객체 연산만 사용해서 다중 객체 트랜잭션인 것처럼 구현할 수 있다.
- 하지만 원자성이 없으면 오류 처리가 더 복잡해지고 격리성이 없으면 동시성 문제가 생길 수 있다.
1.2.3. 오류와 어보트 처리
- 트랜잭션의 핵심 기능은 오류가 생기면 어보트되고 안전하게 재시도할 수 있다는 것이다.
- 하지만 모든 시스템이 이 철학을 따르지는 않는다.
- 리더 없는 복제를 사용하는 데이터스토어는 가능 한 모든 것을 할 것이며 그 때문에 오류가 발생하면 이미 한 일은 취소하지 않는다는 원칙을 가지고 있기 대문에 오류 복구는 애플리케이션에게 책임이 있다.
- 어보트의 취지는 안전하게 재시도를 할 수 있게 하는데 있고 어보트된 트랜잭션을 재시도하는 것은 간단하고 효과적인 오류 처리 메커니즘이지만 완벽하지는 않다.
2. 완화된 격리 수준
- 동시성 버그는 타이밍에 운이 없을 때만 촉발되기 때문에 테스트로 발견하기 어렵다.
- 동시성은 추론하기도 매우 어렵다.
- 그래서 데이터베이스는 트랜잭션 격리를 제공함으로써 애플리케이션 개발자들에게 동시성 문제를 감추려고 했다.
- 직렬성 격리
- 여러 트랜잭션이 직렬적으로 실행되는 것과 동일한 결과가 나오도록 보장
- 성능 비용이 있고 많은 데이터베이스들은 그 비용을 지불하려고 하지 않음
- 완화된 트랜잭션 격리
- 일부 동시성 이슈로부터 보호해주지만 모든 이슈로부터 보호해주지 않음
- 미묘한 동시성 버그를 유발할 수 있음
2.1. 커밋 후 읽기
- read committed
- 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다. (더티 읽기가 없음)
- 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다. (더티 쓰기가 없음)
2.1.1. 더티 읽기 방지
- 커밋되지 않은 것을 읽는 것 - 더티 읽기
- 사용자는 읽지 않은 새 메일은 볼 수 있지만 갱신된 개수를 볼 수 없는 경우
- 나중에 롤백 될 데이터를 보는 경우
2.1.2. 더티 쓰기 방지
- 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮어써버리는 것 - 더티 쓰기
- 동시에 같은 차를 사는 경우 구매자 반영과 구매자에게 판매 송장을 전송해야하는 경우
- 카운터를 증가시키는 경우
2.1.3. 커밋 후 읽기 구현
- 다양한 데이터베이스에서 기본 설저으로 제공
- 로우 수준 잠금 사용으로 더티 쓰기 방지
- 동일한 잠금을 써서 객체를 읽기 원하는 트랜잭션이 잠금을 획득한 후 읽기가 끝난 후 바로 해제하게 하여 더티 읽기 방지
- 위 방법은 어렵고 지연 시간으로 인해 잘 사용되지 않음
- 트랜잭션이 실행 중인 객체를 읽으면 과거의 값을 읽게 함
2.2. 스냅숏 격리와 반복 읽기
- 트랜잭션 처리 중에 데이터를 읽으면 비반복 읽기(nonrepeatable read), 읽기 스큐(read skew) 현상이 발생될 수 있다.
- 읽기 스큐는 커밋 후 읽기 격리에서는 받아들일 수 있는 것으로 여겨진다.
- 대용량 데이터 백업, 분석 질의와 무결성 확인 등 시간이 오래 걸리는 작업일 경우 작업 중에 데이터베이스 쓰기가 발생할 수 있다.
- 각 트랜잭션은 데이터베이스의 일관된 스냅숏으로부터 데이터를 읽어서 해결한다.
- 트랜잭션은 시작할 때 데이터베이스에 커밋된 상태였던 모든 데이터를 보고 데이터가 다른 트랜잭션에 의해 변경되더라도 각 트랜잭션은 특정한 시점의 과거 데이터를 볼 뿐이다.
- 시간이 오래걸리는 읽기 질의에 유용하다.
2.2.1. 스냅숏 격리 구현
- 스냅숏 격리의 핵심 원리는 읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다.
- 데이터베이스는 객체마다 여러 커밋 버전을 유지할 수 있고 진행 중인 여러 트랜잭션에서 서로 다른 시점의 데이터베이스 상태를 볼 수 있다. - 다중 버전 동시성 제어 (multi-version concurrency control, MVCC)
- 데이터베이스가 커밋 후 읽기 격리만 필요하다면 객체마다 두 개의 버전만 유지하면 충분하지만 스냅숏 격리를 지원하는 저장소 엔진은 보통 커밋 후 읽기 격리를 위해서도 MVCC를 사용한다.
- 포스트그레스큐엘 예시
- 트랜잭션이 시작하면 계속 증가하는 고유한 트랜잭션 ID 할당
- 트랜잭션이 데이터베이스 데이터를 쓸 때마다 쓰기를 실행한 트랜잭션 ID가 함께 붙음
- 로우를 테이블에 삽입함 트랜잭션 ID를 갖는 created_by 필드가 있고 비어 있는 deleted_by가 있음
- 트랜잭션이 로우를 삭제하면 DB에서 지우지 않고 deleted_by 필드를 삭제 요청 트랜잭션ID로 설정
- 어떤 트랜잭션도 삭제된 데이터에 접근하지 않으면 표시된 로우들을 삭제
2.2.2. 일관된 스냅숏을 보는 가시성 규칙
- 트랜잭션은 DB에서 객체를 읽을 때 트랜잭션ID를 사용해 볼 수 있는 것을 결정한다.
- 동작 방식
- 각 트랜잭션을 실행할 때 그 시점에 진행 중인 트랜잭션의 목록을 만들고 이 트랜잭션이 쓴 데이터와 커밋은 무시
- 어보트된 트랜잭션이 쓴 데이터는 모두 무시
- 트랜잭션ID가 더 큰 트랜잭션이 쓴 데이터는 그 트랜잭션의 커밋 여부에 관계없이 모두 무시
- 그 밖의 모든 데이터는 애플리케이션의 질의로 볼 수 있음
- 볼수 있는 객체의 조건
- 읽기 실행 트랜잭션이 시작한 시점에 읽기 대상 객체를 생성한 트랜잭션이 이미 커밋된 상태일 경우
- 읽기 대상 객체가 삭제된 것으로 표시되지 않음
- 읽기 대상 객체가 삭제된 것으로 표시됐지만 읽기를 실행한 트랜잭션이 시작한 시점에 삭제 요청 트랜잭션이 아직 커밋되지 않음
2.2.3. 색인과 스냅숏 격리
- 다중 버전 데이터베이스의 색인 동작 방식
- 단순하게 색인이 객체의 모든 버전을 가리키게 하고 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 한다.
- 현실에서는 여러 구현 세부 사항에 따라 다중 버전 동시성 제어의 성능이 결정된다.
- 포스트그레스큐엘
- 동일한 객체의 다른 버전들이 같은 페이지에 저장될 수 있다면 색인 갱신을 회피하는 최적화
- 카우치DB, 데이토믹, LMDB
- B트리를 사용하지만 추가 전용이며 쓸 때 복사되는(append-only/copy-on-write) 변종 사용
- 추가 전용 B트리를 사용하면 쓰기를 실행하는 모든 트랜잭션은 새로운 B트리 루트를 생성하며 특정 루트는 그것이 생성된 시점에 해당하는 데이터베이스의 일관된 스냅숏이 된다.
- 컴팩션과 가비지 컬렉션을 실행하는 백그라운드 프로세스가 필요하다.
2.2.4. 반복 읽기와 혼란스러운 이름
- SQL 표준에 스냅숏 격리의 개념이 없기 때문에 이름이 혼란스럽다.
- 반복 읽기라는 SQL 표준의 정의되어있고 Postgresql, MySQL은 표준 요구사항을 만족시키기 때문에 스냅숏 격리 수준을 반복 읽기라고 한다.
- 하지만 SQL 표준 격리 수준 정의에는 결함이 있다.
- 결과적으로 반복 읽기가 무슨 뜻인지 실제로 아는 사람은 아무도 없다.
2.3. 갱신 손실 방지
- 동시 실행 쓰기 트랜잭션 사이에 발생할 수 있는 충돌 - 갱신 손실 (lost update)
- e.g. 카운터 증가
- 애플리케이션이 데이터베이스에서 값을 읽고 변경한 후 변경된 값을 다시 쓸 때 발생할 수 있다. (read-modify-write 주기)
- 나중에 슨 것이 먼저 쓴 것을 때려눕힌다(clobber).
2.3.1. 원자적 쓰기 연산
- 여러 데이터베이스에서 원자적 갱신 연산을 제공하여 read-modify-write 주기를 구현할 필요를 없애준다.
1
UPDATE counters SET value = value + 1 WHERE key = 'foo';
- 원자적 연산은 객체를 읽을 때 그 객체에 독점적인 잠금을 획득해서 구현한다. - 커서 안정성 (cursor stability)
- 그냥 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 방법이 있다.
- 애플리케이션에서 ORM을 사용하면 read-modify-write 코드 작성이 더 쉽다.
2.3.2. 명시적인 잠금
- 애플리케이션에서 갱신할 객체를 명시적으로 잠그는 것이다.
- 애플리케이션에서 잠금을 얻은 후 read-modify-write 를 수행하고 완료되면 잠금을 푼다.
2.3.3. 갱신 손실 자동 감지
- 병렬 실행을 하용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트시키고 read-modify-write 주기를 재시도하도록 강제하는 방법이다.
- 데이터베이스가 이 확인을 스냅숏 격리와 결합해 효율적으로 수행할 수 있다.
- 애플리케이션 코드에서 데이터베이스의 기능을 쓸 필요 없도록 도와준다.
- 잠금, 원자적 연산을 쓰는 것을 잊어버려서 버그를 유발할 수는 있지만 자동으로 갱신 손실이 감지되어 오류가 덜 발생하게 해준다.
2.3.4. Compare-and-set
- 이 연산의 목적은 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것이다.
1
UPDATE ... SET ... WHERE id = 1234 AND content = 'old content';
-
- content가 일치하지 않으면 이 갱신은 적용되지 않는다.
- DB에서 오래된 스냅숏으로부터 읽는 것을 허용한다면 이 구문은 갱신 손실을 막지 못할 수도 있다.
- 데이터베이스의 compare-and-set 연산에 의존하기 전에 먼저 안전한지 확인이 필요하다.
2.3.5. 충돌 해소와 복제
- 여러 노드에 데이터 복사본이 있어서 다른 노드들에서 동시에 변경될 수 있으므로 갱신 손실을 방지하려면 추가 단계가 필요하다.
- 잠금과 compare-and-set 연산은 최신 복사본이 하나만 있다고 가정한다.
- 다중 리더, 리더 없는 복제를 사용하는 DB는 여러 쓰기가 동시에 실행되고 비동기식으로 복제되는 것을 허용하기 때문에 최신 복사본이 하나라는 보장을 할 수 없다. - 잠금, compare-and-set 기법 적용 불가
- 복제 적용 DB에서 흔히 쓰는 방법은 쓰기가 동시에 실행될 때 한 값에 대해 여러 개의 충돌된 버전(형제(sibling))을 생성하는 것을 허용하고 사후에 애플리케이션 코드나 특별한 데이터 구조를 사용해 충돌을 해소하고 이 버전들을 병합한다.
- 원자적 연산은 복제 상황에서도 잘 동작한다. - 특히 교환 법칙이 성립하는 연산
- 최종 쓰기 승리(las write wins, LWW) 충돌 해소 방법은 갱신 손실이 발생하기 쉽다.
- 많은 복제 DB는 LWW가 기본 설정이다.
2.4. 쓰기 스큐와 팬텀
- 두 의사가 호출 대기를 하는데 동시에 호출 대기를 끄는 동작
- 스냅숏 격리가 둘 다 두 명이상 대기중으로 알려줘서 둘다 호출대기가 꺼질 수 있음
2.4.1. 쓰기 스큐를 특징짓기
- 두 트랜잭션이 같은 객체들을 읽어서 그 중 일부를 갱신할 때 나타날 수 있다.
- 다른 트랜잭션이 하나의 동일한 객체를 갱신하는 특별한 경우에 더티 쓰기나 갱신 손실 이상 현상을 겪게된다.
- 막는 방법
- 여러 객체가 관련되므로 원자적 단일 객체 연산은 도움이 되지 않는다.
- 스냅숏 격리 구현에서 제공되는 갱신 손실 자동 감지도 도움이되지 않는다.
- 어떤 DB에서는 제약 조건을 설정할 수 있다. - 트리거, 구체화 뷰를 사용
- 직렬성 격리 수준을 사용할 수 없다면 트랜잭션이 의존하는 로우를 명시적으로 잠그는 것이 차선책이다.
2.4.2. 추가적인 쓰기 스큐의 예
- 회의실 예약 시스템: 겹치는 시간 예약 확인
- 다중플레이어 게임: 플레이어들이 두 개의 다른 물체를 옮기는 경우
- 사용자명 획득: 계정 생성 시 중복 체크
- 이중 사용 방지: 돈이나 포인트를 더 많이 지불하는 경우
2.4.3. 쓰기 스큐를 유발하는 팬덤
- 스큐를 유발하는 패턴
- SELECT 질의가 어떤 검색 조건에 부합하는 로우를 검색함으로써 어떤 요구사항을 만족하는지 확인한다.
- 첫 번째 질의의 결과에 따라 애플리케이션 코드는 어떻게 진행할지 결정한다.
- 애플리케이션이 계속 처리하기로 결정했다면 데이터베이스에 쓰고 커밋한다.
- 의사 예시는 1단계 로우를 잠금으로써 (SELECT FOR UPDATE) 스큐를 회피할 수 있지만 2.4.2. 예제는 1단계 질의가 아무 로우도 반환하지 않으면 SELECT FOR UPDATE는 아무것도 잠글 수 없어서 다르다.
- 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 팬텀이라고 한다.
2.4.4. 충돌 구체화
- 회의실 예약의 경우 회의실과 시간 범위의 모든 조합에 대해 로우를 미리 만들어 놓고 SELECT FOR UPDATE로 잠글 수 있도록 처리할 수 있다.
- 이런 방법을 충돌 구체화(materializing conflict)라고 한다.
- 충돌을 구체화하는 방법은 알아내기 어렵고 오류가 발생하기 쉽다.
- 동시성 제어 메커니즘이 애플리케이션데이터 모델로 새어 나오는 것도 보기 좋지 않다.
- 다른 대안이 불가능할 때 최후의 수단으로 고려해야 한다.
- 대부분 직렬성 격리 수준이 훨씬 더 선호된다.
3. 직렬성
- 커밋 후 읽기, 스냅숏 격리 수준 등으로 특정 경쟁 조건을 방지할 수 있지만 쓰기 스큐와 팬텀과 관련된 까다로운 케이스는 방지하기 어렵다.
- 직렬성 격리는 가장 강력한 격리 수준으로 여겨진다.
- 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장한다.
- 모든 경쟁 조건을 막아준다.
3.1. 실제적인 직렬 실행
- 동시성을 완전히 제거하고 한 번에 하나씩만 직렬로 단일 스레드에서 실행하여 충돌을 감지하고 방지하는 문제를 완전히 회피할 수 있다.
- 두 가지 발전으로 이 방법이 가능해졌다.
- 하드웨어 성능 향상
- OLTP 트랜잭션이 보통 짧고 읽기, 쓰기의 개수가 적다는 것을 알게 됨
- 처리량은 CPU 코어 하나의 처리량으로 제한되기 때문에 단일 스레드를 최대한 활용하려면 트랜잭션이 전통적인 형태와는 다르게 구조화돼야 한다.
3.1.1. 트랜잭션을 스토어드 프로시저 안에 캡슐화 하기
- 데이터베이스 초창기에는 트랜잭션이 사용자 활동 전체 흐름을 포함하려는 의도가 있었다.
- e.g. 항공권 예약 과정
- 대부분의 데이터베이스는 이를 효율적으로 처리할 수 없어서 모든 OLTP 애플리케이션은 트랜잭션 내에서 대화식으로 사용자 응답을 대기하는 것을 회피함으로써 트랜잭션을 짧게 유지한다.
- e.g. 1개 http 요청에 1개의 트랜잭션
- 이런 상호작용식 트랜잭션은 애플리케이션과 데이터베이스 사이의 네트워크 통신에 많은 시간을 소비하고 동시성을 허용하지 않고 한 번에 트랜잭션 하나씩만 처리하면 처리량이 매우 저하된다.
- 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 미리 만들어두어 사용하면 네트워크나 디스크 I/O 대기 없이 매우 빨리 실행된다고 가정한다.
- 기존에 PL/SQL을 사용했으나 현대에는 DB 벤더사 마다 자바, 그루비, 클로저 등 언어를 지원한다.
- I/O 대기가 필요 없고 다른 동시성 제어 오버헤드를 회피하므로 단일 스레드로 상당히 좋은 처리량을 얻을 수 있다.
- 단점
- DB 마다 지원 언어가 다르고 라이브러리 생태계가 빈약함
- 디버깅이 어려움
- 버전 관리 및 배포가 불편함
- 테스트 까다로움
- 모니터링 어려움
- DB는 공유 자원으로 애플리케이션보다 성능에 더 민감함
3.1.2. 파티셔닝
- 여러 CPU 코어와 여러 노드로 확장하기 위해 데이터를 파티셔닝할 수 있다.
- 각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 데이터셋을 파티셔닝할 수 있다면 다른 파티션과 독립적으로 실행되는 트랜잭션 처리 스레드를 가질 수 있다.
- CPU 코어에 각자의 파티션을 할당해서 트랜잭션 처리량을 CPU 개수에 맞춰 선형적으로 확장할 수 있다.
- 여러 파티션에 접근해야 하는 트랜잭션이 있다면 데이터베이스가 트랜잭션이 접근하는 모든 파티션에 걸쳐서 코디네이션 해야 한다.
- 여러 파티션에 걸친 트랜잭션은 코디네이션 오버헤드로 매우 느리다.
3.1.3. 직렬 실행 요약
- 트랜잭션 직렬 실행의 제약 사항
- 모든 트랜잭션은 작고 빨라야한다.
- 활성 데이터셋이 메모리에 적재될 수 있는 경우로 사용이 제한된다.
- 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮아야 한다.
- 여러 파티션에 걸친 트랜잭션도 쓸 수 있지만 사용할 수 잇는 정도에는 엄격한 제한이 있다.
3.2. 2단계 잠금(2PL)
- 직렬성을 구현하는 데 널리 쓰인 유일한 알고리즘이다. (2단계 커밋과 다르다.)
- 더티 쓰기를 막는데 잠금이 자주 사용된다.
- 2단계 잠금 요구 사항
- 쓰기를 실행하는 트랜잭션이 없는 객체는 여러 트랜잭션에서 읽을 수 있다.
- 객체에 쓰려고 하면 독점적인 접근이 필요하다.
- 예시
- 트랜잭션A가 객체 하나를 읽고 트랜잭션B가 그 객체에 쓰기를 원한다면 B는 진행하기 전에 A의 커밋/어보트를 기다려야 한다.
- 트랜잭션A가 객체에 썼고 트랜잭션B가 그 객체를 읽기 원한다면 B는 진행하기 전에 A의 커밋/어보트를 기다려야 한다.
- 스냅숏 격리는 읽는 쪽은 쓰는 쪽을 막지 않고 쓰는 쪽은 읽는 쪽을 막지 않지만 2PL은 막을 수 있기 때문에 갱신 손실과 쓰기 스큐를 포함한 모든 경쟁 조건으로부터 보호해준다.
3.2.1. 2단계 잠금 구현
- MySQL, SQL 서버의 직렬성 격리 수준 구현에 사용
- DB2의 반복 읽기 격리 수준 구현에 사용
- 읽는 쪽과 쓰는 쪽을 막는 것은 데이터베이스의 각 객체에 잠금을 사용해 구현한다.
- 잠금: 공유 모드
- 쓰기: 독점 모드
- 잠금이 아주 많이 사용되어 잠금 해제를 기다리는 상황이 매우 쉽게 발생할 수 있음 - 교착 상태
- 교착 상태를 감지하여 트랜잭션을 어보트하고 다른 트랜잭션이 진행할 수 있게 한다.
3.2.2. 2단계 잠금 성능
- 완화된 격리 수준보다 트랜잭션 처리량과 질의 응답 시간이 크게 나빠진다.
- 잠금을 획득하고 해제하는 오버헤드가 발생한다.
- 트랜잭션 경쟁 조건이 유발되어 트랜잭션 완료를 기다려야 하기 때문에 동시성이 줄어든다.
- 어보트된 트랜잭션은 애플리케이션에서 재시도해야 한다.
3.2.3. 서술 잠금
- Predicate lock
- 한 트랜잭션이 다른 트랜잭션의 검색 질의 결과를 바꿔버리는 팬텀 문제가 발생한다.
- 검색 조건에 해당하는 모든 객체를 잠근다.
- 접근 제한 방법
- 어떤 조건에 부합하는 객체를 읽기 원한다면 조건에 대한 공유 모드 서술 잠금 획득이 필요하다.
- 어떤 객체를 삽입, 갱신, 삭제하길 원한다면 새로운 값 중에 기존의 서술 잠금에 부합하는게 있는지 확인해야 한다. 서술 잠금이 다른 트랜잭션이 잡고 있다면 해당 트랜잭션의 커밋/어보트를 기다려야한다.
- 2단계 잠금이 서술 잠금을 포함하면 모든 형태의 쓰기 스큐와 다른 경쟁 조건을 막을 수 있어서 직렬성 격리가 된다.
3.2.4. 색인 범위 잠금
- index-range locking.
- 다음 키 잠금(next-key locking) 이라고도 한다.
- 서술 잠금을 간략하게 근사한 것이다.
- 더 많은 객체가 부합하도록 서술 조건을 간략화하는 것은 안전하다.
- 오후 1시 123번 방 예약 - 모든 시간 범위에 123번 방 예약에 대한 잠금
- 쓰기 팬텀과 쓰기 스큐로부터 보호해주는 효과가 발생한다.
- 서술 잠금 보다 정밀하지는 않지만 오버 헤드가 훨씬 더 낮기 때문에 좋은 타협안이 된다.
- 적합한 색인이 없다면 테이블 전체에 공유 잠금을 잡는 것으로 대체할 수 있다.
3.3. 직렬성 스냅숏 격리 (SSI)
- 직렬성 스냅숏 격리 알고리즘은 완전한 직렬성을 제공하지만 스냅숏 격리에 비해 약간의 성능 손해만 있다.
- 역사가 짧지만 새로운 기본값이 될 수 있다.
3.3.1. 비관적 동시성 제어 대 낙관적 동시성 제어
- 2단계 잠금은 비관적이지만 직렬성 스냅숏 격리는 낙관적 동시성 제어 기법이다.
- 트랜잭션이 커밋되기를 원할 때만 격리가 위반됐는지 확인한다.
- 성능이 충분하고 트랜잭션 경쟁이 너무 심하지 않으면 낙관적 동시성 제어 기법은 성능이 좋은 경향이 있다.
- 스냅숏 격리 위에 쓰기 작업 사이의 직렬성 충돌을 감지하고 어보트시킬 트랜잭션을 결정하는 알고리즘을 추가한다.
3.3.2. 뒤처진 전제에 기반한 결정
- 트랜잭션은 어떤 전제를 기반으로 어떤 동작을 한다. (전제: 일관된 스냅숏)
- 트랜잭션에서 실행하는 질의와 쓰기 사이에 인과적 의존성이 있을 수 있다.
- 직렬성 격리를 제공하려면 트랜잭션이 뒤처진 전제를 기반으로 동작하는 상황을 감지하고 트랜잭션을 어보트시켜야 한다.
- 질의 결과 변경 감지 방법
- 오래된 MVCC 객체 버전을 읽었는지 감지하기 (읽기 전에 커밋되지 않은 쓰기가 발생했음)
- 과거의 읽기가 영향을 미치는 쓰기 감지하기 (읽은 후에 쓰기가 실행됨)
3.3.2.1. 오래된 MVCC 읽기 감지하기
- 트랜잭션이 MVCC 데이터베이스에서 일관된 스냅숏에서 읽음녀 스냅숏 생성 시점에 다른 트랜잭션이 썼지만 아직 커밋되지 않은 데이터는 무시한다.
- 트랜잭션이 커밋하려고 할 때 데이터베이스는 무시된 쓰기 중에 커밋된 게 있는지 확인하고 있다면 트랜잭션은 어보트돼야 한다.
- 다른 트랜잭션이 진행되면서 쓰기를 실행할지 알 수 없기 때문에 커밋할 때 까지 기다려야한다.
- SSI는 불필요한 어보트를 피해서 일관된 스냅숏에서 읽으며 오래 실행되는 작업을 지원하는 스냅숏 격리의 특성을 유지한다.
3.3.2.2. 과거의 읽기에 영향을 미치는 쓰기 감지하기
- 다른 트랜잭션을 차단하지 않고 2단계 잠금과 비슷한 기법을 쓸 수 있다.
- 트랜잭션이 데이터베이스에 쓸 때 영향받는 데이터를 최근에 읽은 트랜잭션이 있는지 색인에게 확인해야 한다.
- 키 범위 쓰기 잠금을 획득하는 것과 비슷하지만 읽는 쪽에서 커밋될 때까지 차단하지 않는다.
- 읽은 데이터가 더 이상 최신이 아니라고 트랜잭션에게 알려줄 뿐이다.
- 트랜잭션을 커밋할 때 충돌되는 쓰기가 이미 커밋됐으면 어보트돼야 한다.
3.3.3. 직렬성 스냅숏 격리의 성능
- 한 가지 트레이드오프는 트랜잭션의 읽기 쓰기를 추적하는 세밀함의 정도이다.
- 상세하게 추적하면 어보트해야할 트랜잭션을 정확히 판별할 수 있지만 기록 오버헤드가 심해진다.
- 어떤 경우에는 다른 트랜잭션에서 덮어쓴 정보를 트랜잭션이 읽어도 괜찮다.
- 불필요한 어보트 개수를 줄일 수 있음
- 2단계 잠금과 비교
- 다른 트랜잭션이 잠금을 기다리느라 차단될 필요가 없음
- 읽기 전용 질의는 잠금 없이 일관된 스냅숏 위에서 실행될 수 있음
- 순차 실행과 비교
- 단일 CPU 코어의 처리량에 제한되지 않음
- 직렬성 충돌 감지를 여러 장비로 분산시켜서 처리량이 높도록 확장할 수도 있음 (파운데이션DB)
- 여러 장비에 파티셔닝돼 있더라도 트랜잭션은 직렬성 격리를 보장하면서 여러 파티션으로부터 읽고 쓸 수 있다.
- 오랜 시간 동안 데이터를 읽고 쓰는 트랜잭션은 충돌이 나고 어보트되기 쉬워 SSI는 읽기 쓰기 트랜잭션이 상당히 짧기를 요구하지만 2단계 잠금이나 순차 실행보다는 느린 트랜잭션에 덜 민감할 것이다.