SQLite быстра, но можно ли сделать её ещё быстрее? Исследователи из Университетов Хельсинки и Кембриджа задались этим вопросом и опубликовали работу Serverless Runtime / Database Co-Design With Asynchronous I/O. В ней они продемонстрировали возможность 100-кратного снижения времени задержки, и ниже я в общих чертах эту их работу прокомментирую.

Речь пойдёт об исследовании возможностей Limbo — переписанной на Rust версии SQLite.



Обратите внимание, что это довольно короткий доклад. Акцент в нём сделан на бессерверных и периферийных вычислениях. Тем не менее полученную информацию можно применить в более широких областях.

Основная идея: ускорить работу SQLite можно с помощью асинхронного ввода/вывода и разделения хранилища.

▍ io_uring


В этом докладе мне больше всего понравилось два момента: то, как авторы объяснили выполнение запросов и принцип работы io_uring.

Подсистема io_uring в ядре Linux предоставляет интерфейс для асинхронного ввода/вывода. Её название происходит от кольцевых буферов (ring buffers), совместно используемых пространством пользователя и пространством ядра для сокращения вычислительных издержек при копировании между ними [3]. Подсистема io_uring позволяет приложению отправить запрос ввода/вывода и продолжить параллельно выполнять другие задачи, пока от ОС не поступит уведомление о завершении запрошенной операции.

Используя io_uring, приложение сначала производит системный вызов io_uring_setup() для настройки двух областей памяти: очереди отправки и очереди завершения. После этого приложения отправляют в первую из них запросы ввода/вывода и вызывают io_uring_enter(), чтобы ОС начала обработку этих запросов. Тем не менее, в отличие от блокирующих выполнение вызовов read() и write(), вызов io_uring_enter() не блокирует поток, возвращая управление пространству пользователя. Это позволяет приложению параллельно выполнять другую работу, периодически опрашивая очередь завершения на предмет окончания обработки его запроса ввода/вывода.

Авторы также описывают процесс выполнения SQLite запроса, что станет актуально позже:

Сначала приложение открывает базу данных с помощью функции sqlite3_open(). Поскольку в SQLite для хранения данных используются файлы, эта функция для их открытия вызывает низкоуровневую функцию ввода/вывода open() из POSIX. После этого приложение подготавливает SQL-команду, используя функцию sqlite3_prepare(), которая преобразует команды SQL, такие как SELECT и INSERT, в последовательности инструкций байткода. Далее оно выполняет эту команду с помощью функции sqlite3_step(). Эта функция выполняет последовательность инструкций байткода, пока в рамках запроса не перестанут создаваться строки для считывания, после чего завершается. Когда присутствует строка данных, функция возвращает SQLITE_ROW, а при завершении команды — SQLITE_DONE.

Внутренне sqlite3_step() вызывает бэкенд-модуль постраничного вывода (pager), обходя B-деревья базы данных, представляющие таблицы и строки. Если страница B-дерева в кэше страниц SQLite отсутствует, её приходится считывать с диска. Для считывания содержимого страницы в память SQLite использует синхронный ввод/вывод в виде системного вызова read() из POSIX. Это означает, что функция sqlite3_step() блокирует поток ядра, вынуждая приложения задействовать больше потоков для параллельного выполнения работы во время ожидания завершения ввода/вывода.

▍ Вводная часть


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

Одним из решений является колокация данных на самой периферии. Но лучше будет встроить базу данных в среду выполнения этого периферийного приложения. Тогда задержка связи с БД станет нулевой — мечта в реальности.

Это уже реализовано в механизме Cloudflare Workers, но он предоставляет интерфейс KV (в виде пар ключ-значение). Однако авторы работы утверждают, что KV подходит не для всех предметных областей. Отображение табличных данных в модель KV создаёт сложности для разработчиков и накладывает издержки в виде (де)сериализации. SQL может справиться намного лучше, и решит эту задачу встраиванием SQLite непосредственно в бессерверную среду.

SQLite использует синхронный ввод/вывод. Традиционные системные вызовы POSIX read() и write() блокируют поток, пока операция не будет завершена. Для небольших приложений это нормально, но когда на сервере работают сотни баз данных, такой механизм становится узким местом. В этом случае крайне важна максимальная эффективность использования ресурсов.

Но SQLite имеет собственные недостатки: конкурентность и мультиарендность. Поскольку ввод/вывод синхронен, а значит сопряжён с блокировками, это ведёт к конкурированию приложений за ресурсы, что также повышает задержку.

Почему нельзя просто перенести SQLite в io_uring?

Заменить системные вызовы ввода/вывода из POSIX на io_uring не так просто, и архитектуру приложений, использующих блокирующий ввод/вывод, придётся перестроить под асинхронную модель io_uring. В частности, приложениям нужно будет обрабатывать отправку запросов ввода/вывода в своём потоке управления. В случае SQLite библиотеке потребуется возвращать управление приложению, пока выполняется ввод/вывод.

Иными словами, придётся переписать львиную долю SQLite. Исследователи же пошли другим путём: переписали SQLite на Rust, используя io_uring.

▍ Limbo



Перевод
Рис. 1: изменение SQLite под асинхронный ввод/вывод. Архитектура SQLite подразумевает синхронность, то есть поток приложения при выполнении ввода/вывода на границе интерфейса ОС блокируется. Чтобы реализовать асинхронный ввод/вывод, мы предлагаем изменить SQLite — на уровне виртуальной машины и ниже — чтобы асинхронно отправлять запросы ввода/вывода, используя интерфейс io_uring, и возвращать управление потоку приложения. Это позволит каждому приложению параллельно выполнять и вычислительные задачи, и задачи ввода/вывода.

Они перестроили уровни «Virtual machine» и «B-tree» для поддержки асинхронного ввода/вывода, заменив синхронные инструкции байткода их асинхронной альтернативой:



Рассмотрим в качестве примера одну инструкцию — Next. Что она делает? Она продвигает курсор и может запрашивать следующую страницу. Во время выполнения дискового ввода/вывода она блокируется. База данных отправляет вызов Next виртуальной машине и блокируется, пока страница не будет получена с диска и возвращена вызывающему коду.

В асинхронной же версии сначала отправляется NextAsync, которая тут же возвращает результат. Теперь вызывающий код может блокироваться или выполнять другие операции. Асинхронный ввод/вывод исключает блокировку и минимизирует конкурентность. Вот сравнение двух этих подходов в байткоде:



Тем не менее для оптимизации использования ресурсов авторы предлагают ещё кое-что: разделение механизмов запроса и хранения, то есть «Разделённое хранилище». Я расписал основы этой идеи в статье «Disaggregated Storage — a brief introduction».

▍ Анализ и результаты


В целях тестирования авторы симулировали мультиарендную бессерверную среду, где каждый арендатор получает собственную встроенную базу данных. Количество арендаторов в тестировании варьировалось от 1 до 100 с шагом в 10. На каждого арендатора SQLite получала по одному потоку, в котором выполнялся оцениваемый запрос. Далее авторы 1 000 раз выполнили SELECT * FROM users LIMIT 100. В отношении Limbo они проделали то же самое, но использовали корутины Rust.

Результат показывает выдающееся 100-кратное уменьшение задержки выполнения при p999. Также было зафиксировано, что задержка запросов SQLite с ростом числа потоков не растёт.


График изменения задержки запросов в SQLite (оранжевый — это limbo). Меньше — лучше.

Но исследование продолжается, и в работе упоминается пара вопросов, на которые ещё предстоит ответить. Эти вопросы авторы озвучивают в разделе «Future Work», где говорят о выполнении дополнительных бенчмарков со множеством операций чтения и записи. Преимущества становятся заметны только при p999 и выше. При p90 и p99 производительность практически та же, что и в SQLite. (Может ли дело быть в том, что выполняется всего по одному запросу?)

Код Limbo является открытым и лежит здесь.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (17)


  1. MonkAlex
    10.01.2025 13:41

    Судя по https://github.com/tursodatabase/limbo/blob/main/COMPAT.md - совместимость и с SQL и SQLite неполная. Можно ли использовать инструмент для чего-то кроме экспериментов - непонятно.

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


  1. outlingo
    10.01.2025 13:41

    заменив синхронные инструкции байткода их асинхронной альтернативой

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

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


    1. mayorovp
      10.01.2025 13:41

      А с чего бы порядок выполнения операций записи изменился просто от перехода с синхронного i/o на асинхронный?


      1. wigneddoom
        10.01.2025 13:41

        А кто гарантирует порядок в случае асинхронного I/o?


        1. mayorovp
          10.01.2025 13:41

          Код, который этот i/o, ну, вызывает?


          1. wigneddoom
            10.01.2025 13:41

            Правильно! Только нужно копать глубже, сможет ли ваш асинхронный код на уровне языка программирования обеспечить упорядоченную запись. А как там на уровне ОС, контроллеров дисков? И если мы говорим о БД, то о всём этом нужно задумываться.


        1. alan008
          10.01.2025 13:41

          Можно же выполнять операции асинхронно относительно вызывающего кода, но ставить их в очередь, которая обеспечивает именно последовательное выполнение (в том же порядке, как операции были помещены в очередь).


          1. outlingo
            10.01.2025 13:41

            Ага, и вот вы насабимитили в очередь и ждете перед последовательного завершения всех операций. По сути, вы скатились в обычный pread/pwrite, только теперь у вас добавилась очередь запросв, тред опроса завершения I/O, механизмы ожидания завершения I/O. Зато асинхронно.


          1. wigneddoom
            10.01.2025 13:41

            Можно, но как гарантировать что прям до диска всё дойдёт в нужном порядке, тот же posix aio это очередь которая обрабатывается несколькими нитями, кто первый встал того и тапки.

            Я к тому, что в случае БД - системного ПО, всё гораздо сложнее.


      1. outlingo
        10.01.2025 13:41

        Ну тривиальный пример - вы пишете в два места большого файла. Они лежат в разных блоках прошивка решила что блок в который вы отправляете запись надо покомпактить. Запись в него откладывается пока компакт не будет сделан. А во второй блок можно писать сразу. Так что вторая операция завершится раньше первой.

        Ну или если не вдаваться в совсем сложные материи, то у вас может быть raid0 или linear multidevice том на нескольки устройствах, вы отправляете 128 записей асинхронно, первый диск медленней чем второй, те операции которые легли на второй диск закончатся раньше чем те что на первый.

        И из-за всей этой кухни разработчики блочных драйверов в линуксе совсем не просто так ввели флаги preflush и fua в команды, и разработчики файловых систем не просто так упарываются с журналами и redirect write, наверное они что-то знают, вам не кажется?


        1. mayorovp
          10.01.2025 13:41

          А что тут меняется от появления именно асинхронного i/o?

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


          1. outlingo
            10.01.2025 13:41

            то в чём, блин, проблема от асинхронности?

            В куче добавляющихся костылей, когда вам приходится реализовывать pread/pwrite прыжками вокруг асинхронных API, тредов и очередей


            1. wigneddoom
              10.01.2025 13:41

              Может я и не прав, но оппонент скорее всего оперирует высокоуровневыми языками, где всё это скрыто под капотом. Поэтому и нет понимания.


          1. wigneddoom
            10.01.2025 13:41

            Если это одна задача, которая ждёт окончания одной операции прежде чем приступить к другой - то в чём, блин, проблема от асинхронности?

            Если одна задача (поток) ждёт окончания предыдущей операции прежде чем приступить к другой, то это синхронные операции. И тут конечно нет никаких проблем от асинхронности. т.к. её тут нет.


  1. johnfound
    10.01.2025 13:41

    10^7мкс это 10 секунды. На "select * from users limit 100"? Что-то не верится. Обычно такие запросы как раз выполняются за считанные микросекунды.

    Что-то не так с описанием эксперимента.

    А и да – а как же ACID?


    1. kholodovitch
      10.01.2025 13:41

      "К чёрту ACID" читается между строк. Как я понял, на D(urability) они в открытую "положили", не стесняясь.


    1. mayorovp
      10.01.2025 13:41

      C ACID никак, но не потому что асинхронность, а потому что транзакции недоделаны.

      Однако, мне вот теперь интересно, сколько прироста производительности было вызвано асинхронностью, а сколько - недоделанными транзакциями.