All Articles

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

7장 트랜잭션

트랜잭션은 자연 법칙이 아니며, 프로그래밍 모델을 단순화 하기 위해 만든 것이다. (트랜잭션이 없어도 복잡한 애플리케이션을 구현할 수 있지만, 원자성이 없으면 오류 처리가 훨씬 더 복잡해지고 격리성이 없으면 동시성 문제가 생길 수 있다.)

ACID의 의미

1983년, 데이터베이스에서 내결함성 메커니즘을 나타내는 정확한 용어를 확립하기 위해 ACID를 만들었다. 하지만 그 뜻은 모호하며, 데이터베이스마다 ACID 세부 구현은 완전히 다르다.

  • Atomicity - 원자성 : 여러 변경을 적용하는 도중 오류가 발생했을 때, 어보트되고 해당 트랜잭션에서 기록한 모든 내용을 취소한다는 보장이다. Abortability가 더 나은 단어.
  • Consistency - 일관성 : 데이터가 항상 진실인 불변식(invariant)을 만족한다는 보장이다. 데이터의 유효성 및 애플리케이션의 정책적인 측면과 관련 있으며, ACID 중 유일하게 애플리케이션의 책임이다. (예를 들면 회계 프로그램에서 차변과 대변이 항상 같아야 한다는 정책) 데이터베이스는 불변식을 위반하는 잘못된 데이터를 쓰지 못하게 막을 수 없다. (단 Foreign Key, Unique 등은 데이터베이스에서 보장한다)
  • Isolation - 격리성 : 여러 트랜잭션이 동시에 같은 레코드에 접근하면 동시성 문제(경쟁 조건)이 생긴다. 이를 해결하기 위해 동시에 실행되는 트랜잭션은 서로 격리된다는 보장이 격리성이다. 트랜잭션은 다른 트랜잭션을 방해할 수 없다.

    이는 직렬성 격리와 스냅숏 격리로 구현된다.

    • 직렬성 격리(Serializable Isolation) - 동시에 트랜잭션이 실행되었어도, 순차적으로 실행되었을 때의 결과와 동일하도록 보장한다. 성능 손해가 있기 때문에 Real World에서는 거의 사용되지 않음.
    • 스냅숏 격리(Snapshot Isolation) - MVCC 등으로 구현한다.
  • Durability - 지속성 : 트랜잭션 커밋이 성공했다면 하드웨어 결함이 발생하거나 데이터베이스가 죽어도 데이터가 손실되지 않는다는 보장이다. Write-ahead log, 복제, 백업 등을 통해 구현한다.

어보트

트랜잭션의 핵심 기능이다. 오류가 생기면 어보트되고 안전하게 재시도할 수 있다.

리더 없는 복제(179p) 데이터스토어는 best-effort 원칙으로, 오류가 발생하면 이미 한 일은 취소하지 않는다. 애플리케이션에서 오류 처리를 해야 한다.

Rails의 액티브레코드, Django ORM 등은 어보트된 트랜잭션을 재시도하지 않는다. 어보트의 취지는 안전하게 재시도를 할 수 있는 것인데 말이다.

이상 현상

더티 읽기

데이터베이스에서 읽을 때 커밋된 데이터만 보게 된다.

필요한 이유: 부분적으로 갱신된 상태의 데이터베이스를 보면 혼란스러우며 다른 트랜잭션이 잘못된 결정을 하게 된다. 또한 롤백을 생각하면 머리가 아프다. (결코 커밋되지 않을 데이터를 읽으면 혼란스럽다)

더티 쓰기

data-intensive-system-7-5

데이터베이스에 쓸 때 커밋된 데이터만 덮어쓰게 된다. 즉 먼저 수행된 트랜잭션이 데이터를 썼지만, 나중에 수행된 트랜잭션이 이를 덮어써서 커밋하는 것.

해결: 먼저 쓴 트랜잭션이 커밋이나 어보트될 때까지 두 번째 쓰기를 지연시킨다. 지연을 위해 락을 잡는다. (MySQL: X Lock, PostgreSQL: RowExclusiveLock). 대부분의 트랜잭션 구현을 통해 방지할 수 있다.

갱신 손실

data-intensive-system-7-1

두 User가 동시에 counter를 올리는 바람에 경쟁 조건이 생겨서 43이 되었다.

동시 쓰기 작업 시 발생하는 쓰기 충돌 중 하나이다. read-modify-write 주기로 작업할 때 발생한다. 두 트랜잭션이 read-modify-write 작업을 하면 두 번째 쓰기 작업이 첫 번째 쓰기 작업을 포함하지 않으므로 하나는 손실된다. 더티 쓰기가 아닌 정상적인 프로세스임에도 결과값과 기대값이 다르다.

방지법

원자적 쓰기 연산 :

  1. 원자적 갱신 연산을 쓴다. UPDATE counters SET value = value + 1 WHERE key = 'foo'
  2. 해당 Row 읽을 때 Exclusive 락을 획득한다. IBM DB2에서는 Cursor Stability라는 Isolation Level이 있다. (해당 커서를 읽을 때 다른 트랜잭션은 해당하는 열을 업데이트 할 수 없다. 만약 해당 커서를 업데이트 한다면 트랜잭션이 끝날 때까지 락을 잡고 있는다) MySQL, PostgreSQL 등에서는 해당 개념이 없는 거 같다.
  3. 업데이트 로직을 단일 스레드에서 실행하도록 강제한다.

ORM 쓰면 불완전한 read-modify-write 코드를 작성하기 쉽다. 가급적 1을 쓸 수 있는지 확인하자.

명시적인 잠금 :

read-modify-write 주기에서 read 시에 해당 Row를 Exclusive 락 잡고 처리한다. 이러면 다른 트랜잭션에서 접근 못함.

갱신 손실 자동 감지

PostgreSQL의 repeatable read, oracle의 serializable, sql server의 snapshot isolation level은 read-modify-write 도중 갱신 손실을 발견하면 어보트 시킨다!!!

MySQL InnoDB의 Repeatable Read는 갱신 손실을 감지하지 않는다. 이로 인해 제대로 된 Snapshot Isolation을 구현하지 않고 있다고 하기도 한다.

Compare-and-set 연산

각 Row에다가 Version 붙이고, UPDATE 시마다 Version 이 변경하지 않았을 때만 진행하고 그 외에는 재시도한다. (오래된 스냅숏으로부터 읽었을 경우, 갱신 손실을 방지하지 못할 수 있음 - Repeatable Read 쓸 때 유의해야겠음)

Read Skew 혹은 Non-Repeatable Read 이상 현상

Repeatable Read 에서 생길 수 있는 현상이다. 더티 읽기가 아닌 정상적인 프로세스임에도, 시간 차이로 발생하는 이상 현상. 아래 사례는 Alice가 Account 1, Account 2를 가지고 있을 때, 아래와 같이 문제가 생기는 것을 말함.

  1. Account 1 조회 → 이 때에는 Account 1 조회 결과 500 달러
  2. Account 1 → Account 2 으로 100 달러 이체
  3. Account 2 조회 → 이 때에는 Account 2 조회 결과 400 달러

data-intensive-system-7-6

해당 케이스는 새로 고침 하면 별 문제가 없다. 그러나 백업이나 분석, 무결성 확인 워크로드에서는 크리티컬할 수 있다.

Snapshot Isolation를 통해 문제를 막을 수 있다.

Write Skew 이상

두 트랜잭션이 같은 객체를 읽어 그 중 일부를 갱신할 때 나타날 수 있는 정책적인 위반 사항이다.

두 트랜잭션이 두 개의 다른 객체를 갱신하므로, 더티 쓰기도 갱신 손실도 아니다. (더티 쓰기랑 갱신 손실은 같은 객체를 갱신할 때 발생 - 투자모집을 할 때 투자모집완료금액이 투자가능금액보다 큰 경우가 있었음)

아래 사례는 온콜 당직 수를 최소 1명으로 유지한다는 정책을 깰 수 있는 버그이다. (분명 트랜잭션 시작할 때는 두 트랜잭션 모두의 count(*) 결과가 2명이었는데 두 트랜잭션이 끝나니 0명이 되었다)

data-intensive-system-7-8

  • 자동 방지: 직렬성 격리가 필요하다. 모든 트랜잭션이 순차적으로 실행되기 때문에 문제가 방지된다.
  • 수동 방지: 트랜잭션이 의존하는 로우를 명시적으로 (SELECT FOR UPDATE) 잠글 수 있음.

    • 팬텀 현상: 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과. 빈 회의실을 찾아 중복 없이 예약하려는 경우 등. 빈 결과를 SELECT 한 후에 INSERT를 하는 경우에는 아무것도 잠글 것이 없으므로, 스냅숏 격리로는 안되고 직렬성 격리가 필요하다.

완화된 격리 수준

동시성 문제는 다른 트랜잭션에서 동시에 변경한 데이터를 읽거나, 두 트랜잭션이 동시에 같은 데이터를 변경하려고 할 때만 나타난다.

직렬성 격리는 성능 비용이 있어 잘 사용되지 않는다. 그래서 어떤 동시성 이슈는 보호해 주지만 모든 동시성 이슈는 보호해 주지 않는 완화된 격리 수준을 많이 사용한다. (그러나 이해하기 훨씬 더 어렵고 미묘한 버그를 유발한다. Poloniex 인출 기능에서 따닥을 막지 않아, 12.3% 비트코인을 손해 봤다.)

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

  • Read Uncommitted :

    • 더티 읽기 있음
    • 더티 쓰기 없음
  • Read Committed

    • MySQL 外 대부분 기본값
    • 더티 읽기 없음 (과거에 커밋된 값과 현재 트랜잭션에서 쓴 새로운 값 모두를 기억, 쓰기 잠금을 가지고 있지 않은 트랜잭션은 과거의 값을 읽음.)
    • 더티 쓰기 없음
    • 갱신 손실 있음
    • 읽기 스튜 있음
  • Snapshot Isolation

    • MySQL 기본값
    • 더티 읽기 없음

      • 트랜잭션이 시작할 당시 커밋된 상태였던 데이터를 읽고, 갱신할 때는 값을 교체하지 않고 새 버전을 생성 → 전체 트랜잭션에서 같은 트랜잭션을 읽을 수 있다. 각 객체마다 여러 개의 버전을 유지해야 하므로 MVCC 기법 구현
      • (fyi - Read Committed는 객체마다 버전 두개만 기억하면 됨. 그러나 아래의 Snapshot Isolation와 Read Committed를 동시에 지원하는 디비는 MVCC로 해당기능을 구현한다.)
      • PostgreSQL은 트랜잭션마다 계속 증가하는 txid를 만든다. 그런데 이 txid는 uint32 이기 때문에, VACUUM이 제대로 동작하지 않으면 txid Wraparound 문제가 생길 수 있다. 링크는 Sentry의 전면 장애 사례. https://blog.sentry.io/2015/07/23/transaction-id-wraparound-in-postgres
    • 더티 쓰기 없음
    • 읽는 쪽에서 쓰는 쪽을 차단하지 않고, 쓰는 쪽에서 읽는 쪽을 차단하지 않음.
    • 백업이나 분석 등 실행하는 데 오래 걸리며 읽기만 실행하는 쿼리에 요긴함.
    • MySQL, PostgreSQL에서는 Repeatable Read 이라고 부르고, 오라클에서는 Serializable 이라고 부르며, IBM DB2는 Repeatable Read를 Serializable 을 부를 때 사용한다. Repeatable Read가 무슨 뜻인지 실제로 아는 사람은 아무도 없다.

Serializable

  • 위의 완화된 격리 수준은 이해하기 어렵고 데이터베이스마다 구현의 일관성이 없다
  • 경쟁 조건을 감지하는 데 도움이 되는 좋은 도구가 없다 (동시성 문제는 보통 비결정적, 타이밍 이슈 발생)

답 : Serializable 써라!

  • 여러 트랜잭션이 병렬로 실행되더라도 최종 결과는 동시성 없이 한 번에 하나씩 직렬로 실행될 때와 같도록 보장되며, 모든 경쟁 조건을 막아준다. 가장 강력한 격리 수준이라고 여겨진다.

실제적인 직렬 실행

한 번에 트랜잭션 하나씩만 직렬로 단일 쓰레드에서 실행됨. (비관적 동시성 제어)

필요한 데이터가 메모리에 있고, 트랜잭션 코드가 스토어드 프로시저에 모두 있으면, IO 대기가 없고 다른 동시성 제어 메커니즘의 오버헤드를 회피하므로, 단일 스레드로 좋은 처리량을 얻을 수 있음

쓰기 처리량이 높으면 파티셔닝을 해야 하는데, 여러 파티네이션에 접근하는 트랜잭션은 성능이 낮으므로 사용하지 않게 주의한다

2단계 잠금 (2PL)

처음에 LOCK을 잡기만 하는(Expending Phase) 1단계와 나중에 LOCK을 놓기만 하는(Shrinking Phase) 2단계로 나뉜다고 해서 2PL이라고 불린다.

2PC(2 Phase Commit)이랑 완전 다르다!

스냅숏 격리는 읽는 쪽은 쓰는 쪽을 막지 않으며 쓰는 쪽도 읽는 쪽을 막지 않지만, 2PL은 쓰기 트랜잭션은 다른 쓰기 트랜잭션 뿐 아니라 읽기 트랜잭션도 진행하지 못하게 막으며, 읽기 트랜잭션은 다른 읽기 트랜잭션은 허용하지만 쓰기 트랜잭션은 진행하지 못하게 막는다!

MySQL의 Serializable은 2PL로 구현된다. MySQL은 SERIALIZABLE 구현은 REPEATABLE-READ 에다가 모든 SELECT 문을 SELECT FOR SHARE 문으로 인식한다. SELECT 시에는 무조건 S Lock이 걸린다. UPDATE/DELETE 시에는 X Lock을 획득해야 한다. X Lock과 S Lock은 상호 호환되지 않으므로, 읽기는 다른 읽기를 막진 않지만 쓰기를 막고(S Lock때문에), 쓰기는 다른 쓰기도 막고 읽기도 막게 된다(X Lock때문에).

Deadlock 교착 상태가 아주 많이 발생할 수 있으며 애플리케이션은 재시도해야 한다!

2PL - 서술 잠금

데이터베이스에 아직 존재하지 않지만, 미래에 추가될 수 있는 객체에 lock을 걸기 위해 서술 잠금(predicate locking) 획득/해제 하여 쓰기 스튜를 방지할 수 있음. (회의실 예약 문제에서 시간으로 Lock Range 잡기?)

2PL - 색인 범위 잠금

서술 잠금은 너무 구체적이기 때문에, 오버헤드를 줄이기 위해 index-range locking, next-key locking으로 구현한다. WHERE 조건 보다 더 넓은 범위의 인덱스를 lock 잡는 것으로 구현. Write Phantom 과 Write Skew 보호. 그러나 적절한 인덱스가 없으면 테이블 락을 잡는다.

직렬성 스냅숏 격리(SSI)

스냅숏 격리 + 직렬성 충돌 감지 시 어보트시킬 트랜잭션을 결정하는 알고리즘. 직렬성 스냅숏 격리는 완전한 직렬성을 제공하지만 스냅숏 격리에 비해 약간의 성능 손해만 존재한다!

2단계 잠금은 비관적 동시성 제어 메커니즘이다. 상호배제(mutex)와 비슷하다.

직렬성 스냅숏 격리는 낙관적 동시성 제어 기법. 위험한 상황이 발생할 수 있을 때, 일단 진행한다. 마지막에 커밋할 때 격리 위반을 확인하고 어보트한다.

쓰는 쪽은 읽는 쪽을 막지 않고, 반대도 마찬가지이며, 읽기 전용 질의는 어떤 잠금도 없이 일관된 스냅숏 위에서 실행된다.

어보트 비율이 성능 영향을 준다. 트랜잭션 동작을 상세하게 추적하면 정확해지지만 기록 오버헤드가 심해지며, 오랜 시간동안 데이터를 읽고 쓰는 트랜잭션은 충돌 후 어보트의 가능성이 높아짐. 읽기+쓰기 트랜잭션이 짧기를 요구한다.

쓰기 스큐처럼 질의 결과(전제 조건)와 쓰기 작업 사이 인과적 의존성이 있을 때, 전제 조건이 최신 결과가 아니면, 이를 감지해서 트랜잭션을 어보트시켜서 직렬성 격리를 제공한다.

오래된(stale) MVCC 객체 버전을 읽었는지 감지하기 (읽기 전에 커밋되지 않은 쓰기가 발생했음) → 일관된 스냅숏에서 읽을 때에는 무시됐던 쓰기가 지금은 영향이 있고 트랜잭션 43의 전제가 더 이상 참이 아님.

data-intensive-system-7-10

과거의 읽기에 영향을 미치는 쓰기 감지하기 (읽은 뒤에 쓰기가 실행됨) → 다른 트랜잭션을 차단하지 않음; 트랜잭션이 데이터베이스에 쓸 때 영향받는 데이터를 최근에 읽은 트랜잭션이 있는지 확인한다. 쓰기 잠금을 거는 것과 비슷하지만 커밋될 때까지 차단하지 않는다.

data-intensive-system-7-11

단일 CPU 코어의 처리량에 제한되지 않음.

어보트 비율은 SSI의 전체적인 성능에 큰 영향을 미친다. 이를테면 오랜 시간 동안 데이터를 읽고 쓰는 트랜잭션은 충돌이 나고 어보트되기 쉬워서, SSI는 읽기 쓰기 트랜잭션이 상당히 짧기를 요구한다 - 오래 실행되는 읽기 전용 트랜잭션은 괜찮다. 2PL이나 싱글쓰레드 실행보다는 덜 민감하다.

정리

  • 더티 읽기: 한 트랜잭션이 다른 트랙잭션이 썼지만 아직 커밋되지 않은 데이터를 읽는다. read committed 이상에서 방지됨.
  • 더티 쓰기: 한 트랜잭션이 다른 트랜잭션이 썼지만 아직 커밋되지 않은 데이터를 덮어쓴다. 거의 모든 트랜잭션 구현은 더티 쓰기를 방지함.
  • 읽기 스튜(비반복 읽기): 같은 트랜잭션이더라도 읽는 시간에 따라 다른 값을 읽는다. 스냅숏 격리(snapshot isolation, repeatable read) 를 많이 사용한다. mvcc로 구현된다.
  • 갱신 손실: 두 클라이언트가 read-modify-write 를 동시에 진행한다. 이로 인해 데이터가 손실된다. 스냅숏 격리 중 일부는 이를 자동으로 어보트하지만 그 외에는(mysql의 repeatable read) 는 select for update 필요
  • 쓰기 스튜: 무언가 읽고, 읽은 값을 기반으로 어떤 결정을 한 다음 그 결정을 데이터베이스에 쓴다. 하지만 쓰기를 실행할 때는 결정의 전제가 참이 아니다. 직렬성 격리만 막을 수 있다.
  • 팬텀 읽기: 한 트랜잭션이 검색 조건에 부합하는 객체를 읽는다. 다른 트랜잭션이 그 검색 결과에 영향을 주는 쓰기를 실행한다. 스냅숏 격리는 간단한 팬텀 읽기는 막아주지만, 쓰기 스큐의 팬텀은 색인 범위 잠금처럼 처리가 필요하다.(회의실 예약)

직렬성 구현

  • 싱글 쓰레드: 트랜잭션의 실행 시간이 짧고 처리량이 낮다면 간단하고 효과적
  • 2단계 잠금: 수십년 동안 직렬성을 구현하는 방법이지만 성능이 낮다
  • 직렬성 스냅숏 격리(SSI): 트랜잭션이 커밋을 원할 때 트랜잭션을 확인해서 실행이 직렬적이지 않으면 어보트한다.