Real MySQL 8.0을 바탕으로 공부한 MySQL Architecture 관련된 내용을 정리해보려고 한다. 따라서 MySQL 8.0을 기준으로 내용을 정리할 예정이다.
MySQL 전체 아키텍처는 다음과 같다. 크게 클라이언트, MySQL 서버, 운영체제 및 하드웨어로 구성되어 있다.
먼저 MySQL 접속을 위한 클라이언트가 있으며 대부분의 프로그래밍 언어에 대해 접속 API를 제공한다. 그리고 MySQL 아키텍처에서 핵심에 해당되는 MySQL 서버가 있다. MySQL 서버는 크게 MySQL 엔진과 스토리지 엔진으로 구성되어 있다. MySQL 엔진은 MySQL 서버의 두뇌에 해당되며 클라이언트 접속과 SQL 요청을 처리한다. (MySQL 엔진 설명). MySQL 스토리지 엔진은 MySQL 손발에 해당되며 MySQL 엔진에서 구성한 쿼리 실행 계획에 따라 실제로 데이터를 디스크에 저장하거나 디스크에 저장된 데이터를 읽어오는 역할을 담당한다.
MySQL은 프로세스 기반이 아니라 스레드 기반으로 작동하며, 크게 포그라운드 스레드와 백그라운드 스레드로 구분된다.
포그라운드 스레드는 주로 각 클라이언트가 요청하는 쿼리 문장을 처리하는 것이 주된 역할이며, 최소한 MySQL 서버에 접속된 클라이언트의 수만큼 존재한다. 사용자가 작업을 마치고 커넥션을 종료하면 해당 커넥션을 담당하는 스레드는 스레드 캐시로 돌아간다. 만약 스레드 캐시에 일정 개수 이상의 대기 중인 스레드가 있다면 스레드 캐시에 넣지 않고 해당 스레드를 종료시켜 일정 개수의 스레드만 스레드 캐시에 있도록 유지한다. 이 때 스레드 개수를 일정하게 만들어주는 파라미터는 thread_cache_size이다.
포그라운드 스레드는 데이터를 MySQL의 데이터 버퍼나 캐시로부터 가져온다. 이 떄 데이터가 버퍼나 캐시에 없는 경우, 직접 디스크의 데이터나 인덱스 파일로부터 데이터를 읽어와서 처리해야 한다. 디스크 쓰기 작업은 InnoDB 테이블의 경우에는 데이터 버퍼나 캐시까지만 포그라운드 스레드가 처리하고 나머지 버퍼로부터 디스크로 기록하는 작업은 백그라운드 스레그가 처리한다.(MyISAM의 경우에는 디스크 쓰기 작업까지 포그라운드 스레그가 작업한다.)
InnoDB의 경우, 다음과 같은 작업들이 백그라운드 스레드로 처리된다.
이 중 가장 중요한 역할을 하는 스레드는 로그 스레드와 쓰기 스레드이다. 두 작업은 작업량도 많고 모두 백그라운드로 처리하기 떄문에 두 스레드의 경우 디스크를 최적으로 사용할 수 있도록 잘 설정하는 것이 중요하다.
InnoDB의 경우, 쓰기 작업을 버퍼링해서 일괄 처리하는 기능을 당연히 제공한다. 따라서 INSERT, UPDATE, DELETE 쿼리로 데이터가 변경되는 경우, 이를 디스크에 저장될 때까지 기다리지 않아도 된다.
MySQL에서 사용되는 메모리 공간은 크게 글로벌 메모리 영역과 로컬(세션) 메모리 영역으로 구분된다.
글로벌 메모리 영역은 MySQL 서버가 시작되면서 운영체제로부터 할당된다. 일반적으로 하나의 메모리 공간만 할당되며, 모든 스레드에 의해 공유된다. 대표적인 영역은 다음과 같다.
MySQL 서버에 존재하는 클라이언트 스레드가 쿼리를 처리하는 데에 사용하는 메모리 영역으로 세션 메모리 영역이라고도 한다. 클라이언트가 MySQL 서버에 접속하면 MySQL 서버에서는 클라이언트 커넥션으로부터 요청을 처리하기 위해 스레드를 하나씩 할당하게 되는데, 클라이언트 스레드가 사용하는 메모리 공간이라고 해서 클라이언트 메모리 영역이라고도 한다. 이러한 로컬 메모리 영역은 각 클라이언트 스레드별로 독립적으로 할당되며 절대 공유되지 않는다. 또한 각 쿼리의 용도별로 필요할 떄만 메모리 공간을 할당했다가 필요하지 않을 경우에는 해제하는 경우도 있는데 정렬 버퍼와 조인 버퍼가 그렇다. 대표적인 로컬 메모리 영역은 다음과 같다.
기존의 MySQL 아키텍처는 플러그인 모델이었으나 8.0 이후의 버전부터는 컴포넌트 아키텍처가 지원된다. 플러그인 아키텍처의 경우, 독립적인 배포 단위가 아니라 MySQL 서버 인터페이스에만 직접적으로 의존하기 때문에 다음과 같은 단점들이 있기 때문에 이를 극복하기 위해 컴포넌트 아키텍처가 등장하게 되었다.
MySQL에서 쿼리는 크게 다음과 같은 단계를 거치며 실행된다.
사용자가 MySQL 서버에 쿼리를 요청한다.
쿼리 캐시는 SQL 실행 결과를 메모리에 캐싱하는 역할을 한다. 동일 SQL 실행시 이전 결과를 즉시 반환함으로써 빠른 성능을 지원하는 것이 초기의 목적이었다. 하지만 테이블의 데이터가 변경되면 캐싱된 데이터도 변경해줘야 하는데 이 떄 캐시된 데이터에 변경이 일어날 때마다 쿼리 캐시에 접근하는 스레드에 락이 걸리기 때문에 동시 처리에 있어서 성능 저하를 유발하게 된다. 그에 따라 쿼리 캐시는 MySQL 8.0부터는 없어지게 되었다.
쿼리 파서에서는 사용자가 요청한 쿼리 문장을 토큰으로 쪼개서 트리 형태의 구조로 만들어 내는데 이러한 트리 형태의 구조를 파서 트리라고 한다. 파서 트리를 만드는 파싱 과정에서 쿼리 문장의 기본적인 문법 오류 체크를 하게 된다.
전처리기는 파서 과정에서 만들어진 파서 트리를 기반으로 쿼리의 문장 구조를 검사한다. 이 과정에서 파서 트리의 토큰이 유효한지 검증을 하고 접근 권한 등을 확인한다.
옵티마이저는 전처리된 파서 트리를 기반으로 SQL 실행을 최적화해서 실행 계획을 수립한다. 최적화 방법에는 크게 두 가지가 있다.
이렇게 옵티마이저가 만든 실행 계획에 따라서 스트리지 엔진을 호출해 레코드를 읽고 쓰게 된다.
스토리지 엔진은 MySQL 엔진이 수립한 실행 계획에 따라 쿼리 실행 요청을 하게 되면 실제로 데이터를 디스크로 저장하고 읽는다. MySQL 엔진의 핸들러 API에 의해 동작하게 된다.
InnoDB는 MySQL 8.0 이후 버전에서 주로 사용하는 스토리지 엔진으로 레코드 기반의 잠금을 제공하기 때문에 높은 동시성 처리가 가능하고 안정적이며 성능이 뛰어나다. InnoDB의 구조는 다음과 같다.
InnoDB는 다음과 같은 중요한 특징이 있다.
PK에 의한 클러스터링은 PK를 기준으로 데이터를 묶어서 저장한다는 의미인데 쉽게 말해 PK 순서대로 레코드를 정렬해서 디스크에 저장하는 것을 의미한다. InnoDB에서는 PK를 통해 실제 데이터 파일에 접근하며 모든 세컨더리 인덱스는 레코드의 실제 주소 대신에 PK를 논리적인 주소로 사용한다. InnoDB는 PK에 대한 인덱스를 자동으로 생성해주기도 한다. PK 기준으로 순서대로 레코드가 정렬된 상태로 저장되기 때문에 이렇게 묶어서 저장된 데이터에 접근할 때 PK에 의한 레인지 스캔은 매우 빨라지게 된다. 그렇기 때문에 쿼리 실행 계획에서 다른 세컨더리 인덱스보다 PK가 선택될 확률이 높다.
하지만 쓰기 성능은 저하된다. PK값이 바뀌면 그에 따라 레코드의 물리적 순서도 하나씩 바꿔줘야 하기 때문이다.(해당 레코드를 DELETE 후 INSERT) 그럼에도 일반적인 웹서비스는 쓰기보다 읽기 요청이 훨씬 많이 있기 때문에 쓰기 성능을 희생하고 읽기 성능을 얻는 클러스터링을 하는 것이 합리적이다. InnoDB는 PK를 지정하지 않으면 내부적으로 PK를 자동 생성해서 클러스터링을 한다. 내부적으로 생성된 PK는 사용자가 직접 사용할 수는 없다. 따라서 InnoDB에서 테이블을 설계할 때에는 PK 직접 설정하는 것이 좋다.
InnoDB는 기본적으로 Commit, Rollback과 같은 트랜잭션 처리 기능을 제공한다. 또한 MySQL의 중요한 특징 중 하나인 MVCC 기능을 제공한다. MVCC란 Multi Version Concurrency Control의 약자로, 데이터의 다양한 버전이 동시에 관리되어 트랜잭션 격리 수준에 따라 다르게 조회되는 것을 가능하게 해주는 기술을 말한다.
다음의 예시를 살펴보자. 아래 그림은 데이터베이스의 상태를 나타낸다.
InnoDB 버퍼 풀은 데이터의 상태가 변경됐을 때 빈번한 Disk I/O를 줄이기 위해 디스크에 반영하기 전까지 잠시 버퍼링하는 공간이다. 언두 로그는 변경되기 이전의 데이터를 백업해두는 공간이다. m_id가 '12', m_name이 '홍길동', m_area가 '서울'인 레코드가 있을 때, 이 레코드에 대해 m_area를 '서울'에서 '경기'로 변경하는 UPDATE 쿼리를 날리게 되면, 먼저 버퍼 풀에 있는 데이터를 변화된 상태가 곧바로 반영되며, 이전 값은 언두 로그에 복사된다. 이 때 다른 트랜잭션이 해당 id값을 조회하면 데이터베이스에 설정된 트랜잭션 격리 수준에 따라 일관되게 데이터를 조회할 수 있다.
이처럼 MySQL은 MVCC 기능을 이용해 트랜잭션 격리 수준에 따라 Lock이 필요없는 일관된 읽기가 가능하다.
언두 로그는 변경되기 이전의 데이터를 백업해두는 공간이다. Rollback시 언두 로그에 있는 백업된 데이터를 이용해서 복원이 가능하기 때문에 이를 통해 트랜잭션 보장이 가능하다. 또한 트랜잭션 격리 수준에 맞게 언두 로그에 있는 백업된 데이터를 반환할 수 있기 때문에 트랜잭션 격리 수준에 따른 일관된 읽기가 가능하다.
언두 로그의 사용 공간이 너무 늘어나게 되면 언두 로그가 많이 쌓이게 되어 디스크 사용량이 증가하게 되고, 변경된 데이터에 대해 조회할 때 그만큼 많은 양의 언두 로그를 스캔해야 되기 때문에 성능 저하를 유발하게 된다. 대용량 데이터를 처리하는 트랜잭션과 장기간 활성화된 트랜잭션이 언두 로그 사용 공간이 늘어나는 것을 유발한다. MySQL 8.0 버전부터는 이러한 언두 로그 공간의 문제점이 해결되어 필요한 시점에 언두 로그 사용 공간을 자동으로 줄여주기도 한다.
체인지 버퍼는 인덱스를 업데이트하는 작업에 대한 버퍼링을 지원하는 공간이다. RDBMS에서 레코드가 INSERT되거나 UPDATE될 경우 데이터 파일을 변경할 뿐만 아니라 해당 테이블에 포함된 인덱스를 업데이트하는 작업도 필요하다. 하지만 인덱스를 업데이트하는 작업은 랜덤 I/O 작업이 필요하므로 테이블에 인덱스가 많다면 상당한 자원이 소모되는 작업이다. 그래서 InnoDB는 변경해야 할 인덱스 페이지가 버퍼 풀에 있으면 바로 업데이트를 수행하고 그렇지 않다면 인덱스 페이지들의 변화를 체인지 버퍼에만 저장하고 이후 읽기 작업에 의해 페이지가 버버풀에 로드될 때 체인지 버퍼 머지 스레드라는 백그라운드 스레드에 의해 버퍼 풀로 병합된다. 유니크 인덱스에 대해서는 체인지 버퍼를 사용할 수 없다.
리두 로그는 Commit이 완료된 번경된 데이터를 백업하는 공간이다. 이는 서버가 비정상 종료됐을 때 리두 로그 파일에 있는 백업된 데이터를 이용해 데이터 복구를 가능하게 해줌으로써 영속성을 보장해준다. 대부분의 DBMS와 마찬가지로 MySQL도 데이터 변경 내용을 로그로 먼저 기록한다. 그 이유는 보통의 DBMS의 데이터 파일의 경우 쓰기보다 읽기 성능을 고려한 자료 구조를 사용하기 때문에 변경된 데이터를 데이터 파일에 기록하려면 랜덤 I/O가 필요해 연산 비용이 크다. 따라서 쓰기 비용이 낮은 자료 구조를 갖는 리두 로그를 활용한다. 매번 디스크에 기록하면 Disk I/O로 인해 성능 이슈가 있기 때문에 체크 포인트 이벤트 발생 시점에 로그 버퍼에 있는 데이터를 디스크의 리두 로그 파일에 기록한다.
MySQL 서버가 비정상 종료되었을 경우, InnoDB 스토리지 엔진의 데이터 파일에서 일관되지 않은 데이터를 가지는 두 가지 경우 시나리오
InnoDB는 테이블 단위가 아니라 레코드 단위로 잠금을 걸기 때문에 동시 처리 성능이 좋다. 엄밀히 이야기하면 InnoDB 테이블의 레코드 자체를 잠그는 것이 아니라 B-tree 인덱스 리프 노드인 인덱스 테이블의 인덱스 레코드가 잠긴다.
다음과 같은 employees 테이블이 있는 상황을 가정해보자.
> SELECT COUNT(*) FROM employees WHERE first_name='Georgi';
+-----------+
| 100 |
+-----------+
> SELECT COUNT(*) FROM employees WHERE first_name='Georgi' AND last_name='Klassen';
+-----------+
| 1 |
+-----------+
이 때 first_name이 'Georgi'이고 last_name이 'Klassen'인 레코드의 hire_date를 현재 시간으로 UPDATE한다면 어떻게 될까?
> UPDATE employees SET hire_date=NOW() WHERE first_name='Georgi' AND last_name='Klassen';
이 때 InnoDB 테이블은 해당 레코드 자체에 잠금을 거는 것이 아니라, B-tree 인덱스 리프 노드인 idx_firstname 테이블의 인덱스 레코드가 잠긴다. 따라서 UPDATE를 위해 검색에 사용된 'Georgi' 인덱스 레코드가 모두 잠긴다.
만약 first_name 인덱스가 아예 없었다면 해당 레코드를 찾기 위해 기본으로 생성된 PK 인덱스를 사용해서 테이블 풀 스캔을 하게 된다. 이 경우에도 검색에 사용된 모든 PK 인덱스가 잠기게 되며, 이는 곧 테이블 전체가 잠기게 되는 것을 의미한다.
만약, first_name과 last_name에 대한 복합 인덱스를 생성했다면, 하나의 레코드만 잠기게 된다.
인덱스를 어떻게 설정하느냐에 따라 레코드 잠금 범위가 달라질 수 있으니 InnoDB를 사용할 때는 인덱스를 신중하게 설정하는 것이 좋다.
InnoDB 버퍼 풀은 InnoDB 스토리지 엔진에서 가장 핵심적인 부분으로 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐싱해두는 공간이다. 버퍼 풀은 쓰기 작업을 지연시켜 일괄적으로 작업을 처리해주기도 한다. INSERT, UPDATE, DELETE와 같이 데이터를 변경하는 쿼리는 디스크에 존재하는 데이터 파일의 이곳저곳에 위치한 레코드를 변경하기 때문에 랜덤 I/O를 발생시키지만, 버퍼 풀이 이러한 변경된 데이터를 한 번에 모아서 처리하면 랜덤 디스크 작업의 횟수를 줄일 수 있다. 이처럼 InnoDB 버퍼 풀은 데이터 캐싱과 쓰기 지연 버퍼 두 가지 기능을 제공하여 MySQL 서버의 성능을 높인다. 버퍼 풀의 크기는 보통 전체 메모리의 50% 정도로 설정해서 조정하면서 최적점을 찾는 것이 좋다.
버퍼 풀은 SQL 요청 결과를 일정한 크기의 페이지 단위로 캐싱한다. 운영 체제가 가상 메모리를 효율적으로 사용하기 위해 페이징을 하는 것처럼 InnoDB도 테이블 데이터에 대해 페이징을 한다.
버퍼 풀은 페이지를 관리하기 위해 다음 자료 구조들을 관리한다.
InnoDB 스토리지 엔진에서 데이터를 찾는 과정은 다음과 같다.
Insert, Update, Delete 명령으로 변경된 페이지를 더티 페이지라고 한다. InnoDB는 이 더티 페이지들을 모았다가 주기적으로 이벤트를 발생시켜서 한 번에 디스크에 반영한다. 이렇게 변경된 데이터를 한 번에 모았다가 처리하는 이유는 랜덤 I/O를 줄이기 위해서다. 데이터 캐싱과는 달리 쓰기 지연 버퍼의 성능을 올리기 위해서는 버퍼 풀의 메모리 크기 뿐만 아니라 리두 로그 공간의 크기도 고려해야 한다.
InnoDB에는 클린 페이지와 더티 페이지가 있는데, INSERT, UPDATE, DELETE 명령으로 변경된 데이터를 가지고 있는 더티 페이지는 디스크의 실제 데이터와 데이터의 상태가 다르기 때문에 무한정 버퍼 풀에 머무를 수 없고 언젠가는 디스크에 기록이 되어야 한다. 리두 로그의 경우, 1개 이상의 로그 파일을 순환 고리처럼 연결해서 사용하는데 로그마다 LSN(Log Sequence Number)가 있고 이 값을 기준으로 오래되어 덮어써도 될 로그와 그러지 말아야 할 활성 로그를 구분한다. 버퍼 풀의 더티 페이지는 각 리두 로그 엔트리와 관계를 맺고, 체크 포인트 이벤트마다 더티 페이지와 리두 로그가 디스크로 동기화된다. 체크포인트 간의 간격을 체크포인트 Age라고 하는데 이는 리두 로그의 공간을 말한다. 결국 리두 로그 공간만큼 쓰기 지연 버퍼가 가능하다는 의미이다. 그렇기 때문에 버퍼 풀의 크기가 아무리 크더라도 리두 로그 파일의 크기가 너무 작으면 쓰기 지연 버퍼링의 효과를 거의 못 보게 된다. 그렇다고 리두 로그 공간이 무한정 크기가 크다면 버퍼 풀에 더티 페이지의 비율이 매우 높은 상태에서 갑자기 필요해지는 상황이 오면 InnoDB 스토리지 엔진은 엄청난 양의 더티 페이지를 한 번에 리두 로그에 기록해야 하는 상황이 올 수도 있기 때문에 리두 로그 파일의 크기도 적절히 선택하고 조정해주는 것이 중요하다.
버퍼 풀은 아직 디스크로 기록되지 않은 더티 페이지들을 성능 상의 문제가 없이 디스크에 동기화하기 위해 다음과 같이 두 가지 플러시 기능을 백그라운드 스레드를 통해 실행한다.
Buffer Pool Flush
InnoDB의 리두 로그는 공간의 낭비를 막기 위해 페이지의 변경된 내용만 기록한다. 이로 인해 더티 페이지를 디스크로 플러시할 때 하드웨어 오작동이나 시스템의 비정상 종료 등으로 인해 일부만 기록되는 문제가 발생하면 그 페이지 내용을 복구할 수 없을 수도 있다. 이러한 현상을 Partial-page, Torn-page라고 한다. 이러한 문제를 방지하기 위한 것이 Double Write Buffer이다.
이처럼 Double Write Buffer는 데이터의 안정성을 위해 사용되지만 SDD처럼 랜덤 I/O나 순차 I/O의 비용이 비슷한 저장 시스템에서는 부담스럽다. 하지만 데이터의 무결성이 매우 중요한 서비스에서는 Double Write Buffer 활성화를 고려하는 것이 좋다. innodb_doublewrite 시스템 변수를 이용해 활성화 여부를 결정할 수 있다.
어댑티브 해시 인덱스는 버퍼 풀 내에서 데이터 페이지에 빠르게 접근하기 위한 해시 자료구조 기반 인덱스로 InnoDB에서 사용자가 자주 요청하는 데이터에 대해 자동으로 생성하는 인덱스이다. B-tree 인덱스에서 특정 값을 찾기 위해서는 B-tree의 루트 노드를 거쳐서 브랜치 노드, 리프 노드까지 찾아가야 하는데 이런 작업이 동시에 수많은 스레드로 실행하면 컴퓨터의 CPU는 엄청난 프로세스 스케줄링을 하게 되고 쿼리의 성능이 떨어지게 된다. 어댑티브 해시 인덱스는 이러한 B-tree의 검색 시간을 줄여주기 위해 도입된 기능이다.
이처럼 쿼리를 좀 더 빠르게 처리할 수 있도록 B-tree 검색 시간을 줄여주는 데에 도움을 주는 어댑티브 해시 인덱스는 특정 상황에서는 메모리 공간만 잡아먹고 크게 도움이 되지 않아 비활성화하는 경우도 있다.
해당 명령어를 통해 해시를 사용한 검색과 사용하지 못한 검색으 비율을 비교해서 어댑티브 해시 인덱스의 활성화 여부를 결정하는 것이 좋다.
SHOW ENGINE INNODB STATUS\G
그 밖에도 InnoDB는 자동 데드락 감지, 자동화된 장애 복구를 해주는 특징이 있다.
MySQL 5.5 부터는 스토리지 엔진 기본이 InnoDB 스토리지 엔진이 기본 스토리지 엔진으로 채택되었고 MySQL 8.0 부터는 시스템 테이블 포함 모든 테이블이 InnoDB 스토리지 엔진을 사용하게 되었다. 그에 따라 MyISAM에서만 지원하던 전문 검색 기능이나 공간 좌표 검색 기능을 InnoDB에서도 제공하게 되었다. MyISAM 스토리지 엔진의 특징을 간단하게 요약하면 다음과 같다.