В середине обычного рабочего дня B2B-приложение, с которым работают наши партнёры, перестало обслуживать запросы. MariaDB исчерпала лимит подключений, пользователи получали HTTP 500, а за четыре минуты в журнал приложения упало около 20 тысяч ошибок.
Сначала всё выглядело как типичная проблема производительности базы. Расследование показало другое: толчком стал зациклившийся JavaScript в одной вкладке браузера. Но этот всплеск объясняет только начало аварии. Четыре минуты простоя обеспечило само приложение: обработчик ошибок записывал ошибки в уже недоступную базу, страница ошибки снова обращалась к той же базе, а Auth::check() при каждой неудаче пытался подключиться заново.
Ниже — как мы восстанавливали картину по логам Apache, Laravel и счётчикам MariaDB, какие версии не подтвердились, где мы сами ошибались по ходу анализа и что в итоге поменяли.
Контекст
Речь об одном из наших legacy-проектов — B2B-приложении для партнёров: заявки, расчёт стоимости, обмен с внешней учётной системой через HTTP API. Стек: PHP, Laravel, MariaDB, Apache, MPM prefork + mod_php, внешняя система, доступная приложению через HTTP API.
Для расследования оказались важны четыре особенности.
Apache + prefork + mod_php. Каждый процесс Apache несёт в себе интерпретатор PHP и может открыть собственное подключение к MariaDB. PHP-FPM на сервере нет.
Внешняя система отвечает медленно, и это штатно. Весь обмен с ней идёт через единый клиент. Операции расчёта штатно занимают десятки секунд — так устроена обработка на стороне внешней системы, повлиять на это мы не можем. Таймаут curl выставлен чуть меньше минуты. Пока PHP ждёт ответа, подключение к MariaDB остаётся открытым и ничего не делает.
View composer на все шаблоны. В ViewServiceProvider зарегистрирован composer со звёздочкой:
view()->composer(’*’, function ($view) {
if (Auth::check()) {
// данные пользователя для шаблонов
}
});
Он срабатывает при рендере каждой вьюхи: layout, header, footer, каждого @include.
Ошибки пишутся в ту же базу. Обработчик исключений сохраняет их в отдельную таблицу в той же MariaDB. Собственная страница ошибки — обычный Blade-шаблон, который проходит через тот же глобальный composer.
Последние два пункта и определили, почему авария длилась минуты, а не секунды.
Что вообще произошло?
Ниже время указано относительно момента срыва — назовём его T.
Показатель | Значение |
|---|---|
Длительность инцидента | около четырёх минут |
Ошибок 1040 в laravel.log | около 20 тысяч |
Пик | около 10 тысяч ошибок за одну минуту |
Пострадавших пользователей | несколько сотен |
Ответов HTTP 500 | около 2 тысяч, примерно три четверти — на один эндпоинт |
Запросов с одной проблемной вкладки | около 6 тысяч |
Пиковая интенсивность этой вкладки | больше 150 запросов в секунду |
Соотношение лимитов | Apache мог запустить в восемь раз больше процессов, чем разрешала база подключений |
Сама ошибка — стандартная:
SQLSTATE[HY000] [1040] Too many connections
Интереснее оказалась хронология. Сервер замер раньше, чем появилась первая ошибка. Apache пишет строку в access_log в момент завершения запроса. Поэтому число строк за секунду показывает, сколько запросов завершилось. Провал в этом числе означает не затишье, а запросы, которые висят и не могут завершиться.
Время | Завершившихся запросов, % от последней нормальной секунды |
|---|---|
T − 1 с | 100% |
T | 3% |
T + 1 с | 4% |
T + 2 с | 8% |
T + 3 с | 51% |
T + 4 с | 193% |
T + 5 с | 277% |
В момент T завершённые запросы почти исчезли — это и есть срыв. Через несколько секунд очередь прорвало, и за одну секунду завершилось почти втрое больше запросов, чем обычно, — максимум за весь файл.
Первая запись об ошибке 1040 появилась в laravel.log только через двадцать секунд, и сразу пачкой: в ту же секунду пришли ещё почти три десятка. Постепенного нарастания не было — пул подключений не подтекал, он закончился разом.
Дальше по минутам:
Минута | Ошибок 1040 |
|---|---|
первая | ~1,4 тыс. |
вторая | ~4,4 тыс. |
третья | ~3,5 тыс. |
четвёртая | ~10,4 тыс. |
Ещё через минуту шторм стих сам.
Отсюда первый методический вывод: если смотреть только на журнал приложения, начало аварии сдвигается на двадцать секунд. Точный момент срыва дал именно провал в access_log.
Накануне нагрузка была почти вдвое ниже.
Для сравнения взяли то же двадцатиминутное окно предыдущего дня. В день инцидента запросов было примерно в 1,8 раза больше, и все перцентили интенсивности — p50, p90, p95, p99 и максимум — выросли в 1,7–1,8 раза. Но сам по себе рост трафика не объяснял, почему база перестала принимать соединения. Нужно было понять, откуда взялся прирост и сколько времени запросы удерживали подключения.
Первые подозрения: виновата база?
Начали с глобальных счётчиков MariaDB. Сняли их на следующий день. Сервер к тому моменту работал без перезапуска больше полугода.
Здесь мы допустили первую ошибку анализа. Шестизначное число отказов по лимиту подключений выглядело катастрофой, и пару выводов мы успели сделать до того, как получили Uptime и Connections. С делителем картина другая: 0,27% попыток. Та же история с Threads_created: сотни тысяч созданных потоков сначала выглядели как неработающий thread cache, а после нормировки оказались 99% попаданий в кеш — норма. Абсолютные счётчики MySQL и MariaDB без Uptime и Connections читать бесполезно.
Но и нормированная цифра оказалась важной. Доли процента отказов каждый день означают, что проблема существовала задолго до инцидента. Обычно она проявлялась точечно: две-три неудачные загрузки страницы подряд, после которых всё работало дальше. Запаса прочности у системы практически не было.
Почему slow query log был чистым
Следующей версией были медленные SQL-запросы. Но slow query log оказался практически пустым. На первый взгляд это противоречит исчерпанному пулу. На самом деле нет: подключение может быть открыто и при этом не выполнять ни одного запроса. PHP прочитал данные из базы, отправил запрос во внешнюю систему и ждёт ответа десятки секунд. Всё это время соединение занимает слот, но в slow log не попадает в принципе.
Снимок счётчиков согласуется с этой картиной: из двух десятков подключений активно было одно, остальные простаивали. Это единичный снимок в спокойное время, а не измерение во время аварии. Подтвердить гипотезу напрямую могли бы регулярные снимки PROCESSLIST с Command = Sleep.
Так что чистый slow query log не опроверг проблему с подключениями, а подсказал, где её искать: слоты заняты ожиданием, а не работой.
Сколько запаса было на самом деле
Оценить запас помогает закон Литтла: среднее число объектов в системе равно интенсивности их поступления, умноженной на среднее время пребывания.
N = λ × T
Здесь N — число одновременно занятых подключений, λ — интенсивность динамических запросов (без статики), T — среднее время удержания подключения. Подставив наш лимит и данные контрольного дня, получаем:
— при среднем потоке пул заканчивается, если среднее удержание подключения превышает около 14 секунд;
— при пиковом потоке — уже около 3,4 секунды.
Теперь посмотрим на время ответа внешней системы. За два часа до инцидента логировались вызовы дольше 5 секунд:
Показатель | Время |
|---|---|
p50 | ~10 с |
p90 | ~31 с |
p95 | ~37 с |
p99 | ~42 с |
Максимум | упирается в таймаут curl |
Во внешнюю систему ходит лишь часть запросов, поэтому среднее время удержания по всем запросам заметно меньше. Но порог в 3,4 секунды на пике очень низкий: достаточно, чтобы заметная доля запросов ждала внешнюю систему по 10–30 секунд, и среднее подбирается к нему вплотную. Ежедневные отказы говорят о том, что так и происходило.
База данных здесь работала не только хранилищем, но и ресурсом, который приложение держало, пока ждало совершенно другую систему. Увеличение max_connections само по себе эту проблему не решает.
Триггер: одна вкладка и 150 запросов в секунду
Когда дошли до access_log за день инцидента, нашлась аномалия. На эндпоинт сброса расчёта за двадцатиминутное окно пришло около четверти всего трафика приложения.
Заявка | Доля запросов к эндпоинту |
|---|---|
Заявка A | 62% |
Заявка B | 9% |
Заявка C | 8% |
Заявка D | 2% |
Все запросы по заявке A пришли с одним User-Agent и одним Referer — страницей редактирования этой заявки. «Одна вкладка» — вывод, а не установленный факт, но вывод надёжный: одна заявка, один браузер, одна страница. Цикл раскручивался несколько минут: примерно за семь минут до срыва от вкладки приходили единичные запросы в минуту, за две минуты — десятки, за минуту — сотни. На пике вкладка давала больше 150 запросов в секунду, примерно в десять раз больше медианной нагрузки всего приложения накануне.
Что было не так с JavaScript?
Функция в исходном коде:
function resetCalculation()
{
if (ajaxGet("/orders/" + ORDER_ID + «/reset-calculation») === 200) {
// ...
}
}
Здесь два дефекта.
Неверная проверка результата. ajaxGet() возвращает тело HTTP-ответа, а не код статуса. Сравнение с числом 200 не бывает истинным никогда, и содержимое if не выполняется вообще.
Синхронный XMLHttpRequest. Внутри ajaxGet() стоит:
async: false
Каждый вызов блокирует основной поток страницы до ответа сервера. При 150 с лишним запросах в секунду на один запрос приходится около 6 миллисекунд. Важно: синхронный XHR не умножал нагрузку, наоборот — выстраивал запросы строго один за другим. Но это давно устаревший приём, и в тугом цикле он подвешивает страницу.
Откуда взялся сам цикл. resetCalculation() вызывается при любом изменении на странице заявки, вплоть до её перезагрузки. Так задумано: после любой правки результат расчёта сбрасывается, и пользователь обязан заново пересчитать заявку. Вторая часть — автозаполнение блока с несколькими связанными полями. Когда скрипт подставляет значения, он програмно вызывает change на каждом из полей, и каждое снова вызывает resetCalculation().
В итоге одно действие пользователя порождало каскад вызовов, а программные изменения полей порождали новые события change. На заявке A этот каскад не затух.
Как одна вкладка уронила пул подключений
По закону Литтла 150 запросов в секунду × 6 мс ≈ одно одновременное подключение. Сама по себе вкладка держала примерно один слот и пул исчерпать не могла. Она сработала как толчок для системы, у которой запаса не было: добавила к обычному трафику тысячи коротких запросов, каждый из которых занимал воркер Apache и открывал подключение к базе. При этом часть слотов постоянно была занята ожиданием внешней системы, а Apache был готов запустить в разы больше процессов, чем могла обслужить база.
Как именно пул оказался выбран до дна в момент T, по имеющимся данным восстановить до конца нельзя. Но картина согласуется со всеми данными, которые у нас есть.
Оставался главный вопрос: почему приложение не восстановилось через несколько секунд после срыва?
Почему приложение не восстановилось
Здесь расследование перешло от поиска источника нагрузки к тому, как приложение ведёт себя при недоступной базе. Нашлись три дефекта, которые усиливают друг друга.
Auth::check() не запоминает неудачу.
В LaravelSessionGuard::user() устроен так (упрощённо):
if (! is_null($this->user)) {
return $this->user;
}
$id = $this->session->get($this->getName());
if (! is_null($id) && $this->user = $this->provider->retrieveById($id)) {
// пользователь найден
}
Кеш $this->user заполняется только при успешном retrieveById(). Если запрос к базе упал с 1040, $this->user остаётся null, и следующий вызов Auth::check() снова пойдёт в базу. Это не баг фреймворка: кешировать исключение было бы странно. Проблема в сочетании с нашим composer на ’*’, который вызывает Auth::check() на каждой вьюхе. В нормальной работе это лишние вызовы, но дешёвые: после первого успеха пользователь закеширован. При недоступной базе исключение вылетает на первой же вьюхе. В нашем случае это был шаблон подключения стилей в layout: практически все ошибки в логе падали именно там.
Обработчик пишет ошибку о недоступной базе в эту же базу
Получив исключение 1040, обработчик пытается сохранить его в таблицу ошибок. Таблица лежит в той же MariaDB, которая не принимает подключения. Значит, нужна ещё одна попытка подключения, и она тоже получает 1040. Это хорошо видно в логе. Разбивка строк по одному пользователю:
~6,9 тыс. production.ERROR: SQLSTATE[...] 1040 Too many connections
~6,7 тыс. production.ERROR: Ошибка записи в таблицу ошибок: ... 1040
Соотношение почти ровно 1:1: каждая ошибка подключения порождала вторую попытку подключения. Логирование, которое должно помогать диагностике, само добавляло нагрузку на упавший ресурс. Страница ошибки падает там же.
Около 7,5 тысячи записей в логе заканчиваются одинаковым суффиксом, повторённым дважды:
(View: .../layouts/styles.blade.php) (View: .../layouts/styles.blade.php)
Laravel добавляет (View: путь), когда исключение брошено при рендере вьюхи. Двойная обёртка означает: исключение возникло при рендере страницы, Laravel пошёл рендерить страницу ошибки, и она упала в том же месте — в composer, на Auth::check().
Как это складывается
На каждый запрос, пришедший в момент аварии:
рендер страницы
→ composer → Auth::check() → подключение № 1 → 1040
→ Handler пишет в таблицу ошибок → подключение № 2 → 1040
→ рендер страницы ошибки → composer → Auth::check() → подключение № 3 → 1040
→ необработанная ошибка, HTTP 500
Вместо одной попытки подключения — минимум три, и каждая хватает любой освободившийся слот. Приложение утроило собственный поток подключений ровно в тот момент, когда базе нужно было разгрузиться. Пока поток запросов не схлынул, пул восстановиться не мог.
Отдельная деталь — распределение ошибок по пользователям. На одного пользователя пришлось 68% всех строк, у следующего за ним — в пятьдесят раз меньше. В пиковую секунду на него пришлось около 700 строк лога, а каждая строка здесь — неудачная попытка подключения. Легко решить, что это и есть источник атаки. Но заявка A к нему не относится, а за два часа лога он сделал ровно один вызов во внешнюю систему — уже после шторма. Сопоставить его строки в laravel.log с запросами в access_log напрямую не удалось.
Отсюда методический вывод: число строк в laravel.log нельзя приравнивать к числу HTTP-запросов. Один запрос порождает несколько исключений, и без сопоставления с access_log легко обвинить пользователя, робота или партнёра.
Какие версии не подтвердились
Версия | Результат | Основание |
|---|---|---|
Медленные SQL-запросы | Отвергнута | Slow query log чист — и это подтверждает диагноз: ждущее подключение не выполняет SQL |
Утечка подключений в приложении | Отвергнута | Aborted_clients — меньше одного в сутки: приложение закрывает соединения корректно |
Persistent-подключения PDO | Отвергнута | PDO::ATTR_PERSISTENT в config/database.php нет |
Маленький thread cache | Отвергнута | Threads_created — меньше 1% от Connections |
Брошенные вкладки, которые опрашивают сервер по таймеру | Отвергнута | Таймерного опроса в коде нет; такие вкладки дают около 1% трафика |
Автоматизированная нагрузка от партнёра | Отвергнута | Пользователь с 68% ошибок не связан с заявкой A и почти не обращался к внешней системе |
Массовый всплеск обычного трафика | Отвергнута | Прирост почти целиком состоит из запросов одной вкладки |
Зависание внешней системы как триггер | Отвергнута | Медленные вызовы во время шторма не выросли, а упали |
Зациклившийся фронтенд | Подтверждена | Тысячи запросов с одной вкладки, нарастание точно совпадает с началом шторма |
Про внешнюю систему стоит сказать отдельно. Медленные вызовы шли ровным фоном, около десяти в минуту, а во время шторма их стало меньше: 6, 4, 4 и 1 за четыре минуты аварии. Причина простая: приложение уже не могло получить подключение к базе, и до обращения во внешнюю систему дело не доходило. Настоящих curl-таймаутов за два часа было всего два, оба задолго до инцидента. Внешняя система — постоянный фоновый фактор риска, но не толчок.
Версию с брошенными вкладками мы сначала оценили примерно в треть всех подключений, не прочитав код. Код показал, что фоновые запросы отправляются только в ответ на действия пользователя, а не по таймеру. Оценка оказалась завышена на порядок.
Отдельная проблема: Apache против MariaDB
В конфигурации обнаружилась диспропорция: MaxRequestWorkers в Apache был в восемь раз больше, чем max_connections в MariaDB.
С prefork и mod_php каждый процесс Apache может открыть своё подключение. Веб-сервер готов принять в восемь раз больше параллельной работы, чем база способна обслужить. При такой связке ошибка 1040 — вопрос времени: как только одновременных запросов к базе становится больше лимита, начинаются отказы. Будь лимит воркеров сопоставим с лимитом подключений, лишние запросы ждали бы в очереди, а не порождали сотни процессов, дерущихся за слоты.
Есть и вопрос памяти. Процесс prefork с mod_php обычно занимает десятки мегабайт RSS, и завышенный лимит воркеров легко превышает реальную память сервера. Получается неприятная вещь: ошибка 1040 в такой конфигурации работает предохранителем. Она останавливает рост параллелизма раньше, чем сервер начнёт задыхаться от нехватки памяти, — ценой отказа в обслуживании.
Реальный потолок определяется измерением, а не формулой:
free -m
ps -ylC apache2 —sort:rss | awk ’
NR > 1 { sum += $8; n++ }
END { print «процессов:», n, «средний RSS, МБ:», sum / n / 1024 }’
MaxRequestWorkers не должен превышать (свободная память − память под базу и прочее) / средний RSS. И воркеров не должно быть больше, чем подключений к базе, с запасом на cron, очереди и административные задачи.
При этом снижать число воркеров, не решив проблему удержания подключений, опасно. Пока внешние вызовы держат воркер десятки секунд, урезанный пул воркеров можно целиком занять ожиданием внешней системы.
В более широком смысле prefork с mod_php плохо подходит приложению, где заметная часть запросов ждёт внешнюю систему по 10–50 секунд: полноценный процесс с интерпретатором всё это время простаивает. Переход на MPM event и PHP-FPM — отдельная задача.
Что мы исправили
Ограничение для всех правок одно: это legacy, где невозможно предсказать все последствия. Поэтому изменения минимальные и точечные.
Источник нагрузки
Разобрались с resetCalculation():
— нашли все места вызова функции, включая обработчики change в автозаполнении; — разорвали цикл повторных вызовов; — исправили проверку результата: теперь проверяется реальный код ответа, а не тело; — отказались от синхронного XHR; — добавили защиту от повторного запуска, пока предыдущий запрос не завершился.
Composer: один вызов на запрос и никаких исключений
Убрали повторные вызовы Auth::check() в рамках одного HTTP-запроса: данные пользователя получаем один раз и раздаём шаблонам через view()->share(). Набор переменных при этом не меняется — какие из сотен шаблонов их используют, выяснить надёжно невозможно.
public function boot()
{
$isConsole = $this->app->runningInConsole();
view()->composer(’*’, function () use ($isConsole) {
static $shared = false;
if ($shared && ! $isConsole) {
return;
}
$userId = $userName = null;
try {
if (Auth::check()) {
$user = Auth::user();
$userId = $user->id;
$userName = $user->name;
}
} catch (\Throwable $e) {
// БД или сессия недоступны — рендер не роняем
}
view()->share([
’user_id’ => $userId,
’user_name’ => $userName,
]);
$shared = true;
});
}
В этом коде три неочевидные детали
Флаг $shared выставляется после view()->share(). Иначе при исключении переменные не раздадутся вовсе, а composer на следующей вьюхе молча выйдет по return, и вместо настоящей ошибки мы получим «Undefined variable».
Проверка runningInConsole() обязательна. Queue-воркер живёт часами и рендерит письма для разных пользователей. Без этой проверки первая задача в очереди «запомнит» первого пользователя, и все следующие письма уйдут с его именем. Для приложения, которое формирует документы, это тихая подмена данных.
try/catch гарантирует, что недоступная база больше не роняет рендер страницы на первой же вьюхе.
Ошибки базы обрабатываются мимо базы и мимо Blade
Для ошибок подключения сделали отдельную ветку в Handler. Она стоит до любой попытки записать что-либо в таблицу ошибок:
if ($exception instanceof \PDOException) {
\Log::error(’DB недоступна: ’ . $exception->getMessage());
if ($request->expectsJson() || $request->ajax()) {
return response()->json([’error’ => ’Сервис временно недоступен’], 503);
}
$html = @file_get_contents(public_path(’errors/503.html’));
return response($html ?: ’Сервис временно недоступен’, 503, [’Retry-After’ => 30]);
}
QueryException наследуется от PDOException, так что одной проверки хватает. Ошибка пишется в файловый лог, который не зависит от упавшего ресурса. Страница — статический HTML без Blade и без обращений к базе. Отдельная ветка для AJAX нужна, потому что большая часть взаимодействия в приложении идёт через AJAX-запросы.
Код 503 потребовал согласования с фронтендом: часть обработчиков ошибок AJAX по-своему интерпретировала коды ответа, и их пришлось поправить.
Подключение не держится во время ожидания внешней системы
Время ответа внешней системы — её особенность, поэтому сокращать общий таймаут до нескольких секунд нельзя. Вместо этого перед вызовом мы отпускаем подключение к базе:
$releaseDb = \DB::transactionLevel() === 0;
if ($releaseDb) {
\DB::disconnect();
}
$response = curl_exec($curl);
Laravel переподключится сам при первом же следующем обращении к базе. Отключаться можно только вне транзакции — отсюда проверка transactionLevel(). Перед выкаткой мы прошлись по всем местам с beginTransaction и DB::transaction(, чтобы убедиться, что внешний вызов не происходит внутри транзакции.
Какие данные стоит собирать заранее
Значительная часть любого подобного расследования уходит на восстановление того, что можно было бы просто прочитать в логах. Вот что, по нашему опыту, стоит собирать до инцидента.
— Реальный IP клиента в access_log, если запросы приходят через прокси или балансировщик. — Время обработки запроса (%D) — это прямое измерение времени удержания подключения. — Регулярные снимки PROCESSLIST, чтобы видеть состояние пула в момент аварии, а не восстанавливать его по косвенным признакам. — Контекст в записях об ошибках: URL, метод и идентификатор сессии, а не только сообщение и трейс.
Формат лога Apache для этого:
LogFormat "%h %{X-Forwarded-For}i %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\" %D %X" xff_combined
Здесь %{X-Forwarded-For}i — реальный IP клиента, %D — время обработки запроса в микросекундах, %X — оборвал ли клиент соединение.
Снимок подключений MariaDB, например раз в 10 секунд:
SELECT NOW(), id, user, host, db, command, time, state, LEFT(info, 200)
FROM information_schema.processlist;
Главное в этих снимках — подключения в состоянии Sleep. Только они показывают ситуацию, когда база не выполняет SQL, а слоты всё равно заняты. Плюс mod_status с ExtendedStatus On.
Ещё один инструмент, без которого разбор ушёл бы в тупик, — сквозной идентификатор запроса в логах. В какой-то момент по логам казалось, что return в PHP «не прерывает выполнение»: после него выполнялась другая ветка кода. Идентификатор запроса показал, что это два разных HTTP-запроса, строки которых просто шли в логе подряд.
Итоги
В этом инциденте не было одной ошибки. Отказ сложился из нескольких факторов:
У пула подключений MariaDB практически не было запаса относительно реальной нагрузки.
PHP держал подключения к базе, пока ждал ответа внешней системы.
Apache мог запустить в разы больше процессов, чем база готова была обслужить.
JavaScript в одной вкладке начал генерировать тысячи запросов.
Обработчик ошибок пытался записать ошибку в ту же базу, которая уже отказала.
Страница ошибки снова обращалась к базе через глобальный composer, а Auth::check() не запоминал неудачу.
Толчок вызвал отказ, но устройство приложения превратило короткую просадку в четырёхминутный шторм.
Я бы выделил четыре практических вывода.
Причина отказа и причина его длительности — разные вещи. Найти то, что исчерпало пул, недостаточно. Нужно понять, почему система не восстановилась, когда толчок прошёл. Исправить только триггер — значит остаться уязвимым к следующему толчку. Исправить только самоудержание — сократить аварии до секунд, но не предотвратить их.
Обработка ошибок должна быть независимой от отказавшего ресурса. Если база недоступна, нельзя записывать информацию об этом в ту же базу. Если упал механизм рендеринга, нельзя рендерить им страницу ошибки.
Медленная внешняя система способна исчерпать ресурсы совсем другой системы. Внешняя система не создавала пиковой нагрузки в момент аварии, но её штатное время ответа в десятки секунд постоянно держало подключения к MariaDB.
Метрики без контекста ведут к неправильным выводам. Шестизначное число отказов оказалось 0,27% попыток. Чистый slow query log не опроверг проблему с базой, а подтвердил её. Пользователь с 68% ошибок оказался не атакующим, а жертвой петли в обработке ошибок.
Главным результатом расследования стал не найденный цикл в JavaScript. Оно показало, насколько опасно сочетание долгих внешних вызовов, удержания подключений и обработки ошибок, которая сама зависит от упавшего ресурса. Такие сочетания особенно часто встречаются в системах, которые годами развиваются без пересмотра решений, принятых в самом начале.

Комментарии 0