Memory-Mapped I/O в SQLite

SQLite по умолчанию использует методы xRead() и xWrite() объекта VFS sqlite3_io_methods для работы с базами данных, что подразумевает применение системных вызовов «read()» и «write()». Эти вызовы приводят к необходимости копирования данных между буфером ядра и пространством пользователя.

Вся рубрика SQLite: уроки, инструменты и примеры

Начиная с версии 3.7.17 (2013-05-20), SQLite предоставляет возможность обращаться к содержимому диска напрямую с помощью отображения файлов в память (memory-mapped I/O) и новых методов xFetch() и xUnfetch() объекта sqlite3_io_methods.

Использование memory-mapped I/O имеет как преимущества, так и недостатки. Преимущества включают:

В результате, многие операции, особенно те, что требуют интенсивного ввода-вывода, могут выполняться быстрее, поскольку исключается необходимость копирования данных между пространством ядра и пользовательским пространством

Библиотеке SQLite может потребоваться меньше оперативной памяти, поскольку она совместно использует страницы с кэшем страниц операционной системы и не всегда нуждается в собственной копии рабочих страниц

Однако существуют и недостатки:

Ошибка ввода-вывода в файле, отображённом в память, не может быть перехвачена и обработана SQLite. Вместо этого ошибка ввода-вывода вызывает сигнал, который, если не перехвачен приложением, приводит к аварийному завершению программы.

Для корректной работы расширения memory-mapped I/O операционная система должна иметь унифицированный буферный кэш (unified buffer cache), особенно в ситуациях, когда два процесса обращаются к одному и тому же файлу базы данных и один из них использует memory-mapped I/O, а другой — нет. Не все операционные системы имеют унифицированный буферный кэш.

В некоторых операционных системах, заявляющих о наличии унифицированного буферного кэша, реализация содержит ошибки и может приводить к повреждению баз данных.

Следует учитывать, что использование memory-mapped I/O не всегда повышает производительность; есть случаи, когда она может снижаться

В Windows операция уменьшения размера файла, отображённого в память, не проходит успешно. Если попытаться уменьшить размер базы данных с помощью VACUUM или auto_vacuum, этот процесс будет завершён, оставив неиспользуемое пространство в конце файла.

Из-за потенциальных недостатков memory-mapped I/O он отключён по умолчанию. Чтобы его активировать, необходимо использовать прагму mmap_size и установить её на значение порядка 256 МБ или больше, в зависимости от того, сколько адресного пространства может выделить ваша программа

Как работает Memory-Mapped I/O

Чтобы прочитать страницу содержимого базы данных с помощью устаревшего метода xRead(), SQLite сначала выделяет фрагмент памяти в куче размером со страницу, а затем вызывает метод xRead(), который копирует содержимое страницы базы данных во вновь выделенную память кучи. Это предполагает как минимум полное копирование страницы.

Но если SQLite хочет обратиться к странице файла базы данных и memory-mapped I/O включён, он сначала вызывает метод xFetch(). Метод xFetch() просит операционную систему вернуть указатель на запрошенную страницу, если это возможно.

Если запрошенная страница была или может быть отображена в адресное пространство приложения, xFetch возвращает указатель на эту страницу для использования SQLite без необходимости что-либо копировать. Именно пропуск шага копирования делает memory-mapped I/O быстрее

SQLite не предполагает, что метод xFetch() обязательно сработает. Если вызов xFetch() возвращает указатель NULL — что означает, что запрошенная страница в данный момент не отображена в адресное пространство приложения, — SQLite молча переключается на использование xRead(). Ошибка сообщается только в том случае, если xRead() тоже завершается неудачей.

При обновлении файла базы данных SQLite всегда копирует содержимое страницы в память кучи перед изменением страницы. Это необходимо по двум причинам. Во-первых, изменения в базе данных не должны быть видны другим процессам до фиксации транзакции, поэтому изменения должны происходить в приватной памяти.

Во-вторых, SQLite использует отображение памяти только для чтения, чтобы предотвратить перезапись и повреждение файла базы данных случайными указателями в приложении. После завершения всех необходимых изменений xWrite() используется для перемещения содержимого обратно в файл базы данных.

Таким образом, использование memory-mapped I/O существенно не меняет производительность изменений базы данных — этот механизм в основном даёт преимущество при выполнении запросов на чтение

По этой теме полезно отдельно посмотреть EXPLAIN QUERY PLAN: план выполнения SQL-запроса в SQLite, чтобы расширить контекст и сравнить подходы.

По этой теме полезно отдельно посмотреть Создание Flutter-приложения с SQLite, BLoC и Streams, чтобы расширить контекст и сравнить подходы.

Настройка Memory-Mapped I/O

«mmap_size» — это максимальное количество байт файла базы данных, которое SQLite будет пытаться одновременно отобразить в адресное пространство процесса. mmap_size применяется отдельно к каждому файлу базы данных, поэтому общий объём адресного пространства процесса, который потенциально может быть использован, равен mmap_size, умноженному на количество открытых файлов баз данных.

Чтобы активировать memory-mapped I/O, приложение может установить mmap_size в достаточно большое значение. Например:

PRAGMA mmap_size=268435456;

Чтобы отключить memory-mapped I/O, просто установите mmap_size в ноль:

PRAGMA mmap_size=0;

Если mmap_size установлен в N, все текущие реализации отображают первые N байт файла базы данных и используют устаревшие вызовы xRead() для любого содержимого за пределами N байт. Если файл базы данных меньше N байт, отображается весь файл. В будущем новые интерфейсы ОС теоретически могут отображать области файла, отличные от первых N байт, однако такой реализации в настоящее время не существует.

mmap_size устанавливается отдельно для каждого файла базы данных с помощью оператора «PRAGMA mmap_size». Обычное значение mmap_size по умолчанию равно нулю, что означает, что memory-mapped I/O по умолчанию отключён. Однако значение mmap_size по умолчанию можно увеличить либо во время компиляции с помощью макроса SQLITE_DEFAULT_MMAP_SIZE, либо во время запуска с помощью интерфейса sqlite3_config(SQLITE_CONFIG_MMAP_SIZE,…).

SQLite также поддерживает жёсткую верхнюю границу для mmap_size. Попытки увеличить mmap_size выше этой жёсткой верхней границы с помощью PRAGMA mmap_size автоматически ограничат mmap_size значением жёсткой верхней границы. Если жёсткая верхняя граница равна нулю, memory-mapped I/O невозможен. Жёсткая верхняя граница может быть установлена во время компиляции с помощью макроса SQLITE_MAX_MMAP_SIZE.

Если SQLITE_MAX_MMAP_SIZE установлен в ноль, код, реализующий memory-mapped I/O, исключается из сборки. Жёсткая верхняя граница автоматически устанавливается в ноль на определённых платформах — например, OpenBSD — где memory-mapped I/O не работает из-за отсутствия унифицированного буферного кэша.

Если жёсткая верхняя граница mmap_size ненулевая во время компиляции, её всё равно можно уменьшить или обнулить во время запуска с помощью интерфейса sqlite3_config(SQLITE_CONFIG_MMAP_SIZE,X,Y). Параметры X и Y должны быть 64-битными знаковыми целыми числами. Параметр X — это mmap_size процесса по умолчанию, а Y — новая жёсткая верхняя граница.

Жёсткую верхнюю границу нельзя увеличить выше значения, установленного во время компиляции, с помощью SQLITE_CONFIG_MMAP_SIZE, однако её можно уменьшить или обнулить.

Когда Memory-Mapped I/O действительно помогает

На практике я замечаю, что memory-mapped I/O даёт наибольший выигрыш в сценариях с интенсивным чтением: аналитические запросы, полные сканирования таблиц, многократное обращение к одним и тем же страницам в рамках одной сессии. В таких случаях устранение лишнего копирования между пространством ядра и пространством пользователя ощутимо снижает нагрузку.

При этом для рабочих нагрузок с преобладанием записи выигрыш минимален: как описано выше, SQLite всё равно копирует страницу в кучу перед изменением и затем записывает её через xWrite(). Если ваше приложение выполняет преимущественно INSERT, UPDATE или DELETE, включение memory-mapped I/O не даст заметного прироста производительности и может даже незначительно снизить её в отдельных тестовых сценариях.

Отдельного внимания заслуживает выбор значения mmap_size. Слишком маленькое значение означает, что часть файла базы данных всё равно будет читаться через xRead(). Слишком большое — может исчерпать адресное пространство процесса, особенно в 32-битных приложениях. Мой совет — начинать с 256 МБ (268435456 байт) и подбирать конкретное значение исходя из размера базы данных и доступного адресного пространства

Типичные ошибки при использовании Memory-Mapped I/O

Работая с этим механизмом, я выделяю несколько ошибок, которые встречаются чаще всего.

Игнорирование платформенных ограничений. На OpenBSD и ряде других платформ memory-mapped I/O автоматически отключается из-за отсутствия унифицированного буферного кэша. Если PRAGMA mmap_size молча игнорируется, первым делом стоит проверить, не установлен ли SQLITE_MAX_MMAP_SIZE в ноль при компиляции.

Смешивание процессов с разными режимами доступа. Если один процесс работает с базой данных через memory-mapped I/O, а другой — через обычные вызовы xRead()/xWrite(), операционная система должна обеспечивать унифицированный буферный кэш. На системах с ошибками в реализации этого кэша такая конфигурация может привести к повреждению данных.

Ожидание ускорения операций записи. Memory-mapped I/O не ускоряет запись: изменения всегда проходят через копирование в кучу и последующий вызов xWrite(). Ожидать прироста производительности при записи не стоит.

Проблема усечения файла в Windows. Если приложение работает под Windows и использует VACUUM или auto_vacuum, уменьшение размера файла базы данных при активном memory-mapped I/O молча завершится неудачей. Неиспользуемое пространство останется в конце файла и будет повторно задействовано только при следующем росте базы данных. Версии SQLite ниже 3.7.0 могут ошибочно интерпретировать это как повреждение базы данных при выполнении PRAGMA integrity_check.

Отсутствие обработки сигналов ошибок ввода-вывода. В отличие от обычного xRead(), ошибка ввода-вывода в отображённом файле не возвращается как код ошибки SQLite — она генерирует сигнал операционной системы. Если приложение не перехватывает этот сигнал, оно аварийно завершится. Это принципиальное отличие от стандартного поведения SQLite, к которому привыкли большинство разработчиков.

Ответы на эти вопросы могут быть для вас полезными

Почему memory-mapped I/O по умолчанию отключён в SQLite?

Из-за совокупности рисков: ошибки ввода-вывода в отображённом файле не перехватываются SQLite и приводят к аварийному завершению программы; не все операционные системы имеют корректный унифицированный буферный кэш; Windows не может усекать отображённые файлы. Эти ограничения делают механизм небезопасным для включения по умолчанию.

Как включить memory-mapped I/O и какое значение mmap_size выбрать?

Выполните PRAGMA mmap_size=268435456; для установки 256 МБ. Это рекомендуемая отправная точка. Значение применяется к каждому файлу базы данных отдельно, поэтому при нескольких открытых базах суммарное адресное пространство умножается на их количество.

Влияет ли memory-mapped I/O на операции записи?

Нет. При любых изменениях SQLite копирует страницу в память кучи, вносит изменения там, а затем записывает результат через xWrite(). Memory-mapped I/O не участвует в этом процессе и не ускоряет запись.

Что произойдёт, если xFetch() вернёт NULL?

SQLite молча переключится на использование xRead() для этой страницы. Ошибка будет сообщена только в том случае, если xRead() тоже завершится неудачей. Таким образом, частичное отображение файла работает корректно.

Можно ли ограничить максимальный mmap_size на уровне компиляции?

Да. Макрос SQLITE_MAX_MMAP_SIZE задаёт жёсткую верхнюю границу, которую нельзя превысить через PRAGMA mmap_size. Если установить его в ноль, код memory-mapped I/O полностью исключается из сборки. Во время запуска границу можно только уменьшить — через sqlite3_config(SQLITE_CONFIG_MMAP_SIZE,X,Y), но не увеличить выше значения, заданного при компиляции.

Оцените статью
0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x