PHP не медленный, медленный FPM: 40 000 пользователей на сервере за $12

PHP может быть быстрым. Но готовы ли вы платить? Янтарная пиксельная матрица; около 4 200 RPS — расчётная оценка.

Laravel на PHP-FPM выдержал на сервере за $12 в месяц 750 одновременных пользователей. Решение на голом PHP и Swoole на той же машине и с той же задачей держит 40 000. Ниже разбираем, откуда разница в 53 раза и чего она стоит.

Сразу о том, чей это материал. Челлендж, правила, нагрузочный тест и все итоговые цифры принадлежат автору репозитория arjaythedev/twelve-dollar-server-challenge. Сначала он сравнил восемь языков с популярными фреймворками в первом ролике, потом предложил сообществу написать решения быстрее, сам прогнал их на дроплете и показал результаты во втором ролике. Код решений написали участники, в статье — разбор их кода и подборка мнений инженеров.

Источники цифр и авторы решений
ЧтоЧьёГде смотреть
Правила, API, нагрузка k6arjaythedev, автор челленджаREADME, bench/load.js
750 пользователей у Laravel на PHP-FPMarjaythedevпервый ролик
2 700 у голого PHP на FPM, 40 000 и 39 000 у решений на Swoole, цифры других языковarjaythedevролик с результатами, часть про PHP — 10:32–11:51
Решение на Swoole в 252 строкиMatt ColemanPR #24
Решение на Swoole с патчем рантаймаTobias ZimpelPR #28
Symfony на FrankenPHPPaweł KlebbaPR #40
CodeIgniter 4 на FrankenPHPMelroy van den BergPR #50
Локальные замеры участниковавторы PRREADME в каждом PR
Правила, API, нагрузка k6
arjaythedev, автор челленджа
README, bench/load.js
750 пользователей у Laravel на PHP-FPM
arjaythedev
первый ролик
2 700 у голого PHP на FPM, 40 000 и 39 000 у решений на Swoole, цифры других языков
arjaythedev
ролик с результатами, часть про PHP — 10:32–11:51
Решение на Swoole в 252 строки
Matt Coleman
PR #24
Решение на Swoole с патчем рантайма
Tobias Zimpel
PR #28
Symfony на FrankenPHP
Paweł Klebba
PR #40
CodeIgniter 4 на FrankenPHP
Melroy van den Berg
PR #50
Локальные замеры участников
авторы PR
README в каждом PR

Код в репозитории распространяется по лицензии MIT, фрагменты ниже приводим с указанием автора. В ролике авторы PHP-решений не названы, поэтому мы сопоставили их с PR по описанию. «250 строк и включённый JIT» — это PR #24, «пропатчил Swoole ради памяти простаивающих соединений» — PR #28. PR на момент написания не слиты, и письменной таблицы результатов нет: итоговые цифры есть только в ролике.

Главная мысль статьи. PHP проигрывает в таких тестах из-за модели запуска, а не из-за языка. Если перестать поднимать приложение на каждый запрос, тот же PHP держит в 15 раз больше пользователей, чем лучший вариант на FPM. Цена — отказ от изоляции запросов, и о ней тоже будет отдельный раздел.

Что мерит челлендж

Счёт — это число виртуальных пользователей, которых сервер держит 5 минут при p95 ниже 500 мс, p99 ниже 1 с и ошибках меньше 1%. Для PHP это прежде всего задача про десятки тысяч простаивающих соединений и только потом про скорость кода.

Правила из README и то, что они значат для PHP:

Правила челленджа и последствия для PHP
ПравилоЧто это значит для PHP
Дроплет DigitalOcean Basic на Ubuntu 24.04: 1 shared vCPU, 2 ГБ RAM, без swapОдин процесс выгоднее нескольких, а память на соединение — главный ресурс
Только SQLite, схема фиксирована; в базе 50 000 пользователей, 500 000 постов и 2 млн лайковДрайвер — PDO SQLite внутри процесса; индекс или Redis не добавить
Никакого кэша между запросами, подпись JWT проверяется каждый разДолгоживущий процесс может держать только соединение с БД и подготовленные запросы — их правила разрешают явно
Запись закоммичена до ответа: WAL и synchronous=NORMAL или строже, group commit разрешёнКоммит в WAL — это write(), fsync уходит в чекпоинты
Nginx по желанию: либо за ним на 127.0.0.1:3000, либо напрямую на :80У Nginx из комплекта worker_connections 16384, поэтому за ним не больше ~16 000 пользователей. Все четыре PHP-решения работают напрямую
Тюнинг ядра запрещён, настройки внутри процесса — можноmemory_limit, JIT, GC и патчи своих зависимостей — честная игра
Дроплет DigitalOcean Basic на Ubuntu 24.04: 1 shared vCPU, 2 ГБ RAM, без swap
Один процесс выгоднее нескольких, а память на соединение — главный ресурс
Только SQLite, схема фиксирована; в базе 50 000 пользователей, 500 000 постов и 2 млн лайков
Драйвер — PDO SQLite внутри процесса; индекс или Redis не добавить
Никакого кэша между запросами, подпись JWT проверяется каждый раз
Долгоживущий процесс может держать только соединение с БД и подготовленные запросы — их правила разрешают явно
Запись закоммичена до ответа: WAL и synchronous=NORMAL или строже, group commit разрешён
Коммит в WAL — это write(), fsync уходит в чекпоинты
Nginx по желанию: либо за ним на 127.0.0.1:3000, либо напрямую на :80
У Nginx из комплекта worker_connections 16384, поэтому за ним не больше ~16 000 пользователей. Все четыре PHP-решения работают напрямую
Тюнинг ядра запрещён, настройки внутри процесса — можно
memory_limit, JIT, GC и патчи своих зависимостей — честная игра

Что такое «40 000 пользователей». Каждый виртуальный пользователь из bench/load.js открывает ленту, ждёт 3–7 с, открывает пост, ждёт 3–8 с, с вероятностью 15% лайкает, с вероятностью 2% пишет пост и отдыхает 5–15 с. В среднем цикл длится около 20,5 с и делает 2,17 запроса:

Значит, 40 000 пользователей — это примерно 4 200 запросов в секунду, из них около 8% записей. Без Nginx каждый пользователь держит своё keep-alive соединение, так что на сервере открыто 40 000 сокетов, почти все в простое. Расчёт по входным данным скрипта нагрузки.

Как ставят оценку. По README: k6 работает с отдельной машины, прогрев — 1 000 пользователей на 2 минуты, затем удвоение с 2 500 до первого провала, сужение до шага в 250 и одно 5-минутное подтверждение. Каждый уровень — один прогон на shared vCPU, поэтому разница в 1 000 пользователей между двумя решениями близка к шуму.

Для масштаба. В ролике с результатами практический потолок теста — около 64 000 пользователей: дальше переполняется таблица conntrack ядра, а тюнить её правила не дают. В этот потолок упёрлись решения на C++ и Rust. Лучший Python в том же ролике держал 22 000, Ruby — 14 000. PHP с 40 000 оказался ближе к компилируемым языкам, чем к соседям по интерпретируемому лагерю.

Лестница: от 750 до 40 000

Решающий шаг — перестать поднимать PHP на каждый запрос: долгоживущий процесс даёт в 15 раз больше пользователей, чем лучший вариант на PHP-FPM. Первые две ступени автор челленджа мерил сам: Laravel — в первом ролике, с Postgres и Nginx, голый PHP на FPM — уже после своих оптимизаций. Фреймворки в worker-режиме оказываются посередине, но их цифры — самозамеры участников.

Виртуальные пользователи

Из роликов автора челленджа

  1. Laravel + PHP-FPM750
  2. PHP-FPM без фреймворка2 700
  3. Swoole · Tobias Zimpel39 000
  4. Swoole · Matt Coleman40 000

Самозамеры участников · другое железо

  1. Symfony + FrankenPHP15 000
  2. CodeIgniter 4 + FrankenPHP18 500

Пунктир — 16 384 соединения, ограничение Nginx.

Самозамеры сделаны на другом оборудовании; напрямую сопоставлять их с результатами из роликов нельзя.

Что меняется на каждой ступени:

Что меняется на каждой ступени
СтупеньЧто перестали платить на каждом запросеЧто осталось
Laravel + PHP-FPM + Nginxничего, это базазапуск скрипта, сборка контейнера и роутера, middleware, новое соединение с БД, Nginx
Голый PHP + PHP-FPMфреймворкзапуск скрипта, новое соединение с БД и разбор SQL, передача через FastCGI
Фреймворк на FrankenPHP в worker-режимебутстрап фреймворка, соединение с БД, Nginxслои фреймворка и сброс его состояния, переход Go ↔ PHP в Caddy
Голый Swooleвсё перечисленное вышесам обработчик: JWT, SQL, json_encode
Laravel + PHP-FPM + Nginx
Убралиничего, это база
Осталосьзапуск скрипта, сборка контейнера и роутера, middleware, новое соединение с БД, Nginx
Голый PHP + PHP-FPM
Убралифреймворк
Осталосьзапуск скрипта, новое соединение с БД и разбор SQL, передача через FastCGI
Фреймворк на FrankenPHP в worker-режиме
Убралибутстрап фреймворка, соединение с БД, Nginx
Осталосьслои фреймворка и сброс его состояния, переход Go ↔ PHP в Caddy
Голый Swoole
Убраливсё перечисленное выше
Осталосьсам обработчик: JWT, SQL, json_encode

Пунктир на графике важен сам по себе: за Nginx со стандартным worker_connections 16384 ни одно решение не превысит примерно 16 000 пользователей, как бы быстро оно ни было.

Победитель: 252 строки на Swoole

Решение Matt Coleman (PR #24) — это один процесс Swoole, одно соединение с SQLite и синхронные обработчики без корутин и без Nginx. Хитрых трюков в нём нет: весь выигрыш даёт то, что PHP больше не поднимается заново на каждый запрос.

Стек: PHP 8.3.6 из пакетов Ubuntu, OPcache с tracing JIT, Swoole 6.2.3, собранный из тарбола с закреплённым sha256, и pdo_sqlite на системной SQLite 3.45.1. Ни одного пакета Composer. Фрагменты ниже — из его server.php без изменений, вместе с его комментариями.

Сервер. В режиме SWOOLE_BASE воркер сам принимает и обслуживает соединения, без передачи данных между reactor и worker. Разбор HTTP и epoll работают в C, а PHP-обработчик вызывается прямо из цикла событий:

PHP
$server = new Server($host, $port, SWOOLE_BASE);
$server->set([
    'worker_num' => 1,              // one vCPU: one process, no IPC or context switches
    'enable_coroutine' => false,    // handlers never yield; SQLite calls are sub-millisecond
    'max_connection' => 65535,      // capped by the open-file limit
    'backlog' => 4096,
    'http_parse_post' => false,
    'http_parse_cookie' => false,
    'http_parse_files' => false,
    'http_compression' => false,
    'log_level' => SWOOLE_LOG_WARNING,
]);

Один воркер — потому что ядро одно, и второй процесс только делил бы его и добавлял переключения контекста. Без корутин — потому что запрос к локальной SQLite короче миллисекунды, и уступать управление незачем. Разбор тела, cookie и файлов выключен: API он не нужен.

База открывается один раз. В onWorkerStart создаётся PDO с прагмами (journal_mode=WAL, synchronous=NORMAL, кэш страниц 64 МиБ, mmap 256 МиБ, temp_store=MEMORY) и пять подготовленных запросов. Если база не открылась, сервер всё равно стартует, чтобы /health честно вернул 503.

Лента — один запрос и один json_encode. Маршрутизатора нет, вместо него сравнения строк:

PHP
if ($method === 'GET' && $path === '/feed') {
    $stmt['feed']->execute();
    send($res, 200, '{"posts":' . json_encode($stmt['feed']->fetchAll(PDO::FETCH_ASSOC), JSON_FLAGS) . '}');
    return;
}

Лайк — один запрос в частом случае. Вставка сразу проверяет, что пост существует, и не падает на повторном лайке:

SQL
INSERT INTO likes (user_id, post_id) SELECT ?, ? WHERE EXISTS (SELECT 1 FROM posts WHERE id = ?)
ON CONFLICT (user_id, post_id) DO NOTHING

Вставилась строка — ответ 201. Не вставилась — отдельный SELECT 1 FROM posts WHERE id = ? отличает «уже лайкнуто» (200) от «нет такого поста» (404).

JWT проверяется вручную. hash_hmac('sha256') и hash_equals для сравнения за постоянное время, только HS256, строгий base64url без паддинга и обязательный exp. Библиотеки нет.

Долговечность без хитростей. Запись идёт в autocommit. fetchAll() у INSERT … RETURNING доводит запрос до конца, поэтому коммит случается до ответа 201.

memory_limit=-1 — вынужденная мера. В режиме SWOOLE_BASE каждое соединение держит приёмный буфер в 64 КиБ из аллокатора PHP, и PHP учитывает его целиком: около 1,4 ГБ на 20 000 соединений. С конечным лимитом воркер умер бы на одной бухгалтерии. Учёт памяти PHP и реальная резидентная память процесса — разные показатели.

Цифры. В прогоне из ролика на 40 000 пользователей p95 был 189 мс, p99 — 853 мс, ошибок не было. На 41 000 p95 остался 182 мс, но p99 вырос почти до 2,4 с. Локально у автора (Apple Silicon, контейнер на 1 CPU и 2 ГБ) 20 000 пользователей дали p99 4,4 мс при 25% CPU и 839 МБ RSS; дальше не выдержал уже сам k6. Автор считает, что предел его сервера — память на соединение, а не CPU.

Больше инженерии — тот же результат

Tobias Zimpel (PR #28) взял ту же основу и добавил патч Swoole, фоновый чекпоинтер, group commit и замер каждого шага. В ролике это дало 39 000 — на 1 000 меньше победителя, то есть в пределах шума одного прогона.

Основа та же: SWOOLE_BASE, один воркер без корутин, PDO, OPcache с JIT, без Nginx. Сверху — gc_disable() в воркере (обработка запроса не создаёт циклических ссылок) и JSON поста, склеенный руками. Итого 307 строк.

Патч Swoole: освобождать приёмный буфер после ответа. Без патча Swoole после ответа буфер только очищает, а освобождает лишь тот, что разросся больше 128 КиБ. build.sh правит две ветки в src/server/port.cc и роняет сборку, если патч не применился. Вот дифф, который даёт его скрипт на исходниках Swoole 6.2.3:

Diff
             } else {
                 port->destroy_http_request(conn);
-                buffer->clear();
+                delete _socket->recv_buffer; _socket->recv_buffer = nullptr; // patched
                 return SW_OK;
             }
 ...
     if (conn->active && !_socket->removed) {
         port->destroy_http_request(conn);
-        if (_socket->recv_buffer && _socket->recv_buffer->size > SW_BUFFER_SIZE_BIG * 2) {
+        if (_socket->recv_buffer) { // patched
             delete _socket->recv_buffer;
             _socket->recv_buffer = nullptr;

По его замеру, 10 000 соединений до патча занимали 184 МБ RSS и 700 МБ кучи PHP, после — 23 МБ и 3 МБ. Именно патч позволяет держать в php.ini конечный memory_limit = 512M.

Чекпоинтер в отдельном процессе. При WAL и synchronous=NORMAL коммит — это просто write(), а fsync случается при чекпоинте. Обычно SQLite делает его внутри того запроса, который перешагнул 1 000 страниц WAL, и этот запрос становится хвостом латентности. Здесь воркер выставляет wal_autocheckpoint=0, а процесс из $server->addProcess() раз в секунду делает wal_checkpoint(PASSIVE) и TRUNCATE, когда WAL вырос больше 16 384 кадров и полностью скопирован. Его замер: p99 4–5 мс с чекпоинтером против 43 мс без него.

Group commit на Swoole\Event::defer. Записи, пришедшие за одну итерацию цикла событий, уходят одной транзакцией в конце итерации, и каждый ответ отправляется только после COMMIT (фрагмент сокращён):

PHP
public static function submit(Response $res, string $fn, mixed $a, mixed $b): void
{
    if (!self::$queue) Swoole\Event::defer([self::class, 'flush']);
    self::$queue[] = [$res, $fn, $a, $b];
}

public static function flush(): void
{
    $queue = self::$queue;
    self::$queue = [];
    $out = [];
    try {
        Db::$db->exec('BEGIN IMMEDIATE');
        foreach ($queue as [$res, $fn, $a, $b]) $out[] = $fn($a, $b);
        Db::$db->exec('COMMIT');
    } catch (Throwable $e) {
        // ...
        try { Db::$db->exec('ROLLBACK'); } catch (Throwable) {} // may already be rolled back
        $out = array_fill(0, count($queue), [500, E_INTERNAL]);
    }
    foreach ($queue as $i => [$res]) {
        $res->status($out[$i][0]);
        $res->end($out[$i][1]);
    }
}

Обратите внимание на catch: одна упавшая запись превращает в 500 всю пачку. Пользу автор честно замерил сам: на нагрузке из одних лайков минус 10% CPU на запись, на реальном миксе нейтрально.

Что пробовал и отверг. Два воркера — плюс 27% CPU на запрос на одном ядре из-за переключений контекста и блокировки записи SQLite. Потоки Swoole или ext-parallel — нужен ZTS-PHP, а параллелизму на одном ядре взяться неоткуда. Nginx — потолок в 16 000 соединений и часть единственного CPU.

Цифры автора (Apple Silicon, контейнер --cpus=1 --memory=2g): 10 000 пользователей плюс 30 000 простаивающих соединений — p99 3,9 мс, 13–16% CPU, около 60 МБ памяти. Себестоимость — 50–60 мкс CPU на запрос. Краш-тест: 200 клиентов пишут посты, сервер убивают через kill -9, и все 199 977 id, на которые сервер ответил 201, оказались в базе без дублей.

Почему сложное не выиграло

Сравнение решений Matt Coleman и Tobias Zimpel
mattsplat (#24)tzimpel (#28)
Результат в ролике40 00039 000
Строк в server.php252307
Swoole 6.2.3как естьс патчем приёмных буферов
memory_limit-1512M
Записьautocommit на каждый запросgroup commit за итерацию цикла
Чекпоинты WALвстроенные, внутри запросовотдельный процесс раз в секунду
JSON ответаjson_encode всего массивасклейка строк
GC PHPвключёнgc_disable()
Кэш страниц / mmap SQLite64 / 256 МиБ32 / 512 МиБ

mattsplat (#24)

Результат в ролике
40 000
Строк в server.php
252
Swoole 6.2.3
как есть
memory_limit
-1
Запись
autocommit на каждый запрос
Чекпоинты WAL
встроенные, внутри запросов
JSON ответа
json_encode всего массива
GC PHP
включён
Кэш страниц / mmap SQLite
64 / 256 МиБ

tzimpel (#28)

Результат в ролике
39 000
Строк в server.php
307
Swoole 6.2.3
с патчем приёмных буферов
memory_limit
512M
Запись
group commit за итерацию цикла
Чекпоинты WAL
отдельный процесс раз в секунду
JSON ответа
склейка строк
GC PHP
gc_disable()
Кэш страниц / mmap SQLite
32 / 512 МиБ
  1. 1 000 пользователей — это шум. Каждый уровень проверяется одним прогоном на shared vCPU, и соседи по гипервизору двигают результат сильнее.
  2. Записей всего 8%. Group commit и чекпоинтер оптимизируют запись, а на этой нагрузке предел задаёт себестоимость чтения.
  3. Второй процесс и fsync делят тот же единственный vCPU. На одном ядре «в фоне» не значит «бесплатно».
  4. Патч прежде всего снижает учёт памяти. mattsplat снял ту же проблему одной строкой memory_limit=-1.

Что остановило оба решения на уровне 40 000, неизвестно: загрузку CPU и память для PHP в ролике не показали. Вывод простой: на профиле «92% коротких чтений» выигрывает не тот, кто оптимизировал больше, а тот, кто убрал больше накладных расходов с пути чтения. А убрали оба одно и то же.

Три ловушки долгоживущего PHP

Эти три вещи пригодятся каждому, кто переводит PHP с FPM на долгоживущий процесс. Их описали участники челленджа.

1. ext-sqlite3 вставляет строку дважды

Tobias Zimpel объясняет в README, почему взял PDO: SQLite3Stmt::execute() делает первый шаг запроса и сбрасывает его, а fetchArray() выполняет запрос заново. Для INSERT … RETURNING это значит две вставки. Пример вызовов, при которых возникает проблема:

PHP
$stmt = $db->prepare('INSERT INTO posts (body) VALUES (:b) RETURNING id, created_at');
$stmt->bindValue(':b', 'via ext-sqlite3', SQLITE3_TEXT);
$res = $stmt->execute();
$row = $res->fetchArray(SQLITE3_ASSOC);

Клиент получает id второй строки, первая остаётся в базе дублем, и тесты API этого не видят. Это известный баг #64531 2013 года со статусом Verified. У UPDATE … RETURNING та же причина (#81376). Исправление php-src#5204 закрыто без слияния: core-разработчик PHP Christoph M. Becker считает, что честный фикс ломает API, и советует PDO_SQLite. В основном тексте документации об этом ничего нет, только в пользовательских заметках к execute и fetchArray. Вывод: для записи в SQLite из PHP берите PDO.

2. JSON в SQLite не всегда быстрее

В ролике сборку JSON прямо в SQLite показывают как оптимизацию, которая встречается снова и снова: в Ruby, Erlang и других языках (4:22–4:46). Но в PHP-решении tzimpel она, по его замерам, увеличила CPU на 11–13%. Этот результат относится к его реализации и нагрузке.

3. Курсоры SQLite нужно закрывать сразу

Это подробнее всех описал Paweł Klebba в README PR #40. Невыбранный до конца или не сброшенный statement держит неявную транзакцию чтения. Соединение застревает на старом снимке WAL: записи другого воркера становятся невидимы, а следующая запись падает с SQLITE_BUSY_SNAPSHOT. У INSERT … RETURNING коммит происходит именно при сбросе, поэтому без closeCursor() ответ 201 может уйти раньше коммита. При FPM этого не видно: процесс умирает и закрывает всё сам. Все четыре PHP-решения челленджа сразу дочитывают выборку до конца или закрывают курсор.

А если с фреймворком

Два PR, которых в ролике нет, отвечают на вопрос ближе к реальной работе: сколько даст обычный фреймворк, если убрать только бутстрап на каждый запрос. По замерам самих авторов — 15 000–18 500 пользователей: примерно в 6 раз больше голого PHP на FPM и в два с лишним раза меньше голого Swoole. Автор челленджа их ещё не прогонял, а железо у самозамеров другое, так что напрямую с цифрами из ролика их сравнивать нельзя.

Symfony и CodeIgniter на FrankenPHP
Symfony (PR #40, Paweł Klebba)CodeIgniter 4 (PR #50, Melroy van den Berg)
ВерсииSymfony 8.1.8, PHP 8.5.11 из статической сборки FrankenPHP 1.13.0CodeIgniter 4.7.5, PHP 8.5.11 ZTS из FrankenPHP 1.13.0
HTTPCaddy напрямую на :80, только HTTP/1.1, без логов и сжатиятак же
Воркеры PHP2: на 15 000 пользователях p99 был 763 мс с одним, 401 мс с двумя и 1,06 с с четырьмя2: один и четыре вышли медленнее
Доступ к БДDoctrine DBAL 4.5, одно соединение на воркер, пять запросов подготовлены один разпостоянное PDO-соединение, подготовленные запросы
АвторизацияSymfony Security: stateless firewall и свой AccessTokenHandlerпроверка в коде приложения
Гигиена воркерасброс сервисов Symfony перед каждым handle(), gc_collect_cycles() после ответа, MAX_REQUESTS для перезапускаштатный жизненный цикл CI4 (режим помечен experimental)
Результат самозамера15 000 пользователей, p99 401 мс18 500 пользователей, p99 259 мс; 20 000 не прошли
Условия самозамера60 с удержания, ядро закреплено taskset, лимит fd 20 000 — выше не проверить5 минут удержания, контейнер на 1 CPU и 2 ГБ
Память~770 МБ, почти всё — буферы Caddy, около 45 КБ на простаивающее соединениепик 877 МиБ

Symfony (PR #40, Paweł Klebba)

Версии
Symfony 8.1.8, PHP 8.5.11 из статической сборки FrankenPHP 1.13.0
HTTP
Caddy напрямую на :80, только HTTP/1.1, без логов и сжатия
Воркеры PHP
2: на 15 000 пользователях p99 был 763 мс с одним, 401 мс с двумя и 1,06 с с четырьмя
Доступ к БД
Doctrine DBAL 4.5, одно соединение на воркер, пять запросов подготовлены один раз
Авторизация
Symfony Security: stateless firewall и свой AccessTokenHandler
Гигиена воркера
сброс сервисов Symfony перед каждым handle(), gc_collect_cycles() после ответа, MAX_REQUESTS для перезапуска
Результат самозамера
15 000 пользователей, p99 401 мс
Условия самозамера
60 с удержания, ядро закреплено taskset, лимит fd 20 000 — выше не проверить
Память
~770 МБ, почти всё — буферы Caddy, около 45 КБ на простаивающее соединение

CodeIgniter 4 (PR #50, Melroy van den Berg)

Версии
CodeIgniter 4.7.5, PHP 8.5.11 ZTS из FrankenPHP 1.13.0
HTTP
так же
Воркеры PHP
2: один и четыре вышли медленнее
Доступ к БД
постоянное PDO-соединение, подготовленные запросы
Авторизация
проверка в коде приложения
Гигиена воркера
штатный жизненный цикл CI4 (режим помечен experimental)
Результат самозамера
18 500 пользователей, p99 259 мс; 20 000 не прошли
Условия самозамера
5 минут удержания, контейнер на 1 CPU и 2 ГБ
Память
пик 877 МиБ

Что отсюда стоит взять:

  • Самый крупный выигрыш внутри worker-режима — соединение с БД. В диагностике CodeIgniter постоянное PDO-соединение и подготовленные запросы подняли /feed с 901 до 1 450 запросов в секунду (+61%). Все остальные настройки вместе дали ещё 37%, до 1 989.
  • JIT помогает тем сильнее, чем больше времени уходит на PHP-код. У Symfony tracing JIT дал +17% запросов в секунду и снизил p95 на 15 000 пользователях с 215 до 99 мс. У голого Swoole оба автора сочли JIT нейтральным: там почти всё время уходит в C и SQLite.
  • У Caddy память на соединение на порядок больше. 45 КБ на соединение — это около 1,8 ГБ на 40 000 соединений, то есть весь бюджет машины. Это оценка по цифре автора: FrankenPHP упрётся в память задолго до того, как в неё упрётся Swoole.
  • Готовые JWT-библиотеки отказались работать. firebase/php-jwt 7 и lcobucci/jwt 5 не принимают HMAC-ключ короче 256 бит, а секрет челленджа — 23 байта. Пришлось писать проверку руками, как и в решениях на Swoole. В проде это скорее защита, чем помеха.

Цена: что говорят инженеры

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

За:

  • Kévin Dunglas, автор FrankenPHP и участник core team Symfony, интерес прямой. Hello world на Symfony отвечает за 2,53 мс на FrankenPHP против 9,45 мс на FPM (DevClass, dunglas.dev). Цифры его собственные, мы их не перепроверяли.
  • Антон Титов и John W. Griffin, Spiral Scout, авторы RoadRunner, интерес прямой. На первом API-бэкенде после перехода «полностью избавились от ошибки 502» и на две трети сократили серверы. Там же признают: «диагностика утечек памяти доводит до бешенства» (Хабр, 2018).
  • Tim Düsterhus, инженер Tideways и автор RFC в php-src, интереса в конкретном рантайме нет. Worker-режим сильно ускоряет чисто написанное приложение, а вот FrankenPHP в классическом режиме практически не быстрее FPM (Tideways, 2025). То есть выигрыш даёт модель запуска, а не сервер.
  • Документация CodeIgniter обещает 2–3× для приложений с БД и 10–30% для простых эндпоинтов, но сам режим помечает как experimental (worker mode).

Против или с оговорками:

  • Mattias Geniar, сооснователь Oh Dear, практик эксплуатации. Перевёл серверы проверок аптайма на Octane с FrankenPHP и вернул их на FPM: средний CPU не изменился, воркеры заняли 600–670 МБ, тривиальный маршрут ускорился с 15 до 11 мс. Его вывод: для I/O-bound нагрузки выигрыш слишком мал, чтобы оправдать лишние движущиеся части (ma.ttias.be, июнь 2026). Это самая сильная практическая критика из тех, что мы нашли.
  • Документация Laravel Octane: устаревшие контейнер и запрос в синглтонах, утечки через статические массивы, воркер по умолчанию перезапускается каждые 500 запросов (Octane).
  • Документация FrankenPHP — автор инструмента предупреждает сам: статика, глобальные переменные и ini_set живут между запросами, из-за утечек в библиотеках нужен MAX_REQUESTS. newrelic, imap и pcov не поддерживаются, datadog, blackfire и imagick работают с ошибками (worker, known issues).
  • Документация Symfony Runtime: сервисы могут протекать между запросами, если не реализуют ResetInterface, а полный сброс ядра стоит заметной производительности (Runtime).
  • Derick Rethans, автор Xdebug: поддержки Swoole в Xdebug не будет, потому что Swoole иначе работает с внутренними структурами PHP (тикет в баг-трекере Xdebug, 2021).
  • Keith Adams, главный архитектор Slack, косвенно за FPM: каждый запрос начинается «с чистого листа», и баг не расползается на соседние запросы (Taking PHP Seriously, 2016).
  • Arne Blankerts, сооснователь thePHP.cc: скорость покупается отказом от изоляции процессов. У нас есть только аннотация его доклада (ConFoo 2026).
  • Sara Golemon, core-разработчик php-src: в демоне не срабатывает уборка в конце запроса, и мелкие утечки копятся (письмо в internals, 2005). Письмо старое, но механизм тот же.
  • Matthieu Napoli, автор Bref: «берегитесь утечек памяти», FPM в Bref остаётся режимом по умолчанию (Bref и Octane).

Чего не нашли. Прямых высказываний Тейлора Отвелла или Никиты Попова за или против. Инженерного постмортема компании об утечке состояния между запросами в проде: критика держится на оговорках в документации, одном откате практика и аннотациях докладов. Споров о самих результатах челленджа тоже нет: в issue #7 обсуждают методику замера, а не подход. Пост PHP Foundation в поддержку FrankenPHP мы сознательно не приводим как аргумент: это организационная позиция, а цифры из него мы не смогли проверить по первоисточнику.

Что из этого следует. Челлендж убирает цену самими условиями: 250 строк без фреймворка и без состояния. Для маленького сервиса без состояния 15× — реальный потолок. Для обычного приложения ориентир скорее 2–3× из документации CodeIgniter, а если нагрузка ждёт внешние сервисы, как у Geniar, выигрыша может не быть вовсе.

Итог

PHP держит 40 000 пользователей на сервере за $12, если перестать запускать его заново на каждый запрос. Остальное — детали, и большинство из них про память и состояние, а не про скорость кода. Автор челленджа в ролике формулирует это так: архитектура приложения важна, на каком бы языке вы ни писали.

Если вы переводите PHP-сервис с FPM на Swoole, FrankenPHP, RoadRunner или Octane, вот что мы бы проверили по итогам разбора:

  1. Сначала понять, куда уходит время запроса. Если он в основном ждёт внешние сервисы, бутстрап — не ваша проблема, и выигрыш будет маленьким.
  2. Соединение с БД и подготовленные запросы — один раз на воркер. Это самый крупный выигрыш после самого перехода.
  3. Воркеров — не больше, чем ядер. На одном ядре второй воркер добавляет переключения контекста и блокировки, а не скорость.
  4. Никакого состояния между запросами. Статические массивы и синглтоны с данными запроса — источник утечек и чужих данных в ответе. Сброс сервисов между запросами и MAX_REQUESTS — как страховка.
  5. SQLite через PDO, курсоры закрывать сразу. ext-sqlite3 дублирует INSERT … RETURNING, а открытый курсор держит старый снимок WAL.
  6. Смотреть на RSS, а не только на memory_limit. Сопоставляйте лимит PHP с фактической резидентной памятью процесса.
  7. Сборку JSON проверять на своей нагрузке. Matt Coleman использует один json_encode всего массива; tzimpel по своим замерам отказался от сборки JSON в SQLite.
  8. Инструменты — до переезда. Проверьте, что ваш отладчик, профайлер и APM работают с выбранным рантаймом: Xdebug не поддерживает Swoole, а newrelic не работает в FrankenPHP.

Оговорки

Чего мы не знаем:

  • Результаты 40 000 и 39 000 есть только в ролике: PR не слиты, письменной таблицы нет, и неизвестно, какой коммит каждого PR мерили.
  • Решения из ролика мы сопоставили с PR по описанию, авторы в ролике не названы.
  • Цифры Symfony и CodeIgniter — самозамеры авторов на другом железе.
  • Что остановило решения на Swoole в районе 40 000, неизвестно.
  • Режим THP на дроплете автора мы не видели. madvise — это умолчание ядер Ubuntu, перенастройку в образе DigitalOcean мы не исключаем.
  • На каком ядре и с каким THP мерили участники локально, неизвестно. У tzimpel без патча выходило около 16 КиБ на соединение; эту цифру нельзя автоматически переносить на другие окружения.

Источники

Челлендж и решения:

Swoole, SQLite и память:

Просмотр изображения

Открыть оригинал