본문 바로가기
카테고리 없음

데이터 중심 어플리케이션 설계 - 7장. 트랜잭션

by yeon_zoo 2023. 6. 21.

트랜잭션 : 애플리케이션에서 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법. → 하나의 트랜잭션 내부의 모든 읽기와 쓰기는 하나의 연산. 따라서 전체 성공(커밋) or 전체 실패(롤백, abort)

트랜잭션이 항상 필요한 것은 아님. 오히려 트랜잭션을 쓰지 않을 때 성능 향상, 가용성 등의 이득을 얻을 수도 있다. 따라서 언제 써야 할지 판단할 줄 알아야 함. 

학습 목표 : 트랜잭션으로 문제가 생길 수 있는 사례들을 조사하고 이를 방지하기 위한 DB의 알고리즘을 살펴본다. 동시성 제어 분야와 DB에서 커밋 후 읽기, 스냅숏 격리, 직렬성 같은 격리 수준을 구현하는 방법을 알아본다.

 

 

1. 애매모호한 트랜잭션의 개념

트랜잭션을 둘러싼 두 가지 주장 :

  • 트랜잭션은 확장성의 안티패턴이며 높은 성능과 고가용성을 유지하기 위해 트랜잭션을 포기해야 한다. 
  • 트랜잭션 보장은 값진 데이터가 있는 중대한 애플리케이션에 필수적인 요구사항

→ 둘 다 과장이다. 정상적인 운영상황과 극단적인 운영 상황에서 트랜잭션이 보장하는 내용을 통해 트랜잭션의 트레이드 오프에 대한 이해가 필요.

ACID의 의미

DB마다 ACID 구현이 다 다름. 

  • Atomicity (원자성) : 결함 때문에 완료 될 수 없다면 롤백되고 DB는 이 트랜잭션에서 지금가지 실행한 쓰기를 무시하거나 취소한다. 
  • Consistency (일관성) : 데이터는 항상 진실이어야 하는 선언(불변식)이 존재한다. 불변식을 위반하는 잘못된 데이터를 쓰지 않도록 막아야 한다. (ACID 중 유일하게 DB가 아닌 애플리케이션의 속성.)
  • Isolation (격리) : 동시에 실행되는 트랜잭션은 서로 격리되어 다른 트랜잭션을 방해할 수 없다. 동시에 실행되는 트랜잭션 이후 결과는 순차적으로 실행되는 트랜잭션 이후 결과와 동일해야 한다. 
                                직렬성 = 각 트랜잭션이 전체 DB에서 실행되는 유일한 트랜잭션인 것처럼 동작함. → 성능 손해로 현실에선 거의 사용하지 않음 → 직렬성 보다 보장이 약한 스냅숏 격리 구현
  • Durability (지속성) : 트랜잭션이 성공적으로 커밋되면 어떤 결함이 발생해도 기록한 모든 데이터는 손실되지 않는다는 보장

복제와 지속성 : 지속성을 위해 최근에는 하드디스크에 기록을 넘어서 복제를 사용하기도 함. 지속성 보장을 위해 여러 가지 방법이 있지만 절대적 보장은 없다. 

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

원자성 & 격리성 : 한 트랜잭션 내에서 여러 번의 쓰기를 하면 DB가 어떻게 해야 하는지를 서술.

다중 객체 트랜잭션 : 한 트랜잭션에서 여러 객체(로우, 문서, 레코드)를 변경할 수 있다. 데이터의 여러 조각이 동기화된 상태로 유지되어야 할 때 필요

RDB에서 트랜잭션 적용 방법 : 클라이언트와 데이터베이스 서버 사이의 TCP 연결을 기반으로, 특정 연결 내의 BEGIN TRANSACTION ~ COMMIT 문 사이를 하나의 트랜잭션으로 간주

NoSQL에서 트랜잭션 적용 방법 : 연산을 묶는 방법이 없는 경우가 많다. 

단일 객체 쓰기

단일 객체 쓰기에서도 원자성과 격리성이 적용된다. 

한 노드에 존재하는 단일 객체 수준에서 원자성과 격리성을 제공.

  • 원자성 - 장애 복구용 로그를 써서 구현 (≈B트리)
  • 격리성 - 각 객체에 잠금을 사용(동시에 한 스레드만 객체에 접근하도록)

단일 객체 연산은 여러 클라이언트의 동시 한 객체 쓰기 시도에서 갱신 손실을 방지한다. 하지만 일반적인 의미의 트랜잭션 X

트랜잭션 : 다중 객체에 대한 다중 연산을 하나의 실행 단위로 묶는 매커니즘

다중 객체 트랜잭션의 필요성

필요성 사례 

  • 관계형 데이터 모델 : 외래 키를 갖는 경우 (혹은 그래프에서 다른 정점에 연결된 간선이 있는 경우) 참조가 유효한 상태로 유지되도록 보장해야 한다. 
  • 문서 데이터 모델 : 조인 기능이 없으면 비정규화를 장려. 비정규화된 정보 갱신 시 한 번에 여러 문서를 갱신해야 한다. 이 때 트랜잭션으로 비정규화된 데이터가 동기화 깨지는 것을 방지할 수 있다. 
  • 보조 색인이 있는 데이터베이스 : 값 변경 시 색인도 변경되어야 함. 

트랜잭션으로 원자성과 격리성을 보장하면 쉽게 위의 내용들을 해결할 수 있음. 

오류와 어보트 처리

어보트 처리를 하지 않는 경우 : 리더 없는 복제의 경우. 오류가 발생하면 이미 한 일은 취소하지 않기 때문에 오류 복구는 애플리케이션의 책임

어보트 처리의 목적 : 안전하게 재시도하기 위함. 따라서 어보트된 후에 재시도를 하지 않고 사용자의 입력이 사라지는 것은 안타까운 일이다. 

어보트된 트랜잭션 재시도 : 효과적인 오류 처리 매커니즘이지만 완벽하지는 않다. 

  • 트랜잭션은 성공했으나 커밋 성공을 알리는 도중 실패할 경우, 재시도하면 트랜잭션은 두 번 커밋됨. ∴ 애플리케이션에 추가적인 중복 제거 매커니즘
  • 과부하로 인한 오류라면 트랜잭션 재시도로 악화될 수 있다. ∴ 재시도 횟수 제한, 지수적 백오프, or 과부화와 관련된 오류는 별도 처리
  • 일시적인 오류만 재시도 가치가 있고, 영구적인 오류는 재시도 가치가 없음
  • 트랜잭션이 부수 효과가 있다면 어보트 시에도 부수 효과가 실행될 수 있다. ex) 이메일을 보내는 경우, 재시도에도 이메일을 보내고 싶지는 않음
  • 클라이언트 프로세스가 재시도 중에 죽어버리면 쓰려던 데이터 모두 유실

2. 완화된 격리 수준

동시성 버그 : 재현이나 추론도 어려움. ∴ DB에서는 트랜잭션 격리를 제공해서 동시성 문제를 감추려고 노력

직렬성 격리 : DB가 여러 트랜잭션들이 직렬적으로 실행되는 것과 동일한 결과가 나오도록 보장 → 성능 비용이 있어서 제공하지 않는 DB들이 많다. 

∴ 모든 동시성 이슈로부터 보호해주지는 않지만 일부 이슈로부터는 보호해주는 완화된 격리 수준을 사용하는 시스템 多

→ 실제 사용되는 완화된(비직렬성) 격리 수준을 살펴보고 발생할 수 있는 경쟁 조건과 발생할 수 없는 경쟁 조건을 인지한 채 애플리케이션에 적합한 격리 수준을 선택할 수 있어야 한다. 

커밋 후 읽기 격리

가장 기본적인 수준의 트랜잭션 격리. 

다음의 두 가지를 보장

  1. DB에서 읽을 때 커밋된 데이터만 보게 된다 (= 더티 읽기 X)
  2. DB애서 쓸 때 커밋된 데이터만 덮어쓰게 된다 (= 더티 쓰기 X)

더티 읽기 방지

더티 읽기 : 한 트랜잭션에서 커밋되지 않은 데이터를 다른 트랜잭션에서 읽을 수 있는 것.

더티 읽기 방지의 장점 

  • 한 트랜잭션에서 여러 객체 갱신하는데 일부는 갱신된 값을, 일부는 갱신되지 않은 값을 볼 수 있다. 이로 인해 또 다른 트랜잭션이 잘못된 결정을 할 수도 있다.
  • 어보트될 시에 데이터가 롤백되어야 하는데, 이 경우 커밋되지 않을 데이터를 읽어오는 경우가 존재할 수 있다.

더티 쓰기 방지

더티 쓰기 : 먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션에서 쓴 것이고 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮어써버릴 수 있는 것.

방지 방법 : 먼저 쓴 트랜잭션이 커밋되거나 롤백될 때까지 두 번째 쓰기를 지연시킨다.

더티 쓰기 방지의 장점 : 

커밋 후 읽기 구현

더티 쓰기 방지

  • 로우 수준 잠금 : 특정 객체를 변경하고 싶으면 먼저 해당 객체에 대한 잠금을 획득

더티 읽기 방지

  • 동일한 잠금을 써서 객체를 읽기 원하는 트랜잭션이 잠시 잠금을 획득한 후 읽기가 끝난 후 바로 해제 → 운용성이 나쁨 (∵ 잠금 대기)
  • 커밋된 내역만 읽을 수 있도록 하는 것이 보편적 (DB는 과거에 커밋된 값과 현재 쓰기 잠금을 갖고 있는 트랜잭션에서 쓴 새로운 값을 모두 기억하고 있음)

스냅숏 격리와 반복 읽기

커밋 후 읽기 격리 수준으로도 동시성 버그가 생길 수 있는 경우 多

ex) 비반복 읽기(nonrepeatable read) = 읽기 스큐 (read skew)

 skew = 시간적인 이상 현상
      

             위 사례에서 앨리스는 은행 계좌를 새로고침하면 일관성 있는 계좌 잔고를 볼 수 있겠지만, 이런 일시적인 비일관성을 참을 수 없는 경우도 있다. 

일시적인 비일관성이 문제가 되는 경우

  • 백업 : 백업 시 사용되는 DB 전체 복사본은 크기가 크다. 따라서 저장되는 데 시간이 오래 걸릴 수 있고 그 시간동안에도 데이터 쓰기가 실행되므로 백업 데이터의 일부는 오래된 데이터, 일부는 최신화된 데이터일 수 있다.
  • 분석 질의와 무결성 확인

스냅숏 격리 : 각 트랜잭션은 DB의 일관된 스냅샷으로부터 읽는다. = 트랜잭션은 특정한 시점의 과거 데이터를 보는 것. 백업이나 분석처럼 실행하는 데 오래 걸리고 읽기만 실행하는 질의에서 유용

스냅숏 격리 구현

핵심 원리 : 읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서 읽는 쪽을 결코 차단하지 않는다. (더티 쓰기를 방지하기 위해 쓰기 잠금을 사용하지만 읽을 때는 아무 잠금도 사용하지 않음)

DB는 객체마다 커밋된 버전 여러 개를 유지할 수 있어야 한다. = 다중 버전 동시성 제어 (multi-version concurrency control, MVCC)

  • 커밋 후 읽기 격리 : 객체마다 버전은 최대 두 개씩. (커밋된 버전, 덮어 쓰여졌지만 아직 커밋되지 않은 버전). 질의마다 독립된 스냅숏을 사용
  • 스냅숏 격리 : 커밋 후 읽기 격리를 위해서도 MVCC를 사용. 전체 트랜잭션에 대해 동일한 스냅숏을 사용 
     p. 239

MVCC 기반 스냅숏 격리 구현 방법 :

  • 트랜잭션 시작 시 고유한 트랜잭션 ID (txid)를 할당받음. (계속 증가)
  • 데이터베이스에 쓰기를 진행할 때마다 트랜잭션의 ID가 함께 붙는다. 
  • 테이블의 각 로우에는 created_by (생성한 트랜잭션 ID를 저장하는 필드), deleted_by (처음에는 비어있는 필드) 가 존재한다.
  • 트랜잭션이 로우 삭제 시 deleted_by에 트랜잭션 ID를 적어두고 완전히 접근하지 않는게 확인되면 GC가 데이터를 삭제한다. 
  • 갱신은 내부에서 삭제 생성으로 변환된다. 트랜잭션13이 400 → 500으로 수정한다면, 로우가 두 개 존재하여 400은 삭제된 로우(deleted_by = 트랜잭션 13), 500은 생성된 로우(created_by = 트랜잭션 13)로 보인다. 

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

트랜잭션 ID를 이용한 가시성 규칙

  1. 각 트랜잭션을 시작할 때 그 시점에 진행중인 모든 트랜잭션의 목록을 리스트업. 이 목록의 트랜잭션들이 쓴 데이터는 전부 무시. (목록 내 트랜잭션이 커밋되더라도)
  2. 어보트된 트랜잭션이 쓴 데이터는 무시
  3. 트랜잭션 ID가 더 큰 (= 나중에 쓰인) 데이터는 커밋 여부 무관하게 무시
  4. 그 밖의 모든 데이터는 애플리케이션의 질의로 볼 수 있다.

색인과 스냅숏 격리

다중 버전 DB에서의 색인 동작 방법

  1. 색인이 객체의 모든 버전을 가리키게 하고 색인 질의에서 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 함. GC가 오래된 객체 버전을 삭제할 때 대응되는 색인도 삭제
  2. 동일한 객체의 다른 버전들이 같은 페이지에 저장될 수 있다면 색인 갱신을 회피하여 최적화
  3. 추가 전용 (append-only) + 쓸 때 복사되는 (copy-on-write) 변종 B 트리를 이용해 트리의 페이지가 갱신될 때 덮어쓰는 대신 각 변경된 페이지의 새로운 복사본을 생성한다.
    부모 페이지들은 복사되고, 자식 페이지들의 새 버전을 가리키도록 갱신된다. 새 B 트리의 루트는 DB 스냅숏의 일부가 된다.  

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

스냅숏 격리의 또 다른 이름

  • 오라클 : 직렬성
  • 포스트그레스큐얼, MySQL : 반복 읽기

∵ 스냅숏 격리는 SQL 표준이 아니지만 반복 읽기는 표준에 정의되어 있음. + 표준화된 반복 읽기의 모호성

갱신 손실 방지

두 트랜잭션이 두 쓰기를 동시에 시도할 때 발생할 수 있는 문제.

애플리케이션이 DB에서 값을 읽고 변경한 후 변경된 값을 다시 쓸 때 (read-modify-write 주기) 발생.

원자적 쓰기 연산

사용 가능한 쓰기에서는 원자적 갱신 연산을 사용.

원자적 갱신 연산 예시
UPDATE counters SET value = value + 1 WHERE key = 'foo';

커서 안정성(cursor stability) : 원자적 연산은 객체를 읽을 때 독점적인 잠금을 획득해서 구현한다. 그래서 갱신이 적용될 때까지 다른 트랜잭션에서 그 객체를 읽지 못하게 한다. 

ORM에서 원자적 연산 대신 불안전한 read-modity-write 주기를 실행하는 코드가 발생할 수 있다. 

명시적인 잠금

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

명시적인 잠금
BEGIN TRANSACTION;
 
SELECT * FROM figures
    WHERE name = 'robot' AND game_id = 222
    FOR UPDATE; -- 데이터베이스가 이 질의에 의해 반환된 모든 로우에 잠금을 획득해야 함을 가리킨다.
 
UPDATE figures SET position = 'c4' WHERE id = 1234l
 
COMMIT;

애플리케이션 로직에 대한 신중한 고민이 필요하다. 어딘가에서 필요한 잠금을 추가하는 것을 잊어버려 경쟁 조건을 유발하기 쉽다.

갱신 손실 자동 감지

병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트 시키고 read-modify-write 주기를 재시도하도록 강제하는 방법.

Compare-and-set

compare-and-set 연산을 통해 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용한다. 

compare-and-set
-- DB 구현에 따라 안전할 수도.. 안전하지 않을 수도 있다.
UPDATE wiki_pages SET content = 'new content'
    WHERE id = 1234 AND content = 'old content';

만약 DB가 where절이 오래된 스냅숏으로부터 읽기를 허용한다면 제 역할을 못할 수도 있다. 따라서 안전한지 확인이 필요하다. 

충돌 해소와 복제

복제를 이용(다중 리더 / 리더 없는 복제) 하면 여러 쓰기가 동시에 실행, 비동기식 복제를 허용 → 데이터의 최신 복사본이 여럿 존재할 수 있다. ∴ 잠금이나 compare-and-set은 사용이 불가

이 때 여러 충돌된 버전(= 형제, sibling) 생성을 허용하고 사후에 애플리케이션 코드나 특별한 데이터 구조를 사용해서 충돌을 해결한 뒤 병합한다. 

  • 원자적 연산은 복제 상황에서도 잘 동작
  • 최종 쓰기 승리(last write wins, LWW)는 갱신 손실이 발생하기 쉽다. 

쓰기 스큐와 팬텀

쓰기 스큐를 특징짓기

위의 이상 현상을 쓰기 스큐라고 한다. 두 트랜잭션이 두 개의 다른 객체를 갱신 하므로 더티 쓰기도 갱신 손실도 아니다. 

같은 객체들을 읽어서 그 중 일부를 갱신할 때 나타날 수 있다. 

쓰기 스큐의 선택지

  • 여러 객체가 관련되므로 원자적 단일 객체 연산은 도움이 되지 않는다
  • 갱신 손실 자동 감지도 도움되지 않는다. 
  • 어떤 DB에서는 제약 조건을 설정할 수 있다. (대부분은 지원하지 않지만, 트리거나 구체화 뷰를 사용해 구현 가능한 DB들이 존재)
  • 직렬성 격리 수준을 사용할 수 없다면 트랜잭션이 의존하는 로우를 명시적으로 잠그는 것이 차선책이다. (by. SELECT ... FOR UPDATE)

추가적인 쓰기 스큐의 예

  • 회의실 예약 시스템 : 누군가 예약 하려고 할 때 먼저 충돌하는 예약이 있는지 확인하고 없다면 회의를 예약
  • 다중 플레이어 게임 
  • 사용자명 획득 : 각 사용자가 유일한 사용자명을 가져야 하는데 동시에 같은 이름을 사용자가 획득하고자 할 때 → UNIQUE 제약 조건을 지정해주면 해결 가능
  • 이중 사용(double-spending) 방지 : 돈이나 포인트 지불 서비스에서 한 사용자가 동시에 두 곳에서 소비하고자 한다면 발생 가능. 

쓰기 스큐를 유발하는 팬텀

사례들은 비슷한 패턴이 존재한다. 

  1. SELECT 질의가 검색 조건에 맞는 로우를 검색해서 요구사항에 만족하는지 확인
  2. 첫 번째 (SELECT) 질의의 결과에 따라 애플리케이션 코드가 계속 진행할지 오류를 뱉을지 결정
  3. 애플리케이션에서 계속 처리하기로 결정했다면 데이터베이스에 쓰고 커밋. → 쓰기를 커밋한 후 1단계의 SELECT 질의를 재실행하면 다른 결과를 얻게 된다. 

팬텀 : 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과. 스냅숏 격리는 읽기 전용 질의에서는 팬텀을 회피하지만 읽기 쓰기 트랜잭션에서는 팬텀이 쓰기 스큐의 까다로운 경우를 유발할 수 있다. 

충돌 구체화

충돌 구체화 : 팬텀을 DB에 존재하는 구체적인 로우 집합에 대한 잠금 충돌로 변환

ex) 회의실 예약 - 시간 슬롯과 회의실에 대한 테이블을 만들고, 예약 트랜잭션에서는 원하는 회의실 + 시간에 잠금을 건다. 잠금 획득 후 겹치는 예약이 있는지 확인하고 새 예약을 삽입한다.

  • 방법을 알아내기 어렵고 오류가 발생하기 쉽다
  • 동시성 제어 메커니즘이 애플리케이션 데이터 모델로 새어 나오는 것도 보기 좋지 않다. 

따라서 다른 대안이 불가능할 때 최후의 수단으로 고려. (직렬성 격리 수준을 더 선호)

3. 직렬성

  • 격리 수준은 이해하기 어렵고 DB마다 그 구현에 일관성이 없다
  • 애플리케이션 코드를 보고 특정한 격리 수준에서 해당 코드를 실행하는 게 안전한지 알기 어렵다
  • 경쟁 조건을 감지하는 데 도움이 되는 좋은 도구가 없다 (동시성은 테스트도 어렵다)

→ 직렬성 격리를 사용하자

직렬성 격리 :

  • 가장 강력한 수준의 격리
  • 여러 트랜잭션이 병렬로 실행되더라도 최종 결과는 동시성 없이 직렬 실행될 때와 동일함을 보장
  • DB가 발생할 수 있는 모든 경쟁 조건을 막아준다

직렬성 제공 기법 

  • 트랜잭션을 순차적으로 실행하기
  • 2단계 잠금
  • 직렬성 스냅숏 격리 같은 낙관적 동시성 제어

실제적인 직렬 실행

동시성을 완전히 제거하는 방법. → 한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행

사용 가능한 이유 

  • 램 가격 인하로 모든 데이터셋을 메모리에 유지 가능 → 디스트에서 데이터를 읽어올 때보다 단일 트랜잭션에 걸리는 시간이 줄어들었음
  • OLTP 트랜잭션들은 보통 짧고 실행하는 읽기와 쓰기의 개수가 적다. (반면 OLAP는 읽기 전용이라 스냅숏 격리 이용)

단일 스레드 트랜잭션의 장점 : 잠금을 코디네이션하는 오버헤드 회피 (종종 멀티 스레드보다 성능이 나을 수도)

단일 스레드 트랜잭션의 단점 : 처리량 = CPU 코어 하나의 처리량

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

상호작용식 트랜잭션 : 애플리케이션 코드와 데이터베이스 서버 사이에서 질의와 결과를 주고 받음 → 네트워크 통신에 많은 시간을 소비

∴ 트랜잭션 코드 전체를 스토어드 프로시저 형태로 데이터베이스에 미리 제출

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

단점

  • 벤더마다 제각각의 스토어드 프로시저용 언어가 존재. 대부분의 프로그래밍 언어에서 찾을 수 있는 라이브러리 생태계가 약하다
  • DB에서 실행되는 코드는 관리하기 어렵다. 디버깅, 버전 관리 및 배포, 테스트, 모니터링용 지표 수집 시스템과 통합 등이 애플리케이션에 비해 전부 어렵다
  • 성능에 훨씬 민감하다. 여러 애플리케이션 서버에 DB 인스턴스 하나를 공유하기 때문

스토어드 프로시저가 있고, 데이터가 메모리에 저장된다면 모든 트랜잭션을 단일 스레드에서 실행하는 것이 현실성이 있다. 

  • IO 대기가 필요 없다
  • 다른 동시성 제어 메커니즘의 오버헤드를 회피하여 좋은 처리량을 얻을 수 있다

파티셔닝

쓰기 처리량이 높은 경우 병목이 생길 수 있다. 

데이터가 파티셔닝 된 경우

  • 하나의 파티션에서만 데이터를 읽고 쓰는 경우, 각 파티션이 독립적으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있다. → 각 CPU 코어에 각자의 파티션을 할당해서 트랜잭션 처리량을 코어 개수에 맞춰 확장 가능
  • 여러 파티션에 접근해야 하는 트랜잭션의 경우, 해당 트랜잭션이 접근하는 모든 파티션에 걸쳐서 코디네이션을 해야 한다. 모든 파티션에 걸쳐 잠금을 획득한 후 진행. → 처리량이 매우 낮아지며 장비를 추가해도 처리량을 늘릴 수 없다

직렬 실행 요약

  • 모든 트랜잭션이 작고 빨라야 한다.
  • 활성화된 데이터셋이 메모리에 적재될 수 있는 경우에 사용해야 한다.
  • 쓰기 처리량이 충분히 낮아야 한다. 아니면 파티셔닝 해야 한다.
  • 여러 파티션에 걸친 트랜잭션도 쓸 수는 있지만 처리량 저하를 감수해야 한다. 

2단계 잠금(2PL)

2단계 잠금 (two-phase locking, 2PL) : 쓰기를 실행하는 트랜잭션이 없는 객체는 여러 트랜잭션에서 동시에 읽을 수 있다. 누군가 어떤 객체에 쓰려고 하면 독점적인 접근이 필요하다. ≠ 2PC (two-phase commit)

  • 2PL : 쓰기 트랜잭션은 다른 쓰기 트랜잭션 뿐만 아니라 읽기 트랜잭션도 진행하지 못하게 막고 그 역도 성립한다. 
  • 스냅숏 격리 : 읽는 쪽은 결코 쓰는 쪽을 막지 않으며 쓰는 쪽도 결코 읽는 쪽을 막지 않는다.

2단계 잠금 구현

잠금의 종류 : 공유 모드, 독점 모드

  • 트랜잭션이 객체를 읽기 원한다면 공유 모드로 잠금을 획득. 여러 트랜잭션이 동시에 공유 모드를 가질 수 있지만 이미 독점 모드로 잠금을 획득한 트랜잭션이 있으면 해당 트랜잭션이 끝날 때까지 대기
  • 쓰기를 원한다면 독점 모드로 잠금을 획득. 독점 모드는 한 번에 하나의 트랜잭션만 가질 수 있다. 
  • 객체를 읽다가 쓰기를 실행할 때는 공유 잠금을 독점 잠금으로 업그레이드 한다. 
  • 트랜잭션이 잠금을 획득한 후에는 종료될 때까지 잠금을 갖고 있어야 한다. 2단계라는 말은 즉 1단계) 잠금을 획득 / 2단계) 모든 잠금을 해체 의 두 개 단계이다.

→ deadlock (교착 상태) 발생 가능 (서로가 서로의 트랜잭션이 끝나기를 대기하고 있는 상태)

DB는 트랜잭션 사이의 교착 상태를 자동으로 감지하고 트랜잭션 하나를 어보트 시켜서 다른 트랜잭션들이 진행할 수 있도록 한다. 어보트된 트랜잭션은 애플리케이션에서 재시도 해야 한다. 

2단계 잠금의 성능

완화된 격리 수준보다 트랜잭션 처리량과 질의 응답 시간이 크게 나빠진다.

원인

  • RDB에서 트랜잭션의 실행 시간을 제한하지 않기 때문에 잠금을 획득한 트랜잭션이 끝날 때까지 무한정 대기 + 대기열에 많은 트랜잭션이 쌓일 수 잇다
  • 교착 상태가 자주 발생하고, 교착 상태로 인해 어보트된 트랜잭션들을 재시도하면 작업을 전부 다시 해야 한다

서술 잠금

팬텀 방지 : 검색 조건에 부합하는 모든 객체를 잠금

서술 잠금 구현

  • 객체를 읽을 때 질의의 조건에 대한 공유 모드 서술 잠금 획득. 이 때 그 조건에 부합하는 어떤 객체에 독점 잠금이 걸려있다면 잠금 해제를 대기
  • 어떤 객체에 쓰기를 진행한다면 기존 값이나 새로운 값 중에 기존의 서술 잠금에 부합하는 게 있는지 확인. 다른 트랜잭션이 해당 잠금을 잡고 있다면 종료될 때까지 대기

서술 잠금은 데이터베이스에 아직 존재하지 않지만 미래에 추가될 수 있는 객체(팬텀)에도 적용할 수 있다. 

진행 중인 트랜잭션들이 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는데 시간이 오래 걸린다. → 색인 범위 잠금 사용

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

명확한 그 조건에 부합하는 객체만 잠그는 것이 아니라 근사 조건에 부합하면 잠금.

보통 검색 조건이 색인에 붙는 경우가 많으니 색인에 잠금을 건다. → 다른 트랜잭션에서 해당 로우를 갱신하고 싶다면 색인의 같은 부분을 갱신해야 하고 이 때 공유 잠금을 발견하고 잠금이 해제될 때까지 대기

  • 팬텀과 쓰기 스큐로부터 보호
  • 서술 잠금보다 정밀함이 떨어짐 (필요한 것보다 더 큰 범위를 잠글 수 있음)
  • 오버헤드가 훨씬 낮아서 타협안이 됨.

범위 잠금에 적합한 색인이 없다면 테이블 전체에 공유 잠금을 잡아 대체할 수도 있다. (성능↓ but 안전)

직렬성 스냅숏 격리(SSI)

2단계 잠금과 직렬 실행의 단점 (성능 나쁨 & 확장이 어려움) 과 완화된 격리 수준의 단점 (다양한 경쟁 조건에 취약) 을 모두 극복한 직렬성 스냅숏 격리(serializable snapshot isolation, SSI)

단일 노드와 분산 데이터베이스 모두에서 사용될 수 있다.

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

  • 비관적 동시성 제어 매커니즘 : 뭔가 잘못될 가능성이 있으면 뭔가를 하기 전에 상황이 다시 안전해질 때까지 기다리는 게 낫다는 주의. ex) 2단계 잠금, 멀티 스레드 프로그래밍의 상호 배제
  • 낙관적 동시성 제어 매커니즘 : 뭔가 위험한 상황이 발생할 가능성이 있을 때 트랜잭션을 막는 대신 모든 것이 괜찮아질 것이라는 희망을 갖고 계속 진행. ex) 직렬성 스냅숏 격리

낙관적 동시성 제어의 약점 : 경쟁이 심하면 어보트 시켜야 할 트랜잭션의 비율이 높아지므로 성능이 떨어진다.

낙관적 동시성 제어이 강점 : 예비 용량이 충분하고 트랜잭션 사이의 경쟁이 너무 심하지 않으면 성능이 더 좋다. 경쟁은 가환(communtative) 원자적 연산을 써서 줄일 수 있다. 

SSI는 스냅숏 격리 기반. 스냅숏 격리 위에 쓰기 작업 사이의 직렬성 충돌을 감지하고 어보트시킬 트랜잭션을 결정하는 알고리즘을 추가한다. 

뒤쳐진 전제에 기반한 결정

스냅숏 격리는 전제 (트랜잭션을 시작할 때 참이었던 사실) 를 기반으로 동작을 한다. → 해당 트랜잭션이 커밋하려고 할 때 원래 데이터가 바뀌어서 그 전제가 더 이상 참이 아닐 수 있다.

∴ 직렬성 격리를 제공하려면 트랜잭션이 뒤처진 전제를 기반으로 동작하는 상황을 감지하고 그 상황에서는 트랜잭션을 어보트 시켜야 한다. 

오래된 MVCC 읽기 감지하기

트랜잭션이 MVCC 가시성 규칙에 따라 다른 트랜잭션의 쓰기를 무시하는 경우를 추적해야 한다. 

트랜잭션이 커밋 시도 시 DB는 무시된 쓰기 중에 커밋된 게 있는지 확인 → 커밋된 게 있으면 트랜잭션 어보트

∵ 읽기 전용 트랜잭션(어보트 시킬 필요가 없음)도 있기 때문에 오래된 읽기 확인 후가 아니라 커밋 시도 시에 확인해야 함.

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

직렬성 스냅숏 격리의 성능

  • 트랜잭션의 읽기 쓰기를 추적하는 세밀함의 정도에 따라 성능이 달라짐
    • 각 트랜잭션의 동작을 매우 상세하게 추적하면 기록 오버헤드 ↑
    • 덜 상세하게 추적 시 지나치게 많은 트랜잭션을 어보트 할 수 있음
  • 트랜잭션이 잠금을 기다리느라 차단 될 필요가 없다 → 읽기 작업 부하가 심한 경우에 이점
  • 단일 CPU 코어 처리량에 제한되지 않는다.
  • 트랜잭션을 짧게 유지하는 것이 도움이 된다.

댓글