---
title: "Laravel без таймауту підключення до MySQL: чотири спроби, дві хвилини"
url: https://laravelukraine.com/blog/laravel-bez-taimautu-pidkliucennia-do-mysql-cotiri-sprobi-dvi-xvilini
date: 2026-11-09
source: https://dev.to/tegos/laravels-missing-db-connect-timeout-four-attempts-two-minutes-deb
---

# Laravel без таймауту підключення до MySQL: чотири спроби, дві хвилини

*Переклад статті Івана Михавка [Laravel's Missing DB Connect Timeout: Four Attempts, Two Minutes](https://dev.to/tegos/laravels-missing-db-connect-timeout-four-attempts-two-minutes-deb).*

Наш сервер MySQL був цілком здоровий, а сайт усе одно не працював 8 хвилин.

Ні розгортання, ні сплеску трафіку, ні повільного запиту до бази. Сервер застосунку просто якийсь час не міг дістатися до бази даних, а Laravel чекав. І чекав. Виявилося, що підключення Laravel до MySQL не має власного таймауту підключення, а коли підключитися не вдається, Laravel пробує ще раз, і ще три рази.

### Що сталося

Проєкт, над яким я працюю, розміщено в Hetzner. Сервер застосунку спілкується з MySQL через приватну мережу Hetzner. Одного дня ця внутрішня мережа відмовила в одному датацентрі. MySQL працював. Сервер застосунку працював. Вони просто не бачили одне одного.

Лог Laravel заповнився ось таким:

```plaintext
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 така сама структура):

```php
'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, щоб довести, що він тут ні до чого:

```php
$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();
}
```

```plaintext
$ 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`:

```php
'SQLSTATE[HY000] [2002] Connection timed out',
```

Для помилки з цього списку Laravel повторює спробу у двох місцях.

Перше - конектор. Якщо створити PDO не вдалося через помилку втраченого з'єднання, `Connector::createConnection()` одразу пробує ще раз.

Друге - запит до бази. Laravel підключається ліниво, тож підключення відбувається всередині першого запиту. Коли цей запит падає з помилкою втраченого з'єднання, `Illuminate\Database\Connection` скидає підключення і виконує запит ще раз (тут показано Laravel 12; у 13 додано захист для запитів, що запускаються під час самого перепідключення, але основний запит однаково виконується повторно):

```php
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 спроби підключення** на один невдалий запит до бази.

![Один невдалий запит до бази дає чотири спроби підключення: дві в конекторі, потім перепідключення і ще дві](https://raw.githubusercontent.com/tegos-blog/article-db-connect-timeout/refs/heads/main/assets/connect-retries.png)

Я виміряв це на Laravel 12.43 і на свіжому 13.40, з підключенням до хоста, до якого немає маршруту. Числа на обох однакові:

```php
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, яке використовує застосунок:

```php
'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`.

### Джерела

- [Документація PHP: PDO::setAttribute (PDO::ATTR_TIMEOUT)](https://www.php.net/manual/en/pdo.setattribute.php)
- [Laravel 13.x: Connector.php, повтор підключення](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/Connectors/Connector.php)
- [Laravel 13.x: Connection.php, перепідключення на рівні запиту до бази](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/Connection.php)
- [Laravel 13.x: LostConnectionDetector.php, список помилок, для яких Laravel повторює спробу](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/LostConnectionDetector.php)
- [Скелет Laravel: config/database.php](https://github.com/laravel/laravel/blob/13.x/config/database.php)
