메모리 매핑 I/O

메모리 매핑 I/O (Memory-Mapped I/O)

SQLite가 데이터베이스 디스크 파일을 읽고 갱신하는 기본 메커니즘은 VFS 객체의 xRead()·xWrite() 메서드예요. 이 글은 대안인 메모리 매핑 I/O의 장단점과 설정 방법을 설명해요.

출처: Memory-Mapped I/O 문서

본문

SQLite가 데이터베이스 디스크 파일을 접근하고 갱신하는 기본 메커니즘은 sqlite3_io_methods VFS 객체의 xRead()와 xWrite() 메서드예요. 이 메서드들은 보통 운영체제가 커널 버퍼 캐시와 사용자 공간 사이에서 디스크 콘텐츠를 복사하게 하는 "read()"와 "write()" 시스템 호출로 구현돼요.

버전 3.7.17(2013-05-20)부터 SQLite는 메모리 매핑 I/O와 sqlite3_io_methods의 새로운 xFetch()·xUnfetch() 메서드를 사용해 디스크 콘텐츠에 직접 접근하는 옵션을 제공해요.

메모리 매핑 I/O 사용에는 장단점이 있어요. 장점은 다음과 같아요.

  1. 콘텐츠를 커널 공간과 사용자 공간 사이에서 복사할 필요가 없으므로, 특히 I/O 집약적인 작업에서 많은 연산이 더 빨라질 수 있어요.

  2. SQLite 라이브러리는 운영체제 페이지 캐시와 페이지를 공유하고 항상 자체 복사본을 가질 필요가 없으므로 더 적은 RAM이 필요할 수 있어요.

하지만 단점도 있어요.

  1. 메모리 매핑 파일에서 발생한 I/O 오류는 SQLite가 잡아서 처리할 수 없어요. 대신 I/O 오류가 시그널을 발생시키고, 애플리케이션이 그 시그널을 잡지 않으면 프로그램 크래시로 이어져요.

  2. 메모리 매핑 I/O 확장이 올바르게 동작하려면 운영체제가 통합 버퍼 캐시(unified buffer cache)를 가져야 해요. 특히 두 프로세스가 같은 데이터베이스 파일에 접근할 때 한 쪽은 메모리 매핑 I/O를 사용하고 다른 쪽은 사용하지 않는 경우가 그래요. 모든 운영체제가 통합 버퍼 캐시를 가지는 것은 아니에요. 통합 버퍼 캐시가 있다고 주장하는 일부 운영체제에서는 구현에 버그가 있어 데이터베이스 손상을 초래할 수 있어요.

  3. 성능이 항상 메모리 매핑 I/O로 증가하는 것은 아니에요. 실제로 메모리 매핑 I/O 사용으로 성능이 저하되는 테스트 케이스를 구성하는 것도 가능해요.

  4. Windows는 메모리 매핑 파일을 잘라낼(truncate) 수 없어요. 따라서 Windows에서 VACUUM이나 auto_vacuum 같은 연산이 메모리 매핑 데이터베이스 파일의 크기를 줄이려 하면, 그 크기 축소 시도는 조용히 실패해 데이터베이스 파일 끝에 사용하지 않는 공간이 남아요. 이 문제 때문에 데이터가 손실되지는 않으며, 그 빈 공간은 데이터베이스가 다시 커질 때 재사용돼요. 하지만 3.7.0 이전 SQLite 버전이 그러한 데이터베이스에서 PRAGMA integrity_check를 실행하면, 끝의 빈 공간 때문에 (잘못되게) 데이터베이스 손상을 보고해요. 혹은 3.7.0 이전 SQLite 버전이 끝에 빈 공간이 있는 동안 데이터베이스에 쓰면, 그 빈 공간을 다음 VACUUM 전까지 접근 불가능하고 재사용 불가능하게 만들 수 있어요.

이런 잠재적 단점 때문에 메모리 매핑 I/O는 기본적으로 비활성화돼 있어요. 메모리 매핑 I/O를 활성화하려면 mmap_size pragma를 사용해 mmap_size를 큰 값(보통 256MB 이상, 애플리케이션이 희생할 수 있는 주소 공간에 따라)으로 설정해요. 나머지는 자동이에요. PRAGMA mmap_size 문은 메모리 매핑 I/O를 지원하지 않는 시스템에서는 조용한 no-op이 돼요.

메모리 매핑 I/O가 동작하는 방식

기존 xRead() 메서드로 데이터베이스 페이지 콘텐츠를 읽으려면, SQLite는 먼저 페이지 크기의 힙 메모리 청크를 할당한 다음 xRead() 메서드를 호출해 데이터베이스 페이지 콘텐츠를 새로 할당된 힙 메모리로 복사해요. 이 과정에는 (최소한) 페이지 전체의 복사가 포함돼요.

하지만 SQLite가 데이터베이스 파일의 페이지에 접근하려고 할 때 메모리 매핑 I/O가 활성화되어 있으면, 먼저 xFetch() 메서드를 호출해요. xFetch() 메서드는 가능하면 요청된 페이지에 대한 포인터를 운영체제에 요청해요. 요청된 페이지가 애플리케이션 주소 공간에 매핑되었거나 매핑될 수 있다면, xFetch는 복사 없이 사용할 수 있도록 그 페이지에 대한 포인터를 반환해요. 복사 단계를 건너뛰는 것이 메모리 매핑 I/O를 더 빠르게 만드는 이유예요.

SQLite는 xFetch() 메서드가 항상 동작한다고 가정하지 않아요. xFetch() 호출이 NULL 포인터(요청된 페이지가 현재 애플리케이션 주소 공간에 매핑되지 않았음을 나타냄)를 반환하면 SQLite는 조용히 xRead() 사용으로 대체해요. 오류는 xRead()도 실패할 때만 보고돼요.

데이터베이스 파일을 갱신할 때 SQLite는 페이지를 수정하기 전에 항상 페이지 콘텐츠를 힙 메모리에 복사해요. 이는 두 가지 이유로 필요해요. 첫째, 데이터베이스 변경 사항은 트랜잭션이 커밋될 때까지 다른 프로세스에 보이지 않아야 하므로 변경은 사설(private) 메모리에서 발생해야 해요. 둘째, SQLite는 읽기 전용 메모리 맵을 사용해 애플리케이션의 떠돌이 포인터가 데이터베이스 파일을 덮어쓰고 손상시키는 것을 방지해요.

필요한 모든 변경이 완료된 후 xWrite()를 사용해 콘텐츠를 데이터베이스 파일로 되돌려 보내요. 따라서 메모리 매핑 I/O 사용은 데이터베이스 변경 성능을 크게 바꾸지 않아요. 메모리 매핑 I/O는 대부분 질의에 이점이 돼요.

메모리 매핑 I/O 설정

"mmap_size"는 SQLite가 한 번에 프로세스 주소 공간에 매핑하려고 시도하는 데이터베이스 파일의 최대 바이트 수예요. mmap_size는 각 데이터베이스 파일에 별도로 적용되므로, 잠재적으로 사용될 수 있는 총 프로세스 주소 공간은 mmap_size 곱하기 열린 데이터베이스 파일 수예요.

메모리 매핑 I/O를 활성화하려면 애플리케이션은 mmap_size를 큰 값으로 설정할 수 있어요. 예를 들어:

PRAGMA mmap_size=268435456;

메모리 매핑 I/O를 비활성화하려면 mmap_size를 0으로 설정하면 돼요.

PRAGMA mmap_size=0;

mmap_size가 N으로 설정되면 모든 현재 구현은 데이터베이스 파일의 처음 N바이트를 매핑하고 N바이트를 넘는 콘텐츠는 기존 xRead() 호출을 사용해요. 데이터베이스 파일이 N바이트보다 작으면 전체 파일이 매핑돼요. 이론적으로 미래의 새 OS 인터페이스는 처음 N바이트가 아닌 파일의 다른 영역을 매핑할 수도 있지만, 현재 그런 구현은 존재하지 않아요.

mmap_size는 "PRAGMA mmap_size" 문을 사용해 각 데이터베이스 파일에 대해 별도로 설정돼요. 보통 기본 mmap_size는 0이며, 이는 메모리 매핑 I/O가 기본적으로 비활성화된다는 뜻이에요. 하지만 기본 mmap_size는 컴파일 시 SQLITE_DEFAULT_MMAP_SIZE 매크로로, 또는 시작 시 sqlite3_config(SQLITE_CONFIG_MMAP_SIZE,...) 인터페이스로 증가시킬 수 있어요.

SQLite는 또한 mmap_size에 하드 상한을 유지해요. (PRAGMA mmap_size로) mmap_size를 이 하드 상한 위로 증가시키려는 시도는 자동으로 mmap_size를 하드 상한에서 제한해요. 하드 상한이 0이면 메모리 매핑 I/O는 불가능해요. 하드 상한은 컴파일 시 SQLITE_MAX_MMAP_SIZE 매크로로 설정할 수 있어요. SQLITE_MAX_MMAP_SIZE가 0으로 설정되면 메모리 매핑 I/O를 구현하는 코드는 빌드에서 제외돼요. 하드 상한은 통합 버퍼 캐시가 없어 메모리 매핑 I/O가 동작하지 않는 특정 플랫폼(예: OpenBSD)에서 자동으로 0으로 설정돼요.

컴파일 시 mmap_size의 하드 상한이 0이 아니라면, 시작 시 sqlite3_config(SQLITE_CONFIG_MMAP_SIZE,X,Y) 인터페이스로 줄이거나 0으로 만들 수 있어요. X와 Y 매개변수는 모두 64비트 부호 정수여야 해요. X 매개변수는 프로세스의 기본 mmap_size이고 Y는 새로운 하드 상한이에요. 하드 상한은 SQLITE_CONFIG_MMAP_SIZE로 컴파일 시 설정값 위로는 증가시킬 수 없지만, 줄이거나 0으로 만들 수는 있어요.

더 알아보기 (Learn more)