Автор: Джонатан Джонсон. Опубликовано 2022-05-22. Последнее обновление 2022-05-23.
Что такое Bonsai Db?
BonsaiDb – это новая база данных, которая должна стать самой удобной для разработчиков базы данных Rust. У BonsaiDb есть уникальный набор функций, предназначенных для решения большого количества распространенных проблем с данными. По ссылке можно узнать подробнее, что такое BonsaiDb?
tl; dr: BonsaiDb работает медленнее, чем сообщалось ранее
Я настроил виртуальную машину сервера Ubuntu 20.04 под управлением ядра 5.4.0-110-generic и размышлял, как лучше отключить компьютер после вызова sync_file_range, когда @justinj снова пришел на помощь, указав, что существует /proc/sysrq-trigger. Он также поделился своим постом в блоге, где рассказывал о проведении аналогичных тестов против fsync. С их помощью он хотел изучить, как создавать надежный журнал базы данных. На следующий день после последнего поста @justinj сообщил, что они отследили один из примеров Nebari и не увидели ни одного выполненного системного вызова fsync. Это произошло из-за неправильного трактования термина "true sink" в std::io::Write. Оказалось, выполнение Write::flush() для std::io::File – это не операция, так как "true sink" было ядром, а не диском.
Я выпустил Nebari v0.5.3 в тот же день. Я запустил набор тестов Nebari и... ничего не изменилось. Я запустил пакет на GitHub Actions – никаких изменений. Я запустил пакет на своем выделенном VPS, который использую для более стабильной среды бенчмаркинга, чем GitHub Actions... никаких изменений. Я запустил пакет на своем Mac ... ужасное замедление. Я расскажу ниже, почему так произошло, но первоначальное впечатление было, что я каким-то образом увернулся от пули.
Несколько дней спустя я заметил, что бенчмарк BonsaiDb Commerce работает медленно. Я тут же понял, что это было связано с изменениями в синхронизации, и начал полностью переписывать индексатор представления и хранилище документов. Несколько дней назад я достиг той стадии, когда мог запускать пакет со своими изменениями. Обеспокоенный, достаточно ли этого, чтобы вернуться к PostgreSQL, я запустил его и... стало работать немного быстрее, но все равно очень медленно.
В остальной части этого текста я рассказываю все, что узнал с тех пор. Поскольку это краткое изложение, позвольте мне закончить с tl; dr: Чтение данных из BonsaiDb по-прежнему очень эффективно, но из-за ошибок в бенчмаркинге запись выполняется довольно медленно для рабочих процессов, которые вставляют или обновляют много данных в одной коллекции. Я все еще взволнован и мотивирован для создания BonsaiDb, но в настоящее время я не уверен, буду ли я по-прежнему писать свой собственный низкоуровневый уровень базы данных. Все предположения о производительности BonsaiDb должны быть обнулены.
Почему мой рефакторинг не помог?
Остаток этого дня и еще 2 следующих были потрачены на профилирование и попытки понять результаты. Затем я попытался проверить свои предположения в изолированном тесте, но мне не удалось добиться значительного прогресса.
На следующий день, попивая кофе, я запустил df, чтобы проверить количество свободного места на диске. Я понял, что каждый раз, когда я отправляю команду, /tmp указывается как отдельная точка монтирования. Я использую Manjaro Linux (основанный на Arch), и хотя с его помощью я мог обычно решить любые проблемы, возникающие с компьютером, я никогда не задумывался о причастности /tmp к упомянутому.
Учитывая, что это отдельная точка монтирования, а не моя основная файловая система, следующий логичный вопрос: какую файловую систему она использует? Ответ: tmpfs, файловая система, которая действует как RAM-диск и никогда не сохраняет ваши файлы, кроме как через memory paging. fsync, по сути, и не операция в такой файловой системе. Многие из моих стандартов и тестов использовали отличный tempfile контейнер.
Несмотря на то, что я периодически работал с Linux с начала 2000-х годов, я никогда не замечал этого факта. Временный каталог не является другой файловой системой на Mac, что является одним из факторов, объясняющих, почему тесты Nebari показали серьезные изменения на Mac, в то время как в Linux изменений не произошло.
Должен ли я продолжать Nebari?
Осознание, что все тесты производительности других баз данных были сильно подпорчены, привело к тому, что нужно было все заново перепроверять. Передо мной встал вопрос: стоит ли просто отказаться от Nebari и использовать другую базу данных для поддержки BonsaiDb? Размышляя над ответом, я понял, что никогда не стремился создать "самую быструю базу данных".
Мое предложение для BonsaiDb всегда было больше про опыт в разарботке и про то, чтобы быть "достаточно хорошим" для "большинства людей". Я верю, что многие разработчики тратят огромное количество сил на создание высокодоступных, а не масштабируемых приложений. Ведь для вторых почти никогда будет универсальных решений. Если я смогу упростить переход из тестового проекта в высокодоступное решение и сделать это с "разумной" производительностью, я достигну своих целей для BonsaiDb.
Преимущества Nebari сводятся к тому, что он адаптирован к потребностям BonsaiDb. Благодаря моему новому подходу к хранению документов и представлений, который использует способность Nebari встраивать произвольную статистику в свое B+Tree, я могу очень эффективно создавать и запрашивать map-reduce представления. Мне еще предстоит ознакомиться с другой низкоуровневой базой данных, написанной на Rust, которая позволяет встраивать пользовательскую информацию внутрь структуры B+Tree, чтобы активировать эти возможности.
Поскольку индивидуальное решение было настолько привлекательным, я решил изучить, что может потребоваться, чтобы сделать Nebari быстрее.
Почему Nebari медлителен?
Чтобы ответить на этот вопрос, мне нужно было исправить свое использование tempfile в бенчмарке Nebari. Но даже после этого Nebari продолжал конкурировать с SQLite во многих тестах, а Sled сообщал поразительные цифры. Это заставило меня усомниться в том, действительно ли транзакции Sled совместимы с ACID, когда они явно запрашивают их сброс.
Видите ли, в ходе моего тестирования я смог определить, что fdatasync() не удалось вернуться менее, чем за 1 миллисекунду на моем компьютере. Вот как выглядят результаты, если тест транзакционной вставки измеряет время, необходимое для выполнения ACID-complained вставки 1 КБ данных в новый ключ:
blobs-insert/nebari-versioned/1KiB
time: [2.6591 ms 2.6920 ms 2.7348 ms]
thrpt: [365.66 KiB/s 371.47 KiB/s 376.07 KiB/s]
blobs-insert/nebari/1KiB
time: [2.6415 ms 2.6671 ms 2.7023 ms]
thrpt: [370.05 KiB/s 374.93 KiB/s 378.57 KiB/s]
blobs-insert/sled/1KiB time: [38.438 us 38.894 us 39.376 us]
thrpt: [24.801 MiB/s 25.108 MiB/s 25.406 MiB/s]
blobs-insert/sqlite/1KiB
time: [4.0630 ms 4.1192 ms 4.1913 ms]
thrpt: [238.59 KiB/s 242.76 KiB/s 246.12 KiB/s]
Как вы можете видеть, Nebari показывает средние результаты – 2,6 мс, SQLite самый медленный с 4,11 мс, а Sled заявляет невероятно коротке 38,9 мкс. После быстрого просмотра исходного кода Sled, Sled не вызывает fdatasync поскольку максимальное время одиночной итерации для Sled составляет 39,4 мкс, и я утверждаю, что fdatasync никогда не завершается менее чем за 1 мс на моем компе. Однако Criterion использует статистику на основе выборки, что означает, что он рассматривает не время отдельных итераций, а скорее время итерации для заданного числа итераций.
Выходя из системы во время каждой отдельной итерации, я вижу, что существуют отдельные итерации, которые занимают столько же времени, сколько вызов fdatasync. Чтобы упростить, я создал три теста для тестирования:
append: Запись в конец файла и вызов fdatasync.
preappend: Когда для записи требуется больше места в файле, расширьте файл с помощью ftruncate перед записью. Вызывайте fdatasync после каждой записи.
sync_file_range: Когда для записи требуется больше места, расширьте файл с помощью ftruncate и вызовите fdatasync после записи. Когда для записи не требуется больше места, вызовите sync_file_range для сохранения вновь записанных данных.
Результаты этого теста:
writes/append time: [2.6211 ms 2.6585 ms 2.7058 ms]
writes/preallocate time: [1.2467 ms 1.2675 ms 1.2923 ms]
writes/syncrange time: [190.59 us 193.03 us 195.83 us]
Наш тест syncrange, похоже, никогда не занимает больше 195,8 мкс. Но мы знаем, что он вызывает fdatasync, так что же происходит? Давайте откроем отчет Criterion в формате raw.csv для syncrange:
group | function | value | throughput_num | throughput_type | sample_measured_value | unit | iteration_count |
writes | syncrange | 2,929,333.0 | ns | 6 | |||
writes | syncrange | 2,932,484.0 | ns | 12 | |||
writes | syncrange | 5,701,286.0 | ns | 18 | |||
writes | syncrange | 5,788,786.0 | ns | 24 |
Criterion не отслеживает каждую итерацию. Он отслеживает партии. Из-за этого итоговая подсчитанная статистика не позволяет увидеть истинное максимальное время итерации. Давайте проведем тот же тест в моем собственном бенчмаркинге:
Label | avg | min | max | stddev | out% |
append | 2.628ms | 2.095ms | 5.702ms | 147.0us | 0.004% |
preallocate | 1.243ms | 612.3us | 4.042ms | 859.3us | 0.004% |
syncrange | 189.1us | 12.83us | 2.847ms | 653.6us | 0.063% |
Использовав этот инструмент, я вижу результаты, которые имеют смысл. Средние значения соответствуют тому, что мы видим из Criterion, но теперь наши минимальные и максимальные значения показывают более широкий диапазон. Мы видим, что даже для бенчмарка syncrange некоторые записи будут занимать 2,8 мс.
Вывод очень важен: используя sync_file_range, Sled может увеличить среднее время записи до 38,9 мкс, хотя иногда операции записи будут длиться дольше из-за необходимости fdatasync при изменении размера файла.
Учитывая, насколько быстрее функция sync_file_range(), безопасно ли ее использовать для обеспечения длительной записи?
Что делает sync_file_range?
Функция sync_file_range() имеет множество режимов работы. По сути, она дает возможность просить ядро фиксировать любые грязные кэшированные данные в файловой системе. Для наших целей наиболее актуален режим работы, когда передаются SYNC_FILE_RANGE_WAIT_BEFORE, SYNC_FILE_RANGE_WRITE и SYNC_FILE_RANGE_WAIT_AFTER.
С помощью этих флагов функция sync_file_range() будет дожидаться записи всех грязных страниц из указанного диапазона на диск. Однако в документации к этой функции сообщается, что она "чрезвычайно опасна".
Что такое "гарантированные записи"?
При записи данных в файл операционная система не сразу записывает биты на физический диск. Это было бы невероятно медленно, даже с современными SSD. Вместо этого операционные системы обычно управляют кэшем базового хранилища и время от времени сбрасывают туда обновленную информацию. Это позволяет выполнять быструю запись и дает операционной системе возможность планировать и изменять порядок операций для повышения эффективности.
Проблема с этим подходом возникает, если питание компа внезапно отключается. Представьте, что пользователь нажимает "Сохранить", программа подтверждает, что она была сохранена, но внезапно питание пропадает. Программа утверждала, что сохранила файл, но после перезагрузки он отсутствует или поврежден. Как это происходит? Возможно, файл был сохранен только в кэше ядра и никогда не записывался на физический диск.
Решение называется flushing или синхронизацией. Каждая операционная система предоставляет одну или несколько функций для обеспечения успешного сохранения всех записей в файл на физическом носителе:
В Linux это fsync(), fdatasync() и sync_file_range().
В Windows это FlushFileBuffers.
На Mac/iOS доступна функция fsync(), но она не дает тех же гарантий, что и Linux. Вместо этого для запуска записи на физический носитель необходимо использовать вызов fcntl с параметром F_FULLFSYNC.
Rust использует правильные API для каждой платформы при вызове File::sync_all или File::sync_data для обеспечения длительной записи. Стандартная библиотека не предоставляет API для вызова базовых API, упомянутых выше. К счастью, libc crate позволяет легко вызывать API, которые нас интересуют для этого текста.
Linux: Является ли sync_file_range жизнеспособным для гарантированной записи?
Справочная страница для sync_file_range() содержит это предупреждение (выделено мной):
Этот системный вызов чрезвычайно опасен и не должен использоваться в переносимых программах. Ни одна из этих операций не записывает метаданные файла. Следовательно, если приложение не выполняет строгую перезапись уже созданных дисковых блоков, нет никаких гарантий, что данные будут доступны после сбоя. Нет пользовательского интерфейса, позволяющего узнать, является ли запись чисто перезаписью. В файловых системах, использующих семантику копирования при записи (например, btrfs), перезапись существующих выделенных блоков невозможна. При записи в предварительно выделенное пространство многие файловые системы также требуют вызовов распределителя блоков, который этот системный вызов не синхронизирует с диском. Этот системный вызов не очищает кэши записи на диск и, следовательно, не обеспечивает целостности данных в системах с энергозависимыми кэшами записи на диск.
Sled использует его для достижения невероятной скорости, и автор знает об этом предупреждении. Один из комментаторов на связанной странице указывает, что в RocksDB есть специальный код для отключения использования API в zfs. Pebble, который является портом / побочным продуктом RocksDB, использует подход, основанный на выборе ext4. И RocksDB, и Pebble, похоже, по-прежнему используют fsync / fdatasync в разных местах для обеспечения долговечности.
Я решил также взглянуть на исходный код PostgreSQL. Они используют асинхронный режим sync_file_range(), чтобы подсказать ОS, что записи необходимо сбросить, но они все равно выдают fsync или fdatasync по мере необходимости.
Я также посмотрел на исходный код SQLite: никаких ссылок. Я также не смог найти никаких соответствующих тем для обсуждения.
Я не эксперт ни в одной из этих баз данных, поэтому к моему беглому просмотру их кода следует отнестись с недоверием.
Я попытался найти какую-либо информацию о надежности sync_file_range для длительных перезаписей в различных файловых системах, и не смог отыскать ничего, кроме этих маленьких фрагментов, уже связанных.
Не имея какого-либо окончательного ответа относительно того, способен ли он обеспечить долговечность в любых файловых системах, я решил проверить это сам.
Тестирование долговечности sync_file_range
Я настроил виртуальную машину сервера Ubuntu 20.04 под управлением ядра 5.4.0-110-generic. Размышляя о том, как лучше выключить компьютер после вызова sync_file_range, @justinj снова пришел на помощь, указав, что существует /proc/sysrq-trigger. Он также поделился своим сообщением в блоге, где рассказывал о проведении аналогичных тестов против fsync, с целью изучить, как создавать надежный журнал базы данных.
Оказывается, если вы напишете o в /proc/sysrq-trigger на компьютере с Linux (требуются разрешения), он немедленно отключится. Это значительно упростило настройку моего тестирования.
Я запустил виртуальную машину с помощью команды:
qemu-system-x86_64 </span>
-drive file=ubuntu-server,format=qcow2 </span>
-drive file=extra,format=qcow2 </span>
-enable-kvm </span>
-m 2G </span>
-smp 1 </span>
-device e1000,netdev=net0 </span>
-netdev user,id=net0,hostfwd=tcp::2222-:22
В другом терминале я выполнил различные примеры из репозитория по ssh. После выполнения каждого примера виртуальная машина автоматически перезагружалась. Цикличное выполнение примеров позволило применять команды в течение длительного времени. Мои результаты таковы:
Filesystem | is sync_file_range durable? |
btrfs | No |
ext4 | Yes |
xfs | Yes |
zfs | No |
Безопасное увеличение длины файла при использовании sync_file_range
Мое первоначальное тестирование sync_file_range показало некоторые сбои, но после дополнительных тестов я заметил, что это происходило только на первом или втором этапе, но никогда не при последующих тестах для файловых систем, которые я назвал долговечными выше.
Есть два примера, которые тестируют sync_file_range:
sync_file_range.rs : При инициализации файла данных нули записываются в файл вручную.
sync_file_range_set_len.rs : При инициализации файла данных вызывается File::set_len() для расширения файла, который в настоящее время задокументирован как:
Если он больше, чем размер текущего файла, то файл будет увеличен до нужного размера, и все промежуточные данные будут заполнены символами 0.
В обоих примерах используется File::sync_all(), и оба примера вызывают File::sync_all в содержащих каталогах для синхронизации изменения длины файла.
В ext4 и xfs мое тестирование показало, что я могу надежно воспроизвести потерю данных при первоначальном запуске в примере sync_file_range_set_len, но не в примере sync_file_range. Последующие запуски были длительными. Почему?
Несмотря на то, что указано в документации Rust, под капотом File::set_len использует ftruncate, который задокументирован как:
Если размер файла увеличивается, расширенная область должна выглядеть так, как если бы она была заполнена нулем.
Различие между "должно отображаться так, как если бы оно было заполнено нулем" и "будет увеличено до размера и все промежуточные данные будут заполнены 0" является тонким, но очень важным при рассмотрении безопасности sync_file_range. В моей предыдущей цитате предупреждения sync_file_range второй акцент, по-видимому, также относится к этим выводам.
Из моего тестирования следует, что использование ftruncate для заполнения страниц 0 будет конфликтовать с sync_file_range при первой операции, но, скорее всего, будет успешным в будущих тестах на ext4 и xfs.
Энергозависимые кэши записи
Обновление 2022-05-23: в комментарии на Reddit правильно указали, что я пропустил обсуждение этой части предупреждения sync_file_range:
Этот системный вызов не очищает кэши записи на диск и, следовательно, не обеспечивает целостности данных в системах с энергозависимыми кэшами записи на диск.
Даже при выполнении всех вышеупомянутых предварительных условий мы не можем гарантировать, что sync_file_range будет работать, если включено кэширование записи. Это связано с тем, что само устройство может иметь непостоянную запись кэша. Если кэширование записи явно не отключено, единственный способ обеспечить безопасность sync_file_range в ext4 и xfs – это убедиться, что используемые устройства не имеют ограничений в записи кэша.
Что касается моего загрузочного диска NVME, я вижу, что у него есть такое:
$ sudo nvme get-feature -f 6 /dev/nvme0n1
get-feature:0x06 (Volatile Write Cache), Current value:0x00000001
Давайте выключим его и снова запустим наш бенчмарк:
$ sudo nvme set-feature -f 6 -v 0 /dev/nvme0n1
set-feature:0x06 (Volatile Write Cache), value:00000000, cdw12:00000000, save:0
Label | avg | min | max | stddev | out% |
append | 5.492ms | 5.123ms | 12.75ms | 564.1us | 0.016% |
preallocate | 2.763ms | 1.665ms | 11.75ms | 1.685ms | 0.006% |
syncrange | 2.025ms | 1.592ms | 5.723ms | 910.1us | 0.064% |
Наше субмиллисекундное время исчезло. Единственная причина, по которой sync_file_range был быстрее, заключалась в том, что он выполнял запись только в непостоянный кэш. При отключении энергозависимого кэша записи преимущества sync_file_range по сравнению со стратегией предварительного распределения уменьшаются.
Выводы о sync_file_range
sync_file_range безопасен для использования только в определенных файловых системах. Из четырех, которые я протестировал, xfs и ext4 кажутся полностью надежными в своих реализациях, а zfs и btrfs совершенно ненадежны в своих реализациях.
sync_file_range безопасен для использования только на полностью инициализированных страницах.
ftruncate для расширения файла не полностью инициализирует вновь выделенные страницы нулями и вместо этого может использовать ярлыки. Это делает использование sync_file_range для пространства, выделенного с помощью ftruncate или аналогичных операций, небезопасным для использования.
Даже при соблюдении всех этих условий энергозависимые кэши записи на диске должны быть отключены для обеспечения полной надежности.
Mac OS/iOS: Обеспечивает ли F_BARRIERFSYNC длительную запись?
Нет. Собственная документация Apple ясно дает это понять:
Некоторым приложениям требуется барьер для записи, чтобы обеспечить сохраняемость данных перед выполнением последующих операций. Большинство приложений могут использовать для этого fcntl(::) F_BARRIERFSYNC.
Используйте F_FULLFSYNC только в том случае, если вашему приложению требуется строгое ожидание сохранения данных.
Отлично, так что fnctl с F_FULLFSYNC - это то, что используется вместо fsync. Давайте продолжим читать.
Обратите внимание, что F_FULLFSYNC представляет собой наилучшую гарантию того, что iOS записывает данные на диск, но данные все равно могут быть потеряны в случае внезапного отключения питания.
Apple действительно пропустила мяч здесь. Согласно всей доступной документации, которую я мог найти: нет никакого способа запустить действительно совместимую с ACID базу данных в Mac OS. Вы можете подобраться поближе, но потеря питания все равно приведет к тому, что запись будет сообщена как успешно выполненная и пропадет после отключения питания.
Одно интересное замечание заключается в том, что SQLite использует F_BARRIERFSYNC по умолчанию для всей своей синхронизации файлов на Mac / iOS. При желании вы можете использовать #pragma, чтобы включить использование F_FULLFSYNC. Учитывая относительные накладные расходы двух API в моем ограниченном тестировании, я могу понять их решение, но я не уверен, что это лучший вариант по умолчанию.
Windows: Существуют ли какие-либо API-интерфейсы для частичной синхронизации файлов?
Нет. Если вы не используете файлы с отображением в памяти, единственным доступным API в Windows является FlushFileBuffers.
Что все это значит для BonsaiDb?
Проницательные читатели, возможно, заметили, что тесты Nebari демонстрировали, что они схожи по производительности с SQLite даже после изменений синхронизации. Это верно для многих показателей, но это не для детального сравнения, и разница является основной причиной замедления работы BonsaiDb.
В SQLite есть много подходов к постоянству, однако давайте рассмотрим версию с журналом, поскольку она довольно проста. При использовании журнала SQLite создает файл, содержащий информацию, необходимую для отмены изменений, которые он собирается внести в файл базы данных. Чтобы обеспечить согласованное состояние, которое может быть восстановлено после отключения питания в любой момент этого процесса, он должен выполнить не менее двух вызовов fsync.
Nebari сходит с рук одна операция fsync из-за ее работы в режиме append-only. Однако в тот момент, когда вы используете тип Roots, выполняется еще одна операция fsync: журнал транзакций. Таким образом, Nebari на самом деле не быстрее, чем SQLite, когда используется журнал транзакций, что является обязательным требованием для многодеревянных транзакций.
Это еще более усугубляется моделью транзакций Nebari с несколькими считывателями и одной записью. Если два потока пытаются выполнить запись в одно и то же дерево, один начнет свою транзакцию и завершит ее, в то время как другой должен терпеливо ждать разблокировки дерева.
Этот двухэтапный метод синхронизации в сочетании с конфликтом из-за нескольких коллекций – то, что привело к остановке коммерческого бенчмарка после того, как fsync фактически выполнял реальную работу. Отдельные рабочие потоки будут создавать резервные копии, ожидая своей очереди для изменения коллекции.
Архитектура Nebari была разработана в октябре, и я потратил бесчисленные часы на профилирование и тестирование ее производительности. Из-за вышеупомянутых проблем с моей методологией многие из моих предположений о производительности были совершенно неверными.
Что дальше?
Из этих результатов ясно, что какое бы решение ни было выбрано для BonsaiDb, оно должно поддерживать способ одновременного выполнения нескольких транзакций. Это гораздо более сложная проблема для решения, и я не уверен, что хочу решать ее сам.
Долгое время я разрабатывал BonsaiDb с минимальной "рекламой". Синдром самозванца мешал мне делиться им большую часть 2021 года. В течение некоторого периода я, наконец, начал чувствовать уверенность в его надежности. Теперь я возвращаюсь к вопросу о том, следует ли мне попробовать новую версию Nebari.
С одной стороны, видя, что Nebari все еще довольно быстр после исправления этой ошибки, я должен доказать, что я могу написать быструю базу данных. С другой стороны, мне так стыдно, что я не заметил этих проблем раньше, и это деморализует, как и мысль о том, сколько времени было потрачено на выстраивание ошибочных предположений. Nebari также потребуется перейти на более сложную архитектуру, из-за чего он частично потеряет привлекательность, которую я в нем видел.
Единственное, что я могу сказать с уверенностью прямо сейчас, это то, что я по-прежнему твердо верю в свое видение BonsaiDb, независимо от того, какой уровень хранения его поддерживает. Я постараюсь поскорее определиться со своими планами, чтобы существующие пользователи не страдали слишком долго.
Наконец, я просто хочу сказать спасибо всем, кто поддерживал меня в этом путешествии. Несмотря на недавний стресс, BonsaiDb и Nebari были интересными и полезными проектами для меня.