Вообще-то смотреть какого цвета потроха у Rust я не собирался. Ковырнул хобби-проект на Go, пошел на GitHub посмотреть состояние fasthttp: развивается ли? Ну хотя бы поддерживается? Вспрокрастинулось. Пошел, посмотрел где fasthttp сидит в бенчмарках TechEmpower. Смотрю: а там fasthttp едва показывает половину того, что удаётся лидеру — какому-то actix на каком-то Rust. Какая боль.
Здесь бы мне сложить ручки, стукнуть головой в пол (трижды) и закричать: "Алилуйя, воистину Rust — истинный бог, как слеп я был раньше!". Но то ли ручки не сложились, то ли лоб пожалел… Вместо этого полез в код тестов, написанных на Go и actix-web тестов на Rust. Чтобы разобраться.
Через пару часов узнал:
- почему Rust-фреймворк actix-web занимает первые позиции во всех тестах TechEmpower,
- как в Java заводится Script.
Сейчас всё расскажу по порядку.
Что за TechEmpower Framework Benchmark?
Если веб-фреймворк демонстрирует, собирается или, скажем, иногда задумывается о том, чтобы шепнуть знакомым "я — быстр", то он наверняка попадет в TechEmpower Framework Benchmark. Популярное такое место на сходить померяться производительностью.
У сайта своеобразный дизайн: вкладки фильтров, раундов, условий и результатов по разным типам тестов разбросаны по странице щедрой рукой. Настолько щедрой и размашистой, что их просто не замечаешь. Но щелкать по вкладкам стоит, информация за ними прячется полезная.
Легче всего попасть на результаты тестов plaintext, "Hello World!" для веб серверов. Авторы фреймворка обычно дают ссылку именно на него: мы, мол, в первой сотне держимся. Дело правильное и полезное. Вообще отдавать plaintext получается хорошо у многих, и лидеры идут плотной группой.
Рядом, в тех самых табах, прячутся результаты тестов других типов (сценариев). Всего их семь, подробнее посмотреть можно здесь. Эти сценарии тестируют не только то, как фреймворк/платформа справляется с обработкой простого http-запроса, но комбинацию с клиентом базы данных, шаблонизатором или JSON-сериализатором.
Есть данные тестов в виртуальной среде, на физическом железе. Кроме графиков есть табличные данные. В общем много интересного, стоит порыться, не просто глянуть позицию "своей" платформы.
Первое, что пришло мне в голову после того, как прошелся по результатам тестов: "А почему всё НАСТОЛЬКО отличается от plaintext?!". В plaintext лидеры идут плотной группой, но когда дело доходит до работы с базой данных, actix-web лидирует со значительным отрывом. При этом показывает стабильное время обработки запроса. Шайтан.
Еще одна аномалия: невероятно производительное решение на JavaScript. Называется ex4x. Оказалось, его код чуть менее чем полностью написан на Java. Используется Java runtime, JDBC. Код на JavaScript транслируется в байткод и склеивает Java библиотеки. Вот буквально взяли — и пристроили Script к Java. Хитростям бледнолицых нет предела.
Как посмотреть код и что там внутри
Код всех тестов есть на GitHub. Всё — в монорепозитории, что очень удобно. Можно клонировать и смотреть, можно смотреть прямо на GitHub. В тестировании участвует больше 300 различных комбинаций фреймворка с сериализаторами, шаблонизаторами и клиентом базы данных. На разных языках программирования, с разным подходом к разработке. Реализации на одном языке находятся рядом, можно сравнить с реализацией на других языках. Код поддерживается сообществом, это не работа одного человека или коллектива.
Код бенчмарков — отличное место для расширения кругозора. Разбирать как разные люди решают одни и те же задачи интересно. Кода не очень много, используемые библиотеки и решения легко выделить. Вот совсем не жалею, что туда забрался. Многое узнал. Прежде всего о Rust.
До того о Rust у меня было очень смутное представление. К любой статье о C, C++, D и особенно Go обязательно пристраивается пара-тройка комментаторов, подробно и с надрывом объясняющих, что суета, ерунда и глупость писать на чём-то другом, пока на свете есть Гасконь Rust. Иногда увлекаются настолько, что приводят примеры кода, чем человека неподготовленного или мало принявшего вгоняют в ступор: "Зачем, зачем, ЗАЧЕМ все эти символы?!"
Потому открывать код было страшновато.
Посмотрел. Оказалось, что программы на Rust можно читать. Более того, читается код настолько хорошо, что я даже установил Rust, попробовал тест скомпилировать и немного с ним повозиться.
Тут чуть не забросил это дело, потому что компиляция длится долго. Очень долго. Будь я Д’Артаньяном или хотя бы просто холериком — рванул бы в Гасконь, и тысяча чертей уныло потянулась бы следом. Но я справился. Чаю опять же попил. Кажется, даже не одну чашку: на моём ноутбуке первая компиляция заняла минут 20. Дальше, правда, всё идёт веселее. Возможно, до следующего большого обновления crates.
А разве дело не в самом Rust?
Нет. Не в языке программирования дело.
Конечно же Rust — язык замечательный. Мощный, гибкий, пусть с непривычки и многословный. Но сам по себе язык писать быстрый код не будет. Язык — один из инструментов, одно из решений, принятых программистом.
Как я говорил — отдавать plaintext быстро получается у многих. Производительность фреймворков actix-web, fasthttp и еще десятка других при обработке простого запроса вполне сравнима, то есть техническая возможность поконкурировать с Rust у других языков есть.
Вот сам actix-web, конечно "виноват": быстрый, прагматичный, отличный продукт. Сериализация удобная, шаблонизатор хороший — тоже очень помогают.
Заметнее всего отличаются результаты тестов, работающих с базой данных.
Немного покопавшись в коде, я выделил три основных отличия, которые (как мне кажется) помогли тестам actix оторваться от конкурентов в синтетических тестах:
- Конвейерный (pipelined) режим работы tokio-postgres;
- Использование одного соединения тестом на Rust вместо пула соединений тестом, написанным на Go;
- Обновление бенчмаркамм actix нескольких записей одной командой, отправляемой по упрощенному протоколу (simple query), вместо отправки нескольких команд UPDATE.
Что еще за конвейерный режим?
Вот фрагмент из документации tokio-postgres (используемого в бенчмарке клиентской библиотеки PostgreSQL), объясняющий что её разработчики имеют в виду:
Sequential Pipelined
| Client | PostgreSQL | | Client | PostgreSQL |
|----------------|-----------------| |----------------|-----------------|
| send query 1 | | | send query 1 | |
| | process query 1 | | send query 2 | process query 1 |
| receive rows 1 | | | send query 3 | process query 2 |
| send query 2 | | | receive rows 1 | process query 3 |
| | process query 2 | | receive rows 2 | |
| receive rows 2 | | | receive rows 3 | |
| send query 3 | |
| | process query 3 |
| receive rows 3 | |
Клиент в pipelined (конвейерном) режиме не ждёт ответа PostgreSQL, а отсылает следующий запрос, пока PostgreSQL обрабатывает предыдущий. Видно, что так можно обработать ту же последовательность запросов к базе данных ощутимо быстрее.
Если соединение в конвейерном режиме будет дуплексным (обеспечивающее возможность получения результатов параллельно с отправкой), это время может еще немного сократиться. Кажется, уже есть экспериментальная версия tokio-postgres, где открывается именно дуплексное соединение.
Поскольку на каждый SQL-запрос, отправленный на выполнение, клиент PostgreSQL отправляет несколько сообщений (Parse, Bind, Execute и Sync), и получает на них ответ, конвейерный режим будет эффективнее даже при обработке одиночных запросов.
А почему в Go не так?
Потому что в Go обычно используются пулы соединений с базой данных. Соединения не предполагается использовать параллельно.
Если запустить те же SQL-запросы через пул, а не одно соединение, то с обычным последовательным клиентом теоретически можно получить даже меньшее время их выполнения, чем при работе через одно соединение, будь оно трижды конвейерным:
| Connection | Connection 2 | Connection 3 | PostgreSQL |
|----------------|----------------|----------------|-----------------|
| send query 1 | | | |
| | send query 2 | | process query 1 |
| receive rows 1 | | send query 3 | process query 2 |
| | receive rows 2 | | process query 3 |
| | receive rows 3 | |
Выглядит так, будто овчинка (конвейерный режим) выделки не стоит.
Только вот при высокой нагрузке количество соединений с сервером PostgreSQL может быть проблемой.
А при чём тут вообще количество соединений?
Тут дело в том как сервер PostgreSQL реагирует на увеличение количества подключений.
Левая группа столбцов демонстрирует взлёт и падение производительности PostgreSQL в зависимости от количества открытых соединений:
(Взято из поста Percona)
Видно, что при увеличении количества открытых соединений производительность сервера PostgreSQL стремительно падает.
Кроме того, открытие прямого соединения — не "бесплатно". Сразу после открытия клиент отсылает служебную информацию, "договаривается" с сервером PostgreSQL о том, как будут обрабатываться запросы.
Поэтому на практике приходится ограничивать количество активных соединений с PostgreSQL, часто дополнительно пропуская их через pgbouncer или еще какой odyssey.
Так почему actix-web оказался быстрее?
Во-первых сам actix-web чертовски быстр. Именно он задаёт "потолок", и он чуточку выше, чем у других. Другие использованные библиотеки (serde, yarde) тоже очень, очень производительны. Но мне кажется, что в тестах, работающих с PostgreSQL удалось оторваться потому, что сервер actix-web запускает один поток на ядро процессора. В каждом потоке открывается всего одно соединение с PostgreSQL.
Чем меньше активных подключений — тем быстрее работает PostgreSQL (см. графики выше).
Клиент, работающий в конвейерном режиме (tokio-postgres), позволяет эффективно использовать одно соединение с PostgreSQL для параллельной обработки пользовательских запросов. Обработчики http-запросов сваливают свои SQL-команды в одну очередь и выстраиваются в другую — на получение результатов. Результаты весело разгребаются, задержки минимальны, все счастливы. Общая производительность выше, чем у системы с пулом соединений.
Так нужно отказаться от пула, написать конвейерный клиент PostgreSQL, и сразу придёт счастье и скорость невероятная?
Возможно. Но не всем и сразу.
Когда конвейерный режим вряд ли спасет и уж точно не сохранит
Схема, использованная в коде бенчмарка не будет работать с транзакциями PostgreSQL.
В бенчмарке транзакции не нужны и код написан с учетом того, что транзакций не будет. На практике они случаются.
Если код бэкэнда открывает транзакцию PostgreSQL (например, чтобы сделать изменение в двух разных таблицах атомарным), все команды, отправленные через это соединение, выполнятся внутри этой транзакции.
Поскольку соединение с PostgreSQL используется параллельно, в него валится всё вперемешку. К тем командам, которые должны выполниться в транзакции по замыслу разработчика, подмешаются sql-команды, инициированные параллельными обработчиками http-запросов. Получим случайную потерю данных и проблемы с их целостностью.
Так что здравствуй транзакция — прощай параллельное использование одного соединения. Придётся позаботиться о том, чтобы соединение не использовалось другими обработчиками http-запросов. Нужно будет либо остановить обработку входящих http-запросов до закрытия транзакции, либо для транзакций использовать пул, открывая несколько соединений с сервером БД. Реализации пула для Rust есть, и не одна. Более того, они в Rust существуют отдельно от реализации клиента базы данных. Можно выбирать по вкусу, цвету, запаху или наугад. В Go так не получается. Сила дженериков (generics), ага.
Важный момент: в тесте, код которого я смотрел, транзакции не открываются. Там этот вопрос просто не стоит. Код бенчмарка оптимизирован под конкретную задачу и вполне конкретные условия работы приложения. Решение об использовании одного подключения на поток сервера принято наверняка осознанно и оказалось очень эффективным.
Есть в коде бенчмарка еще что-то интересное?
Да.
Сценарий, по которому проводится измерение производительности, прописан очень подробно. Как и критерии, которым должен удовлетворять код, участвующий в тестах. Одним из них является то, что все запросы к серверу базы данных должны выполняться последовательно.
Следующий (слегка сокращенный) фрагмент кода выглядит так, будто критерию этому не удовлетворяет:
let mut worlds = Vec::with_capacity(num);
// Отправляем num однотипных запросов к PostgreSQL
for _ in 0..num {
let w_id: i32 = self.rng.gen_range(1, 10_001);
worlds.push(
self.cl
.query(&self.world, &[&w_id])
.into_future()
.map(move |(row, _)| {
// ...
}),
);
}
// Ждём завершения всех запросов
stream::futures_unordered(worlds)
.collect()
.and_then(move |worlds| {
// ...
})
Выглядит всё как типичный запуск параллельных процессов. Но, поскольку используется одно соединение с PostgreSQL, запросы к серверу базы данных отправляются последовательно. Один за другим. Как и требуется. Никакого криминала.
Почему так? Ну, во-первых, в коде (он приведен в редакции, отработавшей в 18 раунде) еще не используется async/await, он появился в Rust позже. А через futures num
SQL-запросов отправить проще "параллельно" — так, как в коде выше. Это позволяет получить некоторый дополнительный прирост производительности: пока PostgreSQL принимает и обрабатывает первый SQL-запрос, ему скармливаются остальные. Веб-сервер не ждёт результат каждого, а переключается на другие задачи и возвращается к обработке http-запроса только когда все SQL-запросы выполнены.
Для PostgreSQL бонус в том, что однотипные запросы в одном контексте (подключении) идут подряд. Вероятность, что план запроса не будет перестраиваться, повышается.
Получается, преимущества конвейерного режима (см. диаграмму из документации tokio-postgres) вовсю эксплуатируется даже при обработке единичного http-запроса.
Что еще?
Использование упрощенного протокола (simple query) для пакетного обновления
Протокол обмена информацией между клиентом и сервером PostgreSQL допускает альтернативные способы выполнения SQL-команд. Обычный (Extended Query) протокол предполагает отправку клиентом нескольких сообщений: Parse, Bind, Execute и Sync. Альтернативой является упрощенный (Simple Query) протокол, по которому для выполнения команды и получения результатов достаточно одного сообщения — Query.
Ключевым отличием обычного протокола является передача параметров запроса: они передаются отдельно от самой команды. Так безопаснее. Упрощенный протокол предполагает, что все параметры SQL-запроса будут преобразованы в строку и включены в тело запроса.
Интересным решением, использованным в бенчмарках actix-web, было обновление нескольких записей таблицы одной командой, отправляемой по протоколу Simple Query.
По условиям бенчмарка при обработке пользовательского запроса веб-сервер должен обновить несколько записей в таблице, записать случайные числа. Очевидно, что обновлять записи по очереди последовательными запросами дольше, чем одним запросом, обновляющим все записи разом.
Запрос, формируемый в коде теста, выглядит примерно так:
UPDATE world SET randomnumber = temp.randomnumber FROM (VALUES (1, 2), (2, 3) ORDER BY 1) AS temp(id, randomnumber) WHERE temp.id = world.id
Где (1, 2), (2, 3)
— пары идентификатор строки/новое значение поля randomnumber.
Количество обновляемых записей переменно, подготавливать (PREPARE) запрос заранее нет смысла. Поскольку данные для обновления — числовые, и источнику можно доверять (сам код теста), то риска SQL injection нет, данные просто включаются в тело SQL и всё отправляется по протоколу Simple Query.
Вокруг Simple Query ширятся слухи. Мне встречалась рекомендация: "Работайте только по Simple Query протоколу, и всё будет быстро и хорошо". Я воспринимаю её с большой долей скептицизма. Simple Query позволяет уменьшить количество сообщений, отсылаемых серверу PostgreSQL за счёт переноса обработки параметров запроса на сторону клиента. Виден выигрыш для динамически формируемых запросов с переменным числом параметров. Для однотипных SQL-запросов (которые встречаются чаще) выигрыш не очевиден. Ну и то, насколько безопасной получится обработка параметров запроса, в случае Simple Query определяет реализация клиентской библиотеки.
Как я писал выше, в данном случае тело SQL запроса формируется динамически, данные числовые и генерируются самим сервером. Идеальная комбинация для Simple Query. Но даже в этом случае стоит протестировать другие варианты. Альтернативы зависят от платформы и клиента PostgreSQL: pgx (клиент для Go) даёт возможность отправить пакет команд, JDBC — выполнить одну команду несколько раз подряд с разными параметрами. Оба решения могут работать с той же скоростью или даже оказаться быстрее.
Так почему Rust лидирует?
Лидирует, конечно же, не Rust. Лидируют тесты на основе actix-web — именно он задаёт "потолок" производительности. Есть еще rocket и iron, занимающие скромные позиции. Но на текущий момент именно actix-web определяет потенциал использования Rust в веб разработке. Как по мне — потенциал очень высокий.
Другой неочевидный, но важный "секрет" сервера на основе actix-web, позволивший занять первые места во всех бенчмарках TechEmpower — в том, как он работает с PostgreSQL:
- Открывается всего одно соединение с PostgreSQL на поток веб-сервера. В этом соединении используется конвейерный режим, позволяющий эффективно использовать его для параллельной обработки пользовательских запросов.
- Чем меньше активных подключений — тем быстрее отвечает PostgreSQL. Скорость обработки пользовательских запросов увеличивается. При этом под нагрузкой вся система работает устойчивее (задержки при обработке входящих запросов ниже, растут они медленнее).
Там, где важна скорость, такой вариант работы наверняка будет быстрее, чем при использовании мультиплексоров (таких как pgbouncer и odyssey). И уж точно он оказался быстрее в бенчмарках.
Очень интересно как async/await, появившийся в Rust, и недавняя драма с actix-web повлияют на популярность Rust в веб разработке. А еще интересно как изменятся результаты тестов после переработки их на async/await.