Dla administratorów: pełny przebieg testu z dosłownymi komunikatami, które można porównać z logami własnego serwera.
Środowisko testu (stan na listopad 2026)
nginx 1.30, PHP-FPM 8.3 (opcache włączony), WordPress 7.1.4 (pl_PL), WooCommerce 11.2.1, motyw Storefront, MariaDB 11.4.13 bez zmian w konfiguracji, każdy w osobnym kontenerze; 6 vCPU, 12 GB RAM. 30 002 produkty proste z kategoriami i atrybutami pa_kolor, pa_rozmiar, pa_marka, baza 267 MB danych i indeksów (wp_posts 94 MB, wp_postmeta ok. 600 tys. wierszy).
PHP-FPM ustawione „na większy ruch”:
[www]
pm = dynamic
pm.max_children = 180
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 40
listen.backlog = 1024
MariaDB w konfiguracji po instalacji:
| zmienna | wartość domyślna |
max_connections | 151 |
innodb_buffer_pool_size | 128 MB |
tmp_table_size / max_heap_table_size | 16 MB |
wait_timeout | 28 800 s |
slow_query_log / long_query_time | wyłączony / 10 s |
thread_handling | one-thread-per-connection |
Jak mierzyliśmy
Zamknięta pętla: N wirtualnych odwiedzających, każdy wysyła kolejne żądanie od razu po odpowiedzi. Mieszanka adresów jak w dniu promocji: 30% kategorie (z orderby i stronicowaniem), 25% karty produktów, 20% /sklep/?filter_kolor=…&filter_rozmiar=…, 15% wyszukiwarka /?s=…&post_type=product, 10% strona główna, to samo ziarno losowania w każdym przebiegu. Po minucie rozgrzewki kroki 50, 100, 150, 175 i 200 jednoczesnych odwiedzających po 45 s. Liczniki bazy co sekundę przez jedno stałe połączenie administracyjne:
SELECT VARIABLE_NAME, VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS
WHERE VARIABLE_NAME IN ('THREADS_CONNECTED', 'THREADS_RUNNING', 'MAX_USED_CONNECTIONS',
'CONNECTION_ERRORS_MAX_CONNECTIONS', 'ABORTED_CONNECTS', 'INNODB_BUFFER_POOL_READS',
'INNODB_BUFFER_POOL_READ_REQUESTS', 'CREATED_TMP_DISK_TABLES', 'SLOW_QUERIES');
Warianty, każdy po restarcie bazy i rozgrzewce: A domyślna, B tylko max_connections = 250, C konfiguracja dopasowana do serwera, D jak C plus pula wątków.
Wyniki
| jednocześnie | strony/s (udane) | mediana | p95 | odpowiedzi z błędem |
| 50 | 36,1 | 0,79 s | 5,84 s | 0 |
| 100 | 36,5 | 1,52 s | 13,10 s | 0 |
| 150 | 38,2 | 2,30 s | 18,38 s | 68 (3,8%) |
| 175 | 29,2 | 3,63 s | 18,15 s | 21 236 (94,2%) |
| 200 | 27,4 | 4,87 s | 14,25 s | 24 356 (95,2%) |
Wariant A (domyślny). Wysoki odsetek błędów przy 175 i 200 wynika z tego, że strona błędu wraca w kilka milisekund, a odwiedzający w teście od razu wysyła kolejne żądanie (jak ktoś naciskający F5 albo bot). Mediana i p95 dotyczą tylko udanych odpowiedzi. Liczniki po rampie: Max_used_connections = 152, Aborted_connects = 45 660, Connection_errors_max_connections = 35 909.
| wariant | 150: strony/s, p95, błędy | 175: strony/s, p95, błędy | 200: strony/s, mediana, p95, błędy |
| A domyślna | 38,2 / 18,4 s / 3,8% | 29,2 / 18,2 s / 94,2% | 27,4 / 4,87 s / 14,3 s / 95,2% |
B max_connections = 250 | 37,9 / 18,8 s / 0 | 38,8 / 21,8 s / 0 | 39,7 / 3,27 s / 22,3 s / 0 |
| C konfiguracja dopasowana | 39,2 / 17,7 s / 0 | 39,2 / 19,8 s / 0 | 40,1 / 3,35 s / 20,7 s / 0 |
| D jak C + pula wątków | 36,9 / 7,4 s / 0 | 36,3 / 8,3 s / 0 | 37,2 / 5,37 s / 9,1 s / 0 |
Test stabilności wariantu C: 200 jednoczesnych odwiedzających przez 180 s, 6 967 odpowiedzi 200, średnio 38,7 strony na sekundę, Connection_errors_max_connections = 0, Aborted_connects = 0, Max_used_connections = 184.
Co zapisały logi
Odpowiedź dla odwiedzającego (tytuł „Database Error”, nagłówek „Error establishing a database connection”):
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=UTF-8
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private
nginx, sam kod odpowiedzi, w logu błędów nic:
"GET / HTTP/1.1" 500 2494 "-" "Mozilla/5.0 ..."
PHP-FPM, tylko ostrzeżenia o puli procesów:
WARNING: [pool www] seems busy (you may need to increase pm.start_servers, or pm.min/max_spare_servers), spawning 32 children, there are 0 idle, and 175 total children
WARNING: [pool www] server reached pm.max_children setting (180), consider raising it
WordPress przy WP_DEBUG = false nie zapisuje nic. Po włączeniu WP_DEBUG i WP_DEBUG_LOG w wp-content/debug.log (45 s przy 200 odwiedzających, 14 564 linie):
11466 x PHP Warning: mysqli_real_connect(): (08004/1040): Too many connections in /var/www/html/wp-includes/class-wpdb.php on line 1990
3098 x PHP Warning: mysqli_real_connect(): (HY000/1040): Too many connections in /var/www/html/wp-includes/class-wpdb.php on line 1990
MariaDB, log błędów (35 909 wpisów, tyle samo co Connection_errors_max_connections):
[Warning] Aborted connection 0 to db: 'unconnected' user: 'unauthenticated' host: 'connecting host' (Too many connections)
Klient mariadb administratora w czasie przeciążenia (7 z 10 prób):
ERROR 2002 (HY000): Received error packet before completion of TLS handshake. The authenticity of the following error cannot be verified: 1040 - Too many connections
Przyczyna
WordPress łączy się z bazą na początku każdego żądania (wpdb::db_connect()) i zamyka połączenie dopiero po wygenerowaniu strony. Liczba połączeń w danej chwili jest więc równa liczbie żądań PHP w toku, a górną granicę wyznacza pm.max_children wszystkich pul PHP-FPM korzystających z tej bazy. Przy 6 rdzeniach sklep obsługuje około 36 do 40 stron na sekundę. Powyżej tego żądania czekają na procesor, PHP-FPM dokłada procesy („spawning 32 children”), każdy otwiera połączenie, a po przekroczeniu max_connections MariaDB odrzuca kolejne z błędem 1040.
Pułapki dla administratora
- Limit 151 nie oznacza 151 żądań sklepu. Pulę dzielą z WordPressem cron, kopie zapasowe, panel administratora, monitoring i inne strony na tym samym serwerze bazy. Liczyć trzeba sumę
pm.max_children wszystkich pul plus zapas.
- Strona błędu jest identyczna co do bajtu przy przekroczonym limicie, błędnym haśle w
wp-config.php i wyłączonej bazie (ta sama suma MD5, ten sam kod 500).
- WordPress wycisza błąd. Przy
WP_DEBUG = false wywołuje @mysqli_real_connect(), więc kod 1040 nie trafia do żadnego logu PHP. Ostrzeżenie PHP-FPM „consider raising it” prowadzi w złą stronę: więcej procesów to więcej połączeń.
- Log MariaDB notuje tylko 79% odmów. Odrzucone od razu (
08004/1040) trafiają do logu i licznika Connection_errors_max_connections (35 909). Odrzucone po uwierzytelnieniu (HY000/1040) nie zostawiają wpisu. Pełną liczbę pokazuje tylko Aborted_connects (45 660).
- Zapasowe połączenie administratora nie działa przy zalewie połączeń. MariaDB dopuszcza
max_connections + 1, ostatnie dla użytkownika z uprawnieniem CONNECTION ADMIN, ale przy tysiącach prób na sekundę jest stale zajęte. Klient MariaDB 11.4 domyślnie zestawia TLS, więc administrator widzi komunikat o TLS, a nie „Too many connections”.
- Domyślnie baza nie zapisuje nawet zapytań dłuższych niż 10 s. W wariancie B licznik
Slow_queries doszedł do 743 i żadne nie trafiło do logu. Z progiem 1 s widać winowajców: główną pętlę produktów (SQL_CALC_FOUND_ROWS), zapytania nawigacji warstwowej i liczniki filtrów.
- Bufor InnoDB 1 GB zamiast 128 MB zmniejszył odczyty z dysku z 7,8 GB do zera, ale przepustowość wzrosła tylko o 1 do 4%, bo ograniczeniem był procesor. Sam bufor nie usuwa błędu połączeń.
tmp_table_size i max_heap_table_size 64 MB nie zmieniły nic: około 2 000 tabel tymczasowych na dysku w każdym wariancie, bo tabele z kolumnami TEXT/BLOB (post_content, meta_value) MariaDB tworzy na dysku niezależnie od tych limitów.
- Pula wątków (
thread_handling = pool-of-threads, thread_pool_size = 6) skróciła p95 przy 200 żądaniach z 20,7 s do 9,1 s, ale mediana wzrosła z 3,35 s do 5,37 s, a przepustowość spadła o około 7%.
- Wyszukiwarka WooCommerce (
LIKE '%…%' na post_title, post_excerpt, post_content) przegląda całą tabelę produktów: mediana 15 do 18 s pod obciążeniem w każdym wariancie. Pomoże indeks pełnotekstowy albo zewnętrzna wyszukiwarka, nie konfiguracja bazy.
Konfiguracja MariaDB po zmianie
[mysqld]
# Limit połączeń większy niż suma pm.max_children (180) z zapasem na cron, kopie i administratora
max_connections = 250
# Cała baza sklepu (267 MB danych i indeksów) mieści się w pamięci
innodb_buffer_pool_size = 1G
innodb_log_file_size = 256M
table_open_cache = 4000
table_definition_cache = 2000
# Bezczynne połączenia nie wiszą 8 godzin
wait_timeout = 300
# Wolne zapytania trafiają do logu
slow_query_log = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time = 1
Reguła: max_connections większe niż suma pm.max_children wszystkich pul PHP-FPM korzystających z bazy plus połączenia cron, kopii i administracji. Z buforem 1 GB i 182 połączeniami naraz proces bazy zajmował w szczycie około 0,9 GB.
Diagnostyka bez włączania debugowania WordPressa
SHOW GLOBAL VARIABLES LIKE 'max_connections';
SHOW GLOBAL STATUS WHERE Variable_name IN
('Max_used_connections', 'Threads_connected', 'Connection_errors_max_connections', 'Aborted_connects', 'Slow_queries');
Max_used_connections równe max_connections + 1 i rosnący Aborted_connects oznaczają, że limit był wyczerpany, nawet jeśli w logu bazy nie ma wpisów.
Strona awaryjna wp-content/db-error.php
<?php
error_log('WordPress: brak połączenia z bazą danych, ' . ($_SERVER['REQUEST_METHOD'] ?? 'CLI') . ' ' . ($_SERVER['REQUEST_URI'] ?? ''));
if (!headers_sent()) {
http_response_code(503);
header('Retry-After: 60');
header('Cache-Control: no-store, max-age=0');
header('Content-Type: text/html; charset=utf-8');
}
// dalej strona HTML po polsku: „Sklep jest chwilowo przeciążony…”, telefon
Sprawdzone pod obciążeniem w wariancie A: HTTP/1.1 503 Service Unavailable z Retry-After: 60, polska treść i wpis przy każdym incydencie:
NOTICE: PHP message: WordPress: brak połączenia z bazą danych, GET /produkt/…