동시성 제어

Alan·2023년 4월 30일
0

갱신 손실 방지

  • 동시에 실행되는 쓰기 트랜잭션 사이에 발생할 수 있는 충돌에는 더티 쓰기 이외에도 다양한 것이 존재

  • 이중 가장 널리 알려진 것이 갱신 손실이며 이전에 동시에 카운터를 증가시키는 예시가 이 문제임

  • 갱신 손실은 애플리케이션이 데이터베이스에서 값을 읽고 변경한 후 변경된 값을 다시 쓸 때(read-modify-write) 발생할 수 있음

  • 두 트랜잭션이 이 작업을 동시에 하며 두 번째 쓰기 작업이 첫 번째 변경을 포함하지 않으므로 두 변경 중 하나가 손실될 수 있다는 것(나중에 쓴 것이 먼저 쓴 것을 때려눕힌다(clobber)라고 말하기도 함)

  • 예시

    • 카운터를 증가시키거나 계좌 잔고를 갱신(현재 값을 읽어서 새 값을 계산하고 갱신된 값을 다시 씀)
    • 복잡한 값을 지역적으로 변경한다. 예를 들어 JSON 문서 내에 있는 리스트에 엘리먼트를 추가한다(문서를 파싱해서 변경하고 변경된 문서를 다시 씀).
    • 사용자가 편집한 내용을 저장할 때 전체 페이지 내용을 서버에 보내서 현재 데이터베이스에 저장된 내용을 덮어 쓰도록 만들어진 위키에서 두 명의 사용자가 동시에 같은 페이지를 편집)

원자적 쓰기 연산

  • 데이터베이스에서 원자적 갱신 연산을 제공, read-modify-write 주기를 구현할 필요를 없애므로서 갱신 손실 방지를 해결

  • 대부분의 RDBMS에서 UPDATE 구문을 제공함으로서 이를 구현

  • 보통 원자적 연산은 객체에 대한 독점적인 잠금을 획득해서 구현함. 즉, 갱신이 적용될때까지 다른 트랜잭션에서 그 객체를 읽지 못하게 함(커서 안정성이라고 부르기도 함)

  • 다른 선택지는 그냥 모든 원자적 연산을 단일 스레드에서 실행되도록 강제하는 것

명시적인 잠금

  • 애플리케이션에서 갱신할 객체를 명시적으로 잠금으로서 갱신 손실을 방지하는 방법

  • 첫 번째 트랜잭션이 read-modify-write 수행을 완료할 때까지 객체를 잠그고, 이후 두 번째 트랜잭션이 read-modify-write를 수행하도록 함

갱신 손실 자동 감지

  • 위 두 방법은 read-modify-write 주기가 순차적으로 실행되도록 강제하는 방법

  • 대안으로 병렬실행을 허용하고 트랜잭션 관리자가 갱신 손실을 발견하면 트랜잭션을 어보트시키고 read-modify-write 주기를 재시도하도록 강제할 수 있음

  • 이 방식의 이점은 데이터베이스가 이 확인을 스냅숏 격리와 결합해 효율적으로 수행할 수 있다는 것

  • 실제로 PostgreSQL의 반복 읽기, 오라클의 직렬성, SQL 서버의 스냅숏 격리 수준은 갱신 손실이 발생하면 자동으로 발견해서 문제가 되는 트랜잭션을 어보트 시킴

Compare-and-set

  • 트랜잭션을 제공하지 않는 데이터베이스 중 원자적 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)이라고 함

    • 위 병원 예시로 쓰기 스큐를 유발하는 팬텀을 살펴보자

    1. SELECT 질의가 어떤 검색 조건에 부합하는 로우를 검색함으로써 어떤 요구사항을 만족하는지 확인한다(최소 두 명의 의사가 호출 대기 중이다)

    2. 첫 번째 질의의 결과에 따라 애플리케이션 코드는 어떻게 진행할지(해당 연산을 계속 처리할지 사용자에게 오류를 보고하고 중단할지) 결정한다.

    3. 애플리케이션이 계속 처리하기로 결정했다면 데이터베이스에 쓰고(INSERT, UPDATE, DELETE) 트랜잭션을 커밋한다.이 쓰기의 효과로 2단계를 결정한 전제 조건이 바뀐다. 다시 말해 쓰기를 커밋한 후 1단계의 SELECT 질의를 재실행하면 다른 결과를 얻게 된다. 쓰기의 결과로 검색 조건에 부합하는 로우 집합이 바뀌었기 때문이다

    • 위 예시는 3단계에서 변경될 로우가 1단계에서 반환된 로우 중 하나임

    • 따라서, 1단계의 로우를 잠금으로써(SELECT FOR UPDATE) 트랜잭션을 안전하게 만들 수 있음

    • 하지만, 사용자명 획득의 경우엔 다름. 검색 조건에 부합하는 로우가 존재하는지 확인 하고 쓰기작업을 하는 방식임

충돌 구체화

  • 위와 같이 잠글 수 있는 객체가 없다면, 어떻게 해야 할까?

  • 인위적으로 데이터베이스에 잠금 객체를 추가할 수 있지 않을까?

  • 예를들어 회의실 예약의 경우 시간 슬롯과 회의실에 대한 테이블을 만드는 것을 생각해 볼 수 있음

  • 이 테이블의 각 로우는 특정한 시간 범위(예를 들어 15분) 동안 사용되는 특정한 회의실에 해당

  • 회의실과 시간 범위의 모든 조합에 대해 로우를 미리(예를 들어 다음 6개월 동안의) 만들어 둠으로 잠글 수 있는 객체를 생성할 수 있음

  • 이런 방법을 충돌 구체화(materializing conflict) 라고 함

  • 하지만, 충돌 구체화는 방법을 알아내기가 어렵고 오류가 발생하기도 쉬움(위 예시 중 사용자명 획득을 충돌 구체화로 구현할 수 있을까?)

  • 따라서 충돌 구체화는 다른 대안이 불가능 할때 최후의 수단으로 고려해야 함

  • 대부분의 경우엔 직렬성 격리 수준을 사용하는 것이 충돌 구체화를 사용하는 것보다 선호됨

0개의 댓글