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

SQLite обладает важным свойством: транзакции выглядят атомарными даже в том случае, если транзакция была прервана сбоем операционной системы или отключением питания
Информация применима только тогда, когда SQLite работает в режиме отката (rollback mode), то есть когда SQLite не использует журнал упреждающей записи (write-ahead log). SQLite по-прежнему поддерживает атомарную фиксацию при включённом журнале упреждающей записи, однако обеспечивает её иным механизмом, нежели тот, что описан здесь
Дополнительную информацию о том, как SQLite поддерживает атомарную фиксацию в этом контексте, см. в документации по журналу упреждающей записи
- Допущения об аппаратном обеспечении
- Фиксация изменений в одном файле
- Начальное состояние
- Получение блокировки на чтение
- Чтение информации из базы данных
- Получение зарезервированной блокировки
- Создание файла журнала отката
- Изменение страниц базы данных в пользовательском пространстве
- Сброс файла журнала отката на устройство хранения
- Получение эксклюзивной блокировки
- Запись изменений в файл базы данных
- Сброс изменений на устройство хранения
- Удаление журнала отката
- Снятие блокировки
- Откат транзакции
- Когда что-то идёт не так
- Горячие журналы отката
- Получение эксклюзивной блокировки базы данных при откате
- Откат незавершённых изменений
- Удаление горячего журнала
- Продолжение работы как если бы незавершённые записи никогда не происходили
- Фиксация нескольких файлов
- Отдельные журналы отката для каждой базы данных
- Файл супер-журнала
- Обновление заголовков журналов отката
- Обновление файлов баз данных
- Удаление файла супер-журнала
- Очистка журналов отката
- Дополнительные подробности процесса фиксации
- Всегда записывать полные секторы в журнал
- Работа с мусорными данными, записанными в файлы журнала
- Вытеснение кэша до фиксации
- Оптимизации производительности
- Кэш, сохраняемый между транзакциями
- Режим монопольного доступа
- Страницы списка свободных блоков не журналируются
- Обновления одной страницы и атомарная запись секторов
- Типичные ошибки при работе с атомарной фиксацией
- Ответы на эти вопросы могут быть для вас полезными
Допущения об аппаратном обеспечении
На протяжении всей статьи устройство хранения данных называется «диском», даже если в действительности оно представляет собой флеш-память
Предполагается, что запись на диск осуществляется блоками, которые называются «сектором». Изменить любую часть диска меньше сектора невозможно. Чтобы изменить часть диска меньше сектора, необходимо считать полный сектор, содержащий нужную часть, внести изменение, а затем записать полный сектор обратно
На традиционном вращающемся диске сектор является минимальной единицей передачи данных в обоих направлениях — как при чтении, так и при записи. Однако на флеш-памяти минимальный размер операции чтения, как правило, значительно меньше минимального размера операции записи
SQLite интересует только минимальный объём записи, поэтому в данной статье под «сектором» понимается минимальный объём данных, который может быть записан на устройство хранения за один раз
До версии SQLite 3.3.14 во всех случаях предполагался размер сектора 512 байт. Существовала опция времени компиляции для изменения этого значения, однако код никогда не тестировался с большим значением. Допущение о 512-байтном секторе казалось разумным, поскольку до недавнего времени все жёсткие диски использовали 512-байтный сектор внутренне
Однако в последнее время наметилась тенденция к увеличению размера сектора дисков до 4096 байт. Кроме того, размер сектора флеш-памяти обычно превышает 512 байт. По этим причинам в версиях SQLite начиная с 3.3.14 в уровне интерфейса ОС появился метод, который опрашивает базовую файловую систему для определения истинного размера сектора
В текущей реализации (версия 3.5.0) этот метод по-прежнему возвращает жёстко заданное значение 512 байт, поскольку не существует стандартного способа определить истинный размер сектора ни в Unix, ни в Windows. Однако метод доступен производителям встраиваемых устройств для настройки под собственные нужды, и возможность реализации более содержательного варианта для Unix и Windows в будущем остаётся открытой
Когда происходит запись сектора, оборудование, как правило, начинает с одного конца сектора и движется к другому. Если в этот момент отключается питание, это может привести к тому, что одна часть данных изменится, а другая останется прежней. SQLite предполагает, что если часть сектора изменена, то это относится только к началу или концу, не затрагивая середину
В предыдущем абзаце утверждается, что SQLite не предполагает атомарности записи секторов. Это верно по умолчанию. Однако начиная с версии SQLite 3.5.0 появился новый интерфейс — интерфейс виртуальной файловой системы (Virtual File System, VFS). VFS является единственным средством, с помощью которого SQLite взаимодействует с базовой файловой системой
В состав кода входят реализации VFS по умолчанию для Unix и Windows, а также механизм создания новых пользовательских реализаций VFS во время выполнения. В этом новом интерфейсе VFS есть метод xDeviceCharacteristics. Данный метод опрашивает базовую файловую систему для обнаружения различных свойств и поведений, которые файловая система может или не может демонстрировать
Метод xDeviceCharacteristics может указывать на то, что запись секторов является атомарной, и если он это указывает, SQLite попытается воспользоваться данным фактом. Однако метод xDeviceCharacteristics по умолчанию как для Unix, так и для Windows не указывает на атомарность записи секторов, поэтому эти оптимизации обычно не применяются
SQLite полагает, что операции записи будут буферизоваться операционной системой, и подтверждение записи будет поступать только после того, как данные будут физически сохранены на диске. Также допускается, что порядок выполнения операций записи может изменяться. Поэтому в ключевых точках SQLite выполняет сброс данных (flush) или fsync
Убедитесь, что данные завершены перед выполнением этих команд, чтобы защитить базу данных от возможного повреждения в случае отключения питания
SQLite предполагает, что при увеличении длины файла новое пространство изначально содержит мусор, а затем заполняется данными. Проще говоря, SQLite считает, что размер файла обновляется раньше, чем изменения содержимого. Это предположение довольно пессимистично, и требует дополнительных усилий, чтобы избежать повреждения базы данных в случае отключения питания между увеличением размера файла и записью новых данных
Метод xDeviceCharacteristics интерфейса VFS может указывать на то, что файловая система всегда записывает данные прежде чем обновлять размер файла
SQLite исходит из того, что удаление файла происходит атомарно в контексте пользовательского процесса. Это значит, что когда SQLite запрашивает удаление файла, отключение питания в процессе удаления приведёт к тому, что либо файл останется в целостности со всем содержимым, либо не будет доступен в файловой системе вообще
Если после восстановления питания файл будет частично удалён или виден в изменённом состоянии, это может привести к повреждению базы данных
SQLite подразумевает, что удаление файла происходит атомарно, то есть, если процесс отключения питания случается во время удаления, файл либо остается целым со всем содержимым, либо исчезает полностью. В случае частичного удаления после восстановления питания может возникнуть повреждение базы данных
По умолчанию SQLite предполагает, что системный вызов операционной системы для записи диапазона байт не повредит и не изменит никакие байты за пределами этого диапазона, даже если в процессе записи произойдёт потеря питания или сбой ОС. Это свойство называется «безопасной перезаписью при отключении питания» (powersafe overwrite)
До версии 3.7.9 (2011-11-01) SQLite не предполагал наличия безопасной перезаписи при отключении питания. Однако в связи с увеличением стандартного размера сектора с 512 до 4096 байт на большинстве жёстких дисков стало необходимым предполагать наличие безопасной перезаписи при отключении питания для поддержания исторических уровней производительности, поэтому в последних версиях SQLite это свойство предполагается по умолчанию
При желании допущение о свойстве безопасной перезаписи при отключении питания может быть отключено во время компиляции или во время выполнения. Дополнительные сведения см. в документации по безопасной перезаписи при отключении питания
По этой теме полезно отдельно посмотреть EXPLAIN QUERY PLAN: план выполнения SQL-запроса в SQLite, чтобы расширить контекст и сравнить подходы
По этой теме полезно отдельно посмотреть Создание Flutter-приложения с SQLite, BLoC и Streams, чтобы расширить контекст и сравнить подходы
Фиксация изменений в одном файле
Начнём с обзора шагов, которые SQLite выполняет для атомарной фиксации транзакции в одном файле базы данных. Подробности о форматах файлов, защищающих от повреждений при отключении питания, и методах атомарной фиксации изменений сразу в нескольких базах данных рассматриваются в последующих разделах
Начальное состояние
Состояние компьютера в момент первого открытия соединения с базой данных схематично показано на диаграмме справа. Крайняя правая область диаграммы (обозначенная «Диск») представляет информацию, хранящуюся на устройстве массового хранения. Каждый прямоугольник — это сектор. Синий цвет означает, что секторы содержат исходные данные. Средняя область — это дисковый кэш операционной системы
В начале нашего примера кэш холодный, что отображается пустыми прямоугольниками дискового кэша. Левая область диаграммы показывает содержимое памяти процесса, использующего SQLite. Соединение с базой данных только что открыто, никакая информация ещё не была прочитана, поэтому пользовательское пространство пусто
Получение блокировки на чтение
Прежде чем SQLite сможет выполнить запись в базу данных, он должен сначала прочитать её, чтобы узнать, что там уже есть. Даже если речь идёт лишь о добавлении новых данных, SQLite всё равно должен прочитать схему базы данных из таблицы sqlite_schema, чтобы знать, как разбирать операторы INSERT и где именно в файле базы данных следует сохранить новую информацию
Первый шаг к чтению из файла базы данных — получение разделяемой блокировки на этот файл. «Разделяемая» блокировка позволяет двум и более соединениям с базой данных одновременно читать из файла базы данных. Однако разделяемая блокировка не позволяет другому соединению с базой данных выполнять запись в файл базы данных, пока мы из него читаем
Это необходимо, потому что если бы другое соединение с базой данных выполняло запись в файл базы данных одновременно с нашим чтением, мы могли бы прочитать часть данных до изменения, а другую часть — после. Это создало бы иллюзию, что изменение, внесённое другим процессом, не является атомарным
Обратите внимание, что разделяемая блокировка устанавливается на дисковый кэш операционной системы, а не на сам диск. Файловые блокировки на самом деле представляют собой лишь флаги внутри ядра операционной системы. (Детали зависят от конкретного интерфейса уровня ОС.) Поэтому блокировка мгновенно исчезнет при сбое операционной системы или отключении питания. Как правило, блокировка также исчезает при завершении процесса, который её создал
Чтение информации из базы данных
После получения разделяемой блокировки можно начинать читать информацию из файла базы данных. В данном сценарии предполагается холодный кэш, поэтому информацию сначала нужно прочитать с устройства массового хранения в кэш операционной системы, а затем передать из кэша операционной системы в пользовательское пространство
При последующих операциях чтения часть информации или вся она может уже находиться в кэше операционной системы, и тогда потребуется только передача в пользовательское пространство
Обычно считывается лишь часть страниц файла базы данных. В данном примере показано чтение трёх страниц из восьми. В типичном приложении база данных содержит тысячи страниц, и запрос обычно затрагивает лишь небольшой их процент
Получение зарезервированной блокировки
Прежде чем вносить изменения в базу данных, SQLite сначала получает «зарезервированную» блокировку на файл базы данных. Зарезервированная блокировка похожа на разделяемую в том смысле, что обе позволяют другим процессам читать из файла базы данных. Одна зарезервированная блокировка может сосуществовать с несколькими разделяемыми блокировками от других процессов
Однако на файл базы данных может быть установлена только одна зарезервированная блокировка. Таким образом, только один процесс может одновременно пытаться выполнять запись в базу данных
Идея зарезервированной блокировки состоит в том, что она сигнализирует: процесс намерен изменить файл базы данных в ближайшем будущем, но ещё не приступил к изменениям. Поскольку изменения ещё не начались, другие процессы могут продолжать читать из базы данных. Однако ни один другой процесс не должен также начинать попытки записи в базу данных
Создание файла журнала отката
Прежде чем вносить какие-либо изменения в файл базы данных, SQLite сначала создаёт отдельный файл журнала отката и записывает в него исходное содержимое страниц базы данных, которые будут изменены. Идея журнала отката состоит в том, что он содержит всю информацию, необходимую для восстановления базы данных в исходное состояние
Журнал отката содержит небольшой заголовок (показан зелёным на диаграмме), в котором записан исходный размер файла базы данных. Таким образом, если изменение приводит к росту файла базы данных, исходный размер всё равно будет известен. Номер страницы хранится вместе с каждой страницей базы данных, записываемой в журнал отката
При создании нового файла большинство настольных операционных систем (Windows, Linux, Mac OS X) фактически ничего не записывают на диск. Новый файл создаётся только в дисковом кэше операционной системы. Файл не записывается на устройство массового хранения до тех пор, пока у операционной системы не появится свободный момент
Это создаёт у пользователей впечатление, что ввод-вывод происходит значительно быстрее, чем это возможно при реальных дисковых операциях. На диаграмме справа эта идея проиллюстрирована тем, что новый журнал отката появляется только в дисковом кэше операционной системы, но не на самом диске
Изменение страниц базы данных в пользовательском пространстве
После того как исходное содержимое страниц сохранено в журнале отката, страницы можно изменять в пользовательской памяти. Каждое соединение с базой данных имеет собственную приватную копию пользовательского пространства, поэтому изменения, вносимые в пользовательском пространстве, видны только тому соединению с базой данных, которое их вносит
Другие соединения с базой данных по-прежнему видят информацию в буферах дискового кэша операционной системы, которые ещё не были изменены. Таким образом, даже когда один процесс занят изменением базы данных, другие процессы могут продолжать читать собственные копии исходного содержимого базы данных
Сброс файла журнала отката на устройство хранения
Следующий шаг — сброс содержимого файла журнала отката на энергонезависимое устройство хранения. Как станет ясно далее, это критически важный шаг для обеспечения выживаемости базы данных при неожиданном отключении питания. Этот шаг также занимает много времени, поскольку запись на энергонезависимое устройство хранения — обычно медленная операция
Этот шаг, как правило, сложнее, чем простой сброс журнала отката на диск. На большинстве платформ требуются две отдельные операции сброса (или fsync()). Первый сброс записывает базовое содержимое журнала отката. Затем заголовок журнала отката изменяется, чтобы отразить количество страниц в журнале. После этого заголовок сбрасывается на диск
Подробности о том, зачем выполняется это изменение заголовка и дополнительный сброс, приведены в более позднем разделе данной статьи
Получение эксклюзивной блокировки
Прежде чем вносить изменения непосредственно в файл базы данных, необходимо получить эксклюзивную блокировку на этот файл. Получение эксклюзивной блокировки — это двухэтапный процесс. Сначала SQLite получает «ожидающую» (pending) блокировку. Затем она повышается до эксклюзивной
Ожидающая блокировка позволяет другим процессам, уже имеющим общую блокировку, продолжать чтение файла базы данных. Однако она предотвращает установку новых общих блокировок. Идея ожидающей блокировки состоит в том, чтобы не допустить голодания записи, вызванного большим количеством читателей. Могут существовать десятки и даже сотни других процессов, пытающихся читать файл базы данных
Каждый процесс получает общую блокировку перед началом чтения, читает необходимые данные, а затем освобождает общую блокировку. Однако если множество различных процессов одновременно читают из одной базы данных, может возникнуть ситуация, когда новый процесс всегда успевает получить общую блокировку до того, как предыдущий процесс её освободит
В результате никогда не наступает момент, когда на файле базы данных нет ни одной общей блокировки, и у записывающего процесса никогда нет возможности захватить эксклюзивную блокировку. Ожидающая блокировка призвана разорвать этот цикл: она позволяет существующим общим блокировкам продолжать работу, но блокирует установку новых
В конечном счёте все общие блокировки будут сняты, и ожидающая блокировка сможет повыситься до эксклюзивной
Запись изменений в файл базы данных
Как только эксклюзивная блокировка получена, известно, что никакие другие процессы не читают файл базы данных, и запись изменений в него безопасна. Как правило, эти изменения попадают только в дисковый кэш операционной системы и не доходят до устройства хранения
Сброс изменений на устройство хранения
Необходимо выполнить ещё один сброс, чтобы убедиться, что все изменения базы данных записаны на энергонезависимое устройство хранения. Это критически важный шаг для обеспечения сохранности базы данных при отключении питания без повреждений. Однако из-за присущей записи на диск или флэш-память медлительности этот шаг вместе со сбросом файла журнала отката занимает большую часть времени, необходимого для завершения фиксации транзакции в SQLite
Удаление журнала отката
После того как все изменения базы данных надёжно сохранены на устройстве хранения, файл журнала отката удаляется. Именно в этот момент транзакция фиксируется. Если отключение питания или сбой системы происходит до этого момента, процессы восстановления, описанные далее, обеспечивают видимость того, что никаких изменений в файл базы данных внесено не было
Если отключение питания или сбой системы происходит после удаления журнала отката, считается, что все изменения были записаны на диск. Таким образом, SQLite создаёт видимость либо полного отсутствия изменений в файле базы данных, либо полного применения всего набора изменений — в зависимости от того, существует ли файл журнала отката
Удаление файла — не атомарная операция в строгом смысле, однако с точки зрения пользовательского процесса она таковой выглядит. Процесс всегда может спросить операционную систему: «существует ли этот файл?» — и получить ответ «да» или «нет». После отключения питания, произошедшего во время фиксации транзакции, SQLite спросит операционную систему, существует ли файл журнала отката
Если ответ — «да», транзакция не завершена и откатывается. Если ответ — «нет», это означает, что транзакция была зафиксирована
Существование транзакции определяется наличием файла журнала отката, а удаление файла с точки зрения пользовательского процесса выглядит как атомарная операция. Следовательно, транзакция выглядит как атомарная операция
Удаление файла на многих системах — дорогостоящая операция. В качестве оптимизации SQLite можно настроить на усечение файла журнала до нулевой длины или на перезапись заголовка файла журнала нулями. В обоих случаях результирующий файл журнала больше не способен выполнить откат, и транзакция всё равно считается зафиксированной
Усечение файла до нулевой длины, как и удаление файла, с точки зрения пользовательского процесса считается атомарной операцией. Перезапись заголовка журнала нулями не является атомарной, однако если какая-либо часть заголовка повреждена, журнал не выполнит откат. Поэтому можно утверждать, что фиксация происходит, как только заголовок изменён достаточно, чтобы стать недействительным. Как правило, это происходит сразу после обнуления первого байта заголовка
Снятие блокировки
Последний шаг в процессе фиксации — снятие эксклюзивной блокировки, чтобы другие процессы снова могли получить доступ к файлу базы данных
На диаграмме справа показано, что информация, хранившаяся в пользовательском пространстве, очищается при снятии блокировки. Для более старых версий SQLite это буквально соответствовало действительности. Однако более новые версии SQLite сохраняют информацию пользовательского пространства в памяти на случай, если она понадобится в начале следующей транзакции
Повторное использование информации, уже находящейся в локальной памяти, обходится дешевле, чем её повторная передача из дискового кэша операционной системы или повторное чтение с диска
Перед повторным использованием информации в пользовательском пространстве необходимо сначала заново получить общую блокировку, а затем убедиться, что никакой другой процесс не изменил файл базы данных за время, пока блокировка не удерживалась. В первой странице базы данных есть счётчик, который увеличивается каждый раз при изменении файла базы данных
Проверив этот счётчик, можно узнать, изменял ли другой процесс базу данных. Если база данных была изменена, кэш пользовательского пространства должен быть очищен и перечитан. Однако в большинстве случаев никаких изменений не происходит, и кэш пользовательского пространства можно повторно использовать, что даёт существенный выигрыш в производительности
Откат транзакции
Атомарная фиксация должна происходить мгновенно. Но описанная выше обработка явно занимает конечное количество времени. Предположим, что питание компьютера было отключено в середине операции фиксации, описанной выше. Чтобы сохранить иллюзию мгновенности изменений, необходимо «откатить» любые частичные изменения и восстановить базу данных в состояние, в котором она находилась до начала транзакции
Когда что-то идёт не так
Предположим, что отключение питания произошло во время записи изменений базы данных на диск. После восстановления питания ситуация может выглядеть примерно так: мы пытались изменить три страницы файла базы данных, но только одна страница была успешно записана. Другая страница была записана частично, а третья не была записана вовсе
Журнал отката полностью сохранён на диске после восстановления питания. Это ключевой момент. Причина операции сброса состоит в том, чтобы гарантировать, что весь журнал отката надёжно сохранён на энергонезависимом носителе до внесения каких-либо изменений в сам файл базы данных
Горячие журналы отката
При первом обращении любого процесса SQLite к файлу базы данных он получает разделяемую блокировку. Но затем он замечает, что присутствует файл журнала отката. SQLite проверяет, является ли журнал отката «горячим журналом». Горячий журнал — это журнал отката, который необходимо воспроизвести для восстановления базы данных в согласованное состояние
Горячий журнал существует только тогда, когда более ранний процесс находился в середине фиксации транзакции в момент сбоя или отключения питания
Журнал отката является «горячим», если выполняются все следующие условия:
- Журнал отката существует.
- Журнал отката не является пустым файлом.
- На главном файле базы данных нет зарезервированной блокировки.
- Заголовок журнала отката корректно сформирован и, в частности, не был обнулён.
- Журнал отката не содержит имени файла супер-журнала (см. раздел о фиксации нескольких файлов ниже), либо если содержит имя супер-журнала, то этот файл супер-журнала существует.
Наличие горячего журнала свидетельствует о том, что предыдущий процесс пытался зафиксировать транзакцию, но прервался по какой-то причине до завершения фиксации. Горячий журнал означает, что файл базы данных находится в несогласованном состоянии и требует восстановления (путём отката) перед использованием
Получение эксклюзивной блокировки базы данных при откате
Первый шаг в работе с горячим журналом — получение эксклюзивной блокировки файла базы данных. Это предотвращает попытки двух или более процессов одновременно откатить один и тот же горячий журнал
Откат незавершённых изменений
После того как процесс получает эксклюзивную блокировку, ему разрешается выполнять запись в файл базы данных. Затем он приступает к чтению исходного содержимого страниц из журнала отката и записи этого содержимого обратно на соответствующие места в файле базы данных. Заголовок журнала отката фиксирует исходный размер файла базы данных до начала прерванной транзакции
SQLite использует эту информацию для усечения файла базы данных до его исходного размера в случаях, когда незавершённая транзакция привела к росту базы данных. По завершении этого шага база данных должна иметь тот же размер и содержать ту же информацию, что и до начала прерванной транзакции
Удаление горячего журнала
После того как вся информация из журнала отката воспроизведена обратно в файл базы данных (и сброшена на диск на случай очередного отключения питания), горячий журнал отката можно удалить
Как и в разделе об удалении журнала отката, файл журнала может быть усечён до нулевой длины или его заголовок может быть перезаписан нулями в качестве оптимизации на системах, где удаление файла обходится дорого. В любом случае после этого шага журнал больше не является горячим
Продолжение работы как если бы незавершённые записи никогда не происходили
Последний шаг восстановления — понижение эксклюзивной блокировки до разделяемой. После этого база данных возвращается в состояние, в котором она находилась бы, если бы прерванная транзакция никогда не начиналась. Поскольку вся эта восстановительная деятельность происходит полностью автоматически и прозрачно, для программы, использующей SQLite, выглядит так, будто прерванная транзакция никогда не начиналась
Фиксация нескольких файлов
SQLite позволяет одному соединению с базой данных одновременно работать с двумя и более файлами баз данных посредством команды ATTACH DATABASE. Когда несколько файлов баз данных изменяются в рамках одной транзакции, все файлы обновляются атомарно. Иными словами, либо обновляются все файлы баз данных, либо не обновляется ни один из них
Достижение атомарной фиксации для нескольких файлов баз данных сложнее, чем для одного файла. В этом разделе описывается, как SQLite реализует этот механизм
Отдельные журналы отката для каждой базы данных
Когда в транзакции задействованы несколько файлов баз данных, каждая база данных имеет собственный журнал отката, и каждая база данных блокируется отдельно. Ситуация на этом шаге аналогична сценарию транзакции с одним файлом на этапе изменения страниц в пользовательском пространстве. Каждый файл базы данных имеет зарезервированную блокировку
Для каждой базы данных исходное содержимое изменяемых страниц записано в журнал отката этой базы данных, однако содержимое журналов ещё не сброшено на диск. В сами файлы баз данных изменения ещё не внесены, хотя предположительно изменения хранятся в пользовательской памяти
Для краткости диаграммы в этом разделе упрощены по сравнению с предыдущими. Синий цвет по-прежнему обозначает исходное содержимое, а розовый — новое содержимое. Однако отдельные страницы в журнале отката и файле базы данных не показаны, и не проводится различие между информацией в кэше операционной системы и информацией на диске. Все эти факторы по-прежнему применимы в сценарии фиксации нескольких файлов
Они просто занимают много места на диаграммах и не добавляют новой информации, поэтому здесь они опущены
Файл супер-журнала
Следующий шаг при фиксации нескольких файлов — создание файла «супер-журнала». Имя файла супер-журнала совпадает с именем исходного файла базы данных (той базы данных, которая была открыта с помощью интерфейса sqlite3_open(), а не одной из вспомогательных баз данных, подключённых через ATTACH), к которому добавляется текст -mj HHHHHHHH, где HHHHHHHH — случайное 32-битное шестнадцатеричное число. Случайный суффикс HHHHHHHH меняется для каждого нового супер-журнала
(Nota bene: Формула вычисления имени файла супер-журнала, приведённая в предыдущем абзаце, соответствует реализации начиная с версии SQLite 3.5.0. Однако эта формула не является частью спецификации SQLite и может быть изменена в будущих выпусках.)
В отличие от журналов отката, супер-журнал не содержит никакого исходного содержимого страниц базы данных. Вместо этого супер-журнал содержит полные пути к журналам отката для каждой базы данных, участвующей в транзакции
После того как супер-журнал сформирован, его содержимое сбрасывается на диск до того, как будут предприняты какие-либо дальнейшие действия. В Unix также синхронизируется каталог, содержащий супер-журнал, чтобы гарантировать появление файла супер-журнала в каталоге после отключения питания
Назначение супер-журнала — обеспечить атомарность многофайловых транзакций при потере питания. Однако если файлы баз данных имеют другие настройки, нарушающие целостность при потере питания (например, PRAGMA synchronous=OFF или PRAGMA journal_mode=MEMORY), создание супер-журнала пропускается в качестве оптимизации
Обновление заголовков журналов отката
Следующий шаг — запись полного пути к файлу супер-журнала в заголовок каждого журнала отката. Место для хранения имени файла супер-журнала было зарезервировано в начале каждого журнала отката в момент их создания
Содержимое каждого журнала отката сбрасывается на диск как до, так и после записи имени файла супер-журнала в заголовок журнала отката. Важно выполнить оба этих сброса. К счастью, второй сброс обычно не требует больших затрат, поскольку, как правило, изменяется только одна страница файла журнала (первая страница)
Этот шаг аналогичен шагу сброса журнала отката в сценарии фиксации одного файла, описанном выше
Обновление файлов баз данных
После того как все файлы журналов отката сброшены на диск, можно безопасно приступать к обновлению файлов баз данных. Перед записью изменений необходимо получить эксклюзивную блокировку на все файлы баз данных. После записи всех изменений важно сбросить их на диск, чтобы они сохранились в случае отключения питания или сбоя операционной системы
Этот шаг соответствует получению эксклюзивной блокировки, записи изменений и их сбросу в сценарии фиксации одного файла, описанном ранее
Удаление файла супер-журнала
Следующий шаг — удаление файла супер-журнала. Именно в этот момент многофайловая транзакция фиксируется. Этот шаг соответствует удалению журнала отката в сценарии фиксации одного файла
Если в этот момент произойдёт отключение питания или сбой операционной системы, транзакция не будет откатана при перезагрузке системы, даже несмотря на наличие журналов отката. Разница заключается в пути к супер-журналу в заголовке журнала отката
При перезапуске SQLite считает журнал «горячим» и воспроизводит его только в том случае, если в заголовке отсутствует имя файла супер-журнала (что характерно для фиксации одного файла) или если файл супер-журнала всё ещё существует на диске
Очистка журналов отката
Последний шаг при фиксации нескольких файлов — удаление отдельных журналов отката и снятие эксклюзивных блокировок с файлов баз данных, чтобы другие процессы могли видеть изменения. Это соответствует последнему шагу в последовательности фиксации одного файла
К этому моменту транзакция уже зафиксирована, поэтому время удаления журналов отката не является критичным. Текущая реализация удаляет один журнал отката, затем снимает блокировку с соответствующего файла базы данных, после чего переходит к следующему журналу отката
Однако в будущем это поведение может быть изменено таким образом, чтобы все журналы отката удалялись до снятия блокировок с каких-либо файлов баз данных. Главное условие — журнал отката должен быть удалён до снятия блокировки с соответствующего файла базы данных; порядок удаления журналов отката и снятия блокировок с файлов баз данных при этом не имеет значения
Дополнительные подробности процесса фиксации
Предыдущие разделы дают общее представление о том, как работает атомарная фиксация в SQLite. Однако они обходят стороной ряд важных деталей. Следующие подразделы призваны восполнить эти пробелы
Всегда записывать полные секторы в журнал
Когда исходное содержимое страницы базы данных записывается в журнал отката, SQLite всегда записывает полный сектор данных, даже если размер страницы базы данных меньше размера сектора. Исторически размер сектора в SQLite был жёстко задан равным 512 байтам, и поскольку минимальный размер страницы также составляет 512 байт, это никогда не было проблемой
Однако начиная с версии SQLite 3.3.14 стало возможным использование SQLite с устройствами массового хранения, у которых размер сектора превышает 512 байт. Поэтому начиная с версии 3.3.14 при записи в файл журнала любой страницы из некоторого сектора вместе с ней сохраняются все страницы того же сектора
Хранение всех страниц сектора в журнале отката важно для предотвращения повреждения базы данных после потери питания во время записи сектора. Предположим, что страницы 1, 2, 3 и 4 хранятся в секторе 1, и страница 2 была изменена. Чтобы записать изменения страницы 2, базовое оборудование должно также перезаписать содержимое страниц 1, 3 и 4, поскольку оборудование обязано записывать полный сектор
Если эта операция записи будет прервана отключением питания, одна или несколько страниц из 1, 3 или 4 могут оказаться с некорректными данными. Следовательно, чтобы избежать необратимого повреждения базы данных, исходное содержимое всех этих страниц должно быть включено в журнал отката
Работа с мусорными данными, записанными в файлы журнала
Когда данные добавляются в конец журнала отката, SQLite обычно делает пессимистичное предположение: сначала файл расширяется с недействительными «мусорными» данными, и лишь затем правильные данные заменяют этот мусор. Иными словами, SQLite предполагает, что размер файла сначала увеличивается, и только после этого содержимое записывается в файл
Если в промежутке между увеличением размера файла и записью содержимого произойдёт сбой питания, журнал отката может остаться с мусорными данными. Если после восстановления питания другой процесс SQLite обнаружит журнал отката с мусорными данными и попытается откатить их в исходный файл базы данных, он может скопировать часть мусора в файл базы данных и тем самым повредить его
SQLite использует два уровня защиты от этой проблемы. Во-первых, SQLite записывает количество страниц в журнале отката в заголовок этого журнала. Изначально это число равно нулю. Поэтому при попытке откатить неполный (и возможно повреждённый) журнал отката процесс, выполняющий откат, увидит, что журнал содержит ноль страниц, и не внесёт никаких изменений в базу данных
Перед фиксацией журнал отката сбрасывается на диск, чтобы убедиться, что всё содержимое синхронизировано с диском и в файле не осталось «мусора», и только после этого счётчик страниц в заголовке меняется с нуля на фактическое количество страниц в журнале отката
Заголовок журнала отката всегда хранится в отдельном секторе, отдельно от данных страниц, чтобы его можно было перезаписать и сбросить без риска повреждения страницы данных при отключении питания. Обратите внимание, что журнал отката сбрасывается на диск дважды: первый раз — для записи содержимого страниц, второй раз — для записи счётчика страниц в заголовке
Предыдущий абзац описывает поведение при значении параметра synchronous pragma, равном «full»:
PRAGMA synchronous=FULL;
Значение synchronous по умолчанию равно full, поэтому описанное выше является стандартным поведением. Однако если значение synchronous снижено до «normal», SQLite сбрасывает журнал отката на диск только один раз — после записи счётчика страниц. Это несёт риск повреждения данных, поскольку может случиться так, что изменённый (ненулевой) счётчик страниц достигнет поверхности диска раньше, чем все данные
Данные будут записаны первыми, но SQLite предполагает, что базовая файловая система может изменять порядок запросов на запись, и счётчик страниц может быть записан на диск первым, даже если запрос на его запись поступил последним. Поэтому в качестве второго уровня защиты SQLite также использует 32-битную контрольную сумму для каждой страницы данных в журнале отката
Эта контрольная сумма проверяется для каждой страницы в процессе отката журнала. Если контрольная сумма не совпадает, откат прерывается. Следует отметить, что контрольная сумма не гарантирует корректность данных страницы, поскольку существует небольшая, но ненулевая вероятность того, что контрольная сумма окажется верной даже при повреждённых данных
Тем не менее контрольная сумма по меньшей мере делает такую ошибку маловероятной
Обратите внимание, что контрольные суммы в журнале отката не являются необходимыми, если значение synchronous равно FULL. Мы полагаемся на контрольные суммы только тогда, когда synchronous снижен до NORMAL. Тем не менее контрольные суммы никогда не вредят, поэтому они включаются в журнал отката независимо от значения параметра synchronous
Вытеснение кэша до фиксации
Процесс фиксации, описанный выше, предполагает, что все изменения базы данных помещаются в памяти вплоть до момента фиксации. Это типичный случай. Однако иногда более крупное изменение может переполнить пользовательский кэш до завершения транзакции. В таких случаях кэш должен быть вытеснен в базу данных до завершения транзакции
В начале вытеснения кэша состояние соединения с базой данных соответствует этапу изменения страниц в пользовательском пространстве. Исходное содержимое страниц сохранено в журнале отката, а изменения страниц находятся в пользовательской памяти. Для вытеснения кэша SQLite выполняет сброс журнала отката на диск, устанавливает эксклюзивную блокировку и записывает изменения в базу данных
Однако оставшиеся шаги откладываются до момента реальной фиксации транзакции. В конец журнала отката добавляется новый заголовок журнала (в отдельном секторе), эксклюзивная блокировка базы данных сохраняется, а обработка в остальном возвращается к этапу изменения страниц. При фиксации транзакции или при очередном вытеснении кэша шаги сброса и записи повторяются
(Шаг получения эксклюзивной блокировки пропускается при втором и последующих проходах, поскольку эксклюзивная блокировка базы данных уже удерживается с первого прохода.)
Вытеснение кэша приводит к повышению уровня блокировки файла базы данных с зарезервированной до эксклюзивной. Это снижает параллелизм. Вытеснение кэша также вызывает дополнительные операции сброса на диск или fsync, которые выполняются медленно, поэтому вытеснение кэша может существенно снизить производительность. По этим причинам вытеснение кэша по возможности избегается
Оптимизации производительности
Профилирование показывает, что на большинстве систем и в большинстве случаев SQLite тратит большую часть времени на дисковый ввод-вывод. Из этого следует, что любые меры по сокращению объёма дискового ввода-вывода, вероятно, окажут значительное положительное влияние на производительность SQLite. В этом разделе описаны некоторые методы, используемые SQLite для сведения объёма дискового ввода-вывода к минимуму при сохранении атомарной фиксации
Кэш, сохраняемый между транзакциями
После освобождения общей блокировки все пользовательские кэш-образы содержимого базы данных должны быть удалены. Это делается потому, что без общей блокировки другие процессы могут свободно изменять содержимое файла базы данных, и любой пользовательский образ этого содержимого может устареть. Следовательно, каждая новая транзакция начиналась бы с повторного чтения данных, которые уже были прочитаны ранее
Это не так плохо, как кажется на первый взгляд, поскольку читаемые данные, скорее всего, всё ещё находятся в файловом кэше операционной системы. Таким образом, «чтение» — это фактически просто копирование данных из пространства ядра в пространство пользователя. Но даже так это всё равно занимает время
Начиная с версии SQLite 3.3.14 был добавлен механизм, призванный сократить излишнее повторное чтение данных. В более новых версиях SQLite данные в пользовательском кэше пейджера сохраняются при освобождении блокировки файла базы данных. Позднее, после получения общей блокировки в начале следующей транзакции, SQLite проверяет, изменял ли какой-либо другой процесс файл базы данных
Если база данных была каким-либо образом изменена с момента последнего освобождения блокировки, пользовательский кэш в этот момент очищается. Но обычно файл базы данных остаётся неизменным, и пользовательский кэш может быть сохранён, что позволяет избежать ряда лишних операций чтения
Чтобы определить, изменился ли файл базы данных, SQLite использует счётчик в заголовке базы данных (в байтах с 24 по 27), который увеличивается при каждой операции изменения. SQLite сохраняет копию этого счётчика перед освобождением блокировки базы данных. Затем, после получения следующей блокировки базы данных, он сравнивает сохранённое значение счётчика с текущим и очищает кэш, если значения различаются, или повторно использует кэш, если они совпадают
Режим монопольного доступа
SQLite версии 3.3.14 вводит концепцию «режима монопольного доступа» (Exclusive Access Mode). В режиме монопольного доступа SQLite удерживает монопольную блокировку базы данных по завершении каждой транзакции. Это не позволяет другим процессам обращаться к базе данных, но во многих сценариях развёртывания базу данных использует только один процесс, поэтому это не является серьёзной проблемой
Преимущество режима монопольного доступа состоит в том, что дисковый ввод-вывод может быть сокращён тремя способами:
- Нет необходимости увеличивать счётчик изменений в заголовке базы данных для транзакций после первой. Это часто позволяет сэкономить на записи первой страницы как в журнал отката, так и в основной файл базы данных.
- Никакой другой процесс не может изменить базу данных, поэтому никогда не требуется проверять счётчик изменений и очищать пользовательский кэш в начале транзакции.
- Каждая транзакция может быть зафиксирована путём перезаписи заголовка журнала отката нулями, а не удаления файла журнала. Это позволяет избежать необходимости изменять запись каталога для файла журнала и освобождать дисковые секторы, связанные с журналом. Кроме того, следующая транзакция будет перезаписывать существующее содержимое файла журнала, а не дописывать новое содержимое, и на большинстве систем перезапись выполняется значительно быстрее, чем дозапись.
Третья оптимизация — обнуление заголовка файла журнала вместо удаления файла журнала отката — вовсе не зависит от постоянного удержания монопольной блокировки. Эта оптимизация может быть задана независимо от режима монопольной блокировки с помощью прагмы journal_mode
Страницы списка свободных блоков не журналируются
Когда информация удаляется из базы данных SQLite, страницы, использовавшиеся для хранения удалённой информации, добавляются в «список свободных блоков» (freelist). Последующие операции вставки будут брать страницы из этого списка, а не расширять файл базы данных
Некоторые страницы списка свободных блоков содержат критически важные данные; в частности, местоположения других страниц списка свободных блоков. Но большинство страниц списка свободных блоков не содержат ничего полезного. Эти последние страницы списка свободных блоков называются «листовыми» (leaf) страницами. Содержимое листовой страницы списка свободных блоков можно свободно изменять в базе данных, не меняя смысла базы данных никаким образом
Поскольку содержимое листовых страниц списка свободных блоков несущественно, SQLite избегает сохранения содержимого листовых страниц списка свободных блоков в журнале отката. Если листовая страница списка свободных блоков была изменена и это изменение не откатывается при восстановлении транзакции, база данных не пострадает от этого упущения
Аналогично, содержимое новой страницы списка свободных блоков никогда не записывается обратно в базу данных при записи изменений и не читается из базы данных при чтении. Эти оптимизации могут значительно сократить объём ввода-вывода, происходящего при внесении изменений в файл базы данных, содержащий свободное пространство
Обновления одной страницы и атомарная запись секторов
Начиная с версии SQLite 3.5.0, новый интерфейс виртуальной файловой системы (VFS) содержит метод xDeviceCharacteristics, который сообщает о специальных свойствах, которыми может обладать базовое устройство массового хранения. Среди специальных свойств, о которых может сообщать xDeviceCharacteristics, — возможность выполнять атомарную запись сектора
По умолчанию SQLite предполагает, что запись секторов является линейной, но не атомарной. Линейная запись начинается с одного конца сектора и изменяет информацию байт за байтом до другого конца сектора. Если в середине линейной записи происходит потеря питания, часть сектора может оказаться изменённой, тогда как другой конец останется нетронутым. При атомарной записи сектора либо весь сектор перезаписывается, либо ничего в секторе не изменяется
Большинство современных жёстких дисков, по всей видимости, реализуют атомарную запись секторов. При потере питания накопитель использует энергию, запасённую в конденсаторах и/или угловой момент диска, чтобы обеспечить питание для завершения любой выполняемой операции
Тем не менее, между системным вызовом записи и электроникой самого дискового накопителя существует так много уровней, что в реализациях VFS для Unix и Windows придерживаются безопасного подхода и предполагают, что запись секторов не является атомарной
С другой стороны, производители устройств, имеющие больший контроль над своими файловыми системами, могут рассмотреть возможность включения свойства атомарной записи в xDeviceCharacteristics, если их оборудование действительно выполняет атомарные записи
Когда запись секторов является атомарной, размер страницы базы данных совпадает с размером сектора, и изменение в базе данных затрагивает только одну страницу базы данных, SQLite пропускает весь процесс журналирования и синхронизации и просто записывает изменённую страницу непосредственно в файл базы данных. Счётчик изменений на первой странице файла базы данных изменяется отдельно, поскольку никакого вреда не будет, если питание прервётся до того, как счётчик изменений будет обновлён
Типичные ошибки при работе с атомарной фиксацией
Изучив механизм атомарной фиксации в SQLite, стоит отдельно остановиться на ситуациях, которые на практике чаще всего приводят к нарушению целостности базы данных
Первая и наиболее распространённая ошибка — отключение синхронизации через PRAGMA synchronous=OFF. Это значительно ускоряет запись, однако полностью снимает гарантии атомарности при потере питания: операционная система может не успеть сбросить буферы на диск, и журнал отката окажется неполным или вовсе отсутствующим
Вторая ошибка — использование PRAGMA journal_mode=MEMORY в сценариях, где возможно аварийное завершение процесса. В этом режиме журнал отката хранится только в оперативной памяти и полностью теряется при сбое, что делает восстановление невозможным
Третья ошибка — игнорирование предупреждений о неработающем fsync на конкретной платформе. Если операционная система или файловая система не гарантирует завершения записи при вызове fsync, SQLite не может обеспечить атомарность фиксации вне зависимости от настроек. Это особенно актуально для некоторых сетевых файловых систем и встраиваемых платформ
Четвёртая ошибка — предположение о том, что атомарная фиксация нескольких файлов через ATTACH DATABASE работает так же надёжно, как фиксация одного файла, без понимания роли супер-журнала. Если супер-журнал не был создан (например, из-за настроек синхронизации), многофайловая транзакция может быть зафиксирована лишь частично
Ответы на эти вопросы могут быть для вас полезными
Что произойдёт с базой данных SQLite, если питание отключится в середине транзакции?
При следующем обращении к базе данных SQLite обнаружит «горячий» журнал отката и автоматически откатит незавершённые изменения. База данных вернётся в состояние, в котором она находилась до начала прерванной транзакции. Этот процесс происходит прозрачно для приложения
Зачем SQLite выполняет два отдельных сброса (fsync) при записи журнала отката?
Первый сброс гарантирует, что содержимое страниц журнала надёжно записано на диск. Затем в заголовок журнала записывается фактическое количество страниц (вместо нуля), и выполняется второй сброс. Такой порядок защищает от ситуации, когда счётчик страниц попадёт на диск раньше самих данных, что могло бы привести к некорректному откату с мусорными данными
Что такое горячий журнал отката и как SQLite его определяет?
Горячий журнал — это файл журнала отката, оставшийся от прерванной транзакции. SQLite считает журнал горячим, если он существует, не пуст, на файле базы данных нет зарезервированной блокировки, заголовок журнала корректен, и либо в заголовке нет имени супер-журнала, либо указанный супер-журнал существует на диске
Зачем нужен супер-журнал при фиксации нескольких файлов через ATTACH DATABASE?
Супер-журнал обеспечивает атомарность многофайловой транзакции. Он содержит пути ко всем журналам отката участвующих баз данных. При восстановлении после сбоя SQLite проверяет наличие супер-журнала: если он существует, транзакция не была завершена и все журналы отката воспроизводятся. Если супер-журнал удалён, транзакция считается зафиксированной
Как режим монопольного доступа (Exclusive Access Mode) влияет на производительность?
В режиме монопольного доступа SQLite удерживает эксклюзивную блокировку между транзакциями. Это позволяет не обновлять счётчик изменений в заголовке базы данных для каждой транзакции, не проверять кэш на устаревание и перезаписывать журнал отката вместо его удаления и создания заново. На практике это даёт заметный выигрыш в производительности в однопользовательских сценариях, где параллельный доступ не требуется



