데이터 중심 애플리케이션 7장. 트랜잭션
by academey
0. 들어가며
데이터 중심 애플리케이션 책의 7장 부분을 요약한 내용입니다. 학습을 위해 정리한 내용을 공유합니다. 정말 좋은 책이니 꼭 읽어보시길 강추드립니다.
7장: 트랜잭션
트랜잭션이 필요한 이유
- 냉혹한 현실 세계
- 데이터베이스 소프트웨어와 하드웨어는 언제라도 실패할 수 있다.
- 네트워크가 끊기면 노드 사이의 통신이 안 될 수 있다.
- 클라이언트 사이의 경쟁 조건은 예측하지 못한 버그를 유발할 수 있다.
- 모든 경우의 수를 따질 수 없으니, 좀 더 단순하게 만들자.
- 몇 개의 읽기와 쓰기를 하나로 묶으면 오류 처리하기가 훨씬 단순하다.
- 프로그래밍 모델을 단순화함으로써 데이터베이스의 정합성을 보장해준다.
- 단일 노드 + 분산 데이터베이스 모두!
- 분산 시스템에서만 생기는 난제는 8장에서 다뤄보자.
트랜잭션의 개념
- 현대의 RDB, NOSQL 모두 트랜잭션에 대해 관심이 커지면서 잘못된 정보들이 퍼졌다.
- 높은 성능과 고가용성을 위해 트랜잭션을 포기해야 한다. (X)
- 트랜잭션은 데이터가 중대한 애플리케이션에 필수적이다.(X)
ACID
- 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)
- 그러나 데이터베이스마다 ACID 구현이 제각각이다. 또한 격리성 의미 주변에는 모호함이 많이 있다.
- ACID 를 따르지 않는 시스템을 BASE라고 부른다.
- 기본적으로 가용성을 제공하며, 유연한 상태를 가지며, 최종적 일관성을 지닌다. (애매모호)
원자성
- 더 작은 부분으로 쪼갤 수 없다. 쪼개거나 안 쪼개거나. 중간 상태에는 머물 수 없다.
- 일부만 처리되는 후결함이 생겨 완료(커밋)될 수 없다면 어보트되고, 데이터베이스는 지금까지 실행된 걸 무시하거나 취소해야 한다.
- 오류가 생겼을 때 트랜잭션을 어보트하고 취소하는 게 원자성이다.(abortability)
- 원자성은 동시성과 관련이 없다. 여러 프로세스가 동시에 같은 데이터에 접근하려고 할 때 무슨일이 생기는지는 설명하지 않는다. 이 문제는 격리성에서 다룬다.
일관성
- 굉장히 여러 의미로 쓰인다.
- 복제 일관성과 비동기식으로 복제되는 시스템에서 발생하는 최종적 일관성
- 일관적 해싱은 어떤 시스템들에서 재균형화를 위해 사용하는 파티셔닝
- 일관성은 선형성을 의미한다.
- 일관성은 항상 데이터가 진실이어야 한다는 것이다. 회계 시스템에서 대변과 차변이 일치해야 한다.
- 그러나, 일관성의 아이디어는 애플리케이션에 의존한다. 데이터베이스가 보장하지 않는다. 데이터베이스는 외래 키 제약이나 유일성 제약 조건 정도만 지켜줄 수 있고, 데이터의 유효성은 애플리케이션에서 지켜야 한다. 데이터베이스의 속성이 아니다.
격리성
- 동일한 데이터베이스 레코드에 여러 클라이언트가 접근하면 동시성 문제(경쟁 조건)에 맞닥뜨린다.
- 격리성은 동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미한다. 트랜잭션은 다른 트랜잭션을 방해할 수 없다.
- 격리성? 직렬성?
- 직렬성은 각 트랜잭션이 실행되는 유일한 트랜잭션인 것 처럼 동작할 수 있다. 순차적으로 실행됐을 때의 결과와 동일하도록 보장한다.
- 그러나 직렬성 격리는 성능 손해를 동반하므로 현실에서 거의 사용되지 않는다. 오라클에서는 직렬성이라는 격리 수준이 있지만 사실 직렬성보다 보장이 약한 스냅숏 격리로 구현한 것이다.
지속성
- 트랜잭션이 성공적으로 커밋됐다면 하드웨어 결함이 발생하거나 디비가 죽더라도 트랜잭션에서 기록한 모든 데이터는 손실되지 않는다는 보장.
- 단일 노드 데이터베이스에서 지속성은 하드디스크나 SSD 같은 비휘발성 저장소에 기록되었다는 걸 의미한다.
- 보통 디스크가 오염됐을 때 복구하기 위한 쓰기 전 로그 등을 이용하기도 한다.
-
복제 기능 있는 데이터베이스에서 지속성은 다른 노드 몇개에도 복사가 된 것을 의미한다. 지속성을 보장하려면 복제가 완료될 때까지 기다려야 한다.
- 결론적으로, 트랜잭션에서 신경 써야 할 것은 두가지다.
- 원자성 : 쓰기를 이어서 실행하는 도중 오류가 발생되면 그떄까지 쓰여진 내용은 전부 폐기한다.
- 격리성 : 동시에 실핸되는 트랜잭션들은 서로를 방해하지 말아야 한다. 한 트랜잭션에서 쓰는 연산이 여러번 일어난다면 다른 트랜잭션은 내용을 전부 볼 수 있던지 아무것도 볼 수 없던지 해야 한다.
단일 객체 연산과 다중 객체 연산
다중 객체 트랜잭션
- 한번에 여러 객체를 변경할 수 있는 환경에서 다중 객체 트랜잭션은 여러 데이터들을 동기화시키도록 하는 것이다.
- 읽지 않은 이메일과 읽지 않은 이메일의 개수를 동시에 추가하고 변경할 때, 변경된 것을 모두 보거나 모두 보지 못하게 만들어 일관성이 깨지지 않도록 한다.
- 반면, 비관계형에서는 이런 식으로 연산을 묶는 방법이 거의 없다. 여러 키를 갱신하는 다중 객체 API 가 있더라고 반드시 트랜잭션 시맨틱을 의미하지 않는다. 부분적으로 갱신된 상태가 될 수 있다.
단일 객체 트랜잭션
- 20KB 의 JSON 문서를 데이터베이스에 쓴다고 해보자. 10KB 를 보내다가 네트워크가 끊기면 반만 저장할 것인가?
- 저장소 엔진들은 단일 객체 수준에서 원자성과 격리성을 보장하는 것을 목표로 한다. 원자성은 장애 복구용 로그를 써서, 격리성은 서로를 방해하지 않고, 각 객체에 잠금을 사용해 한 스레드만 객체에 접근하도록 구현할 수 있다.
- 누군가에 의해 바뀌지 않았을 때만(Compare and set) 쓰기가 반영되도록 만드는 경우도 있다. 이러한 단일 객체 트랜잭션은 동시에 같은 객체에 쓰려고 할 때 갱신 손신을 방지하므로 유용하다.
- 그러나 일반적인 의미의 트랜잭션은 아니다. 트랜잭션은 보통 다중 객체에 대한 다중 연산을 하나의 실행 단위로 묶는 메커니즘으로 이해된다.
다중 객체 트랜잭션의 필요성
- 다중 객체 트랜잭션이 필요한 이유?
- 여러 개의 다른 객체에 실행되는 쓰기 작업은 조율되어야 한다.
- 다른 테이블의 로우를 참조하거나, 최신 값을 반영해야 한다.
- 문서 데이터 모델에서는 비정규화된 데이터들을 한 번에 갱신해야 한다.
- 보조 색인이 있는 데이터베이스에서는 값을 변경할 때마다 색인도 갱신되어야 한다.
- 트랜잭션이 없어도 위의 요구사항을 지킨 어플리케이션을 구현할 수는 있지만, 원자성이 보장되지 않으면 오류처리가 훨씬 더 복잡해지고, 격리성이 없으면 동시성 문제가 생길 수 있다.
- 많은 분산 데이터스토어는 다중 객체 트랜잭션 지원을 포기했다. 여러 파티션에 걸쳐 구현하기 어렵고 방해되는 시나리오도 많기 때문이다. 그러나 근본적으로 막는 것은 아무것도 없다. 9장에서 분산 트랜잭션의 구현에 대해 살펴보자.
오류와 어보트 처리
- 트랜잭션은 오류가 생기면 어보트되고 안전하게 재시도할 수 있다.
- 그러나 모든 시스템이 이 철학을 따르지 않는다. 리더 없는 복제는 “데이터베이스는 가능한 모든 것을 할 것이며 그 때문에 오류가 발생하면 이미 한 일은 취소하지 않는다” 즉, 오류 복구는 애플리케이션에 책임이 있다.
- ORM 들은 어보트된 트랜잭션을 재시도하지 않는다. 재시도하는 것은 간단하고 효과적인 오류 처리 매커니즘이지만 완벽하지는 않다.
- 네트워크가 끊겼을 때 재시도하면 트랜잭션이 두 번 실행된다.
- 오류가 과부하 때문이라면 재시도는 문제를 개선하는 게 아니라 악화시킬 수 있다.
- 일시적인 오류가 아니라 영구적인 오류면 재시도해도 소용없다.
완화된(비직렬성) 격리 수준
두 트랜잭션이 동일한 데이터에 접근하지 않으면 서로 의존하지 않으므로 안전하게 병렬 실행될 수 있다. 동시성 문제는 트랜잭션이 동시에 같은 데이터를 변경하거나 하면 문제가 생긴다.
그래서 트랜잭션 격리를 제공함으로써 동시성 문제를 감추고자 했다. 격리성은 동시성이 없는 것처럼 행동할 수 있다. 그 중 직렬성 격리는 여러 트랜잭션들이 직렬적으로 실행되는 것과 동일한 결과를 보장한다.
그러나, 직렬성 격리가 간단하지는 않다. 성능 비용이 있어서 완화된 격리 수준을 사용하는 시스템들이 흔하다.
물론 완화된 격리 수준은 더 어렵고 미묘한 버그를 유발한다.
완화된 격리 수준에 의해 발생하는 여러 동시성 문제의 종류들을 미리 이해하고 방지해야 한다. 발생할 수 있는 경쟁 조건과 발생할 수 없는 경쟁 조건을 살펴보고, 어떤 게 맞을지 생각해보자.
커밋 후 읽기
- 가장 기본적인 수준의 트랜잭션 격리이다. 두 가지를 보장한다.
- 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다. (더티 읽기 X)
- 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다. (더티 쓰기 X)
더티 읽기와 더티 쓰기 는 무엇인가?
더티 읽기 방지
- 다른 트랜잭션에서 썼지만 커밋되지 않은 데이터를 볼 수 있는 걸 더티 읽기라고 한다.
- 트랜잭션 1에서 업데이트 해도, 트랜잭션 2에서는 커밋을 하기 전까지는 알지 못한다.
- 즉, 트랜잭션이 쓴 내용은 커밋된 후에야 다른 트랜잭션에게 보여야 한다.
- 더티 읽기를 막아야 하는 이유
- 부분적으로 갱신된 상태에 있는 데이터를 보는 것은 혼란스럽다.
- 트랜잭션 1이 롤백될 트랜잭션이었다면, 결코 커밋되지 않을 데이터를 볼 수 있다.
더티 쓰기 방지
- 두 트랜잭션이 동일한 객체를 동시에 갱신하려고 하면 어떻게 될까
- A 는 T1 이, B는 T2 가 늦게 업데이트하면 데이터의 정합성이 망가진다. 따라서 먼저 쓴 트랜잭션이 커밋되거나 어보트될 때까지 두 번째 쓰기를 지연시킨다. 락을 건다.
커밋 후 읽기 구현
- 오라클, Postgre, MemSQL … 등등 에서 쓰는 기본 격리 설정이다
- 더티 읽기 방지
- 락을 걸어서 객체를 읽기 원하는 트랜잭션이 락을 획득한 후 읽기가 끝나고 해제한다. 그러나, 읽기 락을 하려면 락 대기 경우가 너무 많다.
- 따라서 커밋 이후에만 데이터를 볼 수 있도록 한다. 데이터베이스는 과거에 커밋된 값과 현재 쓰기 잠금을 갖고 있는 트랜잭션에서 쓴 새로운 값을 기억하고, 커밋되기 전까지는 과거의 값만 보여주도록 한다.
- 더티 쓰기 방지
- 가장 흔한 방법으로는 로우 수준 잠금을 사용해 더티 쓰기를 방지한다.
- 동일한 객체에 쓰기를 원한다면 첫 번째 트랜잭션이 완료된 후에야 락을 얻어 진행할 수 있다.
스냅숏 격리와 반복 읽기
- 커밋 후 읽기가 모든일을 해주는 것이라고 생각할 수 있지만, 동시성 문제가 생길 경우는 아직 많이 있다.
-
트랜잭션 1이 커밋되고 반영된 결과를 트랜잭션 2의 중간에서 읽어온다면 처음에 읽었던 값과 다른 값이 나올 수 있다. 이런 이상 현상을 비반복 읽기(nonrepeatable read) 혹은 읽기 스큐(read skew) 라고 한다.
skew 는 핫스팟이 있는 불균형적 작업부하라는 뜻으로 사용했지만, 여기서는 시간적인 이상 현상을 뜻한다.
- 데이터의 비일관성을 감내할 수 없는 경우가 있다.
- 백업할 때 일부는 데이터의 과거 버젼, 일부는 새버젼으로 복원하면 비일관성이 영속적으로 된다.
- 분석 질의를 할 때 불합리한 결과를 볼 수 있다.
- 이 문제를 해결하기 위해 스냅숏 격리를 쓴다. 각 트랜잭션은 특정 일관된 스냅숏으로부터만 데이터를 읽도록 만든다.
스냅숏 격리 구현
- 더티 쓰기를 방지하기 위해 쓰기 락을 사용한다. 그러나 읽을 때는 아무 잠금도 필요 없다. 성능 관점에서 스냅숏 격리의 핵심 원리는 읽을 때는 쓰는 쪽을 차단하지 않고, 쓰는 쪽에서 읽는 쪽을 차단하지 않는 것이다.
- 따라서 락 경쟁 없이 일상적으로 처리되는 것과 동시에 일관성있는 스냅숏에 대해 오래 실행되는 읽기 작업을 동시에 할 수 있다.
- 데이터베이스는 객체마다 커밋된 버젼 여러개를 유지해야 한다. 이를 다중 버전 동시성 제어(Multi Version Concurrency Control)라고 한다.
- 커밋 후 읽기 격리에서는 객체마다 두 버젼만 유지하면 된다. 커밋된 버젼과 덮여 쓰여졌지만 아직 커밋되지 않은 버젼.
- 스냅숏 격리를 지원하는 경우는 거의 질의마다 독립된 스냅숏을 이용하고, 스냅숏 격리는 전체 트랜잭션에 대해 동일한 스냅숏을 사용하도록 한다.
- Postgre 에서 MVCC 기반 스냅숏 격리를 구현한 방법은 다음과 같다.
- 트랜잭션이 시작하면 고유한 트랜잭션 ID(txid)를 할당한다. 테이블의 각 로우마다 txid 를 주입하는 created_by, deleted_by 필드가 있다. 각 로우들을 변경하거나 제거할 때 이 값들을 변경한다.
일관된 스냅숏을 보는 가시성 규칙
- 트랜잭션을 시작할 때 그 시점에 진행 중인 모든 트랜잭션의 목록을 만든다. 이 트랜잭션들이 쓴 데이터는 모두 무시된다.
- 어보트 된 트랜잭션이 쓴 데이터는 모두 무시된다.
- 트랜잭션 ID가 더 큰 트랜잭션은 무시한다.
- 읽기를 실행하는 시점 전에 커밋되었거나, 삭제했더라도 삭제한 트랜잭션이 커밋되지 않았다면 데이터를 조회할 수 있다.
- 오래 실행되는 경우는 오랫동안 스냅숏을 사용해서 오래된 값을 계속 읽을 수도 있다.
색인과 스냅숏 격리
- 다중 버전 데이터베이스에서 색인 질의는 객체의 모든 버젼을 가리키게 하고, 현재 트랜잭션에서 볼 수 없는 버젼을 걸러내게 하면 된다.
- 동일한 객체의 다른 버젼들이 같은 페이지에 저장될 수 있다면 색인 갱신을 회피하는 최적화를 한다.
- 추가 전용 B트리를 사용하면 쓰기를 실행하는 모든 트랜잭션은 새로운 B트리 루트를 생성해 일관된 스냅숏으로 사용한다.
반복 읽기와 혼란스러운 이름
- 스냅숏 격리는 읽기 전용 트랜잭션에서 유용하다. 그러나 이를 오라클에서는 직렬성, Postgre와 MySQL 에서는 반복 읽기(repeatable read) 라고 한다. 우리 서비스는 repeatable read 를 쓴다. unit 등을 빌드할 때 다른 시점의 데이터를 읽으면 안 되기 때문에..
- 이름이 혼란스러운 이유는 SQL 표준에 스냅숏 격리의 기준이 없다. Repeatable read 는 정의는 되어 있으나 다 구현을 다르게 쓴다. 엉망이다.
갱신 손실 방지
- 더티 쓰기를 막기 위해서 쓰는 방법은 다음과 같다.
- 커밋 후 읽기 (무조건 기다려라)
- 스냅숏 격리, Repeatable Read (그 시점의 데이터를 봐라)
- 그러나, 두 트랜잭션은 동시에 쓰기를 실행할 때 충돌은 더 다양하다. 그 중 대표적인 갱신 손실. ex) 카운터 증가
- 애플리케이션이 데이터베이스에서 값을 읽고(Read) 변경한 후(Modify) 변경된 값을 다시 쓰는(Write) 과정에서 발생한다.
- 만약 두 개가 동시에 실행되면 하나의 값은 사라진다.
- 이 방법의 해결책은 여러 가지가 있다.
- 원자적 쓰기 연산
- 명시적인 잠금
- 갱신 손실 자동 감지
- Compare and Set
- 충돌 해소와 복제
원자적 쓰기 연산
- 이 연산은 R-M-W 주기를 구현할 필요가 없다. Concurrency safe 하기 때문이다.
UPDATE counters SET value = value + 1 WHERE key = 'foo';
- 문서 데이터베이스도 JSON 문서의 일부를 변경하는 원자적 연산을 제공하고, 레디스도 제공한다. 원자적 연산이 가능하면 그게 보통 최선의 선택이다.
- 원자적 연산은 보통 객체를 읽을 때 Exclusive Lock 을 획득해서 구현한다. 그래서 다른 트랜잭션이 읽지 못해 Cursor Stability 라고 부르기도 한다. 무조건 원자적 연산을 단일 스레드에서 실행하도록 하는 것이다.
명시적인 잠금
- 애플리케이션에서 갱신할 객체를 명시적으로 잠근다. 첫 번째 R-M-W 가 끝날 때까지 기다려야 한다.
SELECT * FROM figures
WHERE name = 'robot'
FOR UPDATE;
- 두 개의 트랜잭션이 서로 동시에 변경할 수 없도록 잠금을 사용한다. 그러나 경쟁 조건을 유발하기 쉬우므로 주의하자.
갱신 손실 자동 감지
- 원자적 연산과 잠금은 R-M-W 이 순차적으로 실행되도록 강제한다. 그 대신에 병렬 실행을 허용하고, 손실을 발견하면 트랜잭션을 어보트시키고 재시도하도록 만들자.
- 스냅숏 격리와 결합해 효율적으로 수행할 수 있다. 실제로 Repeatable Read, 스냅숏 격리 수준들은 갱신 손실이 발생하면 자동으로 발견해서 문제가 되는 트랜잭션을 어보트시킨다. (어떤 방식으로? 뭔가 txid 를 기준으로 비교해서 감지하는듯?)
- 그러나 MySql의 Repeatable Read 는 갱신 손실은 감지하지 않는다.
Compare-and-set
- 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용한다. 현재 값이 이전에 읽은 값과 일치하지 않으면 갱신을 반영하지 않고 재시도해야 한다.
UPDATE wiki_pages SET content = 'new content'
WHERE id =1234 AND content = 'old content'; // 아니면 어보트!
- 그러나 데이터베이스가 스냅숏으로 읽고 있으면 갱신 손실을 막지 못한다.
충돌 해소와 복제
- 복제가 적용된 DB에서는 여러 노드에 데이터의 복사본이 있어서 데이터가 다른 노드들에서 동시에 변경될 수 있다.
- 따라서 갱신 손실을 방지하려면 추가 단계가 필요하다. 락과 compare-and-set 은 복제된 상태에서 동시에 여러 쓰기가 실행되고 복제되는 것을 허용하므로 뭘 잠그거나 비교해야하는지 알 수 없다. 그래서 불가능하다.
- 그 대신에 동시에 쓰기가 실행될 때 여러 개의 충돌된 버전을 생성하고 사후에 애플리케이션 코드에서 충돌을 해소하고 병합하는 것이 흔히 쓰는 방법이다.
- 원자적 연산은 복제 상황에서도 잘 된다.
- 최종 쓰기 승리(LWW) 방법은 갱신 손실이 많이 발생하지만, 이게 기본 설정인 복제 데이터베이스가 많다.
쓰기 스큐(이상 현상)와 팬텀
- 더티 쓰기란? 다중 객체 트랜잭션이 동시에 발생할 때 A는 T1이, B는 T2가 수정함
- 더티 쓰기를 막으려면? 락걸거나 스냅숏 격리
- 갱신 손실이란? 동시에 R-M-W 할 때 자기 꺼만 생각해서 정합성을 못 지켜줌.
-
갱신 손실을 막으려면? 원자적 쓰기, 명시적 락, 자동 감지, Compare-and-Set
- 그러나, 이런 현상외에도 더 있다.
- 다중 트랜잭션이 같은 객체를 읽고(R), 다른 객체들을 변경하고(M), 그 중 일부를 갱신(W)할 때 나타난다.
- 갱신 손실과 다른 건 뭐냐?
- 갱신 손실은 하나의 객체고, 쓰기 스큐는 각 트랜잭션이 다른 객체를 변경하는 데 문제가 생기는 거다.
- 다른 객체들이므로 원자적 단일 객체 연산은 도움이 되지 않는다.
- 갱신 손실 자동감지도 도움이 되지 않는다. 스냅숏 격리 수준에서 감지를 할 수 없는 소프트웨어적 요구사항이기 때문이다.
- 데이터베이스 내에서 제약 조건을 설정할 수 있는 건 외래 키 제약, 특정 값 제한 등이다. 특정 조건의 레코드의 총 수 같은 것들은 구체화 뷰를 사용해 구현은 가능하다.
- 직렬성 격리 수준을 사용할 수 없다면 로우를 잠그는 것이 차선책이다. ex) SELECT * FOR UPDATE
추가적인 예시
- 의사 외에 추가적인 예시는 다양하다.
- 회의실 예약의 경우, 다른 사용자가 동시에 회의를 삽입하는 것을 막아줄 수 없다.
- 사용자 명을 생성할 때는 유일성 제약 조건을 이용해서 해결할 수 있다.
- 은행에서 다른 항목에 대해 동시에 지불 액에 대한 레코드를 생성해 그 합계를 총액으로 쓴다고 하면 잔고가 음수인지 아닌지에 대한 검사는 나중에 하는 것이고 각 레코드와 관계가 없기 때문에 실행되고 이후에 문제가 생긴다.
쓰기 스큐를 유발하는 패턴
- 특정 요구사항을 만족하는지 확인한다
- 1의 질의 결과에 따라애플리케이션 코드를 어떻게 진행할지 결정한다.
- 처리하기로 결정됐따면 데이터베이스에 쓰기 트랜잭션을 커밋한다. 이 커밋에 따라 1.이 바뀐다. 즉, 1의 조건에 부합하는 결과가 변경된다 (의사가 한 명 줄었다. 해당 시간에 예약이 되었다 등등)
- 의사 호출의 예시에서는 SELECT FOR UPDATE 로 쓰기 스큐를 회피할 수 있지만, 아무 로우도 반환되지 않는 경우는 아무것도 잠글 수가 없다.
- 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 팬텀(Phantom)이라고 한다.
충돌 구체화
- 잠글 수 없어서 팬텀 문제가 생긴다면, 임의적으로 데이터베이스에 잠금 객체를 생성하자.
- 회의실의 경우는 모든 시간 범위의 조합을 미리 만들어두고 필요할 때 락을 걸 수 있다.
- 이런 방법을 충돌 구체화라고 한다. 팬텀을 구체적인 로우 집합에 대한 잠금 충돌로 변화하기 때문이다. 그러나 동시성을 해결하기 위해 데이터 모델을 이용하는 것은 좋지 않으므로 최후의 대안이다.
- 이 방법보다는 직렬성 격리 수준을 선호한다
직렬성
- 커밋 후 읽기나 스냅숏 격리로 해결되지만, 그렇지 않은 경우도 있다. 또한 데이터베이스마다 구현의 일관성이 없고 경쟁 조건을 감지하는 데 좋은 방법이 없다. 타이밍이 좋지 않은 경우에만 발생한다.
- 직렬성 격리는 가장 강력한 수준의 격리 수준이다.
- 여러 트랜잭션이 병렬로 실행되어도 최종 결과는 한 번에 하나씩 직렬로 실행될 때와 같도록 보장한다. 따라서 데이터베이스는 트랜잭션을 개별적으로 실행할 때 정상적이라면 동시에 실행해도 올바르게 동작할 것을 보장해준다.
- 그러나 전부 사용하지는 않는다.
- 직렬성을 구현한 방법은? 분산 시스템에서 쓰는 법은 9장에서.
- 실제적인 직렬 실행(순차적 트랜잭션)
- 2단계 잠금 (2PL)
- 직렬성 스냅숏 격리 같은 낙관적 동시성 제어 기법
실제적인 직렬 실행 (순차적 트랜잭션)
- 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행
- 단일 스레드 실행이 가능하게 된 이유는?
- 램 가격이 저렴해져서 데이터셋을 전부 메모리에 올릴 수 있게 됨.
- OLTP 에서 는 읽기 수가 상대적으로 적음.
- 그러나 처리량은 CPU 코어 하나의 처리량으로 제한된다. 그래서 전통적인 방식보다는 다른 구조를 써야 한다. → 스토어드 프로시저
트랜잭션을 스토어드 프로시저 안에 캡슐화하기
- 초창기에는 데이터베이스 트랜잭션이 사용자 활동의 전체 흐름을 포함시키려는 의도가 있었다. 그러나 유휴 시간이 오래 걸려서 전부는 불가능하므로 유저 입력 없이 최대한 짧게 유지한다. 그럼에도 불구하고 애플리케이션과 데이터베이스 간에 통신을 반복하면서 상호작용하는 트랜잭션들이 실행된다. 그러나 이 통신 과정도 무겁다.
- 따라서 단일 스레드에서는 상호작용하는 다중 구문 트랜잭션을 허용하지 않는다. 대신, 이 트랜잭션 코드 전체를 스토어드 프로시저 형태로 미리 데이터베이스에 제출한다. 따라서 네트워크나 디스크 I/O 대기 없이 매우 빨리 실행된다.
스토어드 프로시저의 장단점
- 단점 :
- 데이터베이스 벤더마다 제각각의 스토어드 프로시저용 언어. 발전 느리고 라이브러리 구림.
- DB에서 실행되는 코드는 관리/디버깅/배포/테스트 어렵다.
- 데이터베이스는 애플리케이션 서버보다 훨씬 더 성능에 민감하다.
- 장점:
- 이제 스토어드 프로시저용 언어 버리고 범용 프로그래밍 언어를 사용함.
- 데이터가 메모리에 저장되고, 스토어드 프로시저가 있따면 모든 트랜잭션을 단일 스레드에서 실행할 정도로 처리량이 나온다. I/O 대기 필요 없고, 동시성 제어 메커니즘에서 발생하는 오버헤드를 피한다.
파티셔닝
- 모든 트랜잭션을 순차적으로 실행하면 간단해지지만, 트랜잭션 처리량이 단일 장비의 단일 CPU 코어의 속도로 제한된다. 읽기 전용 트랜잭션은 스냅숏 격리로 실행될 수 있지만 쓰기 처리량이 높으면 심각한 병목이 된다.
- 여러 CPU 코어와 여러 노드로 확장하기 위해 파티션 할 수도 있다. 각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 하고, 다른 파티션과 독립적으로 실행되는 트랜잭션 처리 스레드를 가지도록 한다.
- 그러나, 여러 파티션에 접근해야 하는 트랜잭션이 있다면 접근하는 모든 파티션에 걸쳐서 락을 걸고 직렬성을 보장하도록 만들어야 한다. 이는 엄청나게 느리다.
- 따라서 트랜잭션이 단일 파티션에서 실행되어야만 가능하고, 보조 색인이 여러개면 또 여러 파티션에 걸쳐서 코디네이션이 필요할 수 있다.
직렬 실행 요약
- 몇 가지 제약 사항 안에서 직렬성 격리를 획득하는 실용적인 방법이다.
- 모든 트랜잭션이 작고 빨라야 한다.
- 활성화된 데이터 셋이 메모리에 적제될 수 있어야 한다.
- 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 낮아야 한다.
- 여러 파티션에 걸친 트랜잭션을 쓸 수는 있지만 제한이 많다.
2단계 잠금(2PL)
- 순차적 트랜잭션은 최근 거고, 2단계 잠금(two phase locking)은 30년 동안 써왔다.
- 락을 더티 쓰기를 막을 때 자주 사용했었는데, 2단계 잠금은 요구사항이 더 강하다.
- 쓰기를 실행하는 트랜잭션이 없는 객체는 여러 트랜잭션에서 동시에 읽을 수 있다.
- 그러나 누군가 어떤 객체에 쓰려고 하면 독점적인 접근이 필요하다.
- 트랜잭션 A가 객체를 읽고 있고, 트랜잭션 B에서 쓰려고 하면 A를 기다려야 한다.
- 트랜잭션 B가 객체에 썼고, 트랜잭션 C가 그 객체를 읽으려면 B를 기다려야 한다.
- 2PL 에서 쓰기 트랜잭션은 모든 트랜잭션을 다 막아버리고, 모든 트랜잭션도 쓰기 트랜잭션을 막는다.
- 스냅숏 격리에서는 읽는 것과 쓰는 것이 서로 막지 않고 충돌을 해소했는데 그 차이다.
- 따라서 직렬성을 제공하므로 갱신 손실과 쓰기 스큐를 포함한 경쟁 조건으로부터 보호한다.
2단계 잠금 구현
- 2PL은 MySQL 에서 직렬성 격리 수준을 구현하는데 사용되고 DB2에서는 반복 읽기로 사용된다.
- 읽는 쪽과 쓰는 쪽을 막는 것은 각 객체에 잠금을 사용해 구현한다. 잠금은 공유 모드(Shared Lock) 나 독점 모드(Exclusive Lock)로 사용될 수 있다.
- 잠금이 사용되는 방법
- 트랜잭션에서 객체를 읽으려면 SLock 을 획득해야 한다. 여러 트랜잭션이 SLock 을 획득하는 것은 허용되지만, XLock 이 있다면 기다려야 한다.
- 트랜잭션에서 쓰기를 실행하려면 XLock 으로 업그레이드 해야 한다.
- 트랜잭션에서 잠금을 획득한 후에는 종료될 때까지 잠가야 한다. 따라서 첫 번째는 잠금을 획득할 때, 두번째는 잠금을 해제할 때이므로 2단계 잠금이다.
- 잠금이 아주 많이 사용되므로 트랜잭션 A가 B를 기다리고 B가 A를 기다리는 경우(교착상태)가 많이 발생한다. 데이터베이스는 교착 상태를 자동으로 감지하고 하나를 어보트시켜서 재시도하도록 만들어야 한다. (? 타임아웃으로?)
2단계 잠금의 성능
- 성능이 안 좋다. 완화된 격리 수준(커밋 후 격리, 스냅숏 분리) 보다 트랜잭션 처리량과 질의 응답 시간이 크게 나빠진다.
- 부분적으로는 잠금을 획득하고 해제하는 오버헤드가 있지만 더 큰 이유는 동시성이 확 줄어든다. 경쟁 조건을 유발하는 일이 있으면 무조건 기다려야 한다.
- 따라서 여러 트랜잭션이 같은 객체에 접근하려고 하면 대기열이 생겨서 다 기다려야 한다.
- 따라서 2PL 의 경우 지연 시간이 아주 불안정하고 높은 백분위에서 많이 느릴 수 있다. 2PL 을 쓰면 교착 상태가 잦아지고, 잦으면 재시도해야 하고 성능은 더 안 좋아진다.
서술 잠금
- 우리가 왜 여기까지 왔는가? 팬텀 문제(한 트랜잭션이 다른 트랜잭션의 전제조건, 검색 질의 결과를 바꾸는 문제) 를 해결하고자 직렬성으로 해결하려고 했다.
- 그러나 마찬가지로 직렬성 격리를 쓰는 데이터베이스는 팬텀을 막아야한다.
- “쓰기 스큐를 유발하는 팬텀” 에서 팬텀 문제,
- 직렬성 격리를 쓰는 데이터베이스는 팬텀을 막아야 한다.
- 회의실 예시를 들어보면, 한 트랜잭션이 특정 시간 범위의 회의실을 검색했다면 다른 트랜잭션이 동일한 회의실에 삽입하거나 갱신할 수 없다. 이런 경우 서술 잠금(predicate lock)이 필요하다.
- SLock, XLock 과 유사하지만, PLock 은 특정 객체에 속하지 않고 검색 조건에 부합하는 모든 객체에 속한다.
- PLock 의 접근 제한 방법은 다음과 같다.
- 트랜잭션에서 객체를 읽으려면 Shared PLock 을 획득해야 한다. 다른 트랜잭션이XLock 을 가지고 있으면 기다려야 한다.
- 트랜잭션이 객체를 삽입하려고 하면 기존의 PLock 이 끝날 떄까지 기다려야 한다.
- 여기서 핵심 아이디어는, PLock은 아직 존재하지 않지만 미래에 추가될 수 있는 객체(팬텀)에도 적용할 수 있다.
- 2PL이 PLock도 포함하면 데이터베이스에서 모든 형태의 쓰기 스큐와 다른 경쟁 조건을 막을 수 있어서 직렬성 격리가 가능하다.
색인 범위 잠금
- 그러나 PLock 은 잘 동작하지 않는다. 다른 트랜잭션들이 획득한 잠금이 많으면 조건에 부합하는 락을 얻기에 오래 걸린다
- 따라서 대부분 데이터는 실제로는 색인 범위 잠금(index-range, next-key locking) 을 구현한다.
- 더 많은 객체가 부합하도록 서술 조건을 간략화 하는 것은 안전하다.
- 정오와 오후 1시 사이에 123번 방 예약에 대한 Plock 은 모든 시간의 123번 Plock 또는 정오와 오후 1시 사이에 모든 방을 PLock 을 하는 것으로 근사할 수 있다.
- room_id, start_time, end_time 에 색인이 있을테니, 그 중 하나를 쓰기 위한 것이다.
- 이 방법을 쓰면 PLock 보다 정밀하지 않지만, 오버헤드가 낮아서 좋은 타협안이 된다. 만약 적합한 색인이 없으면 테이블 전체를 SLock 할 수 있다. 물론 성능은 좋지 않다.
직렬성 스냅숏 격리(SSI)
- 동시성 제어를 하는 방법
- 성능이 좋지만 다양한 경쟁 조건(갱신 손실, 쓰기 스큐, 팬텀 등)에 취약한 완화된 격리 수준(커밋 후 읽기 스냅숏 격리)
- 성능이 좋지 않거나(2PL) 확장이 잘 되지 않는 직렬성 구현(직렬 실행)
- 직렬성 격리와 좋은 성능을 공존하려면 어떻게 해야 할까? 직렬성 스냅숏 격리(Serializable snapshot isolation) 을 사용하자.
- SSI는 단일 노드 데이터베이스와 분산 데이터베이스 모두에서 사용된다.
비관적 동시성 제어 대 낙관적 동시성 제어
- 2PL은 비관적 동시성 제어 메커니즘이다. 뭔가 잘못될 가능성이 있으면 무조건 기다려라! 다중 스레드 프로그래밍에서 상호 배제(공유 자원을 동시에 접근 X)와 비슷하다.
- 직렬 실행은 극단적으로 비관적이다. 전체 데이터 베이스에 독점 잠금을 획득하고, 빠르게 실행되게 해서 잠금 유지 시간을 줄이는 방법으로 비관주의를 보완한다
- 그 반대로, 직렬성 스냅숏 격리는 낙관적 동시성 제어 기법이다. 우선 동시에 진행하고, 커밋되기를 원할 때 격리가 위반되었다면 어보트하고 재시도한다.
- 낙관적 동시성 제어 방법의 경우, 트랜잭션의 동일 객체 접근이 너무 많으면 어보트할 트랜잭션의 비율이 높아지므로 성능이 떨어진다.
- 그러나 트랜잭션 사이의 경쟁이 너무 심하지 않으면 비관적 동시성 제어보다 성능이 좋다.
- 경쟁은 최대한 원자적 연산을 써서 줄이자.
- SSI는 스냅숏 격리를 기반으로 해서 일관된 스냅숏을 보게 되며, 쓰기 작업 사이의 직렬성 충돌을 감지하고 어보트시킬 트랜잭션을 결정하는 알고리즘을 추가한다.
뒤쳐진 전제에 기반한 결정
- 스냅숏 격리에서 나타나는 쓰기 스큐 현상에서, R-M-W 하려고 할 때 질의 결과를 조사한 후 관찰한 결과를 기반으로 어떤 동작을 취할지 결정한다. 즉, R과 W 사이에 인과적 의존성이 있다.
- 그러나 스냅숏 격리하에서 W하려고 하는 순간 질의 결과가 최신이 아닐 수 있다.
- 이 경우를 뒤처진 전제라는 상황으로 감지하고 어보트시켜야 한다.
- 어떻게 뒤처졌는지 알 수 있을까?
- 오래된 MVCC 객체 버젼을 읽었는지 감시하기(읽기 전에 커밋되지 않은 쓰기가 발생함)
- 과거의 읽기에 영향을 미치는 쓰기 감지하기(읽은 후에 쓰기가 실행됨)
오래된 MVCC 읽기 감지하기
- 스냅숏 격리는 다중 버전 동시성 제어(MVCC)로 구현한다. 원래 같으면 43은 42의 변경사항을 조회할 수 없지만, 이상 현상을 막기 위해서는 현재 진행중인 트랜잭션을 조회해야 한다.
- 즉, 아직 커밋되지 않은 트랜잭션 42의 실행 결과를 트랜잭션 43이 확인하고 커밋될 때까지 기다린 다음에 커밋된다면 어보트한다.
- 물론 읽기 전용이면 어보트하지 않아도 되고, 읽는 시점에 트랜잭션 42가 쓰기 연산을 할지, 커밋될지 어보트될지 모르니까 우선 진행하고 기다리는 것이다.
과거의 읽기에 영향을 미치는 쓰기 감지하기
- 데이터를 읽은 후 다른 트랜잭션에서 그 데이터를 변경할 때 문제가 생긴다.
- 2PL 에서 색인 범위 잠금을 쓰는 것처럼, 42와 43은 모두 동일한 색인 항목을 이용해 데이터를 읽는다. 읽은 사실을 기록해두고, 트랜잭션이 데이터베이스에 쓸 때 영향 받는 데이터를 최근에 읽은 트랜잭션이 있는지 색인에서 확인한다.
- 이 과정은 키 범위에 쓰기 락을 획득하는 것과 비슷하지만 읽는 쪽에서 커밋될 때까지 차단하지 않는다. 트랜잭션이 읽은 데이터가 더 이상 최신이 아니라는 것을 알려줄 뿐이다.
- 43은 42에게 전에 읽은 데이터가 뒤쳐졌다고 알려주고 42도 43에게 알려준다. 그리고 42가 먼저 커밋을 시도해서 성공한다. 43은 42에 영향을 주지만 아직 효과가 없다. 그러나, 43이 커밋하려고 할 때는 42이 커밋되었으므로 43은 어보트되어야 한다.
직렬성 스냅숏 격리의 성능
- 트랜잭션의 읽기 쓰기를 추적하는 걸 상세하게 하면 기록 오버헤드가 심해지고,덜 상세하게 추적하면 빠르지만 지나치게 많은 트랜잭션이 어보트될 수 있다.
- 어떤 경우에는 다른 트랜잭션에서 덮어쓴 정보를 읽어도 괜찮다. 어떤 일이 있었는지에 따라 때로는 덮어씌워졌어도 결과가 직렬적이라는 것을 증명하는 게 가능하다(?)
- 2PL 과 비교할 때 직렬성 스냅숏 격리의 이점은 다른 트랜잭션들이 잡고 있는 락을 기다릴 필요가 없다. 스냅숏 격리와 마찬가지로 쓰는 쪽과 읽는 쪽이 서로 막지 않는다. 특히 읽기 전용 질의는 어떤 락도 없이 일관된 스냅숏 위에서 실행될 수 있다.
- 순차 실행과 비교할 때 직렬성 스냅숏 격리는 단일 CPU 코어의 처리량에 제한되지 않는다. 여러 장비로 분산시킬 수 있고, 파티셔닝 되어 있어도 직렬성을 보장할 수 있다.
- 어보트 비율이 SSI 전체의 성능에 큰 영향을 미친다. 이를테면 오랜 시간 동안 데이터를 읽고 쓰는 트랜잭션은 충돌나고 어보트되기 쉬워서 쓰기 트랜잭션을 짧도록 요구한다. 그러나 2PL과 순차 실행보다는 덜 민감하다.
정리
- 트랜잭션은 동시성 문제와 결함이 존재하지 않는 것 처럼 동작할 수 있게 도와주는 추상층이다.
- 많은 종류의 오류가 트랜잭션 어보트로 줄어들고, 어플리케이션은 재시도만 하면 된다.
- 모든 애플리케이션이 이런 문제에 민감하지는 않지만, 패턴이 복잡해지면 잠재적인 오류 상황의 수를 줄여줄 수 있다. 또한 비정규화된 데이터는 원천 데이터와 동기화가 깨지기 쉽다. 트랜잭션이 없다면 복잡한 상호작용을 하는 접근이 데이터베이스에 미치는 영향을 따지기가 매우 어렵다.
-
격리 수준, 커밋 후 읽기, 스냅숏 격리(반복 읽기), 직렬성 격리를 살펴봤다.
- 동시성 관련 문제들
- 더티 읽기 : 다른 클라이언트가 썼지만 아직 커밋되지 않은 데이터를 읽는 것. 커밋 후 읽기를 통해 해결한다.
- 더티 쓰기 : 다른 클라이언트가 썼지만 아직 커밋되지 않은 데이터를 덮어쓴다. 거의 모든 트랜잭션을 구현해 더티 쓰기를 방지한다.
- 읽기 스큐(비반복 읽기): 클라이언트는 다른 시점에 다른 데이터베이스의 다른 부분을 본다. 이 문제를 막기 위해 트랜잭션이 어느 시점의 일관된 스냅숏을 읽도록 한다. 스냅숏 격리는 다중 버전 동시성 제어를 이용해 구현한다.
- 갱신 손실: 두 트랜잭션에서 R-M-W를 동시에 실핸하면 다른 트랜잭션의 변경을 포함하지 않은 채로 덮어써서 손실된다. 스냅숏 격리 중에서 이걸 자동으로 막아주거나, 수동 잠금(SELECT FOR UPDATE)을 해야 한다.
- 쓰기 스큐: 트랜잭션이 무언가를 읽은 값을 기반으로 결정하고 그 결정을 데이터베이스에 쓴다. 그러나 쓰기 시점에 결정의 전제가 참이 아니다. 직렬성 격리로 이 이상 현상을 해결한다.
- 팬텀 읽기: 한 트랜잭션이 어떤 검색 조건에 부합하는 객체를 읽는다. 이 때 다른 트랜잭션이 그 검색 결과에 영향을 주는 쓰기를 실행하는 경우. 색인 잠금을 이용해 해결한다.
완화된 격리 수준은 이런 현상 중 일부를 막아주지만, 명시적인 잠금으로 해결해야 한다. 직렬성 격리만 이 모든 문제들로부터 보호해준다. 직렬성 구현을 하는 3가지 방법은 아래와 같다.
- 순차 실행: 단일 CPU 코어에서 스토어드 프로시저로 실행.
- 2단계 잠금 : 수십년동안 직렬성을 구현하는 표준. SLock과 XLock 이 서로를 막는다.
- 직렬성 스냅숏 격리: 낙관적 방법을 사용해서 트랜잭션이 차단되지 않으나, 어보트가 많으면 성능 이슈가 생김.
단일 데이터베이스에서 쓰는 트랜잭션을 살펴봤다. 다음 두 장은 분산 데이터베이스에서 마주하는 새로운 문제들을 기대하자.
Subscribe via RSS