5. Блокировки. Locking
Введение: Блокировки как фундамент координации в БД
Блокировки (Locks) — это фундаментальные примитивы синхронизации на уровне СУБД, которые гарантируют корректность данных при конкурентном доступе. Если в предыдущих статьях мы рассматривали уровни изоляции как абстрактные математические гарантии, то блокировки — это их осязаемая физическая реализация в оперативной памяти движка базы данных.
Для архитектора и старшего инженера понимание механики блокировок критично, потому что:
- Они определяют реальную пропускную способность системы под нагрузкой, а не на синтетических тестах.
- Неверное расставление или эскалация блокировок — главная причина каскадных таймаутов и отказа микросервисов.
- В Go вы управляете транзакциями явно, и знание того, как драйвер и СУБД взаимодействуют через сокеты и пул соединений, позволяет избежать скрытых дедлоков и истощения ресурсов.
В этой статье мы разберём классификацию блокировок, их внутреннее представление в памяти движков, влияние на кэш-линии CPU и системные вызовы ОС, а также покажем идиоматичные паттерны работы с локами в Go.
flowchart TD
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["Запрос к БД"]:::entry --> B{"Требуется блокировка?"}
B -->|"Нет"| C["Выполнение без локов"]
B -->|"Да"| D["Проверка таблицы совместимости"]
D --> E{"Совместима с текущими?"}
E -->|"Да"| F["Установка лока в Lock Manager"]
E -->|"Нет"| G["Ожидание в очереди ожидания"]
G --> H["Переход в состояние Park<br>в рантайме Go"]
H --> I{"Таймаут или Ресурс свободен?"}
I -->|"Ресурс"| J["Пробуждение и продолжение"]
I -->|"Таймаут"| K["Возврат ошибки 57014"]
F --> L["Выполнение операции"]
L --> M["COMMIT / ROLLBACK<br>и освобождение локов"]
1. Классификация блокировок: Режимы и гранулярность
Блокировки в реляционных СУБД характеризуются двумя параметрами: режимом доступа и гранулярностью.
Режимы блокировок
- Shared (S): Разделяющая блокировка. Ставится при
SELECT. Несколько транзакций могут одновременно держатьS-лок на одном ресурсе. ЗапрещаетX-локи. - Exclusive (X): Исключительная блокировка. Ставится при
INSERT,UPDATE,DELETEилиSELECT ... FOR UPDATE. Только одна транзакция может держатьX-лок. Запрещает любые другие локи. - Intent (IS, IX, SIX): Интенциональные блокировки. Это «флаги» на уровнях таблицы или страницы, которые сообщают менеджеру блокировок: «Я планирую взять более детальный лок внутри этой структуры». Они предотвращают конфликты между разными гранулярностями (например, блокировкой всей таблицы и блокировкой отдельной строки внутри неё).
Гранулярность
| Уровень | Когда применяется | Стоимость удержания |
|---|---|---|
| Строка | UPDATE ... WHERE id=1, FOR UPDATE |
Минимальная. Максимальный параллелизм. |
| Страница | При обновлении множества строк на одной 8КБ странице | Средняя. В PostgreSQL не используется явно, в InnoDB — как промежуточный этап. |
| Таблица | ALTER TABLE, массовые операции без индексов, DDL |
Высокая. Полная сериализация доступа к объекту. |
| Gap / Next-Key | WHERE range, индексы в InnoDB |
Защищает диапазоны от фантомов. Замедляет INSERT в соседние диапазоны. |
2. Механика: Эскалация и управление памятью
Эскалация блокировок — это автоматическое преобразование множества мелких блокировок (например, строк) в одну крупную (таблицы). Это происходит, когда СУБД превышает лимит памяти, выделенный под таблицы блокировок, или когда число удерживаемых локов превышает порог (обычно ~5000 на одну транзакцию в SQL Server, в PostgreSQL/MySQL эскалация ручная или происходит через VACUUM/autovacuum косвенно).
Почему эскалация опасна:
- Она блокирует весь объект, убивая параллелизм.
- В Go это мгновенно приводит к таймаутам в пуле соединений, так как остальные горутины ждут освобождения таблицы, а не строки.
3. Механическая симпатия: Цена блокировок для железа и ОС
Блокировки в СУБД — это не абстракция. Они материально влияют на производительность процессора, памяти и операционной системы.
Кэш-линии CPU и False Sharing
Менеджер блокировок хранит счётчики удерживаемых локов в общих структурах памяти. Когда два ядра CPU одновременно пытаются проверить или изменить статус блокировки для разных строк, но находящихся в одной кэш-линии (64 байта), срабатывает протокол когерентности MESI. Кэш-линия постоянно переключается между состояниями Modified и Invalid на разных ядрах. Это называется False Sharing и может снизить пропускную способность транзакций на 30-50%, даже если сами строки не конфликтуют.
Системные вызовы и парковка тредов
Когда транзакция в БД не может получить лок, она не «крутится» в цикле. Она ставится в очередь ожидания (Lock Wait Queue) и переходит в состояние сна.
- На стороне СУБД: процесс/поток вызывает
epoll_waitилиfutexдля засыпания. - На стороне Go-драйвера: вызов блокируется на уровне сетевого
read(). Тред ОС паркуется. - Рантайм Go переводит горутину в статус
waiting. - При снятии блокировки СУБД пишет в сокет, ядро ОС будит тред через прерывание сетевого контроллера, и планировщик Go переносит горутину обратно в
runnable.
Каждое такое пробуждение стоит тысячи тактов. При тысячах одновременных блокировок переключение контекста становится основным потребителем ресурсов.
4. Практика в Go: Идиоматичные паттерны работы с локами
В промышленном коде SELECT ... FOR UPDATE используется редко и только там, где это оправдано. Чаще применяются его оптимизированные вариации.
Паттерн 1: SKIP LOCKED для фоновых воркеров
Идеально для очередей задач, отложенных писем или обработки чанков данных. Горутина берёт только те строки, которые сейчас никем не заблокированы, и не ждёт.
func ProcessNextTask(ctx context.Context, db *sql.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
var taskID int64
var payload string
// Берём первую доступную задачу, пропуская уже обрабатываемые другими воркерами
err = tx.QueryRowContext(ctx, `
SELECT id, data FROM tasks
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1 FOR UPDATE SKIP LOCKED
`).Scan(&taskID, &payload)
if err == sql.ErrNoRows {
return nil // Нет доступных задач
}
if err != nil {
return fmt.Errorf("query task: %w", err)
}
_, err = tx.ExecContext(ctx, "UPDATE tasks SET status = 'processing' WHERE id = $1", taskID)
if err != nil {
return err
}
return tx.Commit()
}
Паттерн 2: NOWAIT для быстрой реакции
Если лок занят, не ждать, а сразу вернуть управление приложению для повторной попытки или возврата ошибки клиенту.
func ReserveItem(ctx context.Context, db *sql.DB, itemID int64) error {
// В PostgreSQL
_, err := db.ExecContext(ctx,
"SELECT 1 FROM inventory WHERE id = $1 FOR UPDATE NOWAIT", itemID)
if err != nil {
if pgErr, ok := err.(*pq.Error); ok && pgErr.Code == "55P03" {
return fmt.Errorf("item currently locked by another process: %w", err)
}
return fmt.Errorf("reserve check: %w", err)
}
// Логика резервирования...
return nil
}
5. Разница между блокировками в БД и sync.Mutex в Go
Многие разработчики проводят прямую параллель между FOR UPDATE и sync.Mutex. Это опасно.
| Характеристика | sync.Mutex (Go) |
FOR UPDATE (PostgreSQL/MySQL) |
|---|---|---|
| Область видимости | Один процесс, одна память | Кластер, несколько процессов, реплики |
| При падении процесса | Мьютекс остаётся залоченным (паника/дедлок) | СУБД автоматически снимает лок при закрытии соединения |
| Стоимость захвата | Атомарная инструкция CPU (CAS) | Сетевой запрос, хэш-таблица локов, возможен futex |
| Приоритеты | FIFO / Spinlock | Зависит от реализации (часто FIFO, но может быть starvation) |
Итог
- Режимы локов (
S,X,Intent) образуют матрицу совместимости. Понимание матрицы помогает предсказывать конфликты. - Гранулярность определяет параллелизм. Стремитесь к строковым локам, избегайте блокировок целых таблиц.
- Железо и ОС: Локи вызывают
False Sharingкэш-линий, используютfutexдля ожидания и паркуют горутины в рантайме Go. Это дорого при массовых ожиданиях. - Идиомы в Go: Используйте
SKIP LOCKEDдля конкурентной обработки очередей,NOWAITдля отказов от ожидания, и всегда контролируйте время жизни транзакции через контекст. - Отличие от мьютексов: Локи БД устойчивы к краху процессов и работают на уровне кластера, но имеют сетевую и дисковую цену захвата.
Блокировки неизбежно приводят к ситуациям, когда две транзакции ждут друг друга бесконечно. В следующей статье мы разберём механизм обнаружения, алгоритмы разрешения этих ситуаций и стратегии проектирования систем, устойчивых к взаимным блокировкам: 6. Deadlock и их предотвращение.