
MySQL의 스토리지 엔진 중 가장 많이 사용되는 InnoDB 스토리지 엔진은 거의 유일하게 레코드 기반의 잠금을 제공하며, 그 때문에 높은 동시성 처리가 가능하고 안정적이며 성능이 뛰어나다.
InnoDB 엔진의 구성 요소는 다음과 같다.
InnoDB의 모든 테이블은 기본적으로 프라이머리 키를 기준으로 클러스터링(군집)되어 저장된다.
즉, 프라이머리 키 값의 순서대로 디스크에 저장된다는 뜻이며, 모든 세컨더리 인덱스는 레코드의 주소 대신 프라이머리 키의 값을 논리적인 주소로 사용한다.
꽤 중요한 파트라고 생각하는데, 인덱스를 통해 쿼리가 실행되면 프라이머리 키 값을 찾고, 프라이머리 키 값을 통해 원하는 값을 조회하기 때문이다.
이 때문에 옵티마이저는 풀 테이블 스캔이 더 빠르다고 선택을 할 수 있다.
또한 PK가 순서대로 디스크에 저장되기에, 순서가 일관적인 auto-increment가 아닌 UUID와 같은 값을 PK로 사용한다면 INSERT 마다 성능에 악영향이 있을 수 있다.
(이를 극복하려면 UUIDv7 또는 ULID를 사용하면 된다)
MyISAM 스토리지 엔진에서는 클러스터링 키를 지원하지 않으므로, MyISAM 테이블에서는 프라이머리 키와 세컨더리 인덱스는 구조적으로 차이가 없다.
외래 키 지원은 InnoDB 스토리지 엔진 레벨에서 지원하는 기능이므로, MyISAM, MEMORY 테이블에서는 사용할 수 없다.
외래 키를 사용하면 정합성을 유지할 수 있다는 장점이 있지만, 제약으로 인해 단점이 발생하기도 한다.
외래 키가 있는 테이블에 ROW가 삽입되면 해당 외래 키의 주인에 락이 걸리는데, 이로인해 데드락이 발생할 수 있다.
정확히는 S락이 걸리는데, 2개 이상의 INSERT 요청이 생기고, 똑같은 외래 키 주인의 레코드에 UPDATE 작업을 동시에 할 경우 100% 데드락이 발생한다. ex: 티켓 예매, 상품 재고 관리
foreign_key_checks 시스템 변수를 ON, OFF 처리하여 외래키 제약을 조절할 수 있다.
일반적으로 레코드 레벨의 트랜잭션을 지원하는 DBMS가 제공하는 기능이며, MVCC의 가장 큰 목적은 잠금을 사용하지 않는 일관된 읽기를 제공하는데 있다.
InnoDB는 언두 로그를 이용해 이 기능을 구현한다.
격리 수준이 READ_COMMITED에서 INSERT 후 UPDATE 작업을 수행할때, UPDATE로 일어나는 값의 변경은 우선 버퍼 풀에만 적용된다.
InnoDB가 ACID를 보장하기에 디스크에도 저장된다고 생각하면 된다.
이때 COMMIT 또는 ROLLBACK이 되지 않았을 때, 다른 세션에서 위 INSERT 된 값을 조회할 때, 격리 수준에 따라 버퍼 풀 또는 언두 로그에 있는 값을 조회한다.
READ_UNCOMMIT
READ_COMMIT 이상
이러한 과정을 DBMS에서는 MVCC라고 표현한다.
ROLLBACK은 언두 영역에 있는 데이터를 버퍼 풀로 다시 복구하고, 언두 영역의 내용을 삭제한다.
또한 COMMIT이 되면 버퍼 풀의 상태를 영구적인 데이터로 만드는데, 이 때 언두 영역이 바로 삭제되는 것은 아니고, 이 언두 영역을 필요로 하는 트랜잭션이 더는 없을 때 삭제된다.
트랜잭션이 길어지면 언두 로그에 데이터가 오래 남게 되는데, 이는 성능에 악영향을 유발한다.
트랜잭션을 최대한 빠르게 끝내야 하는 이유가 이것이다.
InnoDB 스토리지 엔진은 MVCC를 이용해 잠금을 사용하지 않고, 읽기 작업을 수행한다.
격리 수준이 SERIALIZABLE이 아닌 경우 INSERT와 연결되지 않은 순수한 읽기(SELECT) 작업은 잠금을 대기하지 않고 바로 실행된다.
다른 세션에서 잠금을 걸었더라도, 다른 세션에서 SELECT 작업을 방해하지 않는다.
이를 잠금 없는 일괄된 읽기라고 표현하며, InnoDB에서는 변경되지 전의 데이터를 읽기 위해 언두 로그를 사용한다.
InnoDB 스토리지 엔진은 내부적으로 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프(Wait-for List) 형태로 관리한다.
이는 InnoDB 스토리지 엔진의 데드락 감지 스레드를 통해 그래프를 주기적으로 검사해 교착 상태에 빠진 트랜잭션을 강제 종료한다.
이때 어느 트랜잭션을 종료할 지는 언두 로그의 양으로 정하는데, 언두 처리를 더 적게 할 수 있는 언두 로그가 적은 트랜잭션을 선택한다.
MySQL 엔진에서 관리되는 테이블 잠금의 데드락 감지를 위해
innodb_table_locks시스템 변수를 활성화 하는게 좋다고 나와있는데, 8.0.36 버전 기준 기본으로 활성화되어 있다.
데드락 감지 스레드는 동시 처리 스레드가 매우 많아지거나 잠금을 필요로 하는 트랜잭션의 개수가 많아지면 데드락 감지 스레드가 느려지고, 병목을 유발하므로 innodb_deadlock_detect 시스템 변수를 OFF로 설정하여 데드락 감지 스레드를 비활성화 할 수 있다.
이 경우 데드락 감지를 하지 못하니, 교착 상태가 무한히 이뤄질 것 같지만 innodb_lock_wait_time 시스템 변수 덕분에 일정 시간이 지나면 자동으로 요청이 실패하게 된다.
따라서 데드락 감지 스레드가 부담된다면, 데드락 감지 스레드를 비활성화 하고 innodb_lock_wait_time을 기본 값인 50(초)보다 낮게 설정하여 사용할 것을 권장한다.
InnoDB 스토리지 엔진은 매우 견고해서 데이터 파일이 손상되거나 MySQL이 실행되지 못하는 경우는 거의 발생하지 않는다.
하지만 MySQL과 무관하게 디스크나 하드웨어 이슈로 InnoDB 스토리지 엔진이 복구를 하지 못하는 경우가 발생할 수 있다.
InnoDB 데이터 파일은 기본적으로 MySQL 서버가 시작될 때, 항상 자동 복구를 수행하는데, 이 단계에서 파일에 손상이 있다면 복구를 멈추고 MySQL은 종료된다.
이러한 손상으로 인해 MySQL을 실행하지 못한다면 innodb_force_recovery 시스템 변수를 설정해서 MySQL 서버를 시작해야 한다.
리눅스 기준
/etc/mysql/my.cnf파일에서 밑의 설정을 추가한다.
[mysqld]
innodb_force_recovery = 1 # 1~6 기본값 0
innodb_force_recovery 변수의 기본 값은 0이고, 1~6 까지 설정할 수 있다.
해당 값에 대한 자세한 내용은 책 106 페이지 또는 공식 문서를 참고하자.
InnoDB 스토리지 엔진의 가장 핵심적인 부분으로, 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐시해 두는 공간이다.
쓰기 작업을 지연시켜 일괄 작업으로 처리할 수 있게 해주는 버퍼 역할도 한다.
일반적으로 전체 물리 메모리 중 80% 정도를 버퍼 풀로 설정하라는 게시글도 있지만, 운영체제와 각 포어그라운드 스레드가 사용할 메모리도 충분히 고려해서 설정해야 한다.
공식 문서에서는 최대 80% 정도 할당되는 경우가 많다고 적혀있다.
또한, MySQL 서버 내에서 메모리로 필요로 하는 부분은 크게 없지만, 커넥션이 많고 사용하는 테이블도 많다면 러코드 버퍼 용도로 사용되는 메모리 공간이 많이 필요해 질 수 있기에 버퍼 풀의 사이즈를 적게 설정하고, 상황을 봐가며 적절히 증가시키는 것이 최적이다.
MySQL 5.7 부터 동적으로 버퍼 풀의 사이즈를 조절할 수 있도록 기능이 추가되었다. ex)
SET GLOBAL innodb_buffer_pool_size=402653184;# 384MB
버퍼 풀을 변경하는 작업은 크리티컬한 변경이므로 MySQL이 유휴 상태일 때 진행하는 것을 권장한다.
메모리 공간이 8GB 미만이면 50% 정도를 버퍼 풀로 설정하고, 이상이라면 50%에서 조금씩 올려가며 최적점을 찾는다.
메모리 공간이 50GB 이상이라면, 15~30GB 뺀 나머지 공간을 버퍼 풀 사이즈로 할당한다.
또한, 버퍼 풀의 크기는 innodb_buffer_pool_instances * innodb_buffer_pool_chunk_size의 배수로 할당해야 한다.
만약 해당 값과 같지 않거나, 배수가 아닐 경우 배수인 값으로 자동으로 설정된다.
ex)
innodb_buffer_pool_instances=16,innodb_buffer_pool_chunk_size=128MB일때innodb-buffer-pool-size를 9GB로 설정하면, 9GB가 아닌 10GB로 설정된다.
보다 자세한 내용은 공식 문서를 참고하자.
버퍼 풀 용량이 1GB 미만일 경우 innodb_buffer_pool_instances의 값은 1개로 설정되고, 이상일 경우 8개로 설정된다.
innodb_buffer_pool_size기본 값이134217728, 128MB 이므로, 기본 값은 1이라고 생각해도 될 것 같다.
innodb_buffer_pool_instances을 설정하면 버퍼 풀을 여러 작은 버퍼 풀로 쪼개며, 잠금으로 인해 발생하는 경합을 분산시킬 수 있다.
Java의
ConcurrentHashMap또한 내부에 버킷 단위로 락을 걸어서 동시성 성능을 높인다.
버퍼 풀로 할당할 수 있는 용량이 40GB 이하라면 기본 값인 8을 사용하고, 그 이상이라면 버퍼 풀 인스턴스 당 5GB가 할당되도록 인스턴스 개수를 설정하는 것이 좋다.
위의 배수에 따라 설정한다면,
innodb-buffer-pool-size=50G일때innodb_buffer_pool_chunk_size=5G로 설정하고innodb_buffer_pool_instances=10으로 설정한다.
InnoDB 스토리지 엔진은 버퍼 풀을 페이지 크기(innodb_page_size)의 조각으로 쪼개어 관리한다.
그리고 이러한 조각을 관리하기 위해 크게 3가지 자료 구조로 관리한다.
프리 리스트
InnoDB 버퍼 풀에서 실제 사용자의 데이터로 채워지지 않은 비어 있는 페이지들의 목록이며, 사용자의 쿼리가 새롭게 디스크의 데이터 페이지를 읽어와야 하는 경우 사용된다.
LRU 리스트
LRU 리스트는 엄밀하게는 LRU와 MRU 리스트가 결합된 형태라고 보면 된다. (변형된 LRU 리스트)

InnoDB 스토리지 엔진에서 데이터를 찾는 과정은 다음과 같다.
또한 자주 접근된 데이터 페이지의 인덱스 키는 어댑티브 해시 인덱스에 추가된다.
플러시 리스트
디스크로 동기화되지 않은 데이터를 가진 데이터 페이지(이 상태의 페이지를 더티 페이지라고 함)의 변경 시점 기준 페이지 목록을 관리한다.
디스크에서 읽은 뒤 상태에 변경이 있다면 플러시 리스트에 관리되고, 특정 시점이 지나면 디스크로 기록돼야 한다.
책에서 플러시 리스트에 대한 내용이 두루뭉실하게 나오는데, 이후 내용은 플러시 리스트의 내용이 디스크로 기록된 이후를 말하는지 모르겠다.
변경된 데이터는 리두 로그에 기록되고, 버퍼 풀의 데이터 페이지에도 반영한다.
하지만 리두 로그가 기록됐다고 해서, 데이터 페이지가 디스크로 기록됐다는 것을 보장하지 않는다. (데이터 페이지가 기록됐지만, 리두 로그가 기록되지 않는 경우도 있다.)
InnoDB 스토리지 엔진은 일정 주기로 체크포인트를 발생시켜 디스크의 리두 로그와 데이터 페이지의 상태를 동기화한다.
체크포인트는 MySQL이 실행될 때, InnoDB 스토리지 엔진이 리두 로그의 어느 부분부터 복구를 해야할 지 판단하는 기준점을 만드는 역할을 한다.
버퍼 풀과 리두 로그는 매우 밀접한 관계를 맺고 있다.
단순히 버퍼 풀의 용량을 높이면 디스크의 모든 데이터가 메모리에 적재되므로 쿼리의 성능이 빨라지지만, 쓰기 버퍼링 성능을 높이지는 못한다.
InnoDB 스토리지 엔진의 쓰기 버퍼링 성능을 높이려면 버퍼 풀과 리두 로그의 관계를 이해해야 한다.
책에서 리두 로그는 1개 이상의 고정 크기 파일을 연결해서 순환 고리처럼 사용한다고 하는데 이는 MySQL 8.0.30 이전의 이야기이다.
MySQL 8.0.30 이상 부터는 32개의 리두 로그 파일을 생성해서 리두 로그를 관리한다.
따라서 책에 나온 내용은 8.0.30 이전에 관한 내용이니 적당히 걸러서 학습해야 할 것 같다. (순환하여 기록한다는 부분들)

리두 로그 각 파일의 크기는 innodb_redo_log_capacity / 32 이다. (innodb_redo_log_capacity 기본값은 100MB)
리두 로그가 가득차면 버퍼 풀의 수정된 내용이 덮어 씌어지는데, 리두 로그 파일이 작으면 디스크 쓰기가 많이 발생한다.
따라서 적절한 리두 로그 용량을 설정해서 디스크 쓰기를 줄이는 것이 쓰기 버퍼링 성능을 높이는 것이라고 할 수 있겠다.
MySQL 5.6 까지는 더티 페이지 플러시 기능이 부드럽게 처리되지 않았지만(디스크 쓰기 폭증 현상), 5.7 버전을 거치며 8.0으로 올라가면서 예전과 같은 디스크 쓰기 폭증 현상은 발생하지 않았다.
해당 섹션의 설정들은 서비스에서 성능 문제가 발생하지 않는다면 조절할 필요는 없다.
InnoDB 스토리지 엔진은 버퍼 풀에서 디스크로 동기화되지 않은 더티 페이지를 성능에 악영향 없이 동기화하기 위해 2개의 플러시 기능을 백그라운드로 실행한다.
플러시 리스트 플러시
책에서는 InnoDB 스토리지 엔진은 리두 로그 공간의 재활용을 위해 주기적으로 오래된 리두 로그 엔트리가 사용하는 공간을 비워야 하고, 오래된 리두 로그 공간을 지우려면 버퍼 풀의 더티 페이지가 디스크로 동기화 되어야 한다고 설명되어 있다.
하지만 MySQL 8.0.30 이후 리두 로그 아키텍처가 변경되었기 때문에 책의 내용은 틀린 곳이 있을 수 있다.
MySQL 8.0.30 이후에는 리두 로그 파일이 가득차면 디스크 쓰기(플러시)가 발생한다. (이전에도 가득차면 발생했지만, 리두 로그가 32개로 쪼개졌기에 더 자주 발생할 것 같다)
버퍼 풀의 더티 페이지를 디스크로 동기화하는 스레드를 클리너 스레드라고 부르며 innodb-page-cleaners 변수를 통해 클리너 스레드의 개수를 조절할 수 있다. (기본값 4)
이때 클리너 스레드의 개수는 innodb_buffer_pool_instances를 넘지 못하는데, 버퍼 풀 인스턴스보다 높게 설정되면 버퍼 풀 인스턴스의 개수와 동일하게 설정된다.
버퍼 풀은 용량의 한계가 있기 때문에 무한정 더티 페이지를 유지할 수 없기에, 더티 페이지의 비율이 innodb_max_dirty_pages_pct의 비율(기본값 90)을 넘어가면, 디스크 쓰기가 폭증하는 현상이 발생한다.
따라서 이를 막고자 innodb_max_dirty_pages_pct_lwm의 비율(기본값 10)을 조절하여, 디스크 쓰기가 폭증하는 것을 막는다.
이때, 더티 페이지 비율이 낮은 상태에서 디스크 쓰기가 많이 발생하고 더티 페이지의 비율이 너무 낮은 상태로 머물러 있다면 innodb_max_dirty_pages_pct_lwm 변수를 높이는 것이 디스크 쓰기 횟수를 줄이는 효과를 얻을 수 있다.
또한 innodb_io_capacity, innodb_io_capacity_max 변수를 통해 디스크 쓰기 처리량을 설정할 수 있는데, 장착된 디스크의 IOPS 만큼 설정한다면, 디스크 읽기에 사용할 자원이 모자를 수 있으므로 적절히 설정해야 한다.
하지만 이렇게 특정 값을 적절하게 설정하기는 번거롭기 때문에 MySQL은 어댑티브 플러시 기능을 제공한다.
어댑티브 플러싱은 리두 로그 생성 속도와 현재 플러시 속도를 기반으로 플러시 속도를 동적으로 조절한다.
기본으로 활성화 되어 있고, 내부 벤치마크 결과 시간이 지나도 처리량을 유지하고 전반적인 처리량을 크게 향상시켰다고 한다.
하지만 모든 경우에는 적합하지 않고, 리두 로그가 가득찰 위험이 있을 때 가장 큰 이점을 제공한다고 한다.
책에서는
innodb_adaptive_flushing_lwm변수를 넘어서면 그제서야 어댑티브 플러싱이 작동한다고 나와 있는데, 공식 문서에서는 어댑티브 플러싱이 비활성화 되어도 해당 값을 넘으면 어댑티브 플러싱이 작동한다고 나와있다.
그 외 innodb_flush_neighbors 변수는 보조 기억 장치에 HDD를 사용할 때, 1 또는 2로 설정해서 활성화 하는게 좋다고 한다.
LRU 리스트 플러시
InnoDB 스토리지 엔진은 LRU 리스트에서 사용 빈도가 낮은 데이터 페이지를 제거하여, 새로운 페이지를 읽어올 공간을 만들기 위해 LRU 리스트 플러시 함수가 사용된다.
따라서 InnoDB 스토리지 엔진은 LRU 리스트의 끝부분부터 최대 innodb_lru_scan_depth 변수 만큼 페이지를 스캔한다.
InnoDB 스토리지 엔진은 스캔하면서 더티 페이지는 디스크에 동기화하게 하며, 클린 페이지는 즉시 프리 리스트로 페이지를 옮긴다.
해당 작업은 매 초 마다 이뤄지고, innodb_buffer_pool_instances 변수의 수만큼 곱으로 수행된다.
공식 문서에서는 낮은 값으로 시작하여 프리 페이지가 보이지 않는 것을 목표로 설정을 하라고 나와있다.
InnoDB 스토리지 엔진의 버퍼 풀은 쿼리의 성능에 매우 밀접하게 연결되어 있다.
쿼리 요청이 매우 빈번한 서버를 재시작하면, 쿼리 성능이 평소의 1/10도 안 되는 경우가 대부분일 것이다.
하지만, 버퍼 풀에 쿼리들이 사용할 데이터가 준비되어 있으면 디스크에서 데이터를 읽지 않아도 쿼리가 처리될 수 있기 때문에 성능이 매우 빠르다.
이렇게 버퍼 풀에 디스크의 데이터가 적재되어 있는 상태를 워밍업이라고 한다.
책에서는 느린 성능에 대해 얘기하다가 갑자기 버퍼 풀에 데이터가 준비되어 있어서 디스크에서 읽지 않는다는 식으로 잘못 적혀있다.
MySQL 5.6 이전에는 워밍업을 위해 주요 테이블과 인덱스에 대해 풀 스캔을 한 번씩 실행하고 서비스를 오픈했다.
하지만 MySQL 5.6 이후부터는 버퍼 풀 덤프 및 적재 기능이 도입되어 innodb_buffer_pool_dump_now 변수를 통해 MySQL을 종료하기 전에 버퍼 풀을 백업할 수 있다.
그리고 innodb_buffer_pool_load_now 변수를 통해 백업된 버퍼 풀의 상태를 다시 복구할 수 있다.
백업된 버퍼 풀의 용량은 버퍼 풀이 크다고 하더라도 몇십 MB 이하인 것을 알 수 있는데, 이는 버퍼 풀의 모든 정보가 아닌, LRU 리스트에서 적재된 데이터 페이지의 메타 정보만 가져와 저장하기 때문이다.
하지만 버퍼 풀의 내용을 다시 읽어올 때는 많은 시간이 걸릴 수 있기 때문에 중간에 멈추고자 한다면 innodb_buffer_pool_load_abort 변수를 사용하여 멈출 수 있다.
하지만 이러한 버퍼 풀 백업과 복구는 번거롭기에 innodb_buffer_pool_dump_at_shutdown, innodb_buffer_pool_load_at_startup 변수를 활성화 시키면 자동으로 버퍼 풀을 백업하고 복구할 수 있다.
그러나 사실 이 작업을 할 필요는 없는데,
innodb_buffer_pool_dump_at_shutdown,innodb_buffer_pool_load_at_startup변수는 기본으로 활성화되어 있기 때문이다.
MySQL 5.6 부터 information_schema 데이터베이스의 innodb_buffer_page 테이블을 이용해 InnoDB 버퍼 풀의 메모리에 어떤 테이블의 페이지들이 적재돼 있는지 확인할 수 있었다.
하지만 InnoDB 버퍼 풀이 큰 경우에는 이 테이블 조회가 상당히 큰 부하를 일으키며 서비스 쿼리가 많이 느려지는 문제가 있었다.
그래서 실제 서비스에서 사용되는 MySQL에서는 버퍼 풀의 상태를 확인하는 것이 거의 불가능했다.
MySQL 8.0 부터 이러한 문제점을 해결하기 위해 information_schema 데이터베이스에 innodb_cached_indexes 테이블이 새로 추가되어, 이 테이블을 이용하여 테이블의 인덱스별로 데이터 페이지가 얼마나 버퍼 풀에 적재돼 있는지 확인할 수 있다.
select it.name, ii.name, ici.n_cached_pages
from information_schema.innodb_tables it
inner join information_schema.innodb_indexes ii on ii.table_id = it.table_id
inner join information_schema.innodb_cached_indexes ici on ici.index_id = ii.index_id
order by ici.n_cached_pages desc;

InnoDB 스토리지 엔진의 리두 로그는 리두 로그 공간의 낭비를 막기 위해 페이지의 변경된 내용만 기록한다.
이로 인해 InnoDB의 스토리지 엔진에서 더티 페이지를 디스크 파일로 플러시할 때 일부만 기록되는 문제가 발생하면 그 페이지의 내용은 복구할 수 없을 수도 있다.
이렇게 페이지가 일부만 기록되는 형상을 파셜 페이지(Partial-page) 또는 톤 페이지(Torn-page)라고 하는데, 이런 현상은 하드웨어의 오작동이나 시스템의 비정상 종료로 발생할 수 있다.
이런 문제를 막기 위해 InnoDB 스토리지 엔진에서는 Double-Wrtie 기법을 이용한다.
InnoDB 스토리지 엔진은 더티 페이지를 디스크로 쓰기 전에, 더티 페이지를 모아서 한 번에 기록한다.
데이터가 두 번 기록되지만, 운영체제의
fsync()호출으로 인해 두 배의 I/O 오버헤드 또는 I/O 작업이 필요하지 않다.
Doublewrite Buffer는 데이터의 안정성을 위해 자주 사용되는데, HDD의 경우에는 한 번의 순차 디스크 쓰기를 하는 것이기 때문에 큰 부담이 없지만, SSD 같은 경우에는 부담이 될 수 있다.
책에서는 Doublewrite Buffer가 InnoDB 시스템 테이블스페이스에 저장된다고 나와있는데, MySQL 8.0.20 이후에는 별도의 공간으로 저장된다. (
innodb_doublewrite_dir)
자세한 내용은 공식 문서를 참고하자
