Data Engineering
[데이터 중심 어플리케이션 설계] 07장. 트랜잭션
minjiwoo
2025. 2. 15. 17:50
728x90
완화된 격리 수준
- 데이터베이스는 트랜잭션 격리를 제공함으로써, 동시성 문제를 감추려고 함. → 어플리케이션 개발자들의 부담을 줄여줌.
- 직렬성 격리 : 데이터베이스가 여러 트랜잭션들이 직렬적으로 실행되는 것과 동일한 결과가 나오도록 보장한다는 것을 의미함.
- 심지어 RDBMS 에서도 완화된 격리성을 사용하는 경우도 많음. 이런 버그 발생을 반드시 막아주지는 않음.
커밋 후 읽기 (read commited)
가장 기본적인 수준의 트랜잭션 격리로 이 수준에서는 두 가지를 보장해 준다.
- 데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다(더티 읽기가 없음)
- 데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다(더티 쓰기가 없음)
더티 읽기 방지
더티 읽기(dirty read) : 어떤 트랜잭션에서 처리한 작업이 커밋되지 않았는데도 다른 트랜잭션에서 볼 수 있는 현상
- dirty read 가 있으면 생기면 다른 트랜잭션이 일부는 갱신된 값을, 일부는 갱신되지 않은 값을 볼 수 있다.
- ex) 읽지 않은 새 이메일은 볼 수 있지만 갱신된 개수는 볼 수 없다.
- 트랜잭션이 abort 되면 모두 롤백되어야 하나, 더티 읽기를 허용하면 트랜잭션이 나중에 롤백될 데이터를 볼 수 있다. → 결과가 혼란스러움.
더티 쓰기 방지
- dirty write : 두 트랜잭션이 동일한 객체를 동시에 갱신하려고 할 때, 먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션에서 쓴 것이고 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮는 경우 (내용이 섞일 수 있음)
- 그림 7-5 : 구매자는 밥인데 수신자는 앨리스가 되어버림.
커밋 후 읽기 구현
- Oracle 11g, PostgreSQL, SQL Server 2012, MemSQL 에서 기본적으로 쓰고 있는 격리 수준
- 더티쓰기 방지 : 트랜잭션이 커밋되거나 어보트될 때까지 잠금을 보유한다. 이런 잠금은 커밋 후 읽기 모드에서 데이터베이스에 의해 자동으로 실행된다.
- 더티 읽기 방지: 과거의 커밋된 값/ 현재 쓰고 있는 새로운 값을 모두 기억하게 하여 해당 트랜잭션이 실행 중인 동안 과거의 값을 읽게하여 더티 읽기를 방지 할 수 있다.
스냅숏 격리와 반복 읽기
- 커밋 후 읽기 격리 수준에서도 동시성 버그가 생길 수 있으며 이런 현상을 비반복 읽기(nonrepeatable read)나 읽기 스큐(read skew)라고 한다.
- 읽기 스큐 : 일관성이 깨진 상태에서 데이터베이스를 read 작업하는 경우. 커밋 후 읽기 격리 수준에서도 동시성 버그가 생길 수 있다.
- 몇 초 후 새로고침하면 일관성 있는 계좌를 볼 수 있는 정도도 있으나, 어떤 상황에서는 이런 비일관성을 감내할 수 없는 경우도 있다.
- 백업 스냅샷의 차이
- 스냅숏 격리 : 각 트랜잭션을 데이터베이스의 일관된 스냅숏으로부터 읽는 구현
스냅숏 격리 구현
- 다중 버전 동시성 제어 (multi-version concurrency control, MVCC)
하나의 테이블 내에서 동일한 데이터(객체)의 여러 버전을 유지하는 방식.
→ 읽기(SELECT), 쓰기(INSERT/UPDATE/DELETE)가 서로 락 없이 병렬로 처리될 수 있다.
- 커밋 후 읽기와 차이?
→ 커밋 후 읽기는 질의마다 독립된 스냅숏을 사용하고 스냅숏 격리는 전체 트랜잭션에 대해 동일한 스냅숏을 사용한다는 차이이다.
색인과 스냅숏 격리
다중 버전 데이터베이스에서, 색인의 동작 방식은 ?
- 객체의 모든 버전을 가리키게 하고, 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 한다.
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
price NUMERIC
);
CREATE INDEX price_idx ON products(price);
- 업데이트 시나리오 1 : 같은 페이지 내 업데이트
- 데이터가 업데이트 되었을 때, 새로운 버전의 튜플이 같은 페이지에 저장된다면 price_idx 는 갱신되지 않는다.
- 업데이트 시나리오 2: 다른 페이지로 이동하는 경우
- 만약 페이지에 공간이 부족해서 새 페이지로 이동하게 되면, 인덱스도 갱신 → 추가적인 비용 발생
PostgreSQL MVCC(Multi-Version Concurrency Control)
- 동일한 객체의 다른 버전들이 같은 페이지(page)에 저장될 수 있다면 색인 갱신을 회피하는 최적화를 실행
- 쉽게 말해서, PostgreSQL은 데이터를 업데이트할 때마다 새 버전을 저장한다. 그리고 새 버전이 같은 페이지에 저장될 수 있다면, PostgreSQL은 색인(Index) 갱신을 생략하여 성능을 최적화한다.
- pg 는 데이터를 8KB 의 페이지 단위로 디스크에 저장한다.
일관된 스냅숏을 보는 가시성 규칙
트랜잭션은 데이터베이스에서 객체를 읽을 때 트랜잭션 ID를 사용해 어떤 것을 볼 수 있고 어떤 것을 볼 수 없는지 결정한다. 가시성 규칙을 정의해서 데이터베이스의 일관된 스냅숏을 제공할 수 있다.
- 트랜잭션 ID가 더 큰(즉 현재 트랜잭션이 시작한 후에 시작한) 트랜잭션이 쓴 데이터는 그 트랜잭션의 커밋 여부에 관계 없이 모두 무시된다.
- 어보트 된 트랜잭션이 쓴 데이터는 모두 무시.
- 그 밖의 데이터는 쿼리 가능함.
갱신 손실 방지
- 커밋 후 읽기와 스냅숏 격리 수준은 주로 동시에 진행되는 쓰기 작업이 있을 때 읽기 트랜잭션을 할 때의 격리성 보장과 관련있었음.
- 동시에 실행되는 쓰기 트랜잭션이 있을 때, 갱신 손실 문제가 있다.
- read-modify-write : 두번째 쓰기 작업이 첫번째 변경을 포함하지 않아서 변경 중 하나가 손실 될 수 있다.
트랜잭션이 작업을 동시에 하면 두번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 변경 중 하나는 손실될 수 있음
해결책
- 원자적 쓰기 연산
- 명시적인 잠금
- 갱신 손실 자동 감지
- Compare-and-set
- 충돌 해소와 복제
1. 원자적 쓰기 연산
- 원자적 연산은 보통 exclusive lock 을 획득하여 구현한다. (갱신이 적용될 때까지, 다른 트랜잭션에서 그 객체를 읽지 못하게 함. 커서 안전성)
- 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 방법으로도 구현할 수 있다.
2. 명시적인 잠금
- 어플리케이션에서 갱신할 객체를 명시적으로 잠근다.
- 데이터베이스 질의로는 합리적으로 구현할 수 없는 로직이 포함 될 수도 있음. 이런 경우 사용.
3. 갱신 손실 자동 감지
- 여러 트랜잭션의 병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 abort 시키고, 재시도 하도록 강제하는 방법 → ? 근데 재시도 까지도 자동이라는 것인지 ?
4. Compare and set
- 값을 마지막으로 읽은 후로 변경되지 않았을 때만 (비교를 통해 확인해서 compare라고 하는 듯) 갱신을 허용 (새로운 값을 SET 함.)함으로써 갱신 손실을 회피하는 것
UPDATE wiki_pages SET content = 'new'
WHERE id = 1234 AND content = 'old';
5. 충돌 해소와 복제
- 잠금 + compare and set
쓰기 스큐 와 팬텀
- 거의 동시에 두 트랜잭션이 시작되었다고 가정.
- 최소 한명 이상의 의사가 호출 대기해야 한다는 요구사항을 위반함. → write skew
- 쓰기 스큐는 두 트랜잭션이 같은 객체들을 읽어서 그 중 일부를 갱신할 때 나타날 수 있음
- 팬텀 (Phantom) : 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 것
쓰기 스큐를 특정 짓기
쓰기 스큐 : 두 트랜잭션이 동시에 두개의 각자 다른 객체를 갱신하는 경우 쓰기 스큐에 해당한다. 이는 더티쓰기도 아니고 갱신 손실에도 해당하지 않는다. 책의 예시 (밥과 앨리스 호출대기 상태 각자 동시에 끄기) 에서, 두 트랜잭션이 한번에 하나씩 실행되었다면 두번째 의사인 밥이 호출대기를 끄는게 방지되어 문제를 예방할 수 있었을 것이다. 그러나 두 트랜잭션에 동시에 실행되어서 이상 동작이 일어났다.
- 두 트랜잭션이 같은 객체들을 읽어서 그중 일부를 갱신할 때 나타날 수 있음.
- 다른 트랜잭션이 하나의 동일한 객체를 갱신하는 경우에 더티쓰기나 갱신 손실 이상 현상을 겪는다.
- 직렬성 격리 수준을 사용할 수 없다면, 트랜잭션이 의존하는 로우를 명시적으로 잠그는 것이 차선책이다.
예시로 아래 쿼리처럼 on_call 을 잠글 수 있다.
BEGIN TRANSACTION;
SELECT * FRIN doctors
WHERE on_call = true
AND shift_id 1234 FOR UPDATE;
UPDATE doctors
SET on_call = false
WHERE name = 'Alice'
AND shift_id = 1234;
COMMIT;
충돌 구체화
- 팬텀의 문제 : 잠글 수 있는 객체가 없다.
- 해결방법 : 인위적으로 데이터베이스에 잠금 객체를 추가한다.
- ex) 회의실 예약의 경우 시간 슬롯과 회의실에 대한 테이블을 만든다.
- 회의실과 시간범위의 모든 조합에 대해 row 를 미리 만들어 둘 수 있다.
- 예약을 하는 트랜잭션은 원하는 회의실과 시간 범위에 해당하는 로우를 잠글 수 있다.
- 충돌 구체화 : 팬텀을 데이터베이스에 존재하는 구체적인 로우 집합에 대한 잠금 충돌로 변환하는 것.
- 동시성 제어 매커니즘이 어플리케이션 데이터 모델로 새어나오는 것은 보기 좋지 않음 → 최후의 수단!!
- 대부분의 경우에 직렬성 격리 수준이 더 선호됨.
- 해결방법 : 인위적으로 데이터베이스에 잠금 객체를 추가한다.
728x90