동시성과 격리 수준
여러 트랜잭션이 동시에 같은 데이터를 만질 때 생기는 일과, 락·MVCC로 막는 방법, 격리 수준마다 허용하는 이상 현상이다.
동시에 고치면 생기는 일
트랜잭션을 하나씩 차례로 돌리면(직렬 실행) 문제가 없지만 느리다. 데이터베이스는 여러 트랜잭션의 연산을 섞어 동시에 돌리고, 그 결과가 "어떤 순서로 하나씩 돌린 것"과 같으면 직렬화 가능(serializable)하다고 한다. 섞는 방법이 허술하면 직렬 실행에서는 절대 나올 수 없는 결과가 나온다. 이것이 이상 현상이다.
SQL 표준은 앞의 세 가지(더티 리드, 반복 불가능 읽기, 팬텀)로 격리 수준을 정의한다. 1995년 Berenson 등의 「A Critique of ANSI SQL Isolation Levels」는 이 정의가 모호하고, 갱신 손실과 쓰기 치우침처럼 표준이 이름 붙이지 않은 이상이 있으며, 특히 스냅숏 격리가 표준의 어느 수준에도 딱 맞지 않는다는 점을 보였다. 이 노트의 낱말과 표는 그 논문을 따른다.
잠금과 2단계 잠금
가장 오래된 해법은 잠금이다. 읽을 때는 공유 잠금(S, 여럿이 함께 가질 수 있음), 쓸 때는 배타 잠금(X, 혼자만)을 잡는다. 하지만 연산마다 잡았다 바로 풀면 직렬화가 보장되지 않는다. 읽고 푼 사이에 남이 고칠 수 있기 때문이다.
2단계 잠금(two-phase locking, 2PL)은 규칙 하나를 더한다. 한 번이라도 잠금을 풀었으면 그 뒤로는 새 잠금을 잡지 않는다. 그러면 트랜잭션은 잠금을 늘리기만 하는 단계와 줄이기만 하는 단계로 나뉘고, 이 규칙만으로 실행 결과가 직렬화 가능함이 증명된다. 실무에서는 커밋할 때까지 모든 잠금을 들고 있는 엄격한 2PL(strict 2PL)을 쓴다. 중간에 풀면 남이 커밋 안 된 값을 읽고, 이쪽이 취소될 때 그 트랜잭션까지 함께 취소해야 하기 때문이다.
잠금을 기다리다 보면 교착(deadlock)이 생긴다. T1이 A를 쥐고 B를 기다리는데 T2가 B를 쥐고 A를 기다리는 경우다. 데이터베이스는 대기 그래프에서 사이클을 찾아 한쪽을 중단시키거나 (PostgreSQL은 deadlock_timeout만큼 기다린 뒤 검사한다), 미리 순서를 정해 사이클이 생기지 않게 한다. 응용 프로그램 쪽에서는 여러 행을 늘 같은 순서(예: 기본 키 순)로 잠그고, 중단되면 트랜잭션 전체를 다시 시도하는 것이 정석이다. PostgreSQL 문서의 Explicit Locking 장이 잠금 종류와 교착 처리를 설명한다.
팬텀은 행 잠금만으로 못 막는다. 아직 없는 행은 잠글 수 없기 때문이다. 그래서 조건 자체를 잠그는 술어 잠금(predicate lock)이나, 인덱스 항목 사이의 틈을 잠그는 갭 잠금(gap lock)을 쓴다.
MVCC
2PL에서는 읽는 쪽과 쓰는 쪽이 서로를 기다린다. 긴 보고서 질의 하나가 쓰기를 모두 막을 수 있다. 다중 버전 동시성 제어(multi-version concurrency control, MVCC)는 행을 덮어쓰지 않고 새 버전을 만든다. 각 트랜잭션은 시작할 때(또는 문장마다) 찍은 스냅숏에 보이는 버전만 읽으므로, 읽기는 쓰기를 막지 않고 쓰기도 읽기를 막지 않는다.
처음 상태. 잔액 행의 버전이 하나 있다(만든 트랜잭션 xmin=100, 커밋됨). T1(101)과 T2(102)가 시작한다.
- 버전의 표시: PostgreSQL은 행마다 만든 트랜잭션 번호(xmin)와 지운 트랜잭션 번호(xmax)를 적고, 고치기는 "옛 버전에 xmax 적기 + 새 버전 넣기"다(MVCC 소개). MySQL InnoDB는 최신 버전만 테이블에 두고 옛 버전은 undo 로그에서 거꾸로 만들어 낸다.
- 보이는 규칙: 만든 쪽이 스냅숏 시점에 커밋돼 있고, 지운 쪽이 없거나 스냅숏 시점에 아직 커밋 전이면 보인다. 자기 변경은 늘 보인다.
- 쓰기끼리는 여전히 충돌: 두 트랜잭션이 같은 행을 고치면 나중 쪽은 먼저 쪽이 끝나기를 기다린다. 그다음 어떻게 할지가 격리 수준마다 다르다(마지막 절).
- 청소: 아무도 볼 수 없는 옛 버전은 PostgreSQL의 VACUUM, InnoDB의 purge가 치운다. 오래 열린 트랜잭션이 있으면 그 스냅숏이 볼 수 있는 버전을 못 치워 테이블이 부푼다.
격리 수준
완전한 직렬화는 비싸서, 데이터베이스는 어느 이상까지 허용할지 고르게 해 준다. 이것이 격리 수준(isolation level)이다. 아래 표는 Berenson 등의 논문 표 4를 줄인 것이다.
표에서 눈여겨볼 곳은 아래 두 줄이다. 잠금으로 구현한 반복 읽기는 읽은 행을 잠가 두므로 갱신 손실과 쓰기 치우침을 막지만, 행이 새로 생기는 팬텀은 막지 못한다. 스냅숏 격리는 그 반대로, 스냅숏이 고정돼 팬텀이 거의 안 보이지만 서로 다른 행을 고치는 쓰기 치우침은 지나간다. 그래서 같은 "REPEATABLE READ"라는 이름이라도 제품마다 실제로 막는 것이 다르다.
스냅숏 격리와 직렬화 가능 스냅숏 격리
스냅숏 격리(snapshot isolation, SI)는 MVCC로 자연스럽게 얻는 수준이다. 트랜잭션 전체가 시작 시점 스냅숏을 읽고, 같은 행을 동시에 고친 두 트랜잭션 중 나중에 커밋하려는 쪽을 실패시킨다 (먼저 커밋한 쪽이 이긴다). 남는 구멍이 쓰기 치우침(write skew)이다.
막는 방법은 셋이다. 응용 프로그램이 SELECT … FOR UPDATE로 읽은 행을 잠그거나, 규칙을 제약 조건으로 표현하거나, 데이터베이스가 직렬화를 보장하게 하는 것이다. 마지막 방법이 직렬화 가능 스냅숏 격리(serializable snapshot isolation, SSI)다. 스냅숏 격리 위에서 트랜잭션이 읽은 범위를 기록해 두고, 그림처럼 읽기-쓰기 의존 두 개가 이어진 위험한 구조가 생기면 한쪽을 중단시킨다. 잠금처럼 기다리게 하지 않으므로 읽기가 많은 부하에서도 2PL보다 처리량이 잘 나온다. 대신 실제로는 문제가 없는데 중단되는 경우(거짓 양성)가 있어 재시도가 늘어난다. PostgreSQL 9.1이 이를 구현했고, 설계는 Ports와 Grittner의 「Serializable Snapshot Isolation in PostgreSQL」에 있다.
PostgreSQL과 MySQL의 기본값
두 데이터베이스는 기본 격리 수준부터 다르다. PostgreSQL의 기본은 READ COMMITTED이고, MySQL InnoDB의 기본은 REPEATABLE READ다. 같은 이름의 수준도 구현이 달라, 같은 순서로 실행해도 결과가 갈린다.
- PostgreSQL(Transaction Isolation): 실제로는 세 수준만 있다. READ UNCOMMITTED를 고르면 READ COMMITTED로 동작한다. READ COMMITTED는 문장마다 새 스냅숏을 찍는다. REPEATABLE READ는 스냅숏 격리라 표준이 허용하는 팬텀도 막지만, 동시에 고친 행을 만나면
could not serialize access due to concurrent update오류를 낸다. SERIALIZABLE은 위의 SSI다. - MySQL InnoDB(Transaction Isolation Levels): REPEATABLE READ의 평범한 SELECT는 처음 읽을 때 찍은 스냅숏을 끝까지 쓴다(Consistent Nonlocking Reads). 반면 UPDATE·DELETE와
SELECT … FOR UPDATE같은 잠그는 읽기는 최신 커밋 값을 읽고, 인덱스 항목과 그 앞 틈을 함께 잠그는 다음 키 잠금으로 그 범위에 새 행이 끼어드는 것(팬텀)을 막는다. SERIALIZABLE은 평범한 SELECT도 공유 잠금을 잡는 잠금 방식이다.
어느 쪽이든 결론은 같다. 격리 수준은 이름이 아니라 그 제품 문서의 정의로 확인하고, 직렬화 실패나 교착으로 중단된 트랜잭션은 처음부터 다시 시도하도록 응용 프로그램을 짠다. 재시도할 수 없는 외부 호출(결제, 메일)은 트랜잭션 밖으로 뺀다.
낙관적 잠금과 비관적 잠금
사람이 화면을 열어 두고 몇 분 뒤에 저장하는 일처럼 한 작업이 여러 요청에 걸치면, 데이터베이스 트랜잭션을 그동안 열어 둘 수 없다. 이때 응용 프로그램이 고르는 두 전략이 Martin Fowler가 『Patterns of Enterprise Application Architecture』에서 이름 붙인 비관적 잠금과 낙관적 잠금이다.
| 비관적 잠금 | 낙관적 잠금 | |
|---|---|---|
| 가정 | 충돌이 잦다 | 충돌이 드물다 |
| 방법 | 쓰기 전에 먼저 잠가 한 번에 한 작업만 들어오게 한다(한 트랜잭션 안이면 SELECT … FOR UPDATE) | 잠그지 않고 일한 뒤, 저장하는 순간 그사이 누가 고쳤는지 확인해 고쳤으면 되돌린다 |
| 대가 | 기다림이 생기고 동시에 일할 수 있는 양이 준다. 잠금을 쥔 채 사라진 작업을 풀어 줄 규칙이 필요하다 | 충돌하면 그때까지 한 일을 다시 해야 한다 |
낙관적 잠금은 흔히 행마다 버전 열을 두고 UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ?처럼 읽을 때 본 버전을 조건에 넣어 구현한다. 고친 행이 0개면 그사이 남이 먼저 저장한 것이므로 사용자에게 알리고 다시 읽게 한다. 확인과 저장이 한 문장(한 트랜잭션) 안에서 함께 일어나야 한다는 것이 핵심이다. 앞 절의 갱신 손실을 응용 프로그램 수준에서 막는 방법이다.
- A Critique of ANSI SQL Isolation Levels — Berenson 외, Microsoft Research
- Serializable Snapshot Isolation in PostgreSQL — Ports·Grittner
- Optimistic Offline Lock — Martin Fowler (PoEAA)
- Pessimistic Offline Lock — Martin Fowler (PoEAA)
- Transaction Isolation — PostgreSQL Documentation
- MVCC Introduction — PostgreSQL Documentation
- Explicit Locking — PostgreSQL Documentation
- Transaction Isolation Levels — MySQL 8.4 Reference Manual
- Consistent Nonlocking Reads — MySQL 8.4 Reference Manual
- Phantom Rows (Next-Key Locking) — MySQL 8.4 Reference Manual