본문 바로가기
교육 및 책/Real MySQL

InnoDB 스토리지 엔진 아키텍처

by oneny 2023. 11. 2.

InnoDB 스토리지 엔진 아키텍처

InnoDB는 MySQL에서 사용할 수 있는 스토리지 엔진 중 거의 유일하게 레코드 기반의 잠금을 제공하며, 그 때문에 높은 동시성 처리가 가능하고 안정적이며 성능이 뛰어나다. InnoDB의 간단한 아키텍처는 위 그림과 같다.

 

프라이머리 키에 의한 클러스터링

InnoDB의 모든 테이블은 기본적으로 프라이머리 키를 기준으로 클러스터링되어 저장된다. 즉, 프라이머리 키 값의 순서대로 디스크에 저장된다는 뜻이며, 모든 세컨더리 인덱스는 레코드의 주소 대신 프라이머리 키의 값을 논리적인 주소로 사용한다. 프라이머리 키가 클러스터링 인덱스이기 때문에 프라이머리 키를 이용한 레인지 스캔은 상당히 빨리 처리될 수 있다. 결과적으로 쿼리의 실행 계획에 프라이머리 키는 기본적으로 다른 보조 인덱스에 비해 비중이 높게 설정(쿼리의 실행 계획에서 다른 보조 인덱스보다 프라이머리 키가 선택될 확률이 높음)된다.

 

외래 키 지원

외래 키에 대한 지원은 InnoDB 스토리지 엔진 레벨에서 지원하는 기능으로 MyISAM이나 MEMORY 테이블에서는 사용할 수 있다. InnoDB에서 외래 키는 부모 테이블과 자식 테이블 모두 해당 컬럼에 인덱스 생성이 필요하고, 변경 시에는 반드시 부모 테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하므로 잠금이 여러 테이블로 전파되고, 그로 인해 데드락이 발생할 때가 많으므로 개발할 때도 외래 키의 존재에 주의하는 것이 좋다.

외래 키가 복잡하게 얽힌 경우에 서비스에 문제가 생겨 긴급하게 뭔가 조치를 해야하는 경우에는 SET foreign_key_checks=OFF 로 설정하면 외래 키 관계에 대한 체크 작업을 일시적으로 멈출 수 있다.

 

MVCC(Multi Version Concurrency Control)

일반적으로 레코드 레벨의 트랜잭션을 지원하는 DBMS가 제공하는 기능이며, MVCC의 가장 큰 목적은 언두 로그(Undo log)를 이용해 잠금을 사용하지 않는 일관된 읽기를 제공하는 데 있다. 여기서 멀티 버전이라 함은 하나의 레코드에 대해 여러 개의 버전이 동시에 관리된다는 의미이다. 격리 수준(Isolation level)이 READ_COMMITED인 MySQL 서버에서 InnoDB 스토리지 엔진을 사용하는 테이블의 데이터 변경을 어떻게 처리하는지 살펴보자.

 

MEMBER 테이블에서 한 건의 레코드에 INSERT한 다음 UPDATE해서 발생하는 변경 작업 및 절차에 대해서 확인해보자. 위처럼 명령어를 실행하면 데이터베이스의 상태는 그림처럼 상태가 바뀔 것이다. 여기 MEMBER 테이블에 UPDATE 문장이 실행되면 처리 절차가 아래와 같다.

 

UPDATE 문장이 실행되면 커밋 실행 여부와 관계없이 InnoDB의 버퍼 풀은 새로운 값인 '경기'로 업데이트된다. 그리고 디스크의 데이터 파일에는 체크포인트나 InnoDB의 Write 스레드에 의해 새로운 값으로 업데이트될 수도 있고 아닐 수도 있지만 InnoDB가 ACID를 보장하기 때문에 일반적으로는 InnoDB의 버퍼 풀과 데이터 파일은 동일한 상태라고 가정해도 무방하다.

이 때, 아직 COMMIT이나 ROLLBACK이 되지 않은 상태에서 다른 사용자가 SELECT * FROM member WHERE m_id=12; 쿼리로 작업 중인 레코드를 조회하면 어디에 있는 데이터를 조회할까? 이는 MySQL 서버의 시스템 변수(transaction_isolation)에 설정된 격리 수준(Isolation level)에 따라 다르다는 것이다. 격리 수준이 READ_UNCOMMITED인 경우에는 InnoDB 버퍼 풀이 현재 가지고 있는 변경된 데이터를 읽어서 반환한다. READ_COMMITTED나 그 이상의 격리 수준(REPEATABLE_READ, SERIALIZABLE)인 경우에는 아직 커밋되지 않았기 때문에 InnoDB 버퍼 풀이나 데이터 파일에 있는 내용 대신 변경되기 이전의 내용을 보관하고 있는 언두 영역의 데이터를 반환한다.

즉, 하나의 레코드(m_id가 12인 레코드)에 대해 2개의 버전이 유지되고, 필요에 따라 어느 데이터가 보여지는지 여러 가지 상황에 따라 달라지는 구조이다. 그리고 커밋이 된다고 언두 영역의 백업 데이터가 바로 삭제되는 것이 아니라 언두 영역을 필요로 하는 트랜잭션이 더는 없을 때 비로소 삭제된다.

 

잠금 없는 일관된 읽기(Non-Locking Consistent Read)

InnoDB 스토리지 엔진은 MVCC 기술을 이용해 잠금을 걸지 않기 때문에 다른 트랜잭션이 가지고 있는 잠금을 기다리지 않고, 읽기 작업이 가능하다. 즉, 격리 수준이 SERIALIZABLE이 아닌 READ_UNCOMMITED나 READ_COMMITTED, REPEATABLE_READ 수준인 경우 INSERT와 연결되지 않은 순수한 읽기(SELECT) 작업은 다른 트랜잭션의 변경 작업과 관계없이 항상 잠금을 대기하지 않고 바로 실행된다. READ_UNCOMMITED인 경우에는 버퍼 풀이 현재 가지고 있는 변경된 데이터를, READ_COMMITED이나 그 이상의 격리 수준인 경우에는 아직 커밋되지 않았기 때문에 변경되기 이전의 내용을 보관하고 있는 언두 로그 영역의 데이터를 반환한다.

 

자동 데드락 감지

InnoDB 스토리지 엔진은 내부적으로 잠금이 교착 상태에 빠지지 않았는지 체크하기 위해 잠금 대기 목록을 그래프(Wait-for List) 형태로 관리한다. 이때, InnoDB 스토리지 엔진은 데드락 감지 스레드를 가지고 있어 언두 로그 레코드를 더 적게 가진 트랜잭션이 일반적으로 롤백을 시킨다. 즉, 트랜잭션 강제 롤백으로 인한 MySQL의 부하를 덜 유발시키기 위해 트랜잭션의 언두 로그 양이 적은 것을 선택한다.

 

Google처럼 프라이머리 키 기반의 조회 및 변경이 아주 높은 빈도로 실행되는 서비스는 매우 많은 트랜잭션을 동시에 실행될 때 데드락 감지 스레드가 상당히 성능을 저하시키는 것을 알아냈다. 즉, 동시 처리 스레드가 매우 많아지거나 각 트래잭션이 가진 잠금의 개수가 많아지면 데드락 감지 스레드가 느려진다. 따라서 오라클에서는 이 데드락 감지 스레드를 비활성화할 수 있도록 innodb_deadlock_detect 시스템 변수를 제공하면 OFF로 설정하면 데드락 감지 스레드는 더는 작동하지 않는다. 하지만 데드락 상황이 발생하면 무한정 대기하는 것을 방지하기 위해 innodb_lock_wait_timeout 시스템 변수를 활성화하면 락을 설정한 시간 동안 획득하지 못한다면 쿼리는 실패하고 에러를 반환할 수 있다. 데드락 감지 스레드가 부담되어 innodb_deadlock_detect를 OFF로 설정해서 비활성화한 경우라면 innodb_lock_wait_timeout을 기본값인 50초보다 훨씬 낮은 시간으로 변경해서 사용할 것을 권장한다.

 

자동화된 장애 복구

InnoDB에는 손실이나 장애로부터 데이터를 보호하기 위해 MySQL 서버가 시작될 때 완료되지 못한 트랜잭션이나 디스크에 일부만 기록된(Partial write) 데이터 페이지 등에 대한 일련의 복구 작업이 자동으로 진행된다.

 InnoDB 스토리지 엔진은 매우 견고해서 데이터 파일이 손상되거나 MySQL 서버가 시작되지 못하는 경우는 거의 발생하지 않지만 디스크나 서버 하드웨어 이슈로 InnoDB 스토리지 엔진이 자동으로 복구하지 못하는 경우는 발생할 수 있다. InnoDB 데이터 파일은 기본적으로 MySQL 서버가 시작될 때 항상 자동 복구를 수행하고, 복구될 수 없는 손상이 있다면 자동 복구를 멈추고 MySQL 서버는 종료된다.

이때는 MySQL 서버의 설정 파일에 innodb_force_recovery 시스템 변수를 설정해서 MySQL 서버를 시작해야하는데 순서를 다음과 같다.

  1. InnoDB의 로그 파일이 손상됐다면 6으로 설정하고 MySQL 서버를 기동한다.
  2. InnoDB 테이블의 데이터 파일이 손상됐다면 1로 설정하고 MySQL 서버를 기동한다.
  3. 어떤 부분이 문제인지 알 수 없다면 innodb_force_recovery 설정값을 1부터 6까지 변경하면서 MySQL을 재시작한다. innodb_force_recovery 값이 커질수록 그만큼 심각한 상황이어서 데이터 손실 가능성이 커지고 복구 가능성은 적어진다.

 

InnoDB 버퍼 풀

InnoDB 스토리지 엔진에서 가장 핵심적인 부분으로, 디스크의 데이터 파일이나 인덱스 정보를 메모리에 캐시해 두는 공간이다. 데이터를 변경하는 쿼리는 데이터 파일의 이곳저곳에 위치한 레코드를 변경하기 때문에 랜덤한 디스크 작업을 발생시킨다. 버퍼 풀은 이러한 쓰기 작업을 지연시켜 일괄 작업으로 처리할 수 있게 해주는 버퍼 역할도 같이 한다.

 

버퍼 풀의 크기 설정

MySQL 서버 내에서 메모리를 필요로 하는 부분은 크게 없지만 아주 독특한 경우 레코드 버퍼가 상당한 메모리를 사용하기도 한다. 레코드 버퍼는 각 클라이언트 세션에서 테이블의 레코드를 읽고 쓸 때 버퍼에 일시적으로 저장하여 데이터 검색 및 변경의 성능을 향상시키는 것을 지원하는 공간이다. MySQL 서버가 사용하는 레코드 버퍼 공간은 전체 커넥션 개수와 각 커넥션에서 읽고 쓰는 테이블의 개수에 따라 결정되기 때문에 정확히 필요한 메모리 공간의 크기를 계산할 수 없다.

따라서 MySQL 5.7 버전부터는 InnoDB 버퍼 풀의 크기를 동적으로 조절할 수 있다. InnoDB 버퍼 풀은 innodb_buffer_pool_size 시스템 변수로 크기를 설정할 수 있다. 가능하면 InnoDB 버퍼 풀의 크기를 적절히 작은 값으로 설정해서 조금씩 상황을 봐 가면서 증가시키는 방법이 최적이고, 내부적으로 128MB 청크 단위로 쪼개어 관리되기 때문에 사이즈를 조절할 때 128MB 단위로 처리된다.

https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool-resize.html 해당 사이트를 통해 메뉴얼을 숙지하는 것이 좋다.

 

InnoDB 버퍼 풀은 예전에 버퍼 풀 전체를 관리하는 잠금(세마포어)으로 인해 내부 잠금 경합을 많이 유발했는데, 이런 경합을 줄이기 위해 버퍼 풀을 여러 개의 작은 버퍼 풀로 쪼개어 관리하여 개별 버퍼 풀 전체를 관리하는 잠금(세마포어) 자체도 경합이 분산되도록 개선하였다. innodb_buffer_pool_instances 시스템 변수를 이용해 버퍼 풀을 여러 개로 분리해서 관리할 수 있는데, 각 버퍼 풀을 버퍼 풀 인스턴스라고 표현한다.

 

버퍼 풀의 구조

InnoDB 스토리지 엔진은 버퍼 풀이라는 거대한 메모리 공간을 페이지 크기(innodb_page_size 시스템 변수)의 조각으로 쪼개어 InnoDB 스토리지 엔진이 데이터를 필요로 할 때 해당 데이터 페이지를 읽어서 각 조각에 저장한다.

버퍼 풀의 페이지 크기 조각을 관리하기 위해 InnoDB 스토리지 엔진은 크게 LRU(Least Recently Used) 리스트와 플러시(Flush) 리스트, 그리고 프리(Free) 리스트라는 3개의 자료 구조를 관리한다.

프리 리스트는 InnoDB 버퍼 풀에서 실제 사용자 데이터로 채워지지 않은 비어 있는 페이지들의 목록이며, 사용자의 쿼리가 새롭게 디크의 데이터 페이지를 읽어와야 하는 경우 사용된다.

 

버퍼 풀 관리를 위한 LRU 리스트 구조

LRU 리스트는 엄밀하게 LRU와 MRU(Most Recently Used) 리스트가 결합된 형태라고 보면 된다. 위 그림에서 'Old 서브리스트' 영역은 LRU에 해당하며, 'New 서브리스트' 영역은 MRU 정도로 이해하면 된다. LRU 리스트를 관리하는 목적은 디스크로부터 한 번 읽어온 페이지를 최대한 오랫동안 InnoDB 버퍼풀의 메모리에 유지해서 디스크 읽기를 최소화하는 것이다. InnoDB 스토리지 엔진에서 데이터를 찾아가는 과정은 다음과 같다.

  1. 필요한 레코드가 저장된 데이터 페이지가 버퍼 풀에 있는지 검색
    • InnoDB 어댑티브 해시 인덱스를 이용해 페이지 검색
    • 해당 테이블의 인덱스(B-Tree)를 이용해 버퍼 풀에서 페이지를 검색
    • 버퍼 풀에 이미 데이터 페이지가 있었다면 해당 페이지의 포인터를 MRU 방향으로 승급
  2. 디스크에서 필요한 데이터 페이지를 버퍼 풀에 적재하고, 적재된 페이지에 대한 포인터를 LRU 헤더 부분에 추가
  3. 버퍼 풀의 LRU 헤더 부분에 적재된 데이터 페이지가 실제로 읽히면 MRU 헤더 부분으로 이동(Read Ahead와 같이 대량의 읽기의 경우 디스크의 데이터 페이지가 버퍼 풀로 적재는 되지만 실제 쿼리에서 사용되지 않을 수도 있으며, 이런 경우에는 MRU로 이동되지 않음)
  4. 버퍼 풀에 상주하는 데이터 페이지는 사용자 쿼리가 얼마나 최근에 접근했었는지에 따라 나이(Age)가 부여되며, 버퍼 풀에 상주하는 동안 쿼리에서 오랫동안 사용되지 않으면 데이터 페이지에 부여된 나이가 오래되고 결국 해당 페이지는 버퍼 풀에서 제거된다.
  5. 필요한 데이터가 자주 접근됐다면 해당 페이지의 인덱스 키를 어댑티브 해시 인덱스에 추가

 

플러시 리스트는 디스크로 동기화되지 않은 데이터를 가진 데이터 페이지(이를 더티 페이지라고 함)의 변경 시점 기준의 페이지 목록을 관리한다. 즉, 디스크에서 읽은 상태 그대로 변경이 없다면 플러시 리스트에 관리되지 않지만, 한 번이라도 변경이 가해진 데이터 페이지는 플러시 리스트에 관리되고 특정 시점이 되면 디스크로 기록되어야 한다.데이터가 변경되면 InnoDB는 변경 내용을 리두 로그에 기록하고 버퍼 풀의 데이터 페이지에도 변경 내용을 반영한다. 따라서 리두 로그의 각 엔트리는 특정 데이터 페이지와 연결되지만 리두 로그가 디스크로 기록됐다고 해서 데이터 페이지가 디스크로 기록됐다는 것을 항상 보장하지 않는다.


버퍼 풀과 리두 로그

리두 로그는 데이터베이스 내부의 데이터 변경 작업을 기록하고 추적하는데 사용된다. 이는 시스템 장애 시 데이터를 복원하는데 사용할 수 있는데 데이터 변경 작업을 임시로 저장하는데 사용되기 때문에 성능을 향상시키고 동시성을 관리하는데 중요하다.

InnoDB의 버퍼 풀과 리두 로그는 매우 밀접한 관계를 맺고 있다. InnoDB 버퍼 풀은 데이터베이스 서버의 성능 향상을 위해 데이터 데이터 캐시쓰기 버퍼링이라는 두 가지 용도가 있다. 버퍼 풀의 메모리 공간만 단순히 늘리는 것은 데이터 캐시 기능만 향상시키는 것이기 때문에 InnoDB 버퍼 풀의 쓰기 버퍼링 기능까지 향상시키려면 InnoDB 버퍼 풀과 리두 로그와의 관계를 먼저 이해해야 한다.

 

InnoDB 버퍼 풀은 클린 페이지와 함께 INSERT, UPDATE, DELETE 명령으로 변경된 데이터를 가진 더티 페이지(Dirty Page)를 가지고 있다. 더티 페이지는 디스크와 메모리(버퍼 풀)의 데이터 상태가 다르기 때문에 언젠가는 디스크로 기록돼야 하는데 무한정 버퍼 풀에 머무를 수 있는 것도 아니다.

따라서 InnoDB 버퍼 풀의 더티 페이지는 특정 리두 로그 엔트와 관계를 가지고, 체크포인트 이벤트를 발생시켜 체크포인트 LSN(Log Sequence Number)보다 작은 리두 로그 엔트리와 관련된 더티 페이지는 모두 디스크로 동기화되어야 한다. 당연히 체크포인트 LSN보다 작은 LSN 값을 가진 리두 로그 엔트리도 디스크로 동기화되어야 한다.

 

버퍼 풀 플러시(Buffer Pool Flush)

InnoDB 스토리지 엔진은 버퍼 풀에서 아직 디스크로 기록되지 않은 더티 페이지들을 성능상의 악영향 없이 디스크에 동기화하기 위해 다음과 같이 2개의 플러시 기능을 백그라운드로 실행한다.

  • 플러시 리스트(Flush_list) 플러시
  • LRU 리스트(LRU_list) 플러시

 

플러시 리스트 플러시

InnoDB 스토리지 엔진은 리두 로그 공간의 재활용을 위해 주기적으로 오래된 리두 로그 엔트리가 사용하는 공간을 비워야 한다. 이때 오래된 리두 로그 공간이 지워지려면 반드시 InnoDB 버퍼 풀의 더티 페이지가 먼저 디스크로 동기화되어야 한다. 이를 위해 InnoDB 스토리지 엔진은 주기적으로 플러시 리스트(Flush_list) 플러시 함수를 호출해서 플러시 리스트에서 오래전에 변경된 데이터 페이지 순서대로 디스크에 동기화하는 작업을 수행한다. 이를 위해 InnoDB 스토리지 엔진은 다음과 같은 시스템 변수들을 제공한다.

  • innodb_page_cleaners: 더티 페이지를 디스크로 동기화하는 클리너 스레드의 개수를 조정
    • 보통 하나의 클리너 스레드가 하나의 버퍼 풀 인스턴스를 처리하도록 설정한다.
  • innodb_max_dirty_pages_pct: 전체 버퍼 풀 페이지 중 더티 페이지의 비율 조정
    • InnoDB 버퍼 풀은 더티 페이지를 많이 가지고 있을수록 디스크 쓰기 작업을 버퍼링함으로써 여러 번의 디스크 쓰기를 한 번으로 줄이는 효과를 극대화할 수 있다. 따라서 기본값을 유지하는 것이 좋다.
  • innodb_io_capacity: 설정된 값을 기준으로 더티 페이지 쓰기를 실행
    • 데이터베이스 서버에서 어느 정도의 디스크 읽고 쓰기가 가능한지를 설정하는 값으로 일반적인 상황에서 디스크가 적절히 처리할 수 있는 수준의 값을 처리한다.
    • 여기서 디스크 읽고 쓰기란 InnoDB 스토리지 엔진의 백그라운드 스레드가 수행하는 디스크 작업을 의미한다.
  • innodb_max_dirty_pages_pct_lwm: 일정 수준 이상의 더티 페이지가 발생하면 조금씩 더티 페이지를 디스크로 기록
    • 디스크로 기록되는 더티 페이지 개수보다 더 많은 더티 페이지가 발생하면 디스크 쓰기 폭발(Disk IO Bust) 현상이 발생할 가능성이 높기 때문에 이를 완화하기 위해 해당 시스템 변수를 사용하면 된다.
  • innodb_io_capacity_max: innodb_io_capacity와 비슷하지만 디스크가 최대의 성능을 발휘할 때 어느 정도의 디스크 읽고 쓰기가 가능한지를 설정한다.
  • innodb_adaptive_flushing: 어댑티브 플러시 기능을 켜고 끄는데 사용
    • 어댑티브 플러시 기능이 활성화되면 InnoDB 스토리지 엔진은 단순히 버퍼 풀의 더티 페이지 비율이나 innodb_io_capacity, innodb_io_capacity_max 설정값에 의존하지 않고 새로운 알고리즘을 사용하여 리두 로그의 증가 속도를 분석해 적절한 수준의 더티 페이지가 버퍼 풀에 유지될 수 있도록 디스크 쓰기를 실행한다.
  • innodb_adaptive_flushing_lwm: 기본값은 10%인데, 전체 리두 로그 공간에서 활성 리두 로그의 공간이 10% 미만이면 어댑티브 플러시가 작동하지 않다가 10%를 넘어서면 그때부터 어댑티브 플러시 알고리즘이 작동된다.
  • innodb_flush_neighbors: 더티 페이지를 디스크에 기록할 때 디스크에서 근접한 페이지 중에서 더티 페이지가 있다면 InnoDB 스토리지 엔진이 함께 묶어서 디스크로 기록하게 해주는 기능을 활성화할지 결정하는데 사용
    • 데이터 저장을 하드디스크로 하고 있다면 innodb_flush_neighbors 시스템 변수를 1 또는 2로 설정해서 활성화하는 것이 좋다.
    • 요즘은 대부분 솔리드 스테이트 드라이브(SSD)를 사용하기 때문에 기본값인 비활성 모드로 유지하는 것이 좋다.

 

LRU 리스트 플러시

InnoDB 스토리지 엔진은 LRU 리스트에서 사용 빈도가 낮은 데이터 페이지들을 제거해서 새로운 페이지들을 읽어올 공간을 만들어야 하는데, 이를 위해 LRU 리스트(LRU_list) 플러시 함수가 사용된다. LRU 리스트의 끝부분부터 시작해서 최대 innodb_lru_scan_depth 시스템 변수에 설정된 개수만큼의 페이지들을 스캔하면서 더티 페이지는 디스크에 동기화하고, 클린 페이지는 즉시 프리(Free) 리스트로 페이지를 옮긴다.

InnoDB 버퍼 풀 인스턴스별로 최대 innodb_lru_scan_depth 개수만큼 스캔하기 때문에 실질적으로 LRU 리스트 스캔은 (innodb_buffer_pool_instances * innodb_lru_scan_depth) 수만큼 수행한다.

 

버퍼 풀 상태 백업 및 복구

디스크의 데이터가 버퍼 풀에 적재돼 있는 상태를 워밍업(Warming Up)이라고 표현하는데, 버퍼 풀이 잘 워밍업된 상태에서는 그렇지 않은 경우보다 몇십 배의 쿼리 처리 속도를 보이는 것이 일반적이다. 즉, MySQL 서버를 셧다운했다가 다시 시작하고 서비스를 시작하면 쿼리 처리 성능이 평상시보다 1/10도 안 되는 경우가 대부분일 것이다.

 

-- // MySQL 서버 셧다운 전에 버퍼 풀의 상태 백업
mysql> SET GLOBAL  innodb_buffer_pool_dump_now=ON;

-- // MySQL 서버 재시작 후, 백업된 버퍼 풀의 상태 복구
mysql> SET GLOBAL innodb_buffer_pool_load_now=ON;

따라서 MySQL 5.6 버전부터는 버퍼 풀 덤프 및 적재 기능이 도입됐다. 서버 점검이나 기타 작업을 위해 MySQL 서버를 재시작해야 하는 경우 MySQL 서버를 셧다운하기 전에 다음과 같이 innodb_buffer_pool_dump_now 시스템 변수를 이용해 현재 InnoDB 버퍼 풀의 상태를 백업할 수 있다.  그리고 MySQL 서버를 다시 시작하면 innodb_buffer_pool_load_now 시스템 변수를 이요해 백업된 버퍼 풀의 상태를 복구할 수 있다.

또한 InnoDB 스토리지 엔진은 MySQL 서버가 셧다운되기 직전에 버퍼 풀의 백업을 실행하고, MySQL 서버가 시작되면 자동으로 백업된 버퍼 풀의 상태를 복구할 수 있는 기능을 제공한다. 버퍼 풀의 백업과 복구를 자동화하려면 innodb_buffer_pool_dump_at_shutdown과 innodb_buffer_pool_load_at_startup 설정을 MySQL 서버의 설정 파일에 넣어두면 된다.

 

Double Write Buffer

Double Write 작동 방식

InnoDB 스토리지 엔진의 리두 로그는 리두 로그 공간의 낭비를 막기 위해 페이지의 변경된 내용만 기록한다. 이로 인해 InnoDB의 스토리지 엔진에서 더티 페이지를 디스크 파일로 플러시할 때 하드웨어의 오작동이나 시스템의 비정상 종료 등으로 일부만 기록되는 문제가 발생하면 그 페이지의 내용은 복구할 수 없을 수도 있다.

이 같은 문제를 막기 위해 Double-Write 기법을 이용한다. 위 그림처럼 InnoDB에서 'A' ~ 'E'까지의 더티 페이지를 디스크로 플러시한다고 가정할 때 실제 데이터 파일에 변경 내용을 기록하기 전에 'A' ~ 'E'까지의 더티 페이지를 우선 묶어서 한 번의 디스크 쓰기로 시스템 테이블스페이스의 DoubleWrite 버퍼에 기록한다. 이 DoubleWrite 버퍼 공간에 기록된 변경 내용이 실제 데이터 파일에 정상적으로 기록되면 더 이상 필요가 없지만 도중에 운영체제가 비정상적으로 종료되어 재시작하면 항상 DoubleWrite 버퍼의 내용과 데이터 파일의 페이지들을 모두 비교해서 다른 내용을 담고 있는 페이지가 있으면 DoubleWrite 버퍼의 내용을 데이터 파일의 페이지로 복사한다. DoubleWrite 기능은 innodb_doublewrite 시스템 변수로 제어할 수 있다.

 

언두 로그

InnoDB 스토리지 엔진은 트랜잭션과 격리 수준을 보장하기 위해 DML(INSERT, UPDATE, DELETE)로 변경되기 이전 버전의 데이터를 별도로 백업한다. 이렇게 백업된 데이터를 언두 로그(Undo Log)라고 한다. 언두 로그는 다음과 같이 매우 중요한 역할을 담당한다.

  • 트랜잭션 보장: 트랜잭션이 롤백되면 트랜잭션 도중 변경된 데이터를 변경 전 데이터로 복구해야 하는데, 이때 언두 로그에 백업해둔 이전 버전의 데이터를 이용해 복구한다.
  • 격리 수준 보장: 특정 커넥션에서 데이터를 변경하는 도중에 다른 커넥션에서 데이터를 조회하면 트랜잭션 격리 수준에 맞게 변경중인 레코드를 읽지 않고 언두 로그에 백업해둔 데이터를 읽어서 반환하기도 한다.

언두 로그는 매우 중요한 역할을 하지만 관리 비용이 많이 든다. 언두 로그가 어떤 문제점을 가지고 있고, 이를 위해 InnoDB 스토리지 엔진이 어떤 기능을 제공하는지 살펴보자.

 

언두 로그 레코드 모니터링

대용량의 데이터를 처리하는 트랜잭션 뿐만 아니라 트랜잭션이 오랜 시간 동안 실행될 때도 언두 로그의 양은 급격히 증가할 수 있다. 트랜잭션들은 서로 시작과 종료 시점이 다르기 때문에 다른 트랜잭션들이 완료되었다고 하더라도 하나의 트랜잭션이 활성 상태인 이상 언두 로그는 삭제되지 않는다.

 

-- // MySQL 서버의 모든 버전에서 사용 가능한 명령
mysql > SHOW ENGINE INNODB STATUS \G

-- // MySQL 8.0 버전에서 사용 가능한 명령
mysql > SELECT count
              FROM information_schema.innodb_metrics
              WHERE SUBSYSTEM='transaction' AND NAME='trx_rseg_history_len';

계속해서 언두 로그 저장 공간이 증가하고 그 동안 빈번하게 변경된 레코드를 조회하는 쿼리가 실행되면 InnoDB 스토리지 엔진은 언두 로그의 이력을 필요한 만큼 스캔해야만 필요한 레코드를 찾을 수 있기 때문에 쿼리의 성능이 전반적으로 떨어지게 된다. 따라서 서비스 중인 MySQL 서버에서 활성 상태의 트랜잭션이 장시간 유지되는 것은 성능상 좋지 않다. MySQL 서버의 언두 로그 레코드가 얼마나 되는지는 항상 모니터링하는 것이 좋은데 다음 명령어를 통해 확인할 수 있다.

 

체인지 버퍼

RDBMS에서 레코드가 INSERT되거나 UPDATE될 때는 데이터 파일을 변경하는 작업뿐 아니라 해당 테이블에 포함된 인덱스를 업데이트하는 작업도 필요하다. 그런데 인덱스를 업데이트하는 작업은 랜덤하게 디스크를 읽는 작업이 필요하므로 로 테이블에 인덱스가 많다면 이 작업은 상당히 많은 자원을 소모하게 된다. 그래서 InnoDB는 변경해야 할 인덱스 페이지가 버퍼 풀에 있으면 바로 업데이트를 수행하지만 그렇지 않고 디스크로부터 읽어와서 업데이트해야 한다면 이를 즉시 실행하지 않고 임시 공간에 저장해 두고 바로 사용자에게 결과를 반환하는 형태로 성능을 향상시킨다. 이때 사용하는 임시 메모리 공간을 체인지 버퍼(Change Buffer)라고 한다.

사용자에게 결과를 전달하기 전에 반드시 중복 여부를 체크해야 하는 유니크 인덱스는 체인지 버퍼를 사용할 수 없다. 체인지 버퍼에 임시 저장된 인덱스 레코드 조각은 이후 백그라운드 스레드에 의해 병합되는데, 이를 체인지 버퍼 머지 스레드(Merge thread)라고 한다.

 

리두 로그 및 로그 버퍼

리두 로그(Redo Log)는 트랜잭션의 4가지 요소인 ACID 중에서 D(Durable)에 해당하는 영속성과 가장 밀접하게 연관돼 있다. 리두 로그는 하드웨어나 소프트웨어 등 여러 가지 문제점으로 인해 MySQL 서버가 비정상적으로 종료됐을 때 데이터 파일에 기록되지 못한 데이터를 잃지 않게 해주는 안전장치이다.

MySQL 서버를 포함한 대부분 데이터베이스 서버는 데이터 변경 내용을 로그로 먼저 기록하고, 쓰기 비용이 낮은 자료 구조를 가진 리두 로그를 가지고 비정상 종료가 발생하면 리두 로그의 내용을 이용해 데이터 파일을 다시 서버가 종료되기 직전의 상태로 복구한다.

데이터베이스 서버는 ACID도 중요하지만 성능도 중요하기 때문에 데이터 파일뿐만 아니라 리두 로그를 버퍼링할 수 있는 InnoDB 버퍼 풀이나 리두 로그를 버퍼링할 수 있는 로그 버퍼와 같은 자료 구조도 가지고 있다.

 

어댑티브 해시 인덱스

어댑티브 해시 인덱스(Adaptive Hash Index)는 사용자가 수동으로 생성하는 인덱스가 아닌 InnoDB 스토리지 엔진에서 사용자가 자주 요청하는 데이터에 대해 자동으로 생성하는 인덱스이며, 사용자는 innodb_adaptive_hash_index 시스템 변수를 이용해서 어댑티브 해시 인덱스 기능을 활성화하거나 비활성화할 수 있다.

이러한 어댑티브 해시 인덱스는 B-Tree 검색 시간을 줄여주기 위해 도입된 기능이다. 일반적으로 '인덱스'라고 하면 테이블에 사용자가 생성해둔 B-Tree 인덱스를 의미한다. B-Tree 인덱스에서 특정 값을 찾기 위해서는 B-Tree의 루트 노드를 거쳐서 브랜치 노드, 그리고 최종적으로 리프 노드까지 찾아가야 원하는 레코드를 읽을 수 있다. InnoDB 스토리지 엔진은 자주 읽히는 데이터 페이지의 키 값을 이용해 해시 인덱스를 만들고, 필요할 때마다 어댑티브 해시 인덱스를 검색해서 레코드가 저장된 데이터 페이지를 즉시 찾아갈 수 있다. B-Tree를 루트노드부터 리프 노드까지 찾아가는 비용이 없어지고 그만큼 CPU는 적은 일을 하지만 쿼리의 성능을 빨라진다. 그와 동시에 컴퓨너틑 더 많은 쿼리를 동시에 처리할 수 있게 된다.

 

InnoDB와 MyISAM, MEMORY 스토리지 엔진 비교

이미 MySQL 8.0에서는 MySQL 서버의 모든 기능이 InnoDB 스토리지 엔진 기반으로 재편됐고, 개선된 만큼 MyISAM 스토리지 엔진의 기능은 도태되는 상황이며, 이후 버전에서는 MyISAM 스토리지 엔진은 없어질 것으로 예상한다.

MEMORY 스토리지 엔진 또한 동시 처리 성능에 있어서 InnoDB 스토리지 엔진을 따라갈 수 없다. MEMORY 스토리지 엔진은 모든 처리를 메모리에서만 수행하니 빠를 것이라고 예상할 수 있겠지만 하나의 스레드에서만 데이터를 읽고 쓴다면 InnoDB보다 빠를 수 있다. 하지만 MySQL 서버는 일반적으로 온라인 트랜잭션 처리를 위한 목적으로 사용되며, 온라인 트랜잭션 처리에서는 동시 처리 성능이 매우 중요하다. 동시에 몇십 또는 몇백 개의 클라이언트에서 쿼리 요청이 실행되는 경우라면 MEMORY 스토리지 엔진은 테이블 수준의 잠금으로 인해 제대로 된 성능을 내지 못할 것이다. 따라서 MEMORY 스토리지 엔진 또한 향후 버전에서 제거될 것으로 보인다.

 

 

출처

Real MySQL 8.0 1권 : 개발자와 DBA를 위한 MySQL 실전 가이드

'교육 및 책 > Real MySQL' 카테고리의 다른 글

실행 계획  (0) 2023.11.30
옵티마이저와 힌트 (2/2)  (1) 2023.11.13
옵티마이저와 힌트 (1/2)  (0) 2023.11.10
인덱스  (1) 2023.11.06
MySQL 서버 설정과 사용자 및 권한  (1) 2023.10.21