Переклад статті Івана Михавка Laravel's Missing DB Connect Timeout: Four Attempts, Two Minutes.
Наш сервер MySQL був цілком здоровий, а сайт усе одно не працював 8 хвилин.
Ні розгортання, ні сплеску трафіку, ні повільного запиту до бази. Сервер застосунку просто якийсь час не міг дістатися до бази даних, а Laravel чекав. І чекав. Виявилося, що підключення Laravel до MySQL не має власного таймауту підключення, а коли підключитися не вдається, Laravel пробує ще раз, і ще три рази.
Що сталося
Проєкт, над яким я працюю, розміщено в Hetzner. Сервер застосунку спілкується з MySQL через приватну мережу Hetzner. Одного дня ця внутрішня мережа відмовила в одному датацентрі. MySQL працював. Сервер застосунку працював. Вони просто не бачили одне одного.
Лог Laravel заповнився ось таким:
SQLSTATE[HY000] [2002] Connection timed out
Помилка в лозі під час мережевої проблеми - це нормально. Але перестав відповідати весь сайт, включно з маршрутами, які взагалі не звертаються до бази.
На сервері один пул PHP-FPM на 5 воркерів, і його ділять два Laravel-застосунки: API вітрини магазину та API, через яке наша ERP-система обмінюється даними. Кожен запит, якому була потрібна база, займав воркер і зависав на підключенні. За хвилину всі 5 воркерів застрягли, і більше ніхто не міг пробитися. Короткий мережевий збій перетворився на повну недоступність.
Логи nginx за той день (сервер застосунку обслуговує обидва API, вітрина стоїть перед ним):
| Де |
Помилок під час збою |
До збою |
| Сервер застосунку |
260 x 5xx |
0 |
| Вітрина |
7 x 504, 43 x 499 (клієнт не дочекався) |
0 |
Чому одна спроба підключення триває 30 секунд
Подивіться на опції підключення mysql у config/database.php (у нас він зі старішого скелета; у Laravel 13 така сама структура):
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
Жодного PDO::ATTR_TIMEOUT. У скелеті Laravel 13 його теж немає.
Примітка перекладача. У скелеті Laravel 13 константа записана через клас драйвера: Mysql::ATTR_SSL_CA (тобто Pdo\Mysql), а не PDO::MYSQL_ATTR_SSL_CA. Суть не змінюється: крім SSL-сертифіката, в опціях нічого немає.
То хто вирішує, скільки чекає підключення? Не php.ini, і це мене здивувало. У pdo_mysql є власне значення за замовчуванням: коли ви передаєте PDO будь-який масив опцій і не вказуєте в ньому ATTR_TIMEOUT, він чекає 30 секунд. Laravel передає опції завжди (у Connector є набір за замовчуванням: режим помилок, регістр тощо), тож у Laravel-застосунку це завжди 30.
Примітка перекладача. У документації PHP до PDO::ATTR_TIMEOUT число 30 не назване, але вихідний код розширення підтверджує слова автора: у ext/pdo_mysql/mysql_driver.c таймаут береться як pdo_attr_lval(driver_options, PDO_ATTR_TIMEOUT, 30), і лише тоді, коли масив опцій передано.
Я перевірив це на PDO з адресою, до якої немає маршруту, і з default_socket_timeout, виставленим у 3, щоб довести, що він тут ні до чого:
$start = microtime(true);
try {
new PDO('mysql:host=10.255.255.1;dbname=x', 'x', 'x', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
} catch (PDOException $e) {
echo round(microtime(true) - $start, 2) . 's ' . $e->getMessage();
}
$ php -d default_socket_timeout=3 probe.php
29.84s SQLSTATE[HY000] [2002] Connection timed out
Зверніть увагу: приберіть масив опцій, і те саме підключення впаде через 3 секунди, бо голий new PDO() без опцій якраз зважає на default_socket_timeout. Тому швидкий тест на чистому PHP може показати не те число, яке бачить ваш застосунок.
30 секунд на запит - це вже погано. Спойлер: Laravel чекає чотири рази.
Laravel пробує чотири рази
Connection timed out є в списку помилок «втраченого з'єднання» Laravel, у LostConnectionDetector:
'SQLSTATE[HY000] [2002] Connection timed out',
Для помилки з цього списку Laravel повторює спробу у двох місцях.
Перше - конектор. Якщо створити PDO не вдалося через помилку втраченого з'єднання, Connector::createConnection() одразу пробує ще раз.
Друге - запит до бази. Laravel підключається ліниво, тож підключення відбувається всередині першого запиту. Коли цей запит падає з помилкою втраченого з'єднання, Illuminate\Database\Connection скидає підключення і виконує запит ще раз (тут показано Laravel 12; у 13 додано захист для запитів, що запускаються під час самого перепідключення, але основний запит однаково виконується повторно):
protected function tryAgainIfCausedByLostConnection(QueryException $e, $query, $bindings, Closure $callback)
{
if ($this->causedByLostConnection($e->getPrevious())) {
$this->reconnect();
return $this->runQueryCallback($query, $bindings, $callback);
}
throw $e;
}
Виконати запит ще раз - означає ще раз підключитися, через той самий конектор, з його власним повтором. Отже, 2 спроби x 2 = 4 спроби підключення на один невдалий запит до бази.

Я виміряв це на Laravel 12.43 і на свіжому 13.40, з підключенням до хоста, до якого немає маршруту. Числа на обох однакові:
config(['database.connections.probe' => [
'driver' => 'mysql',
'host' => '10.255.255.1',
'database' => 'x',
'username' => 'x',
'password' => 'x',
'options' => [PDO::ATTR_TIMEOUT => 2],
]]);
$start = microtime(true);
try {
DB::connection('probe')->select('select 1');
} catch (Throwable $e) {
echo round(microtime(true) - $start, 2) . 's';
}
PDO::ATTR_TIMEOUT |
Один невдалий select 1 |
| 1 |
4.01 с |
| 2 |
8.01 с |
| не задано |
4 x 30 с, близько 2 хвилин |
Останній рядок наша команда виміряла локально ще до виправлення: близько 30 секунд на спробу. Зі стандартним для nginx fastcgi_read_timeout у 60 секунд nginx здається на півдорозі, але воркер FPM за ним продовжує чекати.
А транзакції? Усередині відкритої транзакції повтор на рівні запиту до бази пропускається. Але beginTransaction() має власне перепідключення при втраченому з'єднанні, тож DB::transaction() однаково вийшов приблизно на 4x (3.8 с з таймаутом в 1 секунду).
Виправлення
Одна опція в кожному підключенні до MySQL, яке використовує застосунок:
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
// Секунди на одну спробу підключення; Laravel повторює спроби, тож невдалий запит чекає ~4x від цього значення.
PDO::ATTR_TIMEOUT => (int) env('DB_CONNECT_TIMEOUT', 2),
]) : [],
Плюс DB_CONNECT_TIMEOUT=2 в .env.example з тією самою приміткою, що це значення на одну спробу.
Чотири деталі:
- Не використовуйте
0 як «без обмеження». У нашому конфігу і в скелеті Laravel 13 array_filter() його відкидає, і підключення повертається до 30 секунд за замовчуванням. Скелет 12.x відфільтровує лише null, тож там 0 доходить до PDO, який тоді бере default_socket_timeout (за замовчуванням 60 секунд). «Без обмеження» не виходить у жодному випадку.
- Це таймаут підключення, а не таймаут запиту до бази. На staging
select sleep(5) проходив і з таймаутом 2.
- Чому 2? У приватній мережі здорове підключення займає значно менше мілісекунди. Беріть значення в кілька разів більше за ваше найгірше здорове підключення: з TLS або з керованою базою, що стоїть далі, може знадобитися більше.
- Обидва застосунки на спільному пулі отримали ту саму зміну. Якби виправили лише один, другий так само міг би забити весь пул.
Щоб це довести, я запустив наведену вище перевірку на копії справжнього конфігу mysql з підміненим хостом. Локально: 8.0 с. На staging: 8.0 с, після config:cache і перезавантаження FPM (opcache там не перевіряє зміни файлів).
Чого це не виправляє
Скажу чесно: це не тримає сайт живим, коли база недоступна. Запити, яким потрібна база, однаково падають. Змінюється те, як вони падають: помилка через 8 секунд замість воркера, що застряг на 2 хвилини. Воркери лишаються вільними, тож маршрути, які віддаються з кешу, і health check однаково можуть відповідати.
Чому не звичні підозрювані?
max_execution_time не допоміг би. У Linux він не враховує час, проведений у потокових операціях і системних викликах, а завислий connect - це саме воно.
request_terminate_timeout у FPM вбиває застряглий воркер, але кожен новий запит однаково впирається в те саме 30-секундне підключення.
- Більший пул лише відкладає недоступність. Дві хвилини очікування на кожен запит заб'ють і 50 воркерів, просто пізніше. Збільшення
pm.max_children - окреме рішення.
- Повтор спроби в Laravel я залишив. При справжньому секундному збої саме повтор рятує запит, а з коротким таймаутом він коштує секунди, а не хвилини.
- Circuit breaker або проксі на кшталт ProxySQL можуть падати ще швидше. Таймаут під ними однаково потрібен.
Ця стаття про MySQL. Redis і вихідні HTTP-виклики теж потребують власних таймаутів, а як тут поводиться PostgreSQL, я не перевіряв.
Коротко
- Підключення Laravel до MySQL без
PDO::ATTR_TIMEOUT чекає 30 секунд pdo_mysql на кожну спробу, а не значення з вашого php.ini.
- Підключення, що впало з таймаутом, вважається «втраченим з'єднанням», тож Laravel робить 4 спроби на один запит до бази: близько 2 хвилин.
- Під час мережевого збою це тримає всі воркери FPM, тож лягають і маршрути, яким база не потрібна.
- Задайте
PDO::ATTR_TIMEOUT і пам'ятайте, що справжнє очікування - 4x від цього значення.
Спробуйте на своєму застосунку: спрямуйте копію свого підключення mysql на 10.255.255.1 і заміряйте select 1.
Джерела