OSTEP 18장 Paging: Introduction과 19장 Paging: Faster Translations (TLBs)를 읽으면서 가장 오래 남은 내용은 운영체제가 메모리를 고정 크기의 페이지로 나누는 이유였습니다. 세그멘테이션처럼 크기가 제각각인 덩어리를 할당하면 외부 단편화가 생깁니다. 페이징은 같은 크기의 페이지와 프레임을 써서 물리 메모리의 떨어진 공간에도 데이터를 나눠 둘 수 있게 합니다.
MySQL의 InnoDB도 데이터를 페이지 단위로 관리합니다. 그래서 다음 질문이 생겼습니다.
InnoDB도 운영체제와 같은 이유로 데이터를 페이지 단위로 관리할까요?
처음에는 두 페이지가 같은 이유로 쓰일 것이라고 생각했습니다. 그런데 InnoDB의 페이지 구조와 buffer pool을 따라가 보니 두 시스템이 페이지로 해결하려는 문제는 서로 달랐습니다. 운영체제의 페이지는 메모리 가상화를 위한 단위이고 InnoDB의 페이지는 데이터를 저장하고 디스크와 주고받는 단위입니다. 고정 크기 단위로 나눠 할당을 단순하게 만든다는 점은 같습니다.
이 글에서는 18장의 페이징을 먼저 정리하고 InnoDB가 row 대신 페이지로 데이터를 관리하는 이유를 페이지 내부 구조, buffer pool, 쓰기 과정, 페이지 크기 순서로 정리합니다. OSTEP과 MySQL 8.4 공식 문서를 읽고 정리한 내용이며 직접 실행한 실험이나 측정 결과는 포함하지 않았습니다.
1. 운영체제는 왜 메모리를 고정 크기로 나눌까요?
세그멘테이션에서는 어떤 문제가 생길까요?
세그멘테이션은 프로세스의 주소 공간을 code, heap, stack 같은 논리적인 영역으로 나눠 관리합니다. 세그먼트마다 크기가 다를 수 있고 하드웨어는 세그먼트마다 시작 위치(base)와 크기(bounds)를 레지스터에 담아 주소를 변환합니다. 운영체제는 프로세스를 바꿀 때 이 값을 저장하고 복원합니다.
프로세스의 논리적 구조를 그대로 표현할 수 있지만 메모리를 가변 크기로 할당하기 때문에 외부 단편화(external fragmentation)가 생길 수 있습니다. 물리 메모리에 다음처럼 빈 공간이 흩어져 있다고 가정하겠습니다.
빈 공간은 모두 12KB입니다. 여기에 10KB 세그먼트를 할당하려면 연속된 10KB 공간이 필요한데, 가장 큰 연속 공간은 4KB입니다. 여유 공간의 합은 충분해도 연속된 공간이 부족해서 할당하지 못합니다. 이것이 외부 단편화입니다.
페이징은 이 문제를 어떻게 해결할까요?
페이징(paging)은 가상 주소 공간을 고정 크기의 페이지(page)로, 물리 메모리를 같은 크기의 프레임(frame)으로 나눕니다. 페이지 크기가 4KB라면 10KB 데이터는 페이지 3개에 나눠 담깁니다.
세 페이지는 물리 메모리에서 서로 떨어진 프레임에 있어도 됩니다. 앞의 예에서 흩어져 있던 4KB 빈칸 세 개에 페이지를 하나씩 넣으면, 10KB 세그먼트를 할당하지 못했던 메모리에 같은 데이터를 담을 수 있습니다. 운영체제는 페이지 테이블(page table)로 가상 페이지 번호(VPN)를 물리 프레임 번호(PFN)로 바꿉니다. 페이지 테이블은 VPN마다 항목(PTE, page table entry)을 하나씩 두고 그 안에 PFN을 적어 둡니다. 가상 주소는 VPN과 offset으로 나뉩니다. 변환할 때 VPN만 PFN으로 바뀌고 offset은 그대로 남습니다.
물리 메모리에서 연속된 공간을 찾지 않아도 되고 할당 단위가 모두 같은 크기라서 크기가 제각각인 빈 공간을 관리하는 복잡함도 줄어듭니다.
비용도 있습니다. 18장이 짚는 비용은 두 가지입니다. 페이지 테이블 자체가 메모리를 차지하고 주소를 변환할 때마다 페이지 테이블을 읽는 메모리 접근이 한 번 더 필요합니다. 19장의 TLB는 이 변환 비용을 줄이기 위한 캐시입니다. 위 예의 Page 2처럼 4KB 중 2KB만 쓰고 나머지를 비워 두는 낭비도 생깁니다. 이것을 내부 단편화(internal fragmentation)라고 합니다. 20장에서 페이지 크기를 고를 때 다시 나옵니다.
정리하면 고정 크기 단위로 메모리를 관리하면 외부 단편화를 피하는 대신 페이지 테이블의 메모리와 변환 비용이 생깁니다. 이제 같은 질문을 InnoDB에 해 보겠습니다.
2. InnoDB에서 row는 어디에 저장될까요?
InnoDB에서 데이터와 인덱스를 저장하는 기본 단위는 페이지입니다. 기본 페이지 크기는 16KB이고 innodb_page_size로 다른 크기를 쓸 수 있습니다. 참고: The Physical Structure of an InnoDB Index.
테이블의 row는 클러스터형 인덱스(clustered index)의 리프 페이지에 저장됩니다. 다음 테이블을 예로 들겠습니다.
CREATE TABLE users (
id BIGINT PRIMARY KEY,
name VARCHAR(100)
);
InnoDB는 기본 키 id로 클러스터형 인덱스를 만듭니다. 기본 키가 없으면 NOT NULL인 첫 번째 UNIQUE 인덱스를 쓰고 그것도 없으면 내부에서 만든 row ID로 숨은 인덱스를 만듭니다. 참고: Clustered and Secondary Indexes.
SELECT * FROM users WHERE id = 100;을 실행하면 InnoDB는 루트 페이지에서 시작해 B+Tree를 내려가 id = 100인 row가 들어 있는 리프 페이지를 찾습니다.
3. row 하나가 필요한데 왜 페이지 단위로 읽을까요?
여러 row를 한 번에 읽어 I/O 횟수를 줄입니다
row마다 디스크 I/O를 따로 한다면 id 100부터 150까지 row 51개를 읽을 때 I/O 요청도 51번 생길 수 있습니다. 한 페이지에 여러 row가 들어 있으면 페이지 하나를 읽어 그 row들에 모두 접근할 수 있습니다.
19장에서 TLB가 효과를 내는 이유로 든 공간 지역성(spatial locality)이 여기에도 적용됩니다. 어떤 데이터에 접근한 프로그램은 그 주변 데이터에도 곧 접근할 가능성이 큽니다. 다음 범위 조회가 그런 경우입니다.
SELECT *
FROM users
WHERE id BETWEEN 100 AND 150;
클러스터형 인덱스의 리프 페이지에서는 row가 기본 키 순서로 정렬되어 있습니다. 대상 row가 같은 리프 페이지에 모여 있으면 페이지 한 번을 읽어 여러 row를 처리합니다.
범위에 해당하는 row가 모두 한 페이지에 들어 있다는 보장은 없습니다. row 크기, 페이지 분할(가득 찬 페이지에 row를 넣을 때 페이지를 둘로 나누는 일), 삭제된 레코드에 따라 여러 페이지에 나뉘어 있을 수 있습니다. 모든 페이지는 맨 앞의 FIL header에 같은 레벨의 이전 페이지와 다음 페이지 번호를 적어 두므로, 범위 조회는 다음 리프 페이지로 이어서 읽습니다. 참고: The physical structure of InnoDB index pages.
고정 크기 단위로 저장 공간을 관리합니다
18장과 이어지는 부분은 여기입니다. 운영체제는 고정 크기의 페이지와 프레임으로 메모리 할당을 단순하게 만들었습니다. InnoDB도 고정 크기의 페이지로 저장 공간을 나눕니다.
row 단위로만 공간을 관리한다고 가정하면 다음처럼 크기가 제각각인 공간을 다뤄야 합니다.
tablespace는 InnoDB가 데이터를 담는 저장 공간이고 기본 설정에서는 테이블마다 .ibd 파일 하나입니다. 페이지 단위로 관리하면 tablespace는 같은 크기의 페이지가 이어진 구조가 되고 InnoDB는 이 단위로 공간을 할당합니다.
InnoDB는 tablespace ID와 page number로 페이지를 식별합니다. tablespace에서는 같은 크기의 페이지를 할당하고 반납하면 되고 크기가 제각각인 row의 자리는 페이지 안에서만 다룹니다. 18장의 운영체제가 프레임 단위로 메모리를 할당해 크기가 제각각인 빈칸을 관리하지 않아도 됐던 것과 같은 이점입니다.
다만 이점이 같다고 목적까지 같지는 않습니다. 운영체제의 페이지는 가상 주소를 물리 주소로 변환하는 단위이고 InnoDB의 페이지는 데이터베이스 안에서 데이터를 저장하고 캐시하고 디스크와 주고받는 단위입니다.
4. 페이지 안에서는 row를 어떻게 찾을까요?
페이지가 row를 여러 개 담고 있다면, 원하는 row는 어떻게 찾을까요? InnoDB의 인덱스 페이지에서 주요 영역만 그리면 다음과 같습니다.
| 구성 요소 | 역할 |
|---|---|
| FIL Header | 모든 페이지에 붙는 공통 header. 페이지 번호, 같은 레벨의 이전 페이지와 다음 페이지 번호 |
| Page Header | 레코드 수, 빈 공간 위치 같은 인덱스 페이지의 상태 |
| Infimum / Supremum | 페이지의 모든 키보다 작은 가상 레코드와 모든 키보다 큰 가상 레코드. 레코드 목록의 시작과 끝 |
| User Records | 실제 레코드 |
| Free Space | 새 레코드를 넣을 수 있는 공간 |
| Page Directory | 레코드 몇 개마다 하나씩 위치를 적어 둔 slot 배열 |
| FIL Trailer | 페이지가 온전히 기록됐는지 확인하는 checksum 정보 |
페이지 안의 레코드는 infimum에서 시작해 키 순서대로 다음 레코드를 가리키는 단일 연결 리스트로 이어져 있고 supremum에서 끝납니다. page directory에는 레코드 4개에서 8개마다 하나씩 slot이 있습니다. InnoDB는 slot을 이진 탐색해 범위를 좁힌 뒤, 그 slot에 속한 레코드 몇 개만 연결 리스트를 따라 읽습니다. 16KB 페이지를 읽었다고 페이지의 row를 처음부터 끝까지 훑지는 않습니다. 참고: Efficiently traversing InnoDB B+Trees with the page directory.
그래서 row 하나를 찾는 과정은 두 단계로 나뉩니다.
- B+Tree를 내려가 원하는 row가 들어 있는 리프 페이지를 찾습니다.
- 리프 페이지 안에서 page directory로 원하는 row를 찾습니다.
운영체제는 VPN을 인덱스로 써서 페이지 테이블의 PTE를 바로 읽고 프레임을 찾은 뒤에는 offset으로 위치를 바로 계산합니다. InnoDB는 페이지를 찾은 뒤에도 키로 레코드를 찾아야 해서 page directory라는 탐색 구조를 페이지 안에 따로 둡니다.
5. buffer pool은 TLB와 같은 캐시일까요?
페이지 단위로 읽어도 매번 디스크에서 읽는다면 I/O 비용은 여전히 큽니다. InnoDB는 읽어 온 데이터 페이지와 인덱스 페이지를 메모리의 buffer pool에 캐시합니다. 참고: Buffer Pool.
SELECT * FROM users WHERE id = 100;을 실행하면 InnoDB는 필요한 페이지가 buffer pool에 있는지 먼저 확인합니다.
cache miss라면 디스크에서 페이지를 읽어 buffer pool에 넣고 같은 페이지에 다시 접근할 때 페이지가 남아 있으면 cache hit으로 디스크를 읽지 않습니다. 실제 B+Tree 탐색에서는 루트, 내부, 리프 페이지마다 이 과정을 거칩니다. 위 그림은 페이지 하나를 읽는 과정만 단순하게 그린 것입니다.
19장의 TLB도 느린 접근을 빠른 계층에 캐시해서 줄입니다. 그런데 두 캐시가 저장하는 것은 다릅니다.
| 구분 | TLB | InnoDB buffer pool |
|---|---|---|
| 캐시 대상 | 가상 주소에서 물리 주소로의 변환 정보 | 데이터 페이지와 인덱스 페이지 |
| 위치 | CPU 안의 하드웨어 캐시 | mysqld 프로세스가 쓰는 메모리 |
| hit일 때 줄어드는 비용 | 페이지 테이블 조회 | 해당 페이지의 디스크 읽기 |
| 관리 주체 | CPU 하드웨어와 OS | InnoDB 스토리지 엔진 |
TLB에는 주소 변환 결과만 들어 있습니다. 파일 내용을 메모리에 두고 디스크 읽기를 줄인다는 점에서 buffer pool과 같은 일을 하는 운영체제 구성 요소는 page cache입니다. 공통점은 느린 계층에 반복해서 접근하는 비용을 빠른 계층에 캐시해 줄인다는 것까지입니다.
6. buffer pool이 가득 차면 어떤 페이지를 내보낼까요?
buffer pool의 크기는 정해져 있습니다. 새 페이지를 읽어야 하는데 여유 공간이 없으면 기존 페이지 중 하나를 내보내야 합니다. 19장에서 TLB가 가득 찼을 때 어떤 항목을 교체할지 정하는 replacement policy와 같은 문제입니다.
단순한 LRU는 전체 스캔에 약합니다
LRU(Least Recently Used)는 가장 오래 쓰지 않은 항목을 내보냅니다. 19장은 LRU의 약점도 함께 보여 줍니다. 항목 n개를 담는 TLB에서 프로그램이 페이지 n+1개를 반복해서 순서대로 읽으면, LRU는 다음에 쓸 항목을 계속 내보내서 매번 miss가 납니다.
LRU는 최근에 쓴 항목을 곧 다시 쓴다고 가정하는데, 반복 예에서는 이 가정이 맞지 않아 다음에 쓸 항목이 밀려납니다. 데이터베이스에서는 전체 스캔이 같은 가정을 깨뜨립니다. 이번에는 다시 쓰지 않을 페이지가 자주 쓰던 페이지를 밀어냅니다. buffer pool에 자주 조회하는 페이지 A, B, C, D가 있다고 가정하겠습니다.
여기서 큰 테이블을 전체 스캔하면 새 페이지가 계속 buffer pool에 들어옵니다.
SELECT *
FROM very_large_table;
단순한 LRU에서는 방금 읽은 스캔 페이지가 가장 최근에 쓴 항목이 되어 A, B, C, D를 밀어냅니다. 스캔으로 읽은 페이지는 다시 쓰이지 않을 수 있는데도, 자주 쓰던 페이지가 먼저 빠집니다. 한 번 읽고 마는 대량 조회가 캐시를 오염시킵니다.
InnoDB는 새 페이지를 LRU 목록 중간에 넣습니다
InnoDB는 LRU 목록을 new sublist와 old sublist로 나누고 새로 읽은 페이지를 두 sublist의 경계에 넣습니다. MySQL 문서는 이 방식을 midpoint insertion이라고 부릅니다.
기본값에서 새로 읽은 페이지는 LRU 쪽 끝(가장 오래 쓰지 않은 쪽)에서 3/8 지점, 즉 old sublist의 시작 위치에 들어갑니다. old sublist가 차지하는 비율은 innodb_old_blocks_pct로 정하고 기본값 37(%)이 약 3/8입니다. old sublist에 있는 페이지에 접근하면 그 페이지는 young이 되어 new sublist 맨 앞으로 옮겨집니다. 사용자 쿼리가 읽은 페이지는 읽자마자 첫 접근이 일어나는데, 이 첫 접근을 바로 승격으로 칠지는 innodb_old_blocks_time이 정합니다.
전체 스캔이 자주 쓰는 페이지를 밀어낼까요?
buffer pool 8칸에 자주 조회하는 페이지 A, B, C, D가 있습니다. 교체 정책을 고르고 페이지 8개를 읽는 전체 스캔을 실행해 보세요. 스캔이 끝난 뒤 A를 다시 조회하면 cache hit일까요?
buffer pool을 8칸으로 줄인 모형입니다. 실제 buffer pool은 훨씬 많은 페이지를 담고, old sublist의 비율은 innodb_old_blocks_pct(기본값 37, 약 3/8)로 정합니다. 모형에서는 목록의 뒤 3칸을 old sublist로 그렸습니다. 스캔은 한 페이지의 row를 1초 안에 모두 읽는다고 가정했습니다. E부터 H는 예전에 읽어 둔 다른 페이지이고, 스캔 도중 다른 쿼리가 A, B, C, D를 읽는 경우와 곧 읽을 페이지를 미리 읽어 두는 read-ahead는 그리지 않았습니다.
innodb_old_blocks_time이 0이면 스캔 페이지도 첫 접근에 바로 new sublist 맨 앞으로 갑니다. 중간에 넣은 효과가 사라지고 단순한 LRU처럼 A, B, C, D가 밀려납니다. 기본값 1000ms에서는 첫 접근 뒤 1초 안에 다시 접근해도 옮기지 않고 첫 접근에서 1초 이상 지난 뒤에 다시 접근해야 new sublist로 옮깁니다. 스캔 페이지는 짧은 시간에 몇 번 읽히고 다시 쓰이지 않으므로 old sublist에 머물다가 먼저 빠집니다. 참고: Making the Buffer Pool Scan Resistant, innodb_old_blocks_time.
OSTEP에서 배운 교체 정책이 데이터베이스에서는 전체 스캔이라는 워크로드에 맞게 바뀌어 쓰입니다.
7. 쓰기에서는 페이지가 어떤 역할을 할까요?
지금까지는 SELECT만 다뤘습니다. 데이터를 수정할 때도 InnoDB는 페이지 단위로 작업합니다.
UPDATE users
SET name = 'Kim'
WHERE id = 100;
InnoDB는 이 row가 들어 있는 페이지를 buffer pool에서 수정합니다. 메모리의 페이지 내용이 디스크의 페이지 내용과 달라지면 그 페이지를 dirty page라고 부릅니다.
수정한 페이지를 바로 데이터 파일에 쓰지는 않습니다. InnoDB는 변경이 일어날 때마다 그 내용을 redo log에 덧붙여 기록하고 dirty page는 나중에 조금씩 데이터 파일에 씁니다. 이렇게 dirty page를 디스크에 쓰는 일을 flush라고 합니다. checkpoint는 이 지점 이전의 변경은 모두 데이터 파일에 반영됐다고 redo log에 남기는 표시입니다. 장애가 나면 InnoDB는 마지막 checkpoint부터 redo log를 읽어 데이터 파일에 아직 반영하지 못한 변경을 다시 적용합니다. 참고: Redo Log, InnoDB Checkpoints.
페이지를 쓰는 도중에 장애가 나면 16KB 중 일부만 기록된 torn page가 남을 수 있습니다. 저장 장치가 16KB 페이지 하나를 한 번에 쓴다는 보장이 없기 때문입니다. InnoDB는 flush하는(디스크에 쓰는) 페이지를 doublewrite buffer에 먼저 쓰고 나서 데이터 파일의 제자리에 씁니다. 복구할 때 데이터 파일의 페이지가 깨져 있으면 doublewrite buffer의 사본을 씁니다. 참고: Doublewrite Buffer.
페이지는 읽기 단위이면서 메모리와 디스크 사이에서 변경을 추적하고 기록하고 복구하는 단위이기도 합니다.
8. 페이지는 클수록 좋을까요?
페이지가 크면 한 번의 I/O로 더 많은 row를 읽습니다. 그렇다고 페이지가 클수록 좋은 것은 아닙니다.
100바이트 row 하나를 읽을 때 4KB 페이지는 16KB 페이지보다 함께 읽는 불필요한 데이터가 적습니다. 반대로 넓은 범위를 읽을 때는 큰 페이지가 접근할 페이지 수를 줄입니다. 나머지 차이는 표로 정리했습니다.
| 구분 | 작은 페이지 | 큰 페이지 |
|---|---|---|
| row 하나를 읽을 때 함께 읽는 데이터 | 적음 | 많음 |
| 한 페이지에 담는 row | 적음 | 많음 |
| 넓은 범위 조회 | 더 많은 페이지에 접근 | 접근할 페이지 수가 줄어듦 |
| buffer pool 공간 | 작은 단위로 나눠 씀 | 한 페이지가 큰 공간을 차지 |
OSTEP 20장 Paging: Smaller Tables는 페이지를 키우면 페이지 테이블은 작아지지만 페이지 안에서 쓰지 않고 남는 공간, 즉 내부 단편화가 커진다고 설명합니다. 그래서 대부분의 시스템이 4KB나 8KB 같은 작은 페이지를 씁니다.
InnoDB의 페이지 안에도 빈 공간이 남는데, 성격이 두 가지입니다. 하나는 일부러 남기는 공간입니다. InnoDB는 클러스터형 인덱스에 레코드를 넣을 때 이후의 삽입과 수정에 쓰도록 페이지의 1/16을 비워 둡니다. 다른 하나는 삽입 순서 때문에 남는 공간입니다. 키 순서대로 넣으면 페이지는 15/16 정도 차지만 무작위 순서로 넣으면 1/2에서 15/16 사이로 찹니다.
16KB라는 기본값도 “디스크는 16KB 단위로 읽을 때 가장 빠르다”는 식으로 설명할 수 없습니다. 16KB는 InnoDB가 정한 기본값이고 알맞은 페이지 크기는 row 크기, 접근 패턴, 저장 장치에 따라 달라집니다. InnoDB는 4KB, 8KB, 16KB, 32KB, 64KB를 지원합니다. 페이지 크기는 MySQL 인스턴스를 초기화할 때 정하고 이후에는 바꿀 수 없습니다.
9. 처음 질문으로 돌아가면
InnoDB도 운영체제와 같은 이유로 데이터를 페이지 단위로 관리할까요?
고정 크기 단위가 할당을 단순하게 만든다는 이점은 같지만 페이지를 쓰는 주된 목적은 다릅니다. 두 시스템이 페이지로 해결하는 문제를 나란히 놓으면 다음과 같습니다.
| 항목 | OS 페이징 | InnoDB 페이지 |
|---|---|---|
| 관리 대상 | 프로세스의 가상 메모리 | 데이터베이스의 데이터와 인덱스 |
| 페이지 크기 | 보통 4KB | 기본 16KB |
| 페이지를 쓰는 목적 | 메모리 가상화와 물리 메모리 할당 | 저장 공간, 디스크 I/O, 캐시 관리 |
| 주요 자료구조 | 페이지 테이블 | B+Tree, tablespace |
| 관련 캐시 | TLB (주소 변환 결과) | buffer pool (페이지 자체) |
| 관련 캐시가 줄이는 비용 | 주소 변환 비용 | 디스크 I/O 비용 |
| 고정 크기의 비용 | 내부 단편화, 페이지 테이블 메모리 | 함께 읽는 불필요한 데이터, 페이지 안의 빈 공간, 페이지 교체 |
운영체제의 페이지 테이블은 가상 주소와 물리 주소의 매핑을 관리합니다. InnoDB의 B+Tree는 키 순서로 데이터를 찾는 구조이고 buffer pool은 자주 쓰는 페이지를 메모리에 둡니다. B+Tree를 페이지 테이블과, buffer pool을 TLB와 같은 구조로 이해하면 각각이 무엇을 저장하는지 놓치게 됩니다.
공통점은 관리 대상을 고정 크기 단위로 나누고 자주 쓰는 정보를 더 빠른 계층에 둔다는 설계입니다. 고정 크기 단위는 할당을 단순하게 만들고 캐시와 I/O를 같은 단위로 다룰 수 있게 합니다. 대신 비용도 남습니다. 운영체제는 페이지 안의 낭비와 페이지 테이블의 메모리를 감수하고 InnoDB는 함께 읽는 불필요한 데이터와 페이지 안의 빈 공간, buffer pool의 페이지 교체를 감수합니다.
18장에서 외부 단편화를 피하려고 고정 크기를 택한 이유를 이해하고 나니, InnoDB의 페이지 크기도 한 번에 읽는 데이터의 양과 함께 읽는 불필요한 데이터 사이에서 고른 값이라는 점이 눈에 들어왔습니다. 아직 정리하지 못한 질문도 있습니다.
- buffer pool과 운영체제의 page cache가 같은 페이지를 두 번 캐시하지 않을까요? MySQL 8.4는 Unix 계열에서
innodb_flush_method기본값이 O_DIRECT라서 데이터 파일을 page cache를 거치지 않게 엽니다. O_DIRECT를 쓰지 않는 설정에서는 이중 캐시가 성능에 얼마나 영향을 줄까요? - 페이지 크기를 바꾸면 B+Tree의 높이와 buffer pool hit rate는 실제로 얼마나 달라질까요?
학습 자료: Remzi H. Arpaci-Dusseau, Andrea C. Arpaci-Dusseau, Operating Systems: Three Easy Pieces, Chapter 18 Paging: Introduction, Chapter 19 Paging: Faster Translations (TLBs), Chapter 20 Paging: Smaller Tables. MySQL 8.4 Reference Manual, The Physical Structure of an InnoDB Index, Clustered and Secondary Indexes, Buffer Pool, Making the Buffer Pool Scan Resistant, Redo Log, InnoDB Checkpoints, Doublewrite Buffer, InnoDB Startup Options and System Variables. Jeremy Cole, The physical structure of InnoDB index pages, Efficiently traversing InnoDB B+Trees with the page directory.