Junuuu 2022. 12. 15. 00:01

냉혹한 현실 세계의 데이터 시스템

 

쓰기 연산이 실행 중일 때는 포함해서 시스템은 다음과 같은 문제들이 발생할 수 있습니다.

- 소프트웨어나 하드웨어는 언제라도 실패할 수 있다.

- 애플리케이션은 언제라도 죽을 수 있다

- 네트워크가 갑자기 끊겨서 노드 사이의 통신이 안될 수 있다

- 여러 클라이언트가 동시에 쓰기를 실행할 수 있다

- 클라이언트가 부분적으로만 갱신되어 비정상적인 데이터를 읽을 수 있다

- 클라이언트 사이의 경쟁 조건은 예상치 못한 버그를 유발할 수 있다

 

시스템이 신뢰성을 갖추기 위해서 이런 결함을 처리해서 장애로 이어지는 것을 막아야 합니다.

 

트랜잭션

위의 문제를 해결하기 위해 수십 년 동안 트랜잭션은 이런 문제를 단순화하는 메커니즘으로 채택되어 왔습니다.

몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶어서 트랜잭션 전체가 성공하거나 실패하도록 합니다.

 

하지만 모든 애플리케이션이 트랜잭션이 필요하지는 않으며 때로는 성능에 따라 사용하지 않는 것이 이득일 수 있습니다.

심지어 어떤 상황은 트랜잭션이 불필요할 수 있습니다.

 

RDB, NoSQL 그리고 트랜잭션

대부분의 관계형 데이터베이스와 일부 비관계형 데이터베이스가 트랜잭션을 지원합니다.

 

이때 새로운 세대의 데이터베이스 중 다수는 복제와 파티셔닝 기능을 제공함으로써 트랜잭션을 완전히 포기하거나 과거보다 약한 보장을 지원합니다.

 

새로운 분산 데이터베이스가 홍보되면서 대규모 시스템의 높은 성능과 고가용성을 위해 트랜잭션을 포기해야 한다는 믿음이 퍼졌습니다.

 

트랜잭션과 ACID

데이터베이스와 트랜잭션을 공부해보았다면 ACID에 대해 들어보았을 수 있습니다.

 

Atomic : 원자성 (트랜잭션이 완료되면 전부 완료되거나 실패하면 전부 실패해야 한다)

Consistency : 일관성 (데이터에 관한 어떤 선언이 항상 진실되어야 한다)

Isolcation : 격리성 (동시에 실행되는 트랜잭션은 서로 격리된다)

Durability : 지속성 (하드웨어에 결함이 발생하거나 데이터베이스가 죽더라도 데이터는 손실되지 않는다)

 

하지만 현실에서는 ACID의 구현이 제각각이여서 많은 모호함이 존재합니다.

 

실패한 트랜잭션

트랜잭션에 실패하고 재시도하는 것은 간단하고 효과적이지만 완벽하지는 않습니다.

 

- 트랜잭션이 실제로 성공했지만 서버가 클라이언트에게 성공을 알리는 도중 네트워크가 끊어지게 된다, 클라이언트는 실패했다고 생각하고 재시도하여 중복 트랜잭션이 실행될 수 있다

 

- 오류가 과부화 때문이라면 트랜잭션 재시도는 문제를 악화시킬 수 있습니다.

 

- 일시적인 오류만 재시도할 가치가 있으며 영구적인 오류는 재시도해도 아무 소용이 없습니다.

 

- 트랜잭션이 데이터베이스 외부에도 부수 효과가 있다면 트랜잭션이 rollback 되어도 부수 효과는 실행됩니다.

트랜잭션이 재시도 될때마다 이메일이 계속 다시 전송될 수 있습니다.

 

 

동시성 문제

여러 사용자가 동시에 같은 레코드에 접근하는 경우 동시성 버그가 발생할 수 있습니다.

일반적으로 예측하기도 힘들고 재현하기도 힘듭니다.

 

이런 이유로 serializable 격리를 통해 여러 트랜잭션의 동시성이 없는 것처럼 사용하였습니다.

하지만 성능이 급격하게 떨어지게 됩니다.

 

따라서 어떤 동시성 이슈는 보호해주지만 어떤 동시성 이슈는 보호해주지 않는 완화된 격리 수준을 사용하는 시스템들이 흔합니다.

 

이때 발생하는 동시성 버그는 단순 이론적인 문제가 아닙니다.

금융쪽으로 들어가게 되면 상당한 금전적 손실을 일으킬 수 있습니다.

 

완화된 격리 수준

Read committed

Dirty read를 유발하지 않는 커밋 후 읽기 (오라클 11g, PostgreSQL, SQL 서버 2012, MemSQL 등의 기본 설정)

 

쓰인 모든 객체에 대해 데이터베이스는 과거에 커밋된 값과 현재 쓰기 잠금을 가지고 있는 트랜잭션에서 쓴 값을 모두 기억합니다.

새 값이 커밋되기 직전까지는 과거의 값을 읽게 됩니다.

 

어보트를 허용하고, 트랜잭션의 미완료된 결과를 읽는 것을 방지, 동시에 실행되는 쓰기를 막아줍니다.

 

하지만 여전히 문제가 발생할 수 있습니다.

 

A가 은행에 1,000 달러를 계좌 1, 계좌 2에 나누어 두었습니다.

계좌 1에서 다른 계좌로 100달러를 전송하는 트랜잭션을 실행합니다.

트랜잭션이 처리되고 있는 순간에 계좌 잔고를 보면 한 계좌는 입금이 되기 전 상태를 보고, 다른 계좌는 출금이 된 후 상태를 볼 수 있습니다.

이때 엘리스는 현재 계좌 총액이 900달러 있는 것처럼 나옵니다.

 

이런 현상을 비반복 읽기나 읽기 스큐라고 합니다.

엘리스가 트랜잭션이 끝난 시점에 계좌 1의 잔고를 다시 읽으면 600을 보게 됩니다.

 

이런 경우에는 몇 초 후 새로고침을 하면 해결되는 문제입니다.

하지만 어떤 상황에서는 이런 일시적인 비일관성을 감내할 수 없는 경우도 있습니다.

 

스냅숏 격리

위의 문제를 해결하는 가장 흔한 해결책입니다.

각 트랜잭션은 데이터베이스의 일관된 스냅숏으로부터 읽습니다.

트랜잭션은 시작할 때 DB에 커밋되었던 상태의 모든 데이터를 봅니다.

 

즉, 트랜잭션이 특정 시점에 고정된 데이터베이스의 일관된 스냅숏만 볼 수 있습니다.

 

PostgreSQL, InnoDB 저장소 엔진을 쓰는 MySQL, Oracle, SQL Server 등에서 지원됩니다.

 

더티 쓰기를 방지하기 위해 쓰기 잠금을 사용합니다.

하지만 읽을 때는 잠금을 사용하지 않습니다.

따라서 읽는 쪽에서는 쓰는 쪽을 결코 차단하지 않으며, 쓰는 쪽에서도 읽는 쪽을 차단하지 않습니다.

 

진행 중인 여러 트랜잭션에서 서로 다른 시점의 데이터베이스 상태를 봐야 할 수 있습니다.

따라서 데이터베이스는 객체마다 커밋된 버전 여러 개를 유지할 수 있어야 합니다.

 

이 기법을 다중 버전 동시성 제어 (MVCC)라고 합니다.

 

트랜잭션 ID를 사용하여 어떤 것을 볼 수 있고 어떤 것을 볼 수 없는지 결정합니다.

 

색인과 스냅숏 격리

MVCC 데이터베이스에서 색인은 어떻게 동작할까요?

단순하게 색인이 객체의 모든 버전을 가리키게 하고 색인 질의가 현재 트랜잭션에서 볼 수 없는 버전을 걸러내게 하는 것입니다.

 

현실에서는 여러 구현 세부 사항에 따라 MVCC의 성능이 결정됩니다.

예를 들어 PostgreSQL은 동일한 객체의 다른 버전들이 같은 페이지에 저장될 수 있다면 색인 갱신을 회피하는 최적화를 합니다.

 

PostgreSQL이 MVCC 구현을 최적화하는 방법

https://momjian.us/main/writings/pgsql/mvcc.pdf

 

 

스냅숏 격리의 다른 이름 : Repeatable Read

스냅숏 격리는 유용한 격리 수준이며 읽기 전용 트랜잭션에 매우 유용합니다.

그러나 이를 구현한 많은 데이터베이스에서 다른 이름을 사용합니다

Oracle에서는 직렬성, PostgreSQL, MySQL에서는 반복 읽기(repeatable read)라고 합니다.

 

SQL 표준에 스냅숏 격리의 개념이 없기 때문에 이런 용어의 혼란이 야기되었습니다.

 

 

갱신 손실 방지

동시에 실행되는 쓰기 트랜잭션 사이에 발생할 수 있는 흥미로운 종류의 충돌이 존재합니다.

보통 counter를 증가시키는 갱신 손실 문제가 있습니다.

 

흔한 문제여서 다양한 해결책이 개발되었습니다.

 

원자적 쓰기 연산

여러 데이터베이스에서 다음과 같은 원사적 갱신 연산을 제공합니다.

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

read - update - write 주기를 구현할 필요를 없앱니다.

이렇게 해결할 수 있다면 가장 좋은 해결책이며 concurrency-safe 합니다.

 

보통 독점적인 exclusive 잠금을 획득하여 구현합니다.

갱신이 적용될 때까지 다른 트랜잭션에서 그 객체를 읽지 못하게 합니다.

 

보통 ORM을 사용하면 뜻하지 않게 데이터베이스 제공하는 원자적 연산을 사용하는 read - update - write 주기를 실행하는 코드를 작성하기 쉽습니다.

 

명시적인 잠금

SELECT SOME_COLUMN FROM SOME_TABLE FOR UPDATE;

select를 할 때 LOCK을 명시적으로 걸어버립니다.

 

갱신 손실 자동 감지

스냅숏 격리와 결합하여 compare-and-set 연산을 통해 버전을 통해 병렬 연산을 처리합니다.

 

쓰기 스큐와 팬텀

더티 쓰기와 갱신 손실 이외에도 미묘한 충돌이 존재할 수 있습니다.

 

의사들이 병원에서 교대로 서는 호출 대기를 관리하는 애플리케이션이 존재합니다.

최소 한 명의 의사는 반드시 호출 대기를 해야 합니다.

이때 동료가 호출 대기를 하고 있다면 자신의 몸이 좋지 않아 호출 대기를 그만둘 수 있습니다.

 

이때 동시에 호출 대기 상태를 끄는 버튼을 클릭했습니다.

 

각 트랜잭션에서 애플리케이션은 먼저 현대 두 명 이상의 의사가 대기 중인지 확인합니다.

이제 스냅숏 격리를 사용하여 둘 다 2를 반환해서 두 트랜잭션 모두 다음 단계로 진행합니다.

두 의사가 모두 대기 상태를 끄고 커밋됩니다.

최소 한 명의 의사가 호출 대기해야 한다는 요구사항이 위반되었습니다.

 

이런 현상을 쓰기 스큐라고 합니다.

두 트랜잭션이 두 개의 다른 객체를 갱신하므로 더티 쓰기도 갱신 손실도 아닙니다.

충돌이 발생함이 명백하지 않지만 분명 경쟁 조건입니다.

 

쓰기 스큐를 해결하기 위한 선택지는 제한됩니다.

- 여러 객체가 관련되어 있어 원자적 단일 객체 연산은 도움이 되지 않습니다.

- 스냅숏 격리 구현도 도움이 되지 않습니다. 진짜 직렬성 격리가 필요합니다.

- 제약 조건을 설정하여 여러 객체와 연관된 제약 조건을 걸어야 합니다. (트리거나 구체화 뷰 등)

- 트랜잭션이 의존하는 로우를 명시적으로 잠그는 것이 차선책입니다(select update for)

 

 

이와 비슷한 상황이 추가적으로 존재합니다

- 회의실 예약 시스템

- 다중 플레이어 게임

- 사용자명 획득

- 이중 사용 방지

 

모든 예는 비슷한 패턴을 따릅니다.

1. SELECT 질의

2. SELECT의 결과가 애플리케이션 코드에 따라 어떤 요구사항을 만족하는지 확인

3. DB에 commit 또는 요구사항 만족 위반

 

하지만 다른 순서로 쓰기를 먼저 실행한 후 SELECT 질의를 실행하고 마지막으로 그 질의 결과에 따라 어보트할지 커밋할지 결정할 수도 있습니다.

어떤 트랜잭션에서 실행한 쓰기가 다른 트랜잭션의 검색 질의를 바꾸는 효과를 팬텀이라고 합니다.

 

충돌 구체화

팬텀의 문제가 잠글 수 있는 객체가 없다는 것이라면 인위적으로 데이터베이스에 잠금 객체를 추가할 수 있습니다.

예를 들어 회의실 예약의 경우에는 각 로우를 특정한 시간 (예를 들어 15분)으로 나누고 회의실과 시간 범위의 모든 조합에 대해 로우를 미리 만들어 둘 수 있습니다.

 

이를 통해 이제 로우를 잠금이 가능해집니다.

이런 방법을 충돌 구체화라고 합니다.

 

하지만 이런 방법은 알아내기 어렵고 오류가 발생하기 쉽습니다.

이런 경우 직렬성 격리 수준이 훨씬 더 선호됩니다.

 

 

직렬성

- 격리 수준은 이해하기 어렵고 데이터베이스마다 그 구현에 일관성이 없습니다.

- 거대한 애플리케이션에서는 애플리케이션의 코드를 보고 특정한 격리 수준에서 코드를 실행하는 게 안전한지 알기 어렵습니다.

- 경쟁 조건을 감지하는 좋은 도구가 존재하지 않습니다. 동시성 문제는 보통 비결정적이라 테스트하기 어렵습니다.

 

동시성 문제를 가장 피하는 가장 간단한 방법을 직렬성을 도입하여 동시성을 완전히 제거하는 것입니다.

 

한 번에 트랜잭션 하나씩만 직렬로 단일 스레드에서 실행하면 됩니다.

 

뻔한 생각이지만 DB 설계자들은 최근이 되어서야 단일 스레드에서 트랜잭션을 실행하는 게 실현 가능하다고 결론 내렸습니다.

과거에는 높은 성능을 위해 다중 스레드 동시성이 필수적이었습니다.

 

두 가지 발전이 생각을 바꾸게 했습니다

- 램 가격이 저렴해져 데이터셋 전체를 메모리에 유지 가능해짐(트랜잭션의 속도 향상으로 이어짐)

- 트랜잭션이 보통 짧고 실행하는 읽기와 쓰기의 개수가 적다는 것을 깨달음

 

이런 방법은 볼트 DB/H-스토어, 레디스, 데이토믹에서 구현되어 있습니다.

잠금으로 인한 오버헤드가 없기 때문에 단일 스레드의 시스템이 동시성을 지원하는 시스템보다 성능이 나을 때도 있습니다.

 

트랜잭션과 스토어드 프로시저

데이터베이스의 초장기에는 트랜잭션이 사용자 활등의 전체 흐름을 포함할 수 있게 하려는 의도가 있었습니다.

예를 들어 항공권 예약은 경로, 요금, 가용 좌석 탐색, 일정표 정하기, 좌석 예약, 지불하기 등 전체 과정을 하나의 트랜잭션으로 다루어 원자적으로 커밋될 수 있다면 깔끔할 것으로 생각했습니다.

 

하지만 사람이 결정하는 것은 반응하는 것도 느립니다.

사용자의 입력을 기다리는 동안 유휴 상태로 남아있고 자원을 효율적으로 사용하지 못합니다.

따라서 대부분의 데이터베이스를 트랜잭션 내에서 대화식으로 사용자 응답을 대기하는 것을 회피함으로써 트랜잭션을 짧게 유지합니다.

 

또한 웹 환경에서는 하나의 HTTP 요청이 새로운 트랜잭션을 시작시킵니다.

따라서 애플리케이션과 데이터베이스 사이의 네트워크 통신에 많은 시간이 소모됩니다.

쓸만한 성능을 얻으려면 여러 트랜잭션을 동시에 처리해야 합니다.

이런 까닭으로 단일 스레드에서 트랜잭션을 순차적으로 처리하는 시스템들은 상호작용하는 다중 구문 트랜잭션을 허용하지 않습니다.

 

대신 스토어드 프로시저 형태로 데이터베이스에 미리 제출해야 합니다.

트랜잭션에 필요한 데이터는 모두 메모리에 있고 스토어드 프로시저는 네트워크나 디스크 I/O 대기 없이 매우 빨리 실행됩니다.

 

하지만 데이터베이스에서 실행되는 코드는 관리하기 어려웠으며, 성능에 민감했고, 벤더들의 스토어드 프로시저용 언어들은 매우 조잡하고 낡았습니다.

 

이런 이유로 현대 스토어드 프로시저는 기존의 범용 프로그래밍 언어를 사용합니다.

 

파티셔닝

모든 트랜잭션을 순차적으로 실행하면 동시성 제어는 간단하지만 단일 장비의 단일 CPU 코어의 속도로 처리량이 제한됩니다.

읽기 전용 트랜잭션은 스냅숏 격리를 사용해 다른 곳에서 실행될 수 있지만 쓰기 처리량이 높은 애플리케이션에게는 단일 스레드 트랜잭션 처리자가 심각한 병목이 될 수 있습니다.

 

심지어 여러 CPU 코어와 여러 노드로 확장하기 위해 파티셔닝을 할 수 있습니다.

볼트 DB는 이를 지원하고 각 트랜잭션이 단일 파티션 내에서만 데이터를 읽고 쓰도록 데이터셋을 파티셔닝 할 수 있다면 각 파티션은 다른 파티션과 독립적으로 실행되는 자신만의 트랜잭션 처리 스레드를 가질 수 있습니다.

이는 트랜잭션 처리량을 CPU 코어 개수에 맞춰 선형적으로 확장할 수 있음을 시사합니다.

 

하지만 여러 파티션에 접근해야 한다면 직렬성을 보장하기 위해 모든 파티션에 걸쳐 잠금을 획득한 단계에서 실행되어야 합니다.

볼트 DB는 여러 파티션에 걸친 쓰기 작업을 초당 약 1,000개 정도 처리할 수 있는데 단일 파티션 처리량보다 매우 낮은 수치이며 장비를 추가해도 처리량을 늘릴 수 없습니다.

 

직렬 실행 요약

- 모든 트랜잭션은 작고 빨라야 한다, 느린 트랜잭션 하나다 모든 트랜잭션 처리를 지연시킨다

- 활성화된 데이터셋이 메모리에 적재될 수 있는 경우로 사용이 제한된다

- 쓰기 처리량이 단일 CPU 코어에서 처리할 수 있을 정도로 충분히 낮아야 한다

- 여러 파티션에 걸친 트랜잭션은 엄격한 제한 아래 사용해야 한다.

 

2단계 잠금(2PL)

30년 동안 데이터베이스의 직렬성을 구현하는데 널리 쓰인 2PL 알고리즘이 존재합니다.

 

 

앞에서 더티 쓰기를 막는 데 잠금이 자주 사용된다고 했습니다.

트랜잭션이 동시에 같은 객체에 쓰려고 하면 잠금은 나중에 쓰는 쪽이 진행하기 전에 먼저 쓰는 쪽에서 트랜잭션을 완료할 때까지 기다리도록 보장해줍니다.

 

2단계 잠금도 비슷하지만 요구사항이 훨씬 더 강합니다.

읽기는 여러 트랜잭션에서 동시에 읽을 수 있지만 어떤 객체에 쓰려고 하면 독점적인 접근이 필요합니다.

 

2PL에서 쓰기 트랜잭션은 읽기 트랜잭션도 진행하지 못하게 막습니다.

반대로  읽기 중에서도 쓰기 트랜잭션을 진행하지 못합니다.

 

위의 조건이 스냅숏 격리와의 중요한 차이입니다.

 

잠금은 공유 모드와 독점 모드로 사용될 수 있습니다.

이런 상황에서 교착상태에 빠질 수 있습니다.

 

2단계 잠금의 성능

2단계 잠금을 사용하면 완화된 격리 수준을 쓸 때보다 트랜잭션 처리량과 질의응답 시간이 크게 나빠집니다.

2PL에서는 교착상태다 더 빈번하게 발생하며 교착상태는 성능 문제가 될 수 있습니다.

 

서술 잠금

직렬성 격리를 쓰는 데이터베이스는 팬텀을 막아야 합니다.

 

회의실 예약 예제에서 한 트랜잭션이 특정 범위 내에 있는 회의실 예약을 검색했다면 다른 트랜잭션이 같은 시간 범위 내에서 동일한 회의실을 쓰는 예약을 삽입하거나 갱신하는 게 허용되지 않아야 합니다.

 

서술 잠금은 공유/독점 잠금과 비슷하게 동작하지만 특정 테이블의 로우에 속하지 않고 검색 조건에 부합하는 모든 객체에 대하여 접근을 제한합니다.

 

SELECT * FROM bokkings
WHERE rood_id = 123 AND
end_time > '2018-01-01 12:00' AND
start_time < '2018-01-01 13:00';

 

2PL 잠금이 서술 잠금을 포함하면 모든 형태의 쓰기 스큐와 다른 경쟁 조건을 막을 수 있어 격리 수준이 직렬성이 됩니다.

 

 

색인 범위 잠금

서술 잠금의 경우 트랜잭션이 획득한 잠금이 많으면 조건에 부합하는 잠금을 확인하는데 시간이 오래 걸립니다.

이 때문에 2PL을 지원하는 대부분의 DB는 색인 범위 잠금과 다음 키 잠금을 구현합니다.

 

123번 방에 대해서 특정 시간에 대한 범위의 잠금으로 접근하지 않고 123번방 자체에 대해서 잠금을 합니다.

더 나아가 room_id뿐만 아니라 start_time, end_time 칼럼에도 색인이 존재할 수 있고, 해당 색인에 대해 잠금을 할 수도 있습니다.

 

색인 범위 잠근은 서술 잠금보다 정밀하지 않지만 오버헤드가 훨씬 더 낮기 때문에 좋은 타협안이 됩니다.

 

만약 적합한 색인이 없다면 테이블 전체에 공유 잠금을 잡는 것으로 대체할 수 있습니다.

 

 

직렬성 스냅숏 격리 : SSI

2PL을 사용하면 성능이 좋지 않지만 직렬성이 보장됩니다.

완화된 격리 수준을 사용하면 성능은 좋지만 다양한 경쟁 조건에 취약할 수 있습니다.

 

직렬성 격리와 좋은 성능은 근본적으로 공존할 수 없을까요?

그렇지 않습니다.

2008년에 등장한 직렬성 스냅숏 격리라는 알고리즘이 있습니다.

오늘날 단일 노드 데이터베이스(PostgreSQL 9.1 버전 이상)와 분산 데이터베이스(파운데이션 DB) 모두에서 사용합니다.

 

아직 현장에서 성능을 증명하는 중이지만 미래에는 새로운 기본값이 될 정도로 충분히 빨라질 가능성이 있습니다.

 

비관적 동시성 제어 대 낙관적 동시성 제어

2PL은 비관적 동시성 제어 메커니즘입니다.

반대로 직렬성 스냅숏 격리는 낙관적 동시성 제어 기법입니다.

 

정리

트랜잭션은 애플리케이션이 어떤 동시성 문제와 어떤 종류의 하드웨어와 소프트웨어 결함이 존재하지 않는 것처럼 동작할 수 있게 도와주는 추상층입니다.

 

많은 종류의 오류가 간단한 트랜잭션 어보트로 줄어들고 애플리케이션은 재시도만 하면 됩니다.

 

이번장에서는 다양한 문제들을 접했습니다.

하지만 모든 애플리케이션이 이런 문제에 민감하지는 않습니다.

 

특히 동시성 제어에 대한 내용을 깊게 다루며 커밋 후 일기, 스냅숏 격리, 직렬성 격리에 대해 알아보았습니다.

 

핵심 키워드

더티 읽기, 더티 쓰기, 읽기 스큐, 갱신 손실, 쓰기 스큐, 팬텀 읽기, 2PL, SSI