💳 Раздел 11 · Вопрос #7

Что такое грязное чтение (Dirty Read)

Если транзакция, изменившая данные, впоследствии завершится ошибкой или будет принудительно отменена (ROLLBACK), прочитанные первой транзакцией данные оказываются ложными («гряз...


🟢 Junior Level

Грязное чтение (Dirty Read) — это классическая аномалия параллельного доступа к данным, при которой одна транзакция считывает промежуточные изменения, внесенные другой параллельной транзакцией, которая еще не была зафиксирована командой COMMIT.

Если транзакция, изменившая данные, впоследствии завершится ошибкой или будет принудительно отменена (ROLLBACK), прочитанные первой транзакцией данные оказываются ложными («грязными»), так как в базе данных их фактически никогда не существовало.

Пример сценария грязного чтения

Время    Транзакция А (Пополнение счета)         Транзакция Б (Выдача кредита)
  |      -------------------------------         -----------------------------
  |  T1: BEGIN;
  |  T2: UPDATE accounts SET balance = 500_000
  |      WHERE id = 42;
  |  T3:                                         BEGIN;
  |  T4:                                         SELECT balance FROM accounts WHERE id = 42;
  |                                              -- Прочитал 500 000 руб.! (ГРЯЗНЫЕ ДАННЫЕ)
  |  T5:                                         -- Одобрил кредит на основе 500 000 руб.
  |  T6: ROLLBACK; -- Ошибка банка!
  |      Баланс вернулся к 10 000 руб.
  v  T7:                                         COMMIT; -- Кредит выдан банкроту!

На каких уровнях изоляции возможно

  • Read Uncommitted: ⚠️ Единственный уровень, на котором допускается грязное чтение.
  • Read Committed, Repeatable Read, Serializable: ❌ Полностью защищены от грязного чтения.

Аналогия: Вы заглянули в черновик диплома сокурсника и переписали себе формулу. На следующий день преподаватель указал на ошибку в формуле, и сокурсник зачеркнул ее. Но вы уже сдали работу с ложными данными.


🟡 Middle Level

Формальное определение (ANSI SQL vs Berenson et al.)

В классическом стандарте ANSI SQL-92 аномалия $A_1$ (Dirty Read) формулировалась строго: \(w_1[x] \dots r_2[x] \dots (a_1 \text{ and } c_2 \text{ in any order})\) Транзакция $T_1$ изменяет $x$, транзакция $T_2$ считывает $x$, затем $T_1$ отменяется ($a_1$), а $T_2$ коммитится ($c_2$).

В знаменитой исследовательской работе 1995 года («A Critique of ANSI SQL Isolation Levels», Джим Грей, Хэл Беренсон и др.) исследователи доказали, что определение ANSI было неполным, и ввели более строгое феноменологическое правило $P_1$: \(w_1[x] \dots r_2[x] \dots (c_1 \text{ or } a_1)\) Транзакция $T_2$ не должна читать значение $x$, пока транзакция $T_1$ не завершится коммитом или абортом.

Как современные СУБД защищают от Dirty Read

                 ЗАЩИТА ОТ DIRTY READ В СУБД
                              │
       ┌──────────────────────┴──────────────────────┐
       ▼                                             ▼
ПЕССИМИСТИЧЕСКИЕ БЛОКИРОВКИ (2PL)              МНОГОВЕРСИОННОСТЬ (MVCC)
- Пишущая транзакция берет Exclusive Lock (X)  - Пишущая транзакция создает новую версию кортежа
- Читающая транзакция требует Shared Lock (S)  - Версия помечается незавершенным xmin (IN_PROGRESS)
- S-lock конфликтует с X-lock: читатель ждет!  - Читатели мгновенно читают старую версию из Snapshot
- Пример: классический MS SQL Server           - Пример: PostgreSQL, Oracle, MySQL InnoDB

Демонстрация в коде Java / Spring

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountOperationService {

    private final AccountRepository accountRepository;

    public AccountOperationService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Использование Read Committed гарантирует защиту от грязного чтения:
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public BigDecimal checkVerifiedBalance(Long accountId) {
        // Запрос физически не увидит незакоммиченные апдейты других потоков
        return accountRepository.findBalanceById(accountId);
    }
}

🔴 Senior Level

Аппаратная проверка видимости в ядре PostgreSQL (HeapTupleSatisfiesMVCC)

В движке PostgreSQL защита от грязного чтения является фундаментальным свойством структуры хранения строк в файлах страниц (Heap Page):

Каждый кортеж строки хранит два служебных 32-битных идентификатора транзакций:

  • t_xmin: ID транзакции, создавшей эту версию строки.
  • t_xmax: ID транзакции, удалившей или обновившей эту версию строки.

При выполнении любого чтения функция HeapTupleSatisfiesMVCC проверяет статус t_xmin по битовой карте транзакций в оперативной памяти (pg_xact):

// Упрощенный фрагмент исходного кода PostgreSQL (tqual.c):
if (!TransactionIdDidCommit(tuple->t_xmin)) {
    if (TransactionIdIsCurrentTransactionId(tuple->t_xmin)) {
        // Видим свои собственные незакоммиченные изменения
    } else {
        // Чужие незакоммиченные изменения (IN_PROGRESS) -> СТРОКА НЕВИДИМА!
        return false;
    }
}

Ключевой архитектурный вывод: В архитектуре PostgreSQL защита от Dirty Read не требует никаких блокировок памяти или ожидания освобождения строк. Незакоммиченная строка признается невидимой на уровне нескольких процессорных инструкций сравнения битовых масок. Именно поэтому PostgreSQL в принципе не имеет накладных расходов на предотвращение грязного чтения и не реализует Read Uncommitted.

Каскадные разрушения: почему Dirty Read считается критической угрозой

Грязное чтение является самой разрушительной аномалией, потому что в отличие от Deadlock или Timeout:

  1. Оно не генерирует ошибок (Silent Corruption): Приложение не падает, не логирует warning и считает прочитанные данные абсолютно валидными.
  2. Эффект домино (Cascading Data Contamination): \(\text{Грязное чтение } \to \text{ Неверное бизнес-решение } \to \text{ Запись новых данных в БД } \to \text{ Отправка во внешние API}\) Даже если СУБД откатит первую транзакцию, вторая транзакция уже успеет отправить деньги через платежный шлюз или напечатать накладную на складе.

4 Tricky Questions

1. Если транзакция изменяет данные и затем читает их же до выполнения COMMIT, считается ли это «грязным чтением»?

Ответ: Нет, это не считается грязным чтением. Чтение транзакцией своих собственных незакоммиченных изменений (Own-XID Visibility) — это штатное и строго обязательное поведение всех реляционных СУБД. Транзакция всегда обязана наблюдать результаты своих собственных предшествующих операций (Read-Your-Own-Writes). Правило запрета Dirty Read относится исключительно к чтению промежуточных изменений чужих параллельных транзакций.


2. В чем разница между аномалией Dirty Read ($A_1$) и аномалией Dirty Write ($G_0$)?

Ответ:

  • Dirty Read (Грязное чтение): Транзакция $T_2$ читает незафиксированные данные транзакции $T_1$, которая затем откатывается. Допускается только на уровне Read Uncommitted.
  • Dirty Write (Грязная запись): Транзакция $T_2$ перезаписывает (UPDATE/DELETE) значение строки, которая была изменена незакоммиченной транзакцией $T_1$. Dirty Write категорически запрещен на ВСЕХ уровнях изоляции во ВСЕХ СУБД (включая Read Uncommitted). Если бы Dirty Write был возможен, СУБД физически не смогла бы выполнить ROLLBACK (непонятно, чью версию восстанавливать), что привело бы к фатальному разрушению целостности хранилища.

3. Почему в MS SQL Server чтение с хинтом WITH (NOLOCK) может вернуть дублирующиеся строки одного и того же объекта?

Ответ: Хинт WITH (NOLOCK) включает чтение без разделяемых блокировок (эквивалент Read Uncommitted). В движках на базе B-Tree страниц при интенсивных параллельных вставках происходит расщепление страниц (Page Split): переполненная 8-килобайтная страница делится пополам, и часть записей переносится на новую страницу. Если запрос с NOLOCK сканирует таблицу по физическому порядку страниц (Allocation Order Scan), он может сначала прочитать строку на исходной странице, а затем, продвинувшись дальше по цепочке страниц, встретить ту же самую строку, перенесенную параллельной транзакцией на новую страницу.


4. Может ли возникнуть Dirty Read в микросервисной архитектуре при межсервисном взаимодействии через REST API?

Ответ: Да, и это одна из самых распространенных архитектурных проблем! Если Сервис А открыл локальную БД-транзакцию @Transactional, изменил статус заказа на PAID, а затем прямо из тела транзакции отправил HTTP-запрос в Сервис Б:

  • Сервис Б делает обратный запрос в базу Сервиса А (или читает общую реплику).
  • Либо Сервис Б выполняет списание товара на складе, а Сервис А после возврата ответа выбрасывает исключение и делает ROLLBACK своей БД-транзакции! Сервис Б принял критическое решение на основе незафиксированного состояния Сервиса А. Решение: Никогда не вызывать внешние синхронные API внутри открытых БД-транзакций; публиковать события только после фактического коммита транзакции (паттерн Transactional Outbox).

🎯 Шпаргалка для интервью

  • Суть Dirty Read: Чтение незафиксированных данных параллельной транзакции, которая затем может сделать ROLLBACK.
  • На каком уровне возможно: Только на Read Uncommitted. На всех остальных уровнях (Read Committed, Repeatable Read, Serializable) гарантированно запрещено.
  • Где отсутствует: В PostgreSQL и Oracle грязного чтения нет в принципе (архитектура MVCC автоматически исключает незакоммиченные кортежи).
  • Разница с Non-Repeatable Read:
    • Dirty Read — читаются незакоммиченные данные, которые могут исчезнуть.
    • Non-Repeatable Read — читаются закоммиченные данные, но они изменились между двумя запросами.
  • Dirty Write: Запрещен всегда и везде (все СУБД берут эксклюзивные блокировки на запись).
  • Главная опасность: Бессимптомная порча данных (Silent Data Corruption) и цепная реакция ошибочных бизнес-решений.

Связанные темы