Laravel на PHP-FPM выдержал на сервере за $12 в месяц 750 одновременных пользователей. Решение на голом PHP и Swoole на той же машине и с той же задачей держит 40 000. Ниже разбираем, откуда разница в 53 раза и чего она стоит.
Сразу о том, чей это материал. Челлендж, правила, нагрузочный тест и все итоговые цифры принадлежат автору репозитория arjaythedev/twelve-dollar-server-challenge. Сначала он сравнил восемь языков с популярными фреймворками в первом ролике, потом предложил сообществу написать решения быстрее, сам прогнал их на дроплете и показал результаты во втором ролике. Код решений написали участники, в статье — разбор их кода и подборка мнений инженеров.
| Что | Чьё | Где смотреть |
|---|---|---|
| Правила, 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 |
- 750 пользователей у Laravel на PHP-FPM
- arjaythedev
- первый ролик
- 2 700 у голого PHP на FPM, 40 000 и 39 000 у решений на Swoole, цифры других языков
- arjaythedev
- ролик с результатами, часть про PHP — 10:32–11:51
- Локальные замеры участников
- авторы 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 |
|---|---|
| Дроплет 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-режиме оказываются посередине, но их цифры — самозамеры участников.
Из роликов автора челленджа
- Laravel + PHP-FPM750
- PHP-FPM без фреймворка2 700
- Swoole · Tobias Zimpel39 000
- Swoole · Matt Coleman40 000
Самозамеры участников · другое железо
- Symfony + FrankenPHP15 000
- 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-обработчик вызывается прямо из цикла событий:
$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. Маршрутизатора нет, вместо него сравнения строк:
if ($method === 'GET' && $path === '/feed') {
$stmt['feed']->execute();
send($res, 200, '{"posts":' . json_encode($stmt['feed']->fetchAll(PDO::FETCH_ASSOC), JSON_FLAGS) . '}');
return;
}Лайк — один запрос в частом случае. Вставка сразу проверяет, что пост существует, и не падает на повторном лайке:
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:
} 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 (фрагмент сокращён):
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, оказались в базе без дублей.
Почему сложное не выиграло
| mattsplat (#24) | tzimpel (#28) | |
|---|---|---|
| Результат в ролике | 40 000 | 39 000 |
| Строк в server.php | 252 | 307 |
| Swoole 6.2.3 | как есть | с патчем приёмных буферов |
| memory_limit | -1 | 512M |
| Запись | autocommit на каждый запрос | group commit за итерацию цикла |
| Чекпоинты WAL | встроенные, внутри запросов | отдельный процесс раз в секунду |
| JSON ответа | json_encode всего массива | склейка строк |
| GC PHP | включён | gc_disable() |
| Кэш страниц / mmap SQLite | 64 / 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 000 пользователей — это шум. Каждый уровень проверяется одним прогоном на shared vCPU, и соседи по гипервизору двигают результат сильнее.
- Записей всего 8%. Group commit и чекпоинтер оптимизируют запись, а на этой нагрузке предел задаёт себестоимость чтения.
- Второй процесс и fsync делят тот же единственный vCPU. На одном ядре «в фоне» не значит «бесплатно».
- Патч прежде всего снижает учёт памяти. mattsplat снял ту же проблему одной строкой memory_limit=-1.
Что остановило оба решения на уровне 40 000, неизвестно: загрузку CPU и память для PHP в ролике не показали. Вывод простой: на профиле «92% коротких чтений» выигрывает не тот, кто оптимизировал больше, а тот, кто убрал больше накладных расходов с пути чтения. А убрали оба одно и то же.
Три ловушки долгоживущего PHP
Эти три вещи пригодятся каждому, кто переводит PHP с FPM на долгоживущий процесс. Их описали участники челленджа.
1. ext-sqlite3 вставляет строку дважды
Tobias Zimpel объясняет в README, почему взял PDO: SQLite3Stmt::execute() делает первый шаг запроса и сбрасывает его, а fetchArray() выполняет запрос заново. Для INSERT … RETURNING это значит две вставки. Пример вызовов, при которых возникает проблема:
$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 (PR #40, Paweł Klebba) | CodeIgniter 4 (PR #50, Melroy van den Berg) | |
|---|---|---|
| Версии | Symfony 8.1.8, PHP 8.5.11 из статической сборки FrankenPHP 1.13.0 | CodeIgniter 4.7.5, PHP 8.5.11 ZTS из FrankenPHP 1.13.0 |
| HTTP | Caddy напрямую на :80, только HTTP/1.1, без логов и сжатия | так же |
| Воркеры PHP | 2: на 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, вот что мы бы проверили по итогам разбора:
- Сначала понять, куда уходит время запроса. Если он в основном ждёт внешние сервисы, бутстрап — не ваша проблема, и выигрыш будет маленьким.
- Соединение с БД и подготовленные запросы — один раз на воркер. Это самый крупный выигрыш после самого перехода.
- Воркеров — не больше, чем ядер. На одном ядре второй воркер добавляет переключения контекста и блокировки, а не скорость.
- Никакого состояния между запросами. Статические массивы и синглтоны с данными запроса — источник утечек и чужих данных в ответе. Сброс сервисов между запросами и MAX_REQUESTS — как страховка.
- SQLite через PDO, курсоры закрывать сразу. ext-sqlite3 дублирует INSERT … RETURNING, а открытый курсор держит старый снимок WAL.
- Смотреть на RSS, а не только на memory_limit. Сопоставляйте лимит PHP с фактической резидентной памятью процесса.
- Сборку JSON проверять на своей нагрузке. Matt Coleman использует один json_encode всего массива; tzimpel по своим замерам отказался от сборки JSON в SQLite.
- Инструменты — до переезда. Проверьте, что ваш отладчик, профайлер и APM работают с выбранным рантаймом: Xdebug не поддерживает Swoole, а newrelic не работает в FrankenPHP.
Оговорки
Чего мы не знаем:
- Результаты 40 000 и 39 000 есть только в ролике: PR не слиты, письменной таблицы нет, и неизвестно, какой коммит каждого PR мерили.
- Решения из ролика мы сопоставили с PR по описанию, авторы в ролике не названы.
- Цифры Symfony и CodeIgniter — самозамеры авторов на другом железе.
- Что остановило решения на Swoole в районе 40 000, неизвестно.
- Режим THP на дроплете автора мы не видели. madvise — это умолчание ядер Ubuntu, перенастройку в образе DigitalOcean мы не исключаем.
- На каком ядре и с каким THP мерили участники локально, неизвестно. У tzimpel без патча выходило около 16 КиБ на соединение; эту цифру нельзя автоматически переносить на другие окружения.
Источники
Челлендж и решения:
- Репозиторий челленджа: README, bench/load.js, bench/nginx.conf
- Первый ролик автора челленджа и ролик с результатами
- PR #24 Matt Coleman, PR #28 Tobias Zimpel, PR #40 Paweł Klebba, PR #50 Melroy van den Berg, issue #7
Swoole, SQLite и память:
- Исходники Swoole: swoole_config.h (SW_BUFFER_SIZE_BIG), port.cc, релизы
- Баги PHP: #64531, #81376, php-src#5204
- Ubuntu: THP по умолчанию в режиме madvise, Docker for Mac с THP always, 2016
