Home 데이터베이스 트랜잭션 - 1
포스트
취소

데이터베이스 트랜잭션 - 1

데이터베이스 트랜잭션

우리는 항상 트랜잭션 없이 코딩하는 것보다. 트랜잭션을 과용해서 병목지점이 생기는 성능 문제를 애플리케이션 프로그래머가 처리하게 하는 게 낫다고 생각한다.?!

정의

  • 어플리케이션에서 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법
  • 개념적으로 하나의 트랜잭션 내 모든 읽기와 쓰기는 한 연산으로 실행된다.
  • 트랜잭션은 전체가 성공(커밋)하거나 실패(어보트)한다.
  • 어떤 안정성 속성은 트랜잭션 없이도 안정성을 보장할 수 있고, 모든 애플리케이션에서 트랜잭션을 필요하지는 않는다. 때로는 트랜잭션적인 보장을 완화하거나 아예 쓰지 않는게 이득인 경우도 있다 (가용성이나 성능적 측면에서)
  • 트랜잭션이 필요한지 어떻게 알 수 있을까?

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

  • 최근의 데이터베이스들이 복제, 파티셔닝 기능을 제공하기 시작하면서 트랜잭션은 이 움직임에 피해자가 됨
    • 새로운 세대의 데이터베이 중 다수는 트랜잭션을 완전히 포기하거나 과거에 인식되던 것 보다 훨씬 약한 보장을 지원한다.
  • 값진 데이터가 있는 중대한 애플리케이션에만 필수고 그 외 높은 성능과 고가용성을 유지하려면 트랜잭션을 포기해야한다는 믿음이 널리 퍼지기도 했다. 두 관점 모두 완전한 과장이다.
  • 진실은 단순하지 않고 상황에 따른 트레이드오프를 생각하면서 적용해야한다.

ACID란?

  • 원자성 Atomicity, 일관성 Consistency, 격리성 Isolation, 지속성 Durability

원자성

  • ACID의 맥락에서 보면 원자성은 동시성과 관련이 없다!?
  • 원자성은 여러 프로세스가 동시에 같은 데이터에 접근하려고 할 때 무슨 일이 생기는지 설명하지는 않는다.
    • 오히려 이 문제는 격리성에서 다룬다
  • 대신 ACID의 원자성은 클라이언트가 쓰기 작업 몇 개를 실행하려고 하는데 그중 일부만 처리된 후 결함이 생기면 무슨 일이 생기는지 설명한다.
    • ::여러 쓰기 작업이 하나의 원자적인 트랜잭션으로 묶여 있는데 결함 때문에 완료(커밋) 될 수 없다면 어보트 되고 데이터베이스는 이 트랜잭션에서 지금까지 실행한 쓰기를 무시하거나 취소해야 함::
  • 이런 이유 때문에 아마도 어보트 능력이 원자성 보다 더 나은 단어라고 볼 수 있다.

일관성

  • ACID 일관성은 항상 진실이어야 하는, 데이터에 관한 어떤 선언(불변식)이 있다는 것
    • 예를 들어 회계 시스템에서 모든 계좌에 걸친 대변과 차변은 항상 맞아야 한다.
    • 트랜잭션이 이런 불변식이 유효한 데이터베이스에서 시작하고 트랜잭션에서 실행된 모든 쓰기가 유효성을 보전한다면 불변식이 항상 만족된다고 확신할 수 있다.
  • 그러나 이는 애플리케이션의 책임이 강하고 데이터베이스가 보장할 수 있는게 아니다.
    • 외래키, 유니크 제약 조건 같이 제약조건을 걸 순 있어도.
  • 원자성, 격리성, 지속성과 다르게 일관성은 사실상 어플리케이션의 속성이라고 볼 수 있다.

격리성

  • ::동시에 실행되는 트랜잭션은 서로 격리된다는 것을 의미, 트랜잭션은 다른 트랜잭션을 방해할 수 없다.::

지속성

  • 트랜잭션이 성공적으로 커밋됐다면 하드웨어 결함이 발생하거나 데이터베이스가 죽더라도 트랜잭션에서 기록된 모든 데이터는 손실되지 않는다는 보장

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

  • ACID에서 원자성과 격리성은 클라이언트가 한 트랜잭션 내에서 여러 번의 쓰기를 하면 데이터베이스가 어떻게 해야 하는지를 서술한다.
    • 원자성
      • ::데이터베이스는 전부 반영되거나 아무것도 반영되지 않는 것을 보장함으로써 부분 실패를 걱정할 필요가 없게 도와준다.::
    • 격리성
      • ::한 트랜잭션이 여러 번 쓴다면 다른 트랜잭션은 그 내용을 전부 볼 수 있든지 아무것도 볼 수 없든지 둘 중 하나여야 하고 일부분만 볼 수 있어서는 안된다.::
  • 다중 객체 트랜잭션은 어떤 읽기 연산과 쓰기 연산이 동일한 트랜잭션에 속하는지 알아낼 수 단이 있어야 한다.
    • 관계형 데이터베이스에서 이것은 전형적으로 클라이언트와 데이터베이스 서버 사이의 TCP 연결을 기반으로 한다.
    • 어떤 특정 연결 내에서 BEGIN TRANSACTION 문과 COMMIT 문 사이의 모든 것은 같은 트랜잭션에 속하는 것으로 여겨진다.

단일 객체 쓰기

  • 단일 객체를 변경하는 경우도 원자성과 격리성이 요구되는데 예를 들면
    • 20KB의 Json 문서를 데이터베이스에 쓴다고 가정하면
      • 첫 10KB를 보낸 후에 네이트워크 연결이 끊기면 데이터베이스는 파싱 불가능한 10KB Json 조각을 저장할 것인가?
      • 데이터베이스가 디스크에 저장된 기존 값을 덮어쓰는 도중에 전원이 나가면 기존 값과 새 값이 함께 붙어있게 될까?
      • 문서를 쓰고 있을 때 다른 클라이언트에서 그 문서를 읽으면 부분적으로 갱신된 값을 읽게 될까?
  • 원자성은 장애복구 로그를 써서 해결하고, 격리성은 각 객체에 잠금을 걸어 해결할 수 있다. 또 증감 같이 복잡한건 compare-and-set 연산으로 해결 가능하다.
  • 이런 단일 객체 연산은 여러 클라이언트에서 동시에 같은 객체를 쓰려고 할 때 갱신 손실을 방지하므로 유용하다.
  • 하지만 트랜잭션은 보통 다중 객체에 대한 다중 연산을 하나의 실행 단위로 묶는 메커니즘으로 우리는 보통 이해된다.

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

  • 많은 분산 데이터베이스들은 다중 객체 트랜잭션 지원을 포기했다. 다중 객체 트랜잭션은 여러 파티션에 걸쳐서 구현하기가 어렵고 높은 가용성과 성능이 필요한 곳에서는 방해가 되기 때문이다.
  • 단일 객체 쓰기만으로 어플리케이션을 만들 수 도 있고, 트랜잭션이 없더라도 다중 객체 쓰기를 지원하는 어플리케이션을 만들 순 있지만, 원자성이 없으면 오류 처리가 훨씬 복잡해지고 격리성이 없으면 동시성 문제가 생길 수 있다.

오류와 어보트 처리

  • 트랜잭션의 핵심 기능은 오류가 생기면 어보트되고 안전하게 재시도할 수 있다는 것이다.
  • 모든 시스템이 이런 철학을 따르지는 않느다.
  • 어보트의 취지는 안전하게 재시도를 할 수 있는데 있다.
  • 어보트된 트랜잭션을 재시도하는 것은 효과적인 오류 처리 메커니즘이지만 완벽하지는 않다.
    • 주로 트랜잭션 안에 부수효과가 있으면 그럴 가능성이 크다
    • 오류가 과부하 때문이라면 트랜잭션 재시도는 문제를 개선하는 게 아니라 악화시킬 수 있다.
    • 영구적인 오류는 아무리 재시도해도 의미가 없다.

완화된 격리 수준 (=비직렬성)

  • 동시성 문제는 트랜잭션이 다른 트랜잭션에서 동시에 변경한 테이터를 읽거나 두 트랜잭션이 동시에 같은 데이터를 변경하려고 할 때만 나타난다.
  • 직렬성 격리는 데이터베이스가 여러 트랜잭션들이 직렬적으로 실행되는 것과 동일한 결과가 나오도록 보장한다는 것
    • 이건 완화가 안된 격리
  • 하지만 현실에서는 그리 간단하지는 않다. 직렬성 격리는 성능 비용이 있고 많은 데이터베이스들은 그 비용을 감당하지 않는다.

커밋 후 읽기 (=read committed)

  • 오라클의 기본 격리 수준
  • 이 수준에서는 딱 두가지를 보장해준다.
    • ::데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다 (dirty read가 없음)::
    • ::데이터베이스에서 쓸 때 커밋된 데이터만 보게 된다 (dirty write가 없음)::

Dirty Read 방지?

  • 다른 트랜잭션에서 커밋되지 않은 데이터를 볼 수 있을까? 만약 그렇다면 이를 Dirty Read라고 부른다.
    • ::즉, 한 클라이언트가 다른 클라이언트가 썼지만 아직 커밋되지 않은 데이터를 읽는 상황.::
  • 커밋 후 읽기 격리 수준에서는 더티 읽기를 막아야한다. 트랜잭션이 쓴 내용은 커밋된 후에야 다른 트랜잭션에게 보인다는 뜻이다.
  • 더티 읽기를 막는 게 유용한 이유
    • 부분적으로 갱신된 데이터를 보는건 사용자에게 혼란을 야기하면 다른 트랜잭션이 잘 못된 결정을 하는 원인이 될 수도 있다.
    • 더티 읽기를 허용하면 트랜잭션이 나중에 롤백될 데이터, 결론적으로는 데이터베이스에 커밋되지 않을 데이터를 볼 가능성이 있다 이는 복잡한 문제를 야기할 가능성이 높다.

Dirty Write 방지

  • 먼저 쓴 내용이 아직 커밋되지 않은 트랜잭션에서 쓴 것이고 나중에 실행된 쓰기 작업이 커밋되지 않은 값을 덮어써버리면 이를 Dirty Write라고 부른다.
    • ::즉, 한 클라이언트가 다른 클라이언트가 썼지만 아직 커밋되지 않은 데이터를 덮어쓰는 상황.::
  • 보통 먼저 쓴 트랜잭션이 커밋되거나 어보트될 때까지 두번째 쓰기를 지연시키는 방법을 사용한다.
  • 더티 쓰기를 막는 것 만으로도 몇 가지 동시성 문제는 해결된다.
  • ::그러나 갱신 손실 방지 이슈에서는 해결이 안된다.::
    • 갱신 손실
      • 두 클라이언트가 동시에 read-modify-write 주기를 실행한다고 했을 때 한 트랜잭션이 다른 트랜잭션의 변경을 포함하지 않은 채로 다른 트랜잭션이 쓴 내용을 덮어써서 데이터가 손실된다.

커밋 후 읽기 (=read committed) 구현하기

  • 가장 흔한 방법으로는 데이터베이스는 row 수준의 잠금을 사용해 더티 쓰기를 방지한다.
  • 더티 쓰기를 방지
    • 트랜잭션에서 특정 객체(row or document)를 변경하고 싶다면 먼저 해당 객체에 대한 잠금을 획득해야 한다. 그리고 트랜잭션이 커밋되거나 어보트될 때까지 잠금을 보유하고 있어야 한다.
    • 즉, 오직 한 트랜잭션만 어떤 주어진 객체에 대한 잠금을 보유할 수 있다.
    • 여기서 말하는 잠금은 베타적 잠금(=쓰기잠금, =exclusive lock)
  • 더티 읽기 방지
    • 위와 같은 방법으로 동일한 잠금을 사용해서 더티 읽기를 막는건 좋지 않다.
      • 읽기만 실행하는 여러 트랜잭션들이 오랫동안 실행되는 쓰기 트랜잭션 하나가 완료될 때까지 기다려야 할 수 있기 때문이다. 읽기만 실행하는 트랜잭션들의 응답 시간에 해를 끼치며 성능이 안좋다.
    • 요즘 데이터 베이스는 보통 다음과 같이 한다.
      • 대부분의 데이터베이스에서는 쓰여진 모든 객체에 대해 데이터베이스는 과거에 커밋된 값과 현재 쓰기 잠금을 가지고 있는 트랜잭션에서 쓴 새로운 값을 모두 기억한다.
      • 해당 트랜잭션이 실행 중인 동안 그 객체를 읽는 다른 트랜잭션들은 과거의 값을 읽게 된다 (undo영역에 있는 데이터를 가져온다)
      • 새 값이 커밋되어야만 다른 트랜잭션들이 새 값을 읽을 수 있게 된다.

Snapshot 격리와 반복 읽기 (repeatable Read)

  • 커밋 후 읽기 격리 수준이 모든 동시성 이슈를 해결해 줄 것 같지만 사실 아니다
  • ::클라이언트는 다른 시점에 데이터베이스의 다른 부분을 본다. 이러다보면 비반복 읽기 (=읽기 스큐)의 문제가 생길 수 있다.::
    • ::스냅숏 격리는 이런 문제의 가장 흔한 해결책이다.::
    • ex) 나의 A,B 계좌에 각각 500원씩 있고 나의 계좌 B에서 꺼내서 A의 계좌에 100원씩 송금을 하는 트랜잭션을 실행한다. 운이 안좋게 트랜잭션이 처리되는 도중에 A잔고를 보면 500원이 보일거고 트랜잭션이 끝난뒤에 B계좌를 보면 400원이 보일거다 총합이 900원처럼 보이게 된다.
    • 물론 재조회하면 정상적으로 조회 될 것이다. 이런 일시적인 비일관성을 그냥 허용하는 시스템도 있다.
  • 각 트랜잭션은 데이터베이스의 일관된 스냅숏으로 부터 읽는다.
    • ::즉, 트랜잭션은 시작할 때 데이터베이스에 커밋된 상태였던 모든 데이터를 본다. 데이터가 나중에 다른 트랜잭션에 의해 바뀌더라도 각 트랜잭션은 특정한 시점의 과거 데이터를 볼 뿐이다.::
    • 즉, 특정 시점의 스냅숏 정보를 기반으로 잠금을 걸지않고 읽는다.

스냅숏 격리 구현하기

  • 스냅숏 격리 구현은 커밋 후 읽기 격리처럼 전형적으로 더티 쓰기를 방지하기 위해서 쓰기 잠금을 사용한다.
  • 그러나 읽을 때는 아무 잠금도 필요 없다.
  • 읽는 쪽에서 쓰는 쪽을 결코 차단하지 않고 쓰는 쪽에서는 읽는 쪽을 결코 차단하지 않는다.
    • 데이터베이스는 잠금 경쟁 없이 쓰기 작업이 일상적으로 처리되는 것과 동시에 일관성 있는 스냅숏에 대해 오래 실행되는 읽기 작업을 처리할 수 있다.
  • 데이터베이스가 객체의 여러 버전을 함께 유지하므로 이 기법은 다중 버전 동시성 제어 (MVCC) 라고도 한다.

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

  • 트랜잭션은 데이터베이스에서 객체를 읽을 때 트랜잭션ID를 사용해 어떤 것을 볼 수 있고 어떤 것은 볼 수 없는지 결정한다.
    1. 데이터베이스는 각 트랜잭션을 시작할 때 그 시점에 진행 중인(아직 커밋이나 어보트가 되지 않은) 모든 트랜잭션의 목록을 만든다. 이 트랜잭션들이 쓴 데이터는 모두 무시된다. 설령 데이터를 쓴 트랜잭션이 나중에 커밋되더라도 마찬가지다.
    2. 어보트된 트랜잭션이 쓴 데이터는 모두 무시된다.
    3. 트랜잭션 ID가 더 큰(즉, 현재 트랜잭션이 시작한 후에 시작한) 트랜잭션이 쓴 데이터는 그 트랜잭션의 커밋 여부와 관계없이 모두 무시된다.
    4. 그 밖의 모든 데이터는 어플리케이션의 질의로 볼 수 있다.
  • 간단하게 말하면 두 조건이 참이면 객체를 볼 수 있다.
    • 읽기를 실행하는 트랜잭션이 시작한 시점에 읽기 대상 객체를 생성한 트랜잭션이 이미 커밋된 상태
    • 읽기 대상 객체가 삭제된 것으로 표시되지 않았다. 또는 삭제된 것으로 표시됐지만 읽기를 실행한 트랜잭션이 시작한 시점에 삭제 요청 트랜잭션이 아직 커밋되지 않았다.

색인과 스냅숏 격리

  • 다중 버전 데이터베이스 (스냅샷) 에서 색인은 어떻게 동작할까?
    • 하나의 선택지는 단순하게 색인이 객체의 모든 버전을 가리키게 하고 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 하는 것
  • 디비마다 조금 씩 다르다.

갱신 손실 방지

  • ::만약 두 트랜잭션이 이 작업을 동시에 하면 두 번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 변경 중 하나는 손실될 수 있다.::
    • 나중에 쓴 것이 먼저 쓴 것을 때려눕힌다고 표현하기도 한다.
  • 예시
    • 카운터를 증가시키거나 계좌 잔고를 갱신할때 (현재 값을 읽어서 새 값을 계산하고 갱신된 값을 다시 써야하는 경우)
    • 복잡한 값을 지역적으로 변경하는 경우 (Json 문서 내에 있는 특정 리스트 필드에 값을 추가)
    • 사용자가 편집한 내용을 저장할 때 전체 페이지 내용을 서버에 보내서 현재 데이터베이스에 저장된 내용을 겊어 쓰도록 만들어진 위키에서 두명의 사용자가 동시에 같은 페이지를 편집하는 경우

원자적 쓰기 연산

1
UPDATE counters SET value = value + 1 WHERE key = 'foo'
  • 위 쿼리와 같이 조회 + 변경을 하나의 명령어로 제공해 원자적 갱신 연산을 제공하는 데이터 베이스도 있다.
  • 어플리케이션에서 read-modify-write 주기를 구현할 필요를 없애 준다.
  • 다만 모든 쓰기가 쉽게 원자적 연산으로 표현되진 않는다. 위의 위키 페이지 수정 같은 경우도 그렇다.
  • 원자적 연산은 보통 객체를 읽을 때 그 객체에 베타적 잠금을 획득해서 구현한다.
  • 혹은 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 것 이다.

명시적 잠금

  • 데이터베이스에 내장된 원자적 연산이 필요한 기능을 제공 하지 않을 때 갱신 손실을 막는 또 다른 선택지는 어플리케이션에서 갱신할 객체를 명시적으로 잠그는 것 이다.
  • 그러면 어플리케이션에서는 read-modify-write 주기를 수행할 수 있다.
1
2
3
4
5
6
7
8
9
BEGIN TRANSACTION;

SELECT * FROM figures 
	WHERE name = 'robot' AND game_id = 12
	FOR UPDATE;	-- 베타적 잠금

UPDATE figures SET position = 'c4' WHERE id = 123;

COMMIT;
  • for update 절은 데이터베이스가 이 질의에 의해 반환된 모든 로우에 잠금을 획득해야 함을 가르킨다.

갱신 손실 자동 감지

  • 원자적 연산과 잠금은 read-modify-write 주기가 순차적으로 실행되도록 강제함으로써 갱신 손실르 방지하는 방법인데, 갱신 손실 자동 감지란 트랜잭션 병렬 실행을 허용한 뒤 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트 시키고 read-modify-write 주기를 재시도 하도록 강제하는 방법이다.
  • 장점은 데이터베이스가 이 확인을 스냅숏 격리와 결합해 효율적으로 수행할 수 있다는 것 이다.
  • 실제로 oracle의 직렬성, postgresQL의 반복 읽기는 갱신 손실이 발생하면 자동으로 발견해서 문제가 되는 트랜잭션을 어보트 시킨다.
  • 그러나 mysql inno DB 는 갱신 손실을 감지하지 않는다.

Compare-and-set

  • 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것.
    1
    2
    3
    4
    
    -- 데이터베이스 구현에 따라 안전할 수도 안전하지 않을 수도 있다.
    -- 비교 후 반영
    UPDATE wiki_pages SET content = 'new content'
    WHERE id = 1234 AND content = 'old_content';
    
  • 갱신이 적용되었는지 미리 확인하고 갱신이 되었다고 판단되면 실행하지 않는다.
  • 만약 그럼에도 불구하고 갱신이 필요하다면 재시도해야함
  • 그러나 데이터베이스가 where 절이 오래된 스냅숏으로부터 읽는 것을 허용한다면 이 구문은 갱신 손실을 막지 못할 수도 있다.

충돌 해소와 복제

  • 여러 노드에 복사본이 있어서 데이터가 다른 노드들에서 동시에 변경될 수 있으므로 갱신 손실을 방지하려면 추가 단계가 필요하다.
  • 지금까지 배웠던 잠금이나 compare-and-set 연산은 데이터의 최신 복사본이 하나만 있다고 가정했다. 그러나 다중 리더 (+비동기 복제) 상황이라면 최신 복사본이 하나만 있으리라고 보장할 수 없다.
    • 이런 상황에서는 잠금이나 compare-and-set 으로 해결 불가
    • 보통은 이런 상황에서는 여러 개의 충돌된 버전(형제=sibling)을 생성하는 것을 허용하고 즉, 동시 쓰기를 허용한 뒤 사후에 어플리케이션 코드나 특별한 데이터 구조를 사용해 충돌을 해소하고 이 버전들을 병합하는 방법을 많이 사용한다.

쓰기 스큐와 팬텀

쓰기 스큐

  • ::트랜잭션이 무언가를 읽고 읽은 값을 기반으로 어떤 결정을 하고 그 결정을 데이터베이스에 쓴다. 그러나 쓰기를 실행하는 시점에는 결정의 전제가 더이상 참이 아닌 상황::
  • 두개 이상의 다른 트랜잭션이 다른 객체를 갱신하므로 더티 쓰기도 갱신 손실도 아니다.
  • 직렬성 격리만 이런 상황을 막을 수 있다.
  • 직렬성 격리를 상요할 수 없다면 트랜잭션이 의존하는 로우를 명시적으로 잠그는게 차선책
  • 예시
    • 다중플레이어 게임
    • 게임에서 고유한 닉네임 획득

쓰기 스큐를 유발하는 팬텀 읽기

  • 어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 팬텀이라고 한다.
  • 예시)
    • ::어떤 검색 조건에 부합하는 로우가 존재하지 않는지 확인하고 쓰기 작업이 같은 조건에 부합하는 로우를 추가하는 기능이 있다고 가정하자::
    • ::그런데 그 검색 조건에 대한 질의 결과가 아무 로우도 반환하지 않으면?? select for update는 아무것도 잠글 수 없다. (잠글 row가 없으니깐!):: => 팬텀 현상
  • 스냅숏 격리는 간단한 팬텀 읽기는 막아주지만 쓰기 스큐 맥락에서 발생하는 팬텀은 색인 범위 잠금처럼 특별한 처리가 필요하다.
  • 충돌을 구체화해서 미리 관련된 로우를 만들어두는 것도 하나의 방법이지만 모든 상황마다 이렇게 할 수 없다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

상위 호환성, 하위 호환성 차이

데이터베이스 트랜잭션 - 2

Comments powered by Disqus.