Как возникают блокировки и deadlock’и при работе с транзакциями в Go, и как их избежать?

Ответ

Блокировки и deadlock'и — это проблемы, возникающие на уровне системы управления базами данных (СУБД) для обеспечения целостности данных (ACID), а не в самом Go. Go-приложение лишь инициирует запросы, которые могут к ним приводить.

Как возникают блокировки?

Когда транзакция обращается к данным, СУБД устанавливает блокировку, чтобы другие транзакции не могли некорректно изменить эти данные. Основные типы блокировок:

  1. Shared Lock (S-lock, блокировка чтения): Несколько транзакций могут одновременно читать один и тот же ресурс, но ни одна не может его изменить, пока все S-блокировки не сняты.
  2. Exclusive Lock (X-lock, блокировка записи): Если транзакция захватывает X-блокировку на ресурсе (например, при UPDATE или DELETE), никакая другая транзакция не может получить ни S-, ни X-блокировку на этот ресурс до завершения первой транзакции.

Deadlock (взаимная блокировка)

Deadlock возникает, когда две или более транзакций ожидают друг друга для освобождения ресурсов, создавая замкнутый круг ожидания.

Пример на Go (database/sql):

// Транзакция 1: переводит 100 с аккаунта 1 на 2
go func() {
    tx1, _ := db.Begin()
    defer tx1.Rollback() // Откатить, если не будет Commit
    // Шаг 1: Блокирует строку с id=1
    tx1.Exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
    time.Sleep(100 * time.Millisecond) // Имитация работы
    // Шаг 4: Пытается заблокировать строку с id=2, но она уже заблокирована tx2
    tx1.Exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2")
    tx1.Commit()
}()

// Транзакция 2: переводит 50 с аккаунта 2 на 1
go func() {
    tx2, _ := db.Begin()
    defer tx2.Rollback()
    // Шаг 2: Блокирует строку с id=2
    tx2.Exec("UPDATE accounts SET balance = balance - 50 WHERE id = 2")
    // Шаг 3: Пытается заблокировать строку с id=1, но она уже заблокирована tx1
    tx2.Exec("UPDATE accounts SET balance = balance + 50 WHERE id = 1")
    tx2.Commit()
}()

// Результат: Deadlock! СУБД принудительно откатит одну из транзакций.

Как избежать блокировок и deadlock'ов:

  • Делайте транзакции короткими: Выполняйте внутри транзакции только необходимые операции с БД. Не делайте в них долгих вычислений или сетевых запросов.
  • Соблюдайте единый порядок блокировки ресурсов: Все транзакции должны запрашивать блокировки в одном и том же порядке (например, всегда сначала обновлять таблицу accounts, а потом users, или по возрастанию id).
  • Используйте правильный уровень изоляции: Более низкие уровни изоляции (например, Read Committed) создают меньше блокировок, чем высокие (Serializable), но могут приводить к другим аномалиям. Выбирайте минимально необходимый уровень.
  • Используйте SELECT ... FOR UPDATE: Если вам нужно прочитать данные, а затем обновить их, используйте SELECT ... FOR UPDATE, чтобы сразу установить эксклюзивную блокировку и предотвратить изменение этих данных другими транзакциями.
  • Устанавливайте таймауты: Используйте context.WithTimeout для запросов к БД, чтобы прервать операцию, которая ждет блокировку слишком долго.
  • Оптимизируйте запросы и индексы: Медленные запросы дольше держат блокировки. Наличие правильных индексов кардинально ускоряет операции и сокращает время блокировки.