6. Deadlock и их предотвращение
Введение: Циклическая зависимость как неизбежность
Дедлок (взаимная блокировка) — это тупиковое состояние, при котором две или более транзакции бесконечно ожидают ресурсы, удерживаемые друг другом. В отличие от обычных очередей ожидания или сетевых таймаутов, дедлок принципиально не способен разрешиться самостоятельно: ни одна из транзакций не может продвинуться вперед, не получив ресурс соседа, а сосед не отдаст ресурс, пока не завершится сам.
Если ядро СУБД не вмешается хирургическим путем, система мгновенно окажется в состоянии ливлока или полного зависания пула сетевых соединений.
Для инженера уровня Senior/Lead дедлоки — это не «баги базы данных», а четкий симптом архитектурных или логических ошибок в коде приложения. Понимание механики их возникновения, алгоритмов обнаружения и стратегий предотвращения позволяет:
- Проектировать транзакционные контуры, устойчивые к высокой конкуренции.
- Писать отказоустойчивый код на Go с детерминированным поведением при конфликтах.
- Избегать каскадных отказов микросервисов, когда один зависший запрос блокирует весь пул.
В этой лекции мы разберем математическую природу дедлоков, алгоритмы поиска циклов в ориентированных графах ядра СУБД, оценим цену обнаружения на уровне кэш-линий CPU и освоим идиоматичные инженерные практики защиты в Go.
Механика возникновения: Граф ожидания
Классический сценарий дедлока требует четырёх условий (условия Коффмана):
- Взаимное исключение: ресурс может удерживаться только одной транзакцией.
- Удержание и ожидание: транзакция держит один ресурс и ждёт другой.
- Отсутствие вытеснения: ресурс нельзя принудительно отобрать.
- Циклическое ожидание: образуется замкнутая цепочка зависимостей.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Транзакция A"]:::process -->|"Захватывает ресурс X"| B["X занят"]
C["Транзакция B"]:::process -->|"Захватывает ресурс Y"| D["Y занят"]
A -->|"Ожидает Y"| D
C -->|"Ожидает X"| B
B -->|"Цикл найден"| E["Детектор дедлоков"]
D -->|"Цикл найден"| E
E -->|"Откатывает жертву"| F["Ошибка 40P01<br>Освобождение локов"]
В СУБД эти зависимости моделируются как ориентированный граф ожидания (Wait-For Graph). Вершины — транзакции, рёбра — зависимости «ждёт». Появление цикла в графе = дедлок.
Детектор дедлоков: Как СУБД «видит» круги
Проверка на циклы — дорогая операция. Если запускать её при каждой постановке транзакции в очередь ожидания, накладные расходы съедят всю производительность. Поэтому СУБД используют таймаутный или гибридный подход.
Выбор жертвы
СУБД не использует случайность. Алгоритм минимизирует:
- Объём
UNDO-лога, который нужно применить. - Время, потраченное транзакцией на выполнение.
- Вероятность повторного дедлока при ретрае (иногда выбирается транзакция, удерживающая более «горячие» ресурсы, чтобы разорвать узкий место).
Механическая симпатия: Цена обнаружения для ОС и рантайма Go
Дедлок влияет не только на базу, но и на ваше Go-приложение на системном уровне.
Память пула соединений
Когда горутина попадает в ожидание лока, соединение database/sql не возвращается в пул. Оно остаётся в состоянии idle in transaction или active на стороне СУБД. Если SetMaxOpenConns(20) и 20 горутин ждут разблокировки, пул исчерпается. Следующие запросы получат sql: connection pool exhausted, даже если CPU и диск сервера БД простаивают.
Системные вызовы и парковка
Ожидание лока реализовано через futex (Linux) или WaitForSingleObject (Windows). Тред ОС, обслуживающий горутину, переходит в состояние TASK_INTERRUPTIBLE и выходит из планировщика. Когда детектор откатывает транзакцию-жертву:
- СУБД закрывает соединение или отправляет
TCP RST/ ошибку в сокет. - Ядро ОС будит ждущий тред.
- Рантайм Go переводит горутину из
waitingвrunnable. - Планировщик выделяет тред ОС для выполнения откатанной ветки.
Этот путь контекстных переключений стоит ~5-10 мкс на переход, но при сотнях дедлоков в секунду накладные расходы на пробуждение тредов и очистку структур PGPROC становятся заметными.
Кэш-линии CPU
Обход графа ожидания детектором требует чтения множества указателей в shmem. При высокой конкуренции это вызывает промахи кэша (cache misses) и загрязнение L3, так как данные о локах разбросаны по разделяемой памяти и редко попадают в предвыборку.
Предотвращение и стратегии в Go
Дедлоки нельзя устранить полностью в распределённой системе, но можно свести вероятность к минимуму и сделать реакцию детерминированной.
-
Канонический порядок блокировок
Всегда запрашивайте ресурсы в одинаковой последовательности. Если транзакцииAиBвсегда лочат строки в порядке возрастанияID, цикл невозможен:Go // Всегда сортируем входные данные перед блокировкой sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) for _, id := range ids { // FOR UPDATE в отсортированном порядке } -
Минимизация времени удержания
Не выполняйте внешние вызовы (HTTP, RPC, тяжелые вычисления) внутри транзакции. Чем короче окно междуBEGINиCOMMIT, тем меньше шанс пересечения с другой транзакцией. -
Явные таймауты и NOWAIT
Не позволяйте транзакциям ждать бесконечно. Используйтеlock_timeoutна стороне СУБД или контекстные таймауты в Go:Go // Установка таймаута ожидания лока на уровне сессии _, err := tx.ExecContext(ctx, "SET LOCAL lock_timeout = '200ms'") -
Паттерн ретрая с экспоненциальной задержкой
Go func ExecuteWithDeadlockRetry(ctx context.Context, db *sql.DB, maxRetries int, fn func(context.Context, *sql.Tx) error) error { for attempt := 0; attempt <= maxRetries; attempt++ { tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } if err := fn(ctx, tx); err != nil { _ = tx.Rollback() // Проверяем код дедлока PostgreSQL if pgErr, ok := err.(*pq.Error); ok && pgErr.Code == "40P01" { delay := time.Duration(1<<uint(attempt)) * 50 * time.Millisecond select { case <-time.After(delay): continue case <-ctx.Done(): return ctx.Err() } } return err } return tx.Commit() } return fmt.Errorf("max retries exceeded after deadlocks") }
Итог
- Дедлок — это циклическая зависимость в графе ожидания ресурсов. СУБД разрешает его, принудительно откатывая одну из транзакций.
- Обнаружение происходит фоновым процессом с интервалом
deadlock_timeout. Проверка графа имеет накладные расходы на память и кэш-линии. - Влияние на Go: Ожидающие транзакции удерживают соединения из пула, что ведёт к
connection pool exhaustedпри массовых конфликтах. - Стратегии: Сортировка блокировок, минимизация транзакций,
NOWAIT/таймауты, идемпотентный ретрай. - Архитектура: Дедлоки — индикатор проблем с гранулярностью транзакций или порядком доступа к данным. Логируйте
40P01в Sentry/Prometheus и отслеживайте частоту.
Понимание блокировок и дедлоков подводит нас к альтернативному подходу, который лежит в основе современных высоконагруженных СУБД: вместо ожидания и отката хранить несколько версий данных и обслуживать читателей без конфликтов.
В следующей статье мы детально разберём механизм, который делает это возможным: 7. MVCC. Multi Version Concurrency Control.