AWS PostgreSQL 01 - MVCC

2025. 1. 13. 22:28·Database/postgreSQL

해당 포스팅의 이유

현재 AWS PostgreSQL를 멀티 리전으로 Active 서버와 Standby 서버를 운영하고 있습니다. 또한, Active 서버의 부하 방지를 위해 Read Replica 복제본을 사용하고 있습니다. Read Replica의 Long Transaction이 있는 경우 트랜잭션 연결이 해제되는 현상이 발생하였습니다. 원인 분석을 위해 PostgreSQL 의 작동 방식을 학습하고 쿼리 충돌의 원인을 파악하고자 합니다. 

MVCC 란?

우선 MVCC에 대해 짚고 넘어가겠습니다. 대부분의 DBMS에서 동시성을 위해 제공하는 MVCC(Multi-Version Concurrency Control) 기능은 동시에 여러 트랜잭션이 수행되는 환경에서 각 트랜잭션에게 쿼리 수행 시점의 데이터를 제공하여 읽기 일관성을 보장하고 Read/Write 간의 충돌 및 lock을 방지하여 동시성을 높일 수 있는 기능으로, 모든 MVCC의 기본 원리는 트랜잭션이 시작된 시점의 Transaction ID와 같거나 작은 Transacion ID를 가지는 데이터를 읽는 것입니다.

 

Lock-based 동시성 제어의 경우 같은 데이터의 경우 두개의 트랜잭션이 Read를 동시에 수행하는 것을 허용하지만, 그 외의 경우에는 Lock을 통해 허용하지 않습니다. 즉, 한쪽이 실행되면 다른 한쪽은 Block이 되어 대기하게 됩니다. 이를 해결하기 위해 MVCC가 등장하게 됩니다. MVCC를 사용할 경우 같은 데이터에 대해서 각 트랜잭션이 같은 데이터에 대해 write 작업을 수행할 경우에만 Block이 걸립니다.

MVCC에서는 데이터를 읽을 때 Isolation level 기준으로 가장 최근에 commit 된 데이터를 읽습니다. 즉, RDBMS가 내부적으로 write 등의 데이터 변화 이력을 관리해야 합니다. 이로 인해, mvcc는 데이터 히스토리를 관리해야 하기 때문에 추가적인 저장 공간을 많이 사용하게 됩니다. 하지만 이로 인해, read와 write는 서로 block 하지 않습니다. 

 

Example 01 - MVCC는 Commit된 데이터만 읽는다.

Trasaction 01 과 Trasaction 02 가 동시에 x라는 값에 대해서 작업을 수행한다고 가정하겠습니다. 이후 시간 순차별로 상황에 대해 나열해 보도록 하겠습니다

 

- Time 1 : 원본 데이터베이스 x = 10

- Time 2 : Transaction 02 가 write(x = 50) 작업을 수행

- Time 3 : Transaction 02 가 write_lock(x) 을 통해 x에 대해서 write lock을 획득한다. 

- Time 4 : x=50 작업은 바로 commit 되지 않고 Transaction 02 의 공간에 x=50 을 작성한다.

- Time 5 : Transaction 01 이 read(x)를 수행하고 10을 반환받는다. ( Commit된 데이터만 읽기 때문 )

- Time 6 : Transaction 02 의 Commit 수행 후 x = 50 이 데이터베이스에 반영하고 write lock 을 반환한다.

- Time 7 : 이후 Transaction 01 이 다시 read(x) 작업을 수행한다면, Isolation level 에 따라 다른 값을 반환받는다. 

         - read committed : read 하는 시간을 기준으로 그 전에 commit 된 데이터를 읽는다. 즉, x = 50

         - repeatable read : transaction 시작 시간 기준으로 그 전에 commit된 데이터를 읽는다. 즉 x = 10 (Time 5)

 

이번엔 Transaction 01 과 Transaction 02 가 동시에 x와 y라는 값에 대해서 작업을 수행한다고 가정하겠습니다. 원본 데이터베이스에는 x = 50, y = 10 의 데이터가 있습니다. Trasaction 01 이 x가 y에 40을 이체하는 작업을 수행하고, Transaction 02 가 x 에 30을 입금 작업을 수행합니다. 정상적으로 동작한다면 최종 결과는 x = 40 , y = 50 이 되어야 합니다. 

Example 02 - Transaction 01 과 Transaction 02 의 Isolation level 이 모두 read commited 

- Time 1 : Transaction 01 이 read(x) 작업을 수행 후 50을 반환받는다. 

- Time 2 : Transaction 01 이 write_lock(x) 를 얻고 write(x = 10)을 수행한다. ( 40 이체 작업 시작 )

- Time 3 : x = 10 작업은 바로 commit 되지 않고 Transaction 01 의 공간에 x = 10 을 작성한다. 

- Time 4 : Transaction 02 가 read(x) 작업을 수행 후 x 값으로 50 을 반환받는다. ( 아직 Transaction 01 의 commit 수행 x )

- Time 5 : Transaction 02 가 write(x = 80) 을 수행(30 입금 작업) 하려고 하지만, Transaction 01 이 x 에 대한 write lock 을 가지고 있으므로 Transaction 01 이 write lock 을 해제할때까지 대기하게 된다. 

- Time 6 : Transaction 01 이 read(y) 작업을 수행 후 10을 반환받고 write(y=50)을 수행하면서 write_lock(y)를 획득한다. 

- Time 7 : 마찬가지로 Transaction 01 의 공간에 y = 50 을 작성한다.

- Time 8 : Transaction 01 의 작업이 모두 끝마쳤으므로 commit을 수행한다. 이때, 데이터베이스 상태는 x=10, y=50

- Time 9 : Time 5 에서 발생한 대기가 풀리면서 Transaction 02 가 write_lock(x)를 획득한다. 이후 write(x=80) 작업을 드디어 실행하게 된다. 물론, 데이터베이스에 바로 적용되지 않고 추가 공간에 작성된 후 commit 된다. 

 

결과적으로, 데이터베이스의 값은 기대했던 x = 40 , y = 50 가 아닌, x = 80 , y = 50 이 됩니다. 이렇게 잘못된 결과가 나오는 현상을 "LOST UPDATE" 현상이라고 부릅니다. 즉, Time 1과 Time 2에서 수행한 x 에 40을 빼는 작업이 사라지게 된 상태입니다. 이를 해결하는 방법은 여러가지가 있지만, Isolation level 만을 바꿔서 해결할 수 있습니다. 

Example 03 - Transaction 02 의 Isolation level 만 repeatable read 로 변경

Time 1 ~ Time 8 까지의 작업은 Example 02 와 동일하게 수행됩니다. Time 8 의 결과로 데이터베이스의 상태는 x = 10 , y = 50 

 

- Time 9 : Time 5 에서 발생한 대기가 풀리면서 Transaction 02 가 write_lock(x)를 획득한다. 하지만, Transaction 02 의 Isolation level 은 repeatable read 이기 때문에 write(x = 80) 작업이 실패가 됩니다. 

      - PostgreSQL 에서는 같은 데이터에 대해서 먼저 update한 트랜잭션이 commit이 되면 나중 트랜잭션은 Rollback 합니다. 

      - 즉, x에 대해 Transaction 01 이 먼저 update (write) 를 시도했고 commit 됐으므로 Transaction 02 는 Rollback 

- Time 10 : Transaction 02 의 Rollback 으로 인해 데이터베이스의 상태는 x = 10 , y = 50 

- Time 11 : Transaction 02 의 재시도 -> 최종적으로 정상적인 결과가 된다. 

 

이러한 현상을 "first-updater-win" 이라고 부릅니다.  

 

Example 04 - Transaction 02  이 먼저 수행할 경우 

- Time 1 : Transaction 02 가 read(x) 를 수행하여 50을 반환받는다. 

- Time 2 : Transaction 01 이 read(x) 를 수행하여 마찬가지로 50을 반환받는다.

- Time 3 : Transaction 02 가 write(x=80) 수행하기 위해 write_lock(x)를 획득하고 따로 값을 저장한다.

- Time 4 : Transaction 01 이 write(x=10) 수행하려고 하지만 Transaction 02 가 x 에 대한 write_lock 획득 상태

- Time 5 : Transaction 02 가 최종적으로 commit 수행 후 lock 반환 -> 데이터베이스 상태 x = 80 , y = 10

- Time 6 : Transaction 01 의 write(x=10) 작업 수행 시작 

- Time 7 : Transaction 01 의 read(y) 작업과 write(y) 작업도 이후로 계속 수행 

- Time 8 : Transaction 01 의 commit 작업 시 최종 데이터베이스 결과는 x = 10 , y = 50 

 

마찬가지로 LOST UPDATE 가 발생하였다. Example 03과 같은 상황이지만 Transaction의 순서에 따라 LOST UPDATE가 발생한 것이다. 즉, Transaction 01 의 Isolation level 또한 repeatable read 으로 수정하여 first-updater-win 현상에 따라 정상으로 바꾼다. 최종적으로 한 Transaction 과 연관 있는 다른 Transaction 의 Isolation level 또한 신경써야 한다는 점이다. 

 

PostgreSQL 의 MVCC

PostgreSQL은 데이터를 관리할 때 앞서 언급한 MVCC를 의해, 이전 Tuple과 변경된 신규 Tuple을 같은 page에 저장하고
Tuple별로 생성된 시점과 변경된 시점을 기록 및 비교하는 방식으로 MVCC를 제공합니다. (PostgreSQL에서는 record 대신 Tuple이라는 용어를 사용합니다) 그리고 Tuple이 생성되거나 변경된 시점을 각 Tuple 내 xmin, xmax라는 메타데이터 필드에 기록하여 어떤 Tuple을 읽을 수 있는지 버전 관리를 하게 됩니다.

  1. Tuple의 저장 방식
    • 데이터를 수정하거나 삭제할 때, 기존 데이터를 바로 덮어쓰지 않습니다.
    • 기존 Tuple(이전 데이터)과 새로 생성된 Tuple(변경된 데이터)을 같은 데이터 페이지에 저장합니다.
  2. 버전 관리
    • Tuple이 생성되거나 변경된 시점을 기록하여, 어떤 Tuple을 사용자가 볼 수 있는지 관리합니다.
    • 이를 위해 각 Tuple에는 xmin과 xmax라는 메타데이터 필드가 포함되어 있습니다.
  3. xmin 
    • Tuple이 생성된 시점을 기록합니다.
    • Insert: 새로운 Tuple이 만들어질 때, 그 시점의 트랜잭션 ID를 xmin에 저장합니다.
    • Update: 수정된 데이터(새로운 Tuple)에 대해 xmin에 트랜잭션 ID를 저장합니다.
  4. xmax 
    • Tuple이 변경되거나 삭제된 시점을 기록합니다.
    • Delete: 삭제된 Tuple에 xmax에 해당 시점의 트랜잭션 ID를 저장합니다.
    • Update: 기존 Tuple은 xmax에 트랜잭션 ID를 저장하고, 새로운 Tuple의 xmin에는 동일한 트랜잭션 ID를 저장합니다. 새롭게 생성된 Tuple의 xmax는 NULL로 설정됩니다(아직 변경되지 않았다는 의미).

- 위 그림에서 value=2 를 value=2_update 로 UPDATE 하였습니다. 

- 기존 old version 의 value=2 가 그대로 데이터 페이지 내에 저장되어 있는 상태 (빨간색 Tuple) 를 유지합니다.

- new version 의 value=2_update 를 새로운 Tuple 로 INSERT 합니다. 

- old version 과 new version 의 선택 판단은 Transaction ID가 저장된 xmin, xmax 값을 비교하여 판단합니다.

 

 

Transaction ID 비교를 통한 MVCC 읽기 일관성에 대해 간단한 예를 살펴보겠습니다.

- Transaction 2015 에서는 'AAA' , 'BBB' 그리고 'CCC' 를 볼 수 있습니다.

       - ‘ZZZ’는 xmin이 2020으로 미래의 값이므로 볼 수 없습니다.

- Transaction 2021에서는 ‘BBB’, ‘CCC’, ‘ZZZ’를 볼 수 있습니다.

       - ‘AAA’는 xmax가 2020으로 2020까지만 존재하던 값으로, 2021에서는 볼 수 없습니다.

- Transaction 2031에서는 ‘BBB’, ‘ZZZ’를 볼 수 있습니다.

       -‘AAA’, ‘CCC’는 각각 xmax가 2020, 2030까지만 존재하던 값으로, 2031에서는 볼 수 없습니다.

 

'Database > postgreSQL' 카테고리의 다른 글

AWS PostgreSQL 05 - 쿼리 충돌 처리하기  (1) 2025.01.14
AWS PostgreSQL 04 - Physical Replication  (0) 2025.01.13
AWS PostgreSQL 03 - Read Replica 구성의 모범 사례  (0) 2025.01.13
AWS PostgreSQL 02 - VACUUM  (0) 2025.01.13
'Database/postgreSQL' 카테고리의 다른 글
  • AWS PostgreSQL 05 - 쿼리 충돌 처리하기
  • AWS PostgreSQL 04 - Physical Replication
  • AWS PostgreSQL 03 - Read Replica 구성의 모범 사례
  • AWS PostgreSQL 02 - VACUUM
Hyukops
Hyukops
안녕하세요
  • Hyukops
    Hyukops 님의 Tech Blog
    Hyukops
    • 분류 전체보기 (141)
      • Introduction (1)
      • Kubernetes & EKS (43)
        • k8s in action (9)
        • k8s 공부 기록 (17)
        • k8s 운영 가이드 (10)
        • k8s 운영 특이사항 (7)
      • Service Mesh (29)
        • Istio 공부 기록 (20)
        • Istio 운영 특이사항 (9)
      • CICD (10)
        • argoCD 공부 기록 (6)
        • argoCD 운영 특이사항 (4)
      • Logging & Monitoring (5)
        • Prometheus 운영 특이사항 (0)
        • fluent bit 운영 특이사항 (5)
      • Infrastructure as Code (8)
        • terraform 공부 기록 (3)
        • terraform 운영 특이사항 (5)
      • AWS (40)
        • aws 공부 기록 (29)
        • 솔루션 사례 & 문제 해결 (11)
      • Database (5)
        • postgreSQL (5)
  • 태그

    AWS
    kubernetes
    aws saa
    prometheus
    PostgreSQL
    fluentbit
    eks
    Logging
    k8s in action
    argocd
    canary
    MSK
    Database
    fluent bit
    Istio
    Terraform
  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Hyukops
AWS PostgreSQL 01 - MVCC
상단으로

티스토리툴바