데이터 시스템은 여러 가지 문제 (비정상적인 오류나 실패)가 일어날 수 있다.
이러한 문제를 단순화한 메커니즘으로 트랜잭션이 채택돼 왔다.
이를 당연한 것으로 여기면 안된다. 트랜잭션은 자연 법칙이 아니며, 데이터베이스에 접속하는 애플리케이션에서 프로그래밍 모델을 단순화하려는 목적으로 만들었다. 트랜잭션을 통해 잠재적인 오류 시나리오와 동시성 문제를 무시할 수 있다.
이번 장에서는 문제가 생길 수 있는 예를 조사하고, 이런 문제를 방지하기 위한 데이터베이스 알고리즘을 살펴본다.
ACID는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질이다.
데이터베이스는 전부 반영되거나 아무것도 반영되지 않는 것을 보장하는 것으로써 원자성을 통해 부분 갱신으로 더 큰 문제가 야기되는 것을 방지할 수 있다.
트랜잭션이 실행을 성공적으로 완료하면 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 말한다.
일관성의 아이디어는 항상 진실이어야 하는, 데이터에 관한 어떤 선언(불변식)이 있다는 것이다.
일관성(C)은 실제로는 ACID에 속하지 않고 애플리케이션의 속성으로 본다.
데이터베이스 자체만으로 불변식을 위반하는 잘못된 데이터를 쓰지 못하도록 막을 수 없기 때문이다.
이러한 것은 애플리케이션의 책임으로 보고 일관성을 달성하기 위해 데이터베이스의 원자성과 격리성 속성에 기댈 수 있다.
동시에 실행되는 트랜잭션은 서로 격리되어 방해할 수 없는 것을 의미한다. 트랜잭션은 다른 트랜잭션을 방해할 수 없다.
한 트랜잭션이 여러 번 쓴다면 다른 트랜잭션은 그 내용을 전부 볼 수 있든지 아무것도 볼수 없든지 둘 중 하나여야 하고 일부분만 볼 수 있어서는 안된다.
데이터베이스는 실제로 여러 트랜잭션이 동시에 실행되더라도 트랜잭션이 커밋됐을 때의 결과가 트랜잭션이 순차적으로 실행됐을 떄의 결과와 동일하도록 보장한다.
그러나 직렬성 격리는 성능 손해를 동반하므로, 거의 사용되지 않는다.
데이터베이스 시스템의 목적은 데이터를 잃어버릴 염려가 없는 안전한 저장소를 제공하는 것이다.
지속성은 트랜잭션이 성공적으로 커밋됐다면 하드웨어 결함이 발생하거나 데이터베이스가 죽더라도 트랜잭션에서 기록한 모든 데이터는 손실되지 않는다는 보장이다.
오라클, MySQL의 경우 REDO 로그(쓰기 전 로그)를 활용하여 지속성을 구현했다.
트랜잭션의 핵심 기능이다. 오류가 생기면 어보트되고 안전하게 재시도할 수 있다. ACID는 이 철학을 바탕으로 한다.
하지만, 모든 시스템이 이를 따르지는 않으며, 리더 없는 복제는 데이터스토어는 "최선을 다하는"(best-effort) 원칙으로, 오류가 발생하면 이미 한 일은 취소하지 않는다. 애플리케이션에서 오류 처리를 해야 한다.
Rails의 액티브레코드, Django ORM 등은 어보트된 트랜잭션을 재시도하지 않는다.
어보트된 트랜잭션을 재시도하는 것은 간단하고 효과적인 오류 처리 메커니즘이지만 완벽하지 않다.
가장 기본적인 수준의 트랜잭션 격리로 이 수준에서는 두 가지를 보장해 준다.
데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다(더티 읽기가 없음)
데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다(더티 쓰기가 없음)
더티 읽기(dirty read) : 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상
그림 7-2. 격리성 위반: 트랜잭션이 다른 트랜잭션에서 썼지만 커밋되지 않는 데이터를 읽음"(더티 읽기(dirty read))"
더티 읽기를 막는게 유용한 이유
더티 쓰기(dirty write) : 두 트랜잭션이 동일한 객체를 동시에 갱신하려고 할 때, 먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션에서 쓴 것이고 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮는 경우
그림 7-5. 다른 트랜잭션에서 충돌하는 쓰기를 실행할 때 더티 쓰기가 있으면 내용이 섞일 수 있다.
위 예시를 보면, 밥에게 판매 됐지만, 송장은 앨리스에게 간다.
커밋 후 읽기는 Oracle 11g, PostgreSQL, SQL Server 2012, MemSQL 등에서 기본 설정으로 쓰고 있는 격리 수준이며, 더티 쓰기와 더티 읽기를 방지한다.
더티 쓰기 방지 : 트랜잭션이 커밋되거나 어보트될 때까지 잠금을 보유한다. 이런 잠금은 커밋 후 읽기 모드에서 데이터베이스에 의해 자동으로 실행된다.
더티 읽기 방지: 과거의 커밋된 값/ 현재 쓰고 있는 새로운 값을 모두 기억하게 하여 해당 트랜잭션이 실행 중인 동안 과거의 값을 읽게하여 더티 읽기를 방지 할 수 있다.
더티 읽기도 잠금을 통해 할 수 있으나, 성능 상 문제 때문에, 위 방식을 사용한다.
그림 7-6. 읽기 스큐: 앨리스는 일관성이 깨진 상태인 데이터베이스를 본다. (본인 계좌1에서 계좌2로 100달러 옮기는 예시)
위 경우, 엘리스는 1000달러가 들어있으어야하는 잔고에, 현재 계좌 총액이 500 + 400으로 총 900 달러만 있는 것처럼 보인다.
커밋 후 읽기 격리 수준에서도 동시성 버그가 생길 수 있으며 이런 현상을 비반복 읽기(nonrepeatable read)나 읽기 스큐(read skew)라고 한다.
위와 같은 경우 몇 초 후 새로고침하면 일관성 있는 계좌를 볼 수 있으나 어떤 상황에서는 이런 비일관성을 감내할 수 없는 경우도 있다.
따라서, 스냅숏 격리를 통해 해결한다.
성능 관점에서 읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다. 잠금이 필요없다.
다중 버전 동시성 제어(multi-version concurrency control, MVCC) : 데이터베이스가 객체의 여러 버전을 함께 유지하는 기법
그림. MySQL MVCC 동작 방식
트랜잭션 ID가 더 큰(즉 현재 트랜잭션이 시작한 후에 시작한) 트랜잭션이 쓴 데이터는 그 트랜잭션의 커밋 여부에 관계 없이 모두 무시된다
아래 두 조건이 참이면, 객체를 볼 수 있다.
다중 버전 데이터베이스에서 색인의 동작
색인이 객체의 모든 버전을 가리키게 하고 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내고, 가비지 컬렉션이 어떤 트랜잭션에게도 더 이상 보이지 않는 오래된 객체 버전을 삭제 할때 대응되는 색인 항목도 삭제
스냅숏 격리는 읽기 전용 트랜잭션에서 유용하며, SQL 표준에 스냅숏 격리의 개념이 없기 때문에 여러 데이터베이스에서 다른 이름으로 불린다.
Oracle: 직렬성(Serializable)
PostgreSQL, MySQL: 반복 읽기(Repeatable Read)
지금까지 커밋 후 읽기와 스냅숏 격리 수준은 동시에 실행되는 쓰기 작업이 있을 때 읽기 전용 트랜잭션이 무엇을 볼 수 있는지에 대한 보장과 관련된 것이었다.
두 트랜잭션이 동시에 쓰기를 실행할 때의 문제는 거의 무시했다.
만약 두 트랜잭션이 작업을 동시에 하면 두번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 변경 중 하나는 손실될 수 있음
이 패턴은 다양하게 발생한다.
이 문제들을 해결하기 위해 아래와 같은 해결책들이 개발되었다.
1. 원자적 쓰기 연산
2. 명시적인 잠금
3. 갱신 손실 자동 감지
4. Compare-and-set
5. 충돌 해소와 복제
예제 7-1. 로우를 명시적으로 잠금으로써 갱신 손실 막기
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE; (1)
-- 이동이 유효한지 확인한 후
-- 이전의 SELECT에서 반환된 것의 위치를 갱신한다.
UPDATE figures SET position = '4' WHERE id = 1234;
COMMIT;
여러 트랜잭션의 병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 abort 시키고, 재시도하도록 강제하는 방법
MySQL은 이를 제공하지 않는다.
갱신 손실 감지는 애플리케이션 코드에서 잠금이나 원자적 연산을 쓰는 것을 잊어버리더라도 오류를 덜 발생하게 해준다.
값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것
예를 들어, 두 사용자가 노션 페이지를 갱신하지 못하도록 이런 방법을 시도할 수 있다. 사용자가 페이지 편집을 시작한 이후로 내용이 바뀌지 않았을 때만 갱신되게 하는 것이다.
-- 데이터베이스 구현에 따라 안전할 수도 안전하지 않을 수도 있다
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
내용이 바뀌어서 더는 'old content'와 일치하지 않으면 갱신이 적용되지 않는 것이다.
보통 최종 쓰기 승리 (last write wins, LWW)를 사용해 갱신 손실이 일어난다.
거의 동시에 두 트랜잭션이 시작되었다고 가정
데이터베이스에서 스냅숏 격리를 사용하므로 둘 다 2를 반환해서 두 트랜잭션 모두 다음 단계로 진행함
최소 한 명의 의사가 호출 대기해야 한다는 요구사항 위반
이러한 현상을 쓰기 스큐 (wirte skew) 라고 함
1. SELECT 질의가 어떤 검색 조건에 부합하는 로우를 검색함으로써 어떤 요구사항을 만족하는지 확인
2. 첫 번째 질의의 결과에 따라 애플리케이션 코드는 어떻게 진행할지 결정
3. 애플리케이션이 계속 처리하기로 결정했다면 데이터베이스에 쓰고 트랜잭션을 커밋한다. 이 쓰기의 효과로 2단계를 결정한 전제조건이 바뀐다.
어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 것을 팬텀(Phantom) 이라고 함
INSERT 시에는 잠금을 적용하지 못한다. 이는 최초의 select 시 잠글 수 있는 객체가 없기 때문이었다.
1. 인위적으로 데이터베이스에 잠금 객체를 추가하자
2. 대상 row 를 미리 만들고 lock 을 건다. 트랜잭션 대상이 되는 특정 범위의 모든 조합에 대해 미리 row 를 만들어 둠 (ex, 회의실 예약의 경우 다음 6개월 동안에 해당되는 양)
3. 예약을 하는 트랜잭션은 테이블에서 원하는 대상 row 를 잠글 수 있음 (위에서 미리 생성했기 때문에)
이렇게 미리 만들어 놓는 방식을 충돌 구체화라고 한다.
생성된 row 는 단지 동시에 변경되는 것을 막기 위한 잠금의 모음일 뿐이다. 실제 사용되는 데이터가 아니다.
단점은 동시성 제어 메커니즘이 애플리케이션 데이터모델로 새어 나오는 것은 보기 좋지 않다. 다른 대안이 불가능할 때 최후의 수단으로 고려
DB의 동시성을 관리하는 방식의 문제점
대안은 직렬성 격리 사용이다. 직렬성 격리는 보통 가장 강력한 격리 수준이라고 여겨진다.
여러 트랜잭션이 병렬로 실행되더라도, 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장
실제로 볼드DB/H-스토어, 레디스, 데이토믹의 경우는 한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행하고, 이는 잠금을 위한 오버헤드를 피할 수 있다. 문제는 CPU 코어 하나의 처리량 제한이라는 성능적인 문제가 있다.
그러나 이런 문제는 범용 프로그래밍 언어를 사용하여 극복할 수 있다.
볼트 DB -> Java, Groovy
레디스 -> Lua Script
각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 파티셔닝 할 수 있다면, 각 파티션은 다른 파티션과 독립적으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있다. 이 경우 각 CPU 코어에 각자의 파티션을 할당해서 트랜잭션 처리량을 CPU 코어 개수에 맞춰 선형적으로 확장할 수 있다.
그러나 여러 파티션에 접근해야 하는 트랜잭션이 있다면, 코디네이션 오버헤드가 있으므로 단일 파티션 트랜잭션보다 엄청 느리다
트랜잭션 직렬 실행은 몇 가지 제약 사항 안에서 직렬성 격리를 획득하는 시용적인 방법이 됐음
쓰기를 실행하는 트랜잭션이 없는 객체는 여러 트랜잭션에서 동시에 읽을 수 있다. 하지만, 누군가 어떤 객체에 쓰려고 하면 독점적 접근이 필요하다.
트랜잭션 A가 객체 하나를 읽고 트랜잭션 B가 그 객체에 쓰기를 원한다면 B는 진행하기 전에 A가 커밋되거나 어보트될 때까지 기다려야 한다(이렇게 하면 B가 A 몰래 갑자기 객체를 변경하지 못하도록 보장된다).
트랜잭션 A가 객체에 썼고 트랜잭션 B가 그 객체를 읽기 원한다면 B는 진행하기 전에 A가 커밋되거나 어보트될 때까지 기다려야 한다(그림 7-4에 나왔듯이 2PL을 쓸 때는 객체의 과거 버전을 읽는 게 허용되지 않는다).
조건에 부합하는 모든 객체에 잠금을 획득하는 것
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2018-01-01 12:00' AND
start_time < '2018-01-01 13:00'
서술 잠금은 오래 걸림 (조건에 부합하는 잠금을 확인하는 데 시간이 오래 걸림)
이 때문에 2PL 을 지원하는 대부분의 데이터베이스는 실제로는 색인 범위 잠금, 다음 키 잠금을 구현하여 사용함
2단계 잠금은 비관적 동시성 제어 메커니즘임
직렬성 스냅숏 격리는 낙관적 동시성 제어 메커니즘임
그림 7-10. 트랜잭션이 MVCC 스냅숏에서 뒤처지 값을 읽었는지 감지하기
트랜잭션 43이 커밋하기 원할 때 트랜잭션 42가 이미 커밋된 상태이고, 무시됐던 쓰기가 지금은 영향이 있고 트랜잭션 43의 전제가 더이상 참이 아니라는 뜻이다.
그림 7-11. 직렬성 스냅숏 격리에서 트랜잭션이 다른 읽은 데이터를 변경하는 경우를 감지하기