DB 트랜잭션!

트랜잭션

트랜잭션은 작업의 완전성을 보장한다.

논리적인 작업 셋을 모두 완벽하게 처리하거나, 작업의 일부만 적용되는 현상(Partial update)이 발생하지 않는것을 보장하는 기능이다.

트랜잭션을 상당히 골치아픈 기능으로 생각하지만, 그만큼 애플리케이션 개발에서 고민해야 할 문제를 줄여주는 필수적 DBMS 기능이다.

계좌시스템을 예로 들면 A가 B에게 입금하는 작업을 하나의 트랙잰석(작업단위)로 볼 수 있다.
아래 2작업이 한번에 이루어져야 한다.

1) A가 B에게 돈을 입금하고 A의 보유금을 마이너스
2) B는 A에게 입금받은 돈은 보유금에서 플러스

만약 1번만 성공하고 2번은 실패할 경우 A 계좌에서 돈만 빠져나가는 오류가 발생하게 된다.

트랜잭션이 성공하면 commit 하고 중간에 어떠한 과정이라도 실패한게 있다면 rollback해야 한다.

DML문을 실행하면 해당 트랜젝션에 의해 발생한 데이터가 다른 사용자에 의해 변경이 발생하지 않도록 lock을 발생시킨다.

lockcommit 또는 rollback 문이 실행되면 해제된다.

오라클 SYS 계정과 SCOTT 계정에 각각 접속하여 트랜잭션 처리를 확인. SYS계정에선 MILLER의 job이 CLERK라 출력된다.

-- SCOTT의 계정에서 실행
UPDATE emp SET job = 'ARTIST' WHERE ename = 'MILLER';

SELECT ename, job FROM emp WHERE ename = 'MILLER'; 
-- (MILLER, ARTIST) 출력
-- SYS의 계정에서 실행
SELECT ename, job FROM emp WHERE ename = 'MILLER';
-- (MILLER, CLERK) 출력

image1

SYS가 빨간색, SCOTT이 파란색.

SCOTT 계정 접속세션에서만 변경되었기 때문에 SYS 계정 세션에선 MILLER의 job은 아직 CLERK이다.

이 상태에서 SYS계정에서 MILLER의 job을 XXXX로 업데이트 진행.

UPDATE scott.emp
SET job = 'XXXXX'
WHERE ename = 'MILLER';

아직 SCOTT 계정 세션에서 update & commit 하지 않았기 때문에 SYS 계정 세션에선 lock 으로 인해 무한로딩된다.

image1

SCOTT 계정세션에서 commit 하는 순간 SYS 계졍세션의 updatelock 이 풀리면서 실행된다.

image1

이젠 SYS 계정 세션에서 emp 테이블에 lock 을 걸었고 commit 해주기 전엔 다른 세션에서 해당 데이터를 수정하거나 삭제하지 못한다.

ACID

출처: 위키

ACID(원자성, 일관성, 고립성, 지속성) 는 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어이다.

원자성(Atomicity)
트랜잭션 내 작업들이 부분적으로 실행되다가 중단되지 않는 것을 보장하는, 전체 성공하거나 실패하더라도 모든 내용을 취소하는 어보트 능력(abortability) 이다.

일관성(Consistency)
트랜잭션이 성공적으로 완료하면 설정해둔 무결성 제약 을 포함해 언제나 일관성 있는 데이터베이스 상태로 유지하는 것을 의미한다.

격리성(Isolation)
트랜잭션A 수행 시 다른 트랜잭션B 가 끼어들지 못하도록 보장하는 것을 의미한다.

지속성(Durability)
트랜잭션이 성공적으로 완료하면 영원히 반영되어야 함을 의미한다. 복구/복제 기능을 구현하기 위해 여러가지 기능이 트랜잭션 로그를 사용함.

스케줄 serializable

자세한 설명: https://www.youtube.com/watch?v=DwRN24nWbEc

Serializable 여러개의 트랜잭션이 동일한 시간대에 실행될 경우 read/write 요청이 섞이더라도 모두 동일한 결과값을 요하는 성질이다.

DB 가 트랜잭션을 받게되면 아래 2가지 방식으로 수행될 수 있다.

  • Serial Shcedule
  • Non-serial Schedule

image1

모든 트랜잭션이 Serial shcedule 으로 처리된다면 아래에서 알아볼 [락, 격리] 등은 알필요가 없다.

하지만 대부분 DBMS 에선 성능때문에 트랜잭션을 Non-serial Schedule 으로 처리하면서
ACID 요구사항을 만족시키기 위해 각종 제약조건을 사용한다.

제약조건의 목표는 Non-serial Schedule 의 트랜잭션들이 conflict serializable(충돌직렬화) 조건을 만족하는 것.

conflict serializableNon-serial Schedule 의 트랜잭션들이 하나의 자원을 read/write 하는 순서가
Serial shcedule 에서 하나의 자원을 read/write 하는 순서가 동일함을 뜻한다.

read/write 하는 순서가 동일할 경우 Serial Schedule Non-serial Schedule 상관없이 모두 동일한 결과가 출력된다.

스케줄 recoverability

recoverability 는 스케줄이 rollback 되었을 때 복원성을 의미한다.

여러개의 트랜잭션이 동시에 실행되다 특정 트랜잭션이 rollback 될 경우 정상적으로 복원되지 않을 경우가 있다.

특이한 조건에서 recoverability 가 손상되는 상황을 알아보고
아래 3가지 종류의 스케줄을 사용해 방지하는 방법을 알아본다.

  • Recoverability Schedule
  • Cascadeless Schedule
  • Strict Schedule

https://www.geeksforgeeks.org/types-of-schedules-based-recoverability-in-dbms/

Recoverability Schedule

아래 그림처럼 스케줄 내에서 Dirty Read 가 발생하는 경우를 복구하기 위한 스케줄 방법임.

image1

T2 가 commit 되지 않은 값을 read, R(A)=6 하고 T1 이 rollback 하게되면서 부정확한 값이 되어버렸다.

트랜잭션의 의존관계를 파악하고, 부모 트랜잭션이 commit/rollback 되는것을 확인한 후 자식 트랜잭션도 commit/rollback 하는 스케줄을 Recoverability Schedule(보존 스케줄) 이라 한다.

Recoverability Schedule 은 트랜잭션의 의존관계를 파악하고 부모 트랜잭션과 자식 트랜잭션을 연결해서 commit/rollback 해야한다. cacading rollback 이라 하는데 비용이 높아 사용하지 않는다.

그래서 cacading rollback 기술을 사용하지 않으면서도 recoverability 를 확보하기 위해 Cascadeless Schedule 를 사용한다.

Cascadeless Schedule

데이터를 write 한 트랜잭션이 commit/rollback 되기 전까지는 read 를 제한하는 스케줄을 Cascadeless Schedule(비종속 스케줄) 이라 한다.

아래 그림처럼 동일한 자원 X 를 건드리는 트랜잭션 3개가 있을 경우 commit(초록박스) 되기까지는 read 접근을 막아 종속관계를 없엔다.

image1

Cascadeless Scheduleread 에 대해서만 접근제한하고 wirte 는 별도로 제한하지 않기 때문에,
write 트랜잭션이 연속적으로 일어나는 환경에서 rollback 으로 인해 일부 write 트랜잭션들이 무시될 수 있다.

Strict Schedule

데이터를 write 한 트랜잭션이 commit/rollback 되기 전까지는 read/write 모두 제한하는 스케줄을 Strict Schedule(엄격한 스케줄) 이라 한다.

아래 그림과 같이 스케줄 다이어그램으로 구분할 수 있으며
Strict Schedule 이 공유자원에 대해 가장 엄격하게 관리한다.

image1

앞으로 설명할 [락, 격리레벨] 등은 모두 트랜잭션의 [serializable, recoverability] 속성을 만족시키기 위해서다.

MySQL 트랜잭션

MySQL 은 로그 기반(Undo/Redo Logs)의 트랜잭션 관리를 통해 원자성을 제공한다.

  • Undo Log
    • 트랜잭션 중에 변경된 데이터를 이전 상태로 되돌릴 수 있게 해주는 로그.
    • 만약 트랜잭션 도중 실패하면 Undo 로그를 사용해 변경된 데이터를 원래 상태로 복구.
  • Redo Log
    • 트랜잭션이 커밋된 후, 시스템 장애가 발생해도 데이터를 복구할 수 있게 해주는 로그.
    • 커밋된 데이터를 디스크에 안전하게 기록해주는 역할.

InnoDB 엔진의 경우 디스크쓰기 작업 오류 방지를 위해 첫 번째로 버퍼에 기록하고, 이후 실제 데이터 파일에 기록하는 Doublewrite Buffer 기법을 사용한다.

락(Lock)

락은 동시성을 제어하기 위한 기능
트랜잭션은 데이터의 정합성을 보장하기 위한 기능.

락은 개념적으로는 아래와 같이 두종류로 나눌 수 있다.

  • Read Lock
    Shared Lock(공유 락) 이라고도 하며 다른 트랜잭션이 read 하는것을 허용하고 write 하는것을 비허용한다.
  • Write Lock
    Exclusive Lock(배타적 락) 이라고도 하며 다른 트랜잭션이 read/write 하는것을 모두 비허용한다.

락의 호환성은 아래와 같다.

  Read Lock Write Lock
Read Lock O X
Write Lock X X

MySQL InnoDB Lock

MySQL 에서 기본적으로 제공하는 잠금은 아래와 같다.

  • Global Lock: MySQL 서버 전체 잠금을 설정, 백업시에 주로 사용.
  • Table Lock: 개별 테이블 단위로 잠금을 설정
  • Named Lock: GET_LOCK() 함수를 통해 임의 문자열에 대해 잠금을 설정.

MySQL InnoDB 에서 별도로 레코드 기반 잠금기능을 제공한다.

  • Record Lock: 인덱스의 레코드를 잠근다. 기본키, 유니크 인덱스 관련 작업은 Record Lock 을 사용한다.
  • Gap Lock: 레코드와 인접한 위 아래 간격을 잠근다. 레코드 사이 새로운 INSERT 를 제어한다.
  • Next-Key Lock: Record Lock + Gap Lock 형태의 잠금.

image1

MySQL InnoDB 에서 인덱스 기반으로 SELECT 요청을 할 경우 해당 인덱스의 레코드에 Next-Key Lock 이 발생한다.
FOR SHARE, FOR UPDATE 와 같이 명시적인 잠금을 사용할 때에도 Next-Key Lock 이 발생한다.

기본키나 고유 인덱스를 사용하여 단건 조회시 Next-Key Lock 을 피할 수다.

Next-Key Lock으로 인해 해당 인덱스를 수정/삭제 하거나, Gap 안에 새로운 레코드를 추가할 수 없다.

만약 인덱스가 아닌 필드를 기반으로 SELECT 요청을 할 경우 테이블 전체에 락을 걸게 됨으로 철저한 인덱스 설계가 필요하다.

트랜잭션 격리

트랜잭션 격리 는 트랜잭션 처리 과정에서 두개 이상의 트랜젝션이 동시에 같은 공유 자원 접근시(Race Condition) 발생하는 문제를 처리하기 위한 제약조건이다.

위에서 설명한 트랜잭션의 [serializable, recoverability] 속성을 만족시키기 위한 제약조건이기도 하다.

여러개의 트랜잭션이 하나의 자원에 접근할 때 별도의 제약조건 없이 read/write 할 수 있도록 풀어줄경우 아래와 같은 이상한 상황이 발생할 수 있다.

  • Dirty Read
  • Nonrepeatable Read
  • Phantom Read

격리레벨은 커넥션(세션)마다 고유하게 설정할 수 있으며 아래 명령어로 현재 DB 에서 기본 실행되는 커넥션의 격리레벨 조회가능하다.

-- mysql
-- SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT @@transaction_isolation;
-- REPEATABLE-READ

Dirty Read

image1

READ_UNCOMMITED 가능할 경우 commit 되기전 정보를 read 할 수 있다.
이 과정에서 오류가 발생되어 읽어온 정보가 rollback 된다면 read 된 정보는 잘못된 정보가 되어버린다.

이렇게 잘못된 정보를 읽는 것을 Dirty read 라 한다.

READ_COMMITTED 으로 설정하면 commit 된 데이터만 read 할 수 있음으로 Dirty Read 가 발생하지 않는다.

Nonrepeatable Read

image1

한 트랜잭션에서 별도의 쓰기작업 없이 처음 읽어드린 값과 두번째 읽어드린 값이 다른 상황을 Nonrepeatable Read 이라한다.

Phantom Read

image1

여러개의 레코드를 read 하는 경우에서 발생하는 이슈로 처음 읽었을 때 와의 레코드개수와 두번째 읽었을 때 레코드 개수가 다른 경우를 Phantom Read 라 한다.

격리레벨(isolation level)

위와 같은 문제를 해결하기 위해 하나의 트랜잭션을 격리하기 위한 제약조건을 level 로 표현했다.

격리레벨 Dirty Read Nonrepeatable Read Phantom Read
READ_UNCOMMITED O O O
READ_COMMITTED X O O
REPEATABLE_READ X X O
SERIALIZABLE X X X
  • READ_UNCOMMITED
    다른 트랜잭션에서 커밋하지 않은 데이터 read 가능.
  • READ_COMMITTED
    다른 트랜잭션에 의해 커밋된 데이터만 read 가능.
  • REPEATABLE_READ
    DB 별로 구현방법이 다름, 스냅샷과 같은 방식으로 트랜잭션들을 격리함.
  • SERIALIZABLE
    선점한 트랜잭션이 수행중일 때 read/write 할 수 없다.

DB 제조사별로 격리레벨에 차이가 있다, MySQL, PostgreSQL 의 경우 MVCC 방식을 사용하기에 READ_COMMITTED 격리레벨만 사용해도 Dirty Read, Nonrepeatable Read 를 방지할 수 있다.

MVCC

출처: Real MySQL

락 기반 Concurrency Control 의 경우 Read Lock 만 공유되기에 성능에서 떨어진다.
특히 [READ_COMMITTED, REPEATABLE_READ] 를 락 기반으로 구현할 경우 다량의 Read Lock 이 발생하고 심한 고립성을 가지게 된다.

그래서 대부분 DBMS 가 락 기반 Concurrency Control 을 하지 않고 MVCC(Multiversion Concurrency Control) 기법을 사용해 REPEATABLE_READ 를 구현한다.
DBMS 별로 다양한 MVCC 구현방법이 있는데, MySQL InnoDBMVCC 는 특정 시점의 변경정보를 version 으로 기록하기 위해 Undo Log 를 사용한다.

아래 그림과 같이 업데이트 이전 값은 Undo Log 에 빼놓고 새로 삽입한 값은 Buffer Pool 에 일정시간 저장되어 있다가 백그라운드 스레드에 의해 디스크에 기록된다.

image1

READ_UNCOMMITTED 의 경우 Buffer Pool 에서 데이터를 읽어서 반환하고
READ_COMMITTED 혹은 그 이상의 격리레벨의 경우 Undo Log 에서 데이터를 읽어 반환한다.

Undo Log 에서 락 제한 없이 값을 읽어 Non-Locking Consistent Read(잠금 없는 일관된 읽기) 라 부른다.

아래 그림처럼 4번 index rowBuffer PoolUndo Log 2가지 version 으로 관리된다.

image1

MySQL 격리레벨

MySQL 의 경우 기본 격리레벨이 REPEATABLE_READ

MySQLUndo Log MVCC 기법은 READ_COMMITTED, REPEATABLE_READ 에서 모두 사용된다.

트랜잭션은 접근한 데이터를 Undo Log 를 통해 조회 및 변경하기 때문에 실제 테이블에 바로 데이터를 변경하지 않고 commit 이 완료된 이후, 백그라운드 스레드에 의해 적용된다, 따라서 자연스럽게 Dirty Read 를 방지한다.
또한 모든 트랜잭션이 MVCC 로 동작할 경우 접근한 데이터를 Undo Log 를 통해 관리하기에 타 트랜잭션이 접근한 데이터에 침범하는 Nonrepeatable Read 를 방지할 수 있다.

그리고 MySQL REPEATABLE_READ 격리레벨에선 추가적으로 Next-Key Lock 을 사용하기 때문에 Phantom Read 가 발생하지 않는다.

따라서 엄밀히 말하자면 MySQLREAD_COMMITTE 에선 Nonrepeatable Read 까지 방지할 수 있고, REAPEATABLE_READ 에선 Phantom Read 까지 방지할 수 있다.

Next-Key Lock 이 성능저하를 야기함으로 인덱스를 통한 범위조회 사용시 주의해야 하고, 안정성보다 성능을 중요시하고 싶다면 READ_COMMITTED 로 격리레벨을 낮춰 사용해야 한다.

MySQL 에서 SERIALIZABLE 격리레벨을 사용하면 모든 SELECT 가 자동으로 SELECT ... FOR SHARE 로 암묵적 변환된다.

MySQL InnoDB 에선 기본 REAPEATABLE_READ 로 커넥션을 생성한다.

PostgreSQL 격리레벨

PostgreSQL 의 경우 기본 격리레벨이 READ_COMMITTED

PostgreSQL 내부적으로 READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE 3가지 격리레벨만 존재한다.

PostgreSQL 역시 트랜잭션 고유의 txid 를 사용하여 데이터들의 스냅샷을 관리하는 MVCC 방식을 사용한다.

트랜잭션은 자신의 txid 가 반영된 데이터 레코드만 보이기 때문에 READ_COMMITTED 격리레벨만 설정해도 Dirty ReadNonrepeatable Read 가 방지된다. 하지만 Phantom Read 는 발생할 수 있다.

자신보다 큰 txid 가 작성한 row 는 commit 여부 상관없이 질의해도 보이지 않는다.

REPEATABLE_READ 격리레벨은 스냅샷 형태로 구현하다 보니 Snapshot Isolation 이라 부르기도 한다.

각 트랜잭션은 데이터 접근시 자신만의 데이터 스냅샷을 보유하게 되며, 이를 통해 다른 트랜잭션에서의 변경으로 인해 Nonrepeatable Read 가 발생하지 않는다.

또한 Snapshot Isolation 레벨에서 두개의 트랜잭션에서 write 요청 이 발생하면 트랜잭션 매니저abort(쓰기 충돌) 를 발생시킨다.
만약 트랜잭션1 start-commit 사이에 트랜잭션2 가 먼저 commit 했다면 후에 트랜잭션1 commitabort 된다. 그래서 First-Committed-Wins 이라는 이름으로 불리기도 한다.

트랜잭션이 abort 되면 그동안의 write 명령 은 모두 무시된다.

image1

트랜잭션이 직렬적으로 실행되었는지 여부는 commit 발생시 해당 레코드의 version row 를 트랜잭션 매니저가 확인하여 사이에 변화가 일어났는지 확인하여 판단한다.

위 그림에선 트랜잭션43 이 보고있는 Alice true 데이터 레코드가 deleted by 42 처리되었다.
더이상 해당 트랜잭션이 직렬적으로 실행되었다고 볼 수 없게 되었다.

트랜잭션이 commit 되기 전 해당 데이터 레코드의 version row 가 변경되었는지 한번 더 확인하고, 실행중 격리가 되지 않은 트랜잭션은 abort 처리, 직렬적으로 실행된 트랜잭션은 commit 된다.

Snapshot Isolation 은 잠금 없이 트랜잭션들을 실행시키지만 트랜잭션들의 경쟁이 심해지면 직렬로 실행되지 못한 트랜잭션이 많아지면서 abort 비율이 높아져서 Write Lock 을 사용하는 SERIALIZABLE 보다 성능이 떨어질 수 있다.

또한 Snapshot Isolation 레벨에서 인덱스 조회시 모든 version row 를 찾고 필터링 하는 방식으로 운영되다 보니 많이 느리다. 그리고 직렬화 여부를 온전히 트랜잭션 매니저에 의탁하기에 DB 부하가 일어날 수 있다.

그럼에도 Snapshot Isolation 를 사용해야 하는 이유는 락 없이 직렬화 구현이 가능하다는 점, 대부분 쿼리요청이 read 이기에 abort 가 많이 발생하지 않아 효율적이기 때문이다.

SERIALIZABLE 격리레벨은 Serializable Snapshot Isolation(SSI) 이라 불리며, 읽기에 대한 version row 를 추가로 관리한다.

image1

Snapshot Isolation 에서 약간의 변화만 있을 뿐 추가적인 락을 걸지 않고 읽기 충돌 을 추가로 감지할 수 있게 되었다.
만약 트랜잭션42 가 트랜잭션43 이 읽은 key range 사이에 새로운 데이터 레코드를 삽입할 경우 위 그림과 같이 읽기 충돌 을 감지하여 읽은 데이터가 old version 임을 알 수 있게된다.

MySQL MVCC 문제점

Undo Log 를 사용해서 Non-Locking Consistent Read 하는 것은 좋지만, read 에 대해 제약조건 없이 접근을 허용할 경우 Dirty Read 가 발생할 수 밖에 없다.

위 그림만 봐도 실제 값은 경기 인데 읽어드린 값은 서울 이다.

여기서의 Dirty Read 는 위에서 설명한 rollback 으로 인한 Dirty Read 와는 결이 다르다.

순서 차이일 뿐 Undo Log 로부터 정상적으로 read 했기 때문에 Dirty Read 가 아니라고 볼수 있지만,
대다수의 아티클에서 해당 상황도 오류를 야기함으로 Dirty Read 에 포함시켜야 한다고 주장하고 있다.

만약 해당 방식으로 Dirty Read 한 값을 기반으로 write 를 수행하면 Lost Update 까지 이어진다.

물론 여기서 말하는 Lost Update 는 2번의 트랜잭션으로 이루어지는 어플리케이션 코드에서 발생하는 오류이다.

MySQL MVCC 에서 기본 격리레벨인 REPEATABLE_READ 는 별도의 락 없이 동작하기 때문에 Dirty Read - Lost Update 가 충분히 발생할 수 있다.

대부분 코드에서 read-modify-write 주기로 DB 에 접근하는 방식으로 문제를 해결하는 경우가 많아 Lost Update 는 자주 발생하는 이슈이다.

PostgreSQL MVCC 문제점

아래는 [Account1, Account2] Entityupdate 요청MVCC 로 처리하는 과정이다.
모든 트랜잭션은 자신보다 높은 txid 는 읽을 수 없어 Lost Update 가 발생할 수 있다.

  • txid=12select 요청 진행
  • txid=13update 요청 진행

row 의 write 요청이 일어날 때마다 새로운 version 의 복사본이 생성된다.

image1

  • created_by: row 를 삽입한 txid
  • deleted_by: row 를 삭제한 txid

txid=13deleted_by 만 설정할 뿐, 기존 value 는 건드리지 않고 업데이트 사항을 새로 추가한 복사본 version 을 생성한다.

나중에 모든 트랜잭션이 commit 되면 GC 가 deleted_by 표시된 row 들을 삭제한다.

deleted_by 가 활성화 되어 있어도 commit 되지 않았다면 읽기를 실행한다.
PostgreSQLMVCC 에서 별도 Read Lock 을 사용하지 않기 때문에 위에서 설명한 Dirty Read - Lost Update 가 발생할 수 있다.
물론 PostgreSQL 의 트랜잭션 매니저가 이미 deleted_by 표시된 row 업데이트는 abort 하겠지만 읽어들인 500 을 가지고 어떤 작업을 할지는 모르는 일이다.

추가 이상현상

격리레벨에선 3가지 이상현상인 [Dirty Read, Nonrepeatable Read, Phantom Read] 만 소개했는데, 3가지 이외에도 격리레벨마다 추가이상현상이 존재한다.

사실상 위 3가지 이상현상에 의해 파생되는 추가이상현이다.

Dirty Write

READ_UNCOMMITED 에서 발생가능한 이상현상이다.

commit 되지 않는 데이터를 write 할 때 발생한다.

연달아서 write 만 하는 트랜잭션이 트랜잭션 스케줄이 있을 경우, 첫번째 트랜잰셕이 rollback 되면서 두번째 트랜잭션의 반영사항이 무시될 수 있다.

Lost Update

READ_COMMITTED 에서 발생가능한 이상현상이다.

Shared Lock 이 없을 때 read -> modify -> write 형태로 구성된 트랜잭션이 동시에 실행될 때 발생한다. 트랜잭션2 가 트랜잭션1 을 덮어 씌우는 경우이다.

Dirty Read 한 후 해당 데이터로 write 하고 commit 하는 것이다.

Lost Update 를 해결하는 첫번째 방법은 원자적 쓰기연산 이다.

조회와 수정이 동시에 일어나는 write 요청 을 사용하여 로직을 처리할 수 있다면 가장 쉬운 해결책이다.

UPDATE counters SET value = value + 1 WHERE key = 'foo';

Lost Update 를 해결하는 두번째 방법은 명시적인 잠금 이다.

read/write 가 복잡하게 얽힌 비즈니스 로직과 높은 동시성 제어가 필요한 트랜잭션이라면 read 요청에도 베타적 설정을 지정해줘야 한다.

-- Locking Read
SELECT balance FROM account WHERE id = '1' FOR UPDATE; -- WRITE LOCK

이런 형태의 락을 Locking Read 라 하고 read 요청 이지만 Write Lock 을 요구한다.
동시에 실행되는 두 트랜잭션을 Locking Read 로 수행할 경우 read 요청 동시에 실행되지 못하도록 막기 때문에 Dirty Read 가 발생하지 않는다.

사실상 베타적락 기반 으로 실행되는 Concurrency Control 이랑 다름없다.
경쟁 조건이 발생할 수 있지만 동시에 발생하는 트랜잭션의 read-modify-write 가 순서대로 처리되도록 강제한다.

Lost Update 를 해결하는 세번째 방법은 갱신손실 자동감지 이다.

MySQL MVCC 에서 지원하지 않음, PostgreSQL MVCC 에선 비슷한 형태로 지원함.

DB에서 자체적으로 운영하는 트랜잭션 관리자read-modify-write 과정에서 다른 트랜잭션의 deleted_by 설정된 version 이 먼저 commit 되어 Lost Update 를 발견할 경우 현재 트랜잭션을 abort 한다.
PostgreSQL MVCCSnapshot isolation 처럼 version 별 커밋 여부를 파악할 수 있는 DB에서 사용되고 있는 방법이며 어플리케이션에서도 예외처리만 하면 될뿐 별도의 수정을 할 필요가 없다.

Lost Update 를 해결하는 네번째 방법은 Compare & Set 이다.

원자적 쓰기연산 과 유사한 방법으로 수정하며 이전값의 조건문을 통해 갱신을 시도한다.

UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content'; -- 이전 값 기준 필터링

Write Skew

Lost Update 가 일어나지 않는다면 모든 문제가 해결될것 같지만 그렇지 않다.

Write Skew 는 DB 레이어가 아닌 어플리케이션 레이어에서 제한하고 있는 조건을 위배하는 이상현상이다.

예를들어 최소 한명 이상의 경비원이 모니터링해야 하는 시스템에서 경비원이 휴식버튼을 누를때마다 어플리케이션은 다른 경비원이 모니터링중인지 확인하는 로직이 있다 가정하자.

만약 모든 경비원이 동시에 모니터링상태에서 휴식버튼을 누르게 될 경우, 어플리케이션에선 당연히 한명이상 근무중인 경비원이 있다고 판단하고 모든 경비원에게 휴식상태를 부여하게 된다.
최종적으로는 어플리케이션에서 제한하고 있는 조건을 위배하게 될 것이다.

경비원 모두 자신의 레코드에 접근하여 상태값을 변경할 것이기 때문에 Lost Update, Dirty Read 로 발생한 문제도 아니다.

예약 시스템도 마찬가지이다, 해당 시간대에 예약이 없는지 체크하고 예약 레코드를 삽입하는 시스템에서 동시에 동일한 예약시간에 예약을 진행하면 레코드가 2개가 들어가게 된다.

범위 질의를 통해 Phantom Read 가 발생한 이후 write 요청 시에 주로 발생한다.
SERIALIZABLE 격리레벨을 사용하면 막을 수 있다.

SERIALIZABLE 격리레벨 구현 방법

충돌 구체화(materializing conflict)
충돌 구체화는 어플리케이션에서 데이터 모델을 인위적으로 비틀어 해결하는 방법이라 거의 사용하지 않는다.
위의 회의실 예약을 예로 들면, 예약 시간에 대한 row 를 만들고 row 집합에 대한 잠금 충돌로 Write Skew 를 예방하는 방법이다.

대부분 Write Skew 를 피하기 위해 SERIALIZABLE 격리수준을 사용한다.

SERIALIZABLE 격리수준은 발생 가능한 모든 경쟁 조건을 막는 가장 강력한 격리 수준이다.
대부분 DB 은 아래 3가지 기법중 하나를 사용한다.

  1. 트랜잭션을 순차실행
    단일코어, SP 기반으로 트랜잭션을 강제로 순서대로 실행되도록 제어한다.
  2. 2PL(two-phase locking)
  3. SERIALIZABLE SNAPSHOT

2PL(two-phase locking)

2PL(two-phase locking) 은 트랜잭션에서 모든 locking 명령이 최초의 unlock 명령보다 먼저 수행되도록 하는 방법이다.

락을 통해 트랜잭션의 read/write 순서를 강제하면서 자연스럽게 Serializable 를 만족하게 된다.
하지만 모든 트랜잭션이 자원을 공유하지 않기 위해 락을 흭득하게되면 데드락 상태가 발생하게 된다.

여기서 말하는 잠금의 종류는 아래와 같다.

  • 테이블 전체를 잠금
  • 색인 범위 잠금(index-range locking, next-key locking)
  • 서술 잠금(WHERE 조건을 통해 검색된 row 잠금)

Conservative 2PL

모든 Lock 을 취득한 다음 트랜잭션을 수행한다.
모든 자원을 가져야 트랜잭션이 수행됨으로 데드락이 발생하지 않는다.
하지만 모든 Lock 을 취득하기 위한 시간이 오래 걸림으로 성능이 떨어져 잘 사용하지 않는 방법이다.

read_lock(X)
write_lock(Y)
write_lock(Z)
...
unlock(X)
...
unlock(Y)
unlock(Z)
commit

Strict 2PL

Strict Schedule 을 보장하는 2PL.
Write Lockcommit/rollback 이후에 해제한다.

read_lock(X)
write_lock(Y)
write_lock(Z)
...
unlock(X)
...
commit
unlock(Y)
unlock(Z)

Strong Strict 2PL(SS2PL)

Strict Schedule 을 보장하는 2PL.
Write Lock, Read Lock 모두 commit/rollback 이후에 해제한다.

read_lock(X)
write_lock(Y)
write_lock(Z)
...
...
commit
unlock(X)
unlock(Y)
unlock(Z)

카테고리:

업데이트: