Database

[Database] PostgreSQL - Transaction

kahnco 2024. 7. 18. 19:41
반응형

이번 시간에는 PostgreSQL 에서 트랜젝션이 어떻게 동작하고 어떠한 특성을 가지고 있는지 알아보겠습니다.

 


개요

트랜잭션은 BEGIN 혹은 START TRANSACTION 을 사용하여 명시적으로 생성하고 COMMIT 혹은 ROLLBACK 을 사용하여 종료할 수 있습니다. 명시적 트랜잭션 외부의  SQL 문은 자동으로 단일 문 트랜잭션을 사용합니다.

 

모든 트랜잭션은 백엔드 ID와 각 백엔드에 로컬로 순차적으로 할당된 번호, 즉 localXID 로 구성된 고유한 VirtualTransactionId 로 식별됩니다. 예를 들어, 가상 트랜잭션 ID 4/12532 의 백엔드 ID 는 4이고 localXID 는 12532 입니다.

 

PostgreSQL 클러스터 내의 모든 데이터베이스에서 사용하는 글로벌 카운터에서 트랜잭션에 non-virtual transactionId(혹은 xid) 가 순차적으로 할당됩니다. 이 할당은 트랜잭션이 트랜잭션이 처음 데이터베이스에 쓸 때 발생합니다. 이는 낮은 숫자의 xid 가 높은 숫자의 xid 보다 먼저 쓰기 시작했다는 것을 의미합니다. 트랜잭션이 첫 번째 데이터베이스 쓰기를 수행하는 순서는 트랜잭션이 시작된 순서와 다를 수 있으며, 특히 트랜잭션이 데이터베이스 읽기만 수행하는 문으로 시작된 경우에는 더욱 그렇습니다.

 

PostgreSQL의 트랜잭션 ID (xid) 처리 로직

  1. 트랜잭션 ID (xid)
    • PostgreSQL은 각 트랜잭션에 고유한 트랜잭션 ID (xid)를 부여합니다.
    • 이 트랜잭션 ID 는 32비트 크기로, 약 40억 번의 트랜잭션이 발생할 때마다 한 번씩 래핑됩니다. 즉, 40억 번의 트랜잭션이 발생한 후에는 트랜잭션 ID가 초기 값으로 되돌아갑니다.
  2. 에포크 (epoch)
    • 트랜잭션 ID 가 래핑될 때마다 32비트 에포크 값이 증가합니다.
    • 이 에포크 값은 트랜잭션 ID가 래핑된 횟수를 카운트하여 고유성을 유지하도록 돕습니다.
  3. 64비트 트랜잭션 ID (xid8)
    • 32비트 트랜잭션 ID 와 에포크 값을 결합하여 64비트 크기의 트랜잭션 ID (xid8) 를 생성할 수 있습니다.
    • xid8 은 설치된 PostgreSQL 인스턴스의 전체 수명 동안 고유성을 유지하므로 래핑 문제를 피할 수 있습니다.
    • xid8 값을 32비트 트랜잭션 ID 로 캐스팅할 수 있습니다.
  4. MVCC (다중 버전 동시성 제어)
    • PostgreSQL은 MVCC 를 사용하여 데이터베이스의 동시성을 관리합니다.
    • 트랜잭션 ID는 MVCC 의 핵심 요소로, 각 트랜잭션의 시점을 추적하여 동시에 여러 트랜잭션이 안전하게 실행될 수 있도록 합니다.
    • 이는 각 트랜잭션이 자신만의 스냅샷을 보면서 다른 트랜잭션의 영향을 받지 않고 데이터를 읽고 쓸 수 있게 해줍니다.
  5. 스트리밍 복제
    • 트랜잭션 ID는 PostgreSQL 의 스트리밍 복제에서도 중요한 역할을 합니다.
    • 복제는 주 데이터베이스에서 보조 데이터베이스로 트랜잭션을 전송하여 두 데이터베이스를 동기화합니다.
    • 이 과정에서 트랜잭션 ID 를 사용하여 각 트랜잭션의 순서를 정확히 유지하고, 복제 중 데이터 일관성을 보장합니다.

Transactions and Locking

현재 실행 중인 트랜잭션의 트랜잭션 ID는 virtualxid 및 transactionId 열의 pg_lock에 표시됩니다. 읽기 전용 트랜잭션은 virtualxid 만 가지고, transactionId 값은 NULL 로 가지게 됩니다. 반면, 쓰기 전용 트랜잭션은 두 값 모두 가지게 됩니다.

 

일부 Locking 유형은 virtualxid 에서 대기하는 반면 다른 유형은 transactionId 에서 대기합니다. 행 수준 읽기 및 쓰기 잠금은 잠금 행에 직접 기록되며 pgrowlocks 확장자를 사용하여 검사할 수 있습니다. 행 수준 읽기 잠금은 멀티액트 ID (mxId) 의 할당이 필요할 수도 있습니다.


Subtransactions

하위 트랜잭션은 트랜잭션 내부에서 시작되어 큰 트랜잭션을 더 작은 단위로 분할할 수 있습니다. 하위 트랜잭션은 상위 트랜잭션에 영향을 미치지 않고 커밋하거나 중단할 수 있으므로 큰 트랜잭션은 영향을 받지 않습니다. 이를 통해 오류를 더 쉽게 처리할 수 있으며, 이는 일반적인 애플리케이션 개발 패턴입니다. 해위 트랜잭션이라는 단어는 종종 suxact 라는 단어로 약칭됩니다.

 

하위 트랜잭션은 SAVEPOINT 명령을 사용하여 명시적으로 시작할 수 있지만,  PL/pgSQL의 EXCEPTION 명령과 같은 다른 방법으로도 시작할 수 있습니다. PL/Python 및 PL/Tcl도 명시적 하위 트랜잭션을 지원합니다. 하위 트랜잭션은 다른 하위 트랜잭션에서도 시작할 수 있습니다. 최상위 트랜잭션과 하위 하위 트랜잭션은 계층 구조 또는 트리를 형성하므로 메인 트랜잭션을 최상위 트랜잭션이라고 부릅니다.

 

하위 트랜잭션에 non-virtual transaction id 가 할당된 경우, 해단 트랜잭션 ID 를 "subxid" 라고 합니다. 읽기 전용 하위 트랜잭션에서는 subxid 가 할당되지 않지만 일단 쓰기를 시도하면 subxid 가 할당됩니다. 이로 인해 최상위 트랜잭션을 포함한 모든 하위 트랜잭션 ID가 non-virtual transaction id 으로 할당됩니다. 상위 xid 는 해당 xid의 하위 xid보다 항상 낮도록 유지합니다.

 

각 subxid의 바로 앞 부모 xid는 pg_subtrans 디렉터리에 기록됩니다. 최상위 xid는 부모가 없으므로 입력하지 않으며 읽기 전용 하위 트랜잭션에 대한 입력도 없습니다.

 

하위 트랜잭션이 커밋되면, subxid를 가지는 모든 커밋된 child subtransaction 들도 해당 트랜잭션에서 서브 트랜잭션으로 커밋된 것으로 간주됩니다. 하위 트랜잭션이 중단되면 하위 트랜잭션도 모두 중단된 것으로 간주합니다.

 

xid 가 포함된 최상위 트랜잭션이 커밋되면 서브 커밋된 하위 트랜잭션도 모두 커밋된 것으로 pg_xact 하위 디렉터리에 영구적으로 기록됩니다. 최상위 트랜잭션이 중단되면 서브 커밋된 것일지라도 해당 하위 트랜잭션도 모두 중단됩니다.

 

각 트랜잭션이 공개 (롤백 혹은 릴리스되지 않음)된 상태로 유지될수록 트랜잭션 관리 오버헤드가 커집니다. 각 백엔드마다 최대 64개의 열린 subxid가 공유 메모리에 캐시되고, 그 이후에는 pg_subtrans 에서 subxid 엔트리의 추가 검색으로 인해 스토리지 I/O 오버헤드가 크게 증가합니다.


Two-Phase Transactions

PostgreSQL은 여러 분산 시스템이 트랜잭션 방식으로 함께 작동할 수 있도록 하는 2단계 커밋(2PC) 프로토콜을 지원합니다. 지원하는 명령은 PREPARE TRANSACTION, COMIIT PREPARED 그리고 ROLLBACK PREPARED 입니다. 2단계 트랜잭션은 외부 트랜잭션 관리 시스템에서 사용하기 위한 것입니다. PostgreSQL은 X/OPEN XA 표준에서 제안한 기능과 모델을 따르지만, 덜 자주 사용되는 일부 측면은 구현하지 않습니다.

 

사용자가 PREPARE TRANSACTION 을 실행할 때 가능한 다음 명령어는 COMMIT PREPARED 또는 ROLLBACK PREPARED 뿐입니다. 일반적으로 이러한 준비 상태는 매우 짧은 지속 시간을 의도하지만 외부 가용성 문제는 트랜잭션이 이 상태를 더 긴 시간 동안 유지하는 것을 의미할 수 있습니다. 짧은 수명의 준비 트랜잭션은 공유 메모리 및 WAL 에만 저장됩니다. 체크 포인트에 걸쳐 있는 트랜잭션은 pg_twophase 디렉터리에 기록됩니다. 현재 준비된 트랜잭션은 pg_prepared_xacts를 사용하여 검사할 수 있습니다.


Transaction Isolation

SQL 표준은 네 가지 트랜잭션 격리 수준을 정의합니다. 그 중 가장 엄격한 수준은 Serializable이며, 다른 세 가지 수준은 특정 "현상"이 발생하지 않도록 정의됩니다. 이러한 현상들은 동시 실행 중인 트랜잭션 간의 상호작용으로 인해 발생할 수 있는 문제들입니다.

 

  1. Serializable
    • 정의: SQL 표준에 따르면, 어떤 집합의 Serializable 트랜잭션이 동시 실행될 때, 이것은 마치 하나씩 순서대로 실행된 것과 동일한 효과를 보장합니다.
    • 현상: 이 수준에서는 어떤 상호작용으로 인해 발생하는 현상도 볼 수 없습니다. 즉, 동시 실행이 순차 실행처럼 보이기 때문에 모든 현상이 제거됩니다.
  2. Repeatable Read
    • 금지된 현상
      • Dirty Read: 트랜잭션이 다른 동시 실행 중인 트랜잭션이 커밋되지 않은 데이터를 읽는 현상
      • Nonrepeatable Read: 트랜잭션이 이전에 읽은 데이터를 다시 읽을 때, 그 데이터가 다른 트랜잭션에 의해 수정된 것을 발견하는 현상
      • Phantom Read: 트랜잭션이 특정 조건을 만족하는 행 집합을 반환하는 쿼리를 다시 실행할 때, 최근에 커밋된 다른 트랜잭션으로 인해 조건을 만족하는 행 집합이 이전과는 다르게 변경된 것을 발견하는 현상
      • Serialization Anomaly: 여러 트랜잭션이 성공적으로 커밋된 결과가, 해당 트랜잭션들을 하나씩 실행했을 때의 결과와 일치하지 않는 현상
  3. Read Committed
    • 금지된 현상
      • Dirty Read: 트랜잭션이 다른 동시 실행 중인 트랜잭션이 커밋되지 않은 데이터를 읽는 현상
    • 허용된 현상
      • Nonrepeatable Read: 트랜잭션이 이전에 읽은 데이터를 다시 읽을 때, 그 데이터가 다른 트랜잭션에 의해 수정된 것을 발견하는 현상
      • Phantom Read: 트랜잭션이 특정 조건을 만족하는 행 집합을 반환하는 쿼리를 다시 실행할 때, 최근에 커밋된 다른 트랜잭션으로 인해 조건을 만족하는 행 집합이 이전과는 다르게 변경된 것을 발견하는 현상
  4. Read Uncommitted
    • 허용된 현상
      • Dirty Read: 트랜잭션이 다른 동시 실행 중인 트랜잭션이 커밋되지 않은 데이터를 읽는 현상
      • Nonrepeatable Read: 트랜잭션이 이전에 읽은 데이터를 다시 읽을 때, 그 데이터가 다른 트랜잭션에 의해 수정된 것을 발견하는 현상
      • Phantom Read: 트랜잭션이 특정 조건을 만족하는 행 집합을 반환하는 쿼리를 다시 실행할 때, 최근에 커밋된 다른 트랜잭션으로 인해 조건을 만족하는 행 집합이 이전과는 다르게 변경된 것을 발견하는 현상

각 격리 수준의 설명

  • Dirty Read: 다른 트랜잭션이 아직 커밋되지 않은 데이터를 읽을 수 있는 현상입니다. 이로 인해 데이터 일관성이 깨질 수 있습니다.
  • Nonrepeatable Read: 동일한 쿼리를 두 번 실행했을 때, 첫 번째 실행 후 다른 트랜잭션이 데이터를 수정하여 두 번째 실행 시 다른 결과가 나오는 현상입니다.
  • Phantom Read: 동일한 쿼리를 두 번 실행했을 때, 첫 번째 실행 후 다른 트랜잭션이 새로운 데이터를 추가하거나 기존 데이터를 삭제하여 두 번째 실행 시 결과 집합이 달라지는 현상입니다.
  • Serialization Anomaly: 동시 실행된 여러 트랜잭션의 결과가, 순차적으로 실행된 것과 같은 일관성을 보이지 않는 현상입니다.

Read Committed Isolation Level

PostgreSQL의 기본 격리 수준인 Read Committed 는 트랜잭션이 이 격리 수준을 사용할 때, SELECT 쿼리는 쿼리가 시작되기 전에 커밋된 데이터만 볼 수 있습니다. 쿼리 실행 중에 커밋되지 않은 데이터나 동시 트랜잭션에 의해 커밋된 변경 사항은 볼 수 없습니다. 즉, SELECT 쿼리는 실행 시작 순간의 데이터베이스 스냅샷을 보는 것입니다. 그러나 SELECT는 본인의 트랜잭션 내에서 이전에 실행된 내에서 업데이트의 영향을 볼 수 있으며, 이는 아직 커밋되지 않은 경우에도 마찬가지입니다. 또한, 두 개의 연속된 SELECT 명령은 같은 트랜잭션 내에서 실행되더라도 첫 번째 SELECT가 시작된 후 두 번째 SELECT가 시작되기 전에 다른 트랜잭션이 변경 사항을 커밋하면 서로 다른 데이터를 볼 수 있습니다. 

 

UPDATE, DELETE, SELECT FOR UPDATE 및 SELECT FOR SHARE 명령은 대상 행을 찾는 데 있어 SELECT와 동일하게 동작합니다. 명령 시작 시점에 커밋된 대상 행만 찾습니다. 그러나 이러한 대상 행이 발견될 때까지 다른 동시 트랜잭션에 의해 이미 업데이트(또는 삭제 또는 잠금)된 경우가 있습니다. 이 경우, 업데이트를 시도하는 트랜잭션은 첫 번째 업데이트 트랜잭션이 커밋되거나 롤백될 때까지 기다립니다. 첫 번째 업데이트 트랜잭션이 롤백되면 그 효과는 무효화되고 두 번째 업데이트 트랜잭션은 원래 찾은 행을 업데이트할 수 있습니다. 첫 번째 업데이트 트랜잭션이 커밋되면 두 번째 업데이트 트랜잭션은 첫 번째 업데이트 트랜잭션이 해당 행을 삭제한 경우 이를 무시하고, 그렇지 않으면 업데이트된 버전의 행에 대해 작업을 시도합니다. 명령의 검색 조건(WHERE 절)은 업데이트된 버전의 행이 여전히 검색 조건과 일치하는지 확인하기 위해 다시 평가됩니다. 일치하는 경우, 두 번째 업데이트 트랜잭션은 업데이트된 버전의 행을 사용하여 작업을 계속 진행합니다. SELECT FOR UPDATE 및 SELECT FOR SHARE의 경우, 이는 클라이언트에 반환되고 잠금된 행이 업데이트된 버전이라는 것을 의미합니다.

 

ON CONFLICT DO UPDATE 절이 있는 INSERT는 유사하게 동작합니다. Read Committed 모드에서 각 삽입 제안 행은 삽입 또는 업데이트 중 하나를 수행합니다. 관련 없는 오류가 없는 한, 두 결과 중 하나는 보장됩니다. 충돌이 아직 INSERT에 표시되지 않은 다른 트랜잭션에서 발생한 경우, UPDATE 절이 해당 행에 영향을 미치며, 명령에 통상적으로 표시되지 않는 행의 버전일 수 있습니다.

 

ON CONFLICT DO NOTHING 절이 있는 INSERT는 다른 트랜잭션의 결과로 삽입이 진행되지 않을 수 있습니다. 이는 Read Committed 모드에서만 발생합니다.

 

MERGE는 INSERT, UPDATE 및 DELETE 하위 명령의 다양한 조합을 지정할 수 있습니다. INSERT 및 UPDATE 하위 명령이 모두 있는 MERGE 명령은 ON CONFLICT DO UPDATE 절이 있는 INSERT와 유사해 보이지만, INSERT나 UPDATE 중 하나가 발생한다고 보장하지 않습니다. MERGE가 UPDATE나 DELETE를 시도하고 행이 동시 업데이트되지만 현재 대상 및 현재 소스 튜플에 대해 조인 조건이 여전히 통과하면 MERGE는 UPDATE 또는 DELETE 명령과 동일하게 동작하여 업데이트된 버전의 행에서 작업을 수행합니다. 그러나 MERGE는 여러 작업을 지정할 수 있으며 이들은 조건적일 수 있으므로, 각 작업의 조건은 업데이트된 버전의 행에서 처음부터 다시 평가됩니다. 반면, 행이 동시 업데이트되거나 삭제되어 조인 조건이 실패하면 MERGE는 NOT MATCHED 작업의 조건을 다음으로 평가하고 성공하는 첫 번째 작업을 실행합니다. MERGE가 INSERT를 시도하고 고유 인덱스가 존재하며 중복 행이 동시 삽입된 경우, 고유성 위반 오류가 발생합니다; MERGE는 MATCHED 조건의 평가를 다시 시작하여 이러한 오류를 피하려고 하지 않습니다.

 

위의 규칙들로 인해, 업데이트 명령은 일관되지 않은 스냅샷을 볼 수 있습니다: 동일한 행을 업데이트하려는 동시 업데이트 명령의 영향을 볼 수 있지만, 데이터베이스의 다른 행에 대한 해당 명령의 영향을 볼 수 없습니다. 이 동작은 복잡한 검색 조건이 포함된 명령에 대해서는 Read Committed 모드가 적합하지 않음을 의미하지만, 더 단순한 경우에는 적합합니다. 예를 들어, 은행 계좌 잔액을 업데이트하는 다음과 같은 트랜잭션을 고려해 보아야 합니다.

BEGIN;
UPDATE accounts SET balance = balance + 100.00 WHERE acctnum = 12345;
UPDATE accounts SET balance = balance - 100.00 WHERE acctnum = 7534;
COMMIT;

 

두 개의 이러한 트랜잭션이 동시에 계좌 12345의 잔액을 변경하려고 하면, 두 번째 트랜잭션은 계좌의 행을 업데이트된 버전으로 시작하는 것이 명확히 필요합니다. 각 명령은 미리 결정된 행에만 영향을 미치므로, 업데이트된 버전의 행을 보는 것이 문제가 되는 일관성을 생성하지 않습니다.

 

더 복잡한 사용은 Read Committed 모드에서 바람직하지 않은 결과를 생성할 수 있습니다. 예를 들어, 다른 명령에 의해 제한 기준에서 추가 및 제거되고 있는 데이터에서 작동하는 DELETE 명령을 고려해 보십시오. 예를 들어, website가 hits 값이 9 및 10인 두 행 테이블이라고 가정합니다:

 

BEGIN;
UPDATE website SET hits = hits + 1;
-- 다른 세션에서 실행: DELETE FROM website WHERE hits = 10;
COMMIT;

 

DELETE는 UPDATE가 완료되고 DELETE가 잠금을 얻을 때까지 행 값 9를 건너뛰고, 새로운 행 값이 더 이상 10이 아니라 11이 되므로 아무런 영향을 미치지 않습니다.

 

Read Committed 모드는 각 명령을 해당 순간까지 커밋된 모든 트랜잭션을 포함하는 새로운 스냅샷으로 시작하기 때문에 동일한 트랜잭션의 후속 명령은 커밋된 동시 트랜잭션의 영향을 어쨌든 보게 됩니다. 위의 요점은 단일 명령이 데이터베이스의 절대적으로 일관된 뷰를 보는지 여부입니다.

 

Read Committed 모드가 제공하는 부분적인 트랜잭션 격리는 많은 응용 프로그램에 충분하며, 이 모드는 빠르고 사용하기 쉽습니다. 그러나 모든 경우에 충분하지는 않습니다. 복잡한 쿼리와 업데이트를 수행하는 응용 프로그램은 Read Committed 모드가 제공하는 것보다 더 엄격하게 일관된 데이터베이스 뷰를 필요로 할 수 있습니다.

 


Repeatable Read Isolation Level

Repeatable Read 격리 수준은 트랜잭션이 시작되기 전에 커밋된 데이터만 볼 수 있으며, 트랜잭션 실행 중에 동시 트랜잭션에 의해 커밋된 변경 사항이나 커밋되지 않은 데이터는 볼 수 없습니다. (그러나 각 쿼리는 자체 트랜잭션 내에서 이전에 실행된 업데이트의 영향을 보며, 이는 아직 커밋되지 않았더라도 마찬가지입니다.) 이 수준은 SQL 표준에서 요구하는 것보다 더 강력한 보장을 제공하며, Table 13.1에 설명된 모든 현상을 방지합니다(직렬화 이상을 제외하고). 위에서 언급했듯이, 이는 표준에서 명시적으로 허용하는 것이며, 각 격리 수준이 제공해야 하는 최소 보호만을 설명합니다.

 

이 수준은 Read Committed와 다릅니다. Repeatable Read 트랜잭션의 쿼리는 트랜잭션 내의 현재 명령이 아니라 트랜잭션 내의 첫 번째 비-트랜잭션 제어 명령의 시작 시점에서 스냅샷을 봅니다. 따라서 단일 트랜잭션 내의 연속된 SELECT 명령은 동일한 데이터를 보게 되며, 트랜잭션이 시작된 후 다른 트랜잭션에 의해 커밋된 변경 사항을 보지 않습니다.

 

이 수준을 사용하는 애플리케이션은 직렬화 실패로 인해 트랜잭션을 다시 시도해야 할 수 있습니다.

 

UPDATE, DELETE, MERGE, SELECT FOR UPDATE 및 SELECT FOR SHARE 명령은 대상 행을 찾는 데 있어 SELECT와 동일하게 동작합니다: 트랜잭션 시작 시점에 커밋된 대상 행만 찾습니다. 그러나 이러한 대상 행이 발견될 때까지 다른 동시 트랜잭션에 의해 이미 업데이트(또는 삭제 또는 잠금)된 경우가 있습니다. 이 경우, Repeatable Read 트랜잭션은 첫 번째 업데이트 트랜잭션이 커밋되거나 롤백될 때까지 기다립니다. 첫 번째 업데이트 트랜잭션이 롤백되면 그 효과는 무효화되고 Repeatable Read 트랜잭션은 원래 찾은 행을 업데이트할 수 있습니다. 그러나 첫 번째 업데이트 트랜잭션이 커밋되고 (실제로 행을 업데이트하거나 삭제했을 경우, 단순히 잠근 것이 아니라) Repeatable Read 트랜잭션은 다음과 같은 메시지와 함께 롤백됩니다.

ERROR:  could not serialize access due to concurrent update

 

이는 Repeatable Read 트랜잭션이 시작된 후 다른 트랜잭션에 의해 변경된 행을 수정하거나 잠글 수 없기 때문입니다.

 

애플리케이션이 이 오류 메시지를 받으면, 현재 트랜잭션을 중단하고 처음부터 전체 트랜잭션을 다시 시도해야 합니다. 두 번째 시도 시, 트랜잭션은 초기 데이터베이스 뷰의 일부로 이전에 커밋된 변경 사항을 보게 되므로, 새로운 트랜잭션의 업데이트를 위한 시작점으로 새 버전의 행을 사용하는 데 논리적 충돌이 없습니다.

 

업데이트 트랜잭션만 다시 시도해야 할 수 있음을 유의해야 합니다. 읽기 전용 트랜잭션은 절대 직렬화 충돌을 겪지 않습니다.

 

Repeatable Read 모드는 각 트랜잭션이 완전히 안정적인 데이터베이스 뷰를 볼 수 있도록 엄격한 보장을 제공합니다. 그러나 이 뷰는 반드시 항상 동시 트랜잭션의 일부 직렬(하나씩 순차적) 실행과 일치하는 것은 아닙니다. 예를 들어, 이 수준에서의 읽기 전용 트랜잭션조차도 배치가 완료되었음을 나타내는 제어 레코드는 업데이트되었지만 배치의 논리적 일부인 세부 레코드는 보지 못할 수 있습니다. 이는 제어 레코드의 이전 버전을 읽었기 때문입니다. 이 격리 수준에서 실행되는 트랜잭션이 비즈니스 규칙을 강제하려는 시도는 상충되는 트랜잭션을 차단하기 위한 명시적 잠금을 신중하게 사용하지 않으면 올바르게 작동하지 않을 가능성이 큽니다.


Serializable Isolation Level

Serializable 격리 수준은 가장 엄격한 트랜잭션 격리를 제공합니다. 이 수준은 모든 커밋된 트랜잭션이 마치 하나씩 순차적으로 실행된 것처럼 동작합니다. 하지만, Repeatable Read 수준과 마찬가지로, 이 격리 수준을 사용하는 애플리케이션은 직렬화 실패로 인해 트랜잭션을 재시도해야 할 수 있습니다. 사실, 이 격리 수준은 Repeatable Read와 동일하게 동작하지만, 동시 실행 중인 트랜잭션 집합의 실행이 모든 가능한 순차적 실행과 일치하지 않는 방식으로 동작할 수 있는 조건을 모니터링합니다. 이 모니터링은 Repeatable Read에서 발생하는 블로킹을 초과하지 않지만, 모니터링에는 일부 오버헤드가 있으며, 직렬화 이상이 발생할 수 있는 조건을 감지하면 직렬화 실패가 트리거됩니다.

 

예를 들어, 처음에 다음과 같은 테이블이 있다고 가정해 보겠습니다.

 class | value
-------+-------
     1 |    10
     1 |    20
     2 |   100
     2 |   200

 

Serializable 트랜잭션 A가 다음과 같은 연산을 수행한다고 가정해 보겠습니다.

SELECT SUM(value) FROM mytab WHERE class = 1;

 

그런 다음 결과(30)를 class = 2인 새로운 행의 value로 삽입합니다. 동시에, Serializable 트랜잭션 B가 다음과 같은 연산을 수행합니다. 

SELECT SUM(value) FROM mytab WHERE class = 2;

 

그리고 결과(300)를 class = 1인 새로운 행에 삽입합니다. 그런 다음 두 트랜잭션이 커밋을 시도합니다. 만약 두 트랜잭션이 Repeatable Read 격리 수준에서 실행되었다면, 두 트랜잭션 모두 커밋될 수 있었을 것입니다. 하지만, Serializable 트랜잭션을 사용할 때는 다음과 같은 메시지와 함께 하나의 트랜잭션만 커밋되고 다른 하나는 롤백됩니다:

ERROR:  could not serialize access due to read/write dependencies among transactions

 

이는 A가 B보다 먼저 실행되었을 경우 B는 300이 아닌 330을 계산했을 것이며, 반대로 B가 A보다 먼저 실행되었을 경우 A는 다른 합계를 계산했을 것이기 때문입니다.

 

Serializable 트랜잭션을 사용하여 이상을 방지하려면, 영구 사용자 테이블에서 읽은 데이터는 이를 읽은 트랜잭션이 성공적으로 커밋될 때까지 유효하지 않다고 간주하는 것이 중요합니다. 이는 읽기 전용 트랜잭션에도 해당되지만, 지연 가능한 읽기 전용 트랜잭션 내에서 읽은 데이터는 읽자마자 유효한 것으로 간주됩니다. 이는 해당 트랜잭션이 데이터를 읽기 시작하기 전에 그러한 문제가 없는 스냅샷을 확보할 때까지 대기하기 때문입니다. 다른 모든 경우에는, 애플리케이션은 나중에 중단된 트랜잭션 중에 읽은 결과에 의존해서는 안 되며, 대신 성공할 때까지 트랜잭션을 재시도해야 합니다.

 

PostgreSQL은 진정한 직렬성을 보장하기 위해 predicate locking을 사용합니다. 이는 만약 먼저 실행되었다면 이전 읽기에서의 결과에 영향을 미쳤을 쓰기를 결정할 수 있도록 잠금을 유지하는 것을 의미합니다. PostgreSQL에서 이러한 잠금은 블로킹을 일으키지 않으므로 교착 상태를 유발할 수 없습니다. 이들은 특정 조합에서 직렬화 이상을 초래할 수 있는 동시 Serializable 트랜잭션 간의 종속성을 식별하고 플래그를 지정하는 데 사용됩니다. 반면, 데이터 일관성을 보장하려는 Read Committed 또는 Repeatable Read 트랜잭션은 전체 테이블에 잠금을 설정해야 할 수 있으며, 이는 해당 테이블을 사용하려는 다른 사용자를 블로킹할 수 있습니다. 또는 SELECT FOR UPDATE나 SELECT FOR SHARE를 사용하여 다른 트랜잭션을 블로킹하거나 디스크 접근을 유발할 수 있습니다.

 

대부분의 다른 데이터베이스 시스템과 마찬가지로 PostgreSQL의 predicate lock은 트랜잭션이 실제로 접근한 데이터를 기반으로 합니다. 이러한 잠금은 pg_locks 시스템 뷰에서 SIReadLock 모드로 표시됩니다. 쿼리 실행 중에 획득된 특정 잠금은 쿼리에서 사용된 계획에 따라 다르며, 메모리 소진을 방지하기 위해 트랜잭션 과정에서 여러 더 세분화된 잠금(예: 튜플 잠금)이 더 적은 수의 더 거친 잠금(예: 페이지 잠금)으로 결합될 수 있습니다. READ ONLY 트랜잭션은 직렬화 이상을 초래할 수 있는 충돌이 발생할 가능성이 없음을 감지하면 완료 전에 SIRead 잠금을 해제할 수 있습니다. 사실, READ ONLY 트랜잭션은 시작 시점에 이를 입증하고 어떤 predicate lock도 취하지 않을 수 있습니다. 만약 명시적으로 SERIALIZABLE READ ONLY DEFERRABLE 트랜잭션을 요청하면, 이러한 사실을 입증할 때까지 블로킹됩니다. (이것이 Serializable 트랜잭션이 블로킹되지만 Repeatable Read 트랜잭션은 블로킹되지 않는 유일한 경우입니다.) 반면, SIRead 잠금은 종종 트랜잭션 커밋 이후에도 유지되어야 하며, 이는 중첩된 읽기 쓰기 트랜잭션이 완료될 때까지 유지되어야 합니다.

 

Serializable 트랜잭션을 일관되게 사용하면 개발이 단순해질 수 있습니다. 성공적으로 커밋된 동시 Serializable 트랜잭션의 모든 집합이 하나씩 순차적으로 실행된 것과 동일한 효과를 가질 것이라는 보장은, 단일 트랜잭션이 단독으로 실행될 때 올바른 작업을 수행할 것이라고 입증할 수 있다면, 다른 Serializable 트랜잭션의 혼합에서도 올바른 작업을 수행할 것이라는 확신을 가질 수 있음을 의미합니다. 직렬화 이상을 방지하기 위해서는 항상 트랜잭션이 성공할 때까지 재시도해야 합니다. 읽기/쓰기 종속성을 모니터링하는 것에는 비용이 따르며, 직렬화 실패로 인해 종료된 트랜잭션을 재시작하는 것에도 비용이 따르지만, 명시적 잠금 및 SELECT FOR UPDATE 또는 SELECT FOR SHARE의 사용과 관련된 비용 및 블로킹과 비교했을 때, Serializable 트랜잭션은 일부 환경에서 최적의 성능 선택입니다.

 

PostgreSQL의 Serializable 트랜잭션 격리 수준은 동일한 효과를 생성하는 직렬 실행 순서가 존재한다고 증명할 수 있을 때만 동시 트랜잭션을 커밋할 수 있도록 허용하지만, 실제 직렬 실행에서 발생하지 않을 오류가 발생하는 것을 항상 방지하지는 않습니다. 특히, 삽입하려는 키가 존재하지 않는다는 것을 명시적으로 확인한 후에도 중복 키 충돌로 인해 유일성 제약 위반이 발생할 수 있습니다. 이는 잠재적으로 충돌할 수 있는 키를 삽입하는 모든 Serializable 트랜잭션이 먼저 이를 수행할 수 있는지 명시적으로 확인하도록 해야 합니다.

 

동시성 제어를 위해 Serializable 트랜잭션에 의존할 때 최적의 성능을 위해 다음 사항을 고려해야 합니다.

  • 가능한 경우 트랜잭션을 READ ONLY 로 선언해야 합니다.
  • 활성 연결 수를 제어하고, 필요한 경우 Connection Pool 을 사용해야 합니다. 이는 항상 중요한 성능 고려 사항이지만, Serializable 트랜잭션을 사용하는 바쁜 시스템에서는 특히 중요할 수 있습니다.
  • 무결성을 위해, 필요한 것보다 더 많은 작업을 단일 트랜잭션에 포함하지 않아야 합니다.
  • Conneciton을 "트랜잭션 내 유휴" 상태로 더 이상 필요하지 않은 경우 더 오래 남겨두지 않아야 합니다. 구성 매개변수 idle_in_transaction_session_timeout 을 사용하여 지속적인 세션을 자동으로 연결 해제할 수 있습니다.
  • Serializable 트랜잭션이 자동으로 제공하는 보호 기능으로 인해 더 이상 필요하지 않은 명시적 잠금, SELECT FOR UPDATE 및 SELECT FOR SHARE를 제거해야 합니다.

시스템이 메모리 부족으로 인해 여러 페이지 수준 predicate 잠금을 단일 관계 수준 predicate 잠금으로 결합해야 하는 경우, 직렬화 실패율이 증가할 수 있습니다. 이를 방지하려면 max_pred_locks_per_transaction, max_pred_locks_per_relation 및/또는 max_pred_locks_per_page를 증가시킬 수 있습니다.

 

순차 스캔은 항상 관계 수준 predicate 잠금을 필요로 합니다. 이는 직렬화 실패율을 증가시킬 수 있습니다. random_page_cost를 줄이거나 cpu_tuple_cost를 증가시켜 인덱스 스캔 사용을 권장하는 것이 도움이 될 수 있습니다.

 

Serializable 격리 수준은 Snapshot Isolation에 직렬화 이상 검사를 추가하여 구현된 기술인 Serializable Snapshot Isolation으로 구현됩니다. 전통적인 Locking 기법을 사용하는 다른 시스템과 비교할 때 동작 및 성능에서 차이가 있을 수 있습니다.


여기까지 PostgreSQL의 트랜잭션에 대해 알아보았습니다. 일단 기본적인 기능들에 대해서는 학습이 끝난 것 같으니, 추후에 더 궁금해지는 사항이 생기면 해당 부분에 대해서 글을 작성해보도록 하겠습니다. 읽어주셔서 감사합니다.

반응형

'Database' 카테고리의 다른 글

[Database] PostgreSQL - Index  (0) 2024.07.18
[Database] PostgreSQL Commands - DCL  (0) 2024.07.18
[Database] PostgreSQL Commands - DML  (0) 2024.07.17
[Database] PostgreSQL Commands - DDL  (8) 2024.07.15
[Database] RDBMS - PostgreSQL  (0) 2024.07.10