PHP, Dart и C++ Drogon
PHP, Dart и C++ Drogon

Вообще говоря, я всегда делал бэкенд исключительно на PHP. Я считаю его отличным языком для бэкенда, вернее не только для бэкенда, а отличным языком. Некоторые не любят PHP за малый порог вхождения. Типа через этот малый порог вхождения входит очень много непрограммистов, которые потом пишут говнокод. Но я придерживаюсь мнения, что PHP никак не виноват в этом. Скорее наоборот. На PHP логика просыпается даже у тех, кто кроме говнокода ничего написать не может. Поэтому, в любом случае, это отличный язык программирования, который каждый год хоронят, но никак похоронить не могут. И в 2026 году он живее всех живых и показывает отличные результаты.

Вторым в списке для сравнения я решил поставить Дарт. Дело в том, что самое узкое место применения Дарта — это как раз-таки применение на серверах. Потому что в мобильной разработке он уже как стандарт, веб, фронтенд и десктоп мало-мальцевски он тоже подтягивает. Но вот на серверах пока что его используют очень мало. Одна из причин на то, что его мало используют на серверах, — это отсутствие библиотек, таких как, например, есть у PHP. Но, сейчас в эпоху LLM, это совсем не значит, что Дарт негоден на серверах. Как покажет сравнение, он очень-таки годен.

Третий кандидат для сравнения — это C++. Это его фреймворк Drogon, который в каком-то там 2014 или 2015 годах взлетел как самый быстрый фреймворк для серверов на бэкенде по скорости. Если честно, я ни разу не писал бэкенд на C++, поэтому это была первая попытка. Просто хотелось посмотреть, как C++ уничтожит двух других оппонентов со своей 100x производительностью.

Что сравнивалось

  • Полный существующий PHP API через PHP-FPM с OPcache и явно документированными исправлениями в одноразовых исследовательских копиях.

  • Независимый Dart API, скомпилированный AOT.

  • Независимый Drogon C++ API, сборка Release. Dart и Drogon не вызывают PHP и не перенаправляют запросы в PHP.

  • В нативных реализациях объявлены 127 методов app.php и реализованы семь операций ai.php; отдельно проверены HTTP upload/download, нативные push-воркеры и доставка открытым клиентам.

  • WebSocket-инфраструктура общая: отдельный Dart gateway для каждого варианта, обращающийся именно к соответствующему API.

Все операции замеров проходят через HTTP → nginx → реальный API → авторизацию/права → SQLite → сериализацию ответа.

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

Условия и воспроизводимость

Сервер: Англия/Лондон, Ubuntu 24.04, 2 vCPU AMD EPYC 9354P, 8 ГБ RAM.

PHP 8.4.22; Dart 3.13.0 linux_x64; Drogon 1.8.7; JsonCPP 1.9.5; ICU 74.2; nginx 1.30.3.

  • Во всех трёх API через SQL-запросы проверены SQLite 3.51.3, WAL, synchronous=FULL, foreign_keys=ON, busy_timeout=5000, page_size=4096.

  • У каждого варианта независимая одинаковая синтетическая база: 1000 строк тестовой сущности, 100 исходных сообщений, одинаковые роли, пользователи, индексы и разрешения. Новая копия базы для каждого числа процессов и повторения.

  • Все рабочие процессы исполнялись от одного пользователя.
    Использован одинаковый приватный nginx, HTTP/1.1 с повторным использованием соединений, без gzip и TLS в серверном тесте.

  • Основной прогон: 1 и 2 процесса, 3 повторения, 200 последовательных запросов на операцию в каждом повторении. В таблицах ниже по 600 исходных наблюдений на ячейку.

  • Основная нагрузка: 100/300/600 запросов/с, по 8 секунд; стресс: 1000/1500/2000 запросов/с, по 5 секунд, 2 процесса, 2 повторения; дополнительная проверка: 8 процессов, 1000/2000 запросов/с, по 3 секунды, 2 повторения.

  • Смесь: 30% история, 20% роли, 10% список чатов, 20% пагинация, 20% отправка сообщения. Сохранение контента измерялось отдельно. У отправляемых сообщений отключены внешние уведомления, но сохраняются полноценная запись, события и сигнал Redis.

  • Генератор посылает запланированный поток с пределом 32 одновременных запросов. Время считается от запланированного момента поступления, включая очередь генератора. Ошибки и завершившиеся запросы учитываются отдельно.

  • Процентили объединены из сырых выборок; это не среднее значение процентилей разных запусков. Исходные файлы и SHA256 перечислены в JSON-сводках.

Ограничение стенда: генератор нагрузки, прокси и рабочий проект используют те же 2 vCPU. Например, при 600 запросах/с генератор тратил около 0,60 мс CPU на запрос. Его CPU и память измерены отдельно. Поэтому значения на перегрузке — наблюдаемая производительность всей этой конфигурации, а не универсальный предел языка или чистый максимум API на выделенном железе.

Тёплые запросы, один рабочий процесс

В каждой ячейке p50 / p95, мс. Меньше — лучше.

Операция

PHP-FPM

Dart AOT

Drogon C++

Снимок ролей и разрешений

2.17 / 2.95

1.01 / 2.02

0.96 / 1.45

История: 80 сообщений

2.72 / 4.16

3.33 / 5.12

3.76 / 4.64

Список чатов

2.13 / 2.91

0.93 / 1.87

0.91 / 1.42

Пагинация: 40 строк

2.18 / 3.17

1.20 / 2.33

1.22 / 1.59

Сохранение сообщения

4.99 / 7.66

2.14 / 4.36

1.85 / 3.25

Сохранение документа

2.35 / 3.60

0.97 / 1.86

0.98 / 1.36

Для отправки сообщения Drogon уменьшил медиану относительно PHP примерно на 63%, Dart — на 57%. Для истории PHP опередил обе текущие нативные реализации. Это разница конкретного кода: наличие C++ само по себе не гарантирует более быстрый результат.

Смешанная нагрузка 600 запросов/с, два процесса

API

Обработано, запросов/с

p50, мс

p95, мс

p99, мс

CPU API, мс/запрос

Память PSS, МиБ

PHP-FPM

524.38

236.76

879.42

1 207.94

1.97

30.91

Dart AOT

599.79

3.42

31.76

167.65

1.14

31.08

Drogon C++

599.92

3.59

8.19

11.99

1.32

12.17

Dart и Drogon выдержали входящий поток 600 запросов/с в этом тесте; PHP с двумя процессами не успевал. Ноль ошибок у PHP здесь означает, что очередь в конце дождалась обработки, а не что задержка была приемлемой. При одном процессе PHP обработал около 377 запросов/с на таком же входящем потоке; p95 достигал 4,58 секунды.

PSS — сумма пропорционально разделённой памяти процессов API, включая мастер PHP-FPM. Она не считает общие страницы целиком по нескольку раз, как сумма RSS. Это наблюдение после серии, не непрерывно измеренный пик. Общий nginx, генератор, Redis, WebSocket gateway и push-воркеры в этой колонке не смешаны с памятью API.

Перегрузка, два процесса:

Входящий поток

API

Обработано, запросов/с

p50, мс

p95, мс

PSS, МиБ

1000

PHP-FPM

560.94

1 971.21

3 853.38

31.37

1000

Dart AOT

996.98

10.86

107.44

33.50

1000

Drogon C++

973.27

54.82

128.49

12.64

1500

PHP-FPM

531.80

4 715.65

8 617.77

31.36

1500

Dart AOT

1 051.33

1 090.88

2 045.36

34.59

1500

Drogon C++

922.35

1 534.06

2 934.37

12.70

2000

PHP-FPM

509.15

6 633.42

13 945.13

31.37

2000

Dart AOT

1 096.23

2 052.18

3 951.29

36.22

2000

Drogon C++

919.18

2 812.01

5 562.40

12.73

При 2000 входящих запросах/с все варианты перегружены. Dart обработал примерно в 2,15 раза больше запросов, чем PHP с теми же двумя процессами; Drogon — примерно в 1,81 раза больше. Это не режим, который стоит принимать за нормальную эксплуатацию: задержки уже измеряются секундами, в реальных проектах при таком - нужно думать над оптимизацией или апгрейдить железо.

Проверка пула из восьми процессов

API

Обработано при входящих 1000/с

Обработано при входящих 2000/с

PSS при 2000/с, МиБ

PHP-FPM

721.33

709.72

46.77

Dart AOT

931.36

927.14

117.60

Drogon C++

950.59

798.98

34.08

Больше процессов помогло PHP: на перегрузке получено около 710–721 запросов/с вместо 509–561 при двух процессах. Поэтому было бы неправильно называть 509 запросов/с пределом PHP. Dart и Drogon от восьми процессов на этих двух vCPU не выиграли; память выросла. Среди проверенных конфигураций лучше выглядит небольшой пул нативных процессов и более широкий PHP-FPM-пул. Для назначения production SLO нужны более длительные прогоны с внешним генератором и реальным распределением данных.

Разбор времени отправки сообщения

Медианы внутренних таймеров при одном процессе, мс:

Этап

PHP-FPM

Dart AOT

Drogon C++

Авторизация

0.23

0.09

0.10

Получение блокировки записи

0.01

0.01

0.01

SQL-записи

0.24

0.10

0.11

Коммит

1.54

0.92

0.86

Сериализация ответа

0.00

0.00

0.01

Внутренний total

3.30

1.33

1.20

Таймеры вложены друг в друга, и их медианы нельзя складывать для получения общей медианы. Внешнее время HTTP включает также прокси, планирование процессов и передачу ответа. В PHP отправка уведомления Redis измерялась отдельно: медиана около 0,25 мс. В нативных API публикация асинхронная.

Коммит остаётся заметной частью отправки: около 0,86–1,54 мс в этих сериях. Переписывание языка не убирает ожидание надёжной записи SQLite. На чтении истории у Dart/Drogon дороже текущая обработка и сериализация: внутренний dispatch примерно 1,46/2,00 мс против 0,64 у PHP; encode примерно 0,42/0,37 против 0,04 мс. Именно этот путь заслуживает следующего профилирования нативных реализаций.

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

От нажатия «отправить» до кадра с галочкой

По 30 измеренных сообщений на вариант после трёх прогревочных; отправка через настоящий WSS, интервал между сценариями 400 мс после подтверждения. Все 90 сообщений подтверждены; ошибок Flutter в журнале нет. Время отсчитывается внутри обработчика, поэтому запуск CLI и доставка команды автоматизации не включены.

Backend

До кадра с галочкой p50, мс

p95, мс

Диапазон, мс

WSS внутри клиента p50, мс

PHP-FPM

117.50

126.22

109.10–126.93

105.71

Dart AOT

120.70

136.97

108.17–138.27

103.93

Drogon C++

117.20

123.44

109.22–125.66

103.05

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

Тёплые внешние HTTPS запросы

Финальный сетевой прогон: 40 измерений на каждую из шести операций для каждого API; всего 720 запросов. По два прогревочных запроса исключены. Значения p50 / p95, мс:

Операция

PHP-FPM

Dart AOT

Drogon C++

Роли

102.97 / 105.42

101.66 / 103.81

101.89 / 102.73

История 80 сообщений

103.72 / 117.38

105.81 / 111.16

103.93 / 105.12

Список чатов

102.83 / 103.97

101.82 / 103.61

101.78 / 103.31

Пагинация 40 строк

102.97 / 104.78

101.66 / 103.34

102.90 / 106.42

Отправка сообщения

104.47 / 105.36

102.60 / 104.57

103.10 / 104.40

Сохранение контента

102.54 / 103.08

101.70 / 102.72

101.81 / 103.00

Подтверждение и доставка второму открытому клиенту по WebSocket

Отдельно 60 сообщений на вариант после двух прогревочных; ACK — подтверждение отправителю; доставка — получение соответствующего сообщения вторым сокетом. Это транспортные измерения, без Flutter. Ошибок и таймаутов в завершённом прогоне нет.

Backend

ACK p50, мс

ACK p95, мс

Доставка p50, мс

Доставка p95, мс

Доставка p99, мс

PHP-FPM

103.75

105.96

105.19

106.86

112.38

Dart AOT

108.74

111.58

109.24

111.68

120.81

Drogon C++

102.47

103.84

103.07

104.17

105.77

Установка TCP-соединения: медиана 99.60 мс, 12 проб. TCP вместе с TLS: 203.18 мс, 6 проб. Это два разных измерения; второе включает первое. Все тёплые результаты выше используют уже установленные соединения. TCP connect показывает масштаб сетевой составляющей около 100 мс, т е по сути большая часть времени уходила на сетевой транспорт, а не на обработку на бэкенде, поэтому особого толку от замены PHP на более высокопроизводительные языки на малых нагрузках - просто нет. Тут вспомнились Дуровы, Цукерберги, киткаты и HPHPHPHP.

Вывод

Для простой настройки сервера и простого поиска специалиста, который сможет быстро настроить бэкенд, при этом довольно близкого уровня производительности - в лидерах, конечно, PHP. Для высокой пропускной способности текущей смешанной нагрузки предпочтительнее Dart AOT. Dart оказался тем самым серединным путем по скорости близок к Drogon, по потреблению памяти к PHP, поэтому свой выбор остановлю на нем. Для минимальной памяти и максимальных нагрузкок — Drogon C++. Выбор между заменой стека и покупкой железа лежит на пользователе, но при текущих ценах на память, C++ может реально сэкономить бюджет.

При сохранении PHP существенное улучшение под этой нагрузкой уже даёт правильно выбранный пул PHP-FPM, хотя в проверенных конфигурациях он уступает нативным вариантам по смешанной пропускной способности. Переводить всё на C++ только из предположения «C++ всегда быстрее» эти данные не поддерживают. 100x я не получил и 10x тоже (возможно, я не знаю каких-то секретов оптимизации C++ и попрошу тех, кто знает - написать подобную статью, с удовольствием прочту), это еще раз показывает, что современные языки программирования в большинстве случаев не настолько тормознутые и выбор C++ только ради увеличения производительности - далеко не всегда себя оправдывает.