Atobaum

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

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

트랙잭션은 ACID를 보장한다고 한다.

ACID의 의미

Atomicity(원자성)

동시성(여러 프로세스가 동시에 같은 데이터에 접근할 때)와 관련 없다. 동시성은 격리에서 다룬다.

시스템은 트랜잭션을 실행하기 전이나 후의 상태에만 있을 수 있으며 그 중간 상태에는 머물 수 없다.

Consistenct(일관성)

데이터에 관한 어떤 불변식이 항상 참을 뜻한다. 따라서 불변식에 의존하는데 불변식은 DB의 책임이 아니라 애플리케이션의 책임이다. (물론 유일성, 외래키 같이 DB의 책임인 것도 있다)

AID는 DB의 속싱이나 C는 DB의 속성이 아니다.

Isolation(격리성)

동시성의 문제. 트랜잭션끼리 서로 격리되어 있고 다른 트랜잭션을 방해할 수 없다. 따라서 다른 트랜잭션을 신경 쓸 필요 없게 해준다.

여러 트랜잭션이 동시에 실행되었을 때 DB는 이들이 순차적으로 실행됐을 때와 동일한 결과를 내도록 보장한다.

그러나 완전한 직렬성 격리는 성능 손실이 있으므로 대부분의 상황에서는 완화된 수준의 격리성을 사용한다.

Durability(지속성)

트랜잭션 후 커밋한 데이터는 손실되지 않는다.

  • 단일 노드 DB에서는 일반적으로 데이터가 HDD/SDD같은 비휘발성 저장 매체에 기록됐다는 뜻.

  • 복제 기능이 있는 DB에서는 보통 여러 노드에 복사되었다는 뜻.

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

단일 객체를 변경할 때도 원자성과 격리성은 적용된다. 예를 들어 20KB JSON 문서를 저장할 때

  • 첫 10KB를 보낸 후 네트워크 연결이 끊기면 DB는 받은 JSON 조각을 저장할 것인가
  • DB가 디스크에 저장된 기존 값을 덮어 쓸 때 전원이 나가면 어떻게 될까
  • 문서를 쓰고 있을 때 다른 클라이언트에서 그 문서를 읽으면 어떤 값을 줄까

완화된 격리수준

동시성 문제는 언제나 어려운 문제. DB는 트랜잭션 격리를 제공해 동시성 문제를 감추었다. 직렬성 격리는 여러 트랜잭션이 직렬적으로 실행되는 것과 등일한 결과가 나오는 것을 보장. 그런데 직렬성 격리는 성능 손실이 있다. 따라서 DB는 어떤 동시성 문제는 보호해주지 않는 격리수준을 제공한다.

커밋 후 읽기(read committed)

다음 두가지를 보장

  • 더티 읽기가 없다: DB에서 읽을 때 커밋된 데이터만 보게된다.

  • 더티 쓰기가 없다: DB에 쓸 때 커밋된 데이터만 덮어쓰게 된다.

더티 읽기(dirty read)

트랜젝션이 아직 커밋되지 않은 다른 트랜잭션의 데이터를 보는 것을 더티 읽기라고 한다.

막는 이유

  1. 트랜잭션이 여러 객체를 갱신할 때 더티 읽기가 생기면 다른 트랜잭션이 일관적이지 않은 데이터를 볼 수 있다.
  2. 트랙잭션이 롤백될 때 더티 읽기가 생기면 나중에 롤백될, 즉 DB에 쓰이지 않을 데이터가 보일 수 있다.

더티 쓰기(dirty write)

두 트랜잭션이 동일한 객체를 갱신하려고 할 때 나중에 실행된 갱신이 커밋되지 않은 값을 덮어 쓰는 것을 더티 쓰기라고 한다.

Fig.7-5

위 그림에서 구매자는 밥이 되었고 송장은 앨리스에게 전송되 데이터의 일관성이 어긋난다.

구현

커밋 후 읽기는 많은 DB의 기본 설정.

더티 쓰기 막기

보통 row 수준 잠금을 통해. 트랜잭션이 특정 객체(row/문서)를 쓰기 위해서는 해당 객체에 대한 잠금을 획득해야 한다. 하나의 트랜잭션만 row에 대한 소유권을 가지고 있다가 커밋 시 소유권을 내놓는다. 따라서 두번째 트랜잭션이 갱신을 하려면 첫번째 트랜잭션이 commit/abort를 해야한다.

더티 읽기 막기

위와 같은 잠금을 사용해 읽기도 막으면 가능하다. 읽기 전에 잠금을 획득하고 읽은 후에 바로 잠금을 해제하는 것이다. 이렇게 하면 갱신되었으나 아직 커밋되지 않은 데이터는 아무도 읽을 수 없게 된다.

그러나 이 방법은 성능이 안좋다. 읽기만 하는 여러 트랜잭션이 오랫동안 실행되는 하나의 쓰기 트랜잭션을 기다려야 할 수도 있기 때문이다. 따라서 대부분의 DB는 갱신된 객체에 대해 과거의 값과 아직 커밋되지 않은 트랜잭션에서 갱신한 값을 모두 가지고 있다가 그 트랜잭션이 커밋하기 전에는 과거의 값을 돌려준다.

문제

커밋 후 읽기 격리를 사용해도 동시성 버그가 생길 수 있다.

Fig.7-6

위 상황에서 앨리스에게 잔고 100이 사라진 것처럼 보인다. 이런 현상을 비반복 읽기(nonrepeatable read) 또는 **읽기 스큐(read skew)**라고 한다. 읽기 스큐는 지속적인 문제는 아니다. 그러나 이런 일시적인 비일관성을 감내할 수 없는 경우도 있다.

  • 백업: 백업은 몇시간까지 걸릴 수 있다. 백업이 실행 중일때도 DB는 계속 업데이트된다. 이 경우 이 백업을 사용해서 복원을 하면 비일관성이 계속 있을 수 있다.
  • 분석 질의/무결성 확인: DB의 큰 부분을 질의할 때 또는 주기적인 무결성 확인 시 잘못된 결과를 반환할 수 있다.

스냅숏 격리는 이런 문제의 가장 흔한 해결책이다.

스냅숏 격리와 반복 읽기

각 트랜잭션은 DB의 일관된 스냅숏으로부터 읽는다. 스냅숏 격리는 읽기와 쓰기가 서로 차단하지 않는다.

구현

더티 쓰기 방지를 위해 커밋 후 읽기처럼 쓰기 잠금을 사용한다. 그러나 더티 읽기 방지를 위해 잠금을 사용할 필요가 없다.

DB는 진행중인 트랜잭션이 서로 다른 시점의 DB를 볼 수 있게 해야한다. DB가 동시에 여러 버전의 객체를 유지하는 방법을 **다중 버전 동시성 제어(multi-version concurrency control, MVCC)**라고 한다.

Fig.7-7

  • Postgresql에서 MVCC를 구현하는 방법

각 트랜잭션은 계속 증가하는 트랜잭션 id를 할당 받는다. 트랜잭션이 객체를 갱신하면 이전 객체의 deleted by에 해당 트랜잭션 id를 저장하고 새 객체를 만든다. 그리고 DB는 다음 목록에 해당하는 객체는 숨긴다.

  1. DB는 트랜잭션이 시작할 때 그 시점에 진행중인 모든 트랜잭션 목록을 만들고 이 트랜잭션들이 쓴 데이터는 무시한다.
  2. abort된 트랜잭션도 무시한다.
  3. 트랜잭션 id가 더 큰 트랜잭션이 쓴 데이터도 무시한다.

즉 트랜잭션은

  1. 시작할 때 커밋된 트랜잭션이 쓴 데이터
  2. deleted by == null 또는 트랜잭션 시작 시 deleted by를 한 트랜잭션이 커밋되지 않은 데이터 를 볼 수 있다.

갱신 손실 방지

앞의 두 격리 수준은 동시에 실행되는 쓰기가 있을 때 읽기 전용 트랜잭션이 무엇을 볼 수 있는지에 대한 문제에 대한 것이었다. 두 트랜잭션이 동시에 쓰기를 할 때 생기는 문제는 더티 쓰기만 다루었다. 두 트랜잭션이 동시에 쓸 때 발생하는 문제는 **갱신 손실(lost update)**도 있다. 두 트랜잭션이 값을 읽고 갱신 할 때 (read-modify-write 주기)에서 동기화 문제가 생길 수 있다. 예를들어

  1. 두 트랜잭션이 같은 값을 읽고 (x=1), x = x+1=2로 update한다.
  2. JSON 배열에 객체를 추가한다. 이 경우 첫번째 쓰기는 두번째 쓰기에 의해 무시된다.

해결책

원자적 쓰기 연산

애플리케이션에서 read-modify-write 주기를 구현할 필요 없이 DB가 제공하는 원자적 갱신 연산을 사용한다.

UPDATE counter SET x = x + 1;

몽고DB 같은 문서 DB도 JSON 문서의 일부를 지역적으로 변경하는 원자적 연산을 제공한다.

그러나 모든 쓰기가 쉽게 원자적 연산으로 표현할 수는 없다.

명시적인 잠금

애플리케이션이 갱신할 객체를 명시적으로 잠그고 다른 애플리케이션은 잠금이 해제될 때 까지 기다리면 된다. 그러나 어디선가 잠금을 해제하는 것을 잊어버려 경쟁조건을 유발하기가 쉽다.

경쟁 손실 자동 감지

또는 그냥 병렬 실행을 허용하고 DB가 충돌을 감지해 트랜잭션을 abort 시키는 방법도 있다. 이 방법의 이점은스냅숏 격리와 결합해 효율적으로 수행할 수 있다는 장점이 있다.

Compare-and-set

트랜잭션을 제공하지 않는 DB중에 원자적 compare-and-set연산을 제공하는 것도 있다. 이 연산은 마지막으로 읽은 후 변경되지 않았을 때만 갱신을 허용해 갱신손실을 피한다. 예를들면

UPDATE page SET content="new" WHERE id=1 AND content="old";

그러나 DB가 where절이 오래된 스냅숏으로 부터 읽는 것을 허용하면 잘 작동하지 않는다.

복제된 DB

복제된 DB에서 갱신손실을 막는 것은 다른 차원의 문제이다. 복제된 DB는 보통 여러 쓰기가 동시에 실행되고 비동기적으로 복제가 되므로 잠금과 compare-and-set을 이용할 수 없다. 대신 충돌된 버전을 허용하고 나중에 충돌을 해소하는 방법을 많이 사용한다.

원자적 연산은 복제 상황에서도 잘 작동한다. 특히 commutative한 연산은.

쓰기 스큐와 팬텀

동시 쓰기가 유발하는 문제는 또 있다. on_call=true인 사람이 최소 한명 이상 있어야 한다고 해 보자. Fig.7-8

위와 같은 현상을 쓰기 스큐(write skew)라고 한다. 두 트랜잭션이 다른 객체를 갱신하므로 더티 쓰기도 갱신 손실도 아니다.

  • 여러 객체가 관련되므로 원자적 계산은 도움이 안된다.
  • 일부 스냅숏 격리 구현에서 제공되는 갱신손실감지도 도움되지 않는다.
  • 직렬성 격리를 사용할 수 없다면 의존하는 row를 명시적으로 잠그는 것이 차선이다. (for update)

다른 예

  • 회의실 예약. 해당하는 시간에 예약이 없는지 확인한 후 예약 row를 insert한다.
  • 사용자명. 해당 사용자가 없는 것을 확인한 후 사용자를 insert한다.

쓰기 스큐는 비슷한 패턴을 가진다.

  1. 어떤 검색 조건에 부합하는 row를 선택 수 요구사항을 확인한다.
  2. 그 결과에 따라 애플리케이션은 처리를 분기한다.
  3. 업데이트하기로 결정 했으면 DB에 쓴다. 이 결과로 2를 결정하는 조건이 바뀐다. 이처럼 트랜잭션에서 쓴 결과가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 **팬텀(phantom)**이라고 한다.

의사 호출 예제에서는 잠글 수 있는 row가 있었지만 다른 예에서는 잠글 수 있는 row가 없다.

해결법

회의실 예약 예제에서 시해당 시간에 해당하는 row들을 미리 만들어 놓으면 해결할 수 있다. 이와 같이 팬텀을 DB에 존재하는 구체적인 row에 대한 잠금충돌로 바꾸는 방법을 충돌 구체화(materializing conflit)라고 한다. 그러나 이 방법은 알아내기 어렵고 오류가 발생하기도 쉽다. 그렇기 때문에 대부분의 경우 직렬성 격리 수준이 더 선호된다.

직렬성

직렬성 격리수준은 여러 트랜잭션이 병렬로 실행되도 직렬로 실행된 것과 동일한 결과를 보장한다. 따라서 모든 경쟁조건을 막을 수 있다.

오늘날 대부분의 DB는 직렬성을

  • 진짜로 순차적으로 실행
  • 2단계 잠금
  • 직렬성 스냅숏 격리 를 통해 구현한다.

실제적인 직렬 실행

한번에 트랜잭션 하나씩 단일 스래드에서 진행하면 된다.

  • 트랜잭션 시간이 아주 짧고
  • 모든 활성화된 데이터가 메모리에 있고(오랫동안 사용하지 않는 데이터는 디스크에 있어도 ok)
  • 단일 CPU에서 실행할 정도로 처리량이 낮을 때 는 괜찮은 선택이다.

처리량이 CPU 코어 하나에 제한되기 때문에 단일 스래드를 최대한 활용하려면 트랜잭션이 다르게 구조화되어야 한다.

Stored procedure

보통 애플리케이션이 구문을 하나씩 실행한다. 그런데 이 경우 애플리케이션과 DB 사이의 네트워크 통신이 너무 길다. 따라서 하나의 트랜잭션을 한꺼번에 DB에 제출하면 네트워크 비용을 줄일 수 있다.

  • DB마다 스토어드 프로시저용 언어가 다를 수 있다. 또 이 언어들은 오늘날 범용 언어의 발전을 따라잡지 못해 대부분 조잡하다. 그러나 현대의 스토어드 프로시저 구현은 lua, java 같은 범용 언어를 사용하기 때문에 해결할 수 있다.
  • DB에서 실행되는 코드는 디버깅도 어렵고 버전 관리 및 배포가 불편하는 등 관리가 어렵다.
  • 보통 여러 애플리케이션이 하나의 DB를 공유하기 때문에 잘못 작성된 오랜 시간이 걸리는 스토어드 프로시저는 유쾌하지 않은 상황을 유발한다.

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

여러 파티션에 결친 트랜잭션도 쓸 수 있지만 제한이 있다.

2단계 잠금(2PL)

약 30년동안 직렬성을 구현하는데 널리 쓰인 알고리즘. 2단계 잠금(two-phase locking, 2PL).

  • 잠금에 두가지 모드가 있다: 공유모드, 독점 모드.

  • 트랜잭션은 읽을 때 공유 모드 잠금을 얻어야 한다.

  • 트랜잭션은 갱신할 때 독점 모드 잠금을 얻어야 한다.

  • 독점 모드 잠금은 모든 공유 모드 잠금을 가지고 있는 트랜잭션이 없을 때 혼자만 가질 수 있다.

  • 2PL은 스냅숏 격리와 다르게 읽기와 쓰기가 서로 막는다.

  • 2PL은 dead lock이 발생하기 쉽다.

    • 두 트랜잭션이 같은 row를 읽은 후 쓰려고 할 때.
    • 이때 DB는 교착상태를 감지하고 하나를 abort 시킨다.

성능

  • 2PL의 가장 큰 단점은 성능.
  • 동시성이 줄어들기 때문에 완화된 격리수준보다 응답시간이 크게 저하된다.
  • 잠금 기반 커밋 후 읽기 격리보다 교착생태가 훨씬 더 자주 발생한다.
  • 많은 데이터를 읽는 트랜잭션이 있으면 성능이 크게 저하된다.

서술 잠금(Predicate lock)

직렬성 격리는 팬텀을 막아야 한다. 개념상 서술잠금이 필요하다.

SELECT * FROM page WHERE id > 5 AND id < 10;

서술 잠금은 특정 객체가 아니라 어떤 검색 조건에 부합하는 객체를 잠구어 미래의 객체도 잠글 수 있다..(id > 5 and id < 10)

색인 범위 잠금

그런데 진행중인 트랜잭션이 획득한 잠금이 많으면 잠금을 확인하는데 시간이 오래 걸린다. 그래서 2PL을 구현하는 대부분의 DB는 색인 범위 잠금(index-range locking)을 사용한다. 서술 잠금보다 더 많은 범위의 객체를 index를 이용해 잠근다. 아마 해당 column에 index가 걸려있을 것이므로... 해당 index가 없으면 전체 테이블을 잠글 수 있다.

다른 트랜잭션이 갱실할 때 색인도 갱신해야하므로 색인을 잠그면 팬텀을 막을 수 있다. 정밀하지는 않지만 오버헤드가 낮다.

직렬성 스냅숏 격리(SSI)

2008년에 처음 기술된 알고리즘. Serializable snapshot isolation, SSI

  • 2PL은 비관적 동시성 제어이다. 잘못될 가능성이 있으면 괜찮아질때까지 기다린다.

  • 직렬실행은 전체 DB에 독점적인 잠금을 거는 것으로 볼 수 있으므로 극단적인 비관적 제어이다.

  • SSI는 반대로 낙관적 제어이다. 일단 해보고 충돌이 생기면 abort한다.

  • 경쟁이 심하면 성능이 감소하나 예비 처리량이 충분하고 트랜잭션이 너무 경쟁하지 않으면 비관적 제어보다 성능이 좋다.

  • 또한 경쟁은 commutative 원자적 연산을 이용해 줄일 수 있다. (카운터 증가)

쓰기 스큐는 갱신된 전제에 기반한 결정때문에 생긴다. DB가 트랜잭션 도중에 전제가 바뀌었는지를 감지할 때 두가지 상황을 고려해야한다.

  • 오래된 MVCC(다중 버전 동시성 제어) 객체 버전을 읽었는지: 읽기 전에 커밋되지 않은 쓰기 발생
  • 과거의 읽기에 영향을 미치는 쓰기 감지: 읽은 후에 쓰기 실행

오래된 MVCC 읽기 감지하기

  • 트랜잭션이 커밋할 때 DB의 무시한 트랜잭션 중 커밋된 트랜잭션이 있는지 검사한다.
  • 왜 오래된 읽기가 감지했을 때 abort 시키지 않고 커밋할때까지 기다릴까?
    • 트랜잭션이 읽기 전용이라면 상관 없다.
    • 갱신한 트랜잭션이 abort 될 수도 있다.

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

  • 색인 범위 잠금과 비슷하지만 다른 트랜젝션을 차단하지 않는다.

Fig.7-11

  1. 읽을 때 사용한 색인을 기록한다.(트랜잭션이 끝나고 동시에 실행되는 트랜잭션이 모두 끝나면 버려도 된다.)
  2. 트랜젝션이 DB에 쓸 때 영향받는 데이터를 최근에 읽은 트랜잭션이 있는지 색인에서 확인하고 알려준다.
  3. 커밋할 때 2에서 알림받은 트랜잭션이 커밋되었다면 어보트한다.

성능

  • 2PL과 비교했을 때 다른 트랜잭션이 잡고있는 잠금을 기다리지 않아도 되어 질의 지연시간 예측을 쉽게 만들고 변동도 적게 만든다.
  • 읽기 전용 트랜잭션은 어떤 잠금도 없이 일관된 스냅숏 위에서 이뤄진다.
  • 순차 진행과 비교시 단일 CPU 처리량에 제한받지 않는다.
  • SSI도 abort 비율에 영향을 받으므로 짧은 read-write 트랜잭션을 요구한다. 그래도 2PL/순차진행 보다는 느린 트랜잭션에 덜 민감하다.

정리

문제들

  • 더티 읽기
  • 더티 쓰기
  • 읽기 스큐(비반복 읽기)
  • 갱신손실
  • 쓰기 스큐
  • 팬텀 읽기