
본 포스트는 Real MySQL 8.0 1권을 읽은 뒤 정리하는 글입니다.

InnoDB의 모든 테이블은 프라이머리 키를 기준으로 클러스터링되어 저장된다. 즉 프라이머리 키 값의 순서대로 디스크에 저장되며, 모든 세컨더리 인덱스는 레코드의 주소 대신 프라이머리 키의 값을 논리적 주소로 사용한다. 이로 인해 프라이머리 키를 이용한 레인지 스캔 성능이 우수하며, 다른 보조 인덱스에 비해 프라이머리 키의 비중이 높게 설정되어 있다.
InnoDB에서 외래 키는 부모 테이블과 자식 테이블 모두 해당 칼럼에 인덱스 생성이 필요하고, 변경 시에는 반드시 부모 테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하므로 잠금이 여러 테이블로 전파되고, 그로 인해 데드락이 발생할 때가 많으므로 개발할 때도 외래 키의 존재에 주의하는 것이 좋다.
서비스의 문제가 발생해 긴급하게 조치를 해야 하는 경우
foreigh_key_checks시스템 변수를 OFF로 설정하면 외래 키 관계 체크 작업을 일시적으로 멈출 수 있다. 비활성화 이후 작업 시 테이블 간의 관계를 일관성있도록 조정하는 작업을 거친 뒤 활성화해야한다.
일반적으로 레코드 레벨 트랜잭션을 지원하는 DBMS가 제공하는 기능으로, 잠금을 사용하지 않는 일관된 읽기를 제공하는 것이 가장 큰 목적이다. InnoDB는 이를 언두 로그(Undo log)를 이용해 구현한다.
멀티 버전은 하나의 레코드에 대해 여러개의 버전이 동시에 관리된다는 의미이다.
아래는 격리 수준이 READ_COMMITTED인 MySQL 서버의 InnoDB 스토리지 엔진을 사용하는 테이블 데이터 변경 예시이다.
mysql> CREATE TABLE member (
m_id INT NOT NULL,
m_name VARCHAR(20) NOT NULL,
m_area VARCHAR(20) NOT NULL,
PRIMARY_KEY (m_id),
INDEX ix_area(m_area)
);
mysql> INSERT INTO member (m_id, m_name, m_area) VALUES (12, '홍길동', 서울');
mysql> COMMIT;
위 INSERT 문이 실행된 경우 데이터베이스 상태는 아래와 같다.

위 상황에서 UPDATE 문을 실행하게 된다면 아래 그림처럼 데이터베이스 상태가 변경된다.
myql> UPDATE member SET m_area='경기' WHERE m_id=12;

UPDATE 문이 실행되면 커밋 실행 여부와 관계 없이 InnoDB 버퍼 풀은 새로운 값인 '경기'로 업데이트되며, m_area의 변경 전 값만을 언두 로그로 복사한다. 디스크의 데이터 파일에는 체크포인트 혹은 InnoDB의 Write 스레드에 의해 새로운 값이 업데이트되어 있을 수도 있고 아닐 수도 있다.
InnoDB는 ACID를 보장하기 때문에 일반적으로는 InnoDB의 버퍼 풀과 데이터 파일은 동일한 상태라고 가정해도 무방하다.
위 상태에서 COMMIT을 실행하면 InnoDB는 더 이상의 변경 작업 없이 지금의 상태를 영구적인 데이터로 만들며, ROLLBACK을 실행하는 경우 InnoDB는 언두 영역에 있는 백업된 데이터를 InnoDB 버퍼 풀로 다시 복구한 뒤 언두 영역의 내용을 삭제한다. 언두 영역의 백업 데이터는 언두 영역을 필요로 하는 트랜잭션이 존재하지 않을 때 삭제된다.
만약 COMMIT 혹은 ROLLBACK이 되지 않은 상태에서 사용자가 작업 중인 레코드를 조회하게 되는 경우 MySQL 서버의 시스템 변수에 설정된 격리 수준에 따라 다른 데이터를 조회하게 된다. 격리 수준이 READ_UNCOMMITTED인 경우 데이터 커밋 여부와 관계없이 InnoDB 버퍼 풀이 현재 가지고 있는 변경된 데이터를 읽어 반환한다. 그 외의 READ_COMMITTED나 그 이상의 격리 수준(REPEATABLE_READ, SERIALIZABLE)의 경우에는 아직 커밋이 되지 않았기 때문에 변경되기 이전 내용을 보관하는 언두 영역의 데이터를 반환한다.
이처럼 하나의 레코드에 대한 여러 버전을 유지하며 필요에 따라 어느 데이터가 보여지는지 여러 가지 상황에 따라 달라지는 구조를 MVCC라고 한다.
InnoDB 스토리지 엔진은 MVCC 기술을 이용해 잠금을 걸지 않고 읽기 작업을 수행하므로 다른 트랜잭션이 가지고 있는 잠금을 기다리지 않고 읽기 작업이 가능하다. 격리 수준이 SERIALIZABLE이 아닌 READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ인 경우 순수한 읽기 작업은 다른 트랜잭션의 변경 작업과 관계없이 항상 잠금을 대기하지 않고 바로 실행된다. 이를 잠금 없는 일관된 읽기라고 표현하며, InnoDB에서는 변경 전 데이터를 읽기 위해 언두 로그를 사용한다.
MySQL 서버가 느려지거나 문제가 발생하는 경우, 일관된 읽기를 위해 언두 로그를 삭제하지 못하고 계속 유지해야 하기 때문에 발생하는 문제다. 따라서 트랜잭션이 시작됐다면 가능한 한 빨리 롤백이나 커밋을 통해 트랜잭션을 종료하는 것이 좋다.
InnoDB 스토리지 엔진은 내부적으로 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프(Wait-for List) 형태로 관리하며, 데드락 감지 스레드가 주기적으로 잠금 대기 그래프를 검사해 교착 상태에 빠진 트랜잭션들을 찾아서 그중 하나를 강제 종료한다. 트랜잭션 종료 우선순위 기준은 트랜잭션의 언두 로그 양으로 언두 로그 레코드를 더 적게 가진 트랜잭션이 일반적인 롤백 대상이 된다. 해당 트랜잭션은 롤백을 해도 언두 처리를 해야 할 내용이 적으며 강제 롤백으로 읺란 MySQL 서버 부하도 덜 유발하기 때문이다.
일반적인 서비스 에서는 데드락을 찾아내는 작업이 크게 부담되지 않지만 동시 처리 스레드가 매우 많아지거나 각 트랜잭션이 가진 잠금 개수가 많아지는 경우 데드락 감지 스레드가 느려진다. 데드락 감지 스레드는 잠금 목록을 검사해야 하기 때문에 잠금 상태가 변경되지 않도록 잠금 목록이 저장된 리스트에 새로운 잠금을 걸고 데드락 스레드를 찾게 된다. 이로 인해 서비스 쿼리를 처리 중인 스레드가 작업을 진행하지 못하고 대기하며 서비스에 악영향을 미칠 수 있다.
위 문제점을 해결하기 위해 MySQL 서버는 innodb_deadlock_detect 시스템 변구를 제공하며 이를 OFF로 설정하면 데드락 감지 스레드는 작동하지 않는다. 만약 2개 이상의 트랜잭션이 상대방이 가진 잠금을 요구하는 데드락이 발생해도 중재를 할 수 없어 무한정 대기하게 된다.
innodb_lock_wait_timeout 시스템 변수를 활성화하면 데드락 상황에서 일정 시간이 지나면 자동으로 요청이 실패하고 에러 메세지를 반환하므로 데드락 감지 스레드를 비활성화 하는 경우 함께 설정하는 것이 좋다.
InnoDB에는 손실이나 장애로부터 데이터를 보호하기 위한 여러 매커니즘이 탑재되어 있어 MySQL 서버가 시작될 때 완료되지 못한 트랜잭션이나 디스크에 일부만 기록된 데이터 페이지 등에 대한 일련의 복구 작업을 자동으로 진행한다.
InnoDB 스토리지 엔진은 매우 견고해 데이터 파일이 손생되거나 MySQL 서버가 시작되지 못하는 경우는 거의 발생하지 않지만 디스크나 서버 하드웨어 이슈로 자동으로 복구를 못하는 경우가 발생할 수 있다. InnoDB는 기본적으로 MySQL 서버가 시작될 때 자동 복구를 수행하며, 복구할 수 없는 손상이 있다면 자동 복구를 멈추고 서버는 종료된다.
위 경우 innodb_force_recovery 시스템 변수를 설정해 데이터 파일이나 로그 파일의 손상 여부 검사 과정을 선별적으로 진행할 수 있게 해야한다. 해당 시스템 변수에 설정 가능한 값은 1부터 6까지로, 각 숫자 값의 의미는 아래와 같다.
InnoDB 버퍼 풀은 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐시해 두는 공간이며 쓰기 작업을 지연시켜 일괄 작업으로 처리할 수 있게 해주는 버퍼 역할도 함께 한다. 버퍼 풀은 변경된 데이터를 모아서 처리하여 디스크 작업 횟수를 줄인다.
InnoDB 버퍼 풀의 크기는 운영체제와 각 클라이언트 스레드가 사용할 메모리를 충분히 고려해서 설정해야 한다. 만약 MySQL 서버를 처음 준비하는 경우, 운영체제 메모리 공간이 8GB 미만이라면 50% 정도만 InnoDB 버퍼 풀로 설정하고 나머지는 MySQL 서버와 운영체제, 그리고 다른 프로그램이 사용할 수 있는 공간으로 확보해주는 것이 좋다. 메모리 공간이 그 이상이라면 버퍼 풀의 크기를 50%부터 조금씩 올려가며 최적점을 찾는다.
InnoDB 버퍼 풀 전체를 관리하는 잠금(세마포어)으로 인해 재부 잠금 경합을 유발해왔으나 이를 줄이기 위해 버퍼 풀을 여러 개로 쪼개어 관리할 수 있는 버퍼 풀 인스턴스를 사용하도록 개선되었다.
InnoDB 스토리지 엔진은 버퍼 풀을 페이지 크기의 조각으로 쪼개어 InnoDB 스토리지 엔진이 데이터를 필요로 할 때 해당 데이터 페이지를 읽어서 각 조각에 저장한다. 버퍼 풀의 페이지 크기 조각을 관리하기 위해 LRU(Least Recently Used) 리스트, 플러시(Flush) 리스트, 프리(Free) 리스트 자료 구조를 관리한다.
프리 리스트는 InnoDB 버퍼 풀에서 실제 사용자 데이터로 채워지지 않는 비어 있는 페이지들의 목록이며, 사용자의 쿼리가 새롭게 디스크의 데이터 페이지를 읽어와야 하는 경우 사용된다.

위 그림은 LRU리스트 구조로, LRU와 MRU(Most Recently Used)리스트가 결합된 형태이다. LRU 리스트를 관리하는 목적은 디스크로부터 한 번 읽어온 페이지를 최대한 오랫동안 InnoDB 버퍼 풀의 메모리에 유지해 디스크 읽기를 최소하하는 것이다. InnoDB 스토리지 엔진에서 데이터를 과정은 아래와 같다.
디스크에서 필요한 데이터 페이지를 버퍼 풀에 적재한 뒤 포인터를 LRU 헤더 부분에 추가
버퍼 풀의 LRU 헤더 부분에 적재된 데이터 페이지가 실제로 읽히면 MRU 헤더 부분으로 이동
버퍼 풀에 상주하는 데이터 페이지는 사용자 쿼리가 얼마나 최근에 접근했었는지에 따라 나이(Age)가 부여되며 오래된 데이터 페이지는 버퍼 풀에서 제거된다. 버퍼 풀의 데이터 페이지가 쿼리에 의해 사용되면 나이가 초기화된 뒤 MRU 헤더 부분으로 옮겨진다.
필요한 데이터가 자주 접근됐다면 해당 페이지의 인덱스 키를 어댑티브 해시 인덱스에 추가
플러시 리스트는 디스크로 동기화되지 않은 데이터를 가진 데이터 페이지(더티 페이지)의 변경 시점 기준의 페이지 목록을 관리한다. 한 번 데이터 변경이 가해진 데이터 페이지는 플러시 리스트에 관리되고 특정 시점이 되면 디스크로 기록돼야 한다. 데이터가 변경되면 InnoDB는 변경 내용을 리두 로그에 기록하고 버퍼 풀의 데이터 페이지에도 변경 내용을 반영한다.
체크포인트는 MySQL 서버가 시작될 때 InnoDB 스토리지 엔진이 리두 로그의 어느 부분부터 복구를 실행할 지 판단하는 기준점을 만드는 역할을 하는데, InnoDB 스토리지 엔진은 이를 발생시켜 디스크의 리두 로그와 데이터 페이지의 상태를 동기화한다.
InnoDB 버퍼 풀은 디스크에서 읽은 상태로 전혀 변경되지 않은 클린 페이지(Clean Page)와 함께 INSERT, UPDATE, DELETE 명령으로 변경된 데이터를 가진 더티 페이지(Dirty Page)도 가지고 있다. 더티 페이지는 디스크와 메모리의 데이터 상태가 다르기 때문에 언젠가 디스크로 기록되어야 하지만 더티 페이지가 무한정 버퍼 풀에 머무를 수는 없다.
InnoDB 스토리지 엔진에서 리두 로그는 1개 이상의 고정된 크기 파일을 연결하고, 데이터 변경이 발생하면 리두 로그 파일에 기록됐던 로그 엔트리는 새로운 로그 엔트리로 덮어 쓰인다.리두 로그 파일에서 재사용 불가능한 공간을 활성 리두 로그(Active Redo Log)라고 하며, 아래 그림의 화살표를 가진 엔드리들이 활성 리두 공간이다.

리두 로그 파일의 공간은 매번 기록될 때마다 로그 포지션은 계속 증가된 값을 갖게 되며, 이를 LSB(Log Sequence Number)라고 한다. InnoDB 스토리지 엔진은 주기적으로 체크포인트 이벤트를 발생시켜 리두 로그와 버퍼 풀의 더티 페이지를 디스크로 동기화하는데, 가장 최근 체크포인트 지점의 LSN이 활성 리두 로그 공간의 시작점이 된다. 또한 가장 최큰 체크포인트의 LSN과 마지막 리두 로그 엔트리의 LSN의 차이를 체크포인트 에이지(Checkpoint Age)라고 하며, 활성 리두 로그 공간의 크기를 나타낸다.
InnoDB 버퍼 풀의 더티 페이지는 특정 리두 로그 엔트리와 관계를 가지고, 체크포인트가 발생하면 체크포인트 LSN보다 작은 리두 고르 엔트리와 관련된 더티 페이지와 리두 로그 엔트리는 모두 디스크로 동기화돼야 한다.
InnoDB 스토리지 엔진은 버퍼 풀에서 아직 디스크로 기록되지 않은 더티 페이지들을 성능상의 악영향 없이 디스크에 동기화하기 위해 2개의 플러시 기능을 백그라운드로 실행한다.
플러시 리스트(Flushlist) 플러시
InnoDB 스토리지 엔진은 리두 로그 공간의 재활용을 위해 주기적으로 오래된 리두 로그 엔트리가 사용하는 공간을 비워야 하는데, 이때 반드시 InnoDB 버퍼 풀의 더티 페이지가 먼저 기스크로 동기화돼야 한다. 이를 위해 플러시 리스트(Flush_list) 플러시 함수를 호출해 플러시 리스트에서 오래전에 변경된 데이터 페이지 순서대로 디스크에 동기화하는 작업을 수행하며, 얼마나 많은 더티 페이지를 한 번에 디스크로 기록하느냐에 따라 사용자 쿼리 처리에 영향을 미친다.
LRU 리스트(LRU_list) 플러시
InnoDB 스토리지 엔진은 LRU 리스트에서 사용 빈도가 낮은 데이터 페이지들을 제거하여 새로운 페이지들을 읽어올 공간을 만드는데, 이때 LRU 리스트(LRU_list) 플러시 함수를 사용해 LRU 리스트의 끝부분부터 최대 innodb_lru_scan_depth 시스템 변수만큼의 페이지들을 스캔하며, 더티 페이지는 디스크에 동기화하고 클린 페이지는 프리(Free) 라스트로 옮긴다.
MySQL 5.6 버전부터 서버 점검이나 기타 작업을 위해 서버를 재시작해야 하는 경우 셧다운 전 InnoDB 버퍼 풀의 상태를 백업할 수 있고, 재시작 시 백업된 버퍼 풀의 상태를 복구할 수 있다.
InnoDB 버퍼 풀의 백업 파일의 크기는 InnoDB 크기가 크다 하더라도 몇십 MB 이하이다. 이는 버퍼 풀의 LRU 리스트에서 적재된 데이터 페이지의 메타 정보만 가져와서 저장하기 때문이며 버퍼 풀의 백업은 매우 빨리 완료된다. 하지만 복구하는 과정은 백업된 내용에서 각 테이블의 데이터 페이지를 다시 디스크에서 읽어와야 하기 때문에 상당한 시간이 걸릴 수 있다.
MySQL 5.6 버전부터 information_schema 데이터베이스의 innodb_buffer_page 테이블을 이용해 InnoDB 버퍼 풀의 메모리에 어떤 테이블 페이지들이 적재돼 있는지 확인할 수 있었지만, 버퍼 풀이 큰 경우 테이블 조회가 큰 부하를 일으켜 서비스 쿼리 성능 저하를 유발하였다.
MySQL 8.0 버전에서는 innodb_cached_indexes 테이블을 새로 추가해 테이블의 인덱스별로 데이터 페이지가 얼마나 InnoDB 버퍼 풀에 적재돼있는지 확인할 수 있다.