При каких уровнях изоляции транзакций может возникнуть аномалия Non-Repeatable Read?

Ответ

Non-Repeatable Read (неповторяющееся чтение) возникает, когда транзакция повторно читает те же данные и обнаруживает, что они были изменены (и изменения закоммичены) другой параллельной транзакцией между двумя чтениями.

Уровни изоляции, где это возможно:

  • Read Uncommitted (чтение незафиксированных данных): На этом уровне транзакция может видеть изменения, сделанные другими транзакциями, даже если они ещё не закоммичены (что также приводит к Dirty Read). Если другая транзакция закоммитит изменения между чтениями, это вызовет Non-Repeatable Read.
  • Read Committed (чтение зафиксированных данных): Это уровень изоляции по умолчанию во многих СУБД (например, PostgreSQL, SQL Server, Oracle). Транзакция видит только закоммиченные изменения. Однако, если другая транзакция закоммитит изменения после первого чтения, но до второго чтения текущей транзакции, возникнет Non-Repeatable Read.

Пример на Go с использованием SQL:

Предположим, у нас есть таблица accounts с полем balance.

package main

import (
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/lib/pq" // Пример для PostgreSQL
)

func main() {
    // Предполагается, что у вас есть запущенный PostgreSQL и база данных
    // db, err := sql.Open("postgres", "user=postgres password=postgres dbname=test_db sslmode=disable")
    // if err != nil {
    //  log.Fatal(err)
    // }
    // defer db.Close()

    // // Инициализация данных
    // db.Exec("DROP TABLE IF EXISTS accounts")
    // db.Exec("CREATE TABLE accounts (id INT PRIMARY KEY, balance INT)")
    // db.Exec("INSERT INTO accounts (id, balance) VALUES (1, 100)")

    // Имитация работы с базой данных
    fmt.Println("Начальный баланс: 100")

    // Транзакция 1 (читающая)
    go func() {
        // tx1, err := db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
        // if err != nil { log.Fatal(err) }
        fmt.Println("nТранзакция 1: Начинается")

        var balance1 int
        // err = tx1.QueryRow("SELECT balance FROM accounts WHERE id = 1").Scan(&balance1)
        // if err != nil { log.Fatal(err) }
        balance1 = 100 // Имитация первого чтения
        fmt.Printf("Транзакция 1: Первое чтение баланса: %dn", balance1)

        time.Sleep(50 * time.Millisecond) // Даем время для выполнения Транзакции 2

        var balance2 int
        // err = tx1.QueryRow("SELECT balance FROM accounts WHERE id = 1").Scan(&balance2)
        // if err != nil { log.Fatal(err) }
        balance2 = 200 // Имитация второго чтения после изменения другой транзакцией
        fmt.Printf("Транзакция 1: Второе чтение баланса: %dn", balance2)

        if balance1 != balance2 {
            fmt.Println("Транзакция 1: Обнаружена аномалия Non-Repeatable Read!")
        }
        // tx1.Commit()
        fmt.Println("Транзакция 1: Завершена")
    }()

    // Транзакция 2 (изменяющая)
    go func() {
        time.Sleep(20 * time.Millisecond) // Запускаем чуть позже
        // tx2, err := db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
        // if err != nil { log.Fatal(err) }
        fmt.Println("Транзакция 2: Начинается")
        // _, err = tx2.Exec("UPDATE accounts SET balance = 200 WHERE id = 1")
        // if err != nil { log.Fatal(err) }
        // tx2.Commit()
        fmt.Println("Транзакция 2: Баланс обновлен до 200 и закоммичен")
        fmt.Println("Транзакция 2: Завершена")
    }()

    time.Sleep(100 * time.Millisecond) // Ждем завершения горутин
}

Решение:

Для предотвращения Non-Repeatable Read необходимо использовать более высокие уровни изоляции:

  • Repeatable Read (повторяемое чтение): На этом уровне транзакция видит только те данные, которые были зафиксированы до её начала. Все последующие чтения в рамках той же транзакции будут возвращать те же данные, что и первое чтение, даже если другие транзакции их изменили и закоммитили. Однако на этом уровне всё ещё возможна аномалия Phantom Read.
  • Serializable (сериализуемый): Самый строгий уровень изоляции. Гарантирует, что параллельное выполнение транзакций будет эквивалентно некоторому последовательному выполнению. Предотвращает все аномалии, включая Non-Repeatable Read и Phantom Read.

Ответ 18+ 🔞

А, слушай, вот эта штука — Non-Repeatable Read, неповторяющееся чтение, блядь. Представь себе, ты в транзакции два раза подряд читаешь одни и те же данные, а они, сука, уже другие! Как так? А потому что между твоими чтениями какая-то другая параллельная транзакция их успела изменить и закоммитить, вот и вся магия, ёпта.

На каких уровнях изоляции эта дичь может вылезти:

  • Read Uncommitted (чтение незафиксированного дерьма): Тут вообще пиздец, можно увидеть даже те изменения, которые ещё не закоммитили. Ну и понятное дело, если между твоими чтениями другая транзакция таки закоммитится — данные поменяются, и ты охуеешь.
  • Read Committed (чтение только закоммиченного): Это, кстати, уровень по умолчанию во многих базах, типа PostgreSQL. Казалось бы, уже лучше — видишь только то, что другие уже сохранили. Но нет! Если другая транзакция успеет закоммитить свои правки между твоим первым и вторым чтением — опять получишь Non-Repeatable Read, блядь. Волнение ебать.

Вот тебе пример на Go, чтобы стало совсем понятно, как это выглядит в коде:

Представь табличку accounts с полем balance, ну, с балансом, всё как у людей.

package main

import (
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/lib/pq" // Допустим, для PostgreSQL
)

func main() {
    // ... тут код подключения к базе, создание таблицы, инициализация баланса в 100 ...
    fmt.Println("Начальный баланс: 100")

    // Транзакция 1 (та, которая читает и офигевает)
    go func() {
        fmt.Println("nТранзакция 1: Начинается")

        var balance1 int
        balance1 = 100 // Имитируем первое чтение
        fmt.Printf("Транзакция 1: Первое чтение баланса: %dn", balance1)

        time.Sleep(50 * time.Millisecond) // Спим, давая шанс второй транзакции нагадить

        var balance2 int
        balance2 = 200 // Имитируем второе чтение, а тут уже другая транзакция всё поменяла!
        fmt.Printf("Транзакция 1: Второе чтение баланса: %dn", balance2)

        if balance1 != balance2 {
            fmt.Println("Транзакция 1: Обнаружена аномалия Non-Repeatable Read! Ёбаный насос!")
        }
        fmt.Println("Транзакция 1: Завершена")
    }()

    // Транзакция 2 (та, которая подло меняет данные)
    go func() {
        time.Sleep(20 * time.Millisecond) // Подождём чутка, чтобы первая начала читать
        fmt.Println("Транзакция 2: Начинается")
        fmt.Println("Транзакция 2: Баланс обновлен до 200 и закоммичен") // Бац, и поменяли!
        fmt.Println("Транзакция 2: Завершена")
    }()

    time.Sleep(100 * time.Millisecond) // Ждём, пока горутины отработают
}

Как с этим бороться, или «Решение, блядь»:

Чтобы эта хуйня не возникала, нужно юзать уровни изоляции покруче:

  • Repeatable Read (повторяемое чтение): Вот это уже дело. На этом уровне транзакция как будто замораживает снимок данных на момент своего старта. И сколько бы раз ты внутри неё ни читал — данные будут одни и те же, даже если снаружи их уже десять раз переписали и закоммитили. Правда, фантомные чтения (Phantom Read) ещё возможны, но это уже другая история.
  • Serializable (сериализуемый): Ну, это полный аналог бронежилета, ёпта. Самый строгий уровень. Гарантирует, что результат параллельного выполнения транзакций будет такой же, как если бы они выполнялись строго по очереди, одна за другой. Тут тебе и Non-Repeatable Read, и Phantom Read, и прочая хуйня — всё нахуй предотвращается. Правда, производительность может просесть, но зато надёжно, как швейцарские часы, блядь.