Naver D2의 트랜잭션 관련 아티클을 정리해보려 한다. 프레임워크 레벨에서만 적용하던 트랜잭션의 내부 동작 방식을 자세히 공부할 수 있는 좋은 기회였다.
트랜잭션 종료의 3가지 상태
- 문제 없이 정상적으로 커밋
- 롤백(철회)
- 시스템 문제로 인한 DBMS 서버의 재시작
트랜잭션 관리를 위한 DBMS의 전략
위의 사진처럼 DBMS는 크게 질의 처리기와 저장 시스템으로 구성되어있다. MySQL을 예로 든다면 MySQL 엔진과 스토리지 엔진을 말한다. DBMS는 대부분의 데이터를 디스크에 저장하지만 데이터의 일부는 메인 메모리에 유지한다. 이 메인 메모리 영역을 페이지 버퍼라고 하고, MySQL에서는 InnoDB 버퍼 풀을 지칭한다.
페이지 버퍼를 관리하는 모듈을 버퍼 관리자라고 하는데, 이 모듈의 정책에 따라 트랜잭션의 UNDO 복구나 REDO 복구의 필요성이 결정된다.
UNDO 로그와 복구
INSERT, UPDATE, DELETE 같은 오퍼레이션이 실행될때 버퍼 관리자의 정책에 따라서 디스크에 수정된 페이지가 반영될 수 있다. 여기서 중요한건 아직 트랜잭션이 커밋되지 않았더라도 수정된 페이지가 디스크에 반영될 수 있다는 것이다. 이 포인트가 UNDO 복구의 필요성을 결정짓는다.
만약 수정된 페이지들이 트랜잭션이 커밋되야지만 디스크에 반영된다면 트랜잭션이 진행되는 동안에는 메모리에 모든 수정된 페이지가 저장되는걸 의미한다. 이 상황에서는 UNDO 복구는 메모리에 대해서만 이루어지면 되기 때문에 간단하다.
대신에 메모리 버퍼의 크기가 커야한다는 단점이 있다.
수정된 페이지가 디스크에 반영되는 시점을 기준으로 두 가지 정책이 존재한다.
- - STEAL : 수정된 페이지를 언제든지 디스크에 쓸 수 있는 정책
- ~STEAL : 수정된 페이지를 트랜잭션 종료 시점까지는 버퍼에 유지하는 정책
대부분의 상용 DBMS는 STEAL 정책을 사용한다. 그렇기에 트랜잭션 커밋 전 디스크에 수정 사항이 반영될 수 있고 UNDO 로깅과 복구가 필요한 지점이 된다.
MySQL은 이 UNDO 로그를 사용해서 MVCC(Mutli version concurrency control)을 구현한다. MVCC는 하나의 레코드에 대해 여러가지 버전을 관리해주는 기능이다. 예를 들어서 DML(INSERT, UPDATE, DELETE)를 실행하면 메모리 버퍼에는 즉시 새로운 페이지가 반영된다. 그리고 예전 데이터는 언두 로그에 기록이 된다. 위에서 다룬 STEAL 정책으로 인해 디스크에는 임의적으로 수정된 페이지가 반영된다.
이때 트랜잭션 격리 레벨에 따라 다른 트랜잭션에서 조회할 수 있는 데이터가 달라진다. 만약 READ-COMMITED 수준을 사용하고 있다면 다른 트랜잭션에서는 언두로그에 기록된 예전 페이지를 조회하게 되고, READ-UNCOMMITED 수준을 사용한다면 메모리 버퍼에 있는 수정된 페이지를 조회하게 된다.
이 상태에서 트랜잭션이 커밋되면 현재 메모리 버퍼의 수정된 페이지가 그대로 반영되고, 만약 롤백된다면 언두 로그의 예전 페이지가 복구된다. 트랜잭션 커밋 시 언두 로그 내의 복구 데이터에 대한 다른 트랜잭션의 수요가 없어지면, 그 때 언두 로그의 복구 데이터는 삭제된다.
REDO 로그와 복구
REDO 복구는 UNDO 복구의 반대 개념이다. REDO 복구는 트랜잭션의 ACID 중에 D(durability)를 지키게 해주는 방법이다. Durability는 커밋한 트랜잭션의 수정 사항은 유지되어야 한다는 속성이고, REDO 복구는 커밋한 트랜잭션의 수정 사항을 재반영하는 방식으로 복구 작업을 실행한다. REDO 복구도 버퍼 관리 정책의 영향을 받는다.
- FORCE : 수정한 모든 페이지를 트랜잭션 커밋 시점에 디스크로 출력한다.
- ~FORCE : 수정한 모든 페이지가 트랜잭션 커밋 후에도 디스크에 반영 안될 수도 있다.
상용 DBMS는 기본적으로 ~FORCE 정책을 사용한다. 즉, 사용자가 트랜잭션 커밋을 했다고 해도 디스크에 반영이 되지 않을 수 있다는 것이다. 따라서 REDO 복구에 대비가 필요해진다.
결론적으로 DBMS가 사용하는 STEAL과 ~FORCE 정책은 UNDO 복구와 REDO 복구를 모두 필요하게 만든다.
복구를 위해서 사용되는 로그
UNDO 복구와 REDO 복구를 위해서 주로 로그(Log)가 사용된다. 로그를 이해하기 위해서는 로그 레코드, 로그 버퍼 그리고 로그 파일에 대해 알아야 한다.
로그 레코드 : 오퍼레이션이 실행되면 그에 대한 로그 레코드가 생성된다. 로그 레코드는 LSN(Log Sequence Number)라는 로그 식별자를 가지고, append 되는 방식으로 축적되기 때문에 단조 증가하는 성질을 가진다. MySQL의 Auto_Increment와 비슷한 느낌이다.
로그 버퍼 : 로그 레코드는 한개씩 디스크에 반영되는 것이 아니라 로그 버퍼에 먼저 축적된다.
로그 파일 : 로그가 축적되는 디스크에 있는 파일이다. 로그 버퍼의 로그들은 특정 시점에 로그 파일로 출력된다.
로그가 써질때는 특별한 규칙을 따른다.
- 특정 업데이트가 디스크에 써지기 전에 먼저 UNDO 정보가 로그에 써져야 한다. 이 원칙을 WAL(Write Ahead Logging)이라고 한다. 즉 STEAL 정책으로 인해 메모리 버퍼 내의 수정된 페이지가 디스크에 반영되기 전에 꼭 UNDO 로그가 디스크에 출력되야 한다는 것이다. 그래야 문제가 생겼을때 UNDO 로그를 사용한 복구가 가능하다.
- 트랜잭션이 커밋되고 정상적으로 종료되기 전에 REDO 로그가 무조건 써져야 한다. 만약 트랜잭션은 커밋했는데 ~FORCE 정책으로 인해 수정된 페이지가 디스크에 반영이 안됐다면 디스크 로그 파일의 REDO 로그를 사용해서 복구를 해야 하기 때문이다.
위의 규칙으로 인해 WAL이 필요할때와 트랜잭션이 커밋될때 로그 버퍼 내의 로그 레코드들이 일괄적으로 디스크의 로그 파일에 출력된다. 로그 버퍼의 공간이 부족할때도 디스크의 로그 파일로 출력된다. 아래의 그림처럼 LSN3의 커밋 로그가 들어왔을때, 그 커밋 로그를 포함하여 이전까지의 로그들이 모두 로그 파일로 출력된다.
로그를 쓰는 일이 느린 이유
로그 레코드는 복구 작업에 꼭 필요하기 때문에 손실을 최대한 줄여야 한다. 그러려면 디스크에 로그 레코드가 반영되는걸 확실하게 보장해야 한다. DBMS는 디스크에 대한 안전한 쓰기를 위해 fsync 함수를 호출한다.
fsync 함수란
프로세스가 파일에 쓰기 작업을 요청하면 운영체제에서는 요청한 작업을 수행한다. 이때 운영체제는 효율적인 입출력을 위해 바로 디스크에 쓰기를 하는것이 아니라 버퍼에 저장했다가 일괄처리를 한다. 이때 fsync 함수를 호출하면 곧바로 디스크에 쓰기가 반영되게 해준다. 만약 fsync를 호출하지 않으면 DBMS에서는 로그를 로그 파일에 쓰도록 실행했더라도 중간에 서버가 뻗었을 때 실제로 디스크에 로그가 반영되지 않았을 수도 있다. 따라서 정확한 복구를 위해서는 fsync의 호출이 필수적이다.
하지만 fsync의 호출이 디스크에 물리적으로 쓰기가 반영되는걸 보장하지는 않는다. 따라서 DBMS는 fsync 호출 후 fsync 가 종료되기 까지 대기하면서 체크해야 한다. fsync는 느린 연산이기 때문에 이 과정에서 많은 시간이 소요된다.
로그 버퍼로 돌아와보자. 로그 버퍼의 로그 레코드를 로그 파일에 출력하는 과정은 1. 로그 페이지 출력 2. 로그 헤더 출력으로 이루어 진다. 만약 로그 페이지가 여러개 있다면(대부분이 이 경우일 것이다) 로그 페이지를 쓸때 마다 fsync가 호출되야 한다.
대부분의 커밋 연산에서 로그 레코드를 로그 파일에 쓰고 fsync 호출하는 과정이 많은 시간을 잡아먹는다. 수만 TPS가 발생하는 웹서비스 환경에서 커밋 요청하는 상황을 생각하면 로깅을 위해서 정말 많은 디스크 출력이 실행된다는 것을 알 수 있다. 이때 성능 최적화를 위해 그룹 커밋과 비동기 커밋이 사용될 수 있다.
그룹 커밋
그룹 커밋은 여러 트랜잭션의 커밋 요청을 개별적으로 처리하는것이 아니라 한꺼번에 모아서 처리하는 방법이다. 이 방식을 사용하면 시스템 전체의 처리량을 늘릴 수 있지만 약간의 지연 시간은 발생하게 된다. 시스템의 TPS에 따라 얼마의 시간동안 커밋 요청을 모을지 잘 파악해야 한다.
비동기 커밋
비동기 커밋은 로그 레코드를 로그 버퍼에만 집어넣고 그대로 커밋을 완료시키는 방식이다. 원래는 커밋을 요청한 후 로그 버퍼가 로그 파일에 다 출력될 때가지 대기한 후에 커밋이 완료된다. 이 과정을 비동기로 처리하는 것이다.
복구 작업이 일어나는 과정
트랜잭션 롤백
트랜잭션 롤백이 일어날때는 UNDO 복구를 사용해야 한다. 로그를 역방향으로 탐색하면서 UNDO 복구가 필요한 로그를 찾는다. UNDO 수행 후에는 해당 UNDO 작업에 대한 보상 로그 레코드(CLR)이라는 REDO 전용 로그를 쓴다. 이는 UNDO된 로그에 다시 UNDO 작업이 발생 하지 않도록 보장해주는 역할을 한다.
시스템 장애
시스템 장애 시에는 REDO 복구와 UNDO 복구를 같이 사용한다. 먼저 마지막 체크포인트 시점부터 최근 로그까지 탐색하면서 어느 범위에 REDO 복구를 적용해야 하는지 탐색한다. 다음으로 로그들에 REDO 복구를 실행한다. 이를 통해 장애가 발생한 딱 그시점의 상태로 만든다. 마지막으로 UNDO 로그를 통해 철회해야 하는 모든 트랜잭션들을 롤백한다. 이때의 UNDO를 Global UNDO라고 한다.
트랜잭션 커밋하면 일어나는 일
트랜잭션 수행 중의 리소스 반환이나 DB replication을 위한 복제 로그 작성 작업은 트랜잭션 커밋이나 롤백 같은 트랜잭션 종료 시점까지 지연 시키게 된다.
- 트랜잭션 커밋이 요청
- 트랜잭션 수행 과정 중 얻었던 모든 락들 해제
- 트랜잭션 최종 커밋을 알리는 로그 쓰기
- 리소스를 반환한 뒤에 트랜잭션이 최종 종료
reference
Real MySQL 8.0