[DBMS] 데이터 중심 어플리케이션 설계 7장 (트랜잭션) 요약

Damongsanga·2024년 8월 9일
0

최근에 데이터 중심 어플리케이션을 읽고 있는데 트랜잭션 내용을 CS 스터디에서 발표하게 되어 정리해보았다.

주요 포인트는 아래와 같다

  1. 격리 수준에 따른 문제와 이를 극복하기 위한 방법
  2. 직렬성 구현방법
  3. MySQL과 PostgreSQL (일반적인 DBMS)의 스냅숏 생성 시점의 차이

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

ACID의 애매모호함에 대해

  • 원자성 (A) : 핵심은 중간에 에러 발생시 Abort 될 수 있는 기능

  • 일관성 (C) : 실제로는 DB 책임이 아닌 어플리케이션 책임으로 보는 것이 타당하다.

    • 데이터베이스에서의 일관성이란 데이터에 관한 어떤 선언 (불변식)이 있다는 것을 의미
    • 데이터베이스 자체만으로 불변식을 위반하는 잘못된 데이터를 쓰지 못하도록 막을 방법이 없음 (외래키, 데이터 속성, unique 외의 validation 기능에 제한이 분명함)
    • 이러한 것은 애플리케이션의 책임으로 보고 일관성을 달성하기 위해 데이터베이스의 원자성과 격리성 속성을 사용할 수는 있다.
  • 격리성 (I) : 한 트랜잭션이 여러 번 쓴다면 다른 트랜잭션은 그 내용을 전부 볼 수 있든지 아무것도 볼수 없든지 둘 중 하나여야 한다는 뜻.

    • 완전한 직렬성(SERIALIZABLE) 격리는 성능이슈 존재 (일부에 대해서만 직렬성을 제공하거나 완화된 격리조건인 snapshot 격리로 대체, 그러나 이에 대한 문제 또한 발생 가능)
  • 지속성 (D) : 트랜잭션이 성공적으로 커밋되면 하드웨어 결함 등이 발생해도 데이터는 소실되지 않아야 한다.

    • 테이프 → 디스크/SSD → 복제의 형태로 발전. 그러나 완벽한 영속성이란 존재할 수 없다. (만약 모든 데이터베이스 센터가 불타버린다면?)

완화된 격리 수준

  • 모든 동시성 이슈는 해결 못하나 "일부" 동시성 이슈는 해결할 수 있는 상태

Read Commmitted

: 더티 읽기, 쓰기 방지

  • 더티 읽기 방지 : 데이터베이스에 커밋된 데이터만 읽을 수 있음
  • 더티 쓰기 방지 : 데이터베이스에 커밋된 데이터만 덮어 쓰게 됨

REPETABLE READ

: 스냅숏 격리와 사실상 같음(?) → SQL 표준에 스냅숏 격리 개념이 없기 때문. 따라서 데이터베이스마다 의미가 가지가지임

스냅숏 격리

  • 핵심 아이디어 : 읽는 쪽은 쓰는쪽 차단 X, 쓰는 쪽은 읽는 쪽 차단 X ⇒ MVCC

    • 만약 READ COMMITTED 만 구현한다면 객체마다 2개의 버전 (커밋 버전, 수정되었으나 커밋되지 않은 버전) 만 필요하다

    • 그러나 스냅숏 격리를 지원하는 경우 읽기 격리를 위해 질의마다 독립정인 스냅숏을 사용하고, 전체 트랜잭션에 대해 동일한 스냅숏 사용

    • 트랜잭션 ID가 증가함으로, 현재 트랜잭션이 시작한 후에 시작한 트랜잭션이 쓴 데이터는 무시됨, abort 된 데이터도 물론 무시됨

    • 즉, 스냅숏은 REPETABLE READ나 SERIALIZABLE에 사용된다고 생각하면 된다. (근데 Oracle은 READ COMMITTED 인데 MVCC 사용한다고 함. 다 그런 것은 아닌듯)

    • 일관된 스냅숏 관련 이미지

  • 스냅숏 생성 타이밍 구현 차이

    • 여기서 트랜잭션의 시작때 스냅숏이 생성되는지, 트랜잭션에서 첫 읽기 쿼리가 발생할 때 생성되는지는 구현에 차이로 보인다.
    • MySQL은 트랜잭션에서 첫 읽기 쿼리가 발생할 때 스냅숏 생성
      • MySQL은 언두로그에서 읽어옴으로, 스냅숏 생성 시점보다 후에 생성된 언두로그의 데이터는 읽어오지 않는 방식으로 구현된다. (보통은 트랜잭션 ID만으로 알 수 있을 것이다.)
    • PostgreSQL은 트랜잭션이 시작될 때 바로 스냅숏 생성

문제 상황

  1. 더티 쓰기 → 두 트랜잭션이 같은 객체 갱신

  2. 갱신 손실 → 두 트랜잭션이 같은 객체 갱신

    • read-modify-update에 의한 업데이트 내용 반영이 생략되는 현상 (LLW)
    • 갱신 손실 자동 방지 (MySQL은 구현 X)
    • 여러 트랜잭션의 병렬 실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 abort 시키고, 재시도하도록 강제하는 방법
    • read-modify-write vs compare-and-set
    • read-modify-write : 원자적이지 않음
    • compare-and-set : 원자적 연산
      • ( UPDATE a_table SET content = ‘new’ WHERE id = 1234 AND content = ‘old’ ) → 그러나 만약 DB 구현상 WHERE 절이 오래된 스냅샷을 읽는다면 문제 발생
  3. 쓰기 스큐 → 두 트랜잭션이 여러 객체들을 조회하는데, 일부 같은 객체들을 읽고, 다른 객체를 갱신

    • 쓰기 스큐 예시

    • 원자적 연산 무의미, 갱신손실 감지 불가

    • 락 (비관적, 낙천적락) 등으로 명시적으로 row를 잠그거나 진짜 직렬성 격리 수준으로 해결해야

    • 예시

      1. 회의실 예약 시스템
      2. 다중 플레이어 게임
      3. 사용자명 획득 (유일성 제약 조건으로 해결 가능)
      4. 이중 사용방지 (잔고 음수)
    • 팬텀 : 트랜잭션이 실행한 쓰기가 다른 트랜잭션의 검색조건을 바꾸는 경우. 쓰기 스큐를 유발

      • 보통의 트랜잭션 순서

        1. SELECT
        2. 조건 만족 여부 판단 (값 비교, count, 존재여부 등)
        3. INSERT, UPDATE, DELETE
      • 여기서 만약 CUD가 SELECT 조건 판단에 영향을 준다면? 만약 INSERT로 인해 락을 걸 수도 없다면??

        • 예시로 A 트랜잭션에서 금일 예약손님이 3명 이하임을 SELECT로 확인하였는데, 그 사이에 B 트랜잭션에서 오늘 예약 손님을 3명 추가하였고, 이후 A 트랜잭션에서 3번 과정, 즉 예약 손님을 INSERT하게 된다면 의도대로 동작한 것이 아님.
      • MySQL은 범위 질의에서 갭락으로 방지함

        • 예시에 created_date 라는 속성에 between에 따라서 범위 락이 걸림. 따라서 A 트랜잭션이 SELECT한 순간 다른 트랜잭션들은 created_date가 오늘인 범위에는 못넣음
        • 추후에 설명하겠지만, InnoDB는 레코드 기준으로 락을 걸고, 이 record Lock은 index를 기준으로 걸게 된다. 따라서 created_date 등의 where 절에 index가 걸려있지 않다면 테이블 전체를 락을 걸 수 있음으로 index 설정에 신경써야 한다.
      • 비슷하게 회의실 문제를 예약할 수 있는 시간을 특정 간격으로 나누어 미리 회의실을 만들고 SELECT에 해당하는 회의실을 Lock을 걸 수 있다 (SELECT FOR UPDATE)

  • 문제 해결 방안

    • 복제된 경우 compare-and-set이나 Lock이 사용할 수 없게 됨 (최신 복사본이 1개라고 가정하기 때문)

직렬성 (SERIALIZABLE)

: 여러 트랜잭션이 병렬로 실행되더라도 최종 결과는 하나씩 직렬로 실행될 때와 같도록 보장. 완화된 격리조건과 반대!

1. 트랜잭션 실제로 순차적으로 실행

  • 단일 스레드 루프에서 트랜잭션 실행이 가능하다는 결론 (2007년) : 대표적으로 레디스
    • 램 가격이 저렴해지면서 트랜잭션이 접근해야 할 모든 데이터를 메모리에 저장할 수 있음
    • OTLP 트랜잭션이 보통 짧고 읽기 쓰기의 갯수가 적은 것을 알게됨. 반대로 분석 질의는 오래걸리나 읽기 전용이라 SERIALIZABLE 대신 일관된 스냅숏을 사용할 수 있음
  • 스토어드 프로시저
    • 상호작용하는 다중 구문 대신 트랜잭션 코드 전체를 DB에 제출하여 네트워크 IO를 최소화
    • 대표적으로 레디스의 Lua, 볼트DB의 Java, Groovy
  • 파티셔닝
    • 단일 CPU 코어 사용시 병목 현상 발생 가능
    • 여러 CPU 코어, 노드로 확장하기 위해 파티셔닝을 할 수 있지만 이로 인한 다중 파티션 트랜잭션은 오버헤드가 엄청나다
    • 이는 데이터 구조에 크게 의존하며, 키-값 데이터는 쉽게 될 수 있지만 인덱스가 포함된 데이터는 코디네이션이 많이 필요하다
  • 제한 조건
    1. 모든 트랜잭션은 작고 빨라야
    2. 활성화된 데이터셋이 가용 메모리보다 작아야
    3. 쓰기 처리량이 단일 CPU 코어 내에서 처리할 수 있을 정도로 작아야 (아니면 파티셔닝)
    4. 다중 파티셔닝 트랜잭션은 주의해서 사용

2. 2단계 잠금 (2PL, two-phase Locking) [비관적 동시성 제어]

  • 공유락, 배타락!

  • 스냅숏 격리는 읽는쪽과 쓰는쪽은 서로를 막지 않지만, 2PL은 쓰기를 원하는 객체에 대해서는 읽기, 쓰기 모두 불가능하게 하고, 읽기를 원하는 객체에 대해서는 쓰기를 불가능하게 한다.

  • 교착상태 발생할 확률 높음. 성능 이슈 주의

    2.1 서술 잠금 (Predicate Lock, MySQL에서의 Gap Lock)

  • 특정 범위에 대해서 SELECT하게 되면 미래에 추가될 수 있는 팬텀 객체에 대해서도 서술잠금이 적용됨

  • 실제로는 index-range Lock, next-key Lock으로 구현

  • 범위 잠금을 사용하는데에 있어, 검색조건에 index가 걸어두는 것이 매우 좋다. ⇒ 오버헤드가 매우 낮아짐

  • index가 없으면 테이블 전체 공유잠금을 걸게 됨 (성능 나쁘나 안전)

3. 직렬성 스냅숏 격리같은 낙관적 동시성 제어

  • SSI (직렬성 스냅숏 격리) : 완전한 직렬성을 제공하지만 약간의 성능 손해, PostgreSQL, 분산데이터베이스에서 사용
  • 트랜잭션의 모든 읽기는 DB의 일관된 스냅숏을 보게 됨 + 직렬성 충돌 감지 + 어보트 트랜잭션 결정하는 알고리즘
  • 단점
    • 경쟁 심화시 어보트할 트랜잭션 비율 증가
    • 시스템 최대처리량 접근시 재시도로 인한 부하
  • 장점
    • 예비 용량이 충분하다면 성능 오히려 좋아짐
    • 가환 원자적 연산을 통해 경쟁 줄일 수 있음 (카운터 증가시, 이를 같은 트랜잭션에서 다시 읽지 않는다면 충돌없이 증가 연산 적용 가능)
  • 오래된 MVCC 읽기 감지하기
    • 트랜잭션에서 커밋시 사이에 다른 트랜잭션에서 커밋한 적이 있다면 어보트되어야 한다.
    • 왜 읽을 때 오래된 스냅샷 여부 확인하지 않고 커밋할 때까지 기다려서 어보트? : 다른 트랜잭션이 어보트될 수도 있는 것이고 만약 읽기전용 트랜잭션인 경우 쓰기 스큐의 위험이 없음
  • 직렬성 스냅숏 격리 성능
  • 트랜잭션 동작 추적을 상세하게 할 수록 어보트되어야 할 트랜잭션을 정확하게 판별할 수 있으나, 기록 오버헤드가 심해짐
  • 덜 상세하게 하면 빠르나 지나치게 많은 트랜잭션이 어보트될 수 있음
  • 어떤 경우에는 다른 트랜잭션에서 덮어 쓴 정보를 트랜잭션이 읽어도 괜찮음
  • 이 만으로 실행결과가 직렬적이라고 증명할 수 있는 경우 존재
  • PostgreSql은 불필요한 어보트를 줄이기 위해 이 방법 사용 ⇒ 읽기 전용 트랜잭션에 대해서는 덮어쓴 데이터를 읽는 것을 허용
  • 2PC에 비해 읽기 부하에 강하다
  • 순차실행에 비해 단일 CPU 코어 처리량에 제한되지 않는다
  • 그러나 어보트 비율은 SSI의 전체적 성능에 큰 영향을 미친다
  • 이로인해 트랜잭션의 길이는 매우 짧기를 요구한다
  • 그러나 오래실행되는 읽기 전용 트랜잭션은 괜찮다!
    • 따라서 @Transactional(readOnly = true)가 일반 @Transactional보다 성능이 더 좋다
    • 이는 직렬성 스냅숏 격리(Serializable Snapshot Isolation, SSI)가 읽기 전용 트랜잭션에 대해 더 유리한 특성을 갖기 때문

추가 공부) InnoDB에서의 Phantom Read 발생 추가 예시

전 주에 정리했던 내용에 추가적으로 InnoDB에서 Phantom Read가 발생하는 보다 더 구체적인 예시를 찾아서 공유하고자 한다.

다른 트랜잭션에서 데이터를 추가한 후에 내 트랜잭션에서 “UPDATE”하는 경우이다.
처음에 없던 데이터가 UPDATE 되기도하고, 이후에 SELECT하면 보이게 된다.

지금부터는 이 블로그 의 내용을 발췌한 것임을 밝힌다!!!!!! 더 자세한 내용은 꼭 이 블로그를 읽어보길 바란다.

예시

오른쪽 트랜잭션에서 첫 SELECT 구문에서는 아무런 데이터가 없었으나, 이후 다른 트랜잭션에서 데이터를 INSERT 하였고, 그 INSERT한 데이터에 대해 UPDATE를 할 수 있게 된다..!

심지어 UPDATE 이후 SELECT 구문을 다시 날리면 UPDATE된 glen 데이터를 볼 수 있다.

어떻게 된 것일까?

블로그에 너무 잘 설명되어있어 부끄럽지만 그대로 가지고 왔다. 같이 이해해보자.


이유

우선 InnoDB에서 Phantom Read를 막는 방법은 언두 로그트랜잭션 번호를 사용하여 Phantom Read를 방지한다.

A 트랜잭션을 시작하고, 데이터를 조회한다. A 트랜잭션 번호는 3이다.

B 트랜잭션을 시작하고, 데이터를 추가하고 커밋한다. B 트랜잭션 번호는 4이다.

그리고 다시 A 트랜잭션이 데이터를 조회할 때 언두 로그를 조회하는데, 언두 로그에 자신의 트랜잭션 번호보다 큰 트랜잭션 번호는 조회하지 않는다. (B 트랜잭션이 추가한 데이터는 트랜잭션 번호가 4번)

따라서 Phantom Read가 발생하지 않는다.

하지만 UPDATE 쿼리를 실행했을 때, Phantom Read가 발생하는 이유는 다음과 같다.

  1. UPDATE 쿼리는 쓰기 락이 걸린다.
  2. 이때 언두 로그에 쓰기 락을 걸 수 없기 때문에 테이블에 쓰기 락을 걸고, 데이터를 변경한다.
  3. 그리고 UPDATE 쿼리가 반영되며, 언두 로그에 자신의 트랜잭션 번호가 갱신된 데이터가 생긴다.
  4. 그리고 SELECT 쿼리를 실행하면 언두 로그에 있는 데이터를 조회한다.
  5. 언두 로그에 있던 데이터의 트랜잭션 번호는 자신의 트랜잭션 번호이므로, Phantom Read가 발생한다.

참조

profile
향유하는 개발자

0개의 댓글