Jump to content
View in the app

A better way to browse. Learn more.

T.M.I IThub

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.

Пятница, 23:47. Прод лежит. Я в ванной. Классика.

(0 reviews)

Привет, коллеги по несчастью. Меня зовут Максим, я продуктовый DevOps с десятью годами шрамов на психике и подгоревшим нервным окончанием там, где у нормальных людей находится чувство покоя. Сегодня я расскажу вам историю, которую в каждой IT-компании мира знают наизусть, но всё равно каждый раз проживают как первый раз. Историю о том, как прод падает именно тогда, когда тебе это меньше всего нужно.

Итак. Декабрь. Пятница. Мы только что задеплоили «маленький hotfix» — ну там, буквально пять строчек, ничего серьёзного. Я уже мысленно дома, уже открываю холодильник, уже слышу шипение открываемой банки. И тут — дзынь. Алерт в Telegram. Потом ещё один. Потом ещё пять. Потом просто поток, как будто кто-то открыл кран с тревогами.

🔴 CRITICAL: Response time > 30s

🔴 CRITICAL: Error rate 78%

🔴 CRITICAL: Database connections exhausted

🔴 CRITICAL: Redis timeout

🔴 CRITICAL: Payment service unreachable

Пять алертов за 40 секунд. Это рекорд, кстати. Я горжусь.


Анатомия катастрофы

Теперь давайте по-серьёзному, потому что случай был действительно интересный с технической точки зрения, и на ithub.uno такие постморtem-разборы ценятся.

Итак, что мы имели на тот момент:

  • Стек: PHP 8.2, CodeIgniter 4.5, MySQL 8.0 (кластер primary + 2 replica), Redis 7.0 Cluster, Nginx, всё это добро в k8s на трёх нодах

  • Трафик: ~3500 RPS в пике, средний — около 800 RPS

  • Hotfix: изменили одну строчку в модели, которая отвечала за выборку пользовательских настроек

Что могло пойти не так? Всё. Абсолютно всё.

Первое, что я сделал — зашёл на Grafana. Там картина маслом: RPS упал с 800 до 120, латентность взлетела с 80ms до 28 секунд (ДВАДЦАТИ ВОСЬМИ, Карл!), количество активных connections к MySQL упёрлось в потолок — 500/500, CPU на всех подах — 95%+.

Классический симптом. Я такое уже видел. Это называется «connection pool exhaustion» в сочетании с «slow query лавиной». Но причина была нетривиальной.


Копаем

Первым делом — slow query log на MySQL:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;

Через 30 секунд смотрю лог и вижу:

SELECT u.*, us.*, p.*, pr.*, c.*
FROM users u
LEFT JOIN user_settings us ON us.user_id = u.id
LEFT JOIN profiles p ON p.user_id = u.id
LEFT JOIN preferences pr ON pr.user_id = u.id
LEFT JOIN cart c ON c.user_id = u.id AND c.status = 'active'
WHERE u.id = 12345

Запрос сам по себе несложный. Выполняется за 0.003 секунды. Но... EXPLAIN показывает Using temporary; Using filesort на таблице cart. И вот тут начинается детективная история.

Дело в том, что за час до деплоя наш аналитик (земля ему пухом) запустил миграцию, которая добавила в таблицу cart новое поле meta_json TEXT. При этом индекс idx_cart_user_status не пересоздавался. Он просто... перестал эффективно работать после изменения статистики таблицы. MySQL решил, что full scan выгоднее. При этом таблица cart содержала 47 миллионов строк.

А наш «маленький hotfix»? Он убрал кэширование этого запроса. Буквально одну строку:

// Было:
return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId));

// Стало (hotfix убрал кэш "для дебага"):
return $this->buildUserData($userId);

И вот оно. Идеальный шторм. Медленный запрос × отсутствие кэша × высокий трафик = прод в нокауте.


Решение в боевых условиях

Времени на красивые решения нет. Алгоритм действий:

Шаг 1: Feature flag

У нас есть система feature flags на Redis. Мгновенно включаю maintenance mode для новых пользователей, пропуская залогиненных:

// CI4 Filter
class MaintenanceFilter implements FilterInterface
{
    public function before(RequestInterface $request, $arguments = null)
    {
        if (cache()->get('maintenance_mode') && !auth()->check()) {
            return redirect()->to('/maintenance');
        }
    }
}

Трафик упал на 40%. Дышим.

Шаг 2: Откат

kubectl rollout undo deployment/app --to-revision=15

Это не решение проблемы с индексом, но снимает острую боль — кэш вернулся, запросы снова летают.

Шаг 3: Экстренное создание индекса

Пока прод восстанавливается, параллельно:

-- На реплике сначала тестируем
CREATE INDEX idx_cart_user_status_new
ON cart(user_id, status)
INCLUDE (id, created_at, meta_json)
ALGORITHM=INPLACE, LOCK=NONE;

ALGORITHM=INPLACE, LOCK=NONE — это не просто красивые слова. На таблице в 47 миллионов строк это разница между «индекс создаётся 4 минуты без локов» и «БД заблокирована на 25 минут, прощайте».

Через 6 минут индекс на реплике. Тестируем — запрос летает за 1.2ms. Прогоняем на primary. Ещё 4 минуты. Готово.

Шаг 4: Хотфикс хотфикса

return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId));

Да, просто вернули строку обратно. Иногда лучшее решение — отмотать назад.


Постмортем и уроки

Через два дня я провёл постмортем. Вот ключевые выводы, которые я теперь вколачиваю в голову каждому новому разработчику:

1. Никогда не убирайте кэш "для дебага" в прод Это как снять шлем "чтобы лучше видеть". Дебажьте на стейджинге. Там специально и создано это место.

2. Любая миграция схемы БД требует аудита индексов Мы внедрили правило: к каждому PR с миграцией прилагается EXPLAIN до и после на production-like данных (у нас есть анонимизированный дамп).

3. Алерты должны быть actionable Пять одновременных алертов — это не пять проблем. Это одна проблема с пятью симптомами. Мы перенастроили alertmanager с группировкой и подавлением дублей.

4. Connection pool — ваш первый друг и первый враг В CI4 мы теперь явно конфигурируем пул:

// app/Config/Database.php
public array $default = [
    'DBDriver' => 'MySQLi',
    'hostname' => env('DB_HOST'),
    'pconnect'  => false, // persistent connections OFF в highload!
    'DBDebug'   => false,
    // ...
];

Persistent connections в highload — это бомба замедленного действия. Отключайте.

5. Chaos engineering — не роскошь, а необходимость После этого инцидента мы раз в квартал намеренно убиваем случайный под в прод-кластере. Да, в проде. Нет, это не безумие — это единственный способ убедиться, что система действительно resilient.


Финал

В 01:23 прод поднялся полностью. Показатели вернулись к норме. Я наконец открыл ту банку. Она была тёплой.

Но знаете что? Этот инцидент стоил нам примерно $4,000 потерянной выручки и несколько седых волос. Зато мы получили бесценный опыт и полностью переработали процесс деплоя. Теперь у нас есть автоматическая проверка slow queries перед каждым деплоем, обязательный review индексов при миграциях и — самое главное — правило: никаких деплоев в пятницу после 18:00.

Это правило нарушают только те, кто ещё не прожил свою первую пятничную аварию. После первой — никогда.


0 Comments

Recommended Comments

There are no comments to display.

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.