원문 캡처 · materialize.com
원문 캡처 · materialize.com

Materialize는 PostgreSQL의 SERIALIZABLE 격리 수준에서 메타데이터 쓰기가 급격히 느려지는 장애를 겪은 뒤, READ COMMITTED에서도 선형화 가능한 로그를 구현했다. 같은 데이터베이스 인스턴스에서 새 쿼리는 초당 약 13,000회의 비교 후 설정(CaS)을 처리했으며, 기존 방식은 약 4,300회 이후 성능이 무너졌다.

Materialize의 persist는 테이블이나 구체화 뷰 같은 객체의 상태를 샤드 단위로 관리한다. PostgreSQL 메타데이터 저장소에는 샤드별 상태 변경 로그가 쌓이고, 여러 프로세스가 같은 샤드에 쓰더라도 하나의 일관된 기록을 유지해야 한다. 이를 위해 쓰기는 예상한 최신 버전을 확인한 뒤 다음 버전을 추가하는 CaS 방식으로 이뤄진다.

SERIALIZABLE에서 발생한 병목

장애 당시 자체 관리형 배포의 PostgreSQL 연결 대부분이 대기 상태였고 SIRead 잠금은 수십만 개까지 늘었다. SERIALIZABLE 격리 수준에서 PostgreSQL은 동시 트랜잭션의 읽기·쓰기 충돌을 감지하기 위해 읽은 행과 인덱스 페이지에 잠금 정보를 유지한다. 작은 테이블에 동시 쓰기가 몰리면 새 삽입마다 최근에 같은 페이지를 읽은 트랜잭션의 잠금 정보를 확인해야 한다.

Materialize가 재현한 부하 시험은 db.r6g.2xlarge RDS 인스턴스에서 클러스터 20개를 가동하고, 각 클러스터에 빈 테이블을 참조하는 단순 구체화 뷰 600개를 둔 조건으로 진행됐다. 초당 약 4,300회의 CaS까지는 부하를 처리했지만, 그 이후 트랜잭션 지연과 SIRead 잠금 수가 함께 늘면서 삽입 비용이 더 커졌다. 지연은 30초까지 증가했고 처리량은 급락했으며, 부하를 낮추기 전까지 시스템은 회복하지 못했다.

Materialize는 이를 일시적 처리 지연이 지속적인 병목으로 이어지는 ‘준안정 장애’로 설명한다. 데이터베이스 규모를 키우거나 샤드 수를 줄이면 장애가 시작되는 지점은 달라질 수 있지만, SERIALIZABLE에 의존하지 않으면 잠금 증가가 장애를 지속시키는 고리를 제거할 수 있다는 판단이다.

연속 로그와 잠금으로 쓰기를 보장

새 설계의 핵심은 각 샤드의 시퀀스 번호가 항상 연속된 구간을 이룬다는 불변식이다. 쓰기는 구간의 끝에 한 행을 더하고, 자르기(truncate)는 끝 행을 남긴 채 앞부분을 삭제한다. 따라서 예상한 이전 행이 있고 그 다음 행이 없다면, 연속성에 따라 이전 행이 현재 끝이라는 사실을 확인할 수 있다. 이전 행 확인에는 SELECT를, 다음 행이 이미 있는지 확인하는 데는 기본 키 제약을 사용한다.

sql
WITH expected_row AS (
    SELECT sequence_number FROM consensus
    WHERE shard = $1 AND sequence_number = $expected
    FOR KEY SHARE
)
INSERT INTO consensus (shard, sequence_number, data)
SELECT $1, $expected + 1, $data
FROM expected_row;

동일한 버전을 동시에 쓰려는 요청은 기본 키가 하나만 받아들이고, 나머지는 불일치로 처리한다. FOR KEY SHARE 잠금은 쓰기끼리의 순서를 정하지 않는다. 대신 진행 중인 CaS의 이전 행을 truncate가 삭제하지 못하게 한다. 그렇지 않으면 한 요청이 이전 행을 확인한 뒤 삽입하기 전에 다른 요청이 그 행을 지울 수 있고, 그 결과 로그가 불연속이 될 수 있다.

head와 scan은 각각 단일 SELECT로 처리하며, truncate는 삭제 대상 구간 위에 남길 행이 있는지 확인하는 조건을 둔 단일 DELETE로 처리한다. 첫 쓰기는 이전 행이 없으므로 별도로 다뤄야 한다. 설계는 시퀀스 번호 -1에 초기화 표식을 추가하고, 첫 버전과 함께 삽입한다. 이 표식은 truncate 대상에서 제외돼 오래된 첫 쓰기가 샤드를 다시 만드는 상황을 막는다.

Lean 4로 정확성 검증

Materialize는 새 쿼리가 PostgreSQL의 READ COMMITTED 동작에서 선형화 가능한지 Lean 4로 모델링하고 증명했다. 이 격리 수준에서는 각 문장이 시작될 때의 스냅샷을 읽지만, INSERT의 기본 키 검사는 그 뒤 커밋되거나 삭제된 행까지 반영하는 현재 인덱스를 확인한다. 모델은 CaS를 스냅샷에서 이전 행을 확인하는 단계와 현재 인덱스에 삽입하는 단계로 나누고, 그 사이에 다른 작업이 실행될 수 있도록 했다.

구체적인 PostgreSQL 상태를 추상적인 샤드 로그 상태에 대응시키는 전방 시뮬레이션을 증명했다. 연속성 불변식을 포함해 모든 구체적 실행이 같은 결과를 내는 추상적 순차 실행에 대응함을 보였으며, 증명은 클라이언트 수나 로그 길이에 제한을 두지 않는다.

같은 데이터베이스 인스턴스에서 새 쿼리를 시험했을 때 처리량은 시험이 끝날 때까지 유지됐고 초당 약 13,000회의 CaS에 도달했다. 더 많은 클러스터를 추가하기 전에 시험이 끝나 데이터베이스의 실제 한계는 확인하지 못했다. Materialize는 새 방식에서는 삽입 비용이 동시에 실행 중이거나 최근 완료된 트랜잭션 수에 따라 커지지 않아, 부하가 줄면 지연도 다시 낮아진다고 설명한다.

업그레이드 중에는 구버전과 신버전 쿼리가 함께 실행되는 기간도 고려해야 했다. 이를 위해 새 쿼리의 격리 수준을 설정하는 변수를 도입해 롤아웃 동안 양쪽 모두 SERIALIZABLE로 실행되게 했다. 초기화 쿼리에는 기존의 비어 있음 확인 조건도 유지해 직렬화 충돌 감지에 참여하도록 했다. 업그레이드가 끝난 뒤 READ COMMITTED로 전환할 때도 두 격리 수준의 새 쿼리가 함께 실행되는 상황과, 구 방식으로 초기화돼 표식이 없는 샤드를 고려했다.

자체 관리형 PostgreSQL 배포에서 사용할 설정은 persist_pg_consensus_read_committed다. 기본값은 꺼짐이며, Materialize는 배포를 v26.33 이상으로 업그레이드한 뒤 켜기를 권장한다.

참고 자료: Metastable Failures in Distributed Systems