PostgreSQL은 전통적인 2단계 잠금의 성능 저하 없이 진정한 직렬성을 달성하기 위해 술어 잠금과 직렬화 그래프 테스트를 사용하여 **Serializable Snapshot Isolation (SSI)**를 구현합니다. 40001 오류(직렬화 실패)는 두 트랜잭션이 rw-종속성 사이클을 형성할 때, 특히 쓰기를 통한 왜곡 또는 읽기-쓰기 충돌 동안 발생합니다. 예를 들어, 트랜잭션 A는 조건을 만족하는 행을 읽고(e.g., WHERE color = 'red'), 트랜잭션 B는 겹치지 않는 조건을 만족하는 행을 읽습니다(e.g., WHERE color = 'blue'), 이후 A가 '파란색'으로 업데이트하고 B가 '빨간색'으로 업데이트하는 경우입니다. 두 트랜잭션은 서로를 차단하지 않지만 결과는 비직렬화 상태가 됩니다.
이 패턴은 직렬화 그래프에서 위험한 구조를 나타냅니다: 두 개의 연속적인 rw-반종속성이 잠재적인 사이클을 형성합니다. PostgreSQL은 이를 감지하고 비정상 상태를 방지하기 위해 하나의 트랜잭션을 중단합니다. 트랜잭션이 서로 다른 물리적 행을 수정할 수 있기 때문에 이 문제는 미묘한데, 이는 하위 격리 수준에서 사용되는 행 잠금 메커니즘에서는 충돌이 보이지 않기 때문입니다.
요구되는 솔루션은 애플리케이션이 낙관적 재시도 루프를 구현하도록 요구합니다. **SQL EXCEPTION '40001'**을 잡을 때, 애플리케이션은 현재 트랜잭션을 롤백하고 전체 작업을 지수 백오프로 재시도해야 합니다. 데드락과 달리, 즉시 재시도되어야 하는 경우가 많은 데드락과는 달리, 높은 경합 하에서의 직렬화 실패는 천둥치는 떼를 방지하기 위해 지연 시간을 조정하는 것이 유익합니다.
-- PL/pgSQL에서 애플리케이션 재시도 로직의 예 DO $$ DECLARE retries INT := 0; max_retries INT := 3; BEGIN WHILE retries < max_retries LOOP BEGIN SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE; PERFORM * FROM inventory WHERE category = 'electronics' AND count > 0; UPDATE inventory SET count = count - 1 WHERE item_id = 123; COMMIT; EXIT; EXCEPTION WHEN SQLSTATE '40001' THEN ROLLBACK; retries := retries + 1; PERFORM pg_sleep(power(2, retries) * 0.1); -- 지수 백오프 END; END LOOP; END $$;
콘서트 티켓 교환 플랫폼은 사용자가 확인-작업 로직을 통해 좌석 카테고리를 교환할 수 있도록 했습니다. 트랜잭션 A는 VIP 좌석이 가능하다고 확인한 후 보유한 VIP 좌석을 일반 좌석으로 다운그레이드했습니다. 동시에, 트랜잭션 B는 일반 좌석 가능성을 확인하고 일반 좌석을 VIP로 업그레이드했습니다. READ COMMITTED에서 두 트랜잭션 모두 가용성을 true로 읽고 업데이트를 실행했으며, 시스템은 각 트랜잭션이 제약 조건을 확인했음에도 불구하고 두 카테고리에서 부정적인 재고로 끝났습니다.
세 가지 솔루션이 설계되었습니다. 첫 번째는 명시적으로 SELECT FOR UPDATE 잠금을 사용했지만, 가용성 쿼리가 0행을 반환할 때 실패하여 잠금을 획득하지 못하고 시스템이 유령 삽입에 취약하도록 만들었습니다. 두 번째 접근 방식은 **pg_try_advisory_lock()**을 사용하여 좌석 카테고리에 대한 접근을 직렬화하는 ADVISORY LOCKS를 구현하여 충돌을 방지했지만 복잡한 잠금 순서 위험을 초래하고 모든 카테고리 검사에서 40%의 처리량 감소를 가져왔습니다.
세 번째 솔루션은 애플리케이션 수준의 재시도 루프와 함께 SERIALIZABLE 격리를 채택했습니다. 이는 수동 잠금 관리를 필요로 하지 않으면서 정당성을 보장했으며, 동시 스왑의 빈도가 읽기 작업에 비해 낮기 때문에 재시도 오버헤드가 수용 가능했기 때문입니다. 구현은 SQLException을 잡는 JDBC 재시도 핸들러를 사용하여 SQLState 40001을 처리하고, 100ms * 2^attempt를 기다리며 트랜잭션을 재실행했습니다. 이것은 과잉 예약 사건을 완전히 제거했지만, 피크 판매 기간 동안 p99 지연 시간이 15ms 증가했습니다.
직렬화 격리에서 술어 잠금과 반복 가능한 읽기에서 행 잠금의 정확한 차이는 무엇입니까?
Repeatable Read는 쿼리에 의해 실제로 반환된 행에 잠금을 걸어 비반복 독서를 방지하지만, 다른 트랜잭션에 의해 삽입되는 새로운 행이 쿼리의 WHERE 절을 만족시키는 유령 읽기를 방지하지는 않습니다. Serializable 격리는 술어 잠금을 사용하여 검색 범위를 잠가 쿼리 술어와 일치하는 어떤 삽입도 방지하며, 쿼리가 실행될 때 존재하지 않았던 행에서도 차단합니다. 후보자들은 이를 혼동하여 Repeatable Read가 유령 읽기를 방지한다고 잘못 믿거나, Serializable이 기존 행만 잠금한다고 잘못 믿는 경우가 많습니다.
사이클이 감지되었을 때 직렬화 그래프 테스트 알고리즘은 어떤 트랜잭션을 중단할지 어떻게 결정합니까?
PostgreSQL은 "첫 번째 커밋자 승" 전략과 위험한 구조 감지를 결합하여 사용합니다. 동시 트랜잭션 간에 rw-충돌(읽기-쓰기 종속성)이 형성될 때, 시스템은 이 엣지가 직렬화 그래프에서 사이클을 완료하는지를 추적합니다. 사이클을 완성하는 트랜잭션은 SQLSTATE 40001로 중단됩니다. 선택은 트랜잭션 연령이 아니라 그래프 구조에 따라 결정적이며, 감지된 사이클에서 롤백 비용이 가장 적거나 최신인 트랜잭션을 중단하는 것을 선호합니다. 이는 잘못된 이력을 방지하기 위한 예방적 중단(사망 다리 기다리는 것과는 달리)이라는 점을 이해하는 것이 적절한 오류 처리에 필수적입니다.
왜 SELECT FOR UPDATE가 직렬화 격리가 충돌을 감지하는 시나리오에서 직렬화 실패를 방지하지 못할 수 있습니까?
SELECT FOR UPDATE는 실행 순간에 존재하는 행에 대해서만 ROW SHARE 잠금을 획득합니다. 체크-작업 패턴에서 초기 쿼리가 0행을 반환할 경우(예: 사용 가능한 좌석이 0임을 확인), FOR UPDATE는 잠금을 전혀 획득하지 않으므로 다른 트랜잭션이 충돌하는 행을 삽입할 수 있습니다. Serializable 격리는 "0행" 결과가 동시 삽입으로 인해 무효화된 유효 읽기 집합을 구성하기 때문에 이를 술어 충돌로 감지합니다. 후보자들은 종종 FOR UPDATE가 포괄적인 보호를 제공한다고 잘못 가정하며, 초기 조건이 아무것도 일치하지 않을 때 유령 삽입에 대한 방어를 제공하지 않는다는 사실을 이해하지 못합니다.