동시에 실행되는 쓰기 트랜잭션 사이에 발생할 수 있는 충돌에는 더티 쓰기 이외에도 다양한 것이 존재
이중 가장 널리 알려진 것이 갱신 손실이며 이전에 동시에 카운터를 증가시키는 예시가 이 문제임
갱신 손실은 애플리케이션이 데이터베이스에서 값을 읽고 변경한 후 변경된 값을 다시 쓸 때(read-modify-write) 발생할 수 있음
두 트랜잭션이 이 작업을 동시에 하며 두 번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 두 변경 중 하나가 손실될 수 있다는 것(나중에 쓴 것이 먼저 쓴 것을 때려눕힌다(clobber)라고 말하기도 함)
예시
데이터베이스에서 원자적 갱신 연산을 제공, read-modify-write 주기를 구현할 필요를 없애므로서 갱신 손실 방지를 해결
대부분의 RDBMS에서 UPDATE 구문을 제공함으로서 이를 구현
보통 원자적 연산은 객체에 대한 독점적인 잠금을 획득해서 구현함. 즉, 갱신이 적용될때까지 다른 트랜잭션에서 그 객체를 읽지 못하게 함(커서 안정성이라고 부르기도 함)
다른 선택지는 그냥 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 것
애플리케이션에서 갱신할 객체를 명시적으로 잠금으로서 갱신 손실을 방지하는 방법
첫 번째 트랜잭션이 read-modify-write 수행을 완료할 때까지 객체를 잠그고, 이후 두 번째 트랜잭션이 read-modify-write를 수행하도록 함
위 두 방법은 read-modify-write 주기가 순차적으로 실행되도록 강제하는 방법
대안으로 병렬실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트시키고 read-modify-write 주기를 재시도하도록 강제할 수 있음
이 방식의 이점은 데이터베이스가 이 확인을 스냅숏 격리와 결합해 효율적으로 수행할 수 있다는 것
실제로 PostgreSQL의 반복 읽기, 오라클의 직렬성, SQL 서버의 스냅숏 격리 수준은 갱신 손실이 발생하면 자동으로 발견해서 문제가 되는 트랜잭션을 어보트 시킴
트랜잭션을 제공하지 않는 데이터베이스 중 원자적 compare-and-set 연산을 제공하기도 함
이 연산은 값을 마지막으로 읽은 후로 변경되지 않았을 때만 갱신을 허용함으로써 갱신 손실을 회피하는 것
현재 값이 이전에 읽은 값과 일치하지 않으면 갱신은 반영되지 않고 재시도
-- 데이터베이스 구현에 따라 안전할 수도 안전하지 않을 수도 있다
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
의사들이 병원에서 교대로 서는 호출 대기를 관리하는 애플리케이션을 만드는 예를 생각해보자.
병원은 보통 한 시점에 여러 의사가 호출 대기 상태에 있게 하려고 하지만 최소 한 명의 의사는반드시 호출 대기를 해야 한다.
의사들은 최소 한 명의 동료가 같은 교대 순번에서 호출 대기를 하고 있다면 (자기 몸이 아픈 경우) 호출 대기를 그만둘 수 있다.
앨리스와 밥이 어느날 함께 호출 대기를 하고 있다고 상상해보자. 둘 다 몸이 안 좋아서 호출 대기를 그만두기로 결심했다.
불행하게도 그들은 거의 동시에 호출 대기 상태를 끄는 버튼을 클릭했다.
위 예시는 두 트랜잭션이 두 개의 다른 객체를 갱신하므로 더티 쓰기도 갱신 손실도 아님. 충돌이 덜 명백해보이지만 분명한 경쟁 조건임
이러한 충돌을 쓰기 스큐(write skew) 라고 함
즉, 두 트랜잭션이 같은 객체들을 읽어서 그 중 일부를 갱신할 때 나타날 수 있음
해결을 위한 고려
여러 객체가 관련되므로 원자저 단일 객체 연산은 도움이 되지 않음
스냅샷 격리에서 제공되는 갱신 손실 자동 감지도 도움이 되지는 않음. 쓰기 스큐를 자동으로 방지하려면 진짜 직렬성 격리가 필요
제약 조건을 설정할 수 있음(외래 키 제약 조건, 특정 값에 대한 제한). 하지만, 최소 한 명의 의사가 호출 대기를 해야한다고 명시하려면 여러 객체와 연관된 제약 조건이 필요. 대부분의 DB는 이런 제약 조건을 지원하지 않지만, 데이터베이스에 따라 트리거나 구체화 뷰를 사용해 구현할 수 있기도함
직렬성 격리 수준을 사용할 수 없다면 트랜잭션이 의존하는 로우를 명시적으로 잠그는 것이 차선책임
BEGIN TRANSACTION;
SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE;
UPDATE doctors
SET on_call = false
WHERE name = 'Alice'
AND shift_id = 1234;
COMMIT;
다양한 예시가 있을 수 있음
회의실 예약 시스템
BEGIN TRANSACTION;
-- 정오에서 오후 1시까지의 시간과 겹치는 예약이 존재하는지 확인
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';
-- 이전 질의가 0을 반환했다면
INSERT INTO bookings
(room_id, start_time, end_time, user_id)
VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);
COMMIT;
사용자명 획득
팬텀
어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의 결과를 바꾸는 효과를 팬텀(phantom)이라고 함
위 병원 예시로 쓰기 스큐를 유발하는 팬텀을 살펴보자
SELECT 질의가 어떤 검색 조건에 부합하는 로우를 검색함으로써 어떤 요구사항을 만족하는지 확인한다(최소 두 명의 의사가 호출 대기 중이다)
첫 번째 질의의 결과에 따라 애플리케이션 코드는 어떻게 진행할지(해당 연산을 계속 처리할지 사용자에게 오류를 보고하고 중단할지) 결정한다.
애플리케이션이 계속 처리하기로 결정했다면 데이터베이스에 쓰고(INSERT, UPDATE, DELETE) 트랜잭션을 커밋한다.이 쓰기의 효과로 2단계를 결정한 전제 조건이 바뀐다. 다시 말해 쓰기를 커밋한 후 1단계의 SELECT 질의를 재실행하면 다른 결과를 얻게 된다. 쓰기의 결과로 검색 조건에 부합하는 로우 집합이 바뀌었기 때문이다
위 예시는 3단계에서 변경될 로우가 1단계에서 반환된 로우 중 하나임
따라서, 1단계의 로우를 잠금으로써(SELECT FOR UPDATE) 트랜잭션을 안전하게 만들 수 있음
하지만, 사용자명 획득의 경우엔 다름. 검색 조건에 부합하는 로우가 존재하는지 확인 하고 쓰기작업을 하는 방식임
위와 같이 잠글 수 있는 객체가 없다면, 어떻게 해야 할까?
인위적으로 데이터베이스에 잠금 객체를 추가할 수 있지 않을까?
예를들어 회의실 예약의 경우 시간 슬롯과 회의실에 대한 테이블을 만드는 것을 생각해 볼 수 있음
이 테이블의 각 로우는 특정한 시간 범위(예를 들어 15분) 동안 사용되는 특정한 회의실에 해당
회의실과 시간 범위의 모든 조합에 대해 로우를 미리(예를 들어 다음 6개월 동안의) 만들어 둠으로 잠글 수 있는 객체를 생성할 수 있음
이런 방법을 충돌 구체화(materializing conflict) 라고 함
하지만, 충돌 구체화는 방법을 알아내기가 어렵고 오류가 발생하기도 쉬움(위 예시 중 사용자명 획득을 충돌 구체화로 구현할 수 있을까?)
따라서 충돌 구체화는 다른 대안이 불가능 할때 최후의 수단으로 고려해야 함
대부분의 경우엔 직렬성 격리 수준을 사용하는 것이 충돌 구체화를 사용하는 것보다 선호됨