designing-data-intensive-applications

7장 트랜잭션

트랜잭션은 이런 문제를 단순화하기 위해 전체가 성공(커밋), 실패(롤백)한다.

동시성 제어 분야에서는 여러 경쟁 조건

가 존재한다.

애매모호한 트랜잭션 개념

데이터베이스마다 구현하는 트랜잭션의 강도가 다르기 때문에 트랜잭션 개념이 조금 애매모호하다. 어디서는 느슨한 트랜잭션, 어디서는 강도 높은 트랜잭션을 구현한다.

ACID 의미

ACID 구현은 제각각이며 격리성에 모호함이 있다.

원자성

원자적이란 쪼갤 수 없는 것을 의미하지만, 대충 어보트 능력(abortability) 정도.

일관성

= 좋은 상태

격리성

한 트랜잭션의 작업은 다른 트랜잭션에 의해 영향을 받으면 안된다.

직렬성 격리 같은 경우엔 성능 오버헤드로 인해 거의 사용되지 않음

팬텀 리드, 더티 리드 등 다른 트랜잭션에 의해 영향을 받을 수 있음.

혹은 동시 접근으로 인해 동시성 문제가 발생할 수도 있음.

지속성

한번 쓴 데이터는 비휘발성 저장소에 기록되어 잃어버리지 않아야 한다.

단일 객체 연산과 다중 객체 연산

원자성, 격리성 두개에 대해 나오는 듯?

단일 객체 쓰기

원자성과 격리성은 단일 객체를 변경하는 경우에도 적용된다.

이 문제들은 혼란스럽기 때문에 저장소 엔진들은 거의 보편적으로 한 노드에 존재하는 단일 객체 수준에서 원자성/격리성 제공을 목표로 한다.

원자성은 장애 복구 로그를 통해 구현할 수 있고, 격리성은 각 개체 잠금을 사용해 구현할 수 있다.

어떤 데이터베이스는 compare-and-set 연산을 제공해 read-modify-write 주기 반복을 없앤다.

다중 객체 트랜잭션 필요성

많은 분산 데이터스토어는 다중 객체 트랜잭션 지원을 포기했다. 왜냐하면 여러 파티션에 걸쳐 구현하기 어렵고 매우 높은 가용성과 성능이 필요한 곳에서는 방해가 되는 시나리오도 있기 때문이다.

오류와 어보트 처리

트랜잭션의 핵심은 어보트되고 안전하게 재시도가 가능한 것.

여기서 오류 복구는 애플리케이션에게 책임이 있다. 어보트된 트랜잭션을 재시도하는 것은 간단하고 효율적인 오류 처리 메커니즘이지만 완벽하진 않다.

완화된 격리 수준

커밋 후 읽기(read committed)

더티 읽기 방지

아직 커밋되지 않은 쓰기 데이터를 읽는 것.

커밋 후 읽기 격리 레벨에서는 더티 리드를 막아야 한다. 더티 리드를 막았을 때 유용한 이유

더티 쓰기 방지

두 트랜잭션이 동일한 객체를 동시에 쓰려고 하면 나중에 쓴 내용이 먼저 쓴 내용을 덮어쓰게 된다.

더티 쓰기를 막음으로써 해당 문제를 해결한다.

  1. 트랜잭션들이 여러 객체를 갱신하면 더티 쓰기는 나쁜 결과를 유발할 수 있음.
  2. 커밋후 읽기는 동시성 문제에서 경쟁 조건을 막지는 못한다.

커밋 후 읽기 구현

매우 널리 쓰이는 격리 수준이며, 가장 흔하게 로우 수준 잠금을 사용해 더티 쓰기를 방지할 수 있다.

트랜잭션에서는 쓰기를 하기 전에 해당 로우에 대한 잠금을 획득하고, 트랜잭션이 커밋/어보트 될 때까지 잠금을 보유하고 있어야 한다.

더티 읽기도 동일하게 잠금을 사용해 구현할 수 있지만, 읽기만 실행하는 여러 트랜잭션들이 쓰기 트랜잭션이 완료되길 기다려야 할 수 있기 때문에 현실적으로 잘 사용되진 않는다.

스냅숏 격리와 반복 읽기

커밋 후 읽기를 사용하더라도 동시성 버그가 생기는 경우는 존재한다.

비반복 읽기(nonrepeatable read)나 읽기 스큐(read skew)가 있다.

스냅숏 격리는 이 문제의 가장 흔한 해결책이다. 각 트랜잭션은 일관된 트랜잭션으로 부터 읽는다.

스냅숏 격리는 백업이나 분석처럼 오래 걸리며 읽기만 실행하는 질의에 요긴할 수 있다.

Postgresql, innoDB 저장소 엔진을 쓰는 mysql, oracle, sql 서버 등에서 지원됨.

스냅숏 격리 구현

더티 쓰기를 방지하기 위해 쓰기 잠금을 사용하며, 읽기 작업에 대한 차단을 하지 않는다.

데이터베이스는 커밋된 버전 여러개를 유지할 수 있기 때문에 다중 버전 동시성 제어(MVCC)라고도 한다.

해당 로우를 삽입/삭제 요청을 한 트랜잭션 ID를 저장하며, 삭제 요청 로우에 대해서 접근하지 않음이 확실해지면 데이터베이스 가비지 컬렉션 프로세스가 해당 로우들을 실제 삭제한다.

일관된 스냅숏을 보는 가시성 규칙

트랜잭션은 데이터베이스에서 객체를 읽을 때 트랜잭션 ID를 사용해 판단한다.

색인과 스냅숏 격리

다중 버전 데이터베이스에서 색인은 단순하게 객체의 모든 버전을 가리키고, 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 하는 것이다.

반복 읽기와 혼란스러운 이름

오라클은 직렬성, 포스트그레스큐엘, mysql은 반복 읽기(repeatable read)라고 하는데, 초창기엔 스냅숏 격리 개념이 없었기 때문이다.

갱신 손실 방지

동시에 데이터베이스 쓰기를 할 경우 발생할 수 있는 또 다른 문제는 갱신 손실(lost update)이며 동시에 쓸 경우 먼저 쓴 데이터가 손실될 수 있다. (LWW)

갱신 손실 방지의 해결책

원자적 쓰기 연산

커서 안정성(cursor stability)라고도 부르는데, 갱신이 적용될 때 다른 트랜잭션에서 그 객체를 읽기 못하게 데이터베이스에서 원자적 연산을 제공해주는 것이다.

명시적인 잠금

애플리케이션에서 명시적으로 갱신할 객체를 잠그는 것.

갱신 손실 자동 감지

read-modify-write 주기가 순차적으로 실행되도록 해서 갱신 손실을 방지하는 것이다.

대안으로 병렬 실행을 허용하고, 트랜잭션 관리자가 갱신 손실을 발견하면 어보트 시키고 재시도하게끔 하는 방법.

mysql/innoDB는 갱신 손실 감지를 하지 않는다.

Compare-and-set

값을 마지막으로 읽은 후 해당 객체에 변경이 이루어졌는지 확인하고, 변경이 없었다면 갱신되게 한다. (갱신 손실 회피)

충돌 해소와 복제

복제 데이터베이스 환경에서는 최신 복사본이 여러개 존재할 수 있기 때문에

잠금과 compare-and-set 기법은 사용할 수 없다(최신 복사본이 하나라고 가정하기 때문에)

반면에 원자적 연산은 복제 상황에서도 잘 동작하고, 최종 쓰기 승리(LWW)는 갱신 손실이 발생하기 쉽지만, 많은 복제 데이터베이스는 LWW가 기본 설정이다.

쓰기 스큐와 팬텀

_다른 트랜잭션들이 동시에 같은 객체에 쓰려고 할 때 발생할 수 있는 경쟁조건

쓰기 스큐를 특징짓기

더티 쓰기 / 갱신 손실

쓰기 스큐

추가적인 쓰기 스큐의 예

쓰기 스큐를 유발하는 팬텀

phantom : 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과

충돌 구체화

materializing conflict : 구체화 뷰를 사용해서 팬텀을 해당 뷰의 로우 집합에 대한 잠금 충돌로 변환

직렬성

여러 트랜잭션이 병렬로 실행되더라도 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장 → 가장 강력한 격리 수준

트랜잭션을 개별적으로 실행할 때 올바르게 동작한다면 동시에 실행할 때도 올바르게 동작할 것을 보장 → DB가 발생할 수 있는 모든 경쟁 조건을 막아줌

실제적인 직렬 실행

동시성 문제를 피하는 가장 간단한 방법 : 한 번에 트랜잭션 하나만 직렬로 단일 스레드에서 실행

구현 가능 조건

볼트DB/H 스토어/레디스/데이토믹에서 구현

CPU 코어 하나의 처리량으로 제한

트랜잭션을 스토어드 프로시저 안에 캡슐화하기

상호작용식 트랜잭션

스토어드 프로시저의 장단점

관계형 DB에서 사용 & SQL 표준이 됨

단점 (확장이 잘 되지 않는다!)

장점

볼트DB는 복제에도 SP 사용 ← 결정적이어야 함 (다른 노드에서 실행돼도 같은 결과가 나옴)

파티셔닝

직렬 실행은 DB의 트랜잭션 처리량이 단일 장비에 있는 단일 CPU 코어의 속도로 제한됨

여러 CPU 코어와 여러 노드로 확장하기 위해 파티셔닝 (볼트DB 지원)

직렬 실행 요약

제약 사항

2단계 잠금(2PL)

two_phase locking, 2PL : 쓰기 트랜잭션은 다른 쓰기 트랜잭션 뿐만 아니라 읽기 트랜잭션도 허용하지 않으며 그 역도 성립 → 직렬성을 제공하여 모든 경쟁 조건으로 부터 보호

vs

스냅숏 격리 : 읽는 쪽은 결코 쓰는 쪽을 막지 않으며 반대도 허용

2단계 잠금 구현

MySQL, SQL 서버 ⇒ 직렬성 격리 수준 구현에 사용

DB2 → 반복 읽기 격리 수준 구현에 사용

공유 모드(shared mode) vs 독점 모드(exclusive mode)

교착 상태 : A가 B의 잠금 해제 대기 & B가 A의 잠금 해제 대기 → DB가 자동으로 감지하여 트랜잭션 중 하나를 어보트 시키고 이 트랜잭션은 재시도

2단계 잠금의 성능

단점 (성능이 나쁘다!)

서술 잠금

팬텀 문제(한 트랜잭션이 다른 트랜잭션의 검색 질의 결과를 바꿔버리는 문제)는 서술 잠금(predicate lock)으로 해결

색인 범위 잠금

진행 중인 트랜잭션들이 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는데 오래 걸려서 잘 동작 X

색인 범위 잠금(index-range locking) = 다음 키 잠금(next-key locking)

직렬성 스냅숏 격리(SSI)

serializable snapshot isolation : 완전한 직렬성을 제공하면서도 스냅숏 격리에 비해 약간의 성능 손해만 있음

단일 노드 DB(포스트그레)와 분산 DB(파운데이션DB)에서 모두 사용

비관적 동시성 제어 대 낙관적 동시성 제어

2단계 잠금 = 비관적 동시성 제어

SSI = 낙관적 동시성 제어

뒤처진 전제에 기반한 결정

트랜잭션은 어떤 전제 (현재 두명의 의사가 호출 대기 중)을 기반으로 동작을 하는데, 나중에 해당 트랜잭션이 커밋하려고 할 때 원래의 데이터가 바뀌어서 해당 전제가 참이 아닐 수 있음

따라서 직렬성 격리를 제공하려면 뒤처진 전제를 기반으로 동작하는 트랜잭션을 감지하고 어보트 시켜야함

오래된 MVCC 읽기 감지하기

트랜잭션 43이 읽을때는 (과거의) 스냅숏을 보지만 커밋하려고 할 때는 트랜잭션 42가 커밋된 상태이므로 읽을 때는 무시됐던 쓰기가 영향이 있어지고 트랜잭션 43의 전제가 유효하지 않음

→ 트랜잭션이 커밋하려고 할 대 DB가 무시된 쓰기 중 커밋된게 있는지를 추적/확인해야 하며 이 트랜잭션을 어보트 시킴

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

트랜잭션 43이 커밋하려고 할 때 트랜잭션 42의 충돌되는 쓰기가 이미 커밋됐으므로 트랜잭션 43은 어보트됨

직렬성 스냅숏 격리의 성능

트레이드 오프는 얼마나 세밀하게 추적하느냐 → 어보트 비율에 좌우됨

정리

많은 경쟁 조건

완화된 격리 수준

직렬성 격리