이 페이지에서는 다양한 격리 수준을 소개하고 Spanner에서 작동하는 방식을 설명합니다.
격리 수준은 동시 트랜잭션에 표시되는 데이터를 정의하는 데이터베이스 속성입니다. Spanner는 ANSI/ISO SQL 표준에 정의된 격리 수준 중 직렬화 가능과 반복 가능한 읽기의 두 가지를 지원합니다. 트랜잭션을 만들 때 트랜잭션에 가장 적합한 격리 수준을 선택해야 합니다. 선택한 격리 수준을 통해 개별 트랜잭션에서 지연 시간, 중단 비율, 애플리케이션이 데이터 이상치의 영향을 받는지 여부와 같은 다양한 요인에 우선순위를 지정할 수 있습니다. 가장 적합한 선택은 워크로드의 구체적인 요구사항에 따라 달라집니다.
직렬화 가능한 격리
직렬화 가능한 격리는 Spanner의 기본 격리 수준입니다. 직렬화 가능 격리에서 Spanner는 트랜잭션에 대해 가장 엄격한 동시 실행 제어 보장을 제공합니다. 이를 외적 일관성이라고 합니다. Spanner는 단일 서버 데이터베이스보다 성능과 가용성을 높이기 위해 실제로 여러 서버 (가능한 경우 여러 데이터 센터)에서 트랜잭션을 실행하더라도 모든 트랜잭션이 순차적으로 실행되는 것처럼 작동합니다. 또한 다른 트랜잭션이 커밋되기 전에 트랜잭션 하나가 완료되면 Spanner는 클라이언트가 항상 트랜잭션 결과를 순차적으로 확인할 수 있도록 보장합니다. 직관적으로 Spanner는 단일 머신 데이터베이스와 유사합니다.
트레이드오프는 직렬화 가능한 트랜잭션의 기본 특성으로 인해 워크로드에 읽기-쓰기 경합이 심한 경우(즉, 많은 트랜잭션이 다른 트랜잭션이 업데이트하는 데이터를 읽는 경우) Spanner가 트랜잭션을 중단할 수 있다는 것입니다. 하지만 이는 운영 데이터베이스에 적합한 기본값입니다. 일반적으로 동시성이 높은 경우에만 발생하는 까다로운 타이밍 문제를 방지하는 데 도움이 됩니다. 이러한 문제는 재현하고 해결하기가 어렵습니다. 따라서 직렬화 가능 격리는 데이터 이상에 대한 가장 강력한 보호를 제공합니다. 트랜잭션을 재시도해야 하는 경우 트랜잭션 재시도로 인해 지연 시간이 증가할 수 있습니다.
반복 읽기 격리
Spanner에서 반복 가능한 읽기 격리는 스냅샷 격리로 알려진 기술을 사용하여 구현됩니다. Spanner의 반복 가능한 읽기 격리는 트랜잭션 내의 모든 읽기 작업이 트랜잭션 시작 시점에 존재했던 데이터베이스의 일관된 스냅샷을 확인하도록 보장합니다. 또한 동일한 데이터에 대한 동시 쓰기는 충돌이 없는 경우에만 성공합니다. 이 방법은 다른 트랜잭션이 수정할 수 있는 데이터를 여러 트랜잭션이 읽는 읽기-쓰기 충돌이 많은 시나리오에서 유용합니다. 고정 스냅샷을 사용하면 반복 가능한 읽기가 더 제한적인 직렬화 가능 격리 수준의 성능 영향을 방지합니다. 읽기는 잠금을 획득하지 않고 동시 쓰기를 차단하지 않고 실행될 수 있으므로 잠재적인 직렬화 충돌로 인해 재시도해야 할 수 있는 트랜잭션이 중단되는 경우가 줄어듭니다. 클라이언트가 이미 읽기-쓰기 트랜잭션에서 모든 것을 실행하고 읽기 전용 트랜잭션을 재설계하고 사용하기 어려운 사용 사례에서는 반복 가능한 읽기 격리를 사용하여 워크로드의 지연 시간을 개선할 수 있습니다.
직렬화 가능한 격리와 달리 반복 가능한 읽기는 애플리케이션이 데이터베이스 스키마에 의해 강제되지 않는 특정 데이터 관계 또는 제약 조건을 사용하는 경우 특히 작업 순서가 중요한 경우 데이터 이상으로 이어질 수 있습니다. 이러한 경우 트랜잭션은 데이터를 읽고, 해당 데이터를 기반으로 결정을 내린 다음, 데이터베이스 스키마 제약 조건이 여전히 충족되더라도 이러한 애플리케이션별 제약 조건을 위반하는 변경사항을 쓸 수 있습니다. 이는 반복 가능한 읽기 격리가 엄격한 직렬화 없이 동시 트랜잭션이 진행되도록 허용하기 때문에 발생합니다. 잠재적인 이상 중 하나는 쓰기 비대칭이라고 하며, 각 업데이트가 독립적으로 허용되지만 결합된 효과가 애플리케이션 데이터 무결성을 위반하는 특정 종류의 동시 업데이트에서 발생합니다. 예를 들어 의사가 항상 대기해야 하는 병원 시스템이 있고 의사가 교대 근무에서 대기하지 않도록 요청할 수 있다고 가정해 보겠습니다. 반복 가능한 읽기 격리에서 리처드 박사와 스미스 박사가 모두 동일한 교대 근무에 당직으로 예정되어 있고 동시에 당직 해제를 요청하려고 하면 각 요청이 동시에 성공합니다. 두 트랜잭션 모두 트랜잭션 시작 시 당직으로 예정된 의사가 한 명 이상 있다고 읽기 때문입니다. 트랜잭션이 성공하면 데이터 이상이 발생합니다. 반면 직렬화 가능한 격리를 사용하면 직렬화 가능한 트랜잭션이 잠재적인 데이터 이상을 감지하고 트랜잭션을 중단하므로 이러한 트랜잭션이 제약 조건을 위반하지 않습니다. 따라서 더 높은 중단 비율을 허용하여 애플리케이션 일관성을 보장합니다.
이전 예시에서는 반복 가능한 읽기 격리에서 SELECT FOR UPDATE
절을 사용할 수 있습니다.
SELECT…FOR UPDATE
절은 선택한 스냅샷에서 읽은 데이터가 커밋 시간에 변경되지 않았는지 확인합니다. 마찬가지로 쓰기의 무결성을 보장하기 위해 내부적으로 데이터를 읽는 DML 문과 변형도 커밋 시 데이터가 변경되지 않았는지 확인합니다.
자세한 내용은 반복 가능한 읽기 격리 사용을 참고하세요.
사용 사례
다음 예에서는 반복 가능한 읽기 격리를 사용하여 잠금 오버헤드를 제거하는 이점을 보여줍니다. Transaction 1
와 Transaction 2
는 모두 반복 가능한 읽기 격리에서 실행됩니다.
Transaction 1
는 SELECT
문이 실행될 때 스냅샷 타임스탬프를 설정합니다.
GoogleSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
/*-----------+------------------*
| AlbumId | MarketingBudget |
+------------+------------------+
| 1 | 50000 |
| 2 | 100000 |
| 3 | 70000 |
| 4 | 80000 |
*------------+------------------*/
PostgreSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
/*-----------+------------------*
| albumid | marketingbudget |
+------------+------------------+
| 1 | 50000 |
| 2 | 100000 |
| 3 | 70000 |
| 4 | 80000 |
*------------+------------------*/
그런 다음 Transaction 2
는 Transaction 1
가 시작된 후 커밋되기 전에 스냅샷 타임스탬프를 설정합니다. Transaction 1
에서 데이터를 업데이트하지 않았으므로 Transaction 2
의 SELECT
쿼리는 Transaction 1
와 동일한 데이터를 읽습니다.
GoogleSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 > T1
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
INSERT INTO Albums (SingerId, AlbumId, MarketingBudget) VALUES (1, 5, 50000);
COMMIT;
PostgreSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 > T1
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
INSERT INTO albums (singerid, albumid, marketingbudget) VALUES (1, 5, 50000);
COMMIT;
Transaction 1
는 Transaction 2
가 커밋된 후 계속됩니다.
GoogleSQL
-- Transaction 1 continues
SELECT SUM(MarketingBudget) as UsedBudget
FROM Albums
WHERE SingerId = 1;
/*-----------*
| UsedBudget |
+------------+
| 300000 |
*------------*/
PostgreSQL
-- Transaction 1 continues
SELECT SUM(marketingbudget) AS usedbudget
FROM albums
WHERE singerid = 1;
/*-----------*
| usedbudget |
+------------+
| 300000 |
*------------*/
Spanner가 반환하는 UsedBudget
값은 Transaction 1
에 의해 읽힌 예산의 합계입니다. 이 합계에는 T1
스냅샷에 있는 데이터만 반영됩니다. Transaction 2
가 스냅샷 T1
을 설정한 후 커밋했기 때문에 Transaction 2
가 추가한 예산은 포함되지 않습니다.Transaction 1
반복 가능한 읽기를 사용하면 Transaction 2
가 Transaction 1
에서 읽은 데이터를 수정하더라도 Transaction 1
가 중단되지 않습니다. 하지만 Spanner에서 반환하는 결과가 의도한 결과가 아닐 수도 있습니다.
읽기-쓰기 충돌 및 정확성
이전 예에서 Transaction 1
의 SELECT
문으로 쿼리한 데이터를 사용하여 후속 마케팅 예산 결정을 내린 경우 정확성 문제가 있을 수 있습니다.
예를 들어 총예산이 400,000
이라고 가정해 보겠습니다. Transaction 1
의 SELECT
문에서 나온 결과를 바탕으로 예산에 100,000
가 남아 있다고 생각하고 이를 모두 AlbumId = 4
에 할당하기로 결정할 수 있습니다.
GoogleSQL
-- Transaction 1 continues..
UPDATE Albums
SET MarketingBudget = MarketingBudget + 100000
WHERE SingerId = 1 AND AlbumId = 4;
COMMIT;
PostgreSQL
-- Transaction 1 continues..
UPDATE albums
SET marketingbudget = marketingbudget + 100000
WHERE singerid = 1 AND albumid = 4;
COMMIT;
Transaction 2
이 이미 남은 100,000
예산 중 50,000
을 새 앨범 AlbumId = 5
에 할당했더라도 Transaction 1
이 성공적으로 커밋됩니다.
SELECT...FOR UPDATE
구문을 사용하여 트랜잭션의 특정 읽기가 트랜잭션의 전체 기간 동안 변경되지 않는지 확인하여 트랜잭션의 정확성을 보장할 수 있습니다. SELECT...FOR UPDATE
를 사용하는 다음 예시에서 Transaction 1
는 커밋 시간에 중단됩니다.
GoogleSQL
-- Transaction 1 continues..
SELECT SUM(MarketingBudget) AS TotalBudget
FROM Albums
WHERE SingerId = 1
FOR UPDATE;
/*-----------*
| TotalBudget |
+------------+
| 300000 |
*------------*/
COMMIT;
PostgreSQL
-- Transaction 1 continues..
SELECT SUM(marketingbudget) AS totalbudget
FROM albums
WHERE singerid = 1
FOR UPDATE;
/*-------------*
| totalbudget |
+-------------+
| 300000 |
*-------------*/
COMMIT;
자세한 내용은 반복 가능한 읽기 격리에서 SELECT FOR UPDATE 사용을 참고하세요.
쓰기-쓰기 충돌 및 정확성
반복 가능한 읽기 격리 수준을 사용하면 동일한 데이터에 대한 동시 쓰기는 충돌이 없는 경우에만 성공합니다.
다음 예시에서 Transaction 1
는 첫 번째 SELECT
문에서 스냅샷 타임스탬프를 설정합니다.
GoogleSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
PostgreSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
다음 Transaction 2
는 Transaction 1
와 동일한 데이터를 읽고 새 항목을 삽입합니다. Transaction 2
가 기다리거나 중단하지 않고 커밋됩니다.
GoogleSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 (> T1)
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
INSERT INTO Albums (SingerId, AlbumId, MarketingBudget) VALUES (1, 5, 50000);
COMMIT;
PostgreSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 (> T1)
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
INSERT INTO albums (singerid, albumid, marketingbudget) VALUES (1, 5, 50000);
COMMIT;
Transaction 1
는 Transaction 2
가 커밋된 후 계속됩니다.
GoogleSQL
-- Transaction 1 continues
INSERT INTO Albums (SingerId, AlbumId, MarketingBudget) VALUES (1, 5, 30000);
-- Transaction aborts
COMMIT;
PostgreSQL
-- Transaction 1 continues
INSERT INTO albums (singerid, albumid, marketingbudget) VALUES (1, 5, 30000);
-- Transaction aborts
COMMIT;
Transaction 2
이 이미 AlbumId = 5
행에 삽입을 커밋했으므로 Transaction 1
이 중단됩니다.
다음 단계
반복 가능한 읽기 격리 수준 사용 방법을 알아보세요.
반복 가능한 읽기 격리에서 SELECT FOR UPDATE 사용 방법을 알아보세요.
직렬화 가능한 격리에서 SELECT FOR UPDATE 사용 방법을 알아보세요.
Spanner 직렬 가능성 및 외부 일관성에 대해 자세히 알아보려면 TrueTime 및 외부 일관성을 참고하세요.