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.

Как мы пережили Black Friday: postmortem, который я не хотел писать

(0 reviews)

Это не та история, которой гордятся. Это история, которую рассказывают тихо, за пивом, другим DevOps'ам — чтобы они не совершили те же ошибки. Но знаете что? Я решил рассказать её громко. Потому что честность важнее репутации, а реальные истории учат лучше, чем придуманные кейсы.

Black Friday. Наш e-commerce на CI4. Трафик × 15 от обычного. Что могло пойти не так?

Спойлер: всё.


Подготовка (которой, как оказалось, было недостаточно)

За три недели до BF мы провели "подготовку". По тем временам нам казалось, что мы сделали всё правильно:

Провели нагрузочный тест на 5× нормального трафика
Настроили автомасштабирование k8s
Прогрели CDN-кэш для статики
Оптимизировали топ-20 медленных запросов
Увеличили connection pool MySQL
Настроили алерты

Что мы не сделали (и о чём потом пожалели):

Не протестировали 15× трафика (только 5×)
Не протестировали scenario с высоким числом одновременных checkout операций
Не проверили поведение при частичном отказе зависимостей
Не подготовили runbook для дежурной команды


Хронология событий

00:00 — Midnight madness старт

Трафик начал расти в 23:45. К полуночи — ×4 от нормы. Всё отлично, k8s масштабируется, метрики в норме, team в Teams чатится позитивно.

00:23 — Первый звонок

🔴 ALERT: Checkout error rate > 5%

5% checkout'ов не проходят. Это много. Начинаем копать.

В логах:

Deadlock found when trying to get lock; try restarting transaction

MySQL deadlock. Два процесса пытались обновить одну и ту же запись в таблице inventory (остатки товаров) одновременно. При ×4 трафике вероятность коллизии выросла критически.

Быстрое решение: добавили SELECT ... FOR UPDATE с retry-логикой:

public function decrementStock(int $productId, int $quantity): bool
{
    $maxRetries = 3;
    $retryDelay = 100; // ms

    for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
        $this->db->transStart();

        try {
            $product = $this->db
                ->table('inventory')
                ->where('product_id', $productId)
                ->lockForUpdate()
                ->get()
                ->getRowArray();

            if ($product['stock'] < $quantity) {
                $this->db->transRollback();
                return false;
            }

            $this->db->table('inventory')
                ->where('product_id', $productId)
                ->update(['stock' => $product['stock'] - $quantity]);

            $this->db->transCommit();
            return true;

        } catch (\Exception $e) {
            $this->db->transRollback();

            if ($attempt < $maxRetries && str_contains($e->getMessage(), 'Deadlock')) {
                usleep($retryDelay * 1000 * $attempt);
                continue;
            }

            throw $e;
        }
    }

    return false;
}

Деплой. Прошло. Продолжаем.

01:47 — Главное событие

🔴 CRITICAL: Database primary unreachable

🔴 CRITICAL: All checkout endpoints down

🔴 CRITICAL: Payment service timeout

MySQL primary упал. Не лёг — упал. Полностью. Причина выяснилась позже: диск заполнился из-за бинарных логов (binary log retention не был настроен для ситуации с × 15 write операциями).

df -h /var/lib/mysql
# Filesystem: 100% used (512GB / 512GB)

512 гигабайт. Всё. Диск кончился. MySQL не может писать — MySQL падает. Элегантно, ничего не скажешь.

Автоматический failover сработал — реплика стала primary. Это заняло 23 секунды. 23 секунды — 100% ошибок на checkout. При трафике BF это $147,000 потерянной выручки. За двадцать три секунды.

Затем выяснилось: наша реплика не была настроена на роль primary. У неё не было некоторых критических stored procedures. Checkout стал работать, но с ошибками 15%.

Следующие 40 минут команда в поту накатывала stored procedures на новый primary, чистила binlogs на упавшем сервере (он был ещё нужен), настраивала новую репликацию.

02:47 — Всё относительно стабильно

Error rate 2.3%. Для BF — терпимо, но не хорошо.

04:15 — Redis OOM

OOM command not allowed when used memory > 'maxmemory'

Redis закончил память. Eviction policy была noeviction — вместо того чтобы выкидывать старые ключи, Redis начал отклонять все write-операции. Приложение посыпалось.

Быстрый фикс:

redis-cli CONFIG SET maxmemory-policy allkeys-lru

LRU eviction включён. Redis начал вытеснять старые ключи. Кэш-хиты упали с 94% до 61%, нагрузка на MySQL снова выросла, но хотя бы приложение работало.


Постмортем: что пошло не так

Причина 1: Мы не тестировали реальный сценарий

Нагрузочный тест в ×5 — это не BF при ×15. Мы протестировали "всё нормально", а не "всё горит". Нужно было делать chaos testing: убивать primary во время нагрузки, заполнять диск, устраивать OOM Redis.

Причина 2: MySQL binlog retention

-- Должно быть настроено!
SET GLOBAL binlog_expire_logs_seconds = 86400; -- 24 часа

Это одна строчка. ОДНА. И она бы предотвратила заполнение диска.

Причина 3: Replica не была готова к роли primary

Наша "репликация" была настроена для read scaling, а не для failover. Stored procedures, triggers, специфичные настройки — ничего из этого не дублировалось. Это фундаментальная ошибка в архитектуре HA.

Причина 4: Redis maxmemory-policy noeviction

Кто-то (я) настроил noeviction "чтобы данные не терялись". Логика благородная. Результат катастрофический. В production eviction лучше, чем полный отказ сервиса.


Что изменилось после

1. GameDay — обязательная практика

Раз в квартал мы проводим "день катастрофы": специально ломаем production-like окружение и смотрим как команда реагирует. Сценарии: упал primary MySQL, заполнился диск, OOM Redis, одна нода k8s недоступна.

2. Runbook для каждого critical alert

Каждый алерт в PagerDuty теперь ссылается на confluence-страницу с пошаговым runbook. Дежурный не должен думать — он должен читать и выполнять.

3. Pre-BF чеклист

# Automated pre-event checks
./scripts/pre-event-check.sh

# Checks:
# ✓ MySQL disk usage < 50%
# ✓ Binlog retention configured
# ✓ Redis maxmemory-policy = allkeys-lru
# ✓ Replica can promote to primary (stored procs present)
# ✓ HPA max replicas sufficient
# ✓ CDN cache warm
# ✓ All alerts configured and tested
# ✓ On-call rotation confirmed
# ✓ Rollback plan documented

Финансовые итоги

Общие потери от инцидентов BF: ~$230,000 (прямые потери выручки + компенсации + репутационный ущерб).

Следующий год, после всех изменений: BF прошёл без единого critical инцидента. Error rate — 0.08%. Выручка выросла на 340% по сравнению с прошлым BF.

Цена нормальной подготовки: 3 недели работы команды + $15,000 на gameday инфраструктуру.

Разница: 230,000 vs 15,000. Выбор очевиден.

Делитесь похожими историями — на ithub.uno такие postmortem'ы читают и обсуждают живее всего. Потому что в них — настоящий опыт. 💀➡️🧠


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.